一把鎖解不了的問題
從第 1 篇你知道互斥鎖做什麼:它保護一個臨界區,讓同一時間只有一個執行緒去碰某份共用資料。那是互斥,它解決的是相撞。但還有第二種、不一樣的需求,是單靠一把鎖滿足不了的:等一個條件變成真。想像一個共用佇列,由一個工作執行緒從裡頭拉出工作。當佇列空了,工作執行緒就沒事可做——它必須等,等到別的執行緒加進一份工作。鎖能守住佇列,卻沒有辦法說「睡到有工作為止,然後叫醒我」。
最天真的修法是空轉:上鎖、看看佇列是不是空的、解鎖,再迴圈一次——一遍又一遍,直到有東西冒出來。這行得通,卻浪費得最糟糕。一個忙碌等待的迴圈會把一整顆 CPU 核心燒在什麼都不做上,只是一秒問上百萬次「準備好了沒?準備好了沒?」。在筆電上那意味著風扇變燙、電池見底;在伺服器上那意味著一顆核心被從真正的工作裡偷走。你在第 1 篇的自旋鎖那裡碰過同樣的誘惑——空轉只有在已知等待長度只有寥寥幾道指令時才說得過去。等一個人去打字、或等一份可能要好幾秒才到的工作,並不是那種情況。
我們真正想要的,是讓那個等待的執行緒去睡覺——被排程器擱到一旁、耗用零 CPU——而且只在它所等待的東西可能變了的時候才被叫醒。那正是條件變數的工作:它是「我握著一把鎖,而世界還不是我需要的樣子」與「別的執行緒剛剛改變了世界;叫醒那些睡著的」之間的橋樑。
條件變數是什麼,又不是什麼
一個條件變數是一個小物件,讓一個執行緒等待,直到另一個執行緒發訊說某件事可能變了。它永遠和一把互斥鎖搭檔工作——絕不單飛。核心操作有三個:`wait`,它原子性地釋放互斥鎖、並讓呼叫它的執行緒去睡覺;`signal`(有時叫 `notify_one`),它叫醒一個正在等待的執行緒;以及 `broadcast`(`notify_all`),它叫醒全部的。在 POSIX 執行緒裡,這三個是 pthread_cond_wait()、pthread_cond_signal() 與 pthread_cond_broadcast()。
首先要弄精確的一件事:一個條件變數不帶任何值、也什麼都不記得。它不是一個你能讀的旗標;它不是一個布林值。真正的條件——「佇列非空了嗎?」——住在你自己的共用變數裡,由互斥鎖保護。條件變數純粹是接在那份資料上的一間候診室。這之所以要緊,是因為一個嚴酷的後果:如果一個執行緒在當下沒有任何人等待的時候對條件變數發訊,那個訊號就單純地丟失了——它不會被存起來、留給下一個到來的執行緒。這正是它與號誌(第 3 篇)之間最大的單一差異,號誌會記住計數。把這兩個搞混,你就會寫出微妙的臭蟲。
讓它成立的那個原子性的「釋放並睡覺」
這裡是微妙的部分,值得放慢來看。一個等待的執行緒握著互斥鎖(它必須握著,才能安全地去看那份共用資料、發現條件是假的)。但如果它就這樣握著鎖去睡,那就沒有別的執行緒能取得那把鎖去改變資料並發訊了——大家會永遠卡死。所以 `wait` 必須當作一個不可分割的步驟做兩件事:釋放互斥鎖並讓執行緒去睡覺。等到它後來被叫醒時,它做相反的事:在回傳前重新取得互斥鎖,於是執行緒又回到握著鎖、就跟先前一模一樣,可以再去看那份資料。
為什麼「釋放並睡覺」必須是原子的——一個不可中斷的單一步驟?想像另一種、分成兩步來做的做法:執行緒解開互斥鎖,然後,過了一瞬,才呼叫睡覺。在這兩步之間的縫隙裡,另一個執行緒可能搶到鎖、加進一份工作、然後發訊——但我們的執行緒還沒去睡,所以它完全錯過了那個訊號,接著它才睡下,此刻卻在等一個早已發生、再也不會來第二次的喚醒。這就是喚醒遺失問題,正是條件變數的設計所要防範的那個競爭。把解鎖與睡覺折進一個原子操作裡,pthread_cond_wait() 就沒留下任何縫隙,讓訊號從中溜走。
以述詞為條件反覆等待的模式(那唯一的規則)
有一個模式你必須內化,而打破它,是全世界最常見的條件變數臭蟲。你不是呼叫一次 wait、就相信它回傳時你的條件為真。相反地,你在一個迴圈裡等待,每次醒來都重新檢查述詞。用白話說:只要我需要的條件還是假的,就繼續等。只有當迴圈的測試終於失敗——意味著條件此刻為真——你才落出迴圈、繼續往下走,而且仍然握著互斥鎖。
/* consumer: wait until the queue has an item, then take one */
pthread_mutex_lock(&m);
while (count == 0) { /* WHILE, not if */
pthread_cond_wait(&cv, &m); /* atomically: unlock m, sleep,
wake, relock m */
}
item = take_from_queue(); /* safe: we hold m and count > 0 */
count--;
pthread_mutex_unlock(&m);
/* producer: add an item, then wake a waiter */
pthread_mutex_lock(&m);
add_to_queue(item);
count++;
pthread_cond_signal(&cv); /* wake one consumer */
pthread_mutex_unlock(&m);迴圈必須是 `while`、不能是 `if`,有兩個理由。第一,假性喚醒:一個假性喚醒是指 pthread_cond_wait() 即使沒有任何人發訊也回傳了——POSIX 標準明確允許這件事,因為禁止它會讓實作在某些硬體上變慢。所以從 wait 回傳意味著「也許有什麼變了」,絕不是「一定」。第二,被偷走的喚醒:即使在一個真正的訊號之後,另一個執行緒也可能先醒、搶到互斥鎖、把你被通知的那份東西消費掉,等你重新取得鎖時佇列又空了。這兩種情況的解藥一模一樣——重新檢查述詞,如果它還是假的,就單純地再等一次。迴圈把「也許」變成一個保證。
signal、broadcast,以及這往哪走
當你改變了共用狀態、想叫醒等待者時,你在 `signal` 與 `broadcast` 之間做選擇。signal 叫醒(至少)一個正在等待的執行緒;當任何單一一個等待者都能往前推進、而叫醒一個就夠時用它——例如往佇列加進一份工作,因為只有一個消費者能拿走它。broadcast 叫醒所有等待者;當那個改變可能讓好幾個等待者往前走時、或當不同的等待者在等共用同一個條件變數的不同述詞時用它。拿不準時,一條安全但較慢的拇指法則是:broadcast 永遠是對的(那些醒來後仍發現自己述詞為假的執行緒,多虧那個 `while`,會單純地再迴圈、再等一次),而 signal 則可能叫醒得太少。一旦你確定一次喚醒就夠了,為了效率就偏好 signal。
關於順序,有一個誠實的提醒。條件變數對於哪一個等待者醒來、以什麼順序醒來,不做任何承諾——沒有公平性保證,所以原則上一個特定的執行緒可能被跳過很多次。通常這沒關係;當它有關係時,你就踏進了飢餓的地界,那是第 5 篇與死結的討論會回頭談的顧慮。另外,別為了「省一把鎖」而試圖在沒握著互斥鎖時發訊:雖然 POSIX 技術上允許,但在鎖內發訊才是簡單、無競爭的習慣,而代價微不足道。
退一步,看看你建起來的形狀。一把互斥鎖加一個條件變數加一個共用述詞,正好就是經典的監視器——那個 監視器模式,Java 這類語言把它烘進了 `synchronized`,而 C++ 以 std::condition_variable 搭配 std::unique_lock 提供。你現在握有這整個章節核心範例所需的一切:生產者-消費者問題,生產者往一個緩衝區加東西、消費者從裡頭拿走,各自在緩衝區空了或滿了時禮貌地等待。第 3 篇引入號誌這個會計數的替代工具,而第 4 篇把生產者與消費者真正湊在一起。你在這裡學到的「以述詞為條件反覆等待」的迴圈,就是這一切跳動的心臟。