LOADING

加载过慢请开启缓存 浏览器默认开启

Windows高精度定时器折腾记录

2026/8/27 C++ Cpp UE Windows 性能优化
本文总阅读量

最近在UE4里面遇到一个需求:希望一个线程每隔1ms,甚至500微秒唤醒一次,同时又不能靠死循环把一个CPU核心跑满。一开始觉得这件事应该很简单,调用一下Sleep(1)不就行了吗?实际测下来才发现,Windows里面的“睡1ms”和“1ms后准时醒来”完全是两回事,故写一篇文章记录一下整个折腾过程。

先说需求

我需要的不是动画帧率,也不是普通的定时回调,而是一个间隔很短的后台线程:

项目要求
目标间隔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.51ms18.21ms基本退化到了约15.6ms
Sleep(1)配合timeBeginPeriod(1)3.04ms16.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最慢一次晚到比例
定时器直接设为1ms1.26ms2.11ms6.23ms44%
每次重设,提前300微秒1.01ms1.55ms5.15ms2.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.947ms1.560ms4.921ms80.8%
请求200微秒0.525ms1.007ms5.015ms2.0%
请求1微秒0.522ms0.788ms4.822ms1.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线程接口创建了一个单独线程。定时器线程醒来后只记录时间并把事件放入队列,真正的游戏逻辑仍然回到游戏线程处理,避免在高优先级线程里执行复杂任务。

使用时还要注意

  1. 高精度标志需要Windows 10版本1803或更新版本,创建失败时要准备退化方案。
  2. timeBeginPeriod(1)timeEndPeriod(1)应该成对调用。
  3. Windows 11中,窗口完全被遮挡或最小化后,系统不保证继续提供较高的普通计时器分辨率,退化路径需要单独测试。
  4. 提高线程优先级不等于获得实时保证,不要在高优先级线程里做文件读写、复杂计算或长时间持锁。
  5. 如果业务需要绝对的最坏时间上界,普通Windows用户态线程不是合适的基础。

总结

  1. Sleep(1)表示至少等待1ms,并不保证1ms后立即执行。
  2. 高精度可等待定时器更适合短间隔阻塞等待,但它仍然要经过线程调度。
  3. 每次唤醒后重新设置定时器,更符合“做完一次,再等待下一段时间”的需求。
  4. 可以通过故意少等一小段时间,补偿定时器到期后的调度延迟。
  5. Windows多媒体类调度服务能够改善线程获得CPU的机会,但不能提供严格的最坏时间上限。
  6. 最优提前量与机器和负载关系很大,上线前必须重新测量。

参考资料

本站不开放评论区,如有讨论内容请移步至本站Github讨论区