1. KVM 的 Guest Enter / Guest Exit 到底在干嘛
先说结论:KVM 把一台虚拟机跑起来,靠的就是让物理 CPU 在两个模式之间反复横跳——从宿主机所在的内核态跳进客户机的执行环境,这就是 Guest Enter;客户机里某个敏感动作触发硬件拦截,CPU 再跳回宿主机内核,把控制权交给 KVM 模块处理,这就是 Guest Exit。这一进一出,是整个 CPU 虚拟化最核心、也最容易被低估的两条路径。
以前很多人学习 KVM,第一眼看到的是 qemu 命令行、libvirt 的 XML 配置、virsh 管理,觉得虚拟化就是“进程 + 设备模拟”。等真正开始看性能问题、排查虚拟机 CPU 占用异常、优化大量虚机在宿主机上的密度时,才会被迫去面对 Guest Enter / Guest Exit。因为所有虚拟化开销,本质上都体现在这两条路径上:guest 每退出一次,CPU 管线要被冲刷一次,关键寄存器要保存恢复,KVM 软件还要根据退出原因做一轮分发和处理。退出频率太高,整个系统的性能就直接塌方。
这篇文章适合三类人:正在做云平台底层性能调优的运维工程师,想把 KVM 源码读明白的虚拟化开发,以及刚接触虚拟化、想从原理层面搞清楚“虚拟机为什么比物理机慢”的学生或转行开发者。我会把从硬件机制、内核代码路径到性能观测、真实排查的一整套流程都拆开讲,尽量用大家都能听明白的方式。
2. 从整体设计看为什么 KVM 必须要“进出”
2.1 隔离与控制的核心矛盾
虚拟机本质上是让同一个物理 CPU 跑多套操作系统。但操作系统天生认为自己拥有整台机器,它要写控制寄存器、要改页表、要关中断、要读写设备。如果放任 guest 里的系统调用直接访问硬件,那它分分钟就能把宿主机搞挂;如果所有指令都拦截下来交给软件模拟,性能又完全不可接受。
于是硬件厂商想出了“两层运行模式”的方案:CPU 增加一个非根模式,客户机就运行在这个模式里。大部分普通指令(加减乘除、访存、算数逻辑)在非根模式下照常全速运行,不产生额外开销;只有敏感指令和异常、中断到达时,CPU 才自动切回根模式,把控制权交给虚拟机监视器。这就是 Guest Enter 和 Guest Exit 出现的根本原因——它是“高性能”和“强隔离”这对矛盾的硬件解法。
你可能会有疑问:操作系统不是分 Ring 0 和 Ring 3 吗?为什么不能直接在 Ring 1 跑虚拟机?这是早期 x86 虚拟化的经典思路,但它有一个绕不开的坑:x86 里有些指令在 Ring 0 才能执行,硬件却没有办法区分“这条指令来自虚拟机的内核还是来自宿主机内核”。一旦 guest 内核在非特权模式执行特权指令,CPU 只能直接把控制权交给宿主机,而宿主机的处理代码为了搞清楚发生了什么,往往需要做一堆软件模拟和地址翻译,代价昂贵且容易出错。硬件虚拟化扩展出现后,这套模型被彻底替换成了“根模式 / 非根模式”的二元设计。
2.2 一进一出背后的完整脉络
Guest Enter 本质上不是一条命令就能搞定的。它是一条由软硬件协作完成的路径:
宿主机 KVM 内核线程(也就是 vCPU 线程)准备好各种状态之后,通过一条特殊指令把 CPU 切换到非根模式。硬件会从一块被称为 VMCS 的内存区域里装载 guest 的关键处理器状态,包括寄存器、段寄存器、控制寄存器、指令指针、中断状态等;CPU 进入非根模式后,从 guest 指定的入口继续执行。这一小段路径决定了一个虚拟机的指令流真正开始运行的位置,也是后续所有 guest 行为的起点。
Guest Exit 则相反。当 guest 执行了敏感指令(比如 CPUID、访问特殊寄存器)、发生异常(比如缺页、调试异常)、收到中断,或者外部事件需要 vmm 介入时,CPU 自动保存当前的 guest 状态回 VMCS,再从 VMCS 里装载宿主机状态,然后把控制权交给 KVM 的退出处理函数。KVM 读取退出原因(Exit Reason),结合退出时保存的寄存器信息,判断“这个事件该怎么处理”,处理完之后再次经过 Guest Enter 路径回到客户机。
整个体系里,VMCS 是绕不开的核心对象。你可以把它理解成一张“状态交接单”:guest 进 CPU 前,硬件按这张单子装载客户机状态;guest 被切走时,硬件又把现场按这张单子保存好。KVM 的维护工作大量花在读写 VMCS 的各个字段上。
2.3 软件介入的边界在哪
并不是所有退出都必须由软件处理。KVM 在设计上做了一个很重要的分流:硬件能把一部分事件直接处理掉的,就不让 KVM 软件干预。比如中断控制器发给 vCPU 的中断,KVM 开启了 Posted Interrupt 之后,硬件能把中断直接投递给正在非根模式运行的 guest,完全不需要退出到根模式。这类优化大幅减少了中断引起的 Guest Exit。
软件真正要处理的退出,通常集中在几个方面:需要软件决策的设备访问(MMIO)、需要宿主机内存管理的缺页(EPT Violation)、需要对虚拟化特性做一致性校验的 MSR 访问、以及 timer 等周期性事件。理解了这条边界,后面再谈性能优化时,你就知道该往哪个方向努力了:减少无用退出,而不是让 KVM 对所有退出都熟视无睹。
3. vCPU 线程到 Guest Enter 的实现拆解
3.1 一次 vCPU 运行调用的完整链条
从用户看,qemu 在启动虚拟机时会为每个虚拟 CPU 创建一个线程,这个线程最终落到内核的 ioctl 调用上。等 KVM 模块拿到控制权后,会走下面这条主干路径进入 guest:
用户态 qemu 线程 -> kvm_vcpu_ioctl -> kvm_arch_vcpu_ioctl_run -> vcpu_enter_guest -> vmx_vcpu_run -> __vmx_vcpu_run(汇编) -> 硬件 VMRESUME / VMLAUNCH -> guest 开始执行关键的地方在vcpu_enter_guest。这个函数是 VMM 软件逻辑的总闸门,它按顺序完成很多状态准备:把 vCPU 当前需要的中断注入到中断窗口、处理惰性状态(比如 FPU 状态)、检查是否有 pending 的 NMI / 中断、刷新 EPT 相关的缓存、整理 guest 要用的段寄存器与调试寄存器。这些工作如果不提前做好,硬件在进入 guest 时要么直接报错,要么让 guest 运行在一个错误的环境里。
值得一提的是,KVM 没有在每次进入 guest 前都从零装载所有寄存器状态。大多数寄存器在宿主机和客户机之间其实是一致的,KVM 会使用“加载寄存器子集”的优化方式,只把必须由软件负责的字段写入 VMCS。真实需要填充的内容远比想象中少,这也是很多虚拟化开发初学者容易误解的地方。
3.2 VMLAUNCH 和 VMRESUME,谁先谁后
这里藏着一个经典的坑。首次进入 guest 时,CPU 需要干净的 VMCS 状态,KVM 必须使用 VMLAUNCH 指令来完成初始化;之后的每一次进入,都应当使用 VMRESUME 指令,因为它会自动复用已经建立好的 VMCS 执行状态,避免重新做整套初始化,速度更快。
很多初学 KVM 的开发者会误以为每轮 Guest Enter 都要 VMLAUNCH,然后在看代码时对vmresume分支感到困惑。实际上 KVM 在每个 vCPU 的 VMCS 状态里记录了一个标志,判断之前是否已经成功启动过 guest,只有首次或者 VMCS 被重新加载时才走 VMLAUNCH。我建议你在阅读内核源码时,重点关注vmx_vcpu_run里对vm_entry_flag的处理,以及在__vmx_vcpu_run汇编代码里对VMCS_LAUNCH的判断逻辑。
VMRESUME 并不是零成本。虽然它不需要重新初始化整个 VMCS,但 CPU 仍然要重新装载一部分 guest 状态、做特权级别切换时的安全检查、刷新流水线。因此它适合高频复用,但绝不等于“没有副作用”。
3.3 硬件自动保存了什么,又漏掉了什么
很多刚接触虚拟机底层的人,以为 Guest Exit 时寄存器会自动保存到 VMCS,于是整个 guest 现场就完整了。实际上,硬件只自动保存一小部分关键寄存器,并且是分层保存的:
- 自动保存到 VMCS 的:guest 退出时的指令指针、RSP、控制寄存器 CR0/CR3/CR4、段寄存器基础与选择子、调试寄存器、全局描述符表等。
- 需要 KVM 软件额外保存的:通用目的寄存器、XMM 寄存器、位域寄存器等,这些由
__vmx_vcpu_run汇编代码中的 push / pop 指令完成。
这种“硬件保存一部分、软件保存一部分”的设计是为了节省切换开销。如果所有寄存器都交给硬件自动保存,每次 Guest Exit 都要付出巨大代价;如果全部交给软件,又容易漏掉。KVM 选择的折中方案是:硬件保存与虚拟化核心语义最强相关的状态,软件处理那些编译器视角可管理的寄存器。
这也解释了为什么你在 eBPF 或 ftrace 跟踪 KVM 时,看到 guest 现场的通用寄存器在手写汇编的栈帧里能找得到,而不是直接躺在 VMCS 区域里。
3.4 Guest Exit 后的分发流程
Guest Exit 发生后,CPU 控制权回到 KVM 的汇编退出路径。这段路径同样做两件事:保存宿主机的寄存器状态、恢复 KVM 自己的内核环境,然后跳转到 C 函数vmx_vmx_exit_handler。在这个函数里,KVM 首先读取 hardware exit reason,再用它去查一张分发表,找到对应的处理函数。
真实的退出原因种类很多,常见的有几十种。KVM 源码里的handle_external_interrupt、handle_triple_fault、handle_ept_violation、handle_cr、handle_rdmsr/wrmsr等等,都是这张表上的分支。每个处理函数处理完自己的事件后,会返回一个布尔值:真表示可以继续进入 guest,假表示这个 vCPU 需要停下来,让用户态做进一步处理(比如设备模拟)。
这一步也是调优的重点。如果你能让大多数 Guest Exit 都走“快速处理返回”路径,而不是每次都要回到用户态 qemu 做一遍完整的事件模拟,虚拟机的运行效率就会有明显提升。
4. 具体实操:观测一进一出的频率和原因
4.1 用 perf kvm 采集退出事件
不管你是做底层研发还是性能排查,第一件要做的事就是量化:这台虚拟机的 Guest Exit 次数是多少?哪些原因占大头?没有数据,所有优化都只是猜。
我通常会在宿主机上用perf kvm来统计。先确认内核支持相关 tracepoint:
ls /sys/kernel/debug/tracing/events/kvm/如果能看到kvm_entry、kvm_exit等事件,就可以采集一段时间内所有 KVM 事件:
perf kvm --host stat record -a sleep 60这个命令会在宿主机全局模式下,持续 60 秒采样所有顾客机的进入/退出事件。结束后生成报告:
perf kvm --host stat report报告里会给出按退出原因分类的统计结果,比如:
Analyze events for all VMs: EXTERNAL_INTERRUPT 2462001 PREEMPT_TIMER 184057 EPT_MISCONFIG 98012 MSR_WRITE 21106 EXC_NMI 9980注意,具体数字会因内核版本、CPU 型号、虚拟机负载而不同,所以关键不是背数字,而是学会解读:外部中断占大头是很常见的,只要比例合理就不用慌;如果 EPT_MISCONFIG 或 EPT_VIOLATION 占比异常高,说明宿主机内存分配或大页配置可能有问题。
4.2 用 kvm_stat 做实时采样
perf kvm适合做离线采样,方便留存记录。做实时观测时,我更喜欢用kvm_stat脚本,它在宿主机上可以直接实时刷新:
kvm_stat -l输出里会动态显示各退出原因的累计次数和增量。看到某列数字狂跳时,可以直接联系到此时虚拟机上正在跑的负载。比如跑数据库压力测试时EPT_VIOLATION快速增长,很可能是因为代码段和数据段跨页分配、内存页合并频繁,或者透明大页让宿主机后台回收线程频繁改动映射。
使用kvm_stat还有个好处:它能显示 occurred 的时间间隔,方便你对比压测脚本的波峰波谷,判断退出事件是否与业务负载强相关。如果虚拟机闲着没事做,退出率仍然居高不下,那就要怀疑是不是有周期性的定时器事件在作祟。
4.3 把退出原因按场景喂给性能分析
单一指标不够,得和 CPU 占用、延迟结合起来看。我的一个习惯做法是,同时抓三路数据:
- 宿主机侧:
perf top观察kvm相关内核函数占 CPU 的比例。 - 虚拟机侧:
top或者压测工具的吞吐、延迟。 - KVM 退出统计:
perf kvm stat/kvm_stat。
举个例子,某次我观测一个网络转发型虚拟机,发现宿主机上kvm模块 CPU 占用非常高,但虚拟机自己 CPU 占用却没有很高。查kvm_stat后,发现EXTERNAL_INTERRUPT事件暴增,再抓中断路径,发现是宿主机大量设备中断被自动转发给了这个 vCPU,导致它频繁被切走又切回来。解决办法是调整中断亲和性,把宿主机上的高频率设备中断绑定到别的物理 CPU,避免和 vCPU 线程挤在一起。
这个例子说明,Guest Exit 不一定是虚拟化模块自己的错,往往还牵扯到宿主机的中断管理、调度器亲和性、NUMA 拓扑等外部因素。会看退出原因分布,才能快速排除这些干扰项。
5. 核心 Exit Reason 解读与常见问题排查实录
5.1 占比最高的几个退出原因
| 退出原因 | 触发场景 | 正常占比判断 |
|---|---|---|
| EXTERNAL_INTERRUPT | 外部中断到达 vCPU | 通常很高,尤其宿主机设备多时 |
| PREEMPT_TIMER | vCPU 时间片轮转 | 与调度周期强相关,合理偏高 |
| EPT_VIOLATION / EPT_MISCONFIG | 内存访问穿越影子映射 | 内存不规整时显著增加 |
| MSR_WRITE / MSR_READ | guest 访问模型相关寄存器 | 驱动启动、固件操作时出现峰值 |
| EXC_NMI | 非屏蔽中断 | 一般极少,出现需关注 |
| PENDING_INTERRUPT | 处于中断注入窗口的竞争 | 负载高、中断密集时常见 |
| CPUID | guest 执行 CPUID | 内核启动、特性探测时会集中出现 |
需要特别提醒的是:不要看到 EXTERNAL_INTERRUPT 次数高就觉得是异常。中断总量高只是说明该 vCPU 比较繁忙,关键要看它每个中断对应的处理链路是否高效。真正需要警惕的是EPT_MISCONFIG和EPT_VIOLATION占比突然拔高,这往往表示内存映射出了问题。
5.2 案例一:虚拟机 CPU 使用率高但业务吞吐极低
现象是虚拟机的 top 显示多核跑满,但业务延迟飙升,宿主机的 CPU 使用率却不高。用perf kvm stat采集后,发现EPT_VIOLATION异常高,每秒达到几十万次。进一步排查,发现这个虚拟机的内存启用了透明大页,但由于宿主机内存碎片化,大页并没成功分配,结果内存映射呈现“零碎”状态,guest 的内存访问频繁触发缺页类退出。
处理方法是关闭该虚拟机的透明大页,并把内存以固定大小的大页给到 qemu。改完之后,同一压测负载的EPT_VIOLATION数量降了一个数量级,业务吞吐直接回升。这个案例说明:高 Exit Rate 只是一个表面信号,具体原因要靠对内存机制的了解去定位,不能机械地加大内存或换更快的 CPU。
5.3 案例二:周期性的时钟抖动
另一个常见问题是虚拟机里的时钟漂移,表现为 NTP 周期性地跳变,应用定时任务起伏明显。kvm_stat显示PREEMPT_TIMER和EXTERNAL_INTERRUPT有固定周期波动。进一步看,宿主机开启了内核轮询定时器,每毫秒都会产生一个 tick 去戳 vCPU。期间 vCPU 线程频繁被踢出 guest,造成调度抖动。
优化的思路有两个方向:一是保证宿主机的 tickless 特性,也就是让 vCPU 所在物理 CPU 尽量减少 tick 数量洗扰;二是通过isolcpus和nohz_full内核参数让 vCPU 线程独占某些物理核心,把宿主机自身的定时器噪声隔离开。这套配置在大型虚拟化部署里很常见,日常测试机则未必关注。
5.4 案例三:嵌套虚拟化下性能变差
很多人会开启嵌套虚拟化,在虚拟机里再跑虚拟机。这时候重点不再是单一 Guest Exit 的数量,而是“退出深度”——guest 里的 L1 虚拟机如果频繁退出,它的退出会作为 L0(宿主根模式)的一个普通事件再次退出,形成两层退出叠加。这种情况下,我建议不要盯着 L0 的 exit reason,而应在 L1 虚拟机里再用perf kvm抓一次,看是 L1 内部的内存映射问题还是 timer 问题。只要 L1 里的退出原因分布健康,L0 多出的那层“退出见证”其实影响相对可控。
5.5 排查工具速查
| 工具 | 适合场景 | 典型命令 |
|---|---|---|
| perf kvm stat | 离线历史统计 | perf kvm --host stat record -a sleep 60 |
| kvm_stat | 实时观测退出原因 | kvm_stat -l |
| ftrace / trace-cmd | 追踪某个客户机详细事件 | trace-cmd record -e kvm:* |
| evlist | 查看 perf 已捕获的事件 | perf kvm stat record --list |
| /proc/interrupts | 中断亲和性分析 | cat /proc/interrupts |
6. 优化 Guest Enter / Guest Exit 的实战思路
6.1 减少无意义退出的原则
优化不能只看某一项,最好先画一张“退出原因分布图”。分布图里,能够直接从业务或配置层面降低的,优先处理;剩下那些客观存在的事件,再考虑从硬件和内核机制优化。一句话总结:能不进则不进,必须在退出时则快进快出。
常见可落地的操作包括:
- 给虚拟机的内存分配使用大页,减少 EPT 映射层级和缺页事件。
- 为高优先级虚拟机 pin vCPU 线程,减少宿主机调度器来回切换导致的 PREEMPT_TIMER 数。
- 使用 vhost 网络加速,把数据面收发包尽量留在内核态处理,避免每次 IO 都退出到用户态。
- 对直通设备使用 VFIO,让设备中断直接进入 vCPU,省掉中间多次退出。
6.2 利用 Posted Interrupt 加速中断注入
传统模式下,外部中断到达时必须触发 Guest Exit,KVM 记录中断信息后重新进入 guest,再把中断注入进去。这个流程每来一个中断都要退出一次,中断密集时开销非常可观。开启 Posted Interrupt 之后,硬件允许在 guest 正在运行时直接把中断投递过去,减少一次不必要的触发切换。
这个特性在 KVM 里通常默认开启,但要注意 CPU 和内核版本支持情况。如果你观察到一个网络密集型的虚拟机EXTERNAL_INTERRUPT退出次数依然很高,可以先确认是否真的生效,而不是盲目相信配置。
6.3 优化 EPT 相关退出
EPT_VIOLATION高时,除了大页问题,还可能与页表回收机制有关。KVM 模块有自己的反向映射机制,当宿主机内存压力大、触发页面回收时,会把 guest 的 EPT 表项视为可回收页,导致重新访问时再次触发缺页。这会让虚拟机的内存密集负载出现明显波动,且宿主机kvm模块的 CPU 占用升高。
应对方式:给虚拟机预留足够内存,尽量避免宿主机内存打满;也可以调整 KVM 模块参数,降低 EPT 表项被回收的频率。对于关键业务虚拟机,我更倾向于无人值守的内存超卖,直接把内存固定分配好。
6.4 从调度层面降低切换频率
vCPU 本质上是一个普通线程,宿主机调度器完全按照线程的优先级和时间片来进行调度。如果一个物理 CPU 上有多个 vCPU 互相抢,每次切换都会带来额外的 Guest Enter / Guest Exit 开销。用 cpuset 隔离物理核心和 vCPU 线程,并提高 vCPU 线程的实时优先级,往往能获得立竿见影的效果。
我自己在压测场景里的做法是:
cat /proc/$(pgrep -f qemu-system)/task/*/comm | grep "CPU"先找到 vCPU 线程,再用taskset绑定到指定的物理核心组合,并设置 SCHED_FIFO。绑定之后,业务中断和网络延迟通常会稳定不少。但需要注意,绑核会牺牲宿主机侧的灵活性,适合对性能稳定性有要求的场景,不适合随意变化的测试环境。
7. 最后聊点实际的体会
做 KVM 底层的性能排查,我踩过最大的坑就是只看总退出的次数,忽略了事件类型分布。总退出次数高不一定性能差,退出类型如果都是轻量级事件,实际开销很小;相反,哪怕每秒钟退出次数低,只要大量触发EPT_MISCONFIG、PENDING_INTERRUPT这类重事件,性能照样崩。所以我建议所有做相关调优的同学,第一个习惯就是先看类型分布,把重事件挑出来,再针对性地做放大和定位。
另外一个小技巧是:排查时把宿主机和虚拟机的时钟对齐好,尽量用 NTP 同步。很多看似莫名的性能抖动,查到最后原来是宿主机时钟跳变引起 guest 里的调度和网络超时跟着抖。把时钟源选成 kvm-clock 或者给虚拟机配置稳定的 TSC,能省掉不少低级弯路。
还有一点值得多说:Guest Enter / Guest Exit 的调优并不是一劳永逸的事。硬件平台换一代,虚拟化特性就会增加新能力;内核版本升级,KVM 的默认行为也会变化。每次换环境后,都值得重新跑一轮perf kvm stat,对比一下退出原因分布,确认新环境到底默认帮你优化了哪些,偷偷退化掉了哪些。带着这个习惯去看 KVM 源码、看宿主机内核参数,你会比大多数人更快定位到问题所在。