National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 10

條件變數、號誌與生產者消費者

等待某個條件成立:不要忙等,也不要睡過頭
Interaction 40 minute(s)

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 的關鍵是「放開鎖」和「開始等待」對其他執行緒來說是一個不可分割的動作,所以這個空隙不存在。

下面兩個練習,把第一列和第三列弄壞。

互動練習:通知比等待先到

這支程式沒有共用變數,等的人上鎖之後直接等通知。你來排順序:如果 main 先把通知送出去,waiter 才開始等,會發生什麼事?

加 sleep 不算修好

遇到這個錯誤,很多人的第一反應是在 main 的 signal 前面加一行 sleep(1),讓對方先開始等。這樣測試通常會過,但它只是賭對方一秒內一定走到 wait;機器忙的時候賭輸,錯誤又回來,而且更難重現。所以上面的任務不接受 sleep。

同步的錯誤要用同步的方法修:讓程式不管誰先誰後都對。

互動練習:if 和 while 只差一個字

兩個消費者,main 生產兩個東西,用 if 檢查。要排出問題,順序是這樣:第一個消費者發現沒東西,開始等;main 生產一個,叫醒它;但在它重新拿到鎖之前,第二個消費者先進來把東西拿走了。

注意卡片上的狀態:被叫醒的人只是變成「就緒」,還要輪到它、重新拿到鎖才會繼續。

真實 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);               // 多了一個東西

這個順序不能反過來。下面的程式就是反過來的。

互動練習:號誌與鎖的順序

兩個生產者、兩個消費者、緩衝區大小 2,生產者和消費者都是先上鎖才 sem_wait。sem_t 的目前值可以在「變數與鎖」面板看到。

一條可以記住的規則

不要拿著鎖去做可能會等很久的事。 上一課的 sleep 在鎖裡只是變慢;這裡是拿著鎖等的那件事,剛好需要這把鎖才會發生,就永遠卡住了。下一課會把這種情況一般化。

條件變數沒有這個問題,因為 pthread_cond_wait 等待時會放開鎖。號誌不知道你拿著哪把鎖,所以要自己排好順序。

把鎖拿掉行不行?兩個生產者可能同時寫同一格 buf[in],答案會錯;可以試試看,隨機跑會出現很多種 sum。

讀者寫者:可以同時讀,寫要獨佔

很多資料是讀的人多、寫的人少。只讀不改,多個人同時讀完全沒有問題;只用一把 mutex 就白白排隊了。這個問題由 Courtois、Heymans 與 Parnas 在 1971 年提出並討論,規則是:

  • 讀者可以同時好幾個一起讀。
  • 寫者要獨佔:有人在寫就不能讀,有人在讀就不能寫。

最常見的解法是用一個號誌 room 代表「整個房間」:寫者進去要佔住它;第一個讀者進來時替所有讀者佔住,最後一個讀者離開時放開,中間的讀者直接進來。

問題是:如果讀者一直來,而且總有人還在讀,readers 永遠不會變成 0。寫者就永遠進不去。這叫飢餓(starvation):不是死結,系統一直有進展,只是某個人一直輪不到。

下面的程式讓兩個讀者錯開時間、讀的時間有重疊,寫者在 0.05 秒時就想寫了。睡眠用的是虛擬時鐘,所以每次跑的時間關係都一樣。

互動練習:讓寫者不要一直排不到

程式印出寫者進去之前,讀者已經完成幾次讀取,還有最多同時幾個讀者。

修法是加一道閘門(turnstile):寫者想寫就先佔住 gate;每個讀者進來前要先通過 gate(sem_wait 緊接著 sem_post,只是經過,不佔著)。這樣寫者一到,後面的讀者就被擋在閘門外,房間裡的讀者讀完,寫者就輪到了。

真實 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

確認一下

1.

為什麼 pthread_cond_wait 要傳入一把鎖?

2.

下面哪一種寫法最不可靠?

3.

一個號誌初值 0。A 先呼叫 sem_post,B 之後才呼叫 sem_wait。B 會怎樣?如果換成條件變數,A 先 signal、B 才 wait(沒有共用變數)呢?

4.

有界緩衝區的生產者寫成 lock(&m); sem_wait(&empty); …。緩衝區滿了的時候會怎樣?

5.

讀者優先的讀者寫者鎖,寫者一直進不去。這是死結嗎?

第 1 次作答

重點整理

  • 等條件成立不要忙等:條件變數讓人睡著,改條件的人負責叫醒。固定寫法是鎖、共用變數、while 三樣都要有。
  • 條件變數沒有記憶:沒人在等時的 signal 會消失(喚醒遺失)。用共用變數記下「已經發生」。
  • 被叫醒不代表條件成立,要用 while 重新檢查。
  • 號誌是有記憶的計數器。有界緩衝區要先 sem_wait 再上鎖;反過來就是拿著鎖等一件需要這把鎖才會發生的事。
  • 讀者優先可能讓寫者飢餓;閘門可以修好,代價是讀者多等。飢餓不是死結。
  • 修同步錯誤不要靠 sleep 湊時機。

想再深入,可以讀免費公開的教科書 OSTEP 第 30 章 Condition Variables、第 31 章 Semaphores。

這一課你已經見過幾次「大家都在等,沒有人能動」。下一課專門談這件事:死結需要哪些條件,怎麼偵測,怎麼預防。

這一課完成了!
Next lesson:死結
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。