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)。

我的策略是:

  1. I/O部分用async/await,让线程不被阻塞。
  2. CPU部分用Task.Run,丢到线程池里并行处理。
  3. 整体用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密集型靠并行,线程池调优要因地制宜。下一章我们聊聊连接池和资源复用,那也是高并发里容易翻车的地方。