为什么在 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 至少存在两个层面的不确定性。
- timeout 的判定本身就会受 timer 粒度的牵制
- 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,等待就被满足
- 醒来的那一刻,就已经有明确的理由
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
- 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 个问题。
- 即使 queue 为空,也会定期醒来
- latency 会受到 timer 粒度的牵制
- 在 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 用
WaitForSingleObject或WaitForMultipleObjects等待 - 醒来后就 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 同时等待
stop和work
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 个。
- 存在 power / performance 方面的成本
- 在较新版本的 Windows 上,行为会稍微复杂一些
- 多数情况下并没有解决根本原因
7. 代码审查时的检查清单
- 是否用
Sleep(1)/Thread.Sleep(1)/Task.Delay(1)构建了「观望型」循环 - 是否本来在等待 queue 到达、I/O 完成或停止请求,却用 timer 去 poll
- 是否设计成可以由 producer / completion 一侧发出 signal
- 能否把
stop和work合并到一次 wait 中一起等待 - 如果是同一进程内的值变化,能否改用
WaitOnAddress来实现 - 在使用 timer 的地方,真正想等待的对象是否确实是「时间」
8. 总结
在 Windows 上,使用短时 timer wait 来实现「每隔一定时间看一下」的设计,终究会受到 timer 粒度和 scheduler 的影响。
因此,Sleep(1) 或短时 timeout 并不像表面看起来那样精确。
另一方面,如果真正想等待的对象是任务到达、I/O 完成、停止请求、状态变化这类「事件」,那么 event wait 显然更自然。
归纳成一句话就是:
要等待时间,就用 timer;要等待事件,就用 event。
只要把这条界线划清楚,就能带来以下效果:
- latency 变得更容易预估
- 减少不必要的 periodic wakeup
- 在 power 方面也会有所改善
- 代码的意图更容易理解
9. 参考资料
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- timeBeginPeriod function
- CreateWaitableTimerExW function
- SetWaitableTimer function
- Thread.Sleep Method (.NET)
- Results for the Idle Energy Efficiency Assessment
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在桌面应用中使用 .NET Generic Host 与 BackgroundService 的理由
本文整理在 Windows 工具或常驻应用中,如何使用 Generic Host 与 BackgroundService 来梳理启动、定期处理、退出处理、日志、配置与 DI。
FileSystemWatcher 实务指南:应对遗漏通知与重复通知
本文从遗漏通知、重复通知、完成判定的陷阱、重新扫描、原子式 claim、idempotency(幂等性)等角度,整理 FileSystemWatcher 的使用方法与注意事项。
在普通 Windows 上尽量做到软实时的实践指南
本文整理在普通 Windows 上让周期处理与低延迟处理保持稳定的思路,从定时器精度、线程优先级、电源设置和监测方法等角度展开说明。
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
Windows 事件日志・ETW 入门 ── 让业务应用程序的日志接入操作系统标准机制
Windows 业务应用程序的日志,是不是只靠文件日志来解决?事件日志与 ETW,是运维人员和操作系统标准工具所能看到的另一层记录。本文整理三种手段的分工方式、.NET 中的写入方法、通过 EventSource 进行的 ETW 埋点、收集与排查的实务做法,以及来源未注册、...
常见问题
汇总了咨询这一主题时常见的问题。
- 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。