Day 04 · CPU 调度:内核怎么决定下一个跑谁

Week 01 · 周四 · 2026-05-16 · 主线:操作系统核心 · 预计 1.5h 阅读 + 30min 实操
今日导读:你的笔记本只有 8 个 CPU 核,但ps -e | wc -l大概会告诉你正在跑着 400 多个进程。它们怎么"同时"运行?答案是——它们根本没在同时运行,是内核以毫秒级精度在不停地切换,让你看上去它们在同时跑。今天我们深入这个"魔术":内核每一次时钟中断都在问自己「下一个该跑谁?」这个看似简单的问题。我们会回答 7 个问题:调度想达成什么目标?为什么 Linux 不再用古老的 O(1) 调度器而改用 CFS?nice那个 -20 到 +19 的怪数字到底什么意思?uptimeload average: 2.43 这个 2.43 究竟在说什么?读完今天你会发现,调度器是整个操作系统里最像一个真实"AI"的子系统——它无时无刻不在做权衡。
今日目录
  1. 调度目标:内核的五个相互冲突的 KPI
  2. 抢占式 vs 协作式:谁有权打断谁
  3. 调度类(scheduling class):Linux 的多策略框架
  4. 历史课:O(n) → O(1) → CFS
  5. CFS 详解:vruntime + 红黑树
  6. nice 与实时优先级:用户能怎么影响调度
  7. CPU 亲和性与 load average
  8. 与 AI / Agent 安全的连结
  9. 今日小练习(3 道)

1. 调度目标:内核的五个相互冲突的 KPI

在讲算法之前必须先讲"想要什么"。调度器并不是简单地"公平"那么浪漫,它在不停地在 5 个互相矛盾的目标之间走钢丝:

这五条互相打架。为了响应性必须频繁切换 → 上下文切换开销变大 → 吞吐量降低;为了公平不偏袒 → 高优先级任务被稀释;为了利用率让 CPU 满载 → 突然来个交互任务延迟就高。没有一个调度器能同时把这五条做到最好,你能做的是挑一个平衡点。这就是为什么 Linux 有"调度类"——不同任务用不同的调度器。

数学家视角:这是一个典型的多目标优化(Pareto front)。调度算法本质上是在帕累托前沿上选一个点。CFS 选的是「在公平约束下最大化交互响应」,而实时调度器 SCHED_FIFO 选的是「死守优先级,公平和利用率都让步」。

2. 抢占式 vs 协作式:谁有权打断谁

这是调度的根本设计选择。

协作式(cooperative / non-preemptive)

任务必须主动让出 CPU,调度器才能切到下一个。早期 Windows 3.x、经典 macOS 9 都是这样。优点:上下文切换可控,没并发竞态。致命缺点:一个 bug 让进程死循环,整个系统就卡死,因为它永远不主动放手。

你今天还能在哪里见到协作式?Python 的 asyncioasync def 里的协程必须自己 await 才把控制权交还事件循环。如果你写了 while True: pass,你的整个 asyncio 程序就僵了。这就是协作式的现代回声。

抢占式(preemptive)

内核可以在任何时刻把当前进程踢下 CPU,换一个上去。Linux、Windows NT 之后、macOS X 之后都是抢占式。怎么做到?靠两样东西:

抢占式让一个死循环的进程不会卡死系统,但代价是必须处理并发:当你正在改一个共享变量时,可能下一毫秒你就被换走了,等回来时变量已经被别人改过。这就是为什么我们需要锁。

内核态也能抢占? Linux 2.6 之前内核态不可抢占(用户态可以)——一旦你陷入 syscall,必须等它跑完才能切。现代 Linux 是 PREEMPT 内核,连内核态都能被抢占,所以实时性更好(PREEMPT_RT 补丁更激进,几乎所有内核代码都可抢占)。

3. 调度类(scheduling class):Linux 的多策略框架

Linux 不止有一个调度器。它把任务分成几类,每类有自己的算法。当 CPU 空闲要找下一个任务时,内核按优先级遍历调度类,第一个有 ready 任务的类就赢。优先级从高到低:

调度类策略常量用途优先级
stop_sched_class内核内部用,迁移任务、停 CPU最高
dl_sched_classSCHED_DEADLINEEDF 算法,硬实时
rt_sched_classSCHED_FIFO / SCHED_RR软实时,固定优先级 1-99
fair_sched_classSCHED_OTHER / SCHED_BATCHCFS,普通进程(99%)
idle_sched_classSCHED_IDLE系统真没事干才跑最低

你写的所有 Python 脚本、Chrome、Slack 默认全是 SCHED_OTHER,由 CFS 调度。SCHED_FIFO/RR 给音频驱动、工业控制这种「丢一帧就完蛋」的场景。SCHED_DEADLINE 是 3.14 内核才加的,给你「这个任务必须在 5ms 内完成 2ms 的计算」这种声明式表达。

查看一个进程的调度类:

# 看自己 shell 用什么策略
chrt -p $$
# 输出形如:pid 12345's current scheduling policy: SCHED_OTHER
#         pid 12345's current scheduling priority: 0

4. 历史课:O(n) → O(1) → CFS

Linux 的调度器换过 3 次,每次都因为前一个撞墙了。理解为什么换,比记参数重要得多。

O(n) 调度器(Linux 2.4,1999-2003)

每次要挑下一个进程时,遍历所有 runnable 任务,给每个算一个 goodness 分数,挑最高的。任务越多越慢——这是字面意义的 O(n)。多核扩展性更糟,因为有一把全局大锁。100 个进程还行,10000 个就卡。

O(1) 调度器(Linux 2.6,2003-2007,Ingo Molnar)

每个 CPU 维护两个数组:active 和 expired,每个里面 140 个优先级桶(runqueue)。挑下一个任务 = 找到 active 数组里最高优先级的非空桶 = 用位图查 + 队首取,常数时间。任务用完时间片就从 active 挪到 expired;active 空了把两者交换。革命性进步,但有个不优雅的地方:交互性的判断靠一堆复杂启发式——"睡得多的任务说明是交互式,奖励它",公式靠经验拼凑,调起来很玄学,社区戏称"调度器是个黑魔法咒语"。

CFS(Completely Fair Scheduler,Linux 2.6.23,2007-至今,Ingo Molnar)

Ingo 推翻自己重写。核心思想极其优雅:"假装我们有无限多核 CPU,每个进程都拿到 1/N 的算力。" 真实硬件只有 8 个核,做不到,所以我们用一个数字 vruntime(虚拟运行时间)来记录每个任务"欠 / 拿到"了多少 CPU,然后总是挑 vruntime 最小的跑。不再需要启发式,公平性是数学自然出来的。

5. CFS 详解:vruntime + 红黑树

这是今天的核心。把 CFS 拆成三个机制:

vruntime:欠债簿

每个任务都有个 vruntime(单位是纳秒)。它跟实际运行时间不完全相等,会按优先级加权:

delta_vruntime = delta_real_time × (NICE_0_WEIGHT / task_weight)

NICE_0_WEIGHT = 1024
nice -10 task_weight ≈ 9548   → vruntime 涨得慢 → 跑得多
nice   0 task_weight  = 1024
nice +10 task_weight ≈ 110    → vruntime 涨得快 → 跑得少

同样真实跑 1ms,nice 值低(高优先级)的任务 vruntime 只涨一点点,nice 值高的涨好几倍。调度器规则只有一条:永远挑 vruntime 最小的跑。优先级自然就被尊重了,因为低优 nice 的总是显得"很饿"。

红黑树:找最小元素的数据结构

所有 runnable 任务按 vruntime 插入一棵红黑树(自平衡 BST)。挑下一个跑谁 = 拿最左节点。插入、删除都是 O(log n)。比 O(1) 调度的位图慢一点,但带来的公平性和简洁性值得。

[vruntime=10] <- 当前正在跑的(暂时不在树里) 跑完一会儿后用更新的 vruntime 重新插回 红黑树:按 vruntime 排序 (15) / \ (12) (20) / \ / \ (11)(13)(18)(25) <- pick_next: 拿最左边那个 (11)

调度延迟(sched_latency)

CFS 不是"想跑多久跑多久"。它有个目标周期 sched_latency_ns(默认 6ms),保证每个 runnable 任务在这个周期内至少跑一次。如果有 N 个任务,每人理论分到 6ms / N。但任务太多时会限制最小切片 sched_min_granularity_ns(默认 0.75ms),避免切换开销吃光 CPU。

# 看 CFS 实时调度参数
sysctl -a 2>/dev/null | grep sched_

# 看某个进程的 vruntime(se.vruntime)
cat /proc/$$/sched

6. nice 与实时优先级:用户能怎么影响调度

普通用户调 CFS 任务用 nice,root 用户调实时任务用 chrt

nice 值(-20 到 +19)

历史包袱:低数字 = 优先级高("不 nice",霸占 CPU),高数字 = 优先级低("很 nice",让给别人)。每差一级 nice,CPU 时间约差 1.25 倍(精确说是 1024/820 ≈ 1.25 的指数级)。nice -20 比 nice 0 大概多拿 100 倍的 CPU。

# 起一个低优先级进程(nice 10)
nice -n 10 python heavy_compute.py

# 改已经跑着的进程
renice 5 -p 12345

# 普通用户只能调高 nice 值(让自己更让步)
# 调低 nice(更霸道)需要 CAP_SYS_NICE 或 root

实时优先级(1-99)

# 把一个进程改成 SCHED_FIFO,优先级 50
sudo chrt -f -p 50 $PID

# SCHED_FIFO:跑到自己 yield 或被更高优先级抢
# SCHED_RR :跟 FIFO 一样,但同优先级之间轮转

警告:实时进程会饿死所有普通进程。一个写死循环的 SCHED_FIFO 进程能把整台机器冻住。所以内核有 sched_rt_runtime_us 限制(默认每 1s 给实时任务最多 950ms,留 50ms 给普通任务)。

7. CPU 亲和性与 load average

CPU 亲和性(affinity)

默认 CFS 会在多核之间迁移任务做负载均衡,但迁移有代价:L1/L2 缓存全废,新核要重新加载内存到缓存。对 cache 敏感的工作负载(高频交易、DPDK 收包、推理引擎)会"钉"住核:

# 把进程绑定到核 2 和 3
taskset -c 2,3 -p 12345

# 起新进程就绑
taskset -c 0-3 python serve.py

# 查看绑定
taskset -p 12345  # 返回 mask,0xf = 核 0-3

vLLM、TensorRT-LLM 这类推理引擎在生产环境基本都会做亲和性绑定 + isolcpus 隔离 CPU + 中断打散,目标是消除尾延迟抖动

load average:被严重误解的三个数字

uptimew 会显示:

$ uptime
 22:31:05 up 12 days,  3:21,  2 users,  load average: 1.23, 0.85, 0.42

三个数字分别是 1 / 5 / 15 分钟的平均 runnable + uninterruptible 任务数注意 Linux 把 D 状态(不可中断睡眠,通常在等磁盘 IO)也算进去了——这跟 BSD/Unix 经典定义不一样,所以 Linux 上 load 高不一定是 CPU 忙,可能是磁盘卡。

与 AI / Agent 安全的连结

1. 推理服务的尾延迟抖动 == 调度抖动。 你跑一个 vLLM 服务,95% 的请求 50ms 返回,但 P99 突然飙到 500ms。第一反应是查模型,但十有八九是调度问题:你的 Python 进程被迁到了另一个 NUMA 节点的核,或者突然来了个 cron 任务抢了 CPU。解决方案就是今天讲的:taskset 钉核 + isolcpus 内核参数把核留给推理 + chrt 提高优先级。

2. Agent 沙箱里的 fork 炸弹防御。 让 Agent 跑用户代码的容器里,一旦攻击者塞进 :(){ :|:& };:,子进程指数级增长,CFS 还在尽职地"公平分配"——结果是整台机器 100 万个进程,OOM 之前 load average 飙到几百。防御靠 cgroups 的 pids.max 限制进程数(不是调度的事,但调度暴露了攻击面)。

3. 时间侧信道。 CFS 调度时机和 vruntime 是可观测的。在多租户 GPU/CPU 共享环境里,攻击者可以通过测量自己被调度的频率,反推同核上其他租户的活动模式(甚至猜出对方在推理什么模型——不同模型的 attention pattern 导致 CPU 端 token 序列化时机不同)。这是 Spectre 之外的另一类侧信道,研究称为 scheduler side channel。

4. 实时优先级提权。 如果 Agent 不小心拿到了 CAP_SYS_NICE,它能把自己提到 SCHED_FIFO 高优先级,饿死监控守护进程。这是为什么 K8s SecurityContext 默认不应该给 SYS_NICE capability。

5. load average 是最便宜的入侵检测信号。 一台只跑 LLM 推理的服务器,load 应该贴近 GPU 利用率而不是 CPU。如果 load 突然飙高但 GPU 不动,要么挖矿木马,要么大模型权重在被偷偷拷贝(IO 阻塞)。把 load average 拉进 Prometheus,配 burn-rate 告警比写 ML 异常检测便宜 100 倍。

今日小练习(3 道)

1动手:观察 nice 对 CPU 分配的影响。

开两个 shell。Shell A 跑 yes > /dev/null,Shell B 跑 nice -n 19 yes > /dev/null(最低优先级)。再开 Shell C 跑 top,按 P 按 CPU 排序,看两个 yes 占用的 %CPU。再把 B 改成 nice -n -10(需要 sudo),对比变化。预期:低 nice(高优先级)拿到的 CPU 显著多于高 nice。

2观察:CFS 的 vruntime。

cat /proc/self/sched(macOS 上没有 /proc,需要在 Linux 上做,或者用 Docker 起个 ubuntu 容器 docker run -it ubuntu bash)。找到 se.vruntimese.sum_exec_runtimenr_switchesnr_voluntary_switchesnr_involuntary_switches 几行,解释每个数字的含义。预期理解:voluntary 是自己阻塞让出(IO 等待)、involuntary 是被抢占。

3思考:load average 解谜。

一台 16 核机器,uptime 显示 load average: 32.0, 31.5, 28.0,但 top 显示 CPU 用户态 + 内核态加起来才 40%、idle 50%、wa(iowait)10%。问:哪里出了问题?应该从哪些命令查起?(提示:D 状态进程、磁盘 IO 队列、是否在 swap)。写下你的诊断步骤。