先说我遇到的场景。某个压测服务里,一个.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 变大就慌了,其实要分清三种内存读数:
| 指标 | 全称 | 说明 |
|---|---|---|
| VSZ | Virtual Size | 进程地址空间大小,包含从未触碰过的映射,mmap 之后立即变大,但基本不吃物理内存 |
| RSS | Resident Set Size | 当前实际驻留的物理页数量,是 OOM 最常见的判断依据 |
| PSS | Proportional Set Size | RSS 按页共享数均摊后的值,多进程场景下更公平 |
.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的页表还指着 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 RWp_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 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刚好等于文件大小。到这里,src+out双映射的问题基本就坐实了。
4.3 第三步:strace 核对 mmap 参数,确认是谁开了写权限
为了确认不是别的第三方库干的,我用 strace 抓了一次系统调用:
strace -f -e trace=mmap,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. 修复:去掉一份视图,或者让输出不经过 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相关内存告警,都会按固定顺序排查:
- 先看
smaps_rollup的FilePgs和AnonPages,确认是 COW 翻倍还是缓存膨胀; - 再看
pmap -X按段定位,找到是否有两个 VMA 指向同一个文件; - 用
strace -e trace=mmap确认映射来源,排查第三方库是否有隐藏映射; - 最后用一份最小复现跑
time -v,对比不同映射 flag 的峰值。
这个流程走完,多数 mmap 内存翻倍都能在半小时内定位。关键是先弄明白“RSS”统计的是页表项还是物理页,再决定要不要动手改代码。单映射的 COW 替换不是翻倍,双视图的“文件页 + 匿名页”才是真正的内存杀手。