SemaphoreSlim.WaitAsync取消时偶发不抛OperationCanceledException
SemaphoreSlim.WaitAsync 取消可靠性问题说明
复现场景
参考如下.NET 6控制台程序代码:
// See https://aka.ms/new-console-template for more information Console.WriteLine("Hello, World!"); var semaphore = new SemaphoreSlim(1, 1); var cts = new CancellationTokenSource(); var tasks = Enumerable.Range(0, 10).Select(WaitingTask); var t = Task.Run(async () => { await Task.WhenAll(tasks); Console.WriteLine("Tasks complete"); }); await Task.Delay(500); Console.WriteLine("Press any key to Cancel waiting tasks, then immediately release semaphore"); Console.ReadKey(); cts.Cancel(); semaphore.Release(); await t; async Task WaitingTask(int i) { try { Console.WriteLine($"{i} Waiting"); await semaphore.WaitAsync(cts.Token); Console.WriteLine($"{i} Aquired"); await Task.Delay(50); Console.WriteLine($"{i} Complete"); } catch (OperationCanceledException) { Console.WriteLine($"{i} Cancelled"); } }
代码逻辑为创建10个任务竞争最大并发数为1的SemaphoreSlim信号量,待首个任务执行完成、其余9个任务均处于等待信号量状态时,先触发传入等待任务的CancellationToken取消,再立即释放信号量锁。
- 预期行为:剩余9个等待任务均抛出并处理
OperationCanceledException,输出"Cancelled" - 实际行为:8个等待任务符合预期,另有1个任务会成功获取信号量并正常执行完成,即
WaitAsync(CancellationToken)调用无法被100%可靠取消;如果注释掉semaphore.Release();代码行,9个等待任务均会按预期输出取消结果。
示例运行输出如下:
Hello, World! 0 Waiting 0 Aquired 1 Waiting 2 Waiting 3 Waiting 4 Waiting 5 Waiting 6 Waiting 7 Waiting 8 Waiting 9 Waiting 0 Complete Press any key to Cancel waiting tasks, then immediately release semaphore 1 Aquired 4 Cancelled 8 Cancelled 5 Cancelled 9 Cancelled 6 Cancelled 3 Cancelled 2 Cancelled 7 Cancelled 1 Complete Tasks complete
原因说明
预期的"取消后所有等待任务一定抛出取消异常"的行为不符合.NET的设计逻辑,该现象是SemaphoreSlim本身的设计导致的正常竞态结果,不属于bug:
WaitAsync(CancellationToken)的取消操作与信号量分配操作不存在原子性保证。调用cts.Cancel()时,取消信号需要经过注册回调触发、等待任务状态流转的极短时间窗口,并不是调用Cancel的瞬间所有等待任务就立刻完成了取消流程。SemaphoreSlim的等待队列遵循FIFO(先进先出)公平调度规则,调用Release()时会立刻从等待队列头部取出等待者分配信号量。如果此时队列头部的等待者还没来得及完成取消状态流转,就会成功拿到信号量,WaitAsync会返回成功结果,不会抛出OperationCanceledException。- 从设计语义上看,传入CancellationToken的作用是取消等待操作本身——如果在等待操作拿到信号量之前取消生效,就抛出异常终止等待;如果取消生效前已经成功获取到信号量,等待操作就视为成功完成,自然不会触发取消异常。
适配方案
如果需要保证取消信号触发后所有等待任务都不会继续执行后续逻辑,可以在WaitAsync返回成功后增加一次取消状态检查,发现已取消就主动释放信号量再抛出异常,修改WaitingTask逻辑如下:
async Task WaitingTask(int i) { bool semaphoreTaken = false; try { Console.WriteLine($"{i} Waiting"); await semaphore.WaitAsync(cts.Token); semaphoreTaken = true; // 拿到信号量后二次校验取消状态 cts.Token.ThrowIfCancellationRequested(); Console.WriteLine($"{i} Aquired"); await Task.Delay(50); Console.WriteLine($"{i} Complete"); } catch (OperationCanceledException) { if (semaphoreTaken) { semaphore.Release(); } Console.WriteLine($"{i} Cancelled"); } }
内容的提问来源于stack exchange,提问作者Rob Smith
相关产品推荐
相关产品推荐

