3、I/O密集型与CPU密集型:区分两种场景、选择合适的异步策略、线程池调优
好,咱们直接进入正题。
很多同学写高并发代码,上来就是Task.Run一把梭。嗯,我当年也这么干过。结果呢?CPU没跑满,线程却爆了,上下文切换把系统拖死。说白了,就是没搞清楚场景——你面对的到底是I/O密集型,还是CPU密集型?
这两种场景,应对策略完全不同。搞混了,线程池调优就是瞎调。今天我就把这块掰开揉碎了讲清楚。
3.1 先搞清楚:你的瓶颈在哪?
我习惯用一个简单粗暴的方法来判断:
- CPU密集型:代码大部分时间在计算,CPU利用率接近100%。比如图像处理、视频编码、复杂数学运算。
- I/O密集型:代码大部分时间在等待,CPU利用率很低。比如读写数据库、调用远程API、读写文件。
你想想看,如果一个请求进来,90%的时间都在等数据库返回,你开100个线程和开10个线程,对CPU的影响其实不大。但如果你开100个线程去做CPU密集的运算,那系统直接就崩了。
核心原则:
- I/O密集型:用异步I/O(async/await),减少线程占用。
- CPU密集型:用并行计算(Parallel / PLINQ),压榨CPU性能。
3.2 I/O密集型场景:异步才是王道
我在项目中遇到过最典型的场景——网关服务。每个请求进来,要查三次缓存、调两个下游API、写一次日志。全是I/O操作。
如果每个请求都用一个线程去阻塞等待,那线程池很快就枯竭了。我记得有一次线上告警,线程数飙到3000多,CPU才20%,但请求超时率高达40%。
怎么解决?用async/await。
// ❌ 错误做法:同步阻塞
public UserInfo GetUser(int userId)
{
var user = _db.Users.Find(userId); // 线程在这里阻塞等待
var orders = _orderService.GetOrders(userId); // 又阻塞
return new UserInfo { User = user, Orders = orders };
}
// ✅ 正确做法:异步非阻塞
public async Task<UserInfo> GetUserAsync(int userId)
{
var userTask = _db.Users.FindAsync(userId);
var ordersTask = _orderService.GetOrdersAsync(userId);
await Task.WhenAll(userTask, ordersTask); // 同时发起,不浪费线程
return new UserInfo { User = await userTask, Orders = await ordersTask };
}
这里有个关键点:异步不是让操作变快,而是让线程不被浪费。当你在await的时候,线程会回到线程池去处理其他请求。等I/O完成了,再回来继续执行。
我的习惯:所有涉及I/O的方法,一律从底层开始设计成async。不要等到上层再包一层Task.Run,那是掩耳盗铃。
3.3 CPU密集型场景:并行计算的艺术
CPU密集型的场景,异步帮不了你。因为CPU本身就是瓶颈,你异步了,计算还是得CPU来做。
我记得有一次做报表系统,需要把几十万条记录做聚合计算。一开始用单线程跑,跑了20秒。用户直接骂娘。
后来改成Parallel.ForEach,把数据分块,每个CPU核心处理一块。结果呢?4秒搞定。
// 单线程版本
var results = new List<ReportRow>();
foreach (var record in allRecords)
{
var row = HeavyComputation(record); // 纯CPU计算
results.Add(row);
}
// 并行版本
var results = new ConcurrentBag<ReportRow>();
Parallel.ForEach(allRecords, record =>
{
var row = HeavyComputation(record);
results.Add(row);
});
但这里有个坑——并行度不是越高越好。我曾经犯过一个错误,在Parallel.ForEach里又嵌套了Parallel,结果线程数爆炸,上下文切换开销比计算本身还大。
避坑指南:我曾经在并行循环里调用了数据库查询,结果连接池被打满。记住:并行计算只适合纯CPU操作,里面不要有I/O调用。
3.4 线程池调优:别让默认值坑了你
.NET线程池的默认配置,说实话,只适合一般场景。高并发下,你得手动调。
| 参数 | 默认值 | 我的建议 | 说明 |
|---|---|---|---|
| MinThreads | CPU核心数 | CPU核心数 * 2 | 减少线程创建延迟 |
| MaxThreads | 1000(.NET 6+) | 视场景而定 | I/O密集型可以大,CPU密集型要小 |
| IOCP MinThreads | CPU核心数 | CPU核心数 * 4 | 异步I/O专用线程池 |
我一般在程序启动时就设置:
// 在 Program.cs 或 Startup 中
ThreadPool.SetMinThreads(
workerThreads: Environment.ProcessorCount * 2,
completionPortThreads: Environment.ProcessorCount * 4
);
// 验证设置
ThreadPool.GetMinThreads(out int worker, out int io);
Console.WriteLine($"Worker: {worker}, IOCP: {io}");
为什么要调大MinThreads?因为线程池创建线程是有延迟的。高并发瞬间涌入,如果线程池还在慢慢创建线程,请求就会排队。我遇到过这种情况——QPS刚上去,延迟直接翻倍,就是因为线程池在「热启动」。
重要提醒:MaxThreads不要随便改。默认1000对于绝大多数场景已经够用。你调大了,反而可能让系统在极端情况下创建过多线程,导致内存溢出。
3.5 混合场景:怎么选?
现实中的业务,往往是混合的。比如一个请求进来,先查数据库(I/O),然后做数据转换(CPU),再调第三方API(I/O)。
我的策略是:
- I/O部分用async/await,让线程不被阻塞。
- CPU部分用Task.Run,丢到线程池里并行处理。
- 整体用async包装,保持调用链路的异步性。
public async Task<ProcessResult> ProcessAsync(int id)
{
// 第一步:I/O - 异步查询
var data = await _db.GetDataAsync(id);
// 第二步:CPU - 并行计算
var computed = await Task.Run(() => HeavyTransform(data));
// 第三步:I/O - 异步调用
var result = await _apiService.SendAsync(computed);
return result;
}
这里要注意:Task.Run会占用一个线程池线程。如果CPU计算很快(毫秒级),直接用Task.Run反而有线程切换开销。我一般只在计算超过50ms时才用Task.Run。
3.6 实战中的几个坑
- 坑一:异步里用.Result或.Wait()——这会导致死锁。我见过太多人这么写了。记住:从底层到顶层,一路async到底。
- 坑二:Parallel.ForEach里操作共享变量不加锁——数据错乱是必然的。用ConcurrentBag或者加锁。
- 坑三:盲目调大线程池——线程不是越多越好。CPU密集型场景,线程数超过CPU核心数,性能反而下降。
- 坑四:忽略IOCP线程池——异步I/O用的是IOCP线程,不是worker线程。很多人只调worker,忘了调IOCP。
我的调试技巧:用dotnet counters实时监控线程数。如果worker线程数长期接近MinThreads,说明线程池在频繁创建线程,需要调大MinThreads。如果CPU利用率很低但线程数很高,说明I/O阻塞严重,需要检查异步代码。
好了,这一章就到这里。总结一句话:I/O密集型靠异步,CPU密集型靠并行,线程池调优要因地制宜。下一章我们聊聊连接池和资源复用,那也是高并发里容易翻车的地方。