引用本文(DOI: 10.5281/zenodo.21615446)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《.NET 三种计时器的使用区分 - PeriodicTimer/Timer/DispatcherTimer》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615446 https://comcomponent.com/zh-CN/blog/2026/03/12/002-periodictimer-system-threading-timer-dispatchertimer-guide/
- DOI(最新版本)
- 10.5281/zenodo.21615446
- DOI(此版本)
- 10.5281/zenodo.22281960
在上一篇 尽可能在普通 Windows 上实现软实时的实践指南 - 先看的检查清单 中,整理了避开依赖 Sleep 的周期循环、改用事件驱动或 waitable timer 的做法。一句话概括,上一篇的结论是:想减少周期抖动和 deadline miss,就要先设计“等待方式”本身,而不是先挑计时器的种类。
那么在更日常的 .NET 应用开发中该怎么做呢。
这里容易犹豫的是 PeriodicTimer、System.Threading.Timer、DispatcherTimer。
名字都叫计时器,但是,
- 用
await等待 tick 的计时器 - 在 ThreadPool 上飞来 callback 的计时器
- 在 UI 线程的
Dispatcher上运行的计时器
三者的性格相当不同。
flowchart TB
accTitle: 名字相似但性格不同的三种计时器
accDescr: 展示 PeriodicTimer 是用 await 等待 tick 的计时器、System.Threading.Timer 是在 ThreadPool 上飞来 callback 的计时器、DispatcherTimer 是在 UI 线程的 Dispatcher 上运行的计时器这三者的性格差异。
t["三种计时器"] --> pt["PeriodicTimer:用 await 等待"]
t --> st["Timer:callback 飞过来"]
t --> dt["DispatcherTimer:UI 线程"]
图1:名字都叫计时器,但等待方式和运行场所的性格完全不同。
在实务上容易混在一起的,大致就是这些地方。
- 明明是异步的定期处理,却把
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 更自然。
本文的 UI 部分说明偏向 WPF,但 WinForms 也在讨论范围内。把 DispatcherTimer 换读成 System.Windows.Forms.Timer,4.3 和 5.2 的注意事项就能直接套用。在此基础上,只有下面 2 点不同。
System.Windows.Forms.Timer是通过消息循环触发 Tick 的单线程计时器,没有DispatcherTimer那样的优先级(DispatcherPriority)指定- 微软的文档中写明 精度的极限在 55 毫秒左右。它不适合细密的周期,这种情况下应该考虑 UI 用计时器以外的方案
另外,这里讨论的是 应用侧的定期执行该怎么写。 当 周期的准确性本身就是主题 时,就要回到上一篇软实时文章的话题。
flowchart TB
accTitle: 本文的讨论范围
accDescr: 展示这样的划分:应用侧的定期执行该怎么写属于本文范围,而当周期的准确性本身是主题时,就回到设计等待方式的软实时文章的话题。
q{"主题是什么"}
q -->|"应用的定期执行怎么写"| here["本文三种计时器的话题"]
q -->|"周期的准确性本身"| rt["设计等待方式的上一篇话题"]
图2:同样是“每隔一定间隔做点什么”,定期执行的写法和周期精度的设计是两个不同的问题。
另外,本文中出现的代码,已经作为一整套可以构建、可以运行的示例(PeriodicTimer / System.Threading.Timer 的库与控制台演示,以及验证 tick 合并和 callback 重叠的单元测试)在 GitHub 上公开。
periodictimer-system-threading-timer-dispatchertimer-guide - komurasoft-blog-samples (GitHub)
目录
- 先讲结论(一句话)
- 先用一页整理
- 2.1. 整体图
- 2.2. 先看判断表
- 先要区分清楚的事
- 3.1. 是 callback 型,还是等待 tick 型
- 3.2. 在 ThreadPool 上运行,还是在 UI 线程上运行
- 3.3. 周期处理和精度保证是两回事
- 典型模式
- 4.1. async 的定期处理就用
PeriodicTimer - 4.2. 想在 ThreadPool 上跑轻量 callback 就用
System.Threading.Timer - 4.3. WPF 的 UI 更新就用
DispatcherTimer - 4.4. 偏软实时的周期处理,要看别的工具
- 4.1. async 的定期处理就用
- 常见的反面模式
- 评审时的检查清单
- 大致的使用区分
- 总结
- 参考资料
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 18 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
1. 先讲结论(一句话)
- 想以
await为基础自然地写固定间隔的处理,首选PeriodicTimer - 想在 ThreadPool 上定期触发轻量 callback,用
System.Threading.Timer - 想在 WPF 的 UI 线程上更新界面,用
DispatcherTimer System.Threading.Timer的 callback 可能重叠。把异步处理草率地塞进去容易乱DispatcherTimer能直接操作 UI,代价是放入沉重处理容易把 UI 一起卡住- 在上一篇软实时的语境里,这 3 个都不是高精度等待的主角
概括来说,最先该看的是下面 3 点。
- 想让它在哪个线程 / 上下文中运行
- 想不想用
async/await把处理本体写成一条直线 - 能不能允许 callback 重叠
光是把这 3 点分开,就会少走很多弯路。
flowchart TB
accTitle: 最先要看的三个问题
accDescr: 只要把想在哪个线程或上下文运行、想不想用 async/await 把处理本体写成一条直线、能不能允许 callback 重叠这三点分开,就不容易犯错。
q1["想在哪里运行"] --> pick["计时器的选择就定下来了"]
q2["想不想用 async 写成一条直线"] --> pick
q3["能不能允许 callback 重叠"] --> pick
图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"]
图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 名称,而是因为没有看清“在哪里运行”。
flowchart TB
accTitle: 选计时器时的着眼点
accDescr: 选计时器出问题,往往是因为按 API 的名字来选;只要看在哪里运行这一运行场所和想怎么写这一书写方式,就不容易选错。
name["按名字选"] --> miss["没看在哪里运行而出问题"]
look["按运行场所和书写方式选"] --> hit["不容易选错"]
图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 次。
重要的是不要在这里误以为“它会自动追赶回来”。
flowchart TB
accTitle: callback 型与等待 tick 型
accDescr: 展示 System.Threading.Timer 和 DispatcherTimer 是由计时器那边调用过来的 callback 型,而 PeriodicTimer 是我们这边用 await 等待下一个 tick 的类型这一区别。
q{"属于哪一种类型"}
q -->|"callback 型"| cb["计时器那边调用过来"]
q -->|"等待 tick 型"| tick["我们这边等待下一个 tick"]
cb --> cbex["Timer 和 DispatcherTimer"]
tick --> ptex["PeriodicTimer"]
tick -.-> flow["等待、处理、再等待汇成一条流程"]
图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”,
但这同时也意味着“放入沉重处理就会把输入和重绘一起卷进去”。
flowchart TB
accTitle: 运行场所的差异与其代价
accDescr: System.Threading.Timer 的 callback 在 ThreadPool 上运行,想操作 UI 就必须显式切回;DispatcherTimer 能直接操作 UI,代价是占用 UI 线程的时间。
st["Timer:在 ThreadPool 上运行"] --> back["想操作 UI 就要显式切回"]
dt["DispatcherTimer:UI 线程"] --> easy["可以直接操作 UI"]
easy --> cost["沉重处理会把输入和绘制卷进去"]
图7:在哪里执行的差异,就是 UI 操作是否方便与 UI 线程被占用之间的取舍。
3.3. 周期处理和精度保证是两回事
这里作为与上一篇文章的衔接很重要。
虽然都说成“每隔一定间隔做点什么”,但是,
- 出于应用自身的需要,想每隔几秒做一次定期处理
- 想在 1ms~几 ms 级别上尽可能贴近 deadline
这是两个不同的问题。
System.Threading.Timer 是轻量好用的计时器,
但它不是为精度准备的专用工具。
DispatcherTimer 同样会受 Dispatcher 队列的状况和优先级影响。
PeriodicTimer 光看名字似乎“周期很严谨”,
但它在实务中的强项与其说是 precision,不如说是 async 流程写起来顺手。
所以,
- 是想写 应用的定期执行
- 还是想把 等待精度 抠到位
先分开来看更安全。
这两者一旦混在一起,选计时器的讨论就会渐渐跑偏。
flowchart TB
accTitle: 定期执行与等待精度的划分
accDescr: 每隔几秒做一次定期处理属于应用自身的需要,而想在毫秒级贴近 deadline 属于等待精度的问题,两者不同,且这三种计时器都不是为精度准备的专用工具。
same["想以固定间隔做点什么"] --> a["应用的定期执行"]
same --> b["想把等待精度抠到位"]
a --> timers["三种计时器登场"]
b --> design["转向等待方式本身的设计"]
b -.-> note["三种计时器都不是精度的专用工具"]
图8:即便“固定间隔”这个说法相同,定期执行和精度保证也要当作不同的问题来处理。
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);
}
}
这种写法的好处是,
- 代码流程能作为一条
async方法来追踪 CancellationToken容易原样传给下游- 能减少 callback 式的生命周期管理和异常管理
特别是当处理本体是
- 调用 HTTP
- 查询数据库
- 读取文件
- await 其他 async API
这种 以 I/O 等待为主 的场景时,契合度相当高。
有 2 点需要注意。
- 以 1 计时器 1 消费者为前提使用
- 处理时间比周期长时的方针,要自己来定
PeriodicTimer 并不会因为上一次处理拖长了,就自动并发执行来追赶。
从这个意义上说,它是一个用来“自然地写出固定间隔 async 循环”的计时器。
如果连测试的便利性也一并考虑,能使用接收 TimeProvider 的构造函数这一点也很实用。
flowchart TB
accTitle: 用 PeriodicTimer 实现定期循环的流程
accDescr: 用 WaitForNextTickAsync 等待下一个 tick,执行 async 的处理本体后再继续等待,可以写成一条流程,并通过 CancellationToken 的取消跳出循环。
wait["用 WaitForNextTickAsync 等待"] --> work["执行 async 的处理本体"]
work --> wait
wait -.->|"token 被取消"| exitl["跳出循环并停止"]
work -.-> note["即使延迟也不会自动并发化"]
图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
flowchart TB
accTitle: callback 的重叠与防护
accDescr: System.Threading.Timer 不会等待上一次 callback 完成,所以处理时间比间隔长就可能重叠。加入 Interlocked.Exchange 的防护后,并发执行仍保持为 1,执行期间触发的 callback 会被跳过。
fire["每隔一个间隔触发 callback"] --> q{"上一次还在执行中?"}
q -->|"无防护"| overlap["callback 重叠着运行"]
q -->|"有防护"| skip["跳过本次触发"]
skip --> one["并发执行仍保持为 1"]
图10:面对不等待上一次完成的计时器,是允许重叠还是用防护挡掉,要由自己来决定。
另一个不起眼但很重要的点,是 保持引用。
System.Threading.Timer 即使正在运行,一旦没有引用也会成为 GC 的回收对象。
另外,即使刚调用完 Dispose(),已经排入队列的 callback 也可能在之后才运行。
也就是说,System.Threading.Timer 虽然
- 轻量
- 快速
- 简单
但作为代价,它是一个需要由我们来妥善承担 callback 各种情况的计时器。
flowchart TB
accTitle: System.Threading.Timer 生命周期的注意事项
accDescr: System.Threading.Timer 即使正在运行,一旦没有引用也会成为 GC 的回收对象;而且即使刚调用完 Dispose,已经排入队列的 callback 也可能在之后才运行。
t["System.Threading.Timer"] --> ref["持续保持引用"]
ref -.-> gc["引用断掉就成为 GC 对象"]
t --> disp["用 Dispose 收尾"]
disp -.-> late["已排队的 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 内容保持轻量这个前提就更加重要了。
flowchart TB
accTitle: DispatcherPriority 的定位
accDescr: Background 是在其他所有非空闲处理都结束之后再处理的优先级,低于 Input 和 Render,不会为了运行 Tick 而挤掉输入或绘制。想尽快显示到界面上也可以提升到 Normal。
tick["DispatcherTimer 的 Tick"] --> bg["以 Background〔值 4〕排入队列"]
bg --> after["在输入和绘制之后被处理"]
after -.-> fit["适合不妨碍操作的时钟"]
bg -.-> up["着急就提升到 Normal〔值 9〕"]
up -.-> cond["相应地更需要把 Tick 保持轻量"]
图12:默认的 Background 优先级,把 Tick 排在“不挤掉输入和绘制”的位置上。
DispatcherTimer 的好处在于,Tick 是在 WPF 的 Dispatcher 上处理的,因此可以直接操作 UI。
这与下面这些场景契合度很好,例如,
- 时钟显示
- 连接状态的轻量显示更新
- 触发 Command 的重新求值
- 界面上数值的轻量更新
不过,这里也有情况为之一变的地方。
DispatcherTimer 运行在 UI 线程上,
所以在 Tick 处理器里做沉重处理,就会连输入、绘制、重新布局一起拖慢。
另外,DispatcherTimer 也不是保证“在指定时刻精确触发”的工具。
它会受 Dispatcher 队列上其他工作和优先级的影响。
所以在实务中,做到下面这些程度会更稳定。
- 把 Tick 的内容保持轻量
- 把沉重的 I/O 或 CPU 计算挪到别处
- 关闭时用
Stop()和退订来明确生命周期
flowchart TB
accTitle: 让 DispatcherTimer 稳定的三点
accDescr: 把 Tick 的内容保持轻量、把沉重的 I/O 或 CPU 处理挪到别处、关闭界面时用 Stop 和退订明确生命周期,靠这三点就能稳定下来。
dt["DispatcherTimer 的运维"] --> l1["把 Tick 的内容保持轻量"]
dt --> l2["把繁重的工作挪到后台"]
dt --> l3["用 Stop 和退订明确生命周期"]
l1 -.-> why["因为会占用 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 计时器好”这样的问题, 有一半已经不是选计时器,而是等待方式和设计的问题了。
flowchart TB
accTitle: 从一开始就把问题分成四类
accDescr: 日常应用中 async 的定期处理用 PeriodicTimer,ThreadPool callback 用 System.Threading.Timer,UI 更新用 DispatcherTimer,而周期精度本身是主角时则归入设计等待方式的软实时话题。
p["想做的事"] --> a["async:PeriodicTimer"]
p --> b["callback:Timer"]
p --> c["UI:DispatcherTimer"]
p --> d["精度:设计等待方式"]
图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 线程上的未处理异常,默认会让整个进程崩掉。除非自己用 try / catch 把 callback 内部包起来,否则外侧接不住。
flowchart TB
accTitle: 把 async lambda 传给 callback 后异常的去向
accDescr: TimerCallback 是 void,所以传进去的 async lambda 等同于 async void,异常会被重新抛到启动时的 SynchronizationContext 上,但 ThreadPool 上没有它,于是变成未处理异常,默认让整个进程崩掉。
ex["async lambda 内的异常"] --> void["以 async void 等价形式抛到外面"]
void --> ctx{"SynchronizationContext 在哪"}
ctx -->|"ThreadPool 上没有"| unh["变成 ThreadPool 的未处理异常"]
unh --> crash["默认让整个进程崩掉"]
ex -.-> guard["只能靠自己写的 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 次,所以
- 延迟了就跳过
- 只看最新一次就够
- 还是必须把所有次数都处理掉
这些都需要在设计上决定。
flowchart TB
accTitle: 无人等待期间 tick 的合并
accDescr: PeriodicTimer 在没有人等待的期间即使 tick 多次也会被合并成 1 次,所以延迟了要跳过、只看最新一次,还是要把所有次数都处理掉,需要在设计上决定。
idle["无人等待期间发生多次 tick"] --> fold["被合并成 1 次"]
fold --> q{"延迟该怎么处理"}
q --> s1["跳过"]
q --> s2["只看最新一次"]
q --> s3["处理所有次数"]
图16:积攒的 tick 会被合并成 1 次。追赶的方式不是自动的,而要靠设计来决定。
5.4. 把停止和生命周期管理往后拖
计时器这东西,让它跑起来容易,停下来才容易出事。
容易漏看的是这些地方。
- 把
System.Threading.Timer建成局部变量,没有保持引用 - 不停止
System.Threading.Timer,Dispose()相关的处理也含糊不清 - 不对
DispatcherTimer调用Stop(),Tick 的订阅也不解除 - 界面关闭之后,计时器仍然拖着对象的生命周期
特别是 DispatcherTimer,可能会让绑定了方法的对象一直存活。
一旦出现“这个 Window 明明关掉了却还留着”这种怪异感觉,就该怀疑这里了。
flowchart TB
accTitle: 停止与生命周期管理的陷阱
accDescr: 没有保持引用的 Timer 和含糊不清的 Dispose 都是让停止方式模糊不清的漏看点,而既不 Stop 也不解除 Tick 订阅的 DispatcherTimer 会让绑定对象一直存活,表现为本该关闭的 Window 仍然残留。
m1["没有保持 Timer 的引用"] --> trouble["停止方式一直含糊不清"]
m2["Dispose 相关处理含糊"] --> trouble
m3["既不 Stop 也不解除 Tick 订阅"] --> keep["让绑定对象一直存活"]
keep --> ghost["本该关闭的 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 点。
- 在哪里运行
- 想以怎样的流程来写
- 怎么处理重叠和延迟
作为方针,光靠这些就足以应付了。
- async 的定期处理就用
PeriodicTimer - 想在 ThreadPool 上跑轻量 callback 就用
System.Threading.Timer - WPF 的 UI 更新就用
DispatcherTimer - 精度是主角的话,就去看别的等待方式
计时器因为名字相似,所以容易混淆。 但它们的角色并没有那么相似。
PeriodicTimer是理顺 async 流程的工具System.Threading.Timer是定期触发 callback 的工具DispatcherTimer是在 UI 线程上定期更新的工具
光是把这 3 个分开来想,代码就会安静很多。
flowchart TB
accTitle: 三种计时器角色的总结
accDescr: 总结 PeriodicTimer 是理顺 async 流程的工具、System.Threading.Timer 是定期触发 callback 的工具、DispatcherTimer 是在 UI 线程上定期更新的工具这三者的角色差异。
t["计时器的角色"] --> pt["PeriodicTimer:async"]
t --> st["Timer:callback"]
t --> dt["DispatcherTimer:UI"]
t -.-> rt["要精度就转向等待设计"]
图18:名字虽然相似,角色却并不相似。按这三类来记,就不会大幅跑偏。
反过来,这里一旦混在一起,
- 明明是 async 却变成了
async void的样子 - 直接操作 UI 而崩掉
- callback 重叠导致状态变浑浊
- 连周期精度的话题也一锅端
这些相当常见又相当麻烦的事情就会发生。
先从“想在哪里运行”看起。 光是这样,选计时器这件事就会平稳很多。
9. 参考资料
- 本文的整套示例代码(库、演示、单元测试) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/periodictimer-system-threading-timer-dispatchertimer-guide
- 相关文章:尽可能在普通 Windows 上实现软实时的实践指南 - 先看的检查清单
- 相关文章:C# async/await 实务判断表 - Task.Run 与 ConfigureAwait
- 相关文章:用一页整理 WPF/WinForms 的 async 与 UI 线程
- 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)
- DispatcherTimer Constructor(默认为 Background 优先级)
- DispatcherPriority Enum
- Timer Class (System.Windows.Forms)(精度在 55 毫秒左右)
- Async/Await - Best Practices in Asynchronous Programming(async void 的异常处理)
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在桌面应用中使用 .NET Generic Host 与 BackgroundService 的理由
本文整理在 Windows 工具或常驻应用中,如何使用 Generic Host 与 BackgroundService 来梳理启动、定期处理、退出处理、日志、配置与 DI。
多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事
面向 .NET/C# 梳理防止“线程一开就偶尔崩溃、偶尔卡死”的设计做法,涵盖不自己创建线程而依托 Task、减少共享可变状态、加锁的纪律、用 CancellationToken 设计停止流程,直到 UI 线程的处理方式。
业务系统的编码设计 ── 商品编码・客户编码的确定方法与校验位
确定商品编码・客户编码等业务系统编码体系的实践指南。整理了有意义编码与无意义流水号的判断表、JAN・Luhn等校验位算法及C#实现、Excel开头零丢失的应对方法,直至位数溢出与迁移。
WinForms / WPF 应用的 CI/CD 实践 ── 用 GitHub Actions 实现从构建到签名、发布的自动化
一份用 GitHub Actions 搭建 WinForms / WPF 应用 CI/CD 的实务指南。整理了在 windows-latest 上构建+测试的最小 YAML、标签驱动的版本编号、集成 signtool 完成签名,以及按 MSI/MSIX/ClickOnce/...
Windows 应用的任务栏托盘常驻与 Toast 通知 —— NotifyIcon 的坑与 AppNotification 的选型
本文整理了将业务 Windows 应用常驻在任务栏托盘(通知区域)并通过 Toast 通知告知用户的实现要点。内容涵盖 NotifyIcon 的正确用法与「关闭后驻留托盘」的设计、资源管理器重启后的重新注册、三种 Toast API(Windows App SDK AppN...
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
UI 线程 & 计时器
整理 WPF / WinForms UI 线程、异步流程、Dispatcher 使用、计时器判断的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
在包含定期执行、UI 更新、后台处理的 Windows 应用开发 中,计时器的选型会直接影响实现质量。
技术咨询 & 设计评审
如果正处在梳理 PeriodicTimer 与 DispatcherTimer 的职责该在哪里划分的阶段,可以按技术咨询与设计评审来考虑。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 中想定期更新界面时使用,比如时钟显示或轻量的状态显示。因为 Tick 是在 WPF 的 Dispatcher(UI 线程)上处理的,所以能在处理器里直接操作 UI,这是它的强项。不过既然运行在 UI 线程上,一旦在 Tick 里放入沉重处理或阻塞 I/O,就会把输入和绘制一起拖慢。而且它也不保证在指定时刻精确触发。把 Tick 的内容保持轻量,把繁重的工作挪到后台,关闭界面时用 Stop() 和退订来明确生命周期,这样运行会更稳定。