Windows计时器分辨率:重大规则更改

2021-01-31 02:14:07

Windows调度程序的行为在Windows 10 2004(又名2020年4月版Windows)中发生了重大变化,这种方式将破坏一些应用程序,并且似乎没有公告,并且文档尚未更新。这不是第一次发生这种情况,但是这种变化似乎比上一次更大。到目前为止,由于这种无声的更改,我发现了三个遇到问题的程序。

简短的版本是,从一个进程调用timeBeginPeriod现在对其他进程的影响要比以前小。仍然会产生影响,并且Sleep和其他函数的线程延迟可能不像以前那样一致(请参阅下面的[updated]部分),但通常,进程不再受调用timeBeginPeriod的其他进程的影响。

我认为新行为是一种改进,但是很奇怪,值得记录。合理的警告–我所拥有的只是我进行的实验的结果,因此我只能推测这种变化的怪癖和目标。如果我的结论有误,请告诉我,我将对此进行更新。

首先,介绍一些操作系统设计上下文。希望程序能够进入睡眠状态,然后稍后再唤醒。实际上,这不应该经常执行-线程通常应该等待事件而不是计时器-但有时这是必要的。因此,我们有了Windows睡眠功能–将其传递给您所需的午睡时间(以毫秒为单位),然后将您唤醒,如下所示:

值得暂停一下,以考虑一下它是如何实现的。理想情况下,当调用Sleep(1)时,CPU会进入睡眠状态,以节省电量,因此,如果CPU处于睡眠状态,操作系统(OS)如何唤醒您的线程?答案是硬件中断。操作系统对计时器芯片进行编程,然后该计时器芯片触发中断以唤醒CPU,然后操作系统可以调度线程。

WaitForSingleObject和WaitForMultipleObjects函数也具有超时值,并且这些超时是使用相同的机制实现的。

如果有许多线程都在等待计时器,则操作系统可以为每个线程的唤醒时间对计时器芯片进行编程,但这往往会导致线程在随机时间唤醒,并且CPU永远不会出现长时间的小睡。 CPU的电源效率与CPU可以保持睡眠状态的时间紧密相关(8+ ms显然是一个不错的数字),随机唤醒可以解决这一问题。如果多个线程可以同步或合并它们的计时器等待,则系统将变得更加省电。

有很多方法可以合并唤醒,但是Windows使用的主要机制是使全局计时器中断以稳定的速度跳动。当线程调用Sleep(n)时,操作系统将安排线程在经过该时间后第一个计时器中断触发时运行。这意味着线程可能最终会唤醒得有点晚,但是Windows不是实时操作系统,它实际上不能保证特定的唤醒时间(无论如何那时都可能没有CPU内核),因此唤醒晚一点应该没问题。

计时器中断之间的间隔取决于Windows版本和您的硬件,但在我最近使用的每台计算机上,默认间隔为15.625毫秒(1,000毫秒除以64)。这意味着,如果您在某个随机时间调用Sleep(1),那么将来每当下一个中断触发时(或者如果下一个中断过早,则在此之后触发),您可能会在1.0毫秒至16.625毫秒之间的某个时间被唤醒。 。

简而言之,计时器延迟的本质是(除非使用繁忙的等待,请不要繁忙的等待)操作系统只能通过使用计时器中断在特定的时间唤醒线程,而定期的计时器中断就是Windows使用。

一些程序(WPF,SQL Server,Quartz,PowerDirector,Chrome,Go Runtime,许多游戏等)发现等待延迟中的这种巨大差异很难处理,但是幸运的是,有一个函数可以让他们控制此延迟。 timeBeginPeriod允许程序传入请求的计时器中断间隔,从而请求较小的计时器中断间隔。还有NtSetTimerResolution,它可以以毫秒级的精度来设置时间间隔,但这很少使用,也不需要,因此我不再赘述。

这很疯狂:任何程序都可以调用timeBeginPeriod,它会更改计时器中断间隔,并且计时器中断是全局资源。

假设进程A坐在一个调用Sleep(1)的循环中。它不应该这样做,但默认情况下,它每15.625毫秒或每秒64次唤醒。然后进程B出现并调用timeBeginPeriod(2)。这使计时器中断更频繁地触发,并且进程A突然每秒唤醒500次,而不是每秒64次。太疯狂了!但这就是Windows始终有效的方式。

在这一点上,如果进程C出现并调用timeBeginPeriod(4),则不会有任何变化–进程A将继续每秒唤醒500次。这不是最后确定规则的方法,而是最低要求确定规则的方法。

更具体地说,无论是什么仍在运行的程序在未完成的timeBeginPeriod调用中都指定了最小的计时器中断持续时间,可以设置全局计时器中断间隔。如果该程序退出或调用timeEndPeriod,则新的最小值将接管。如果单个程序名为timeBeginPeriod(1),则这是整个系统的计时器中断间隔。如果一个程序调用了timeBeginPeriod(1),然后另一个程序调用了timeBeginPeriod(4),则一个毫秒的计时器中断间隔将成为定律。

这很重要,因为较高的计时器中断频率(以及相关的线程调度频率)会浪费大量功率,如此处所述。

在实现Web浏览器时,需要基于计时器的计划的一种情况。 JavaScript标准具有一个名为setTimeout的函数,该函数要求浏览器在数毫秒后调用JavaScript函数。 Chromium使用计时器(大多数是带有超时而不是睡眠的WaitForSingleObject)来实现此功能和其他功能。这通常需要提高计时器中断频率。为了减少对电池寿命的影响,最近对此Chromium进行了修改,以便在使用电池供电时不会将计时器中断频率提高到125 Hz(间隔为8毫秒)以上。

timeGetTime(不要与GetTickCount混淆)是一个返回当前时间的函数,该时间由计时器中断更新。历史上,CPU一直不擅长保持准确的时间(由于其他原因,它们的时钟有意波动以避免成为FM发射器),因此它们通常依赖于单独的时钟芯片来保持准确的时间。从这些时钟芯片读取数据非常昂贵,因此Windows会维护一个64位的时间计数器(以毫秒为单位),该时间由计时器中断更新。该计时器存储在共享内存中,因此任何进程都可以廉价地从那里读取当前时间,而不必与计时器芯片对话。 timeGetTime调用ReadInterruptTick,它的核心只是读取此64位计数器。简单!

由于该计数器是通过定时器中断更新的,因此我们可以对其进行监视并找到定时器中断的频率。

在Windows 10 2004(2020年4月发行)中,其中的一些内容悄然发生了变化,但方式非常混乱。我最初是通过timeBeginPeriod不再工作的报告听说的。现实比这更复杂。

进行一些实验得出了令人困惑的结果。当我运行一个名为timeBeginPeriod(2)的程序时,clockres显示计时器间隔为2.0毫秒,但是带有Sleep(1)循环的单独测试程序每秒仅唤醒约64次,而不是每秒500次。在以前的Windows版本中会被唤醒。

然后,我编写了两个程序,揭示了正在发生的事情。一个程序(change_interval.cpp)只是坐在一个循环中,调用timeBeginPeriod的时间间隔为1到15 ms。它会将每个计时器间隔请求保留四秒钟,然后转到下一个请求,完成后回绕。这是十五行代码。简单。

另一个程序(measure_interval.cpp)运行一些测试,以查看change_interval.cpp的行为对其行为的影响。它通过收集三个信息来做到这一点。

它使用NtQueryTimerResolution询问操作系统当前的全局计时器分辨率是多少。它通过循环调用timeGetTime的精度直到其返回值更改,从而测量其精度。当它改变时,它改变的量就是它的精度。它通过在一个循环中调用Sleep(1)并计算其可以进行的调用次数来衡量Sleep(1)的延迟。平均延迟只是迭代次数的倒数。

@FelixPetriconi在Windows 10 1909上为我运行了测试,并且在Windows 10 2004上运行了测试。结果(清理以消除随机性)如下所示:

这意味着在所有版本的Window上,timeBeginPeriod仍设置全局计时器中断间隔。从timeGetTime()的结果可以看出,中断以该速率在至少一个CPU内核上触发,并且时间被更新。还要注意,1909年第一行的2.0在Windows XP上是2.0,然后在Windows 7/8上是1.0,显然回到了2.0?我猜?

但是,调度程序的行为在Windows 10 2004中发生了巨大变化。以前,在任何进程中,Sleep(1)的延迟都与计时器中断间隔(timeBeginPeriod(1)除外)相同,给出了如下图:

在Windows 10 2004中,timeBeginPeriod和另一个过程(未调用timeBeginPeriod)中的睡眠延迟之间的映射是特殊的:

正如在reddit讨论中指出的那样,鉴于全局计时器中断的可用精度,图表的左半部分似乎是在尽可能接近地模拟“正常” 15.625 ms延迟的尝试。也就是说,在6毫秒的中断间隔内,它们会延迟〜12 ms(两个周期),而在7毫秒的中断间隔下,它们会延迟〜14 ms(两个周期),这与数据非常匹配。但是,中断间隔为8毫秒又如何呢?他们可以睡两个周期,但平均延迟为16 ms,测量值更像14.5 ms。

进一步的分析显示,当另一个进程调用timeBeginPeriod(8)时,一个时间间隔约20%的时间后返回Sleep(1),其余两个时间间隔后返回。因此,三个对Sleep(1)的调用导致平均14.5 ms的延迟。对Sleep(1)的处理上的这种变化有时会在其他定时器中断间隔发生,但是当设置为8 ms时,这种变化最为一致。

这一切都很奇怪,我不了解其原理或实现方式。 Sleep(1)延迟中的故意不一致特别令人担忧。也许这是一个错误,但我对此表示怀疑。我认为这背后有复杂的向后兼容逻辑。但是,避免兼容性问题的最有效方法是,最好是事先记录更改,并且似乎没有通知任何人。

这种行为似乎也适用于CreateWaitableTimerEx及其迄今未公开的CREATE_WAITABLE_TIMER_HIGH_RESOLUTION标志,基于您可以在此处找到的快速且可等待的计时器测试(需要Windows 10 1803或更高版本)。

大多数程序将不受影响。如果进程需要更快的计时器中断,那么它应该自己调用timeBeginPeriod。也就是说,这可能会导致以下问题:

程序可能会意外地假设Sleep(1)和timeGetTime具有相似的分辨率,并且该假设现在已被打破。但是,这种假设似乎不太可能。程序可能依赖于快速的计时器分辨率而无法请求。有许多人声称某些游戏存在此问题,并且有一个名为Windows System Timer Tool的工具和另一个名为TimerResolution 1.2的工具,该工具通过提高计时器中断频率来“修复”这些游戏。这些修复程序可能不再起作用,或者至少不会起作用。也许这将迫使这些游戏进行适当的修复,但是在此之前,此更改是向后兼容性问题。多进程程序可能会使其主控制程序提高计时器中断频率,然后期望这会影响其子进程的调度。这曾经是一个合理的设计选择,但现在不起作用。这就是提醒我此问题的方式。现在有问题的产品在其所有过程中都调用timeBeginPeriod,因此很好,谢谢您的询问,但是他们的软件出现了几个月的故障,没有任何解释。自从我撰写本文以来,我已经收到有关其他两个具有相同问题的多进程程序的报告。这是一个简单的解决方法,但是要弄清楚问题出在哪里,这是一个痛苦的调查。

change_interval.cpp测试程序仅在没有请求更高计时器中断频率的情况下才能运行。由于Chrome和Visual Studio都有这样做的习惯,因此我必须在笔记本中编写代码的同时进行大部分实验,而无法访问网络。有人建议Emacs,但是涉足这场辩论超出了我的意愿。

我希望能从Microsoft那里获得更多信息,包括对我的分析所做的任何更正。 讨论: