Linux-27 调度器演进:O(n) → O(1) → CFS → EEVDF
前两篇讲内存 —— 一种可以超卖但不能压缩时间的资源。这一篇讲 CPU:一种完全不能存储的资源。内存分配错了顶多慢、顶多 OOM,CPU 分配错了直接表现为「服务毛刺」,而且很难归因。
调度器是内核里唯一一个被公开推翻重写了三次的核心子系统。这本身就是最好的教材:每一代的死法都精确地暴露了上一代的设计假设错在哪。
先看五个问题:
- 调度器为什么要换代?O(n) → O(1) → CFS → EEVDF,每一代是被什么问题逼死的?
nice值从 -20 到 19,到底怎么影响 CPU 份额?为什么是每级 1.25 倍这个奇怪的数字?- 内核什么时候才会切走一个正在跑的进程?「时间片用完」这个说法哪里不准确?
- 容器里设
cpu: 1到底限制了什么?为什么 Go 服务的 P99 会突然飙到 100ms,而 CPU 使用率看起来只有 30%? - CFS 用了 16 年、口碑很好,6.6 为什么又换成 EEVDF?
1. 调度器要解决的问题
1.1 一个不可能三角
调度器的输入是「一堆想跑的任务」,输出是「下一个跑谁、跑多久」。看起来简单,难点在于三个目标互相冲突:
| 目标 | 想要什么 | 与谁冲突 |
|---|---|---|
| 吞吐量 | 时间片尽量长,少切换(切换要刷 TLB、丢 L1/L2 缓存) | 与延迟冲突 |
| 延迟 | 时间片尽量短,让新来的任务快点上 | 与吞吐冲突 |
| 公平 | 每个任务按权重拿到应得的份额 | 与延迟冲突 |
「公平与延迟冲突」不太直观,但这是理解 CFS→EEVDF 换代的钥匙:一个只需要跑 0.1ms 但要求马上跑的任务(比如处理一个网络包的 goroutine),和一个要跑 10s、什么时候跑都行的任务(比如编译),在「公平」的眼里是一样的 —— 都按权重分时间。CFS 没有任何语法能让前者说出「我不要更多 CPU,我只要更快拿到 CPU」。
1.2 两类负载的根本冲突
历史上调度器所有的启发式(heuristic)都在试图区分两类进程:
交互式(interactive / IO-bound)
特征:跑一小会儿 -> 睡眠等 IO -> 被唤醒 -> 再跑一小会儿
例子:shell、编辑器、Web 服务的请求处理、数据库连接
需求:【低延迟】—— 唤醒后要马上能跑,否则用户感知到卡
批处理(batch / CPU-bound)
特征:拿到 CPU 就一直用,用满时间片才被切走
例子:编译、视频转码、科学计算、GC 的标记阶段
需求:【高吞吐】—— 少被打断,缓存别被冲掉
问题是内核无法可靠地区分这两类 —— 它只能观察行为(睡得多不多),不能知道意图。而且一个进程会在两种状态间来回切:一个 Web 服务在低峰是交互式的,在压测下是 CPU-bound 的。
O(1) 调度器的死因就是它试图靠观察行为来猜意图。 CFS 的突破在于:放弃猜测,只讲「谁欠得多谁先跑」,把交互性作为公平的自然副产品 —— 一个睡了很久的任务,天然「欠」了很多 CPU。
1.3 调度器的输入:状态与就绪队列
调度器只关心 R 状态(TASK_RUNNING) 的任务。这一点常被误解:
R TASK_RUNNING 正在跑【或】在就绪队列里等着跑 <- 调度器管这些
S TASK_INTERRUPTIBLE 可中断睡眠(等 IO、等锁、等信号)
D TASK_UNINTERRUPTIBLE 不可中断睡眠(多为等磁盘/NFS) <- 计入 loadavg!
T TASK_STOPPED 被 SIGSTOP 停住
Z EXIT_ZOMBIE 已退出、父进程还没 wait
I TASK_IDLE 内核线程的空闲态(不计入 loadavg)
ps 里的 R 包含了「正在跑」和「排队等」两种,这就是为什么 8 核机器上能看到几十个 R 状态进程。
关键点:每个 CPU 有自己独立的就绪队列(struct rq)。这是从 O(1) 时代确立、至今未变的设计 —— 全局单队列必须全局加锁,核数一多锁就成瓶颈。代价是引入了新问题:负载均衡(把任务从忙的 CPU 迁到闲的 CPU),这是调度器代码里最脏、最难调的部分,而且迁移会丢掉缓存亲和性,所以内核宁可让任务在原 CPU 上多等一会儿也不轻易迁走。
# 看每个 CPU 的运行队列长度与调度统计
cat /proc/schedstat
# cpu0 0 0 0 0 0 0 3405558091 224951893 1284219
# ^^^^^^^^^^ 该 CPU 累计运行时间(ns)
# ^^^^^^^^^ 累计等待时间(ns) <- 这个涨得快说明在排队
# ^^^^^^^ 上下文切换次数
# 全局视角
vmstat 1
# procs -----------memory---------- ---system--
# r b swpd free buff cache in cs
# 9 0 0 1043284 93400 2109344 5231 9832
# ^ 就绪队列里的任务数(含正在跑的)
# ^ 阻塞在不可中断 IO 上的任务数
# ^^^^ ^^^^ 中断/上下文切换 每秒
# ✅ r 持续 > 核数 -> CPU 是瓶颈(任务在排队)
# ✅ b 持续 > 0 -> IO 是瓶颈(这些任务也算进 loadavg)
2. 第一代:O(n)(Linux 2.4 及以前)
2.1 每次调度都遍历一遍
2.4 的 schedule() 逻辑简单到可以在脑子里跑完:
全局一个就绪队列(所有 CPU 共享,一把大锁 runqueue_lock)
schedule():
for 队列里每个任务 p:
weight = goodness(p) // 算一个「优先级得分」
记录最大的
切到得分最高的那个
goodness(p) 大致是:
p->counter // 本轮剩余时间片(tick 数)
+ 20 - p->nice // nice 越小得分越高
+ (同一个内存空间? +1) // 同进程的线程切换更便宜
counter 是剩余时间片。它同时承担了两个职责:既是「还能跑多久」,也是优先级的一部分 —— 睡眠的进程 counter 没被消耗,醒来后得分自然高。这算是「用剩余额度表达优先级」的朴素版本,思路和后来 CFS 的 vruntime 有一点血缘关系。
当所有就绪任务的 counter 都归零时,触发一次全局重算:
/* 2.4 kernel: 遍历【系统里所有】进程,不只是就绪的 */
for_each_task(p)
p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);
/* ^^^^^^^^^^^^^^^^^ 睡眠进程保留一半旧额度 -> 累积成优先权 */
2.2 三个致命缺陷
❶ O(n) 的选择过程
每次 schedule() 遍历整个就绪队列。
桌面机 10 个任务无所谓;服务器上几千个任务,
调度器自己就吃掉可观的 CPU —— 而且【负载越高越慢】,
这是最糟糕的一种复杂度:在你最需要它的时候它最慢。
❷ O(n) 的时间片重算,而且遍历的是【全部】进程
一次重算要扫描系统里所有 task_struct(包括睡着的)。
这一下会造成明显的调度停顿。
❸ 一把全局锁 + 一个全局队列
SMP 上所有 CPU 抢同一把 runqueue_lock -> 核数一多直接崩。
而且任务在 CPU 间随意漂移,缓存亲和性完全没有保障。
第 ❸ 点在 2000 年前后变得不可容忍 —— 多路 SMP 服务器开始普及,而 Linux 想进企业市场。这直接催生了下一代。
3. 第二代:O(1)(2.6.0 ~ 2.6.22)
Ingo Molnár 在 2002 年提交的 O(1) 调度器,是一次教科书式的「用空间换时间」。
3.1 用数据结构消灭遍历
核心思路:优先级只有 140 档,那就为每一档准备一个链表。
选任务 = 找到「最高的非空档位」= 位图上找第一个 1。
优先级空间(数值越小优先级越高)
0 ....................... 99 | 100 ................ 139
|<---- 实时(RT)---------->| |<---- 普通(nice -20~19)--->|
每个 CPU 有两个数组(这是最精妙的一笔):
+----------------------------------+
| active (本轮还有额度的) | <- 从这里挑
| bitmap[140] 链表[140] |
+----------------------------------+
| expired (额度用完的) | <- 用完的扔进来
| bitmap[140] 链表[140] |
+----------------------------------+
active 空了 -> 【交换两个指针】—— O(1) 完成「新一轮」
不需要遍历任何进程去重算时间片!
这一笔解决了 O(n) 的缺陷 ❶ 和 ❷:
- 选任务:
sched_find_first_bit(bitmap)—— 在 x86 上就是几条bsf指令,与任务数无关 - 开始新一轮:交换
active和expired两个指针,O(1)
加上per-CPU 队列(各自一把锁)解决了 ❸。至此调度器的「机械效率」问题被彻底解决 —— 从 CFS 到 EEVDF,都保留了 per-CPU 队列这个结构。
3.2 死因:交互性靠「猜」
O(1) 剩下的问题是怎么定优先级。它的答案是:观察睡眠行为,猜谁是交互式的。
sleep_avg:一个进程「睡了多久」的累积值
睡眠时 -> sleep_avg 增加
运行时 -> sleep_avg 减少
动态优先级 = 静态优先级(nice) + bonus(sleep_avg)
^^^^^^^^^^^^^^^ 范围 -5 ~ +5
被判定为「交互式」的任务有两项特权:
① 优先级奖励,插到更高档位
② 时间片用完后【重新放回 active 数组】,而不是扔进 expired
-> 可以连续运行,不必等下一轮
思路正确,但启发式无法穷尽现实。真实世界的负载千奇百怪,于是 2.6 早期的调度器变成了一场打补丁的军备竞赛:
❌ 误判一:视频播放器 / 音频线程
规律地醒来做一点计算 —— 睡得不够多,拿不到交互式认定 -> 卡顿、爆音
❌ 误判二:编译时的「饥饿」
make -j 起一堆 CPU-bound 任务,全在 expired 数组里排队;
几个被误判成交互式的任务反复回插 active -> expired 里的迟迟轮不到
❌ 误判三:可以被【故意】欺骗
写个循环「计算一点 -> sleep(1ms)」的程序,
就能骗到交互式认定,抢到远超份额的 CPU(一种本地 DoS)
❌ 根本问题:sleep_avg 的衰减曲线、bonus 的映射、
时间片长度,全是【魔数】。改一个数就有一批负载变好、
另一批变差。没有任何【可证明的性质】可以依赖。
2003~2007 年围绕这些魔数的争吵(尤其是 Con Kolivas 的 RSDL / SD 调度器与主线之争)最终逼出了一个截然不同的思路。
3.3 换代的真正教训
O(1) 的死因不是性能,而是**「用启发式猜测意图」这条路走不通**。
CFS 的答案是换掉问题本身:不再问「谁是交互式的」,而是问「谁被欠得最多」。这个量是可以精确计算的,不需要魔数,也不能被欺骗 —— 你想多拿 CPU,就必然多欠,下次就排在后面。
一个睡了很久的交互式任务,自然「欠」了很多 CPU,醒来后自然排在前面。交互性变成了公平的副产品,而不是需要特殊识别的类别。 这是整个调度器演进史里最漂亮的一次问题转化。
4. 第三代:CFS(2.6.23 ~ 6.5)
CFS(Completely Fair Scheduler,完全公平调度器)由 Ingo Molnár 主导,于 2007 年进入 Linux 2.6.23。名字里的「完全公平」不是说现实能做到绝对公平,而是先构造一台理想机器,再让真实机器尽量逼近它。
4.1 理想多任务 CPU
假设有一颗能无限细分的 CPU,同时运行 N 个同权重任务:
真实单核 CPU:
时间 -> AAAAA | BBBBB | CCCCC | AAAAA | ...
一次只能跑一个,轮流切换
CFS 的理想 CPU:
时间 -> ABCABCABCABCABCABCABCABCABCABC...
A/B/C 每一瞬间都各得 1/3 CPU
真实硬件不能同时运行,只能用时间片模拟。于是调度器要记录每个任务在理想世界里「已经跑了多少」,每次选跑得最少的那个:
A 的虚拟运行时间:12ms
B 的虚拟运行时间:15ms
C 的虚拟运行时间: 9ms <- 最小,下一个跑 C
这个账本就是 vruntime(virtual runtime)。对同权重任务,它近似等于实际运行时间;谁的 vruntime 最小,谁就是被欠得最多的人。
CFS 把可运行任务按 vruntime 放进红黑树:
[vruntime=21]
/ \
[vruntime=13] [vruntime=29]
/ \ \
[9] [17] [35]
^
+-- 最左节点 = vruntime 最小 = 下一个运行
入队/出队:O(log n)
找最小值:O(1)(内核缓存 rb_leftmost)
这里有个常见误解:CFS 不是为了比 O(1) 更快。O(1) 选择任务在渐进复杂度上反而更低。CFS 接受了 O(log n),换来的是一个简单、连续、可推理的公平模型。实际每个 CPU 的可运行任务通常只有个位数,红黑树高度很低,复杂度不是瓶颈。
4.2 vruntime 如何把 nice 变成份额
不同 nice 值不能让实际时间一比一记账。低优先级任务实际跑 1ms,虚拟账本应该走得更快;高优先级任务跑 1ms,虚拟账本应该走得更慢:
vruntime 增量 = 实际运行时间 × NICE_0_LOAD / 任务权重
1024
nice = 0,weight = 1024:实际跑 1ms -> vruntime +1.00ms
nice = -5,weight = 3121:实际跑 1ms -> vruntime +0.33ms (账走得慢)
nice = +5,weight = 335:实际跑 1ms -> vruntime +3.06ms (账走得快)
所以高优先级任务不是被硬塞进队首,而是消耗虚拟额度更慢,最终自然获得更多实际 CPU。
Linux 的 40 档权重不是线性表,而是近似等比数列:
weight(nice) ≈ 1024 / 1.25^nice
nice -20 -10 -5 0 +5 +10 +19
weight 88761 9548 3121 1024 335 110 15
为什么每级是 1.25 倍?这是为了解决旧调度器一个反直觉问题:如果 nice 是线性差值,那么优先级变化的效果依赖对手是谁。比如权重从 10 加到 11,在与权重 1 的任务竞争时影响很小;在与权重 10 的任务竞争时却很明显。
等比权重让每一级 nice 都有稳定的相对含义:
两个任务竞争一个 CPU:
A nice 0 weight 1024
B nice 0 weight 1024
A 份额 = 1024 / (1024 + 1024) = 50%
把 B 调成 nice +1,weight 820:
A 份额 = 1024 / (1024 + 820) ≈ 55.6%
B 份额 ≈ 44.4%
相邻两级的权重比 ≈ 1.25
也就是:同一竞争集合里,nice 每降低 1,权重约增加 25%。
注意三个边界:
nice表示的是相对权重,不是「每秒保证多少毫秒」;没人竞争时,nice 19 也能独占 100% CPU。- 份额只在同一个调度竞争域、同一套层级权重里计算;cgroup 会在任务上面再套一层。
nice只影响普通调度类(SCHED_OTHER/SCHED_NORMAL),对实时类不生效。
# 查看/修改 nice
ps -o pid,ni,cls,rtprio,comm -p "$PID"
nice -n 10 ./batch-job
renice 5 -p "$PID"
# 普通用户只能把自己的任务调得更“nice”(数值增大)
# 降低 nice 值需要 CAP_SYS_NICE
4.3 CFS 到底有没有时间片
常见说法「CFS 没有固定时间片」是对的,但「CFS 没有时间片」是错的。
CFS 不像 O(1) 那样给每个优先级写死一个时间片,而是根据目标延迟和就绪任务数动态计算:
调度周期 period:希望在这段时间内让每个可运行任务至少跑一次
任务时间片 slice = period × 任务权重 / 就绪队列总权重
例:period = 24ms,三个同权重任务
每个 slice = 24ms / 3 = 8ms
例:权重 1024、1024、2048
三者 slice = 6ms、6ms、12ms
任务数很少时,周期接近 sched_latency_ns;任务数太多时,如果仍强行塞进固定周期,每个时间片会短到频繁切换,因此内核用最小粒度把周期拉长:
period ≈ max(sched_latency, nr_running × sched_min_granularity)
现代内核的具体参数和命名随版本变化,不能把网上某个 6ms/24ms 当成永恒常量。更重要的是理解关系:
- 任务少:照顾唤醒延迟,一轮尽快轮完
- 任务多:守住最小粒度,避免上下文切换吞掉吞吐
- 权重高:同一轮获得更长的实际运行时间
4.4 睡眠者奖励,不再靠猜
一个任务睡眠时不会累加 vruntime。醒来时,其他任务已经跑了很多,它的 vruntime 较小,于是自然排到树的左侧:
t=0:A、B 的 vruntime 都是 100
A 等磁盘睡眠,B 连续运行到 vruntime=150
A 被唤醒:
A vruntime≈100
B vruntime=150
-> A 欠了很多,优先运行
但不能把 A 原封不动放到 100。如果它睡了一小时,回来后凭巨额欠账霸占 CPU 一小时,就会惩罚当前任务。因此 CFS 用运行队列的 min_vruntime 给唤醒任务设下限,只允许有限度的 sleeper bonus。
这就是 CFS 比 O(1) 干净的地方:
- O(1):记录睡眠时间 → 套启发式公式 → 猜是不是交互任务
- CFS:记录已经获得的加权 CPU 时间 → 欠得多就先跑
不用识别「交互式」这个身份,交互行为本身就导致了低 vruntime。
4.5 调度组:容器权重不是进程权重的简单相加
如果调度器只按线程公平,一个有 1000 个线程的容器会轻易压过只有 10 个线程的容器。cgroup 的 group scheduling 把红黑树变成层级结构:
根 CPU 运行队列
├── cgroup A(cpu.weight=100) -> 先拿约 50%
│ ├── task A1
│ └── task A2 -> 两个任务在 A 的份额内再平分
└── cgroup B(cpu.weight=100) -> 先拿约 50%
├── task B1
├── task B2
└── ... 100 个任务 -> 100 个任务平分 B 的 50%
A 和 B 的线程数相差 50 倍,两个组仍各拿约一半。这是容器 CPU 隔离的第一种语义:权重(weight/share)只在发生竞争时生效,不限制空闲时多用。
# cgroup v2:默认值 100,范围 1~10000
cat /sys/fs/cgroup/my-service/cpu.weight
# systemd 的 CPUWeight=200 最终写到这里
systemctl set-property my-service.service CPUWeight=200
# Docker --cpu-shares 是 cgroup v1 的相对权重;在没人竞争时仍可跑满
# 它与 --cpus / cpu.max 的【硬限额】完全不是一回事
5. 内核什么时候真正发生调度
5.1 调度点与抢占
调度不是每来一个时钟中断就一定切换。内核通常先设置一个「需要调度」标志(TIF_NEED_RESCHED),等到安全点调用 schedule()。
触发来源主要有四类:
❶ 当前任务主动阻塞
read 等不到数据、futex 等锁、nanosleep、等待磁盘
-> 把自己设为睡眠态 -> schedule()
这是【主动调度】,不是抢占
❷ 当前任务用掉了应得份额
调度 tick / 高精度定时器发现它的 vruntime 已落后于别人
-> 设置 need_resched
❸ 更值得运行的任务被唤醒
网卡中断唤醒服务线程、锁释放唤醒等待者
-> wakeup preemption:比较是否应该抢占当前任务
❹ CPU 负载不均
空闲 CPU 拉任务,或周期性负载均衡推任务
-> 可能迁移后触发调度
所谓「时间片用完」只覆盖 ❷,而且对 CFS 也不是一个固定倒计时。一个任务是否该走,要看它和竞争者之间的虚拟进度差。
5.2 用户态抢占与内核态抢占
从用户态返回时检查 need_resched 很容易;难的是任务正在内核里运行时能不能被切走。
用户态执行
-> 时钟中断发现该抢占
-> 中断返回用户态前检查 need_resched
-> schedule() ✅ 可抢占
内核态执行(可抢占区域)
-> 中断后发现 need_resched
-> preempt_count == 0
-> schedule() ✅ PREEMPT 内核可抢占
内核态执行(持有自旋锁 / 关抢占 / 中断上下文)
-> preempt_count > 0
-> 只能先记 need_resched ❌ 此刻不能切
-> 解锁或离开临界区后再调度
这就是为什么「调度延迟」不只取决于时间片,还取决于内核最长不可抢占区。Linux 的抢占模型是编译时/启动时的重要选择:
| 模型 | 取舍 | 常见场景 |
|---|---|---|
PREEMPT_NONE |
吞吐优先,内核态只在主动点调度 | 批处理、部分服务器内核 |
PREEMPT_VOLUNTARY |
在长路径中插入主动抢占点 | 传统通用发行版 |
PREEMPT / dynamic full |
大部分内核代码可抢占 | 桌面、低延迟服务 |
PREEMPT_RT |
尽量把自旋锁和中断线程化,压低最坏延迟 | 工控、音频、实时系统 |
实时内核的目标不是「平均更快」,而是最坏情况有上界。它可能牺牲吞吐。低 P99 的服务也不等于必须上 RT:绝大多数尾延迟来自排队、限流、锁、GC 和 IO,而不是内核不可抢占区。
# 当前内核抢占模型(不同发行版暴露方式不同)
cat /sys/kernel/debug/sched/preempt 2>/dev/null
zgrep PREEMPT /proc/config.gz 2>/dev/null || grep PREEMPT /boot/config-"$(uname -r)"
# 调度延迟追踪(需要 debugfs/ftrace 权限)
# wakeup tracer 测量「高优先级任务被唤醒 -> 真正上 CPU」的最长延迟
sudo sh -c 'echo wakeup > /sys/kernel/debug/tracing/current_tracer'
5.3 上下文切换到底换了什么
一次进程切换不只是保存几个寄存器:
直接成本:
保存/恢复通用寄存器、栈指针、程序计数器
切换内核栈、地址空间页表(mm)
更新 CPU 本地的当前任务指针
间接成本(往往更大):
L1/L2 cache 中原任务的热数据被新任务顶掉
TLB 项失效或受 PCID/ASID 保护而部分保留
分支预测器状态被扰动
跨 CPU 迁移时,缓存行要经一致性协议搬家
同一进程的线程共享 mm,页表切换更轻;但缓存污染仍存在。goroutine 切换更轻,是因为它发生在 Go 用户态运行时里:多数时候只保存少量寄存器和栈指针,不切页表,也不进入内核。但如果 M 阻塞或 P 被抢占,最终仍会发生内核线程调度。
# cs:每秒上下文切换;只是信号,不是“越多必然越坏”
vmstat 1
pidstat -w -p "$PID" 1
# cswch/s 主动切换:等待 IO、锁、channel 等
# nvcswch/s 被动切换:时间份额耗尽或被更高优先级任务抢占
# perf 关联调度事件与调用栈
sudo perf sched record -p "$PID" -- sleep 10
sudo perf sched timehist
# wait time:在就绪队列里等 CPU 的时间
# sch delay:被唤醒到真正运行的延迟
6. CFS 的短板:公平不等于低延迟
6.1 CFS 已经很好,为什么还要换
CFS 解决了 O(1) 的启发式泥潭,并稳定服务了 16 年。它的问题不是「不公平」,而是它用一个标量 vruntime 同时表达两件事:
- 过去拿了多少 CPU —— 公平债务
- 接下来应该多快得到 CPU —— 延迟需求
这两件事并不等价。
假设有两个同权重任务:
A:音频线程,每 5ms 醒一次,只跑 100us,晚 2ms 就爆音
B:编译任务,一次能跑几秒,晚 20ms 没关系
A 因为经常睡眠,CFS 通常会照顾它;但这是从历史运行量间接推断出来的。A 没法明确告诉调度器:「我的份额不需要变多,但每次请求希望在 1ms 内完成。」
CFS 还有几个长期补丁化的角落:
- sleeper bonus 与唤醒抢占需要调粒度,避免频繁抢占
- 新任务该继承什么 vruntime,既不能占便宜也不能饿死
yield、任务迁移、组调度让 vruntime 的语义越来越复杂- 只有「最欠 CPU」一个排序维度,无法直接表示虚拟截止期
内核不是因为 CFS 崩了才换,而是调度理论里已有一个更统一的模型,能同时表达公平与延迟。
6.2 EEVDF:先看谁有资格,再看谁最急
EEVDF 全称 Earliest Eligible Virtual Deadline First:
在「有资格运行」的任务里,选择「虚拟截止期最早」的任务。
它源于 1995 年的调度算法,Linux 从 6.6 开始用它替换 CFS 的任务选择核心。它仍属于 fair scheduler:仍有 per-CPU 运行队列、权重、虚拟时间和红黑树,不是推倒所有代码重来。
EEVDF 给每个任务维护两个关键概念:
lag(滞后/欠账):实际获得的服务与理想应得服务之间的差
lag >= 0 -> 任务没有超支,有资格(eligible)竞争
lag < 0 -> 已经提前拿多了,暂时没资格
virtual deadline(虚拟截止期):
deadline = virtual start + virtual slice
在有资格的任务中,deadline 最小的先运行
不同资料对 lag 的正负号可能采用相反定义;看 Linux 实现时记住核心判断即可:先过滤掉已经超前的任务,再在欠账任务里按虚拟截止期选最急的。
任务 lag 虚拟 deadline 是否 eligible
A +2ms 31ms 是
B +1ms 25ms 是 <- deadline 最早,选 B
C -3ms 20ms 否 <- 虽最早,但已经拿多了
为什么比「vruntime 最小者优先」更强?因为 deadline 同时带入了请求长度:短请求会得到更近的虚拟截止期,因此能表达「不多拿 CPU,但希望早点完成」。
6.3 slice 如何成为延迟提示
EEVDF 允许任务通过 slice 表达延迟与吞吐偏好:
短 slice:
虚拟 deadline 更近 -> 更快被选中
运行一小段就重新排队 -> 响应快、切换多
长 slice:
虚拟 deadline 更远 -> 不强调立即执行
一旦运行可连续跑更久 -> 吞吐高、切换少
这不是提高权重。权重决定长期 CPU 份额,slice 决定这份 CPU 以多大的颗粒、怎样的延迟交付。这正是 CFS 缺失的维度。
Linux 随后引入 sched_setattr() 的 SCHED_FLAG_UTIL_CLAMP_* 等能力,并为 fair 任务推进 latency nice/自定义 slice 的接口方向;用户态接口和发行版支持仍在演进。不要把 nice、latency_nice 和实时优先级混为一谈:
| 参数 | 主要控制 | 是否承诺硬实时 |
|---|---|---|
nice / CPU weight |
长期 CPU 份额 | 否 |
| latency hint / slice | 份额交付的延迟与粒度偏好 | 否 |
SCHED_FIFO/RR 优先级 |
实时调度类的严格优先级 | 仍需系统完整实时设计 |
SCHED_DEADLINE |
runtime / deadline / period 预留 | 最接近明确的时限契约 |
EEVDF 仍然是尽力而为的公平调度器。如果机器已经过载、被更高调度类压制或进程受 cgroup 限额,它不会凭空创造 CPU。
6.4 CFS 到 EEVDF:变与不变
| 维度 | CFS | EEVDF |
|---|---|---|
| 长期公平依据 | 加权虚拟运行时间 | lag / 加权虚拟服务 |
| 候选条件 | vruntime 最小附近 | 先满足 eligible |
| 候选排序 | vruntime | virtual deadline |
| 短任务延迟表达 | 主要靠睡眠与唤醒启发 | deadline 原生表达 slice |
| 数据结构 | 增强红黑树 | 增强红黑树(节点维护最小虚拟运行时间等信息) |
| 调度类 | fair class | 仍是 fair class |
| cgroup 权重/限额 | 支持 | 继续支持 |
所以更准确的说法是:Linux 6.6 把 fair class 的核心选择算法从 CFS 逐步过渡到 EEVDF,不是整个 Linux 调度器只剩 EEVDF。实时类、deadline 类、idle 类仍各自存在。
7. 调度类:公平调度器不是最高裁判
7.1 类之间先比等级,类内部再排队
Linux 同时支持多种调度策略。简化后的优先顺序是:
stop class CPU 停机/迁移等内核内部任务
↓
deadline class SCHED_DEADLINE
↓
real-time class SCHED_FIFO / SCHED_RR
↓
fair class SCHED_NORMAL / SCHED_BATCH(CFS/EEVDF)
↓
idle class SCHED_IDLE
fair class 内的 nice -20 再高,也压不过一个可运行的 SCHED_FIFO 任务。实时任务如果写成死循环,能让普通任务长期得不到 CPU,因此 Linux 有 RT bandwidth throttling 作为保险,但不要依赖保险来修正错误设计。
7.2 FIFO、RR 与 DEADLINE
SCHED_FIFO:
同优先级先到先服务,没有时间片
一直运行到主动阻塞、yield 或被更高实时优先级抢占
SCHED_RR:
与 FIFO 相同,但同优先级任务按固定时间片轮转
SCHED_DEADLINE:
提交 runtime / deadline / period
例如:每 10ms 周期内需要 2ms CPU,截止期 10ms
内核用 EDF + CBS 管理带宽并做准入控制
# 查看调度类
ps -eLo pid,tid,cls,rtprio,pri,ni,psr,stat,comm
# TS/OTHER:普通 fair;FF:FIFO;RR:Round Robin;DLN:Deadline
# 让命令以批处理策略运行:降低唤醒抢占倾向,不改变 nice 权重语义
chrt --batch 0 ./batch-job
# 实时策略有饿死系统的危险,仅限明确的实时设计和资源边界
sudo chrt --fifo 50 ./rt-worker
# 查看 RT 限流:默认常见值表示每 1s 最多给 RT 任务 950ms
sysctl kernel.sched_rt_period_us kernel.sched_rt_runtime_us
实时优先级不能用来「让 Web 服务快一点」。服务里一次慢路径、日志阻塞或锁死都可能放大成全机事故。普通在线服务先解决 CPU 限额、排队、亲和性和过载保护,再谈实时策略。
8. 容器 CPU 限流:P99 的 100ms 悬崖
8.1 weight 与 quota 是两种完全不同的合同
容器 CPU 配置经常混淆两套机制:
CPU weight / shares:相对份额
只有竞争时才按权重分
CPU 空闲时可以多用
cgroup v2: cpu.weight
CPU quota:绝对带宽上限
每个 period 最多用 quota 的 CPU 时间
用完就被 throttle,哪怕其他 CPU 都空闲
cgroup v2: cpu.max = "$MAX $PERIOD"
例如:
cat /sys/fs/cgroup/my-service/cpu.max
# 100000 100000
# ^quota ^period,单位 us
# 每 100ms 最多消耗 100ms CPU 时间 = 平均 1 个 CPU
cat /sys/fs/cgroup/my-service/cpu.max
# 200000 100000
# 每 100ms 最多 200ms CPU 时间 = 平均 2 个 CPU
cat /sys/fs/cgroup/my-service/cpu.max
# max 100000
# 不设硬限额
Kubernetes 里:
requests.cpu主要用于调度和 CPU weight(竞争时的保障)limits.cpu通常转成 CFS/CPU bandwidth quota(硬上限)cpu: "1"是 1 CPU 的时间预算,不是绑定某一颗 CPU
8.2 为什么 1 CPU 限额能被 8 个线程在 12.5ms 用完
这是尾延迟问题最关键的一步。假设容器可在 8 核上运行,但限额是:
period = 100ms
quota = 100ms CPU time(1 CPU)
Go 的 8 个 M 同时在 8 个核上执行:
墙上时间 运行情况 累计 CPU 时间
0 ~ 12.5ms 8 个核 × 12.5ms 100ms
12.5 ~ 100ms quota 用完,整个 cgroup 被 throttle 不能运行
100ms 新周期补充额度 恢复运行
于是一个在第 13ms 到达的请求,即使只需要 0.2ms CPU,也可能等到第 100ms,凭空多出接近 87ms 延迟。监控上会出现接近 period 的台阶:P99 从几毫秒跳到 50~100ms。
这不是某个线程「时间片用完」,而是整个 cgroup 的 CPU 钱包见底。同组的业务 goroutine、GC worker、网络轮询线程都一起停。
8.3 为什么平均 CPU 看起来只有 30%
「CPU 30%」必须先问分母是什么。
节点 8 核,容器限额 1 CPU:
容器持续用满限额 = 1 核 = 节点总容量的 12.5%
如果仪表盘用“节点总核数”作分母:
显示 12.5%,看起来很空闲
如果采样窗口是 1 分钟:
100ms 周期里的突发和 throttle 被平均掉
显示 30% 也不代表某个 100ms 窗口没耗尽 quota
还有一种更隐蔽的情况:服务平均只用 0.3 CPU,但每次流量突发让 8 个线程并行跑,短时间耗尽当期额度;长时间平均仍是 30%。quota 是短周期预算,CPU utilization 常是长窗口平均值。 平均值无法证明没有周期性断粮。
8.4 Go 为什么特别容易撞到这个问题
Go 的并发模型会主动使用多个内核线程。默认 GOMAXPROCS 通常根据可见 CPU/容器环境决定;旧版本或特殊环境中,它可能看到宿主机 8/32/64 核,却不知道自己只有 1 CPU quota。
宿主机:32 核
容器 limit:1 CPU
GOMAXPROCS:32(错误或过大的并行度)
一次请求突发 / GC:
32 个 P 驱动多个 M 同时运行
-> 很快烧光 100ms quota
-> 整组 throttle
-> 网络响应和 GC 都停到下一周期
即使运行时能正确感知容器配额,GOMAXPROCS=1 也只是减少额度被瞬间烧光和无效并行,不会提高总预算;一个真正需要 1.5 CPU 的服务仍会被限流。
近年的 Go 版本持续改进容器感知并支持根据 cgroup quota 自动选择/更新 GOMAXPROCS。生产环境仍应直接验证:
# 程序里观察
# runtime.GOMAXPROCS(0)
# 运行时调试(具体 GODEBUG 选项随 Go 版本变化,先查对应版本文档)
go version
# 容器中核对 cpuset 与 quota,而不是只看 nproc
cat /sys/fs/cgroup/cpuset.cpus.effective 2>/dev/null
cat /sys/fs/cgroup/cpu.max 2>/dev/null
8.5 证明是 throttling,不要凭感觉
cgroup v2 的第一证据是 cpu.stat:
watch -n 1 'cat /sys/fs/cgroup/my-service/cpu.stat'
# usage_usec 532100000 累计使用 CPU 时间
# user_usec 490000000
# system_usec 42100000
# nr_periods 28190 进入过多少个带宽周期
# nr_throttled 8231 其中多少个周期发生限流
# throttled_usec 91820000 累计被限流的墙上时间(注意内核版本统计语义)
# 重点看增量,不看启动以来的绝对值:
# throttle ratio = Δnr_throttled / Δnr_periods
# 与 P99 时间线对齐;若尖峰同时增长,证据很强
Prometheus/cAdvisor 常见指标:
rate(container_cpu_cfs_throttled_periods_total[5m])
/
rate(container_cpu_cfs_periods_total[5m])
rate(container_cpu_cfs_throttled_seconds_total[5m])
rate(container_cpu_usage_seconds_total[5m])
不要把 throttled_seconds / wall time 直接解释成「服务停了这么大比例」。不同 CPU 上同时被限流的等待、层级 cgroup 和内核版本可能影响统计。最可靠的是:period 限流比例 + P99 时间对齐 + run queue/CPU profile + 改配置后的对照实验。
8.6 怎么修
按优先级处理:
- 确认服务是否需要硬 limit。 对延迟敏感服务,常见做法是保留 request/weight,谨慎设置 CPU limit;这需要结合组织的多租户策略,而不是机械删除。
- 让
GOMAXPROCS接近 quota 可提供的并行度。 避免 32 个 P 抢 1 CPU 预算;使用支持容器感知的现代 Go,并在启动日志打印最终值。 - 给真实峰值留余量。 平均 0.7 CPU 的服务设 1 CPU,不代表安全;看 100ms 级突发、GC 和 P99,而不是只看 5 分钟平均。
- 做过载保护。 并发上限、队列长度、超时、熔断和负载 shedding,防止排队把延迟无限放大。
- 分离干扰源。 批任务、压缩、日志处理不要与在线请求共享同一个紧 quota cgroup。
- 不要只缩短 period。 它可能减小单次停顿,却增加带宽管理开销;应先修预算和并行度。
# Kubernetes 示例:语义要分清,不是固定推荐值
resources:
requests:
cpu: "1" # 调度容量 + 竞争权重
limits:
cpu: "2" # 硬带宽上限;是否需要应由压测决定
9. 亲和性、NUMA 与负载均衡
9.1 CPU affinity 不是免费优化
把线程绑到 CPU 能减少迁移和缓存失效,但同时缩小了调度器的选择空间:
# 查看进程允许在哪些 CPU 上运行
taskset -cp "$PID"
grep Cpus_allowed_list /proc/"$PID"/status
# 绑定到 CPU 2、3
taskset -c 2,3 ./service
# 每个线程可能有不同 affinity,要按 TID 查看
ps -eLo pid,tid,psr,comm | grep service
适合绑定的场景:
- 专用低延迟线程,工作集对 cache 很敏感
- 数据面轮询线程与网卡队列/IRQ 协同
- 经过基准测试的数据库、存储引擎
不适合盲绑的场景:
- 普通 Web 服务,线程负载波动大
- 绑定集合太小,造成局部运行队列拥堵,而旁边 CPU 空闲
- 忽略中断、软中断和其他 cgroup 也在同一 CPU 上竞争
9.2 NUMA:CPU 空闲不代表内存就在身边
多路服务器里,每个 NUMA node 有本地 CPU 和内存。线程迁到远端 node 后访问原内存,延迟和互联带宽都会变差:
numactl --hardware
numactl --show
numastat -p "$PID"
# 观察每 CPU / NUMA 调度和迁移
pidstat -u -w -p "$PID" 1
perf stat -p "$PID" -e context-switches,cpu-migrations -- sleep 10
调度器要同时平衡:
- 让空闲 CPU 有活干
- 尽量不迁移热任务
- 保持 SMT/core/NUMA 拓扑上的负载合理
- 尊重 cpuset、affinity、cgroup 和隔离 CPU
因此「明明有空闲核,这个线程为什么还在等」不一定是 bug:空闲核可能不在允许集合里、属于别的 cpuset、NUMA 代价过高,或者任务受 quota 限流,即使全机空闲也没有资格运行。
10. 线上 CPU 问题的分层定位
10.1 先区分忙、等、限、抖
CPU 问题四类:
❶ 忙:确实有太多指令要执行
证据:CPU usage 高,run queue 高,profile 有明确热点
❷ 等:任务已就绪,但排队拿不到 CPU
证据:run queue / PSI some 高,sched delay 高
❸ 限:cgroup quota 用完,被人为断粮
证据:cpu.stat 的 nr_throttled 与 P99 同步增长
❹ 抖:迁移、锁竞争、中断、GC 等造成短促毛刺
证据:平均 CPU 不高,但 context switch、migration、IRQ 或 pause 尖峰
这四类不能只靠 top 区分。
10.2 一套可执行的排查顺序
# ① 全机:到底是 CPU 还是 IO
uptime
vmstat 1
mpstat -P ALL 1
# r 高 + us/sy 高:CPU 排队
# b 高 + wa 高:更像 IO
# 单核 100%、其他核空闲:亲和性、单线程热点或 IRQ 偏斜
# ② 压力:任务因 CPU/IO/内存资源等了多久
cat /proc/pressure/cpu
# some avg10=12.34 avg60=8.10 avg300=3.22 total=...
# CPU 的 some 表示至少有任务因等 CPU 而停顿
# CPU 没有 full 指标:只要还有一个任务运行,CPU 就在做有效工作
# ③ 进程/线程:谁在用,谁在切
pidstat -u -w -t -p "$PID" 1
top -H -p "$PID"
# ④ 容器边界:是否被 quota 限流
cat /sys/fs/cgroup/my-service/cpu.max
cat /sys/fs/cgroup/my-service/cpu.stat
cat /sys/fs/cgroup/my-service/cpu.pressure 2>/dev/null
# ⑤ 热点:CPU 时间花在哪
sudo perf top -p "$PID"
sudo perf record -F 99 -g -p "$PID" -- sleep 30
sudo perf report
# Go 服务同时抓 CPU profile
curl -o cpu.pprof 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'
go tool pprof -http=:8081 cpu.pprof
# ⑥ 调度延迟:被唤醒后等了多久
sudo perf sched record -a -- sleep 10
sudo perf sched latency
sudo perf sched timehist
10.3 指标如何组合判断
| CPU 使用率 | CPU PSI / run queue | throttling | 常见判断 |
|---|---|---|---|
| 高 | 高 | 低 | 真实 CPU 饱和,找热点/扩容/限并发 |
| 低或中 | 高 | 高 | quota 断粮,节点可能仍空闲 |
| 单核高 | 局部高 | 低 | 单线程瓶颈、affinity、锁串行化 |
高 sy |
高 | 低 | 系统调用、网络/块 IO、缺页、中断路径 |
| 不高 | 不高 | 低 | P99 更可能在 IO、锁、GC、下游或应用队列 |
| 不高 | 瞬时尖峰 | 低 | 采样粒度掩盖突发,查 sched/trace 时间线 |
load average 也不能直接等同 CPU 使用率。Linux load 包含:
- 正在运行或等待 CPU 的任务(R)
- 不可中断睡眠任务(D,常见于 IO)
所以 load 100 可能是 CPU 排队,也可能是存储/NFS 卡住 100 个 D 状态任务。先看 vmstat 的 r/b,再下结论。
11. 常用命令知识点扩展
11.1 调度与优先级命令
| 短参 | 长参 | 英文全称 | 作用 |
|---|---|---|---|
nice -n N |
nice --adjustment=N |
niceness adjustment | 以指定 nice 增量启动程序 |
renice -n N |
renice --priority N |
re-nice priority | 修改已有进程的 nice 值 |
chrt -f N |
chrt --fifo N |
change real-time attributes / FIFO | 使用 SCHED_FIFO |
chrt -r N |
chrt --rr N |
round robin | 使用 SCHED_RR |
chrt -b 0 |
chrt --batch 0 |
batch policy | 使用 SCHED_BATCH |
taskset -c LIST |
taskset --cpu-list LIST |
task CPU set | 按列表设置 CPU affinity |
ps -L |
ps --threads |
list threads | 展开线程 |
pidstat -w |
无统一长参 | task switching activity | 显示主动/被动上下文切换 |
mpstat -P ALL |
无统一长参 | multiprocessor statistics | 按 CPU 显示使用率 |
几个容易踩的坑:
nice -n 5对新进程通常表示在继承值上增加 niceness;renice 5设置目标 nice 值。脚本里不要混用语义。PRI、NI、RTPRIO不是同一列;不同ps/top的显示方向也可能不同。taskset改一个 PID 时不一定覆盖该进程的所有线程;线程可以各自有 affinity。chrt能让系统失去响应,不要在生产机上用 FIFO/RR 做临时「加速」。
11.2 场景配方
让离线压缩少抢在线服务:
nice -n 15 ionice -c 3 tar -czf backup.tar.gz /data
# nice 管 CPU 权重;ionice 管块 IO 优先级
# 二者都不是绝对限额,必要时放入独立 cgroup
确认线程是否频繁跨 CPU:
watch -n 0.5 "ps -eLo pid,tid,psr,ni,stat,comm | grep '[m]y-service'"
sudo perf stat -p "$PID" -e context-switches,cpu-migrations -- sleep 30
判断容器是竞争不足还是硬限额:
cat cpu.weight
cat cpu.max
# weight 低:只在竞争时少拿
# cpu.max 非 max:预算用尽会硬停
before=$(grep nr_throttled cpu.stat)
sleep 10
after=$(grep nr_throttled cpu.stat)
printf '%s\n%s\n' "$before" "$after"
判断高 load 是 CPU 还是 D 状态:
vmstat 1
ps -eo state,pid,wchan:32,comm | awk '$1=="D"'
# r 高 -> CPU runnable 多
# b / D 高 -> 等不可中断 IO;wchan 帮助定位等待点
12. 面试题
Q:O(n)、O(1)、CFS、EEVDF 每一代为什么被替换?
O(n) 每次选任务扫描整个运行队列,时间片重算甚至扫描所有进程;全局队列和全局锁也无法扩展到 SMP。O(1) 用 per-CPU 队列、优先级数组、位图以及 active/expired 双数组解决了机械效率,但它依赖 sleep_avg 等启发式猜测交互任务,魔数多、容易误判甚至被利用。
CFS 放弃识别任务类型,用加权 vruntime 表示公平债务,「谁被欠得多谁先跑」,结构和性质都更容易推理。CFS 的不足是主要用历史服务量间接兼顾延迟,不能原生分离「长期份额」与「服务粒度/截止期」。EEVDF 先用 lag 判断 eligible,再按 virtual deadline 选择,让短 slice 的低延迟需求和长期公平进入同一理论模型。
Q:CFS 为什么用红黑树,而 O(1) 明明复杂度更低?
调度器优化的不只是一次选择操作的复杂度。O(1) 的位图适用于离散的固定优先级,但普通任务的公平债务是连续变化的 vruntime。红黑树能按连续键动态排序,插入删除 O(log n),最左节点缓存后取最小值 O(1)。每个 CPU 上可运行任务通常不多,树高很低;CFS 用极小的机械成本换掉了不可维护的交互性启发式。所谓「O(1) 比 CFS 快,所以换代是倒退」忽略了算法要表达的语义。
Q:nice 每差一级,CPU 份额就固定差 10% 吗?
不是。Linux 权重近似 1024 / 1.25^nice,相邻档权重约差 25%。任务份额是 自己的权重 / 竞争集合总权重,因此百分比取决于其他任务。两个任务从同为 nice 0 的 50/50,改其中一个为 nice +1 后,约变为 55.6/44.4。没有竞争时,nice 19 也能使用全部 CPU。nice 是相对权重,不是硬限速。
Q:vruntime 为什么要除以任务权重?
公式近似为 Δvruntime = Δexec × NICE_0_LOAD / weight。权重高的任务实际运行同样时间,虚拟时间走得慢,因此需要更多实际 CPU 才会积累到与其他任务相同的虚拟进度;权重低的任务虚拟时间走得快,很快排到后面。这样长期实际份额自然与权重成比例,不需要写死每档时间片。
Q:CFS 到底有没有时间片?
有动态 slice,没有传统意义上按优先级固定的时间片。CFS 根据调度周期、可运行任务数量和权重计算任务在一轮中应获得的运行长度,并用最小粒度避免任务多时切换过于频繁。是否抢占还取决于 vruntime 差、唤醒任务和粒度,不是单纯倒计时归零。
Q:内核在什么情况下会切走当前任务?
当前任务主动阻塞会直接调用调度;运行份额落后、一个更值得运行的任务被唤醒、负载均衡迁入任务等情况会设置 need_resched。真正切换要等安全调度点:返回用户态前,或内核可抢占且 preempt_count==0 时。持有自旋锁、关闭抢占或处于中断上下文时不能立刻切,只能延后。因此「每个 tick 都切换」和「时间片一到立即切换」都不准确。
Q:主动上下文切换和被动上下文切换分别是什么?
主动切换是任务因等 IO、锁、futex、定时器等主动睡眠,pidstat 常显示为 cswch/s。被动切换是任务仍想运行,却因公平份额、优先级或更合适任务到来而被抢占,常显示为 nvcswch/s。两者都多不一定有问题:事件驱动服务会有大量正常主动切换;关键要结合吞吐、运行队列、调度等待和调用栈判断。
Q:为什么容器限制 1 CPU,不等于只能在一颗 CPU 上运行?
quota 限制的是周期内累计 CPU 时间,不是 affinity。quota=100ms, period=100ms 表示每周期可消费 100ms CPU;容器仍可能在 8 个核上同时执行,并在 12.5ms 墙上时间内花完预算,随后整个 cgroup 被 throttle 到下个周期。若要限定可运行 CPU,要用 cpuset/affinity;它与 quota 是两套机制。
Q:CPU 平均使用率只有 30%,为什么仍可能发生 throttling?
因为统计窗口和分母不同。quota 按常见的 100ms 短周期结算,而仪表盘可能按 1~5 分钟平均;短突发能耗尽某个周期额度,后面的请求等待到补充,长期平均仍很低。指标也可能以宿主机全部核为分母:在 8 核节点用满 1 CPU 只显示 12.5%。应查看 cpu.stat 中 nr_throttled/nr_periods 的增量并与 P99 对齐。
Q:EEVDF 比 CFS 多解决了什么?
CFS 主要按 vruntime 追赶公平,低延迟是睡眠者欠账和唤醒抢占的副产品。EEVDF 显式维护 lag 和 virtual deadline:先从没有超支、具备资格的任务中筛选,再选择截止期最早者。slice 会进入 deadline,短 slice 能表达「少量但尽快」的请求,而权重仍控制长期份额。它把公平和延迟粒度分成两个维度,但仍是尽力而为,不提供硬实时承诺。
Q:为什么不能把 Web 服务改成 SCHED_FIFO 来解决 P99?
FIFO 实时任务没有普通时间片,会一直运行到阻塞、yield 或被更高实时优先级抢占。Web 服务包含不可控的请求代码、锁、日志、GC 和第三方库,一条忙循环就可能饿死系统服务与普通任务。它也不能突破 cgroup quota、增加 CPU 总量或修复下游延迟。在线服务应先解决过载、限额、锁、GC 和队列;实时策略只适用于经过完整优先级、资源上界和故障隔离设计的系统。
Q:CPU 使用率高时,一套正确的排查顺序是什么?
先用 vmstat/mpstat 区分全机 CPU 忙、单核热点和 IO 等待;再看 CPU PSI、run queue 判断是否有人排队;检查 cgroup cpu.max/cpu.stat 排除人为限流;用 pidstat -t 找进程与线程,并区分主动/被动切换;最后用 perf 和 Go pprof 找实际热点。若平均不高但 P99 抖,追加 perf sched/ftrace 做时间线,关联 GC、IRQ、迁移和 throttling。顺序的核心是先判定「忙、等、限、抖」,再优化代码。
小结
- 调度器同时追求吞吐、延迟、公平,三者无法同时最大化;每次换代都是在重新定义可接受的平衡
- O(n) 的死因是扫描和全局锁无法扩展;O(1) 的死因不是慢,而是靠
sleep_avg等启发式猜任务意图 - CFS 的核心是加权
vruntime:不识别交互任务,只计算「谁被欠得最多」,交互性成为公平的自然副产品 - nice 使用近似 1.25 倍等比权重;它控制相对份额,不是绝对限速,没有竞争时低优先级任务仍可跑满
- CFS 有动态 slice,但没有按优先级写死的固定时间片;调度发生在阻塞、份额落后、唤醒抢占和负载均衡等条件下
need_resched只表示应该调度;持锁、关抢占和中断上下文中必须等到安全点,内核抢占模型决定最坏调度延迟- EEVDF 用 eligible + virtual deadline 把长期公平和低延迟粒度纳入统一模型;它替换 fair class 的选择核心,不是替换所有调度类
cpu.weight是竞争时的相对份额,cpu.max是周期内的硬预算;两者语义完全不同- 容器 1 CPU quota 可被 8 个线程在 12.5ms 内烧完,随后整组等待到 100ms 周期结束,这就是 Go 服务 P99 的百毫秒悬崖
- 排查 CPU 要先区分忙、等、限、抖;平均 CPU 低不能排除短周期 throttling,必须把
cpu.stat增量与 P99 对齐
下一篇讲 同步原语:futex 为什么被发明 —— 从原子指令、用户态自旋、每次进内核为什么太贵,一路走到 futex 的快慢路径,再把 Go 的 sync.Mutex、channel、GMP park/unpark 与内核等待队列串起来。
xingliuhua