Menu

C# 定时器:System.Threading.Timer、System.Timers.Timer、PeriodicTimer

C# 有好几个定时器类,它们的行为各不相同。学习什么时候用 System.Threading.Timer、System.Timers.Timer、PeriodicTimer 或 UI 定时器,如何启动和停止它们,为什么回调在线程池上运行并可能重叠,为什么必须保留对定时器的引用,以及如何用 Stopwatch 测量耗时。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

定时器会在一段延迟之后,或按固定间隔重复运行代码:每分钟刷新一次缓存、每 30 秒保存一次草稿、轮询某个服务、更新时钟。.NET 有好几个看起来很像的定时器类,但它们的回调在哪里运行、你如何控制它们都各不相同。本页逐一介绍,另外还会讲用来测量耗时的 Stopwatch。

该用哪种定时器

定时器回调运行在风格适用于
System.Threading.Timer线程池回调委托服务和库中的后台工作
System.Timers.Timer线程池(默认)Elapsed 事件,Start/Stop偏好事件的组件式代码
PeriodicTimer(.NET 6+)你的异步循环所在的地方await WaitForNextTickAsync()异步代码;触发永不重叠
System.Windows.Forms.Timer、WPF DispatcherTimerUI 线程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 相减的精度更粗,并且在系统时钟被调整时会跳变。

Coddy 编程语言插图

和 Coddy 一起学编程

开始