操作系统内功 · 第 17 篇|mmap:内存映射的工程账本——文件映射、匿名映射与共享的边界

mmap 是什么:把「读文件」变成「解引用指针」

普通读文件要两次拷贝:磁盘 → Page Cache → 你的用户态缓冲区(第 4、6 篇讲过这条路径)。mmap 换了一种思路:把文件的页直接映射进进程的虚拟地址空间,之后读文件内容就是普通的内存读取——第一次访问某个偏移时触发缺页中断,内核把对应的文件页调入 Page Cache,并把同一个物理页映射进你的地址空间。数据仍然在 Page Cache 里,只是你的指针与内核的缓存指向了同一份物理内存。

flowchart TB
    subgraph P1["进程 A 虚拟地址空间"]
        VA1["虚拟页 @0x7f..."] 
    end
    subgraph P2["进程 B 虚拟地址空间"]
        VA2["虚拟页 @0x7a..."]
    end
    VA1 -->|"页表映射"| PP["同一物理页<br>(Page Cache 页)"]
    VA2 -->|"页表映射"| PP
    PP --> D["磁盘文件"]

这张图同时解释了 mmap 的三重身份:对单进程,它是免拷贝的文件访问;对多进程,MAP_SHARED 让两个映射指向同一物理页,就成了共享内存(上一章的零拷贝通道);对内核,映射建立的是文件与内存的按需绑定——这就是为什么它被称为内存管理的枢纽概念。

四种映射:两个维度定完全部

mmap 的行为由两个正交参数完全决定:映射来源(文件 / 匿名)× 共享性(MAP_PRIVATE / MAP_SHARED)。

组合 语义 典型用途
文件 + PRIVATE 读文件页,写时触发 CoW 复制私有副本 加载可执行文件/动态库的代码段
文件 + SHARED 修改直接回写文件(由内核回写,见第 21 篇) 共享内存、数据库数据页
匿名 + PRIVATE 私有匿名内存 malloc 大块分配、栈、bss 扩展
匿名 + SHARED 无文件支撑的共享内存(配合 MAP_ANONYMOUS) 父子进程共享(fork 前 mmap)、memfd

两个组合值得点名。文件 + PRIVATE 解释了一个日常现象:一百个进程共享 glibc 的代码段,ps 里每进程 RSS 却几乎不叠加——它们映射的是同一批物理页,只有真正被改写的页(如 GOT 表)才会 CoW 出私有副本。匿名 + SHARED 配合 fork 是父子进程共享数据的最简方案:先 mmap(MAP_SHARED|MAP_ANONYMOUS) 再 fork,子进程继承同一映射——比 System V shm 少一次 key 管理。

缺页:mmap 的隐藏账单

mmap 调用本身几乎是免费的——它只是在页表里登记了一段「虚拟地址 ↔ 文件偏移」的对应关系,一个字节都没读。真正的成本发生在第一次访问每个页的时刻:缺页中断 → 内核分配物理页或查找 Page Cache → 建立页表项。这意味着:

  • 首次顺序扫一个大文件,mmap 不比 read 快。每 4KB 一次缺页的开销(哪怕是最便宜的 minor fault),叠加起来常常反超 read 的批量拷贝。有的场景可以用 MADV_SEQUENTIAL/MADV_WILLNEED 提前预读缓解。
  • 「mmap 了 10GB 文件」不占 10GB 内存。RSS 只统计实际触碰过的页——监控里看到 mmap 大文件后 RSS 缓慢爬升,不是泄漏,是缺页在按需兑现映射。
  • 尾巴页的半页浪费。文件大小不是 4KB 整数倍时,最后一个映射页仍是整页——几乎可以忽略,但做内存预算时要心里有数。

回写与一致性:内核替你 fsync 的边界

MAP_SHARED 的写操作落在 Page Cache 里,之后由内核的回写线程异步刷盘(第 21 篇的 writeback 机制)。这带来三笔明确的账:

  1. 持久性没有承诺。程序崩溃数据还在(Page Cache 是内核的,不随进程消失),但机器断电可能丢——与 write 的语义完全一致。要持久化照样得 msync,它的角色等价于文件的 fsync。
  2. 随机写放大了回写成本。mmap 的写是分散在页里的脏页,回写线程要逐页跟踪;顺序大块写 write 反而对回写更友好。
  3. 与 read/write 混用同文件有一致性风险。同一文件的 mmap 视图与 read/write 缓冲理论上可能看到不同步的状态(尤其多进程 + 信号量场景),数据库系统几乎都因此放弃 mmap 作为主存储路径。

谁在用 mmap,谁明确不用:两个案例

Redis 早期用 mmap 持久化(VM 机制的底层就是文件映射),后来主动弃用转向 fork + CoW 的 RDB 重写——原因正是上面第二、三笔账:脏页跟踪的不可控性与一致性边界复杂。Kafka 的日志段索引用 mmap(offset index),而数据日志本身用普通 write + 顺序读 + sendfile——索引页小且随机访问多,适合映射;日志段大且顺序,适合批量写。RocketMQ 的 CommitLog 则全面采用 mmap 写入。三个系统的选择指向同一条规律:mmap 适合「小页、随机读、多进程共享」的访问形态,不适合「大段、顺序写、强持久化承诺」的路径。

数据库领域(PostgreSQL 也在 2024 年起讨论移除 mmap 路径)的共识更明确:一旦你需要自己控制刷盘时机、预读策略、错误处理(SIGBUS——映射页被截断时 mmap 会给进程发总线错误,而 read 只返回错误码),普通 read/write + 自管缓冲区反而更可控。

工程检查清单

  • 大文件随机访问(索引、B-tree 页、机器学习权重共享):mmap 是正解,配合 MADV_RANDOM 关掉预读。
  • 大文件顺序读写(日志、流水):read/write + 大缓冲,mmap 无收益还引入缺页抖动。
  • 需要跨进程共享只读大资源(字库、模型权重、代码段):mmap MAP_SHARED,RSS 天然共享(第 20 篇讲 OOM 时会算这本账)。
  • 写了 mmap 要持久化:msync(MS_SYNC) 或依赖内核回写 + 明确的丢失窗口预算。
  • 访问前用 mincore 查页是否在内存,可避免热路径上的磁盘缺页。

fork 加 mmap 的组合经济学:进程创建的内存账

fork 的写时复制(第 1 篇)与 mmap 的页粒度共享是一对天然盟友,组合起来的内存经济学值得单独算账。Redis 执行 BGSAVE 时的形态:主进程 fork 出子进程,两者共享全部数据页(物理内存只有一份);主进程继续处理写请求,被改写的页由 CoW 复制出私有副本——内存增量只与「fork 期间的写入量」成正比,而不是数据总量。这解释了两条生产铁律:其一,写负载突增时段避免重写持久化(CoW 翻倍风险,Redis 官方文档明确警告);其二,监控要看 redis 的 rss 与 cow 相关指标(info stats 的 mem_fragmentation 与 fork 耗时)。JVM 的并行 GC 与 fork 组合则是反例教材:JMM 的地址随机化与预触碰页面让 fork 后的 CoW 大面积失效,「fork 大 JVM 进程做快照」的成本经常远超预期。工程结论:fork 加 mmap 的组合适合「读多写少 + 中等进程体积」的场景,进程一大于等于十 GB 且写活跃,就该换持久化策略而非硬扛 CoW。

内存占用的观测口径:RSS、PSS 与 mmap 的统计陷阱

mmap 让「进程占了多少内存」的度量变得微妙,三个口径必须分清。RSS(resident set size):进程驻留的物理页总量,共享页被重复计入每个映射者——一百个进程共享 glibc 的代码段,RSS 各算一份,加起来是物理占用的百倍。PSS(proportional set size):把共享页按映射者数量均摊(每个进程算 1/N),相加等于真实物理占用——评估「这台机器到底被谁占满」时 PSS 才是诚实口径。USS(unique set size):纯私有页,进程死掉能立即释放的部分。观测入口是 /proc/PID/smaps(每映射区的 Rss/Pss/Private_Dirty 逐行明细)与 smaps_rollup(汇总版,成本低的官方替身)。mmap 场景的三个具体陷阱:其一,mmap 大文件的进程 RSS 会随访问爬升(缺页逐页兑现),监控上像缓慢泄漏,其实是 Page Cache 的映射页—— Private_Dirty 为零说明是共享文件页,驱逐压力下会自动让出;其二,MAP_SHARED 的写让文件页变成脏页(第 21 篇),计入 Page Cache 而非进程匿名内存,OOM 评分(只看 RSS)与真实内存压力可能脱节;其三,fork 后的 CoW 页父子各计一份 RSS,物理上只有一份——Redis 的内存监控在 BGSAVE 期间 RSS 翻倍告警的经典误报就源于此。结论:容量规划看 USS 与系统级 available,事故复盘看 PSS 分布,RSS 只配当粗略参考。

NUMA 与 mmap 的交叉:页分配的位置学

mmap 的页在 NUMA 机器上落在哪个节点,遵循「首次触碰时按当前 CPU 分配」的 first-touch 规则——这条规则有两个反直觉的推论。其一,mmap 调用者的位置不重要,触碰页面的线程位置才重要:主线程 mmap 的大块内存,如果由跑到另一个节点的 worker 线程逐页初始化,页会分散到两个节点,局部性反而碎掉。其二,预触碰(prefault)是把页钉在正确节点的工具:MAP_POPULATE 标志让 mmap 时就分配全部页(按当前 CPU 的节点),大块共享内存在 fork 前由「住在正确节点的线程」触碰一遍,后续所有进程的访问都是本地——DPDK 与高频交易系统初始化时的「内存驯服」动作就是这个。观测手段:numastat -p 的各节点分布偏离度,或 /proc/PID/numa_maps 按地址段看页的节点归属。一个部署反模式值得点名:systemd 服务的启动线程被调度器随机放置,mmap 共享区后由多个 worker 并发初始化——每次重启后内存的节点分布都不同,性能表现「看运气」。确定性做法是初始化线程显式绑定节点(pthread_setaffinity 或 numactl 的 membind),让 first-touch 规则为我所用,而不是被它随机化。

glibc 的 malloc 与 mmap 的分界:128KB 阈值的来龙去脉

应用申请内存时,glibc 的 malloc 内部有两条路径,分界线是 MMAP_THRESHOLD(默认 128KB):小于阈值从堆(brk 或主 arena,第 3 篇的 ptmalloc 体系)切分,大于阈值直接走 mmap 匿名映射、释放时立即归还内核。这条阈值的工程含义常常出人意料:其一,大对象的内存_return行 为不同——128KB 以上的 malloc/free 与 mmap/munmap 等价(还给内核,下次再要),以下的留在堆里被 ptmalloc 复用(free 后 RSS 不降)——监控里「小对象服务内存平稳、大对象服务内存锯齿」的根因就是这条线。其二,阈值是动态的:glibc 会根据释放行为自适应调整阈值(最高到 32MB 的 DEFAULT_MMAP_THRESHOLD_MAX),进程曾经分配过大块后阈值被抬高,行为随历史漂移——同一负载重启前后内存形态不同,多半是它。其三,多线程的 arena 竞争:64 位系统默认最多 8×核数 个 arena,每个 arena 独立堆锁,但 arena 数固定后大并发分配仍可能挤在同一把锁上——GLIBC_TUNABLES=glibc.malloc.arena_max 限 arena 配合 tcmalloc/jemalloc 替换是标准的治理组合。把这条分界线放进第 17 篇的语境:malloc 大于 128KB 的每次申请都是一次匿名 mmap,本篇讲的所有 mmap 特性(缺页、按需兑现、NUMA first-touch)自动适用——理解 malloc 就是在理解受限版的 mmap。

小结

mmap 把文件页与进程虚拟地址空间按需绑定,用缺页中断代替显式 IO,是「拷贝次数」坐标系上的零拷贝极点。它的真实成本不在映射调用,而在首次访问的缺页、不可控的回写与 SIGBUS 式的错误模型。工程规律一句话:随机读、共享读用 mmap,顺序流、强持久化用 read/write——Redis、Kafka、RocketMQ 的不同选择都是这条规律的映射。下一篇我们转向 CPU 侧的隐藏成本:缓存行与伪共享,看多核机器上另一类「看不见的拷贝」。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?