1. 先搞清楚:共享内存到底解决了什么问题
前几年我接手一个数据采集的活儿,两个进程,一个是高频采样的采集端,一个是把数据落盘并做实时计算的消费端。一开始图省事,用管道传,每秒钟几万条小记录,CPU 占用率一路飙到单核打满,用perf一看,大半时间耗在copy_to_user和上下文切换上。后来把这条链路换成共享内存,采集端只往一块映射好的内存里写,消费端直接从同一块内存里读,CPU 占用掉到原来的四分之一不到,延迟也从毫秒级压到了几十微秒。那次改造之后,我对共享内存这套东西的态度就变了:它不是"高级特性",而是进程间通信里最该被优先考虑的一档。
共享内存的核心就一句话:让两个互不相关的进程,把同一块物理内存页映射到各自的虚拟地址空间里。写的一方往自己的地址写,读的一方在自己的地址上就能看见,中间没有任何数据拷贝,也没有内核来回搬运。它解决的是高频、大块、低延迟的数据交换问题——日志汇聚、音视频帧传递、采集与计算解耦、多进程缓存共享、嵌入式设备上的进程协作,都是它的主战场。代价是:内核不再帮你做同步,谁先写谁后读、写到一半被读走、进程崩了锁没释放,这些全得自己管。
这篇东西适合谁看?写过一点 Linux 系统编程、知道open/read是怎么回事,但一提到mmap、shmget、PTHREAD_PROCESS_SHARED就有点发怵的同学;也适合已经在用共享内存、但被SIGBUS、数据错乱、/dev/shm越用越满这些毛病折腾过的老手。我下面按照"为什么这么选 → 映射怎么建立 → 数据怎么摆 → 怎么同步和控制 → 出问题怎么查"这条线走,中间会给出可以直接抄的代码,也会讲清楚每一步背后的道理。
1.1 选型对比:管道、消息队列、socket 和共享内存
很多人第一反应是"进程间通信嘛,socket 最通用"。通用是真的,但通用往往意味着层层封装。我做过一组粗糙但真实的对比,场景是两个本机进程传 1KB 左右的记录,每秒 5 万条,测下来差距相当明显:管道和 UNIX domain socket 的瓶颈在数据拷贝和唤醒次数上,System V 消息队列稍好一点但仍有内核拷贝,共享内存几乎是纯内存带宽,唯一的额外成本是同步原语的原子操作。
| 方式 | 数据拷贝次数 | 典型延迟 | 容量弹性 | 跨机 | 同步负担 |
|---|---|---|---|---|---|
| 匿名管道 / 命名管道 | 2 次(用户→内核→用户) | 微秒到几十微秒 | 内核缓冲有限,易阻塞 | 否 | 内核帮你做流控 |
| System V / POSIX 消息队列 | 2 次 | 十微秒级 | 受队列上限约束 | 否 | 内核管消息边界 |
| UNIX domain socket | 2 次(可零拷贝优化) | 十微秒级 | 受缓冲区约束 | 否 | 内核管连接 |
| 共享内存 | 0 次 | 纳秒到微秒级 | 只受内存和磁盘(tmpfs)限制 | 否 | 全靠自己 |
| 共享内存 + 信号量 | 0 次 | 增加一次原子/唤醒 | 同上 | 否 | 自己设计协议 |
这张表最该看的是最后一列。拷贝次数是性能问题,同步负担是正确性问题。很多团队上共享内存失败,不是性能没达标,而是同步没设计好,读到了半截数据,或者写端崩溃后读端死等一把永远不释放的锁。
提示:如果两个进程之间的数据量很小(几 KB、低频),别上共享内存,管道或 socket 更省心。共享内存的收益跟数据量、频率是正相关的,量小的时候,你付出的复杂度远大于省下的那点拷贝。
1.2 本质拆解:一块物理页,两张页表
要理解共享内存为什么快,得先理解它到底"共享"了什么。进程访问内存走的是虚拟地址,虚拟地址经过页表翻译成物理页帧号。普通情况下,进程 A 的某个虚拟页和进程 B 的某个虚拟页,映射的是不同的物理页,彼此看不见。共享内存做的事,就是在两个进程各自的页表里,把不同的虚拟地址指向同一个物理页帧。
这件事带来的直接后果有三个,每一个都影响你后面怎么写代码。第一,两个进程看到的内容是同一份,A 改了 B 立刻能看见,不需要任何"发送"动作。第二,虚拟地址可以不同——A 里这块内存可能落在0x7f2a...,B 里落在0x7f8c...,所以绝对不能在共享内存里存指针,因为指针存的是虚拟地址,换个进程就指向了别处。第三,这块内存不归任何一个进程私有,它的生命周期由内核按共享内存对象的规则管理,进程退出不等于内存消失,这也是后面清理工作必须重视的原因。
顺便说一句,fork之后的写时复制(COW)跟共享内存是两码事。fork出来的子进程一开始跟父进程共享物理页,但任何一方写入都会触发缺页异常、复制出新页,两边又分开了。共享内存是MAP_SHARED,写入不触发复制,这才是真正的共享。
1.3 两条技术路线:POSIX 与 System V 怎么选
Linux 上共享内存有两套 API,一套是 System V 的老接口(shmget/shmat/shmdt/shmctl),一套是 POSIX 的新接口(shm_open/ftruncate/mmap/munmap/shm_unlink)。新项目我一律推荐 POSIX,理由很实在:它的用法跟你熟悉的文件操作完全一致,shm_open返回一个文件描述符,你可以用select/poll监听,可以dup,出错处理跟普通 fd 一样;名字是路径风格(/my_name),比 System V 那个靠ftok生成 key 的方式直观太多,也不会出现 key 冲突那种玄学问题。
| 维度 | POSIX(shm_open + mmap) | System V(shmget + shmat) |
|---|---|---|
| 命名方式 | /name,实际落在/dev/shm/name | key_t,靠ftok或IPC_PRIVATE |
| 大小设定 | ftruncate显式定容 | shmget时指定 |
| 句柄 | 文件描述符,可 poll/dup | 整数 ID |
| 生命周期 | 引用计数,shm_unlink后最后一个munmap才释放 | shmctl(IPC_RMID)标记删除 |
| 查看命令 | ls -l /dev/shm | ipcs -m/ipcrm -m |
| 可移植性 | POSIX 标准,macOS 需注意共享内存对象限制 | 传统 UNIX 广泛支持 |
不过 System V 也不是没用,老系统、老代码、某些嵌入式 SDK 里全是它,你得能看懂、能改。所以下面的实操我会两套都过一遍,主推 POSIX,System V 给你一份对照。
2. 映射这件事:从创建、定容到挂载的完整链路
共享内存的第一步永远是建映射,这一步做错,后面全是白搭。POSIX 的流程只有三步:shm_open拿到描述符,ftruncate定好大小,mmap挂到地址空间。三步的顺序不能乱,原因我下面逐个说。
2.1 shm_open 的参数与名字规则
shm_open的签名跟open几乎一样,int shm_open(const char *name, int oflag, mode_t mode)。名字必须是/开头、后面跟若干个非/字符,比如/ring_v1。别用带斜杠的路径,/tmp/foo这种写法在某些实现上会被当成非法名字。它实际创建的文件在/dev/shm/ring_v1,权限位按mode和当前 umask 共同决定,所以你会看到实际权限是0666 & ~umask。
oflag的组合决定了"创建还是打开",这里有个细节很容易踩:如果你需要"创建并打开",用O_CREAT | O_RDWR;如果只想打开已存在的,用O_RDWR;如果想"有就打开、没有就报错"来区分首次启动,用O_CREAT | O_EXCL | O_RDWR,O_EXCL会在已存在时返回EEXIST,靠这个来判断自己是不是第一个进程,是个很干净的初始化信号。
int fd = shm_open("/ring_v1", O_CREAT | O_EXCL | O_RDWR, 0666); if (fd < 0 && errno == EEXIST) { /* 已经有人创建过了,这次只打开 */ fd = shm_open("/ring_v1", O_RDWR, 0666); } if (fd < 0) { perror("shm_open"); return -1; }权限这块也有讲究。如果写端和读端用不同用户跑,0666配合 umask 可能会变成0644,读端就打不开了。要么两边统一用户,要么在shm_open之后用fchmod显式放宽,要么干脆在启动脚本里把 umask 设好。我曾经在一个多用户部署里被这个问题卡了半天,读端一直报EACCES,查了半天以为是 SELinux,实际就是 umask 把写权限给抹了。
2.2 ftruncate 为什么必须放在 mmap 之前
新建的共享内存对象初始长度是0。如果直接mmap一个 0 长度的对象,mmap会直接失败返回MAP_FAILED。所以必须先ftruncate(fd, size)把对象撑到目标大小。这个调用会实际在 tmpfs 里分配空间(或者说预留,具体看实现),因此size是实打实的内存占用,别一上来就ftruncate一个 G,机器内存不够会直接返回ENOSPC。
size怎么算?两个原则:向上对齐到页(通常 4096 字节),并且把元数据区一起算进去。我一般这么写:
#define PAGE_SIZE 4096 #define SLOT_SIZE 256 /* 每条记录 256 字节 */ #define SLOT_COUNT 8192 /* 槽位个数,取 2 的幂便于取模 */ #define HDR_SIZE (64 * 2) /* 头部元数据,留两行缓存行 */ #define SHM_SIZE ((HDR_SIZE + SLOT_SIZE * SLOT_COUNT + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1))按这个算下来是128 + 2097152 = 2097280,不是页的整数倍,向上对齐后是2097152 + 4096 = 2101248。页对齐不是为了好看,是因为mmap的偏移参数必须是页大小整数倍,将来如果你想分段映射,对齐过的大小让偏移量算起来清爽很多。
注意:
ftruncate在已经存在且已被映射的对象上再调用,如果缩短了长度,另一个进程访问被截掉的区域会直接收到SIGBUS。这个坑后面第 5 节会专门讲。稳妥做法是让大小只增不减,或者干脆固定不变。
2.3 mmap 的三个关键参数:PROT、MAP_SHARED 和偏移
mmap的完整形式是void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)。参数里最容易被忽略、又最要命的是这两个:prot和flags。
prot决定你能对这块内存做什么。只读端用PROT_READ,读写端用PROT_READ | PROT_WRITE。给只读端加写权限,一是没意义,二是会让"本该只读"的约定形同虚设,别人误写你也拦不住。
flags里必须带MAP_SHARED。如果你手滑写成MAP_PRIVATE,那恭喜你,你会得到一份写时复制的私有副本,写进去的内容另一个进程永远看不见,而且不报错,就是"数据莫名其妙丢了"。这个错误我见过两次,都是复制粘贴模板没改标志位,然后花了半天查同步逻辑,最后发现压根没共享。
offset必须是页大小整数倍,传 0 表示从头开始。如果你要做"分段映射",比如头部映射一次、数据区映射一次,那数据区的 offset 就必须是 4096 的倍数。
void *base = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (base == MAP_FAILED) { perror("mmap"); close(fd); return -1; } close(fd); /* 映射建立后,fd 可以关,映射依然有效 */最后那句close(fd)值得强调。很多同学以为 fd 关了映射就没了,其实不是。mmap建立之后,映射引用的是那块对象本身,跟 fd 已经不是一条命了。把 fd 关掉的好处是省一个描述符,也避免后续误操作。当然,如果你还要fchmod或者再次ftruncate,那就先别关。
2.4 一份能直接跑起来的最小骨架
把上面三步串起来,写端长这样:
/* shm_create.c —— 创建并初始化共享内存 */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <fcntl.h> #include <unistd.h> #include <sys/mman.h> #include <sys/stat.h> #define SHM_NAME "/ring_v1" #define SHM_SIZE 2101248UL int main(void) { int fd = shm_open(SHM_NAME, O_CREAT | O_EXCL | O_RDWR, 0666); if (fd < 0) { if (errno == EEXIST) { printf("already exists\n"); return 0; } perror("shm_open"); return 1; } if (ftruncate(fd, SHM_SIZE) < 0) { perror("ftruncate"); return 1; } void *base = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (base == MAP_FAILED) { perror("mmap"); return 1; } close(fd); memset(base, 0, SHM_SIZE); /* 首次创建时清零,很重要 */ printf("created %s, size=%lu\n", SHM_NAME, SHM_SIZE); munmap(base, SHM_SIZE); return 0; }读端(或者任何后续进程)只要把O_EXCL去掉、ftruncate去掉就行。为什么首次创建要memset清零?因为 tmpfs 上的新建对象虽然是零填充的,但如果你复用的是O_CREAT(不带O_EXCL)并且对象之前被别的程序用过,里面可能是脏数据。用magic字段做校验配合清零,能避免"读到上一版程序残留的元数据然后解析崩溃"这种诡异问题。
System V 那一套的等价写法是这样,留给你对照:
#include <sys/ipc.h> #include <sys/shm.h> key_t key = ftok("/tmp", 'R'); /* 用一个存在的路径生成 key */ int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666); void *base = shmat(shmid, NULL, 0); if (base == (void *)-1) { perror("shmat"); return -1; } /* 使用完毕后 */ shmdt(base); /* 解除本进程映射 */ shmctl(shmid, IPC_RMID, NULL); /* 标记删除,最后一个 detach 后释放 */ftok的坑在于它依赖那个路径的 inode 和低 8 位设备号,如果路径被删了重建,key 就变了,两边对不上。所以实际项目里我倾向于用固定常量拼一个 key(比如(0x52 << 16) | 0x0001),或者直接IPC_PRIVATE配合 fork 传递 shmid。ftok只在"必须靠约定名字碰头"的场景下才用。
3. 读写实现:共享内存里到底怎么摆数据
映射建好之后,这块内存对你来说就是一个void *,你可以当数组用、当结构体用、当环形缓冲区用。但"能随便用"恰恰是陷阱所在。这一节讲的是数据布局,布局错了,性能再高也是错的。
3.1 绝对不要在共享内存里存指针
这是共享内存的头号铁律。原因在 1.2 节说过:两个进程的虚拟地址不同,A 里存的0x7f2a12345000到 B 里可能指向一段完全无关的内存,解引用直接段错误,或者更糟——不报错,读到垃圾。
正确做法是存偏移量,不存地址。约定一个基准,比如"以这块共享内存的起始地址为 0",任何需要引用别处数据的字段,都存相对基准的字节偏移。访问时用(char *)base + offset现场算地址。
struct shm_header { uint32_t magic; /* 0x53484D31,'SHM1' */ uint32_t version; /* 布局版本号,改结构就加一 */ uint64_t capacity; /* 数据区总字节数 */ uint64_t head; /* 写端游标,单调递增 */ uint64_t tail; /* 读端游标,单调递增 */ uint64_t data_off; /* 数据区相对 base 的偏移,通常是 sizeof(header) 对齐后 */ };注意data_off存的是偏移而不是指针,而且我把头部的布局固定成 POD(纯数据类型),不带任何指针、不带std::string、不带虚函数表。这条在 C++ 项目里特别重要:任何带虚表的类、任何 STL 容器,扔进共享内存都是灾难,因为内部指针全都失效。
3.2 对齐、填充与版本兼容
结构体布局还有两个实际问题:对齐和演进。
对齐方面,x86 上不对齐访问只是慢,ARM 上某些指令不对齐访问会直接触发异常。而且多核场景下,两个变量挤在同一条缓存行(通常 64 字节)里,一个核写head、另一个核写tail,会导致缓存行在两个核之间来回弹,这就是伪共享(false sharing),性能能掉一大截。解决办法是把高频写的变量各自独占一条缓存行,用填充撑开:
struct shm_slot { uint64_t seq; /* 序号,用于检测覆盖 */ uint32_t len; uint32_t flags; char payload[240]; /* 凑满 256 字节 */ } __attribute__((aligned(64)));我把槽位定成 256 字节、按 64 字节对齐,是为了让一个槽位正好占 4 条缓存行,边界清晰,读者写者互不干扰。
版本兼容方面,头部的version字段不是摆设。我踩过一次:加了新字段之后只重编了写端,读端还是老二进制,结果它按老布局去解析新数据,读出来的长度字段是个天文数字,直接越界。后来养成习惯,读端启动时先校验magic和version,不匹配就打印明确错误并退出,而不是硬着头皮解析。这个检查只要三行代码,能省掉一晚上的 debug。
3.3 环形缓冲区的读写下法
环形缓冲区是共享内存里最常用的结构,因为它是天然的"生产者-消费者"模型,而且不需要动态分配。核心就两个游标:head由写端推进,tail由读端推进,两者都单调递增(不是取模后的小数值),实际下标用head % SLOT_COUNT算。单调递增的好处是可以直接用差值判断"里面有多少条",不用处理回绕的歧义。
static inline uint64_t ring_count(struct shm_header *h) { return __atomic_load_n(&h->head, __ATOMIC_ACQUIRE) - __atomic_load_n(&h->tail, __ATOMIC_ACQUIRE); } int ring_push(void *base, const void *data, uint32_t len) { struct shm_header *h = (struct shm_header *)base; char *ring = (char *)base + h->data_off; uint64_t head = __atomic_load_n(&h->head, __ATOMIC_RELAXED); uint64_t tail = __atomic_load_n(&h->tail, __ATOMIC_ACQUIRE); if (head - tail >= SLOT_COUNT) return -1; /* 满了 */ struct shm_slot *s = (struct shm_slot *)(ring + (head % SLOT_COUNT) * SLOT_SIZE); s->len = len > sizeof(s->payload) ? sizeof(s->payload) : len; memcpy(s->payload, data, s->len); s->seq = head + 1; /* 先写数据,最后发布序号 */ __atomic_store_n(&h->head, head + 1, __ATOMIC_RELEASE); return 0; }这段代码里最有讲究的是写顺序。必须先写payload和len,最后用RELEASE语义写head。这样读端用ACQUIRE语义读到新head的时候,能保证在此之前的所有写入都已可见。如果顺序反过来,读端可能看到head更新了但payload还是旧的,拿到的就是半截数据。这个规则不是理论洁癖,我在一次跨核压力测试里真的复现过——X86 因为内存模型比较强,跑一万次都没事,换到 ARM 板上跑几百次就出一条脏数据。
读端对应地先ACQUIRE读head,确认有新数据,再读槽位内容,最后RELEASE推进tail:
int ring_pop(void *base, void *out, uint32_t *len) { struct shm_header *h = (struct shm_header *)base; char *ring = (char *)base + h->data_off; uint64_t tail = __atomic_load_n(&h->tail, __ATOMIC_RELAXED); uint64_t head = __atomic_load_n(&h->head, __ATOMIC_ACQUIRE); if (head == tail) return -1; /* 空了 */ struct shm_slot *s = (struct shm_slot *)(ring + (tail % SLOT_COUNT) * SLOT_SIZE); *len = s->len; memcpy(out, s->payload, s->len); __atomic_store_n(&h->tail, tail + 1, __ATOMIC_RELEASE); return 0; }这套单生产者单消费者的写法是无锁的,性能极好,我实测 256 字节记录能跑到每秒千万级。但它有严格前提:只能有一个写者、一个读者。多写者就必须加锁,或者改用 CAS 抢槽位,复杂度立刻上一个台阶。这一点必须先想清楚,别指望无锁方案包打天下。
4. 控制与同步:真正难的不是映射,是协调
映射十分钟能学会,同步能折腾一个月。共享内存的"控制"包含两件事:一是并发控制,保证多进程读写不打架;二是生命周期控制,保证内存该在的时候在、该清的时候清。
4.1 进程共享互斥锁:初始化和崩溃处理
想在共享内存里放pthread_mutex_t,直接pthread_mutex_init(&m, NULL)是不行的——默认属性是PTHREAD_PROCESS_PRIVATE,只能同进程的线程用,跨进程行为未定义。必须显式设置PTHREAD_PROCESS_SHARED:
pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED); pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST); /* 关键 */ pthread_mutex_init(&h->lock, &attr); pthread_mutexattr_destroy(&attr);PTHREAD_MUTEX_ROBUST这个属性是救命用的。默认(非 robust)的互斥锁,持有者进程如果崩溃退出,锁会永远处于"已加锁"状态,其他进程pthread_mutex_lock会一直阻塞,整个系统僵死。设成 robust 之后,下一个加锁的进程会拿到EOWNERDEAD返回值,意思是"上一个主人死在里面了,锁现在归你,但你得先决定这块数据还能不能用"。
int rc = pthread_mutex_lock(&h->lock); if (rc == EOWNERDEAD) { /* 上一个持锁进程挂了,共享数据可能处于中间状态 */ repair_shared_state(h); /* 按你的业务做修复,比如重置游标 */ pthread_mutex_consistent(&h->lock); /* 告诉系统:状态已修复,锁恢复正常 */ } else if (rc != 0) { /* 其他错误,按实际处理 */ }这里有个判断题:拿到EOWNERDEAD之后,数据到底能不能继续用?答案取决于你的数据是否可自愈。像环形缓冲区,重置head/tail就干净了,代价是丢掉未消费的数据,通常可接受。如果是账本类数据,就得落日志做恢复,那是另一个层次的工程。如果不调用pthread_mutex_consistent就直接解锁,锁会进入"不一致"状态,后续加锁全都返回ENOTRECOVERABLE,只能重新初始化。
注意:条件变量
pthread_cond_t同样要设PTHREAD_PROCESS_SHARED,而且它必须和一把共享互斥锁配合使用。用条件变量做"有数据了叫我",比轮询省 CPU,但也带来一个新问题:等待方可能在信号发出之后才进入等待,造成丢失唤醒。所以等待必须写成while (!条件) pthread_cond_wait(...)的循环形式,把条件检查放在锁内。
4.2 信号量方案:更简单也更稳妥的选择
如果觉得 robust mutex 太绕,信号量是个更朴素的选择。POSIX 无名信号量的sem_init(&sem, pshared, value)第二个参数非 0 就表示进程间共享,前提是这个sem_t本身必须放在共享内存里,两边映射到同一份才行。
sem_init(&h->sem_data, 1, 0); /* 数据可用计数 */ sem_init(&h->sem_free, 1, SLOT_COUNT); /* 空槽计数 */ /* 生产者 */ sem_wait(&h->sem_free); ring_push_locked(base, data, len); sem_post(&h->sem_data); /* 消费者 */ sem_wait(&h->sem_data); ring_pop_locked(base, out, &len); sem_post(&h->sem_free);这套写法直观,语义清晰,缺点是每次sem_wait/sem_post都是一次系统调用(在某些实现上还可能陷入内核),高频场景下开销比原子操作大。我的经验是:数据频率在每秒十万以内,信号量完全够用,写起来还省心;超过这个量级,再考虑第 3.3 节的无锁方案。
另外提醒一句,sem_init的pshared参数在 macOS 上不支持,那边得用命名信号量sem_open。跨平台项目要提前考虑这一点,别写完才发现编不过。
4.3 生命周期控制:谁负责删,什么时候删
共享内存最容易被忽视的就是清理。POSIX 对象在/dev/shm下,掉电重启就没了;System V 对象由内核持久管理,重启也在,直到ipcrm。两种实现的生命周期语义不同,混着用就容易出乱子。
POSIX 的规则是引用计数:shm_unlink只是把名字从文件系统里摘掉,已经存在的映射和打开的 fd 依然有效,直到最后一个引用消失,内存才真正释放。这个设计非常好用,它意味着你可以"创建完立刻 unlink",让这个对象变成匿名的,谁都没法再按名字打开,但当前映射的进程照常工作,进程一退出自动回收,绝不会残留。
/* 推荐模式:创建者负责初始化,初始化完立刻 unlink */ int fd = shm_open(SHM_NAME, O_CREAT | O_EXCL | O_RDWR, 0666); ftruncate(fd, SHM_SIZE); void *base = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); init_header(base); shm_unlink(SHM_NAME); /* 名字摘掉,映射继续有效 */ close(fd); /* 后续通过 fork 或 Unix socket 把映射关系/继承关系传给子进程 */这个模式的前提是"后续进程能拿到这块内存"。如果是fork出来的子进程,映射天然继承;如果是两个独立启动的进程,那就不能提前 unlink,得靠约定名字碰头,此时清理责任要明确指定给某一方(通常是"最后退出的那个"或者一个独立的管理进程)。
System V 那边用shmctl(shmid, IPC_RMID, NULL)标记删除,效果类似:标记之后不允许新的shmat,已挂载的进程继续用,最后一个shmdt后释放。区别在于 System V 对象会跨重启残留,所以强烈建议在程序启动时扫一遍ipcs -m,把 owner 是自己、size 对得上、但 nattch 为 0 的僵尸段清掉。我见过一台机器上残留了四十多个共享内存段,加起来好几个 G,全是历史调试程序留下的。
| 操作 | POSIX | System V |
|---|---|---|
| 查看 | ls -l /dev/shm | ipcs -m |
| 删除单个 | shm_unlink("/name")或rm /dev/shm/name | ipcrm -m <shmid>或shmctl(IPC_RMID) |
| 掉电后 | 消失(tmpfs) | 保留 |
| 空间占用 | 占内存/tmpfs 配额 | 占内存 |
| 残留排查 | du -sh /dev/shm/* | ipcs -m看 nattch=0 的段 |
5. 常见故障排查实录:那些让我熬夜的坑
这一节是我这些年攒下来的问题清单,基本都是文档里不会写、但实际一定会遇到的东西。
5.1 SIGBUS:最经典的共享内存崩溃
进程毫无征兆地被SIGBUS杀掉,gdb看栈停在一个memcpy里,这是共享内存最典型的故障。原因几乎只有三类。
第一类,ftruncate没做或者做小了。映射了 1MB,实际对象只有 0 字节或者 4KB,进程访问超出实际长度的页时,内核发现这个页在对象里不存在,直接发SIGBUS。这类问题在代码审查时很难看出来,因为mmap成功了,失败发生在第一次访问越界页的时候。
第二类,别的地方把对象截短了。多个进程共享同一个对象时,如果某个进程调用了ftruncate缩小长度,其他进程映射的超出部分立刻失效,一访问就SIGBUS。所以共享内存的规则是:创建时定容,之后永不改变。要扩缩容,就新建一个对象,做一次数据迁移,别原地动刀。
第三类,tmpfs 空间被撑爆。/dev/shm默认大小通常是物理内存的一半,df -h /dev/shm能看到。如果容器里限制得很小(有些环境只给 64MB),ftruncate大对象时可能不报错(因为 tmpfs 是惰性分配),真正写入时才触发SIGBUS。这个特别阴,因为错误发生在写数据的时候,看着像是代码问题。
提示:排查
SIGBUS,第一件事是确认stat /dev/shm/xxx的 size 跟你mmap的 length 是否一致,第二件事是df -h /dev/shm看剩余空间。这两个一查,八成问题就定位了。
5.2 数据错乱:不是共享内存坏了,是内存序和同步没做对
"读出来的数据偶尔是半截"、"字段 A 是新的、字段 B 是旧的"、"X86 上好好的,到 ARM 板上就不对"。这类问题的根因通常是两个:缺少内存屏障,或者同步粒度不对。
缺少屏障的情况前面 3.3 节讲过,写数据用RELAXED、发布用RELEASE、读取用ACQUIRE,这套配对不能省。第二个问题是同步粒度:如果你用一把锁保护整个环形缓冲,那是对的;但如果为了性能,把"读游标"和"读数据"分成两个锁保护,就可能出现读端拿到了游标、写端已经覆盖了对应槽位的情况。共享内存里没有免费的性能,每个优化都要对应一个正确的同步协议。
还有一种"错乱"其实是结构体布局不一致。写端编译时加了-O2,读端加了-m32,两边sizeof(struct)就差了几字节,字段偏移全错。防御手段是:头部放magic+version+sizeof(header)三个校验,读端开机自检,不匹配直接拒绝服务。三行代码,能挡住一大类问题。
5.3 问题速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
进程收到SIGBUS | 对象长度小于映射长度,或 tmpfs 满 | stat对比 size;df -h /dev/shm |
shm_open返回 EACCES | 权限位被 umask 削,或多用户不一致 | ls -l /dev/shm/xxx,检查 umask |
| 读端永远读不到新数据 | 映射用了MAP_PRIVATE,或游标没更新 | 检查 flags;打印 head/tail |
| 读到半截数据 | 写序不对,缺RELEASE/ACQUIRE | 审查发布顺序 |
| 加锁永久阻塞 | 持锁进程崩溃,锁未设 robust | 改PTHREAD_MUTEX_ROBUST |
加锁返回ENOTRECOVERABLE | 上次EOWNERDEAD未调用consistent | 补pthread_mutex_consistent |
/dev/shm越用越满 | 进程退出没 unlink | du -sh /dev/shm/*,逐个清理 |
| System V 段残留 | 未ipcrm | ipcs -m找 nattch=0 |
| X86 正常 ARM 异常 | 内存序或未对齐访问 | 补屏障;检查aligned属性 |
mmap返回MAP_FAILED | 长度 0、偏移非页对齐、fd 无效 | 检查ftruncate是否执行 |
6. 性能优化与工程化收尾
6.1 大页与 madvise:让 TLB 少干活
共享内存本身已经很快了,但如果数据区很大(几百 MB 以上),瓶颈会转移到 TLB 上。默认页是 4KB,512MB 需要 13 万个页表项,TLB 根本装不下,每次访问都可能 miss。解决办法是透明大页(THP)或者显式大页,把页从 4KB 变成 2MB,页表项数量直接降到 256 个。
检查 THP 状态看/sys/kernel/mm/transparent_hugepage/enabled,如果是[always]或者[madvise],可以在mmap之后调用:
madvise(base, SHM_SIZE, MADV_HUGEPAGE);这一行的收益在大数据量下非常明显,我实测过 1GB 的共享缓冲区,加上大页之后随机访问的吞吐提升了约 30%。另外还有MADV_DONTNEED用来主动归还物理页,适合"用一段时间就不用了"的场景,MADV_WILLNEED用来预取。
还有个小技巧:如果只是做单次顺序扫描,madvise(MADV_SEQUENTIAL)能提示内核调整预读策略,虽然对共享内存的收益不如对磁盘文件明显,但对大块连续读取还是有帮助的。
6.2 监控和自动化清理
生产环境上,我习惯在启动脚本里加两件事:一是启动前清理,二是运行中监控。
启动前清理的目标是"确认没有历史残留"。POSIX 对象简单,ls /dev/shm看有没有同名文件,有就先rm(前提是确认没有别的进程在用)。System V 稍微麻烦点,得配合ipcs和ipcrm:
# 清理 nattch 为 0 且 owner 是当前用户的共享内存段 ipcs -m | awk -v u="$(whoami)" ' $3 == u && $6 == "0" { print $2 } ' | while read -r id; do [ -n "$id" ] && ipcrm -m "$id" done运行中监控就简单了,定时采样/dev/shm的占用,超过阈值告警。因为共享内存泄漏是最典型的"慢性病",不会立刻炸,但一两个月后机器内存告急,排查起来极其痛苦。
还有一个工程上的经验:把头部结构里的magic、version、create_time、owner_pid都填上。出问题时xxd /dev/shm/xxx | head看一眼,就知道这块内存是谁在什么时候创建的、布局版本是多少,比翻日志快得多。这个习惯是当年被一个"不知道是哪版程序留下的脏共享内存"逼出来的,后来成了我的标配。
最后分享一个我自己一直在用的调试技巧:在头部留一个 64 字节的debug区,写端往里写自己的 pid、写入计数、最后写入的时间戳,读端同样。压测的时候用一个独立的小工具 attach 上去读这几个字段,就能实时看到两端的进度差、卡在哪一侧,比打印日志侵入性小得多,也不影响被测进程的性能。这套东西在排查"到底是生产慢还是消费慢"的问题时特别管用,比任何 profile 工具都直接。