1. 内容整体设计与思路拆解
1.1 为什么偏偏是 System.Threading.Timer?
做C#开发的人,定时器算是家常便饭了。System.Windows.Forms.Timer、System.Timers.Timer、System.Threading.Timer,还有后来.NET 6里加入的PeriodicTimer,光官方就给了好几种。很多新手上来就懵:到底该用哪个?甚至有人干脆全用System.Windows.Forms.Timer,反正拖到窗体上就能用。但如果你做的是上位机、服务端程序,或者任何需要后台定时执行任务的场景,System.Threading.Timer才是真正的底层主力。
这个名字看起来很长,但拆开就明白了:它位于System.Threading命名空间下,本质是一个基于线程池的回调机制。它不像WinForms那个定时器那样依赖UI消息泵,也不像System.Timers.Timer那样包装了一层事件,它直接由线程池管理,回调在线程池线程上执行。这意味着两件事:第一,它不会阻塞UI线程;第二,它足够轻量、精准、可控,适合在后台跑周期性任务。
我见过不少项目里,定时采集数据用System.Windows.Forms.Timer,结果窗口一最小化,采集频率就变得一塌糊涂。为什么?因为WinForms定时器依赖Windows消息循环,UI线程忙了、卡了,定时器就跟着“偷懒”。而System.Threading.Timer走的是线程池调度,根本不受UI线程状态影响,这才是工业级场景下它被广泛使用的原因。
1.2 它能干什么、适合谁学?
一句话概括:System.Threading.Timer就是一个让你在后台精确执行循环任务的工具。典型场景包括:
- 上位机周期性轮询传感器数据、PLC状态
- 服务端程序定时清理缓存、过期会话
- 客户端定时发送心跳包、检测连接状态
- 定时刷新界面数据(需要配合UI线程切换)
它适合所有写过C#但还没深入理解多线程的开发者。尤其是做上位机开发的、做服务端的、写工具类程序的朋友,认真搞懂这一个定时器,能省掉以后很多踩坑的时间。
2. 核心细节解析与实操要点
2.1 构造函数的四个参数,每个都别轻视
System.Threading.Timer最常用的构造函数长这样:
var timer = new System.Threading.Timer( callback: SomeMethod, // 回调方法 state: null, // 传给回调的参数 dueTime: 1000, // 首次执行前的延迟毫秒数 period: 2000 // 后续每次执行的间隔毫秒数 );看着简单,但四个参数里藏着不少讲究。
callback—— 回调方法。它的签名是void Callback(object state),注意返回类型是void。如果你习惯写async方法,这里有个坑:不能直接传async void方法,因为async void的异常无法被捕获,而且它返回后定时器并不知道你在等什么。正确做法是回调里写一个同步外壳,内部再Task.Run或者直接_ = DoWorkAsync(),但异常处理必须自己做。
state—— 状态参数。这玩意儿能让你用一个方法服务多个定时器。比如我有三个温度传感器,回调方法一样,只是采集逻辑不同,那就可以把传感器ID传给state,在回调里区分。如果你不需要传参,就传null。
dueTime—— 首次执行延迟。注意是“首次”,不是“立即”。设为0表示异步立即执行(实际上还是要等线程池调度),设为Timeout.Infinite(即-1)表示永不执行——这个用法配合Change方法,可以把Timer变成一个“手动扳机”。
period—— 循环周期。设为0或Timeout.Infinite表示只执行一次、不循环。这个特性经常被用来做“延迟执行一次”的效果。
2.2 Change方法:让定时器“活”起来
定时器创建后不是一成不变的。.Change(dueTime, period)方法允许你动态调整定时策略。这个灵活性在用定时器做超时控制的时候特别有用,是上位机通讯场景里常见的“看门狗”玩法。
timer.Change(TimeSpan.FromSeconds(5), Timeout.InfiniteTimeSpan);比如你给下位机发了一条指令,需要在5秒内收到回复。收到就Change重新计时,没收就执行超时逻辑。把dueTime设为5秒、period设为永不重复,就得到一个很好的单次延迟定时代理。这种“重置警戒”的思路,比每次销毁重建Timer要高效得多。
另外,调用Change是线程安全的。你可以在任意线程,包括回调线程内部,安全地重新调整定时器的下一个触发时间。这在处理重入问题时,是一个不错的辅助手段。
2.3 Dispose:释放的不只是对象,还有句柄
System.Threading.Timer实现了IDisposable,很多人知道要释放,但不清楚释放的到底是什么。它内部封装了一个操作系统定时器句柄和一个TimerHolder,Dispose()的作用是切断这个底层回调触发链路,避免回调在你不注意的时候继续被调度。
这里有一个经典的问题:对象已经Dispose了,但回调还在执行中。这正常吗?正常。因为Dispose只是阻止后续周期的“排队”,已经在线程池中运行的当前回调并不会立刻中断。如果你需要“确保回调完全结束后才释放”,就得用一个单独的ManualResetEvent来等待。
var waiter = new ManualResetEvent(false); timer.Dispose(waiter); // 等待所有已经排队/正在执行的回调完成 waiter.WaitOne(); // 直到回调全部结束注意这个Dispose(WaitHandle)重载,平时基本没人用,但在做优雅停机、动态库卸载的时候非常关键。
2.4 回调重入:定时器最大的暗坑
只用Thread.Sleep当回调的Demo程序当然不会遇到重入问题。但用System.Threading.Timer做正经业务时,回调重入是必须面对的一道坎。
因为Timer的回调在线程池上并发执行,如果period为1秒,但回调本身耗时3秒,那么线程池会同时跑3个回调实例。这种现象叫“重入”、或者“回调堆积”。后果轻则逻辑错乱、数据脏写,重则线程池耗尽、程序假死。
避免重入有三种常见方案:
方案一:锁。在回调开头lock一个私有对象,确保同一时刻只有一个回调能执行:
private readonly object _lock = new object(); private void OnTimer(object state) { bool entered = false; try { Monitor.TryEnter(_lock, ref entered); if (!entered) return; // 上一次还没跑完,这次直接跳过 // 业务逻辑 } finally { if (entered) Monitor.Exit(_lock); } }这里我用的是Monitor.TryEnter而非lock,原因是有个需求场景是“如果上次没执行完,这次直接放弃”。如果用lock硬等,就形同排队,起不到防堆积的作用。
方案二:标志位。用Interlocked做一个轻量的bool判断:
if (Interlocked.Exchange(ref _running, 1) == 1) return; try { // 业务逻辑 } finally { Interlocked.Exchange(ref _running, 0); }这种方式更轻量,适合严格要求耗时短的场景。
方案三:Change模式。回调开头先把period设为Timeout.Infinite,执行完再重新Change回去。这样天然保证执行期间不会触发下一次回调,但要注意,如果你手动Change的时机和Timer内部调度有竞态,需要谨慎设计。
我个人推荐优先用Interlocked标志位,因为锁在回调里如果遇到异常没有正确释放,很容易引发死锁。
3. 实操过程与核心环节实现
3.1 最朴素的使用模板
来一个最直观的使用方式:
using System; using System.Threading; public class TimerDemo { private System.Threading.Timer _timer; public void Start() { _timer = new System.Threading.Timer( DoWork, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5) ); } private void DoWork(object state) { // 注意:这里运行在线程池线程上,不要直接改UI Console.WriteLine($"当前时间:{DateTime.Now:HH:mm:ss.fff}"); } public void Stop() { _timer?.Dispose(); _timer = null; } }运行效果大概是每5秒打印一次,首次延迟1秒。这里有一个经验:测试时请一定打印到毫秒。完成一次定时任务,你甚至能直接观察到每一次回调之间的时间间隔。线程池调度正常情况下误差不大,但当系统中存在大量线程竞争时,回调的实际执行时间会和period有明显偏差。
3.2 上位机场景:带异常隔离的定时轮询
开发过串口或网口上位机的朋友应该深有体会:第三方设备的反馈并不是每次都符合预期。如果你把通讯逻辑直接塞进定时器回调,一旦抛出未处理异常,线程池线程就game over了。注意,回调里抛异常不会导致进程崩溃,但会终止这个线程的该次执行,并且异常会丢失。后果是数据采集静默失败,很难排查。
我的做法是:回调外面套一层异常隔离,把任何异常都记录下来。
private void SafePolling(object state) { try { PollDevice(state); } catch (Exception ex) { // 记录到日志/状态栏,不要吞掉 LastError = ex.Message; } }此外再结合Interlocked防重入标志,可以说已经达到生产级质量了。
3.3 精准到1ms:别被名字骗了
“C#定时器精准到1ms”是经常出现在搜索引擎里的关键词。我必须先泼一盆冷水:Windows不是实时操作系统。System.Threading.Timer的精度受系统时钟中断频率限制,通常默认精度约15.6毫秒。也就是说,如果你想让它每隔1毫秒跑一次,很可能是达不到准确值1ms的。
但好消息是,可以通过调用时间API来提升系统时钟分辨率:
[DllImport("winmm.dll")] private static extern uint timeBeginPeriod(int uMilliseconds); [DllImport("winmm.dll")] private static extern uint timeEndPeriod(int uMilliseconds); // 程序启动时调用 timeBeginPeriod(1);timeBeginPeriod(1)请求系统把时钟中断周期降到1毫秒,此时System.Threading.Timer的实际精度通常能达到1~2毫秒级别。不过,这个调用会提高CPU唤醒频率,增加功耗。在笔记本电脑或工控机上长期运行,你要权衡一下是否值得。
如果你确实需要微秒级精度的定时(比如硬件数据采样),建议直接用Stopwatch自旋等待,或借助第三方库。但对于绝大多数业务轮询和通讯心跳,System.Threading.Timer的精度足够用。
3.4 Winform里怎么用?
做Winform开发,如果回调里需要更新Label、TextBox这类控件,直接访问会抛异常《线程间操作无效》。这是老生常谈,但真正踩过坑的人会明白,核心难点不在“怎么切换”,而在“切换太频繁导致UI假死”。
我推荐的做法是:在回调里把数据塞到队列或变量里,用一个UI定时器(如System.Windows.Forms.Timer)隔几百毫秒统一刷新。频率降低,交互流畅度反而更高。
private void DoWork(object state) { // 从设备采集到的值 var val = ReadFromDevice(); // 塞到线程安全的字段或容器 _latestValue = val; }然后在窗体加载时,启动一个500ms间隔的UI定时器,从_latestValue中读取值并显示。这样既避开了跨线程问题,也减少了控件反复刷新的性能开销。
4. 常见问题与排查技巧实录
4.1 定时器突然失效了,是什么原因?
问这个问题的人,八成遇到了以下三种情况之一。排查时从这三个方向入手,基本能定位90%的问题。
原因一:定时器对象被GC回收了。System.Threading.Timer如果创建后没有变量引用它,即便你在某个方法里用局部变量创建了定时器,方法结束、变量出作用域之后,定时器就成了“孤魂野鬼”——虽然回调还在线程池上排队,但底层句柄可能随时被GC回收,导致回调彻底不触发。解决办法很简单:始终用一个字段保存Timer引用。
原因二:回调方法抛了未处理异常。前面说了回调异常会丢失,但很多初学者不知道的是,连续抛异常会让定时器看起来“彻底不工作”。 检查异常最好的办法是回调就加try/catch,至少在开发阶段要加。
原因三:线程池线程饥饿。如果回调里有阻塞操作(如Console.ReadLine()、Task.Result同步等待),线程池线程可能被卡死,新回调排不上。这种情况把阻塞操作改成异步,或增加线程池最小线程数。
4.2 锁屏后定时器不走了?
有朋友发现,Windows锁屏后,System.Threading.Timer好像停了,恢复登录后又继续了。其实不是Timer停了,而是系统进入了睡眠或混合睡眠模式,CPU暂停调度。处理方案包括:
- 检查电源计划,把“睡眠”设为“从不”;
- 在
SystemEvents.PowerModeChanged事件中监听电源状态,从睡眠唤醒后重新校准定时器; - 考虑用Windows任务计划程序的服务,以系统服务方式跑任务,不受用户登录状态影响。
4.3 Windows锁屏定时器失效(扩展思考)
关于Windows锁屏定时器失效,还有一个容易忽略的点:System.Threading.Timer本质是线程池调度,不依赖消息循环,这和System.Windows.Forms.Timer有本质区别。后者在锁屏或窗口假死时是真的会罢工——因为消息循环被阻塞了。而前者只要系统不睡,基本还能正常跑。
如果确实有需求做“锁屏轮询”,我习惯于在PowerModeChanged里记录一个时间戳,唤醒后立即补跑一次任务:
SystemEvents.PowerModeChanged += (s, e) => { if (e.Mode == PowerModes.Resume) { _timer.Change(0, _periodMs); } };这样能保证从睡眠中恢复后,回调不会因为“睡眠期间错过周期”而一直等待到下一个周期。
4.4 定时器漂移和周期不准,怎么办?
定时器回调的实际执行时刻和设定周期之间会有偏差,而且偏差会累积。例如设定period = 1000,但回调执行时间有几十毫秒的抖动,时间长了,回调的时刻点会越漂越远。
如果你需要“整点触发”这类对齐需求(例如每秒第0毫秒触发),不要直接用period,改用一个“自校准”的方式:每次回调内重新计算下次执行时间。
private void DoWork(object state) { var start = DateTime.UtcNow; // 业务逻辑 var elapsed = DateTime.UtcNow - start; var next = TimeSpan.FromSeconds(1) - elapsed; if (next < TimeSpan.Zero) next = TimeSpan.Zero; _timer.Change(next, Timeout.InfiniteTimeSpan); }这样虽然不能做到绝对精确,但每次触发都能“纠正”一点点漂移,比死板地固定周期要准不少。
5. 停不下来的引擎:上手踩坑后的一些思考
动手写代码之前,先想清楚需求到底是“周期任务”还是“延时任务”。这两种场景在System.Threading.Timer里只是参数不同,但设计思路上略有区别。周期任务关键在于防重入和异常隔离;延时任务关键在于Change方法的灵活运用和句柄释放时机。把这两类应用分清楚,写代码时就不容易走弯路。
对于刚入门的朋友,我的建议是:先从一个简单的日志打印开始,故意把period设小一点,比如100毫秒,然后在回调里加个1秒的Sleep,看看输出到底发生了什么——你会更直观地理解线程池调度和重入问题。这种“故意踩坑”的方法,比我在这里讲多少句话都来得快。
我在实际项目里遇到过一个相当隐蔽的坑:回调里使用了某个被Dispose的资源对象,偶尔会抛ObjectDisposedException。排查了老半天,才发现问题是我在Dispose定时器的时候,没有等待正在执行的回调完全退出。后来把“停止”和“释放资源”的顺序反过来,先停定时器,再等待回调完成,最后释放资源,这个问题就再也没出现过。顺序问题看似小,弄错了却很难查。
再补充一个日常最容易忽略的小技巧:开发调试时,不要在回调里直接写Console.WriteLine观察执行时机。控制台输出本身会阻塞,影响定时器调度精度。要观察调度偏差,用一个集合记录每次回调的Environment.TickCount,结束后统一打印输出,这样测出来的数据才是真实的。
如果你做的是上位机或者工控项目,搭配一个日志组件记录每次回调的开始时间、结束时间、耗时,会给你排查问题带来极大的方便。很多幽灵问题其实都藏在定时器回调的执行时间波动里。
System.Threading.Timer并不是C#定时器的全部,但搞懂它,你已经超过了大部分只会拖控件的人。接下来可以把System.Timers.Timer和PeriodicTimer拿来对比学习,再把CancellationTokenSource结合起来做优雅停止,你会发现定时器这个看似简单的东西,其实有很深的挖掘空间。