C#线程取消异常:已取消的锁续约线程仍持续执行
问题背景
生产环境出现异常:循环执行的后台线程已停止,但关联的KeepRenewingLock线程仍在运行,锁项持续被续约。原预期通过finally块中的leaseCancellation?.Cancel()取消该线程,但未生效。触发场景为IBackgroundJob实现因Http超时(超过100秒)抛出TaskCancelledException,代码退出内层while循环后,锁续约仍未停止。
核心原因分析
1. KeepRenewingLock方法未及时响应取消令牌
KeepRenewingLock是同步void方法,内部循环依赖!cancellationToken.IsCancellationRequested判断是否继续,但存在两个关键问题:
- 如果循环内的延迟使用
Thread.Sleep而非支持取消的Task.Delay,线程会进入无响应睡眠,直到睡眠结束才会检查取消状态,这段时间锁仍会被续约; - 若锁续约的业务代码执行时间较长,即使取消令牌已触发,也需等待当前续约操作完成才会退出循环,导致锁续约持续。
2. 内层while循环退出条件存在冗余逻辑
内层循环条件为!cancellationToken.IsCancellationRequested && !maintainLockTask.IsCompleted,但maintainLockTask只有在KeepRenewingLock方法执行完毕后才会标记为完成,而KeepRenewingLock是无限循环(除非被取消),因此!maintainLockTask.IsCompleted始终为真,该条件等价于仅检查cancellationToken.IsCancellationRequested。若IBackgroundJob抛出的TaskCancelledException未触发外层cancellationToken的取消信号,内层循环可能因其他异常退出,但此时leaseCancellation的取消是否及时生效,仍取决于KeepRenewingLock对令牌的响应速度。
3. 取消令牌的传播存在潜在漏洞
RunBackgroundJobSafely中捕获异常的条件为when (!_cts.IsCancellationRequested),若IBackgroundJob抛出的TaskCancelledException并非来自_cts的取消(如Http超时),异常会被捕获并仅记录日志,不会触发leaseCancellation的取消。此时内层循环可能因Task.Delay抛出异常而退出,但finally块的leaseCancellation?.Cancel()是否能立即终止KeepRenewingLock,仍受限于该方法的取消响应能力。
修复方案
1. 改造KeepRenewingLock为异步方法,确保及时响应取消
将同步void方法改为async Task,使用支持取消的Task.Delay,并在关键节点检查取消令牌:
private async Task KeepRenewingLock(CancellationToken cancellationToken) { try { while (!cancellationToken.IsCancellationRequested) { // 使用支持取消的延迟 await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); // 检查取消令牌,避免执行不必要的续约 cancellationToken.ThrowIfCancellationRequested(); // 执行锁续约逻辑 // code to renew lease } } catch (OperationCanceledException) { // 取消异常无需日志,属于预期流程 } catch (Exception ex) { // code to log error } }
2. 简化内层循环逻辑,明确退出条件
移除冗余的!maintainLockTask.IsCompleted判断,确保循环仅受cancellationToken控制:
while (!cancellationToken.IsCancellationRequested) { await RunBackgroundJobSafely(cancellationToken); await Task.Delay(TimeSpan.FromMinutes(2), cancellationToken); }
3. 优化异常处理,确保取消信号正确传递
在RunBackgroundJobSafely中,若IBackgroundJob因超时抛出TaskCancelledException,可根据业务需求决定是否触发leaseCancellation的取消,例如:
private async Task RunBackgroundJobSafely(CancellationToken cancellationToken) { try { await _backgroundJob.ExecuteAsync(cancellationToken); } catch (TaskCancelledException ex) when (!_cts.IsCancellationRequested) { // 记录超时日志,并触发锁续约的取消 // log timeout error throw; // 抛出异常,让外层try/catch捕获并进入finally块执行取消 } catch (Exception ex) when (!_cts.IsCancellationRequested) { // log other errors } }
4. 确保leaseCancellation的安全使用
在try块中初始化leaseCancellation后,可添加空值检查,避免极端场景下的空引用:
try { leaseCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); if (leaseCancellation == null) { // 日志记录并跳过后续逻辑 return; } var maintainLockTask = Task.Run(() => KeepRenewingLock(leaseCancellation.Token), leaseCancellation.Token); // 内层循环逻辑... }
内容的提问来源于stack exchange,提问作者Tim Butterfield

