System.Timers命名空间提供Timer组件,它使您可以指定的间隔引发事件。依照MSDN的说法,服务器计时器是针对编写控制服务器的服务程序而制定的[3]。System.Timers命名空间只包含了三个公开类和一个公开的委托,概述如下:
ElapsedEventArgs类“为 Timer.Elapsed 事件提供数据。”也就是Timer.Elapsed事件的参数。ElapsedEventHandler委托“表示将要处理 Timer 的 Elapsed 事件的方法。”将这个委托指向我们的事件处理函数。Timer类“在应用程序中生成定期事件。 ”服务器计时器组件的类。TimersDescriptionAttribute类“设置可视化设计器在引用事件、扩展程序或属性时可以显示的说明。”用于VS设计器的特性,与计时器功能无关。此命名空间非常简洁,就是围绕Timer类的实现。有Windows计时器的认识,对于这个Timer类的使用应该不会感到为难。今天比较懒,这是MSDN的例子:
[例1]
using System; using System.Timers; public class Timer1 { // 声明一个服务器计时器 aTimer private static System.Timers.Timer aTimer; public static void Main() { // 通常,该计时器被声明在类级别里,例如此段代码。但是如果将计时器声明在 // 一个较长执行的方法中,GC垃圾回收器也许会在该方法执行后将计时器回收掉, // 那么它委托的方法会因此失效导致错误。所以必须在该方法的最后一行加上 // KeepAlive方法告诉GC不要回收此计时器。见此方法最后面的注释。 // 创建一个10秒的服务器计时器 aTimer = new System.Timers.Timer(10000); // 将计时器的Elapsed事件委托给OnTimedEvent方法 aTimer.Elapsed += new ElapsedEventHandler(OnTimedEvent); // 设置计时器的计时间隔为2秒 aTimer.Interval = 2000; // 开始计时器 aTimer.Enabled = true; Console.WriteLine("按回车键退出程序。"); Console.ReadLine(); // 如果计时器是在一个会较长执行的代码中声明的,应该在此调用KeepAlive方法, // 保护它不被GC(垃圾回收器)回收: //GC.KeepAlive(aTimer); } // OnTimedEvent执行代码 private static void OnTimedEvent(object source, ElapsedEventArgs e) { Console.WriteLine("Elapsed 事件被触发,触发时间:{0}", e.SignalTime); } }
把这段代码保存为ServerTimer01.cs文件,然后用C#编译器直接编译,在命令提示符中运行,效果如下:
Setting environment for using Microsoft Visual Studio 2008 x86 tools. e:/Program Files/Microsoft Visual Studio 9.0/VC>i: I:/>csc.exe ServerTimer01.cs 适用于 Microsoft(R) .NET Framework 3.5 版的 Microsoft(R) Visual C# 2008 编译器 3 .5.30729.1 版 版权所有(C) Microsoft Corporation。保留所有权利。 I:/>ServerTimer01.exe 按回车键退出程序。 Elapsed 事件被触发,触发时间:2009-7-13 12:10:38 Elapsed 事件被触发,触发时间:2009-7-13 12:10:40 Elapsed 事件被触发,触发时间:2009-7-13 12:10:42 Elapsed 事件被触发,触发时间:2009-7-13 12:10:44 Elapsed 事件被触发,触发时间:2009-7-13 12:10:46 Elapsed 事件被触发,触发时间:2009-7-13 12:10:48
程序运行后会每隔2秒钟触发一次Elapsed事件,事件参数ElapsedEventArgs对象只有一个SignalTime信号时间属性(它是通过kernel32.dll的GetSystemTimeAsFileTime函数得到的文件时间转换成DateTime类型的,此API的精度固定是100纳秒)。看起来很简单,的确如此,但是有两点你必须清楚:
虽然Elapsed事件与Windows计时器的Tick事件功能相同,但是,我们知道Windows计时器是工作的UI线程中的,而服务器计时器则工作在辅助线程中,就是说,我们委托出来的OnTimedEvent方法实际上是在辅线程中执行的。正是由于Elapsed事件在ThreadPool(线程池)线程上引发,当我们在主线程上调用Stop方法或者Enabled=false停止计时后,也许会因为并发或者延时仍然引发Elapsed事件执行处理程序。这种情况通常被称为可重入性[4]。我们需要采取措施防止重入的发生。
方法一:为多个线程共享的变量提供原子操作,通过一个原子变量保证该代码不会被重复执行。
原子操作是处理多线程共享必须的手段,更多.NET中的原子操作知识,可以学习System.Threading.Interlocked类[5]。同样是来自MSDN中的例子,它利用Interlocked.CompareExchange方法来解决计时器Stop的Elapsed事件重入问题[4]。
[例2]
using System; using System.Timers; using System.Threading; public class Test { // 测试次数 private static int testRuns = 100; // 单位是毫秒: private static int testRunsFor = 500; private static int timerIntervalBase = 100; private static int timerIntervalDelta = 20; // 两个计时器 private static System.Timers.Timer Timer1 = new System.Timers.Timer(); private static System.Timers.Timer Timer2 = new System.Timers.Timer(); // 当前计时器 private static System.Timers.Timer currentTimer = null; // 用来模拟一个长度在50和200毫秒之间随机变化的任务 private static Random rand = new Random(); // 此“同步点”防止事件处理程序被同时执行,防止Stop方法后的事件处理程序重复执行。 private static int syncPoint = 0; // 统计事件被调用、执行、跳过和Stop后被调用的次数 private static int numEvents = 0; private static int numExecuted = 0; private static int numSkipped = 0; private static int numLate = 0; // 统计主线程调用Stop方法的次数,必须等待事件处理程序完成。 private static int numWaits = 0; [MTAThread] public static void Main() { Timer1.Elapsed += new ElapsedEventHandler(Timer1_ElapsedEventHandler); Timer2.Elapsed += new ElapsedEventHandler(Timer2_ElapsedEventHandler); Console.WriteLine(); for(int i = 1; i <= testRuns; i++) { TestRun(); Console.Write("/r测试 {0}/{1} ", i, testRuns); } Console.WriteLine("{0} 次测试已经完成。", testRuns); Console.WriteLine("{0} 次事件触发", numEvents); Console.WriteLine("{0} 次事件执行", numExecuted); Console.WriteLine("{0} 次事件因同时执行被跳过", numSkipped); Console.WriteLine("{0} 次事件因Stop后执行被跳过", numLate); Console.WriteLine("控制线程等待完成一个事件而等待了 {0} 次", numWaits); } public static void TestRun() { // 在测试前将同步点设置为0,表示没有需要同步的处理程序被执行,可以执行此次的处理程序。 syncPoint = 0; if (currentTimer == Timer1) currentTimer = Timer2; else currentTimer = Timer1; currentTimer.Interval = timerIntervalBase - timerIntervalDelta + rand.Next(timerIntervalDelta * 2); currentTimer.Enabled = true; // 启动控制线程去关闭计时器 Thread t = new Thread(ControlThreadProc); t.Start(); // 等待控制线程结束,使得TestRun可以重叠。 t.Join(); } private static void ControlThreadProc() { // 允许计时器周期运行,然后停止它。 Thread.Sleep(testRunsFor); currentTimer.Stop(); // 这个线程是否处于等待 bool counted = false; // 若有一个事件处理程序正在执行,在那个事件处理未完成前一直等待。 // 在syncPoint不为0时(有事件处理正在执行)进入循环体,每1毫秒判断同步点状态, // 直到syncPoint为0时(其他事件处理已经完成)设置值为-1,表示本线程正在处理。 // 注意CompareExchange函数是原子操作,永远返回syncPoint的原始值。 while (Interlocked.CompareExchange(ref syncPoint, -1, 0) != 0) { Thread.Sleep(1); // 等待计数+1 if (!counted) { numWaits += 1; counted = true; } } } // 两个计时器的Elapsed事件调用相同的处理代码: private static void Timer1_ElapsedEventHandler(object sender, ElapsedEventArgs e) { HandleElapsed(sender, e); } private static void Timer2_ElapsedEventHandler(object sender, ElapsedEventArgs e) { HandleElapsed(sender, e); } private static void HandleElapsed(object sender, ElapsedEventArgs e) { numEvents += 1; // 此例是假设不进行事件重入处理的情况。 // 若Elapsed事件的处理程序在完成之前被再次触发,则第二次的事件处理会被忽略。 // 用CompareExchange方法控制同步点syncPoint,它会在同步点为0的时候赋值为1 // 并执行事件处理代码,若它已经设置为1(在调用了Stop方法后触发了此次事件)或者被 // 其他线程设置为-1(其他线程正在执行同步的处理代码),就跳过这次的事件处理程序。 int sync = Interlocked.CompareExchange(ref syncPoint, 1, 0); if (sync == 0) { // 事件处理代码。模拟一个长度在50和200毫秒之间随机变化的任务。 int delay = timerIntervalBase - timerIntervalDelta / 2 + rand.Next(timerIntervalDelta); Thread.Sleep(delay); numExecuted += 1; // 释放同步点,将其设置为原值0 syncPoint = 0; } else { if (sync == 1) { numSkipped += 1; } else { numLate += 1; } } } }
把这段代码保存为ServerTimer02.cs文件,然后用C#编译器直接编译,在命令提示符中运行,效果如下:
I:/>csc ServerTimer02.cs 适用于 Microsoft(R) .NET Framework 3.5 版的 Microsoft(R) Visual C# 2008 编译器 3 .5.30729.1 版 版权所有(C) Microsoft Corporation。保留所有权利。 I:/>ServerTimer02.exe 测试 100/100 100 次测试已经完成。 426 次事件触发 346 次事件执行 80 次事件因同时执行被跳过 0 次事件因Stop后执行被跳过 控制线程等待完成一个事件而等待了 81 次
方法二:同步访问对象机制。
System.Threading.Monitor类提供的同步对象锁机制,当一个线程拥有对象的锁时,其他任何线程都不能获取该锁[6]。你知道.NET框架2.0增加了lock语句,事实上,它就是Monitor类应用的一种简化方式,好吧,大家习惯叫这类语法为.NET语法糖。到目前为止,我们从计时器的问题讨论到.NET多线程同步的问题,似乎有些跑题。MSDN中没有举出利用同步锁解决服务器计时器的Stop同步问题的例子,关于多线程同步的问题有机会在其他日志中专门讨论。
在[例1]的注释中有讲到,当计时器被声明在一个较长执行的方法中时,需要在该方法的结束位置加入GC.KeepAlive[7]方法,使计时器不会因为失去引用而导致垃圾回收器将它提前回收。因此建议大家在使用服务器计时器时将它的声明放到类级别中。
服务器计时器的成员比Windows计时器的成员多几个,它不仅有设置时间间隔的Interval属性,开始/关闭计时的Enabled属性和Start/Stop方法,处理周期事件由Windows计时器的Tick改为Elapsed,它还有另外两个重要属性和一些方法[8]。
AutoReset属性 该属性设置计时器是否在引发Elapsed事件后重新计数,以便触发下一次的Elapsed事件。当它为false时,计时器只会触发一次Elapsed事件。它的默认值 为true,就如前面的几个例子,都没有设置AutoReset属性,但是它仍能够重复地触发Elapsed事件。
SynchronizingObject属性 又回到线程同步问题,不过这个很容易使用。服务器计时器继承了Component(组件)类,我们可以将它作为组件添加到VS窗体设计器的工具箱里,当我们通过工具箱将这个计时器放入Windows窗体设计器时,该属性会自动被设置为计时器的父控件。比如说我在form1里添加了一个服务器计时器,那么它的SynchronizingObject属性就为form1。这样,便采用了一种非常简便的方法避免了线程池中无法访问主线程中组件的问题。(BTW,在下一篇讨论线程计时器的时候,你会知道它是怎样实现的。)
Close方法 释放由计时器占用的资源。既然使用的多线程的工作方式,释放资源是必要的,不需要太多的解释。
Dispose方法 释放由当前计时器使用的所有资源。原因同Close方法,不做解释。
这次没有分析服务器计时器的源码,但是你知道.NET框架2.0的服务器计时器是通过线程计时器实现的,所以在下一篇讨论线程计时器之后,你对服务器计时器会有更深入的理解。有兴趣可以使用Reflector[9]自己分析。本篇日志的两个例子全部摘自MSDN,它们可以很好的表达使用服务器计时器的编程要点,因此,我重新进行了注释。
注:在.NET Framework 1.0和1.1版本中的服务器计时器的实现与现在(.NET Framework 2.0, 3.0, 3.5)的版本不同,而且在1.0, 1.1 (包括1.1 SP1)版本中的服务器计时器存在诸多BUG[10]。就目前来看,毕竟大多数人都在使用2.0之后的版本,因此不对之前的版本进行讨论。