本篇文章假定你熟悉 C++ 和 Win32 API
概要
從 Windows NT 裡獲得的時間戳(Timestamp),根據你所使用的硬件,其最大精度為 10 到 15 毫秒。但是, 有時候你需要時間標簽頻繁事件時,獲得更高的精度更能令人滿意。舉個例子,如果你要與線程打交道,或者以間隔不低於 10 毫秒的頻率實現某些其它任務時該怎麼辦?為了獲得更好的精度,建議的方法包括使用性能計數器和系統時間一起去計算更小的時間增量。然而使用性能計數器技術有其自身的問題。本文將揭示一種可行的途徑來克服該方法固有的局限。
你為什麼會對獲得小於1毫秒精度的系統時間感興趣?在我工作期間,我發現有必要去確定我的進程裡不同線程執行引發的事件的順序。還需要把這些事件同絕對時間相 關聯,但注意到系統時間的實際精度是不會超過10毫秒粒度的。
在本文隨後的內容中,我將解釋該系統時間精度的限制,解決的步驟,以及某些一般缺陷。例子程序的實現可以從本文開始鏈接處下載。這些文件的源代碼是在 Visual C++? 7.1 和 Windows? XP 專業版下編寫測試的。在編寫本文時,我頻繁地提到 Windows NT® 操作系統家族(Windows NT 4.0, Windows 2000, 或者 Windows XP)產品,而不是某一個特定的版本。 本文中用到的 Win32? APIs 的參數類型及用法,參見 MSDN library/Platform SDK 文檔。
究竟誰有這樣的需求?
最近我用“Windows NT millisecond time resolution”作為關鍵字在 Internet 上搜索了一番, 得到了 400 多個滿足條件的結果。其中大多數是討論如何獲得高於10毫秒精度的系統時間,或者是如何讓一個線程的休眠時間小於10毫秒。本文我將專注於為什麼獲得一個高於10毫秒精度的系統時間 會如此困難。你可能認為用 GetSystemTime API 很容易解決問題,這個 API 函數返回一個SYSTEMTIME 結構,該結構包含一個 wMilliseconds 域,在 MSDN 文檔中說它保存 當前的毫秒時間。但實際上並不象這麼簡單。那麼用 GetSystemTimeAsFileTime 獲取 100 納秒的精度如何呢?就讓我們從一個小試驗 開始吧:嘗試重復獲取系統時間,將它格式化並輸出到屏幕上(見 Figure 1 )。
我的目標不是納秒,而僅是毫秒精度,它應該能夠從 SYSTEMTIME 結構中判斷。讓我們看一下輸出結果:
20:12:23.479
20:12:23.479
20:12:23.494
20:12:23.494
[...有很多被移去了...]
20:12:23.494
20:12:23.509
20:12:23.509
20:12:23.509
...
正如你所看到的,我所能得到的最好的精度是15毫秒,這是 Windows NT 時鐘周期的長度。每過一個時鐘周期,Windows NT都會更新系統時間。Windows NT調度器也會 突然啟動並可能選擇一個新的線程來執行。關於這方面的更多信息,請看《Inside Windows 2000》第三版(Microsoft Press®, 2000),作者是 David Solomon 和 Mark Russinovich。
如果你運行我剛才所示的代碼,你也許會看到時間大約是每10毫秒更新一次。如果是那樣,可能意味著你是在單處理器的機器上運行 Windows NT,其時鐘周期通常為10毫秒。正如你所看到的, 在這種方法中,系統時間更新頻率不夠快,不足以成為一種為我所用的技術。下面我們就來嘗試找一個解決方案。
最初的嘗試
當你詢問如何得到一個比10毫秒精度更好的系統時間時,你也許會得到下面這樣的回答:使用性能計數器,並讓性能計數器值和即時變化的系統時間同步。結合這些值來計算一個 精度極高的當前時間。Figure 2 顯示了實現方法。
性能計數器是一個高精度的硬件計數器,它能高精確、低開銷地計量一個短周期時間。我通過在一個緊湊循環內不斷重復把性能計數器值和對應的系統時間進行同步,等待系統時間變化。當系統時間 以變,我就保存計數器的值及系統時間。
使用這兩個值作為參考,就有可能計算出一個高精度的當前系統時間(詳情見 Figure 2 中的get_time),看一下結果:
...
21:23:22.296
21:23:22.297
21:23:22.297
21:23:22.298
21:23:22.298
21:23:22.299
21:23:22.300
21:23:22.300
21:23:22.301
21:23:22.301
21:23:22.302
21:23:22.302
21:23:22.303
...
盡管它看起來非常成功,但這個實現卻有幾個問題:同步實現(函數被命名為 "simplistic_synchronize"的一個很好的理由);QueryPerformanceFrequency 報告的頻率 ;系統時間變化缺乏保護。在接下來的章節中,我們會考慮這些問題的一些可能的改進。
實現同步的可靠方法
該同步實現沒有考慮 Windows NT 調度器的搶先問題。例如,它無法保證在下面的兩行代碼之間不會發生線程上下文的切換,從而導致一個未知時間周期的延遲:
::GetSystemTimeAsFileTime(&ft1);
::QueryPerformanceCounter(&li);
大多時候只要滿足下面的條件,這個過分單純化的同步函數還是成功的:
當前線程不會被優先級更高的線程搶先進入就緒狀態;
當前線程的時間片永遠不會結束
很少有硬件中斷(不同於時鐘中斷自身)
為此,最簡明的解決方案是將進程的優先級提升為 REALTIME_PRIORITY_CLASS,將線程的優先級提升為 THREAD_PRIORITY_TIME_CRITICAL,從而阻止在同步期間線程被搶先。不幸的是,對於硬件中斷你沒有什麼可做的,但行為良好的驅動程序應該處理它們的中斷,排隊延期的過程調用(deferred procedure call, DPC),甚至以微秒級次序處理DPC。問題是你不能保證系統內所有驅動程序的行為都良好。事實上,即使在你系統裡只有乖巧聽話的驅動程序,你仍然會有許多中斷。
盡管如此,我們還是有一個可靠的同步方法,不必提升進程和線程的優先級。Figure 3 是這個方法實現步驟的基本流程圖。
Figure 3 可靠的同步
你需要不斷地檢查看系統時間是否變化,就像 Figure 2 所示的 simplistic_synchronize 實現一樣。同先前實現的最大不同之處是你現在也要用性能計數器本身去驗證你始終保持於希望的精確級別。這聽起來很簡單,但仔細看看 Figure 3 便會看出它並不像想象的那麼簡單。需要進一步的解釋,比如你為什麼在 prev_diff 變量中保存性能計數器最近兩個值之間的差異。原因是從系統時間被保存到t1的點到計數器值被保存到p1的點,系統時間可能會有潛在的變化而沒有被檢測到,直到下一次內部循環執行(才能檢測到系統時間變化)。
接下來,你可能錯誤地假設在最新的兩個計數器值之間(注:P1->P0)時間變化了,而實際上卻沒有。為了對此進行安全保證,你應該假定系統時間變化要麼在最新的兩個計數器值(注:即prev_diff)之間;要麼在先前的兩個計數器值之間(除了在循環內部發生了不可能的事件——該事件通過內部循環改變了最開始的時間)。在同步末尾,實現一個計數器值的調整;這可以保證返回值能夠在希望的精度之內。Figure 4 顯示了這個過程。
Figure 4 計算
這個同步方法需要多次迭代完成,但實際上還不能證明有問題。有關同步的更多信息及其精確性,你應該看一下本文副題 “同步:有多好?”。
頻率問題
盡管我們已經有了一個好的開端,仍有些問題需要解決。假設你及時在某些特定環節執行這個同步操作。然後,無論何時,只要你需要高精度的時間,就調用get_time。如早先講述,QueryPerfomanceCounter 報告的頻率被用於以高精度計算當前系統時間。由 get_time 報告的時間一定會同實際的時鐘時間發生很大偏差的,得到一個比你所獲得的精度大得多的值。這是因為性能計數器天生不是被用來計量長周期時間的。
我進行了一個小測試考察這個影響會有多大,以 2 微秒作為可接受的同步極限。(我選擇 2 微秒是因為我的 雙 PII 400HZ CPU 機子能得到最好結果),Figure 5 顯示了這個結果。
Figure 5 同步測試
測試結果證明,僅在 110 秒後我的高精度時鐘就偏離了實際系統時間1毫秒。速算一下表明性能計數器頻率報告中存在一個大約百萬分之九 的錯誤。一個0.000009的錯誤聽起來很微小,但是如果你想報告一個微秒以下的時間這就是一個很大的沖擊了。起初,我有兩個想法。第一,用戶負責定期的再同步,而且由此必須決定多長時間做一次。第二,同步由一個後台線程每n秒執行一次。
進行第一個想法的測試之前,我就決定反對它了。第二個想法似乎更加可行,我所能預見的唯一問題就是在客戶端和同步線程之間的必須的同步會產生一些開銷。使用上的簡單總是會增加復雜性和開銷的。
本文提供的下載例子中實現了用後台線程來同步性能計數器和系統時間。Figure 6 解釋了這個實現如何設法讓自己同實際系統時間保持接近(注意縱坐標現在被設為+/-100微秒)。
Figure 6 同步例子
Figure 6 顯示了某個 13 分鐘時段高精度時間偏離系統時間的情況。藍線顯示的是在偏離值達到所允許的系統時間偏離值(本例子中是 50 微秒)之前應用周期性再同步的情況。它也表明每次執行後在同步之間的時間增加值。這是因為當前實現的時間供應器適應了性能計數器所報告的頻率計量錯誤,並不斷地將之應用到內部的高精度時間計算上。
雖然藍線顯示的數據應用了平滑過濾,黃線顯示了與系統時間偏差的原始數據。這個過濾是實時完成的,並且這是實際用於決定性能計數器真正頻率以及高精度時間與系統時間之間偏離的數據。更多細節,請見下載的源代碼。
防止系統時間受到更改
另外還有系統時間變化的問題。無論何時發生這種事情,你必須立即再同步以便保證計算的時間是正確的。在 Windows 2000 和 Windwos XP 下這到這一點並不困難,因為每當設置系統系統時間時,系統總會廣播一個 WM_TIMECHANGE 消息到所有的頂層窗口。不幸的是,在 Windows NT 以及更早的版本這不是被強制的,盡管在 SDK 文檔中確實如是說:在改變系統時間後,應用程序應該發送這個消息到所有的頂層窗口。注意這個句子使用的是“應該”,所以你不能依賴每個人都這麼做。
為了透徹地理解這個問題,我應該說改變系統時間對於任何應用程序來說不什麼特別的事情。為了改變系統時間或相關的配置,需要啟用 SE_SYSTEMTIME_NAME 優先權。如果用戶沒有啟用這個權利,你可以在一個管理員帳戶下運行程序,要管理員將這個程序安裝為 Windows NT服務,或者要管理員給運行該程序的帳戶一個必須的權限。例如,對於 Windows NT 4.0 而言,你最希望的是系統管理員不會或者不允許安裝病態程序(即改變系統時間而不知會其它應用程序)。
所以你如何實際處理 WM_TIMECHNAGE 消息呢?既然你已經有了一個用於周期性同步的線程,唯一你要做的事情就是讓你的線程創建一個不可見的頂層窗口,並且,除了定期同步外還要運行一個消息循環。
時間調整
與 Windows NT 維護系統時間有關的還有另外一個問題。為了幫助軟件例如網絡時間協議(Network Time Protocol, NTP)客戶端同外部資源保持時間同步,Windows NT 暴露了一個SetSystemTimeAdjustment API。這個API有兩個參數,以100納秒為單位的時間調節器本身以及一個布爾值,它指示 Windows NT 是否禁用時間調節器。當啟用時間調節器時,系統會在每個時鐘中斷時加上指定的時間調節器的值。當禁用時,系統會用添加缺省的時間增量取而代之(在本文中它與時鐘間隔一樣),更多詳情見平台SDK文檔。
但是還有兩個問題。首先啟用(改變)時間調節器改變了參考頻率——時間流。第二,也是一個較大的問題,就是當系統時間被修改後,系統不發送啟用或禁止通知。即使以最小的 156250 個單位(1單位100納秒)缺省時間增量改變某個系統上的時間調節器,也將導致參考頻率 6.4PPM (1/156250) 的改變。再一次的,聽起來可能不多,但是考慮一下你要在50微秒內阻止系統時間起變化,那意味著幾秒之後如果沒有進行再同步,你就會超過極限。
為了減少這類調整的沖擊,時間供應器必須監視當前時間調節器的設置。不用借助於操作系統本身,通過調用SetSystemTimeAdjustment 的伙伴 API GetSystemTimeAdjustment 來實現。在足夠短的間隔內不斷地執行這個檢查並且根據需要調整內部頻率,你就能夠避免偏離系統時間太遠。
時間供應器
現在你已經對問題的各個方面有了較好的理解,我將對下載代碼中的 time_provider 類作一個簡單介紹。這個時間供應器是以參數化單模式方式實現的,為客戶提供了一個高精度,持續更新的時間:
template<
typename counter_type,
int KEEP_WITHIN_MICROS = 100,
int SYNCHRONIZE_THREAD_PRIORITY = THREAD_PRIORITY_BELOW_NORMAL,
int TUNING_LIMIT_PARTSPERBILLION = 100,
int MAX_WAIT_MILLIS = 10000,
int MIN_WAIT_MILLIS = 100
>
class time_provider
使用時間供應器獲得當前時間類似於使用 Windows API:
typedef hrt::time_provider<hrt::performance_counter>time_provider_t;
time_provider_t& provider=time_provider_t::instance();
SYSTEMTIME st;
provider.systemtime(&st);
Figure 7 解釋了time_provider 類可用的模板參數、類型定義和成員函數。你可能會對將不同的調諧參數被指定為模板參數感到奇怪。從我的觀點來看,他們全都是設計參數,並且可以在編譯時,根據你的應用程序的需求來確定。
Figure 8 的代碼示范了使用 time_provider 類在一個小循環中收集原始時間的例子,然後轉換和輸出。在下載的源代碼中你可以找到另外一個使用多線程的例子(在多線程環境中示范了同樣的想法)。
性能因子
那麼使用 time_provider 類獲得系統時間的開銷有多大呢?當你必須計算時間而不只是獲取時間時,一些額外的工作是不可避免的。如果你確實關心代碼某些臨界部分中的性能,使用 Figure 8 中所示的涉及原始計數器值的技術。使用原始值讓你延遲系統時間轉化,這樣不會立即產生額外的開銷(調用收集計數器本身的值除外,當然,這是不可避免的)。
Figure 9 的表格中顯示得很清楚,它給出了一個 Win32 API 相對於 time_provider 的性能評估。表格中的數字是相對於在Windows XP 對稱多處理器(SMP)系統上 GetSystemTime 執行時間的百分比(括號中的數字對應單處理器系統)。
我在本文前面曾提到,調用 QueryPerformanceCounter 的代價是不能忽略的,對於單處理器系統尤其如此。使用性能計數器API 調用的執行時間在對稱多處理系統上(SMP)通常要快得多。這是因為大多數對稱多處理系統的性能計數器中都實現了奔騰時戳計數器(time stamp counter, TSC),與單處理器系統實現比較調用開銷相對較低。
對於性能我稍微有點失望,即便沒有努力去優化計算。為了獲得較好的性能而喪失了可移植性,你可能嘗試使用其它計數器。time_provider 類在計數器類型上是參數化的,可用於其它高精度計數器。下載的源代碼中還有另外一個實驗類 tsc_counter ,可以直接使用奔騰 TSC 。對這個類的初步測試表明:它比使用性能計數器 API 有好得多的性能,甚至是(比性能計數器)在SMP機器上。當進行與 Figure 9 中同樣的測試時,tsc_counter 版本的時間供應器時鐘在 33%(文件時間),133%(系統時間)和 5.9%(原始時間)。
未來方向
當前的實現還有許多潛在的問題——鑒於問題的復雜性,對此不要感到驚訝。由於硬件兼容性所引起的問題,該代碼不可以用在任何可獲得的系統上,比如省電,CPU 超頻以及非持續性計數器。如果在這些條件下你找到辦法使這個供應器更可靠,請讓我知道。在決定使用該代碼之前你應該知道你的硬件平台。
為 .NET 和 COM 進行包裝肯定是可行的,允許時間供應器在除了C++語言之外語言中使用。實際上我已經實現了一個作為 COM 進程內服務器的時間供應器。
結論
如果你現在認為你可以獲得幾乎任意精度的系統時間,給一個小警告:不要忘記像 Windows NT 這樣的搶先式多任務系統,最好的情況下,你獲得的時戳僅僅是讀取性能計數器所花時間並將所讀內容轉化為絕對時間的時間差。最壞的情況下,時間流失會很容易地達到數十毫秒之多。
盡管這有可能預示著你所作的一切都毫無用處,但同時也不見得真的就如此。即使執行對 Win32 API GetSystemTimeAsFileTime (或者 Unix 下的 gettimeofday)的調用也受制於同樣的條件,所以你實際做的不會比那更遭。在大多數情況下,你會得到好的結果。只是不要對基於 Windows NT 的時間戳有任何實質性的預言。
背景知識
時間函數(Time Functions) :Inside Windows 2000,第三版,作者 David Solomon 和 Mark Russionvich(Microsoft Press, 2000)
性能計數器值可能會意外地向前跳躍(Performance Counter Value May Unexpectedly Leap Forward)
結束語
性能計數器(Performance Counter)的一些介紹:
在一些計算機硬件系統中,包含有高精度運行計數器,利用它可以獲得高精度定時間隔,其精度與 CPU 的時鐘頻率有關。采用這種方法的步驟如下:
1、首先調用 QueryPerformanceFrequency 函數取得高精度運行計數器的頻率f。單位是每秒多少次(n/s),此數一般很大。
2、在需要定時的代碼的兩端分別調用 QueryPerformanceCounter 以取得高精度運行計數器的數值n1,n2。兩次數值的差值通過f換算成時間間隔,t=(n2-n1)/f。
本文配套源碼