做KVM虚拟化的人,大概都经历过这样的夜晚:宿主机内存一吃紧,虚拟机里的业务突然卡顿,top一看,kvm进程的CPU在飙升,guest内部却一切正常;又或者为了省内存开了KSM,结果发现虚拟机CPU使用率不降反升。这种时候,如果不了解MMU Notifier,排查起来真的会一头雾水。
MMU Notifier是Linux内核内存管理子系统和KVM虚拟化之间的一座桥梁,它解决的是一个非常核心的问题:当host侧的内存映射发生变化时,KVM怎么才能第一时间知道,并同步更新自己的影子页表。这个机制不像调度器或网络协议栈那么显眼,但它是保证虚拟机内存一致性和性能的关键。
这篇文章的内容,适合正在做内核虚拟化开发的工程师、云平台基础设施的维护者,也适合对Linux内存管理感兴趣并且想搞清楚“为什么host换页会导致虚拟机卡顿”的进阶读者。我会从问题本身讲起,把它背后的设计逻辑、KVM的实际用法、调试排障经验一次说清楚。
1. MMU Notifier到底在解决什么问题
1.1 一个容易被忽略的同步问题
先说个最基础的模型。在一台跑着QEMU/KVM的服务器上,虚拟机看到的内存是一段连续的物理内存,但实际上这段“物理内存”在host侧是由普通进程的虚拟地址空间承载的。KVM为了让guest的访问不经过QEMU的漫长模拟路径,会直接通过EPT/NPT把guest的物理地址映射到host的真实物理页框上。这个映射关系,就是KVM自己的影子页表,在x86平台上通常叫EPT页表。
问题来了:guest的物理页框在host侧并不是锁定的。它可能被swap换出到磁盘,可能被KSM和别的进程合并成同一个页,可能被memory hotplug流程迁移走,也可能在你调用munmap的时候直接被解除映射。而这一切发生时,宿主机的MMU页表被修改,KVM的EPT页表却还傻傻地指着原来的页框。如果guest此刻访问这块内存,硬件会通过EPT翻译到一个已经不在线、甚至已经被复用的页框上,轻则读到错误数据,重则直接造成内核内存损坏。
这里的本质是:Linux内存管理假设,只要修改了某个mm的页表,那么所有“以该mm为基准建立的外部映射”都需要同步失效。普通CPU的页表是由硬件和内核自己维护的,但KVM的EPT页表更像是“外包”出去的独立页表,内核必须有一种方法通知KVM这个外部消费者:你的缓存映射已经过期了。
你可以把它想象成快递转寄:你搬家了(进程页表变了),快递公司(KVM)还按老地址送件(EPT映射),如果不告诉它新地址,包裹就只能丢。
1.2 没有mmu_notifier的世界
没有这个机制之前,内核其实有几种笨办法。一是让所有guest内存都钉死在内存里,不允许换出。这样确实安全了,但对一个上百GB内存的宿主机来说,代价是不可接受的,尤其云场景内存超卖后,swap是回收内存的重要手段。
二是每次guest访问内存都由软件查一遍host页表,相当于放弃硬件EPT加速,性能直接倒退十年。KVM这种对性能极度敏感的场景,显然不能接受。
所以在Linux 2.6.x后期,mmu_notifier机制被引入内核。它的功能说白了就是给内存管理子系统开了一个“订阅通知”的接口:谁关心页表变化,谁就注册自己的回调;内存代码在修改页表前后,会主动调用这些回调。
这也是为什么新手第一次看mmu_notifier的代码会困惑:它本身不做任何事情,只是一堆钩子。真正干活的是KVM、Xen、VFIO、DRM这些注册者。
1.3 核心设计:把“外部页表消费者”纳入内存管理生命周期
理解mmu_notifier,最重要的是理解“外部页表消费者”这个身份。普通CPU访问内存,通过本进程的页表即可;但KVM代表guest中所有vCPU去访问host内存,它维护的是另一棵页表。这棵树的内容是从host mm页表推导而来的,所以host页表一变,必须让KVM有机会重算或失效。
这种设计思路,在Linux内核里其实是一以贯之的:任何缓存一致性问题的解法,要么是“尽量不缓存”,要么是“缓存失效时能及时收到通知”。mmu_notifier走的是第二条路。内存管理子系统是“生产者”,KVM这类模块是“消费者”,当生产者的页表发生变化时,通过notifier机制把变化事件广播出去,消费者各自维护自己的那份缓存。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心数据结构与API族谱
2.1 mmu_notifier_ops:你要实现的回调
内核在include/linux/mmu_notifier.h中定义了struct mmu_notifier_ops,这是整个机制的“接口契约”。常见的回调大概有这几个:
| 回调名 | 触发时机 | 典型用途 |
|---|---|---|
| release | mm即将被销毁,外部引用必须释放 | 清理KVM的EPT映射,阻止vCPU再访问该mm |
| clear_flush_young | 检查并清除PTE的young位,且强制刷新TLB | 内存回收的二次机会 |
| clear_young | 只清除PTE的young位 | LRU访问年龄统计 |
| test_young | 测试PTE的young位 | KSM候选页面判断 |
| change_pte | 某个PTE被修改,但映射仍然有效 | KSM合并页时,直接把外部映射指向新页 |
| invalidate_range_start | 一段地址范围内的映射即将失效 | 主要事件,KVM在这里撤销EPT映射 |
| invalidate_range_end | 地址范围内的映射已经失效完成 | 释放锁,允许建立新映射 |
这里先解释一下young位。PTE中有一个accessed位,表示这个页面最近是否被访问过。内存回收时,内核通过这个位来判断哪些页面是“冷”的。KVM的EPT页表里也有类似的accessed状态,这几个回调的存在,本质上是把host的内存老化评估逻辑扩展到KVM的EPT页表上。
比如KVM做live migration时,需要知道guest在一轮迭代后哪些页面被写过,这套dirty page tracking就和mmu_notifier的clear_flush_young等回调有协作关系。
2.2 注册与注销:与mm生命周期绑定
注册接口很简单:
c复制int mmu_notifier_register(struct mmu_notifier *mn, struct mm_struct *mm);
KVM在创建VM时,会取current->mm作为宿主地址空间的根,注册notifier。注销用mmu_notifier_unregister或mmu_notifier_put。注销后不能再有回调进来。
这里有一个非常容易踩的坑:注册了mmu_notifier之后,必须持有mm的引用,避免在回调还没有清理完时mm已经被释放。KVM的kvm_create_vm里会拿mmget,并一直持有到VM关闭。很多自定义模块的同学会忘记这一步,结果就是use-after-free,而且这种bug往往只在特定时序下出现,特别难复现。
2.3 invalidate_range_start/end配对与blockable
为什么要设计成start和end成对出现?
因为内存回收或unmap往往不是瞬间完成的,它有一个过程:先在start中告诉注册者“我要让这段地址失效了”,注册者可以在这个时间点来做清理(比如把EPT映射摘除),随后内存子系统开始操作页表,操作完成后调用end通知“事情干完了”。
KVM在invalidate_range_start里会获取kvm->mmu_lock,把所有vcpu的TLB冲刷掉,把对应spte删除;在invalidate_range_end里释放锁,允许建立新的映射。
这里有个关键参数叫blockable。当启动start时,内存子系统会传入一个“是否允许阻塞”的信号。为什么要有这个参数?因为如果notifier的回调睡眠了,但当前上下文是kswapd或oom_reaper,是不能睡眠的。
KVM的实现是:如果不能立刻获得mmu_lock,它会选择轻量级的等待方式,或者通过请求机制延迟处理,而不是在原子上下文里死等。这个设计保证了内存回收路径不会被KVM卡死。
提示:如果你自己在写notifier回调,不要在invalidate_range_start里做重量级的同步操作或长时间持锁,否则会拖慢内存回收,甚至引起系统级卡顿。
3. KVM是如何使用MMU Notifier的
3.1 KVM注册mmu_notifier的完整路径
看一下KVM用户态的完整调用链:
- qemu打开/dev/kvm设备节点。
- 调用ioctl(KVM_CREATE_VM)。这个ioctl在内核中对应kvm_dev_ioctl_create_vm,进而调用kvm_create_vm。
- 在kvm_create_vm中,内核会取出current->mm,增加引用计数,然后注册mmu_notifier。
在virt/kvm/kvm_main.c里,注册代码大概是这样的逻辑:
c复制struct kvm *kvm_create_vm(unsigned long type)
{
struct kvm *kvm;
struct mm_struct *mm = current->mm;
kvm = kvm_arch_alloc_vm();
mmget(mm);
kvm->mm = mm;
kvm->users_count = 1;
mutex_init(&kvm->lock);
mmu_notifier_register(&kvm->mmu_notifier, mm);
...
}
而KVM的mmu_notifier_ops定义大概是这样:
c复制static const struct mmu_notifier_ops kvm_mmu_notifier_ops = {
.invalidate_range_start = kvm_mmu_notifier_invalidate_range_start,
.invalidate_range_end = kvm_mmu_notifier_invalidate_range_end,
.clear_flush_young = kvm_mmu_notifier_clear_flush_young,
.clear_young = kvm_mmu_notifier_clear_young,
.test_young = kvm_mmu_notifier_test_young,
.change_pte = kvm_mmu_notifier_change_pte,
.release = kvm_mmu_notifier_release,
};
注意一个细节:KVM并不是为每个vcpu单独注册notifier,而是整个VM只注册一个。原因很简单,KVM的EPT页表在x86上是per-VM共享的(或者更准确地说,per-MMU context),一个notifier就足以覆盖所有vcpu的地址空间。
3.2 收到invalidate后,KVM如何处理spte
当内存子系统调用mmu_notifier_invalidate_range_start时,KVM的回调会做这么几件事:
- 记录当前invalidate的地址区间。
- 递增kvm->mmu_notifier_count,这个计数器用来标记当前正处于invalidate过程中。
- 调用kvm_unmap_hva_range,遍历这个区间对应的gfn范围,将相应的EPT叶子项清掉。
- 触发kvm_flush_remote_tlbs,让所有vcpu的TLB失效。
KVM在x86的实现中,kvm_unmap_hva_range最终会调用kvm_mmu_unmap_gfn_range,它会遍历指定gfn范围内的spte,把它们清成非present状态。注意,它不会回收整个EPT页表结构,只清叶子PTE,并做相应的generation标记。
这里有个很重要的问题:为什么要“失效”而不是“更新”?
答案很实际:KVM不知道host页被换到swap后对应的新页框是什么,它只能在下一次guest访问时通过gfn_to_pfn重新解析。如果试图同步更新映射,就必须把“内存换出”的全流程暴露给KVM,代价太大。
所以策略是:让spte失效,guest下一次访问对应内存时就会触发EPT violation,KVM重新到host页表查询新页框,建立新映射。这个“按需重建”的机制,和CPU的TLB miss处理是一个思路。
3.3 KSM场景下的change_pte优化
KSM(内核同页合并)是另一个典型的mmu_notifier使用场景。它会扫描进程地址空间中的匿名页,发现相同内容的页面后,将多个页合并为同一个只读页。如果目标是KVM guest使用的页面,那么合并时host页框会发生变化,KVM的EPT映射也需要跟着变。
如果每次KSM合并都走invalidate_range_start/end,guest会经历一场缺页风暴。所以内核提供了change_pte回调:在替换PTE的同一瞬间,告诉KVM“新页的页框是xxx,你可以把EPT直接指过去”。
KVM的kvm_mmu_notifier_change_pte会尝试在mmu_lock保护下更新对应的spte。如果更新成功,guest完全无感知;如果条件不满足(比如映射不在leaf层,或spte已经被换页流程处理过),就退回invalidate。
实操中,开启KSM后可以用下面这个命令观察合并情况:
bash复制cat /sys/kernel/mm/ksm/pages_shared
如果pages_sharing很大,说明KSM在大量合并。配合tracepoint看change_pte事件的频率,就能知道KVM是否成了KSM的主要消费者。
3.4 内存回收、balloon、透明大页的场景
再展开几个日常最容易碰到的场景。
第一个是内存回收。try_to_unmap扫描进程页表,把匿名页的PTE换成swap entry,这一步会触发mmu_notifier_invalidate_range_start/end。所以host内存压力大时,guest会频繁缺页,这是开头说“宿主机内存吃紧导致虚拟机卡顿”的根因。
第二个是virtio-balloon。balloon驱动在guest内申请内存来“占用”内存,host端对应页框被释放,也会经历unmap流程。如果balloon膨胀太快,invalidate事件会非常密集,对guest的瞬时性能影响很大。
第三个是THP的collapse/split。透明大页在合并或拆分时,PMD/PUD级别的页表发生变化,KVM如果没同步好,guest可能看到半个大页。KVM必须在这个流程中通过mmu_notifier确保spte级别与host页表级别一致。
4. 与内存生命周期其他场景的联动
4.1 页面迁移与内存热插拔
页面迁移(page migration)在现在的内存管理中使用越来越频繁,比如NUMA balancing、memory compaction、memory hotplug offline。这些场景中,物理页框会从一处搬到另一处,页表必须更新,mmu_notifier自然也会介入。
比如NUMA balancing会把热页迁移到更靠近CPU的node上。在迁移过程中,内核先建立临时映射,然后通过migrate_pages流程把页表指向新页框,同时触发notifier通知KVM。
memory hotplug offline时,内核会把要移除的内存区域的所有映射解绑,这个范围可能非常大。KVM收到invalidate_range_start后,会清掉大段spte。如果这段内存恰好是某个虚拟机的全部内存,guest会在一瞬间经历大量EPT violation,表现就是虚拟机“卡住”一下。
4.2 进程退出与release回调
进程退出时,mmput会走到__mmput,最终调用mmu_notifier_release。这个回调告诉所有注册者:mm马上要消失了,你们必须释放对它的引用。
KVM在这个回调中的处理非常关键。因为KVM的vCPU线程是异步运行的,可能在release回调执行时,某个vcpu还在尝试访问guest内存。如果处理不当,KVM会访问到一个已经释放的mm,直接oom或者panic。
KVM的处理方式是:在release回调里标记VM已经被关闭或内存已经不可用,同时唤醒所有阻塞在内存访问路径上的vcpu,让它们退出。这个流程的时序很微妙,也是KVM代码里比较容易出bug的地方。
4.3 从KVM到IOMMU:mmu_notifier的下一站
mmu_notifier并不是KVM专属。近年来,随着SVA(Shared Virtual Address)概念的流行,IOMMU也开始大量使用mmu_notifier。
SVA允许设备直接访问进程的虚拟地址空间,而无需驱动做复杂的DMA映射。IOMMU需要维护一份和进程页表同步的设备页表,这份页表的更新完全依赖mmu_notifier机制。比如Intel的ENQCMD、AMD IOMMU v2、ARM SMMU等,都有对应的mmu_notifier实现。
在未来的虚拟化场景里,设备直通(device passthrough)加SVA会让IOMMU的notifier使用越来越多。如果只熟悉KVM视角,看到IOMMU里的mmu_notifier会有点陌生,但核心模型完全一样:缓存了进程页表的映射,就必须监听进程页表的变更。
5. 调试与性能排查实录
5.1 用tracepoint观察invalidate事件
mmu_notifier相关的tracepoint在较新内核里默认是编译进来的,可以通过tracefs查看。
bash复制mount -t tracefs nodev /sys/kernel/tracing
echo 1 > /sys/kernel/tracing/events/mmu_notifier/mmu_notifier_invalidate_range_start/enable
echo 1 > /sys/kernel/tracing/events/mmu_notifier/mmu_notifier_invalidate_range_end/enable
cat /sys/kernel/tracing/trace_pipe
如果系统里跑着虚拟机,你会看到源源不断的invalidate事件,里面有start和end地址。通过对照QEMU的内存映射范围,可以判断哪些invalidate落在虚拟机内存区段内。
注意不同内核版本的事件名可能有差异,以/sys/kernel/tracing/events/mmu_notifier/目录下实际存在的事件名为准。老内核可能没有专门的mmu_notifier tracepoint,这时候可以用perf probe来动态挂载:
bash复制perf probe 'kvm_mmu_notifier_invalidate_range_start start=%di end=%si'
5.2 经典性能坑:invalidate风暴
大概没有哪个KVM运维没踩过这个坑:宿主机可用内存不多,Swap开始频繁读写,虚拟机里的应用响应延迟从毫秒级变成秒级。
用perf排查时,你会发现kvm_mmu_notifier_invalidate_range_start占的CPU时间很高:
bash复制perf top -p $(pgrep qemu | head -1)
定位到这个原因后,解决思路有两类。一类是从host层面保证内存充足,尽量避免swap。另一类是减少invalidate事件的影响范围,比如给虚拟机配置大页内存。大页的好处是页表项少,同样一段内存被unmap时,KVM需要处理的spte数量会少一个量级,性能损耗自然小很多。
还有一个日常建议:如果虚拟机对性能要求很高,可以考虑关闭KSM或者只对特定内存区域启用KSM。KSM虽然省内存,但change_pte和invalidate的代价在某些场景下可能抵消收益。
5.3 锁顺序与死锁问题
mmu_notifier涉及两把锁的协作:mmap_lock(保护进程页表)和kvm->mmu_lock(保护KVM页表)。这两把锁的获取顺序必须一致,否则就会死锁。
正确的顺序是先获取mmap_lock,再获取mmu_lock。因为mmu_notifier的回调是在mmap_lock持有期间被调用的,KVM内部如果反向获取mmap_lock,就会和正在修改页表的路径形成循环等待。
我见过一次真实的死锁:某个内部模块在持有mmu_lock时去读取host页表,尝试获取mmap_lock,而内存回收路径正持有mmap_lock等待mmu_lock。两个线程互相等待,lockdep的报错信息是:
code复制[ INFO: possible circular locking dependency detected ]
排查方法很简单,看内核log里lockdep是否报告了mmap_lock和mmu_lock的循环依赖。修复方法也很明确:代码路径统一遵守先mmap_lock后mmu_lock的顺序。KVM自身对这一点是非常严格的,扩展模块如果不注意,很容易在边缘场景翻车。
5.4 常见问题速查表
这里整理一个速查表,方便快速定位问题。
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 虚拟机周期性卡顿 | host内存压力大,invalidate频繁 | 看tracepoint统计、检查swap使用量 |
| KVM进程CPU占用高 | EPT被反复清空,guest频繁缺页 | 检查是否开启了大页,是否频繁balloon膨胀 |
| guest内存数据错乱 | spte未及时失效 | 检查注册的notifier回调是否遗漏了关键区间 |
| 开启KSM后guest性能下降 | change_pte更新频繁或回退invalidate | 对比pages_sharing和events统计 |
| 系统启动或关闭虚拟机时死锁 | 锁顺序反了 | 看lockdep报错,统一mmap_lock到mmu_lock的顺序 |
| 自定义模块出现use-after-free | 未持有mm引用或注销时机不对 | 检查mmget/mmu_notifier_register配对 |
我印象最深的一次排障,是某个云平台在压测时发现虚拟机内存分配失败率偏高,后来发现是notifier注册时没有对mm做充足的引用保护,导致内存回收路径在极端情况下释放了mm,KVM的回调访问了已经释放的内存。这种问题不压测到临界点根本发现不了。
所以最后再说一个经验:如果你的代码涉及mmu_notifier,第一要务是搞清楚mm的生命周期,第二是搞清楚锁顺序。这两个弄明白了,这个机制在你手里就是一头温顺的骆驼,否则它随时可能变成一头咬人的狮子。
