每逢处理定时任务我最怕的就是“重入”。一句“定时器回调里阻塞了超过间隔时间同一个方法被并发执行”足以让数据错乱、资源争抢、日志刷屏这些麻烦一次性找上门。做了几年后端开发踩过的和见过的定时器坑已经不少了这篇文章就以C#定时器防重入锁为主题把常用的几种方案、设计思路和踩坑记录一次性讲明白。文章会从重入问题的本质讲起逐步拆解 Monitor、SemaphoreSlim、Interlocked、PeriodicTimer 等不同方案的适用场景再给一套可以直接抄作业的完整实现。无论你是刚接触定时任务的新人还是被线上偶发问题折磨得头疼的老开发读完都能搞清楚“为什么需要防重入”以及“到底怎么锁才对”。1. 定时器重入问题到底是怎么发生的1.1 定时器触发的真实行为很多人对定时器的理解是“每隔一段时间调用一次方法”这个说法没错但忽略了最关键的细节绝大多数定时器不是在某个固定线程上串行执行回调的而是把回调扔给线程池。这意味着上一个回调还没跑完下一个触发点到了定时器会毫不犹豫地再调度一次回调两个回调就同时在两个线程里跑了。比如我用System.Timers.Timer设置Interval 1000回调里跑一个数据库同步操作正常情况 500ms 就能完成。某天数据库压力大了一次同步花了 1.2 秒好了第二个 tick 立刻进来同一个同步方法被两个线程同时执行。轻则重复更新同一批数据重则触发死锁、数据覆盖、资源句柄泄漏。从机制上讲System.Timers.Timer和System.Threading.Timer都是基于线程池的回调以异步方式调度定时器本身完全不关心上一次回调是否结束。这就是重入问题的根源定时器只管“到点触发”不管“上次的活干完没有”。1.2 重入带来的三宗“罪”第一是数据错乱。两个回调同时读写同一个状态字段或者数据库记录读写顺序一旦交叉结果就没法看了。比如给用户发消息重入可能造成同一条消息连发两次做数据同步同一批数据可能被重复拉取、重复落库。第二是资源争抢。回调里如果用了某些不自带并发保护的资源比如非线程安全的Dictionary、List或者一个只允许单线程访问的外部系统接口重入会直接打破单线程假设轻则抛出集合被修改的异常重则让第三方系统过载。第三是问题排查难度陡增。重入问题往往是“偶发性”的压测时不出现线上高峰期突然爆发。而且当多个回调并发运行日志之间的关联性被打乱你很难判断当前数数据到底是哪一次回调写入的。我在实际项目里遇到过一个很典型的案例一个订单状态轮询服务用System.Threading.Timer每 2 秒拉取一次订单状态回调里有网络请求和数据库更新某次上游服务响应变慢请求耗时超过了 2 秒结果 5 分钟内同一条订单被 20 多个线程并发处理数据库里同一订单的版本号互相覆盖最后只能手动修复脏数据。从那以后我给自己定了一条规矩所有定时器回调默认先考虑防重入再考虑业务逻辑。2. 防重入锁方案对比哪种方式最省心2.1 方案一Monitor.TryEnter 轻量级同步锁先看最传统的做法在回调入口尝试获取一把锁获取不到说明上一个回调还没结束直接跳过本次触发。private readonly object _syncLock new object(); private void TimerCallback(object state) { if (!Monitor.TryEnter(_syncLock)) { // 上一次回调还没结束丢弃本次触发 return; } try { // 执行业务逻辑 DoWork(); } finally { Monitor.Exit(_syncLock); } }这种方式的优点非常明显简单、轻量、无额外依赖逻辑一目了然。适合回调里没有await的场景比如纯计算、内存数据整理、快速文件写操作。但它的局限也很致命Monitor是线程关联的进入锁的线程和退出锁的线程必须是同一个。一旦回调里用了async/awaitawait之后代码可能在另一个线程上运行Monitor.Exit就会因为“从不同线程释放锁”而抛异常。即使侥幸没抛异常锁的语义也已经被破坏会出现不可预期的行为。所以我的经验是只在全同步回调里使用 Monitor.TryEnter。如果回调里有任何异步操作直接跳到下面的 SemaphoreSlim 方案。2.2 方案二SemaphoreSlim 异步场景的可靠选择SemaphoreSlim是我在异步场景里最常用的防重入工具。它支持异步等待不要求释放线程和获取线程一致而且它的Wait(0)可以做到非阻塞尝试获取获取不到就立刻返回完美满足“这次没空就不干”的需求。private readonly SemaphoreSlim _semaphore new SemaphoreSlim(1, 1); private async Task TimerCallbackAsync() { if (!await _semaphore.WaitAsync(0)) { return; } try { await DoWorkAsync(); } finally { _semaphore.Release(); } }SemaphoreSlim(1, 1)表示初始计数为 1最大计数为 1也就是同时只允许一个线程进入。WaitAsync(0)表示以零超时等待如果拿不到信号量就立刻返回 false直接跳过本次执行。这里有个细节很多文章不会提WaitAsync(0)和Wait(0)是有区别的。Wait(0)是同步阻塞尝试虽然不会真的阻塞因为超时为 0但在异步上下文里最好使用WaitAsync(0)避免任何额外同步阻塞的可能。我在多线程测试中对比过用Wait(0)在极端高并发的异步回调里会增加线程池调度的延迟而WaitAsync(0)行为更稳定。SemaphoreSlim还有一个额外好处是可以设置超时。比如业务要求“如果 5 秒内拿不到锁宁可放弃本次也不等”可以用WaitAsync(TimeSpan.FromSeconds(5))。if (!await _semaphore.WaitAsync(TimeSpan.FromSeconds(5))) { // 记录日志后放弃 return; }这样能防止回调长时间拿不到锁把任务无限期搁置。2.3 方案三停止重启定时器这个思路非常简单回调开始之前先把定时器停掉等回调执行完成后再重新启动。用System.Timers.Timer可以这样实现private readonly System.Timers.Timer _timer; private void OnTimerElapsed(object sender, ElapsedEventArgs e) { _timer.Stop(); try { DoWork(); } finally { _timer.Start(); } }这种方案的逻辑很直白宁可错过触发也不允许并发执行。在 Interval 比执行时间短的场景下它会自动“跳”过部分触发这通常是可以接受的。但这个方案有两个隐患。一个是用System.Timers.Timer的Stop()只是停止后续触发已经在执行的回调并不会被中断。如果DoWork()内部抛了异常且没有 finally定时器就永远不会再启动任务直接“静默死亡”。上面代码里我特意写了 finally就是为了防止这种情况。另一个是回调里如果用了async/awaitStop 之后再 Start 也可能存在时序问题。一个知名做法是“回调停止 异步执行”但异步执行部分的完成时机不受定时器控制你很难保证回调完全结束后才重新启动定时器。因此这个方案最安全的使用方式还是同步回调。2.4 方案四PeriodicTimer 的天然顺序执行.NET 6 开始PeriodicTimer提供了一种完全不同的思路。它不再由定时器自动触发回调而是让你在一个循环里主动等待下一个触发点循环体天然是顺序执行的上一个任务没跑完就不会进入下一个循环从根本上消除了重入。using var timer new PeriodicTimer(TimeSpan.FromSeconds(1)); while (await timer.WaitForNextTickAsync()) { await DoWorkAsync(); }看到区别了吗PeriodicTimer把控制权交回给调用方。WaitForNextTickAsync返回一个Taskbool循环体执行完毕后才会进入下一次等待所以同时只会存在一个DoWorkAsync在执行。这种方案最适合长期运行的异步后台任务比如服务启动时创建一个BackgroundService在ExecuteAsync里跑这个循环。它不需要任何锁代码最整洁也最容易测试。需要注意的是PeriodicTimer的最小时间精度依赖系统定时器通常在几毫秒到十几毫秒的误差范围但这对大多数业务定时任务完全够用。如果项目还在用 .NET Framework 或者低版本 .NET优先使用 SemaphoreSlim 方案。如果已经上了 .NET 6我有大部分场景都会想选 PeriodicTimer不仅防重入还天然支持优雅取消。3. 完整实操构建一个带防重入的定时轮询服务3.1 需求设定与选型假设现在要写一个订单状态轮询服务需求是每 3 秒从外部订单系统拉取一次待处理订单更新本地数据库状态并写入操作日志。外部系统响应不稳定正常情况下 1 秒内能返回高峰期可能需要 5 秒以上。要求不允许同一批订单被并发处理且单次任务执行时间超过 3 秒时跳过下一次触发。这个场景非常典型也是对防重入的需求最强烈的场景。我最终选择 .NET 6 以上版本用PeriodicTimer作为主方案同时给出基于 SemaphoreSlim 的备用写法方便还在老项目里维护的小伙伴直接参考。选 PeriodicTimer 的原因有三循环结构天然保证“上一个任务未完成下一个任务不会开始”不需要额外处理重入锁支持CancellationToken服务停止时可以优雅退出循环代码结构简单逻辑集中在循环体内可读性和可维护性都更好。3.2 手写代码实现PeriodicTimer 后台托管服务下面是完整的后台服务实现。基于 .NET 6/7/8 的 Worker Service 模板代码可以直接放进项目里跑。public class OrderPollingService : BackgroundService { private readonly ILoggerOrderPollingService _logger; private readonly IOrderRepository _orderRepository; private readonly IOrderApiClient _orderApiClient; private readonly TimeSpan _pollingInterval TimeSpan.FromSeconds(3); public OrderPollingService( ILoggerOrderPollingService logger, IOrderRepository orderRepository, IOrderApiClient orderApiClient) { _logger logger; _orderRepository orderRepository; _orderApiClient orderApiClient; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(_pollingInterval); _logger.LogInformation(订单轮询服务已启动间隔 {Interval} 秒, _pollingInterval.TotalSeconds); try { while (!stoppingToken.IsCancellationRequested) { // 等待下一轮触发取消时返回 false退出循环 // try/finally 保证拿不到 tick 的时候也跳过本次 if (!await timer.WaitForNextTickAsync(stoppingToken)) { break; } await PollAndProcessOrdersAsync(stoppingToken); } } catch (OperationCanceledException ex) { _logger.LogInformation(ex, 订单轮询服务收到停止信号); } catch (Exception ex) { _logger.LogError(ex, 订单轮询服务发生未处理异常); } finally { _logger.LogInformation(订单轮询服务已停止); } } private async Task PollAndProcessOrdersAsync(CancellationToken cancellationToken) { var startedAt DateTime.UtcNow; var pollingId Guid.NewGuid().ToString(N); try { _logger.LogInformation([{PollingId}]开始拉取订单开始时间{Time}, pollingId, startedAt); // 1. 拉取待处理订单 var orders await _orderApiClient.GetPendingOrdersAsync(cancellationToken); if (orders null || orders.Count 0) { _logger.LogInformation([{PollingId}]没有待处理订单, pollingId); return; } // 2. 逐条处理记录处理结果 var successCount 0; var failCount 0; foreach (var order in orders) { cancellationToken.ThrowIfCancellationRequested(); try { await _orderRepository.UpdateOrderStatusAsync( order.OrderId, order.TargetStatus, cancellationToken); successCount; } catch (Exception ex) { failCount; _logger.LogError(ex, [{PollingId}]订单 {OrderId} 处理失败, pollingId, order.OrderId); } } _logger.LogInformation( [{PollingId}]拉取完成共 {Total} 单成功 {Success} 单失败 {Fail} 单耗时 {Elapsed:F1} 秒, pollingId, orders.Count, successCount, failCount, (DateTime.UtcNow - startedAt).TotalSeconds); } catch (OperationCanceledException) { _logger.LogWarning([{PollingId}]任务执行期间收到取消信号, pollingId); } catch (Exception ex) { _logger.LogError(ex, [{PollingId}]任务执行过程中发生异常, pollingId); } } }关键点解释PeriodicTimer.WaitForNextTickAsync(stoppingToken)在内部捕获OperationCanceledException当服务停止时返回 false循环正常退出。外层 catch 里保留一个OperationCanceledException分支是为了处理其他位置的取消信号保持整体健壮。每个轮询周期都生成一个标志符pollingId记录在日志里方便追踪同一批任务的处理链路。这是我在处理偶发问题时养成的习惯等你有过“日志刷了几千行但找不到同一次执行的分界线”的经历就明白了。如果在处理大批订单时需要控制吞吐还可以把循环体的处理部分拆成独立的 chunk分批提交数据库避免一次事务太大。3.3 老项目场景SemaphoreSlim 版防重入实现如果你的项目还在用 .NET Framework 或 .NET Core 3.1用不了 PeriodicTimer那就用 SemaphoreSlim 方案。下面是基于System.Timers.Timer的完整实现。public class LegacyOrderPollingService : IDisposable { private readonly System.Timers.Timer _timer; private readonly SemaphoreSlim _semaphore new SemaphoreSlim(1, 1); private readonly ILoggerLegacyOrderPollingService _logger; public LegacyOrderPollingService(ILoggerLegacyOrderPollingService logger) { _logger logger; _timer new System.Timers.Timer { Interval 3000, AutoReset true }; // Elapsed 事件在 ThreadPool 线程上触发 _timer.Elapsed Timer_Elapsed; } public void Start() { _timer.Start(); _logger.LogInformation(旧版订单轮询服务已启动); } public void Stop() { _timer.Stop(); _logger.LogInformation(旧版订单轮询服务已停止); } private async void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) { // 零超时尝试获取信号量拿不到说明上一轮还没执行完 if (!await _semaphore.WaitAsync(0)) { _logger.LogWarning(上一轮任务尚未完成跳过本次触发); return; } try { await DoWorkAsync(); } catch (Exception ex) { _logger.LogError(ex, 订单轮询任务执行异常); } finally { _semaphore.Release(); } } private async Task DoWorkAsync() { // 业务逻辑同上 } public void Dispose() { _timer?.Dispose(); _semaphore?.Dispose(); } }这个版本里我用了async void这是System.Timers.Timer事件处理器中比较常见但也容易出错的一种写法。async void的异常不会向上层传播因此必须在方法内部捕获所有异常。上面代码里 catch 已经兜住了所有异常并且 finally 保证了信号量一定会释放不会出现“锁死”的情况。如果你不喜欢async void也能改用“回调内先同步获取锁再启动异步任务”的方式但控制复杂度就上去了。我的态度是老项目中使用async void 完整 try/catch 并不是原罪问题出在有人只用 try/finally 而忽略 catch或者根本没有理解异常处理责任。3.4 用 Interlocked 做极轻量防重入再分享一种我偶尔会用到的轻量级方案用Interlocked.CompareExchange做原子状态切换。适合回调逻辑极其简单耗时通常很短只需要一个标志位防止并发执行的场景。private int _isRunning 0; private void TimerCallback(object state) { // 将 0 替换为 1返回旧值如果旧值是 1说明已经在运行 if (Interlocked.CompareExchange(ref _isRunning, 1, 0) 1) { return; } try { // 极轻量的业务逻辑 } finally { // 执行完毕恢复为 0 Interlocked.Exchange(ref _isRunning, 0); } }这不是锁它只是一个原子标志位。好处是没有锁竞争的开销坏处是无法实现“等待”也无法提供比布尔状态更复杂的语义。使用场景非常窄我用它处理过“定时清理内存缓存”这种零风险的内部任务。对大多数业务场景SemaphoreSlim 更加舒展、可靠。4. 问题排查实录与避坑指南4.1 这些坑我基本都踩过先梳理几个我实际遇到过的典型问题这些都和定时器防重入密切相关。第一个是Timer.Stop 不能终止正在执行的回调。很多初学者以为调用Stop()就能让一切停下来这对System.Timers.Timer来说是不成立的Stop 只是撤销后续的触发计划已经提交给线程池的回调无法被撤回。所以在使用“停止重启定时器”方案时必须依赖 finally 确保重新启动且不要在回调外部尝试“暴力取消”一个正在运行的同步方法。第二个是lock async 的经典死锁。在异步回调里用lock或Monitor轻则编译警告重则运行时死锁。我在排查一个间歇性服务无响应问题时最后定位到的问题就是 async 回调里用 lock 包住了一段网络请求锁在线程 A 上持有await 之后代码在上下文切换后尝试再进入同一把锁导致卡死。这个问题的隐蔽性在于不是每次都会死锁只有线程池调度导致上下文切换时才触发上线两周都没暴露。第三个是AutoReset 与 ManualReset 的选择错误。System.Timers.Timer的AutoReset属性默认是 true表示每次间隔到点都触发。如果设成 false定时器只触发一次必须在回调里手动调用Start()才会进入下一轮。这种模式本身就不存在重入问题但很多人忘记Start()于是任务运行一次后完全消失。后来我在项目组里立了个规矩禁止裸用AutoResettrue且不设防重入的定时器要么显式防重入要么用 single-shot 模式配合下一次启动。第四个是SemaphoreSlim 忘记 Release。这个问题最容易出现在回调中途出现分支比如某个 return 写在 try 块内部finally 里虽然写了 Release但有人把业务代码写在 try 外面、写在 Release 前面。一旦这样的代码上线第二次执行业务逻辑就会永远卡在 WaitAsync 上。为了避免这个问题我在编码规范里强制要求SemaphoreSlim 的获取和 Release 必须成对出现在同一个方法中中间只允许被 try/finally 分割不允许 try/catch 后单独 return 绕过 finally。第五个是线程池饥饿与任务堆积。当回调执行时间长、定时器间隔又短且防重入策略选择的是“等待前一个任务结束”而不是“跳过本次触发”就会发生任务排队。排在后面的任务占着线程池线程整个系统的其他异步操作也受影响最后表现为所有异步操作都有延迟。排查这种现象时性能计数器和线程池队列长度是重要指标。我在写这些坑的时候都是对照自己出过事故的代码回顾的。技术方案本身不难难的是在业务里保持对并发问题的敬畏并保证异常路径下锁一定会释放、任务一定不会死锁。4.2 常见问题速查表与推荐选型逻辑用一张表快速对照四种主流防重入方案方案适用场景是否支持 async是否需要锁对象失败行为实现复杂度Monitor.TryEnter纯同步短回调否是跳过本次低SemaphoreSlim.WaitAsync(0)异步回调是是跳过本次低停止重启 TimerSystem.Timers.Timer 同步回调受限否跳过触发中PeriodicTimer 循环.NET 6 后台服务是否自动串行执行低几个选型时的参考原则只要是 .NET 6 项目后台周期任务优先想一下能不能用PeriodicTimer用不着锁就是最大的优势。老项目 .NET Framework 里异步回调默认用SemaphoreSlim.WaitAsync(0)别指望 Monitor。如果任务性极强且业务允许部分触发被跳过选择“跳过本次”策略。如果业务必须“上一次执行完后紧接着执行下一次”选择队列化或等待策略但一定限制最大等待时间。定时回调中的业务逻辑必须做到可重入也就是即使真的发生重入数据也不会损坏。防重入锁是兜底但不能成为业务数据安全的唯一防线。关于定时器类型也做个简单对比定时器类型所在空间回调线程防重入难易度System.Timers.TimerSystem.Timers线程池需要显式处理System.Threading.TimerSystem.Threading线程池需要显式处理System.Windows.Forms.TimerWinFormsUI 线程天然串行但精度低DispatcherTimerWPFUI 线程天然串行PeriodicTimer内置线程池天然串行Windows Forms 和 WPF 自带的定时器因为是跑在 UI 线程上回调之间天然串行不会出现重入但副作用是 UI 操作不能占太长时间否则界面卡死。这类定时器做轻量 UI 刷新没问题不适合做后台业务任务。4.3 调试防重入逻辑的三个实操技巧先聊一下日志定位法。在所有回调的入口和出口打上日志并带一个唯一标识。周期任务里的标识可以用计数也可以用Guid但一定得在单次任务生命周期的所有日志里保持一致。出现重入时你会看到两条相同标识的任务并发穿插在日志中一眼就能识别。然后说取消令牌核验。在现代 .NET 里几乎所有长时间运行的定时任务都应该接受一个CancellationToken并且在业务循环的多个位置调用ThrowIfCancellationRequested。这样服务停机时任务能及时响应取消信号而不是继续执行到一半造成资源泄漏。配合防重入锁能让任务在取消时立即释放锁避免锁长期占用。再就是压测验证法。写一段测试代码故意把回调里的Delay设置在定时器间隔附近比如间隔 1 秒任务耗时随机在 800 到 1500 毫秒之间波动然后观察是否出现并发执行、是否出现数据重复。压测时不要只看功能对不对还要盯着线程数、CPU、内存。我曾经在一个测试环境里用System.Diagnostics.StackTrace记录并发回调的线程 ID从而确认了防重入逻辑真正生效。5. 改造既有定时任务时的一个共享经验最后再分享一个具体的改造经验。某次负责一个已有模块的维护原代码是一个System.Threading.Timer每 5 秒执行一次数据聚合同步回调内部有网络请求但完全没有防重入。生产环境偶尔出现重复数据每次重启服务能好一段时间过几天又会复现。我没直接改业务逻辑而是先给整个调用链补上日志确认重入是否真的发生。监控了几天后确认在服务响应缓慢的时间窗口出现了并发回调。因为项目当时还在 .NET Core 3.1不能直接用 PeriodicTimer我选择用 SemaphoreSlim 方案加防重入同时把同步回调改成了异步回调。改造时特别关注了三个地方第一原来回调里的业务逻辑用 try/catch 包裹但缺少 finally我在外层统一加了 try/finally 保证信号量释放第二为了不改变原有“每 5 秒触发一次”的行为把 SemaphoreSlim 的等待策略定为 WaitAsync(0)拿不到锁就跳过第三在 Web 服务停机时补充了 CancellationToken 的传递保证正在执行的任务可以安全退出。改造上线后连续跑了两周偶发重复数据的问题没有再出现。这段经历给我的启发是定时器防重入不是有没有必要的问题而是什么时机、用什么方案的问题。把重入问题想清楚方案的落地往往比预想的更快而且能避免后面更大的维护成本。