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()周边变得模糊 - 不对
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 计时器选择真正重要的,比起名字的差异,是以下几点。
- 在哪里运行
- 想以怎样的流程书写
- 怎么处理重叠或延迟
先按以下方针,就相当能应对了。
- 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)
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
.NET 的 Native AOT 是什么 - 先厘清与 JIT、ReadyToRun、trimming 的区别
把 .NET 的 Native AOT 与 JIT、ReadyToRun、self-contained、single-file、trimming、source generator 放在一起厘清,并从启动、发布、依赖关系的角度整理它适合与不适合的场景,帮助读者判断该不该采用。
Windows Forms、WPF、WinUI 该怎么选 - 新建项目、存量资产、发布、UI 表现力判断表
从存量资产的规模、界面是表单为主还是需要表现力、现代 Windows UI 是否是产品刚需、发布与运维怎么落地这四个角度,整理 WinForms、WPF、WinUI 该怎么选的判断表,并提醒只想用 Windows App SDK 不必全面迁移到 WinUI。
将 .NET Framework 迁移到 .NET 之前该确认的事 - 动手前就决定成败的实战检查清单
整理将 .NET Framework 业务应用程序迁移到现代 .NET 之前必须先盘点的论点。涵盖落地版本、Windows 专用前提的取舍、不再支持的 API、共享库切分方式、第三方组件、运维与 CI/CD,帮助在动手前厘清范围并降低迁移风险。
伪随机数与真随机数的区别 - 如何区分的整理
本文整理伪随机数与真随机数的区别,重点不在输出外观而在生成器结构:普通 PRNG 重视可重现性、CSPRNG / DRBG 主打不可预测性、NRBG 则以物理熵源为基础。文中说明种子(seed)与重新播种(reseed)、健康检测(health test)的作用,并给出信息...
Windows的效率模式是什么 - Windows 11绿色叶子图标代表什么,以及如何关闭
整理 Windows 11 任务管理器中绿色叶子图标与效率模式的真正含义,以及它如何通过降低优先级与 EcoQoS 平衡前台响应、电池与散热。同时说明逐一进程的关闭步骤、灰色选项的处理,以及和 Microsoft Edge 节能功能的差异,以及性能比较时必须对齐的条件。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。