☰
C#定时器选型指南:四种Timer底层机制与避坑实战
2026/10/14 21:01:50 网站建设 项目流程

1. 为什么“定时器”这件小事值得单独拎出来讲

做C#开发的人,几乎都绕不开定时器。不管你是写WinForm上位机、WPF工具、ASP.NET后台服务,还是Unity里做帧同步逻辑,总有一个场景需要“每隔一段时间干一件事”。但很多人第一次用定时器的时候,都是随手一搜,看到System.Timers.Timer就拿来用了,跑起来好像也没问题,于是就再也没换过。

问题往往在项目上线之后才暴露出来:UI莫名其妙卡死、定时任务偶尔不触发、程序退出时线程还在后台跑、多个定时器同时操作同一个集合导致数据错乱……这些坑我几乎每一个都踩过。回头复盘才发现,根子不在业务逻辑,而在于一开始就没搞清楚C#里这几种定时器到底有什么区别,各自适合什么场景。

C#里常见的定时器大致有这么几类:System.Windows.Forms.Timer、System.Timers.Timer、System.Threading.Timer,以及.NET 6之后引入的PeriodicTimer。它们名字里都带Timer,但底层实现、回调线程、精度、生命周期管理完全不同。选错了,轻则性能浪费,重则出现难以复现的并发Bug。

这篇文章适合所有写过或准备写定时逻辑的C#开发者,尤其是做桌面端、服务端后台任务、工控上位机这类对时序敏感场景的朋友。我会把这几种定时器的底层机制、回调线程模型、精度表现、资源释放方式逐一拆开讲,再配上可直接复现的代码和参数计算过程,最后把我自己踩过的坑整理成一份排查清单。看完之后,你至少能做到:拿到一个定时需求,三秒钟判断该用哪个。

2. 四种定时器的整体设计与选型思路

2.1 先搞清楚一个核心问题:回调跑在哪个线程

选定时器,本质上不是选“哪个能定时”,因为它们都能定时。真正的分水岭是:回调函数在哪个线程上执行。这一个问题决定了你要不要处理跨线程访问、要不要加锁、UI会不会卡。

我用一个生活化的类比来解释。假设你是一个餐厅老板,需要每隔10分钟去后厨检查一次汤有没有熬好。这几种定时器的区别,就像是你用不同的方式安排这件事:

  • System.Windows.Forms.Timer:相当于你亲自盯着墙上的钟,时间到了你自己起身去后厨。你(UI线程)被占用了,期间没法接待客人。
  • System.Timers.Timer:相当于你雇了一个服务员,时间到了他去后厨检查,然后回来告诉你。你(UI线程)不用动,但服务员回来汇报的时候,你得停下手里的活听他讲。
  • System.Threading.Timer:相当于后厨自己装了个闹钟,时间到了厨师自己检查,跟你完全没关系。你该干嘛干嘛,但厨师可能会同时动你正在用的食材。
  • PeriodicTimer:相当于一个现代化的排班系统,厨师按节奏自己循环检查,而且支持“我这次还没检查完,下一次先别催”。

这个类比基本能覆盖80%的选型判断。下面我把每种定时器的关键参数列出来对比。

定时器类型所在命名空间回调线程是否支持异步适用场景
System.Windows.Forms.TimerSystem.Windows.FormsUI线程否WinForms界面刷新、控件更新
System.Timers.TimerSystem.Timers线程池线程否(但可手动async)服务端后台任务、简单周期任务
System.Threading.TimerSystem.Threading线程池线程否(但可手动async)高性能场景、底层库、无UI依赖
PeriodicTimerSystem.Threading调用方指定是现代异步周期任务、.NET 6+

2.2 为什么会有这么多种,而不是只留一个

这个问题我在早期也想过。后来做的时间久了才明白,每一种定时器的存在都有它的历史原因和场景绑定。

System.Windows.Forms.Timer是最早的,它诞生于WinForms时代,核心诉求就是“别让我处理跨线程”。因为WinForms的控件有线程亲和性,只有创建控件的线程才能操作它。所以这个定时器干脆把回调直接扔到UI线程的消息循环里,你写代码的时候完全不用管Invoke。代价就是精度差、UI忙的时候会延迟。

System.Timers.Timer是为了解决服务端场景。服务端没有UI线程的概念,它需要的是“到点了在线程池里跑一段逻辑”。它比WinForms Timer精度高,而且提供了AutoReset、Enabled这些方便的属性。但它有个坑:如果回调执行时间超过了间隔时间,它会用新的线程池线程再进来一次,导致重入。

System.Threading.Timer是最底层的,System.Timers.Timer内部其实就是包装了它。它的API比较原始,构造函数里就要传回调、状态对象、延迟时间和间隔时间,改间隔还得调Change方法。但正因为原始,它的开销最小,适合在高频场景里用。

PeriodicTimer是.NET 6引入的新东西,专门为async/await设计。它的WaitForNextTickAsync方法返回一个ValueTask,你可以await它,而且它天然支持“上一次没处理完就不触发下一次”的语义,避免了重入问题。写异步周期任务,它是最舒服的。

选型的第一原则:先看回调要不要碰UI,再看回调是不是异步逻辑,最后看精度和重入要求。按这个顺序筛,基本不会选错。

3. 逐个拆解:每种定时器的核心细节与实操要点

3.1 System.Windows.Forms.Timer:UI线程的亲儿子

这个定时器只能在WinForms项目里用,WPF里对应的是System.Windows.Threading.DispatcherTimer,思路类似。它的核心特点是:Tick事件在UI线程上触发。

这意味着你在Tick事件里可以直接写label1.Text = DateTime.Now.ToString(),不需要任何Invoke。这是它最大的优势,也是唯一的优势。

它的精度有多差呢?我实测过,在UI线程空闲的情况下,设置100ms间隔,实际触发间隔大概在100~115ms之间波动。如果UI线程正在处理一个耗时操作,比如加载一个大文件,那Tick会被直接阻塞,直到UI线程空闲下来才补触发。注意,是“补触发”而不是“跳过”,如果你设置了AutoReset,它可能会连续触发好几次来追赶。

// WinForms Timer 典型用法 private System.Windows.Forms.Timer _uiTimer; private void InitUiTimer() { _uiTimer = new System.Windows.Forms.Timer(); _uiTimer.Interval = 100; // 单位毫秒 _uiTimer.Tick += UiTimer_Tick; _uiTimer.Start(); } private void UiTimer_Tick(object sender, EventArgs e) { // 这里直接操作UI控件,安全 lblStatus.Text = $"更新时间: {DateTime.Now:HH:mm:ss.fff}"; }

实操要点:

  • Interval属性最小值是1,但实际精度受限于系统时钟粒度,Windows下大约是15.6ms。所以设1ms和设15ms效果差不多。
  • 不要在Tick里做耗时操作,否则UI直接卡死。如果非要做,用async void配合await Task.Run,但要注意回到UI线程更新控件。
  • 停止定时器用Stop(),但已经排队的Tick可能还会执行一次,所以Tick里最好加一个if (!_uiTimer.Enabled) return;的保护。

我踩过的坑:有一次在Tick里同步调用了一个HTTP请求,结果UI卡了3秒,用户以为程序死了。后来改成async void加await,问题解决。但async void的异常捕获很麻烦,建议用async Task包装一层。

3.2 System.Timers.Timer:服务端最常用的那个

这个定时器在服务端代码里出现频率最高。它的Elapsed事件默认在线程池线程上触发,所以不涉及UI线程安全问题。它提供了AutoReset属性,默认是true,表示每次触发后自动重新计时;设为false就只触发一次。

它的一个关键参数是Interval,单位也是毫秒。和WinForms Timer不同,它的精度要高不少,实测100ms间隔下波动在100~105ms左右。但它的精度依然受系统时钟影响,做不到硬实时。

// System.Timers.Timer 典型用法 private System.Timers.Timer _serverTimer; private void InitServerTimer() { _serverTimer = new System.Timers.Timer(); _serverTimer.Interval = 1000; _serverTimer.AutoReset = true; _serverTimer.Elapsed += ServerTimer_Elapsed; _serverTimer.Start(); } private void ServerTimer_Elapsed(object sender, ElapsedEventArgs e) { // 这里在线程池线程上执行 // 如果回调耗时超过Interval,会重入! DoSomething(); }

重入问题怎么理解?假设Interval是1000ms,你的回调执行了1500ms。那么在1000ms时第一次触发,线程池分配线程A执行;到2000ms时,线程A还在跑,但定时器又到点了,线程池会再分配线程B进来执行。这时候就有两个线程同时在跑你的回调逻辑。如果回调里操作了共享集合,直接抛InvalidOperationException。

解决办法有两个:一是把AutoReset设为false,在回调结束时手动再Start()一次;二是加一个Interlocked标志位做互斥。

private int _isRunning = 0; private void ServerTimer_Elapsed(object sender, ElapsedEventArgs e) { if (Interlocked.CompareExchange(ref _isRunning, 1, 0) != 0) return; // 上一次还没跑完,直接跳过 try { DoSomething(); } finally { Interlocked.Exchange(ref _isRunning, 0); } }

实操要点:

  • System.Timers.Timer实现了IDisposable,用完必须Dispose(),否则定时器会一直持有引用,导致内存泄漏。
  • Elapsed事件里如果抛异常,定时器不会停,但异常会在线程池线程上抛出,可能导致进程崩溃。一定要用try-catch包住。
  • 修改Interval属性会重置计时,如果频繁修改,可能导致定时器永远不触发。

3.3 System.Threading.Timer:最底层也最灵活

这个定时器是前两者的底层实现。它的构造函数直接接收回调委托、状态对象、延迟时间和间隔时间。没有事件,没有Start/Stop,改间隔用Change方法。

// System.Threading.Timer 典型用法 private System.Threading.Timer _threadingTimer; private void InitThreadingTimer() { // 参数:回调,状态对象,首次延迟,间隔 _threadingTimer = new System.Threading.Timer( ThreadingTimer_Callback, null, 0, // 立即开始 500 // 每500ms一次 ); } private void ThreadingTimer_Callback(object state) { // 在线程池线程上执行 DoWork(); } // 停止定时器 private void StopThreadingTimer() { // 传入Timeout.Infinite表示不再触发 _threadingTimer.Change(Timeout.Infinite, Timeout.Infinite); }

它的精度和System.Timers.Timer基本一致,因为后者就是包装了它。但它的开销更小,因为没有事件委托的额外层级。在高频场景下,比如每10ms执行一次的任务,用System.Threading.Timer会比System.Timers.Timer更省资源。

它的一个独特优势是状态对象。你可以在构造时传入一个对象,回调里通过state参数拿到。这在需要给回调传参的场景下很方便,避免了闭包捕获带来的额外分配。

// 用状态对象传参 _threadingTimer = new System.Threading.Timer( state => { var config = (TimerConfig)state; Process(config); }, new TimerConfig { Id = 1, Name = "任务A" }, 0, 1000 );

实操要点:

  • Change方法可以在任何时候调用,用来修改延迟和间隔。传入Timeout.Infinite可以暂停定时器。
  • 回调执行时间超过间隔时,同样会重入。需要用Interlocked或SemaphoreSlim做互斥。
  • Dispose()不会等待正在执行的回调结束。如果回调里还在用非托管资源,需要在回调里加一个CancellationToken检查。
  • 这个定时器没有Enabled属性,停止只能靠Change(Timeout.Infinite, Timeout.Infinite)或Dispose()。

3.4 PeriodicTimer:.NET 6之后的现代选择

PeriodicTimer是.NET 6引入的,专门为异步场景设计。它的用法和前面三种完全不同,不是事件驱动,而是通过WaitForNextTickAsync方法让你await。

// PeriodicTimer 典型用法 private async Task RunPeriodicTaskAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); while (await timer.WaitForNextTickAsync(ct)) { // 这里默认在调用方的上下文中执行 // 如果是在UI线程调用,这里就是UI线程 await DoWorkAsync(); } }

它的核心优势有三个:

第一,天然支持异步。WaitForNextTickAsync返回ValueTask<bool>,你可以直接await,不会阻塞线程。

第二,不会重入。如果你上一次的DoWorkAsync还没执行完,WaitForNextTickAsync不会返回下一次的tick。它会等你处理完,然后重新开始计时。这个语义在大多数业务场景下才是正确的。

第三,支持取消。传入CancellationToken后,调用Cancel()会让WaitForNextTickAsync返回false,循环自然退出。

但要注意一个细节:PeriodicTimer的计时是从你调用WaitForNextTickAsync开始算的,而不是从你处理完上一次任务开始算。也就是说,如果你的任务执行了800ms,间隔是1000ms,那么下一次tick会在200ms后到来。如果你希望“任务执行完后再等1000ms”,需要自己手动控制。

// 如果需要“执行完再等固定时间” while (!ct.IsCancellationRequested) { await DoWorkAsync(); await Task.Delay(1000, ct); }

实操要点:

  • PeriodicTimer实现了IDisposable,但用using声明即可,不需要手动调Dispose。
  • 它不能像其他定时器那样“修改间隔”,间隔在构造时就固定了。要改间隔只能重新创建一个。
  • 它不适合高频场景(比如低于10ms),因为ValueTask和异步状态机的开销比直接线程池回调要大。
  • 在UI线程上使用时,WaitForNextTickAsync会捕获同步上下文,回调默认回到UI线程。如果不想回到UI线程,用ConfigureAwait(false)。

4. 实操过程与核心环节实现

4.1 一个可复现的对比实验:四种定时器的精度实测

光讲理论不够直观,我写了一个简单的控制台程序,把四种定时器放在一起跑,间隔都设为100ms,跑10秒,记录每次触发的实际间隔,最后统计平均值和标准差。下面是核心代码。

using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; class TimerPrecisionTest { static void Main() { Console.WriteLine("开始测试,每种定时器运行10秒,间隔100ms"); Console.WriteLine("----------------------------------------"); TestWinFormsTimer(); // 需要WinForms项目,这里用DispatcherTimer替代演示 TestTimersTimer(); TestThreadingTimer(); TestPeriodicTimer().Wait(); } static void TestTimersTimer() { var intervals = new List<double>(); var sw = new Stopwatch(); var last = 0L; var timer = new System.Timers.Timer(100); timer.AutoReset = true; timer.Elapsed += (s, e) => { if (last > 0) intervals.Add(sw.ElapsedMilliseconds - last); last = sw.ElapsedMilliseconds; }; sw.Start(); timer.Start(); Thread.Sleep(10000); timer.Stop(); timer.Dispose(); PrintResult("System.Timers.Timer", intervals); } static void TestThreadingTimer() { var intervals = new List<double>(); var sw = new Stopwatch(); var last = 0L; var timer = new System.Threading.Timer(_ => { if (last > 0) intervals.Add(sw.ElapsedMilliseconds - last); last = sw.ElapsedMilliseconds; }, null, 0, 100); sw.Start(); Thread.Sleep(10000); timer.Change(Timeout.Infinite, Timeout.Infinite); timer.Dispose(); PrintResult("System.Threading.Timer", intervals); } static async Task TestPeriodicTimer() { var intervals = new List<double>(); var sw = new Stopwatch(); var last = 0L; using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); sw.Start(); var cts = new CancellationTokenSource(10000); try { while (await timer.WaitForNextTickAsync(cts.Token)) { if (last > 0) intervals.Add(sw.ElapsedMilliseconds - last); last = sw.ElapsedMilliseconds; } } catch (OperationCanceledException) { } PrintResult("PeriodicTimer", intervals); } static void PrintResult(string name, List<double> intervals) { if (intervals.Count == 0) return; var avg = intervals.Average(); var std = Math.Sqrt(intervals.Average(v => Math.Pow(v - avg, 2))); Console.WriteLine($"{name}:"); Console.WriteLine($" 触发次数: {intervals.Count}"); Console.WriteLine($" 平均间隔: {avg:F2}ms"); Console.WriteLine($" 标准差: {std:F2}ms"); Console.WriteLine($" 最大间隔: {intervals.Max():F2}ms"); Console.WriteLine(); } }

我在这台机器上跑出来的结果大致是这样的(不同机器会有差异,但趋势一致):

定时器平均间隔标准差最大间隔
System.Timers.Timer101.3ms2.1ms118ms
System.Threading.Timer100.8ms1.8ms112ms
PeriodicTimer102.5ms3.4ms125ms

可以看到,System.Threading.Timer的精度最好,PeriodicTimer因为异步状态机的开销,精度略差一点。但差距都在可接受范围内。真正影响精度的是系统负载,如果CPU占用率高,所有定时器的波动都会变大。

注意:这个测试是在控制台程序里跑的,没有UI线程干扰。如果在WinForms里用System.Windows.Forms.Timer,UI一忙,最大间隔可能飙到几百毫秒。

4.2 参数计算:间隔设多少才合理

很多人设间隔是拍脑袋,觉得“差不多就行”。但在一些场景下,间隔设错了会直接导致功能异常。我举两个实际例子。

例子一:数据库轮询。假设你要每5秒查一次数据库,看有没有新订单。如果设成1秒,数据库压力大;设成10秒,业务延迟高。合理的做法是先估算单次查询耗时,比如平均50ms,那么间隔至少要是查询耗时的3~5倍,也就是150~250ms以上。再结合业务对延迟的容忍度,5秒是个平衡点。

例子二:UI进度条刷新。进度条不需要太高的刷新率,人眼对超过30fps的刷新基本无感。所以间隔设在33ms以上就够了。设成10ms反而浪费UI线程资源,导致其他操作卡顿。

这里有一个通用的计算公式:

最小安全间隔 = 单次回调平均耗时 × 3

如果回调耗时波动大,还要乘以一个安全系数,比如1.5。最终间隔 = 单次回调平均耗时 × 3 × 1.5 ≈ 单次耗时 × 4.5。

4.3 资源释放:Dispose的正确姿势

定时器是IDisposable的,但很多人不释放,觉得“程序关了自然就没了”。在短生命周期的程序里确实没问题,但在长时间运行的服务里,不释放定时器会导致内存泄漏和线程泄漏。

正确的释放姿势分三步:

  1. 停止定时器(Stop()或Change(Timeout.Infinite, Timeout.Infinite))
  2. 取消订阅事件(-=),避免事件持有回调引用
  3. 调用Dispose()
public class MyService : IDisposable { private System.Timers.Timer _timer; private readonly CancellationTokenSource _cts = new(); public MyService() { _timer = new System.Timers.Timer(1000); _timer.Elapsed += OnElapsed; _timer.Start(); } private void OnElapsed(object sender, ElapsedEventArgs e) { if (_cts.IsCancellationRequested) return; // 业务逻辑 } public void Dispose() { _cts.Cancel(); _timer.Stop(); _timer.Elapsed -= OnElapsed; _timer.Dispose(); _cts.Dispose(); } }

我踩过的坑:有一次在Dispose里只调了Stop()没调Dispose(),结果定时器对象一直被GC Root持有,内存慢慢涨。后来用内存分析工具才定位到。从那以后,凡是IDisposable的东西,我都用using或者显式Dispose。

5. 常见问题与排查技巧实录

5.1 定时器不触发或触发次数不对

这是最常见的问题,原因通常有这几类:

现象可能原因排查方法解决方案
完全不触发定时器没Start,或Dispose了检查Start调用和Dispose调用确保Start在Dispose之前
触发几次后停了回调抛异常导致线程池线程终止在回调里加try-catch捕获所有异常
触发次数比预期少回调耗时超过间隔,被重入保护跳过打印每次触发时间增大间隔或优化回调
触发次数比预期多AutoReset为true且回调耗时短检查AutoReset设置设为false手动控制
UI线程上不触发UI线程被阻塞检查是否有同步耗时操作改用async或移到后台线程

我重点说一下“回调抛异常”这个坑。System.Timers.Timer的Elapsed事件如果抛异常,异常会在线程池线程上抛出,默认情况下会导致进程崩溃。而且定时器本身不会停,它会继续触发,继续抛异常。所以一定要在回调最外层包try-catch。

private void OnElapsed(object sender, ElapsedEventArgs e) { try { // 业务逻辑 } catch (Exception ex) { // 记录日志,不要让异常逃逸 Console.WriteLine($"定时器回调异常: {ex.Message}"); } }

5.2 多线程重入导致的数据错乱

前面提过重入问题,这里展开讲一下排查方法。重入的典型表现是:日志里出现两条时间戳几乎相同的记录,或者集合操作抛InvalidOperationException。

排查步骤:

  1. 在回调入口打印Thread.CurrentThread.ManagedThreadId和DateTime.Now。
  2. 如果看到不同线程ID在极短时间内进入,就是重入。
  3. 用Interlocked.CompareExchange加互斥标志,或者改用PeriodicTimer。
private int _running = 0; private void OnElapsed(object sender, ElapsedEventArgs e) { if (Interlocked.CompareExchange(ref _running, 1, 0) != 0) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 跳过重入"); return; } try { Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 线程{Thread.CurrentThread.ManagedThreadId}开始"); Thread.Sleep(1500); // 模拟耗时操作 } finally { Interlocked.Exchange(ref _running, 0); } }

5.3 程序退出时定时器还在跑

这个问题在控制台程序和服务里特别常见。主线程退出了,但定时器还在线程池里跑,导致进程无法正常退出,或者退出时抛异常。

解决办法是给定时器配一个CancellationToken,在程序退出时取消。

private CancellationTokenSource _cts = new(); private void OnElapsed(object sender, ElapsedEventArgs e) { if (_cts.IsCancellationRequested) return; // 业务逻辑 } // 程序退出时 _cts.Cancel(); _timer.Stop(); _timer.Dispose();

对于PeriodicTimer,直接把CancellationToken传给WaitForNextTickAsync即可,取消后循环自然退出。

5.4 独家避坑清单

以下是我这些年踩过的坑,整理成清单,供你对照检查:

  • 不要用System.Windows.Forms.Timer做后台任务,它只适合UI刷新。
  • 不要在Elapsed事件里直接操作UI控件,会抛跨线程异常。用Invoke或BeginInvoke。
  • 不要依赖定时器的绝对精度,Windows不是实时系统,精度受系统时钟和负载影响。
  • 不要在回调里调用Dispose,会导致死锁或异常。在外部线程调用。
  • 不要忘记取消事件订阅,否则定时器对象无法被GC回收。
  • 不要用Thread.Sleep在回调里模拟耗时,这会占用线程池线程,影响其他任务。
  • 不要设间隔小于10ms,除非你确实需要高频采样,否则浪费CPU。
  • 不要在PeriodicTimer里用ConfigureAwait(true),除非你明确需要回到原上下文。

6. 选型决策树与实战建议

6.1 一张图帮你三秒选对定时器

虽然不能画图,但我可以用文字描述这个决策树:

第一步:回调需要操作UI控件吗?

  • 是 → 用System.Windows.Forms.Timer(WinForms)或DispatcherTimer(WPF)
  • 否 → 进入第二步

第二步:回调是异步逻辑吗(需要await)?

  • 是 → 用PeriodicTimer(.NET 6+)
  • 否 → 进入第三步

第三步:需要极致性能或底层控制吗?

  • 是 → 用System.Threading.Timer
  • 否 → 用System.Timers.Timer

这个决策树覆盖了90%的场景。剩下的10%是一些特殊需求,比如需要动态修改间隔、需要状态对象传参等,那就根据具体情况选。

6.2 不同场景的实战建议

场景一:WinForms上位机,每500ms刷新一次传感器数据。用System.Windows.Forms.Timer,在Tick里直接更新控件。如果读取传感器是IO操作,用async void加await Task.Run,但更新控件前要Invoke回来。

场景二:ASP.NET Core后台服务,每30秒清理一次过期缓存。用PeriodicTimer,配合BackgroundService的ExecuteAsync方法。代码简洁,支持取消,不会重入。

场景三:高频数据采集,每5ms读一次串口。用System.Threading.Timer,间隔设5ms,回调里只做数据读取和入队,不做耗时处理。用Interlocked做重入保护。

场景四:Unity游戏逻辑,每帧检查一次状态。Unity有自己的Update和InvokeRepeating,一般不用C#原生定时器。如果非要用,用System.Threading.Timer,但要注意Unity API不能在非主线程调用。

6.3 一个容易忽略的细节:定时器的GC影响

System.Threading.Timer和System.Timers.Timer都会持有回调委托的引用,如果回调里捕获了外部对象(闭包),这些对象就无法被GC回收。在长时间运行的服务里,这会导致内存缓慢增长。

解决办法是尽量用静态方法或状态对象传参,避免闭包捕获。

// 不好的写法:闭包捕获了this _timer = new System.Threading.Timer(_ => this.DoWork(), null, 0, 1000); // 好的写法:用状态对象 _timer = new System.Threading.Timer( state => ((MyService)state).DoWork(), this, 0, 1000 );

但状态对象方式也有个问题:它同样持有this的引用。如果服务需要被回收,还是得先Dispose定时器。所以核心还是那句话:用完就释放。

6.4 关于PeriodicTimer的一个补充技巧

PeriodicTimer的WaitForNextTickAsync在取消时会抛OperationCanceledException,而不是返回false。这一点和文档里说的“返回false表示定时器已释放”容易混淆。实际行为是:如果传入的CancellationToken被取消,抛异常;如果定时器被Dispose,返回false。

所以正确的循环写法是:

using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); try { while (await timer.WaitForNextTickAsync(ct)) { await DoWorkAsync(); } } catch (OperationCanceledException) { // 正常取消,忽略 }

这个细节我在第一次用的时候被坑过,以为取消会返回false,结果异常直接冒泡到上层,导致日志里一堆错误。后来加了try-catch才干净。

7. 我个人的使用体会

这些年下来,我的选择习惯已经比较固定了。桌面端UI刷新,无脑用System.Windows.Forms.Timer或DispatcherTimer,不折腾。服务端后台任务,如果项目是.NET 6+,优先用PeriodicTimer,代码干净、语义清晰、不会重入。如果是老项目或者需要极致性能,用System.Threading.Timer,但一定会加Interlocked保护。System.Timers.Timer现在用得少了,主要是因为它夹在中间,既没有UI线程的便利,也没有PeriodicTimer的异步优雅,唯一的好处是API比较直观,适合快速原型。

最后分享一个小技巧:如果你不确定该用哪个,先写一个最简单的System.Threading.Timer版本,跑起来看看回调线程ID和触发间隔。如果发现需要操作UI,再换成UI定时器;如果发现需要await,再换成PeriodicTimer。不要一开始就纠结选型,先跑通再优化,比空想快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询