PeriodicTimer / System.Threading.Timer / DispatcherTimer 的區分使用 - 先整理 .NET 的定期執行
· 小村 豪 · C#, .NET, WPF, 計時器, 設計
上一篇 在一般 Windows 上盡可能實現軟即時的實踐指南 - 先看的檢核表 中,整理了避免依賴 Sleep 的周期循環,使用事件驅動或 waitable timer 的話題。
那麼在更日常的 .NET 應用程式開發中該怎麼做呢。
這裡容易迷惘的是 PeriodicTimer、System.Threading.Timer、DispatcherTimer。
名稱都是計時器,但,
- 用
await等待 tick 的計時器 - 從 ThreadPool 飛來 callback 的計時器
- 在 UI 執行緒的
Dispatcher上運作的計時器
性格相當不同。
在實務上容易混在一起的大致是這一帶。
- 明明是非同步的定期處理,卻傳
asynclambda 給System.Threading.Timer - 明明是 WPF 的 UI 更新,卻從 ThreadPool 計時器直接碰畫面
- 在
DispatcherTimer放入沉重處理,連畫面一起變慢 - 上一篇「軟即時」的話題,和日常應用程式的定期執行在腦袋中混在一起
本文以 .NET 6 以後的一般 C# / .NET 應用程式為前提,
按在日常實務上不易迷惘的順序整理 PeriodicTimer / System.Threading.Timer / DispatcherTimer。
對象例如以下。
- worker / 背景服務
- 主控台應用程式
- ASP.NET Core 的幕後處理
- WPF 的桌面應用程式
本文中所說的 DispatcherTimer 主要指 WPF 的 System.Windows.Threading.DispatcherTimer。
WinUI / UWP 也有相同思考方式的 DispatcherTimer。
WinForms 的話,作為 UI 用計時器看 System.Windows.Forms.Timer 比較自然。
另外,這裡處理的是該怎麼寫應用程式端的定期執行。 周期的正確性本身是主題時,回到上一篇軟即時文章的話題。
1. 先講結論(一句話)
- 想以
await為基礎自然地寫一定間隔的處理的話,先用PeriodicTimer - 想在 ThreadPool 上定期啟動輕量 callback 的話,用
System.Threading.Timer - 想在 WPF 的 UI 執行緒更新畫面的話,用
DispatcherTimer System.Threading.Timer的 callback 可能重疊。把非同步處理粗略塞進去容易雜亂DispatcherTimer能直接碰 UI 的代價是,放入沉重處理容易連 UI 一起停下- 在上一篇軟即時的脈絡中,這 3 個不是高精度等待的主角
簡言之,最先該看的是以下 3 個。
- 想在哪個執行緒 / 上下文運作
- 想用
async/await連續寫處理本體嗎 - 能允許 callback 的重疊嗎
光分開這 3 個,就會變得相當不迷惘。
2. 先用一頁整理
2.1. 整體圖
flowchart LR
A["想以一定間隔做某事"] --> B{"想在 UI 執行緒運作?"}
B -- "是" --> C["DispatcherTimer"]
B -- "否" --> D{"想用<br/>async / await<br/>自然地寫處理本體?"}
D -- "是" --> E["PeriodicTimer"]
D -- "否" --> F{"想在 ThreadPool<br/>轉輕量 callback?"}
F -- "是" --> G["System.Threading.Timer"]
F -- "否" --> H["也檢討其他設計<br/>Channel / BackgroundService / event / waitable timer"]
實務上大致這個分歧就夠。
迷惘時最不易偏離的是
非同步處理就 PeriodicTimer、UI 更新就 DispatcherTimer 先切開。
System.Threading.Timer 雖然方便,但有 callback 重疊或壽命管理的習性,
作為第 1 個選擇有點難應付。
2.2. 先的判斷表
| 狀況 | 先選的 | 執行的地方 | 適合的理由 | 先的注意點 |
|---|---|---|---|---|
| 想以一定間隔轉 HTTP / DB / 檔案 I/O 等 async 處理 | PeriodicTimer |
現在 async 方法的流程中 | 能以 await 為基礎寫、停止和取消自然 |
1 計時器 1 消費者前提。延遲不會自動並行化 |
| 想在 ThreadPool 轉輕量 heartbeat / 指標發送 / 快取過期檢查 | System.Threading.Timer |
ThreadPool | 輕量且 callback 型。容易搭上既有的 callback 基礎設計 | callback 以可重入為前提。可能重疊。需要保持參照 |
| 想以一定間隔轉 WPF 的時鐘顯示或輕量 UI 更新 | DispatcherTimer |
WPF 的 Dispatcher(UI 執行緒) |
可以直接碰 UI。能持有優先度 | 不保證精確觸發時刻。沉重處理會讓 UI 塞住 |
周期的正確性是本體,想避免依賴 Sleep |
不以這 3 個為主角 | - | 目的不是應用程式的定期執行而是等待精度的設計 | 看 event / waitable timer 側 |
這表重要的是比起計時器名稱,看執行位置和寫法。 計時器選擇出事時,常常是沒有看「在哪裡跑」而不是 API 名稱。
3. 先想區別的事
3.1. 是 callback 型還是等待 tick 型
這裡分開就相當容易整理。
System.Threading.Timer和DispatcherTimer是 callback / event 型PeriodicTimer是awaittick 等待型
也就是,
- callback 型是「計時器側呼叫過來」
PeriodicTimer是「這邊等待下一個 tick」
的差異。
處理本體是 async,
想把「等待 → 處理 → 再等待」當作 1 條流程閱讀的話,PeriodicTimer 比較自然。
相反地,
- 想搭上既有的 callback 基礎設計
- 處理本體短且同步
- 單純想定期踢一下
這樣的場面,System.Threading.Timer 合適。
PeriodicTimer 雖然方便,但不是萬能。
不是以對一個計時器同時發出多個 WaitForNextTickAsync 為前提,
沒在等待期間多次 tick 也會被摺成 1 次。
這裡不誤解為「會自動追上」是重要的。
3.2. 在 ThreadPool 運作還是在 UI 執行緒運作
接下來該看的是在哪裡執行。
System.Threading.Timer 的 callback 不是在建立的執行緒,而是在 ThreadPool 運作。
所以適合背景處理,但不是以直接碰 UI 為前提。
另一方面,DispatcherTimer 是整合到 Dispatcher 佇列的 UI 用計時器。
WPF 中,因為在同一 Dispatcher 上運作,所以 Tick 處理器中可以直接更新 UI。
這個差異相當大。
- 從 ThreadPool 計時器碰 UI 需要明確回到 UI
DispatcherTimer容易碰 UI,但會用掉 UI 執行緒的時間
也就是說,DispatcherTimer 的強處是「能安全碰 UI」,
但同時也意味著「放入沉重處理會連輸入・重繪也捲進來」。
3.3. 周期處理和精度保證是不同的話題
這裡作為與上一篇文章的連結很重要。
以一定間隔做某事,說法雖然相同,
- 因應用程式方便想每幾秒定期處理
- 想以 1ms~幾 ms 級盡可能接近 deadline
是不同的問題。
System.Threading.Timer 是輕量易用的計時器,
但不是為了精度的專用工具。
DispatcherTimer 也會受 Dispatcher 佇列的方便或優先度的影響。
PeriodicTimer 光看名字像「周期很嚴謹」,
但實務上的強處不是 precision,而是 async 流程的好寫。
所以,
- 想寫應用程式的定期執行
- 想調整等待精度
先分開比較安全。
這 2 個混在一起,計時器選擇的討論會逐漸走向奇怪的方向。
4. 典型模式
4.1. async 的定期處理就用 PeriodicTimer
在 worker 或 BackgroundService、主控台的常駐處理等中,
想以一定間隔轉 async 處理的話,先用 PeriodicTimer 比較好寫。
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class CacheRefreshWorker : BackgroundService
{
private readonly ILogger<CacheRefreshWorker> _logger;
public CacheRefreshWorker(ILogger<CacheRefreshWorker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("CacheRefreshWorker started.");
await RefreshCacheAsync(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RefreshCacheAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("CacheRefreshWorker stopping.");
}
}
private async Task RefreshCacheAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Refreshing cache...");
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
這個形式好的地方是以下。
- 程式碼流程容易以 1 條
async方法追蹤 CancellationToken容易直接傳給下游- 能減少 callback 基礎的壽命管理或例外管理
特別是處理本體是
- 呼叫 HTTP
- 查詢 DB
- 讀檔案
- await 其他 async API
這類以 I/O 等待為中心的話,相性相當好。
注意點有 2 個。
- 以 1 計時器 1 消費者為前提使用
- 處理時間比周期長時的方針自己決定
PeriodicTimer 不會因為之前的處理拖長就自動並行化追上。
在那個意義上,是為了「自然地寫一定間隔的 async 迴圈」的計時器。
到測試容易性來看,能用接收 TimeProvider 的建構子也樸素地方便。
4.2. 在 ThreadPool 轉輕量 callback 就用 System.Threading.Timer
只想定期呼叫短 callback 的話,System.Threading.Timer 很自然。
例如,
- 打 heartbeat
- 採輕量指標
- 放短期過期檢查
- 掛到既有的 callback 基礎設計上
這樣的場面。
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class HeartbeatService : IHostedService, IDisposable
{
private readonly ILogger<HeartbeatService> _logger;
private Timer? _timer;
private int _running;
public HeartbeatService(ILogger<HeartbeatService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(OnTimer, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
return Task.CompletedTask;
}
private void OnTimer(object? state)
{
if (Interlocked.Exchange(ref _running, 1) != 0)
{
return;
}
try
{
_logger.LogInformation("Heartbeat: {Now}", DateTimeOffset.Now);
}
finally
{
Volatile.Write(ref _running, 0);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
}
}
這例子放入 Interlocked.Exchange 是因為
System.Threading.Timer 不等待上次 callback 完成。
這裡相當重要。
- callback 在 ThreadPool 運作
- callback 以可重入為前提
- 處理比間隔長會重疊
所以處理不輕的情況,
- 跳過重複啟動
- 堆到佇列
- 靠向
PeriodicTimer
這樣設計比較穩妥。
另一個樸素重要的是保持參照。
System.Threading.Timer 即使運作中,沒有參照就會成為 GC 對象。
另外,即使是呼叫 Dispose() 直後,已經排隊的 callback 有時之後會跑。
也就是說 System.Threading.Timer,
- 輕量
- 快速
- 單純
但作為代價,callback 的方便由這邊好好承擔的計時器。
4.3. WPF 的 UI 更新就用 DispatcherTimer
在 WPF 中想定期更新畫面上的時鐘或輕量狀態顯示的話,DispatcherTimer 很自然。
using System;
using System.Windows;
using System.Windows.Threading;
public partial class MainWindow : Window
{
private readonly DispatcherTimer _clockTimer;
public MainWindow()
{
InitializeComponent();
_clockTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1)
};
_clockTimer.Tick += ClockTimer_Tick;
_clockTimer.Start();
}
private void ClockTimer_Tick(object? sender, EventArgs e)
{
ClockText.Text = DateTime.Now.ToString("HH:mm:ss");
}
protected override void OnClosed(EventArgs e)
{
_clockTimer.Stop();
_clockTimer.Tick -= ClockTimer_Tick;
base.OnClosed(e);
}
}
DispatcherTimer 好的地方是 Tick 在 WPF 的 Dispatcher 上處理。
所以可以直接碰 UI。
這個例如,
- 時鐘顯示
- 連線狀態的輕量顯示更新
- Command 的重新評估契機
- 畫面上出現的數值的輕量更新
這類場面相性好。
但是,這裡也有空氣變化的點。
DispatcherTimer 在 UI 執行緒運作,
在 Tick 處理器做沉重處理的話,會直接連輸入・繪圖・重排都捲入而變慢。
另外 DispatcherTimer 不是保證「指定時刻剛好」的工具。
會受 Dispatcher 佇列上其他工作或優先度的影響。
所以在實務上,
- 把 Tick 的內容變輕
- 沉重 I/O 或 CPU 逃到別處
- 關閉時做
Stop()和解除訂閱,明示壽命
意識到這些就會穩定。
4.4. 偏軟即時的周期處理就看別的工具
這裡是與上一篇文章的銜接點。
上一篇軟即時文章中處理的不是 「每幾秒大致運作就好」, 而是 怎麼減少周期的抖動或 deadline miss 的話題。
那個脈絡中,
- 不依賴
Sleep的相對等待 - 使用事件驅動或 waitable timer
- 分開 fast path 和 slow path
- 計測延遲
是主題。
所以,
- 日常應用程式的 async 的定期處理
→
PeriodicTimer - ThreadPool callback
→
System.Threading.Timer - UI 更新
→
DispatcherTimer - 周期精度本身是主角 → 上一篇文章的世界
這樣從一開始就分開問題比較漂亮。
「想每 1ms 盡可能準確轉。哪個 .NET 計時器好」這個提問, 有一半已經不是計時器選擇而是等待方法和設計的問題。
5. 常見的反面模式
5.1. 直接傳 async lambda 給 System.Threading.Timer
這個相當常做。
_timer = new Timer(async _ => await RefreshAsync(), null,
TimeSpan.Zero, TimeSpan.FromSeconds(5));
外觀清爽,但 TimerCallback 是 void。
也就是這個 async lambda 實質上是 async void 式的處理。
這樣的話,
- 呼叫端無法 await
- 無法等待完成
- 例外管理困難
- callback 的重疊需要另外考慮
是相當泥濘的狀態。
處理本體是 async 的話,先檢討 PeriodicTimer 比較好讀。
5.2. 在 DispatcherTimer 的 Tick 放入沉重處理
DispatcherTimer 能直接碰 UI,所以不知不覺想什麼都寫。
但是那裡是 UI 執行緒。
- 長的同步處理
- 沉重的 CPU 計算
- 阻塞 I/O
- 可能雙重啟動包含長
await的處理
放入的話,會和 UI 的輸入或繪圖正面衝突。
把 Tick 的內容變輕, 沉重的工作逃到背景,必要的結果才回到 UI 比較穩定。
5.3. 認為 PeriodicTimer 會自動補回延遲
這裡也容易誤解。
PeriodicTimer 作為乾淨地寫一定間隔 async 迴圈的工具雖然優秀,
但上次處理拖長時不會自動並行執行追上。
沒在等待期間的 tick 也會被摺成 1 次,
- 延遲就跳過嗎
- 只看最新就好嗎
- 必須處理所有次數分嗎
需要以設計來決定。
5.4. 把停止和壽命管理放後面
計時器,停止比運作更容易出事。
容易漏看的例如以下。
- 把
System.Threading.Timer做成區域變數不保持參照 - 不停止
System.Threading.Timer就讓Dispose()周邊曖昧 - 不
Stop()DispatcherTimer也不退訂 Tick - 關閉畫面後計時器也拖著物件壽命
特別 DispatcherTimer 會持續讓綁定方法的物件存活。
「這個 Window 明明關了還留著呢」這種奇怪感出現時,會想懷疑這裡。
6. 審查時的檢核表
- 那個周期處理該作為 UI 更新 / ThreadPool callback / async 迴圈的哪個寫,能說明嗎
- 處理本體明明是 async,卻強塞到 callback 型計時器了嗎
- 使用
System.Threading.Timer的話,能耐 callback 的重疊嗎,或是有做防護嗎 DispatcherTimer的 Tick 是否放入沉重處理、阻塞 I/O、長同步處理- 使用
PeriodicTimer的話,延遲時的方針是否已定 - 停止方法(
Change/Dispose/Stop)和應用程式終止時的流程是否明確 - 是否好好保持
System.Threading.Timer的參照 - 是否有
DispatcherTimer的退訂或畫面關閉時的善後 - 那個問題是「應用程式的定期執行」還是「等待精度」,是否從最初就分開
7. 大致的使用區分
實務上的基準大致如下。
-
想每 30 秒打 API 更新設定 →
PeriodicTimer -
想每 5 秒送 heartbeat 或輕量指標 →
System.Threading.Timer -
想在 WPF 做時鐘顯示或輕量狀態更新 →
DispatcherTimer -
想每個 Tick 直接碰 UI →
DispatcherTimer -
定期處理的本體充滿
await,也想自然處理停止或例外 →PeriodicTimer -
想以低成本放入 callback 基礎的小踢 →
System.Threading.Timer -
1~5ms 級的周期精度或抖動管理是本體 → 在這 3 個之前,看上一篇文章的等待方法
相當粗略以 1 行說,
PeriodicTimer是為 async 的計時器System.Threading.Timer是為 ThreadPool callback 的計時器DispatcherTimer是為 UI 的計時器
這個記法就不容易大幅偏離。
8. 總結
.NET 的計時器選擇真正重要的是比起名字的差異以下。
- 在哪裡運作
- 想以怎樣的流程寫
- 怎麼處理重疊或延遲
先的方針以下就相當能戰。
- async 的定期處理就
PeriodicTimer - 在 ThreadPool 轉輕量 callback 就
System.Threading.Timer - WPF 的 UI 更新就
DispatcherTimer - 精度是主角就看別的等待方法
計時器因為名字相似所以會混。 但角色沒那麼相似。
PeriodicTimer是整頓 async 流程的工具System.Threading.Timer是定期踢 callback 的工具DispatcherTimer是在 UI 執行緒定期更新的工具
光分開思考這 3 個,程式碼就會相當安靜。
相反地這裡混在一起,
- 明明是 async 卻變成
async void式 - 直接碰 UI 掉下來
- callback 重疊狀態變混濁
- 連周期精度的話題也一起混雜
這樣的相當普通麻煩的事會發生。
先從「想在哪裡運作」看。 光這樣計時器選擇就會相當穩妥。
9. 參考資料
- 相關文章:在一般 Windows 上盡可能實現軟即時的實踐指南 - 先看的檢核表
- 相關文章:C# 中 async/await 的最佳實踐 - 先看的判斷表
- 相關文章:以一頁整理 WPF / WinForms 的 async/await 和 UI 執行緒 - await 後的回歸處、Dispatcher、ConfigureAwait、.Result / .Wait() 的卡點
- Timers - .NET
- PeriodicTimer Class
- PeriodicTimer.WaitForNextTickAsync(CancellationToken) Method
- PeriodicTimer.Dispose Method
- PeriodicTimer Constructor
- Timer Class (System.Threading)
- Timer Constructor (System.Threading)
- Background tasks with hosted services in ASP.NET Core
- DispatcherTimer Class (System.Windows.Threading)
- DispatcherTimer Class (Microsoft.UI.Xaml)
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
把 Generic Host / BackgroundService 帶進桌面應用程式的理由 - 啟動・壽命・graceful shutdown 的整理會輕鬆很多
整理把 .NET Generic Host 與 BackgroundService 帶進 WPF / WinForms 桌面應用程式的理由,把啟動、lifetime、graceful shutdown 集中於入口管理。透過 StartAsync / ExecuteAsync...
.NET 的 Generic Host 是什麼 - 先整理 DI、設定、日誌、BackgroundService
本文整理 .NET Generic Host 的真面目,並把 DI、設定、日誌、IHostedService 與 BackgroundService 的關係,連同 Host.CreateApplicationBuilder 與 WebApplicationBuilder 的...
.NET 的 Native AOT 是什麼 - 先釐清與 JIT、ReadyToRun、trimming 的差異
把 .NET 的 Native AOT 與 JIT、ReadyToRun、self-contained、single-file、trimming、source generator 放在一起釐清,並從啟動、發布、相依性的角度整理它合適與不合適的情境,幫助讀者判斷該不該採用。
以一頁整理 WPF / WinForms 的 async/await 和 UI 執行緒 - await 後的回歸處、Dispatcher、ConfigureAwait、.Result / .Wait() 的卡點
本文以一頁的篇幅整理 WPF 與 WinForms 中 async/await 與 UI 執行緒的關係。從 await 後接續的回歸處、Dispatcher 與 Invoke 的選擇、ConfigureAwait(false) 真正的意義,到 .Result 與 .Wait...
FileSystemWatcher 的使用方法與注意事項 - 漏掉、重複通知、完成判定的陷阱
本文整理 FileSystemWatcher 的正確用法。把事件視為跡象而非完成通知,將通知摺疊為重新掃描請求,由傳送端以 temp 後 rename 明示完成,多 worker 以原子性 claim 取得所有權,最後以 full rescan 與 idempotency ...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
UI 執行緒 & 計時器
整理 WPF / WinForms UI 執行緒、非同步流程、Dispatcher 使用、計時器判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。