为什么在 Windows 上应优先选择事件等待而非 Sleep(1)

· · Windows开发, 同步, 事件, 定时器, 设计

在上一篇Windows 软实时实践指南中,我写了关于避免依赖 Sleep 的周期性循环的内容。 这次,我将只聚焦一点来整理:为什么应该优先选择 event wait,而不是短时 timer wait

在 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 不容易受到这种限制
  • 在什么场景下应该选择 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、lock 争用等因素的影响。

也就是说,短时 timer wait 至少存在两个层面的不确定性。

  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,等待就被满足
  • 醒来的那一刻,就已经有明确的理由
时间到达任务到达值发生变化I/O 完成停止请求正在等待的 thread真正等待的是什么?timer / waitable timerevent / semaphore / condition variableWaitOnAddresscompletion / eventstop 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
  • 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

如果是在等待队列到达,就应该改为由 producer 来 signal,而不是 polling。

  • producer 把 item 放入 queue
  • 放入 item 之后立刻 SetEvent
  • consumer 用 WaitForSingleObjectWaitForMultipleObjects 等待
  • 醒来后就 drain queue

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) 已经消失
  • producer 在 item 到达时会 SetEvent
  • worker 同时等待 stopwork

5.3 同一进程内可以考虑 WaitOnAddress

如果只是在同一个进程内「等待某个值发生变化」,WaitOnAddress 也是相当有力的选择。

大致的选用原则是这样:

  • 跨进程或一般性的等待对象 -> event / semaphore / waitable object
  • 同一进程内轻量级的值变化 -> WaitOnAddress

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) 构建了「观望型」循环
  • 是否本来在等待 queue 到达、I/O 完成或停止请求,却用 timer 去 poll
  • 是否设计成可以由 producer / completion 一侧发出 signal
  • 能否把 stopwork 合并到一次 wait 中一起等待
  • 如果是同一进程内的值变化,能否改用 WaitOnAddress 来实现
  • 在使用 timer 的地方,真正想等待的对象是否确实是「时间」

8. 总结

在 Windows 上,使用短时 timer wait 来实现「每隔一定时间看一下」的设计,终究会受到 timer 粒度和 scheduler 的影响。 因此,Sleep(1) 或短时 timeout 并不像表面看起来那样精确。

另一方面,如果真正想等待的对象是任务到达、I/O 完成、停止请求、状态变化这类「事件」,那么 event wait 显然更自然。

归纳成一句话就是:

要等待时间,就用 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。
用 Sleep(1) 或 Task.Delay(1) 轮询队列的设计有什么问题?
问题有 3 个:即使队列为空也会定期醒来、延迟会受 timer 粒度牵制、在功耗上也是一种损耗。C# 中 await Task.Delay(1) 的循环外表看起来很温和,但设计的本质仍然是 polling。修正方法是改为由 producer 在把 item 放入队列之后立刻 SetEvent,consumer 则用 WaitForSingleObject 或 WaitForMultipleObjects 等待。把 stop 事件和 work 事件合并到一次 wait 中一起等待,还能让程序对停止请求立刻做出反应。
计时器等待和事件等待应该如何区分使用?
划分原则是:要等待时间就用 timer,要等待事件就用 event。像每 5 秒发送一次 metrics 这种以时间本身为条件的处理,属于 waitable timer 的工作。队列中任务的到达适合用 event 或 semaphore,I/O 完成适合用 overlapped I/O 的 event 或 IOCP,停止请求适合用 stop 事件或 cancellation,同一进程内的值变化则适合用 WaitOnAddress。把这条界线划清楚之后,延迟会变得更容易预估,不必要的 periodic wakeup 也会减少,代码的意图也会更清晰。
用 timeBeginPeriod(1) 提升计时器精度不就能解决问题了吗?
最好不要把它当作常规的首选方案。理由有 3 个:存在 power 和 performance 方面的成本;在较新版本的 Windows 上行为会稍微复杂一些;而且多数情况下并没有解决根本原因。如果真正想等待的是任务到达或 I/O 完成这类事件,与提升计时器精度相比,改为由触发方 signal 的事件驱动设计,对延迟、CPU 和功耗都会更加友好。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表