Bạn cần dậy lúc 6 giờ sáng. Có hai cách. Cách thứ nhất: thức trắng đêm, cứ vài giây lại liếc đồng hồ — phản ứng tức thì đúng 6 giờ, nhưng bạn kiệt sức và phí cả đêm. Cách thứ hai: ngủ, và nhờ lễ tân gọi báo thức — bạn được nghỉ ngơi (không tốn gì trong lúc chờ), đổi lại có một độ trễ nhỏ để tỉnh dậy và ngồi lên khi chuông reo. Biến điều kiện là cách thứ hai. Nhưng cách "nhờ gọi báo thức" có hai cái bẫy mà ít ai để ý: đôi khi bạn tỉnh giấc giữa chừng vì một tiếng động (chứ không phải chuông) — nên tỉnh dậy không có nghĩa là đã tới giờ, phải liếc đồng hồ đã; và nếu lễ tân gọi trước khi bạn kịp báo "tôi đang chờ cuộc gọi", cú gọi đó bay vào hư không và bạn ngủ mãi. Cả hai bẫy đó có thật trong pthread_cond_wait, và có một cái giá cần đo.
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 (thời gian tỉnh dậy và ngồi lên). 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ỗ (thức trắng đêm liếc đồng hồ), 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 (tỉnh giấc vì tiếng động, chưa chắc đã 6 giờ). 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 (liếc đồng hồ rồi mới quyết định dậy hay ngủ tiếp):
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 (lễ tân gọi trước khi bạn kịp báo mình đang chờ). 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ủ.
Hai niềm tin đối nghịch, cùng sai
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. Hai niềm tin sai gặp nhau ở một bài học: "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ề đến code hằng ngày
Điều đầ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ỡ.
Điều 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 (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.
Chỗ này cái hợp đồng "while + giữ khóa" không phải chuyện riêng của C — nó được bê gần nguyên văn vào mọi ngôn ngữ, và biết vậy giúp bạn khỏi vấp cùng một bug ở Java hay Go. Java Object.wait()/notify() yêu cầu y hệt: tài liệu chính thức (và JLS) nói wait() có thể spurious wakeup, nên phải gọi nó trong một vòng while kiểm điều kiện, và trong khối synchronized giữ đúng khóa của object đó — bỏ một trong hai là bug kinh điển "notify bị lỡ" hay "dùng dữ liệu chưa sẵn sàng"; java.util.concurrent.Condition (await/signal) cũng đòi giữ Lock và vòng while. Go thì đi hướng khác để né hẳn hai cạm bẫy: thành ngữ là dùng channel — một <-ch chặn cho tới khi có giá trị, một ch <- v đánh thức, và channel không có spurious wakeup, không cần bạn tự quản mutex cho việc chờ; Go vẫn có sync.Cond (và Wait() của nó cũng đòi giữ lock, vẫn cần vòng while), nhưng cộng đồng khuyên dùng channel chính vì nó xóa bỏ cả lớp bug này. Mẫu số chung: mọi cơ chế "ngủ chờ một điều kiện" đều mang cùng hai rủi ro (thức giả, lỡ tín hiệu), và giải pháp hoặc là kỷ luật while+khóa (C/Java), hoặc là một nguyên hàm cao cấp hơn đã đóng gói sẵn (channel của Go).
Muốn tự soi thì 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ờ. Vài phút đó 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.