Timer 用于以用户定义的事件间隔触发事件。Windows 计时器是为单线程环境设计的,其中,UI 线程用于执行处理。它要求用户代码有一个可用的 UI 消息泵,而且总是在同一个线程中操作,或者将调用封送到另一个线程。
使用此计时器时,请使用控件的Tick事件执行轮询操作,或在指定的时间内显示启动画面。每当 Enabled 属性设置为true且Interval属性大于0时,将引发Tick事件,引发的时间间隔基于Interval属性设置。System.Windows.Forms.Timer是应用于WinForm中的,他是通过Windows消息机制实现的,类似于VB或Delphi中的Timer控件,内部使用API SetTimer实现的。他的主要缺点是计时不精确,而且必须有消息循环,Console Application(控制台应用程式)无法使用。 System.Timers.Timer和System.Threading.Timer很类似,他们是通过.NET Thread Pool实现的,轻量,对应用程式、消息没有特别的需要。System.Timers.Timer还能够应用于WinForm,完全取代上面的Timer控件。
然而其精度都不高(一般情况下 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 的系统定时器精度前提下,可以使用三种方式实现阻塞等待:
(1)Thread.Sleep(2)WaitHandle.WaitOne(3)Socket.Poll另外,多媒体定时器timeSetEvent也使用了阻塞的方式。
(1)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 时间,所以可能会产生