Cho tới giờ công cụ đồng bộ chính của ta là mutex — cho một luồng vào vùng tới hạn tại một thời điểm. Nhưng nhiều bài toán thực không phải "một hoặc không", mà là "tối đa K": tối đa 10 kết nối tới cơ sở dữ liệu, tối đa 4 luồng tải cùng lúc, tối đa N chỗ trong bãi đỗ. Công cụ cho việc này là semaphore (đèn hiệu đếm) — và nó thường bị hiểu nhầm là "chỉ là mutex biết đếm". Tôi đo hai tính chất của nó, và cả hai cho thấy semaphore là một thứ khác mutex, không phải mutex phóng to.

Semaphore: giới hạn K, và báo hiệu

Một bộ đếm giấy phép

Semaphore giữ một bộ đếm các permit (giấy phép). Hai thao tác: sem_wait giảm bộ đếm đi 1 — nếu bộ đếm đang là 0, luồng chờ cho tới khi có permit; sem_post tăng bộ đếm lên 1 — trả lại một permit, có thể đánh thức một luồng đang chờ. Khởi tạo semaphore với K permit nghĩa là: tối đa K luồng có thể qua sem_wait mà không bị chặn cùng lúc. Luồng thứ K+1 phải chờ tới khi một trong K luồng kia sem_post.

Thấy ngay mutex chỉ là trường hợp đặc biệt K=1: một permit, một luồng vào. Nhưng semaphore tổng quát hơn theo hai chiều mà tôi muốn đo: nó giới hạn đồng thời ở K bất kỳ, và — tinh tế hơn — nó không có khái niệm chủ sở hữu. Tôi đo cả hai trong container gcc:13 (10 lõi).

Đo (a): chặn cứng ở đúng K

Câu hỏi đầu tiên: semaphore có thực sự giới hạn số luồng đồng thời ở K không? Tôi cho 16 luồng worker cùng tranh vào một vùng bảo vệ bởi semaphore K permit; mỗi luồng khi vào tăng một bộ đếm cur (số luồng đang ở trong), ghi lại giá trị lớn nhất cur từng đạt, làm việc ~0,5 µs, rồi giảm cursem_post. Nếu semaphore đúng, cur không bao giờ vượt K:

K | MAX đồng thời đo được | throughput
1 | 1  (= K)              |   707.000 op/s
2 | 2  (= K)              | 1.463.000 op/s   (2,1×)
4 | 4  (= K)              | 2.326.000 op/s   (3,3×)
8 | 8  (= K)              | 2.284.000 op/s   (bão hòa)

Hai điều đo được rõ ràng. Thứ nhất: số luồng đồng thời tối đa đúng bằng K ở mọi K (1, 2, 4, 8) — không bao giờ vượt. Semaphore chặn cứng: dù có 16 luồng chực chờ, chỉ K được vào cùng lúc. Đây là bảo đảm cốt lõi mà một pool tài nguyên cần — không bao giờ mở quá K kết nối, dù tải cao đến đâu.

Thứ hai: throughput tăng theo K rồi bão hòa. K=1 (chính là mutex) tuần tự hóa hoàn toàn — 707k op/s. K=2 cho 2 luồng song song → gấp đôi (1,46M). K=4 → 3,3×. Nhưng K=8 không hơn K=4 (2,28M so 2,33M) — bão hòa. Vì việc mỗi lần quá ngắn (0,5 µs) và chi phí sem_wait/sem_post cùng lịch trình đã lấp đầy; thêm permit không giúp khi tài nguyên (ở đây là CPU cho việc ngắn) đã dùng hết. Bài học: K không phải càng lớn càng nhanh — nó tăng song song tới một điểm, sau đó vô ích, và tệ hơn, K quá lớn làm mất tác dụng bảo vệ (nếu K vượt sức chịu của tài nguyên thật, bạn quá tải nó — đúng cái semaphore đáng lẽ ngăn).

Đo (b): semaphore không có chủ — nên báo hiệu được

Đây là tính chất khiến semaphore khác hẳn mutex, không phải "mutex đếm". Một mutex có chủ sở hữu: luồng nào lock thì chính luồng đó phải unlock. Bạn không thể để luồng A khóa rồi luồng B mở khóa — đó là lỗi. Semaphore không có ràng buộc này: bất kỳ luồng nào cũng sem_post được, kể cả luồng chưa từng sem_wait. Điều đó cho phép một dùng hoàn toàn mới: báo hiệu giữa các luồng.

Tôi đo: một luồng producer liên tục sem_post (báo "có thêm một việc"), một luồng consumer liên tục sem_wait (chờ và nhận việc). Bộ đếm permit = số việc đang sẵn. Chạy 300 ms:

producer sem_post : 1.957.880 lần
consumer sem_wait : 1.957.880 lần  (khớp chính xác)

Consumer nhận đúng bằng số producer phát — semaphore làm cầu nối báo hiệu hoàn hảo giữa hai luồng khác nhau. Đây là điều mutex không làm được: bạn không thể dùng mutex để luồng A báo cho luồng B "đến lượt mày" vì mutex đòi cùng luồng lock/unlock. (Đây cũng là nền của producer-consumer và một cách khác để làm việc biến điều kiện đảm nhận — semaphore đếm số item, không cần mutex+cond riêng cho việc đếm.)

Một lần tôi đo hớ: semaphore không phải "mutex đếm"

Tôi vào đo với hai niềm tin. Thứ nhất: "K càng lớn càng nhanh". Sai — throughput tăng theo K tới ~số lõi rồi bão hòa (K=8 không hơn K=4), và K quá lớn còn phá chính mục đích bảo vệ tài nguyên. Số permit tối ưu là số bằng đúng sức chứa của tài nguyên thật (số kết nối DB chịu được, số lõi cho việc CPU), không phải một con số càng to càng tốt.

Thứ hai, và là đo hớ về bản chất: "semaphore chỉ là mutex biết đếm tới K". Nửa đúng — nó đúng là giới hạn K luồng (và mutex là K=1). Nhưng nó thiếu một thứ mutex có: chủ sở hữu. Và cái thiếu đó lại là một tính năng: vì không có chủ, bất kỳ luồng nào cũng sem_post được, nên semaphore làm được việc báo hiệu chéo luồng mà mutex về nguyên tắc không thể. Nếu tôi chỉ nghĩ "mutex đếm", tôi đã bỏ lỡ nửa công dụng của nó.

Bài học đo lường: hai công cụ trông giống nhau có thể khác nhau ở một thuộc tính quyết định — ở đây là quyền sở hữu — và thuộc tính đó mở ra (hoặc đóng lại) cả một lớp cách dùng. Semaphore = limiter K (chặn cứng số luồng đồng thời) cộng công cụ báo hiệu (không chủ sở hữu). Mutex = loại trừ (K=1) cộng quyền sở hữu (chống mở khóa nhầm). Chọn cái nào tùy bạn cần "giới hạn số lượng / báo hiệu" hay "loại trừ có sở hữu". Đúng tinh thần đo lường: đừng gộp hai thứ khác bản chất thành một chỉ vì cú pháp na ná.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: dùng semaphore để giới hạn tài nguyên, đặt K bằng đúng sức chứa thật. Bất cứ khi nào bạn có một tài nguyên có giới hạn số lượng — pool kết nối, số request đồng thời tới một dịch vụ, số luồng tải file — semaphore là công cụ đúng, và K phải khớp giới hạn thật (10 kết nối DB → K=10). Đừng đặt K = số luồng (mất tác dụng bảo vệ) hay K = 1 (phí song song). Semaphore đảm bảo bạn không bao giờ vượt K, dù tải bao nhiêu.

Hệ quả thứ hai: dùng semaphore để báo hiệu giữa luồng, đừng ép mutex làm việc đó. Khi một luồng cần báo cho luồng khác "có việc rồi", "đến lượt bạn", "tài nguyên sẵn sàng", semaphore (hoặc biến điều kiện) là đúng — vì nó không có chủ, luồng phát và luồng nhận là hai luồng khác nhau. Mutex là để bảo vệ dữ liệu chung, không phải để báo hiệu; dùng nhầm sẽ vướng ràng buộc sở hữu.

Hệ quả thứ ba là tinh thần đo lường: hiểu bản chất công cụ, đừng gộp theo vẻ ngoài. Con số mang theo: semaphore giữ K permit, giới hạn CỨNG số luồng đồng thời ở đúng K (đo MAX cur = 1/2/4/8 = K, không bao giờ vượt; mutex là K=1); throughput tăng theo K (707k->2,33M từ K=1 tới 4) rồi BÃO HÒA (K=8 không hơn) nên K không phải càng lớn càng tốt — đặt K bằng sức chứa tài nguyên thật. Và semaphore KHÔNG có chủ sở hữu nên post được từ luồng khác (producer POST = consumer WAIT = 1,96M), làm được BÁO HIỆU chéo luồng mà mutex không thể — semaphore không chỉ là 'mutex đếm'.

Thử ba mươi giây

Nghĩ về một tài nguyên trong hệ của bạn có giới hạn số lượng — bao nhiêu kết nối database, bao nhiêu request đồng thời tới một API bên ngoài, bao nhiêu luồng xử lý ảnh cùng lúc trước khi hết RAM. Hỏi: hiện code có gì đảm bảo không vượt giới hạn đó không? Nếu không — nếu bạn cứ tạo luồng/kết nối theo tải — bạn có nguy cơ quá tải tài nguyên đúng lúc cao điểm. Một semaphore K permit (K = giới hạn thật) là ba dòng code để chặn cứng điều đó. Rồi hỏi tiếp: có chỗ nào một luồng cần báo cho luồng khác một sự kiện không? Nếu bạn đang định "lách" bằng cách chia sẻ cờ và mutex, cân nhắc semaphore — nó sinh ra để báo hiệu chéo luồng. Ba mươi giây phân loại "giới hạn số lượng" và "báo hiệu" đó cho bạn đúng công cụ, và nhắc rằng semaphore không phải mutex phóng to — nó là một thứ khác, mạnh ở đúng hai việc mutex không làm tốt.