文章/linux-idle

22 分钟阅读

Linux 下的 CPU Idle 行为详解:从 idle=poll 到 C-State

CPU 没事干的时候,它真的在休息吗?

1. 为什么需要关心 idle 行为

现代服务器 CPU 大部分时间是闲着的。哪怕整机负载很高,拆到单核上看,仍然有大量"等下一次调度"的空隙。CPU 在这段空隙里干什么,直接决定两件事:

  • 功耗:深睡眠状态(如 C6)下核心电压可以降到接近零,整机功耗差异可达数倍;
  • 唤醒延迟:从深睡眠回到可执行状态需要几十到上百微秒,对延迟敏感业务(高频交易、内存数据库、DPDK 转发)是实打实的尾延迟来源。

Linux 的 idle 子系统就在功耗和延迟之间做权衡,idle= 系列内核参数让你绕开这套权衡,直接指定 idle 行为。

2. idle loop:空闲时内核在跑什么

每个 CPU 一创建就带一个 0 号线程,也就是 idle 线程(swapper)。调度器找不到可运行的任务时切进来,执行 do_idle()kernel/sched/idle.c)。简化之后是这样:

do_idle()
  └── 循环:
        ├── 检查 need_resched(),有任务就赶紧回去调度
        ├── 关中断,进入"准睡眠"
        ├── 调用 cpuidle 框架: cpuidle_idle_call()
        │     ├── governor 选择目标 idle state(预测空闲时长)
        │     └── driver 执行进入该状态的硬件指令(MWAIT / HLT / WFI)
        └── 被中断唤醒 → 开中断 → 回到循环顶部

两个关键抽象:

  • Governor(策略层):根据历史空闲时长、定时器距离、调度器提示,预测这次会空多久,挑一个"够省但不至于醒得太慢"的状态。内核里有四个:menuTEO(Timer Events Oriented)、ladderhaltpoll,用哪个取决于内核配置:tickless 系统(空闲时能停调度 tick)默认 menu,非 tickless 的默认 ladderTEOmenu 的替代品,得自己选;haltpoll 只在虚拟化场景里跟着 haltpoll 驱动一起上。

注意别混淆:这里说的是 CPUIdle 的 governor。

Linux 里有两个都叫 "governor" 的东西:

  • CPUIdle governor(这篇讲的):决定空闲时进哪个 C-State,可选值通常是 menu / teo / ladder(虚拟化场景还会有 haltpoll),查看方式是:
    $ cat /sys/devices/system/cpu/cpuidle/available_governors
    menu teo
    $ cat /sys/devices/system/cpu/cpuidle/current_governor
    menu
    
    如果 teomenu 都编译进去了,默认还是 menu。governor 是按 rating 挑的,menu 是 20、teo 是 19。想换就写 current_governor,或者加 cpuidle.governor=teo
  • CPUFreq governor(调频子系统):决定运行时用哪个频率(P-State)。你看到的如果是 performance / powersave 两个选项,那看的就是它:
    $ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors
    performance powersave
    
    这是 intel_pstate 跑在 active 模式(HWP)下的典型表现:细粒度调频交给硬件自治,内核只留"性能优先"和"省电优先"两档。

两者的分工:cpufreq governor 决定 CPU 在工作时运行在哪个频率(P-State),cpuidle governor 决定 CPU 在空闲时进入哪个休眠状态(C-State)。idle= 参数只作用于后者。

  • Driver(机制层):真正执行硬件指令的那一层。x86 Intel 平台通常是 intel_idle,用驱动内置的 CPU 型号表去驱动 MWAIT,默认不理 ACPI 说什么,BIOS 里关掉的 C-State 也可能被它重新启用。AMD 和其他 x86 平台走通用的 acpi_idle,解析 ACPI 表,BIOS 关掉的状态就不会出现。KVM 半虚拟化场景还有 haltpoll

AMD 没有专属驱动,走的就是 acpi_idle

Intel 有 intel_idle,AMD 没有对应的东西。主线内核里不存在 amd_idle 这种 AMD 专用 cpuidle 驱动,drivers/idle/ 下从头到尾只有 intel_idle.c 一个文件。AMD 机器上的 idle 行为一律交给 acpi_idle,状态全部从 ACPI 表枚举。

这条路上踩过两个历史包袱,后来分别修掉了:

  • dummy wait。2002 年 ACPI support 刚进内核时留了一段"空读 I/O 端口"的延迟操作,用来兼容某些 STPCLK# 断言不及时的老芯片组。2022 年 AMD 的 Prateek Nayak 用 IBS 采样发现,Zen 上大量时间耗在这段空转里,还被误计成 C-State 驻留。驻留数字偏高会诱导 governor 选更深的状态,形成恶性循环。双路 Zen3 上跑 tbench(128 客户端),跳过 dummy wait 后最低吞吐从 2215 MB/s 提到 33016 MB/s,平均提升约 50%,几乎追平"直接把 C2 关掉"。随后 Dave Hansen 又发了个紧急 patch,把 dummy wait 限制成 Intel 专用。
  • 用 MWAIT 还是 HLT。没有 cpuidle 驱动可加载时(BIOS 里关了全局 C-State,或者内核压根没编译 cpuidle),x86 的兜底路径在 AMD 上原本一律用 HLT。AMD 的 Wyes Karny 在 6.0 前后改了这件事:Family 17h(Zen)及以后优先用 MWAIT C1,实测 Zen 3 上退出延迟降约 22%,上下文切换场景的唤醒延迟降约 45%。这也解释了为什么 idle=nomwait 在 AMD 上能把默认路径退回 HLT。

所以 AMD 这边不存在"驱动之争",只有 acpi_idle 一条路上打的补丁。

接力关系:intel_idle 初始化失败(CPU 不认识,或传了 idle= 覆盖默认行为)就退回 acpi_idleacpi_idle 因为 ACPI 信息缺失也起不来,才轮到架构默认的 default_idle(HLT 还是 MWAIT C1,看 CPU 和内核版本)。

3. C-State:idle 状态的硬件分级

Intel/AMD CPU 提供分级睡眠,越深越省电、醒得越慢:

状态含义典型退出延迟典型功耗影响
C0运行中满功耗
C1 (HLT)时钟门控,流水线停< 1 µs略降
C1E增强 C1,降压降频~1 µs进一步降低
C3L1/L2 缓存 flush,总线时钟停~数十 µs明显下降
C6核心电压近零,状态存入 SRAM~50–100+ µs核心几乎零功耗

C2、C4、C5 去哪了?

C-State 的编号不连续:

  • C1–C3 是 ACPI 规范定义的。真正意义上的硬件级 C2("Stop-Clock",通过 STPCLK 引脚在总线层面停时钟)确实已经死了。省电有限,实现复杂,现代平台不再做。

  • 但 "C2" 这个名字还活着,全新的机器上你可能就看得见。ACPI 只有 C1/C2/C3 三个类型档位,idle 状态凡是走 ACPI 表枚举出来的,sysfs 里的名字就是 C1_ACPI / C2_ACPI / C3_ACPI。实际睡多深由 _CST 里携带的 MWAIT hint 决定,很可能直达 C6/C10,ACPI 的 C2/C3 只是粗略分桶。最典型的触发场景是 CPU 型号太新,内核内置的 idle 状态表还没收录它。比如一台 2025 年的 Core Ultra 9 285H(Arrow Lake,model 197),在旧内核上看到的是:

    $ cat /sys/devices/system/cpu/cpu0/cpuidle/state*/name
    POLL
    C1_ACPI
    C2_ACPI
    C3_ACPI
    

    这说明 intel_idle 不认识这个型号,退回去用了 ACPI 枚举。等内核升到收录了该型号的版本,同一台机器可能变成 C1 / C6 / C10 这样的"原生"名字:状态名字跟着内核对 CPU 型号的认知走,硬件本身没变。

    acpi_idle 获取 C-State 信息的两个 ACPI 来源:

    • _CST(C States):挂在每个 CPU 的 ACPI 对象下的方法,现代平台的主路径。它逐个描述状态的三要素:进入方式(寄存器地址 + 地址空间类型,FFH 表示走 MWAIT,SystemIO 表示走 I/O 端口)、退出延迟、功耗。状态数量不限,由固件动态生成。前面说"MWAIT hint 决定实际深度",那个 hint 就藏在 _CST 返回包的 FFH 描述符里。
    • FADT(Fixed ACPI Description Table):老平台的兜底路径。表里只有 P_LVL2_LAT / P_LVL3_LAT 等几个固定字段,最多描述 C2、C3 两个状态,进入方式还写死为 I/O 端口(P_LVL2 / P_LVL3 寄存器)。表达能力远弱于 _CST,这正是老机器上 ACPI 枚举的状态又少又浅的原因。内核只在找不到 _CST 或者 _CST 无效时才退回来读 FADT。 所以一张状态表的生命力排序是:驱动内置表(intel_idle)> _CST > FADT,越靠后信息越粗略。
  • C4/C5 是移动平台过渡时期的产物。Pentium M / Core Duo 时代(2003–2006)Intel 试过 C4(Deeper Sleep,进一步降压)这类中间档位,C5 则停留在规范草案层面,基本没有量产实现。这些中间状态很快被淘汰,因为 C6(2006 年随 Core 2 引入)做到了"状态存入专用 SRAM + 核心电压归零",省电效果碾压式领先,中间的过渡档位就没意义了。

  • 编号是厂商各自扩展的,ACPI 只管 C1–C3。Intel 之后一路往后编:C7(LLC 可以 flush)、C8/C9/C10(更深、更多部件断电),还要区分核级 C-State(CCx)和封装级 C-State(PCx,整个 package 一起睡,要求所有核都睡了才能进)。AMD 有自己的 CC6/PC6。

  • 还有 C1E 这类带后缀的变体,它是 C1 的增强版:进入时顺便降压降频,层级上和 C1 同级。

所以一张现代服务器的状态列表里看到 POLL / C1 / C1E / C6,中间那些档位在硬件演进中从未被实现,或者早已被淘汰。

通过 sysfs 可以看到当前系统注册了哪些状态:

$ ls /sys/devices/system/cpu/cpu0/cpuidle/
state0/ state1/ state2/ state3/

$ cat /sys/devices/system/cpu/cpu0/cpuidle/state*/name
POLL
C1
C1E
C6

$ cat /sys/devices/system/cpu/cpu0/cpuidle/state2/latency
10        # 退出延迟,单位 µs

注意 state0/POLL 始终存在:它就是"什么都不做、原地轮询"的兜底状态,退出延迟为 0。

4. 核心:idle= 启动参数详解

x86 架构认三个合法取值(见 Documentation/admin-guide/kernel-parameters.rst),ARM64 另有三个。

4.1 idle=poll:彻底放弃节能,原地空转

idle=poll

行为:禁用 intel_idleacpi_idle 驱动,等于关掉整个 cpuidle 子系统。空闲 CPU 在一个紧凑循环里执行 cpu_relax()(x86 上就是 PAUSE 指令),不断轮询 TIF_NEED_RESCHED 标志,一旦有任务就绪立刻抢过去。

  • 优点:唤醒延迟最低(理论上零),性能计数器采样更"干净"(不会被 C-State 进出打断)。
  • 代价
    • CPU 功耗大幅上升,发热、风扇、电费全部拉满;
    • 在 Intel 平台上会挡住那些需要"package 内若干核空闲"才能达成的 P-State/Turbo 提频,单线程性能反而可能变差;
    • 和超线程配合很差,空转的 sibling 会抢共享的物理核资源;
    • 官方文档明确提示:在支持 MONITOR/MWAIT 的 CPU 上,idle=poll 相比默认 idle loop 没有性能优势。
  • 适用场景:短时间的基准测试对比、特定的 profiling 场景、调试 idle 问题时拿来当对照组。不建议生产使用。

4.2 idle=halt:只用 HLT,拒绝深睡

idle=halt

行为:同样禁用 intel_idle/acpi_idle,但空闲时执行 HLT 指令,CPU 挂起执行直到下一个中断到达。硬件上相当于只进 C1,C2/C3 及更深的状态永远用不上。

  • 优点:唤醒延迟稳定在 µs 级且可预期,没有深睡的"睡眠惯性";功耗仍显著低于 poll。
  • 代价:放弃了 C6 级别的节能;中断风暴时会频繁进出。
  • 适用场景:延迟敏感又不能接受 poll 功耗的场景,或者老平台上某些 C-State 有硬件 errata 时的规避手段。

4.3 idle=nomwait:禁用 MWAIT 指令

idle=nomwait

行为:三个选项里最精细的一个。它不禁用 cpuidle 框架本身,只禁止用 MONITOR/MWAIT 指令进入 idle 状态。具体效果分平台:

  • Intel 平台:intel_idle 初始化失败,退回 acpi_idle(前提是 ACPI 表信息完整),进入状态时改用 HLT
  • 各 C-State 的"名义层级"还在,但进入方式从 MWAIT 换成 HLT,硬件实际能睡多深受限。
  • 适用场景:某些 CPU errata 导致 MWAIT 不可靠(历史上多个 Atom/Xeon 型号中过招),或者虚拟化环境里 MWAIT 被 hypervisor 拦截出问题时的规避。

4.4 ARM64 的 idle= 参数

ARM64 上取值不同,思路相通:

  • idle=wfi(默认):执行 WFI 指令,等中断,进低功耗状态;
  • idle=yield:改用 YIELD 提示指令,不进低功耗状态,SMT 下把执行资源让给兄弟线程(可以看成 x86 poll 的耗电版);
  • idle=nop:不执行任何 idle 指令,给 WFI 行为异常的平台做规避。

5. 相关参数与运行时控制手段

配套的工具还有这些:

启动参数

参数作用
cpuidle.off=1整体关掉 cpuidle 管理(idle loop 照跑,走架构默认机制,通常很粗糙,不推荐生产)
cpuidle.governor=menu指定 governor
intel_idle.max_cstate=Nintel_idle 丢弃比 N 更深的状态;=0 直接禁用 intel_idle
processor.max_cstate=Nacpi_idle 同理(=0 等价于 =1
intel_idle.states_off=0x4位掩码,默认禁用指定状态(可事后经 sysfs 打开)
nohz=on/off动态时钟(dyntick idle)开关,影响空闲时的 tick 中断
rcu_nocbs=irqaffinity=配合隔离 CPU 使用,减少 idle 核被打扰

运行时控制(无需重启)

# 禁用某个 CPU 的某个深度状态(延迟敏感进程运行前常用)
echo 1 > /sys/devices/system/cpu/cpu3/cpuidle/state3/disable

# 通过 PM QoS 从进程维度声明"我能容忍的最大唤醒延迟"
# 打开 /dev/cpu_dma_latency 写入 0(µs),保持 fd 打开期间生效

后者更优雅:不用全局改内核参数,进程自己声明 QoS 需求,governor 选状态时会保证退出延迟不超过这个上限。tunedlatency-performance profile、real-time 内核的低延迟配置,底层都大量依赖这个接口。

6. 一次 Hygon 服务器性能回退背后的 ACPI C1 问题

6.1 现象与定位

事情是同事先发现的:一台 Hygon 服务器换上 6.6 内核后,I/O 性能掉了约 30%。回退到 5.x 内核,性能恢复。硬件没动,配置没动,变量只有内核版本,问题显然出在内核里。

在 6.6 上做 bisect,配合 A/B 测试,性能变化被定位到一个提交:

9e9b893404d4
ACPI: processor: idle:
Return an error if both P_LVL{2,3} idle states are invalid

Revert 掉重新构建内核,性能恢复。受影响机器上的 acpi_idle 没注册成功,idle driver 显示 none

这个提交怎么就掐掉了 idle 驱动,得从 acpi_idle 的启动流程说起(细节见第 3 节):

  1. 先查每个 CPU 的 ACPI _CST 对象,从里面枚举可用的 C-State;
  2. 查不到 _CST,就退到更老的 FADT 路径:读 P_BLK 寄存器块和 FADT 里的 C2/C3 latency 字段,能凑出多少状态算多少;
  3. 固件连一个深状态都给不出来,只要 P_BLK 还在,内核可以给 CPU 建一个"默认 HLT C1",至少保证 idle 时有个像样的休眠入口。这条路的开关是 Linux 5.8 的 496121c02127Allow probing on platforms with one ACPI C-state)。在那之前,"只提供了一个 C-State"本身就是失败条件:power.count 要求至少 2,而且只有存在 C2/C3 时才置 flags.power,只有 C1 的平台干脆不让 acpi_idle 注册。那个提交的动机其实更偏 _CST 侧,是"BIOS 关掉深 C-State 时 _CST 只返回一个状态",但它顺手把 FADT 路径的同一条限制也放宽了,本案走的正是后半段;
  4. 以上任何一步走不通,acpi_idle 就注册失败,/sys/devices/system/cpu/cpuidle/current_driver 显示 none

driver 显示 none 时 idle loop 仍在跑,只是落回架构默认路径,CPU 照样会执行休眠指令。有没有驱动的行为差异会反映到具体 workload 的性能上。

对照上面的流程,问题收敛成一个具体的点:这台机器的 FADT 路径在第 2 步和第 3 步之间被掐断了。

6.2 根因:一次为虚拟机设计的检查,误伤了裸机

这个提交针对的是 VMware ESXi 一类虚拟机:这类虚拟机通常没有 _CST,FADT 里 C2 latency 填 101 µs、C3 latency 填 1001 µs。这两个数字看着像实测延迟,其实不是。ACPI 规范约定,C2 latency 超过 100 µs、C3 latency 超过 1000 µs 就表示该状态不可用,101 和 1001 正好是刚过线的最小整数,固件在用它们说"C2、C3 我没有"。上游认为这种机器不该由内核自建默认 C1 并加载 acpi_idle,于是加了拒绝检查:

if (!pr->power.states[ACPI_STATE_C2].address &&
    !pr->power.states[ACPI_STATE_C3].address)
    return -ENODEV;

提交进入 Linux 6.15,并回移到了 6.6.87。对比源码可以确认 6.6.86 还没有这段逻辑。这就解释了版本现象:5.x 内核一切正常,6.6(含该回移)开始回退。

这台 Hygon 的固件信息和上游要处理的虚拟机"撞脸"了:

  • DSDT/SSDT 中没有 _CST
  • Processor P_BLK0x810,长度 6;
  • FADT 里 C2 latency 为 101 µs,C3 latency 为 1001 µs,和虚拟机一模一样的"不支持"声明。

这不能说明 BIOS 填错了。_CST 在 ACPI 里是可选对象,固件可以不提供;FADT/P_BLK 虽然老,内核一直当兼容路径支持。从启动日志看,这台机器所有 CPU 走的都是 FADT discovery,于是同样撞上了那个拒绝检查。失败链是这样:

  1. _CST 不可用,退到 FADT 路径;
  2. C2/C3 latency 超限(101 / 1001),对应地址被清零;
  3. 新加的拒绝检查命中,FADT 返回 -ENODEV
  4. 提前退出,默认 HLT C1 没有建立;
  5. acpi_idle 注册失败,driver 显示 none

把 C2/C3 标记为无效是对的,固件明确说了不支持。真正值得讨论的是该不该因此连默认 C1 也一起拒绝。对虚拟机,上游的答案是可以拒绝;对这台裸机,拒绝等于让 idle 行为整个变了样,30% 的 I/O 回退正是由此而来。这个判断只看了"固件长什么样",没看"它运行在什么平台上",于是把两类机器一起处理了。

6.3 从"进不了 C1"到"I/O 慢 30%"

就算 acpi_idle 没注册,idle loop 不还跑着吗?CPU 不还是会 HLT 吗?为什么会慢 30%?

  • 5.x 内核acpi_idle 注册成功,idle 走 cpuidle 框架,状态是 POLL + 默认 C1;
  • 6.6 内核:注册失败,idle 走 do_idle() 里的架构默认路径(default_idle),没有框架、没有状态。这条路径依然会执行休眠指令让核停下来,CPU 并不原地空转。

两条路径都能让 CPU 停止执行,但进入方式和退出延迟不同。落到我们的 workload 上,主导因素大概率是频率。

我们的 I/O 由几个 polling reactor 核处理,这几个核 100% 占满、从不进入 idle,能跑多快取决于 package 的功耗与温度预算还剩多少。其他核空闲时进入 C1 做时钟门控,省下的功耗预算可以让 polling 核提到更高频率,第 4 节提到 idle=poll 会抑制 Turbo,就是这条链路的反面。注册失败后空闲核进不了 C1,这部分功耗省不下来,polling 核的可用频率被压低,IOPS 随之下降。

6.4 修复:只为目标裸机保留 C1 fallback

修复加在 FADT 最后的拒绝条件里:

if (!acpi_processor_allow_c1_fallback() &&
    !pr->power.states[ACPI_STATE_C2].address &&
    !pr->power.states[ACPI_STATE_C3].address)
    return -ENODEV;

放行条件限定为裸机 Hygon,原来的边界全部保留:_CST → FADT 的顺序不变;C2/C3 仍然无效、不会被重新启用;缺 P_BLK 等前置错误照旧返回;虚拟机和非 Hygon 平台保持拒绝行为;目标裸机则可以继续建立默认 HLT C1。

修复后的运行状态:

current_driver: acpi_idle
max_cstate: 8
states: POLL C1

128 个 CPU 都只有这两个状态,C1 使用计数持续增长。max_cstate=8 是上限,系统不一定真的提供更深的状态。这台机器的 C2/C3 已被固件标记为不支持,实际只会得到默认 C1。

7. 虚拟化场景:idle 行为遇到 hypervisor 之后

7.1 Guest 内的 idle 选择:haltpoll 与 CPUID 的门控作用

直觉上,guest 检测到自己在虚拟机里就该老老实实用 hlt 让核。Linux 并没有这个逻辑,实际机制是两层:

第一层:检测到 KVM 后,切换的是 cpuidle 驱动(haltpoll)

  • 内核通过 kvm_para_available()(CPUID 里的 KVM 准虚拟化签名)发现自己跑在 KVM 上,且编译了 CONFIG_HALTPOLL_CPUIDLE(x86 默认 y)时,自动注册 haltpoll driver + governor
  • haltpoll 的行为:idle 后先在 guest 内自旋一小段时间(guest_halt_poll_ns,上限默认 200µs),没等到唤醒事件才执行 hlt,是"快路径免 VM exit、慢路径再让核"的折中;
  • upstream 默认还要求宿主机通过 KVM_HINTS_REALTIME(libvirt 的 hint-dedicated)声明 vCPU 独占物理核才启用,否则要用 cpuidle_haltpoll.force=Y 强开。内核社区认为"guest 内自旋"只适合独占核场景。

第二层:idle 指令本身(hlt 还是 mwait)由 CPUID 决定,与"是不是 guest"无关

KVM 默认不把 MONITOR/MWAIT 通告给 guest。CPUID leaf 5 在 KVM 内部直接返回全零,MWAIT 特性位被标成"已知但故意不暴露"。源码注释说得直白:

MONITOR (and MWAIT) are emulated as NOP, but not advertised to guests via CPUID!

道理也简单:KVM 把 MWAIT 模拟成 NOP,忠实模拟做不到;让 guest 原生执行,又等于把"pCPU 睡多深"的决定权交给了 guest。所以默认不给。

于是 guest 里到底跑 hlt 还是 mwait,取决于 VMM 有没有显式打开

  • 默认 QEMU/KVM 下 guest 拿不到这个 flag,intel_idle 根本不会用 MWAIT,走 hlt;
  • 要暴露它,宿主机侧得开 per-VM capability(KVM_CAP_X86_DISABLE_EXITSKVM_X86_DISABLE_EXITS_MWAIT)。而这个 capability 一开,拦截也一并关掉了。在 KVM 里,"暴露 mwait"和"直通 mwait"是同一件事,没有"只让 guest 看见但不让它真跑"这个中间态。
  • 公有云上常见的是宿主机想压低独占核的唤醒延迟,主动开了直通,这时 guest 照常枚举 C-State,日常就在用 mwait。mwait-sched 论文研究的就是这类环境。

想确认自己手上的实例属于哪种,guest 里 grep mwait /proc/cpuinfo 最直接。

7.2 KVM 对 hlt / mwait 的处理:三种模式

Guest 执行KVM 默认行为调度后果
hlt无条件 VM exit → kvm_vcpu_halt 挂起 vCPU 线程(挂起前 host 侧会先做 halt polling,halt_poll_ns显式让核,pCPU 可以分给别人,这是超卖下的"合作"路径
monitor/mwait默认拦截并模拟为 NOPKVM_X86_QUIRK_MWAIT_NEVER_UD_FAULTS 默认开)guest 的 idle 循环退化成自旋,vCPU 线程永不阻塞、持续烧 pCPU
mwait(直通)配置 KVM_X86_DISABLE_EXITS_MWAIT 后不拦截,原生执行零 exit、裸机级唤醒延迟,但空闲对 host 调度器完全不可见

于是出现了责任真空:guest 用什么 idle 指令,由 VMM 给的 CPUID 说了算;而 KVM 这边,没暴露 mwait 时它把 guest 盲目执行的 mwait 当 NOP 抹掉,暴露了(等于直通)它又彻底看不见空闲。两条路都不让核,这正是 mwait-sched 要补的窟窿。

7.3 生产观察:mwait-sched 论文

上海交大与阿里云的这篇论文系统研究了这个问题:

Yun Wang, Xingguo Jia, Ben Luo, Kenan Liu, Shengdong Dai, et al. What Are You (M)Waiting For: The Hidden Cost of Idle in the Hyperscale Cloud. OSDI '26 (20th USENIX Symposium on Operating Systems Design and Implementation), Shanghai Jiao Tong University & Alibaba Cloud. 论文页面:https://www.usenix.org/conference/osdi26/presentation/wang-yun

几个关键观察:

  1. 1:1 独占场景,mwait 直通很合适:guest 直接驱动硬件 idle 转换,消除 idle 引起的 VM exit,延迟接近裸机。
  2. 超卖场景,同一个机制直接崩坏。裸机上 mwait 是真正的阻塞原语(进 C-State、放弃执行权),但硬件级的阻塞无法被 hypervisor 观测。vCPU 从不让出 pCPU,空闲 vCPU 霸占核心,抬高整机的争抢、steal time、热迁移次数和 SLO 告警。受控实验复现:哪怕只是一个执行 mwait 的空闲 vCPU,也能把同核邻居的尾延迟拉高到 3 倍
  3. 利用率与干扰是脱钩的。论文分析了三个生产 region(共约 320 万 pCPU):平均利用率很低,但超卖比例被死死压在约 1%。卡住它的不是 CPU 数量,再多卖就会触发争抢、违反延迟 SLO。按这个体量算,超卖比例每提高 1 个百分点,就多出约 3.2 万个可售 vCPU。
  4. 修复思路(mwait-sched):由 hypervisor 单方面补课,guest 不用改。三招:确定性定时器模拟(定时夺回控制权,区分 CPU 忙与 idle)、细粒度 idle 区间分类(mwait 空闲时长呈双峰分布,只聚合"稳定空闲"的 vCPU)、多地址 mwait-proxy(hypervisor 侧维护被监控地址链表,每次进入 hypervisor 时扫描,免去每 vCPU 定时器)。
  5. 效果:九个代表性负载 P99 延迟降 30–50%、steal ratio 降 30–40%;生产环境高争抢 steal 事件减少 80%+、日均热迁移降 30–50%、超卖比例从约 1% 提到 20.3%,相当于新增约 60 万个可售 vCPU

同一个机制在两种环境下结果相反:1:1 独占时消除 VM exit、延迟接近裸机,超卖时放大争抢、把邻居尾延迟推高到 3 倍。guest 里看到的 steal timetop 里的 st),来源也可能在这条链路上,而不一定在 guest 自己的负载里。

8. 延迟 vs 功耗:怎么选

一个简化的决策思路:

  1. 默认就好:绝大多数场景下,intel_idle + menu governor 的预测已经相当准,深睡带来的 Turbo 预算收益甚至能反哺性能。
  2. 延迟敏感业务:先用 /dev/cpu_dma_latency 或 sysfs disable 做定点限制,而不是全局 idle=poll;再配合 isolcpus + nohz_full + rcu_nocbs 做核隔离。
  3. 基准测试 / profilingidle=poll 可以消除 C-State 进出对性能计数器的干扰,但记得测完改回来。
  4. 怀疑硬件 errata:表现为随机卡顿、唤醒失败时,试 idle=nomwait 或收紧 intel_idle.max_cstate

观测工具

# 看每个核实际在各 C-State 的驻留比例(最直观)
turbostat --interval 5

# 看 idle 统计与唤醒源
powertop

# 看每个状态的命中次数和累计驻留时长
cat /sys/devices/system/cpu/cpu0/cpuidle/state*/usage
cat /sys/devices/system/cpu/cpu0/cpuidle/state*/time

# 直接读硬件的 C-State 驻留计数器(Intel,名字以 perf list 为准)
perf stat -a -e 'power/cstate_core/c6-residency/' -- sleep 5

# 跟踪 idle 进出与唤醒延迟
perf sched record -- sleep 5

如果 turbostat 显示 CPU% 很低但 C6 驻留率接近 0,说明有东西在频繁唤醒(定时器、中断、用户态轮询),这比调整 idle 参数更值得先查。

9. 总结

睡太死也不行,不睡觉也不行,适度睡觉对身体好,身体是革命的本钱


参考

内核文档

上游提交

  • 496121c02127 — ACPI: processor: idle: Allow probing on platforms with one ACPI C-state(Linux 5.8)
  • 9e9b893404d4 — ACPI: processor: idle: Return an error if both P_LVL{2,3} idle states are invalid(Linux 6.15,回移 6.6.87)

邮件列表讨论(前两条 AMD 改动的出处)

论文

相关阅读

文章gperftools 什么时候把内存还给操作系统MemoFedora 44 的 RXE 无法注册 1 GiB MRMemo修正 ZFS 主机的 Node Exporter 内存告警

讨论

邮箱用于身份识别和回复通知,并由 Waline 存储。