Windows 上為什麼應先用事件等待而不是計時器等待 - 避免以約 15.6ms 粒度做輪詢

· · Windows 開發, 同步, 事件, 計時器, 設計

上次在Windows 軟即時實務指南已經提過要避免依賴 Sleep 的週期迴圈。
這一次把焦點收束到一個重點:為什麼短的 timer wait 不如 event wait

在 Windows 上,若用 Sleep(1) 或帶短 timeout 的 wait 去做「每隔一段時間看看」,終究會被 system clock 的粒度之後的排程延遲 影響。
一般設定下 platform timer resolution 往往落在 15.6ms 級,所以即使你心裡想「1ms 後再看一次」,實際上的等待相當粗糙。

反過來,若真正要等的是工作抵達、I/O 完成、停止請求、狀態變化——這些是「事件」而不是「時間」——就沒必要固定間隔去輪詢。
由事件發生方做 signal,等待方去等 event,對延遲、對 CPU、對耗電都更順。

本文以下列問題來展開。

  • 為什麼 Sleep(1) 或短的 timer wait 不如你想像的那麼準
  • 為什麼 event wait 比較不受這些限制
  • 什麼場景下該選 event 而不是 timer
  • 即便如此,什麼時候仍該用 timer

1. 先下結論

  • 要等工作抵達或 I/O 完成時,應該等 event,不要等 timer
  • 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 的工作

2. 哪裡有問題

2.1 timed wait 受 system clock 粒度限制

Windows 的 wait functions 其 timeout 的精度取決於 system clock resolution。
Sleep 也一樣,指定的毫秒數並不會剛剛好以「你指定的長度」保證。

要記住的是:你給 1ms,不代表 1ms 後就會醒

2.2 到了時間,也不保證立刻跑

更麻煩的是,timeout 到了之後,thread 並不會立刻被執行。

就像 Sleep 的文件所說,等待結束後 thread 會變成 ready,但 能不能馬上拿到 CPU 去跑,沒保證
還會被其他 thread、priority、CPU 的 idle state、DPC / ISR、鎖競爭等影響。

也就是說,短的 timer wait 至少有兩層不確定性:

  1. timeout 的判定本身就被 timer 粒度拉走
  2. timeout 之後能不能馬上跑,要看 scheduler 臉色

2.3 Sleep(1) 不等於「1ms 週期」

看到 Sleep(1) 直覺會想「這是每 1ms 跑一次的迴圈」。
但這樣讀是不對的。

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

這個迴圈的實際狀況是:

  • 每次都要加上 Step() 的執行時間
  • Sleep(1) 的等待本身被粒度拉走
  • 即使醒來也不一定能馬上跑

3. 為什麼 event 等待比較有利

3.1 等待結束條件從「時間到」改成「signal」

event wait 的優勢在於,等待的意義本身變了。

timer wait 的邏輯是:

  • 就算什麼都沒發生
  • 時間到就醒來
  • 醒來後再看「有沒有發生什麼」

event wait 的邏輯是:

  • 事情發生的一方去 signal
  • 有 signal 才算等到
  • 醒來的瞬間就已經有原因
flowchart LR
    start["正在等待的 thread"] --> q{"真正在等的是什麼?"}
    q -- "時間到" --> timer["timer / waitable timer"]
    q -- "工作抵達" --> event["event / semaphore / condition variable"]
    q -- "值的變化" --> addr["WaitOnAddress"]
    q -- "I/O 完成" --> io["completion / event"]
    q -- "停止請求" --> stop["stop event / cancellation"]

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
  • 鎖競爭
  • 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. 佇列空也會定期醒來
  2. latency 被 timer 粒度拉走
  3. 電力面上也是損

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

若是等佇列抵達,改成 由 producer signal,而不是 polling。

  • producer 把 item 放進佇列
  • 放入後立刻 SetEvent
  • consumer 以 WaitForSingleObjectWaitForMultipleObjects
  • 醒來後把 queue drain 掉

5.2 用 WaitForMultipleObjects 同時等 work 與 stop

簡單的 worker,用下列形狀最好理解。

HANDLE waits[2] = { _stopEvent, _workEvent };

for (;;)
{
    DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);

    if (rc == WAIT_OBJECT_0)
    {
        return;
    }

    if (rc != WAIT_OBJECT_0 + 1)
    {
        throw std::runtime_error("WaitForMultipleObjects failed.");
    }

    DrainQueue();
}

這個例子有 3 個重點:

  • Sleep(1) 消失了
  • item 抵達時由 producer SetEvent
  • worker 同時等 stopwork

5.3 同一行程內,WaitOnAddress 也可以考慮

如果只是在同一行程內「等某個值變化」,WaitOnAddress 也是相當有力的選擇。

大致的分界感是:

  • 跨行程或一般的等待對象 -> event / semaphore / waitable object
  • 同一行程的輕量值變化 -> WaitOnAddress

6. 仍然適合用 timer 的場景

6.1 條件真的是「時間」本身

當然 timer 還是有它的場景:

  • 每 5 秒送 metrics
  • 200ms 後 retry
  • 每 1 分鐘清一次 cache
  • 等到期限時間就 timeout

這時要等的 真的是時間

6.2 用 waitable timer

在 Windows 上要等「時間」,比起亂疊 Sleep,使用 waitable timer 語意更清楚。

6.3 別把 timeBeginPeriod 當常用手段

當短 timer wait 的精度令人焦慮時,會想加一句 timeBeginPeriod(1)
但不要把它當首選。

原因有 3 個:

  1. 會有 power / performance 的代價
  2. 近期的 Windows 行為比以前複雜一些
  3. 多半根本原因沒修

7. 審查時的 checklist

  • 是否還在用 Sleep(1) / Thread.Sleep(1) / Task.Delay(1) 寫輪詢迴圈
  • 其實是在等佇列抵達、I/O 完成、停止請求,卻用 timer poll
  • 是否設計成由 producer / completion 側做 signal
  • 能不能用一次 wait 同時等 stopwork
  • 同一行程內的值變化能不能改 WaitOnAddress
  • 用 timer 的地方,真正要等的是不是「時間」

8. 總結

在 Windows 上,用短 timer wait 做「每隔一段時間看看」的設計,一定會被 timer 粒度與 scheduler 拉走。
因此 Sleep(1) 或短 timeout 看似精準,實際上並不是。

反過來,若真正要等的是工作抵達、I/O 完成、停止請求、狀態變化這類「事件」,event wait 就自然得多。

簡短歸納一句:

等時間用 timer,等事件用 event

只要這條界線清楚:

  • latency 變得好讀
  • 減少多餘的週期性 wakeup
  • 電力面也會好一些
  • 程式碼的意圖更清楚

效果就會浮現出來。

9. 參考資料

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

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

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

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽