Cách quen thuộc để giới hạn số goroutine đồng thời trong Go là dùng channel có đệm làm semaphore: ch <- struct{}{} để lấy một slot, <-ch để trả. Nhưng nó chỉ đếm được số lượng — mỗi goroutine chiếm đúng một slot. Điều gì xảy ra khi các công việc tốn tài nguyên khác nhau — một job nặng cần nhiều bộ nhớ hơn job nhẹ? Đó là lúc cần semaphore có trọng số (golang.org/x/sync/semaphore). Bài này đo thật cách nó hoạt động và khi nào thực sự cần.

Cơ chế: mỗi lần lấy một trọng số

semaphore.NewWeighted(cap) tạo một semaphore với tổng sức chứa cap đơn vị. Mỗi Acquire xin một số đơn vị (cost), Release trả lại:

import "golang.org/x/sync/semaphore"

sem := semaphore.NewWeighted(10)   // tổng sức chứa 10 đơn vị

sem.Acquire(ctx, cost)   // CHỜ tới khi đủ chỗ cost đơn vị (tôn trọng ctx)
// ... dùng tài nguyên ...
sem.Release(cost)        // trả lại cost đơn vị

Điểm khác biệt cốt lõi với channel-semaphore: mỗi lần lấy có thể tốn số đơn vị khác nhau:

sem.Acquire(ctx, 1)   // việc nhỏ: 1 đơn vị
sem.Acquire(ctx, 3)   // việc vừa: 3 đơn vị
sem.Acquire(ctx, 5)   // việc lớn: 5 đơn vị
// sức chứa 10 → tối đa: hai việc lớn (5+5), hoặc 10 việc nhỏ, hoặc trộn

Acquire nhận một context, nên hủy/timeout được — nếu không đủ chỗ, goroutine chờ trong hàng đợi FIFO tới khi có đủ (hoặc context bị hủy).

Ảnh chụp đoạn mã Go nền tối minh hoạ semaphore có trọng số giới hạn tài nguyên theo chi phí khác nhau, channel làm semaphore chỉ đếm slot mỗi cái 1 semaphore có trọng số cho phép mỗi lần lấy tốn số đơn vị khác nhau, một tạo và dùng golang.org x sync semaphore import golang.org x sync semaphore sem bằng semaphore.NewWeighted 10 tổng sức chứa 10 đơn vị sem.Acquire ctx cost chờ tới khi đủ chỗ cost đơn vị tôn trọng ctx dùng tài nguyên sem.Release cost trả lại cost đơn vị Acquire nhận context hủy timeout được nếu không đủ chỗ goroutine chờ trong hàng đợi FIFO tới khi có đủ, hai trọng số khác nhau điểm khác biệt cốt lõi công việc tốn tài nguyên khác nhau sem.Acquire ctx 1 việc nhỏ 1 đơn vị sem.Acquire ctx 3 việc vừa 3 đơn vị sem.Acquire ctx 5 việc lớn 5 đơn vị sức chứa 10 tối đa hai việc lớn 5 cộng 5 hoặc 10 việc nhỏ, ba TryAcquire thử không chờ ok bằng sem.TryAcquire 7 lấy ngay nếu đủ không thì trả false không chờ TryAcquire 7 true rồi TryAcquire 5 khi còn 3 false 12 lớn hơn 10, bốn so với channel làm semaphore channel chỉ đếm slot mỗi cái trọng số 1 ch bằng make chan struct 10 ch nhận struct acquire 1 slot nhận ch release 1 slot không diễn đạt được lấy 3 slot một lúc nguyên tử channel-semaphore đơn giản và đủ cho giới hạn đếm tối đa N goroutine cần trọng số biến đổi mới phải dùng semaphore.Weighted

Hình 1: Semaphore có trọng số. NewWeighted(cap), Acquire(ctx, cost), Release(cost), TryAcquire. Khác channel-semaphore (chỉ đếm slot 1-mỗi-cái) ở chỗ mỗi lần lấy một trọng số biến đổi.

Đo thật: giữ đúng giới hạn, và nhanh hơn channel

Chạy thật 12 việc trọng số 1/3/5 với sức chứa 10, quan sát đỉnh đơn vị đồng thời:

Ảnh chụp bảng kết quả đo thật nền tối semaphore trọng số giữ đúng giới hạn nhanh hơn channel go run cộng go test bench golang.org x sync semaphore v0.8.0 Go 1.23 arm64 10 core, chạy thật 12 việc trọng số 1 3 5 sức chứa 10 sức chứa bằng 10 đỉnh đơn vị đồng thời quan sát bằng 10 không vượt sức chứa TryAcquire 7 bằng true TryAcquire 5 khi còn 3 bằng false 7 cộng 5 bằng 12 lớn hơn 10 semaphore giữ đúng tổng đơn vị đang dùng không bao giờ vượt 10 dù việc có trọng số khác nhau TryAcquire từ chối khi không đủ chỗ không chờ, chi phí Acquire cộng Release không tranh chấp cách semaphore.Weighted trọng số 1 7,2 ns mutex cộng đường nhanh khi còn chỗ channel làm semaphore 14,9 ns send cộng recv nặng hơn bất ngờ nhẹ semaphore.Weighted còn nhanh hơn channel-semaphore khoảng 2x 7,2 vs 14,9 ns ở trọng số 1 nhưng giá trị thật của nó không phải tốc độ mà là khả năng lấy trọng số biến đổi mà channel không làm được, chọn cái nào channel-sem giới hạn đếm đơn giản tối đa N goroutine gọn không cần import đủ 90% trường hợp semaphore.Weighted tài nguyên tốn khác nhau bộ nhớ theo cỡ job kết nối DB theo độ nặng query đơn vị GPU băng thông mỗi Acquire một trọng số, cốt lõi Weighted NewWeighted cap Acquire ctx cost Release cost đúng đắn đỉnh không vượt sức chứa dù trọng số khác nhau Acquire chờ FIFO tôn trọng context hủy timeout chi phí 7,2 ns nhanh hơn channel-sem 14,9 ns dùng khi tài nguyên có chi phí biến đổi đếm thuần channel

Hình 2: Chạy thật — đỉnh đơn vị đồng thời = 10, không vượt sức chứa dù trọng số khác nhau; TryAcquire từ chối khi thiếu. Chi phí: Weighted 7,2 ns so với channel-sem 14,9 ns.

  • Đúng đắn: đỉnh đơn vị đồng thời quan sát được = 10, không bao giờ vượt sức chứa — dù việc có trọng số 1, 3, hay 5. TryAcquire(7)=true rồi TryAcquire(5) khi chỉ còn 3 → false (không chờ, từ chối vì 7+5=12>10).
  • Chi phí (Acquire+Release, không tranh chấp): semaphore.Weighted 7,2 ns so với channel-semaphore 14,9 ns.

Bất ngờ nhẹ: semaphore.Weighted còn nhanh hơn channel-semaphore ~2 lần ở trọng số 1 (nó dùng mutex + đường nhanh khi còn chỗ, nhẹ hơn send/recv của channel). Nhưng giá trị thật của nó không phải tốc độ — mà là khả năng lấy trọng số biến đổi, điều channel không diễn đạt được.

Ứng dụng thực tế

Giới hạn bộ nhớ theo cỡ job. Nếu bạn xử lý job có kích thước khác nhau (upload file, decode ảnh), cho mỗi job Acquire số đơn vị tỷ lệ với bộ nhớ nó cần. Sức chứa = tổng bộ nhớ cho phép. Nhờ vậy nhiều job nhỏ chạy song song, nhưng job lớn tự động giới hạn số lượng — thay vì một giới hạn đếm cứng nhắc không phân biệt cỡ.

Giới hạn tải xuống backend theo độ nặng. Query nặng (join nhiều bảng) cho trọng số lớn, query nhẹ trọng số nhỏ. Semaphore giữ tổng tải dưới ngưỡng thay vì chỉ đếm số query — bảo vệ backend chính xác hơn.

Đơn vị GPU, băng thông, hạn ngạch API. Bất kỳ tài nguyên nào chia được thành đơn vị và mỗi tác vụ tiêu thụ số đơn vị khác nhau đều hợp: GPU memory, băng thông mạng, token của API bên ngoài. Weighted semaphore mô hình hóa "ngân sách chia sẻ" tự nhiên.

Đánh đổi cần cân nhắc

Channel-semaphore đủ cho 90% trường hợp — đừng import thừa. Nếu bạn chỉ cần giới hạn số goroutine (tối đa N worker), channel có đệm gọn, không cần dependency ngoài, và đủ nhanh. Chỉ dùng x/sync/semaphore khi thực sự cần trọng số biến đổi; thêm một dependency cho việc channel làm được là phức tạp không cần thiết.

Trọng số một lần không vượt sức chứa, nếu không Acquire treo mãi. Acquire(ctx, cost) với cost > cap sẽ chờ vô hạn (không bao giờ đủ chỗ) trừ khi context hủy. Luôn đảm bảo trọng số lớn nhất ≤ sức chứa. Đây là bug dễ mắc khi trọng số tính động từ input.

Semaphore không phải rate limiter. Semaphore giới hạn số đồng thời (bao nhiêu đang chạy cùng lúc), không giới hạn tốc độ (bao nhiêu mỗi giây). Nếu bạn cần "tối đa 100 request/giây" bất kể chúng nhanh hay chậm, cần rate limiter (token bucket) — chủ đề bài sau. Đừng nhầm hai khái niệm: đồng thời vs tốc độ.

Ba ý mang về

  1. Semaphore có trọng số cho mỗi Acquire tốn số đơn vị khác nhau: NewWeighted(cap) + Acquire(ctx, cost) + Release(cost) — đo thật 12 việc trọng số 1/3/5 với sức chứa 10 giữ đỉnh đồng thời đúng 10, không vượt; Acquire chờ FIFO và tôn trọng context (hủy/timeout).
  2. Điểm khác biệt với channel-semaphore là trọng số biến đổi: channel chỉ đếm slot (mỗi cái 1), không diễn đạt được "lấy 3 đơn vị một lúc" — dùng weighted khi tài nguyên tốn khác nhau (bộ nhớ theo cỡ job, DB theo độ nặng query, GPU); đo thật weighted còn nhanh hơn channel-sem ~2x (7,2 vs 14,9 ns) nhưng giá trị chính là biểu đạt, không phải tốc độ.
  3. Chọn đúng công cụ: channel-semaphore đủ cho giới hạn đếm đơn giản (90% trường hợp, không cần import); weighted cho chi phí biến đổi; và cả hai đều là giới hạn đồng thời, KHÁC với rate limiter giới hạn tốc độ (request/giây) — đừng nhầm.

Phần sau ta xem chính công cụ giới hạn tốc độ vừa nhắc: Phần sau mổ xẻ rate limiting bằng token bucket (golang.org/x/time/rate) — cách nó khác semaphore, thuật toán token bucket, burst, và đo thật hành vi giới hạn tốc độ.