定时器会在一段延迟之后,或按固定间隔重复运行代码:每分钟刷新一次缓存、每 30 秒保存一次草稿、轮询某个服务、更新时钟。.NET 有好几个看起来很像的定时器类,但它们的回调在哪里运行、你如何控制它们都各不相同。本页逐一介绍,另外还会讲用来测量耗时的 Stopwatch。
该用哪种定时器
| 定时器 | 回调运行在 | 风格 | 适用于 |
|---|---|---|---|
System.Threading.Timer | 线程池 | 回调委托 | 服务和库中的后台工作 |
System.Timers.Timer | 线程池(默认) | Elapsed 事件,Start/Stop | 偏好事件的组件式代码 |
PeriodicTimer(.NET 6+) | 你的异步循环所在的地方 | await WaitForNextTickAsync() | 异步代码;触发永不重叠 |
System.Windows.Forms.Timer、WPF DispatcherTimer | UI 线程 | Tick 事件 | 在桌面应用中更新控件 |
前两种在线程池线程上触发,所以它们的回调可能与程序的其他部分同时运行,甚至彼此同时运行。UI 定时器在 UI 线程上运行,这就是它们能直接操作控件的原因,也是处理程序一慢窗口就会卡住的原因。
System.Threading.Timer
它的构造函数接收回调、一个状态对象、第一次触发前的延迟,以及周期。创建后立即开始计时。
示例输出:
tick 1 at ~200 ms
tick 2 at ~500 ms
tick 3 at ~800 ms
tick 4 at ~1100 ms
stopped after 4 ticks
这里有两个要点。Main 必须等待(done.WaitOne());控制台程序会在 Main 返回时退出,而定时器回调运行在后台线程上,这些线程不会让进程保持存活。另外,定时器放在一个静态字段里:没有任何引用的 System.Threading.Timer 可能会被垃圾回收,然后它就会悄无声息地停止触发。最常踩到这个坑的情况,是把定时器作为局部变量创建在一个会返回的方法中。
Change(dueTime, period) 会重新安排定时器;两个参数都传 Timeout.Infinite 会暂停它,再传一组新值就会重新启动。Dispose() 会彻底停止它。
System.Timers.Timer
System.Timers.Timer 把同样的机制包装成了基于事件的 API:设置 Interval,订阅 Elapsed,然后调用 Start() 和 Stop()。
示例输出:
autosave #1
autosave #2
autosave #3
autosave stopped
AutoReset = false 会得到一个一次性定时器:它触发一次后就停止,再次调用 Start() 会安排下一次单次触发。Enabled = true 和 false 分别等同于 Start() 和 Stop()。这里写出完整名称 System.Timers.Timer,是因为 System.Threading 里也有一个 Timer;两个命名空间都导入时,单写 Timer 会产生歧义,无法编译。
System.Timers.Timer 还有一个 SynchronizingObject 属性,可以在 WinForms 中把 Elapsed 调度到 UI 线程上。实际上,直接用 UI 框架自己的定时器更简单。
回调会重叠,并运行在其他线程上
上面两个类都在线程池上按计划触发,不管上一次回调有没有结束。如果一次回调要 3 秒,而周期是 1 秒,就会有三个回调同时运行。这带来了两项责任:
- 回调访问的任何东西都必须是线程安全的。 计数器用
Interlocked(就像示例中那样),更复杂的东西用 lock。 - 在需要时防止重叠。 要么在上一次还在运行时跳过这次触发,要么使用一种不可能重叠的模式。
用 Interlocked 实现的跳过保护:
private int running = 0;
private void OnTick(object state)
{
if (Interlocked.Exchange(ref running, 1) == 1) return; // previous tick still busy
try
{
SyncOrders(); // slow work
}
finally
{
Volatile.Write(ref running, 0);
}
}
异常是另一个陷阱。从 System.Threading.Timer 回调中逃逸出去的异常会让进程崩溃,而 System.Timers.Timer 会吞掉 Elapsed 处理程序中的异常,导致失败无人察觉。请把定时器回调的主体包在 try/catch 中,并记录日志。
PeriodicTimer 与异步循环
在异步代码中,在每次迭代之间等待的循环就是最清晰的定时器:只有在工作完成之后才开始下一次等待,所以触发永远不会重叠,异常也会出现在你能捕获它的地方。.NET 6 正是为此新增了 PeriodicTimer:
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await SaveDraftAsync();
}
定时器被释放后,WaitForNextTickAsync 会返回 false,循环随之结束;token 被取消时,它会抛出 OperationCanceledException。与围绕 Task.Delay 的循环不同,PeriodicTimer 会保持固定的节奏:如果工作花了 2 秒,下一次触发仍然在上一次触发的 30 秒之后到来,而不是 32 秒。
在任何版本上,如果不在乎时间漂移,用 Task.Delay 写成同样的结构也可以:
示例输出:
poll 1
poll 2
poll 3
poll 4
polling cancelled after 4 rounds
取消代替了 Stop():把 token 传进循环,再从外部取消它。关于取消 token 的更多内容,请参阅 Task。
用 Stopwatch 测量耗时
定时器负责安排代码何时运行;Stopwatch 负责测量代码花了多长时间。它读取的是一个只会向前走的高精度时钟,因此是计时的正确工具:
示例输出:
string += 64 ms
StringBuilder 0 ms
Same length: True
High resolution clock: True
用 Start、Stop、Reset 和 Restart 控制它;Elapsed 是一个 TimeSpan,ElapsedMilliseconds 和 ElapsedTicks 则给出原始数值。不要用 DateTime.Now 计时:它的精度可能粗到 10 至 15 毫秒,而且系统时钟被调整时会跳变。对于正式的基准测试,JIT 预热和垃圾回收会让单次运行的结果失真,请使用 BenchmarkDotNet 库。
定时器的精度
定时器的间隔是一个最小值,而不是承诺。在 Windows 上,默认的系统定时器精度约为 15.6 毫秒,所以 10 毫秒的间隔往往每 15 或 16 毫秒才触发一次;在繁忙的机器上,线程池回调的启动还可能更晚。定时器适合“大约每 N 秒一次”的场景。精确的帧计时或媒体处理,请使用专门为此设计的 API。
常见错误
System.Threading.Timer只保存在局部变量中。 它可能被垃圾回收并停止触发;请把它保存在字段中。- 让
Main返回。 进程结束,定时器也随之消失。 Timer有歧义。 同时导入了System.Threading和System.Timers时,请写出完整名称。- 以为触发永远不会重叠。 当工作比间隔还慢时,线程池定时器的触发就会重叠。
- 在线程池定时器中操作 UI 控件。 请使用 UI 框架的定时器,或者调度回 UI 线程。
- 回调中未处理的异常。 它们会让进程崩溃(
Threading.Timer)或者凭空消失(Timers.Timer);请捕获并记录日志。 - 忘记
Dispose。 定时器占用系统资源,在被释放之前会一直触发。
常见问题
C# 中应该用哪种定时器?
在 .NET 6 或更高版本的异步代码中,在循环里配合 await timer.WaitForNextTickAsync() 使用 PeriodicTimer。如果要在线程池上运行回调,用 System.Threading.Timer。如果想要带 Start、Stop 和 Elapsed 事件的基于事件的定时器,用 System.Timers.Timer。在 WinForms 或 WPF 中,使用 UI 框架自带的定时器,这样处理程序会在 UI 线程上运行。
C# 中如何每隔几秒运行一次代码?
创建一个带间隔的定时器,例如 new System.Threading.Timer(_ => Refresh(), null, 0, 5000) 会立即调用 Refresh,之后每 5 秒调用一次,并且要保留对它的引用。在异步代码中,用 PeriodicTimer(.NET 6+)或 await Task.Delay(5000) 写一个循环;循环的写法永远不会同时运行两次触发。
C# 中如何停止定时器?
对于 System.Timers.Timer,调用 Stop()(或设置 Enabled = false),不再需要时调用 Dispose()。对于 System.Threading.Timer,调用 Change(Timeout.Infinite, Timeout.Infinite) 暂停它,或调用 Dispose() 彻底停止。已经开始执行的回调,可能在你停止定时器之后仍会运行完。
为什么我的 System.Threading.Timer 不再触发了?
没有任何东西引用这个定时器,所以垃圾回收器把它回收了,它的回调也随之停止。当定时器作为局部变量创建在一个会返回的方法中时,就会出现这种情况。只要它还需要继续运行,就把它保存在字段中。
C# 中如何测量耗时?
使用 System.Diagnostics.Stopwatch:var sw = Stopwatch.StartNew(); ... sw.Stop();,然后读取 sw.ElapsedMilliseconds 或 sw.Elapsed。它使用的是高精度的单调时钟,而两个 DateTime.Now 相减的精度更粗,并且在系统时钟被调整时会跳变。