問題的形狀
你日後會讀到的幾乎每一支並行程式,骨架裡都藏著同一副形狀。一組執行緒製造工作;另一組消費工作;夾在中間的,是一個共享緩衝區,由製造者填、使用者抽。一台網頁伺服器有接受連線的執行緒和處理連線的執行緒。一套日誌系統有許多吐出訊息行的執行緒,和一個把它們寫到磁碟的執行緒。一條影片管線有一條解碼影格的執行緒、一條繪製影格的執行緒。把外衣剝掉,它們都是同一齣戲,叫做生產者—消費者(producer–consumer)問題——而一旦你能乾淨地解掉它,你就能解掉一百支表面看起來各不相同的程式。
我們之所以在中間擺一個緩衝區,理由是解耦(decoupling)。生產者與消費者幾乎從不跑在完全相同的速度上,而我們不希望任一邊時時刻刻在等另一邊。緩衝區讓快的生產者衝在前頭、先囤幾個項目,等消費者趕上;也讓快的消費者在生產者暫停時把積壓抽光。緩衝區吸收那些顛簸。但它不能是無限的——記憶體是有限的——所以它有固定的容量,比方說可放 N 個項目。就是這個事實,有界(bounded)容量,讓問題變得有意思,因為現在有兩種卡住的方式,不只一種。
把那兩種卡住的方式講清楚,因為整個解法就是為了恰好處理這兩種而打造的。第一,緩衝區可能是滿的:手裡握著新項目的生產者沒地方放,於是它必須等到有空位騰出來。第二,緩衝區可能是空的:兩手空空的消費者沒東西可拿,於是它必須等到有項目出現。而疊在這兩條等待規則之上的,是你已經熟悉的危險——緩衝區是共享可變狀態,所以每一次放入、每一次取出都是一個臨界區,兩條執行緒絕不能同時跑它。於是有三項義務:對緩衝區的互斥、滿時阻塞、空時阻塞。本事就在於一次滿足這三項。
三樣工具,各司一職
令人滿意的部分來了:你需要的每一塊零件,在前面三篇都已經造好,而生產者—消費者不過就是它們咬合在一起的地方。第一篇給了你互斥鎖來做互斥——它處理第一項義務,讓兩條執行緒絕不破壞緩衝區的內部。第二篇給了你條件變數與「在述語上等待」的樣式——它處理第二、第三項義務,也就是阻塞直到某條件成立。第三篇給了你號誌,作為一種數資源的方式。把這些組裝起來有兩條乾淨的路,看過兩條,這個樣式才會真正黏在腦子裡。
用條件變數的組裝法,是一把互斥鎖加兩個條件變數。互斥鎖保護緩衝區。一個條件變數,叫它 `not_full`,是生產者在緩衝區滿時等待的對象;另一個 `not_empty`,是消費者在緩衝區空時等待的對象。放入一個項目的生產者,會發訊號給 `not_empty`,喚醒一個沉睡的消費者;移除一個項目的消費者,會發訊號給 `not_full`,喚醒一個沉睡的生產者。每一邊等它自己需要的條件,並喚醒另一邊正在等的條件。這份對稱正是核心所在。
用號誌的組裝法,用不同的名詞講同一個故事,許多人覺得它是兩者中較優雅的。用兩個計數號誌:`empty` 初始化為 N(空位的數量),`full` 初始化為 0(已填位的數量)。生產者先做 `wait(empty)`——消耗一個空位,若一個都沒有就自動阻塞——接著把項目放進去,再做 `post(full)`——宣告又多了一個已填位。消費者完全鏡像它:`wait(full)`、取一個項目、`post(empty)`。號誌就是那些位子的計數,於是「滿時阻塞」與「空時阻塞」白白從計數中掉出來,連一條述語都不必寫。
一步一步走過有界緩衝區
讓我們看著條件變數版本跑一遍,因為親手追一次,這個樣式就會烙進腦中。想像一個可放 3 個項目的緩衝區,目前裝了 2 個。一個生產者想加入第三個。它鎖住互斥鎖,檢查緩衝區未滿(還有一個空位),把項目放進去,並發訊號給 `not_empty`,以防有消費者正在睡。現在第二個生產者來了,想加入第四個項目:它鎖住互斥鎖,看見緩衝區滿了,於是它不去自旋、也不失敗,而是對 `not_full` 呼叫 wait——這會原子地釋放互斥鎖並去睡覺,好讓消費者得以進來。
- 生產者:鎖住互斥鎖(進入擁有緩衝區的臨界區)。
- 生產者:當緩衝區為滿時(WHILE,而非 IF)就在 not_full 上等待。注意是「while」不是「if」——每次醒來都要重新檢查條件,因為有偽喚醒,也因為在你真正執行之前,別的執行緒可能已把那個空位又填掉。
- 生產者:此時保證有一個空位——放入項目,推進尾索引。
- 生產者:發訊號給 not_empty(喚醒一個可能正在等的消費者),然後解鎖互斥鎖。
- 消費者鏡像它:上鎖;當為空時(WHILE)在 not_empty 上等待;取出一個項目;發訊號給 not_full;解鎖。
/* condition-variable bounded buffer: the two halves are mirror images */
void produce(item x) {
pthread_mutex_lock(&m);
while (count == N) /* WHILE, never IF */
pthread_cond_wait(¬_full, &m);
buf[tail] = x;
tail = (tail + 1) % N;
count++;
pthread_cond_signal(¬_empty); /* a consumer may be asleep */
pthread_mutex_unlock(&m);
}
item consume(void) {
pthread_mutex_lock(&m);
while (count == 0) /* WHILE, never IF */
pthread_cond_wait(¬_empty, &m);
item x = buf[head];
head = (head + 1) % N;
count--;
pthread_cond_signal(¬_full); /* a producer may be asleep */
pthread_mutex_unlock(&m);
return x;
}追一下喚醒的過程,就能體會為什麼 `while` 迴圈不是可有可無。當消費者取走一個項目並發訊號給 `not_full` 時,沉睡的生產者醒來——但它並不會立刻執行;它得先重新取得互斥鎖,而在那個空檔裡,另一個生產者可能已搶進來把空位又填掉。如果我們這個被喚醒的生產者盲目相信訊號就放入項目,緩衝區就會溢位。所以它繞回去重新測 `count == N`,只在空位真的存在時才往下走。這正是條件變數那篇講的「在述語上等待」紀律,也是你對抗偽喚醒的防線——作業系統毫無理由地喚醒一個等待者。把訊號當成重新檢查的提示,絕不要當成承諾。
會咬到真實系統的陷阱
現在來談誠實的部分,因為生產者—消費者在紙上看起來整齊,實務上卻會以惡毒、時有時無的方式壞掉。第一個陷阱是遺失喚醒(lost wakeup)。假設你不小心在沒有持有互斥鎖時就發訊號給 `not_empty`,而那一刻消費者剛測完 `count == 0`、卻還沒真正睡著。訊號朝著一間空房射出——此刻還沒人在等——就消失了。一個心跳之後消費者睡著,但它需要的那次喚醒早已來了又走,於是即便緩衝區裡正躺著一個項目,它仍永遠睡下去。解藥正是我們為何要在檢查與等待之間全程持有互斥鎖:它讓「看見條件」與「去睡覺」成為一個不可分割的步驟,沒有任何訊號能擠進兩者之間。
第二個陷阱是死結(deadlock),而生產者—消費者正是它經典的現身場所之一——這也正是本階段下一篇、也就是最後一篇要專門講它的原因。在這裡最簡單的死結方式,是在仍持有某把鎖(而唯一能喚醒你的執行緒正需要那把鎖)時就去等待。想像一個生產者鎖住互斥鎖、發現緩衝區滿了,然後沒釋放互斥鎖就睡在 `not_full` 上。沒有任何消費者能鎖住互斥鎖去取走項目,於是沒有人會發訊號給 `not_full`,於是生產者永遠睡下去,整個系統凍結。我們的程式碼之所以安全,正是因為 pthread_cond_wait() 在睡眠的同時原子地釋放互斥鎖——這正是初學者自己手刻時最常弄錯的那一個細節。
為什麼這個樣式無所不在
退一步看你實際造出了什麼:一個執行緒安全的佇列。生產者—消費者真正的本質就是這個——一個你能跨執行緒邊界放入與取出的佇列,放入在滿時阻塞、取出在空時阻塞。這個物件有許多名字——阻塞佇列、通道(channel)、管道(pipe)——而它是嚴肅並行軟體如何組織的骨幹。與其讓執行緒伸手進彼此的記憶體、到處為鎖爭搶,不如讓它們只透過這些佇列傳遞項目來溝通。那份危險的共享可變狀態,就收縮成一個你只需要做對一次、守護得當的緩衝區。
這個觀念會一路往抽象堆疊上方延伸。你用互斥鎖與條件變數寫出的「生產者與消費者之間的緩衝區」,跟 shell 管線 `producer | consumer` 是同一副形狀——那裡核心的管道就是有界緩衝區,作業系統替你處理阻塞。它跟執行緒池(thread pool)也是同一副形狀——工作執行緒消費著其他執行緒放上共享工作佇列的任務。為並行而生的語言,例如 Go,直接把這個物件當成通道(channel)交給你,並在底下默默地跑著互斥鎖與條件變數那套機制。在這個層級學會它一次,你就會在上面的每一層永遠認得它。
所以把這份東西當成前三篇的獎賞帶著走:互斥鎖給了你安全,條件變數給了你等待某個狀態的能力,號誌給了你計數,而生產者—消費者就是這三者合為一個可重複使用之物的地方。你學到的不只是一個問題;你學到了真實並行系統賴以組裝的那個單元。剩下的最後一個危險,正是我們一路擦身而過的那個——當等待出了錯、執行緒在一個循環裡永遠互相阻塞時會發生什麼——那就是死結,本階段最後一篇的主題。