内核调度 · 凭什么选人——调度类的分层与 CFS/EEVDF 的公平之道
一台服务器上同时跑着 MySQL、nginx、编译任务、还有几个没人管的脚本,它们的诉求完全不一样:有的要毫秒级响应、有的只是后台慢慢算。Linux 不可能用一套算法通吃,于是把调度器做成了分层的调度类——先按类分优先级,类内再各用各的算法。本篇讲清五层调度类怎么排、普通任务(
SCHED_OTHER)的 CFS/EEVDF 凭什么选人。
相关:"有几种队列"见 scheduling-queues(运行队列与等待队列的全景);何时切换、抢占时机见 scheduling-timing;实时任务优先级怎么用
chrt调见 chrt;被调度的实体(task/task_struct)见 task-struct。
破题:为什么不能只有一套调度算法
如果所有任务都一视同仁,会出现两个极端问题:交互型任务(比如编辑器、Web 请求)等不起——它只想要"尽快轮到我一下",给它一大块时间片反而浪费;而纯计算型任务又不 care 延迟,只要总吞吐高就行。更麻烦的是还有实时任务——错过截止期就是事故。
Linux 的答案不是发明一个"万能算法",而是把调度器拆成几个调度类,类内各用最合适的算法,类与类之间按固定优先级排。高优先级的类里只要有可运行 task,就轮不到低优先级的类。这也解释了为什么实时任务能"抢"普通任务的 CPU——不是玄学,是分层结构保证的。
一、调度类:一个核内的优先级分层
从高到低,Linux 有五层调度类:

对应关系一目了然:
| 调度类 | 调度策略 | 谁用 | 队列结构 |
|---|---|---|---|
| stop | 不可抢占 | 内核内部(CPU 热插拔、迁移) | 无(直接跑) |
| dl | SCHED_DEADLINE | 有硬实时期限的任务 | 按 deadline 排的红黑树 |
| rt | SCHED_FIFO/SCHED_RR | 实时任务(优先级 0-99) | 每优先级一条链表(多级队列 + bitmap) |
| fair(CFS) | SCHED_OTHER/BATCH/IDLE | 普通任务(默认) | 按 vruntime 排的红黑树 |
| idle | — | 无任务可跑时 | 无 |
要点:一个核的运行队列
rq里其实内嵌了多个子队列——rt 的多级链表、cfs 的红黑树、dl 的红黑树各是一个。调度器pick_next_task()按调度类从高到低问:"你这类有可运行的吗?"第一个有的就出人。所以"有几种队列"要分两层看:跨类是分层,类内各有自己的队列结构(详见 scheduling-queues §四 总表)。
二、CFS / EEVDF:普通任务凭什么被选中
绝大多数程序是 SCHED_OTHER,归 CFS(Completely Fair Scheduler,完全公平调度器) 管(Linux 6.6 起换成改进版 EEVDF,思想一脉相承)。核心思想一句话:让每个 task 获得"公平的一份"CPU 时间。
虚拟运行时间 vruntime —— 公平的度量
CFS 给每个 task 记一个 vruntime(虚拟运行时间):task 在 CPU 上跑,vruntime 就增加;谁的 vruntime 最小,说明它"欠跑得最多",下次就选它。
- nice 值/权重:
vruntime的增速被 nice 值(-20~+19)加权——nice 低(优先级高)的 task,vruntime 涨得慢,于是能更频繁被选中、拿到更多 CPU。nice 每差 1,CPU 时间比例约差 1.25 倍。也就是说 nice 不是"绝对优先级",而是权重的映射:weight ≈ 1024 / 1.25^n(1024 是 nice 0 的基准权重)。 - 为什么叫"公平":不设固定时间片,而是动态让所有 task 的 vruntime 尽量趋同——CPU 时间按权重比例公平切分。谁欠跑得多(vruntime 小)就补谁,补完了它 vruntime 涨上去,又轮到别人。整个系统像一个不断自我纠正的"欠账本"。
- EEVDF(6.6+):在公平基础上引入虚拟截止期(virtual deadline),更好地兼顾延迟敏感任务(交互任务能更快被响应),但"按权重公平 + 红黑树选最小"的骨架不变。
CFS 的队列:一棵按 vruntime 排序的红黑树

- 数据结构就是一棵红黑树:节点按 vruntime 排序,最左边的节点 vruntime 最小 = 下一个被选中。取最左 O(1)、插入删除 O(log n)。这就是为什么 CFS 能同时做到"公平"和"高效"——选人不用扫全队,直接看最左。
- 一轮调度周期:CFS 尽量在一个"调度周期(sched period)"内让每个可运行 task 都跑一次;可运行 task 越多,每个分到的时间片越短(但有
sched_min_granularity下限,防止切换太频繁——切换本身有开销,见 scheduling-timing §四)。 - 新建/唤醒的 task:vruntime 会被设成接近当前队列最小值(不让它因为"欠跑很多"一上来霸占 CPU,也不让它饿死)。这背后还有个细节:完全公平不等于绝对平均,CFS 允许新任务"插队"到接近最左的位置,是为了保证交互延迟——一个刚被唤醒的终端命令应该几乎立刻跑起来。
nice 权重数值表:一个 nice 到底差多少
CFS 的权重表是内核写死的常量(kernel/sched/core.c 的 sched_prio_to_weight[]):
| nice | 权重 | 相对 nice 0 的 CPU 份额(单跑时) |
|---|---|---|
| -20 | 88761 | 约 86.7 倍 |
| -10 | 9548 | 约 9.3 倍 |
| -5 | 3355 | 约 3.3 倍 |
| 0 | 1024 | 基准 |
| 5 | 335 | 约 0.33 倍 |
| 10 | 335 | 约 0.33 倍(权重表在 +10 后收敛) |
| 19 | 15 | 约 0.015 倍 |
两个 task 各跑时,CPU 份额 ≈ 各自权重 / 权重和。所以 nice -20 相对 nice 0 能拿约 98.9% 的 CPU,但只要还有别的 task 在跑,它就不是"独占"——CFS 保证每个可运行 task 至少分到一口(
sched_min_granularity下限)。把 nice 当"软实时"用没问题,但想要硬保证得用 rt 类或 cgroup 限额。
组调度(cgroup):把"公平"提升到分组层面
CFS 的公平粒度默认是单个 task,但实际运维往往想按业务组分配 CPU(比如"给数据库 4 个核、给 Web 2 个核")。这靠 cgroup CPU 子系统 + 组调度(group scheduling):每个 cgroup 在 CFS 红黑树里是一个调度实体(sched_entity),组的 vruntime 按组内所有任务加权——组内再建一棵子树递归调度。
# 用 cgroup v2 给两个业务各分 50% CPU
mkdir /sys/fs/cgroup/db /sys/fs/cgroup/web
echo 512 > /sys/fs/cgroup/db/cpu.weight # 权重 512(默认 100)
echo 512 > /sys/fs/cgroup/web/cpu.weight
echo <db_pid> > /sys/fs/cgroup/db/cgroup.procs
echo <web_pid> > /sys/fs/cgroup/web/cgroup.procs对排查的意义:top 里看到的"一个进程 99% CPU",如果它在一个限额很小的 cgroup 里,实际拿到的份额可能远小于它想要的——先看 cgroup 限额,再判断是不是真的"吃满 CPU"。容器(docker/k8s)的 --cpus 就是用它实现的。
一个直觉例子:为什么 nice -20 不是"独占"
假设一个核上有两个 task:A 是 nice 0,B 是 nice -10。B 的权重比 A 大约高 9.3 倍(1.25^10),于是 B 的 vruntime 涨速只有 A 的约 1/9.3。直观效果就是:B 被选中的次数远多于 A,拿到大约 90% 的 CPU 时间,但 A 依然有约 10%——不是"零和"的饿死,而是"按权重分蛋糕"。这和实时类(rt 里有 task 就轮不到 CFS,可能彻底饿死)有本质区别,别把 nice 当实时优先级用。
三、一句话总结
一个核内按调度类分层选人:stop > dl(按 deadline 排的红黑树)> rt(每优先级一条链表的多级队列,FIFO 跑到底/RR 时间片轮转)> fair/CFS(普通任务几乎全在这,按 vruntime 排的红黑树,谁欠跑得最多即 vruntime 最小就选谁,nice 加权、6.6 起用 EEVDF)> idle。从高到低第一个有可运行 task 的类出人;实时类只要有货就能饿死普通类,而 nice 只是 CFS 类内的权重因子,两者是完全不同的体系。