Hình dung một cái lọ đựng thẻ đặt trên quầy chung, ai cần thì tới lấy một thẻ. Nếu trong lọ còn thẻ, bạn thò tay lấy một cái rồi đi ngay — một giây, chẳng phiền tới ai. Nhưng nếu lọ rỗng, bạn phải ngồi xuống ghế chờ và thiếp đi, và người bỏ thẻ mới vào lọ phải tới lay bạn dậy — chính cái động tác đánh thức từ giấc ngủ ấy mới tốn thời gian, chứ không phải việc thò tay vào lọ. sem_wait hành xử đúng như vậy, và đó là lý do rất nhiều người gán chi phí của semaphore nhầm chỗ.
Phần trước ta thấy shared memory cho hai tiến trình chung một vùng nhớ, nhưng bắt bạn tự lo đồng bộ. Công cụ đồng bộ liên tiến trình cổ điển là POSIX semaphore — một bộ đếm mà sem_wait giảm (chờ nếu đã cạn) và sem_post tăng (đánh thức kẻ đang chờ). Nhiều người ngại semaphore vì tin rằng "mỗi lần gọi là một syscall, mà syscall thì đắt" — nên tránh nó ở đường nóng. Tôi đo, và con số cho thấy niềm tin đó sai ở chỗ mấu chốt nhất: chi phí của semaphore không nằm ở việc gọi nó, mà ở việc nó có phải cho tiến trình ngủ hay không.
Semaphore là gì, và futex ẩn dưới nó
Một semaphore là một số đếm không âm. sem_wait: nếu đếm > 0, giảm 1 rồi đi tiếp; nếu đếm = 0, ngủ cho tới khi ai đó sem_post. sem_post: tăng đếm, và nếu có kẻ đang ngủ chờ thì đánh thức một kẻ. Để dùng giữa hai tiến trình, bạn tạo semaphore ở nơi cả hai thấy: hoặc sem_init(&s, pshared=1, ...) đặt trong shared memory, hoặc sem_open("/tên", ...) tạo một semaphore có tên (nằm trong /dev/shm). Cả hai tiến trình thao tác cùng một bộ đếm — cái lọ thẻ chung.
Điều then chốt nằm ở cách hiện thực. Trên Linux, semaphore (như mutex ở Series 25) được xây trên futex ("fast userspace mutex"). Ý tưởng futex: phần thường gặp — khi không có tranh chấp — được xử lý hoàn toàn bằng atomic trong user-space, không hề gọi vào nhân. Chỉ khi thao tác thực sự phải chặn (đếm = 0, phải ngủ) thì mới có một syscall futex() để nhân đưa tiến trình vào giấc ngủ và sau đó đánh thức. Nói cách khác: sem_wait không luôn là một syscall — nó chỉ là syscall khi buộc phải ngủ (khi lọ rỗng).
Tôi đo hai đầu của phổ này trong container gcc:13: (1) đường nhanh — sem_post rồi sem_wait lặp lại trên một semaphore luôn có sẵn (đếm vừa tăng đã giảm, không bao giờ phải ngủ); (2) đường chậm — ping-pong giữa hai tiến trình qua hai semaphore, mỗi vòng buộc bên kia thức dậy từ giấc ngủ.
Đo: chênh gần năm nghìn lần
sem_post + sem_wait, KHÔNG tranh chấp (fast path) : ~1,8 ns mỗi op (atomic, không vào nhân)
ping-pong 2 tiến trình, PHẢI ngủ/đánh thức : ~8.695 ns mỗi lần đánh thức (futex + chuyển ngữ cảnh)
-> chênh ~4.800 lần
Con số nói rất rõ. Khi semaphore không phải cho ai ngủ, một thao tác sem_wait hay sem_post chỉ tốn khoảng 1,8 nano-giây — đúng cỡ một phép toán atomic, và không hề vào nhân. Đây là đường nhanh futex: giảm/tăng một biến đếm bằng lệnh atomic ngay trong user-space, xong. Nhanh ngang một lần truy cập biến thường — thò tay lấy thẻ khi lọ còn đầy.
Nhưng khi semaphore phải cho một tiến trình ngủ rồi đánh thức nó — như trong ping-pong hai tiến trình, nơi mỗi vòng bên A post để đánh thức bên B đang ngủ trong sem_wait — chi phí vọt lên ~8.695 nano-giây mỗi lần đánh thức. Đó là ~4.800 lần đắt hơn đường nhanh. Và con số 8.695 ns này không xa lạ: nó chính là chi phí một lần chuyển ngữ cảnh mà phần 5 đã đo, và trùng với chi phí đánh thức qua cond_wait ở Series 25. Vì đánh thức một tiến trình đang ngủ là một lần lập lịch lại — nhân phải cất tiến trình đang chạy, khôi phục tiến trình được đánh thức. Cái ~8,7 µs đó là sàn cứng của mọi "ngủ rồi thức", bất kể công cụ là semaphore, mutex, hay biến điều kiện.
(Môi trường đo: container gcc:13 trên host ARM AArch64, semaphore trong shared memory, mỗi số lấy sau warmup. Con số tuyệt đối đổi theo máy; cái cần nhìn là khoảng cách gần 3 bậc độ lớn giữa đường nhanh atomic và đường phải-ngủ.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin phổ biến: "sem_wait là một lời gọi hệ thống, mà syscall thì đắt (hàng trăm ns, như phần 1 đo), nên tránh semaphore ở đường nóng". Con số phá tan điều đó: đường nhanh chỉ 1,8 ns — rẻ hơn cả một syscall rỗng hàng trăm lần, vì nó không phải syscall. Niềm tin sai vì nó gán chi phí nhầm chỗ: nó tưởng cái đắt là bản thân việc gọi semaphore, trong khi cái đắt thật là việc phải cho tiến trình ngủ và đánh thức lại.
Nhưng có một niềm tin ngược cũng nguy hiểm không kém: "semaphore rẻ mà, 1,8 ns thôi, cứ rải khắp nơi vô tư". Đó là nhìn nhầm nửa còn lại của phép đo. Nó chỉ 1,8 ns khi không tranh chấp; đẩy nó vào tình huống hai bên liên tục phải chờ nhau thì mỗi lần bàn giao trả nguyên một chuyển ngữ cảnh ~8,7 µs. Đây cũng là lời giải cho câu hỏi treo lại từ phần 26: vì sao shared memory một-buffer lockstep lại chậm? Vì mỗi khối, hai tiến trình thay phiên ngủ chờ nhau — mỗi lần bàn giao trả một cái ~8,7 µs này. Không phải semaphore chậm; mà là cái thiết kế buộc phải ngủ mỗi khối mới chậm. Sửa thiết kế để không phải ngủ (ring buffer nhiều ô, đếm luôn sẵn) thì semaphore lập tức trở lại đường nhanh 1,8 ns. Nói gọn: chi phí không nằm ở việc gọi semaphore, mà ở việc nó có phải chặn hay không — nên cả "sợ nó" lẫn "coi thường nó" đều là nhìn sai đại lượng.
Về đến code hằng ngày
Điều đầu tiên: đừng sợ semaphore/mutex ở đường nóng vì nghĩ "mỗi lần là một syscall". Khi không tranh chấp — trường hợp thường gặp nếu bạn thiết kế tốt — chúng chỉ tốn vài ns atomic, không vào nhân. Cái bạn thật sự phải tránh là tranh chấp cao buộc phải ngủ/thức liên tục; đó mới là chỗ chi phí ~8,7 µs mỗi lần đánh thức tích lại thành nút thắt.
Điều thứ hai: thiết kế để giảm số lần phải ngủ, không phải để tránh công cụ đồng bộ. Nếu một tiến trình phải ngủ chờ mỗi mẩu việc, bạn trả một chuyển ngữ cảnh mỗi mẩu — hãy gom việc thành lô (batching) để một lần đánh thức phục vụ nhiều việc, hoặc dùng ring buffer/hàng đợi có đệm để hai bên ít khi chặn nhau. Đây đúng mẫu "gom việc rồi đánh thức một lần" xuyên suốt các phần về đồng thời.
Chỗ này lặp lại y hệt ở mọi runtime cao cấp, và biết nó giải thích được nhiều "mẹo hiệu năng" nghe khó hiểu. ReentrantLock và Semaphore của Java (xây trên AbstractQueuedSynchronizer) có đúng cấu trúc này: đường không-tranh-chấp là một CAS vài ns, chỉ khi phải chờ mới park luồng (một cú vào nhân + chuyển ngữ cảnh) — nên "uncontended lock gần như miễn phí" là câu thần chú của JIT. sync.Mutex của Go cũng có fast path atomic rồi mới rơi xuống chặn qua runtime. Đây là lý do lời khuyên tối ưu khóa luôn là giảm tranh chấp (chia nhỏ vùng khóa, sharding, lock-free ở điểm nóng) chứ không phải "bỏ khóa cho nhanh". Và những semaphore đếm tài nguyên — giới hạn số kết nối trong connection pool, số request đồng thời — cũng vậy: rẻ khi còn slot, đắt (phải xếp hàng ngủ) khi cạn slot, đúng cái lọ thẻ. Cùng một bài học ai-cũng-gặp: cái đắt là phải ngủ, không phải cái tên hàm.
Muốn tự thấy thì nhìn một chỗ code dùng mutex hay semaphore mà bạn lo "nó chậm": hỏi xem trong trường hợp thường gặp, thao tác đó có phải chờ (ngủ) không, hay khóa/đếm gần như luôn sẵn? Nếu gần như luôn sẵn, chi phí thật chỉ vài ns atomic — không đáng lo, và "tối ưu" bằng cách bỏ khóa đi chỉ mời data race. Nếu nó thường xuyên phải chờ, thì vấn đề không phải là công cụ mà là tranh chấp — bạn đang trả một chuyển ngữ cảnh (~vài µs) mỗi lần chặn, và cách chữa là giảm tranh chấp (gom lô, chia nhỏ vùng khóa, thêm đệm), không phải đổi sang một nguyên hàm "nhanh hơn". Vài phút phân biệt "đường nhanh hay đường chậm" đó cho bạn biết chi phí đồng bộ thật của mình nằm ở đâu — và nó gần như luôn nằm ở chỗ phải ngủ, không phải ở cái tên hàm.