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() 周边变得模糊
  • 不对 DispatcherTimer 调用 Stop() 也不退订 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. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

伪随机数与真随机数的区别 - 如何区分的整理

本文整理伪随机数与真随机数的区别,重点不在输出外观而在生成器结构:普通 PRNG 重视可重现性、CSPRNG / DRBG 主打不可预测性、NRBG 则以物理熵源为基础。文中说明种子(seed)与重新播种(reseed)、健康检测(health test)的作用,并给出信息...

常见问题

汇总了咨询这一主题时常见的问题。

PeriodicTimer、System.Threading.Timer、DispatcherTimer 该怎么选?
想以 await 为基础自然地写固定间隔的 async 处理,先用 PeriodicTimer;想在 ThreadPool 上定期触发轻量 callback,用 System.Threading.Timer;想在 WPF 的 UI 线程更新画面,用 DispatcherTimer。判断时先看三件事:想在哪个线程运行、想不想用 async/await 连续写处理本体、能不能允许 callback 重叠。
为什么不该把 async lambda 直接传给 System.Threading.Timer?
因为 TimerCallback 是 void,传入的 async lambda 实质上变成 async void 式的处理。调用端无法 await、无法等待完成、异常管理困难,callback 的重叠也需要另外考虑。而且 System.Threading.Timer 不会等待上次 callback 完成,处理时间比间隔长就会重叠执行。处理本体是 async 的话,先考虑 PeriodicTimer 会更易读。
PeriodicTimer 会自动补回延迟的 tick 吗?
不会。上次处理拖长时,PeriodicTimer 不会自动并发执行来追赶,没在等待期间发生的多次 tick 也会被合并为 1 次。因此延迟时要跳过、只看最新一次,还是处理所有次数,需要自己以设计来决定。它的前提是 1 个计时器对应 1 个消费者,强项不是周期精度,而是能把「等待、处理、再等待」写成一条自然的 async 流程。
DispatcherTimer 有什么注意点?
DispatcherTimer 在 UI 线程运行,可以直接操作 UI,但在 Tick 处理器中放入沉重处理会连输入、绘制、重新布局都拖慢。它也不保证在指定时刻精确触发,会受 Dispatcher 队列中其他任务或优先级影响。实务上要把 Tick 的内容变轻,沉重的 I/O 或 CPU 计算移到后台,画面关闭时记得调用 Stop() 并取消 Tick 订阅,否则会拖住对象的生命周期。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表