从异构内存管理角度看 Linux MM 锁机制的进化史 —— 一把大锁到异构内存迁移

📅 2026/7/22 21:09:46
从异构内存管理角度看 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_ownerMMU_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)。依赖:正是阶段二的rmapsplit PTLfolio 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-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,立即放弃快路径。推论(对 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. 一张演进全景图阶段五 · 缺页提速阶段四 · 异构内存迁移阶段二 · 粒度细化阶段一 · 大锁时代驱动腾显存触发更名 mmap_lock,拆分做准备阶段三 · 同步外部/访问者mmu_notifier(range / interval,事件类型 owner)migration entry(阻塞 CPU 访问者于 folio lock)mmap_sem(整块地址空间)page_table_lock(每 mm 一把)split PTL(每页表页一把)rmap(物理页 → 所有映射)page lock → folio lockHMM / ZONE_DEVICE / device-privatemigrate_vma_*(按地址,持 mmap_lock,MMU_NOTIFY_MIGRATE)migrate_device_*(按 PFN,免 VMA,rmap,MMU_NOTIFY_CLEAR)per-VMA lock (SPF)device-private 主动退回 mmap_lock只拿物理页也能操作全部映射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 / 内核版本,可按各机制名逐一查证。