WindowsでSleep(1)よりイベント待機を優先すべき理由

· 更新日: · · Windows開発, 同期, イベント, タイマー, 設計

更新履歴(4件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
`WaitOnAddress`の例で、フラグを素の変数のまま両スレッドから読み書きしていました。C++ではデータ競合(未定義動作)で、最適化されたビルドでは値がレジスタに載ったまま更新が見えず、起こされても止まり続けることがあります。`WakeByAddressSingle`は待っているスレッドを起こすだけで、直前の書き込みをアトミックにも可視にもしません。書く側をrelease、読む側をacquireで揃えたアトミック変数に直し、一緒に渡すデータの可視性についても書き足しました。
この記事が単体で読めることと前回記事の結論の要約、用語表を冒頭に追加しました。自分の環境のタイマー粒度を確かめる方法、`WaitForMultipleObjects`の戻り値の扱い(同時にシグナルされたときは添字の小さいほうが返るので停止用を先頭に置く)、`WaitOnAddress`の前提と早期復帰への対処を追加しました。3.1の図は判断表と重複していたため、待ちの終わり方の違いを示す図に差し替えました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589664)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「WindowsでSleep(1)よりイベント待機を優先すべき理由」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589664 https://staging.comcomponent.com/blog/2026/03/16/006-windows-timer-vs-event-wait/

DOI(最新版)
10.5281/zenodo.21589664
DOI(この版)
10.5281/zenodo.21732692

前回のWindows ソフトリアルタイムの実践ガイドでは、Sleep 任せの周期ループを避ける話を書きました。 今回はその中でも、なぜ 短い timer wait より event wait を優先したいのかを 1 点に絞って整理します。

この記事は単体で読めます。 前回の結論は「Sleep に周期を任せた様子見ループは、待ち時間も起床タイミングも保証されないので、周期処理の土台にしない」の 1 行です。ここだけ頭に入っていれば、以降は前回記事を読まなくても追えます。

Windows では、Sleep(1) や短い timeout 付きの wait を使って「一定時間ごとに様子を見る」設計をすると、どうしても system clock の粒度その後のスケジューリング遅延の影響を受けます。 普通の設定では 15.6ms 級の platform timer resolution が前提になることが多いので、「1ms 後にもう一回見よう」というつもりでも、実際にはかなり雑な待ちになりやすいです。

一方で、仕事の到着、I/O 完了、停止要求、状態変化のように、本当に待ちたいものが「時間」ではなく「出来事」なら、一定間隔で見に行く必要はありません。 イベントが起きた側が signal し、待つ側は event を待つほうが、遅延にも CPU にも電力にも素直です。

この記事で答えたい問いは、この 4 つです。

  • Sleep(1) や短い timer wait が、なぜ思ったより正確でないのか
  • なぜ event wait はその制限を受けにくいのか
  • どういう場面で timer ではなく event を選ぶべきか
  • それでも timer を使うべき場面は何か

この記事で出てくる用語

本文で説明なしに出てくる略語だけ、先に置いておきます。

用語 意味
platform timer resolution / system clock resolution OS が時刻を更新する間隔。timed wait の timeout 判定はこの粒度に引っ張られます
ISR (Interrupt Service Routine) 割り込みが起きたときに最優先で走る処理。ここが走っている間、こちらの thread は待たされます
DPC (Deferred Procedure Call) ISR が「あとで続きをやる」ために積む、優先度の高い遅延処理。ISR と合わせて 割り込み処理に伴う遅延要因と読んでおけば、この記事では足ります
IOCP (I/O Completion Port) 非同期 I/O の完了通知をキューにまとめ、専用の thread 群で受け取る Windows の仕組み
WaitOnAddress 「あるメモリ番地の値が変わるまで待つ」ための同期 API。同一プロセス内専用です (5.3 で扱います)
signal する 待っている側の条件を満たしてやること。event なら SetEvent を呼ぶことです

1. まず結論

  • 仕事の到着や I/O 完了を待つなら、timer ではなく event を待つほうがよいです。
  • Windows の timed wait は、どうしても system clock の粒度の影響を受けます。
  • Sleep(1) は「1ms 後に正確に起きる」意味ではありません。
  • しかも timeout が過ぎても、thread はまず ready になるだけで、即実行は保証されません。
  • だから 「本当は出来事を待っているのに、timer で様子を見に行く」設計は、遅延にも電力にも不利です。
  • timer を使うのは、本当に時間そのものが条件のときだけに絞ったほうがきれいです。

実務での言い方にすると、ほぼこれです。

  • 「5 秒おきに metrics を送る」 -> timer の仕事
  • 「キューに仕事が入ったらすぐ動く」 -> event / semaphore / condition variable / WaitOnAddress の仕事
  • 「I/O が終わったら続きを実行する」 -> completion / event の仕事
  • 「停止要求が来たら止まる」 -> stop event / cancellation の仕事

この記事の知識マップ

Windowsの短いtimer waitはsystem clock resolutionの粒度に縛られるうえ、timeoutが来てもスレッドはready状態になるだけで実行開始はスケジューラ次第になるため、Sleep(1)でキューや停止要求を様子見するポーリング設計は思ったより不正確です。producerがSetEventで知らせ、consumerがWaitForSingleObjectやWaitForMultipleObjectsで待つイベント駆動に変えると、待ちの終わり方が時間切れではなくsignalになり、無駄な空振りが消えます。I/O完了はoverlapped I/OのイベントやIOCP、同一プロセス内の値変化はWaitOnAddress、時刻そのものが条件のときだけwaitable timerを使うというのが道具の選び分けで、timeBeginPeriodによる精度向上は根本的な解決にはなりません。

timerポーリングとイベント待機の知識マップ短いtimer waitがsystem clock resolutionとスケジューリング遅延の二重の不確実さを持つこと、イベント駆動の待機がキュー到着・I/O完了・停止要求・同一プロセス内の値変化のそれぞれにどう対応するか、waitable timerとWaitOnAddressの使い分けの関係を示す図前提とする原因になり得る原因になり得る用いるのは非推奨推奨される対応利用する推奨される対応推奨される対応用いるのは非推奨推奨される対応用いるのは非推奨推奨される対応推奨される対応原因になり得る推奨される対応用いるのは非推奨で確認できる原因になり得る利用する利用する用いるのは非推奨timerポーリング(様子見ループ)イベント駆動の待機設計system clock resolution(platform timer resolution)スケジューリング遅延キューへの仕事到着待ちWindowsのイベントオブジェクトWindowsの待機関数(Wait Functions)Overlapped I/OI/O完了待ちI/O完了ポート(IOCP)停止要求待ちWaitOnAddress API同一プロセス内の値変化待ちデータ競合(data race)待機可能タイマー(waitable timer)時刻そのものが条件の待ちtimeBeginPeriod(タイマー分解能要求)GetSystemTimeAdjustment割り込み処理に伴う遅延要因(ISR/DPC)

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. 何が問題なのか

2.1 timed wait は system clock の粒度に縛られる

Windows の wait functions の timeout 精度は、system clock resolution に依存します。 Sleep も同じで、指定したミリ秒がそのまま「その通りの長さ」で保証されるわけではありません。

ここで大事なのは、1ms を指定したから 1ms 後に起きるとは限らないという点です。

自分の環境の粒度を確かめる

「15.6ms 級」は一般論なので、手元の値は自分で見たほうが早いです。確かめ方は 2 つあります。

1 つは、GetSystemTimeAdjustment を呼ぶことです。第 2 引数の lpTimeIncrement に、system が time-of-day clock を更新する間隔が 100 ナノ秒単位で返ります。15.6ms 級なら 15 万台の値になります。

#include <windows.h>
#include <cstdio>

int main()
{
    DWORD adjustment = 0;
    DWORD increment = 0;
    BOOL adjustmentDisabled = FALSE;

    if (!GetSystemTimeAdjustment(&adjustment, &increment, &adjustmentDisabled))
    {
        std::printf("GetSystemTimeAdjustment failed. GetLastError=%lu\n", GetLastError());
        return 1;
    }

    // increment は 100ns 単位なので、ms に直して見る
    std::printf("time increment = %lu (100ns) = %.4f ms\n",
                increment,
                increment / 10000.0);
    return 0;
}

もう 1 つは、Sysinternals の ClockRes を実行することです。これは中で同じ GetSystemTimeAdjustment を呼んで、system clock の分解能、つまりアプリが得られる最大の timer resolution を表示するだけの小さなツールです。コードを書かずに確認したいときはこちらが早いです。

なお、ドキュメント上 lpTimeIncrement起動時に system が決める固定値で、稼働中に変わらないものとして説明されています。つまりこれは「この環境の素の粒度」を知るための値であって、timeBeginPeriod を呼んだ結果を測るためのものではありません。そちらについては 6.3 で触れます。

2.2 期限が来ても、すぐ実行されるとは限らない

さらにややこしいのは、timeout が過ぎた瞬間に thread が即実行されるわけではないことです。

Sleep の説明にもある通り、待ち時間が終わったあと thread は readyにはなりますが、今すぐ CPU をもらって走れる保証はありません。 ほかの thread、priority、CPU の idle state、DPC / ISR、lock 競合などの影響を受けます。

つまり、短い timer wait には少なくとも 2 段階の不確実さがあります。

  1. そもそも timeout の判定自体が timer 粒度に引っ張られる
  2. timeout 後も、実行開始は scheduler 次第になる

2.3 Sleep(1) は 1ms 周期の意味にならない

Sleep(1) を見ると、つい「1ms ごとに回る loop」っぽく見えます。 でも実際には、そう読んではいけません。

while (!g_stop)
{
    Step();
    Sleep(1);
}

この loop の実態はこうです。

  • Step() の実行時間が毎回足される
  • Sleep(1) の待ち時間自体が粒度に引っ張られる
  • 目が覚めても、すぐ走れるとは限らない

3. なぜイベント待機が有利なのか

3.1 待ちの終了条件が「時間切れ」ではなく「signal」になる

event wait が有利なのは、待ちの意味が変わるからです。

timer wait は、こうです。

  • まだ何も起きていなくても
  • 一定時間が来たら起きる
  • 起きてから「何か起きたか」を確認する

event wait は、こうです。

  • 何かが起きた側が signal する
  • signal されたら待ちが満たされる
  • 起きた時点で、もう理由がある

図にすると、待ちの終わり方そのものが違うことが見えます。

event wait: 起きたから起こされる起きた側が signal する待つ起床時点で理由が確定している処理するtimer wait: 時間が来たから起きるいいえはいtimer 粒度で起床待つ何か起きていたか?処理する

timer wait 側にだけ、空振りして戻るループがあります。ここが latency と電力の両方で効いてきます。

3.2 何を待ちたいのかで道具を分ける

では、実際にどの道具を選ぶか。まずの判断は、だいたいこの表で足ります。

待ちたいもの よくない例 まずの選択
キューに仕事が入ること Sleep(1)TryPop する event / semaphore
I/O が完了すること timer で状態を見に行く overlapped I/O の event / IOCP
停止要求が来ること 100ms ごとに stop flag を見る stop event / cancellation
同一プロセス内の値変化 while (flag == 0) Sleep(1) WaitOnAddress
時刻が来ること event に無理やり寄せる timer / waitable timer

3.3 event も魔法ではない

event wait は、timer 粒度で起きる必要がないという意味で有利ですが、signal された瞬間に絶対ゼロ遅延で走るわけではありません。

event wait でも、こうした影響は受けます。

  • scheduler latency
  • thread priority
  • CPU の power state
  • lock 競合
  • page fault
  • DPC / ISR

ただし少なくとも、「次の timer tick まで寝ている」という余計な待ち方は外せます

4. 典型的なアンチパターン

4.1 Sleep(1) でキューをポーリングする

いちばんよく見るのはこれです。

for (;;)
{
    if (g_stop)
    {
        break;
    }

    WorkItem item;
    if (TryPop(item))
    {
        Process(item);
        continue;
    }

    Sleep(1);
}

この書き方は、一見単純ですが、問題が 3 つあります。

  1. queue が空でも定期的に起きる
  2. latency が timer 粒度に引っ張られる
  3. power 的にも損

4.2 Thread.Sleep(1) / Task.Delay(1) で状態を監視する

C# / .NET でも同じ匂いは出ます。

while (!stoppingToken.IsCancellationRequested)
{
    if (_queue.TryDequeue(out WorkItem? item))
    {
        await ProcessAsync(item, stoppingToken);
        continue;
    }

    await Task.Delay(1, stoppingToken);
}

見た目は async で穏やかでも、設計の本質は polling です。

5. こう直す

5.1 producer が到着時に signal する

queue 到着待ちなら、polling ではなく producer が signal する形に変えます。

  • producer が queue に item を入れる
  • item を入れた直後に SetEvent する
  • consumer は WaitForSingleObject または WaitForMultipleObjects で待つ
  • 起きたら queue を drain する

5.2 WaitForMultipleObjects で work と stop を同時に待つ

単純な worker なら、この形が分かりやすいです。

HANDLE waits[2] = { _stopEvent, _workEvent };  // index 0 = stop, index 1 = work

for (;;)
{
    // bWaitAll = FALSE なので、戻り値は「最初に signal された handle の index」
    DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);

    // 失敗は WAIT_FAILED ((DWORD)0xFFFFFFFF)。理由は GetLastError でしか分からない
    if (rc == WAIT_FAILED)
    {
        throw std::system_error(
            static_cast<int>(GetLastError()),
            std::system_category(),
            "WaitForMultipleObjects failed.");
    }

    if (rc == WAIT_OBJECT_0)  // stop
    {
        return;
    }

    if (rc == WAIT_OBJECT_0 + 1)  // work
    {
        DrainQueue();
        continue;
    }

    // ここに来るのは INFINITE 待ちでは想定外
    // (WAIT_TIMEOUT や WAIT_ABANDONED_0 系)。握りつぶさず落とす
    throw std::runtime_error("WaitForMultipleObjects returned an unexpected value.");
}

この例のポイントは 3 つです。

  • Sleep(1) が消えている
  • item 到着時に producer が SetEvent している
  • worker は stopwork を同時に待っている

戻り値の扱いだけ、実務で外しやすい点を補足しておきます。

  • bWaitAllFALSE のとき、成功時の戻り値は WAIT_OBJECT_0 から WAIT_OBJECT_0 + nCount - 1 の範囲で、そこから WAIT_OBJECT_0 を引いた値が 配列の index です。== WAIT_OBJECT_0!= WAIT_OBJECT_0 + 1 の 2 分岐だけで書くと、handle を 3 本に増やした瞬間に壊れます
  • 複数が同時に signal された場合は、index の小さいほうが返ります。上の例で stop を index 0 に置いているのは、停止要求を取りこぼさないためです
  • 失敗は例外ではなく WAIT_FAILED(DWORD)0xFFFFFFFF という戻り値で返ります。原因は GetLastError を呼ばないと分かりません。rc != 期待値 をまとめて「失敗」と書いてしまうと、handle が閉じられていた、SYNCHRONIZE 権がない、といった原因が消えます
  • mutex を待ち対象に混ぜるなら WAIT_ABANDONED_0 系も返り得ます。この例では event しか待っていないので想定外として扱っています

5.3 同一プロセスなら WaitOnAddress も候補

同じプロセス内で、単に「ある値が変わるまで待ちたい」だけなら、WaitOnAddress もかなり有力です。 event を作って初期化して、値と同期がずれないように面倒を見る、という手間がなくなります。

使い分けの感覚としては、だいたいこうです。

観点 event / semaphore / waitable object WaitOnAddress
待機対象の範囲 プロセス間も可。名前付きにできる 同一プロセス内のみ
起こす側 SetEvent / ReleaseSemaphore など WakeByAddressSingle / WakeByAddressAll
事前準備 カーネルオブジェクトの生成と handle 管理が要る 待つ変数があればよい
使えるバージョン 古くから利用可能 Windows 8 / Windows Server 2012 以降
リンク Kernel32.lib Synchronization.lib

使うときに外したくない点が 3 つあります。

  1. 必ず WakeByAddressSingleWakeByAddressAll と対で使います。 値を書き換えた側がこれを呼ばないと、待っている thread は起きません。1 本だけ起こすなら Single、全部起こすなら All です
  2. WaitOnAddress は signal されていなくても戻ることがあります。 ドキュメントでも、低メモリ状態などで早く起きる可能性が明記されています。戻ってきたら必ず値をもう一度読んで、本当に変わったかを確かめる while ループで書きます
  3. 待てるサイズは 1 / 2 / 4 / 8 バイトのいずれかです
  4. フラグはアトミックにします。WakeByAddressSingle は待っている thread を起こすだけで、直前の書き込みをアトミックにも可視にもしません。素の変数を両側から読み書きすると C++ ではデータ競合(未定義動作)になり、最適化されたビルドでは更新が見えないまま止まり続けることがあります。書く側は release、読む側は acquire で揃えます
// 待つ側と起こす側は別スレッドなので、フラグは必ずアトミックにする。
// 素の ULONG を両側から読み書きすると C++ ではデータ競合(未定義動作)で、
// 最適化されたビルドでは値がレジスタに載ったまま更新が見えず、
// 起こされても止まり続けることがある
std::atomic<ULONG> g_ready{ 0 };
static_assert(std::atomic<ULONG>::is_always_lock_free,
              "WaitOnAddress に渡すので、ロックフリーである必要がある");

// 「g_ready が 0 でなくなるまで待つ」最小形
ULONG undesired = 0;
ULONG captured = g_ready.load(std::memory_order_acquire);

while (captured == undesired)
{
    // 早期に戻ることがあるので、戻ったら必ず読み直す
    WaitOnAddress(&g_ready, &undesired, sizeof(ULONG), INFINITE);
    captured = g_ready.load(std::memory_order_acquire);
}

書き換える側は、値を更新したうえで起こします。

// release で書く。こう書くと、この行より前に用意したデータ(下の payload)も、
// acquire で読んだ側から必ず見えます。順序を保証するのはこのストアであって、
// WakeByAddressSingle ではありません ── あれは待っている thread を
// 起こすだけで、直前の書き込みをアトミックにも可視にもしません
g_payload = ...;                                  // 一緒に渡したいデータ
g_ready.store(1, std::memory_order_release);
WakeByAddressSingle(&g_ready);

6. それでも timer を使う場面

6.1 時間そのものが条件のとき

もちろん、timer を使う場面はちゃんとあります。

  • 5 秒ごとに metrics を送る
  • 200ms 後に retry する
  • 1 分ごとにキャッシュを掃除する
  • 期限時刻まで待って timeout にする

ここでは待ちたいものが 本当に時間です。

6.2 waitable timer を使う

Windows で「時間そのもの」を待つなら、Sleep を雑に積むより、waitable timer を使ったほうが意味がはっきりします。

6.3 timeBeginPeriod を常用しない

短い timer wait の精度が気になると、つい timeBeginPeriod(1) を足したくなります。 でも、これは常用の第一選択にしないほうがよいです。

理由は 3 つあります。

  1. power / performance のコストがある
  2. 最近の Windows では挙動が少し複雑
  3. 根本原因を直していないことが多い

7. レビュー時のチェックリスト

  • Sleep(1) / Thread.Sleep(1) / Task.Delay(1) で様子見 loop を作っていないか
  • 本当は queue 到着、I/O 完了、停止要求を待っているのに timer poll していないか
  • producer / completion 側から signal できる設計になっているか
  • stopwork を 1 回の wait でまとめて待てないか
  • 同一プロセスの値変化なら WaitOnAddress で書けないか
  • timer を使っている場所で、本当に待ちたいものが「時間」なのか

8. まとめ

Windows で短い timer wait を使って「一定時間ごとに様子を見る」設計は、どうしても timer 粒度と scheduler の影響を受けます。 そのため、Sleep(1) や短い timeout は、見た目ほど正確な待ちではありません。

一方で、仕事の到着、I/O 完了、停止要求、状態変化のように、本当に待ちたいものが「出来事」なら、event wait のほうが自然です。

まとめると、この 1 行に尽きます。

時間を待つなら timer、出来事を待つなら event。

この線引きがはっきりするだけで、

  • latency が読みやすくなる
  • 無駄な periodic wakeup が減る
  • power 的にもましになる
  • コードの意図が分かりやすくなる

という形で効いてきます。

9. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

WindowsのSleep(1)はなぜ1ミリ秒後に正確に起きないのですか?
Windowsのtimed waitのtimeout精度はsystem clock resolutionに依存し、普通の設定では15.6ミリ秒級のplatform timer resolutionが前提になることが多いからです。さらに待ち時間が終わってもthreadはreadyになるだけで、即座にCPUをもらって走れる保証はありません。他のthread、priority、CPUのidle state、DPC/ISR、lock競合などの影響を受けます。つまり短いtimer waitには、timeout判定がtimer粒度に引っ張られる点と、timeout後の実行開始がscheduler次第になる点の、少なくとも2段階の不確実さがあります。
Sleep(1)やTask.Delay(1)でキューをポーリングする設計の何が問題ですか?
問題は3つあります。キューが空でも定期的に起きること、レイテンシがtimer粒度に引っ張られること、電力的にも損なことです。C#のawait Task.Delay(1)ループも見た目は穏やかですが設計の本質はpollingです。直し方は、producerがキューにitemを入れた直後にSetEventし、consumerがWaitForSingleObjectやWaitForMultipleObjectsで待つ形に変えることです。stopイベントとworkイベントを1回のwaitでまとめて待つと、停止要求にもすぐ反応できます。
タイマー待機とイベント待機はどう使い分ければよいですか?
時間を待つならtimer、出来事を待つならevent、というのが線引きです。5秒おきにmetricsを送るような時間そのものが条件の処理はwaitable timerの仕事です。キューへの仕事の到着はeventやsemaphore、I/O完了はoverlapped I/OのeventやIOCP、停止要求はstop eventやcancellation、同一プロセス内の値変化はWaitOnAddressが向いています。この線引きがはっきりすると、レイテンシが読みやすくなり、無駄なperiodic wakeupが減り、コードの意図も分かりやすくなります。
timeBeginPeriod(1)でタイマー精度を上げれば解決しませんか?
常用の第一選択にはしないほうがよいです。理由は3つで、powerとperformanceのコストがあること、最近のWindowsでは挙動が少し複雑になっていること、そして根本原因を直していないことが多いことです。本当に待ちたいものが仕事の到着やI/O完了のような出来事なら、タイマー精度を上げるのではなく、起きた側がsignalするイベント駆動に設計を変えるほうが、遅延にもCPUにも電力にも素直です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る