Windows Sleep机制分析
Sleep()在默认情况下的分辨率为15.625ms,在系统低负载的情况下也是如此。本文希望能回答以下几个问题;
Sleep()在OS层面的工作原理- 调整
Sleep()或其他时间相关API精度的办法,及它们的工作原理
用户态:timeBeginPeriod()
搜一下Sleep()精度相关的问题,不难找到timeBeginPeriod()这个API。根据官方文档,这个API有一个很奇怪的行为:
Starting with Windows 10, version 2004, this function no longer affects global timer resolution. For processes which call this function, Windows uses the lowest value (that is, highest resolution) requested by any process. For processes which have not called this function, Windows does not guarantee a higher resolution than the default system resolution.
换句话说,timeBeginPeriod只会影响调用进程的时钟精度,不会影响其他进程。这与我们对CPU的认知不太一致:时钟中断可不会区分进程。
简单做个实验:写两个测试程序,一个调用timeBeginPeriod()设置精度1ms,另一个什么也不做,然后两个进程都循环Sleep(1)。为了排除多核CPU时钟中断频率不一致的问题,将两个进程都绑到同一个CPU上。类似下面的代码:
|
|
在干净的Windows 10 22H2上测试,结果令人惊讶:调用了timeBeginPeriod的进程以1ms的精度Sleep(),而没调用的精度仍然是15.625ms。且此时使用Clockres检查系统时钟精度,会发现(全局的)精度已经被调整为1ms:

既然有一个进程已经以1ms的进度进行Sleep(),说明OS的时钟中断精度确实也达到了1ms,否则OS没法及时唤醒这个进程。但是,另一个没调用timeBeginPeriod()的进程行为就比较奇怪了:既然时钟中断已经按1ms触发,为何这个进程没有被及时唤醒?
整理一下实验现象,可以得到几个想法:
- 调度进(线)程时,OS根据该进程的设置,调整该CPU的中断频率。仔细想一想就会发现不现实:执行时钟精度低的线程时,时钟精度高的进程处于
wait状态。此时,低精度的中断势必导致它wait时间过长 - CPU的时钟中断频率确实提高了,但每次中断时,OS不检查那些没调用
timeBeginPeriod的进程。这确实与观察到的现象符合。
第二个猜想确实与观测更符合。在验证猜想前,还需要检查一下驱动层是否可以直接调整时钟分辨率,从而避免分析复杂的进程、线程;以及Sleep()、timeBeginPeriod()的实现原理。
驱动层对时钟分辨率的调整:ExSetTimerResolution()
简单搜索一下,就可以找到驱动调整系统时钟分辨率的函数:ExSetTimerResolution。写一个驱动验证它的效果:

ExSetTimerResolution()确实能使不调用timeBeginPeriod的进程也享受到更高精度的时钟。尝试解释这个现象,看来需要对驱动层、用户层调整时钟分辨率的方法实现进行分析。
Sleep()的实现
跟一下可以发现,Sleep()用户态调用栈大致是kernelbase!Sleep -> kernelbase!SleepEx -> ntdll!NtDelayExecution。

在ntoskrnl里面,NtDelayExecution做些简单的参数转换和检查后,调用nt!KeDelayExecutionThread并返回。

然而nt!KeDelayExecutionThread的实现却比较复杂。大体上看,它让调用线程进入wait状态,wait对象是位于线程控制块tcb中的一个_KTIMER。而这个过程大致分2步:
- 调用
KiBeginThreadWait,根据wait信息更新Prcb - 调用
KiCommitThreadWait完成等待

KiCommitThreadWait完成了等待的大部分工作。根据调用流程和函数名,可以猜测它首先计算等待时间(KiComputeDueTime),然后根据等待时间,更新Tcb中的Timer,将这个Timer插入内核的待更新时钟列表中(KiInsertTimerTable),最后切换线程(KiSwapThread)。
一个很重要的结论是:Sleep()使用了内核的_KTimer对象。也就是说,Sleep()和开一个定时器然后WaitForSingleObject()是一回事。调整时钟分辨率会同时影响两种方法。后面可以验证这一点。以下是KiCommitThreadWait相关部分:

timeBeginPeriod()的实现
用户态:kernel32!timeBeginPeriod.png -> ntdll!NtSetTimerResolution。

在删去tracing等相关代码后,nt!NtSetTimerResolution大致只做了三件事:
- 根据系统时钟分辨率,调整更新分辨率参数
- 把本次请求的进程插入分辨率调整链表中,以备后续释放(
ExpInsertTimerResolutionEntry。进程退出时,即使没把分辨率调回去,OS也会做这件事) - 更新系统时钟中断间隔(
ExpUpdateTimerResolution())

ExpInsertTimerResolutionEntry插入的链表名为ExpTimerResolutionListHead。Windows Internals 7th, Part 2 中对此的说明,也可以交叉验证这一点:

nt!ExSetTimerResolution()的实现
nt!ExSetTimerResolution与nt!NtSetTimerResolution几乎相同,只有以下几点区别:
nt!ExSetTimerResolution设置了nt!KeNonHrTimeIncrement,而nt!NtSetTimerResolution没有nt!NtSetTimerResolution调用ExpInsertTimerResolutionEntry,将调用方插入了这个链表- 两者都更新了一些计数器。在没人需要高精度中断时,OS将恢复时钟中断间隔。对本文分析不太重要
下面是ExSetTimerResolution的相关部分

KeNonHrTimeIncrement中的Hr可能是High Resolution的意思。检查一下它的引用,发现在时钟中断处理、定时器Expire检查等功能中都存在:

据此,可以提出解释timeBeginPeriod问题的猜想:
timeBeginPeriod()(或NtSetTimerResolution())确实调整了OS全局的时钟中断间隔NtSetTimerResolution()通过调用nt!ExpInsertTimerResolutionEntry(),把自身插入ExpTimerResolutionListHead,这个链表保存了调整了定时器精度的进程- 时钟中断到来,OS检查
ExpTimerResolutionListHead中进程等待的定时器,忽略其他定时器 - 只有时间间隔超过
nt!KeNonHrTimeIncrement,OS在时钟中断才会检查其他的常规定时器 nt!ExSetTimerResolution()会修改KeNonHrTimeIncrement,而nt!NtSetTimerResolution()不会
下面通过实验验证一下这个猜想。
验证nt!KeNonHrTimeIncrement的功能
用Windbg连接一台虚拟机,通过x nt!KeNonHrTimeIncrement找到nt!KeNonHrTimeIncrement的地址,然后在内存窗口中修改。避免了手写驱动的麻烦(而且nt!KeNonHrTimeIncrement这个符号怕是压根就没导出)。
为方便观测,每次打印前额外加入了Sleep(1000),即间隔1s打印一次:

(似乎开启内核调试后,相关驱动会把时钟分辨率提升至2ms,而且是ExSetTimerResolution全局的)
(而且,修改KeNonHrTimeIncrement后,连系统响应速度也受了影响,估计在Windows看来,这些也不是高精度时钟要考虑的问题吧)
结论
timeBeginPeriod()仍然会影响OS全局时钟中断- 将需要高精度时钟的进程保存在
ExpTimerResolutionListHead中,减少了OS时钟中断的工作量(不需要每次都检查所有的等待时钟了) - OS在时钟中断里检查所有高精度进程的时钟,在经过
nt!KeNonHrTimeIncrement时间后检查所有时钟