最近在UE4里面遇到一个需求:希望一个线程每隔1ms,甚至500微秒唤醒一次,同时又不能靠死循环把一个CPU核心跑满。一开始觉得这件事应该很简单,调用一下
Sleep(1)不就行了吗?实际测下来才发现,Windows里面的“睡1ms”和“1ms后准时醒来”完全是两回事,故写一篇文章记录一下整个折腾过程。
- 先说需求
Sleep(1)为什么不够用- 高精度可等待定时器
- 周期模式和每次重新设置
- 为什么要故意少等一点
- 提高线程被调度的机会
- 500微秒测试中的反直觉现象
- 换一台机器,结果会差多少
- 几毫秒的长尾从哪里来
- 最后整理出的代码
- 使用时还要注意
- 总结
- 参考资料
先说需求
我需要的不是动画帧率,也不是普通的定时回调,而是一个间隔很短的后台线程:
| 项目 | 要求 |
|---|---|
| 目标间隔 | 1ms或500微秒 |
| CPU占用 | 尽量低,不能一直空转 |
| 唤醒误差 | 可以稍微提前,尽量不要明显晚到 |
| 时间含义 | 每次任务完成后,再等待下一段时间 |
最后一条比较重要。这里要的是“做完一次任务,再等1ms”,而不是严格追赶一条固定的时间线。
为了方便比较,我把超过目标时间1.3倍的样本记作一次“晚到”。例如目标是1ms,那么实际间隔超过1.3ms就算晚到;目标是500微秒,那么超过650微秒就算晚到。
除了平均值,我还记录了下面两个数字:
p99.9:1000次唤醒中,大约有999次不会超过这个时间max:整轮测试中最慢的一次
平均值看起来再漂亮,只要偶尔冒出一次十几毫秒的停顿,对这种短周期任务来说仍然可能无法接受。
Sleep(1)为什么不够用
我们先看最直接的写法:
while (running) {
Sleep(1);
DoWork();
}Sleep(1)的意思并不是“精确暂停1ms”,而是告诉系统:这个线程至少1ms内不用再调度,时间到了之后可以重新参与抢占CPU。
注意这里是“可以重新参与”,不是“立即执行”。如果此时系统正在处理驱动、中断或者其他更高优先级的工作,我们的线程还要继续等。
实际结果也很直观:
| 方法 | 平均间隔 | 最慢一次 | 现象 |
|---|---|---|---|
Sleep(1) | 15.51ms | 18.21ms | 基本退化到了约15.6ms |
Sleep(1)配合timeBeginPeriod(1) | 3.04ms | 16.77ms | 大部分接近1ms,但偶尔会出现十几毫秒的异常慢值 |
从Windows 10版本2004开始,timeBeginPeriod不再替所有进程修改全局计时器分辨率,别的程序请求了1ms并不代表我们的进程也能直接受益。即使自己调用,它也只能改善部分计时行为,不能保证线程准时得到CPU。
因此这里需要换一种工具。
高精度可等待定时器
Windows提供了一种叫做“可等待定时器”的内核对象。它和事件、互斥量有点像:线程可以在它上面等待,定时器到期后,等待中的线程被唤醒。
创建高精度版本的代码如下:
HANDLE timer = CreateWaitableTimerExW(
nullptr,
nullptr,
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
TIMER_ALL_ACCESS
);CREATE_WAITABLE_TIMER_HIGH_RESOLUTION表示希望使用高精度路径,该标志从Windows 10版本1803开始支持。
接下来通过SetWaitableTimer设置多久后触发:
LARGE_INTEGER due;
due.QuadPart = -10000;
SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE);
WaitForSingleObject(timer, INFINITE);这里的单位是100纳秒,负数表示“从现在开始算的相对时间”。所以-10000就是1ms后触发。
容易误解的一点是:100纳秒只是参数的表示单位,不代表线程真的能精确到100纳秒醒来。定时器到期后,线程仍然要经过Windows调度器才能运行。
周期模式和每次重新设置
SetWaitableTimer本身支持周期模式,只需要把第三个参数设成周期毫秒数。但测试后我最后选择了每次醒来重新设置:
SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE);
while (running) {
WaitForSingleObject(timer, INFINITE);
DoWork();
// 本次工作做完,再开始计算下一段等待时间
SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE);
}这样正好对应最开始的需求:“做完一次,再等下一段时间”。同时每一轮都可以重新调整等待参数,方便针对实际机器做校准。
为什么要故意少等一点
如果目标是1ms,最直觉的参数当然是-10000。但定时器到期之后,线程还要经过一次调度,因此实际间隔往往比1ms更长。
于是我尝试让定时器提前一点到期,例如只设置700微秒:
due.QuadPart = -7000; // 700微秒后到期这不是要把任务周期改成700微秒,而是给后面的线程调度预留约300微秒。最终测到的实际间隔仍然接近1ms。
原测试机上1ms目标的结果如下:
| 方案 | 平均间隔 | p99.9 | 最慢一次 | 晚到比例 |
|---|---|---|---|---|
| 定时器直接设为1ms | 1.26ms | 2.11ms | 6.23ms | 44% |
| 每次重设,提前300微秒 | 1.01ms | 1.55ms | 5.15ms | 2.7% |
可以看到,提前量主要改善的是整体分布,最慢的一次仍然可能达到几毫秒。Windows并不是实时操作系统,这个长尾无法仅靠换一个参数彻底消除。
提高线程被调度的机会
定时器负责告诉系统“时间到了”,但什么时候真正轮到线程运行,仍然由调度器决定。
Windows为音频、视频等对延迟敏感的任务提供了一套“多媒体类调度服务”。我们可以把定时器线程登记成Pro Audio任务,并申请较高优先级:
DWORD taskIndex = 0;
HANDLE avrt = AvSetMmThreadCharacteristicsW(L"Pro Audio", &taskIndex);
if (avrt) {
AvSetMmThreadPriority(avrt, AVRT_PRIORITY_CRITICAL);
}它的作用是让这个线程在系统繁忙时更容易获得CPU,但依然不能保证任何情况下都在固定时间上限内醒来。线程退出前还要恢复登记状态:
if (avrt) {
AvRevertMmThreadCharacteristics(avrt);
}测试中,“高精度可等待定时器 + 每次重设 + 多媒体调度”是整体表现最好的一组。
500微秒测试中的反直觉现象
目标缩短到500微秒后,出现了一个很奇怪的结果。
按照前面的思路,500微秒对应-5000;如果仍然提前300微秒,就应该写成-2000。但在两台测试机上,-10反而得到了更好的分布。
-10代表请求1微秒后到期,理论上几乎是立即触发。然而实际线程并没有形成空转,而是大约每0.52ms醒来一次:
| 设置 | 平均间隔 | p99.9 | 最慢一次 | 晚到比例 |
|---|---|---|---|---|
| 请求500微秒 | 0.947ms | 1.560ms | 4.921ms | 80.8% |
| 请求200微秒 | 0.525ms | 1.007ms | 5.015ms | 2.0% |
| 请求1微秒 | 0.522ms | 0.788ms | 4.822ms | 1.34% |
这个结果只能说明:在当时两台机器、当时系统负载下,请求立即到期后,Windows实际形成了接近500微秒的调度间隔。
它并不意味着-10是什么通用的“500微秒参数”。Windows版本、电源计划、CPU和后台负载改变后,结果都可能变化。如果业务真的要求严格的500微秒语义,不能直接照抄这个值,必须在目标机器上重新测试。
换一台机器,结果会差多少
第二台机器的结果比原测试机好得多:
| 目标 | 参数 | 原机器最慢一次 / 晚到比例 | 第二台机器最慢一次 / 晚到比例 |
|---|---|---|---|
| 1ms | 提前500微秒 | 4.90ms / 3.4% | 1.51ms / 0.013% |
| 500微秒 | 请求1微秒 | 4.82ms / 1.34% | 1.001ms / 0.10% |
原机器上多个方案的最大值都在5ms左右,一度很容易让人误以为这是Windows用户态定时器的固定下限。但第二台机器所有已测方案都低于2ms,直接推翻了这个猜测。
所以这里最重要的结论并不是某个具体参数,而是:短周期定时器必须在实际部署机器和实际负载下校准,不能拿一台电脑的测试结果当成系统保证。
几毫秒的长尾从哪里来
可等待定时器只能决定“什么时候到期”,不能保证“线程什么时候开始执行”。中间还可能受到下面这些事情影响:
- 显卡、网卡、USB和音频驱动正在处理比普通线程优先级更高的工作
- CPU核心进入了较深的省电状态,重新唤醒需要时间
- 线程被迁移到另一个核心后,缓存需要重新建立
- 系统中还有其他高优先级线程正在运行
想知道某一次5ms停顿究竟由谁造成,需要用Windows事件跟踪工具把“定时器到期”和“线程真正运行”放到同一条时间线上分析。仅凭用户态测到的一个最大值,不能直接断定是哪一个驱动或硬件机制导致的。
最后整理出的代码
下面是最小化后的核心结构:
HANDLE timer = CreateWaitableTimerExW(
nullptr,
nullptr,
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
TIMER_ALL_ACCESS
);
if (!timer) {
// 创建失败时应根据目标系统选择退化方案
return;
}
DWORD taskIndex = 0;
HANDLE avrt = AvSetMmThreadCharacteristicsW(L"Pro Audio", &taskIndex);
if (avrt) {
AvSetMmThreadPriority(avrt, AVRT_PRIORITY_CRITICAL);
}
LARGE_INTEGER due;
due.QuadPart = -7000; // 这是原测试机的1ms校准值,不是通用默认值
SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE);
while (running) {
WaitForSingleObject(timer, INFINITE);
// 定时器线程不要做耗时工作,最好只把事件放入队列
PushEventToQueue();
SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE);
}
if (avrt) {
AvRevertMmThreadCharacteristics(avrt);
}
CloseHandle(timer);集成到UE4时,我通过引擎的FRunnable线程接口创建了一个单独线程。定时器线程醒来后只记录时间并把事件放入队列,真正的游戏逻辑仍然回到游戏线程处理,避免在高优先级线程里执行复杂任务。
使用时还要注意
- 高精度标志需要Windows 10版本1803或更新版本,创建失败时要准备退化方案。
timeBeginPeriod(1)和timeEndPeriod(1)应该成对调用。- Windows 11中,窗口完全被遮挡或最小化后,系统不保证继续提供较高的普通计时器分辨率,退化路径需要单独测试。
- 提高线程优先级不等于获得实时保证,不要在高优先级线程里做文件读写、复杂计算或长时间持锁。
- 如果业务需要绝对的最坏时间上界,普通Windows用户态线程不是合适的基础。
总结
Sleep(1)表示至少等待1ms,并不保证1ms后立即执行。- 高精度可等待定时器更适合短间隔阻塞等待,但它仍然要经过线程调度。
- 每次唤醒后重新设置定时器,更符合“做完一次,再等待下一段时间”的需求。
- 可以通过故意少等一小段时间,补偿定时器到期后的调度延迟。
- Windows多媒体类调度服务能够改善线程获得CPU的机会,但不能提供严格的最坏时间上限。
- 最优提前量与机器和负载关系很大,上线前必须重新测量。
