从异构内存管理角度看 Linux MM 锁机制的进化史 —— 一把大锁到异构内存迁移
本文系统地梳理 Linux MM 子系统中与"内存迁移"相关的锁与同步机制的演进脉络:每把锁因何而生、解决了什么瓶颈、又带来什么新问题。
贯穿全文的一条主线:锁粒度不断从"一把大锁保护整块"细化为"多把小锁各管一小块";同时不断引入新机制,去同步 CPU 页表之外的观察者(设备/虚拟机)和迁移中的访问者。
目录
- 阶段一:一把大锁的时代
- 阶段二:锁粒度细化
- 阶段三:同步 CPU 之外的观察者
- 阶段四:异构内存与设备迁移
- 阶段五:缺页路径再提速
- 一张演进全景图
- 对"迁移"这件事的累积影响
1. 阶段一:一把大锁的时代
1.1mmap_sem—— 地址空间的全局读写锁
最早,一个进程地址空间里几乎一切都由mm->mmap_sem(读写信号量)保护:VMA 树的查找与修改;缺页处理handle_mm_fault();页表遍历、get_user_pages();以及mmap/munmap/mprotect/mremap/brk等系统调用。
优点:模型极简——“改地址空间相关的东西,先拿这把锁”。
瓶颈:它是进程级的单锁。多线程程序里,一个线程缺页(读锁)会与另一个线程mmap(写锁)互斥;高并发缺页彼此也在读锁的 cache line 上颠簸。对数据库、JVM、大型 C++ 服务这类"多线程 + 大量按需分页"的负载,mmap_sem长期是头号扩展性瓶颈。
演进动作:2020 年前后社区把mmap_sem更名为mmap_lock,并引入一整套封装 API(mmap_read_lock()/mmap_write_lock()/...)。更名本身不改变语义,但把"所有加锁点"收敛到统一入口,为后续"把职责从这把大锁里拆出去"铺路。
1.2page_table_lock—— 每 mm 一把的页表锁
页表项的修改最初由mm->page_table_lock(每个 mm 一把自旋锁)串行化。
瓶颈:多核同时修改不同页表区域时,仍要争抢同一把锁。对大地址空间、多核并发缺页,这把锁的争抢很快显现。
阶段一的共同问题:锁的作用域太大。保护范围与并发实体(线程/CPU)不匹配,天然限制扩展性。阶段二就是针对性地"拆"。
2. 阶段二:锁粒度细化
2.1 split PTL —— 把页表锁拆到"每个页表页一把"
动机:page_table_lock太粗。
做法:引入split page table lock——锁不再是每 mm 一把,而是挂在每个页表页(pmd/pte 页)的struct page上,每页一把。取锁接口:PTE 级用pte_offset_map_lock(mm, pmd, addr, &ptl);PMD 级(THP)用pmd_lock(mm, pmd)。
收益:修改不同页表页的 PTE 之间不再互斥,页表操作真正并行化。这就是基础篇里反复出现的PTL。后来还配合RCU 释放页表页,让无锁的页表读者(如 GUP-fast、lockless page fault 的部分阶段)能安全地遍历。
关键遗产:“改一个 PTE 必须拿覆盖它的那把 PTL”成为全内核的硬约定。munmap 的
zap_pte_range、mprotect 的change_pte_range、mremap 的move_ptes、迁移的 collect/finalize、rmap 的try_to_migrate_one——全都遵守它。这正是"某条路径即使不持 mmap_lock,只要在 PTL 下改 PTE 就与其他改 PTE 者串行化"的根基。
2.2 object-based rmap —— 从物理页反查所有映射
动机:早期没有高效的"物理页 → 谁映射了它"的反查。换出或迁移一个被多进程共享的页时,找齐所有 PTE 代价高昂,甚至只能扫全表。
做法:引入基于对象的reverse mapping(rmap):匿名页通过anon_vma/anon_vma_chain把一个页关联到映射它的所有 VMA;文件页通过address_space的区间树。
收益:rmap_walk()可以枚举一个 folio 的所有映射并逐一处理。这是后来try_to_migrate()能"只拿着物理页、不知道任何虚拟地址"就把该页从所有地址空间里解除映射的前提——也是migrate_device_*(按 PFN)整条路线成立的基础。
2.3 page lock → folio lock —— 冻结单个物理页
动机:需要一个"对单页操作的序列化点",让迁移/回收/写回期间别人别插手。
做法:用struct page的PG_locked位(lock_page/unlock_page)。近年folio 化改造后统一为folio lock(folio_lock/folio_trylock/folio_unlock),天然覆盖复合页(THP)。
收益:迁移用它来"冻结" folio——从装 migration entry 到 finalize 全程持锁;等在 migration entry 上的访问者,本质就是在等这把 folio lock(基础篇 §5.1 已核实等待对象正是PG_locked)。
3. 阶段三:同步 CPU 之外的观察者
前两阶段都在解决"CPU 侧页表/物理页的并发"。但现代系统里,对同一物理页持有映射的不只是 CPU。
3.1 mmu_notifier —— 让 GPU/IOMMU/KVM 与 CPU 页表同步
动机:KVM 的影子页表、GPU 的设备页表、IOMMU 的映射,都缓存了"虚拟/设备地址 → 物理页"的翻译。当 CPU 改动或迁移某页时,这些外部翻译必须先失效,否则设备会访问到已被搬走/释放的旧页,造成数据损坏或安全问题。
做法:2008 年前后引入mmu_notifier,有两种形态。经典范围通知:mmu_notifier_invalidate_range_start()/end()成对包住页表修改,约定失效通知必须发生在改 PTE 之前(start)与之后(end)。interval notifier(较新):mmu_interval_notifier,驱动在一段 VA 上注册,该段内任何失效都会回调其.invalidate,GPU SVM(如 drm_gpusvm)用的就是它。
事件类型enum mmu_notifier_event:同一套回调用事件类型区分语义,常见如下表。
| 事件 | 大意 | 是否带pgmap_owner |
|---|---|---|
MMU_NOTIFY_UNMAP | 地址被解除映射 | 否 |
MMU_NOTIFY_CLEAR | 通用清除/失效 | 否 |
MMU_NOTIFY_MIGRATE | 按地址范围迁移 | 是 |
MMU_NOTIFY_MIGRATE带owner的意义:让按 owner 过滤的驱动跳过对"自己拥有、且本次不会真正迁移的 device-private 映射"的失效,避免自我失效。是否利用这个过滤完全取决于驱动回调实现——这一点会直接影响后续具体路径分析的结论。
3.2 migration entry —— 对 CPU 访问者的"路障"
动机:迁移一个页需要时间窗口。窗口内如果有 CPU 访问该 VA,既不能丢访问,也不能让它读到"搬到一半"的页。
做法:把 PTE 临时换成一种特殊swap entry(migration entry)。窗口内任何访问该 VA 会缺页进do_swap_page(),识别出 migration entry 后migration_entry_wait()阻塞在目标 folio 的PG_locked上,直到迁移完成解锁再重试。
migration entry(路障)+ folio lock(等待对象)+ PTL(原子替换/移除)三者合起来,构成迁移窗口内对 CPU 侧访问的完整保护。
4. 阶段四:异构内存与设备迁移
4.1 HMM /ZONE_DEVICE/ device-private page
动机:GPU/加速器有自己的高带宽显存。希望让这块显存能像普通内存一样参与进程地址空间(SVM/统一地址空间),按需在 CPU RAM 与设备显存之间迁移。
做法:2017 年前后的HMM(Heterogeneous Memory Management)引入ZONE_DEVICE页与device-private页:设备显存以ZONE_DEVICE的struct page表示;迁到设备后,CPU 侧 PTE 变成device-private swap entry(非 present,但仍在 rmap 中、仍计入 mapcount);CPU 访问该 entry 会触发->migrate_to_ram回调,由驱动把页迁回 RAM。
4.2 两个迁移入口:按地址 vs 按 PFN
在上述积木之上,内核提供了两套面向设备的迁移 API,分别服务不同场景。
(a)migrate_vma_*—— 按虚拟地址范围迁移。场景:CPU 缺页触发(migrate_to_ram)、或用户/驱动请求把一段 VA 迁到显存(migrate_to_devmem)。特征:有 VMA、持 mmap_lock(至少读),走walk_page_range收集 PTE,并发MMU_NOTIFY_MIGRATE(带pgmap_owner)。局限:单 VMA / 单 mm、按地址范围。
(b)migrate_device_*—— 按物理 PFN 迁移。场景:驱动主动腾显存 / 设备 unbind / shrinker——驱动只知道要搬哪些物理 device PFN,不一定持有(也不想遍历)它们的虚拟映射,且这些页可能被多个进程共享。特征:不碰 VMA、不持 mmap_lock,靠rmap(try_to_migrate)找到并解除该页在所有地址空间中的映射,其 PTE 失效通知走MMU_NOTIFY_CLEAR(经try_to_migrate_one,不带 owner)。依赖:正是阶段二的rmap+split PTL+folio lock让"只拿物理页也能安全迁移"成为可能。
这两个入口的差异(是否要 VMA/mmap_lock、如何定位 PTE、发什么 mmu 事件),正是 drm_pagemap 里fault 路径 vs eviction 路径分歧的根源。
5. 阶段五:缺页路径再提速
5.1 per-VMA lock(SPF,Speculative Page Fault)
动机:即便有了 split PTL,缺页仍要先拿进程级mmap_lock读锁,高并发缺页依旧在这把锁上排队。典型场景是多线程程序:线程 A 在缺页(需要mmap_lock读锁),线程 B 在mmap/brk(需要写锁),二者互斥;即使 A、B 操作的是完全不相干的两段 VMA,也被这把全局锁强行串行化。split PTL 解决的是"改 PTE"的并发,却没解决"进入缺页处理前那道门槛"的并发。
核心思路:既然一次缺页通常只关心一个 VMA,那就把"读锁"下沉到VMA 粒度——只锁住命中的那个 VMA,而不是锁住整棵 VMA 树。
做法:2023 年前后合入per-VMA lock。关键机制有三层。
- RCU 查找 VMA:缺页快路径用
lock_vma_under_rcu(),在RCU 读侧临界区里(不持mmap_lock)从 maple tree 查到覆盖故障地址的 VMA。RCU 保证遍历期间 VMA 结构体不会被释放。 - seqcount 校验 + VMA 读锁:找到候选 VMA 后,用
vma_start_read()尝试拿该 VMA 的读锁。它基于seqcount:写者(改这个 VMA 的人)会通过vma_start_write()递增序列号;读者拿锁时比对序列号,若发现有写者正在改这个 VMA,就放弃快路径。成功拿到后,置标志FAULT_FLAG_VMA_LOCK进入handle_mm_fault(),全程不碰mmap_lock。 - 失败即回退:任何一步不满足(VMA 没找到、正被写者修改、或落进尚未适配 per-VMA lock 的慢路径),就
vma_end_read()释放 VMA 读锁,返回VM_FAULT_RETRY,由上层重新以传统mmap_read_lock()走一遍。回退保证了正确性:快路径只做"能安全做"的那部分,拿不准就退回老路。
直觉:
mmap_lock是"整栋楼的大门钥匙",per-VMA lock 是"每个房间自己的钥匙"。改 A 房间不再挡住进 B 房间的人;但凡遇到需要动整栋楼结构(跨 VMA、合并/split)的操作,还是得回去拿大门钥匙。
边界:哪些缺页不能在 per-VMA lock 下完成。per-VMA lock 是渐进式铺开的——先覆盖最常见、最独立的匿名页/文件页缺页,尚未适配的路径一律主动退回。device-private 的migrate_to_ram正是尚未适配的一员:
}elseif(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 重试 */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,立即放弃快路径。
推论(对 GPU SVM 的直接影响):所以 GPU SVM 的 fault 回调真正执行时,一定在mmap_read_lock之下,而不可能在 per-VMA lock 下。这条"退回"逻辑正是"fault 路径 VMA 稳定"这一前提的制度保证——不是驱动自己去争取,而是内核在入口就替它把不安全的情形挡掉了(详见基础篇 §1.1)。
未来演进方向:社区在逐步让更多路径"VMA-lock 化"。若某天
migrate_to_ram也适配了 per-VMA lock,上面这段VM_FAULT_RETRY会被移除,fault 路径的锁前提也会随之改写——这也是为什么本文强调"结论要绑定到具体内核版本"。
想深入 per-VMA lock 的底层实现(
vm_refcnt/vm_lock_seq/mm_lock_seq三字段、读写锁流程、"假加锁"语义)?见专文:深入 per-VMA lock:Linux 缺页路径如何摆脱 mmap_lock。
6. 一张演进全景图
7. 对"迁移"这件事的累积影响
把历史落到"今天一次设备内存迁移到底靠什么":
| 迁移要保护的东西 | 由哪一阶段的产物负责 |
|---|---|
| 地址空间布局(VMA) | 阶段一mmap_lock/ 阶段五 per-VMA lock |
| 单个 PTE 的原子替换 | 阶段二split PTL |
| 找齐一个页的所有映射 | 阶段二rmap |
| 冻结物理页(迁移窗口) | 阶段二folio lock+ refcount |
| 挡住迁移中的 CPU 访问 | 阶段三migration entry |
| 同步 GPU/IOMMU 外部映射 | 阶段三mmu_notifier |
| 按地址 / 按 PFN 两种入口 | 阶段四migrate_vma_/ migrate_device_** |
一句话总结:
今天 drm_pagemap 的fault 路径(migrate_vma_)与eviction 路径(migrate_device_)之所以能各自安全、且分别"要/不要 mmap_lock",不是某一个设计决定的,而是上面五个阶段积木叠加的自然结果。理解了这条历史线,再看两条路径的锁差异就会觉得"本该如此"。
注:文中年份为社区大致引入时间,用作时间坐标而非精确版本号;如需落到具体 commit / 内核版本,可按各机制名逐一查证。
