Linux 深入 per-VMA lock:Linux 缺页路径如何摆脱 mmap_lock

本文系统地拆解 Linux 的 per-VMA lock (每-VMA 锁,常与 SPF / Speculative Page Fault 一并提及):它想解决什么、底层用了哪些字段、读锁与写锁各自怎么工作、为什么允许"偶尔假报加锁失败"、以及它与 device-private(GPU SVM)迁移路径的关系。本文以内核当前的 refcount 版实现 为准(vm_refcnt + vm_lock_seq + mm_lock_seq),而非早期基于 rw_semaphore 的旧版。

贯穿全文的一句话:per-VMA lock 是一把"乐观、可失败、可退回"的读锁------它只在最常见的缺页快路径上生效,一旦情况复杂就干净利落地退回传统 mmap_lock,用"正确性优先、性能尽力"的方式换来高并发缺页的可扩展性。


目录


一、为什么需要 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_seq vs mm_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 的路径都调用它------mprotectmremapmadvisekhugepaged、VMA 的 split/merge(mm/vma.c)、mempolicyuserfaultfd 等等。

一次性解锁全部 VMA:vma_end_write_all() 它并不遍历所有 VMA,而是对 mm_lock_seq 做一次带 RELEASE 语义 的递增。代际一变,所有停留在旧代际的 vm_lock_seq 立刻被判为"已解锁"。这与读侧 vma_start_read() 里对 mm_lock_seqACQUIRE 读配对,保证读者要么看到"仍锁定"从而退回,要么在解锁完成之后才开始读 VMA 内容------不会读到"解锁一半"的中间态。这个动作通常发生在放开 mmap_lock 写锁时。

设计精妙之处:写锁标记是"逐个 VMA 打点",解锁却是"整体推进代际"。 加锁精确(只标记真正改动的 VMA),解锁廉价(O(1) 递增,不必逐个清理)。这正是 per-VMA lock 能做到低开销的关键之一。


四、读锁:RCU 找 VMA + 乐观拿锁

缺页快路径的入口是 lock_vma_under_rcu(mm, address)。它把"找 VMA"和"锁 VMA"合成一个在 RCU 读侧临界区内完成的乐观流程:
#mermaid-svg-WOzfvElLtWf3L6Xw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WOzfvElLtWf3L6Xw .error-icon{fill:#552222;}#mermaid-svg-WOzfvElLtWf3L6Xw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WOzfvElLtWf3L6Xw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WOzfvElLtWf3L6Xw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WOzfvElLtWf3L6Xw .marker.cross{stroke:#333333;}#mermaid-svg-WOzfvElLtWf3L6Xw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WOzfvElLtWf3L6Xw p{margin:0;}#mermaid-svg-WOzfvElLtWf3L6Xw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WOzfvElLtWf3L6Xw .cluster-label text{fill:#333;}#mermaid-svg-WOzfvElLtWf3L6Xw .cluster-label span{color:#333;}#mermaid-svg-WOzfvElLtWf3L6Xw .cluster-label span p{background-color:transparent;}#mermaid-svg-WOzfvElLtWf3L6Xw .label text,#mermaid-svg-WOzfvElLtWf3L6Xw span{fill:#333;color:#333;}#mermaid-svg-WOzfvElLtWf3L6Xw .node rect,#mermaid-svg-WOzfvElLtWf3L6Xw .node circle,#mermaid-svg-WOzfvElLtWf3L6Xw .node ellipse,#mermaid-svg-WOzfvElLtWf3L6Xw .node polygon,#mermaid-svg-WOzfvElLtWf3L6Xw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WOzfvElLtWf3L6Xw .rough-node .label text,#mermaid-svg-WOzfvElLtWf3L6Xw .node .label text,#mermaid-svg-WOzfvElLtWf3L6Xw .image-shape .label,#mermaid-svg-WOzfvElLtWf3L6Xw .icon-shape .label{text-anchor:middle;}#mermaid-svg-WOzfvElLtWf3L6Xw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WOzfvElLtWf3L6Xw .rough-node .label,#mermaid-svg-WOzfvElLtWf3L6Xw .node .label,#mermaid-svg-WOzfvElLtWf3L6Xw .image-shape .label,#mermaid-svg-WOzfvElLtWf3L6Xw .icon-shape .label{text-align:center;}#mermaid-svg-WOzfvElLtWf3L6Xw .node.clickable{cursor:pointer;}#mermaid-svg-WOzfvElLtWf3L6Xw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WOzfvElLtWf3L6Xw .arrowheadPath{fill:#333333;}#mermaid-svg-WOzfvElLtWf3L6Xw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WOzfvElLtWf3L6Xw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WOzfvElLtWf3L6Xw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WOzfvElLtWf3L6Xw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WOzfvElLtWf3L6Xw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WOzfvElLtWf3L6Xw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WOzfvElLtWf3L6Xw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WOzfvElLtWf3L6Xw .cluster text{fill:#333;}#mermaid-svg-WOzfvElLtWf3L6Xw .cluster span{color:#333;}#mermaid-svg-WOzfvElLtWf3L6Xw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WOzfvElLtWf3L6Xw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WOzfvElLtWf3L6Xw rect.text{fill:none;stroke-width:0;}#mermaid-svg-WOzfvElLtWf3L6Xw .icon-shape,#mermaid-svg-WOzfvElLtWf3L6Xw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WOzfvElLtWf3L6Xw .icon-shape p,#mermaid-svg-WOzfvElLtWf3L6Xw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WOzfvElLtWf3L6Xw .icon-shape rect,#mermaid-svg-WOzfvElLtWf3L6Xw .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WOzfvElLtWf3L6Xw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WOzfvElLtWf3L6Xw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WOzfvElLtWf3L6Xw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 没找到
找到候选 vma
NULL
EAGAIN
成功
否: 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_lock
mas_walk(): 在 maple tree 中

查找覆盖 address 的 VMA
goto inval 退回 mmap_lock
vma_start_read(mm, vma)
mas_set 重置, goto retry
rcu_read_unlock
address 仍落在

[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 false locked result ... The function should never yield false unlocked result."

翻译成人话:

  • 允许"假的加锁失败"(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 lock mmap_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() 里有一段明确的退回逻辑:

c 复制代码
} else if (softleaf_is_device_private(entry)) {
    if (vmf->flags & FAULT_FLAG_VMA_LOCK) {
        /* migrate_to_ram is not yet ready to operate under VMA lock. */
        vma_end_read(vma);
        ret = VM_FAULT_RETRY;   /* 回退到 mmap_read_lock 重试 */
        goto out;
    }
    ...
}

为什么 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-private migrate_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_structvm_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_LOCKVM_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

延伸阅读

注:本文以内核当前的 refcount 版 per-VMA lock 实现为准;早期基于 rw_semaphore 的实现字段名与流程不同,阅读老内核源码时请以对应版本为准。

相关推荐
爱写代码的森3 小时前
蒙三方库 | harmony-utils之FileUtil文件重命名与属性查询详解
linux·运维·服务器·华为·harmonyos·鸿蒙·huawei
XMAIPC_Robot4 小时前
软硬协同实时控制|RK3588业务调度+FPGA硬件时序,ethercat实现半导体设备微秒级响应(125us)
linux·arm开发·人工智能·fpga开发
重生的黑客4 小时前
Linux 进程优先级、切换与调度:从孤儿进程到 O(1) 调度模型
linux·运维·服务器·进程优先级·nice
骑上单车去旅行6 小时前
MD5校验对比脚本
linux·服务器·windows
平生幻6 小时前
Linux 常用命令
linux
ShirleyWang0127 小时前
让headlamp控制台能访问
linux·服务器·python·k8s·k3s
liuccn7 小时前
Linux 存储系统:LVM 与直接分区
linux·运维
IT曙光9 小时前
在Ubuntu上本地部署Dify?
linux·ubuntu
xexpertS9 小时前
CI/CD 熔断机制:如何通过编排级熔断器提升开发者效率
java·linux·运维