天天看點

高精度定時器實作 z

1背景Permalink

.NET Framework 提供了四種定時器,然而其精度都不高(一般情況下 15ms 左右),難以滿足一些場景下的需求。

在進行媒體播放、繪制動畫、性能分析以及和硬體互動時,可能需要 10ms 以下精度的定時器。這裡不讨論這種需求是否合理,它是确實存在的問題,也有相當多的地方在讨論,說明這是一個切實的需求。然而,實作它并不是一件輕松的事情。

這裡并不涉及核心驅動層面的定時器,隻分析在 .NET 托管環境下應用層面的高精度定時器實作。

Windows 不是實時作業系統,是以任何方案都無法絕對保證定時器的精度,隻是能盡量減少誤差。是以,系統的穩定性不能完全依賴于定時器,必須考慮失去同步時的處理。

2等待政策Permalink

想要實作高精度定時器,必然需要等待和計時兩種基礎功能。等待用來跳過一定時間間隔,計時可以進行時間檢查,用以調整等待時間。

等待政策實際就是兩種:

  • 自旋等待:讓 CPU 空轉消耗時間,占用大量 CPU 時間,但是時間高度可控。
  • 阻塞等待:線程進入阻塞狀态,出讓 CPU 時間片,在等待一定時間後再由作業系統排程回到運作狀态。阻塞時不占用 CPU,然而需要作業系統排程,時間難以控制。

可以看到二者各有優劣,應該按照不同需求進行不同的實作。

而計時機制可以說能用的隻有一種,就是

Stopwatch

類。它内部使用了系統 API QueryPerformanceCounter/ QueryPerformanceFrequency來進行高精度計時,依賴于硬體,它的精度可以高達幾十納秒,非常适合用來實作高精度定時器。

是以難點在于等待政策,下面先分析簡單的自旋等待。

2.1自旋等待Permalink

可以使用

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阻塞等待Permalink

阻塞等待會把控制權交給作業系統,這樣就必須確定作業系統能夠及時的将定時器線程排程回運作狀态。預設情況下,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.SleepPermalink

它的參數使用毫秒機關,是以最多隻能精确到 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.WaitOnePermalink

WaitHandle.WaitOne

Thread.Sleep

類似,參數也是毫秒機關。

不同之處是,沒有任務負載的情況下(純粹循環調用

WaitOne(1)

),阻塞時長穩定在 1.5ms;而有任務負載時,則可能僅阻塞近乎于 0 的時間(猜測是它僅阻塞到目前時間片結束,尚未找到具體的文檔說明)。是以它阻塞的時長範圍是 0 到 2ms 多。

WaitHandle.WaitOne(0)

是用來測試等待句柄狀态的,它并不阻塞,是以用它來進行誤差修正類似于自旋,但不如直接使用自旋可靠。

Socket.PollPermalink

Socket.Poll

方法的參數是以微秒為機關,理論上,它是使用了網卡的硬體來定時,精度很高。然而,由于阻塞的實作仍然要依賴線程,是以它也隻能達到 1ms 的精度。

它的優勢是比

Thread.Sleep

WaitHandle.WaitOne

要更穩定,誤差也更小,可以不需要修正,但要占用一個 Socket 端口。

沒有任務負載的情況下(純粹循環調用

Poll(1)

),阻塞時長穩定在 1ms;而有任務負載時,則和

WaitOne

類似,可能僅阻塞近乎于 0 的時間。是以它阻塞的時長範圍是 0 到 1ms 多。

Socket.Poll(0)

是用來測試 Socket 狀态的,但它會阻塞,而且可能阻塞高達 6ms,是以不能用它來進行誤差修正。

timeSetEventPermalink

timeSetEvent和之前提到的

timeBeginPeriod

一樣屬于 winmm.dll 提供的多媒體定時器功能。它可以直接當作定時器使用,也是提供 1ms 的精度。在不需要的時候使用timeKillEvent來關閉。

它的穩定性和精度也很高,如果需要 1ms 的定時,而又不能使用自旋,那麼這是最理想的方案。

雖然 MSDN 上說

timeSetEvent

是個過時的方法,應該用

CreateTimerQueueTimer

替換。但是

CreateTimerQueueTimer

精度和穩定性都不如多媒體定時器,是以在需要高精度的時候,隻能使用

timeSetEvent

3定時器實作Permalink

需要注意的是,無論自旋還是阻塞,顯然定時器都應該運作在獨立的線程,不能幹擾使用方線程工作。而對于高精度定時器來說,觸發事件以執行任務的線程一般都在定時器線程内,而不是再使用獨立的任務線程。

這是因為高精度定時場景下,執行任務的時間開銷很可能大于定時器的時間間隔,如果預設就在其它線程執行任務,可能導緻占用大量線程。是以應該把控制權交給使用者,讓使用者在需要的時候自行排程任務執行的線程。

3.1觸發模式Permalink

由于在定時器線程執行任務,是以定時器的觸發就産生了三種模式。以下是它們的說明和主循環僞代碼:

固定時間架構
比如間隔 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線程優先級Permalink

最好把線程優先級調高,以保證定時器能夠穩定工作,減少被搶占的機會。然而需要注意,這在 CPU 資源不足時可能導緻低優先級線程的饑餓。也就是說不能讓高優先級線程去等待低優先級線程改變狀态,很有可能低優先級線程沒有機會運作,導緻死鎖或類似死鎖 的狀态。(見一種類似的饑餓的例子)

線程的最終優先級和程序的優先級有關,是以有時候也需要提高程序優先級(見 C# 中的多線程系列的線程優先級說明)。

4其它Permalink

還有兩點需要注意:

  1. 線程安全:定時器在獨立線程運作,其暴露的成員都應該實作線程安全,否則在定時器運作時調用可能會産生問題。
  2. 及時釋放資源:多媒體定時器、等待句柄、線程等等這些都是系統資源,在不需要它們的時候應該及時釋放/銷毀。

如何檢視系統定時器精度?Permalink

簡單的檢視可以使用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

)資訊。

參考:

  1. http://www.codeproject.com/Articles/98346/Microsecond-and-Millisecond-NET-Timer
  2. http://www.codeproject.com/Articles/571289/Obtaining-Microsecond-Precision-in-NET
  3. http://www.pinvoke.net/default.aspx/winmm/timeSetEvent.html
  4. http://www.geisswerks.com/ryan/FAQS/timing.html
  5. http://omeg.pl/blog/2011/11/on-winapi-timers-and-their-resolution/
  6. https://randomascii.wordpress.com/2013/07/08/windows-timer-resolution-megawatts-wasted/
  7. http://www.windowstimestamp.com/description
高精度定時器實作 z