CPU密集型与IO密集型任务:任务类型区分、调度策略差异、资源消耗模型分析
做调度优化这么多年,我见过太多团队一上来就调线程数、改队列大小,结果越调越慢。为什么?因为他们根本没搞清楚自己处理的任务到底是什么类型。
CPU密集型和IO密集型,这两类任务的调度策略几乎是反着来的。你想想看,一个任务大部分时间在算,一个任务大部分时间在等,怎么可能用同一套参数?
任务类型的本质区分
先给个最直白的定义:
- CPU密集型:任务执行时间主要花在CPU计算上。比如视频编码、矩阵运算、加密解密、数据压缩。
- IO密集型:任务执行时间主要花在等待IO完成上。比如文件读写、网络请求、数据库查询、消息队列消费。
判断标准其实很简单——看任务的CPU利用率。如果CPU利用率长期在80%以上,那就是CPU密集型;如果CPU利用率很低但任务迟迟不返回,那就是IO密集型。
top -H -p [pid] 看线程状态。如果大量线程处于R状态(运行中),大概率是CPU密集;如果大量线程处于D状态(不可中断睡眠)或S状态(可中断睡眠),那就是IO密集。
我记得有一次帮一个金融客户排查性能问题,他们抱怨系统响应慢。我一看监控,CPU才用了20%,但请求排队严重。再查线程状态,80%的线程都在等数据库查询返回。这就是典型的IO密集型场景,但他们却用了CPU密集型的线程池配置——线程数设成了CPU核数+1,结果IO等待时线程全堵住了。
调度策略差异
两类任务的调度策略,核心差异在于线程数设置和队列策略。
线程数计算公式
先记住两个经典公式:
// CPU密集型
最佳线程数 = CPU核数 + 1
// IO密集型
最佳线程数 = CPU核数 * (1 + 等待时间 / 计算时间)
为什么CPU密集要加1?说白了就是留一个线程给系统抖动用。当某个线程因为缺页中断或上下文切换暂停时,这个额外的线程能顶上,不让CPU闲着。
IO密集型的公式就更有意思了。等待时间/计算时间这个比值,我见过最大的能到几百倍。一个HTTP请求,计算可能只要1毫秒,但网络等待可能要200毫秒。那比值就是200。8核机器算下来,线程数能到1600多。
队列策略选择
两类任务对队列的要求完全不同:
| 维度 | CPU密集型 | IO密集型 |
|---|---|---|
| 队列类型 | 有界队列 | 有界队列(但容量可大一些) |
| 队列容量 | 小(几十到几百) | 中等(几百到几千) |
| 拒绝策略 | CallerRunsPolicy 或 AbortPolicy | DiscardOldestPolicy 或 CallerRunsPolicy |
| 核心线程数 | 等于最大线程数 | 小于最大线程数 |
CPU密集型的队列为什么不能大?因为任务一旦入队,就要等前面的任务算完。队列太长,后面的任务等待时间会指数级增长。我习惯把CPU密集型的队列容量控制在100以内,超过就直接拒绝,让上游重试或降级。
IO密集型的队列可以大一些,因为线程处理得快——大部分时间在等IO,真正占CPU的时间很短。但也不能无限大,否则内存扛不住。
资源消耗模型分析
理解了调度策略,我们来看看两类任务到底消耗了什么资源。
CPU密集型:计算资源是瓶颈
CPU密集型的资源消耗模型很简单:
- CPU:核心瓶颈,利用率高
- 内存:取决于任务数据量,通常可控
- IO:几乎不消耗
- 上下文切换:频繁切换会严重降低吞吐
这里有个坑——CPU密集型的上下文切换代价极高。每次切换都要保存和恢复寄存器、刷新TLB、处理缓存失效。我见过一个团队把CPU密集型的线程数设成了CPU核数的4倍,结果CPU利用率是上去了,但吞吐反而下降了30%。为什么?因为大量时间花在了上下文切换上。
CPU密集型调优核心:线程数不超过CPU核数+1,减少上下文切换,让每个线程尽量长时间地占用CPU。
IO密集型:等待时间是瓶颈
IO密集型的资源消耗模型:
- CPU:利用率低,不是瓶颈
- 内存:线程栈和IO缓冲区是主要消耗
- IO:核心瓶颈,包括磁盘IO、网络IO
- 线程数:需要大量线程来掩盖IO等待
IO密集型的调优思路和CPU密集型完全相反。我们要用更多的线程来并行等待IO,让CPU在等待间隙去处理其他任务。
我曾经优化过一个日志采集系统,它要从Kafka消费消息然后写入HDFS。典型的IO密集型——网络IO(读Kafka)+ 磁盘IO(写HDFS)。一开始线程数设了16,吞吐只有2000条/秒。我算了一下,每条消息的处理时间中,计算只占2ms,但IO等待占了50ms。比值是25。8核机器,理论线程数 = 8 * (1 + 25) = 208。我调到200后,吞吐直接飙到18000条/秒。
混合型任务的处理
现实中的任务很少是纯粹的CPU密集或IO密集。大部分任务都是混合型——既有计算又有等待。
我的处理策略是:拆。把一个大任务拆成多个阶段,每个阶段单独判断类型,用不同的线程池处理。
举个例子,一个图片处理服务:
- 从OSS下载原图(IO密集型)
- 缩放、加水印(CPU密集型)
- 上传处理后的图片(IO密集型)
如果用一个线程池处理全部三个阶段,那调度策略怎么设都不对。我习惯拆成三个线程池:
// IO密集型:下载线程池
ExecutorService downloadPool = new ThreadPoolExecutor(
50, 200, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// CPU密集型:处理线程池
ExecutorService processPool = new ThreadPoolExecutor(
9, 9, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.AbortPolicy()
);
// IO密集型:上传线程池
ExecutorService uploadPool = new ThreadPoolExecutor(
50, 200, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
这样每个线程池的配置都能精准匹配对应阶段的特性。吞吐量比用一个线程池提升了3倍以上。
一张图总结
下面这张图把两类任务的调度策略和资源消耗模型做了对比,我建议你保存下来,做性能调优时对照着看:
嗯,到这里你应该能分清CPU密集和IO密集了。记住一句话:CPU密集看计算,IO密集看等待。下次调优前,先搞清楚你的任务属于哪一类,再动手改参数。不然就是在碰运气。