共享内存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域Socket2次以上内核阻塞中等流量、通用共享内存0次完全自管高频、大流量、低延迟2. shm的两种打开方式POSIX与System V2.1 POSIX shmshm_open mmapPOSIX共享内存的核心是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 shmshmget shmatSystem 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信号量Demo3.1 共享数据结构怎么设计共享内存本质上就是一块原始字节区域。要在这块区域里放结构化数据通常做法是把所有字段打包到一个struct里然后把它安放在mmap返回的起始地址上。一个很重要的设计原则是共享内存里不要放指针。两个进程映射同一文件的基址大概率不同你存一个指向共享区内部的指针换到另一个进程里地址可能落在完全无关的位置上。要引用共享区里的数据用相对偏移量或者直接用固定数组、固定长度缓冲区。下面这个结构就足够跑通一个简单Demostruct 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 seq0 textmessage 0 from producer [consumer] got seq1 textmessage 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 SIGBUSftruncate没做或者映射区间超过文件大小这是共享内存新手最容易踩的坑。mmap本身不会报错但一旦你通过映射指针访问超出文件大小的页面内核会直接发SIGBUS把进程干掉。我见过有人把错误定位到“共享内存读写不稳定”其实是文件大小和映射长度不匹配。排查办法比较直接。先看代码里有没有在mmap之前调用ftruncate并检查返回值。再去看对应的shm对象大小POSIX shm用stat或者ls -l /dev/shm/xxxSystem V用ipcs -m查看。如果文件大小小于你mmap的长度基本就是这个原因。第二个容易忽略的角度是“别人把共享文件缩小了”。如果某个进程在运行中调用ftruncate缩小了文件其他进程已映射的区域也会超过文件尾访问同样会SIGBUS。所以在多进程共享时尽量只让一个管理进程负责调整大小别让多个进程同时ftruncate。5.2 信号量残留导致的生产者/消费者死锁用命名信号量时最烦人的问题是进程被杀后信号量计数值没复位。比如上一轮生产者在sem_wait(empty)后等消费者干活时被Ctrl-Cempty变成0full变成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迁到别的方式改动范围能被控制住。共享内存的核心价值是性能但工程上的稳定性终究要靠规范的生命周期管理来兜底。