1 背景
.NET Framework 提供了四种定时器,然而其精度都不高(一般情况下 15ms 左右),难以满足一些场景下的需求。
在进行媒体播放、绘制动画、性能分析以及和硬件交互时,可能需要 10ms 以下精度的定时器。这里不讨论这种需求是否合理,它是确实存在的问题,也有相当多的地方在讨论,说明这是一个切实的需求。然而,实现它并不是一件轻松的事情。
这里并不涉及内核驱动层面的定时器,只分析在 .NET 托管环境下应用层面的高精度定时器实现。
Windows 不是实时操作系统,所以任何方案都无法绝对保证定时器的精度,只是能尽量减少误差。所以,系统的稳定性不能完全依赖于定时器,必须考虑失去同步时的处理。
2 等待策略
想要实现高精度定时器,必然需要等待和计时两种基础功能。等待用来跳过一定时间间隔,计时可以进行时间检查,用以调整等待时间。
等待策略实际就是两种:
自旋等待:让 CPU 空转消耗时间,占用大量 CPU 时间,但是时间高度可控。
阻塞等待:线程进入阻塞状态,出让 CPU 时间片,在等待一定时间后再由操作系统调度回到运行状态。阻塞时不占用 CPU,然而需要操作系统调度,时间难以控制。
可以看到二者各有优劣,应该按照不同需求进行不同的实现。
而计时机制可以说能用的只有一种,就是 Stopwatch
类。它内部使用了系统 API QueryPerformanceCounter/ QueryPerformanceFrequency 来进行高精度计时,依赖于硬件,它的精度可以高达几十纳秒,非常适合用来实现高精度定时器。
所以难点在于等待策略,下面先分析简单的自旋等待。
2.1 自旋等待
可以使用 Thread.SpinWait(int iteration)
来进行自旋,也就是让 CPU 在一个循环里空转,iteration
参数是迭代次数。.NET Framework 中不少同步构造都用到了它,用来等待一小段时间,减少上下文切换的开销。
这里很难根据 iteration
来计算消耗的时间,因为 CPU 速度可能是动态的。所以需要结合使用 Stopwatch
。伪代码如下:
var 等待开始时间 = 当前计时;while ((当前计时 - 等待开始时间) < 需要等待的时间){
自旋;}
写成实际代码:
void Spin(Stopwatch w, int duration){
var current = w.ElapsedMilliseconds;
while ((w.ElapsedMilliseconds - current) < duration)
Thread.SpinWait(10);}
这里的 w
是一个已经启动的 Stopwatch
,为了演示简单使用了 ElapsedMilliseconds
属性,精度是毫秒级的,使用 ElapsedTicks
属性就可以获得更高的精度(微秒级)。
然而如前所述,这样精度高但是是以消耗 CPU 时间为代价的,这样实现定时器会让一个 CPU 核心满负荷工作(如果执行的任务也没有阻塞的话)。相当于浪费了一个核心,在有些时候不太现实(比如核心很少甚至是单核的虚拟机上),所以需要考虑阻塞等待。
2.2 阻塞等待
阻塞等待会把控制权交给操作系统,这样就必须确保操作系统能够及时的将定时器线程调度回运行状态。默认情况下,Windows 的系统定时器精度是 15.625ms,也就是说时间切片是这个尺寸。如果线程阻塞,出让其时间片进行等待,再被调度运行的时间至少是一个切片 15.625ms。那么必须减少时间切片的长度,才有可能实现更高的精度。
可以通过系统 API timeBeginPeriod 来修改系统定时器精度到 1ms(它内部使用了没有给出文档的 NtSetTimerResolution
,这个 API 可以修改到 0.5ms)。不需要的时候使用 timeEndPeriod 还原。
修改系统定时器精度有副作用。它会增加上下文切换的开销,增加耗电量,降低系统整体性能。然而,很多程序都不得不这么做,因为没有其它方式能获得更高的定时器精度。比如基于 WPF 的程序(包括 Visual Studio)、使用 Chromium 内核的应用(Chrome、QQ)、多媒体播放器、游戏等等很多程序都会在一定时间内把系统定时器精度修改到 1ms。(查看方法见后面)
所以实际上这个副作用在桌面环境已经成为常态。而且从 Windows 8 开始,这个副作用减弱了。
在 1ms 的系统定时器精度前提下,可以使用三种方式实现阻塞等待:
Thread.Sleep
WaitHandle.WaitOne
Socket.Poll
另外,多媒体定时器 timeSetEvent
也使用了阻塞的方式。
Thread.Sleep
它的参数使用毫秒单位,所以最多只能精确到 1ms。不过事实上很不稳定,Thread.Sleep(1)
会在 1ms 与 2ms 两种状态间跳动,也就是可能会产生 +1ms 多的误差。
实测发现,没有任务负载的情况下(纯粹循环调用 Sleep(1)
),阻塞时长稳定在 2ms;而有任务负载时,则至少会阻塞 1ms。这和其它两种阻塞方式不同,详见后文。
如果需要修正这个误差,可以在阻塞 n 毫秒时,使用 Sleep(n-1)
,并通过 Stopwatch
计时,剩余等待时间用 Sleep(0)
、Thread.Yield
或自旋来补充。
Sleep(0)
会出让剩余的 CPU 时间片给优先级相同的线程,而 Thread.Yield
是出让剩余的 CPU 时间片给运行在同一核心上的线程。在出让的时间片结束后,其会被重新调度。一般情况下,整个过程可以在 1ms 之内完成。
Thread.Sleep(0)
和 Thread.Yield
在 CPU 高负载情况下非常不稳定,实测可能会阻塞高达 6ms 时间,所以可能会产生更多的误差。因此误差修正最好通过自旋方式实现。
WaitHandle.WaitOne
WaitHandle.WaitOne
与 Thread.Sleep
类似,参数也是毫秒单位。
不同之处是,没有任务负载的情况下(纯粹循环调用 WaitOne(1)
),阻塞时长稳定在 1.5ms;而有任务负载时,则可能仅阻塞近乎于 0 的时间(猜测是它仅阻塞到当前时间片结束,尚未找到具体的文档说明)。所以它阻塞的时长范围是 0 到 2ms 多。
WaitHandle.WaitOne(0)
是用来测试等待句柄状态的,它并不阻塞,所以用它来进行误差修正类似于自旋,但不如直接使用自旋可靠。
Socket.Poll
Socket.Poll
方法的参数是以微秒为单位,理论上,它是使用了网卡的硬件来定时,精度很高。然而,由于阻塞的实现仍然要依赖线程,所以它也只能达到 1ms 的精度。
它的优势是比 Thread.Sleep
和 WaitHandle.WaitOne
要更稳定,误差也更小,可以不需要修正,但要占用一个 Socket 端口。
没有任务负载的情况下(纯粹循环调用 Poll(1)
),阻塞时长稳定在 1ms;而有任务负载时,则和 WaitOne
类似,可能仅阻塞近乎于 0 的时间。所以它阻塞的时长范围是 0 到 1ms 多。
Socket.Poll(0)
是用来测试 Socket 状态的,但它会阻塞,而且可能阻塞高达 6ms,所以不能用它来进行误差修正。
timeSetEvent
timeSetEvent 和之前提到的 timeBeginPeriod
一样属于 winmm.dll 提供的多媒体定时器功能。它可以直接当作定时器使用,也是提供 1ms 的精度。在不需要的时候使用 timeKillEvent 来关闭。
它的稳定性和精度也很高,如果需要 1ms 的定时,而又不能使用自旋,那么这是最理想的方案。
虽然 MSDN 上说 timeSetEvent
是个过时的方法,应该用 CreateTimerQueueTimer
替换。但是 CreateTimerQueueTimer
精度和稳定性都不如多媒体定时器,所以在需要高精度的时候,只能使用 timeSetEvent
。
3 定时器实现
需要注意的是,无论自旋还是阻塞,显然定时器都应该运行在独立的线程,不能干扰使用方线程工作。而对于高精度定时器来说,触发事件以执行任务的线程一般都在定时器线程内,而不是再使用独立的任务线程。
这是因为高精度定时场景下,执行任务的时间开销很可能大于定时器的时间间隔,如果默认就在其它线程执行任务,可能导致占用大量线程。所以应该把控制权交给用户,让用户在需要的时候自行调度任务执行的线程。
3.1 触发模式
由于在定时器线程执行任务,所以定时器的触发就产生了三种模式。以下是它们的说明和主循环伪代码:
固定时间框架
比如间隔 10ms,任务 7-12ms,则会按照等待 10ms 、任务 7ms、等待 3ms、任务 12ms(超时 2ms 失去同步)、任务 7ms、等待 1ms(回到同步)、任务 7ms、等待 3ms、… 进行。就是尽量按照设定好的时间框架来执行任务,只要任务不是始终超时,就可以回到原本的时间框架上。
var 下一帧时间 = 0;while(定时器开启){
下一帧时间 += 间隔时间;
while (当前计时 < 下一帧时间)
{
等待;
}
触发任务;}
可推迟时间框架
上面的例子会按照等待 10ms 、任务 7ms、等待 3ms、任务 12ms(超时,推迟时间框架 2ms)、任务 7ms、等待 3ms、… 进行。超时的任务会推迟时间框架。
var 下一帧时间 = 0;while(定时器开启){
下一帧时间 += 间隔时间;
if (下一帧时间 < 当前计时)
下一帧时间 = 当前计时
while (当前计时 < 下一帧时间)
{
等待;
}
触发任务;}
固定等待时间
上面的例子会按照等待 10ms、任务 7ms、等待 10ms、任务 12ms、等待 10ms、任务 7ms… 进行。等待时间始终不变。
while(定时器开启){
var 等待开始时间 = 当前计时;
while ((当前计时 - 等待开始时间) < 间隔时间)
{
等待;
}
触发任务;}// 或者:var 下一帧时间 = 0;while(定时器开启){
下一帧时间 += 间隔时间;
while (当前计时 < 下一帧时间)
{
等待;
}
触发任务;
下一帧时间 = 当前计时;}
如果使用多媒体定时器(timeSetEvent
),它固定实现了第一种模式,而其它的等待策略能够实现全部三种模式,可以根据需求选择。
在 while
循环中的等待
可以使用自旋或阻塞,也可以结合它们来达到精度、稳定性和 CPU 开销的平衡。
另外,由上面的伪代码可以看出,这三种模式的实现可以统一,能够做到根据情况切换。
3.2 线程优先级
最好把线程优先级调高,以保证定时器能够稳定工作,减少被抢占的机会。然而需要注意,这在 CPU 资源不足时可能导致低优先级线程的饥饿。也就是说不能让高优先级线程去等待低优先级线程改变状态,很有可能低优先级线程没有机会运行,导致死锁或类似死锁的状态。(见一种类似的饥饿的例子)
线程的最终优先级和进程的优先级有关,所以有时候也需要提高进程优先级(见 C# 中的多线程系列的线程优先级说明)。
4 其它
还有两点需要注意:
线程安全:定时器在独立线程运行,其暴露的成员都应该实现线程安全,否则在定时器运行时调用可能会产生问题。
及时释放资源:多媒体定时器、等待句柄、线程等等这些都是系统资源,在不需要它们的时候应该及时释放 / 销毁。
如何查看系统定时器精度?
简单的查看可以使用 Sysinternals 工具包中的 ClockRes,它会显示如下信息:
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
Current timer interval: 15.625 ms
// 或
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
Current timer interval: 1.000 ms
如果是想查看哪些程序请求了更高的系统定时器精度,那么运行:
powercfg energy -duration 5
它会监视系统能耗 5s,然后在当前目录生成一个 energy-report.html
的分析报告,可以打开它查看。
找到里面的警告部分,会有平台计时器分辨率:未完成的计时器请求
(Platform Timer Resolution:Outstanding Timer Request
)信息。
参考:
http://www.codeproject.com/Articles/98346/Microsecond-and-Millisecond-NET-Timer
http://www.codeproject.com/Articles/571289/Obtaining-Microsecond-Precision-in-NET
http://omeg.pl/blog/2011/11/on-winapi-timers-and-their-resolution/
https://randomascii.wordpress.com/2013/07/08/windows-timer-resolution-megawatts-wasted/
原文链接: http://blog.gkarch.com/2015/09/high-resolution-timer.html
关注我们的方法:1. 点击文章标题下的 “dotNET 跨平台” 蓝字,或者在微信搜索 “opendotnet”,加关注
2. 老朋友点击点击右上角 “……” 标志分享到朋友圈
本文分享自微信公众号 - dotNET 跨平台(opendotnet)。
如有侵权,请联系 [email protected] 删除。
本文参与 “OSC 源创计划”,欢迎正在阅读的你也加入,一起分享。
评论记录:
回复评论: