.NET中有多少種定時器一文介紹過.NET中至少有6種定時器,但精度都不是特別高,一般在15ms~55ms之間。在一些特殊場景,可能需要高精度的定時器,這就需要我們自己實現了。本文將討論高精度定時器實現的思路。
高精度定時器
一個定時器至少需要考慮三部分功能:計時、等待、觸發模式。計時是進行時間檢查,調整等待的時間;等待則是用來跳過指定的時間間隔。觸發模式是指定時器每次Tick的時間固定還是每次定時任務時間間隔固定。比如定時器時間間隔10ms,定時任務耗時7ms,是每隔10ms觸發一次定時任務,還是等定時任務執行完後等10ms再觸發下一個定時任務。
計時
Windows提供了可用於獲取高精度時間戳或者測量時間間隔的API。系統原生API是QueryPerformanceCounter (QPC)
。在.NET中提供了System.Diagnostics.Stopwatch
類獲取高精度時間戳,它內部也是透過QueryPerformanceCounter (QPC)
進行高精度計時。
QueryPerformanceCounter (QPC)
使用硬體計數器作為其基礎。硬體計時器由三個部分組成:時鐘週期生成器、計數時鐘週期的計數器和檢索計數器值的方法。這三個分量的特徵決定了QueryPerformanceCounter (QPC)
的解析度、精度、準確性和穩定性[1]。它的精度可以高達幾十納秒,用來實現高精度定時器基本沒什麼問題。
等待
等待策略通常有兩種:
- 自旋:讓CPU空轉等待,一直佔用CPU時間。
- 阻塞:讓執行緒進入阻塞狀態,出讓CPU時間片,滿足等待時間後切換回執行狀態。
自旋等待
自旋等待可以使用Thread.SpinWait(int iteration)
來實現,引數iteration
是迭代次數。由於CPU速度可能是動態的,所以很難根據iteration
計算消耗的時間,最好是結合Stopwatch
使用:
void Spin(Stopwatch w, int duration)
{
var current = w.ElapsedMilliseconds;
while ((w.ElapsedMilliseconds - current) < duration)
Thread.SpinWait(5);
}
由於自旋是以消耗CPU為代價的,上述程式碼執行時,CPU處於滿負荷工作狀態(使用率持續保持100%左右),因此短暫的等待可以考慮自旋,長時間執行的定時器不太建議使用該方法。
阻塞等待
阻塞等待需要作業系統能夠及時把定時器執行緒排程回執行狀態。預設情況下,Windows的系統的計時器精度為15ms左右。如果是執行緒阻塞,出讓其時間片進行等待,然後再被排程執行的時間至少是一個時間切片15ms左右。要透過阻塞實現高精度計時,則需要減少時間切片的長度。Windows系統API提供了timeEndPeriod
可以把計時器精度修改到1ms,在使用計時器服務之前立即呼叫timeEndPeriod
,並在使用完計時器服務後立即呼叫timeEndPeriod
。timeEndPeriod
和timeEndPeriod
必須成對出現。
在Windows 10, version 2004之前,
timeEndPeriod
會影響全域性Windows設定,所有程式都會使用修改後的計時精度。從Windows 10, version 2004開始,只有呼叫timeEndPeriod
的程式收到影響。
設定更高的精度可以提高等待函式中超時間隔的準確性。 但是,它也可能會降低整體系統效能,因為執行緒計劃程式更頻繁地切換任務。 高精度還可以阻止 CPU 電源管理系統進入節能模式。 設定更高的解析度不會提高高解析度效能計數器的準確性。[2]
通常我們使用Thread.Sleep來掛起執行緒等待,Sleep的引數最小為1ms,但實際上很不穩定,實測發現大部分時候穩定在阻塞2ms。我們可以採用Sleep(0)或者Thread.Yield
結合Stopwatch
計時的方式修正。
void wait(Stopwatch w, int duration)
{
var current = w.ElapsedMilliseconds;
while ((w.ElapsedMilliseconds - current) < duration)
Thread.Sleep(0);
}
Thread.Sleep(0)和Thread.Yield在 CPU 高負載情況下非常不穩定,可能會產生更多的誤差。因此誤差修正最好透過自旋方式實現。
還有一種阻塞的方式是多媒體定時器timeSetEvent
,也是網上關於高精度定時器提得比較多的一種方式。它是winmm.dll
中的函式,穩定性和精度都比較高,能提供1ms的精度。
官方文件中說timeSetEvent
是一個過時的方法,建議使用CreateTimerQueueTimer
替代[3]。但CreateTimerQueueTimer
的精度和穩定性都不如多媒體定時器,所以在需要高精度定時器時,還是要用timeSetEvent
。以下是封裝多媒體定時器的例子
public enum TimerError
{
MMSYSERR_NOERROR = 0,
MMSYSERR_ERROR = 1,
MMSYSERR_INVALPARAM = 11,
MMSYSERR_NOCANDO = 97,
}
public enum RepeateType
{
TIME_ONESHOT=0x0000,
TIME_PERIODIC = 0x0001
}
public enum CallbackType
{
TIME_CALLBACK_FUNCTION = 0x0000,
TIME_CALLBACK_EVENT_SET = 0x0010,
TIME_CALLBACK_EVENT_PULSE = 0x0020,
TIME_KILL_SYNCHRONOUS = 0x0100
}
public class HighPrecisionTimer
{
private delegate void TimerCallback(int id, int msg, int user, int param1, int param2);
[DllImport("winmm.dll", EntryPoint = "timeGetDevCaps")]
private static extern TimerError TimeGetDevCaps(ref TimerCaps ptc, int cbtc);
[DllImport("winmm.dll", EntryPoint = "timeSetEvent")]
private static extern int TimeSetEvent(int delay, int resolution, TimerCallback callback, int user, int eventType);
[DllImport("winmm.dll", EntryPoint = "timeKillEvent")]
private static extern TimerError TimeKillEvent(int id);
private static TimerCaps _caps;
private int _interval;
private int _resolution;
private TimerCallback _callback;
private int _id;
static HighPrecisionTimer()
{
TimeGetDevCaps(ref _caps, Marshal.SizeOf(_caps));
}
public HighPrecisionTimer()
{
Running = false;
_interval = _caps.periodMin;
_resolution = _caps.periodMin;
_callback = new TimerCallback(TimerEventCallback);
}
~HighPrecisionTimer()
{
TimeKillEvent(_id);
}
public int Interval
{
get { return _interval; }
set
{
if (value < _caps.periodMin || value > _caps.periodMax)
throw new Exception("invalid Interval");
_interval = value;
}
}
public bool Running { get; private set; }
public event Action Ticked;
public void Start()
{
if (!Running)
{
_id = TimeSetEvent(_interval, _resolution, _callback, 0,
(int)RepeateType.TIME_PERIODIC | (int)CallbackType.TIME_KILL_SYNCHRONOUS);
if (_id == 0) throw new Exception("failed to start Timer");
Running = true;
}
}
public void Stop()
{
if (Running)
{
TimeKillEvent(_id);
Running = false;
}
}
private void TimerEventCallback(int id, int msg, int user, int param1, int param2)
{
Ticked?.Invoke();
}
}
觸發模式
由於定時任務執行時間不確定,並且可能耗時超過定時時間間隔,定時器的觸發可能會有三種模式:固定時間框架,可推遲時間框架,固定等待時間。
- 固定時間框架:儘量按照設定的時間來執行任務,只要任務不是始終超時,就可以回到原來的時間框架上
- 可推遲時間框架:也是儘量按照設定的時間執行任務,但是超時的任務會推遲時間框架。
- 固定等待時間:不管任務執行時長,每次任務執行結束到下一次任務開始執行間的等待時間固定。
假定時間間隔為10ms,任務執行的時間在7~11ms之間,下圖中顯示了三種觸發模式的區別。
其實還有一種觸發模式:任務執行時長大於時間間隔時,只要時間間隔一到,就執行定時任務,多個定時任務併發執行。之所以這裡沒有提及這種模式,是因為在高精度定時場景中,執行任務的時間開銷很有可能大於定時器的時間間隔,如果開啟新執行緒執行定時任務,可能會佔用大量執行緒,這個需要結合實際情況考慮如何執行定時任務。這裡討論的是預設在定時器執行緒上執行定時任務。
https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps#low-level-hardware-clock-characteristics ↩︎
https://learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod?redirectedfrom=MSDN ↩︎
https://learn.microsoft.com/en-us/previous-versions//dd757634(v=vs.85)?redirectedfrom=MSDN ↩︎