PeriodicTimer / System.Threading.Timer / DispatcherTimer 的區分使用 - 先整理 .NET 的定期執行

· · C#, .NET, WPF, 計時器, 設計

上一篇 在一般 Windows 上盡可能實現軟即時的實踐指南 - 先看的檢核表 中,整理了避免依賴 Sleep 的周期循環,使用事件驅動或 waitable timer 的話題。

那麼在更日常的 .NET 應用程式開發中該怎麼做呢。 這裡容易迷惘的是 PeriodicTimerSystem.Threading.TimerDispatcherTimer

名稱都是計時器,但,

  • await 等待 tick 的計時器
  • 從 ThreadPool 飛來 callback 的計時器
  • 在 UI 執行緒的 Dispatcher 上運作的計時器

性格相當不同。

在實務上容易混在一起的大致是這一帶。

  • 明明是非同步的定期處理,卻傳 async lambda 給 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 個。

  1. 想在哪個執行緒 / 上下文運作
  2. 想用 async / await 連續寫處理本體嗎
  3. 能允許 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.TimerDispatcherTimer 是 callback / event 型
  • PeriodicTimerawait tick 等待型

也就是,

  • 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 計時器 1 消費者為前提使用
  2. 處理時間比周期長時的方針自己決定

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));

外觀清爽,但 TimerCallbackvoid。 也就是這個 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 的計時器選擇真正重要的是比起名字的差異以下。

  1. 在哪裡運作
  2. 想以怎樣的流程寫
  3. 怎麼處理重疊或延遲

先的方針以下就相當能戰。

  1. async 的定期處理就 PeriodicTimer
  2. 在 ThreadPool 轉輕量 callback 就 System.Threading.Timer
  3. WPF 的 UI 更新就 DispatcherTimer
  4. 精度是主角就看別的等待方法

計時器因為名字相似所以會混。 但角色沒那麼相似。

  • PeriodicTimer 是整頓 async 流程的工具
  • System.Threading.Timer 是定期踢 callback 的工具
  • DispatcherTimer 是在 UI 執行緒定期更新的工具

光分開思考這 3 個,程式碼就會相當安靜。

相反地這裡混在一起,

  • 明明是 async 卻變成 async void
  • 直接碰 UI 掉下來
  • callback 重疊狀態變混濁
  • 連周期精度的話題也一起混雜

這樣的相當普通麻煩的事會發生。

先從「想在哪裡運作」看。 光這樣計時器選擇就會相當穩妥。

9. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽