不論在客戶端應用程序還是服務器組件(包括窗口服務)定時器通常扮演一個重要的角色。寫一個高效的定時器驅動型可管理代碼要求對程序流程有一個清晰的理解及掌握.NET線程模型的精妙之處。.NET框架類庫提供了三種不同的定時器類:System.Windows.Forms.Timer, System.Timers.Timer, 和System.Threading.Timer。每個類為不同的場合進行設計和優化。本文章將研究這三個類並讓你理解如何及何時應該使用哪一個類。
Microsoft® Windows®裡的定時器對象當行為發生時允許你進行控制。定時器一些最常用的地方就是有規律的定時啟動一個進程,在事件之間設置間隔,及當進行 圖形工作時維護固定的動畫速度(而不管處理函數的速度)。在過去,對於使用Visual Basic®的開發者來說,定時器甚至用來模擬多任務。
正如你所期望的那樣,對於你需要應對的不同場合微軟為你裝備了一些工具。在.NET框架類庫中有三種不同的定時器類:System.Windows.Forms.Timer,System.Timers.Timer,和System.Threading.Timer。頭兩個類出現在Visual Studio® .NET的工具箱窗口,這兩個定時器控件都允許你直接把它們拖拽到Windows窗體設計器或組件類設計器上。如果你不小心,這就是麻煩的開始。
Visual Studio .NET工具箱上的Windows窗體頁和組件頁(見Figure 1)都有定時器控件。非常容易的錯誤地使用它們當中的一個,或者更糟糕的是,根本意識不到它們的不同。僅當目標是Windows窗體設計器時才使用Windows窗體頁上的定時器控件。這個控件將在你的窗體上放置一個Systems.Windows.Forms.Timer類的實例。像工具箱上的其它控件一樣,你可以讓Visual Studio .NET處理其生成或者你自己手動的實例和初始化這個類。
Figure 1 定時器控件
在組件頁上的定時器控件可以被安全的用在任何類中。這個控件創建了一個System.Timers.Timer類的實例。如果你正在使用Visual Studio .NET工具箱,無論是Windows窗體設計器還是組件類設計器你都可以安全的使用這個類。在Visual Studio .NET中當你設計一個派生於System.ComponentModel.Component的類時使用組件類設計器。System.Threading.Timer類不出現在Visual Studio .NET工具箱窗口上。它稍微有點復雜但提供了一個更高級別的控件,稍後你會在本文章中看到。
Figure 2 例子程序
讓我們首先研究System.Windows.Forms.Timer和System.Timers.Timer類。這兩個類有著非常相似的對象模型。稍後我將探索更加高級的System.Threading.Timer類。Figure 2 是我將在整個文章引用的例子程序的一個屏幕快照。這個應用程序將會讓你獲得對這幾個定時器類的清晰的理解。你可以從本文章的開始鏈接處下載完整的代碼並試驗它。
System.Windows.Forms.Timer
如果你在找一個節拍器,你已經走錯了地方了。這個定時器類引發的定時器事件是同你的窗口應用程序的其余代碼相同步的。這意味著正在執行的代碼從來不會被這個定時器類的實例所搶占(假設你不調用Application.DoEvents)。就像一個典型窗體程序裡的其它代碼一樣,任何駐留在一個定時器事件處理函數(指的是該類型的定時器類)中的代碼也是使用應用程序的UI線程所執行。在空閒時候,該UI線程同樣要對應用程序的窗體消息隊列中的所有消息進行負責。這不僅包括由這個定時類引發的消息,也包括窗體API消息。無論何時你的程序不忙於做其它事情時該UI線程就處理這些消息。
在Visual Studio .NET之前如果你寫過Visual Basic代碼,你可能知道在一個窗口應用程序裡當正在執行一個事件處理函數時讓你的UI線程去響應其它窗體消息的唯一方法就是調用Application.DoEvents方法。就像Visual Basic一樣,從.NET框架中調用Application.DoEvents能夠產生許多問題。Application.DoEvents產生了對UI消息泵的控制,讓你對所有未處理的事件進行處理。這能夠改變我剛才提到的所期望的執行路徑。如果為了處理由該定時器類產生的定時器事件而在你的代碼中有一個Application.DoEvents的調用,你的程序流程可能會被打斷。這會產生不希望的行為並使調試困難。
運行例子程序就會使這個定時器類的行為變得清楚。單擊程序的Start按鈕,接著單擊Sleep按鈕,最後單擊Stop按鈕,將會產生下面的輸出結果:
System.Windows.Forms.Timer Started @ 4:09:28 PM--> Timer Event 1 @ 4:09:29 PM on Thread:UIThread--> Timer EVENT 2 @ 4:09:30 PM on Thread: UIThread--> Timer Event 3 @ 4:09:31 PM on Thread: UIThreadSleeping for 5000 ms...--> Timer Event 4 @ 4:09:36 PM on Thread: UIThreadSystem.Windows.Forms.Timer Stopped @ 4:09:37 PM
例子程序設置System.Windows.Forms.Timer類的間隔屬性為1000毫秒。正如你所看到的,當UI線程正在睡眠(5秒)期間如果定時器事件處理函數仍然繼續捕捉定時器事件的話,當睡眠線程再次被喚醒的時候應該有5個定時器事件被顯示——在UI線程睡眠時每秒鐘一個。然而,當UI線程在睡眠時定時器卻保持掛起狀態。
對System.Windows.Forms.Timer的編程不能再簡單了——它有一個非常簡單和可直接編程的接口。Start和Stop方法實際上提供了一個設置使能屬性的改變方法(其本身是對Win32®的SetTimer和KillTimer功能的一個包裝)。我剛才提到的間隔屬性,名字本身就說明了問題。即使技術上你可以設置間隔屬性低到1毫秒,但你應該知道在.NET框架文檔中指出這個屬性大約精確到55毫秒(假定UI線程對於處理是可用的)。
捕捉由System.Windows.Forms.Timer類實例引發的事件是通過感知一個標准的EventHandler委托的標記事件來處理的,就像下面的代碼片斷所示:
System.Windows.Forms.Timer tmrWindowsFormsTimer = new System.Windows.Forms.Timer();tmrWindowsFormsTimer.Interval = 1000;tmrWindowsFormsTimer.Tick += new EventHandler(tmrWindowsFormsTimer_Tick);tmrWindowsFormsTimer.Start();...private void tmrWindowsFormsTimer_Tick(object sender, System.EventArgs e){ //Do something on the UI thread...}
System.Timers.Timer
.NET框架文檔指出System.Timers.Timer類是一個服務器定時器,是為多線程環境進行設計和優化。該定時器類的實例能夠被多個線程安全地訪問。不像System.Windows.Forms.Timer,System.Timers.Timer缺省的,將在一個工作者線程上調用你的定時器事件處理函數,該工作者線程是從公共語言運行時(CLR)線程池中獲得。這意味著在你的逝去的時間處理函數代碼中必須遵從Win32編程的黃金規則:除了創建該控件實例的線程之外,一個控件的實例從來不被任何其它的線程所訪問。
System.Timers.Timer提供了一個簡單的方法處理這樣的困境——暴露一個公共的SynchronizingObject屬性。把該屬性設置為一個窗體實例(或者窗體上的一個控件)將保證你的事件處理函數代碼運行在SynchronizingObject被實例化的同一個線程裡。
如果你使用了Visual Studio .NET工具箱,Visual Studio .NET自動的設置SynchronizingObject屬性為當前的窗體實例。首先它設定該定時器的SynchronizingObject屬性使其在功能上同System.Windows.Forms.Timer類一樣。對於大部分功能,的確是這樣。當操作系統通知System.Timers.Timer類所允許的定時時間已過去,定時器使用SynchronizingObject.Begin.Invoke方法在一個線程上去執行事件委托,該線程是創建SynchronizingObject的線程。事件處理函數將被阻塞直到UI線程能夠處理它。然而不像System.Windows.Forms.Timer類一樣,該事件最終仍然能夠被引發。像你在Figure 2中看到的,當UI線程不能夠處理時System.Windows.Forms.Timer不會引發事件,可是當UI線程可用時System.Timers.Timer卻會排隊等候處理。
Figure 3是如何使用SynchronizingObject屬性的例子。使用例子程序並通過選擇System.Timers.Timer的radio按鈕你可以分析這個類,並按照執行System.Windows.Forms.Timer類行為的同樣順序運行該類,這樣就會產生Figure 4的輸出結果。
正如你所看到的,它不會跳過一個跳動——即使UI線程在睡眠。在每一個事件間隔就有一個時間消失事件處理會被排隊執行。因為UI線程在睡眠,所以當UI線程一旦被喚醒例子程序就會列出5個定時器事件(4到8)並能夠處理處理函數。
正如我早先提到的,System.Timers.Timer類成員非常類似與System.Windows.Forms.Timer。最大的區別就在與System.Timers.Timer類是對Win32可等待定時對象的一個包裝,並在工作者線程上產生一個時間片消失事件而不是在UI線程上產生一個時間標記事件。時間片消失事件必須與一個同ElapsedEventHandler委托像匹配的事件處理函數相連接。事件處理函數接受一個ElapsedEventArgs類型的參數。
除了標准的EventArgs成員,ElapsedEventArgs類暴露了一個公共的SignalTime屬性,它包含了一個精確的定時器時間片消失的時間。因為這個類支持不同線程的訪問,除了時間消失事件所在的線程,應該相信它的Stop方法能夠被其它線程所調用。這會潛在的導致消失事件被引發即使其Stop方法已經被調用。你可以把SignalTime和Stop方法調用的時間進行比較來解決這個問題。
System.Timers.Timer也提供了AutoReset屬性來決定當時間片消失事件引發後是繼續進行還是只這一次。要記住在定時器開始後重設間隔屬性會導致當前計數為0。比如,設置了一個5秒的間隔,在間隔被改變為10秒時3秒已經過去了,那麼下一個定時器事件將會在上一個定時器事件13秒後發生。
System.Threading.Timer
第三個定時器類來自System.Threading名字空間。我願意說這是所有定時器類中最好的一個,但這會引起誤導。舉一個例子,我驚訝的發現對於駐留在System.Threading名字空間的這個類天生就不是線程安全的。(很明顯,這不意味著它不能以線程安全的方式使用)。這個類的可編程接口同其它兩個類也不一致,它稍微有點麻煩。
不像我開始描述的兩個定時器類,System.Threading.Timer有四個重載構造函數,就像下面這樣:
public Timer(TimerCallback callback, object state, long dueTime, long period);
public Timer(TimerCallback callback, object state, UInt32 dueTime, UInt32 period);
public Timer(TimerCallback callback, object state, int dueTime, int period);
public Timer(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period);
第一個參數(callback)要求一個TimerCallback的委托,它指向一個方法,該方法具有下面的結構:
public void TimerCallback(object state);
第二個參數(state)可以為空或者是包含程序規范信息的對象。在每一個定時器事件被調用時該state對象作為一個參數傳遞給你的定時回調函數。記住定時回調功能是在一個工作者線程上執行的,所以你必須確保訪問state對象的線程安全。
第三個參數(dueTime)讓你定義一個引發初始定時器事件的時間。你可指定一個0立即開始定時器或者阻止定時器自動的開始,你可以使用System.Threading.Timeout.Infinite常量。
第四個參數(period)讓你定義一個回調函數被調用的時間間隔(毫秒)。給該參數定義一個0或者Timeout.Infinite可以阻止後續的定時器事件調用。
一旦構造函數被調用,你仍然可以通過Change方法改變dueTime和period。該方法有下面四種重載形式:
public bool Change(int dueTime, int period);public bool Change(uint dueTime, uint period);public bool Change(long dueTime, long period);public bool Change(TimeSpan dueTime, TimeSpan period);
下面是我在例子程序中用到的開始和停止該定時器的代碼:
//Initialize the timer to not start automatically...System.Threading.Timer tmrThreadingTimer = newSystem.Threading.Timer(new TimerCallback(tmrThreadingTimer_TimerCallback), null, System.Threading.Timeout.Infinite, 1000);
//Manually start the timer...tmrThreadingTimer.Change(0, 1000);
//Manually stop the timer...tmrThreadingTimer.Change(Timeout.Infinte, Timeout.Infinite);
正如你所期望的那樣,通過選擇System.Threading.Timer類運行例子程序會產生同你看到的System.Timers.Timer類一樣的輸出結果。因為TimerCallback功能也是在工作者線程上被調用,沒有一個跳動被跳過(假設有工作者線程可用)。Figure 5顯示了例子程序的輸出結果。
不像System.Timers.Timer類,沒有與SynchronizingObject相對應的屬性被提供。任何請求訪問UI控件的操作都必須通過控件的Invoke或BeginInvoke方法被列集
定時器的線程安全編程
為了最大限度的代碼重用,三種不同類型的定時器事件都調用了同樣的ShowTimerEventFired方法,下面就是三個定時器事件的處理函數:
private void tmrWindowsFormsTimer_Tick(object sender, System.EventArgse)
{
ShowTimerEventFired(DateTime.Now, GetThreadName());
}
private void tmrTimersTimer_Elapsed(object sender, System.TimersElapsedEventArgse){
ShowTimerEventFired(DateTime.Now, GetThreadName());
}
private void tmrThreadingTimer_TimerCallback(object state){ ShowTimerEventFired(DateTime.Now, GetThreadName());
}
正如你所看到的,ShowTimerEventFired方法采用當前時間和當前線程名字作為參數。為了區別工作者線程和UI線程,在例子程序的主入口點設置CurrentThread對象的名字屬性為"UIThread"。GetThreadName幫助函數返回Thread.CurrentThread.Name值或者當Thread.CurrentThread.IsThreadPoolThread屬性為真時返回"WorkerThread"。
因為System.Timers.Timer和System.Threading.Timer的定時器事件都是在工作者線程上執行的,所以在事件處理函數中的任何用戶交互代碼都不是馬上進行的,而是被列集等候返回到UI線程上進行處理。為了這樣做,我創建了一個ShowTimerEventFiredDelegate委托調用:
private delegate void ShowTimerEventFiredDelegate (DateTime eventTime, string threadName);
ShowTimerEventFiredDelegate允許ShowTimerEventFired方法在UI線程上調用它自己,Figure 6顯示了發生這一切的代碼。
通過查詢InvokeRequired屬性可以非常容易的知道你是否從當前線程可以安全的訪問Windows窗體控件。在這個例子中,如果列表框的InvokeRequired屬性為真,窗體的BeginInvoke方法就可以被ShowTimerEventFired方法調用,然後再被ShowTimerEventFiredDelegate方法調用。這能夠保證列表框的Add方法在UI線程上執行。
正如你所看到的,當你編寫異步定時器事件時有許多問題需要意識到。在使用System.Timers.Timer和System.Threading.Timer之前我推薦你閱讀Ian Griffith的文章“Windows Forms:Give Your .NET-based Application a Fast and Responsive UI with Multiple Threads”, 該文刊登在MSDN雜志的2003年2月份的期刊上。
處理定時器事件重入
當和異步定時器事件打交道時,如由System.Timers.Timer和System.Threading.Timer產生的定時器事件,有另外一個細微之處你需要考慮。問題就是必須處理代碼重入。如果你的定時器事件處理函數代碼執行時間比你的定時器引發定時器事件的時間間隔要長,你預先又沒有采取必要的措施保護防止多線程訪問你的對象和變量,你就會陷入調試的困境。看一下下面的代碼片斷:
private int tickCounter = 0;
private void tmrTimersTimer_Elapsed(object sender, System.Timers.ElapsedEventArgse)
{
System.Threading.Interlocked.Increment(ref tickCounter);
Thread.Sleep(5000);
MessageBox.Show(tickCounter.ToString());
}
假設你的定時器間隔屬性設置為1000毫秒,你也許會奇怪當第一個信息框彈出時顯示的值是5。這是因為在這5秒期間第一個定時器事件正在睡眠,而定時器卻在不同的工作者線程上繼續產生時間消失事件。因此,在第一個定時器事件處理完成之前tickCounter變量被增加了5次。注意我使用了Interlocked.Increment方法以線程安全的方式增加tickCounter變量的值。也有其它方法可以這樣做,但是Interlock.Increment是為這種操作而特別設計的。
解決這種問題的簡單方法就是在你的事件處理函數代碼塊中暫時禁止定時器,接著再允許定時器,就像下面的代碼:
private void tmrTimersTimer_Elapsed(object sender, System.Timers.ElapsedEventArgse)
{
tmrTimers.Enabled = false;
System.Threading.Interlocked.Increment(ref tickCounter);
Thread.Sleep(5000);
MessageBox.Show(tickCounter.ToString());
tmrTimersTimer.Enabled = true;
}
有了這段代碼,消息框就會每5秒鐘顯示一次,就像你所期望的那樣,tickCounter的值每次只增加1。另外一些可選的原始同步對象就是Monitor或mutex去確保所有將來的事件被排隊直到當前的事件處理函數執行完成。
結論
為了快速方便的看到.NET框架中這三個定時器類的不同之處,見Figure 7對三個類的比較。當使用定時器類時有一點你要考慮的就是是否可以使用Windows調度器去定期的運行標准的可執行程序來更簡單的解決問題。