Khi một luồng phải chờ một điều kiện — hàng đợi có việc, dữ liệu sẵn sàng — nó có hai lựa chọn: quay bận (busy-poll) kiểm cờ liên tục, hay ngủ cho tới khi ai đó đánh thức. Công cụ chuẩn cho cách ngủ là biến điều kiện (condition variable): pthread_cond_wait để ngủ chờ, pthread_cond_signal để đánh thức. Nghe đơn giản, nhưng nó chứa hai cạm bẫy đúng đắn mà nhiều người bỏ qua, và một cái giá hiệu năng cần đo. Tôi đo cả ba.
Đánh thức không tức thì
Khi luồng B gọi cond_signal, luồng A đang cond_wait không chạy tiếp ngay lập tức — nó phải được hệ điều hành lập lịch lại, tức một lần chuyển ngữ cảnh. Tôi đo bằng một ping-pong hai luồng thay phiên đánh thức nhau qua biến điều kiện, và so với một ping-pong busy-poll (quay bận trên một cờ atomic), trong container gcc:13:
cond_wait / cond_signal : ~8500 ns mỗi lần đánh thức (round-trip ~17 µs)
busy-poll (spin cờ) : ~35 ns mỗi lần bàn giao (round-trip ~70 ns)
tỉ lệ cond / poll : ~245×
Đánh thức qua biến điều kiện tốn khoảng 8,5 µs — đúng bằng chi phí một chuyển ngữ cảnh mà ta đo ở phần trước, vì mỗi lần đánh thức là một lần ngủ→thức thật sự. Busy-poll thì chỉ 35 ns — nhanh hơn 245 lần về độ trễ. Nhưng con số độ trễ không kể hết câu chuyện: busy-poll đạt độ trễ thấp bằng cách đốt trọn một lõi CPU quay tại chỗ, trong khi biến điều kiện để luồng chờ ngủ, nhường CPU (0% khi chờ). Đây là đánh đổi kinh điển — độ trễ so với lãng phí CPU — mà ta đã gặp ở spinlock vs mutex.
Hai cạm bẫy đúng đắn: spurious và lost wakeup
Đây mới là phần khiến biến điều kiện khó dùng đúng. Cạm bẫy thứ nhất: spurious wakeup (đánh thức giả). cond_wait có thể trả về dù không ai gọi signal — do cơ chế bên dưới (tín hiệu, cạnh tranh nội bộ). Nghĩa là "thức dậy" không bảo đảm điều kiện bạn chờ đã thỏa. Hệ quả: bạn bắt buộc kiểm điều kiện trong một vòng while, không phải một if:
pthread_mutex_lock(&mx);
while (!ready) // WHILE, không phải IF
pthread_cond_wait(&cv, &mx);
// tới đây chắc chắn ready == true
pthread_mutex_unlock(&mx);
Nếu dùng if, một spurious wakeup sẽ cho code chạy tiếp khi điều kiện chưa đúng — dùng dữ liệu chưa sẵn sàng.
Cạm bẫy thứ hai: lost wakeup (mất tín hiệu). Nếu luồng B signal trước khi luồng A kịp cond_wait, tín hiệu đó bay đi vào hư không — A ngủ sau đó và không ai đánh thức nữa, treo mãi. Cách chống: cond_wait phải đi kèm một mutex bảo vệ cả điều kiện lẫn việc chờ. Vì A kiểm ready và gọi cond_wait trong khi giữ mutex, còn B đặt ready=1 rồi signal cũng trong khi giữ mutex, hai bên không thể chen vào giữa nhau — nếu B đã đặt ready trước, A thấy nó ngay ở vòng while và không ngủ.
Một lần tôi đo hớ: "thức dậy" không bằng "điều kiện đúng"
Tôi vào đo với hai niềm tin phổ biến. Thứ nhất: "đánh thức gần như tức thì". Đo ra 8,5 µs — không tức thì, đó là một chuyển ngữ cảnh; nếu bạn cần độ trễ cực thấp (dưới µs), biến điều kiện không phải công cụ, mà là busy-poll (đổi bằng CPU). Thứ hai, nguy hiểm hơn: "cond_wait trả về nghĩa là điều kiện đã đúng, dùng if là đủ". Sai — spurious wakeup là thật, và một if biến nó thành bug.
Cái bẫy ở đây giống hệt reorder bộ nhớ ở phần trước: spurious và lost wakeup hiếm, nên code dùng if hay bỏ mutex sẽ chạy đúng trong gần như mọi lần test, rồi hỏng một lần trong nhiều ở production — một treo luồng không tái hiện được, hay một lần đọc dữ liệu chưa sẵn sàng. Bài học đo lường: "chạy thử thấy đúng" không chứng minh code đồng thời đúng; mẫu đúng phải theo đúng hợp đồng của công cụ, không phải theo cái chạy được lần này. Mẫu while(!điều_kiện) cond_wait(cv, mx) dưới mutex không phải nghi thức thừa — mỗi phần của nó chống một cạm bẫy cụ thể.
Vì sao chi phí đánh thức đúng bằng chuyển ngữ cảnh
Con số 8,5 µs không phải ngẫu nhiên trùng với chi phí chuyển ngữ cảnh — nó chính là chi phí đó. Một biến điều kiện được xây trên futex, giống mutex khi bị giành: luồng chờ được đưa vào trạng thái ngủ trong nhân, và signal đánh thức nó, kéo theo một lần lập lịch lại và một lần chuyển ngữ cảnh. Nên mọi thứ liên quan đến "ngủ rồi thức" trong sê-ri này — mutex tranh chấp vùng dài, cond_wait, bất kỳ chỗ nào luồng thật sự chặn — đều chia sẻ cùng một sàn chi phí ~8,5 µs. Điều này giúp ước lượng nhanh: nếu code của bạn đánh thức luồng hàng trăm nghìn lần mỗi giây, chỉ riêng khâu đánh thức đã ngốn một phần đáng kể CPU (hàng trăm nghìn × 8,5 µs). Đó cũng là lý do các mẫu hiệu năng cao gom việc thành lô rồi đánh thức một lần thay vì đánh thức cho mỗi phần tử — và đo lường đúng cách là cách duy nhất biết khâu đánh thức có phải điểm nóng của bạn không.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: luôn dùng vòng while quanh cond_wait, và luôn giữ mutex. Đây là hợp đồng của biến điều kiện, không phải tùy chọn. Vòng while chống spurious wakeup; mutex chống lost wakeup và bảo vệ điều kiện. Bỏ một trong hai là mời một bug đua tranh gần như không thể gỡ.
Hệ quả thứ hai: chọn ngủ hay quay theo yêu cầu độ trễ và CPU. Nếu luồng chờ một sự kiện hiếm (vài lần mỗi giây), biến điều kiện là đúng: nó nhường CPU, và 8,5 µs độ trễ chẳng đáng gì. Nếu chờ một sự kiện rất thường xuyên và cần độ trễ cực thấp (như một vòng lặp thời gian thực), busy-poll hoặc lai (quay một chút rồi ngủ) có thể đáng — nhưng chỉ khi bạn có lõi CPU để đốt.
Hệ quả thứ ba là tinh thần đo lường: hiểu cả cái giá lẫn hợp đồng của một nguyên hàm trước khi dùng. Con số mang theo: đánh thức qua cond_wait tốn ~8,5 µs (một chuyển ngữ cảnh, luồng chờ ngủ nhường CPU); busy-poll nhanh hơn 245 lần về độ trễ (~35 ns) nhưng đốt trọn một lõi; và cond_wait có thể spurious wakeup (thức không do signal) lẫn lost wakeup (signal trước wait) — nên PHẢI kiểm điều kiện trong vòng while (không phải if) và giữ mutex quanh nó. "Thức dậy" không bao giờ bằng "điều kiện đã đúng".
Thử ba mươi giây
Nhìn lại đoạn code dùng biến điều kiện gần nhất của bạn (hay của thư viện bạn đọc) và kiểm hai thứ: điều kiện được kiểm bằng while hay if? và cond_wait có nằm dưới một mutex bảo vệ đúng biến điều kiện đó không? Nếu là if, hoặc mutex bị thiếu/sai, bạn có một bug đua tranh tiềm ẩn — nó sẽ đúng trong test và hỏng ở production. Rồi thử một thí nghiệm suy nghĩ: nếu luồng báo tín hiệu chạy trước luồng chờ kịp ngủ, chuyện gì xảy ra? Nếu câu trả lời là "luồng chờ treo mãi", bạn đã thiếu mutex hoặc thiếu việc kiểm điều kiện trước khi chờ. Ba mươi giây đó bắt được một lớp bug mà không lần chạy thử nào bắt được — vì với đồng thời, đúng nghĩa là theo đúng hợp đồng, không phải theo cái chạy được hôm nay.