virt-report虚拟化社区动态

KVM vCPU 的阻塞、轮询与唤醒

本文由 OpenAI Codex (GPT-6-Astra) 辅助编写。

一、空闲 vCPU 的轮询与阻塞

客户机没有工作可做时,空闲循环可以执行 HLT,等待中断。KVM 处理这次退出时,可以短暂轮询即将到来的事件,也可以让承载 vCPU 的宿主线程阻塞。轮询消耗 CPU 时间,但可能减少睡眠与重新调度的延迟;阻塞释放 CPU,唤醒后则需等待调度。halt polling 通过有限的轮询预算在两者之间调整。

可以先比较两个极端:立即睡眠能尽快归还 CPU,但很快到来的事件也要付出唤醒与调度成本;一直轮询能及时发现事件,却会在长时间空闲时占着 CPU。KVM 在两者之间设置有限预算,并按此前等待的耗时调整下一轮。下文沿一次完成事件跟踪轮询、阻塞和重新运行,最后比较实际收益。

本文用一次网络请求等待完成事件为例,跟踪 x86、内核 LAPIC、HLT 被截获后的路径。客户机 HLT、宿主线程睡眠和整台虚拟机暂停是不同状态;硬件直接处理 HLT、posted interrupt 与客户机内部 haltpoll 驱动有各自的路径。

HLT 由客户机内核的空闲代码执行;本文的轮询与阻塞代码运行在宿主 KVM 中,承载它的仍是该 QEMU vCPU 线程。线程真正休眠后,唤醒和 CPU 分配由宿主 Linux 调度器处理。客户机应用不直接调用这些 KVM 等待函数。

源码版本:Linux 6.18、QEMU 10.2.0。

二、一个例子:请求完成前等待 30 µs

客户机已经提交网络请求,没有其他可运行任务,空闲循环执行 HLT。假设后端的完成事件在 30 µs 后到达;当前 vCPU 轮询预算为 50 µs。KVM 在轮询中观察到事件,直接回到运行循环,不必先把宿主线程挂起、再等待它重新取得 CPU。

事件到达时间KVM 的处理收益与成本
30 µs,落在 50 µs 窗口内轮询观察到事件,结束等待避免一次睡眠往返,同时占用约 30 µs CPU
120 µs,超过 50 µs 窗口先轮询,随后准备等待、休眠;完成事件唤醒线程这轮轮询未避免睡眠,已付出约 50 µs CPU 成本
等待很久,例如 10 ms到预算即停止轮询,把 CPU 交给其他任务有界等待限制空转成本;预算应随这种负载缩小

这些时间是教学输入,忽略退出、检查和注入开销。后面的交互还单列“准备休眠”和“已唤醒、等待调度”两段:完成事件已经到达,线程仍可能因宿主繁忙而晚些运行。增大轮询预算只能覆盖一部分短等待,无法消除后端处理时间,也不能保证获得物理 CPU。

接下来依次解释三个动作:如何识别可以继续运行的事件,如何安全入睡与唤醒,以及如何判断轮询预算是否值得保留。

三、进入等待:HLT 处理与有限轮询

1. 从 HLT 退出到 KVM 等待路径

VMX 的 HLT 退出处理调用 kvm_emulate_halt(),跳过已模拟的指令。内核管理 LAPIC 时,__kvm_emulate_halt() 检查已有事件:已经可以继续运行,就保留 RUNNABLE;否则记录 HALTED。运行循环随后进入 vcpu_block(),由它调用通用的 kvm_vcpu_halt()。VMX 分发表、HLT 模拟

这条路径通常留在 KVM 内核中。KVM ABI 也保留 KVM_EXIT_HLT,QEMU 的 kvm_handle_halt() 处理相应用户态返回;不过 QEMU 8.0 起已移除普通含 LAPIC 的 x86 KVM 虚拟机的用户态 LAPIC 模式。本文 QEMU 10.2 的常见 on 与 split irqchip 配置都由 KVM 管理 LAPIC,应按前面的内核等待路径分析。HLT 返回分支、用户态 LAPIC 模式移除说明

2. 轮询预算与阻塞条件

轮询期间反复执行 kvm_vcpu_check_block():检查架构可运行条件、待处理定时器、宿主信号与 UNBLOCK 请求。cpu_relax() 之间还检查期限、need_resched() 和当前 CPU 是否只有一个可运行任务;有调度需要便提前结束轮询。预算为零时直接进入阻塞流程。事件检查、轮询循环、轮询继续条件

四、事件到达:从休眠、唤醒到重新运行

HLT、轮询与事件唤醒
内核 LAPIC 路径:HLT 后先检查已有事件,再短暂轮询;轮询结束后发布等待者并再次检查,仍无事件才休眠。事件发布后唤醒宿主线程,线程取得 CPU 后继续处理客户机事件。
内核 LAPIC 路径:HLT 后先检查已有事件,再短暂轮询;轮询结束后发布等待者并再次检查,仍无事件才休眠。事件发布后唤醒宿主线程,线程取得 CPU 后继续处理客户机事件。 打开原图
Mermaid 源码
flowchart TD
    accTitle: KVM vCPU 的 HLT、轮询与事件唤醒
    accDescr: x86 内核 LAPIC 路径。HLT 模拟先检查已有事件。没有事件时可短暂轮询,然后发布 rcuwait 等待者、设置任务状态并重新检查。仍无事件才休眠。事件发布与唤醒配合,避免轮询到休眠之间丢失唤醒。线程重新取得 CPU 后继续检查并处理客户机事件。
    H[HLT 被截获<br/>kvm_emulate_halt] --> E{已有可运行事件?}
    E -->|是| R[返回架构运行循环<br/>复查事件与 mp_state]
    E -->|否| P[记录 HALTED<br/>kvm_vcpu_halt 短暂轮询]
    P -->|观察到事件| R
    P -->|预算耗尽或需调度| W[发布 rcuwait 等待者<br/>设置 TASK_INTERRUPTIBLE]
    W --> C{再次检查<br/>可以停止等待?}
    C -->|是| R
    C -->|否| S[schedule<br/>宿主线程休眠]
    I[中断或定时器事件<br/>先发布 pending / request] --> K[kvm_vcpu_kick<br/>唤醒等待者]
    I -.-> C
    K --> Q[线程可运行<br/>等待宿主调度]
    S --> Q
    Q --> C
    classDef active fill:#e9f4ef,stroke:#3b7c65,color:#203d31
    classDef wait fill:#f5efe2,stroke:#9d8152,color:#514328
    classDef event fill:#eaf0f9,stroke:#6684ab,color:#263c56
    class H,P,R active
    class W,C,S,Q wait
    class I,K event

kvm_vcpu_block() 先发布 rcuwait 等待者,把当前任务设为 TASK_INTERRUPTIBLE,再检查一次阻塞条件。只有条件仍不满足才调用 schedule();返回后循环复查,最后撤销等待者。一次唤醒可能只是宿主信号或维护请求,x86 路径还会判断 vCPU 是否真正可运行,不能无条件把 mp_state 改回 RUNNABLE。阻塞循环、架构侧复查

以 VMX 的普通中断软件回退路径为例,vmx_deliver_interrupt() 先记录 LAPIC IRR,再设置 EVENT 请求并调用 kvm_vcpu_kick()。已经睡眠的线程经 rcuwait 唤醒;正在客户机模式执行的 vCPU 则可能需要 IPI 促使其退出。线程得到唤醒后只具备被调度的条件,何时取得 CPU 还受宿主负载影响。中断交付、wake 与 kick

待处理的可屏蔽中断还要满足架构允许条件;“有中断记录”不保证立即注入。定时器也有独立入口:常规软件 LAPIC 定时器增加 pending 并请求 UNBLOCK。x86 在阻塞前把仅在客户机运行时计时的硬件定时器切换为软件定时器,避免退出客户机模式后失去唤醒来源。事件资格与定时器切换、定时器到期

五、正确性:阻塞前的事件复查与唤醒

如果只在轮询结束时检查一次,然后直接睡眠,事件可能在两步之间到达:发送方没有看到等待者,接收方又错过事件,造成丢失唤醒。KVM 的等待顺序是“发布等待者 → 设置任务状态 → 再检查条件 → schedule”。事件先到达,复查会阻止休眠;事件较晚到达,唤醒端能找到已发布的任务。

这个顺序还需要内存屏障。set_current_state() 与 rcuwait_wake_up() 中的屏障配合,约束等待者发布与事件条件观察的次序。若事件发生在最终检查之后、schedule 之前,唤醒会把任务恢复为可运行状态,后续调度不会让它因为这次等待永久睡下。它仍可能因其他任务而让出 CPU。等待端、唤醒端与屏障配对说明

源码统计也保留了这一区别:轮询循环已经结束,但在阻塞前复查命中事件、没有调用 schedule,仍可计为成功轮询。因此分析 trace 时,应同时看 poll、wait 和唤醒事件,不能仅凭一次成功计数推断事件一定在预算内到达。成功条件

六、等待状态:客户机活动状态与宿主任务状态

上例中,“客户机执行到 HLT”和“承载它的宿主线程进入睡眠”之间还有轮询阶段。KVM 分别保存客户机状态、等待者和预算,才能在不睡眠的情况下发现短期事件,也能在真正睡眠后找到需要唤醒的任务。

对象与字段用途
kvm_vcpu.arch.mp_state客户机的活动状态,如 HALTED、RUNNABLE;不等同于宿主任务状态
kvm_vcpu.halt_poll_ns这个 vCPU 当前学习到的轮询预算,单位纳秒
kvm_vcpu.wait(rcuwait)发布等待者任务指针,供唤醒端找到正在阻塞的线程
kvm_vcpu.requestsKVM 内部请求位图;EVENT、UNBLOCK 等请求有各自语义
kvm.max_halt_poll_ns、override_halt_poll_ns虚拟机显式覆盖的轮询上限;否则采用模块参数

通用字段见 kvm_vcpu,架构字段见 LAPIC 与 mp_state;上限选择见 kvm_vcpu_max_halt_poll_ns()。中断向量由 LAPIC 的待处理中断状态保存;requests 只表达需要处理的工作,不能把它当成中断向量队列。

七、自适应预算:短等待增长,长等待缩小

轮询预算按 vCPU 保存,上限默认取 halt_poll_ns;用户态可用 KVM_CAP_HALT_POLL 为整台虚拟机覆盖上限。一次 halt 返回后,KVM 用包含轮询、阻塞和重新调度在内的总耗时调整下一轮预算。它观察过去的等待,不能预知下一次中断。

1. 观测结果对下一轮轮询的影响

设当前预算 50 µs、上限 200 µs:事件在 30 µs 到达并被轮询捕获,预算保持 50 µs。若总等待为 160 µs,超过当前预算但低于上限,默认增长系数为 2,下一轮预算可增至 100 µs;若总等待达到 1 ms,默认缩小为 25 µs。若总耗时恰等于上限,则不进入增长或缩小分支。

2. 源码中的边界条件

源码用严格不等式区分增长和缩小;总耗时恰等于上限时,两者均不触发。本文 x86 基线把唤醒分类视为有效,其他架构还可根据无效唤醒缩小窗口。复核边界时看调整条件与唤醒分类即可;判断业务收益仍需同时比较 CPU 时间与请求延迟。

6.18 的增长系数与缩小除数默认均为 2,增长起点为 10000 ns。增长取“乘积与起点中的较大值”;缩小后低于起点便归零,除数为零也直接归零。增长结果可以暂时超过上限,下一次进入 halt 时再裁剪。长期空闲与密集短等待交替时,预算会反复调整;需要结合负载观测,不能把更大的窗口当成通用优化。默认参数、增长与缩小

八、交互验证:改变到达时间、预算与调度等待

事件到达与轮询窗口

从进入 halt 路径的 0 µs 开始,改变事件到达时刻和本轮预算,观察是否休眠,以及下一轮有效预算。

默认示例:预算 50 µs,事件在 30 µs 到达,轮询就能观察到事件,不进入睡眠。若事件改在 120 µs 到达,模型在 60 µs 完成等待准备并休眠;唤醒后再等待自选的 40 µs,至 160 µs 返回。启用 JavaScript 可调整时序。

教学设定:轮询上限 200 µs,等待准备固定 10 µs,增长起点 10 µs、增长和缩小系数为 2。准备阶段仅在末尾检查事件;事件等于轮询边界时归入准备阶段。假设事件可使 vCPU 继续运行,无宿主抢占、信号和中断注入成本;“返回”指本次 halt 返回,不表示客户机指令已经执行。图中时间和比例不是性能测量。

九、效果评价:降低了多少延迟,消耗了多少 CPU

比较同一业务负载下的三组结果:关闭轮询、较小窗口、较大窗口,保持 vCPU 绑定、后端线程布局、并发和宿主负载一致。每组都观察稳定运行阶段,同时保留短突发与空闲阶段;只测高负载吞吐容易掩盖空闲轮询成本。

现象核对的证据可尝试的方向
短请求 p99 延迟降低成功轮询增加、睡眠往返减少;同时核对 vCPU CPU 时间若 CPU / 功耗成本在目标内,保留能取得收益的较小窗口
CPU 增多,业务延迟基本不变halt_poll_fail_ns 增长,或成功轮询耗时很高但未改善业务减小上限或关闭轮询,确认后端实际完成时间
完成事件已到,vCPU 仍迟迟不运行将 sched_wakeup 与 sched_switch 对齐,检查 runnable 等待检查超分、CPU 绑定、其他任务争用;单纯增大窗口无法解决调度拥塞
事件本身到达得晚后端队列延迟、设备处理时间、定时器或中断合并策略先缩短事件产生路径;KVM 轮询不能提前完成尚未做完的 I/O

KVM 的 halt_poll_success_ns、halt_poll_fail_ns、halt_wait_ns 及直方图给出等待路径的分解;结合 kvm_vcpu_wakeup trace 与业务延迟,才能区分节省的调度时间和新增的空转。统计中的“成功”包括准备休眠时复查命中、实际没有 schedule 的情形。统计字段

成本可以先做量纲检查:若每秒有 10000 次等待,每次白耗 50 µs,累计便是 0.5 秒 CPU 时间,约半个 CPU 的处理时间。多 vCPU 还会累加。这个估算不含真实调度与提前退出,适合检查预算数量级,不代替实测。轮询的合理目标是以可接受的 CPU 成本满足延迟要求。

1. 用调度事件核对一次唤醒

在宿主连接目标 QEMU 的 QMP,执行 query-cpus-fast,取得对应 vCPU 返回项顶层的 thread-id。下面命令同样在宿主执行,以线程 31415 为例,记录十秒内的唤醒、切入和 KVM 事件。运行前用 perf list 确认该内核提供这些 tracepoint;采集需要相应权限,也会增加跟踪开销。

TID=31415
sudo perf record -a -o halt.data \
  -e sched:sched_wakeup --filter "pid == $TID" \
  -e sched:sched_switch --filter "prev_pid == $TID || next_pid == $TID" \
  -e kvm:kvm_vcpu_wakeup --filter "common_pid == $TID" \
  -e kvm:kvm_entry --filter "common_pid == $TID" \
  -- sleep 10
sudo perf script -i halt.data

使用全 CPU 采集并按事件字段筛选,是因为唤醒者可能运行在另一个线程、另一个 CPU 上;只按当前执行线程采样容易遗漏它。每次采集使用新的输出文件。以下时间只用于演示读法,并非性能实测:

相对时刻事件对应的运行状态
0 µssched_wakeup,pid = 31415目标 vCPU 线程变为可运行
40 µssched_switch,next_pid = 31415线程取得宿主 CPU;本次 runnable 等待约 40 µs
43 µskvm_vcpu_wakeupKVM 的 halt 处理接近结束;这是返回侧记录,并非设备事件刚到达的时刻
48 µskvm_entry执行即将再次进入客户机;随后才是客户机中的中断处理与业务执行

计算差值时需对齐同一次等待,并检查丢失事件和中途再次调度。轮询命中时,线程可能从未睡眠,自然没有对应的一对 sched_wakeup/sched_switch。kvm_vcpu_wakeup 在 kvm_vcpu_halt() 末尾发出,包含总时长与是否等待等信息;硬件中断到达则是更早的另一观测点。等待统计与 trace 发出位置

分析后端完成到客户机处理之间的延迟,还需加入后端完成和客户机处理的观测点。宿主 kvm_entry 并不证明客户机已经运行到中断处理函数,更不代表业务请求已完成。

2. 沿源码复核一次等待

顺序源码入口主要职责
处理 HLTkvm_emulate_halt() → vcpu_block()检查可运行事件,决定进入等待
轮询与预算kvm_vcpu_halt()控制轮询时长、停止条件与下一轮预算
真正阻塞kvm_vcpu_block()发布等待者、复查条件并调用 schedule
发布事件与唤醒vmx_deliver_interrupt() → kvm_vcpu_kick()先发布事件,再唤醒等待者或促使客户机退出

十、演进与局限:从固定窗口到按 vCPU 调整

2015 年引入 halt_poll_ns 的提交针对短等待后的调度往返,在休眠前增加可配置轮询。空闲 vCPU 同样会消耗这段时间,后来采用每 vCPU 的自适应预算;当前算法把短等待留下的经验用于下一轮,又对长等待缩小窗口。

2020 年增加的 KVM_CAP_HALT_POLL允许不同虚拟机选择不同上限;随后启用默认缩小除数的提交把缩小行为从直接清零改为默认减半,仍保留低于增长起点时归零。参数选择始终涉及延迟、宿主 CPU 占用及功耗的取舍。

如今 kvm_vcpu_halt() 负责轮询与预算调整,kvm_vcpu_block() 单独负责等待循环。两者共用事件检查,轮询结束后仍会在入睡前重查条件。两个入口的实现

自适应算法减少了人工选择固定窗口的负担,但它使用过去的总等待预测下一轮;业务突变、后端批处理和宿主调度拥塞都会影响判断。进一步优化应根据证据选择方向:短且稳定的等待关注轮询窗口,长等待关注事件源,已唤醒后的延迟关注调度。客户机 haltpoll、硬件中断加速等机制还改变了等待或通知发生的位置,评估时应把它们与 KVM 的宿主侧轮询分开。

十一、参考资料