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.

POSIX semaphore giữa tiến trình

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.

Đ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ủ.

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.

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ủ 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ột lần tôi đo hớ: "semaphore = syscall = đắt"

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". Đo 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.

Bài học đo lường: chi phí của một nguyên hàm đồng bộ không nằm ở việc gọi nó, mà ở việc nó có phải chặn (ngủ) hay không. Một semaphore không tranh chấp gần như miễn phí; một semaphore buộc phải ngủ/thức trả nguyên giá một chuyển ngữ cảnh. Đâ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.

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

Hệ quả đầ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.

Hệ quả 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.

Hệ quả thứ ba là tinh thần đo lường: đo cả hai đường — nhanh và chậm — trước khi kết luận một nguyên hàm là "đắt". Con số mang theo: sem_wait/sem_post KHÔNG luôn vào nhân: khi đếm > 0 nó chỉ là atomic decrement trong user-space (~1,8 ns, futex fast path); chỉ khi phải NGỦ (đếm = 0) mới gọi futex vào nhân, tốn ~8.695 ns mỗi lần đánh thức = một chuyển ngữ cảnh (phần 5), gấp ~4.800 lần. Giá của semaphore = có phải ngủ/đánh thức hay không, chứ không phải bản thân 'gọi sem' — nên tối ưu là giảm số lần phải ngủ (lock-free/batching/đệm), không phải né semaphore. Một công cụ "đắt" hay "rẻ" tùy vào việc bạn có ép nó phải ngủ hay không.

Thử ba mươi giây

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". Ba mươi giây 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.