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).

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:

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)=truerồiTryAcquire(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.Weighted7,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ề
- 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;Acquirechờ FIFO và tôn trọng context (hủy/timeout). - Đ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 độ.
- 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 độ.