目录

Linux-27 调度器演进:O(n) → O(1) → CFS → EEVDF

目录

前两篇讲内存 —— 一种可以超卖但不能压缩时间的资源。这一篇讲 CPU:一种完全不能存储的资源。内存分配错了顶多慢、顶多 OOM,CPU 分配错了直接表现为「服务毛刺」,而且很难归因。

调度器是内核里唯一一个被公开推翻重写了三次的核心子系统。这本身就是最好的教材:每一代的死法都精确地暴露了上一代的设计假设错在哪。

先看五个问题:

  1. 调度器为什么要换代?O(n) → O(1) → CFS → EEVDF,每一代是被什么问题逼死的
  2. nice 值从 -20 到 19,到底怎么影响 CPU 份额?为什么是每级 1.25 倍这个奇怪的数字?
  3. 内核什么时候才会切走一个正在跑的进程?「时间片用完」这个说法哪里不准确?
  4. 容器里设 cpu: 1 到底限制了什么?为什么 Go 服务的 P99 会突然飙到 100ms,而 CPU 使用率看起来只有 30%?
  5. 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 指令,与任务数无关
  • 开始新一轮:交换 activeexpired 两个指针,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%。

注意三个边界:

  1. nice 表示的是相对权重,不是「每秒保证多少毫秒」;没人竞争时,nice 19 也能独占 100% CPU。
  2. 份额只在同一个调度竞争域、同一套层级权重里计算;cgroup 会在任务上面再套一层。
  3. 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 同时表达两件事:

  1. 过去拿了多少 CPU —— 公平债务
  2. 接下来应该多快得到 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 的接口方向;用户态接口和发行版支持仍在演进。不要把 nicelatency_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 怎么修

按优先级处理:

  1. 确认服务是否需要硬 limit。 对延迟敏感服务,常见做法是保留 request/weight,谨慎设置 CPU limit;这需要结合组织的多租户策略,而不是机械删除。
  2. GOMAXPROCS 接近 quota 可提供的并行度。 避免 32 个 P 抢 1 CPU 预算;使用支持容器感知的现代 Go,并在启动日志打印最终值。
  3. 给真实峰值留余量。 平均 0.7 CPU 的服务设 1 CPU,不代表安全;看 100ms 级突发、GC 和 P99,而不是只看 5 分钟平均。
  4. 做过载保护。 并发上限、队列长度、超时、熔断和负载 shedding,防止排队把延迟无限放大。
  5. 分离干扰源。 批任务、压缩、日志处理不要与在线请求共享同一个紧 quota cgroup。
  6. 不要只缩短 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 状态任务。先看 vmstatr/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 值。脚本里不要混用语义。
  • PRINIRTPRIO 不是同一列;不同 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.statnr_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 与内核等待队列串起来。