☰
C#定时器防重入:四种锁方案与后台任务实践
2026/10/21 23:53:57 网站建设 项目流程

每逢处理定时任务,我最怕的就是“重入”。一句“定时器回调里阻塞了超过间隔时间,同一个方法被并发执行”,足以让数据错乱、资源争抢、日志刷屏这些麻烦一次性找上门。做了几年后端开发,踩过的和见过的定时器坑已经不少了,这篇文章就以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/await,await之后代码可能在另一个线程上运行,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/await,Stop 之后再 Start 也可能存在时序问题。一个知名做法是“回调停止 + 异步执行”,但异步执行部分的完成时机不受定时器控制,你很难保证回调完全结束后才重新启动定时器。因此这个方案最安全的使用方式还是同步回调。

2.4 方案四:PeriodicTimer 的天然顺序执行

.NET 6 开始,PeriodicTimer提供了一种完全不同的思路。它不再由定时器自动触发回调,而是让你在一个循环里主动等待下一个触发点,循环体天然是顺序执行的,上一个任务没跑完就不会进入下一个循环,从根本上消除了重入。

using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); while (await timer.WaitForNextTickAsync()) { await DoWorkAsync(); }

看到区别了吗?PeriodicTimer把控制权交回给调用方。WaitForNextTickAsync返回一个Task<bool>,循环体执行完毕后才会进入下一次等待,所以同时只会存在一个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 ILogger<OrderPollingService> _logger; private readonly IOrderRepository _orderRepository; private readonly IOrderApiClient _orderApiClient; private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(3); public OrderPollingService( ILogger<OrderPollingService> 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 ILogger<LegacyOrderPollingService> _logger; public LegacyOrderPollingService(ILogger<LegacyOrderPollingService> 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(),于是任务运行一次后完全消失。后来我在项目组里立了个规矩:禁止裸用AutoReset=true且不设防重入的定时器,要么显式防重入,要么用 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 的传递,保证正在执行的任务可以安全退出。

改造上线后,连续跑了两周,偶发重复数据的问题没有再出现。这段经历给我的启发是:定时器防重入不是有没有必要的问题,而是什么时机、用什么方案的问题。把重入问题想清楚,方案的落地往往比预想的更快,而且能避免后面更大的维护成本。

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

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

立即咨询