资讯动态

Linux共享内存实战:原理、API对比与常见坑排查

发布时间:2026/10/21 12:47:37 来源:尧图企业网站定制
1. 共享内存到底解决了什么问题先聊点实在的。写过并发程序的人应该都有这种体会两个进程要交换数据翻来覆去就那么几条路——管道、消息队列、Socket。管道用起来简单但本质是内核帮忙拷贝数据一进一出两次复制数据量大一点延迟就上来了。消息队列则是按消息块传递适合小数据量搞个几百KB的大块头照样吃力。Socket就更不用说了跨网络通信没问题本机两个进程互传数据拿它来用属于杀鸡用牛刀而且性能损耗一点没少。共享内存的思路完全不一样干脆把一块物理内存映射到多个进程的虚拟地址空间里大家直接读写同一块内存。数据放进去另一个进程马上就能看到中间不需要内核参与搬运。这就相当于几个人共用一个白板谁想写就写谁想读就读而不是像管道那样A写一封信塞进邮筒邮递员内核再送到B手里。这套方案能给性能带来质的提升原因很简单省掉了两次数据拷贝。管道和消息队列传输数据时数据先从用户态拷贝到内核态再从内核态拷贝到另一个进程的用户态两次拷贝都是实打实的CPU开销和内存带宽开销。共享内存呢数据只用写一次到共享区域读方直接访问同一块物理内存零拷贝。实测下来同样是传几十MB的数据共享内存的吞吐能比管道高出好几倍延迟更是低了一个数量级。不过天下没有免费的午餐。共享内存最大的问题不是性能而是同步。多个进程同时读写同一块内存没有一套协调机制的话数据错乱、读到半截数据都是家常便饭。所以共享内存几乎总得搭配信号量、互斥锁之类的同步手段一起用包括Linux自带的POSIX信号量、System V信号量或者自己用原子操作实现一个简单的自旋锁。另外还有一个容易被新手忽视的点共享内存的生命周期不跟随进程。管道、消息队列随着最后一个进程退出就销毁了共享内存不是。它一旦创建就一直在内核里挂着除非你显式调用删除接口或者重启机器。写服务端程序的人如果忘了在退出时清理共享内存时间一长系统里就会积累一堆僵尸共享内存段看着碍眼还占着物理内存。后面我会专门讲这个问题。这篇文章的内容我会以Linux平台为主围绕共享内存的两种主流实现——System V共享内存和POSIX共享内存来展开。先说底层原理和关键数据结构再完整演示两个进程通过共享内存传递结构体数据的例子最后把常见坑和排查手段整理成一份速查表。2. 两类共享内存API怎么选2.1 System V共享内存经典但啰嗦System V共享内存是Unix老牌进程间通信手段接口设计比较古早但胜在稳定和普及。核心接口就三个shmget创建或获取共享内存段shmat把共享内存挂到当前进程的虚拟地址空间shmdt卸载还有一个shmctl负责控制操作比如查询状态、标记删除。创建一段共享内存的典型代码长这样int shmid shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666); if (shmid -1) { perror(shmget); exit(EXIT_FAILURE); }这里的第一个参数IPC_PRIVATE表示创建一个全新的共享内存段相当于“私有”的、不和任何既有段关联。第二个参数是大小单位是字节系统会按页对齐也就是说你申请4096字节实际上可能占了一页通常4KB物理内存。第三个参数是权限标志IPC_CREAT表示不存在就创建0666是八进制权限位跟文件权限一个意思表示所有用户可读可写。拿到shmid之后把共享内存映射到当前进程的地址空间void *ptr shmat(shmid, NULL, 0); if (ptr (void *)-1) { perror(shmat); exit(EXIT_FAILURE); }shmat的第二个参数传NULL让内核自己选一块合适的虚拟地址来映射这是最省心的做法。第三个参数通常传0不做特殊限制。用完后或者进程退出前要卸载映射shmdt(ptr);注意shmdt只是解除当前进程的映射关系共享内存段本身还活着。要想彻底删除得用shmctlshmctl(shmid, IPC_RMID, NULL);但这里有个经典陷阱如果在还有进程attach着这段共享内存的时候就调用IPC_RMID会发生什么在Linux上删除标记会挂上但共享内存不会被真正释放直到所有进程都detach之后才彻底销毁。已经attach的进程还能继续读写只是不能再有新的进程attach进来了。这个语义曾经坑过不少老手后面排查环节我会专门说。2.2 POSIX共享内存更现代的替代品POSIX共享内存是POSIX标准下的另一套API设计风格比System V清爽不少有点“文件操作”的味道。它把共享内存对象当作文件一样处理shm_open打开或创建对象ftruncate设置大小mmap映射到进程地址空间用完munmap最后shm_unlink删除。创建并映射的代码int fd shm_open(/my_shared_mem, O_CREAT | O_RDWR, 0666); if (fd -1) { perror(shm_open); exit(EXIT_FAILURE); } ftruncate(fd, 4096); void *ptr mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (ptr MAP_FAILED) { perror(mmap); exit(EXIT_FAILURE); }可以看到整体流程就是标准的文件操作加mmap映射。这里的/my_shared_mem是POSIX共享内存对象的名称必须以斜杠开头而且最大长度有限制一般是NAME_MAX级别。对象实际存放在/dev/shmtmpfs文件系统上这也意味着它是内存文件重启就没了跟System V段一样不持久化。删除对象很简单只要调shm_unlink传入名称不需要等所有进程都解除映射。这一点和System V不同。但要注意如果还有进程在mmap着这个对象shm_unlink之后它们依然能正常访问只是对象的名字从文件系统里消失了新进程再想shm_open同一个名字会找不到。2.3 两者对比怎么选维度System V共享内存POSIX共享内存接口风格传统、偏底层类文件操作更清晰对象标识整型IDshmid字符串名称大小设置创建时指定创建后可用shmctl改创建后可随时ftruncate调整删除语义IPC_RMID后需等所有进程detachshm_unlink后立即从文件系统消失查看工具ipcsls /dev/shm / ipcs内核支持几乎所有Unix/LinuxLinux 2.6macOS/BSD也支持适用场景老系统兼容、复杂控制新项目、配合mmap工作流我个人在实际项目中更倾向POSIX共享内存。原因有两个第一接口设计更贴近程序员日常的文件操作直觉出错率低第二配合mmap可以用MAP_SHARED之外的一些高级特性比如MAP_POPULATE预先映射物理页、MAP_LOCKED锁页防止被换出这些都是System V的shmat不太好直接控制的。当然如果你维护的是老代码或者必须考虑非常老的Unix环境兼容性System V依然是可靠选择。3. 用共享内存传结构体完整实操3.1 场景设定与总体架构现在进入实战环节。整个演示用两个进程一个服务端writer一个客户端reader。服务端启动后创建一块共享内存往里写入一个包含多条记录的结构体数组然后通知客户端去读。客户端读取后打印并做简单校验最后双方协调清理共享内存。这里有个核心设计问题进程之间怎么知道共享内存的标识如果是System V方式进程A创建共享内存后得到shmid进程B怎么拿到这个数字最原始的办法是把shmid写进一个文件或者通过命令行参数传过去。但更标准的做法是用ftok函数通过一个文件路径加一个项目ID生成一个固定的key两个进程各自调用ftok拿到同样的key再用这个key去shmget。这相当于共享内存的“暗号”——双方约定好同一个文件名和项目ID就能定位到同一块内存。POSIX共享内存就简单一些双方约定好同一个对象名称字符串就行比如/my_shared_mem。这个名称就是全局可见的类似一个文件路径名不会像shmid那样随机变化。我这次演示用POSIX接口因为流程更直观而且和文件操作的习惯一致便于扩展。3.2 共享内存中应该如何组织数据共享内存里存的往往不是单个变量而是一结构体数组或者队列。但结构体里一旦有指针事情就麻烦了进程A的地址空间和进程B的地址空间完全不同A写入的指针值B根本无法解引用。这是新手最容易踩的坑之一。解决办法有两个第一结构体里绝对不用指针全部用定长数组或者值类型。比如要传一个字符串不要用char *而用char name[32]。第二如果必须要传变长数据那就用偏移量代替指针。比如结构体里存一个int offset指向数据在共享内存中的相对位置接收方用基地址加偏移量来访问。这在实现共享内存环形队列或日志缓冲时非常常见。我的演示结构体就按第一条来做全部定长#define NAME_LEN 32 #define MAX_RECORDS 10 typedef struct { int id; char name[NAME_LEN]; double score; } Record; typedef struct { int count; Record records[MAX_RECORDS]; } SharedData;这样整个SharedData的大小是固定的count占4字节records数组是10 * (4 32 8) 440字节总大小444字节考虑对齐后是448字节左右。我申请共享内存时直接给了4096字节完全够用还留了余量以便以后扩展字段。3.3 服务端完整实现与解析服务端代码#include fcntl.h #include sys/mman.h #include sys/stat.h #include unistd.h #include stdio.h #include stdlib.h #include string.h #include semaphore.h #define SHM_NAME /demo_shm #define SHM_SIZE 4096 typedef struct { int id; char name[32]; double score; } Record; typedef struct { int count; Record records[10]; } SharedData; int main() { int fd shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666); if (fd -1) { perror(shm_open); exit(EXIT_FAILURE); } if (ftruncate(fd, SHM_SIZE) -1) { perror(ftruncate); exit(EXIT_FAILURE); } SharedData *data mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (data MAP_FAILED) { perror(mmap); exit(EXIT_FAILURE); } >#include fcntl.h #include sys/mman.h #include sys/stat.h #include unistd.h #include stdio.h #include stdlib.h #define SHM_NAME /demo_shm #define SHM_SIZE 4096 typedef struct { int id; char name[32]; double score; } Record; typedef struct { int count; Record records[10]; } SharedData; int main() { int fd shm_open(SHM_NAME, O_RDWR, 0666); if (fd -1) { perror(shm_open); exit(EXIT_FAILURE); } SharedData *data mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (data MAP_FAILED) { perror(mmap); exit(EXIT_FAILURE); } printf(count %d\n,>sem_t *sem sem_open(/demo_sem, O_CREAT, 0666, 0); if (sem SEM_FAILED) { perror(sem_open); exit(EXIT_FAILURE); } // 写入数据... // 写完所有数据后发布信号量 sem_post(sem);客户端添加sem_t *sem sem_open(/demo_sem, 0); if (sem SEM_FAILED) { perror(sem_open); exit(EXIT_FAILURE); } // 等待服务端发信号 sem_wait(sem); // 读取共享内存...这里信号量初始值为0表示刚开始没有“可用资源”。服务端写完数据后sem_post把信号量加1客户端sem_wait发现值大于0才继续执行否则阻塞等待。这个模式在实际项目中是最基础的“就绪通知”模式。如果需要两个进程反复交替读写同一块共享内存那就得用两个信号量一个表示“缓冲区空”一个表示“缓冲区满”这就是经典的生产者-消费者模型。比如生产者写完后post“满”信号量消费者读完后post“空”信号量双方交替等待。这种模式比单纯一个信号量更安全。还有个更细的粒度问题如果多个进程同时往共享内存的不同区域写数据是不是就不需要加锁不一定。有些CPU架构下即使是不同地址的写入也可能因为缓存行cache line的竞争而互相拖慢更不用说结构体里的字段如果落在同一个缓存行内两个进程的写入会互相践踏。所以即便是分区写入也最好让不同的写区域尽量分散到不同的缓存行。这个优化在低延迟场景非常重要。3.6 编译与运行效果两个程序分别编译gcc -o writer writer.c -lrt -lpthread gcc -o reader reader.c -lrt -lpthread注意POSIX共享内存和POSIX信号量都要链接lrt老版本glibc需要信号量还要链接lpthread。现在较新的glibc2.34之后把lrt合并进了libc不加也能编过但为了兼容旧环境建议还是保留。先运行writer再运行reader./writer ./readerreader输出count 3 id1 namealpha score88.50 id2 namebeta score92.00 id3 namegamma score76.30是不是很简单但就是这层“简单”背后隐藏着不少只可意会的坑。4. 常见问题与排查技巧实录4.1 共享内存没清理系统里堆了一堆僵尸段这是运维和开发都会遇到的经典问题。用System V共享内存的程序如果崩溃了或忘记shmctl删除共享内存段就一直在内核里挂着。时间长了用ipcs -m一看------ Shared Memory Segments -------- key shmid owner perms bytes nattch status 0x00000000 32768 user 600 4096 0 0x00000000 32769 user 600 1048576 0 0x00000000 32770 user 600 65536 0nattch为0说明没有进程attach但段一直在。这种段占着内存不干正事唯一的处理方式是手工清理ipcs -m | awk {print $2} | xargs -I {} ipcrm -m {}注意生产环境操作前一定确认段归属别把别的业务的共享内存也删了。对POSIX共享内存来说残留的对象会出现在/dev/shm下面ls -lh /dev/shm对应的文件删掉即可rm /dev/shm/xxx。但这里有个细节如果还有进程正在mmap映射这个文件直接rm之后那个进程依然能正常访问只有新进程再去shm_open同名对象才会失败。所以清理之前还是要确认没有活跃用户。4.2 shm_open返回ENOENT或EACCESENOENT一般是客户端用了O_CREAT之外的标志去打开一个不存在的共享内存对象。EACCES则是权限问题。常见原因有两个一是服务端创建对象时权限位设了0600其他用户身份的进程访问不了二是对象所在的/dev/shm挂载目录有特殊的挂载选项。解决思路是创建时明确指定权限比如0666并确保服务端和客户端运行在同一个用户下或者通过用户组来控制访问。另外如果系统对/dev/shm有nosuid等挂载限制可能会影响某些操作排查时可以用mount命令看一下挂载参数。4.3 mmap之后写入数据另一个进程读不到这个问题的原因九成是mmap时用了MAP_PRIVATE而不是MAP_SHARED。MAP_PRIVATE会做写时复制Copy-on-Write你往里写数据只是改了进程自己的副本别的进程根本看不到。这在调试共享内存时特别容易踩。检查代码和编译输出确保MAP_SHARED传进去了。还有一个原因mmap映射的大小超过了你ftruncate设置的大小。比如ftruncate只给了1024字节你却mmap了4096字节那超出1KB的部分访问结果是未定义的可能触发SIGBUS信号导致进程崩溃。所以mmap的len参数必须小于等于ftruncate设置的size。4.4 SIGBUS崩溃SIGBUS不是SIGSEGV是总线错误通常发生在访问mmap区域中尚未被文件或共享内存对象“支撑”的页面。举个例子你ftruncate了512字节但mmap了4096字节。访问前512字节没问题一旦访问第513字节之后的任何地址内核尝试从映射对象读取数据发现对象大小不够直接SIGBUS。这在配置共享内存大小时是个隐蔽的坑。一定记住mmap的映射长度永远不要超过ftruncate设置的长度。更好的做法是用一个宏把两者统一起来避免手写数字不一致#define SHM_SIZE 4096 ftruncate(fd, SHM_SIZE); void *ptr mmap(NULL, SHM_SIZE, ...);4.5 结构体里有指针跨进程直接崩前面说过共享内存里的指针不能直接跨进程使用。进程A往共享内存写入一个char *指向自己堆上的字符串进程B拿到这个指针去访问B自己的地址空间根本没有这段内存直接段错误。我自己踩过这类坑之后形成了一个习惯凡是要放进共享内存的结构体一律只用定长数组。如果必须要变长数据那就用相对偏移量。比如typedef struct { int data_offset; int data_len; char reserved[64]; } VariableRecord;这里的data_offset是相对共享内存基地址的偏移接收方拿到基地址加偏移就能定位数据。这个模式在实现日志缓冲区、共享内存环形队列时非常实用。4.6 系统限制不够用共享内存有系统级上限。Linux下几个关键参数参数默认值含义/proc/sys/kernel/shmmax通常18446744073709551615或8388608单个共享内存段的最大字节数/proc/sys/kernel/shmmni4096系统允许的共享内存段数量上限/proc/sys/kernel/shmall通常很大系统允许的共享内存页总数如果在创建大块共享内存时失败先看这几个值。临时调整可以用echo直接写永久改要写/etc/sysctl.conf。比如sysctl -w kernel.shmmax1073741824 echo kernel.shmmax1073741824 /etc/sysctl.conf对POSIX共享内存/dev/shm的大小还受tmpfs挂载参数影响df -h /dev/shm看一下余量。临时扩大mount -o remount,size2G /dev/shm这个命令在大多数发行版上能直接生效但重启后失效要持久化还得改/etc/fstab里tmpfs那行。4.7 多进程同时写数据乱了这个问题我在前面提到过这里再展开。杀进程级数据错乱的本质是“读-改-写”不是原子的。比如两个进程同时执行data-count >strace -f -e traceshm_open,shm_unlink,mmap,ftruncate ./reader如果shm_open返回-1strace会直接显示错误码比如ENOENT或EACCES一眼就能看出是权限问题还是对象不存在。比在代码里加一堆printf排查高效得多。5.3 用共享内存做性能验证的一个小心得很多人验证共享内存性能时喜欢用单次大块拷贝来测比如一次性拷贝128MB。这种测试并不能真实反应生产场景因为大块拷贝的耗时主要被DMA和内存带宽卡住共享内存和管道的差距反而没那么明显。更贴近真实场景的测试是高频小数据交互比如10字节一条消息每秒发送几十万次。这种情况下共享内存零拷贝的优势会非常突出因为每次通信都省去了两次内核拷贝和上下文切换的开销。我在这类测试中测过共享内存比Unix域Socket快2到4倍比TCP loopback快一个量级都不稀奇。所以如果你在评估一个系统该不该用共享内存先想清楚它的通信模式高频小消息、大数据量低频率、还是高并发读写共享内存在前两种模式下优势明显在第三种模式里优势会被锁竞争抵消一部分需要仔细设计分区和锁粒度。6. 适合用共享内存的场景梳理6.1 高频交易和实时数据处理系统这类系统对延迟极其敏感消息路径上每多一次拷贝、多一次系统调用都可能影响整体性能。共享内存作为进程间数据交换的“高速公路”配合忙等待spin-wait或低开销信号量可以把单次通信延迟压到微秒级。常见的架构是一个数据采集进程写共享内存多个计算进程从共享内存读数据各自处理不同维度。因为没有锁的话多读单写可以直接跑比任何消息队列都省心。6.2 图像帧或音视频流传输摄像头采集程序拿到的帧数据通常是几个MB的原始YUV或RGB如果走管道或Socket每次传输都有两次拷贝高帧率下CPU占用会非常难看。用共享内存加环形缓冲区ring buffer采集进程写入一帧播放或编码进程消费一帧中间只需要维护读写指针缓冲区满则丢帧或阻塞。这套方案在不少嵌入式设备上跑得很稳。6.3 多进程协作的服务器架构Nginx的worker进程之间虽然通常不共享业务数据但一些自研的高性能服务器会用共享内存维护全局计数器、配置热更新副本、或者各worker之间的负载指标。这些数据变更频繁、读多写少共享内存搭配原子操作比加解锁的跨进程锁性能好很多。6.4 不适合的场景共享内存不是万能的。如果进程之间只是偶尔传个几百字节的请求那就用消息队列或管道引入共享内存反而要处理映射生命周期、同步、崩溃恢复等一系列问题收益还覆盖不了复杂度。如果进程分布在多台机器上那就老老实实用网络协议共享内存只在本机生效。如果一个数据项只是定期变化、不要求实时性那用文件读写加信号量也完全够用。简单说共享内存适用的是“高频、大数据量、低延迟”的进程间通信场景其他场景选用更简单的IPC机制往往更省事。7. 最后的工程化建议代码能跑和代码能上线是两回事。共享内存在生产环境要稳定工作除了API本身还有很多工程化细节第一生命周期管理要统一。我见过很多项目里共享内存是由各个业务进程自己创建和删除的结果就是进程退出顺序不同导致清理不干净。比较好的做法是划分清楚哪个进程负责创建、哪个进程负责删除文档里写明代码和文档保持一致。第二启动顺序要有保障。服务端创建共享内存后客户端才连接。如果客户端启动早了需要写重试逻辑而不是一失败就退出。这个重试间隔可以固定也可以用指数退避避免启动瞬间一堆客户端同时重试打爆日志。第三所有的错误路径都要考虑清理。程序可能在任何一行崩溃如果用shm_open创建了对象之后某一步失败了要有检查并shm_unlink的代码。这种RAII风格虽然C语言里没有但可以用一个结构体包住fd和指针配合atexit或信号处理器来做最后的清理。第四权限和安全要重视。共享内存是全局可见的任何有权限的进程都能attach进来读取数据。如果存的是敏感业务数据至少应该设置严格的权限位并限制可访问用户组。更稳妥的方案是使用系统级安全模块来限制对特定共享内存对象的访问。第五好好利用已有的工具和库。Linux内核自带的工具只是一部分生产环境可以考虑用现成的IPC封装库比如Boost.Interprocess或者一些消息中间件底层的共享内存传输模块比自己反复造轮子靠谱。学API是一回事把通信架构设计好是另一回事。我在实际项目中的体会是共享内存不难难的是把它的边界想清楚。什么时候用、什么时候不用、谁来管、崩了怎么办这四个问题搞定了共享内存就是一把利器没想清楚就是一地鸡毛的调试噩梦。希望这篇文章能帮你把后者变成前者。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑