SemaphoreSlim是控制Task并发数最直接的方式,通过异步闸门限制同时执行任务数,需在共享作用域初始化、配对使用WaitAsync()和Release(),并在finally中确保释放,避免许可证泄漏。

用 SemaphoreSlim 控制 Task 并发数最直接
绝大多数场景下,你根本不需要配置或继承 TaskScheduler。真正要解决的不是“任务往哪调度”,而是“别让 100 个 Task.Run 同时冲进线程池”。SemaphoreSlim 就是这道轻量、异步、可控的闸门。
它不碰线程池底层,不改 SynchronizationContext,只在业务逻辑入口加一层等待:最多 N 个任务能同时进临界区(比如发 HTTP 请求、查数据库),其余自动排队等 Release()。
-
SemaphoreSlim(3)表示最多 3 个任务并发执行;第 4 个会await WaitAsync()挂起,不占线程 - 必须在类字段或静态域初始化一次,不能在方法里每次
new—— 否则限流完全失效 - 初始计数不能为 0,否则所有
WaitAsync()都永久挂起 - 务必配对
await WaitAsync()和Release(),异常路径也要释放:用try/finally或 C# 8+ 的using(注意不是await using,除非你用的是AsyncSemaphore封装)
别碰自定义 TaskScheduler,除非你在写框架
继承 TaskScheduler 并重写 QueueTask 或 GetScheduledTasks 是高危操作。它和线程池、await 续传、调试器诊断深度耦合,稍有不慎就会导致任务丢失、死锁、或 Task 状态错乱。
常见误判是以为“重写 QueueTask 加个计数器就能限流”——其实它只决定“这个 Task 扔给谁执行”,不管“扔多少”“啥时候扔”。真正的并发控制仍得靠上层协调(比如 SemaphoreSlim)。
- 真实适用场景极少:UI 调度器(如 WinForms/WPF 自定义同步上下文)、测试用确定性调度器、嵌入式环境彻底接管任务分发
- .NET 6+ 中
ThreadPoolTaskScheduler已 internal,公开扩展点只剩抽象基类,兼容性和可维护性变差 -
QueueTask内不能做耗时操作(如 I/O、Thread.Sleep),否则会阻塞整个调度链路
ParallelOptions.MaxDegreeOfParallelism 只适合 CPU 密集型同步循环
如果你的任务是纯计算(比如图像缩放、JSON 解析、数组排序),且全部是同步方法,Parallel.ForEach(..., new ParallelOptions { MaxDegreeOfParallelism = 4 }) 是简单可靠的选择。
但它对任何含 await 的异步操作完全无效——Parallel 不理解 Task,也不会 await。强行传 async lambda 会导致返回 Task<Task>,实际并发数失控。
- HTTP 请求、EF Core
SaveChangesAsync、文件读写等 I/O 场景,Parallel基本没用,必须换SemaphoreSlim - 混合场景(比如先并行拉数据,再并行处理):拉数据部分用
SemaphoreSlim,处理部分若为同步计算,才考虑Parallel - 它限制的是
Parallel内部线程调度器的并发度,不影响线程池全局行为
常见错误:释放次数不匹配、未 await、作用域错乱
限流失效往往不是逻辑问题,而是使用姿势错了。三个高频坑点:
-
Release()调用次数 >WaitAsync():比如在try块里多调了一次,导致信号量计数溢出,后续所有等待都立即通过,限流形同虚设 - 忘记
awaitWaitAsync():写成semaphore.WaitAsync().Wait()或直接semaphore.Wait(),会阻塞线程池线程,引发饥饿甚至死锁 -
SemaphoreSlim声明在方法内:每次调用都新建实例,每个实例都是独立计数器,根本无法跨调用限流
最稳妥写法是字段级单例 + try/finally,或者封装成带 await using 支持的异步资源管理器(需 C# 12+)。并发控制的脆弱点不在算法,而在这些细节能否稳住。


















