虛假喚醒 ── 條件變數為何「沒被通知也會醒來」,以及 Windows 上正確的等待方式

· · Windows, 多執行緒, 條件變數, 同步, C++, C#, Win32 API, 故障調查

「我們把資料放進佇列並喚醒正在等的工作者執行緒。跑了六個月,有一天它試圖讀空佇列然後當機。」「我們有送通知,但偶爾有一條執行緒永遠不醒。」── 多執行緒會合看起來像在運作,卻是只偶爾出現的錯誤溫床。這類調查常常落到把條件變數的 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 裡再檢查條件並回到 Wait6
  • 在同一把鎖下更新並檢查條件。若在鎖外看條件再進入 wait,通知可能從縫隙間穿過──遺失喚醒。1
  • 不要用事件上的脈衝重現條件變數「喚醒此刻正在等的人」這種短暫通知。尤其 PulseEvent 可能在核心模式 APC 短暫抬起等待的那一瞬間漏掉通知,Microsoft 自己也明白寫道「它不可靠,不要用,改用條件變數」。7

接下來依序走完支撐這個結論的機制。

2. 虛假喚醒是什麼 ── 醒來不代表條件成立

條件變數是「讓執行緒睡到某個條件成立,成立時再把它喚醒」的同步原語。在 Win32 上,那是 CONDITION_VARIABLE 結構搭配 SleepConditionVariableCS / SleepConditionVariableSRW(等待)與 WakeConditionVariable / WakeAllConditionVariable(通知)。等待 API 以原子方式釋放你握著的鎖(臨界區段或 SRW 鎖)並入睡,醒來時在返回前重新取得鎖。1

問題是「已從 wait 返回」這個事實實際代表什麼。直覺上想成「通知到了=條件成立」,但現實裡 wait 返回有三種情況。

情況 通知 返回時的條件
真正的喚醒 常常滿足,但不保證
虛假喚醒 沒有對你發出的 仍未滿足
被竊取的喚醒 另一條執行緒先消耗掉;未滿足
wait 返回的三種情況條件變數等待不只因真正的通知返回,也可能因沒有通知的虛假喚醒,以及通知到了但條件先被消耗的被竊取喚醒而返回,因此每種情況都要再檢查條件從 wait 返回真正的通知虛假喚醒(沒有通知)被竊取的喚醒(條件已被消耗)再檢查條件,然後繼續

圖 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 返回,中間永遠有時間縫隙。若第三條執行緒能在那個間隔取得鎖,它就能先消耗掉條件(佇列的內容等等)。那是無論怎麼打磨實作都抹不掉的縫隙,因為它來自條件變數這個工具本身的形狀。

被竊取喚醒的時間軸生產者把一項放進佇列並喚醒正在等的消費者 A,但在 A 重新取得鎖之前,消費者 B 取得鎖並拿走那一項,因此 A 醒來時佇列是空的消費者 B生產者消費者 A(正在等)消費者 B生產者消費者 A(正在等)已喚醒,正在等重新取得鎖佇列是空的(被竊取)把一項加入佇列WakeConditionVariable取得鎖並拿走一項重新取得鎖並從 wait 返回在 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

每一層都要求再檢查述語官方文件要求在每一層──C++ std::condition_variable、.NET Monitor、Win32 CONDITION_VARIABLE,以及低層 WaitOnAddress──醒來後再檢查條件C++ std::condition_variable醒來時再檢查條件(while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

圖 3: 換語言或架構,官方要求在每一個等待原語層仍相同:醒來後再檢查。

5. 正確的等待方式 ── 用 while 與述語來寫

從這裡開始是實作。原則只有三條。

  1. 把你在等的東西當成狀態(述語)持有,而不是當成「通知」。條件是鎖保護的共享狀態──「佇列是否非空?」「旗標是否已設定?」──不是「我有沒有被喚醒?」。
  2. 永遠把 wait 放在條件的 while 迴圈裡。每次醒來檢查條件,不成立就回去睡。
  3. 在同一把鎖下更新並檢查條件。通知端更新狀態然後通知。
正確等待迴圈的流程取得鎖並檢查條件;若不成立,釋放鎖並入睡;醒來時重新取得鎖並回到條件檢查。只有條件成立時才握著鎖繼續取得鎖條件滿足嗎?wait(釋放鎖並入睡)醒來(重新取得鎖)仍握著鎖繼續

圖 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 不同。在鎖外呼叫會丟出 SynchronizationLockException10

帶逾時的等待 ── 從截止期限計算剩餘時間

帶逾時等待時,每次迴圈迭代都傳「同一個逾時值」,會在每次虛假喚醒時把等待拉長。正確形狀是先固定截止期限,再重算剩餘時間。

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);
帶逾時等待的正確流程先固定截止期限,每次醒來檢查條件與截止期限;若還有時間,重算剩餘時間並回到等待固定截止期限條件滿足嗎?進入處理截止期限過了嗎?處理逾時計算剩餘時間並等待

圖 5: 帶逾時的等待不再傳「同一個等待時長」;它從截止期限重算剩餘時間。

在 C++ 裡,可以把這個計算連同截止期限交給 wait_until(絕對時間)加上述語多載。即使因逾時返回,它也給你述語的最終值,因此也能依述語判斷「是逾時了,還是趕上了?」。5

6. 該避開的模式目錄

只用 if 檢查一次。這是本文的主角。虛假喚醒或被竊取喚醒一發生,處理就在條件未滿足的情況下繼續。從空佇列取出、碰到未初始化資料、重複釋放──症狀變成「只偶爾出現的當機或資料損壞」。

在鎖外檢查或更新條件。若等待端在鎖外看條件、判定「還沒」,並在進入 wait 前的縫隙間通知端更新狀態並送出通知,通知就會對沒有等待者的條件變數發射然後消失。等待端隨後進入 wait,繼續等一個再也不會來的通知。那是遺失喚醒,虛假喚醒的鏡像。條件變數的等待 API 設計成「以原子方式釋放鎖並入睡」,正是為了關上這道縫隙。1 只要守住鎖的紀律就不會發生。

遺失喚醒的時間軸若等待端在鎖外檢查條件,通知端在進入 wait 前的縫隙間更新狀態並通知,通知就被送到沒有等待者的條件變數然後消失,等待端繼續等一個再也不會來的通知通知端等待端通知端等待端這一刻沒有等待者通知已經沒了,永遠不醒在鎖外檢查條件(未滿足)更新狀態並通知進入 wait

圖 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。從那裡追過程式碼「誰本該送那個通知、以什麼順序」。

從症狀分流若處理在條件未滿足的情況下繼續,用搜尋程式碼梳出沒有述語的等待;若執行緒不醒,從傾印辨識等待位置並懷疑遺失喚醒或 PulseEvent只偶爾出現的錯誤處理在條件未滿足的情況下繼續本該醒來的執行緒不醒搜尋程式碼裡沒有述語的等待從傾印辨識正在等的執行緒把 if 改成 while,或使用述語 wait懷疑遺失喚醒或 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。而那一行背後,是條件變數這個工具的設計想法:「精準通知很貴,所以檢查是等待端的責任」。當成機制理解,語言或架構改變時,你仍該能毫不猶豫地套用同樣的紀律。

相關文章

相關諮詢領域

小村軟體有限公司承接多執行緒設計審查、「只偶爾重現」的當機與卡住之根本原因調查(傾印分析),以及把舊同步程式碼(依賴事件與 PulseEvent 等)遷移到條件變數基底。從症狀分流開始也可以──歡迎聯絡。

參考連結

  1. Microsoft Learn, Condition Variables. 關於條件變數是以原子方式釋放鎖並進入等待的使用者模式物件;關於有虛假喚醒(與明確喚醒無關的喚醒)與被竊取的喚醒(另一條執行緒在被喚醒的執行緒之前跑),因此從等待返回後應在 while 迴圈裡再檢查述語;以及通知可從鎖內或鎖外發出,但釋放鎖後再喚醒較有利於減少內容切換。  2 3 4 5 6 7 8

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). 關於以原子方式釋放指定臨界區段並在條件變數上等待;關於被喚醒的執行緒在返回前重新取得臨界區段;關於逾時傳回 ERROR_TIMEOUT;以及有虛假喚醒與被竊取的喚醒,因此從等待返回後應再檢查述語(通常在 while 迴圈裡)。  2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. 關於 pthread_cond_wait / pthread_cond_timedwait 的虛假喚醒可能發生;關於從 wait 返回對述語的值不代表任何事,因此應重新評估述語;以及 Rationale 寫道「恰好喚醒一個」的實作會讓條件變數操作變慢,尤其在多處理器上,允許虛假喚醒會強制述語檢查迴圈並讓應用程式更健壯。  2 3

  4. cppreference.com, std::condition_variable::wait. 關於沒有述語的 wait 可能被虛假喚醒解除阻塞;以及述語多載等同於 while (!pred()) wait(lock);,定義為每次通知或虛假喚醒時重新取得鎖並檢查述語的迴圈。  2

  5. Microsoft Learn, condition_variable Class. 關於沒有述語的 wait 被記載為在 notify_one / notify_all 時解除阻塞,也可能虛假醒來;關於述語形式 wait(lock, pred) 實質上跑 while (!Pred()) wait(Lck);;以及 wait_for / wait_until 有相同性質與述語多載。  2 3

  6. Microsoft Learn, Monitor.Wait Method. 關於 Wait 釋放鎖並進入等待佇列;關於被 Pulse / PulseAll 喚醒後在重新取得鎖之前不返回;以及預期用法是被喚醒的執行緒重新評估讓它進入等待的條件,必要時再次呼叫 Wait。  2

  7. Microsoft Learn, PulseEvent function (winbase.h). 關於等待中的執行緒可能被核心模式 APC 暫時移出等待狀態並在 APC 完成後返回,因此若 PulseEvent 在該間隔被呼叫該執行緒不會被釋放;以及因此 PulseEvent 不可靠、不應在新應用程式使用,改用條件變數。  2

  8. Microsoft Learn, Using Condition Variables. 關於用一個臨界區段與兩個條件變數(BufferNotEmpty 與 BufferNotFull)實作生產者–消費者佇列的官方範例。等待在檢查述語的迴圈內執行。 

  9. Microsoft Learn, WaitOnAddress function (synchapi.h). 關於等待位址值改變的函式在被發訊號時保證返回,但也允許因其他理由返回;關於提早醒來的例子包括低記憶體狀況、放棄同一位址先前的喚醒,以及跑檢查組建;以及因此返回後需要再次比較值,官方範例本身就是 while 迴圈。 

  10. Microsoft Learn, Monitor.PulseAll Method. 關於 PulseAll 把執行緒從等待佇列移到就緒佇列,鎖釋放時就緒佇列上的下一條執行緒取得鎖;以及 Pulse / PulseAll / Wait 只能從同步區塊內呼叫。  2

  11. Microsoft Learn, Waits and APCs. 關於核心 APC 搶佔執行,系統內部中斷並恢復等待而不從等待 API 返回,因此 KePulseEvent 這類短暫訊號可能在該間隔被漏掉。 

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. 關於對使用 PulseEvent 發出靜態分析警告;關於因 APC 而離開等待的執行緒不被釋放並可能永遠卡住;以及改用 SetEvent 或其他同步物件的指引。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

虛假喚醒是作業系統或程式庫的錯誤嗎?
不是──這是規格寫明的行為。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 的可靠性問題,因此處理程序內等條件,條件變數是較安全的工具。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽