SemaphoreSlim的WaitAsync取消问题及.NET5与.NET Framework行为差异
结论
这不是.NET 5 SemaphoreSlim 的bug,是.NET Core 3.0及后续版本(包括.NET 5+)针对SemaphoreSlim的性能优化导致的行为变更,和.NET Framework的实现逻辑存在差异。
行为差异原因
- .NET Framework中,
SemaphoreSlim.WaitAsync(CancellationToken)在取消令牌触发时,会立刻将对应的等待请求从内部等待队列中移除,后续调用Release()时不会匹配到已取消的请求,因此信号量计数会加1,等待任务状态也会立刻标记为Canceled,和你的预期一致。 - .NET Core 3.0及之后的版本,为了降低高并发场景下无超时
WaitAsync调用的性能开销,调整了实现逻辑:取消令牌触发时不会主动从队列中删除等待项,而是等到Release()唤醒该等待项时,才会校验取消状态。你测试时打印状态的时机是在Release()之后、等待任务被调度校验之前,因此任务还保持WaitingForActivation状态,同时你释放的信号量刚好被这个未被移除的等待项占用,所以CurrentCount为0。
补充现象解释
- 在
Release()前await entrance:会主动触发取消状态校验,等待项会被从队列中移除,同时抛出OperationCanceledException,符合你的预期。 - 在
Release()后await entrance:等待项已经被Release()唤醒,调度时检测到虽然令牌已取消,但已经成功获取到信号量,因此不会抛出取消异常,信号量被正常消耗,这是新版逻辑的正常表现。
复现.NET Framework行为的方案
不需要使用额外的hack,官方提供了兼容实现:
- 调用带超时参数的
WaitAsync重载,显式传入无限超时即可:semaphore.WaitAsync(Timeout.Infinite, cts.Token)。带超时参数的重载保留了旧版的实现逻辑,取消时会立刻从队列移除等待项,这就是你之前测试加超时参数就符合预期的原因,该方案是官方支持的兼容方案,可放心使用。 - 如果不想使用超时重载,可在取消令牌触发后、调用
Release()前,主动await等待任务并捕获取消异常,确保等待项被提前从队列中移除,后续释放的信号量就不会被消耗,示例代码如下:
using System; using System.Threading; using System.Threading.Tasks; public class Program { public static async Task Main(string[] args) { var semaphore = new SemaphoreSlim(0); var cts = new CancellationTokenSource(); var entrance = semaphore.WaitAsync(cts.Token); cts.Cancel(); // 主动等待取消生效 try { await entrance; } catch (OperationCanceledException) { // 忽略预期的取消异常 } cts.Dispose(); semaphore.Release(); Console.WriteLine("Entrance status: " + entrance.Status); Console.WriteLine("Current count: " + semaphore.CurrentCount); } }
上述代码运行后会输出你预期的结果:
Entrance status: Canceled
Current count: 1
内容的提问来源于stack exchange,提问作者fharreau
相关产品推荐
相关产品推荐

