ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux共享内存完全指南:原理、API、实战与踩坑经验

2026/9/26 4:52:50 拓冰建站 浏览量
Linux共享内存完全指南:原理、API、实战与踩坑经验 写这篇博文之前先说我自己的一个体会Linux下做进程间通信但凡你写过一段时间最后一定会回到共享内存上来。管道、消息队列、信号量这些花架子玩了一圈一旦遇到真正的高频数据交换场景你会发现所有绕过共享内存的方案都在绕远路。当然绕远路也有绕远路的好处——安全、可控但代价就是性能。这篇文章就围绕共享内存这一种IPC机制把原理、API、实战代码、踩坑经验一次讲透。1. 为什么还要用共享内存它解决的痛点1.1 三种IPC的数据流路径对比先把概念摆清楚。Linux下进程间通信本质上就解决一个问题两个进程的用户态地址空间是隔离的A进程看不见B进程的内存怎么把数据从A安全、高效地送到B我们平时最常用的三种IPC数据流的路径完全不同管道A进程调用write()数据从A的用户态缓冲区拷贝到内核的管道缓冲区B进程调用read()数据再从内核缓冲区拷贝回B的用户态缓冲区。注意这里发生了两次拷贝而且每次都伴随一次用户态和内核态的模式切换。消息队列A进程调用msgsnd()数据从A的用户态拷贝到内核的消息队列结构B进程调用msgrcv()数据从内核拷回B的用户态。同样是两次拷贝两次切换。共享内存A进程和B进程通过某种机制把同一块物理内存映射到各自的虚拟地址空间。A进程直接往自己的虚拟地址上写数据B进程直接读整个过程零拷贝也完全不需要进入内核态。我把这三者的数据路径放在一起对比就很直观了IPC方式数据拷贝次数是否需要内核参与典型延迟量级管道2次每次传输都要微秒级甚至更高消息队列2次每次传输都要微秒级共享内存0次只在建立/撤销映射时纳秒级到百纳秒级1.2 性能数据与适用场景有个很经典的基准测试数据可以参考在一台普通的x86服务器上通过管道传输1MB数据大概需要几毫秒而通过共享内存做同样的操作只要几十微秒差距是两个数量级。在需要高频收发小数据包比如交易系统、实时控制程序、游戏服务器的场景里这个差距就是决定系统能不能扛住压力的关键。但这里我要说句公道话共享内存不是银弹它只适合两类场景第一类是大数据块交换。比如视频帧、配置快照、日志文件这种动辄几十KB到几MB的数据如果走管道或消息队列一次传输要拷贝两次大块内存开销极其可观共享内存则只需要一次映射后面读写就是对内存直接操作。第二类是高频小数据包。比如状态心跳包、控制指令虽然单个包可能只有十几个字节但如果每秒发送几万次系统调用的开销就会被放大到不可接受。共享内存配合原子操作或信号量就能做到极低延迟的收发。反过来如果项目里只是偶尔通信一次数据量又小那我建议还是用管道或者套接字原因很简单共享内存需要额外的同步机制开发成本和心智负担都比较高。很多初学者最容易犯的错误就是在不需要高吞吐的地方硬上共享内存结果被并发问题折磨得怀疑人生。共享内存的核心价值就一句话它让两个进程像操作自己的内存一样操作同一块物理内存省掉了所有拷贝和系统调用开销。但省掉开销的同时也把同步责任从内核转嫁给了程序员——这恰恰是后续所有坑的来源。2. 共享内存的内核级原理从页表到物理页2.1 进程地址空间与共享物理页要真正理解共享内存必须回到操作系统的虚拟内存机制。每个进程都有自己的页表把虚拟地址翻译成物理地址。正常情况下A进程虚拟地址0x7f0000000000处的页映射的是物理页P1B进程虚拟地址0x7f0000000000处的页映射的是物理页P2两不相干。共享内存做的事说穿了就一句内核创建了一个物理页集合然后把同一组物理页同时填进A进程和B进程的页表项中。A进程往虚拟地址写数据硬件MMU把虚拟地址翻译成物理地址写进了物理页B进程读取时MMU把同一个物理页翻译到B的虚拟地址上读到的正是A写入的数据。你可以把它想象成教室里挂了一块公共黑板A同学和B同学坐在不同位置但都能看到同一块黑板。A往黑板上写公式B抬头就能看到。不需要A抄一份送到B的座位上——这就是零拷贝的本质。共享内存的实现细节在Linux内核里对应着一个叫shmid_kernel的结构我画不出图但可以描述一下整个生命周期进程调用shmget()申请共享内存时内核在内存中分配一组物理页或者先只建立数据结构等真正访问时再触发缺页异常分配物理页并返回一个shmid作为标识符。进程调用shmat()附加时内核在当前进程的页表中插入指向这些物理页的页表项同时把共享内存的虚拟地址返回给进程。进程此后对这个虚拟地址的读写直接就是对物理内存的操作。当进程调用shmdt()卸载时内核从当前进程页表中摘除这些页表项此后进程再访问这个虚拟地址就会触发段错误。2.2 同一个物理页如何出现在多个进程里关键点在于页表项里存的是物理页帧号不同进程的页表项只要指向同一个物理页帧号就实现了真正意义上的共享。Linux内核里把共享内存实现为文件系统tmpfs上的一个特殊文件这个文件的大小就是共享内存的大小多个进程通过mmap或者在shmat时把这个文件映射到各自地址空间。我遇到过一个很有意思的问题两个进程用共享内存通信A进程写入数据后立刻退出B进程还能不能读到数据答案是能。因为共享内存的生命周期不依赖于创建它的进程只要shmid还存在即没有被shmctl(IPC_RMID)删除物理页就一直被内核维护着B进程照样能访问。这一点和管道完全不同——管道读端一旦关闭数据就没了共享内存只要标记没删除数据一直在。还有一个细节容易被忽略fork()之后子进程会继承父进程已附加的共享内存映射。所以如果你在父进程里先shmat()然后fork()子进程不需要再次shmat()直接就能访问同一个共享内存。这是个很方便的特性但也是个陷阱——如果父子进程同时操作共享内存同步问题必须要处理否则数据错乱分分钟发生。3. 共享内存API的完整使用逻辑四个函数撑起整个机制3.1 key到底是什么共享内存API属于System V IPC家族核心函数只有四个shmget()、shmat()、shmdt()、shmctl()。用之前先弄明白key这个概念。key是一个全局标识用于让互相独立的进程找到同一块共享内存。它有三种来源第一种是IPC_PRIVATE创建时传IPC_PRIVATE作为key内核会生成一个全新的共享内存这种方式创建的共享内存没有固定key其他进程无法通过key找到它只能通过shmid访问。常见做法是先fork()子进程继承映射或者把shmid通过其他IPC方式告诉对方。第二种是ftok()函数。给定一个文件路径和一个项目ID通过文件的inode和设备号计算出唯一key。只要路径相同、项目ID相同不同进程算出的key一定相同这样大家就能通过key找到同一块共享内存。第三种是硬编码一个固定值。比如团队约定key取0x20240101大家直接写死在代码里。这种做法简单粗暴但缺点是多个项目跑在同一台机器上时key容易冲突导致不同应用互相踩到对方的共享内存。key_t key ftok(/tmp/shm_demo, 0x66); if (key -1) { perror(ftok); exit(1); }注意ftok的历史坑如果文件被删除再重建inode会变算出来的key跟着变老代码就可能找不到共享内存。所以ftok要选一个稳定存在的路径别拿临时文件或者会定期清理的日志文件做底子。3.2 shmget、shmat、shmdt、shmctl逐个拆解shmget()用于创建或获取共享内存int shmid shmget(key, 4096, IPC_CREAT | 0666); if (shmid -1) { perror(shmget); exit(1); }第二个参数是共享内存大小单位是字节内核会按页对齐向上取整。第三个参数是标志位IPC_CREAT表示如果不存在就创建存在则直接获取IPC_EXCL和IPC_CREAT一起用如果目标已存在则返回失败防止误用旧资源。权限位0666表示读写权限注意这和文件权限一样实际控制靠进程的有效用户ID。shmat()用于把共享内存挂到当前进程的地址空间void *addr shmat(shmid, NULL, 0); if (addr (void *)-1) { perror(shmat); exit(1); }第二个参数通常传NULL让内核选择合适地址第三个标志位有SHM_RDONLY只读映射和SHM_REMAP强制使用指定地址替换旧映射。返回值是映射后的虚拟地址注意返回值是(void *)-1表示失败不是NULL。shmdt()用于解除映射if (shmdt(addr) -1) { perror(shmdt); exit(1); }这里只解除当前进程的映射共享内存本身还存在于内核中。只有通过shmctl(shmid, IPC_RMID, NULL)才能标记删除这块共享内存。有意思的是IPC_RMID只是打了删除标记真正物理释放要等所有附加它的进程都shmdt()之后才会发生。Linux在进程退出时也会自动解除所有已附加的共享内存所以就算忘了shmdt()进程结束后映射也会被清理但如果你希望进程还在运行时及时释放地址空间还是应该手动shmdt()。shmctl()除了IPC_RMID之外还能取共享内存信息和设置属性struct shmid_ds buf; shmctl(shmid, IPC_STAT, buf); printf(size: %zu\n, buf.shm_segsz); printf(attach count: %lu\n, buf.shm_nattch);3.3 完整生命周期代码演示我把整个生命周期串成一段能跑的小例子#include stdio.h #include stdlib.h #include string.h #include sys/ipc.h #include sys/shm.h #include unistd.h int main() { key_t key ftok(/tmp/shm_demo, 0x66); int shmid shmget(key, 4096, IPC_CREAT | 0666); if (shmid -1) { perror(shmget); exit(1); } char *addr shmat(shmid, NULL, 0); if (addr (void *)-1) { perror(shmat); exit(1); } strcpy(addr, hello shared memory); printf(parent wrote: %s\n, addr); if (shmdt(addr) -1) { perror(shmdt); exit(1); } shmctl(shmid, IPC_RMID, NULL); return 0; }4. 生产者-消费者实例带信号量同步的完整代码4.1 为什么单靠共享内存不够必须配合同步机制相信我这是本篇文章最关键的一个岔路口。共享内存解决了“数据怎么到对方手里”的问题但完全没解决“数据什么时候到”和“对方读的时候数据写完了没有”的问题。举个例子两个进程共享一个整数变量counter生产者进程每次把counter加1消费者进程每次把counter打印出来。从汇编层面看counter不是一条指令是“读内存到寄存器、寄存器加1、写回内存”三步。生产者刚读完counter还没来得及写回消费者也读了一次旧值两边同时操作数据就乱了。解决办法是老搭档信号量。System V信号量或者POSIX信号量都行我把后者写进项目里因为它API更简单。4.2 完整源码与执行演示下面这个demo我用POSIX有名信号量做同步模拟传统生产者消费者模型。共享内存里放一个环形缓冲区生产者往里面放整数消费者从里面取整数。#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include sys/mman.h #include sys/ipc.h #include sys/shm.h #include semaphore.h #include unistd.h #include sys/wait.h #define BUF_SIZE 8 #define LOOP_CNT 20 struct ring_buf { int head; int tail; int data[BUF_SIZE]; }; int main() { key_t key ftok(/tmp/shm_ring, 0x99); int shmid shmget(key, sizeof(struct ring_buf), IPC_CREAT | 0666); if (shmid -1) { perror(shmget); exit(1); } struct ring_buf *rb shmat(shmid, NULL, 0); if (rb (void *)-1) { perror(shmat); exit(1); } memset(rb, 0, sizeof(struct ring_buf)); sem_t *empty sem_open(/shm_ring_empty, O_CREAT, 0666, BUF_SIZE); sem_t *full sem_open(/shm_ring_full, O_CREAT, 0666, 0); sem_t *mutex sem_open(/shm_ring_mutex, O_CREAT, 0666, 1); if (empty SEM_FAILED || full SEM_FAILED || mutex SEM_FAILED) { perror(sem_open); exit(1); } pid_t pid fork(); if (pid 0) { // 消费者子进程 for (int i 0; i LOOP_CNT; i) { sem_wait(full); sem_wait(mutex); int val rb-data[rb-tail]; rb-tail (rb-tail 1) % BUF_SIZE; sem_post(mutex); sem_post(empty); printf(consumer read: %d\n, val); } exit(0); } // 生产者父进程 for (int i 0; i LOOP_CNT; i) { sem_wait(empty); sem_wait(mutex); rb-data[rb-head] i; rb-head (rb-head 1) % BUF_SIZE; sem_post(mutex); sem_post(full); printf(producer wrote: %d\n, i); } wait(NULL); shmdt(rb); shmctl(shmid, IPC_RMID, NULL); sem_unlink(/shm_ring_empty); sem_unlink(/shm_ring_full); sem_unlink(/shm_ring_mutex); return 0; }编译运行gcc -o shm_ring shm_ring.c -lrt -lpthread ./shm_ring三个信号量的分工是empty计数缓冲区剩余空间full计数缓冲区已有数据mutex保证同一时刻只有一个进程操作环形缓冲区的head/tail。生产者先等empty有空间才写再拿mutex操作指针消费者先等full有数据才读再拿mutex。这样生产者和消费者自然互斥又不会死锁。进程退出前sem_unlink清理信号量共享内存用shmctl删除。这里要注意信号量是内核对象进程退出后如果不清理内核里会残留命名信号量再次运行sem_open时如果不加O_EXCL会拿到旧状态造成诡异bug。4.3 环形缓冲区设计的优点与职责划分为什么用环形数组而不是先写入再读出因为环形数组天然支持无锁的生产者和消费者协调只要保证生产者和消费者在同一时刻操作不同元素即使不用mutex只在head和tail指针的读写上做原子操作也能工作。当然这里为了简化演示直接上了互斥锁先把正确性保证住。真实场景中环形缓冲区的魅力在于它不会导致生产者和消费者互相等待对方“结束”——生产者不会持续把缓冲填满因为消费者一直在消费消费者也不会空转因为它要等full信号量。两者形成一个动态平衡吞吐量由慢的一方决定。共享内存的职责是“运输数据”信号量的职责是“同步节奏”。前者是卡车后者是交警。很多新手只上卡车不上交警结果两辆卡车直接撞上了。5. 共享内存排错实录那些让我掉进去的坑5.1 最隐蔽的坑shmat的返回值和段错误我见过最多的问题长这样shmat返回了某个地址程序就直接往里写数据一跑就段错误。为什么因为shmap失败时返回值是(void *)-1如果你直接把它强转成char *用-1在用户态地址空间中也是个非法地址一访问就崩。正确的写法必须检查返回值void *addr shmat(shmid, NULL, 0); if (addr (void *)-1) { perror(shmat); exit(1); }同时shmat失败时一定要看errno。最常见的EINVAL是因为shmid不存在或者映射大小非法EACCES是权限不足比如只有读权限却试图写ENOMEM是虚拟地址空间不够。今年我在服务器上排查过一个诡异问题shmat老返回失败一查原因是有个守护进程疯狂fork系统vm.max_map_count被耗尽根本映射不了新的内存段。先把系统配额的坑排除再排查代码逻辑这个顺序不能反。5.2 误用IPC_EXCL造成的重复创建竞争shmget(key, size, IPC_CREAT | IPC_EXCL | 0666)的语义是“如果key对应的共享内存已经存在就报错返回”。这个逻辑本身很清晰但多进程并发启动时就麻烦了两个进程同时启动都想创建共享内存一个成功一个失败。如果你没处理失败的逻辑就直接以EEXIST退出那就成了bug。正确做法是分两步先尝试shmget(key, size, IPC_CREAT | IPC_EXCL)创建如果失败且errno是EEXIST再用不带IPC_CREAT或带IPC_CREAT不带EXCL的方式获取已有句柄。典型代码int shmid shmget(key, size, IPC_CREAT | IPC_EXCL | 0666); if (shmid -1 errno EEXIST) { shmid shmget(key, size, 0666); } if (shmid -1) { perror(shmget); exit(1); }很多人图省事永远只写IPC_CREAT那也会有另一种坑如果老进程创建的共享内存size比新进程期望的小新进程调用shmat时就会因为请求超过实际大小而映射失败。所以共享内存的size必须在所有进程里统一约定任何一方改大改小都要同步更新。5.3 没有同步时的“脏读”现象共享内存如果不加同步最典型的表现就是“数据看起来是乱码或者明明写了一次读出来却变来变去”。举个例子两个进程各写一个结构体到同一个地址A进程每隔10毫秒写一次B进程每隔10毫秒读一次。只要A写了一半B读到一半读出来的结构体就是半新半旧字段组合完全对不上。这种问题靠volatile解决不了因为volatile只保证每次访问都从内存地址读不保证多核之间缓存的可见性。真正的解法只有同步互斥锁、信号量、原子操作、内存栅栏四选一。在我的实践里简单场景用POSIX互斥锁就够了高频场景用无锁环形队列加原子变量再高频就只能上seqlock或者RCU思路了。排查这种脏读问题时我常用的手段是在共享内存结构体里加一个校验字段比如uint32_t magic和uint32_t seq生产者写入前设置magic为固定值写完数据后递增seq消费者读取时先读magic再读seq读两次seq比较是否相同。如果两次seq不一致说明数据在读取过程中被改写了。这个技巧能快速确认同步缺失问题比对着内存一顿找快得多。5.4 资源清理的漏网之鱼平时开发调试时最容易遇到的问题是程序崩溃退出共享内存没删再次启动时拿到的共享内存是上一次残留的数据。你可以用ipcs -m查看当前系统里所有共享内存段用ipcrm -m shmid手动删除。我自己的习惯是在程序入口处统一加一段“清理残留”的逻辑int shmid shmget(key, size, IPC_CREAT | 0666); if (shmid -1) { perror(shmget); exit(1); } shmctl(shmid, IPC_RMID, NULL);注意这个顺序不能乱。先shmget拿到旧段然后立刻IPC_RMID把它标记删除最后再重新shmget创建新段。如果你反过来先删后用或者只删不建下一行shmget可能直接拿不到段。这个清理大法在写演示程序时非常有用能避免循环调试时反复手动删段。6. 把共享内存用好调优与工程实践建议6.1 系统限额的调整为什么明明大页内存够却shmget失败Linux内核默认对System V共享内存的大小和个数有限制。几个关键参数kernel.shmmax单个共享内存段允许的最大字节数默认值在x86_64上常为18446744073709551615几乎无限但有些发行版会设成很小的值。kernel.shmall系统范围内可用于共享内存的总页数。kernel.shmmni系统共享内存段的最大个数。如果shmget调用时失败且errno为ENOMEM或者EINVAL先查这三个值sysctl kernel.shmmax kernel.shmall kernel.shmmni临时修改sysctl -w kernel.shmmax4294967296永久修改的话写入/etc/sysctl.conf然后sysctl -p生效。有个常见的坑是数据库系统比如PostgreSQL安装时要求调整kernel.shmmax但调整后其他应用创建的共享内存段也共享这个上限如果你的单个段请求超过了系统限制shmget直接返回EINVAL。排查这类问题不要先怀疑代码先看系统参数。6.2 开启大页共享内存的性能还能再上一个台阶共享内存最怕的是什么缺页。首次访问共享内存每个页时硬件必须触发缺页异常把物理页从磁盘或交换空间里读进来这是有开销的。用大页HugePage可以减少页表项数量降低TLB miss尤其在共享内存非常大几百MB甚至几GB时效果明显。在shmget时传SHM_HUGETLB标志前提是系统配置了足够的HugePagessysctl vm.nr_hugepages512int shmid shmget(key, 256 * 1024 * 1024, IPC_CREAT | SHM_HUGETLB | 0666);注意大页的分配是预留的如果系统没有配置足够多的预留页shmget会失败。我在压测CPU密集型共享内存程序时测过一次2MB块读写的吞吐提升了约3到5个百分点延迟略有下降效果能感觉到但不算夸张。如果你的共享内存段只有几十KB就别折腾大页了收益微小还增加配置复杂度。6.3 什么时候你该放弃共享内存换别的方案这个话可能和主题唱反调但在工程里这是最重要的判断。共享内存最擅长的是“多进程高频读写同一块数据”但它有不可忽视的短板第一调试困难。共享内存里的数据是内存快照进程退出后如果没清理你看到的是上一次运行残留的数据很容易产生“我明明没写这个值为什么读出来是这样”的幻觉。没有好的调试工具排查并发问题像在黑暗中摸象。第二跨机器无法使用。共享内存只适用于单机多进程。一旦业务需要跨网络通信你还是得上Socket、gRPC或者消息队列。不要为了共享内存而共享内存把系统架构强行限制在单机多进程里。第三可靠性和可观测性差。管道和消息队列的内核实现自带流量控制和缓冲共享内存则需要自己处理所有异常情况——生产者崩溃了怎么办消费者怎么知道这都需要额外的握手协议来维护状态。我的建议是新版代码优先考虑mmap匿名映射配合fork()它能做到和共享内存几乎一样的性能但生命周期管理更清爽很多现代项目都改用这种方案了。System V共享内存适合的老项目以及需要非常明确持久化语义进程退出后数据还在的场景。7. 最后再分享一个排查利器调试共享内存时我最常用的三个命令放在最后给大家真遇到疑难杂症时能救急ipcs -m查看系统共享内存段列表ipcrm -m shmid删除指定段cat /proc/sysvipc/shm查看更详细的内核视图。另外GDB里有一个很有用的命令直接查看某个shmid对应的映射情况——在gdb命令行里执行maintenance info sections或者info files看有没有异常映射段。如果程序段错误发生在某个随机地址先用bt看调用栈再用x/16gx 地址看看那段内存到底是个什么状态——有时候你用x当二进制读出来的就是生产者写入的明文数据那基本可以确认是共享内存映射和访问不同步的问题。这串排查流程我用了好多年几乎每次都能定位到问题。共享内存这玩意代码写起来不难难的是出了问题之后能快速判断是同步问题、映射问题还是系统配置问题。把这篇文章里的代码和思路吃透Linux下的进程间通信你已经能应付绝大多数实战场景了。