mmap内存翻倍之谜:MAP_PRIVATE双视图的COW陷阱

发布时间:2026/10/22 4:18:04
mmap内存翻倍之谜:MAP_PRIVATE双视图的COW陷阱 先说我遇到的场景。某个压测服务里一个.so计算工具上线后 RSS 直接翻倍文件 1GB跑完内存监控显示 2GB。顶进去看工具逻辑很简单用mmap映射数据文件然后逐页做计算。按理说 mmap 零拷贝再怎么算也不该把内存顶翻倍但现实就是翻得明明白白连/usr/bin/time -v打出来的Maximum resident set size都是文件大小的两倍。这个问题我前后排查了两天最后发现根子不在“映射两次”而是“一份文件被两个视图同时持有”。如果你也遇到过.so里 mmap 后内存翻倍或者正在优化类似的计算工具下面的复现、定位和修复过程可以直接抄作业。1. 先复现一个 .so 计算函数把文件映射成了两份视图1.1 最小复现 Demo读一个视图写另一个视图当时.so里的核心代码并不复杂简化之后大概是这样的// libcalc.so 的核心函数 extern C int calc_file(const char *path) { int fd open(path, O_RDONLY); struct stat st; fstat(fd, st); size_t len (size_t)st.st_size; // 视图1只读源保留计算前的内容 const uint8_t *src (const uint8_t *) mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); // 视图2可写输出往里面写计算结果 uint8_t *out (uint8_t *) mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0); for (size_t off 0; off 4096 len; off 4096) { out[off] (uint8_t)(src[off] ^ src[off 1]); } return 0; }表面上没问题src只读out可写两个映射都指向同一个文件。跑完用工具量一下峰值/usr/bin/time -v ./runner data.bin实测一个 1GB 文件工具显示Maximum resident set size (kbytes): 20971521GB 的文件最大驻留内存 2GB翻倍复现。这时候不要急着猜“mmap 是不是把文件拷了两份”先看内存指标到底谁在翻倍。1.2 RSS、VSZ、PSS 三种读数翻倍的是哪一个很多同学看到 top 里 VIRT 变大就慌了其实要分清三种内存读数指标全称说明VSZVirtual Size进程地址空间大小包含从未触碰过的映射mmap 之后立即变大但基本不吃物理内存RSSResident Set Size当前实际驻留的物理页数量是 OOM 最常见的判断依据PSSProportional Set SizeRSS 按页共享数均摊后的值多进程场景下更公平.so加载本身就会让 VSZ 增加几百 MB这不可怕。真正该盯的是 RSS以及 cgroup 里的memory.current。我上面复现中翻倍的就是 RSS说明有物理页确实多占了一份。1.3 先给结论单个私有映射不会让 RSS 翻倍这里要破除一个常见误解如果你只有一个MAP_PRIVATE | PROT_WRITE的映射然后去写它RSS 通常不会翻倍。原因是mmap返回后页是懒加载的。第一次读某一页内核把文件页放到页表里第一次写同一页因为映射是私有的内核会复制出一个匿名页然后把页表指向匿名页。原来的文件页如果不再被任何 PTE 引用就不会继续算进这个进程的 RSS。所以“一个私有映射 原地写”的净增按页看接近 0。真正的翻倍是像上面代码一样src和out两个视图同时映射了同一个文件页。src一直占着文件页out写的时候又复制出匿名页两个页表项各指一个物理页于是一个 4KB 的页面在 RSS 里变成了两份。2. 根因两个 mmap 视图 MAP_PRIVATE让一页变成两页2.1 写时复制COW到底复制了什么Linux 在处理MAP_PRIVATE映射写入时会执行典型的 COW 流程。假设某一页已经因为读操作映射进来了此时页表项指向文件页缓存里的一个干净页。写入时内核要保证“这次修改不会回写文件”于是在匿名内存区域分配一个新物理页把旧文件页的内容复制到新页更新页表项让进程看到的地址指向新匿名页旧文件页保留在 page cache 里但不再映射进这个进程。所以“写时复制”复制的是页面内容不是文件本身。如果只有一个映射RSS 会被换成一个新页总量不变。但一旦有第二个视图src指着旧文件页旧页和新页就会同时存在于进程页表里翻倍就出现了。2.2 为什么 src 和 out 同时存在会叠出两份看这张简单的状态变化设文件大小为 N 页mmap返回后src和out的页表都是空的懒加载。第一次读src[off]该页变成文件页记作 F。第一次写out[off]该页 COW产生匿名页 A。此时src的页表还指着 Fout的页表指着 A。同一个文件偏移被两个 PTE 指向两个物理页F A。如果程序把每一页都写了最终 RSS 就接近2 × 文件大小。这就是“内存翻倍”最典型的形态一份是只读输入视图一份是可写输出视图。2.3 页缓存和 cgroup 的“隐藏翻倍”还有一种情况会让系统显示“翻倍”但进程 RSS 没翻那就是 page cache 占用的统计。比如上面 1.3 说的单个私有映射如果程序只循环读一遍page cache 里会保留一份文件页虽然它不会再算进进程 RSS在容器 cgroup 的memory.current里这种文件页也会被记到使用量上。如果再叠加 COW 产生的匿名页cgroup 视角就是“文件页 匿名页”一起算也可能达到 2 倍。线上看内存不能只盯top里的 RES要同时看cat /sys/fs/cgroup/memory.stat | grep -E ^(file|anon)file是文件页缓存anon是匿名页。翻倍严重时两者往往都在涨。3. .so 自身的映射也要单独算ELF 段、BSS 和全局缓冲3.1 动态库在 /proc/PID/maps 里的“三段式”面孔排查.so问题时千万别忘了动态库自身也有 mmap。一个.so被dlopen加载后/proc/PID/maps里通常会看到好几个段7f8c5a4a0000-7f8c5a4b0000 r-xp ... /path/libcalc.so 7f8c5a4b0000-7f8c5a4b1000 r--p ... /path/libcalc.so 7f8c5a4b1000-7f8c5a4b5000 rw-p ... /path/libcalc.so分别对应代码段、只读数据段、可写数据段。链接器会用MAP_PRIVATE映射这些段其中代码段和只读段是可共享的多个进程加载同一份.so时物理页可以复用可写数据段则可能触发 COW。如果堆内存和文件 mmap 同时增长光看进程总 RSS 分不清是谁的账。3.2 库内全局大数组与数据文件 mmap 叠加怎么拆账当时我还怀疑过是不是.so自己有个大全局缓冲区和文件映射叠在一起了。这种情况很常见比如计算库内部声明了static uint32_t bucket[1024 * 1024]; // 4MB.so被多实例加载时这个大数组在物理内存里可能有好几份因为它属于可写数据段写的人越多 COW 越厉害。拆账的办法是看映射对应的路径和权限pmap -X pid | grep -E libcalc|data.bin或者直接看/proc/pid/smaps里每个段自己的Rss、Private_Clean、Private_Dirty。文件映射和库映射分开统计后才能确定翻倍是来自数据文件还是来自.so自身的大全局对象。3.3 别把 p_memsz 大于 p_filesz 当成物理内存泄漏用readelf -l libcalc.so看 ELF program header经常会有一类段LOAD off 0x0000000000000df0 vaddr 0x0000000000001df0 p_filesz 0x1b8 p_memsz 0x1250 flags RWp_memsz 比 p_filesz 大出来的部分就是 BSS通常是未初始化的全局变量。这部分映射在虚拟地址里存在但物理页是按需分配的不写就不占物理内存。所以.so的 VSZ 再大只要不触碰 BSSRSS 就不会被它拉起来。看到 VSZ 翻倍不用怕真正可怕的是Private_Dirty涨。这个步骤的目的是把“动态库自身开销”和“数据文件 mmap 开销”分开。分开之后真凶才会浮出来。4. 从翻倍现场到真凶我用 smaps、strace 走完的完整排查链路4.1 第一步用 smaps_rollup 看 File 与 Anon 的消长排查的第一步永远是量化而不是猜。我先在进程计算前记一次cat /proc/pid/smaps_rollup重点看这两行FilePgs: 102400 kB AnonPages: 16 kB跑完计算后再看一次FilePgs: 102400 kB AnonPages: 102400 kBFilePgs没降AnonPages却涨到和文件一样大。这说明文件页还被至少一个 PTE 持有而新增的匿名页又出现了。两列同时存在就是“一份文件两份物理页”的铁证。4.2 第二步按段 grep 出两个同名文件映射smaps_rollup只能确认总量要确认来源需要看每个映射段。用grep把data.bin相关的 VMA 拉出来grep -B 4 -A 12 data.bin /proc/pid/smaps结果通常会出现两段几乎一样的映射7f...-7f... r--s ... /data/input.bin Private_Clean: 102400 kB Private_Dirty: 0 kB 7f...-7f... rw-s ... /data/input.bin Private_Clean: 0 kB Private_Dirty: 102400 kB第一个只读映射干干净净第二个可写映射的Private_Dirty刚好等于文件大小。到这里srcout双映射的问题基本就坐实了。4.3 第三步strace 核对 mmap 参数确认是谁开了写权限为了确认不是别的第三方库干的我用 strace 抓了一次系统调用strace -f -e tracemmap,munmap,mprotect,open -o /tmp/calc.mmap ./runner data.binstrace 里mmap的参数是十六进制需要拆一下 flags。重点找两条指向同一个 fd 的mmap调用一条只带PROT_READ另一条带了PROT_READ | PROT_WRITE。如果两者返回的地址段都能对应到/proc/pid/maps里的同名文件说明问题出在我们自己的.so代码里而不是链接器或 runtime。4.4 第四步用最小样本把结论钉死最后我把原计算逻辑缩成几十行最小复现只保留“一个只读映射 一个私有可写映射 逐页写”。用 256MB 临时文件反复跑峰值 RSS 稳定在 512MB。再把out映射删掉只留src峰值掉回 256MB。两次对照组一摆结论不再有任何歧义翻倍来自双视图 MAP_PRIVATE 的 COW。5. 修复去掉一份视图或者让输出不经过 COW5.1 方案 A一个只读映射 独立结果区不再原地写如果计算结果是摘要、索引或聚合值根本不需要把整个文件拷贝成另一个大视图。改成只读映射输入输出放在独立的小缓冲区里const uint8_t *src (const uint8_t *) mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); uint64_t result[CHUNK_NUM]; // 只存每块的摘要 for (size_t i 0; i CHUNK_NUM; i) { result[i] calc_chunk(src i * CHUNK_SIZE); }这样src的文件页只需要一份结果区是固定大小的匿名内存不会跟输入页重叠。实测峰值 RSS 从 2 倍降到约 1 倍 几 MB。适合大多数聚合类计算。5.2 方案 BMAP_SHARED ftruncate只保留一份文件页如果算法确实需要“原地写回原文件”就不要用MAP_PRIVATE 双视图了。改成单映射的MAP_SHARED写操作直接体现到 page cache不会复制匿名页int fd open(path, O_RDWR); // 如果是新建输出文件先扩大文件长度 ftruncate(fd, expected_size); uint8_t *buf (uint8_t *) mmap(NULL, expected_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); for (size_t off 0; off expected_size; off 4096) { buf[off] transform(buf[off]); // 原地写 } msync(buf, expected_size, MS_ASYNC);这个方案里MAP_SHARED写页不会触发 COW物理内存只有一份。要注意的是文件描述符必须以O_RDWR打开并且在写之前ftruncate预留空间。若原文件是只读的先复制到临时文件再处理。5.3 方案 C流式 pread/pwrite固定内存处理大文件不是所有场景都适合整个文件做 mmap。如果计算是顺序扫描用pread/pwrite反而更稳char in_buf[4096]; char out_buf[4096]; while (offset file_size) { size_t n pread(fd_in, in_buf, sizeof(in_buf), offset); process(in_buf, out_buf, n); pwrite(fd_out, out_buf, n, offset); offset n; }进程用户态内存只剩固定几个缓冲区RSS 基本是个常数。缺点是每块都要进一次系统调用但因为内核有预读顺序 IO 吞吐通常可以接受。这个方法对超大文件特别友好也是我后来最推荐给“计算完即归档”类工具的做法。5.4 三种方案的实测对比用同一份 1GB 文件测三个改法结果如下方案平均峰值 RSS写回原文件适用场景只读映射 结果区约 1GB 固定否聚合、摘要、统计MAP_SHARED 单映射约 1GB是原地改写单进程pread/pwrite 流式固定几十 MB可超大文件、顺序流从“翻倍”到“不翻倍”本质不是魔改内存参数而是别让两个视图同时抢一份数据。6. 哪些“翻倍”需要修哪些是统计假象6.1 峰值翻倍 vs 稳态翻倍COW 产生的匿名页在处理完对应区域后理论上可以被回收。如果内存曲线是跑计算时短暂冲高、算完回落这叫峰值翻倍只要峰值离内存上限还有余量问题不大。真正要警惕的是算完还不回落AnonPages一直维持在文件大小附近说明有映射段一直占着匿名页这时候再排查是不是有MAP_PRIVATE写映射没有释放或者全局容器持有未释放。6.2 多进程加载同一个 .so 时的 RSS 虚高当多个进程都加载同一个.so时代码段和只读段可以共享物理页但top里的 RSS 会把每个进程的页表项都加起来。假设 16 个进程同时加载 100MB 的库RSS 看起来可能有 1.6GB实际物理内存可能只有 100MB 各进程私有部分。这种翻倍是统计口径造成的虚高不能当内存泄漏处理。判断是否虚高用 PSS 或者 cgroup 的memory.current更准。6.3 我现在的例行检查清单踩过这个坑之后我现在每当碰到.so或mmap相关内存告警都会按固定顺序排查先看smaps_rollup的FilePgs和AnonPages确认是 COW 翻倍还是缓存膨胀再看pmap -X按段定位找到是否有两个 VMA 指向同一个文件用strace -e tracemmap确认映射来源排查第三方库是否有隐藏映射最后用一份最小复现跑time -v对比不同映射 flag 的峰值。这个流程走完多数 mmap 内存翻倍都能在半小时内定位。关键是先弄明白“RSS”统计的是页表项还是物理页再决定要不要动手改代码。单映射的 COW 替换不是翻倍双视图的“文件页 匿名页”才是真正的内存杀手。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询