在虚拟内存的抽象模型中,用户进程只感知虚拟地址,物理页面的存放位置对上层完全透明。这意味着内核可以在不修改进程虚拟地址空间的前提下,将一个物理页面的内容完整复制到另一个物理页面,同步更新所有页表映射,而进程毫无察觉。页面迁移(Page Migration)正是利用这一透明性实现的核心机制,也是 Linux 内存管理中最能体现"抽象与效率平衡"设计哲学的模块之一。
我们对页面迁移的认知可能停留在"内存规整时会搬页",但实际上它贯穿了 NUMA 均衡、连续内存分配(CMA)、内存热插拔、异构内存管理、气球页回收等多个核心子系统。本文基于 Linux 6.2 内核源码(mm/migrate.c),从触发场景、可迁移性分类、三阶段执行模型到全链路源码逐层拆解,深入解析页面迁移的底层实现与工程权衡。
二、页面迁移的六大触发场景
页面迁移从来不是目的,而是达成更高层目标的手段。内核中至少有六条独立的触发路径会调用迁移机制,最终全部汇入通用迁移引擎。
2.1 内存规整(Memory Compaction)
最常见的迁移触发源。当系统出现外部碎片、高阶分配失败时,内核通过内存规整将分散的可移动页面"挤"到一起,腾出连续的物理内存块。规整器从 zone 低地址扫描可移动页,从高地址扫描空闲页,将低地址的使用中页面迁移到高地址空闲页,最终在低地址形成大块连续空闲内存。
2.2 NUMA 自动均衡
多 Socket 的 NUMA 架构中,跨节点内存访问延迟比本地访问高出 1.5~3 倍。Linux 的 NUMA_BALANCING 机制会周期性地将进程页表项置为无权限,通过缺页异常捕获访问模式,当发现页面被远端节点频繁访问时,主动将页面迁移到访问者所在的本地节点,降低访问延迟。
2.3 连续内存分配器(CMA)
CMA 预留了一片物理内存区域,平时可被用户态页面占用;当驱动需要大块连续物理内存(如 DMA 缓冲区)时,再将区域内的用户页面迁移出去,腾出连续空间给硬件使用,是嵌入式、ARM 平台的核心机制。
2.4 内存热插拔
支持内存热插拔的服务器上,要下线一个物理内存槽位时,必须先将该槽位上的所有活跃页面迁移到其他内存区域,才能安全地将内存从系统中移除。
2.5 异构内存管理(HMM)
GPU、FPGA 等设备通常有独立的设备内存,HMM 框架利用标准页面迁移接口,将系统内存中的页面透明迁移到设备私有内存,或从设备内存迁回系统内存,对应用层保持地址空间一致性。
2.6 用户态主动迁移
内核提供两个系统调用支持用户态主动触发迁移,是高性能计算、NUMA 手动调优的常用手段:
migrate_pages:按 NUMA 节点批量迁移,将指定进程的页面从一组源节点整体迁移到一组目标节点move_pages:按虚拟地址精确迁移,针对指定的一组虚拟地址,分别迁移到对应的目标节点
numactl --migrate、numa_move_pages 等工具底层均依赖这两个系统调用。
三、可迁移性:页面的分类与边界
不是所有页面都能被迁移。伙伴系统按迁移属性将页面分为三类,对应不同的空闲链表:
| | |
|---|
| 内核栈、slab 核心对象、长期钉住的 DMA 缓冲区 | |
| | 不可直接迁移,可通过 shrinker 释放后再分配 |
| 用户匿名页、页缓存、tmpfs 页面、气球页、驱动自定义可移动页 | |
其中可移动页又可细分为两类:
- LRU 可移动页:绝大多数用户态页面,通过 LRU 链表管理,由通用迁移逻辑处理
- 非 LRU 可移动页:如 virtio 气球页、部分驱动自定义页面,通过
struct movable_operations 回调集实现隔离、迁移、放回操作,内核只做调度,具体逻辑由驱动自行实现
常见误区
mlock 只保证页面不被换出到 swap,并不能阻止迁移——迁移过程中页面始终驻留在物理内存中,只是位置发生变化。真正让页面不可迁移的是页表映射以外的额外内核引用:如果一个页面被 get_user_pages() 长期钉住(如 DPDK 缓冲区),迁移无法进行,因为内核无法更新这些持有物理地址指针的引用。
四、页面迁移的三阶段核心模型
单页迁移的完整流程包含二十余个细粒度步骤,从宏观上可抽象为三个阶段。整个过程的核心矛盾始终是:如何在进程可能并发访问的前提下,安全、透明地完成物理地址的切换。
4.1 第一阶段:锁定与隔离
迁移开始前,必须先将页面从原管理链表(LRU 或驱动私有链表)中隔离,标记 PageIsolated,防止回收路径和迁移路径同时操作同一页面。随后同时锁定源页与目标页的 PG_locked 标志,作为整个迁移周期的同步锚点。
异步迁移模式下(如后台规整、NUMA 自动均衡)如果拿不到锁会直接跳过该页,避免阻塞;同步模式下则会等待锁释放。
4.2 第二阶段:解除映射与迁移项占位
这是整个迁移中非常精妙的设计。内核通过反向映射(RMAP)找到所有映射了该页面的进程页表项,逐一解除映射,但不会把 PTE 清零,而是写入一个特殊的migration entry(迁移项)。
迁移项复用了 swap entry 的编码格式,属于 non-swap entry 的一种,它不指向磁盘扇区,而是指向源页面的 struct page 指针,同时保留原页的脏位、年轻位、软脏位、UFFD-WP 位等属性。当进程在迁移期间访问该虚拟地址时:
- 识别出是 migration entry 而非真正的 swap entry
- 迁移完成解锁后,进程被唤醒,重新走缺页流程即可命中新映射
这个设计保证了迁移期间用户进程不会收到 SIGSEGV,只会感受到一次短暂的访问延迟——这就是"透明迁移"的核心。
4.3 第三阶段:内容复制与映射重建
解除所有映射后,源页处于完全稳定状态,此时执行核心迁移操作:
- 冻结源页引用计数,替换
address_space 中的页槽位(XArray 原子替换) - 复制页面数据,同步所有页面标志位(脏位、活跃位、工作集标记等)
- 用新页面的物理地址重建所有页表项,恢复原有的权限与属性
五、全链路源码深度解析
以下源码全部基于 Linux 6.2 mm/migrate.c,从用户态系统调用到底层原子操作,自顶向下逐层拆解。
5.1 完整调用链路总览
页面迁移是典型的分层架构,不同层级各司其职,最终汇聚到统一的执行引擎:
5.2 系统调用入口层:权限与边界约束
用户态触发的迁移首先经过严格的准入校验,以 kernel_migrate_pages 为例,核心约束包括四层:
NODEMASK_SCRATCH(scratch);old = &scratch->mask1;new = &scratch->mask2;
nodemask_t 大小随系统最大 NUMA 节点数增长,大型服务器上可达数百字节。内核通过 NODEMASK_SCRATCH 从预分配池获取掩码内存,避免栈上分配大对象导致溢出,是内核开发的经典栈优化技巧。
- 进程权限校验复用
ptrace_may_access 权限模型:只有能调试目标进程的用户,才能迁移其内存,保证操作的侵入性与权限等级匹配。 - cpuset 资源隔离约束目标节点必须同时落在调用进程和目标进程的 cpuset 允许掩码范围内,不能突破 cgroup 资源隔离边界。普通用户只能迁移私有页(
MPOL_MF_MOVE),持有 CAP_SYS_NICE 特权才能迁移共享页(MPOL_MF_MOVE_ALL)。 - LSM 安全钩子通过
security_task_movememory 预留安全扩展点,支持 SELinux、AppArmor 等模块对内存迁移做二次拦截。
5.3 节点调度层:拓扑保持与顺序优化
do_migrate_pages 承接系统调用的参数,负责解决「按什么顺序迁移节点对,才能既保持内存布局、又不打满目标节点」的调度问题。
d = node_remap(s, *from, *to);
通过 node_remap 按相对位置一一映射,例如 from=[2,3,4]、to=[3,4,5] 时,严格按照 2→3、3→4、4→5 的对应关系迁移,最大程度保留进程原有的 NUMA 内存拓扑与线程-内存亲和性,避免迁移后性能回退。
优先选择「目标节点不在剩余源节点集合中」的节点对启动迁移。例如将 0、1、2 号节点迁到 1、2、3 时,优先迁 2→3,再迁 1→2,最后迁 0→1,始终让目标节点先处于"空闲接收"状态,避免节点内存脉冲式上涨触发直接回收。
当源节点数与目标节点数不相等时,已经落在目标掩码内的源节点直接跳过不迁移。例如 from=[0-7]、to=[3,4,5] 时,3、4、5 号节点的页面原地不动,只迁移其余节点的页面,最小化无意义的迁移开销。
5.4 通用迁移引擎:重试、降级与统计
migrate_pages 是所有场景共用的批量迁移执行引擎,核心逻辑是 10 轮重试循环 + 分级错误处理 + 大页降级策略。
函数将迁移结果分为四类,采取不同策略:
MIGRATEPAGE_SUCCESS:迁移成功,从链表移除并计数-EAGAIN:临时冲突(拿不到锁、引用计数临时异常),留在原链表下一轮重试-ENOSYS / -ENOMEM:大页场景走拆分降级;普通页遇到 -ENOMEM 直接全局退出——内存不足时继续迁移只会加剧压力,快速失败是更合理的工程选择- 其他错误(如
-EBUSY):永久失败,移入失败链表,不再重试
同时维护三套独立重试计数器:普通页、大页、THP,分别统计不同粒度页面的临时失败情况。
当大页(large folio/THP)迁移失败时,内核不会直接宣告失败,而是调用 try_split_folio() 将大页拆分为 4KB 普通页,降级后重试迁移。
但有一个关键的性能权衡:
bool nosplit = (reason == MR_NUMA_MISPLACED);
NUMA 自动均衡场景绝不拆分 THP。因为拆分 THP 会损失 TLB 命中率,反而违背了 NUMA 优化降低延迟的初衷——宁可不迁移,也不破坏大页属性。这是典型的"性能优先"工程决策。
函数末尾通过 count_vm_events 上报五类统计指标,对应 /proc/vmstat 中的可观测节点:
PGMIGRATE_SUCCESS / PGMIGRATE_FAIL:全局页面迁移成功/失败页数THP_MIGRATE_SUCCES / THP_MIGRATE_FAIL:THP 迁移成功/失败个数THP_MIGRATE_SPLIT:迁移过程中被拆分的 THP 个数
同时通过 tracepoint trace_mm_migrate_pages 暴露给 perf、ftrace 等 tracing 工具,方便线上问题排查。
5.5 单页执行层:__unmap_and_move 的完整流程
__unmap_and_move 是单页迁移的核心执行函数,严格对应三阶段模型:
static int __unmap_and_move(struct folio *src, struct folio *dst, int force, enum migrate_mode mode){ // 阶段一:锁定源页,处理回写 if (!folio_trylock(src)) { ... } if (folio_test_writeback(src)) { ... } // 保护 anon_vma 不被提前释放 if (folio_test_anon(src) && !folio_test_ksm(src)) anon_vma = folio_get_anon_vma(src); // 锁定目标页 if (unlikely(!folio_trylock(dst))) goto out_unlock; // 阶段二:解除所有映射,写入迁移项 if (folio_mapped(src)) { try_to_migrate(src, 0); page_was_mapped = true; } // 阶段三:执行核心迁移,重建映射 if (!folio_mapped(src)) rc = move_to_new_folio(dst, src, mode); if (page_was_mapped) remove_migration_ptes(src, rc == MIGRATEPAGE_SUCCESS ? dst : src, false); ...}
工程细节:
- anon_vma 引用保护:匿名页迁移前会持有
anon_vma 引用,防止解除映射后反向映射结构体被并发释放,导致后续重建映射时崩溃。 - 直接规整禁止阻塞锁:如果当前进程处于直接回收上下文(
PF_MEMALLOC),即使强制模式也不能阻塞等待页面锁,避免与预读路径形成死锁。 - 失败回滚:如果迁移中途失败,
remove_migration_ptes 会用源页重建映射,就像迁移从未发生过,保证用户态无感知。
5.6 底层原子操作:folio_migrate_mapping 的引用冻结
folio_migrate_mapping 是迁移最底层的原子操作,负责安全地将页面在 address_space 的 XArray 中替换。
int expected_count = folio_expected_refs(mapping, folio) + extra_count;if (!folio_ref_freeze(folio, expected_count)) { return -EAGAIN;}
迁移前必须先校验页面引用计数是否符合预期:
- 有 mapping 的文件页:预期 1 + 页面数(缓存引用)
如果引用数超出预期,说明有内核路径通过 get_page() 额外持有了该页,迁移无法安全进行,直接返回 -EAGAIN。这是判断页面是否可迁移的核心依据。
通过 folio_ref_freeze 冻结引用计数后,原子替换 XArray 中的页槽位,同步转移脏位、交换缓存标记。如果跨 zone 迁移,还要同步更新 zone 级别的页统计(文件页数、脏页数、交换缓存数等),最后解冻引用计数,完成替换。
5.7 文件页的特殊处理:缓冲区迁移与兜底
文件页的迁移比匿名页复杂,因为可能附带 buffer_head 等文件系统私有数据,内核提供了多层适配:
- 文件系统自定义回调:
address_space->a_ops->migrate_folio,主流文件系统(ext4、xfs)都实现了自己的迁移函数,处理私有数据。 - 通用缓冲区迁移:
buffer_migrate_folio 处理带 buffer_head 的页面,迁移前需要锁定所有缓冲区头。异步模式下使用 trylock_buffer,拿不到锁直接放弃;同步模式才会阻塞等待。 - 兜底 fallback 逻辑:如果文件系统未实现迁移回调,走
fallback_migrate_folio:脏页触发回写、带缓冲区的尝试释放缓冲区,退化为最简迁移逻辑。
5.8 缺页侧:迁移项的透明等待
当进程访问正在迁移的页面时,缺页异常中的处理逻辑:
void __migration_entry_wait(struct mm_struct *mm, pte_t *ptep, spinlock_t *ptl){ spin_lock(ptl); entry = pte_to_swp_entry(*ptep); if (!is_migration_entry(entry)) goto out; migration_entry_wait_on_locked(entry, ptep, ptl); return;out: pte_unmap_unlock(ptep, ptl);}
识别出迁移项后,进程阻塞在源页的等待队列上,直到迁移完成解锁页面。随后缺页处理返回 VM_FAULT_RETRY,重新走完整的缺页流程,此时 PTE 已指向新的物理页面,进程正常访问,全程无感知。
该机制同时支持普通页 PTE 级、THP PMD 级、巨页 PUD 级的迁移项等待,逻辑完全统一。
六、迁移失败的典型场景
实际系统中页面迁移的成功率并非 100%,以下场景会导致迁移失败:
- 额外内核引用:除页表映射外,页面被 GUP、bio 等内核路径持有额外引用,无法安全替换
- 页面正在回写:脏页正在向磁盘回写时不能迁移,异步模式下直接跳过
- 拿不到页面锁/缓冲区锁:页面被其他内核路径持有锁时,异步迁移直接放弃
- 文件系统未实现迁移回调:小众文件系统未提供
migrate_folio 回调,脏页无法迁移 - 目标页分配失败:系统内存严重不足,无法分配目标页面
- 非 LRU 页隔离失败:驱动自定义可移动页的 isolate 回调返回失败
- 目标节点水位不足:NUMA 均衡场景下,目标节点剩余内存低于水位线时拒绝迁入
- 共享 THP 限制:NUMA 均衡场景下,多进程共享的 THP 不迁移,避免拆分大页
这也是为什么系统严重碎片化时,多次规整也未必能释放出高阶连续内存——很多页面实际上已经失去了可迁移性。
七、性能影响与工程实践
页面迁移不是免费的。一次 4KB 单页迁移的开销包括反向映射遍历、页表修改、TLB 刷新、内存拷贝、进程阻塞唤醒。对于 2MB THP,拷贝开销会成倍放大,极端情况下会造成毫秒级的业务延迟抖动。
观测手段
通过 /proc/vmstat 可实时观察系统迁移状态:
cat /proc/vmstat | grep -E ”pgmigrate|thp_migrate”
重点关注迁移失败率和 THP 拆分率,如果拆分率持续偏高,说明系统在频繁破坏大页做迁移,需要评估收益与代价。
调优建议
- 延迟敏感业务:使用
mmap + MAP_HUGETLB 分配静态巨页,静态巨页不会被常规规整和 NUMA 均衡迁移。 - 物理地址固定场景:DPDK、RDMA 等场景使用 VFIO 或长期 GUP 钉住页面,从根源上避免迁移。
- 主动规整时机:通过
/proc/sys/vm/compact_memory 在业务低峰期主动触发规整,避免高峰期被动规整。 - NUMA 绑定:合理绑定 CPU 和内存节点,减少自动均衡的无意义迁移;对性能极致要求的场景可关闭
kernel.numa_balancing。 - CMA 预留优化:嵌入式场景合理设置 CMA 区域大小,避免频繁迁移腾出连续内存。
八、总结
页面迁移是 Linux 内存管理中最能体现虚拟地址透明性价值的机制之一。它用一套统一的框架支撑了内存规整、NUMA 均衡、CMA、热插拔、异构内存等多个完全不同的上层场景,其核心设计思想可以概括为三点:
- 利用抽象透明性:虚拟地址与物理地址的解耦,是迁移能够成立的根本前提
- 细粒度同步设计:页面锁贯穿整个迁移周期,既是同步手段也是进程等待的锚点;迁移项复用 swap 编码,用最小改动实现透明阻塞
- 分层工程权衡:从系统调用准入、节点调度、批量重试到单页执行,每一层都做了针对性的性能与正确性权衡,在不同场景下选择不同的策略
理解页面迁移,本质上是理解内核如何在不打破用户态抽象的前提下,灵活调度物理内存资源。这种"上层透明、底层灵活"的设计思路,对于理解分布式系统数据迁移、虚拟机热迁移等技术也具有很强的借鉴意义。