.NET 三種計時器的區分使用 - PeriodicTimer/Timer/DispatcherTimer

· 更新日期: · · C#, .NET, WPF, 計時器, 設計

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279366)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616266)
初次發布
引用本文(DOI: 10.5281/zenodo.21616265)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈.NET 三種計時器的區分使用 - PeriodicTimer/Timer/DispatcherTimer〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616265 https://comcomponent.com/zh-TW/blog/2026/03/12/002-periodictimer-system-threading-timer-dispatchertimer-guide/

DOI(最新版本)
10.5281/zenodo.21616265
DOI(此版本)
10.5281/zenodo.22297096

上一篇 在一般的 Windows 上盡量實現軟即時的實踐指南 - 最先要看的檢查清單 梳理了如何避開交給 Sleep 的週期迴圈,改用事件驅動或 waitable timer。用一句話說,上一篇的結論是:想減少週期的浮動與 deadline miss,就要比挑選計時器種類更早一步,先設計「等待方式」本身。

那麼,在更日常的 .NET 應用程式開發中該怎麼做? 這裡最容易迷惘的,是 PeriodicTimer、System.Threading.Timer、DispatcherTimer。

名稱都叫計時器,但

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

三者的性格相差不小。

名稱相似但性格不同的三種計時器呈現 PeriodicTimer 是用 await 等待 tick 的計時器、System.Threading.Timer 是從 ThreadPool 送來 callback 的計時器、DispatcherTimer 是在 UI 執行緒的 Dispatcher 上運作的計時器,這三者性格差異的圖。三種計時器PeriodicTimer: 用 await 等待Timer: callback 送過來DispatcherTimer: UI 執行緒

圖 1: 名稱都叫計時器,但等待方式與運作場所的性格完全不同。

實務上容易混在一起的,大致是這幾種。

  • 明明是非同步的定期處理,卻把 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 會比較自然。 本文的 UI 說明偏向 WPF,但 WinForms 同樣在討論範圍內。把 DispatcherTimer 讀成 System.Windows.Forms.Timer,4.3 與 5.2 的注意事項就能直接套用。在此之上,只有下面 2 點不同。

  • System.Windows.Forms.Timer 是透過訊息迴圈觸發 Tick 的單執行緒計時器,沒有像 DispatcherTimer 那樣的優先權(DispatcherPriority)指定
  • Microsoft 的文件寫明 精度大約以 55 毫秒為極限。它不適合細緻的週期,這種情況要考慮 UI 用計時器以外的選項

另外,這裡處理的是 應用程式端的定期執行該怎麼寫。 當 週期的正確性本身就是主題 時,就回到上一篇軟即時文章的話題。

本文的守備範圍呈現本文的範圍是應用程式端的定期執行該怎麼寫,而當週期的正確性本身就是主題時就回到設計等待方式的上一篇話題,這個切分的圖。應用程式定期執行的寫法週期的正確性本身主題是什麼本文三種計時器的話題設計等待方式的上一篇話題

圖 2: 即使同樣是「每隔一定間隔做點事」,定期執行的寫法和週期精度的設計也是不同的問題。

另外,本文出現的程式碼,已經整理成可以建置與執行的完整範例(PeriodicTimer / System.Threading.Timer 的函式庫與主控台示範,以及驗證 tick 摺疊與 callback 重疊的單元測試),公開在 GitHub 上。

periodictimer-system-threading-timer-dispatchertimer-guide - komurasoft-blog-samples (GitHub)

目錄

  1. 先講結論(一句話)
  2. 先用一張圖整理
    • 2.1. 全貌
    • 2.2. 初步的判斷表
  3. 先要區分的事
    • 3.1. 是 callback 型,還是等待 tick 的型
    • 3.2. 在 ThreadPool 上執行,還是在 UI 執行緒上執行
    • 3.3. 週期處理與精度保證是兩回事
  4. 典型模式
    • 4.1. 非同步的定期處理就用 PeriodicTimer
    • 4.2. 要在 ThreadPool 上跑輕量的 callback 就用 System.Threading.Timer
    • 4.3. WPF 的 UI 更新就用 DispatcherTimer
    • 4.4. 偏向軟即時的週期處理,就看別的工具
  5. 常見的反面模式
  6. 審查時的檢查清單
  7. 大致的取捨
  8. 總結
  9. 參考資料

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 18 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

1. 先講結論(一句話)

  • 想以 await 為基礎自然地寫出固定間隔的處理,先看 PeriodicTimer
  • 想在 ThreadPool 上定期啟動輕量的 callback,用 System.Threading.Timer
  • 想在 WPF 的 UI 執行緒上更新畫面,用 DispatcherTimer
  • System.Threading.Timer 的 callback 可能重疊。把非同步處理隨手塞進去容易亂掉
  • DispatcherTimer 能直接操作 UI,代價是放進沉重的處理就容易讓整個 UI 停住
  • 在上一篇軟即時的脈絡裡,這 3 個都不是高精度等待的主角

歸根究柢,最先該看的是下面 3 件事。

  1. 想在哪個執行緒 / 執行內容(context)上執行
  2. 想不想用 async / await 把處理本體寫成一條直線
  3. 能不能容忍 callback 重疊

只要把這 3 件事分開,就不容易再迷惘。

最先要看的三個問題呈現只要分開想在哪個執行緒或執行內容上執行、想不想用 async/await 把處理本體寫成一條直線、能不能容忍 callback 重疊這三件事,就不容易迷惘的圖。想在哪裡執行計時器的選擇就定了想不想用 async 寫成一條直線能不能容忍 callback 重疊

圖 3: 比起計時器的名稱,先把這三個問題分開來想。

2. 先用一張圖整理

2.1. 全貌

是否是否是否想每隔一定間隔做點事想在 UI 執行緒上執行?DispatcherTimer想把處理本體用 async / await直接了當地寫?PeriodicTimer想在 ThreadPool 上跑輕量的callback?System.Threading.Timer也考慮別的設計Channel / BackgroundService / event / waitable timer

圖 4: 依 UI 執行緒、想不想用 async 寫、是不是輕量 callback 的順序切分,三種計時器就決定了。

實務上大致有這個分支就夠了。迷惘時最不容易選錯的,是先切出 非同步處理就用 PeriodicTimer,UI 更新就用 DispatcherTimer。

System.Threading.Timer 很方便,但 callback 的重疊與生命週期管理有它的脾氣, 拿來當第一個上手的選擇稍嫌難搞。

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 名稱,而是沒有看「在哪裡執行」。

選計時器的著眼點呈現選計時器會出事是因為照 API 名稱挑選,而看在哪裡執行的執行場所與想怎麼寫的寫法就不容易選錯的圖。照名稱挑選沒看在哪裡執行而出事照執行場所與寫法挑選變成不容易選錯的選擇

圖 5: 多數事故都出在照名稱挑。該看的是「在哪裡執行」與「想怎麼寫」。

3. 先要區分的事

3.1. 是 callback 型,還是等待 tick 的型

把這裡分開,一下子就更容易看清全貌。

  • System.Threading.Timer 與 DispatcherTimer 是 callback / event 型
  • PeriodicTimer 是用 await 等待 tick 的型

也就是說,

  • callback 型是「由計時器那邊呼叫過來」
  • PeriodicTimer 是「由我們這邊等待下一次 tick」

差別就在這裡。

處理本體是 async, 而且想把「等待→處理→再等待」讀成一條流程時,PeriodicTimer 比較自然。

反過來,在

  • 想接上既有的 callback 式設計
  • 處理本體很短而且是同步的
  • 只是想單純地定期觸發

這些場面,System.Threading.Timer 比較合用。

PeriodicTimer 雖然方便,但不是萬能。 它不以「對同一個計時器同時發出多個 WaitForNextTickAsync」為前提, 而且在沒有人等待的期間即使 tick 了好幾次,那些也會被摺疊成 1 次。

不要把這裡誤解成「它會自動追上」,這點很重要。

callback 型與等待 tick 的型呈現 System.Threading.Timer 與 DispatcherTimer 是由計時器那邊呼叫過來的 callback 型,而 PeriodicTimer 是由我們這邊用 await 等待下一次 tick 的型,兩者差異的圖。callback 型等待 tick 的型是哪一種型由計時器那邊呼叫過來由我們這邊等待下一次 tickTimer 與 DispatcherTimerPeriodicTimer等待→處理→再等待寫成一條

圖 6: 是「被呼叫」還是「去等待」。光是這個區分,就能一下子看清全貌。

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」, 但這同時也意味著「放進沉重的處理,就會把輸入與重繪一起捲進來」。

執行場所的差異與取捨呈現 System.Threading.Timer 的 callback 在 ThreadPool 上執行,要操作 UI 必須明確切回,而 DispatcherTimer 可以直接操作 UI 但會佔用 UI 執行緒時間的圖。Timer: 在 ThreadPool 上執行要操作 UI 必須明確切回DispatcherTimer: UI 執行緒可以直接操作 UI沉重的處理會把輸入與繪製捲進來

圖 7: 在哪裡執行的差異,換來的是操作 UI 的方便與 UI 執行緒的消耗。

3.3. 週期處理與精度保證是兩回事

這裡是和上一篇文章的連接點,很重要。

即使同樣說「每隔一定間隔做點事」,

  • 基於應用程式本身的需要,想每隔幾秒做一次定期處理
  • 想在 1ms 到數 ms 的等級上,盡量貼近 deadline

這兩者是不同的問題。

System.Threading.Timer 是輕量好用的計時器, 但它不是為精度而生的專用工具。 DispatcherTimer 也會受到 Dispatcher 佇列的狀況與優先權影響。

PeriodicTimer 光看名字好像「週期會很整齊」, 但它在實務上的強項與其說是 precision,不如說是 async 流程好寫。

所以,

  • 想寫的是 應用程式的定期執行
  • 還是想把 等待精度 壓到位

最好一開始就分開,比較安全。

這兩者混在一起,選計時器的討論就會慢慢往奇怪的方向跑。

定期執行與等待精度的切分呈現每隔幾秒的定期處理這種應用程式需要,和想在毫秒等級貼近 deadline 的等待精度問題並不相同,三種計時器也不是為精度而生的專用工具的圖。想每隔一定間隔做點事應用程式的定期執行想把等待精度壓到位三種計時器上場走向等待方式本身的設計三種計時器不是精度的專用工具

圖 8: 即使「一定間隔」這個說法相同,定期執行與精度保證也要當成不同的問題來處理。

4. 典型模式

4.1. 非同步的定期處理就用 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);
    }
}

這個寫法的好處是,

  • 程式碼的流程可以當成一條 async 方法來追
  • CancellationToken 可以直接往下游傳
  • 能減少 callback 式的生命週期管理與例外管理

尤其當處理本體是

  • 呼叫 HTTP
  • 查詢 DB
  • 讀取檔案
  • await 其他 async API

這種 以 I/O 等待為主 的情況,相當合用。

注意事項有 2 個。

  1. 以 1 個計時器 1 個消費者為前提使用
  2. 處理時間比週期長時的方針,要自己決定

PeriodicTimer 並不會因為上一次處理拖長,就自動平行化來追上進度。 就這層意義來說,它是為了「自然地寫出固定間隔的 async 迴圈」而存在的計時器。

如果連測試的容易度也一起看,可以使用接受 TimeProvider 的建構函式,這點雖然不起眼卻很有用。

PeriodicTimer 定期迴圈的流程呈現用 WaitForNextTickAsync 等待下一次 tick、執行 async 的處理本體、再繼續等待,可以寫成一條流程,並在 CancellationToken 取消時離開迴圈的圖。token 取消用 WaitForNextTickAsync 等待執行 async 的處理本體離開迴圈並停止落後也不會自動平行化

圖 9: PeriodicTimer 能把「等待→處理→再等待」寫成一條 async 方法。

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 以可重入為前提
  • 處理時間比間隔長,就可能重疊

這個「可能重疊」,在範例的單元測試裡已經做成可以實際觀測的形式。對週期 50ms 的計時器放進耗時 300ms 的處理,計算同時執行數的最大值會變成 2 以上。加上與前面相同的 Interlocked.Exchange 防護之後,相同條件下同時執行數的最大值仍然是 1,取而代之的是執行期間觸發的 callback 會被跳過。

// 摘自 tests/KomuraSoft.TimerSelection.Tests/ThreadPoolTimerOverlapTests.cs

// 沒有防護: 週期 50ms 對上耗時 300ms 的處理。等到觀測出重疊為止
bool overlapped = await WaitUntilAsync(
    () => Volatile.Read(ref maxObserved) >= 2,
    TimeSpan.FromSeconds(10));

Assert.True(overlapped, "timer callbacks did not overlap within the timeout.");

// 有防護: 相同條件下同時執行數的最大值仍然是 1
Assert.Equal(1, Volatile.Read(ref maxConcurrent));

如果處理並不輕量,

  • 跳過重複啟動
  • 排入佇列
  • 改用 PeriodicTimer

這樣設計會比較平穩。

callback 的重疊與防護呈現 System.Threading.Timer 不會等待上一次 callback 完成,處理比間隔長就可能重疊,而加上 Interlocked.Exchange 的防護後同時執行數維持 1,執行期間觸發的 callback 會被跳過的圖。沒有防護有防護每隔一段間隔觸發 callback上一次還在執行中?callback 重疊執行跳過這次觸發同時執行數維持 1

圖 10: 對不等待上一次完成的計時器,要自己決定是容許重疊,還是用防護擋掉。

另一個雖然不起眼卻很重要的,是 保持參考。 System.Threading.Timer 即使正在運轉,只要參考消失就會成為 GC 的對象。 而且就算剛呼叫完 Dispose(),已經排入佇列的 callback 仍可能在之後執行。

也就是說,System.Threading.Timer 雖然

  • 輕量
  • 快
  • 單純

但代價是 callback 的種種狀況要由我們自己確實承擔。

System.Threading.Timer 的生命週期注意事項呈現 System.Threading.Timer 即使正在運轉,只要參考消失就會成為記憶體回收的對象,而且剛呼叫完 Dispose 之後已排入佇列的 callback 仍可能執行的圖。System.Threading.Timer持續保持參考參考斷掉就成為 GC 對象用 Dispose 收拾已排入佇列的 callback 可能後續才執行

圖 11: 換來輕量的代價,是參考的保持與 Dispose 之後的 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);
    }
}

傳給建構函式的 DispatcherPriority.Background,指定的是 Tick 要以 Dispatcher 佇列的哪個優先權來處理。不帶引數的 new DispatcherTimer() 預設也是 Background,所以這裡只是把預設值寫明,並沒有改變行為。Background(值 4)是「等其他所有非閒置處理都結束之後再處理」的優先權,位置比 Input(5)與 Render(7)都低。也就是說,它不會為了跑 Tick 而擠掉輸入處理或繪製。像時鐘顯示這種「稍微偏一點也沒關係,但不想妨礙操作」的用途,和它很合。想讓 Tick 的結果盡快出現在畫面上,也可以把優先權提到 Normal(9),不過那樣一來,把 Tick 的內容保持輕量的前提就更強了。

DispatcherPriority 的定位呈現 Background 是等其他所有非閒置處理都結束之後再處理的優先權,比 Input 與 Render 低,不會擠掉輸入或繪製來跑 Tick,想快點顯示到畫面上也可以提到 Normal 的圖。DispatcherTimer 的 Tick以 Background〔值 4〕排入在輸入與繪製之後才處理適合不妨礙操作的時鐘要快就提到 Normal〔值 9〕相對地前提是 Tick 要保持輕量

圖 12: 預設的 Background 優先權,把 Tick 排在「不擠掉輸入與繪製」的位置。

DispatcherTimer 的好處在於,Tick 是在 WPF 的 Dispatcher 上處理,因此可以直接操作 UI。

這一點例如和

  • 時鐘顯示
  • 連線狀態的輕量顯示更新
  • 觸發 Command 的重新評估
  • 畫面上顯示的數值的輕量更新

這類場面就很合用。

不過,這裡也有讓情況為之一變的地方。

DispatcherTimer 在 UI 執行緒上執行, 所以在 Tick 處理常式裡做沉重的處理,就會連輸入、繪製、重新排版一起捲進來變慢。

而且,DispatcherTimer 也不是保證「剛好在指定時刻」觸發的工具。 它會受到 Dispatcher 佇列上其他工作與優先權的影響。

所以在實務上,

  • 把 Tick 的內容保持輕量
  • 沉重的 I/O 或 CPU 處理移到別處
  • 關閉時用 Stop() 與解除訂閱把生命週期寫清楚

意識到這種程度,運作就會穩定。

讓 DispatcherTimer 穩定的三點呈現把 Tick 的內容保持輕量、沉重的 I/O 或 CPU 處理移到別處、關閉畫面時用 Stop 與解除訂閱把生命週期寫清楚這三點就會穩定的圖。DispatcherTimer 的維運把 Tick 的內容保持輕量沉重的工作移到背景用 Stop 與解除訂閱寫明生命週期因為會佔用 UI 執行緒的時間

圖 13: 換來直接操作 UI 的方便,就得把 Tick 保持輕量並寫明生命週期。

4.4. 偏向軟即時的週期處理,就看別的工具

這裡是和上一篇文章的接點。

上一篇軟即時的文章談的, 不是「每隔幾秒大致動起來就好」, 而是 該怎麼減少週期的浮動與 deadline miss。

在那個脈絡下,

  • 不採用交給 Sleep 的相對等待
  • 使用事件驅動或 waitable timer
  • 把 fast path 與 slow path 分開
  • 量測落後量

才是主題。

所以,

  • 日常應用程式中 async 的定期處理 → PeriodicTimer
  • ThreadPool callback → System.Threading.Timer
  • UI 更新 → DispatcherTimer
  • 週期精度本身就是主角 → 上一篇文章的世界

像這樣一開始就把問題分開,會很乾淨。

「想每 1ms 盡量準確地跑一次。該用哪個 .NET 計時器?」這樣的問題, 有一半左右已經不是選計時器,而是等待方式與設計的問題。

一開始就把問題分成四類呈現日常應用程式中 async 的定期處理用 PeriodicTimer、ThreadPool callback 用 System.Threading.Timer、UI 更新用 DispatcherTimer、週期精度本身是主角時則歸到設計等待方式的軟即時話題的圖。想做的事async: PeriodicTimercallback: TimerUI: DispatcherTimer精度: 等待方式的設計

圖 14: 在問「該用哪個計時器」之前,先把問題本身分成四類會比較乾淨。

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 Task,例外會載在 Task 上,呼叫端 await 的時候就能收到。等同於 async void 的寫法沒有那個 Task,所以拋出的例外會 直接被重新拋回該方法開始時有效的 SynchronizationContext。System.Threading.Timer 的 callback 在 ThreadPool 上執行,而那裡沒有 SynchronizationContext。結果例外就變成 ThreadPool 執行緒上的未處理例外,預設會讓整個處理程序當掉。除非自己在 callback 裡用 try / catch 包一層,否則外側接不住。

把 async lambda 傳給 callback 之後例外的去向呈現 TimerCallback 是 void,所以傳入的 async lambda 等同於 async void,例外會被重新拋回開始時的 SynchronizationContext,但 ThreadPool 上沒有這個內容,因此成為未處理的例外並在預設情況下讓整個處理程序當掉的圖。ThreadPool 上沒有async lambda 內的例外以等同 async void 的形式往外跑SynchronizationContext 在嗎?變成 ThreadPool 的未處理例外預設會讓整個處理程序當掉只能靠自己的 try / catch 防住

圖 15: 外觀清爽的 async lambda,它的例外沒有地方接住,就這樣讓處理程序當掉。

如果處理本體是 async,先考慮 PeriodicTimer 會比較好讀。

5.2. 在 DispatcherTimer 的 Tick 裡放進沉重的處理

DispatcherTimer 可以直接操作 UI,於是不知不覺就什麼都想往裡面寫。 但那裡是 UI 執行緒。

  • 冗長的同步處理
  • 沉重的 CPU 計算
  • 會阻塞的 I/O
  • 含有長時間 await、可能重複啟動的處理

放進這些,就會和 UI 的輸入與繪製正面衝突。

把 Tick 的內容保持輕量, 沉重的工作移到背景,只把需要的結果送回 UI,這樣比較穩定。

5.3. 以為 PeriodicTimer 會自動把落後補回來

這裡也很容易誤解。

PeriodicTimer 作為把固定間隔的 async 迴圈寫得漂亮的工具很優秀, 但上一次處理拖長時,它不會自行平行執行來追上進度。

這一點在範例的單元測試裡也能查看。把週期 250ms 的計時器在沒有人等待的狀態下擱置 1.5 秒之後再等待,累積下來的份會讓第 1 次等待立刻完成,但第 2 次不會立刻完成。也就是說,擱置期間好幾次份的 tick 被摺疊成了 1 次。

// 摘自 tests/KomuraSoft.TimerSelection.Tests/PeriodicTimerBehaviorTests.cs

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250));
await Task.Delay(TimeSpan.FromMilliseconds(1500)); // 這段期間沒有人在等待

// 第 1 次等待會因為累積的 tick 而立刻完成
ValueTask<bool> first = timer.WaitForNextTickAsync();
Assert.True(first.IsCompleted);
Assert.True(await first);

// 第 2 次等待不會立刻完成(多次份的 tick 沒有留下來)
ValueTask<bool> second = timer.WaitForNextTickAsync();
Assert.False(second.IsCompleted);

沒有人等待期間的 tick 也可能被摺疊成 1 次,所以

  • 落後時要跳過
  • 只看最新一次就好
  • 還是一定要把每一次都處理完

必須由設計決定。

沒有人等待期間 tick 的摺疊呈現 PeriodicTimer 在沒有人等待的期間即使 tick 好幾次也會被摺疊成 1 次,因此落後時要跳過、只看最新一次,還是要處理全部次數,都必須由設計決定的圖。沒有人等待期間多次 tick摺疊成 1 次落後要怎麼處理?跳過只看最新一次處理全部次數

圖 16: 累積的 tick 會摺疊成 1 次。追上的方式不是自動的,而是由設計決定。

5.4. 把停止與生命週期管理往後拖

計時器比起讓它動起來,停下來的時候更容易出事。

容易漏掉的大致是這幾點。

  • 把 System.Threading.Timer 建成區域變數就算了,沒有保持參考
  • 沒有停掉 System.Threading.Timer,Dispose() 相關的處理也含糊帶過
  • 沒有對 DispatcherTimer 呼叫 Stop(),Tick 的訂閱也沒有解除
  • 畫面關閉之後,計時器還在拖住物件的生命週期

尤其是 DispatcherTimer,有時會讓方法所繫結的物件一直活著。 當出現「這個 Window 明明關掉了,怎麼還留著」這種怪怪的感覺時,就會想懷疑這一點。

停止與生命週期管理的陷阱呈現沒有保持參考的 Timer 與含糊的 Dispose 會讓停止方式一直含糊不清,而沒有 Stop 也沒有解除 Tick 訂閱的 DispatcherTimer 會讓繫結對象一直活著,表現為關掉的 Window 仍然留著的圖。沒有保持 Timer 的參考停止方式一直含糊不清Dispose 相關處理含糊既不 Stop 也不解除 Tick 訂閱讓繫結對象一直活著明明關掉的 Window 還留著

圖 17: 計時器比起讓它動起來,停下來時更容易出事。生命週期的善後要一開始就寫進去。

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 個之前,先看上一篇文章的等待方式

相當粗略地用一行來說,

  • PeriodicTimer 是為了 async 的計時器
  • System.Threading.Timer 是為了 ThreadPool callback 的計時器
  • DispatcherTimer 是為了 UI 的計時器

就是這樣。

用這種記法,不容易大幅偏離。

8. 總結

選 .NET 的計時器時真正重要的,不是名稱的差別,而是這 3 點。

  1. 在哪裡執行
  2. 想用什麼樣的流程來寫
  3. 重疊與落後要怎麼處理

作為方針,光是這些就夠用了。

  1. 非同步的定期處理就用 PeriodicTimer
  2. 要在 ThreadPool 上跑輕量的 callback 就用 System.Threading.Timer
  3. WPF 的 UI 更新就用 DispatcherTimer
  4. 精度才是主角的話,就看別的等待方式

計時器因為名稱相似而容易混在一起。 但角色其實沒那麼像。

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

光是把這 3 個分開來想,程式碼就會安靜許多。

三種計時器角色的總結呈現 PeriodicTimer 是整理 async 流程的工具、System.Threading.Timer 是定期觸發 callback 的工具、DispatcherTimer 是在 UI 執行緒上定期更新的工具,這些角色差異的圖。計時器的角色PeriodicTimer: asyncTimer: callbackDispatcherTimer: UI要精度就走向等待設計

圖 18: 名稱相似但角色並不相似。用這三種分類來記,就不會大幅偏離。

反過來,這裡混在一起的話,

  • 本該是 async 的卻變得像 async void
  • 直接操作 UI 而當掉
  • callback 重疊而讓狀態變得混濁
  • 連週期精度的話題也被混為一談

這些相當常見又麻煩的事就會發生。

首先從「想在哪裡執行」開始看。 光是這樣,選計時器就會平順許多。

9. 參考資料

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

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

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

常見問題

整理諮詢這個主題時常見的問題。

PeriodicTimer 與 System.Threading.Timer 有什麼差異?
最大的差異在於,PeriodicTimer 是用 await 等待 tick 的型態,而 System.Threading.Timer 是 callback 型態。PeriodicTimer 可以把「等待→處理→再等待」寫成一條 async 方法的流程,CancellationToken 也容易往下游傳。另一方面,System.Threading.Timer 的 callback 在 ThreadPool 上執行,而且不會等待上一次 callback 完成,因此處理時間比間隔長時就可能重疊。非同步的定期處理適合用 PeriodicTimer,輕量同步 callback 的定期觸發則適合 System.Threading.Timer。
PeriodicTimer 會在處理落後時自動追上嗎?
不會。上一次處理拖長,並不代表它會自行平行執行來追上進度。在沒有人等待的期間即使 tick 了好幾次,那些也會被摺疊成 1 次。因此落後時要跳過、只看最新一次就好,還是一定要把每一次都處理完,必須由設計端自己決定。它也不以對同一個計時器同時發出多個 WaitForNextTickAsync 為前提。
不能把 async lambda 傳給 System.Threading.Timer 嗎?
最好避免。TimerCallback 是 void,所以傳進去的 async lambda 實際上等同於 async void 的待遇。呼叫端無法 await、無法等待完成、例外管理也變得困難,而且還得另外考慮 callback 的重疊。如果處理本體是 async,先考慮 PeriodicTimer 會更好讀也更安全。
DispatcherTimer 該在什麼時候使用?
在 WPF 中想定期更新 UI 時使用,例如時鐘顯示或輕量的狀態顯示。因為 Tick 是在 WPF 的 Dispatcher(UI 執行緒)上處理,所以強項是可以在處理常式裡直接操作 UI。不過既然在 UI 執行緒上執行,只要在 Tick 裡放進沉重的處理或會阻塞的 I/O,就會連輸入與繪製一起拖慢。而且它不保證在指定時刻剛好觸發。把 Tick 的內容保持輕量,沉重的工作移到背景,關閉畫面時用 Stop() 與解除訂閱把生命週期寫清楚,運作才會穩定。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽