先问一个问题:你还在用 Task.WaitAll 吗?还在循环里无脑地 Task.Run 吗?如果你在真实服务中这么干,那线程池饥饿、响应延迟飙升、异常静默这些坑,早晚会踩上一个。
别觉得这是危言耸听。这两类写法,是很多人在高并发场景下遇到性能瓶颈的直接原因。
Task.Run 什么时候该用、什么时候不该用
关键看任务性质——这是判断的唯一标准。CPU 密集型任务,才适合扔给线程池;IO 密集型任务,包一层 Task.Run 只是自欺欺人。
- CPU 密集型任务(比如图像缩放、加密解密、大量数值计算):用
Task.Run是合理的,但必须控制并发数。通常的做法是用SemaphoreSlim限流,避免一下子开几百个任务把线程池拖垮。 - IO 密集型任务(比如
HttpClient.GetAsync、File.ReadAllTextAsync):直接调原生的*Async方法即可。千万不要写Task.Run(() => File.ReadAllText(...))——这种写法会占用线程池线程干等,不是异步,是“伪异步”。 - 混合场景(比如先读配置文件再做校验计算):拆成两段处理,
await File.ReadAllTextAsync(...)负责 IO,Task.Run(() => ValidateAndCompute(...))负责计算,分而治之。
怎么判断自己是不是踩坑了?观察几个信号:ThreadPool.GetA vailableThreads 返回 0、HTTP 请求超时陡增、日志里看不到异常但接口卡死——这些现象出现时,多半是 Task.Run 用错了地方。
并行等待多个任务:必须用 await Task.WhenAll
Task.WaitAll 是同步阻塞调用,在 ASP.NET Core 或 WinForms 等有同步上下文的环境里,轻则性能归零,重则死锁。这可不是危言耸听。
await Task.WhenAll(tasks)返回Task,结果按传入顺序排列,可以直接遍历,干净利落。Task.WaitAll(tasks)返回void,你还得手动遍历每个task.Result,而且一旦某个任务失败,整个调用会抛出AggregateException,不拆包你根本不知道哪个出错了。- 异常处理要提前包裹单个 task。比如这样写:
var t1 = TryGetUserAsync().ContinueWith(t => t.Exception?.ToString() ?? "OK"),而不是等WhenAll完了再统一 try/catch。 - 如果任务数量很大(比如上千个请求),要考虑分批执行。每 20–50 个一组,用
await Task.WhenAll(batch)处理,避免调度器状态机开销过大。
别碰 Task.Factory.StartNew,除非你真懂 TaskCreationOptions
这个方法的默认行为有点“坑”:它不捕获同步上下文,也不支持 async lambda 的自动展平。如果你写 Task.Factory.StartNew(async () => await DoWork()),得到的是 Task,不是 Task。用 await 去等它,不仅会卡住,异常还会被吞掉。
- CPU 密集型任务,一律走
Task.Run。它内部做了正确封装,支持 async lambda 并自动Unwrap,省心很多。 - 只有在真正需要
LongRunning或PreferFairness选项时,才考虑Task.Factory.StartNew,而且必须显式指定TaskScheduler.Default。 - 常见错误现象:任务看似启动了,但后续 await 不返回;或者
task.IsFaulted == true却查不到任何异常堆栈。遇到这类情况,先检查是不是用了Task.Factory.StartNew。
最后说一个容易被忽略的观点:并行不等于更快。如果所有任务都在争抢同一个锁、共用一个数据库连接池、或反复触发 GC,加再多线程也白搭。先压测,再分析瓶颈,比盲目上 Task.Run 有用得多。
