彻底理解 mmap:从虚拟内存、缺页中断、写时复制到 Page Cache

在接触 Linux 内存管理时,我们反复听到虚拟内存、缺页中断、写时复制(COW)和Page Cache这些概念。而 mmap 正是将这些零散知识点串联起来的绝佳案例。本文用一张思维导图的脉络,通俗拆解 mmap 的底层原理与使用细节,让你真正“学透”而不仅仅是“背过”。

前置知识回顾

在进入 mmap 之前,先快速过一遍背景:

  • 虚拟内存:每个进程看到的是独立的、连续的地址空间,背后映射到不连续的物理页框。
  • 缺页中断:当进程访问的虚拟地址没有对应物理页时,触发缺页异常,内核分配物理页并填充数据。
  • COW(写时复制):多个进程共享同一物理页,一旦有进程需要写,就复制一份私有副本,保证隔离。
  • Page Cache:内核用于缓存文件数据的物理内存区域,读写文件实际上优先与 Page Cache 交互。

mmap 正是建立在这一套基础设施之上的上层机制。

一、mmap 到底是什么?

mmap 在进程虚拟地址空间创建一段内存区间,和磁盘文件建立映射关系;访问这段虚拟地址时,由缺页异常按需加载文件数据到内核页缓存,进程直接操作页面,省去传统 read/write 中内核缓冲区到用户缓冲区的数据拷贝;磁盘 IO 依然会在缺页时发生,优势是简化数据拷贝路径,并且可以减少频繁读写场景下的系统调用**。

三个核心关键字

虚拟地址映射
mmap 调用时,内核只是在进程的页表中建立“虚拟地址 → 文件偏移”的逻辑绑定,并不会立刻把文件内容读进物理内存。

缺页中断
当你第一次真正访问这段虚拟地址时,CPU 发现没有对应物理页,触发缺页中断。内核会分配物理内存,并从磁盘读取一页数据(通常 4KB)到物理内存的 Page Cache 中,然后更新页表。

按需加载
你访问哪一页,内核才加载哪一页,而不是一次性把整个文件读进来。这就实现了懒加载,节省内存与时间。

mmap vs read/write 的本质区别

传统read()流程:

磁盘 → 内核 Page Cache → CPU拷贝 → 用户缓冲区(用户态内存)

两次数据拷贝:①磁盘到 Page Cache(DMA),② Page Cache 到用户缓冲区(CPU 拷贝)。

每次读写都要通过 read/write 系统调用,频繁切入内核态。

用户调用 read 时,会预先传入一块用户态虚拟地址 VA(buf),这块 VA 背后的物理页分两种情况:

  • 之前访问过:物理页已经存在;copy_to_user 直接把 Page Cache 的数据复制到这个已存在的用户物理页,不分配新物理页。
  • 从未访问过(缺页):copy_to_user 访问该用户 VA 时触发用户缺页,内核现场分配物理页,再拷贝数据进去。

数据存在两份独立物理页副本:

  • 一份:内核 Page Cache(全局共享,文件唯一内存副本)
  • 一份:用户 buf 对应的物理页(进程私有副本)

本质:数据拷贝,内核把 Page Cache 内容复制到用户空间物理页。

mmap 流程:

磁盘 → 内核 Page Cache (用户进程直接通过虚拟地址读写这块物理内存)
  • 减少一次 CPU 拷贝:数据从磁盘到 Page Cache 后,用户空间直接就能访问,无需再拷贝到用户缓冲区。
  • 减少系统调用:之后读写就像操作普通内存指针,无需反复 read/write。
  • 天然适合随机访问:想读文件任意偏移处的数据,直接指针偏移即可,内核只加载用到的页。

mmap 系统调用本身不读磁盘、不分配 Page Cache、不拷贝数据,仅修改当前进程页表:把一段用户 VA,直接映射到将来的 Page Cache 物理页(此时映射条目标记为无效)。

第一次访问这片 mmap 出来的用户 VA,触发缺页异常,分两种情况:

  • ✅ cache 命中:该偏移对应的 Page Cache 物理页已经存在,直接在当前进程页表建立映射,共享这块已有物理页;无磁盘 IO、不分配新物理页。
  • ❌ cache 未命中:分配新物理页,发起磁盘 IO 把文件数据读到 Page Cache,再把进程页表映射到这块刚填充好的物理页。

数据只有同一份物理页:用户进程和内核 Page Cache 共享,不存在 CPU 拷贝。

写的时候:修改的就是 Page Cache 本身,标记脏页,后续内核线程异步回写磁盘。

补充区分MAP_PRIVATE(私有文件映射)读的时候依然共享 Page Cache;一旦写入,触发 COW 缺页,内核分配新物理页、复制页面内容,之后这一页变成进程私有副本,不再和原 Page Cache 共享,修改不会写回原文件。

两者共性: Page Cache、缺页异常处理、脏页回收、回写、页表维护,全部由内核管理; 用户拿到的都是虚拟地址;虚拟地址只是编号,物理内存是按需分配(懒分配)。

总结

mmap 仅在初始化映射时执行一次 mmap 系统调用。

当目标文件页面已经载入 PageCache:进程通过虚拟地址访问内存,MMU 完成地址翻译,全程用户态运行,无内核切换。

如果页面尚未加载,访问内存触发硬件缺页异常被动进入内核加载数据;这和主动调用 read/write 系统调用不是一回事。

read/write 模式,每一次 IO 操作都需要程序主动发起系统调用,并且额外存在一次 PageCache 到用户缓冲区的 CPU 拷贝,这也是 mmap 性能优势来源。

二、mmap 怎么用?文件映射与匿名映射

mmap 主要分为两大类,背后的载体截然不同。

1. 文件映射(绑定磁盘文件)

通过 mmap 将一个普通文件映射进虚拟地址空间,底层依赖的就是 Page Cache。也就是说,被映射的文件数据会以页为单位缓存在 Page Cache 里,进程直接读写这些页。

2. 匿名映射(MAP_ANONYMOUS)

不关联任何磁盘文件,内核直接分配空白物理页。数据没有文件背景,不经过 Page Cache。

glibc 中,当 malloc 申请的内存大于 128KB 时,底层就会改用匿名 mmap,而不是调整堆的 brk。

🔍 深入对比:文件映射与匿名映射的缺页和页面回收

文件映射(带 fd 普通 mmap)
mmap(fd, offset, len)
  • 创建阶段:虚拟区间逻辑绑定「磁盘文件 + 文件偏移」;没有物理内存。
  • 首次访问触发缺页:内核根据记录的磁盘位置,读取数据到 PageCache,生成文件页;虚拟页 ↔ 物理文件页。
  • 页面回收:可以直接丢弃,不需要 Swap;再次访问重新从磁盘文件加载。
匿名映射(MAP_ANONYMOUS)
mmap(NULL, len, prot, MAP_ANONYMOUS | MAP_SHARED/MAP_PRIVATE, -1, 0)
  • 创建阶段:虚拟区间没有绑定任何磁盘文件,内核只标记这是一段匿名映射区域;依旧不分配物理内存。
  • 首次访问触发缺页:内核不会去磁盘读取任何文件,直接分配一块清零的物理内存页 → 匿名页;虚拟页 ↔ 匿名页。
  • 页面回收:匿名页没有对应的磁盘原始文件!内存不足时必须把页面内容写入 Swap 分区;下次访问缺页,从 Swap 读回内存。

MAP_SHARED vs MAP_PRIVATE(重点理解 COW)

这两个标志决定了多个进程之间以及与文件之间的交互方式。

MAP_SHARED(共享映射)
  • 多个进程映射同一个文件时,它们共享同一份 Page Cache 物理页。
  • 任何一个进程对内存的修改,其他进程立刻可见。
  • 修改产生的脏页最终由内核异步刷回磁盘(或调用msync()强制同步)。
MAP_PRIVATE(私有映射 + 写时复制)
  • 初始状态:同样共享底层文件的物理页(Page Cache)。
  • 一旦某个进程尝试写 → 触发 写时复制(COW):内核为当前进程分配一个全新的物理页,复制原页内容,然后修改页表指向新页。后续的写入操作都在这个私有副本上进行。
  • 修改不会回写到原文件,对其他进程不可见。

💡 与 fork 的联动:fork()创建子进程时,父子进程的私有内存也是通过 COW 共享同一物理页。这背后的硬件机制(页表项写保护位、缺页陷阱)与 MAP_PRIVATE 完全一致。

即:MAP_PRIVATE 相当于在文件映射上实现了类似 fork 的 COW 行为。

三、mmap 常用在哪些场景?

  1. 大文件随机访问
    例如日志解析、简单的数据库存储引擎。利用 mmap 可以将随机读请求转化为直接内存访问,热数据留在 Page Cache,无需频繁 lseek + read。
  2. 进程间通信(共享内存 IPC)
    使用 MAP_SHARED | MAP_ANONYMOUS 创建共享内存区域,多个进程可直接读写同一块物理内存,吞吐高、延迟低。
    ⚠️ 但内核不提供任何同步机制,需要用户自己用互斥锁、信号量或原子操作来避免竞态。
  3. 动态库加载
    Linux 加载动态链接库(.so)时,底层就是用 mmap 将库文件映射到进程地址空间,且通常使用 MAP_PRIVATE(修改不写回文件)或 MAP_SHARED(代码段只读)。
  4. 大块匿名内存分配
    如前所述,glibc 的 malloc 对于超过 MMAP_THRESHOLD(默认 128KB)的请求直接使用匿名 mmap,避免堆碎片化并方便独立释放。

四、为什么 mmap 性能好?三点优势总结

  • 减少系统调用
    映射建立后,读写变成内存访问,无需反复 read/write。
  • 减少一次 CPU 数据拷贝
    从磁盘到用户空间,只需一次 DMA 到 Page Cache,用户态可直接使用。
  • 按需加载 + 页缓存复用
    只有真正访问到的页才会加载,且 Page Cache 可被多个映射共享,节省物理内存。

五、注意事项

  • 不要用于小文件或一次性顺序读写
    mmap 本身有系统调用和页表建立的固定开销,对于很小的文件或只需要顺序读一次的场景,传统 read 更快。
  • 文件截断风险(SIGBUS)
    如果文件被外部 truncate 截断,而进程还在访问已映射但超出文件大小的地址,会收到 SIGBUS 信号,直接崩溃。必须通过信号处理或预先保护。
  • MAP_SHARED 不是线程安全 / 进程安全的
    多个进程同时写共享映射区,必须自己用锁或原子操作保证同步,否则数据会错乱。
  • 缺页风暴
    如果映射了超大区域,又进行完全随机的访问,可能导致大量缺页中断,性能不升反降。

六、总结

mmap 能够把磁盘文件映射至进程虚拟地址空间,将文件 I/O 转变为直接内存访问。它依靠缺页中断完成按需加载,核心收益是减少数据拷贝与系统调用开销。

该机制广泛用于大文件随机读写、进程间共享内存、动态链接库加载、大容量内存分配场景。

同时存在诸多使用限制:不适用于小规模顺序 I/O;文件截断可能触发 SIGBUS;MAP_SHARED 模式要求上层实现同步;纯粹随机访问会引发频繁缺页,造成性能损耗。

七、极易混淆的两个补充知识点

文件映射的缺页 vs 匿名映射的缺页

  • 文件映射缺页:内核从磁盘文件读取数据到 Page Cache,然后映射给进程。
  • 匿名映射缺页:内核分配一个空白物理页(通常全零填充),物理内存紧张时会被换出到 Swap 分区。

文件映射页回收与 Swap 无关

当内存紧张需要回收 Page Cache 中的文件映射页时,如果页是干净的(未修改),直接丢弃;如果是脏页,先写回文件再释放。

它绝不会被换出到 Swap。这是因为文件映射的数据本身就有文件作为后备存储,缺页时重新从文件读取即可。

而匿名页(无文件背景)没有后备存储,只能暂存到 Swap。