mmap 与共享内存:同一块物理页,两个进程的地址不一样

难度 ★★★★☆ · 前置:管道:2GB 文件过一次管道,内存为什么不涨 | 一句话结论:mmap 不复制任何数据,它只是在你的页表里登记一条「这个地址范围对应那个文件的那一段」;共享内存是同一个机制用在匿名区域上,所以它最快,也最不负责同步。

从 2.3 秒到 4 毫秒,然后崩了

一个日志处理服务,每次启动都要读一个 500MB 的索引文件,原来的写法很朴素,打开加 malloc 再加一次 read。

这一篇的 C 例子都用 gcc -O0 -o x x.c && ./x 编译(共享内存那个要加 -lrt),头文件按用到的补 #include <fcntl.h>、#include <sys/mman.h>、#include <unistd.h>。

1
2
3
int fd = open("/data/index.bin", O_RDONLY);
char *buf = malloc(500 * 1024 * 1024);
read(fd, buf, 500 * 1024 * 1024);

启动要 2.3 秒,RSS 直接涨 500MB。

有人建议改成 mmap,改完之后就三行。

1
2
int fd = open("/data/index.bin", O_RDONLY);
char *buf = mmap(NULL, 500 * 1024 * 1024, PROT_READ, MAP_PRIVATE, fd, 0);

启动降到 0.004 秒,RSS 一开始只有几百 KB。

当时觉得这也太便宜了,便宜得有点不真实,果然用了半天之后,程序在某些机器上突然崩,而且不是 SIGSEGV,是下面这个。

1
Bus error (core dumped)

SIGBUS。不是段错误,是总线错误,代码没改过,只是文件被改小了,而这两件事在代码里看不出任何联系,因为文件是谁改的你根本不知道,可能是运维在清理、可能是另一个任务在重建索引、也可能是某个脚本觉得这个文件太大了顺手删了重写,你们之间没有任何约定,也就没有任何通知,它改它的,你读你的,直到某一次访问正好落在已经不存在的页上,内核发现它拿不出这一页,于是把整个进程按在地上,这就是 mmap 的代价,它让你看到的不是文件,是一张通往文件的许可证,而许可证没写有效期,文件变了它不通知你。

它映射的可以是五样东西

mmap 把一段虚拟地址范围映射到某个东西上,那个东西可以是下面这几类,日常遇到的基本都在表里。

映射源 flags 用途
普通文件 MAP_PRIVATE / MAP_SHARED 读大文件、写文件、进程间共享文件
匿名内存 MAP_ANONYMOUS 大块 malloc、线程栈
共享内存对象 /dev/shm 下的文件 进程间通信
设备内存 (内核驱动) 显卡、DMA
memfd memfd_create() 匿名但有 fd,可就地传

关键在最后一句,mmap 不复制数据,read 是把数据搬到你的缓冲区,mmap 是让你的地址直接指向那块数据,这两句话看起来像修辞,其实是两种完全不同的内存布局,前者让同一份内容在物理内存里存在两份,后者只存在一份,两份还是一份,在一个 4GB 的机器上就是能不能同时开五个服务的区别。(写到这里我得承认,前两年我一直以为 mmap 是内核偷偷做了个优化,把 read 的那一份复制省掉了,这个理解不算全错,但它让你以为 mmap 是 read 的加速版,于是就会问出那个经典问题,为什么我换成 mmap 反而更慢了。)

这就是为什么 mmap 快,也是为什么它的 RSS 一开始不涨,页表登记了,物理页还没填,第一次访问才触发缺页,而缺页是个异常,异常处理完之后你回到原来的指令继续执行,整个过程你感知不到,只觉得这次访问比上一次慢了那么一点点,慢到在一秒几百次的循环里根本测不出来,我第一次拿 perf 看这块的时候还以为统计出错了,怎么一个 500MB 的映射只花了 20 毫秒,后来才明白那 20 毫秒里内核做的事只有一件,往页表里填了几条记录,真正的搬运被推迟到了你伸手去碰它的那一刻。

写下去之后,谁看得见

MAP_PRIVATE 和 MAP_SHARED 决定写入之后发生什么,这是 mmap 参数里唯一一个必须想清楚才能下笔的东西。

MAP_PRIVATE MAP_SHARED
读 共享页缓存 共享页缓存
写 COW,复制一份私有页 直接改页缓存
其他人能看到改动 看不到 立刻看到
会回写文件 不会 会(不需要 msync 也能被回写线程刷)
典型用途 读配置文件、只读数据 共享内存、写文件

MAP_PRIVATE + PROT_WRITE 是很常见的组合,读的时候共享,写的时候复制,这就是 COW,和 fork 用的是同一套机制。

快,是因为中间没有东西

方式 模式 数据搬几次
管道 生产者写缓冲区,消费者读缓冲区 2 次 copy
消息队列 拷贝进内核,再拷出 2 次 copy
共享内存 两个进程页表指向同一块物理页 0 次

这是真正的零拷贝,进程 A 写到的地址 0x7f0000001000,进程 B 从 0x7f0000003000 读到,两个地址不同,物理页是同一个。

代价也随之而来,内核完全不管同步,管道帮你做的写满阻塞、读走唤醒,共享内存一样都不提供,要自己上信号量或互斥量,我第一次看到这个对比表的时候以为是笔误,怎么可能搬零次,后来盯着 pmap 的输出才想通,所谓共享就是你俩的页表项指向同一个物理页号,谁先写谁后写内核压根不知道,它连一个字节都没搬过,自然也就没机会插手你俩的顺序,所以 IPC 选型里那句话是成立的,共享内存最快,因为它把同步的责任丢给了你,而责任这东西一旦丢给你,就很难再有人替你兜住了。

mmap 到底做了几件事

mmap(NULL, len, prot, flags, fd, offset) 在内核里做的就是下面五步,一步不多。

  1. 检查 offset 必须是页大小(4KB)的整数倍,否则 EINVAL;
  2. 在当前进程的地址空间里找一段空闲的虚拟地址(从高往低找,通常在 mmap 区);
  3. 创建一个 vm_area_struct(VMA),记下 [start, end)、权限、以及「对应 fd 的 offset」;
  4. 不分配物理页,不读文件;
  5. 返回那个虚拟地址。

全程零 IO,零赋值,所以它快得离谱,真正的工作发生在第一次访问。

1
2
3
4
5
6
访问 0x7f0000001000
→ 页表里没有这个地址的 PTE
→ 缺页异常(page fault)
→ 内核查 VMA,发现「对应文件的 offset 0x1000」
→ 从页缓存拿那一页(没有就从磁盘读)
→ 填 PTE,恢复执行

这叫按需分页,你在 maps 里能看到 VMA,但 RSS 只统计真正访问过的页。

那个 SIGBUS 是哪来的

回到开篇那个问题,文件被改小了,但映射长度还是 500MB,访问超出新文件末尾的那部分时,页缓存里没有这一页,文件已经没那么长了,内核没法凭空造一页出来,于是发 SIGBUS。

SIGSEGV 说的是这个地址根本没有映射,SIGBUS 说的是映射还在但这一页拿不到,两者都是信号,都让进程当场死掉,区别在于前者说明你的指针坏了,后者说明你的指针是好的、环境变了,这个区别在排查的时候价值千金,因为前者要去改代码,后者要去改假设,而绝大多数人第一次遇到它的第一反应都是先去改代码,包括我,我甚至先把那一大段 memcpy 全注释掉跑了一遍,当然是好的,因为问题根本不在那段代码里,把代码改成对的样子并不能让文件变长,这个道理说起来一句话,当时花了两个晚上。(core 文件里只有一行 si_code: BUS_ADRERR,剩下的什么也没说。)

SIGSEGV SIGBUS
触发 访问未映射的地址、权限不足 映射存在但无法满足
典型原因 空指针、栈溢出、野指针 文件被截断、设备 IO 错误、映射超过文件末尾
信号号 11 7

mmap 一个文件时,访问超出文件末尾的部分也会 SIGBUS,比如文件 4096 字节,映射 8192 但只访问到 6000。

这是 mmap 最常见的坑,没有之一,防御方式是先 fstat 拿到真实大小,只映射那么多,并且用 mmap 处理可能被别人改动的文件时,必须处理 SIGBUS,我第一次撞上它是在一个周末的下午,gdb 打出 SIGBUS 的时候我还在搜 SIGSEGV 和信号 7 的区别,搜到一半才反应过来根本不是一个东西,那次之后我在所有映射文件的代码里都加了一行 fstat 比对,多一次系统调用,少一个周末。

msync 之后,数据在哪

MAP_SHARED + PROT_WRITE 的修改会进页缓存,和 write 一样不会立刻落盘。

1
msync(addr, len, MS_SYNC);   /* 等落盘 */

MS_SYNC 等写完,MS_ASYNC 只是交给回写线程,msync 保存的范围会向上取整到页,所以两个进程改相邻的字节数时,msync 可能互相干扰。

不 msync 也不会丢,回写线程会周期性刷,但断电会丢,和 write 一样。

两种共享内存,一个已经过时

路线 A 是 shm_open 加 mmap,POSIX 那套,现在推荐用这个。

1
2
3
int fd = shm_open("/myregion", O_CREAT | O_RDWR, 0600);
ftruncate(fd, 4096);
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

背后是 /dev/shm/myregion,tmpfs 上的文件,ls /dev/shm 能看到,名字必须以 / 开头,且不要带中间路径,"/a/b" 是错的,这个格式限制看着奇怪,其实就是因为它在内核里对应的是 tmpfs 根目录下的一个文件名,多一级目录它就没有地方放了,这也解释了为什么两个不同命名空间里的同名共享内存会互相看不见,它们根本不是同一条路径。(我第一次写 shm_open("/tmp/foo") 的时候还以为它能建个目录,跑出来直接 EINVAL,查了半天手册才发现那个斜杠只是个装饰。)

路线 B 是 shmget 加 shmat,System V 那套,老接口。

1
2
int id = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0600);
void *p = shmat(id, NULL, 0);

老接口,但 ipcs/ipcrm 这套工具还在用,现代程序应该用 POSIX 那套。

POSIX(shm_open) System V(shmget)
命名 文件路径式 整数 key
可见性 ls /dev/shm ipcs -m
大小调整 ftruncate 不支持(创建后大小不可变)
权限 文件权限 单独的权限字段
清理 shm_unlink ipcrm -m
推荐 是 遗留系统

System V 共享内存有个出了名的坑,进程崩溃不会自动清理,ipcs -m 里会积累一堆 nattch 0 的段,占着内存直到重启或者手动 ipcrm,POSIX 的 shm_open 好一点,但如果不 shm_unlink,文件也一直在 /dev/shm 里,而且它的名字看起来太像普通文件了,很容易被清理脚本当成垃圾删掉,删掉之后还在映射的进程不会立刻发现,等你下次访问到那些已经被回收的页,收到的又是一次 SIGBUS,绕了一圈还是回到同一个坑,所以我后来养成一个习惯,凡是 shm_open 出来的名字,都写进服务的启动参数里,让下一个人一眼能看出这是谁的,而不是对着 ls /dev/shm 猜。

/dev/shm 默认只有内存的一半,写满了会影响整个系统,先看一眼它有多大。

1
df -h /dev/shm

三个容易被忽略的细节

第一个是地址对齐,offset 必须是页大小整数倍,想让文件从第 100 字节开始映射没法直接做,只能先映射,再在返回地址上加偏移。

1
2
3
4
/* 省略的参数:PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0 */
char *base = mmap(NULL, len + 100, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (base == MAP_FAILED) { /* 处理错误 */ }
char *p = base + 100; // 这样才落在文件第 100 字节处

第二个是 fork 之后怎么继承,MAP_SHARED 下父子共享同一块物理页,改了立刻互相可见,MAP_PRIVATE 下是 COW,改了各管各的,这也是 fork 加 mmap 做共享内存的基础,不用 shm_open,父子之间 MAP_ANONYMOUS | MAP_SHARED 就够了。

第三个是两个相反的标志,MAP_POPULATE 在映射的同时把所有页都读进来,适合马上要全量扫的场景,能省掉几千次缺页异常,代价是 mmap 本身变慢,MAP_NORESERVE 不参与 overcommit 检查,适合稀疏访问的大块映射,比如给数据库预留 1TB 地址空间。

用 mmap 写文件,顺序不能错

1
2
3
4
5
6
7
int fd = open("data.bin", O_RDWR | O_CREAT, 0644);
ftruncate(fd, size); /* ← 必须先定大小! */
char *p = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(p, data, size);
msync(p, size, MS_SYNC);
munmap(p, size);
close(fd);

ftruncate 不能忘,新建的空文件大小是 0,映射上去之后一访问就是 SIGBUS,因为映射范围超出了文件末尾。

这个错误的表现和开篇那个一模一样,但成因完全不同,一个是被别人改小了,一个是你自己没设大小,前者你要去查谁动的文件,后者你要回去补一行代码,而它们打出来的都是同一个 Bus error,日志里也不会告诉你差别,你只能从上下文里猜,猜错一次就是半天,我后来把这两种情况写进了交接文档,因为我自己就先把后者当成前者查过一次,翻了两小时别人的发布记录,最后发现那个文件从建出来就没超过 0 字节,问题一直在我这边。

数一数 VMA 有几条

VMA 有多少条,这个数字我自己第一眼也没想到。

1
wc -l /proc/self/maps

一个普通进程几十到几百条很正常,再看文件映射的部分。

1
grep -E '\.so|\.data' /proc/self/maps | head -5

实测 mmap 大文件

造一个 1GB 的文件。

1
dd if=/dev/zero of=/tmp/big.bin bs=1M count=1024

这 1GB 会实打实占着 /tmp,测完记得 rm -f /tmp/big.bin。

对比两种读法,一个老老实实读,一个只碰首尾两页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# read 版本
python3 -c "
import os, time
t = time.time()
fd = os.open('/tmp/big.bin', os.O_RDONLY)
n = 0
while True:
b = os.read(fd, 1 << 20)
if not b: break
n += len(b)
print(f'read: {n} bytes in {time.time()-t:.3f}s')
"

# mmap 版本
python3 -c "
import mmap, time
t = time.time()
with open('/tmp/big.bin', 'rb') as f:
m = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
n = len(m)
_ = m[0], m[-1] # 只碰首尾两页
print(f'mmap: {n} bytes in {time.time()-t:.3f}s')
"

mmap 版本会快一个数量级,因为它根本没读全部数据。

再验证按需分页。

1
2
3
4
5
6
7
8
9
10
11
12
13
import mmap

with open('/tmp/big.bin', 'rb') as f:
m = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)

def rss():
for line in open('/proc/self/status'):
if line.startswith('VmRSS'):
return line.strip()

print('映射后 :', rss())
m[0:4096]; print('碰第 1 页后 :', rss())
m[0:1 << 20]; print('碰 1MB 后 :', rss())
1
2
3
映射后     : VmRSS:	     9220 kB
碰第 1 页后 : VmRSS: 9224 kB
碰 1MB 后 : VmRSS: 10248 kB

映射 1GB,只涨了 4KB,碰 1MB 之后涨 1MB,这就是 mmap 比 read 省内存的机制。

复现 SIGBUS

1
2
3
4
5
6
7
8
9
10
11
12
python3 -c "
import mmap, os

open('/tmp/small.bin', 'wb').write(b'A' * 8192)

f = open('/tmp/small.bin', 'r+b')
m = mmap.mmap(f.fileno(), 8192) # 映射 8KB,此刻文件也刚好 8KB
print('first page ok:', m[0:4])
print('touching second page...')
os.truncate('/tmp/small.bin', 1024) # 把文件改小
print(m[4096:4100]) # ← 这里:第二页整个落在文件尾之后
"

Python 的 mmap.mmap() 不接受比文件更长的映射,CPython 会直接抛 ValueError: mmap length is greater than file size,C 里直接调 mmap() 是允许的,超出部分要到访问时才 SIGBUS,所以得先把文件造够大再映射,然后才截断。

在 Linux 上会得到 Bus error (core dumped)。

在某些内核/文件系统上表现是 SIGSEGV 或者读到 0,SIGBUS 是 POSIX 规定的正确行为,但实现细节有差异,生产代码不该依赖哪一种,我那个服务后来改成了每次都 fstat 比对大小,对不上就重新 mmap,虽然多了一次系统调用,但至少不会在半夜收到一个 core dumped 然后对着代码看半小时什么也看不出来。

亲手做一个共享内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* writer.c */
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void) {
int fd = shm_open("/demo_shm", O_CREAT | O_RDWR, 0600);
ftruncate(fd, 4096);
char *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(p, "hello from writer");
printf("writer (pid %d) wrote at %p\n", getpid(), (void *)p);
sleep(10);
munmap(p, 4096);
close(fd);
shm_unlink("/demo_shm");
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* reader.c */
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void) {
int fd = shm_open("/demo_shm", O_RDONLY, 0);
char *p = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);
printf("reader (pid %d) sees at %p: %s\n", getpid(), (void *)p, p);
munmap(p, 4096);
close(fd);
return 0;
}
1
2
3
4
5
gcc -o writer writer.c -lrt && gcc -o reader reader.c -lrt
./writer &
sleep 1
./reader
ls -l /dev/shm/
1
2
writer (pid 12044) wrote at 0x7f3a8c000000
reader (pid 12046) sees at 0x7f3a9b200000: hello from writer

注意两个 0x... 开头的地址不一样,但内容是同一份,这两行是整篇文章的核心证据,第一次跑出这个结果的时候我盯着屏幕看了很久,因为直觉上你会以为共享内存就是把同一块地址给两个进程,可地址明明不同,内容却是通的,这件事只有在页表这一层才讲得通。

/dev/shm/ 里能看到那个文件。

1
-rw------- 1 ark ark 4096 Nov 13 20:58 demo_shm

它是 tmpfs 上的真文件,df -h /dev/shm 能看到用量,这也意味着它能被 ls、能被 rm,能被清理脚本误删。

父子的匿名共享

不用 shm_open,fork 之前建 MAP_ANONYMOUS | MAP_SHARED 就够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
int *shared = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*shared = 0;
if (fork() == 0) {
*shared = 42;
_exit(0);
}
wait(NULL);
printf("parent sees %d\n", *shared); /* 42 */
return 0;
}

把 MAP_SHARED 换成 MAP_PRIVATE,输出变成 0,这就是 COW 的证据。

映射不等于读取

mmap 把文件读进内存了,不是,只登记了映射,没读一个字节,访问才触发缺页。

mmap 的 RSS 应该等于文件大小,不是,只有访问过的页才算,全量扫完之后才接近,你在监控上看到 RSS 只有几 MB 不代表这个映射是空的,它可能已经能用,只是还没被碰过。(我第一次看到自己那个 500MB 的映射 RSS 只有 12MB,第一反应是 mmap 失败了,还去翻了错误码。)

mmap 比 read 快所以永远用 mmap,不是,小文件、顺序读一次的场景 read 更简单也没差多少,mmap 的优势在随机访问、反复访问、以及多个进程共享同一份数据,把一个 4KB 的配置读进内存还要建映射,纯属给自己找麻烦。(这条我踩过,而且是反过来的方向,我当时把一个小配置文件改成了 mmap,理由是书上说这样快,结果代码多了三十行,快了一微秒,评审的时候被问了一句这是图什么。)

SIGBUS 和 SIGSEGV 是一回事,不是,不同信号,SIGBUS 是映射在但拿不到页,SIGSEGV 是映射本身非法。

msync 之后数据一定在磁盘上,不是,它保证落到块设备的队列,不保证物理介质,真正的持久化要靠磁盘自己的缓存策略,fsync 也一样。

共享内存不需要同步,恰恰相反,是最需要同步的,内核一个忙都不帮,原子性、顺序、可见性全靠你自己加锁,CPU 缓存一致性保证了字节可见,但不保证顺序,需要内存屏障。

shm_open 的名字可以带路径,不行,只能是 /name 格式,不能有中间 /。

System V 共享内存会自动清理,不会,进程崩了段还在,ipcs -m 能看到,要 ipcrm -m 手动删。

/dev/shm 可以随便用,不行,它是 tmpfs,占的是内存,写满会影响整个系统,连 fork 都可能失败。

登记,不是复制

mmap 不复制数据,只在页表里登记一条映射,真正的工作在第一次访问的缺页异常里,MAP_PRIVATE 写时复制,MAP_SHARED 直接改页缓存,后者是共享内存的基础,也是唯一一个能让两个进程看到同一份改动的方式,理解这一条,后面 shm_open、memfd、MAP_ANONYMOUS 全都是它的变体。

共享内存是最快的 IPC,因为搬零次,也最危险,因为内核不管同步,SIGBUS 是 mmap 独有的坑,来自映射范围超出文件末尾或者文件被别人改小了,所以 mmap 写文件前必须 ftruncate 定大小。

POSIX 那套比 System V 干净,但 /dev/shm 占内存、要自己 shm_unlink,fork 之前用 MAP_ANONYMOUS | MAP_SHARED 就能让父子共享内存,不用 shm_open,这三种写法你迟早都会遇到,遇到的时候能立刻分清它们各自在页表上做了什么,比背参数有用得多。

到这里你手里有管道、共享内存、消息队列、socket 四把锤子,真正难的是遇到一个具体场景,知道该抽哪一把。