更新履歴(2件・最終更新 2026年08月22日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
- 知識マップの2つの関係を改めました。述語のwhileループが防ぐ対象を「スプリアスウェイクアップ・横取り」から「条件不成立のまま処理が進むこと(競合状態)」に、カーネルAPCが引き起こすものを「PulseEvent」から「起こし損ね」に修正しています。目覚め自体は防げず、防げるのは誤った続行だという本文の説明に図を合わせたものです。本文の説明は変えていません。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.22054277)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「スプリアスウェイクアップ ── 条件変数が「通知なしに目覚める」理由とWindowsでの正しい待ち方」合同会社小村ソフト. https://doi.org/10.5281/zenodo.22054277 https://comcomponent.com/blog/windows-condition-variable-spurious-wakeup/
- DOI(最新版)
- 10.5281/zenodo.22054277
- DOI(この版)
- 10.5281/zenodo.22054278
「キューにデータを入れたら、待っているワーカースレッドを起こす。半年動いていたのに、ある日空のキューを読もうとして落ちた」「通知は送っているはずなのに、たまに目覚めないスレッドがいる」── マルチスレッドの待ち合わせは、動いているように見えて、まれにしか出ない不具合の温床です。こうした調査でしばしば行き着くのが、条件変数(condition variable)の wait を if で囲んだコードです。そしてその背後にいるのが、スプリアスウェイクアップ(spurious wakeup、偽の起床) ── 通知を受け取っていないのに wait から目覚めてしまう現象です。
「通知していないのに目覚める」と聞くと実装の欠陥のようですが、これはWin32・C++・POSIXがそろってドキュメントや規格に明記している仕様であり、.NETの Monitor も「起こされたら条件を確認し直す」使い方を前提に設計されています。なぜそんな挙動が許されているのか。Windowsではどの層で起きるのか。そして、どう書けば絶対に踏まないのか。この記事では、Windowsで業務アプリや装置制御ソフトを書く開発者を対象に、スプリアスウェイクアップの正体を一次情報にもとづいて解き明かし、Win32(C)・C++・C#それぞれの正しい待ち方に落とし込みます。
1. まず結論
- 条件変数の
waitは、通知が来ていなくても戻ることがあります。Win32の公式ドキュメントは、条件変数にスプリアスウェイクアップ(明示的な起床と結びつかない目覚め)と横取り(起こされたスレッドより先に別のスレッドが条件を消費する)があると明記しています。1 - したがって待機は必ず「whileループ+条件の再確認」で書きます。
ifで1回チェックしてwaitするコードは、動いているように見えて、まれにしか再現しないバグを抱えています。12 - これはWindows固有の癖ではなく、POSIXもC++規格も同じです。「絶対に偽の起床をしない」実装は条件変数の全操作を遅くするため、待機側の再確認を前提に許容されています。34
- C++では述語付きの
wait(lock, pred)を使えば、ループはライブラリが代行します。この形は実質的にwhile (!pred()) wait(lock);を実行します。新規コードの既定はこちらです。5 - C#の
Monitor.Waitも同じ規律が必要です。起こされてからロックを取り直すまでの間に条件が消費されうるため、whileで条件を確認してからWaitに戻ります。6 - 条件の更新・確認は同じロックの中で行います。ロックの外で条件を見てから
waitに入ると、その隙間に通知が通り過ぎる「起こし損ね(lost wakeup)」が起きます。1 - 条件変数の「今待っている人を起こす」一時的な通知を、イベントのパルスで再現しないでください。特に
PulseEventは、カーネルモードAPCで待機が一瞬解除された瞬間に通知が素通りするため、Microsoft自身が「信頼できないので使うな、代わりに条件変数を」と明言しています。7
以下、この結論を支える仕組みを順に見ていきます。
この記事の知識マップ
条件変数のwaitは、通知が来ていなくても戻ることがあります。Win32の公式ドキュメントは、スプリアスウェイクアップと、起こされたスレッドより先に別のスレッドが条件を消費する横取りの両方があると明記しています。3つのどの経路で戻ってきたかを呼び出し側は区別できないため、取れる戦略は「戻るたびに条件そのものを確認し、成立していなければもう一度眠る」しかありません。これはWindows固有の癖ではなく、POSIXもC++規格も同じで、ちょうど1つのスレッドだけを確実に起こす通知を厳密に実装すると条件変数のすべての操作に余分な同期コストがかかるという性能上の判断です。しかも横取りは実装を磨いても消せないため、待機側の再確認ループはどのみち必須になります。鏡像の問題が起こし損ねで、条件の確認と更新を同じロックの中で行えば防げます。
flowchart LR
accTitle: 条件変数のスプリアスウェイクアップの知識マップ
accDescr: waitから戻る3つの経路と呼び出し側が区別できないこと、性能とのトレードオフとして仕様が許した経緯、述語のwhileループによる対処、起こし損ねとの対比、PulseEventがカーネルAPCで通知を落とす問題を示す図
spurious_wakeup["スプリアスウェイクアップ"]
predicate_while_loop["述語のwhileループ再確認"]
win32_condition_variable["Win32の条件変数"]
stolen_wakeup["横取り(stolen wakeup)"]
critical_section["CRITICAL_SECTION"]
srw_lock["SRWロック(Slim Reader/Writer Lock)"]
race_condition["競合状態(race condition)"]
predicate_wait_overload["述語付きwait(lock, pred)"]
std_condition_variable["std::condition_variable"]
wait_on_address["WaitOnAddress"]
monitor_wait_pulse["Monitor.Wait / Pulse / PulseAll"]
lost_wakeup["起こし損ね(lost wakeup)"]
deadlock["デッドロック(deadlock)"]
pulseevent["PulseEvent"]
kernel_apc["カーネルモードAPC"]
win32_condition_variable -->|"原因になり得る"| spurious_wakeup
win32_condition_variable -->|"原因になり得る"| stolen_wakeup
win32_condition_variable -->|"利用する"| critical_section
win32_condition_variable -->|"利用する"| srw_lock
predicate_while_loop -->|"防止する"| race_condition
predicate_wait_overload -->|"実装を担う"| predicate_while_loop
std_condition_variable -->|"利用する"| predicate_wait_overload
std_condition_variable -->|"原因になり得る"| spurious_wakeup
wait_on_address -->|"原因になり得る"| spurious_wakeup
monitor_wait_pulse -->|"原因になり得る"| stolen_wakeup
monitor_wait_pulse -->|"前提とする"| predicate_while_loop
lost_wakeup -->|"原因になり得る"| deadlock
win32_condition_variable -->|"防止する"| lost_wakeup
spurious_wakeup -.->|"原因になり得る"| race_condition
pulseevent -->|"原因になり得る"| lost_wakeup
kernel_apc -.->|"原因になり得る"| lost_wakeup
win32_condition_variable -->|"の後継"| pulseevent
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全17件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. スプリアスウェイクアップとは何か ── 「目覚めた=条件成立」ではない
条件変数は、「ある条件が成立するまでスレッドを眠らせておき、成立したら起こしてもらう」ための同期プリミティブです。Win32では CONDITION_VARIABLE 構造体と SleepConditionVariableCS / SleepConditionVariableSRW(待機)、WakeConditionVariable / WakeAllConditionVariable(通知)がこれにあたります。待機APIは、保持しているロック(クリティカルセクションまたはSRWロック)の解放と眠りへの移行を不可分に行い、目覚めるときはロックを再取得してから戻ります。1
問題は、「wait から戻った」という事実が何を意味するかです。素朴には「通知が来た=条件が成立した」と考えたくなりますが、実際には wait から戻るケースは3つあります。
| ケース | 通知 | 戻った時点の条件 |
|---|---|---|
| 正規の目覚め | あり | 成立していることが多いが、保証はない |
| スプリアスウェイクアップ | 自分宛てはなし | 不成立のまま |
| 横取り(stolen wakeup) | あり | 別のスレッドが先に消費して不成立 |
flowchart TB
accTitle: waitから戻る3つのケース
accDescr: 条件変数のwaitからは、正規の通知による目覚めのほかに、通知なしのスプリアスウェイクアップと、通知はあったが条件を先に消費された横取りでも戻るため、どのケースでも条件の再確認が必要になる
w["waitから戻った"] --> a["正規の通知"]
w --> b["スプリアスウェイクアップ(通知なし)"]
w --> c["横取り(条件は消費済み)"]
a --> r["条件を再確認してから進む"]
b --> r
c --> r
図1: waitから戻る経路は3つあり、どれで戻ったかを呼び出し側は区別できないため、必ず条件を再確認する。
スプリアスウェイクアップは、この2番目のケース ── 自分を起こす明示的な通知と結びつかないまま、待機APIが戻ってくる現象です。システム全体で WakeConditionVariable が一度も呼ばれていない状況に限りません。たとえば通知が短時間に連続する高負荷の場面で、実装の都合により待機中のスレッドが余分にまとめて起こされることもあり、対応する通知のない側から見ればそれもスプリアスウェイクアップです。Microsoft Learnの条件変数のページは、このことをはっきり書いています。「条件変数はスプリアスウェイクアップ(明示的な起床と結びつかないもの)と横取りされた起床(起こされたスレッドより先に別のスレッドが実行されること)の対象となる。したがって、待機操作から戻った後は述語を(通常はwhileループで)再確認すべきである」。1
重要なのは、3つのケースのどれで戻ってきたのかを、呼び出し側が区別できないことです。区別できない以上、取れる戦略はひとつしかありません。戻ってくるたびに、待っていた条件そのものを確認し、成立していなければもう一度眠る。これが「waitはwhileで囲む」という鉄則の正体です。逆に言えば、この鉄則さえ守れば、3つのケースのどれで目覚めてもコードは正しく動きます。
3. なぜ仕様として許されているのか ── 正確な通知は高くつく
「通知していないのに目覚めるのは実装の手抜きではないか」という疑問はもっともです。実際、スプリアスウェイクアップを起こさない実装を作ることは理論上可能です。それでもPOSIXもWindowsもC++規格も、そろって「起こりうる」側に倒しました。理由はPOSIX(The Open Group Base Specifications)の pthread_cond_wait のRationale(根拠)に率直に書かれています。3
第一の理由は性能です。「ちょうど1つのスレッドだけを確実に起こす」通知を厳密に実装しようとすると、特にマルチプロセッサ環境で、条件変数のすべての操作に余分な同期コストがかかります。通知と目覚めの間にはスケジューラーが介在し、割り込みやプリエンプションのタイミング次第で「起こしたはずのスレッドより先に別のスレッドが走る」ことは避けられません。これを完全に封じるコストを全員に払わせるより、「まれに余分に目覚めることがある」と割り切るほうが、条件変数を速く保てます。
第二の理由は、その割り切りがアプリケーションを壊さない ── むしろ堅牢にするという観察です。スプリアスウェイクアップが許されている以上、正しいコードは必ず述語(待っている条件)を確認するループを書くことになります。POSIXのRationaleは、このループの強制がコードを自己文書化し、より堅牢にすると述べています。3 ループさえあれば、通知の意味は「条件が成立した保証」から「条件が変わったかもしれないというヒント」に格下げされ、通知側の多少の設計変更(起こしすぎ、まとめて起こす等)にも耐えるようになります。
横取りのほうは、さらに構造的な話です。通知側が WakeConditionVariable を呼んでから、起こされたスレッドがロックを再取得して wait から戻るまでには、必ず時間差があります。その間にロックを取れる第三のスレッドがいれば、条件(キューの中身など)を先に消費できてしまいます。これはどんなに実装を磨いても消せない、条件変数という道具の形そのものに由来する隙間です。
sequenceDiagram
accTitle: 横取り(stolen wakeup)が起きる時系列
accDescr: 生産者がキューに1件入れて待機中の消費者Aを起こすが、Aがロックを再取得する前に消費者Bがロックを取得して1件取り出してしまい、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: 通知から目覚めまでの時間差の間に、第三のスレッドが条件を消費する「横取り」はどんな実装でも起こりうる。
つまり、たとえOSがスプリアスウェイクアップを完全に根絶したとしても、横取りがある限り「目覚めた=条件成立」とは書けません。待機側の再確認ループはどのみち必須であり、それならスプリアスウェイクアップを許して実装を速く保つほうが得だ ── これが数十年にわたり使われてきた条件変数の設計判断です。
4. Windowsではどの層で現れるのか
この性質は、Windowsの同期プリミティブのどの層を使っていても顔を出します。自分がどの層のAPIで書いていても逃げられない、という感覚を持つために、代表的な層を確認しておきます。
Win32の条件変数(CONDITION_VARIABLE)は前述の通り、SleepConditionVariableCS / SleepConditionVariableSRW のドキュメントにスプリアスウェイクアップと横取りの両方が明記され、whileループでの述語再確認が要求されています。2 公式の使用例(生産者・消費者キュー)も、待機をwhileループで書いています。8
さらに低い層の WaitOnAddress は、「指定アドレスの値が変わるのを待つ」という、条件変数より原始的な待機APIです(Windows 8以降)。この最下層に近いAPIですら、ドキュメントは「アドレスがシグナルされたときに戻ることは保証されるが、それ以外の理由で戻ることも許されている」と明記し、早期に目覚める例として低メモリ状態・同じアドレスに対する過去のwakeの放棄・checkedビルドでの実行を挙げています。だからこそ、ドキュメントの使用例そのものが「値を比較し直す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と述語で書く
ここからは実装です。原則は3つだけです。
- 待つ対象を「通知」ではなく「状態(述語)」として持つ。「起こされたか」ではなく「キューが空でないか」「フラグが立ったか」という、ロックで保護された共有状態を条件にします。
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; // 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); // 通知はロック解放後でよい
通知(WakeConditionVariable)はロックの中からでも外からでも呼べますが、ドキュメントは、コンテキストスイッチを減らすためロックを解放してから起こすほうが通常は良い、としています。1 一方、状態の更新そのもの(++queueCount)は必ずロックの中で行います。この2つを混同しないでください。
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は不要です。手書きのループが残っている既存コードを直すときも、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 はロック(lock ブロック)の中からしか呼べない点がWin32と異なります。ロック外で呼ぶと SynchronizationLockException になります。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(絶対時刻)+述語のオーバーロードに任せられます。タイムアウトで戻った場合でも述語の最終値を返してくれるため、「時間切れだったのか、間に合ったのか」の判定も述語基準で行えます。5
6. やってはいけないパターン集
if で1回だけ確認する。この記事の主役です。スプリアスウェイクアップか横取りが起きた瞬間、条件不成立のまま処理が進みます。空のキューからの取り出し、未初期化データの参照、二重解放 ── 症状は「たまにしか出ないクラッシュ・データ破損」になります。
条件の確認や更新をロックの外で行う。待機側がロックの外で条件を見て「まだだ」と判断し、wait に入ろうとしたその隙間に、通知側が状態を更新して通知を送ると、通知は待機者のいない条件変数に向けて発射され、消えます。待機側はその後 wait に入り、二度と来ない通知を待ち続けます。これが起こし損ね(lost wakeup)で、スプリアスウェイクアップの鏡像です。条件変数の待機APIが「ロックの解放と眠りへの移行を不可分に行う」よう設計されているのは、まさにこの隙間を閉じるためです。1 ロックの規律を守っている限り起きません。
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の隙間に通知が素通りする「起こし損ね」が起きる。
条件変数の「一時的な通知」をイベントのパルスで再現する。イベント(CreateEvent + SetEvent)そのものはアンチパターンではありません。1つの消費者がキューを空になるまで処理する構成の起床合図、一度立てたら下ろさない停止指示(手動リセットイベント)はイベントの正しい使いどころですし、WaitForMultipleObjects で他の待機対象と合流させたい場合や、プロセス境界をまたぐ場合には、プロセス間で共有できないユーザーモードオブジェクトである条件変数のほうが使えません。1 危ないのは、条件変数の「その瞬間に待っているスレッドだけを起こし、状態を残さない」という一時的な通知を、イベントの操作で再現しようとすることです。その発想は、ほぼ必ず次の PulseEvent に行き着きます。
PulseEvent を使う。手動リセットイベントで「今待っている全員を起こして、すぐ非シグナルに戻す」というAPIですが、Microsoft自身がドキュメントで「この関数は信頼できないので使うべきではない。主に後方互換のために存在する。代わりに条件変数を使え」と明言しています。理由は、待機中のスレッドがカーネルモードAPCによって一時的に待機状態から外され、APC完了後に待機へ戻ることがあるためです。その一瞬の間に PulseEvent が呼ばれると、そのスレッドは「呼ばれた瞬間に待機していた者」に含まれず、起こされません。7 カーネルAPCはOSが内部的に使うもので、アプリ側から制御できません。11 この問題は静的解析の警告(C28648)にもなっています。12 スプリアスウェイクアップが「余分に目覚める」問題なら、こちらは「目覚めるべきなのに寝過ごす」問題であり、whileループでも救えません ── 通知そのものが失われているからです。
状態を更新する前に、ロックを持たずに通知だけ先に送る。状態がまだ古いままの時点で WakeConditionVariable を呼び、そのあとロックを取って状態を更新する ── という順序だと、起こされたスレッドが条件を確認した時点ではまだ不成立で、再び眠ってしまいます。次の通知が来なければそのままです。なお、同じロックを保持したまま「通知→更新→解放」の順で書いた場合は、待機側はロックを再取得するまで条件を確認できないため実害はありません。とはいえ、読み手が毎回この安全条件を検証せずに済むよう、「ロックの中で状態を更新し、そのあとで通知する」順序に統一しておくのが安全です。
7. 遭遇したときの調べ方
スプリアスウェイクアップ絡みのバグは「まれにしか出ない」のが特徴です。症状から逆引きすると、次の2系統に分かれます。
系統1: 条件不成立のまま処理が進む。空キューからの取り出しによる例外・クラッシュ、処理結果の欠落など。疑うのは述語なしの待機です。コードレビューでは機械的に洗えます ── cv.wait( の引数が1つだけの箇所、SleepConditionVariableCS / Monitor.Wait を囲んでいるのが while ではなく if の箇所を検索します。この検査は再現待ちをせずに済む、最も費用対効果の高い一手です。
系統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 を差し込む、デバッグビルドとリリースビルドの両方で回す、といった操作でタイミングのばらつきを増やします。「述語なしwaitを直したら再現しなくなった」ことを確認するときも、同じストレスをかけた上で比較してください。
8. まとめ ── チェックリスト
waitから戻る経路は「正規の通知・スプリアスウェイクアップ・横取り」の3つで、呼び出し側には区別できない。だから待機は必ず条件のwhileループで書く。- スプリアスウェイクアップはWin32・C++・POSIXが性能とのトレードオフとして意図的に許した仕様で、OSの修正やライブラリの乗り換えでは消えない。.NETの
Monitor.Waitに理由のない目覚めは想定されていないが、横取りとタイムアウトがある以上、同じwhileの規律が必要になる。 - C++は述語付き
wait(lock, pred)を既定にする。ループはライブラリが代行する。 - 条件の更新・確認は同じロックの中で。通知は「状態を更新してから」送る。Win32/C++の通知はロック解放後でよいが、C#の
Pulseはロック内のみ。 - タイムアウト付き待機は締め切り時刻を決めて残り時間を計算し直す。C++なら
wait_until+述語。 - 条件変数の一時的な通知をイベントのパルスで再現しない。特に
PulseEventは公式が「使うな、代わりに条件変数を」と明言している。イベント自体は停止指示・WaitForMultipleObjectsとの合流・プロセス間の同期では今も正しい道具。 - レビューでは「述語なしwait」「
if+wait」を機械的に検索する。まれにしか出ないバグを、再現を待たずに退治できる。
スプリアスウェイクアップは、名前の奇妙さに反して、対処は一行のキーワード ── if を while に変える ── に集約されます。そして、その一行の背後には「正確な通知は高くつくので、確認は待つ側の責任にする」という、条件変数という道具の設計思想が横たわっています。仕組みごと理解しておけば、言語やフレームワークが変わっても同じ規律を迷わず適用できるはずです。
関連記事
- マルチスレッドの実務ベストプラクティス C++編
- マルチスレッドの実務ベストプラクティス C言語編
- マルチスレッドの実務ベストプラクティス .NET編
- WindowsでSleep(1)よりイベント待機を優先すべき理由
- 共有メモリの落とし穴と実務ベストプラクティス
- 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で「ちょうど1つだけ起こす」実装が特にマルチプロセッサで条件変数操作を遅くしうること、スプリアスウェイクアップの許容が述語確認ループを強制しアプリケーションを堅牢にすることが述べられている点について。 ↩ ↩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. 1つのクリティカルセクションと2つの条件変数(BufferNotEmpty・BufferNotFull)で生産者・消費者キューを実装する公式サンプルについて。待機は述語を確認するループの中で行われている。 ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). アドレスの値が変わるのを待つ関数がシグナル時に戻ることは保証されるが他の理由で戻ることも許されていること、早期に目覚める例として低メモリ状態・同じアドレスへの以前のwakeの放棄・checkedビルドでの実行が挙げられていること、そのため戻ったら値を比較し直すべきで公式サンプル自体が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の4オブジェクト、クリーンアップグループ、コールバックでの禁止事項までを一次情報にもとづいて解説します。
名前付きパイプの実務 ── Windowsプロセス間通信の定番を設計からセキュリティまで
Windowsのプロセス間通信の定番・名前付きパイプを実務目線で解説します。バイト/メッセージモードの選択、複数クライアントを捌くサーバー設計、ACLと偽装のセキュリティ、.NETのNamedPipeStreamまで、一次情報にもとづき整理します。
「応答なし」の正体 ── Windowsがアプリを「固まった」と判定する仕組みと、固まらない設計
Windowsの「応答なし」は、ウィンドウが5秒間メッセージを取り出さないとOSが判定し、幽霊ウィンドウに差し替える仕組みです。判定の内部動作から、固まる定番原因、UIスレッドから重い処理を追い出す設計、ハングの調査手順まで解説します。
マルチスレッドの実務ベストプラクティス C言語編 ── Win32 APIの流儀で安全に書く
C言語×Win32のマルチスレッドは、_beginthreadexでのスレッド作成、SRWロックと条件変数、Interlocked、停止イベント+WaitForMultipleObjectsの停止設計が定石。TerminateThreadの危険とDllMainの制約まで整理...
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
不具合調査 / 長期稼働テーマ
再現しにくい不具合、通信停止、長期稼働障害、失敗パス検証を整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
不具合調査・原因解析
再現しにくい障害、長期稼働後の不具合、通信停止などの調査を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- スプリアスウェイクアップはOSやライブラリのバグですか?
- バグではなく、仕様として明文化された挙動です。Win32のSleepConditionVariableCS、C++のstd::condition_variable、POSIXのpthread_cond_waitのいずれも、公式ドキュメントや規格が「通知と結びつかない目覚めが起こりうる」と明記しています。これを禁止する実装も理論上は可能ですが、条件変数の全操作(特にマルチプロセッサでの通知)が遅くなるため、「待機側が条件を再確認すれば正しさは保てる」という割り切りで許容されています。したがって対処はOS側の修正を待つことではなく、waitを必ずwhileループ(または述語付きwait)で書くことです。
- waitをwhileで囲むと性能が落ちませんか?
- 実用上は無視できます。whileで囲んだ場合に増えるのは、目覚めるたびに行う条件のチェック1回分だけで、これはロックを保持した状態での軽い比較です。スプリアスウェイクアップ自体もまれにしか起きないため、余分なループが回るのは例外的な場面に限られます。一方で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でイベントを待つ場合にもスプリアスウェイクアップはありますか?
- 通常の(alertableでない)待機では、WAIT_OBJECT_0が返るのはオブジェクトが実際にシグナル状態になったときで、条件変数のような「理由のない目覚め」は起きません。ただし「イベントがシグナルになった」ことと「自分のアプリの条件が成立している」ことは別です。複数の消費者が同じイベントで起こされる設計なら、先にロックを取ったスレッドが条件を消費するため、結局は目覚めた後の条件確認が必要になります。また、条件変数のような「その瞬間の待機者だけを起こす」一時的な通知をイベントで再現しようとする設計はPulseEventの信頼性問題に行き着きやすいため、プロセス内で状態の成立を待つ用途には条件変数を使うのが安全です。