共享内存这四个字,第一次接触的人容易把它想成某种“高级缓存”,实际上它比缓存朴素得多——就是让两个互不相干的进程,看到同一块物理内存。我在排查一个跨进程数据传输的延迟问题时,把原来走消息队列的链路换成共享内存加环形队列,单次传输的耗时从百微秒级掉到了个位数微秒,这件事之后我才真正把共享内存的映射、读写、控制这三块拧成了一条线。这篇文章就按这三块来讲:共享内存怎么映射进进程地址空间,映射之后怎么做读写,以及怎么用控制接口把它管住、收住、别让它泄漏。面向的是已经会写基本 C 程序、被进程间通信折腾过一轮的同学,也照顾刚接触 System V IPC 的新手,文中所有代码都可以直接编译跑起来验证。
1. 共享内存到底在解决什么问题
1.1 一次跨进程数据搬运的真实路径
先看一条普通管道传一个 64KB 结构体要经历什么。发送方进程在用户态准备好缓冲区,调write,内核把数据从用户地址空间拷到内核的管道缓冲区;接收方调read,内核再把数据从内核缓冲区拷到接收方的用户地址空间。两次拷贝,两次用户态/内核态切换,外加一次调度唤醒。消息队列、Unix 域套接字本质上都逃不掉这个模型:数据必须经过一次中间层。
共享内存把这套流程直接砍掉。内核把一段物理页同时映射进 A 和 B 的页表,A 写进去的字节,B 通过自己的虚拟地址就能读到。没有拷贝,没有系统调用,没有内核参与。这就是它被叫做“最快的进程间通信方式”的全部原因,原理上没什么玄学,就是把中间商去掉了。
但这枚硬币的另一面是:内核不管了,同步也没人管。管道里写满了write会阻塞,队列空了read会等待,这些都由内核帮你兜着。共享内存里,你写你的、我读我的,什么时候算写完、什么时候算读到,得自己用信号量、互斥量或者原子变量摆平。我在实际项目里见过太多“共享内存出数据不一致”的问题,追到最后没有一次是共享内存本身出错,全是同步没做对,或者做对了但内存序设错了。
所以判断要不要上共享内存,我的标准很简单:数据量大(单次超过几 KB)、调用频率高(每秒上千次以上)、两端都在同一台机器上,三个条件同时满足才值得为它承担同步复杂度。如果只是偶尔传个命令,用消息队列或者套接字舒服得多,可维护性差得不是一点半点。
1.2 共享内存的三种常见形态与各自适用场景
同样是“共享”,Linux 上至少有三种常见落地方式,接口不同,脾气也不同,选错了后面会很别扭。
| 形态 | 典型接口 | 命名方式 | 生命周期 | 适合场景 |
|---|---|---|---|---|
| System V 共享内存 | shmget/shmat/shmctl | 整型 key(可用ftok生成) | 除非显式删除或重启,一直存在 | 老代码、需要ipcs直接观测的运维环境 |
| POSIX 共享内存 | shm_open/mmap | 字符串名,形如/myseg,落在/dev/shm | 有名字引用就存在,shm_unlink后等映射全解除才释放 | 新写代码,想用文件路径式的名字管理 |
| 匿名共享映射 | mmap(MAP_SHARED|MAP_ANONYMOUS) | 无名字 | 只在有血缘关系的进程间有效 | fork出来的父子进程共享数据 |
System V 那套是老资格,好处是ipcs -m和ipcrm可以直接从命令行看到和清理,排查问题的时候特别省事;坏处是 key 冲突、ftok返回值受文件系统影响,用起来有点别扭。POSIX 那套借用了文件接口的思路,名字是字符串,/dev/shm下能看到实体,调试时ls -l /dev/shm一目了然,我新写的代码基本都用它。匿名映射的用处最窄但很干脆,fork之前mmap一块,父子进程各拿一个指针,天然共享,不用管任何名字和 key。
提示:三种方式共享的物理内存本质一样,区别只在“怎么找到它”和“谁来回收它”。选型时优先考虑回收机制,而不是性能,性能上三者没有实质差别。
1.3 快在哪里,慢在哪里
说共享内存“快”,快在数据搬运路径上,但它并不是没有代价的,有几个容易被忽略的慢点。
第一个是首次访问的缺页开销。shmget申请 100MB,内核只是把虚拟地址区间和页表框架准备好,物理页要等到你第一次写这块地址时才真正分配。所以申请完立刻开始传数据的话,前几轮会有集中的缺页中断,延迟曲线是前面高后面平。做压测的时候如果不预热,量出来的首包延迟会很难看。
第二个是 TLB 压力。共享内存段越大,跨的页越多,TLB 命中率越低。我在处理一个几百 MB 的共享缓冲区时,把普通 4KB 页换成大页映射,随机访问的吞吐提升相当明显。这个优化不是必须的,但数据量上到百 MB 级别值得试一试。
第三个是伪共享。多个进程或线程频繁写同一缓存行的不同字段,会导致缓存行在两个核之间来回弹,性能可能比单线程还差。共享内存里的结构体布局要按缓存行对齐(一般 64 字节)把热点字段隔开,这一点和普通多线程编程完全一致。
第四个容易被忽视的慢点,是很多人把“共享内存”当成“共享数据结构”来用,往里塞链表、塞指针。这是绝对要避免的:两个进程的虚拟地址不同,A 里存的指针到 B 里就是野指针。共享内存里只能放偏移量、数组下标或者定长结构,任何绝对地址都不能跨进程传递。这条规矩我在代码评审里反复强调,因为它出错的方式极其隐蔽——在 A 进程自测完全正常,一到 B 进程就段错误。
2. 共享内存的映射:从申请到拿到指针
2.1 shmget 的 key 怎么定,size 会被怎样改写
shmget是 System V 路线上的入口,原型是int shmget(key_t key, size_t size, int shmflg),返回一个整型标识符shmid。三个参数各有各的坑。
key是全局命名空间里的一个整型标识。IPC_PRIVATE是个特殊值(0),表示“给我一块全新的、别人找不到的段”,只能靠fork继承shmid来共享,跨无关进程用不了。真正跨进程要用一个约定好的固定值,或者用ftok(路径, 项目号)从一个存在的文件推导出来:
key_t key = ftok("/tmp/shm_demo_key", 0x42); if (key == -1) { perror("ftok"); return 1; }ftok的坑在于它把文件的 inode 号和设备的低位跟项目号混在一起,如果那个路径文件被删了重建,inode 变了,key 就变了,另一头就找不到了。所以我更倾向于直接写死一个常量 key,比如0x53484D31,然后在项目里统一维护一张 key 分配表,比ftok可控得多。
size有个必须知道的细节:内核对它做页对齐圆整。你申请 5000 字节,实际段大小是 8192 字节(按 4KB 页)。这个圆整不影响使用,但会影响你对shmctl(IPC_STAT)返回值里shm_segsz的判断——它给的是圆整后的大小,不是你要的大小。
更关键的规则是段复用时的行为:如果key对应的段已经存在,size小于等于现有段大小,调用成功并返回已有的shmid,原有数据不会被清空;size大于现有段大小,直接失败返回EINVAL。这个设计意味着你不能靠“再调一次shmget传个更大的 size”来扩容,共享内存段一旦建好,大小就固定了。要扩容只能新建一段、拷数据、删旧的,所以我在设计阶段会一次性把容量留足,通常按峰值需求的 1.5 倍向下取整到页。
shmflg常用组合是IPC_CREAT | 0666,0666是权限位,语义和文件权限一样。如果希望“只打开已存在的段,不存在就报错”,用IPC_CREAT | IPC_EXCL反过来判断:创建成功说明是第一个,返回EEXIST说明别人已经建好了,那就去掉IPC_EXCL再调一次拿回已有的。这个模式在“谁是初始化者”的问题上非常好用:
int shmid = shmget(key, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666); if (shmid == -1 && errno == EEXIST) { shmid = shmget(key, SHM_SIZE, 0666); /* 说明段已存在,走 attach-only 路径,不要重新初始化内容 */ } else if (shmid == -1) { perror("shmget"); return 1; } else { /* 第一次创建,负责把头部结构、同步量初始化好 */ }这里有个只有踩过才知道的坑:判断“是不是第一次创建”绝对不能靠“段里内容是不是全零”,因为内核分配的页在第一次写之前读出来就是全零,但别人可能刚初始化完还没写数据。用IPC_EXCL的返回值判断才是唯一可靠的方式。
2.2 shmat 的地址选择与 attach 计数
shmid只是个编号,要真正访问数据必须shmat把段挂到当前进程的地址空间,返回一个可用的指针:
void *p = shmat(shmid, NULL, 0); if (p == (void *)-1) { perror("shmat"); return 1; }第二个参数shmaddr传NULL是最常见也最推荐的做法,让内核自己挑一个空闲地址。原因很简单:你自己指定的地址可能已经被别的东西占了,失败率高;而且在开启了地址随机化的现代内核上,指定地址本来就是徒增麻烦。如果确实需要指定(极少数需要固定地址的场景),必须配合SHM_RND标志,内核会把地址向下圆整到SHMLBA对齐。
第三个参数shmflg里有两个有用的位:SHM_RDONLY声明只读映射,任何写操作直接触发段错误,是个不错的安全网;SHM_EXEC允许执行这段内存,一般不要开。
shmat会在内核里把该段的 attach 计数shm_nattch加一,shmdt减一。这个计数是后面理解“为什么删了段还没释放”的关键。同一个进程对同一段调两次shmat,计数就加两次,shmdt也得调两次,不会自动合并。所以我在封装里通常只允许一次 attach,用一个全局指针持有,进程退出时统一 detach。
shmdt(void *shmaddr)传的就是shmat返回的那个指针,参数必须精确等于返回值,不能加减偏移。解除之后这块虚拟地址就失效了,继续访问是未定义行为,通常是段错误。shmdt只解除映射,不删除段,这是新手最容易搞混的一点:shmdt之后ipcs -m里那个段还在,数据也还在。
2.3 mmap 路线:shm_open 与匿名映射
POSIX 路线的写法更接近操作文件,我个人更偏好它,因为名字直观、调试方便。
#include <sys/mman.h> #include <fcntl.h> #include <unistd.h> int fd = shm_open("/demo_seg", O_CREAT | O_RDWR, 0666); if (fd == -1) { perror("shm_open"); return 1; } /* 关键一步:新创建的 shm 对象初始大小为 0,必须先定大小 */ if (ftruncate(fd, SEG_SIZE) == -1) { perror("ftruncate"); return 1; } void *p = mmap(NULL, SEG_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap"); return 1; } /* 映射建立后 fd 可以关掉,不影响已建的映射 */ close(fd);这里有三个必须记住的点。第一,shm_open刚创建出来的对象大小是 0,不调ftruncate直接mmap再访问,会收到SIGBUS信号。这个错误信息极其误导,看起来像内存越界,实际上是“文件长度不够”。第二,名字必须以/开头,且不能包含其他斜杠,实际文件落在/dev/shm/demo_seg。第三,mmap之后close(fd)是安全的,映射本身持有引用,关 fd 只关掉了文件描述符这条通道。
mmap的MAP_SHARED和MAP_PRIVATE必须分清:MAP_SHARED的写会反映到共享对象上,别的进程可见;MAP_PRIVATE是写时复制,改了只有自己看得到。做共享内存只能是MAP_SHARED,写成MAP_PRIVATE的话两边各自玩各自的,而且这种 bug 极难发现——程序不崩,就是数据对不上。
匿名映射是第三种,mmap(NULL, SIZE, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0),不需要任何名字和 fd,但它只在fork出来的父子进程间有效,因为fork会把映射一起继承下去。这个组合在做单机父子进程协作时最省事,连清理都不用管,进程全退出内核自动回收。
注意:
/dev/shm是一个 tmpfs 挂载点,默认容量通常是物理内存的一半。往里面写大文件或者建超大的共享段之前,用df -h /dev/shm看一眼剩余空间,写爆了的表现是ftruncate返回ENOSPC或者写入时收到SIGBUS。
3. 用共享内存读写:布局先行,同步其次
3.1 一个能直接抄的共享内存头结构
共享内存一旦映射好,读写本身和普通内存没有任何区别,memcpy、赋值、下标访问全都行。真正需要设计的是布局:头部放什么、数据放哪、同步量放哪。我习惯的布局是这样:
#include <stdatomic.h> #include <stdint.h> #define CACHE_LINE 64 struct shm_header { uint32_t magic; /* 0x53484D31,用来识别这是我们的段 */ uint32_t version; /* 结构版本号,升级时校验 */ uint64_t seg_size; /* 数据区总字节数 */ uint32_t cap; /* 环形队列容量,必须是 2 的幂 */ uint32_t mask; /* cap - 1 */ _Alignas(CACHE_LINE) _Atomic uint64_t head; /* 写指针,单调递增 */ _Alignas(CACHE_LINE) _Atomic uint64_t tail; /* 读指针,单调递增 */ uint8_t data[]; /* 柔性数组,数据区起点 */ };几个设计决定值得解释。magic和version是防御性的:映射到一块共享内存时,你无法 100% 确定这块内存是上一次程序留下的还是别人建的,先校验 magic 能挡掉大部分误用,version则让结构升级时能直接判定不兼容,而不是让老进程读到错位的字段导致数据错乱。
head和tail用_Alignas(CACHE_LINE)隔开,是为了避免伪共享。如果两个原子变量挨在一起,写head会失效包含tail的缓存行,读端每次都得多走一次内存。环形队列这种高频场景,这个优化带来的提升是实打实的。
cap用 2 的幂并配合mask做取模,是一个经典技巧:x & mask等价于x % cap,但省掉了一次除法。环形队列的读写都在热路径上,这点开销累积起来不小。
指针用uint64_t而不是size_t或者普通指针,也是刻意的:单调递增的计数配合cap做掩码,天然处理了回绕,而且永远不会溢出(64 位下就算每秒跑十亿次也要几百年才回绕)。用绝对指针则完全不可行,前面说过跨进程地址不同。
3.2 单生产者单消费者环形队列的写入与读取
单生产者单消费者(SPSC)是最简单也最常见的场景,这种情况下不需要任何锁,靠原子变量的 acquire/release 语义就足够,性能比加锁高一个数量级。
写入端逻辑:
static inline size_t ring_free(struct shm_header *h) { uint64_t head = atomic_load_explicit(&h->head, memory_order_relaxed); uint64_t tail = atomic_load_explicit(&h->tail, memory_order_acquire); return h->cap - (size_t)(head - tail); } int ring_write(struct shm_header *h, const void *buf, size_t len) { if (len > h->cap) return -1; if (ring_free(h) < len) return -1; /* 空间不够,交给上层决定重试还是丢弃 */ uint64_t head = atomic_load_explicit(&h->head, memory_order_relaxed); size_t off = (size_t)(head & h->mask); size_t first = h->cap - off; if (first > len) first = len; memcpy(h->data + off, buf, first); if (len > first) { memcpy(h->data, (const uint8_t *)buf + first, len - first); } /* release:保证上面的 memcpy 一定先于 head 更新被其他进程看到 */ atomic_store_explicit(&h->head, head + len, memory_order_release); return 0; }读取端是镜像的:
static inline size_t ring_avail(struct shm_header *h) { uint64_t head = atomic_load_explicit(&h->head, memory_order_acquire); uint64_t tail = atomic_load_explicit(&h->tail, memory_order_relaxed); return (size_t)(head - tail); } int ring_read(struct shm_header *h, void *buf, size_t len) { if (ring_avail(h) < len) return -1; uint64_t tail = atomic_load_explicit(&h->tail, memory_order_relaxed); size_t off = (size_t)(tail & h->mask); size_t first = h->cap - off; if (first > len) first = len; memcpy(buf, h->data + off, first); if (len > first) { memcpy((uint8_t *)buf + first, h->data, len - first); } /* release:保证读走的数据不会在 tail 更新之后才被生产者覆盖 */ atomic_store_explicit(&h->tail, tail + len, memory_order_release); return 0; }这两段代码的核心在于那两个memory_order。写端的head用release存,读端的head用acquire取,这一对构成同步点:读端一旦看到新的head,就能保证写端在这之前的memcpy结果全部可见。如果这里随手写成memory_order_relaxed,在 x86 上大概率也能跑对(x86 的 store-store 天然有序),但换到某些弱内存模型平台上就会偶尔读到半截数据,而且这种 bug 复现率极低、极难定位。我宁可一开始就写对,也不愿意后期去追一个“偶尔出现”的数据错乱。
还有一个必须自己注意的边界:这个无锁队列只适用于单生产者单消费者。一旦有两个进程同时写,head的 load-modify-store 之间存在竞态,两个写者会算出同一个偏移、互相覆盖。多写者场景必须加锁或者改用 CAS 循环,后者在竞争激烈时还不如直接上互斥量。
3.3 用 pthread 进程共享互斥量处理多对多
需要多对多访问共享的复杂结构(比如哈希表、链表池)时,老老实实加锁。pthread 的互斥量可以放在共享内存里让多个进程共用,前提是初始化时把pshared属性设成进程共享:
#include <pthread.h> pthread_mutex_t *mtx = (pthread_mutex_t *)((uint8_t *)h + HEADER_RESERVED); pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED); /* 关键 */ pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST); /* 防止持有者崩溃后死锁 */ if (pthread_mutex_init(mtx, &attr) == -1) { perror("pthread_mutex_init"); } pthread_mutexattr_destroy(&attr);PTHREAD_MUTEX_ROBUST这个属性非常重要,容易被漏掉。普通互斥量在一个进程持锁期间被kill -9干掉之后,锁永远处于锁定状态,其他进程全部卡死,只能重启整条链路。设了 robust 之后,下一个pthread_mutex_lock会返回EOWNERDEAD,你在这个返回值里做一次状态修复(把被中断的操作清理干净),然后调pthread_mutex_consistent告诉 pthread“锁已恢复”,就可以继续用。
int rc = pthread_mutex_lock(mtx); if (rc == EOWNERDEAD) { /* 上一个持锁者死了,把共享结构恢复到一致状态 */ repair_shared_state(h); pthread_mutex_consistent(mtx); } else if (rc != 0) { /* 其他错误 */ } /* 临界区 */ pthread_mutex_unlock(mtx);信号量同理,sem_init(&sem, 1, value)第二个参数为 1 表示进程间共享,而且信号量本身必须放在共享内存里,不能是普通全局变量——这一点和线程间用信号量完全不同,很多人第一次写会在这里栽跟头,sem_post返回EINVAL或者干脆两个进程各操作自己那份副本,互相看不见。
提示:放在共享内存里的同步对象,初始化的时机必须严格由“第一个创建者”负责,且只能初始化一次。用
IPC_EXCL判断出自己是创建者之后再pthread_mutex_init,否则会出现对一个已经初始化过的互斥量重复初始化,行为未定义。
4. 共享内存的控制:状态查询、权限与回收
4.1 shmctl 的四种常用命令
shmctl(int shmid, int cmd, struct shmid_ds *buf)是 System V 路线的控制入口,常用命令四个。
IPC_STAT把段的元信息拷到shmid_ds结构里,最常看的是三个字段:shm_segsz是段大小,shm_nattch是当前 attach 的进程数,shm_lpid是最后一次操作该段的进程 PID。排查“到底还有谁挂着这段内存”时,shm_nattch是最直接的线索。
IPC_SET修改shm_perm.uid、shm_perm.gid、shm_perm.mode三个字段,可以动态调整访问权限。实际用得不多,但做多用户隔离时会需要。
IPC_RMID是最重要也最容易误解的一个。它不是立即释放内存,而是给段打上一个删除标记,让新的shmat无法再挂上来,等所有已经挂着的进程都shmdt之后,内核才真正回收物理内存。这个设计的用意是防止正在使用中的内存突然消失,但它也直接导致了那种经典现象:代码里调了IPC_RMID,ipcs -m里段还在,标题栏的 key 变成0x00000000,attach 数不为零,看起来“删不掉”。不是删不掉,是还有人没撒手。
SHM_LOCK和SHM_UNLOCK把段锁定在物理内存中,防止被换出。System V 共享内存的页是可以被换到交换区的,对延迟敏感的场景可以锁住,代价是需要CAP_IPC_LOCK权限,且占用锁定的内存配额。POSIX 路线对应的操作是mlock。
POSIX 路线没有shmctl这套东西,控制手段就是普通的文件操作:fstat查信息,fchmod改权限,shm_unlink删除名字。语义上shm_unlink和IPC_RMID完全一致——删名字,等所有映射解除后真正释放。
4.2 段泄漏的三种典型成因与收尾顺序
共享内存泄漏在生产环境挺常见,因为它不像内存泄漏那样有工具能抓,只能靠ipcs -m或者ls /dev/shm人工巡检。我遇到过的成因基本就三类。
第一类是创建者崩溃,没走到IPC_RMID。进程被信号杀掉、容器被强制停止,清理代码全都没跑。这种情况的典型特征是ipcs -m里堆着一批 key 固定、shm_nattch为 0 的段。这类泄漏不会导致功能错误,但会持续吃掉内存,而且段大小固定,时间一长就撑爆了。
第二类是 detach 次数不匹配。前面说过shmat两次要shmdt两次,代码里在多个分支各自shmat但只在一个出口shmdt,计数永远归不了零,IPC_RMID之后段就卡在待删除状态。这种最难发现,因为功能完全正常,只有ipcs能看出来。
第三类是“误以为shmdt会删段”。写代码的人调了shmdt就以为清理完了,段实际上一直留着,下次重启程序又shmget拿到同一个段,读到上次的旧数据,表现成“程序启动时数据是脏的”。
清理顺序上,我固定这么写:先让所有使用者shmdt,再由创建者IPC_RMID(或shm_unlink),最后close(fd)(POSIX 路线)。反过来先IPC_RMID再shmdt也能工作,因为删除标记只是阻止新 attach,已挂着的照常访问,但逻辑上讲“先解引用再删名字”更清晰,也更容易在代码评审时看懂。
对于防泄漏,我常用的做法是让创建者在/dev/shm或者一个约定目录下写一个pid文件,新启动的进程先读这个文件,kill(pid, 0)探一下原主是否还活着,死了就直接ipcrm清掉再重建。这一步只多十几行代码,但能省掉大量“重启机器才恢复”的事故。
4.3 内核参数与容量规划
System V 共享内存受三个内核参数约束,排查shmget返回ENOSPC或EINVAL的时候基本都要看它们。
| 参数 | 路径 | 含义 | 调优建议 |
|---|---|---|---|
shmmax | /proc/sys/kernel/shmmax | 单个段的最大字节数 | 新内核默认很大,老系统可能是 32MB,需要按峰值调 |
shmall | /proc/sys/kernel/shmall | 系统内所有段的总页数上限 | 多段并存时会被它卡住,按总需求换算成页 |
shmmni | /proc/sys/kernel/shmmni | 系统内最大段数量 | 默认 4096,一般够用,微服务密集部署时可能不够 |
查看当前所有段用ipcs -m,看详细信息用ipcs -m -i <shmid>,清理用ipcrm -m <shmid>。POSIX 路线则看df -h /dev/shm的剩余空间和ls -l /dev/shm。
容量规划上我的经验是按“峰值并发数 × 单条最大消息 × 2”来算队列容量,再向上取到 2 的幂。乘 2 是为了留出读写速度不匹配时的缓冲,如果两端处理速度差得远,缓冲太小会导致频繁的“队列满、丢弃/重试”,缓冲太大则单次传输的缓存局部性变差,反而拖慢。这个值没什么理论最优解,压测几轮调出来比较实在。
5. 常见问题与排查技巧实录
5.1 高频报错对照表
下面这张表是我这些年攒下来的,基本覆盖了共享内存用错时的绝大部分症状。
| 现象 | 最可能的原因 | 排查与处理 |
|---|---|---|
shmat返回(void*)-1,errno是EINVAL | shmid无效,或段已被IPC_RMID | 用ipcs -m确认段是否还在,注意被标记删除的段 key 会变 0 |
shmget报EINVAL | 段已存在但请求的 size 大于现段大小 | 不能靠shmget扩容,只能新建段搬迁数据 |
shmget报ENOSPC | 超过shmall总页数或shmmni段数上限 | 清理无用段,或临时调大内核参数 |
mmap后访问收到SIGBUS | shm_open建的对象没ftruncate,长度不足 | 补ftruncate,且必须在mmap之前 |
shm_open报EACCES | 权限位不对,或/dev/shm空间不足 | 检查0666权限位与df -h /dev/shm |
| 两端数据对不上但程序不崩 | 用了MAP_PRIVATE而不是MAP_SHARED | 检查mmap标志位 |
| 读端偶尔读到半截数据 | 缺内存屏障,原子操作用了relaxed | 写端release、读端acquire配对 |
| 拿到旧数据 | 段已存在,shmget复用了上次的段 | 用IPC_EXCL判断创建者,只有创建者负责初始化 |
段删不掉,ipcs里一直有 | 还有进程没shmdt,shm_nattch不为 0 | 查shm_nattch,逐个确认持有者 |
| 持锁进程崩溃后全部卡死 | 互斥量没设PTHREAD_MUTEX_ROBUST | 加 robust 属性并处理EOWNERDEAD |
5.2 几个只有踩过才知道的细节
关于结构体布局的对齐。共享内存里的结构体不要依赖编译器的默认对齐来跨进程传递,因为两端可能是不同编译器、不同版本编出来的。我的做法是显式用_Static_assert(sizeof(struct shm_header) == 128, "header size changed")把大小钉死,编译器改了布局立刻编译失败,这比运行时发现数据错位强一百倍。所有字段用定长类型(uint32_t、uint64_t),绝不用int、long这类长度随平台变化的类型。
关于字节序。单机共享内存不存在字节序问题,两端都是同一颗 CPU。但如果这套代码将来可能被改成网络传输的版本,字段就要按网络字节序存储。我在跨进程场景里一律用本机字节序(性能优先),但会在文档里明确写一句“这套结构不可直接用于跨机传输”,避免后面的人踩坑。
关于结构升级。共享内存段在程序重启后是可能被复用的,所以结构体一旦发布出去,改动就要非常小心。我习惯在头部放一个version,升级结构时把版本号加一,新进程 attach 上来先比对版本,不一致就直接拒绝使用并提示清理。用“我删掉段重建”的方式绕过版本管理,在单机自己玩没问题,多进程联调时会到处炸。
关于调试手段。最直接的观察方式是用ipcs -m看全景,ipcs -m -i <shmid>看单个段的 attach 列表和创建者 PID,strace -e trace=ipc -f ./prog追所有 IPC 系统调用的返回值。这招在排查“谁在偷偷 attach”时特别灵。POSIX 路线就看ls -l /dev/shm和lsof /dev/shm/xxx。
关于压测预热。前面提过缺页的影响,做延迟测试时我会先往整段内存写一遍零做预热,再开始计时。不预热的话,第一次传输的延迟可能是稳态的几十倍,测出来的数据毫无意义。
关于错误处理。shmget、shmat、shm_open、mmap每一个都要检查返回值,一个都不能省。这几个调用失败时返回的-1如果不检查,接下来就是对无效指针的访问,段错误现场往往离真正的出错点很远,排查成本极高。我的封装里这几个调用全部包了一层带perror的宏,出错直接带文件名行号打出来。
关于共享内存和文件的关系。/dev/shm下的东西看起来像文件,可以用ls、rm操作,rm一个 POSIX 共享内存文件等价于shm_unlink,效果和代码里调shm_unlink一样。这个认知在应急清理时很有用——现场出问题又不能重启程序,rm /dev/shm/xxx直接就能解决“段删不掉”的尴尬,只要确认没有进程还在用它。
最后分享一个我一直在用的小结构:把“谁创建、谁初始化、谁清理”这三件事变成一个显式的状态机,放在共享内存头部的state字段里,取值是CREATING、READY、DESTROYING三个。创建者先 CAS 把状态从CREATING推到READY,使用者 attach 上来先自旋等READY,清理者把状态推到DESTROYING之后才开始收尾。这么一改,原来那些“新进程拿到半初始化的段”的偶发问题基本绝迹了,代价只是头部多了 4 个字节和一个自旋等待循环,我认为非常划算。