SemaphoreSlim 是控制 Task 并发数最直接轻量的选择,通过异步闸门限制同时执行任务数,需配对 WaitAsync() 和 Release() 并在 finally 中确保释放;自定义 TaskScheduler 适用场景极窄,ParallelOptions.MaxDegreeOfParallelism 仅适用于同步 CPU 密集型任务。

用 SemaphoreSlim 控制 Task 并发数最直接
多数人想限制并发,其实不是真要动 TaskScheduler,而是怕一堆 Task.Run 同时砸向线程池,导致资源打满、响应变慢或外部服务被压垮。这时候 SemaphoreSlim 是更轻、更可控的选择。
它不改调度逻辑,只在业务层加一道“闸门”,而且支持异步等待,不会阻塞线程。
- 别用
lock或Monitor—— 它们是同步的,会白占线程池线程 -
SemaphoreSlim(3)表示最多 3 个任务能同时执行,第 4 个得等前边有释放才进 - 必须配对调用
WaitAsync()和Release(),漏掉Release()就会永久卡死 - 注意异常路径:要在
finally或using(C# 8+)里Release()
var semaphore = new SemaphoreSlim(2);
var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync();
try
{
return await DownloadAsync(url); // 实际耗时操作
}
finally
{
semaphore.Release();
}
});
自定义 TaskScheduler 的真实适用场景很窄
除非你在做框架级开发(比如写一个专用的 UI 调度器、测试用的确定性调度器,或嵌入式环境里彻底接管任务分发),否则不建议碰 TaskScheduler。它底层和线程池、SynchronizationContext、甚至 await 的续传机制都耦合很深。
常见误用:以为继承 TaskScheduler 就能“限流”——其实它只管“把 Task 扔到哪去执行”,不管“扔多少”或“啥时候扔”。并发控制还得靠上层协调。
-
QueueTask方法里不能做耗时操作,否则会拖慢整个调度链路 - 如果重写
GetScheduledTasks,返回的Task必须是当前 scheduler 真正管理的,否则调试器和诊断工具会失准 - .NET 6+ 中
ThreadPoolTaskScheduler已被标记为 internal,公开继承点只剩TaskScheduler基类,扩展性比以前更弱
ParallelOptions.MaxDegreeOfParallelism 适合 CPU 绑定批量任务
如果你的并发目标是跑一堆计算密集型操作(比如图像处理、数据转换),而不是发 HTTP 请求或读文件,Parallel.ForEach 配 MaxDegreeOfParallelism 更简单可靠。
它背后走的是线程池 + 分块策略,对 CPU 利用率友好,也不需要手动管理生命周期。
- 值设为
-1表示不限(默认行为),设为4就最多用 4 个线程并行 - 它**不适用于 async/await 场景**:传进去的
Action<t></t>必须是同步方法,里面写await会导致立即返回、实际没等完 - 和
SemaphoreSlim不同,它不感知 I/O 等待时间,纯看线程占用数
Parallel.ForEach(urls, new ParallelOptions { MaxDegreeOfParallelism = 3 }, url =>
{
var result = HeavyCompute(url); // 必须同步
Save(result);
});
容易被忽略的陷阱:取消与超时必须穿透到底层
无论你用 SemaphoreSlim、Parallel 还是自定义 TaskScheduler,只要涉及外部依赖(HTTP、DB、文件),取消和超时就不能只挂在最外层 Task 上。
比如用 SemaphoreSlim.WaitAsync(cancellationToken) 等闸门,但里层 HttpClient.SendAsync 没传同一个 cancellationToken,那用户点了取消,闸门会退,但请求还在后台跑着。
-
SemaphoreSlim.WaitAsync支持CancellationToken,但Release()不支持——所以取消后记得自己清理状态 - 所有 I/O 调用(
ReadAsync、SendAsync、ExecuteReaderAsync)都要显式传入 token - 不要依赖
Task.CancellationToken属性——它是只读的,且只在Task.Run创建时绑定,无法动态注入
并发控制本身不难,难的是让取消、错误、重试、日志这些横切关注点,在每一层都真正生效。漏一层,就可能变成“看起来取消了,其实没停”。











