共享内存(shm)这个老牌IPC方案,我最近在一个数据采集服务里又把它翻了出来。这个服务原本是跨进程传数据,管道和Unix域Socket用得挺顺手,可流量一起来,CPU占用就再也压不下去。换成共享内存之后,同机两个进程之间搬数据的开销直接从几十微秒掉到两三微秒,链路整体轻快了一大截。这个差距不是靠调参数能补的,根源在底层数据通路不一样。
这篇文章从一个实际调过IPC链路的人的角度,把共享内存(shm)为什么快、怎么用、有哪些坑一次说清楚。会给出可以直接编译运行的Demo,也会讲到信号量、互斥锁、内存屏障这些绕不开的同步细节。适合正在折腾Linux后台服务、中间件、游戏服务器或嵌入式上位机的朋友,尤其适合那种“跨进程传数据传得CPU吃紧”的场景。
1. 为什么共享内存是所有IPC方案里最快的
1.1 其他IPC慢在哪儿:两次拷贝的代价
管道(pipe)可能是最直觉的IPC方式了,一个进程往管道写,另一个进程从管道读。可很多人没意识到,这里面的每个字节都要经历两次完整搬运:写入时从你的用户空间缓冲区拷到内核缓冲区,读出时又从内核缓冲区拷回另一个进程的用户空间。消息队列(msg queue)本质上也一样,发送端把消息拷贝到内核消息队列,接收端再从内核拷贝走。Socket更狠,除了两次拷贝之外还要过协议栈的封包、解包、校验,以及一堆软中断和唤醒开销。
想象一下,你左手边的人要递一张纸条给右手边的人,结果要先跑到楼下邮局,让邮局重新写一份,再送到右手边的人手里。管道和消息队列干的就是这个事。数据量小的时候感受不出来,一旦一轮处理要过成千上万条消息,这些拷贝消耗的带宽、缓存和CPU时间就全暴露出来了。
还有个容易被忽略的成本是上下文切换和锁竞争。每次read/write都涉及系统调用,系统调用本身有用户态和内核态的切换开销。多条消息并发时,内核还要在等待队列里做唤醒和调度。一句话总结:传统IPC的慢,主要慢在“内核当邮局”这件事上。
1.2 共享内存到底快在哪里
共享内存的思路很直接:既然两个进程都在同一台机器上,为什么不能直接给他们同一块物理内存?内核通过页表把同一组物理页面映射到多个进程的地址空间,剩下的读写完全在用户态完成。
也就是说,生产进程往共享缓冲区里写数据,消费进程直接从映射的地址里把数据读出来。整个过程没有内核参与数据搬运,零拷贝,也不需要在用户态和内核态之间来回切。你的进程读写共享内存里的变量,和读写自己进程堆里的变量在指令层面几乎没有区别。
实际测下来,单纯对比小报文传输,共享内存能比Unix域Socket快一个数量级以上。大报文场景里优势更明显,毕竟少掉了两次全量拷贝,内存带宽占用也降了。我在那个数据采集服务里最直观的一个结果就是:同样一批日志批量入队,改用共享内存后CPU占用直接低了一截。
1.3 快是有代价的:同步和生命周期
共享内存把性能还给了用户,代价是内核不再帮你做任何同步。管道和消息队列自带阻塞语义:缓冲区满时写方会自动等,队列空时读方会自动等。共享内存没有这些,两个进程同时读写同一块区域,谁先谁后完全靠自己协调。
你需要解决下面几件事:
- 并发控制:同一时刻只能有一个进程在写关键区域;
- 可见性:一个进程写入的数据,另一个进程的CPU核要能真正看到,不能停留在缓存里;
- 生命周期:共享内存对象谁来创建、谁来删除,进程崩溃了怎么办;
- 结构设计:共享内存里存的是裸字节,不同进程映射基址不同,不能随意塞指针。
这也是为什么共享内存方案总是搭配信号量、互斥锁或原子操作一起出现。它们负责把“共享”变成“安全的共享”。适合用共享内存的场景,一般是高频、批量、对延迟敏感的同机通信;要是低频小消息,用管道、消息队列反而省心,因为内核已经帮你把同步问题解决了。
| IPC方式 | 数据拷贝 | 阻塞/同步 | 适用场景 |
|---|---|---|---|
| 管道/命名管道 | 2次 | 内核阻塞 | 小流量、低频率 |
| 消息队列 | 2次 | 内核阻塞 | 小报文、多对多 |
| Unix域Socket | 2次以上 | 内核阻塞 | 中等流量、通用 |
| 共享内存 | 0次 | 完全自管 | 高频、大流量、低延迟 |
2. shm的两种打开方式:POSIX与System V
2.1 POSIX shm:shm_open + mmap
POSIX共享内存的核心是shm_open加mmap这对组合。shm_open类似open,传一个以/开头的名字,返回一个文件描述符。这个名字在Linux下会对应到/dev/shm下面的一个tmpfs文件。拿到fd之后,一般要先用ftruncate把文件长度设置成你需要的共享区域大小,然后再用mmap映射到进程地址空间。
注意ftruncate这一步别漏。很多第一次写的人shm_open完直接mmap,然后在写数据时突然收到SIGBUS,进程当场崩掉。原因就是映射区域超出了文件实际大小,访问到不存在的页。严格来说,mmap的虚拟地址总能成功建立,但真正访问对应页时,内核发现页面不在文件里,就给你一个总线错误。
mmap的flags必须用MAP_SHARED,这样多个进程映射同一份文件时,对内存的修改会写回文件并互相可见。如果用MAP_PRIVATE,写操作只是写时复制,不会同步到其他进程,那就不是共享内存通信了。映射完成后,fd本身可以关掉,不影响已有映射,后续读写直接通过data指针。
POSIX这套模型的好处是它和文件系统权限天然一致。可以用ls查看/dev/shm下有什么对象,用lsof看谁打开了它,也能用简单的方式配合信号量命名。生命周期也比较清晰:shm_unlink一删,名字就没了,但已经mmap的进程还能继续访问,直到自己munmap。这是后面排查问题时很有用的语义。
2.2 System V shm:shmget + shmat
System V共享内存是老牌实现,API风格明显更“古早”。创建时用shmget,传一个key_t和大小,返回一个shmid;再用shmat把段挂到当前进程地址空间,用shmdt解除,用shmctl控制或删除。
这里最让人别扭的是key_t的来历。可以用IPC_PRIVATE创建只能由父子进程继承的段,也可以通过ftok从一个路径名和项目ID生成key。别人要访问同一个段,必须知道同一个key或shmid,实际工程里通常会通过配置文件或环境变量传递,有点绕。
System V共享内存段不会在进程退出时自动归还。有些老程序退出时忘了shmctl(IPC_RMID),重启后同一key创建的段还在,导致状态混乱。排查时用ipcs -m能看到一堆残留段,再用ipcrm -m把id删掉。我在帮别人看线上问题时见过不止一次这种“僵尸段”,一查就是上次进程被直接杀掉,留下一堆堆积的历史段。
要说性能,两种API底层都是映射同一批物理页,开销没有本质差别。差别在API习惯和生命周期管理上,System V更“原始”,POSIX则更贴合Linux的文件模型。
2.3 实际选型建议
新写的模块我基本优先用POSIX shm。最大的原因是它的生命周期像文件,容易观测,而且shm_unlink、fd、mmap这些概念和普通文件操作一脉相承,新同事上手快。System V虽然也是标准,但key管理和残留清理都更隐性,调试起来不太舒服。
如果项目要跑在不同Unix变体上,或者需要兼容十几年前的老系统,System V可能是更保险的选择,毕竟它在老Unix里支持更广。现在主流Linux内核两者都支持,性能差别可以忽略,所以选型主要看团队熟悉度和现有代码结构。
一个实操建议:选好之后,把共享内存对象的创建、清理封装在同一个模块里,别散落在业务代码各处。因为生命周期是共享内存最容易出问题的地方,集中管理能少踩很多坑。
3. 30分钟跑通一个shm+信号量Demo
3.1 共享数据结构怎么设计
共享内存本质上就是一块原始字节区域。要在这块区域里放结构化数据,通常做法是把所有字段打包到一个struct里,然后把它安放在mmap返回的起始地址上。
一个很重要的设计原则是:共享内存里不要放指针。两个进程映射同一文件的基址大概率不同,你存一个指向共享区内部的指针,换到另一个进程里地址可能落在完全无关的位置上。要引用共享区里的数据,用相对偏移量,或者直接用固定数组、固定长度缓冲区。
下面这个结构就足够跑通一个简单Demo:
struct shared_data { int seq; char text[128]; };seq记录序号,text放实际内容。生产者和消费者都要完整声明同样的结构,确保布局一致。不要在这个结构里试图用malloc或字符串指针,malloc的地址对另一个进程没有意义。
更复杂的设计可以在结构体开头放一个魔数magic,用来校验这块共享内存是否已被正确初始化。比如生产者写入一个固定值,消费者先验证,不一致就报错退出,能有效避免“连了半初始化的内存”这类问题。
3.2 生产者:创建共享内存并写入
看代码,我用的是POSIX shm加命名信号量。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <sys/mman.h> #include <sys/stat.h> #include <unistd.h> #include <semaphore.h> #define SHM_PATH "/demo_shm" #define SEM_EMPTY "/demo_empty" #define SEM_FULL "/demo_full" struct shared_data { int seq; char text[128]; }; 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, sizeof(struct shared_data)) != 0) { perror("ftruncate"); return 1; } struct shared_data *data = mmap(NULL, sizeof(struct shared_data), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (data == MAP_FAILED) { perror("mmap"); return 1; } close(fd); sem_t *empty = sem_open(SEM_EMPTY, O_CREAT, 0666, 1); sem_t *full = sem_open(SEM_FULL, O_CREAT, 0666, 0); if (empty == SEM_FAILED || full == SEM_FAILED) { perror("sem_open"); return 1; } for (int i = 0; i < 5; i++) { sem_wait(empty); >#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <sys/mman.h> #include <sys/stat.h> #include <unistd.h> #include <semaphore.h> #define SHM_PATH "/demo_shm" #define SEM_EMPTY "/demo_empty" #define SEM_FULL "/demo_full" struct shared_data { int seq; char text[128]; }; 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, sizeof(struct shared_data)) != 0) { perror("ftruncate"); return 1; } struct shared_data *data = mmap(NULL, sizeof(struct shared_data), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (data == MAP_FAILED) { perror("mmap"); return 1; } close(fd); sem_t *empty = sem_open(SEM_EMPTY, O_CREAT, 0666, 1); sem_t *full = sem_open(SEM_FULL, O_CREAT, 0666, 0); if (empty == SEM_FAILED || full == SEM_FAILED) { perror("sem_open"); return 1; } for (int i = 0; i < 5; i++) { sem_wait(full); printf("[consumer] got seq=%d text=\"%s\"\n", >gcc -Wall -O2 producer.c -o producer -lpthread gcc -Wall -O2 consumer.c -o consumer -lpthread在终端A跑生产者,终端B跑消费者。如果消费者先启动,它会阻塞在sem_wait(full)上,等生产者启动后开始消费。运行输出大概是:
[consumer] got seq=0 text="message 0 from producer" [consumer] got seq=1 text="message 1 from producer" ...生产者那边每轮sleep(1),方便你观察两个终端输出的节奏。
运行完之后注意看/dev/shm:因为生产者最后shm_unlink了,/demo_shm应该已经不在了。信号量也会被unlink清掉。如果中途用Ctrl-C打断,再去ls /dev/shm,很可能会看到残留的/demo_shm和sem.*,这是正常现象,进程被中断后没有机会执行清理代码。后续我会说怎么处理。
这个Demo很小,但已经具备共享内存加信号量的完整骨架。要把单槽缓冲改成多槽环形队列,核心思路是一样的:多维护一个索引,加上empty/full两个计数信号量,再注意索引用原子操作或锁保护。
4. 锁、信号量和内存屏障:共享内存同步实战
4.1 为什么内核不管共享内存里的锁
很多第一次接触共享内存的朋友会问:为什么内核不帮我们在共享内存区域里加锁?原因是内核在mmap之后就把控制权完全交给了用户态。它只负责建立页表映射,之后你对这块内存的读写和访问普通内存页面没有区别,不存在一个“内核层共享内存锁”可以去抢。
所以共享内存里的互斥锁、信号量状态,本质上也是放在共享内存里的普通数据。一个进程更新了锁变量的值,另一个进程要能看到这个更新,依赖硬件缓存一致性协议和编译器生成的内存屏障指令。CPU厂商在提供多核支持时已经保证了同机多核看到的一致性,但不保证看到的一定是最新值,除非你用了原子指令或者锁原语。
这也是为什么共享内存通信比其它IPC更需要理解同步原语。你可以只把它当作一块更快的传输区,但真正写得稳,要在锁、屏障、原子操作上多下功夫。
4.2 进程间互斥锁怎么写
如果要保护共享内存里的一段复杂结构,最直接的办法是把pthread互斥锁放在共享内存里。前提是初始化时设置process shared属性。
pthread_mutexattr_t attr; pthread_mutex_t *lock; pthread_mutexattr_init(&attr); pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED); /* 假设 lock 位于共享内存结构体的某个偏移处 */ lock = (pthread_mutex_t *)((char *)data + LOCK_OFFSET); pthread_mutex_init(lock, &attr); pthread_mutexattr_destroy(&attr); /* 另一个进程直接使用,不要再 init */ pthread_mutex_lock(lock); ... pthread_mutex_unlock(lock);这里有两个关键点。第一,lock变量的内存必须落在共享内存映射区域内,不能是进程普通全局变量。普通全局变量每个进程各有一份,锁了也白锁。第二,pthread_mutex_init只能做一次,由创建共享内存的进程完成,其它进程直接lock/unlock。如果每个进程都初始化,后面的初始化会覆盖掉前一个的状态,锁就废了。
更稳妥的做法是在共享内存头部放一个初始化标记,比如magic字段。创建者先检查magic是否为预期值,没初始化才执行pthread_mutex_init并写入magic,另一个进程看到magic直接使用。否则每次启动顺序一乱,锁状态就被重置了。
条件变量也一样,pthread_cond_init前要设置pthread_condattr_setpshared。配合互斥锁才能完成“等待条件成立”的逻辑。注意条件变量是要和mutex绑定的,不能独立使用。
4.3 信号量、互斥锁、原子操作怎么选
写共享内存同步时,选择哪种工具取决于你的操作粒度。
| 工具 | 典型用途 | 特点 |
|---|---|---|
| 信号量 | 生产者/消费者、资源池计数 | 支持等待唤醒,有计数能力 |
| 互斥锁 | 保护一段临界区 | 排他访问,不能计数 |
| 条件变量 | 等待某个状态出现 | 需要配合互斥锁 |
| 原子变量 | 计数器、标志位、无锁单字段 | 没有系统调用,开销极小 |
实际经验是,高频简单场景优先考虑原子变量。比如“当前写索引”这种单字段更新,用C11的atomic_fetch_add或者GCC的__atomic_fetch_add即可,整个过程不进入内核,比信号量快很多。但原子变量不适合保护多字段结构,一个结构没法整体变成原子的。多字段需要锁,或者精心设计不变式。
信号量适合做资源计数和阻塞等待。比如多槽队列里槽位数量用信号量控制,消费者没货时会真正睡眠,不会空转CPU。如果进程都在同一台机器上,阻塞式同步的开销其实可以接受,真到大量并发、对调度延迟敏感时,再考虑无锁队列。
一个建议:先用信号量或互斥锁把功能跑通,再针对热点用原子变量或无锁优化。一上来就写无锁队列,调试成本会高得让你怀疑人生。
4.4 缓存行和伪共享
共享内存的性能瓶颈,除了同步原语,还有一个隐藏杀手:伪共享。现代CPU按缓存行(cache line)为单位加载内存,一般64字节。两个核如果各自读写同一个缓存行里不同变量,即使两个变量完全独立,缓存一致性协议也会让整个缓存行在两个核之间来回失效,表现为性能断崖式下降。
如果你在共享内存里放了两个频繁更新的字段,而且它们正好紧紧挨在一起,比如两个进程分别更新各自的控制字段,甚至只是两个线程分别更新相邻的计数,就很容易踩中伪共享。解决办法也不复杂,给字段做对齐和填充,让它们落到不同缓存行:
struct shared_data { _Atomic int producer_pos __attribute__((aligned(64))); _Atomic int consumer_pos __attribute__((aligned(64))); char data[4096] __attribute__((aligned(64))); };这样producer_pos和consumer_pos分布在不同的64字节边界上,两个核同时更新也不会互相拖累。如果机器的缓存行是128字节,就把对齐值设成128,或者干脆在字段后面加预留数组做padding。
排查伪共享不太直观,一般先在perf top里看到函数耗时异常,或者发现多进程并发后吞吐反而下降。可以把热字段拆开对齐试试,通常立竿见影。这个优化细节看起来小,高并发场景下能带来的提升非常可观。
5. 共享内存实战绕不开的坑与排查记录
5.1 SIGBUS:ftruncate没做,或者映射区间超过文件大小
这是共享内存新手最容易踩的坑。mmap本身不会报错,但一旦你通过映射指针访问超出文件大小的页面,内核会直接发SIGBUS把进程干掉。我见过有人把错误定位到“共享内存读写不稳定”,其实是文件大小和映射长度不匹配。
排查办法比较直接。先看代码里有没有在mmap之前调用ftruncate并检查返回值。再去看对应的shm对象大小:POSIX shm用stat或者ls -l /dev/shm/xxx,System V用ipcs -m查看。如果文件大小小于你mmap的长度,基本就是这个原因。
第二个容易忽略的角度是“别人把共享文件缩小了”。如果某个进程在运行中调用ftruncate缩小了文件,其他进程已映射的区域也会超过文件尾,访问同样会SIGBUS。所以在多进程共享时,尽量只让一个管理进程负责调整大小,别让多个进程同时ftruncate。
5.2 信号量残留导致的生产者/消费者死锁
用命名信号量时,最烦人的问题是进程被杀后信号量计数值没复位。比如上一轮生产者在sem_wait(empty)后等消费者干活时被Ctrl-C,empty变成0,full变成1。下一轮启动,生产者sem_wait(empty)再也没人给它post empty,整个Demo就会卡死。
解决思路是启动时主动清理。对命名信号量,常见的稳妥写法是创建前先sem_unlink,再sem_open。sem_unlink会删除信号量名字,但不会影响正在使用它的进程,所以先unlink再open是安全的。代价是如果业务上需要跨进程保持信号量在重启后继续存在,你要自己设计持久化逻辑。
还有一个更细的坑:sem_wait被信号打断时可能返回EINTR,代码里如果没处理,循环会误以为等待成功继续往下走。正确做法是判断errno等于EINTR就重试。我自己写健壮IPC代码,都会把sem_wait包一层循环处理。
5.3 进程崩溃后共享内存不释放
进程被直接杀掉时,析构逻辑不会执行,shm_unlink自然没人调用。POSIX shm对象会遗留在/dev/shm下,System V段则会变成“僵尸段”一直占着内存。你不清理,它就一直占资源。
POSIX这边清理很简单,删掉对应文件就行:rm /dev/shm/demo_shm。但注意,如果还有进程正映射着它,删除名字不会破坏已有映射,进程还能继续互相通信,这正好是POSIX共享内存的一个特性。System V那边要用ipcs -m找到残留段,再用ipcrm -m shmid来释放。
为了防止这个问题,线上程序应该把生命周期管起来:启动时检查和清理上次遗留对象,业务进程协商好谁负责创建、谁负责删除,异常退出的场景靠监控脚本兜底。别指望操作系统帮你回收,共享内存不是管道,进程退出不会自动释放。
5.4 快速排查指令速查
维护共享内存链路时,下面这些命令我基本每天都会用到。
| 需求 | 命令 |
|---|---|
| 查看POSIX共享内存对象 | ls -l /dev/shm/ |
| 查看System V共享内存段 | ipcs -m |
| 删除System V残留段 | ipcrm -m shmid |
| 查看某个共享内存对象被谁占用 | lsof /dev/shm/xxx |
| 查看POSIX命名信号量 | ls -l /dev/shm/sem.* |
| 查看进程内存映射 | cat /proc/ /maps |
用lsof看占用尤其好用。当你想删共享内存对象但又担心使用冲突,先lsof看清楚谁还没解除映射。/proc/ /maps可以确认某个进程是否已经mmap成功,这在排查“为什么数据没刷新”之类的问题时很关键。
5.5 几条长期实战攒下的经验
共享内存里尽量不要存指针,尤其是跨平台代码。不同操作系统、不同进程的映射基址各不相同,存绝对地址等于埋雷。用数组、偏移量、固定长度结构,才是最安全的。
多核对内存可见性的保证比老式单核复杂得多,但绝对不代表可以完全不用屏障。原子操作本身就是屏障,非原子读写在多核间真的可能读到旧值。我在一款RISC架构的嵌入式平台上踩过一次数据看似丢帧的坑,后来把涉及的字段全部改成原子访问才稳定下来。
上线之前一定要做异常退出测试。正常退出时清理代码都会跑,但生产环境里进程被OOM杀掉是家常便饭。用模拟强杀的方式跑一遍,看看共享内存和信号量会不会残留,残留之后系统还能不能自愈。这轮测试能提前暴露很多问题。
我自己的习惯是,把共享内存的创建、同步、清理封装成一个独立模块,业务代码只负责读写,不碰具体API。这样做第一是好调试,第二是万一哪天要从POSIX shm迁到别的方式,改动范围能被控制住。共享内存的核心价值是性能,但工程上的稳定性终究要靠规范的生命周期管理来兜底。