「我們把資料放進佇列並喚醒正在等的工作者執行緒。跑了六個月,有一天它試圖讀空佇列然後當機。」「我們有送通知,但偶爾有一條執行緒永遠不醒。」── 多執行緒會合看起來像在運作,卻是只偶爾出現的錯誤溫床。這類調查常常落到把條件變數的 wait 包在 if 裡的程式碼。背後坐著的就是虛假喚醒──沒有收到通知卻從 wait 返回的現象。
「明明沒人通知它卻醒來」聽起來像實作缺陷,但這是 Win32、C++ 與 POSIX 都在文件或標準裡寫明的行為,而且 .NET 的 Monitor 是假設「一旦醒來,你再檢查一次條件」來設計的。為什麼允許這種行為?在 Windows 上它發生在哪一層?等待要怎麼寫才永遠碰不到它?本文以在 Windows 上撰寫業務應用程式與設備控制軟體的開發者為對象,依一次資訊拆開虛假喚醒到底是什麼,並把正確的等待收成 Win32(C)、C++ 與 C#。
1. 先講結論
- 條件變數的
wait即使沒有通知到來也可能返回。Win32 官方文件寫明,條件變數受虛假喚醒(與明確喚醒無關的喚醒)與被竊取的喚醒(另一條執行緒在被喚醒的執行緒之前消耗掉條件)影響。1 - 因此必須永遠把等待寫成「while 迴圈加上再檢查一次條件」。用
if檢查一次再wait的程式碼看起來像在運作,卻藏著只偶爾重現的錯誤。12 - 這不是 Windows 特有的怪癖;POSIX 與 C++ 標準說同樣的話。「絕對從不虛假喚醒」的實作會讓每一次條件變數操作變慢,因此在假設等待端會再檢查的前提下允許喚醒。34
- 在 C++ 裡,述語形式
wait(lock, pred)由程式庫替你執行迴圈。該形式實質上跑while (!pred()) wait(lock);。這是新程式碼的預設。5 - C# 的
Monitor.Wait需要同樣的紀律。條件可能在被喚醒與重新取得鎖之間的間隔被消耗,因此在while裡再檢查條件並回到Wait。6 - 在同一把鎖下更新並檢查條件。若在鎖外看條件再進入
wait,通知可能從縫隙間穿過──遺失喚醒。1 - 不要用事件上的脈衝重現條件變數「喚醒此刻正在等的人」這種短暫通知。尤其
PulseEvent可能在核心模式 APC 短暫抬起等待的那一瞬間漏掉通知,Microsoft 自己也明白寫道「它不可靠,不要用,改用條件變數」。7
接下來依序走完支撐這個結論的機制。
2. 虛假喚醒是什麼 ── 醒來不代表條件成立
條件變數是「讓執行緒睡到某個條件成立,成立時再把它喚醒」的同步原語。在 Win32 上,那是 CONDITION_VARIABLE 結構搭配 SleepConditionVariableCS / SleepConditionVariableSRW(等待)與 WakeConditionVariable / WakeAllConditionVariable(通知)。等待 API 以原子方式釋放你握著的鎖(臨界區段或 SRW 鎖)並入睡,醒來時在返回前重新取得鎖。1
問題是「已從 wait 返回」這個事實實際代表什麼。直覺上想成「通知到了=條件成立」,但現實裡 wait 返回有三種情況。
| 情況 | 通知 | 返回時的條件 |
|---|---|---|
| 真正的喚醒 | 有 | 常常滿足,但不保證 |
| 虛假喚醒 | 沒有對你發出的 | 仍未滿足 |
| 被竊取的喚醒 | 有 | 另一條執行緒先消耗掉;未滿足 |
flowchart TB
accTitle: wait 返回的三種情況
accDescr: 條件變數等待不只因真正的通知返回,也可能因沒有通知的虛假喚醒,以及通知到了但條件先被消耗的被竊取喚醒而返回,因此每種情況都要再檢查條件
w["從 wait 返回"] --> a["真正的通知"]
w --> b["虛假喚醒(沒有通知)"]
w --> c["被竊取的喚醒(條件已被消耗)"]
a --> r["再檢查條件,然後繼續"]
b --> r
c --> r
圖 1: 從 wait 回來有三條路,呼叫端分不出走了哪一條,因此必須永遠再檢查條件。
虛假喚醒就是這第二種情況──等待 API 與本意要喚醒你的明確通知無關卻返回的現象。它不限於系統裡從未呼叫過 WakeConditionVariable 的情況。例如在短時間內通知大量湧入的高負載下,實作可能一次多喚醒幾條等待中的執行緒,對沒有對應通知的那一側來說那也是虛假喚醒。Microsoft Learn 的條件變數頁面寫得很明白:”Condition variables are subject to spurious wakeups (those not associated with an explicit wake) and stolen wakeups (another thread manages to run before the woken thread). Therefore, you should recheck a predicate (typically in a while loop) after a wait operation returns.”1
重要的是呼叫端分不出它是從三種情況的哪一種返回。分不出來,就只有一個策略可用:每次返回都檢查你正在等的那個條件本身,不成立就回去睡。這才是鐵則「把 wait 包在 while 裡」的真正內容。反過來說,只要守住那條規則,無論三種情況哪一種喚醒你,程式碼都正確。
3. 為什麼規格允許它 ── 精準通知很貴
「沒被通知就醒來,只是實作馬虎吧?」這個問題很合理。事實上理論上可以做出從不虛假喚醒的實作。即便如此,POSIX、Windows 與 C++ 標準都站在「它可以發生」這一側。理由在 POSIX(The Open Group Base Specifications)對 pthread_cond_wait 的 Rationale 裡坦白寫著。3
第一個理由是效能。試圖嚴格實作「可靠地恰好喚醒一條執行緒」的通知,尤其在多處理器上,會給每一次條件變數操作加上額外同步成本。排程器坐在通知與喚醒之間,依中斷與搶佔的時機,你無法避免「另一條執行緒在你本意要喚醒的那條之前跑」。為把那完全封死而讓所有人付錢,對保持條件變數快速來說,比接受「你偶爾可能多喚醒幾個」更糟。
第二個理由是這個取捨不會弄壞應用程式──它其實讓應用程式更健壯。因為允許虛假喚醒,正確的程式碼永遠寫一個檢查述語(正在等的條件)的迴圈。POSIX 的 Rationale 說,強制這個迴圈讓程式碼自我說明且更健壯。3 一旦有了迴圈,通知的意義就從「條件成立的保證」降成「條件可能已改變的提示」,等待側也變得能容忍通知端適度的設計變更(喚醒太多、一次喚醒一批等等)。
被竊取的喚醒是更結構性的事。從通知端呼叫 WakeConditionVariable,到被喚醒的執行緒重新取得鎖並從 wait 返回,中間永遠有時間縫隙。若第三條執行緒能在那個間隔取得鎖,它就能先消耗掉條件(佇列的內容等等)。那是無論怎麼打磨實作都抹不掉的縫隙,因為它來自條件變數這個工具本身的形狀。
sequenceDiagram
accTitle: 被竊取喚醒的時間軸
accDescr: 生產者把一項放進佇列並喚醒正在等的消費者 A,但在 A 重新取得鎖之前,消費者 B 取得鎖並拿走那一項,因此 A 醒來時佇列是空的
participant A as 消費者 A(正在等)
participant P as 生產者
participant B as 消費者 B
P->>P: 把一項加入佇列
P->>A: WakeConditionVariable
Note over A: 已喚醒,正在等重新取得鎖
B->>B: 取得鎖並拿走一項
A->>A: 重新取得鎖並從 wait 返回
Note over A: 佇列是空的(被竊取)
A->>A: 在 while 迴圈裡再檢查並再次等待
圖 2: 第三條執行緒在通知與喚醒之間的時間縫隙消耗掉條件的「被竊取喚醒」,在任何實作下都可能發生。
換句話說,即使作業系統徹底消滅了虛假喚醒,只要被竊取的喚醒存在,你仍不能寫「我醒了=條件成立」。等待端的再檢查迴圈反正都需要,既然如此,允許虛假喚醒並讓實作保持快速比較便宜──這是條件變數攜帶了數十年的設計判斷。
4. 在 Windows 上它出現在哪些層
無論你用 Windows 同步原語的哪一層,這個性質都會露臉。為了感受「無論對哪一層的 API 寫都逃不掉」,我們看幾個代表性的層。
Win32 條件變數(CONDITION_VARIABLE)如已所述,在 SleepConditionVariableCS / SleepConditionVariableSRW 上被記載為同時受虛假喚醒與被竊取喚醒影響,並要求在 while 迴圈裡再檢查述語。2 官方用法範例(生產者–消費者佇列)也把等待寫在 while 迴圈裡。8
更低層的 WaitOnAddress 是比條件變數更原始的等待 API:「等到給定位址上的值改變」(Windows 8 以後)。即使是這個接近底層的 API,文件也寫道「位址被發訊號時保證返回,但也允許因其他理由返回」,並列出提早醒來的例子:低記憶體狀況、放棄同一位址先前的喚醒,以及跑檢查組建。這就是為什麼文件自己的用法範例是「再次比較值的 while 迴圈」這種形式。9
C++ 的 std::condition_variable 也一樣。MSVC 文件對沒有述語的 wait 寫道「阻塞直到被 notify_one / notify_all 的呼叫發訊號。它也可能虛假醒來」,並說明述語形式 wait(lock, pred) 實質上跑以下程式碼。5
while (!Pred())
wait(Lck);
換句話說,C++ 裡建議的述語形式 wait,正是程式庫把本文所說的「包在 while 裡」替你做掉。cppreference 同樣寫明沒有述語的 wait 可能被虛假解除阻塞。4
.NET 的 Monitor.Wait / Pulse 有自己的等待佇列與就緒佇列結構,但紀律不變。被 Pulse / PulseAll 喚醒的執行緒移到就緒佇列,並依能重新取得鎖的順序從 Wait 返回。另一條執行緒能在重新取得鎖之前的間隔消耗掉條件,這與 Win32 相同,文件也假設「被喚醒的執行緒重新評估讓它進入等待的條件,必要時再次呼叫 Wait」。610
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放在條件的 while 迴圈裡。每次醒來檢查條件,不成立就回去睡。 - 在同一把鎖下更新並檢查條件。通知端更新狀態然後通知。
flowchart TB
accTitle: 正確等待迴圈的流程
accDescr: 取得鎖並檢查條件;若不成立,釋放鎖並入睡;醒來時重新取得鎖並回到條件檢查。只有條件成立時才握著鎖繼續
l["取得鎖"] --> c{"條件滿足嗎?"}
c -->|"否"| s["wait(釋放鎖並入睡)"]
s --> wk["醒來(重新取得鎖)"]
wk --> c
c -->|"是"| go["仍握著鎖繼續"]
圖 4: 正確的等待是一個迴圈,檢查條件與處理之間沒有縫隙(兩者都在握著鎖時發生)。
這個形狀有一個容易漏掉的好處。你離開 while 迴圈的那一刻,在你仍握著鎖的情況下,已確立「條件成立」。防禦虛假喚醒的迴圈,原樣就是檢查條件與處理它之間沒有競態縫隙的保證。
Win32(C)的基本形狀
CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0; // shared state protected by cs
// Initialise once at startup (for static initialisation, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);
// Waiter (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) { // always while, never if
SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Here the lock is held and queueCount > 0 is guaranteed
--queueCount;
LeaveCriticalSection(&cs);
// Notifier (producer)
EnterCriticalSection(&cs);
++queueCount; // update the state under the lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv); // notifying after releasing the lock is fine
通知(WakeConditionVariable)可以從鎖內或鎖外呼叫,但文件說釋放鎖後再喚醒通常比較好,以減少內容切換。1 另一方面,狀態更新本身(++queueCount)必須永遠在鎖下發生。不要把兩者搞混。
C++ 的基本形狀 ── 讓述語 wait 當預設
std::mutex m;
std::condition_variable cv;
std::queue<Item> q;
// Waiter
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); }); // internally while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();
// Notifier
{
std::lock_guard<std::mutex> lk(m);
q.push(std::move(item));
}
cv.notify_one();
因為述語形式 wait 替你執行迴圈,手寫 while 不必要。修正仍有手寫迴圈的既有程式碼時,while (q.empty()) cv.wait(lk); 是正確形狀,因此不必急著改寫。唯一不正確的形式是 if (q.empty()) cv.wait(lk);。
C# 的基本形狀
private readonly object _gate = new();
private readonly Queue<Item> _queue = new();
// Waiter
lock (_gate)
{
while (_queue.Count == 0) // always while, never if
{
Monitor.Wait(_gate);
}
var item = _queue.Dequeue();
}
// Notifier
lock (_gate)
{
_queue.Enqueue(item);
Monitor.Pulse(_gate); // Monitor.Pulse can only be called inside the lock
}
Monitor.Wait / Pulse / PulseAll 只能從鎖內(lock 區塊)呼叫,這與 Win32 不同。在鎖外呼叫會丟出 SynchronizationLockException。10
帶逾時的等待 ── 從截止期限計算剩餘時間
帶逾時等待時,每次迴圈迭代都傳「同一個逾時值」,會在每次虛假喚醒時把等待拉長。正確形狀是先固定截止期限,再重算剩餘時間。
ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
ULONGLONG now = GetTickCount64();
if (now >= deadline) {
break; // timeout (condition still unsatisfied)
}
if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
GetLastError() != ERROR_TIMEOUT) {
break; // on failure other than timeout, stop waiting and leave
}
// Confirm ERROR_TIMEOUT finally via the while condition and the deadline check
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
flowchart TB
accTitle: 帶逾時等待的正確流程
accDescr: 先固定截止期限,每次醒來檢查條件與截止期限;若還有時間,重算剩餘時間並回到等待
d["固定截止期限"] --> c{"條件滿足嗎?"}
c -->|"是"| go["進入處理"]
c -->|"否"| t{"截止期限過了嗎?"}
t -->|"是"| to["處理逾時"]
t -->|"否"| w["計算剩餘時間並等待"]
w --> c
圖 5: 帶逾時的等待不再傳「同一個等待時長」;它從截止期限重算剩餘時間。
在 C++ 裡,可以把這個計算連同截止期限交給 wait_until(絕對時間)加上述語多載。即使因逾時返回,它也給你述語的最終值,因此也能依述語判斷「是逾時了,還是趕上了?」。5
6. 該避開的模式目錄
只用 if 檢查一次。這是本文的主角。虛假喚醒或被竊取喚醒一發生,處理就在條件未滿足的情況下繼續。從空佇列取出、碰到未初始化資料、重複釋放──症狀變成「只偶爾出現的當機或資料損壞」。
在鎖外檢查或更新條件。若等待端在鎖外看條件、判定「還沒」,並在進入 wait 前的縫隙間通知端更新狀態並送出通知,通知就會對沒有等待者的條件變數發射然後消失。等待端隨後進入 wait,繼續等一個再也不會來的通知。那是遺失喚醒,虛假喚醒的鏡像。條件變數的等待 API 設計成「以原子方式釋放鎖並入睡」,正是為了關上這道縫隙。1 只要守住鎖的紀律就不會發生。
sequenceDiagram
accTitle: 遺失喚醒的時間軸
accDescr: 若等待端在鎖外檢查條件,通知端在進入 wait 前的縫隙間更新狀態並通知,通知就被送到沒有等待者的條件變數然後消失,等待端繼續等一個再也不會來的通知
participant W as 等待端
participant N as 通知端
W->>W: 在鎖外檢查條件(未滿足)
N->>N: 更新狀態並通知
Note over N: 這一刻沒有等待者
W->>W: 進入 wait
Note over W: 通知已經沒了,永遠不醒
圖 6: 若在鎖外檢查條件,通知會從檢查與 wait 之間的縫隙溜走──「遺失喚醒」。
用事件上的脈衝重現條件變數的「短暫通知」。事件本身(CreateEvent + SetEvent)不是反模式。單一消費者把佇列處理到空為止的喚醒訊號,或一旦升起就不再放下的停止指令(手動重設事件),是事件的正確用法;而當你想經由 WaitForMultipleObjects 與其他等待目標會合,或跨過處理程序邊界時,條件變數──不能跨處理程序共享的使用者模式物件──才是用不了的那一個。1 危險的是試圖用事件操作重現條件變數「只喚醒當下正在等的執行緒、不留下狀態」的短暫通知。那個想法幾乎總是通向下一項,PulseEvent。
使用 PulseEvent。它是在手動重設事件上「喚醒目前所有正在等的人,並立刻把事件帶回無訊號狀態」的 API,但 Microsoft 自己在文件裡寫道「此函式不可靠,不應使用。它主要為向後相容而存在。改用條件變數。」理由是等待中的執行緒可能被核心模式 APC 暫時移出等待狀態,並在 APC 完成後回到等待。若 PulseEvent 在那個短暫間隔被呼叫,該執行緒就不算在「呼叫當下正在等的那些人」裡,也就不會被喚醒。7 核心 APC 是作業系統內部使用的;應用程式控制不了。11 這個問題也是靜態分析警告(C28648)。12 若虛假喚醒是「多醒了」的問題,這就是「本該醒卻睡過頭」的問題,while 迴圈救不了你──因為通知本身已經丟了。
先只送通知、沒握著鎖,再更新狀態。在狀態仍是舊的時候呼叫 WakeConditionVariable,然後才取得鎖並更新狀態──那個順序下,被喚醒的執行緒檢查時仍看到條件未滿足,於是回去睡。若沒有進一步通知,它就停在那裡。注意若你在仍握著同一把鎖時寫「通知 → 更新 → 釋放」,其實沒有真正的傷害,因為等待端在重新取得鎖之前無法檢查條件。即便如此,為了讓讀者不必每次都驗證這個安全條件,較安全的是把順序標準化成「在鎖下更新狀態,之後再通知」。
7. 碰到時怎麼調查
涉及虛假喚醒的錯誤特徵是「只偶爾出現」。從症狀往回推,它們拆成以下兩個家族。
家族 1:處理在條件未滿足的情況下繼續。從空佇列取出造成的例外或當機、結果缺漏等等。懷疑沒有述語的等待。這可以在程式碼審查裡機械地梳出來──搜尋 cv.wait( 只有一個引數的地方,以及 SleepConditionVariableCS / Monitor.Wait 被包在 if 而不是 while 裡的地方。這個檢查不必等重現,是你手上槓桿最高的動作。
家族 2:本該醒來的執行緒不醒(卡住)。懷疑遺失喚醒(在鎖外檢查條件,或在更新狀態前於鎖外通知)以及 PulseEvent。從卡住的處理程序取傾印並看各執行緒的堆疊,就能辨識哪條執行緒卡在哪個等待 API。從那裡追過程式碼「誰本該送那個通知、以什麼順序」。
flowchart TB
accTitle: 從症狀分流
accDescr: 若處理在條件未滿足的情況下繼續,用搜尋程式碼梳出沒有述語的等待;若執行緒不醒,從傾印辨識等待位置並懷疑遺失喚醒或 PulseEvent
s["只偶爾出現的錯誤"] --> a["處理在條件未滿足的情況下繼續"]
s --> b["本該醒來的執行緒不醒"]
a --> a1["搜尋程式碼裡沒有述語的等待"]
b --> b1["從傾印辨識正在等的執行緒"]
a1 -.-> a2["把 if 改成 while,或使用述語 wait"]
b1 -.-> b2["懷疑遺失喚醒或 PulseEvent"]
圖 7: 症狀是「走太遠」還是「永不醒來」,拆開你該懷疑什麼以及怎麼調查。
若想重現,標準動作是加寬競態視窗。用比實體核心更多的執行緒、在等待與通知之間插入刻意的 Sleep,以及同時跑偵錯與發行組建,來增加時機抖動。當你確認「修了沒有述語的等待後就不再重現」,在同樣的壓力下比較。
8. 總結 ── 檢查清單
- 從
wait回來的路有三條──真正的通知、虛假喚醒、被竊取的喚醒──呼叫端分不出它們。因此永遠把等待寫成條件上的 while 迴圈。 - 虛假喚醒是 Win32、C++ 與 POSIX 刻意作為效能取捨而允許的行為,不會因作業系統修正或換程式庫而消失。.NET 的
Monitor.Wait並不假設沒理由就醒來,但因為被竊取的喚醒與逾時存在,同樣的 while 紀律仍需要。 - 在 C++ 裡,預設用述語形式
wait(lock, pred)。程式庫執行迴圈。 - 在同一把鎖下更新並檢查條件。通知「在更新狀態之後」送出。Win32/C++ 通知可以在釋放鎖之後發生;C# 的
Pulse只能在鎖內。 - 帶逾時的等待,固定截止期限並重算剩餘時間。在 C++ 裡,
wait_until加上述語。 - 不要用事件上的脈衝重現條件變數的短暫通知。尤其
PulseEvent是官方文件明白寫道「不要用,改用條件變數」的東西。事件本身對停止指令、與WaitForMultipleObjects會合,以及跨處理程序同步,仍是正確的工具。 - 審查時機械地搜尋「沒有述語的等待」與「
if+ wait」。你可以在不等重現的情況下殺死只偶爾重現的錯誤。
虛假喚醒與名稱的古怪相反,修正濃縮成一行關鍵字──把 if 改成 while。而那一行背後,是條件變數這個工具的設計想法:「精準通知很貴,所以檢查是等待端的責任」。當成機制理解,語言或架構改變時,你仍該能毫不猶豫地套用同樣的紀律。
相關文章
- 多執行緒實務最佳實踐 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 ↩8
-
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
-
cppreference.com, std::condition_variable::wait. 關於沒有述語的 wait 可能被虛假喚醒解除阻塞;以及述語多載等同於 while (!pred()) wait(lock);,定義為每次通知或虛假喚醒時重新取得鎖並檢查述語的迴圈。 ↩ ↩2
-
Microsoft Learn, condition_variable Class. 關於沒有述語的 wait 被記載為在 notify_one / notify_all 時解除阻塞,也可能虛假醒來;關於述語形式 wait(lock, pred) 實質上跑 while (!Pred()) wait(Lck);;以及 wait_for / wait_until 有相同性質與述語多載。 ↩ ↩2 ↩3
-
Microsoft Learn, Monitor.Wait Method. 關於 Wait 釋放鎖並進入等待佇列;關於被 Pulse / PulseAll 喚醒後在重新取得鎖之前不返回;以及預期用法是被喚醒的執行緒重新評估讓它進入等待的條件,必要時再次呼叫 Wait。 ↩ ↩2
-
Microsoft Learn, PulseEvent function (winbase.h). 關於等待中的執行緒可能被核心模式 APC 暫時移出等待狀態並在 APC 完成後返回,因此若 PulseEvent 在該間隔被呼叫該執行緒不會被釋放;以及因此 PulseEvent 不可靠、不應在新應用程式使用,改用條件變數。 ↩ ↩2
-
Microsoft Learn, Using Condition Variables. 關於用一個臨界區段與兩個條件變數(BufferNotEmpty 與 BufferNotFull)實作生產者–消費者佇列的官方範例。等待在檢查述語的迴圈內執行。 ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). 關於等待位址值改變的函式在被發訊號時保證返回,但也允許因其他理由返回;關於提早醒來的例子包括低記憶體狀況、放棄同一位址先前的喚醒,以及跑檢查組建;以及因此返回後需要再次比較值,官方範例本身就是 while 迴圈。 ↩
-
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 通知、結構上必定成立的死結場景、延遲初始化的正確設計,以及無回應的調查方式。
Win32 執行緒集區 API ── 用 CreateThreadpoolWork 做「不自己建立執行緒」的並行
原生程式碼裡到處呼叫 CreateThread 嗎?本文依一次資訊解說 Vista 重新設計的 Win32 執行緒集區 API──work、timer、wait、io 四種物件、清理群組,以及回呼裡不該做的事。
具名管道實務 ── 從設計到安全性,整理 Windows 的標準 IPC
以實務角度解說 Windows 標準行程間通訊──具名管道。從一次資訊整理位元組/訊息模式的選擇、同時服務多個用戶端的伺服器設計、ACL 與偽裝的安全性,以及 .NET 的具名管道串流。
「沒有回應」的真面目 ── Windows 如何判定應用程式當住,以及不卡住的設計
Windows 的「沒有回應」是作業系統判定視窗 5 秒未取出訊息後,換成幽靈視窗的機制。本文涵蓋該判定的內部、無回應的經典原因、把重工作移出 UI 執行緒的設計,以及調查無回應的程序。
多執行緒的實務最佳實踐 .NET篇 ── 在增加執行緒之前先決定的事
針對 .NET/C# 整理能防止「建立執行緒後偶爾當機、卡死」的設計定石。內容涵蓋不自行建立執行緒而改用 Task、減少共享可變狀態、鎖的紀律、以 CancellationToken 設計停止機制,以及 UI 執行緒的處理方式。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
常見問題
整理諮詢這個主題時常見的問題。
- 虛假喚醒是作業系統或程式庫的錯誤嗎?
- 不是──這是規格寫明的行為。Win32 的 SleepConditionVariableCS、C++ 的 std::condition_variable,以及 POSIX 的 pthread_cond_wait,官方文件或標準都明確寫道:可能發生與通知無關的喚醒。理論上可以做一個禁止它的實作,但那會讓每一次條件變數操作變慢(尤其是多處理器上的通知),因此取捨是允許它,前提是「等待端再檢查一次條件,正確性就得以保全」。因此解法不是等作業系統修正,而是永遠把等待寫在 while 迴圈裡(或使用述語形式的等待)。
- 把等待包在 while 迴圈裡會傷害效能嗎?
- 實務上成本可忽略。while 迴圈多出來的只是每次醒來多一次條件檢查,而且那是你已握著鎖時的便宜比較。虛假喚醒本身很少見,因此多一次迴圈迭代只發生在例外情況。另一方面,把檢查留成 if 的代價是「條件未滿足卻繼續處理」這種「只偶爾重現的錯誤」──沒得比。真正主導條件變數等待成本的是鎖爭用與通知頻率,不是有沒有 while。
- 若使用 C++ 的述語形式 wait,就可以忘記虛假喚醒嗎?
- 就等待迴圈而言,可以:cv.wait(lock, pred) 實質上是 while (!pred()) wait(lock);,因此虛假喚醒與被竊取的喚醒都會被自動吸收。新的 C++ 程式碼應預設用述語多載。你仍必須用同一把互斥保護述語讀取的共享狀態,通知端仍必須在呼叫 notify 前更新該狀態。述語等待替你拿掉迴圈;它沒有替你拿掉鎖的紀律。
- C# 的 Monitor.Wait 也有同樣問題嗎?
- 有。在 Monitor.Wait 裡等待的執行緒被 Pulse/PulseAll 喚醒後,會在從 Wait 返回前重新取得鎖,但在那個間隔裡另一條執行緒可能先取得鎖並消耗掉條件(被竊取的喚醒)。Microsoft 文件的寫法假設被喚醒的執行緒會重新評估讓它等待的條件,必要時再次呼叫 Wait。因此 C# 的基本形狀也是 while (!condition) Monitor.Wait(gate);。與 Win32 不同的一項約束是:只能從 lock 陳述式裡呼叫 Wait/Pulse。
- 用 WaitForSingleObject 等事件時也會發生虛假喚醒嗎?
- 在普通(非可警示)等待裡,只有物件真正變成有訊號時才傳回 WAIT_OBJECT_0;沒有條件變數那種「沒理由的喚醒」。話雖如此,「事件變成有訊號」與「你應用程式的條件成立」是兩件事。若數個消費者被同一個事件喚醒,先取得鎖的執行緒消耗掉條件,因此醒來後仍要再檢查條件。試圖用事件重現條件變數「只喚醒當下正在等的人」這種短暫通知的設計,也容易碰到 PulseEvent 的可靠性問題,因此處理程序內等條件,條件變數是較安全的工具。