CPU 调度 入门
我们 32 核服务器跑着几百个线程 —— curl handler、数据库 worker、内核线程、JVM 的 GC —— 任意一个瞬间最多 32 个能跑。内核挑。5 个 section 把画面搭出来:调度问题和「公平」到底是啥意思;CFS + 可交互的 vruntime 走读,看 Linux 怎么决定下一个跑谁; CFS 不是正确答案那些场景下的优先级、nice 值、实时类;cgroup 和 NUMA —— 现代容器和多 socket 约束;最后是速查表。
调度问题
几百个可运行线程。几十颗 CPU。调度器每秒数千次挑「谁跑、跑多久」。 没有正确答案 —— 只有取舍。
繁忙服务器上,你可能在任意瞬间有 32 核 + 800 个可运行线程。 调度器每 ~1 ms(CFS 调度 tick)必须决定:接下来 32 个跑哪些。 每个选择都有后果:
- 公平。对等的线程长期应该拿到对等的 CPU —— 但什么叫「对等」? 相等的周期?相等的墙钟时间?按优先级加权相等?
- 吞吐。系统应该最大化有用工作。切换太频繁浪费在开销上; 切换太少饿死 I/O 受限任务。
- 延迟。刚拿到输入的交互任务应该尽快跑, 即便它已经拿到超过份额 —— 用户在等。
- 优先级。实时音频线程错过 deadline 在音箱里产生咔哒声; 备份任务慢 5% 没人在意。两个不能同等对待。
- 亲和性。一个线程的 cache 状态住在它上次跑的核上。 把它弹到另一颗核要 ~100 μs 的冷 cache 后悔。但钉住又输了负载均衡。
- 功耗。在手机或笔记本上,唤醒一颗睡着的核要付出毫秒级延迟和能耗。 把工作合并到已醒着的核上,比铺到所有可用的核上好。
这些目标互相冲突。优化吞吐伤延迟。严格优先级饿死低优先级。 完美亲和性输了负载均衡。每个生产调度器都是个妥协,挑一组取舍做默认。
简史
轮转(1970–80 年代)。每个任务拿固定时间片(10–100 ms),然后让出。 简单公平,但反应迟钝 —— 刚拿到按键的交互任务,排在一堆计算任务后面等。
多级反馈队列(Unix、BSD、早期 Linux)。多个优先级队列; 任务按观察到的行为在队列之间挪(CPU 受限的下沉、I/O 受限的上升)。延迟更好, 但调策略是门艺术,每种 Unix 都不一样。
O(1) 调度器(Linux 2.6,2003 年)。每 CPU 运行队列、 两个优先级数组(active 和 expired)、数组扫描找最高优先级的可运行任务。 当年的单 CPU benchmark 上很快,但调启发式参数是场长期战。
CFS(Linux 2.6.23,2007 年起)。用一条数学规则替代启发式优先级 boost: 「挑虚拟运行时间最小的任务」。下一节走读。
EEVDF(Linux 6.6,2023 年)。用基于「最早合格虚拟 deadline 优先」 算法的更灵活调度器替代 CFS。同样原理(vruntime),但延迟保证更好、公式更干净。 还在 rollout 中;写作时大部分生产内核还在用 CFS。
要点。「调度在公平、吞吐、延迟、优先级、亲和性、功耗之间做取舍。 CFS 通过用一条规则替代调参旋钮,赢了通用之战:最小 vruntime 优先、按 nice 加权。 EEVDF(Linux 6.6+)是它的接班人;同样 vruntime 原理,更好的延迟数学。」
CFS —— 通用情况下的一条规则
完全公平调度器(Completely Fair Scheduler)用一行替代了十年的启发式: 「挑可运行任务里 vruntime 最小的那个」。这一条规则就产出公平分享、优先级加权、合理延迟。
每个可运行任务有一个叫 vruntime 的计数器,单位是纳秒。 任务跑的时候 vruntime 增长 —— nice-0 任务按墙钟速率、低优先级更快、高优先级更慢。 调度器把任务放在以 vruntime 为 key 的红黑树里,所以最左边 (vruntime 最小)的任务能 O(log n) 找到。那个任务下次跑。
nice 怎么映射到 vruntime 速率
Linux 定义了一张权重表:nice 0 = 权重 1024,nice 每变 ±1 权重变 ~1.25 倍。 nice +10 ≈ 权重 110(少 ~9 倍),nice -5 ≈ 权重 3121(多 ~3 倍), nice -20 ≈ 88761(多 ~87 倍)。
跑着的任务 vruntime 增量是 actual_runtime × (NICE_0_LOAD / task.weight)。 所以 nice-0 任务以墙钟速率累 vruntime。nice-10 任务累得 ~9 倍快 —— 意味着它要等别人追上来才会再被挑,所以拿到 ~1/9 墙钟份额。 nice-(-20) 任务累得 ~87 倍慢,所以主导调度。
关键词是「乘法」。严格优先级意味着「高优先级任务永远先跑」—— 饿死低的。 CFS 加权 vruntime 累积,所以两个任务一直都在前进; 高优先级的那个只是按比例前进得更多。
延迟目标与最小粒度
CFS 定义一个目标延迟(大多数配置默认 6 ms)—— 每个可运行任务都应该在这个窗口内被跑一次。 把延迟除以可运行任务数得到每个任务的片。 3 个 nice 相同的任务每个 2 ms;30 个任务就是 0.2 ms —— 但那样会被上下文切换开销压垮吞吐。 CFS 强制一个最小粒度(默认 0.75 ms):任何任务的片都不能小于这个, 即便意味着延长目标延迟。
所以 30 个可运行任务:延迟拉到 30 × 0.75 = 22.5 ms,而不是停在 6。 这就是为啥 CFS 在高负载下感觉反应没那么快 —— 实际延迟承诺仅以可运行线程数为界。
唤醒抢占
任务唤醒时(比如 I/O 结束,或者别的任务做了 pthread_cond_signal), CFS 检查它的 vruntime 是不是小到「应该」抢占当前跑着的任务。 如果是 —— 而且当前跑的任务已经跑够最小粒度 —— 内核安排一次切换。 这就是让交互任务有响应的机制:阻塞在网络读上的线程睡觉时不累 vruntime, 所以读返回时它 vruntime 很低,立刻被挑。
CFS 不管什么
CFS 处理所有 SCHED_NORMAL(也叫 SCHED_OTHER)任务。它不处理实时任务 —— 那些由单独的调度类(SCHED_FIFO、SCHED_RR、SCHED_DEADLINE)处理,下一节讲。 实时任务永远抢 SCHED_NORMAL,不管 vruntime; CFS 只在实时队列空了之后才挑。
要点。「CFS 的规则一行话:挑可运行任务里 vruntime 最小的。 nice 值加权 vruntime 累的多快 —— nice +10 快 ~9 倍(所以 ~1/9 份额)、 nice -5 慢 ~3 倍(所以 ~3 倍份额)。 目标延迟(默认 6 ms)和最小粒度(默认 0.75 ms)框定片大小。 CFS 不管实时任务;那些有单独的调度类。」
优先级、nice、实时类
大部分代码在 CFS 的公平规则下跑。一小撮场景 —— 音频、工业控制、硬实时 —— 需要更严的保证。Linux 给它们 4 个额外的调度类,严格优先级顺序。
每个 Linux 任务属于一个调度类。从高优先级到低:
- SCHED_DEADLINE —— 最早 deadline 优先 + 带宽强制。 每个任务声明
(runtime、deadline、period); 调度器只在总带宽容得下时录入,然后保证每个period里在 deadline 前 跑足runtime。硬实时用:工业控制、自动驾驶栈、低延迟音频流水线。 - SCHED_FIFO —— 定优先级,跑到完成或阻塞为止。 高优先级 FIFO 任务永远抢低优先级的。同优先级 FIFO 任务之间不切片。 POSIX 经典「实时」策略。音频服务器(JACK)、某些游戏引擎、机器人。
- SCHED_RR —— 像 FIFO 但同优先级任务之间轮转时间片。 同优先级 RR 任务交替;不同优先级仍严格。
- SCHED_NORMAL(也叫 SCHED_OTHER)—— CFS。99% 的东西都跑在这下。
- SCHED_BATCH —— 像 NORMAL 但告诉调度器这个任务是 CPU 受限的, 压制唤醒抢占。后台编译、大数据 job 用。
- SCHED_IDLE —— CFS 内最低可能优先级。 只在没有其他 SCHED_NORMAL 任务想跑的时候才跑。cron job、兜底 GC。
nice —— SCHED_NORMAL 的旋钮
在 SCHED_NORMAL 里,nice 调优先级权重。范围 -20(最高权重)到 +19(最低)。 默认是 0。每变 1 个单位权重变 ~1.25 倍;累计范围大约 1000 倍。 大多数代码从不碰 nice —— 调度器自己处理。例外:
nice 19给后台编译或批处理 job,让它们给交互工作让路。 经典nice -n 19 make习语。nice -10或更低(要 CAP_SYS_NICE)给需要抢占优先级、 但又不要硬实时保证的低延迟服务进程。数据库 master 进程、网络包处理器。
实时的风险
不阻塞的 SCHED_FIFO 任务能饿死整个系统。调度器不会为任何东西抢占它 (除了更高优先级的 FIFO 任务、像中断这种内核工作、或者另一个 SCHED_DEADLINE deadline)。 SCHED_FIFO 任务里的 while (true); 这种 bug 锁死机器 —— 没有 shell、没有日志、没有 SSH 登录能救它,只能重启。
Linux 用 sched_rt_runtime_us 缓解这个:默认实时调度器 在任意 100 ms 窗口里最多被允许 95%,留 5 ms 给 SCHED_NORMAL。 这就是让你能从失控实时任务恢复的东西。任何要部署 SCHED_FIFO 的人都应该懂这个设置。
cgroup CPU 控制 —— 跟调度类正交
cgroup(下一节讲)在调度类之上施加第二层:CPU shares(比例权重)、 CPU quota(硬上限)、CPU set(允许的核)。 cgroup 里 quota 是单核 50% 的 SCHED_NORMAL 任务,不管它本来应该拿多少 CPU 时间, 都会被 throttle。这就是容器怎么限制 CPU 的。
要点。「Linux 有 6 个严格优先级顺序的调度类: DEADLINE、FIFO、RR、NORMAL(=CFS)、BATCH、IDLE。 大多数代码跑在 NORMAL 下;nice 值(-20 到 +19)在它里面调优先级。 不阻塞的 SCHED_FIFO 能饿死整个系统 —— Linux 用 sched_rt_runtime_us 限制(默认:留 5% 给非 RT)。 cgroup CPU 控制在以上一切之上正交组合。」
cgroup、容器、NUMA
现代 Linux 服务器在共享硬件上跑多个工作负载。cgroup 在它们之间划分 CPU、内存、I/O; NUMA 决定哪些 CPU 离哪些内存近。两个都是调度器之上的第二层约束。
90 秒 cgroup
cgroup(控制组)是把进程分组并对整组应用资源限制的内核机制。 cgroup v2(每个现代发行版的默认)在 /sys/fs/cgroup/暴露一个层次化的文件系统;每个目录是一组,每个文件是一个旋钮。
对 CPU 来说,3 个控制项重要:
- cpu.weight —— 比例份额,跟 nice 类似。 weight 200 的 cgroup 在和别人竞争时拿到 weight 100 的两倍 CPU。 只有一个 cgroup 有可运行工作,它拿到全部 CPU。
- cpu.max —— 绝对硬上限,用「quota / period」表示,单位微秒。
cpu.max = "50000 100000"意味着「每 100 ms 墙钟最多 50 ms CPU」—— 半核。Docker / Kubernetes 的--cpus=0.5翻译成的就是这个。 - cpuset.cpus —— 允许的 CPU 集合。
cpuset.cpus = "0-7"意味着 cgroup 只能在核 0 到 7 上跑。NUMA 钉绑和核隔离用。
这些跟调度类正交组合。一个被 cgroup throttle 的 SCHED_NORMAL 任务在 cgroup 里仍然受 nice 影响; cgroup 里的 SCHED_FIFO 任务仍然抢占同 cgroup 里的 SCHED_NORMAL,但也被 cpu.max throttle。
Throttling 陷阱
cpu.max throttle 是容器化工作负载最常见的神秘延迟尖刺来源。 机制:每个周期(默认 100 ms)cgroup 拿到新配额。 如果工作负载在 30 ms 里用完配额,接下来 70 ms 被冻结。 那 70 ms 就是尖刺 —— 没有调度器解释、没有明显 CPU 饿死, 只有应用日志里死寂、然后下个周期开始时突然爆发。
更糟:cpu.max 是按 cgroup 强制,不是按线程。 一个 200 线程、1 核配额的 Java 应用,可以让 1 个线程在 4 ms 里烧光整个配额, 剩下 96 ms 让另外 199 个什么都得不到。JVM 的 -XX:ActiveProcessorCount这种调参,就是告诉 runtime「你其实只有 1 核」,让它据此调线程池大小。 Node(UV_THREADPOOL_SIZE)、Go(GOMAXPROCS)、 Python(asyncio 默认)等等,同样思路。
NUMA —— 非均匀内存访问
多 socket 服务器(2 socket × 32 核 = 64 核,常见)每个 socket 都挂着物理内存。 核访问本 socket 内存 ~100 ns;访问另一 socket 内存要 200–400 ns (走 inter-socket 链路)。这就是 NUMA(Non-Uniform Memory Access), 忽略它跨 socket 访问要付 2–4 倍内存延迟。
Linux 调度器是 NUMA-aware 的:它倾向于把任务调度到离它内存近的 CPU 上, 并倾向于在请求任务当前跑的同一 NUMA 节点上分配内存(first-touch 策略)。 它也做周期性的均衡,把任务搬到数据附近,或者把数据搬到任务聚集的地方。 没一个完美;高性能数据库和 JVM 经常用 numactl 显式钉自己以求可预测性。
实际观察
cat /sys/fs/cgroup/$(cat /proc/self/cgroup | cut -d: -f3)/cpu.max—— 当前进程被加的 CPU 上限是什么。cat /sys/fs/cgroup/.../cpu.stat显示nr_throttled和throttled_usec。如果非零且在涨,你被 throttle 了。numactl --hardware显示机器的 NUMA 拓扑;numastat显示每进程的跨节点内存流量。
要点。「cgroup v2 通过 weight(比例)、max(硬上限)、 cpuset(允许的核)在组之间划分 CPU。大多数容器 CPU 延迟尖刺是 cpu.maxthrottle —— 配额在周期早就用完、接下来到下个周期开始之间静默。 NUMA 加一层:跨 socket 访问 2–4 倍内存延迟惩罚, 被调度器的 NUMA-aware 放置缓解但只有显式钉绑才完美。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。
一句话解释 CFS。
调度器挑可运行任务里 vruntime 最小的,vruntime 以跟任务权重(由 nice 设)成反比的速率前进。 那一条规则同时产出公平分享和优先级加权调度。
nice 和 SCHED_FIFO 优先级的区别?
nice 是 SCHED_NORMAL 里的提示 —— 它加权 vruntime 累积速度, 所以低 nice 的任务按比例拿更多 CPU,但每个任务仍然在前进。 SCHED_FIFO 优先级是严格的:高优先级 FIFO 任务完全抢占所有低优先级的, 除非它阻塞或更高优先级的 FIFO 任务到来,否则不会让出。 SCHED_FIFO 能饿死一切;nice 不会。
为啥我容器化的应用有解释不了的延迟尖刺?
几乎一定是 cgroup 里的 cpu.max throttle。 看 cpu.stat 的 nr_throttled > 0 —— 就是冒烟的枪。 修法是要么提高配额、要么平滑工作负载让 CPU 在整个周期均匀使用、 要么告诉 runtime 它实际有多少核(GOMAXPROCS、-XX:ActiveProcessorCount 等等),让它别把内部线程池调过大。
实时音频线程需要多大的唤醒延迟、怎么达到?
直播音频标准 ~1 ms(你有 5–10 ms 缓冲,要在 underrun 前补满)。 要达到:SCHED_FIFO 优先级 ~80(高于内核内务但低于实时 tick)、 通过 isolcpus 或 cpuset 做 CPU 隔离、用 mlockall 锁定内存避免缺页、 调 sched_rt_runtime_us 只留内核自己需要的周期。
NUMA 对我写的代码意味着什么?
多 socket 机器上,从错的 socket 访问内存慢 2–4 倍。 Linux 调度器做 first-touch 分配并尝试把线程留在它内存附近。 问题出在长寿命进程在一个 socket 上分配大堆、然后许多线程被迁到另一个 —— 跨 socket 流量主导。缓解:启动时显式 numactl --cpubind=N --membind=N、 jemalloc 这种带 per-thread arena 的 allocator、或者代码里做 NUMA-aware 分配。
生产中怎么测调度问题?
vmstat 1 看每秒上下文切换和可运行线程数(r 列);perf sched latency 看 wakeup 到 run 的延迟(你的尾延迟从这来);perf stat -e cs,migrations 看切换和 CPU 弹跳率;cat /sys/fs/cgroup/.../cpu.stat 看 throttle 统计;uptime 的 load average 是可运行线程数的基线。
Code review 红旗
- 没意识到
sched_rt_runtime_us的 SCHED_FIFO。失控的实时任务能锁死机器。文档化优先级选择;实时线程配 watchdog。 - 容器里按
nproc调线程池大小。nproc报的是宿主机核数,不是容器的 CPU 配额。 用 runtime 专门的旋钮,或者直接读cpu.max。 - 没
sched_yield或 sleep 的 polling 循环。一个紧凑的while (!flag)能饿死同核上的其他任务。 用 condvar 或 futex 替代。 - nice 0 的生产 daemon 本该 nice 5–10。批处理 job、log shipper、监控 agent 在 CFS 默认公平下,跟交互请求 handler 竞争。 调低它们的 nice 在负载下保护请求处理延迟。
- NUMA-blind 的大堆分配。启动时单线程分配的 200 GB 堆 最终落在单个 NUMA 节点,伤害每个被调度到另一 socket 的线程。 要么钉住、要么用 first-touch 并行分配。