ps -e | wc -l大概会告诉你正在跑着 400 多个进程。它们怎么"同时"运行?答案是——它们根本没在同时运行,是内核以毫秒级精度在不停地切换,让你看上去它们在同时跑。今天我们深入这个"魔术":内核每一次时钟中断都在问自己「下一个该跑谁?」这个看似简单的问题。我们会回答 7 个问题:调度想达成什么目标?为什么 Linux 不再用古老的 O(1) 调度器而改用 CFS?nice那个 -20 到 +19 的怪数字到底什么意思?uptime里 load average: 2.43 这个 2.43 究竟在说什么?读完今天你会发现,调度器是整个操作系统里最像一个真实"AI"的子系统——它无时无刻不在做权衡。
在讲算法之前必须先讲"想要什么"。调度器并不是简单地"公平"那么浪漫,它在不停地在 5 个互相矛盾的目标之间走钢丝:
这五条互相打架。为了响应性必须频繁切换 → 上下文切换开销变大 → 吞吐量降低;为了公平不偏袒 → 高优先级任务被稀释;为了利用率让 CPU 满载 → 突然来个交互任务延迟就高。没有一个调度器能同时把这五条做到最好,你能做的是挑一个平衡点。这就是为什么 Linux 有"调度类"——不同任务用不同的调度器。
数学家视角:这是一个典型的多目标优化(Pareto front)。调度算法本质上是在帕累托前沿上选一个点。CFS 选的是「在公平约束下最大化交互响应」,而实时调度器 SCHED_FIFO 选的是「死守优先级,公平和利用率都让步」。
这是调度的根本设计选择。
任务必须主动让出 CPU,调度器才能切到下一个。早期 Windows 3.x、经典 macOS 9 都是这样。优点:上下文切换可控,没并发竞态。致命缺点:一个 bug 让进程死循环,整个系统就卡死,因为它永远不主动放手。
你今天还能在哪里见到协作式?Python 的 asyncio。async def 里的协程必须自己 await 才把控制权交还事件循环。如果你写了 while True: pass,你的整个 asyncio 程序就僵了。这就是协作式的现代回声。
内核可以在任何时刻把当前进程踢下 CPU,换一个上去。Linux、Windows NT 之后、macOS X 之后都是抢占式。怎么做到?靠两样东西:
CONFIG_HZ 250 或 1000)触发一次中断,强制进入内核。schedule() 把它换下来。抢占式让一个死循环的进程不会卡死系统,但代价是必须处理并发:当你正在改一个共享变量时,可能下一毫秒你就被换走了,等回来时变量已经被别人改过。这就是为什么我们需要锁。
PREEMPT 内核,连内核态都能被抢占,所以实时性更好(PREEMPT_RT 补丁更激进,几乎所有内核代码都可抢占)。
Linux 不止有一个调度器。它把任务分成几类,每类有自己的算法。当 CPU 空闲要找下一个任务时,内核按优先级遍历调度类,第一个有 ready 任务的类就赢。优先级从高到低:
| 调度类 | 策略常量 | 用途 | 优先级 |
|---|---|---|---|
| stop_sched_class | — | 内核内部用,迁移任务、停 CPU | 最高 |
| dl_sched_class | SCHED_DEADLINE | EDF 算法,硬实时 | ↓ |
| rt_sched_class | SCHED_FIFO / SCHED_RR | 软实时,固定优先级 1-99 | ↓ |
| fair_sched_class | SCHED_OTHER / SCHED_BATCH | CFS,普通进程(99%) | ↓ |
| idle_sched_class | SCHED_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
Linux 的调度器换过 3 次,每次都因为前一个撞墙了。理解为什么换,比记参数重要得多。
每次要挑下一个进程时,遍历所有 runnable 任务,给每个算一个 goodness 分数,挑最高的。任务越多越慢——这是字面意义的 O(n)。多核扩展性更糟,因为有一把全局大锁。100 个进程还行,10000 个就卡。
每个 CPU 维护两个数组:active 和 expired,每个里面 140 个优先级桶(runqueue)。挑下一个任务 = 找到 active 数组里最高优先级的非空桶 = 用位图查 + 队首取,常数时间。任务用完时间片就从 active 挪到 expired;active 空了把两者交换。革命性进步,但有个不优雅的地方:交互性的判断靠一堆复杂启发式——"睡得多的任务说明是交互式,奖励它",公式靠经验拼凑,调起来很玄学,社区戏称"调度器是个黑魔法咒语"。
Ingo 推翻自己重写。核心思想极其优雅:"假装我们有无限多核 CPU,每个进程都拿到 1/N 的算力。" 真实硬件只有 8 个核,做不到,所以我们用一个数字 vruntime(虚拟运行时间)来记录每个任务"欠 / 拿到"了多少 CPU,然后总是挑 vruntime 最小的跑。不再需要启发式,公平性是数学自然出来的。
这是今天的核心。把 CFS 拆成三个机制:
每个任务都有个 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) 调度的位图慢一点,但带来的公平性和简洁性值得。
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
普通用户调 CFS 任务用 nice,root 用户调实时任务用 chrt。
历史包袱:低数字 = 优先级高("不 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
# 把一个进程改成 SCHED_FIFO,优先级 50
sudo chrt -f -p 50 $PID
# SCHED_FIFO:跑到自己 yield 或被更高优先级抢
# SCHED_RR :跟 FIFO 一样,但同优先级之间轮转
警告:实时进程会饿死所有普通进程。一个写死循环的 SCHED_FIFO 进程能把整台机器冻住。所以内核有 sched_rt_runtime_us 限制(默认每 1s 给实时任务最多 950ms,留 50ms 给普通任务)。
默认 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 + 中断打散,目标是消除尾延迟抖动。
uptime 或 w 会显示:
$ 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 忙,可能是磁盘卡。
top 看 wa% 或 iostat -x 1 确认。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 倍。
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.vruntime、se.sum_exec_runtime、nr_switches、nr_voluntary_switches、nr_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)。写下你的诊断步骤。
top / htop 怎么读所有列、ps 的高级用法、用 time 测上下文切换开销、perf 工具入门、strace 跟踪一个 Python 进程的 syscall。准备好打开终端,明天我们不读字,只动手。