Linux 深入 per-VMA lock:Linux 缺页路径如何摆脱 mmap_lock 本文系统地拆解 Linux 的per-VMA lock(每-VMA 锁,常与 SPF / Speculative Page Fault 一并提及):它想解决什么、底层用了哪些字段、读锁与写锁各自怎么工作、为什么允许偶尔假报加锁失败、以及它与 device-private(GPU SVM)迁移路径的关系。本文以内核当前的refcount 版实现为准(vm_refcntvm_lock_seqmm_lock_seq),而非早期基于rw_semaphore的旧版。贯穿全文的一句话:per-VMA lock 是一把乐观、可失败、可退回的读锁——它只在最常见的缺页快路径上生效,一旦情况复杂就干净利落地退回传统mmap_lock,用正确性优先、性能尽力的方式换来高并发缺页的可扩展性。目录一、为什么需要 per-VMA lock二、三个关键字段:锁其实是数字比大小三、写锁:一次锁一个,或一次全解锁四、读锁:RCU 找 VMA 乐观拿锁五、“假加锁与绝不假解锁”:安全边界六、与 mmap_lock 的共存关系七、device-private / GPU SVM 为何主动退回八、局限与演进方向九、代码位置一、为什么需要 per-VMA lock先回顾瓶颈。在 per-VMA lock 出现之前,任何一次缺页都要先拿进程级的mmap_lock读锁,才能进入handle_mm_fault()。这把锁保护的是整个地址空间的 VMA 树。问题在于:一个多线程进程里,线程 A 缺页(要mmap_lock读锁)会与线程 B 的mmap/munmap/brk(要mmap_lock写锁)互斥——哪怕 A、B 操作的是两段毫不相干的 VMA。高并发缺页彼此之间虽然都是读锁、不互相排斥,但仍在同一把锁的 cache line 上颠簸,NUMA 机器上尤其明显。对数据库、JVM、大型服务这类多线程 海量按需分页的负载,这是长期的头号扩展性瓶颈。关键观察:**一次缺页,绝大多数情况下只关心一个 VMA。**既然如此,为什么要为它锁住整棵树?per-VMA lock 的思路就是把读者的锁从整个地址空间下沉到命中的那一个 VMA,让不同 VMA 上的缺页彼此完全无关,也让缺页与改别的 VMA的写者互不阻塞。直觉类比:mmap_lock是整栋楼的大门钥匙,进任何一个房间都得先刷这张卡;per-VMA lock 是每个房间自己的钥匙,进 A 房间不再挡住进 B 房间的人。只有当你要改动整栋楼的结构(跨 VMA、合并 / split VMA)时,才需要回去拿大门钥匙。二、三个关键字段:锁其实是数字比大小per-VMA lock 没有为每个 VMA 塞一把传统意义上的睡眠锁,而是用三个字段配合完成。理解了这三个字段,整套机制就通了。(1)mm-mm_lock_seq—— 每个地址空间一个的序列号(seqcount)。它是这个 mm 当前的写代际编号。每当写者结束一批 VMA 修改并放开mmap_lock写锁时,这个序列号会递增一次,语义上等于宣告:“此前所有基于旧序列号建立的 per-VMA 读锁,全部作废。”(2)vma-vm_lock_seq—— 每个 VMA 一个的上次被写锁标记时的序列号。当某个 VMA 被写锁时,内核把当前的mm_lock_seq值写进它的vm_lock_seq。于是判据变得极其简洁:若vma-vm_lock_seq mm-mm_lock_seq,说明这个 VMA 是在当前代际被写锁标记的,即它正处于写锁定状态,读者必须放弃快路径。若两者不等,说明它上次被写标记是上一代的事、早已随mm_lock_seq递增而失效,VMA 当前没有写者。这也是为什么mm_lock_seq递增一次,就能一次性使所有 VMA 的读锁失效——不需要逐个 VMA 去清标记,只要把代际往前一推,所有vm_lock_seq停留在旧代际的 VMA 自动被判为已解锁。(3)vma-vm_refcnt—— 每个 VMA 一个的引用计数(refcount_t),兼当读写互斥开关。它有一个被保留的高位VMA_LOCK_OFFSET。约定如下:读者用受限的原子自增__refcount_inc_not_zero_limited_acquire(..., VMA_REF_LIMIT)增加计数。VMA_REF_LIMIT小于VMA_LOCK_OFFSET,所以只要VMA_LOCK_OFFSET位被写者置上了,读者的受限自增就会失败——这就是有写者时读者拿不到锁的物理实现。写者用refcount_add_not_zero(VMA_LOCK_OFFSET, ...)置上那个高位,宣告独占;然后等已有读者退出。计数为 0 表示 VMA 已detached(从树上摘除、准备回收),读者此时会拿到-EAGAIN并重试。一句话总结这三者的分工:vm_refcnt管此刻有没有人独占 / 还有没有活着的读者,vm_lock_seqvsmm_lock_seq管这把读锁属不属于当前代际(有没有被写者作废)。两道检查都通过,读锁才算真正拿到。三、写锁:一次锁一个,或一次全解锁写侧有两个互补的动作。写锁单个 VMA:vma_start_write()(底层__vma_start_write())。调用它的前提是已持有mmap_lock写锁(mmap_assert_write_locked)。步骤是:先__vma_enter_locked()通过refcount_add_not_zero(VMA_LOCK_OFFSET, ...)置上独占位;若此刻还有读者(vm_refcnt未降到目标值),就用rcuwait_wait_event()在mm-vma_writer_wait上等所有读者退出;读者清空后,WRITE_ONCE(vma-vm_lock_seq, mm_lock_seq)把该 VMA 打上当前代际的写标记,然后释放独占位。此后任何读者对这个 VMA 都会因vm_lock_seq mm_lock_seq而快速失败退回。所有会改动 VMA 的路径都调用它——mprotect、mremap、madvise、khugepaged、VMA 的 split/merge(mm/vma.c)、mempolicy、userfaultfd等等。一次性解锁全部 VMA:vma_end_write_all()。它并不遍历所有 VMA,而是对mm_lock_seq做一次带RELEASE 语义的递增。代际一变,所有停留在旧代际的vm_lock_seq立刻被判为已解锁。这与读侧vma_start_read()里对mm_lock_seq的ACQUIRE读配对,保证读者要么看到仍锁定从而退回,要么在解锁完成之后才开始读 VMA 内容——不会读到解锁一半的中间态。这个动作通常发生在放开mmap_lock写锁时。设计精妙之处:写锁标记是逐个 VMA 打点,解锁却是整体推进代际。加锁精确(只标记真正改动的 VMA),解锁廉价(O(1) 递增,不必逐个清理)。这正是 per-VMA lock 能做到低开销的关键之一。四、读锁:RCU 找 VMA 乐观拿锁缺页快路径的入口是lock_vma_under_rcu(mm, address)。它把找 VMA和锁 VMA合成一个在RCU 读侧临界区内完成的乐观流程:没找到找到候选 vmaNULLEAGAIN成功否: VMA 已变是vma_start_read 内部相等: 有写者不等失败, 计数0失败, 计数0成功否是相等不等vm_lock_seq mm_lock_seq ?返回 NULL 失败受限自增 vm_refcnt成功?(VMA_LOCK_OFFSET 位)返回 -EAGAIN(已 detached)vm_mm 仍是本 mm?放走引用, 返回 NULL再查 vm_lock_seq mm_lock_seq ?(ACQUIRE)拿到读锁缺页快路径rcu_read_lockmas_walk(): 在 maple tree 中查找覆盖 address 的 VMAgoto inval 退回 mmap_lockvma_start_read(mm, vma)mas_set 重置, goto retryrcu_read_unlockaddress 仍落在[vm_start, vm_end)?vma_end_read 退回在 FAULT_FLAG_VMA_LOCK 下处理缺页拆开看几个要点:第一步 RCU 查找。mas_walk()在 maple tree 里定位覆盖故障地址的 VMA。RCU 读锁保证遍历期间 VMA 结构体不会被释放(VMA 用SLAB_TYPESAFE_BY_RCU分配)。第二步乐观拿锁vma_start_read()。它先做一次无锁的悲观预检:如果vm_lock_seq mm_lock_seq,直接判定有写者,返回 NULL。通过预检后才用受限原子自增去抢vm_refcnt——若写者已置上VMA_LOCK_OFFSET位,这一步必然失败;若计数本就是 0,说明 VMA 已被摘除,返回-EAGAIN。抢到引用后还要再确认两件事:vma-vm_mm仍指向本mm(防止 VMA 被回收后 RCU 复用、挂到了别的 mm),以及带 ACQUIRE 语义地复查一次vm_lock_seq mm_lock_seq(与vma_end_write_all()的 RELEASE 配对,堵住复查与解锁并发的缝隙)。第三步出临界区再验地址。rcu_read_unlock()之后,因为可能与并发的 VMA 修改赛跑,还要最后确认address依然落在[vm_start, vm_end)内;若 VMA 边界已被改动,vma_end_read()放锁并退回。-EAGAIN的重试语义。若在拿锁过程中发现 VMA 被 detached(被另一个 VMA 替换),lock_vma_under_rcu()会mas_set()重置迭代器并goto retry重新查一遍,而不是直接失败——因为该地址现在很可能对应一个新的、有效的 VMA。拿到读锁后,缺页流程带上FAULT_FLAG_VMA_LOCK标志进入handle_mm_fault(),全程不碰mmap_lock。五、“假加锁与绝不假解锁”:安全边界这是理解 per-VMA lock 正确性的核心不变量,源码注释里写得很直白:“The function is allowed to occasionally yield falselockedresult … The function should never yield falseunlockedresult.”翻译成人话:允许假的加锁失败(false locked 误报锁不上)。例如mm_lock_seq溢出回绕、或 VMA 被回收复用等罕见情形,可能让vma_start_read()保守地判定拿不到锁。这不影响正确性,只是白白退回一次mmap_lock慢路径,损失一点性能。绝不允许假的加锁成功(false unlocked 明明有写者却误判为没锁)。这种错误会让读者在写者正在改 VMA 时闯进去,导致数据竞争——所以内核宁可多退回,也绝不冒这个险。之所以能保证,是因为读者对vm_lock_seq的修改与检查都在vm_refcnt保护下进行,而mm_lock_seq的递增会作废所有既有读锁,配合 ACQUIRE/RELEASE 内存序,杜绝了漏判写者的可能。一句话:per-VMA lock 把错误单向地压到了最多损失性能这一侧,永远不会损失正确性。这正是它敢于做得乐观、无锁预检的底气。六、与 mmap_lock 的共存关系per-VMA lock不是mmap_lock的替代品,而是它前面的一层快路径旁路。两者的关系可以这样概括:维度per-VMA lockmmap_lock粒度单个 VMA整个地址空间(VMA 树)典型使用者缺页快路径读者缺页慢路径 所有结构性修改拿不到时退回 mmap_lock 重试——写者如何与它协调改 VMA 前vma_start_write()打标记;放写锁时vma_end_write_all()抬代际直接持写锁保证乐观、可失败、绝不假解锁传统读写信号量语义运行时的配合是:写者始终先拿mmap_lock写锁,再逐个vma_start_write()标记要改的 VMA;读者则先试 per-VMA lock,失败就退回mmap_lock读锁再走一遍传统慢路径。于是结构性改动与快路径缺页之间不再需要在一把大锁上硬碰硬,而快路径搞不定的复杂缺页仍有mmap_lock兜底。七、device-private / GPU SVM 为何主动退回per-VMA lock 是渐进式铺开的:先覆盖最常见、最独立的匿名页 / 文件页缺页,尚未适配的路径一律主动退回mmap_lock。device-private 的-migrate_to_ram就是尚未适配的一员。do_swap_page()里有一段明确的退回逻辑:}elseif(softleaf_is_device_private(entry)){if(vmf-flagsFAULT_FLAG_VMA_LOCK){/* migrate_to_ram is not yet ready to operate under VMA lock. */vma_end_read(vma);retVM_FAULT_RETRY;/* 回退到 mmap_read_lock 重试 */gotoout;}...}为什么migrate_to_ram目前坚持要mmap_lock。设备迁移回调(如migrate_vma_*)会跨越单个 PTE 的边界:它要walk_page_range遍历一段地址、收集多个 PTE、发MMU_NOTIFY_MIGRATE通知、可能触发 VMA 相关的分配与状态更新。这些动作依赖整个地址空间布局在此期间稳定,而 per-VMA lock 只锁住一个VMA、语义上也更弱,尚不足以支撑,所以内核选择保守退回:一旦发现是在FAULT_FLAG_VMA_LOCK下命中 device-private entry,立即vma_end_read()VM_FAULT_RETRY。对 GPU SVM 的直接影响。因此 GPU SVM 的 fault 回调真正执行时,一定在mmap_read_lock之下,不可能在 per-VMA lock 下。这条退回逻辑是fault 路径 VMA 稳定这一前提的制度保证——不是驱动自己去争取,而是内核在缺页入口就替它挡掉了不安全的情形。反过来,eviction 路径(migrate_device_*,按 PFN)本就与 VMA /mmap_lock无关,不受这条逻辑影响。版本敏感提醒:若未来migrate_to_ram也适配了 per-VMA lock,上面这段VM_FAULT_RETRY会被移除,fault 路径的锁前提也随之改写。所以任何fault 路径在 mmap_lock 下的结论,都应绑定到具体内核版本。八、局限与演进方向当前局限。只服务读侧快路径:结构性修改(mmap/munmap/split/merge)仍需mmap_lock写锁。覆盖面渐进:device-privatemigrate_to_ram、部分特殊 VMA(如 hugetlb 的某些路径)、以及一些需要跨 VMA 视角的操作尚未适配,遇到即退回。乐观即可能空跑:序列号回绕、VMA 复用等会导致偶发的假加锁失败,在极端场景下增加退回频率(内核用VMA_LOCK_MISS/VMA_LOCK_ABORT等统计计数暴露这些事件)。演进方向。社区在持续把更多缺页与访问路径VMA-lock 化,目标是让mmap_lock尽量只在真正的结构性修改时出现。对异构内存 / GPU SVM 而言,最值得关注的是migrate_to_ram一类回调何时能在 per-VMA lock 下安全运行——那将直接改变本文第七节描述的锁前提。九、代码位置以下为本文所依据实现的关键位置(以本仓库为准,行号可能随版本漂移):字段定义:struct vm_area_struct的vm_lock_seq/vm_refcnt——include/linux/mm_types.h读锁核心:vma_start_read()/lock_vma_under_rcu()——mm/mmap_lock.c写锁核心:__vma_start_write()/__vma_enter_locked()/vma_mark_detached()——mm/mmap_lock.c全体解锁:vma_end_write_all()(对mm_lock_seq带 RELEASE 递增)——include/linux/mmap_lock.h缺页退回点:device-private 的FAULT_FLAG_VMA_LOCK→VM_FAULT_RETRY——mm/memory.c(do_swap_page())写锁调用方举例:mm/mprotect.c/mm/mremap.c/mm/madvise.c/mm/khugepaged.c/mm/vma.c/mm/mempolicy.c/mm/userfaultfd.c延伸阅读进化史(为什么会一步步演化出这些锁):从异构内存管理角度看 Linux MM 锁机制的进化史 —— 一把大锁到异构内存迁移。注:本文以内核当前的 refcount 版 per-VMA lock 实现为准;早期基于rw_semaphore的实现字段名与流程不同,阅读老内核源码时请以对应版本为准。