Learning Objectives
- 說明忙等的代價,用條件變數讓執行緒睡著等待
- 親手排出喚醒遺失與 if 檢查的錯誤,說明為什麼一定要用 while 重新檢查條件
- 用號誌解有界緩衝區,說明 sem_wait 與上鎖的順序為什麼不能反過來
- 說明讀者優先的鎖為什麼會讓寫者飢餓,並用閘門修好它
- 判斷什麼情況用 mutex、條件變數、號誌或屏障
等待某件事發生,不要一直問
上一課的鎖解決「不要同時進去」。另一種常見的需求是等:等緩衝區有資料、等下載完成、等別人把工作做完。
最直接的寫法是一直檢查:
while (ready == 0) { } // 還沒好就再看一次
主執行緒 sleep(1) 一秒之後才把 ready 設成 1。把這件事放在教學核心實際跑:這樣忙等的版本總共花了 1012 步,改用這一課的條件變數只要 22 步,兩者的虛擬時間都差不多是一秒。多出來的一千步全是等待的人在反覆讀同一個變數,什麼事也沒做。
我們要的是:條件不成立就睡著,別人改了條件再叫醒我。
條件變數的固定寫法
條件變數(condition variable)是「等某個條件」的等候室,一定要搭配一把 mutex 和一個你自己的共用變數。
// 等的人
pthread_mutex_lock(&lock);
while (ready == 0) {
pthread_cond_wait(&cond, &lock); // 放開鎖、睡著;被叫醒後重新拿到鎖才回來
}
// 這裡 ready 一定不是 0,而且我拿著鎖
pthread_mutex_unlock(&lock);
// 改變條件的人
pthread_mutex_lock(&lock);
ready = 1;
pthread_cond_signal(&cond); // 叫醒一個在等的人
pthread_mutex_unlock(&lock);
三個地方都不是隨便寫的:
| 要素 | 少了會怎樣 |
|---|---|
共用變數 ready |
條件變數沒有記憶:沒人在等的時候 signal,這個通知就消失了 |
| 上鎖之後才檢查 | 檢查完、還沒睡著之前對方送了通知,你會睡著等一個已經過去的通知 |
while 而不是 if |
被叫醒不代表條件成立,要再檢查一次 |
pthread_cond_wait 的關鍵是「放開鎖」和「開始等待」對其他執行緒來說是一個不可分割的動作,所以這個空隙不存在。
下面兩個練習,把第一列和第三列弄壞。
加 sleep 不算修好
遇到這個錯誤,很多人的第一反應是在 main 的 signal 前面加一行 sleep(1),讓對方先開始等。這樣測試通常會過,但它只是賭對方一秒內一定走到 wait;機器忙的時候賭輸,錯誤又回來,而且更難重現。所以上面的任務不接受 sleep。
同步的錯誤要用同步的方法修:讓程式不管誰先誰後都對。
真實 Linux 上會出錯嗎
同樣的邏輯放到真正的 Linux:一個生產者、兩個消費者,總共消費 60 萬個,記錄 items 變成負數幾次。一支用 if,一支用 while:
第 1 次:
if items went negative 598327 times
while items went negative 0 times
第 2 次:
if items went negative 238643 times
while items went negative 0 times
第 3 次:
if items went negative 438468 times
while items went negative 0 times
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
if 版本錯得很明顯,次數這麼多是因為一旦變成負數,items == 0 就不成立,消費者連等都不等,繼續往下減。while 版本在這幾次都是 0。
真實系統還有一個理由非用 while 不可:POSIX 明文規定 pthread_cond_wait 可能虛假喚醒(spurious wakeup),沒有人 signal 也可能回來,並建議把等待包在 while 迴圈裡重新檢查。教學核心不會發生虛假喚醒,但你寫的程式要假設它會發生。
另外兩個常用的選擇:
- signal 還是 broadcast:
pthread_cond_signal叫醒至少一個,pthread_cond_broadcast叫醒全部。不確定該叫誰(例如不同人在等不同條件)就用 broadcast,代價是全部醒來再搶一次。因為用 while 檢查,多叫醒並不會出錯。 - signal 時要不要拿著鎖:兩種都合法。改條件這件事本身一定要在鎖裡。
號誌:有記憶的計數器
號誌(semaphore)是 Dijkstra 在 1960 年代提出的同步工具(手稿 EWD 123)。它是一個不會小於 0 的整數,只有兩個不可分割的操作:
| 操作 | POSIX | 意思 |
|---|---|---|
| P | sem_wait(&s) |
值大於 0 就減 1 繼續;是 0 就等到別人加上去 |
| V | sem_post(&s) |
值加 1,有人在等就叫醒一個 |
和條件變數最大的差別:號誌有記憶。先 sem_post 再 sem_wait 不會卡住,因為那個 1 留在數值裡。剛才的喚醒遺失,用初值 0 的號誌就不會發生。
初值決定用途:
| 初值 | 用途 |
|---|---|
| 1 | 當鎖用(互斥) |
| 0 | 排順序:等別人做完某件事 |
| N | 限制人數:最多 N 個人同時使用某個資源 |
經典問題是有界緩衝區(bounded buffer):生產者把東西放進大小為 N 的緩衝區,消費者拿走。滿了生產者要等,空了消費者要等。要兩個號誌加一把鎖:
empty:還有幾個空位,初值 N。生產者放之前先sem_wait(&empty)。full:有幾個東西,初值 0。消費者拿之前先sem_wait(&full)。- 一把鎖:保護
buf、in、out,因為可能有多個生產者同時放。
sem_wait(&empty); // 先確定有空位
pthread_mutex_lock(&lock); // 再上鎖
buf[in] = item;
in = (in + 1) % N;
pthread_mutex_unlock(&lock);
sem_post(&full); // 多了一個東西
這個順序不能反過來。下面的程式就是反過來的。
一條可以記住的規則
不要拿著鎖去做可能會等很久的事。 上一課的 sleep 在鎖裡只是變慢;這裡是拿著鎖等的那件事,剛好需要這把鎖才會發生,就永遠卡住了。下一課會把這種情況一般化。
條件變數沒有這個問題,因為 pthread_cond_wait 等待時會放開鎖。號誌不知道你拿著哪把鎖,所以要自己排好順序。
把鎖拿掉行不行?兩個生產者可能同時寫同一格 buf[in],答案會錯;可以試試看,隨機跑會出現很多種 sum。
讀者寫者:可以同時讀,寫要獨佔
很多資料是讀的人多、寫的人少。只讀不改,多個人同時讀完全沒有問題;只用一把 mutex 就白白排隊了。這個問題由 Courtois、Heymans 與 Parnas 在 1971 年提出並討論,規則是:
- 讀者可以同時好幾個一起讀。
- 寫者要獨佔:有人在寫就不能讀,有人在讀就不能寫。
最常見的解法是用一個號誌 room 代表「整個房間」:寫者進去要佔住它;第一個讀者進來時替所有讀者佔住,最後一個讀者離開時放開,中間的讀者直接進來。
問題是:如果讀者一直來,而且總有人還在讀,readers 永遠不會變成 0。寫者就永遠進不去。這叫飢餓(starvation):不是死結,系統一直有進展,只是某個人一直輪不到。
下面的程式讓兩個讀者錯開時間、讀的時間有重疊,寫者在 0.05 秒時就想寫了。睡眠用的是虛擬時鐘,所以每次跑的時間關係都一樣。
真實 Linux 上的結果
同樣兩支程式放到真正的 Linux 上,先是沒有閘門,再來是有閘門:
第 1 次:
writer got in after 8 reads
most readers at once = 2
第 2 次:
writer got in after 8 reads
most readers at once = 2
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
第 1 次:
writer got in after 1 reads
most readers at once = 2
第 2 次:
writer got in after 1 reads
most readers at once = 2
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
和教學核心的結果相同。這裡用 usleep 把時間關係固定下來,真實的伺服器不會這麼整齊,但只要讀者夠多,寫者一直排不到的情況是一樣的。
屏障:大家都到了才一起走
屏障(barrier)解決另一種等待:好幾個執行緒分階段做事,全部做完這一階段才能進入下一階段。常見於平行計算。
pthread_barrier_init(&bar, NULL, 3); // 三個人都到才放行
// 每個執行緒
printf("load %ld\n", (long)arg);
pthread_barrier_wait(&bar); // 先到的在這裡等
printf("compute %ld\n", (long)arg);
真實 Linux 跑兩次。同一階段裡的順序每次不同,但所有 load 一定在所有 compute 前面:
第 1 次:
load 1
load 0
load 2
compute 2
compute 1
compute 0
第 2 次:
load 0
load 1
load 2
compute 2
compute 1
compute 0
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
上一課測競爭條件的程式也用了屏障:讓兩個執行緒盡量同時開始,錯誤才容易出現。
| 我要的是 | 用這個 |
|---|---|
| 不要同時進去 | mutex |
| 等某個條件成立(條件可以很複雜) | 條件變數+mutex+共用變數 |
| 數量:幾個空位、幾個東西、最多幾個人 | 號誌 |
| 所有人都到了才繼續 | 屏障 |
| 讀的人多、寫的人少 | 讀寫鎖 pthread_rwlock |
確認一下
為什麼 pthread_cond_wait 要傳入一把鎖?
下面哪一種寫法最不可靠?
一個號誌初值 0。A 先呼叫 sem_post,B 之後才呼叫 sem_wait。B 會怎樣?如果換成條件變數,A 先 signal、B 才 wait(沒有共用變數)呢?
有界緩衝區的生產者寫成 lock(&m); sem_wait(&empty); …。緩衝區滿了的時候會怎樣?
讀者優先的讀者寫者鎖,寫者一直進不去。這是死結嗎?
重點整理
- 等條件成立不要忙等:條件變數讓人睡著,改條件的人負責叫醒。固定寫法是鎖、共用變數、while 三樣都要有。
- 條件變數沒有記憶:沒人在等時的 signal 會消失(喚醒遺失)。用共用變數記下「已經發生」。
- 被叫醒不代表條件成立,要用 while 重新檢查。
- 號誌是有記憶的計數器。有界緩衝區要先
sem_wait再上鎖;反過來就是拿著鎖等一件需要這把鎖才會發生的事。 - 讀者優先可能讓寫者飢餓;閘門可以修好,代價是讀者多等。飢餓不是死結。
- 修同步錯誤不要靠
sleep湊時機。
想再深入,可以讀免費公開的教科書 OSTEP 第 30 章 Condition Variables、第 31 章 Semaphores。
這一課你已經見過幾次「大家都在等,沒有人能動」。下一課專門談這件事:死結需要哪些條件,怎麼偵測,怎麼預防。