写 Linux 下多进程程序的人,迟早会被一个问题逼疯:两个进程之间,到底怎么交换数据才又快又稳?管道、消息队列、信号、本地 socket,这些东西文档上都写着“进程间通信”,可真到了要传几十上百 MB 的数据、或者要让多个进程同时维护一份不断变化的状态结构时,你会明显感觉它们要么慢、要么别扭。兜兜转转用了好几年共享内存,我现在可以很直接地说:在 Linux 系统编程里,共享内存是我默认优先考虑的一招。这篇文章就把我从接口到原理、从 Demo 到踩坑的全套心得倒出来,给正在学共享内存、或者准备在项目里真正用它的人做一份能直接参考的实战笔记。
1. 管道、消息队列、共享内存:传输代价与适用场景
1.1 数据拷贝次数决定 IPC 的下限
在选进程间通信方案之前,我建议你先算一笔账:数据从进程 A 到进程 B,中间到底经过了几次拷贝。这个数字几乎决定了方案的性能上限,因为内存拷贝是纯带宽操作,再优化也顶多无限逼近 memcpy 的速度。
管道和消息队列都属于“内核中转”型方案。进程 A 要发送数据,先把数据从用户态缓冲区拷贝到内核缓冲区,进程 B 接收时再把数据从内核缓冲区拷贝回用户态缓冲区,一次通信至少两次完整拷贝。Socket 走 loopback 时也是类似路径,本地 TCP 虽然在内核里做了不少优化,但本质上并没有绕开内核转发这层。信号能传递的信息量又太小,通常只适合做事件通知,不适合传业务数据。
共享内存不一样。它把同一块物理内存同时映射到多个进程的地址空间,之后进程 A 写数据、进程 B 读数据,都是在各自的用户态地址上直接操作,零次内核拷贝。这不是“接近零拷贝”,而是真正的零拷贝,因为数据本身就在你我共用的那块内存里,压根不需要搬动。等到进程 B 需要“收到”数据,它做的只是读自己地址空间里的某个地址而已。
所以我的经验判断很直接:只要数据量大、频率高、结构复杂,共享内存基本就是最优解。比如多个 worker 进程共享一份配置、一个实时行情结构、一段环形缓冲日志,这些场景用管道和 socket 会先死在拷贝上,用共享内存则是水到渠成。
1.2 共享内存不是万能药
但我得说句公道话,共享内存的“零拷贝”优势是有代价的。最大的代价是:内核不再帮你做同步。管道是有缓冲的,写端和读端天然有一层数据流控制;socket 有协议栈帮你维护连接状态。共享内存只是给了你一块公共区域,谁先写、谁后读、写了一半被读走了怎么办,这些问题内核一律不管,你必须自己设计同步机制,信号量、原子操作、自旋锁都得自己上。
共享内存的第二个代价是生命周期管理。管道随进程消亡自动清理,socket 关闭连接就结束。共享内存对象可不是,进程崩溃了、忘记调删除接口了,对象可能继续残留在系统里,要么占据内存,要么污染下一次启动的名字空间。这些坑我在文章后面会专门讲。
一句话总结这个章节:共享内存解决的是“传得快”的问题,它把“传得对”的问题抛回给了你。能用好它,你就掌握了 Linux 系统编程里最锋利的一把刀。
2. 一张页表把两个进程连起来
2.1 虚拟地址与物理页的映射关系
要理解共享内存为什么快,得先搞清楚 Linux 内存管理的底层模型。每个进程都有自己的虚拟地址空间,虚拟地址通过 CPU 的页表翻译成物理地址。页表是一级一级查的,但最终都是同一个结论:虚拟页指向物理页。
共享内存干的事情极其朴素——让多个进程的页表里,某些虚拟页指向同一批物理页。进程 A 在自己地址空间的 0x7f00... 处写入数据,本质是写到了某块物理内存;进程 B 虽然在自己的地址空间里看到的是另一个虚拟地址,但它的页表也指向这块物理内存。于是 A 写完,B 什么都不用做就能看到,这就是“共享”二字的全部秘密。
这里有个容易误解的点:mmap 或者 shmat 返回的地址,在不同进程里是完全不同的数值,千万不要以为两个进程会得到同一个虚拟地址。各进程看到的虚拟地址不一样,但背后的物理页是同一块。如果有人写出“把共享内存指针直接传给另一个进程”的代码,那个指针在对方进程里是无效的,跑起来就是段错误。
2.2 MAP_SHARED 与 MAP_PRIVATE 的区别
用 mmap 映射共享内存时,flags 参数只有两个选择:MAP_SHARED 或 MAP_PRIVATE。这两个词已经把行为说得很直白了。
MAP_SHARED 是说:我对这块映射的任何写入,都会直接落到背后的物理页上,别的进程立刻可见,并且可能写回到文件(如果是文件映射的话)。这是共享内存必须用的标志。
MAP_PRIVATE 是另一种完全不同的语义:映射刚建立时大家确实共享同一份物理页,但一旦任何一个进程对某个页面执行写入,内核就会触发“写时复制”(Copy-On-Write),把那个页复制一份给写入方,从此各走各路。这常用于父子进程共享只读代码段,或者实现 fork 的懒拷贝优化。如果你用 MAP_PRIVATE 做进程间通信,别人写的数据你大概率看不到,这是一个很隐蔽的逻辑 bug。
顺手提一个开发中很常用的小技巧:如果只在父子进程之间共享内存,根本不需要 shm_open 创建命名对象,直接用匿名映射就行:
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);匿名映射没有文件对象,只能通过 fork 被子进程继承。父子进程 fork 之后看到的是同一块物理内存,配合上信号量就是一套极简 IPC。缺点是没有名字,无法让“无关”的两个进程找到它。
2.3 为什么“零拷贝”仍然要小心缓存一致性
说到“零拷贝”,有朋友可能会问:既然数据在两个进程里都可见,是不是写完立刻就能被读到?这里有一个现代 CPU 架构下的坑。
现在的多核 CPU 每个核都有自己的 L1/L2 缓存,不同核看到的内存视图可能短暂不一致。x86 因为内存模型比较强,大多数普通读写会在硬件层面自动保证最终一致;但 ARM 等弱内存模型的架构上,内存屏障是必须考虑的东西。更麻烦的是编译器优化,它可能把共享变量的值搬到寄存器里反复使用,完全意识不到这个变量会被另一个进程修改。
所以在共享内存里读写共享字段,绝对不要用普通变量的裸读写。要么用信号量这类内核同步原语把访问包起来,要么用 C11 的原子操作,要么在关键位置显式加内存屏障。把共享内存当成一块普通堆内存来用的代码,基本都会在 -O2 优化下给你表演一场“灵异事件”:别的进程明明写了,这边死活看不到。
3. System V 与 POSIX 两套接口怎么选
3.1 两套接口的编程差异
Linux 提供两套共享内存 API,这算历史遗留。教材里常见的是 System V 那套:shmget、shmat、shmdt、shmctl。它用整数 key 作为对象的标识,key 可以由程序员硬编码,也可以用 ftok 根据文件路径和项目编号生成。核心调用长这样:
#include <sys/ipc.h> #include <sys/shm.h> #include <string.h> #define SHM_KEY 0x5201 int main(void) { int shmid = shmget(SHM_KEY, 4096, IPC_CREAT | 0666); if (shmid < 0) { perror("shmget"); return 1; } void *p = shmat(shmid, NULL, 0); if (p == (void *)-1) { perror("shmat"); return 1; } strcpy((char *)p, "hello from process A"); shmdt(p); return 0; }shmget 拿到的是共享内存段的 id,shmat 把这个段挂到自己地址空间,返回可用指针,shmdt 解除挂接。要删除段则调 shmctl(shmid, IPC_RMID, NULL)。这套接口年代久远,行为细节比较多,比如 shmat 返回 -1 而不是 NULL 表示失败,很多人第一次写都会在这里栽跟头。
POSIX 那套看起来更现代,因为它把共享内存表达成了一个“文件对象”。shm_open 打开或创建一个名字以斜杠开头的对象,返回一个文件描述符,ftruncate 设定大小,mmap 映射到地址空间。同样的“写一句话”逻辑,POSIX 版本长这样:
#include <fcntl.h> #include <sys/mman.h> #include <unistd.h> #include <string.h> #define SHM_PATH "/hello_shm" #define SHM_SIZE 4096 int main(void) { int fd = shm_open(SHM_PATH, O_CREAT | O_RDWR, 0666); if (fd < 0) { perror("shm_open"); return 1; } if (ftruncate(fd, SHM_SIZE) != 0) { perror("ftruncate"); return 1; } void *p = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap"); return 1; } close(fd); strcpy((char *)p, "hello from process A"); munmap(p, SHM_SIZE); shm_unlink(SHM_PATH); return 0; }注意 mmap 返回 MAP_FAILED 而不是 NULL,这是一个特别容易写错的细节。另外 shm_open 之后 fd 可以立刻 close,因为映射已经持有底层对象,这是文件映射的统一语义。
3.2 对比表
两套接口的差异,我整理成了一张表,方便直接对照:
| 维度 | System V(shmget 系列) | POSIX(shm_open 系列) |
|---|---|---|
| 对象标识 | key_t 整数 key | 以 / 开头的字符串名字 |
| 句柄类型 | int shmid | int fd |
| 地址映射 | shmat | mmap |
| 大小设定 | shmget 创建时固定 | ftruncate 随时可调 |
| 删除接口 | shmctl IPC_RMID | shm_unlink |
| 查看对象 | ipcs -m | ls -l /dev/shm |
| 权限控制 | shmflg 里的 mode | open mode,受 umask 影响 |
| 新代码推荐度 | 低,多为存量项目 | 高,接口与文件操作统一 |
POSIX 版本最大的好处是对象名有实际意义,ls /dev/shm一下就能看到系统里有哪些共享内存对象,排查问题直观得多。System V 的整数 key 只能靠 ipcs 去猜,团队协作时还容易 key 撞车,两个项目用了同一个 key 互相串数据,那种问题排查起来非常酸爽。
3.3 我的选型建议
新写的代码,我的建议是无脑选 POSIX。理由有三条:第一,接口统一,学会了 mmap 就等于会了文件映射、匿名映射、共享内存三件事;第二,名字可读性强,运维和调试都友好;第三,ftruncate 可以动态调整大小,System V 的段大小一旦 shmget 就固定了,改大小只能删了重建。
什么时候还值得用 System V?一种情况是你接手老项目,现有代码全是 shmget,没必要引入两套机制;另一种情况是你需要用到 System V 特有的某些语义,比如 shmctl 的 IPC_SET 修改权限、共享内存段与进程的关联关系查询,这些在 POSIX 版本里没有直接对应物。除此之外,写新代码就别给自己添麻烦了。
4. 一个可以直接抄的共享计数器 Demo
4.1 完整代码
理论讲再多,不如一个能跑的 Demo 实在。我用一个经典场景演示完整链路:父进程创建共享内存,fork 两个子进程,每个子进程对同一个计数器自增一百万次,父进程等待两个子进程结束后打印最终值。程序用一个编译开关控制是否加锁,这样既能演示正确的同步写法,也能直观看到不加锁的后果。
#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <sys/mman.h> #include <sys/stat.h> #include <sys/wait.h> #include <unistd.h> #include <semaphore.h> #ifndef USE_LOCK #define USE_LOCK 0 #endif #define LOOP_N 1000000 #define SHM_PATH "/counter_demo" #define SEM_PATH "/counter_demo_sem" typedef struct { long counter; } shared_data; int main(void) { sem_t *sem = NULL; if (USE_LOCK) { sem = sem_open(SEM_PATH, O_CREAT, 0666, 1); if (sem == SEM_FAILED) { perror("sem_open"); exit(EXIT_FAILURE); } } int fd = shm_open(SHM_PATH, O_CREAT | O_RDWR, 0666); if (fd < 0) { perror("shm_open"); exit(EXIT_FAILURE); } if (ftruncate(fd, sizeof(shared_data)) != 0) { perror("ftruncate"); exit(EXIT_FAILURE); } shared_data *d = mmap(NULL, sizeof(shared_data), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (d == MAP_FAILED) { perror("mmap"); exit(EXIT_FAILURE); } close(fd); for (int i = 0; i < 2; i++) { pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(EXIT_FAILURE); } if (pid == 0) { for (int j = 0; j < LOOP_N; j++) { if (USE_LOCK) { sem_wait(sem); d->counter++; sem_post(sem); } else { d->counter++; } } _exit(0); } } while (wait(NULL) > 0) ; printf("final counter = %ld, expected = %d%s\n", d->counter, 2 * LOOP_N, USE_LOCK ? " (protected by sem)" : " (no lock)"); munmap(d, sizeof(shared_data)); shm_unlink(SHM_PATH); if (sem) { sem_close(sem); sem_unlink(SEM_PATH); } return 0; }这段代码里有几个细节值得说明。shm_open 用 O_CREAT | O_RDWR 创建对象,0666 是权限;ftruncate 把对象大小设定为结构体大小,这一步漏了的话,mmap 虽然能成功,但一访问对象末尾之外的内存就直接 SIGBUS。fork 之后每个子进程都继承了共享内存映射和 sem_t 指针,它们看到的是同一个物理页。子进程结束我用 _exit 而不是 exit,避免冲刷父进程的 stdio 缓冲。
4.2 编译与运行效果
编译时只需要加 -pthread,因为 sem_open 系列函数依赖 pthread 库符号。注意不同 glibc 版本对 -lrt 的要求不一样,glibc 2.34 之前 shm_open 需要 -lrt,新版本已经并入 libc。我建议直接两条编译命令都试一下,哪个能过用哪个:
gcc -O2 -o shm_nolock shm_fork_demo.c -pthread gcc -O2 -DUSE_LOCK=1 -o shm_lock shm_fork_demo.c -pthread运行效果是这样的,我在普通 x86 机器上跑出来的结果:
$ ./shm_nolock final counter = 1094832, expected = 2000000 (no lock) $ ./shm_lock final counter = 2000000, expected = 2000000 (protected by sem)加锁版本稳定得到两百万,不加锁版本每次跑出来的数字都不一样。这个差距就是共享内存同步必要性的最直观证明。
4.3 没有锁会发生什么
很多人第一次看到不加锁的结果会觉得奇怪:两个进程都是执行d->counter++,这条语句翻译成机器指令至少三步——读出 counter、加一、写回。两个进程同时执行时,完全可能发生 A 读出 100,B 也读出 100,A 写回 101,B 也写回 101,结果明明加了两次,计数器只长了 1。这就是经典的 read-modify-write 竞争,俗称丢更新。一百万次自增下来,丢掉的更新次数相当可观。
信号量在这里的作用是把“读出、加一、写回”这三步变成原子操作。sem_wait 会把值减到 0 并锁住临界区,另一个进程再 sem_wait 就得老老实实排队,等 sem_post 释放之后才能进来。这是个简单粗暴但绝对可靠的做法。代价是锁竞争会拖慢吞吐,如果你追求极致性能,可以把计数器换成atomic_long,用atomic_fetch_add做无锁自增,效果也正确。
5. 高频翻车现场:权限、泄漏、可见性与内核参数
5.1 生命周期与“删不掉”的共享内存
共享内存对象创建之后,它的生命周期和你进程的生命周期是解耦的。System V 的段在进程退出后依然存在于内核里,直到有人调用 shmctl IPC_RMID 或系统重启。POSIX 对象也有同样的问题,shm_unlink 之前,文件对象会一直躺在 /dev/shm 里,即使所有进程都退出了。
这就带来一个很常见的事故:程序上次崩溃前没有清理共享内存,这次启动时 shm_open 拿到的是一个残留的旧对象,里面的数据是半年前的脏数据,初始化逻辑一不小心就会读到垃圾值。更隐蔽的是 System V 的删除语义——调用 shmctl IPC_RMID 并不会立刻销毁段,只是标记“待删除”,已经 attach 的进程还能继续使用,等所有进程都 detach 之后段才真正消失。如果你在这个段里又新建了另一个进程去 shmat,它会失败。
我的清理习惯是这样的:程序启动时如果准备创建对象,就先用 shm_unlink(或 IPC_RMID)尝试清理旧的同名对象,再重新创建;正常退出路径上写好清理代码,同时做好“即使没清理也不会炸”的容忍逻辑,比如在共享内存头结构里放一个 magic number 和版本号,每次启动时校验,对不上就当作无效数据重置。这套做法能挡住百分之八十的重启脏数据问题。
5.2 权限、umask 与名字规范
shm_open 的 mode 参数和 open 一样受 umask 影响。你写了 0666,但进程的 umask 是 0022,实际生效权限就是 0644,另一个用户启动的进程想写这个共享内存时直接 Permission denied。不少团队踩过这个坑,排查半天发现不是代码逻辑问题,就是 umask 没处理。需要放松权限时,可以在启动脚本里显式设置 umask 0,或者统一约定共享内存对象的权限位,再配合文件系统 ACL 管理。
另外 POSIX 共享内存对象的名字有硬性规范:必须以单个斜杠开头,且不能包含其他斜杠。/counter_demo合法,demo不合法,/a/b也不合法。名字长度也不能超过 NAME_MAX,否则打开失败。团队项目里建议给对象名加统一前缀,比如/team_project_xxx,防止多个服务之间无意撞名。
System V 那边也有类似的坑。key 如果硬编码,两个项目用了同一个 key,shmget 会返回同一个段的 id,双方无意识地共享了数据。更合理的做法是用 ftok 从文件路径生成 key,但要记住 ftok 依赖文件的 inode 和设备号,文件被删除重建后 key 就变了,依赖 ftok 的旧进程和新进程会各说各话。
5.3 编译器优化与内存可见性
这一节可能是共享内存最容易出“灵异事件”的地方。一个很典型的例子:两个进程共享一个 int 标志位,进程 A 写标志位通知进程 B 退出,进程 B 的主循环里用 while (!flag) 空转等待。这段代码换成 -O0 编译能正常工作,换成 -O2 就死循环,读一百遍都看不到 flag 变化。
原因是编译器看到这个循环里 flag 没有在代码层面被修改,就把它的值从内存里读一次放进寄存器,之后一直用寄存器里的旧值判断,彻底不碰内存了。要解决这个问题,共享变量必须声明成_Atomic类型,或者用atomic_load/atomic_store访问,再或者依赖信号量这种内核原语隐式提供的内存屏障。普通volatile只能阻止编译器优化,不能提供 CPU 级别的原子性和可见性保证,所以别指望用它扛大旗。
我自己写共享内存数据结构时的原则是:能不加锁就不用锁,但能保证正确性的原子操作必须用足;任何共享字段都不要用裸 int 读写,一律通过 stdatomic 接口。这套习惯换来的是,线上服务跑再久也不会因为共享内存可见性问题出现诡异数据。
5.4 伪共享与性能排查
当性能达不到预期时,检查一下“伪共享”常常有惊喜。伪共享是指两个变量逻辑上无关,但恰好落在同一条 64 字节的缓存行里,两个进程分别频繁写这两个变量,导致缓存行在多个核之间反复失效,性能断崖式下跌。
比如你定义了一个共享结构,包含 hit_count 和 miss_count 两个计数,两个进程一个只写 hit,一个只写 miss,它们其实不需要同步,但 CPU 缓存按行管理,只要任何一方写了这个缓存行里的任何字节,整行都要从别的核上失效,双方互相拖累,速度可能慢几十倍。解决办法很暴力:把需要各自独立写的字段按 64 字节对齐填充,让它们落在不同的缓存行。我的 Demo 里的结构体只放了一个 long counter,其实最干净的做法是继续填充到 64 字节,只是演示代码为了可读性没这么做,实际项目里别忘了这一手。
排查性能问题时,先看是不是锁竞争激烈,再看有没有伪共享,最后才考虑改算法。用perf看 cache-misses 事件,通常能快速定位问题所在。
5.5 内核参数调优与调试命令
共享内存也受内核参数约束。System V 三个最常碰到的参数,都在 /proc/sys/kernel/ 下面,路径和含义如下:
| 参数 | 含义 | 默认值参考 |
|---|---|---|
| shmmax | 单个共享内存段最大字节数 | 通常较大,32 位系统上可能受限 |
| shmall | 系统内共享内存页总数上限 | 一般够用 |
| shmmni | 系统内共享内存段数量上限 | 默认 4096 |
如果 shmget 创建超大段时报 EINVAL,先看是不是超过了 shmmax;报 ENOSPC 再看是不是段数量或者页总数到了上限。临时生效可以直接写 /proc/sys/kernel/shmmax,永久生效则要写进 /etc/sysctl.conf。POSIX 共享内存实质落在 /dev/shm 这个 tmpfs 上,它的总容量受 tmpfs 挂载参数 size 限制,用df -h /dev/shm就能看到,需要扩容就修改 /etc/fstab 里 tmpfs 的 size 选项再重新挂载。
调试和运维命令我平时用得很勤,整理成一张速查表:
| 需求 | 命令 |
|---|---|
| 查看 System V 共享内存段 | ipcs -m |
| 查看段与进程的关联 | ipcs -p |
| 删除指定共享内存段 | ipcrm -m |
| 查看 POSIX 共享内存对象 | ls -l /dev/shm |
| 删除 POSIX 共享内存对象 | rm /dev/shm/ |
| 跟踪 System V 共享内存调用 | strace -e trace=shmget,shmat,shmdt,shmctl ./a.out |
| 跟踪 POSIX 共享内存调用 | strace -e trace=mmap,ftruncate,openat ./a.out |
最后再分享一个实战体会
我个人的经验是,共享内存在 Linux 系统编程里属于那种“上限极高、下限也极低”的技术。用得好,它能让你在毫无锁竞争的情况下做出极高吞吐的进程间数据交换;用得糙,权限、生命周期、缓存一致性、名字冲突,每个点都能让你调一整天。建议所有准备在项目中使用共享内存的朋友,开工前先把生命周期和同步方案画清楚,谁创建、谁清理、谁负责加锁、崩溃了怎么恢复,这几个问题想明白了再写代码。把这套功夫练好之后,你会和我一样,在遇到进程间通信需求时第一时间想到它。