1. 一次改了文件却看不见改动的诡异问题说起来有点丢人我入行第三年才真正搞懂 mmap。当时接手一个 Linux 下的日志采集程序同事留下的代码用 mmap 映射日志文件采集进程往里写数据另一个进程实时读。跑起来一切正常但只要把采集进程重启就会发现文件末尾少了一截数据。排查了很久最后发现问题出在 mmap 的同步语义上——改了内存不等于改了文件。那次踩坑之后我把 mmap 在内核里的完整路径啃了一遍今天这篇就是整理出来的东西。mmap 全称 memory map是 Linux 提供的、把文件或匿名内存映射到进程地址空间的系统调用。它的核心价值在于让应用可以用访问内存的方式去访问文件省掉 read/write 那种显式拷贝的过程。做嵌入式、做存储、做数据库、做高性能网络服务的人几乎绕不开这个基础设施。这篇适合所有想在用户态把内核机制用明白的人——不要求你写过内核代码但我会把内核侧发生的事情也尽量讲清楚。先说应用层的定义mmap 返回一个指针之后你把这个指针当数组用读写就是在读写背后的文件或内存。接口确实只有这一层。但内核在这一层下面藏了 VMA、页表、缺页中断、page cache、写时复制一整套机制。后面的内容就是把黑盒一层层拆开。2. 从系统调用到 VMA内核为每一块映射立户mmap 的系统调用原型长这样void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);addr建议的映射起始地址传 NULL 让内核挑length映射长度内核会按页大小通常 4KB向上取整protPROT_READ / PROT_WRITE / PROT_EXEC控制访问权限flagsMAP_SHARED / MAP_PRIVATE / MAP_ANONYMOUS 等控制映射语义fd文件描述符匿名映射时传 -1offset文件内的映射起点一般要求页对齐调用进内核后真正干活的入口是do_mmap()。它会做几件事检查长度和地址是否越界、计算映射权限、在进程地址空间里找一段空闲区间然后创建并插入一个struct vm_area_struct也就是 VMA。VMA 是内核描述进程地址空间里一段连续映射的核心数据结构。它记录这段区间的起始地址、结束地址、对应的文件、偏移、权限位以及一组操作函数指针vm_ops。每个进程的 VMA 用红黑树组织起来挂在task_struct-mm上。内核查找某地址属于哪个 VMA 时走红黑树复杂度 O(log n)。为什么要在意 VMA因为它决定了一切后续行为。比如cat /proc/self/maps看到的每一行本质上就是一个 VMA 的格式化输出。两次 mmap 会在 maps 文件里看到两行说明内核为它们分别创建了 VMA。VMA 数量过多几十万条会导致查找和遍历变慢这也是为什么有人说mmap 太多小片段性能差。一个典型的基础映射代码长这样后面分析都基于它#include sys/mman.h #include sys/stat.h #include fcntl.h #include unistd.h #include string.h int main() { int fd open(/tmp/data.bin, O_RDWR); struct stat st; fstat(fd, st); char *p mmap(NULL, st.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // 直接当内存用 memcpy(p, hello, 5); // 关键主动刷盘否则数据只停留在 page cache msync(p, st.st_size, MS_SYNC); munmap(p, st.st_size); close(fd); return 0; }VMA 还有个常用接口是madvise()。它通过修改 VMA 的 flag 告诉内核你对这段内存的预期使用模式比如顺序读MADV_SEQUENTIAL、随机读MADV_RANDOM、用完就丢MADV_DONTNEED。内核会根据提示调整预读策略和缺页行为这是做大型数据加载时很实用的调优手段。3. 缺页中断与延迟分配mmap 为什么快却又卡很多人第一次测 mmap 读大文件会得出刚开始很快后面偶尔卡一下的体验。这不是玄学而是缺页处理的天然特性。mmap 建立映射时内核只做了地址空间登记并没有真正把文件内容读进内存。它只是建立了一套将来如果访问这里该怎么处理的约定。当你第一次读写某个地址时CPU 访问虚拟地址页表项不存在触发缺页异常page fault内核才在异常处理里真正分配物理页、把数据从磁盘读进来。这就是延迟分配demand paging。3.1 缺页也分轻重缺页分两类minor fault 和 major fault。minor fault 是物理页还没分配但数据已经在内存里比如在 page cache 中内核只需要建页表项代价很小major fault 需要真正发起磁盘 I/O代价大往往要几毫秒。所以 mmap 读大文件第一下慢后面快是因为第一次访问触发了 major fault把文件块读进 page cache后续访问都命中缓存。time命令输出里的 minor/major page faults 就是这两个指标的体现。测试程序里用getrusage()也能拿到这两个数值判断性能瓶颈是内存分配还是磁盘 I/O这是最基本的定位手段。3.2 匿名映射与 zero page匿名映射MAP_ANONYMOUS不关联文件纯粹用来分配内存malloc 分配大块内存时底层就是它。内核在这里有个经典省内存技巧给匿名页统一指向一个共享的 zero page全零页只有当你真正写入时才触发缺页分配一个真实物理页并拷贝内容。所以分配了 1GB 内存但没写的程序实际物理内存占用可能只有几十 MB。我第一次看到 VIRT 远超 RSS 时还以为是监控工具坏了其实是这个机制在起作用。3.3 MAP_POPULATE 与预读如果你明确知道马上要把整个文件用上可以在 mmap 时加 MAP_POPULATE让内核在建立映射时就把页预读进来把缺页开销提前吃掉。文件很大时这让 mmap 调用本身变慢但后续访问延迟会低很多。到底是一次慢还是多次微卡取决于场景。我读大模型权重文件时就习惯配 MAP_POPULATE配合 MAP_LOCKED 和 mlock 防止映射被换出加载虽然慢一点但推理时不会有缺页毛刺。4. 文件映射与 page cachemmap 和 read/write 的本质区别要理解为什么 mmap 在某些场景下比 read 快、在另一些场景下又慢必须回到 page cache。传统 read 路径进程发起 read()内核把磁盘块读入 page cache然后拷贝到用户态缓冲区一次读操作至少多一次 memcpy。mmap 路径进程访问映射地址触发缺页内核把磁盘块读入 page cache然后把这块物理页直接映射进进程页表用户态访问的就是 page cache 本身省掉了那次用户态拷贝。这也是 mmap 读大文件的性能优势来源——零拷贝是内核架构上的红利不是算法技巧。但是mmap 写文件的路径就复杂了。你改的是 page cache 里的页这页变成脏页内核不会立刻写回磁盘。脏页回写由内核 writeback 机制控制要么等后台定时刷盘要么等内存压力大了被动回收要么你主动调msync(MS_SYNC)。如果进程改了数据没调 msync 就崩了数据就丢在窗口期里。开篇我踩的那个坑就是采集进程退出前没有正确 msync日志尾部数据直接丢失。4.1 msync 与内核回写msync 有三种模式MS_ASYNC异步立即返回、MS_SYNC同步等落盘、MS_INVALIDATE使其他映射的数据失效用得少。对可靠性要求高的场景写关键数据后调 MS_SYNC 是底线。但它很贵——每次同步都是真实磁盘刷写频繁调用会抹掉 mmap 的性能优势。正确做法是核心数据及时同步海量不重要数据交给内核后台回写。4.2 mmap 的随机访问优势mmap 另一个被低估的优势是随机访问。read 需要 lseek read 两次系统调用缓冲区管理全得自己做。mmap 相当于把整个文件钉在地址空间里随机访问就是一个指针偏移加解引用没有系统调用开销。数据库缓冲池之所以偏爱 mmap 方案就是因为这个特性。当然数据库最终没有全面用纯 mmap 还有别的原因——比如崩溃恢复需要精确控制页的刷盘时序这已经不是 mmap 能直接给的了。5. MAP_SHARED、MAP_PRIVATE 与写时复制内存语义的分岔路讲到这里mmap 的两个核心 flag 绕不过去MAP_SHARED 和 MAP_PRIVATE。它们决定了写这个映射对其他进程可见吗。标志写数据落盘其他进程可见典型用途MAP_SHARED是通过回写或 msync是共享同一物理页进程间共享内存、文件映射持久化MAP_PRIVATE否写时复制私有页否改动只在自己进程内加载可执行文件、只读配置MAP_SHARED 的文件映射让所有映射共享同一份 page cache 物理页一个进程改了其他映射同一文件区域的进程立即能看到。MAP_PRIVATE 则玩了一个精巧的写时复制Copy-On-Write, COW建立映射时大家共享同一份物理页并标记只读谁要写谁就触发缺页内核复制一份私有页再允许写入。进程加载动态库用的就是这个机制——多个进程共享同一份 .so 的物理页只有修改全局数据时比如重定位才各自复制。5.1 COW 的代价COW 不是免费的。触发写时复制的缺页需要分配新页并拷贝旧页内容如果你写的是几百 MB 的匿名内存且大量页正被共享比如 fork 之后会看到明显的延迟尖峰。衡量方式是看 page fault 计数fork 后大量写minor fault 会暴涨。这也是为什么有些高性能进程在 fork 之前会做优化尽量减少与子进程共享的脏页区域。5.2 文件共享映射做 IPCMAP_SHARED | MAP_ANONYMOUS 是不关联文件的共享内存用 shm_open ftruncate mmap 可以做 POSIX 共享内存。这块内存由 tmpfs 承载落在 /dev/shm 上。做进程间通信时共享内存的带宽远高于 pipe 和 socket同样数据量下延迟也低很多代价是同步得自己来——互斥锁、自旋锁或者原子变量由你管。我做多进程数据分发时惯用方案是固定大小的共享内存环形缓冲再配一个 eventfd 做唤醒信号比纯 socket 方案省将近一半 CPU。6. 实测对比不同读写模式下的 mmap 表现为了不纸上谈兵我写了组小测试一个 1GB 的二进制文件分别用 read 顺序读、mmap 顺序读、read 随机读、mmap 随机读。环境是普通 NVMe 盘4K 页每轮跑三次取中位。场景read 耗时mmap 耗时说明顺序读全文件1.62s1.28s差距不大预读掩盖了部分拷贝开销随机读 10 万次页3.41s0.48smmap 优势明显无系统调用开销顺序写频繁 fsync慢得离谱更慢都受限于磁盘fsync 才是瓶颈顺序写后台回写1.51s0.57smmap 快但数据落盘时机不可控注意第四行mmap 写很快是因为数据先留在 page cache还没真正落盘。如果业务要求写进去必须立刻能读回来这个快是有水分的。同理机器突然断电这批快的数据会全部丢失。所以涉及持久化语义的都得靠 msync 兜底性能和安全自己权衡。还有一个必须提的场景小文件反复读写。文件只有几 KB 时mmap 还要建立 VMA、拉页表开销占比反而大read/write 更划算。别迷信 mmap 万能。我自己定了条经验小于 64KB 的热点小文件一律用普通 IO大文件顺序读用 read 预读大文件随机访问或需要长期驻留时才上 mmap。6.1 内核 THP 对 mmap 的影响现代内核的透明大页Transparent Huge Pages, THP会改变 mmap 的缺页粒度。普通页是 4KTHP 是 2MB大页能显著减少页表项数量和 TLB miss。但对 mmap 的随机小写入来说THP 反而可能放大写放大你只改 4K内核却可能为你分配并标记 2MB 的页。这就是为什么很多数据库会显式用 madvise(MADV_NOHUGEPAGE) 拒绝 THP。反过来你要是映射超大数组并做顺序运算MADV_HUGEPAGE 能带来直观的吞吐提升。这个开关值得针对业务专门压测一轮。7. 排查 mmap 问题的实用工具箱最后分享几个我高频使用的观测手段。排查 mmap 相关问题先看三样东西地址空间布局、页表状态、系统调用行为。7.1 /proc/PID/maps 与 smaps/proc/self/maps列出了进程所有 VMA每行格式是地址区间 权限 偏移 设备号 inode 路径关注点映射的地址范围是否合理、权限位是否和预期一致、同一个文件是否出现大量分散区间映射碎片化。/proc/self/smaps更细给出每个 VMA 的 RSS、PSS、脏页大小、是否可被 swap。排查内存为什么降不下来时我第一件事就是看 smaps 里的 RSS 分布找出是哪个映射在吃内存。7.2 strace 与 perfstrace 用来确认应用到底发没发 mmap、传了什么参数。很多人忘了 strace 能打印 mmap 的返回值——地址区间的起点配合 maps 文件就能还原完整映射关系。内核侧的话用 perf 统计 page fault 与 TLB miss 最直接。perf stat -e page-faults,dTLB-load-misses,dTLB-store-misses ./your_app这两项如果暴涨基本就能判断是被缺页还是 TLB 卡住的。7.3 常见坑速查mmap 成功后写超出文件大小的区域进程收到 SIGBUS不是 SIGSEGV。文件末尾那部分访问要格外小心忘掉 msync 导致数据丢失只在崩溃恢复或断电场景暴露最难查大量小映射导致 VMA 数量膨胀单进程几十万 VMA查找性能劣化MAP_FIXED 乱用导致覆盖已有映射有条件就用 MAP_FIXED_NOREPLACE 避免误覆盖只读映射写入SIGSEGV但共享映射搭配可写文件时权限组合要仔细看多线程同时操作同一共享映射的同一页没有原子性保护需要用户态自旋锁页表层真出诡异问题时可以看/proc/PID/pagemap它给出每个虚拟页对应的物理页号和驻留位需要 root 权限。用它验证两个进程是否真的共享了同一物理页特别好使——先取虚拟地址再读 pagemap 拿物理帧号两边一对比就清楚了。我自己调试时还有个习惯怀疑 mmap 相关 bug先在代码里把每次 mmap 的返回地址打印出来崩溃时对照 maps 文件看命中哪个 VMA。很多所谓的内存越界写坏问题最后都证明是映射区域根本没你以为的那么大。先把边界搞清楚比猜指针省时间得多。从那次日志丢失到现在我再也没在 mmap 的同步语义上栽过跟头。尤其是做嵌入式板卡调试的时候我还会顺手在交叉工具链里加一条规则所有 mmap 返回地址强制对齐到页边界再打印配合 maps 一比对VM 层面的问题基本一眼就能看出来。希望这篇把内核那层黑盒子拆开之后你踩坑时也能少走几段弯路。