更新紀錄(僅初版,2026年08月22日 發布)
- 初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22176655)
以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。
Go Komura(2026)。〈虛假喚醒 ── 條件變數為何「沒被通知也會醒來」,以及 Windows 上正確的等待方式〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/windows-condition-variable-spurious-wakeup/
- DOI(已登錄存檔)
- 10.5281/zenodo.22176655
- DOI(上次登錄版本)
- 10.5281/zenodo.22176656
「明明是先把資料放進去才通知的,工作者執行緒卻讀到空佇列而當掉。」「照理說已經通知了,卻偶爾就是不從等待返回。」兩者都是執行緒會合上的故障,但該查的地方不一樣。
本文的核心是這條原則:從條件變數的 wait 返回,並不保證你等待的條件已經成立。只要理解了「沒有通知也會返回」的虛假喚醒,以及「通知之後條件被別人先消耗掉」的被竊取的喚醒,就會明白為什麼需要的是 while 而不是 if。至於漏接通知的遺失喚醒,本文會與這兩者分開處理。
| 想知道什麼、卡在什麼 | 該讀哪一段 |
|---|---|
| 想知道沒有通知也會醒來的原因,以及它和被竊取的喚醒有什麼不同 | 返回的那一刻保證了什麼、規格為何允許 |
| 想確認 Win32、C++ 與 C# 的正確寫法 | 各語言的基本形狀 |
| 明明指定了逾時,等待時間卻愈拖愈長 | 以截止時間為基準的等待 |
| 已經通知了,執行緒卻不返回 | 遺失喚醒與 PulseEvent |
| 想調查偶爾才發生的當機與卡住 | 依症狀區分的調查步驟 |
本文的對象是在 Windows 上撰寫業務應用程式與設備控制軟體的開發者。先用第一手資料確認機制,再接到 Win32(C)、C++ 與 C# 的實作。
1. 先講結論
等待端的程式碼要守住的,是下面三件事。
| 原則 | 程式碼上要守住的事 |
|---|---|
| 等的是狀態,不是通知 | 條件寫成「佇列是不是非空」之類的敘述,而不是「有沒有被喚醒」 |
| 每次返回都要重新檢查條件 | 寫成 while (!條件) wait(...);C++ 則使用帶述詞的 wait(lock, pred) |
| 檢查與更新要用同一把鎖保護 | 先更新狀態再通知,別讓通知從檢查與等待之間的縫隙漏掉 |
Win32、C++ 與 POSIX 都在規格上允許與明確通知無關的喚醒。而且即使有通知,也可能被別的執行緒搶先消耗掉條件,也就是「被竊取的喚醒」,因此重新檢查是必要的。C# 的 Monitor.Wait 同樣要以顧及被竊取的喚醒的相同紀律來使用。12345
另一個要注意的是,不要用事件的脈衝去重現條件變數那種短暫的通知。尤其 PulseEvent 可能漏掉通知,Microsoft 也明白指示新的應用程式不要使用它,改用條件變數。6
以下依序是:喚醒的機制(第 2~4 章)、正確的實作(第 5 章)、要避開的模式與調查方式(第 6~7 章),以及檢查清單(第 8 章)。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 17 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 什麼是虛假喚醒 ── 「醒來」不等於「條件成立」
2.1 條件變數做的事,是放開鎖去等,再取回鎖才返回
條件變數(condition variable)是讓執行緒一直等到某個條件成立為止的同步原語。在 Win32 上會用到下列結構與 API。1
| 作用 | Win32 的型別與 API |
|---|---|
| 表示條件變數 | CONDITION_VARIABLE |
| 釋放鎖並等待 | SleepConditionVariableCS / SleepConditionVariableSRW |
| 喚醒等待中的執行緒 | WakeConditionVariable / WakeAllConditionVariable |
等待 API 會以不可分割的方式完成「釋放手上的臨界區段或 SRW 鎖」與「進入等待」這兩件事。醒來之後,它會先重新取得那把鎖,才返回呼叫端。1
不過,「重新取回鎖並返回」與「應用程式等待的條件已經成立」是兩回事。
2.2 把正規的通知、虛假喚醒與被竊取的喚醒分開
逾時的處理留到第 5 章再看,這裡先比較醒來的三種情況。
| 情況 | 與明確通知的關係 | 返回時該怎麼解讀 |
|---|---|---|
| 正規的醒來 | 有通知 | 條件多半已經成立,但光憑「返回了」這個事實並不能保證 |
| 虛假喚醒(spurious wakeup) | 與喚醒自己的明確通知無關 | 條件仍未成立也會返回 |
| 被竊取的喚醒(stolen wakeup) | 有通知 | 別的執行緒搶先消耗掉條件,條件已回到未成立 |
flowchart TB
accTitle: 從 wait 返回的三種情況
accDescr: 條件變數的 wait 除了因正規通知而醒來之外,也會因為沒有通知的虛假喚醒,以及有通知但條件已被搶先消耗的被竊取的喚醒而返回,因此不論哪一種情況都必須重新檢查條件
w["從 wait 返回"] --> a["正規的通知"]
w --> b["虛假喚醒(沒有通知)"]
w --> c["被竊取的喚醒(條件已被消耗)"]
a --> r["重新檢查條件之後才往下走"]
b --> r
c --> r
圖 1:從 wait 返回的路徑有三條,呼叫端無法分辨自己是從哪一條回來的,因此一定要重新檢查條件。
虛假喚醒並不侷限於「整個系統一次通知都沒發過」的情況。當通知在短時間內密集出現時,實作上可能會把等待中的執行緒多餘地一併叫起來。從那些沒有對應明確通知的執行緒來看,這同樣是虛假喚醒。
Microsoft Learn 指出,條件變數同時存在虛假喚醒與被竊取的喚醒,因此從等待返回之後要重新檢查述詞,而且通常要用 while 來檢查。這裡說的述詞,就是「佇列非空」之類你正在等的條件。1
2.3 決定要不要往下走的是當下的條件,不是醒來的理由
呼叫端無法分辨自己是從這三種情況的哪一種返回的。因此,寫法只能是每次返回都檢查條件本身,未成立就再等一次。
while 並不能防止虛假喚醒或被竊取的喚醒本身。它的作用是:不論這兩者哪一個發生,都不會在條件未成立的狀態下繼續處理。只要守住這道重新檢查,再加上第 5 章要談的「用同一把鎖保護」,不論從哪一條路徑醒來都能正確處理。
3. 為什麼規格上允許它 ── 精準的通知代價很高
3.1 為了不讓嚴格通知的成本壓在每一次操作上
理論上,做出不會發生虛假喚醒的實作是可能的。即使如此,POSIX、Windows 與 C++ 仍然允許它,理由在於效能,以及「由等待端重新檢查條件」這個設計。POSIX 的 pthread_cond_wait 的 Rationale(理由說明)也解釋了這項判斷。3
若要嚴格實作「確實只叫醒剛好一條」的通知,尤其在多處理器上,條件變數的每一項操作都會背上額外的同步成本。通知與醒來之間夾著排程器,還有中斷與搶佔。與其如此,不如允許偶爾多醒一次、由等待端重新檢查,這樣實作才能維持快速。3
重新檢查的迴圈還有另一項好處:它把意圖寫進程式碼裡,讓程式更健壯。把通知當成「條件可能變了」的提示,而不是「條件已成立」的保證,那麼通知端日後改成多叫醒幾條、或一次全部叫醒,程式碼也撐得住。3
3.2 就算消滅了虛假喚醒,被竊取的喚醒仍然會留下
被竊取的喚醒來自「通知」到「重新取得鎖」之間的時間差。生產者把一筆資料放進佇列並喚醒消費者 A,但只要在 A 重新取回鎖之前,消費者 B 先把那一筆取走,等 A 返回時佇列就已經空了。
sequenceDiagram
accTitle: 被竊取的喚醒(stolen wakeup)發生的時序
accDescr: 生產者把一筆資料放進佇列並喚醒等待中的消費者 A,但在 A 重新取得鎖之前,消費者 B 先取得鎖並取走了那一筆,等 A 醒來時佇列已經是空的
participant A as 消費者 A(等待中)
participant P as 生產者
participant B as 消費者 B
P->>P: 在佇列加入 1 筆
P->>A: WakeConditionVariable
Note over A: 已醒來但還在等重新取得鎖
B->>B: 取得鎖並取走 1 筆
A->>A: 重新取得鎖並從 wait 返回
Note over A: 佇列是空的(被竊取了)
A->>A: 用 while 重新檢查後再次 wait
圖 2:在通知到醒來的時間差之間,由第三條執行緒消耗掉條件的「被竊取的喚醒」,在任何實作上都可能發生。
只要存在能搶先取得鎖的第三條執行緒,光靠打磨實作就消不掉這種被竊取的喚醒。就算能徹底消滅虛假喚醒,只要被竊取的喚醒還在,等待端的迴圈就仍然必要。既然如此,那就乾脆允許虛假喚醒,換取條件變數維持快速——這就是設計上的判斷。
4. 在 Windows 上它出現在哪一層
4.1 共通的是重新檢查的紀律,醒來的理由則要分開看
| API 與程式庫 | 為什麼需要重新檢查 |
|---|---|
| Win32 的條件變數 | 文件明白寫著有虛假喚醒與被竊取的喚醒2 |
WaitOnAddress |
除了對指定位址發訊號之外,也允許因其他理由提早返回7 |
C++ 的 std::condition_variable |
不帶述詞的 wait 可能虛假醒來48 |
.NET 的 Monitor.Wait |
從被喚醒到重新取回鎖之間,別的執行緒可以消耗掉條件5 |
Win32 官方的生產者–消費者佇列範例,也是把等待寫成 while 迴圈。9
WaitOnAddress 是 Windows 8 之後才有的低階 API,用來等待指定位址上的值改變。官方資料舉出的「非發訊號而提早返回」的例子包括:低記憶體狀況、放棄對同一位址先前的喚醒,以及在 checked 組建上執行。它的使用範例同樣是重新比較值的 while 迴圈。7
4.2 C++ 帶述詞的 wait 會代你跑迴圈
MSVC 的資料說明,wait(lock, pred) 實質上執行的是下面這段。4
while (!Pred())
wait(Lck);
不帶述詞的 wait 可能虛假返回,這一點 cppreference 也明白寫著。改用帶述詞的形式,就能把這道重新檢查的迴圈交給程式庫。8
4.3 在 C# 要盯住的是被竊取的喚醒與重新取得鎖
Monitor.Wait 與 Pulse 用到等待佇列與就緒佇列。被 Pulse 或 PulseAll 喚醒的執行緒會移到就緒佇列,重新取回鎖之後才離開 Wait。在這段期間別的執行緒可以搶先消耗掉條件,這一點和 Win32 相同。510
對 .NET 的 Monitor.Wait,不必假設會有條件變數那種沒有理由的醒來,而要分開理解成:因為存在被竊取的喚醒與逾時,所以同樣需要 while 的紀律。文件也是假設你會重新評估當初讓執行緒進入等待的條件,必要時再呼叫一次 Wait。5
flowchart TB
accTitle: 每一層都要求重新檢查述詞
accDescr: 不論是 C++ 的 std::condition_variable、.NET 的 Monitor、Win32 的 CONDITION_VARIABLE,還是低階的 WaitOnAddress,官方文件都要求在醒來之後重新檢查條件
cpp["C++ std::condition_variable"] --> rule["醒來後重新檢查條件(while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
圖 3:就算換了語言或框架,在等待原語這一層,官方一律要求「醒來之後重新檢查」。
5. 正確的等待方式 ── 用 while 與述詞來寫
共通的形狀:先檢查條件,只有成立時才處理
你等的對象不是「有沒有收到通知」,而是受鎖保護的共享狀態。在同一把鎖之內檢查與更新佇列筆數或旗標,未成立就 wait,返回後再檢查一次。
flowchart TB
accTitle: 正確的等待迴圈流程
accDescr: 取得鎖後檢查條件,未成立就放開鎖去睡,醒來後重新取得鎖並回到條件檢查。只有條件成立時才握著鎖繼續處理
l["取得鎖"] --> c{"條件成立?"}
c -->|"否"| s["wait(放開鎖去睡)"]
s --> wk["醒來(重新取得鎖)"]
wk --> c
c -->|"是"| go["握著鎖繼續處理"]
圖 4:正確的等待是一個迴圈,而且條件檢查與處理之間沒有縫隙(兩者都在握著鎖的狀態下進行)。
在這個形狀之下,離開迴圈的那一刻,你是握著鎖並且已確認條件成立。接著就直接消耗那個狀態,所以在條件檢查與處理之間,不會留下讓其他執行緒插進來的縫隙。
Win32(C)的基本形狀
CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0; // 受 cs 保護的共享狀態
// 啟動時只初始化一次(若採靜態初始化則為 cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);
// 等待端(消費者)
EnterCriticalSection(&cs);
while (queueCount == 0) { // 不是 if,一定要用 while
SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// 這裡握著鎖,而且保證 queueCount > 0
--queueCount;
LeaveCriticalSection(&cs);
// 通知端(生產者)
EnterCriticalSection(&cs);
++queueCount; // 狀態的更新要在鎖之內
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv); // 通知在釋放鎖之後發出即可
要分清楚的是狀態的更新,以及呼叫通知的位置。++queueCount 一定要在鎖之內做。另一方面,WakeConditionVariable 在鎖內或鎖外呼叫都可以。Microsoft 表示,為了減少內容切換,通常先釋放鎖再喚醒會比較好。1
C++ 的基本形狀 ── 以帶述詞的 wait 為預設
std::mutex m;
std::condition_variable cv;
std::queue<Item> q;
// 等待端
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); }); // 內部就是 while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();
// 通知端
{
std::lock_guard<std::mutex> lk(m);
q.push(std::move(item));
}
cv.notify_one();
新程式碼一律以帶述詞的 wait 為預設。既有的 while (q.empty()) cv.wait(lk); 也是正確的寫法,不必因為它是手寫迴圈就去改它。有問題的是不重新檢查的 if (q.empty()) cv.wait(lk);。
帶述詞的形式替你代勞的只有迴圈。述詞所讀取的共享狀態,在通知端同樣要用同一把互斥鎖保護,而且要先更新狀態再通知——這道紀律依然要自己守。
C# 的基本形狀
private readonly object _gate = new();
private readonly Queue<Item> _queue = new();
// 等待端
lock (_gate)
{
while (_queue.Count == 0) // 不是 if,一定要用 while
{
Monitor.Wait(_gate);
}
var item = _queue.Dequeue();
}
// 通知端
lock (_gate)
{
_queue.Enqueue(item);
Monitor.Pulse(_gate); // Monitor 的 Pulse 只能在鎖之內呼叫
}
Monitor.Wait、Pulse 與 PulseAll 必須在持有對應那把鎖的同步區塊之內呼叫。在鎖外呼叫會得到 SynchronizationLockException。請不要把它和「Win32 的通知可以移到鎖外」混為一談。10
帶逾時的等待 ── 剩餘時間要從截止時間算出來
如果每次都傳入同一個逾時值,那麼每因虛假喚醒返回一次,等待時間就被墊高一次。做法應該是先決定截止時間,每次返回時再算出剩餘時間。
ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
ULONGLONG now = GetTickCount64();
if (now >= deadline) {
break; // 逾時(條件仍未成立)
}
if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
GetLastError() != ERROR_TIMEOUT) {
break; // 逾時以外的失敗就停止等待離開
}
// ERROR_TIMEOUT 交給 while 的條件判斷與截止時間檢查做最後確認
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
flowchart TB
accTitle: 帶逾時的等待的正確流程
accDescr: 先決定截止時刻,每次醒來都檢查條件與截止時刻,若都還沒到就重新計算剩餘時間再回到等待
d["決定截止時刻"] --> c{"條件成立?"}
c -->|"是"| go["繼續往下處理"]
c -->|"否"| t{"已過截止時刻?"}
t -->|"是"| to["逾時處理"]
t -->|"否"| w["計算剩餘時間後 wait"]
w --> c
圖 5:帶逾時的等待不是反覆傳入「同一段等待時間」,而是以截止時刻為基準重新算出剩餘時間。
在 C++ 裡,可以把這件事交給指定絕對時刻的 wait_until 及其述詞多載。即使逾時,它也會傳回述詞的最後求值結果,因此可以用「條件最後到底有沒有成立」來判斷。4
6. 不該做的模式一覽
6.1 用 if 只檢查一次條件
一旦發生虛假喚醒或被竊取的喚醒,程式就會在條件未成立的狀態下繼續往下走。結果會以「從空佇列取出」「參考未初始化的資料」「重複釋放」等形式,表現成偶爾才出現的當機或資料損毀。請改成第 5 章的 while 或帶述詞的 wait。
6.2 在鎖之外檢查或更新條件
這一項不是「多醒了幾次」,而是漏接通知、就此一直睡下去的遺失喚醒(lost wakeup)問題。
等待端在鎖外看了條件、判斷「還沒好」,到它真正進入等待之間,如果通知端更新了狀態並發出通知,那一瞬間根本沒有人在等。通知消失之後等待端才進入等待,只要沒有下一次通知,它就會一直等下去。
sequenceDiagram
accTitle: 遺失喚醒(lost wakeup)發生的時序
accDescr: 等待端在鎖外檢查條件到真正進入 wait 之間留有縫隙,通知端在這段縫隙裡更新狀態並發出通知,通知送到沒有等待者的條件變數上而消失,等待端於是一直等著永遠不會來的通知
participant W as 等待端
participant N as 通知端
W->>W: 在鎖外檢查條件(未成立)
N->>N: 更新狀態並通知
Note over N: 此時沒有任何等待者
W->>W: 進入 wait
Note over W: 通知早已消失,因此不會醒來
圖 6:在鎖外檢查條件時,通知會從「檢查」與「wait」之間的縫隙穿過去,形成「遺失喚醒」。
條件變數的等待 API 之所以把釋放鎖與進入等待做成不可分割,正是為了關上這道縫隙。只要守住用同一把鎖保護條件的檢查與更新,並且握著鎖進入等待 API 的紀律,就能防止這種遺失喚醒。1
6.3 用事件的脈衝去重現短暫的通知
使用 CreateEvent 與 SetEvent 本身並不是反模式。事件有下列這些正確的用武之地。
| 用途 | 適合使用事件的場面 |
|---|---|
| 喚醒單一消費者 | 被喚醒的消費者會一路處理到佇列清空的架構 |
| 通知停止 | 用手動重設事件表示「一旦豎起就不再放下」的停止指示 |
| 與其他等待對象一起等 | 把它納入 WaitForMultipleObjects |
| 跨處理程序會合 | 條件變數無法跨處理程序共享,因此這是它處理不了的用途 |
條件變數是無法跨處理程序共享的使用者模式物件。因此就某些用途而言,根本沒辦法換成條件變數。1
危險的是想用事件去重現條件變數那種「只喚醒當下正在等的人,不留下通知狀態」的行為。這個念頭,會直接連到接下來 PulseEvent 的問題。
6.4 使用 PulseEvent
PulseEvent 這個 API 在手動重設事件上,會喚醒當下正在等的執行緒,然後立刻把事件退回非訊號狀態。但 Microsoft 明白寫著:它並不可靠,主要是為了向後相容才存在,新的應用程式不該使用它,應改用條件變數。6
理由是等待中的執行緒可能因核心模式 APC 而暫時脫離等待狀態,等 APC 完成後才回到等待。若 PulseEvent 剛好在這段期間被呼叫,那條執行緒就不算在「呼叫當下的等待者」裡面,於是不會被喚醒。核心 APC 是作業系統內部的行為,應用程式這一端控制不了。611
這個問題也被做成了靜態分析警告 C28648。12 虛假喚醒是「多醒了幾次」的問題,而這一個是「該醒卻沒醒」的問題。由於丟掉的是通知本身,光把等待包進 while 裡救不了它。
6.5 在更新狀態之前,就不握著鎖發出通知
如果不握著鎖就先發通知,之後才取得鎖去更新狀態,被喚醒的一方就可能看到舊狀態而再度睡去。只要更新之後沒有再補一次通知,它就會一直維持那樣。
不過,下面這兩者要分開看。
| 順序 | 結果 |
|---|---|
| 在鎖外通知,之後才取得鎖去更新狀態 | 存在讓被喚醒的一方在更新之前重新檢查後又睡去的縫隙 |
| 一直握著同一把鎖,依「通知→更新→釋放」推進 | 等待端在重新取回鎖之前無從檢查,因此這個順序不會造成實害 |
與其每次都把後者拿出來檢討安全性,不如統一成在同一把鎖之內更新狀態,然後再通知的順序,既好懂又安全。
7. 遇到時的調查方式
7.1 先分清楚是「條件未成立也往下走」還是「醒不過來」
| 症狀 | 最先要查的事 | 調查方法 |
|---|---|---|
| 從空佇列取出、當機、處理結果缺漏 | 等待之後有沒有重新檢查條件 | 搜尋不帶述詞的 cv.wait(、SleepConditionVariableCS 與 Monitor.Wait 的周邊 |
| 該醒的執行緒不返回而卡住 | 有沒有遺失喚醒或 PulseEvent |
用傾印確認各執行緒的堆疊,從停住的等待 API 往回追通知端 |
就算找到不帶述詞的 cv.wait(lk),只要它被正確的 while 包著就沒有問題。用搜尋把候選點收集起來,再逐一審查它周圍是不是只有 if、條件的檢查與更新是不是在同一把鎖裡完成。這是不必等問題重現就能檢查的部分。
遇到卡住時,先用傾印找出卡在哪個等待點,再回到程式碼追「本來應該由誰、依什麼順序發出通知」。要確認的是鎖外的條件檢查、更新狀態之前的鎖外通知,以及 PulseEvent。
flowchart TB
accTitle: 從症狀出發的釐清流程
accDescr: 若症狀是條件未成立仍繼續處理,就用程式碼搜尋找出不帶述詞的等待;若症狀是執行緒醒不過來,就用傾印找出等待點並懷疑遺失喚醒與 PulseEvent
s["只偶爾出現的故障"] --> a["條件未成立仍繼續處理"]
s --> b["該醒的執行緒沒有醒"]
a --> a1["用程式碼搜尋找出不帶述詞的等待"]
b --> b1["用傾印找出等待中的執行緒"]
a1 -.-> a2["把 if 改成 while 或帶述詞的 wait"]
b1 -.-> b2["懷疑遺失喚醒與 PulseEvent"]
圖 7:症狀是「走過頭」還是「醒不過來」,決定了該懷疑哪裡、又該用什麼手段調查。
7.2 修正前後要在相同的壓力條件下比較
要重現偶發的故障,就得把競爭的窗口拉寬、讓時序的變異變多。可用的手段包括:把執行緒數開到比實體核心數還多、在等待與通知之間插入調查用的 Sleep、在 Debug 與 Release 兩種組建上都試一遍。
要下「改了等待之後就不再重現」這種比較結論時,修正前後也要施加相同的壓力。
8. 總結 ── 檢查清單
通知只是「條件可能變了」的提示;讓你能繼續處理的依據,是在鎖之內確認過的條件本身。
| 檢查項目 | 正確的形狀 |
|---|---|
| 從等待返回之後 | 不要試圖分辨是正規通知、虛假喚醒還是被竊取的喚醒,直接重新檢查條件 |
| 等待的寫法 | 用 while 包起來。C++ 的新程式碼以帶述詞的 wait(lock, pred) 為預設 |
| 共享狀態 | 檢查與更新用同一把鎖保護,先更新狀態再通知 |
| 呼叫通知的位置 | Win32 與 C++ 可以在釋放鎖之後;C# 的 Pulse 要在鎖之內呼叫 |
| 逾時 | 從截止時間算出剩餘時間。C++ 則使用帶述詞的 wait_until |
| 事件的用法 | 分清楚正確用途與短暫脈衝,不要依賴 PulseEvent |
虛假喚醒是 Win32、C++ 與 POSIX 刻意允許的規格。要處理它,靠的不是等作業系統修正,也不是換一套程式庫,而是等待端的紀律。.NET 的 Monitor.Wait 雖然要和沒有理由的醒來分開看,但為了應付被竊取的喚醒與逾時,同樣要做這道重新檢查。
當機就從「條件未成立也會往下走的地方」查起,卡住就從「會漏接通知的地方」查起。請把這道區分與這份檢查清單,也用在既有程式碼的審查上。
相關文章
- 多執行緒實務最佳實踐 C++ 篇 ── 以 RAII 與 jthread 從結構上消除事故
- 多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫
- 多執行緒的實務最佳實踐 .NET篇 ── 在增加執行緒之前先決定的事
- Windows 上為什麼應先用事件等待而不是計時器等待 - 避免以約 15.6ms 粒度做輪詢
- 使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
- Windows I/O 的深層(第 2 回) ── 同步 I/O 與非同步 I/O:OVERLAPPED 的真正含義
相關諮詢領域
小村軟體有限公司承接多執行緒程式碼的設計審查、「只偶爾重現」的當機與卡住的原因調查(傾印分析),以及把老舊的同步程式碼(依賴事件與 PulseEvent 等)改寫成以條件變數為基礎的做法。從釐清現象開始也沒問題,歡迎隨時與我們聯絡。
參考連結
-
Microsoft Learn, Condition Variables. 關於條件變數是以不可分割的方式釋放鎖並進入等待的使用者模式物件;關於它存在虛假喚醒(與明確喚醒無關的醒來)與被竊取的喚醒(別的執行緒搶在被喚醒的執行緒之前跑),因此從等待返回後應以 while 迴圈重新檢查述詞;以及通知可從鎖內或鎖外發出,但要減少內容切換的話,釋放鎖之後再喚醒比較好。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). 關於它以不可分割的方式釋放指定的臨界區段並在條件變數上等待;關於醒來的執行緒會先重新取得臨界區段才返回;關於逾時時會傳回 ERROR_TIMEOUT;以及因為存在虛假喚醒與被竊取的喚醒,從等待返回後應(通常以 while 迴圈)重新檢查述詞。 ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. 關於 pthread_cond_wait / pthread_cond_timedwait 可能發生虛假喚醒;關於從 wait 返回這件事對述詞的值不代表任何意義,因此應重新評估述詞;以及 Rationale 提到「剛好只叫醒一條」的實作可能讓條件變數的操作變慢(尤其在多處理器上),而允許虛假喚醒會強制寫出述詞檢查迴圈、使應用程式更健壯。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, condition_variable Class. 關於文件明白寫著不帶述詞的 wait 除了被 notify_one / notify_all 解除之外也可能虛假醒來;關於帶述詞的 wait(lock, pred) 實質上執行 while (!Pred()) wait(Lck);;以及 wait_for / wait_until 也具有相同性質與帶述詞的多載。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Monitor.Wait Method. 關於 Wait 會釋放鎖並進入等待佇列;關於被 Pulse / PulseAll 喚醒之後,在重新取得鎖之前不會返回;以及預期的用法是被喚醒的執行緒重新評估當初讓它進入等待的條件,必要時再呼叫一次 Wait。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, PulseEvent function (winbase.h). 關於等待中的執行緒可能被核心模式 APC 暫時移出等待狀態、APC 完成後才回到等待,若 PulseEvent 在這段期間被呼叫,該執行緒就不會被釋放;以及因此 PulseEvent 並不可靠,不該用在新的應用程式,應改用條件變數。 ↩ ↩2 ↩3
-
Microsoft Learn, WaitOnAddress function (synchapi.h). 關於這個等待位址上的值改變的函式,雖然保證在發訊號時返回,但也允許因其他理由返回;關於提早醒來的例子包括低記憶體狀況、放棄對同一位址先前的喚醒,以及在 checked 組建上執行;以及因此返回後應重新比較值,官方範例本身就是 while 迴圈。 ↩ ↩2
-
cppreference.com, std::condition_variable::wait. 關於不帶述詞的 wait 可能被虛假喚醒解除;以及帶述詞的多載等同於 while (!pred()) wait(lock);,被定義為每次通知或虛假喚醒時都重新取得鎖並檢查述詞的迴圈。 ↩ ↩2
-
Microsoft Learn, Using Condition Variables. 關於以一個臨界區段與兩個條件變數(BufferNotEmpty 與 BufferNotFull)實作生產者–消費者佇列的官方範例。其中的等待是寫在檢查述詞的迴圈之內。 ↩
-
Microsoft Learn, Monitor.PulseAll Method. 關於 PulseAll 會把等待佇列的執行緒移到就緒佇列,鎖被釋放時由就緒佇列中的下一條執行緒取得鎖;以及 Pulse / PulseAll / Wait 只能從同步區塊之內呼叫。 ↩ ↩2
-
Microsoft Learn, Waits and APCs. 關於核心 APC 會以搶佔的方式執行,系統會在不從等待 API 返回的情況下於內部中斷並恢復等待,因此在這段期間可能漏掉 KePulseEvent 這類一次性的訊號。 ↩
-
Microsoft Learn, C28648: PulseEvent is an unreliable function. 關於靜態分析會對 PulseEvent 的使用發出警告;關於因 APC 而脫離等待的執行緒不會被釋放,可能永遠卡住;以及改用 SetEvent 或其他同步物件的指引。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
DllMain 與載入器鎖定 ── 「DLL 初始化時什麼都別做」真正的理由
為什麼不能在 DllMain 裡呼叫 LoadLibrary 或與其他執行緒同步?本文依據一次資料,從序列化所有 DLL 通知的載入器鎖定機制,一路說明到死結成立的典型情境、延遲初始化等正確設計,以及無回應的調查步驟。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
父處理程序消失之後還剩下什麼 —— 用 Job Object 圈養子處理程序
為什麼強制結束 UI 之後,SDK 的輔助處理程序仍然殘留,一直佔著攝影機或 COM 連接埠?本文從量測應用的角度,說明如何用 Job Object 把處理程序樹變成一個單位,並借助 KillOnJobClose 與完成埠來設計子處理程序的壽命。
Win32 執行緒集區 API ── 用 CreateThreadpoolWork 做「不自己建立執行緒」的並行
原生程式碼裡是不是到處都在呼叫 CreateThread?本文依據一手資料解說 Vista 全面重新設計的 Win32 執行緒集區 API:work、timer、wait、io 四種物件、清理群組,以及回呼裡禁止做的事。
具名管道實務 ── 從設計到安全,看懂 Windows 行程間通訊的標準做法
以實務角度說明 Windows 行程間通訊的標準做法——具名管道。依據一手資料整理位元組模式與訊息模式的取捨、同時接受多個用戶端的伺服器結構、ACL 與模擬的安全設計,以及 .NET 的具名管道串流。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
常見問題
整理諮詢這個主題時常見的問題。
- 虛假喚醒是作業系統或程式庫的錯誤嗎?
- 不是錯誤,而是規格上白紙黑字寫明的行為。Win32 的 SleepConditionVariableCS、C++ 的 std::condition_variable,以及 POSIX 的 pthread_cond_wait,官方文件或標準都明確寫著「可能發生與通知無關的喚醒」。理論上也能做出禁止它的實作,但那會讓條件變數的所有操作(尤其是多處理器上的通知)都變慢,因此取捨的結果是允許它,前提是「只要等待端重新檢查條件,正確性就保得住」。所以對策不是等作業系統修正,而是務必把 wait 寫在 while 迴圈裡(或使用帶述詞的 wait)。
- 把 wait 包在 while 裡會不會讓效能變差?
- 實務上可以忽略。包成 while 之後多出來的,只是每次醒來多做一次條件檢查,而那是握著鎖時的一次輕量比較。虛假喚醒本身也很少發生,因此多跑一圈迴圈只出現在例外情況。反過來說,放著 if 不管所要付出的代價,是「條件未成立卻繼續處理」這種只偶爾重現的錯誤,兩者根本不成比例。真正左右條件變數等待成本的是鎖的爭用與通知的頻率,不是有沒有 while。
- 只要用 C++ 帶述詞的 wait,就可以不必在意虛假喚醒嗎?
- 就等待迴圈而言確實如此:cv.wait(lock, pred) 實質上執行的是 while (!pred()) wait(lock);,因此虛假喚醒與被竊取的喚醒都會被自動吸收。新的 C++ 程式碼應該以帶述詞的多載為預設。不過,述詞所參考的共享狀態必須用同一把互斥鎖保護,通知端也必須先更新狀態再呼叫 notify,這些仍然要自己守住。帶述詞的 wait 只是替你代勞迴圈,並沒有把鎖的紀律也一起代勞。
- C# 的 Monitor.Wait 也會發生同樣的問題嗎?
- 會。在 Monitor.Wait 等待的執行緒被 Pulse/PulseAll 喚醒之後,要重新取得鎖才會離開 Wait,而在這段期間,別的執行緒可能搶先取得鎖並消耗掉條件(被竊取的喚醒)。Microsoft 的文件同樣是假設被喚醒的執行緒會重新評估當初讓它進入等待的條件,必要時再呼叫一次 Wait。因此在 C# 裡,基本形狀也是 while (!條件) Monitor.Wait(gate);。只能在 lock 陳述式之內呼叫 Wait/Pulse,則是與 Win32 不同的一項限制。
- 用 WaitForSingleObject 等事件時也會有虛假喚醒嗎?
- 在一般(非可警示)的等待中,只有物件真的進入訊號狀態時才會傳回 WAIT_OBJECT_0,不會發生條件變數那種「沒有理由的醒來」。不過,「事件進入訊號狀態」與「你的應用程式的條件成立」是兩回事。如果設計上有多個消費者被同一個事件喚醒,先取得鎖的執行緒會消耗掉條件,結果醒來之後還是得檢查條件。另外,想用事件重現條件變數那種「只喚醒當下正在等的人」的短暫通知,這種設計很容易走到 PulseEvent 的可靠性問題上,因此在處理程序內等待狀態成立的用途,使用條件變數才安全。