本文系统地拆解 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)
- 二、三个关键字段:锁其实是"数字比大小"
- 三、写锁:一次锁一个,或一次全解锁
- [四、读锁:RCU 找 VMA + 乐观拿锁](#四、读锁:RCU 找 VMA + 乐观拿锁)
- 五、"假加锁"与"绝不假解锁":安全边界
- [六、与 mmap_lock 的共存关系](#六、与 mmap_lock 的共存关系)
- [七、device-private / GPU SVM 为何主动退回](#七、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_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 的路径都调用它------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 读侧临界区内完成的乐观流程:
#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_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的实现字段名与流程不同,阅读老内核源码时请以对应版本为准。