CPU 调度回答的是”有限的核心上,下一个让谁跑”的问题,load average 回答的是”此刻有多少任务在等”的问题。这篇从调度目标的取舍讲起,过一遍经典算法与 Linux CFS 的公平思想,最后落到线上最常见的那类怪象:负载很高,CPU 却闲着。

它解决什么问题

一台机器的逻辑 CPU 数量是固定的,想跑的任务却远不止这个数:业务线程、GC 线程、JIT 线程、网络事件循环、日志采集 Agent……8 核机器同一时刻最多只有约 8 个任务真正占用 CPU,其余全部排队。调度器就是决定”下一个谁上”的角色。

难办的是,调度追求的目标互相打架:

  • 吞吐量:单位时间完成更多任务,希望少切换、单个任务一次跑久一点;
  • 响应延迟:交互任务要尽快被看到,希望切换勤快、能及时抢占长任务;
  • 公平性:不能让低优先级任务永远挨饿。

时间片拉长,吞吐上去了,交互卡顿;时间片缩短,响应好了,CPU 却把时间浪费在切换上。不存在三者同时拉满的策略,所有调度算法本质上都是在这个三角里选位置。

核心原理

任务离开 CPU 只有两大类原因。一类是主动让出:调 read 等数据、等锁、等定时器,线程进入睡眠,调度器顺势换人。另一类是被抢占:时钟中断给了内核一个检查机会,发现当前任务跑得够久了,或有更合适的任务就绪,就把它换下去。通用操作系统都是抢占式的,否则一个死循环就能焊死单核机器。

换人的动作叫上下文切换:保存当前任务的寄存器、程序计数器、栈指针,再恢复下一个任务的现场。跨进程切换还伴随地址空间更换,TLB 与 CPU 缓存的局部性一并受损。所以切换从来不是免费的——线程池开得远超核数时,业务代码没跑多少,CPU 时间先消耗在调度和同步上。

深入机制

经典算法:四个简化模型

  • FCFS(先来先服务):按到达顺序执行,实现极简。代价是护航效应——一个长任务排前面,后面所有短任务陪着等,平均响应被拖垮。
  • SJF(短作业优先):预计运行时间短的先跑,平均等待时间在理论上最优。致命前提是得提前知道每个任务还要跑多久,真实系统没有这种上帝视角;长任务还可能被新来的短任务无限插队,最终饿死。
  • RR(时间片轮转):每个任务轮一个时间片,用完回队尾。时间片是那个关键旋钮:太大就退化成 FCFS,太小则上下文切换占比飙升。
  • 多级反馈队列:新任务先进入高优先级队列;总是用完整个时间片的逐步降级(判定为 CPU 密集型),经常主动等 I/O 的保持高优先级(判定为交互型)。用运行行为猜测任务类型,是教科书里最接近实战的模型。

CFS:把公平做成记账

Linux 普通任务的调度器 CFS(Completely Fair Scheduler)不分配固定时间片,而是记账。每个任务维护一个 vruntime(虚拟运行时间):实际运行多久,按 nice 值对应的权重折算后累加进去——nice 越低权重越高,vruntime 涨得越慢,分到的 CPU 份额越大。

可运行任务按 vruntime 组织进一棵红黑树,调度器每次取最左节点,也就是在公平时间轴上”最吃亏”的那个任务。任务多了,每人分到的片段自动变短;任务少了,片段自动拉长。一句话:CFS 把公平定义为 vruntime 的均衡,而不是时间片的均分。(Linux 6.6 起 fair 调度类引入 EEVDF,用 lag 和虚拟截止时间做选择,对延迟敏感任务更友好,但公平记账的骨架没变。)

load average 统计的到底是什么

uptime 吐出的三个数字是 1、5、15 分钟尺度上的指数衰减平均,不是简单算术平均。关键在于统计口径:Linux 的 load 同时计入两类任务——

  • R 状态:可运行,正在跑或在运行队列里排队;
  • D 状态:不可中断睡眠,绝大多数是在等磁盘 I/O、网络存储或 Swap。

所以 load 高绝不等于 CPU 忙。判断时必须除以逻辑核数:8 核机器 load 8 可能只是刚好排满;load 40 而 CPU 有空闲,多半是一批任务卡在 D 状态等 I/O。反过来 load 不高但 CPU 100%,则是少数线程把核心打满,没有排队。

工程实践

排查顺序:先看趋势,再看构成,最后定位到人。

uptime                  # 1/5/15 分钟负载趋势,尖刺还是持续
top # %Cpu(s) 的构成:us/sy/wa/si/st 谁在抬头
vmstat 1 # r 运行队列长度、b 阻塞在 I/O 的任务数、cs 切换频率
pidstat -w -p <pid> 1 # cswch 自愿切换 / nvcswch 被抢占次数
iostat -x 1 # 块设备 await、队列深度、吞吐

对照着读:

  • us 高:业务计算热点。Java 进程在 top 里按 H 切线程视图,线程号转十六进制去 jstack 里找栈,或用 perf top、async-profiler 采样。
  • sy/si 高:时间花在内核路径——系统调用、网络协议栈、软中断。mpstat -P ALL 1 看是否单核倾斜,警惕软中断集中在一个核上。
  • load 高、wa 高、CPU 闲:典型的 D 状态堆积。pidstat -d 找谁在大量读写,iostat -x 看哪块设备延迟高;别忘了 Swap、NFS 这类非本地盘的不可中断等待。
  • load 高、wa 不高、vmstat 的 r 长期大于核数:真的在抢 CPU。查线程数是否失控、锁竞争是否激烈,pidstat -w 里 nvcswch/s 飙升就是被抢占过度的信号。

对比与权衡

算法 选择方式 优点 代价
FCFS 先到先跑 实现最简单 长任务拖住全部短任务
SJF 预计最短的先跑 平均等待最优 无法预知运行时长,长任务饥饿
RR 轮流一个时间片 响应有保证 时间片长短两难,切换开销
多级反馈队列 按行为动态升降级 兼顾交互与长任务 参数多,规则复杂
CFS vruntime 最小者优先 按权重公平分份额,无需估时长 记账与树操作有固定成本

面试视角

load average 到底是什么?

1、5、15 分钟的指数衰减平均值,统计 R 状态可运行任务加 D 状态不可中断睡眠任务。必须结合逻辑核数判断:load 等于核数大约是排满,远超核数才是排队或 I/O 堆积。

负载高但 CPU 使用率不高,怎么查?

说明大量任务不在抢 CPU,而是卡在 D 状态等 I/O。vmstat 1 看 b 和 wa,pidstat -d 定位读写大户,iostat -x 看设备延迟;同时排查 Swap 换入换出和网络存储。

CFS 怎么做到公平?

不给固定时间片,而是给每个任务记 vruntime:实际运行时间按 nice 权重折算累加,可运行任务按 vruntime 排进红黑树,调度器永远取最左(最吃亏)的任务。公平体现为长期份额与权重成正比。

时间片长点好还是短点好?

看你要什么。长则切换少、吞吐高,但交互任务等得久;短则响应快,但上下文切换的固定成本占比上升。这是一个响应与吞吐的取舍旋钮,没有通吃值。

上下文切换的开销在哪?

显式成本是保存和恢复寄存器、程序计数器、栈指针;隐式成本更贵——跨进程切换换地址空间,TLB 条目失效,CPU 缓存里的热数据被新任务顶掉。

小结

  • 调度是在吞吐、延迟、公平之间取舍,FCFS/SJF/RR 是简化模型,CFS 用 vruntime 记账实现按权重的公平。
  • 上下文切换有寄存器现场与 TLB、缓存的双重成本,线程不是越多越好。
  • load average 统计 R+D 两类任务,必须除以核数读;负载高 CPU 闲,先怀疑不可中断 I/O 等待。
  • 排查套路:uptime 看趋势、top 看构成、vmstat 看队列、pidstat/iostat 定位到人。