☰
mmap内存翻倍之谜:MAP_PRIVATE双视图的COW陷阱
2026/10/22 4:17:43 网站建设 项目流程

先说我遇到的场景。某个压测服务里,一个.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): 2097152

1GB 的文件,最大驻留内存 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 流程。假设某一页已经因为读操作映射进来了,此时页表项指向文件页缓存里的一个干净页。写入时,内核要保证“这次修改不会回写文件”,于是:

  1. 在匿名内存区域分配一个新物理页;
  2. 把旧文件页的内容复制到新页;
  3. 更新页表项,让进程看到的地址指向新匿名页;
  4. 旧文件页保留在 page cache 里,但不再映射进这个进程。

所以“写时复制”复制的是页面内容,不是文件本身。如果只有一个映射,RSS 会被换成一个新页,总量不变。但一旦有第二个视图src指着旧文件页,旧页和新页就会同时存在于进程页表里,翻倍就出现了。

2.2 为什么 src 和 out 同时存在会叠出两份

看这张简单的状态变化,设文件大小为 N 页:

  • mmap返回后,src和out的页表都是空的,懒加载。
  • 第一次读src[off],该页变成文件页,记作 F。
  • 第一次写out[off],该页 COW,产生匿名页 A。
  • 此时src的页表还指着 F,out的页表指着 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 RW

p_memsz 比 p_filesz 大出来的部分就是 BSS,通常是未初始化的全局变量。这部分映射在虚拟地址里存在,但物理页是按需分配的:不写就不占物理内存。所以.so的 VSZ 再大,只要不触碰 BSS,RSS 就不会被它拉起来。看到 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 kB

FilePgs没降,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刚好等于文件大小。到这里,src+out双映射的问题基本就坐实了。

4.3 第三步:strace 核对 mmap 参数,确认是谁开了写权限

为了确认不是别的第三方库干的,我用 strace 抓了一次系统调用:

strace -f -e trace=mmap,munmap,mprotect,open -o /tmp/calc.mmap ./runner data.bin

strace 里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. 修复:去掉一份视图,或者让输出不经过 COW

5.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 方案 B:MAP_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相关内存告警,都会按固定顺序排查:

  1. 先看smaps_rollup的FilePgs和AnonPages,确认是 COW 翻倍还是缓存膨胀;
  2. 再看pmap -X按段定位,找到是否有两个 VMA 指向同一个文件;
  3. 用strace -e trace=mmap确认映射来源,排查第三方库是否有隐藏映射;
  4. 最后用一份最小复现跑time -v,对比不同映射 flag 的峰值。

这个流程走完,多数 mmap 内存翻倍都能在半小时内定位。关键是先弄明白“RSS”统计的是页表项还是物理页,再决定要不要动手改代码。单映射的 COW 替换不是翻倍,双视图的“文件页 + 匿名页”才是真正的内存杀手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询