Linux共享内存实战:从System V API到同步控制与避坑指南
说实话我第一次被Linux进程间通信折磨是在一个嵌入式linux项目里。当时需要让采集进程和算法进程共享一帧图像数据一开始用消息队列传结果一张图分成几百个包来回拷贝丢包、延迟、内存碎片全来了。调试得焦头烂额旁边老工程师头也不抬丢给我一句去用共享内存。那是我和System V IPC的第一次正式照面也是从那次开始我才真正理解什么叫数据搬运比处理数据更贵。这篇文章就是想把共享内存这套东西讲透从API用法到同步控制再到那几个我踩过的坑全部摊开来说。1. 为什么数据搬运会成为Linux进程间通信的瓶颈1.1 管道和消息队列都在做搬运工Unix/Linux下的进程间通信管道、FIFO、消息队列本质都是搬运工模式。数据从一个进程的用户态内存先拷贝到内核态的一块缓冲区另一个进程再从内核缓冲区拷到自己用户态内存。我打个比方这就像A要把一箱货交给B但中间隔着一条传送带A把货搬上传送带B再从传送带搬下来货物两次经过人工搬运。搬运本身有成本而且成本不低。一次write到管道或者消息队列涉及用户态到内核态的切换数据要经过系统调用复制另一次read又得从内核态切回用户态数据再复制一遍。如果你是做linux底层原理研究或者性能敏感应用的应该明白这里有两个代价一是两次内存拷贝消耗CPU和内存带宽二是每次系统调用都伴随上下文切换。对于几字节的配置数据、几十字节的状态通知这种成本无所谓反正一次几百纳秒就完事。但你要传一张1080p的帧大概几MB传一个日志文件片段或者嵌入式linux项目里的传感器批量数据用消息队列就会感觉到明显的延迟和CPU占用飙升。因为消息队列还有个限制每个消息有长度上限通常msgmax是8192字节大块数据必须被拆成很多个小消息拆包、组包、排序逻辑全得自己写复杂度直接起飞。1.2 共享内存的黑板模式省掉两次拷贝共享内存的思路完全不同内核在物理内存中划出一块区域然后把它映射到多个进程的虚拟地址空间。进程A往这块地址写数据进程B在相同地址上直接读。整个过程没有内核缓冲区中转没有反复的copy_from_user/copy_to_user数据只在人与人之间传递一次——严格说连一次拷贝都没有因为大家都是直接读写同一块物理内存。我习惯把它类比成一块挂在墙上的黑板。几个参与通信的进程就像几个老师在黑板前讨论谁想说什么直接拿起粉笔写上去其他人抬头就能看到。不需要谁写张纸条递给别人也不需要中间人传话。这就是共享内存的核心价值零拷贝、低延迟、高吞吐。在linux系统管理或者性能排查时你会发现共享内存在进程的地址空间里以堆外匿名映射的形式存在/proc/[pid]/maps里能看到对应段。它不占进程的堆大小但算在进程的虚拟内存里。理解这一点如果你以后排查进程内存暴涨就不会把共享内存误判成内存泄漏。1.3 什么场景真正适合共享内存共享内存在这些场景下非常合适大块数据周期性传输图像帧、音频帧、点云数据、日志批量输出。高频小数据交换实时控制指令、状态遥测希望日志侧近乎零延迟地读取。多进程同时访问的全局数据区比如配置中心落地之后多个worker进程直接读同一块只读配置。但它也有明显的不适合场景。如果通信双方是不同电脑上的进程跨机器共享内存就废了得走网络协议。如果数据量极小且偶发共享内存的创建、挂接、同步开销很可能比管道还贵用管道反而省事。另外共享内存本身没有自动同步机制多进程同时写同一块区域会导致数据错乱这是后面要重点展开的话题。2. 系统调用的四件套shmget、shmat、shmdt、shmctlSystem V IPC是上世纪八十年代从Unix带过来的遗产几大件包括共享内存shm、消息队列msg、信号量sem。共享内存这边核心就是四个API理解它们就掌握了九十。先看一张我自己整理的速查表函数作用关键参数典型返回值shmget()创建或获取一个共享内存段key、size、shmflg成功返回shmid失败返回-1shmat()把共享内存段附加到进程地址空间shmid、shmaddr、shmflg成功返回映射地址失败返回(void *)-1shmdt()从进程地址空间分离共享内存段shmaddr成功返回0失败返回-1shmctl()控制操作查询、销毁、权限修改shmid、cmd、buf成功返回0失败返回-12.1 shmget用key申请一块内核级内存第一个参数key是通信双方的接头暗号。两个进程只要用相同的key调用shmget()就能定位到同一块共享内存。一般有两种方式拿到key硬编码一个常量比如#define SHM_KEY 0x1234。省事但多个项目混在同一台机器上容易冲突。用ftok()从一个真实文件路径加项目ID生成key。key_t key ftok(/tmp/shm_file, A)这样只要文件路径和ID一致生成的key就一致冲突概率低很多。注意ftok()依赖文件的inode和设备号文件如果被删除重建key会变。第二个参数size是共享内存段大小单位是字节。内核会按页大小通常4096字节对齐比如你申请1字节实际分配一页。第三个参数shmflg由权限位和标志位组成IPC_CREAT不存在就创建存在就直接返回已有的段。IPC_EXCL和IPC_CREAT一起用如果段已存在则返回EEXIST错误保证必须由我创建。权限位比如0660表示属主和同组可读写。一个常见的坑是shmget()返回的是shmid它是一个整数标识符在linux系统里可以通过ipcs -m看到一堆这样的ID。它是内核对象句柄不是内存地址还需要shmat()把它映射进来才能真正使用。2.2 shmat把内核地址挂到进程地址空间shmat()把共享内存段挂到当前进程的虚拟地址空间。第二个参数shmaddr通常传NULL让内核自动选一个合适的地址这也是实践中最推荐的做法。如果你非要指定地址还得搭配SHM_RND标志让内核做地址对齐平白无故多一堆麻烦。第三个参数shmflg常用的是0可读可写或SHM_RDONLY只读映射。返回值就是映射后的用户态基地址之后你就可以像操作普通malloc出来的内存一样直接对这块地址读写。这里要提醒一点shmat()返回(void *)-1表示失败不能直接拿NULL判断很多人在这吃过亏。同一块共享内存可以被同一个进程多次shmat()每次返回的地址可以不同引用计数会累加。如果不小心挂多了shmdt()的时候也得一一解挂否则段会一直在你的进程地址空间里赖着不走。2.3 shmdt与shmctl解挂、销毁与权限调整shmdt()只有一个参数就是shmat()返回的那个地址。它做的是解挂从当前进程的地址空间移除这段映射。注意解挂不等于删除段段的内核对象依然存在其他进程依然可以继续使用。真正销毁段用的是shmctl()int ret shmctl(shmid, IPC_RMID, NULL);IPC_RMID标记删除这个共享内存段。这里有个经典误解标记删除后段并不是立即消失而是等所有进程都shmdt()之后才真正释放物理内存。所以你会遇到一种情况ipcs -m还能看到已经标记为destdestroyed的段但新的进程无法再shmat()到它。这种延迟删除的设计是为了保证正在使用该段的进程不会因为暴毙而收到段错误。shmctl()还能干别的活。比如IPC_STAT获取该段的属性结构体SHM_LOCK/SHM_UNLOCK锁定物理页框把它固定在内存避免被换出这些在性能调优时会用到。日常用得最多就是IPC_RMID。2.4 用ipcs和ipcrm做日常体检和清理命令行工具是排查利器。ipcs -m查看当前所有共享内存段输出关键字段有shmid、key、owner、perms、bytes、nattch、status。nattch是当前挂接该段的进程数如果你在进程退出后发现nattch始终不为0说明有进程没调shmdt()那就要去查它的退出路径了。ipcrm -m shmid或者ipcrm -M key可以手动删除一个段。在linux运维故障案例中经常出现运行一段时间后无法创建共享内存的问题十有八九就是代码里忘了shmctl(IPC_RMID)段积累到内核上限/proc/sys/kernel/shmmni指定的数量默认4096后面就创建不了了。处理手段就是ipcs -m | grep your_key加ipcrm -m清一遍。我自己的习惯是程序启动时先尝试用IPC_EXCL|IPC_CREAT创建如果创建失败说明上一次没清理干净就调用shmctl(shmid, IPC_RMID, NULL)把残留段清掉再重来。简单粗暴但特别有效。3. 手写一个共享内存的生产者-消费者demo3.1 代码设计与关键结构体纸上谈兵没用直接上代码。下面这个demo模拟一个生产者和一个消费者通过共享内存传递一个结构体。为了说明同步问题我会引入System V信号量做互斥但这一步先不展开原理代码里看效果。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/ipc.h #include sys/shm.h #include sys/sem.h #include errno.h #define SHM_KEY 0x20240901 #define SEM_KEY 0x20240902 #define SHM_SIZE 4096 struct shared_data { int seq; float values[128]; char message[256]; }; static int set_sem(int semid, int semnum, int val) { return semctl(semid, semnum, SETVAL, val); } int main(void) { int shmid shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0660); if (shmid 0) { if (errno EEXIST) { shmid shmget(SHM_KEY, SHM_SIZE, 0660); printf(segment exists, reuse shmid%d\n, shmid); } else { perror(shmget); exit(1); } } struct shared_data *ptr (struct shared_data *)shmat(shmid, NULL, 0); if ((void *)ptr (void *)-1) { perror(shmat); exit(1); } int semid semget(SEM_KEY, 1, IPC_CREAT | 0660); if (semid 0) { perror(semget); exit(1); } set_sem(semid, 0, 1); for (int i 0; i 3; i) { struct sembuf lock[1] {{0, -1, 0}}; semop(semid, lock, 1); memset(ptr, 0, sizeof(struct shared_data)); ptr-seq i 1; ptr-values[0] i * 0.5f; snprintf(ptr-message, sizeof(ptr-message), package %d, i 1); printf([producer] wrote seq%d\n, ptr-seq); struct sembuf unlock[1] {{0, 1, 0}}; semop(semid, unlock, 1); sleep(1); } shmdt(ptr); shmctl(shmid, IPC_RMID, NULL); printf([producer] detach and cleanup\n); return 0; }这个demo故意简化把生产者和消费者逻辑写在同一个程序里靠fork分叉演示。核心结构体struct shared_data里放了序号、数组和字符串足够演示数据传递。关键是信号量那段写数据前semop做P操作信号量减1相当于拿锁写完做V操作加1释放锁消费者在读取前也做P操作。这样两边就强制串行避免同时访问。3.2 编译、运行与验证编译命令很简单gcc -Wall -O2 shm_demo.c -o shm_demo然后运行./shm_demo程序会创建共享内存段、创建信号量、写入三组数据并打印。运行期间你在另一个终端执行ipcs -m ipcs -s能看到一个大小为4096字节的共享内存段和一个信号量。程序结束后因为调用了shmctl(IPC_RMID)ipcs -m里那个段就消失了。这就是一个完整的共享内存生命周期创建、挂接、读写、解挂、销毁。如果你把进程拆成两个独立程序做真正的跨进程读写关键在于key一致。生产者程序和消费者程序只要都写SHM_KEY 0x20240901它们就能定位到同一块内存。我曾经见过一个团队把key写在两个文件里后来一个文件改成0x20240902两边默默调了一下午。3.3 代码背后容易忽略的细节这段demo里有几个点值得单独说。第一memset(ptr, 0, sizeof(struct shared_data))整体清零但struct shared_data大小可能远小于4096字节。这没问题段本身就是4096字节结构体只是用其中一部分。不过你要小心如果多个结构体同时放在一个段里就得自己搞内存布局通常建议用一个头部结构体记录偏移类似文件格式的设计思路。第二shmat之后指针指向的内存和普通堆内存行为类似但页面可能被内核换出swap。生产环境如果对延迟极其敏感可以考虑mlock()锁定物理页或者用SHM_LOCK。第三这个demo的信号量只是二元互斥实际生产环境如果用多生产者多消费者最好用两个信号量表示数据可写和数据可读类似有界缓冲区否则只有一个互斥锁消费者并不知道有没有新数据只能轮询。性能需求高的时候轮询很浪费CPU这时候你可能需要事件通知机制比如eventfd配合共享内存。4. 同步控制才是共享内存真正难啃的骨头4.1 为什么两块内存没有自动上锁共享内存的本质是多个人共用一块黑板。问题是如果两个老师同时往黑板的两块区域写字互相不越界那没事但一个人写字另一个人同时擦黑板那内容就乱了。内核只负责提供黑板不负责帮你规定谁先写谁后读、谁在写的时候别人不能读。没有同步机制这就是数据竞争data race轻则读到半截数据重则程序崩溃。这就是为什么网上所有共享内存教程都绕不开信号量。共享内存负责快信号量负责稳。两者结合是System V IPC里的经典组合共享内存传数据信号量传状态。我刚学的时候老觉得麻烦——为什么要两个独立的内核对象能不能合在一起后来做linux系统编程多了才明白这种解耦恰恰是设计上的优点谁持有锁、谁等锁跟数据具体在哪儿没有关系你可以灵活组合甚至用文件锁、互斥锁、原子操作分别配合共享内存而信号量只是其中一种方案。4.2 用System V信号量做经典互斥System V信号量的接口是semget()创建、semop()操作、semctl()控制。semop()接收一个sembuf数组可以同时执行多个原子操作struct sembuf { unsigned short sem_num; /* 信号量编号 */ short sem_op; /* 操作数-1P操作1V操作 */ short sem_flg; /* 0表示阻塞等待IPC_NOWAIT表示非阻塞 */ };一个典型的同步策略是共享内存里放一个数据有效标志或者一个序列号生产者写完数据后设置标志、释放信号量消费者等待信号量、读取数据、清除标志。如果数据量大、生产者持续写入还可以用双缓冲double buffer一块内存在写另一块在读交替切换进一步减少锁竞争。这里有个经验信号量是内核对象每次semop()都是系统调用是有开销的。如果你只在进程启动时传配置、之后只读那么根本不需要每次访问都加锁加锁反而拖慢性能。同步粒度一定要按实际写入频率设计别为了保险起见给一个只读数据也上锁。4.3 忙等待与原子操作的一些替代思路也有不用信号量的做法。一种是在共享内存里用自旋锁或者原子变量自己实现互斥。GCC的__atomic内置函数可以对共享内存地址做原子操作比如__atomic_compare_and_swap。这种思路的好处是快不需要内核介入但坏处是如果临界区长其它进程会忙等空转CPU白烧。而且自旋锁在单核机器上如果不同进程优先级差异大还可能造成优先级反转。另一种是单生产者单消费者场景下的无锁设计。生产者只更新写入索引消费者只更新读取索引双方各自维护自己的指针配合内存屏障比如__sync_synchronize()就能避免大部分竞争。我做过一个嵌入式linux项目传感器数据15ms一帧每帧只有几百字节我们用环形缓冲区加两个原子索引跑了好几个月没出过问题。但无锁编程对内存序的理解要求很高新手不建议直接上先从信号量版本跑通再考虑优化。5. 实战中的坑从段残留到段错误5.1 残留段与key冲突先ipcs再ipcrm先说一个最典型的故障。程序跑着跑着突然报shmget: No space left on device但磁盘明明很空df -h也正常。如果你对linux运维故障案例有经验应该立刻反应过来这里的No space不是磁盘满了而是共享内存段数量或者总大小到了上限。查到的方法就三步ipcs -m sysctl kernel.shmmni sysctl kernel.shmmax如果ipcs -m的输出里满屏都是你程序的残留段说明每次进程退出都没做IPC_RMID把段一个个清掉。如果段是正常的但总数到了shmmni上限默认4096那就得调内核参数。生产环境一般不建议为了迁就代码狂调上限正确做法是修代码所有shmat成功之后在进程退出路径上不管异常还是正常都必须走到shmdt IPC_RMID。5.2 权限位和IPC_CREAT的误用另一个高频坑来自权限位。shmget(SHM_KEY, SHM_SIZE, 0660)和shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0660)是两回事。前者如果段不存在会返回ENOENT后者才会创建。我见过有人写代码时漏了IPC_CREAT然后无论怎么试都提示找不到段排查半天发现是权限位没加创建的flag。权限位本身也有讲究。0660表示属主和同组可读写如果你用0600别的用户启动的进程就shmat不进来。嵌入式linux项目里大家经常用root跑程序这个坑不显眼一旦你切换到普通用户运行立刻报Permission denied。多进程通信前先确认各个进程的属主和组是否一致。5.3 映射大小与结构体对齐问题size传小了会段错误吗shmget申请了100字节shmat后结构体字段超过100字节写入会越界可能落在内核映射的其它页也可能触发SIGBUS或SIGSEGV表现不稳定。还有一个隐蔽的对齐问题。如果你在结构体里放了double、int64_t、struct嵌套不同编译器、不同编译选项下结构体大小和字段偏移可能不同。共享内存两边程序如果用了不同的编译器或者加了不同的#pragma pack读出来的数据就是错乱的。解决办法很简单结构体里只使用固定宽度类型uint32_t、int64_t、float不直接用intint在不同平台长度可能不同。避免依赖编译器默认对齐必要时用静态断言C11的_Static_assert验证sizeof符合预期。5.4 内核参数shmmax与shmall的影响kernel.shmmax是单个共享内存段的最大字节数默认通常在32MB左右不同发行版差异很大如果你想申请128MB的段shmget()会失败。kernel.shmall是系统内共享内存页总量上限单位是页4KB它限制所有共享内存段的总和。临时调整sysctl -w kernel.shmmax268435456 sysctl -w kernel.shmall65536永久生效就写到/etc/sysctl.conf。但我必须提醒盲目调大这两个参数会挤占页面缓存反而拖慢整个系统的文件IO。共享内存是常驻物理内存的除非被换出64MB的视频缓冲×10路就是640MB内存不够就开始swap性能断崖式下跌。先算清业务到底需要多少再决定要不要调。5.5 嵌入式linux场景的额外提醒嵌入式linux项目有几个特殊点我在实际项目里吃过亏。第一很多嵌入式环境和服务器不同默认/dev/shmPOSIX共享内存的挂载点可能不存在或者没有挂载如果你用POSIX接口就得特别注意而System V接口依赖内核配置一般都在但要注意内核裁剪时是否打开了CONFIG_SYSVIPC。第二嵌入式机型Flash小很多团队用busyboxipcs、ipcrm不一定有。没有这些工具时排查残留段只能写个小程序循环调用shmctl来清理或者直接看/proc/sysvipc/shm文件这个proc接口几乎一定有。第三嵌入式系统如果跑RT补丁PREEMPT_RT共享内存配合信号量时的锁竞争延迟要格外小心。我在一个实时任务里试过用System V信号量保护共享内存导致调度抖动从微秒级变成毫秒级后来改成了无锁环形缓冲加futex才追上硬实时要求。场景不同方案的选择完全不一样。6. System V与POSIX共享内存怎么选6.1 两组接口的对照Linux下还有一套POSIX共享内存接口shm_open()创建/打开mmap()映射munmap()解挂shm_unlink()删除。它的设计更贴近文件操作名字也直白很多人觉得比System V那套老古董现代。我做了一张对比表维度System V共享内存POSIX共享内存创建/打开shmget()基于keyshm_open()基于名字/xxx映射shmat()mmap()解挂shmdt()munmap()删除shmctl(IPC_RMID)shm_unlink()存活管理显式销毁残留段要清理shm_unlink()后自动释放引用计数调试工具ipcs -m、ipcrmls /dev/shm、rm /dev/shm/xxx可移植性几乎所有类Unix都支持较新但主流Linux/BSD都支持内核参数shmmax、shmall受/dev/shm挂载大小限制与select/poll结合不方便可以结合但本质还是靠轮询System V的命名空间是全局整数key容易冲突但安全性隔离也更粗放POSIX用文件系统风格的路径名天然带命名空间组织和权限管理调试时直接ls /dev/shm就一目了然这是它最大的吸引力。6.2 我的选型习惯如果是新项目、并且确认运行环境内核较新我更倾向于POSIX共享内存理由很简单接口简洁、调试直观、出错率低。如果你有生产代码已经在用System V又没有非改不可的理由别折腾老代码稳定跑着就是最大的财富。但如果碰到底层、嵌入式、或需要精细控制内核共享内存上限的场景System V这块老骨头反而更直接。它不依赖文件系统挂载内核裁剪时只要打开CONFIG_SYSVIPC行为更可控。我自己现在做linux系统编程时手边常备两套模板常规服务用POSIX嵌入式裸环境或者对内核共享资源有强约束的场景用System V。两套都吃透面试也好、实际开发也好才不至于被某一个选择卡死。说到底共享内存只是工具箱里的一把扳手真正值钱的不是你会调用几个API而是你清楚每个方案背后的内存语义、同步代价和故障特征。这块内容多写一点不亏你迟早会在项目里和它狭路相逢。