资讯详情

Linux共享内存深度解析:从零拷贝原理到System V/POSIX实战

📅 2026/10/9 8:26:45 | 华诺云谱 👁 阅读
Linux共享内存深度解析:从零拷贝原理到System V/POSIX实战
有一类问题几乎每个做多进程开发的工程师都会撞上进程之间到底怎么高效传数据管道、消息队列、Socket都试过要么延迟高要么拷贝多要么代码啰嗦。我不止一次跟人讲如果场景是“两个进程频繁读写同一块数据”先别急着上复杂网络协议共享内存Shared Memory是Linux下IPC里性能最顶的一条路。这不是说共享内存是银弹而是它把数据拷贝降到最低在合理的并发控制下吞吐能跑到普通消息队列完全追不上的量级。这篇文章我会从原理讲起落到System V与POSIX两套真实接口的代码细节再聊同步、排错和性能优化适合正在做嵌入式、后端服务、中间件开发的工程师参考。1. 为什么共享内存是Linux IPC性能天花板原理深度拆解1.1 从数据拷贝次数看IPC本质差异假设进程A要把一段1KB的数据发给进程B。如果用管道内核需要把A用户态的数据copy到内核缓冲区B再从内核缓冲区copy回自己的用户态空间全程至少两次拷贝。Socket也差不多应用层还得处理粘包分包。消息队列稍微好一点但发送者用户态到内核队列一次、内核队列到接收者用户态一次仍然免不了两次搬运。共享内存的做法完全不同两个进程的虚拟地址空间里某些页经过各自的页表直接映射到同一组物理内存页。进程A往自己的虚拟地址写实质上就是在写那块物理内存进程B在自己的虚拟地址读立刻就能看到。整个传输过程内核不参与数据搬运零拷贝。这个区别类比一下就像两栋楼的住户经过物业协调打通了一个公用储物间A把东西放进去B转身就能取不需要物业每次帮忙中转。当然没有物业中转也意味着东西什么时候放、什么时候取得A和B自己商量好——对应到技术上就是信号量、锁或者原子操作。这里要澄清一个常见误解共享内存并不是“完全不需要内核”。创建段、建立映射、删除段这些操作仍然需要系统调用只是映射建立好之后的数据读写再不需要内核在中间当搬运工。也就是说省掉的是高频路径上的两次拷贝而不是一次性的管理开销。1.2 页表映射同一物理页如何在多个进程间共享每个进程都有一张独立的页表负责把虚拟地址翻译成物理地址。共享内存的本质是在不同进程的页表里插入指向同一物理页面的页表项。当B进程第一次访问映射区域时可能触发缺页异常内核把已存在的物理页填充到B的页表中并把页表项标记为共享状态。这之后两个进程对该物理页的访问全部交给硬件MMU翻译不需要再进内核做任何转发。这个机制和父子进程的COW写时复制完全是两种思路。fork的COW是把父子进程的地址空间初始指向同一页但只要任何一方写就会触发复制各自得到独立页。而共享内存是主动、持续共享同一页双方都希望看到彼此的最新数据。理解了这一点你会明白为什么共享内存的延迟可以低到亚微秒级——因为数据本来就在那里没有复制没有切换只需要保证读写顺序别打架就行。还有一个很容易困惑的点不同进程可以用不同的虚拟地址去访问同一个物理页。所以你在程序里从shmat或mmap拿到的指针地址不需要在两个进程里保持一致。关键是页表项最终指向的物理页一致虚拟地址本身完全可以各用各的。1.3 管道、消息队列、Socket与共享内存选型对照表IPC方式典型数据拷贝次数数据语义性能特点典型场景管道Pipe至少2次流式、方向固定简单可靠、阻塞语义清晰父子进程间单向流传输消息队列msg至少2次带类型、按序存取内核管理、消息边界明确小消息、多对多低频率通信Unix Socket至少2次流式/报文、全双工通用性强、支持跨主机扩展同一主机或不同主机进程通信共享内存0次数据读写类似黑板、随机访问延迟最低、吞吐最大高频大数据量、进程间共享状态我做过一个粗略压测双向各传4KB数据Unix Socket单个来回大约5-8微秒System V共享内存配合信号量同步大约1-2微秒。吞吐上的差距更夸张因为Socket每次都要经过系统调用和拷贝而共享内存写好同步后几乎等于纯内存访问。当然基准测试环境不同结果会有差异但这个量级差距在绝大多数机器上都是成立的。不过要注意共享内存并不适合做“流式大消息按序到达”的传递它的语义更像一块挂墙上的黑板谁都能读写。如果系统需要严格的收发同步、按消息边界消费你还得在共享内存之上自己实现环形队列或者消息头描述结构。2. System V共享内存四件套API与完整可运行示例2.1 shmget/shmat/shmdt/shmctl 参数与行为详解System V共享内存接口是UNIX老牌设计从上世纪80年代沿用至今大量商业中间件、数据库都在用。核心只有四个函数shmget(key_t key, size_t size, int shmflg)创建或获取一个共享内存段。key是全局标识类似文件名不同进程用同一个key就能找到同一段。size是段大小按页对齐最佳。shmflg常用IPC_CREAT|0666如果已经存在且你想报错可以再加上IPC_EXCL。shmat(int shmid, const void *shmaddr, int shmflg)把共享内存段附加到当前进程的虚拟地址空间。shmaddr传NULL表示由内核选择地址这是最省事的用法。shmflg通常传0。返回值是需要强转的void指针失败返回(void *)-1。shmdt(const void *shmaddr)把共享内存段从进程地址空间分离。注意分离不等于删除它只是断开当前进程和物理页的映射关系。shmctl(int shmid, int cmd, struct shmid_ds *buf)控制操作。最常用的是IPC_RMID删除段以及IPC_STAT获取属性。新手最容易犯的错误是以为shmget返回的shmid就能直接读写。实际上shmid只是一个标识符真正能读写的是shmat返回的映射地址。你不把这个关系理清后面写代码一定会晕。2.2 写端与读端完整代码演练先写一个发送端程序它负责创建共享内存段、附加到地址空间、写入字符串然后保持3秒让读端有机会读取。#include sys/ipc.h #include sys/shm.h #include stdio.h #include string.h #include unistd.h int main() { key_t key 1234; int shmid shmget(key, 4096, IPC_CREAT | 0666); if (shmid 0) { perror(shmget); return 1; } char *addr (char *)shmat(shmid, NULL, 0); if (addr (char *)-1) { perror(shmat); return 1; } const char *msg hello, shared memory!; strcpy(addr, msg); printf([writer] 已写入: %s\n, msg); sleep(3); shmdt(addr); return 0; }接收端程序不创建段只根据key获取已有的shmid并附加读取。#include sys/ipc.h #include sys/shm.h #include stdio.h int main() { key_t key 1234; int shmid shmget(key, 4096, 0666); if (shmid 0) { perror(shmget); return 1; } char *addr (char *)shmat(shmid, NULL, 0); if (addr (char *)-1) { perror(shmat); return 1; } printf([reader] 读出: %s\n, addr); shmdt(addr); return 0; }编译与运行gcc writer.c -o writer gcc reader.c -o reader ./writer # 在另一个终端或几秒内执行 ./reader这段代码能跑通但我在注释里已经提到了它没有同步机制sleep(3)只是为了演示时序。真实项目里读写双方的调度完全无序读端可能在写端strcpy完成前就去读取轻则读到不完整数据重则直接崩溃。所以先把这个例子理解透后面再补上同步。2.3 共享内存段的清理与生命周期管理共享内存段被所有进程shmdt之后内核不会自动回收。它就像持久化的文件一直存在直到某个进程调用shmctl(shmid, IPC_RMID, NULL)主动删除或者系统重启。应用退出前如果不删除就会泄漏时间久了系统里堆满一堆无人使用的共享内存段甚至导致新的shmget失败。排查和维护时最常用的就是ipcs和ipcrm命令。想列出所有共享内存段ipcs -m以列表形式看------ Shared Memory Segments -------- key shmid owner perms bytes nattch status 0x000004d2 32769 user 666 4096 0想删除指定shmid的段ipcrm -m 32769也可以用key删除这在守护进程里比较省事因为你只需要知道约定的key不需要去查动态的shmidipcrm -M 0x000004d2有一个Linux特有的行为容易踩坑调用IPC_RMID并不要求所有进程都已经detach。Linux允许在仍有进程attach的情况下打上删除标记等所有进程都detach之后才真正释放物理内存。也就是说调用IPC_RMID之后已经attach的进程仍然可以继续读写只是新的shmat会失败。如果你从Windows移植代码这个概念差异一定要留意Windows命名共享内存通常是立即失效的而Linux是延迟回收。3. POSIX共享内存基于文件描述符的现代方案3.1 shm_open ftruncate mmap 的三步走POSIX共享内存提供了另一套API设计上把共享内存对象抽象成“文件系统里的对象”整个使用过程更像“打开文件、设置大小、映射内存”三部曲。shm_open(const char *name, int oflag, mode_t mode)负责创建或打开共享内存对象。name必须以斜杠开头例如/my_shm且中间不能再有斜杠。这和System V的整型key不同它更像路径名——实际也真的挂在tmpfs文件系统下典型挂载点是/dev/shm。你可以用df -h /dev/shm看到它的容量上限这个容量决定了你最多能开多大的共享对象。shm_open返回的是普通文件描述符fd不是shmid。接下来要用ftruncate(fd, size)把对象大小设置为目标字节数再通过mmap把对象内容映射到进程地址空间。源代码写端#include fcntl.h #include sys/mman.h #include sys/stat.h #include string.h #include stdio.h #include unistd.h int main() { int fd shm_open(/my_shm, O_CREAT | O_RDWR, 0666); if (fd 0) { perror(shm_open); return 1; } if (ftruncate(fd, 4096) 0) { perror(ftruncate); return 1; } void *addr mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (addr MAP_FAILED) { perror(mmap); return 1; } strcpy((char *)addr, hello posix shm); printf([writer] 已写入\n); sleep(3); munmap(addr, 4096); close(fd); return 0; }这里最容易踩的坑是忘记ftruncate。shm_open创建的是一个大小为0的空对象如果你直接mmap并且引用内容访问时会SIGSEGV。先设置大小再映射顺序不能反。编译时注意老版本的glibc需要加-lrt库gcc writer.c -o writer -lrt从glibc 2.34开始librt已经并入libc不加也能过。为了兼容老环境写上-lrt也不碍事。3.2 POSIX共享内存读端实现与注意事项读端的逻辑更简单用shm_open打开同一个名字不需要O_CREAT然后mmap映射读取。#include fcntl.h #include sys/mman.h #include sys/stat.h #include stdio.h #include unistd.h int main() { int fd shm_open(/my_shm, O_RDWR, 0666); if (fd 0) { perror(shm_open); return 1; } void *addr mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (addr MAP_FAILED) { perror(mmap); return 1; } printf([reader] 读出: %s\n, (char *)addr); munmap(addr, 4096); close(fd); return 0; }读端需要指定映射长度我这里的例子固定用4096。如果业务数据大小是动态的建议在共享对象的前几个字节里存一个“数据长度”字段读取端先读长度再按长度拉数据。否则两边长度约定不一致很容易读到脏数据或者越界。munmap只是解除映射对象本身仍然存在于/dev/shm下。想彻底删除对象要调用shm_unlink(/my_shm)。也有人直接rm /dev/shm/my_shm因为对象本来就是tmpfs文件两者效果类似但生产代码里更推荐shm_unlink因为语义明确、可移植性好。3.3 System V还是POSIX实际项目中的选型心得我也被问过很多次“到底用哪套接口”给不出绝对答案但可以分享几条实际经验老代码和嵌入式环境System V四件套出现频率极高。很多老牌中间件、嵌入式Linux厂商的BSP里还是shmget系列API历史悠久行为稳定。如果你在维护这类项目别为了赶时髦强行改POSIX。新项目、想跟现代Linux生态对齐就选POSIX。原因是对象名是字符串有文件描述符语义配合poll/epoll/eventfd都很顺而且/dev/shm天然就在tmpfs里性能完全够。操作系统层面也更愿意为POSIX共享内存做优化比如pidfd和fd传递机制配合得更好。容器环境要警惕。shm_open默认依赖/dev/shm容器如果把这个目录映射成小容量或者禁用了IPC资源shm_open和mmap就可能失败。System V的shmget走内核自己的IPC namespace限制维度不一样。部署到容器前最好先验证/dev/shm容量和权限。我这里要坦白一个“混搭”的现状实际项目里可能一边用POSIX共享内存传数据一边用System V信号量做同步。两者都是内核对象混用不冲突只是维护时容易精神分裂。新项目我还是强烈建议统一到POSIX全套至少代码风格更一致。4. 同步与并发控制共享内存的数据安全关键4.1 为什么共享内存自己无法保证有序性共享内存是一块“黑板”进程A写入的顺序与进程B读取的顺序之间没有任何天然保证。假设A先写length10再写内容而B在A刚写完length但还没写完内容时去读取就会拿到“length10但内容还是旧值”这样的残缺状态。更隐蔽的是处理器和编译器的指令重排。在C语言层面你写的代码是先写length再写content但编译器可能把顺序反过来CPU也可能乱序执行。在现代多核架构下不同核之间的可见性也不是立即同步的没有内存屏障的话B看到的可能是乱序后的结果。所以共享内存必须搭配一套“可见性顺序”机制。可选方案有四类System V信号量semget/semop跨进程最经典语义稳定。POSIX无名信号量sem_init配合pshared1需要所有进程都能访问同一共享内存。pthread互斥锁设置PTHREAD_PROCESS_SHARED属性不算最常见但可行性能也还可以。C11原子操作加内存屏障适合单写单读的无锁场景最灵活也最难写对。4.2 基于信号量的互斥访问完整示例这里用一个System V信号量的互斥信号量来保护共享内存的写入属于最容易理解的生产级起点。先看写端#include sys/sem.h #include sys/shm.h #include sys/ipc.h #include stdio.h #include string.h #include unistd.h union semun { int val; struct semid_ds *buf; unsigned short *array; }; static void sem_set(int semid, int val) { union semun su; su.val val; semctl(semid, 0, SETVAL, su); } static void sem_p(int semid) { struct sembuf op {0, -1, 0}; semop(semid, op, 1); } static void sem_v(int semid) { struct sembuf op {0, 1, 0}; semop(semid, op, 1); } int main() { key_t shmkey 1234; key_t semkey 5678; int shmid shmget(shmkey, 4096, IPC_CREAT | 0666); int semid semget(semkey, 1, IPC_CREAT | 0666); if (shmid 0 || semid 0) { perror(get resource); return 1; } sem_set(semid, 1); // 初始为1等价于互斥锁可用 char *addr (char *)shmat(shmid, NULL, 0); if (addr (char *)-1) { perror(shmat); return 1; } sem_p(semid); strcpy(addr, sync write ok); sleep(2); // 模拟临界区工作 sem_v(semid); shmdt(addr); return 0; }读端拿同样的key获取信号量读取共享区内容这个模式相信你看一遍就能改出来shmat后sem_p、读addr、sem_v、shmdt。几个关键细节必须强调semget只创建信号量集合不会自动初始化信号值。默认值是0如果忘了用semctl的SETVAL把初值设为1那么所有sem_p都会永远阻塞。这个问题非常隐蔽因为代码能编译、能启动但进程就是卡在semop上。互斥信号量能防止同时写入/读取冲突但它不能解决“数据是否就绪”的问题。生产者消费者模式里光靠一把互斥锁还不够通常还需要一个表示“槽位是否可用”的信号量和一个表示“是否有新数据”的信号量。信号量在进程崩溃时不会自动恢复。如果写进程在sem_p之后、sem_v之前崩溃信号量就永远停在0所有读进程都会卡死。生产级做法是在信号量关联的结构里记录持有者的PID和时间戳新进程发现持有者已经不存在时主动semctl重置。这个办法不优雅但在没有更可靠故障检测机制的环境下总比永久卡死强。4.3 锁粒度、无锁设计与性能平衡共享内存的优势是零拷贝但如果同步粒度太粗把整个共享内存区一把锁锁住性能会直线下滑。我之前看过一个项目两个进程通过共享内存传日志数据数据量只有几千字节却在每次写入时都拿全区域全局锁结果实测吞吐比Unix Socket还差。原因很简单Socket的内核帮你把同步调度做了错开得很干净而共享内存的全局锁会让所有读写等待临界区结束竞争一大就开始排队。更合理的做法按槽位分锁。把共享内存划分成N个固定大小的槽位每个槽位有一把锁。消费者和生产者一次只操作一个槽位竞争范围变小吞吐自然上来。批量写入。攒一批数据一次性拿锁、写共享区、释放锁减少拿锁次数。拿锁本身有系统调用开销把N次小写入合并成一次大写入性能提升非常明显。无锁环形队列。单生产者单消费者的SPSC场景下根本不需要互斥锁只用原子操作维护读写索引即可。数据生产者写完数据后用atomic_store发布索引消费者用atomic_load读取索引。配合内存屏障延迟能压到几百纳秒以下。临界区里的操作要尽量短。如果要在共享内存里传一段字符串最好的姿势是先在本地组装好完整数据拿锁、写共享区、释放锁。不要拿锁以后再逐字节拼接那样锁的持有时间会拖垮整个系统。5. 实战踩坑录排查、运维与性能优化经验5.1 ipcs与ipcrm共享内存的运维基本功遇到共享内存问题我第一件事永远是执行ipcs -m这个命令能把系统里所有共享内存段列得清清楚楚。我需要重点看几个列key是否与程序约定的key一致shmid是否和日志里出现的一致nattch表示当前附加进程数。如果nattch长期为0说明没有任何进程在用这个段基本可以清理。status列如果出现dest字样说明这个段已经被标记删除但还有进程attach着要等所有进程detach后才会真正释放。这种情况下别急着反复创建同名段先排查是不是有进程没退出。清理命令ipcrm -m 32769POSIX共享内存这边没有专门的ipcs对应但你可以直接看/dev/shm目录ls -l /dev/shm rm /dev/shm/my_shm因为/dev/shm就是tmpfs删除文件就能达到shm_unlink的效果。不过正式代码里还是应该调用shm_unlink别依赖手工清理。5.2 高频问题速查表与应对方案问题现象可能原因解决办法shmget返回ENOSPC系统共享内存段数量或总大小达到上限ipcs -m查看残留段调整kernel.shmmax等参数代码内删除无用段shmat返回EACCES段权限不足检查shmget的0666权限位确认两个进程在同一uid或组下读端读到乱码或旧数据缺少同步读写竞争引入信号量、互斥锁或原子操作POSIX共享内存mmap后SIGSEGVftruncate没设置大小或映射长度不匹配确保ftruncate先于mmap两者大小对齐容器里shm_open失败/dev/shm容量过小或挂载异常docker run加--shm-size参数检查挂载状态共享内存段泄漏代码退出前未IPC_RMID或shm_unlink做好清理流程明确谁创建谁删除nattch一直大于0某个进程没有shmdt用gdb或排查卡死的调用链这里再补充一个我自己踩过的坑共享内存泄漏不是一次就能查出来的。曾经有个守护进程每次启动都创建一个新共享内存段但从不删除用了几天后ipcs -m列出来几百个段新进程shmget直接ENOSPC。排查了半天才发现是框架里的一个模块没有处理退出钩子的清理逻辑。从那以后我要求所有共享内存代码都必须有明确的“谁创建谁删除”契约宁可创建失败也不允许静默泄漏。5.3 实测性能优化从页面对齐到NUMA感知合并几轮压测和线上调试的经验总结出几条实实在在的优化策略。共享内存size尽量按页对齐。Linux默认页大小是4096字节mmap和shmget按页映射。你需要5000字节就开到8192字节宁可浪费一点也不要造成“差几个字节就多个单独映射”的尴尬。对齐有利于TLB命中率减少页表遍历开销。初始化阶段就把共享内存触达。创建段之后写进程可以手动对整个区域memset一遍把物理页一次性申请好。这样后面运行时首次访问不会触发缺页异常延迟更稳定。POSIX侧可以用madvise(MADV_WILLNEED)提示内核但我实测下来直接memset最省心效果也最可控。跨NUMA节点时一定要关注内存本地性。如果共享内存的物理页落在node0写进程在node0上读进程却在node1上跨节点访问延迟会明显上升。我在一台上机器上跑过一个高负载推理服务共享内存没绑NUMA时吞吐掉了20%左右后来用numactl把相关进程绑到同一NUMA节点性能立刻回来了。如果你的服务是CPU密集型的这一步优化收益可能比调整锁粒度还大。进程崩溃恢复要提前写进设计。前面提过信号量不会自动释放这里再补充一个更实用的做法在共享内存的头部结构里维护一个“活跃进程表”定期写心跳时间戳。其他进程发现持有者心跳超时就主动执行清理并重新初始化。这套机制在工程上比依赖操作系统信号量异常处理要可靠得多。最后一个建议和性能无关但决定你的代码能不能活过线上大促共享内存里的数据结构和协议一定要有版本号。我见过两个服务因为共享内存结构体升级一个改了字段长度另一个没改结果互相把对方数据读碎。加个version字段不匹配就拒绝互操作这比任何调试技巧都省心。末尾分享一点个人体会。共享内存虽然性能好但它把很多本来由内核帮你兜底的事情——同步、生命周期、崩溃恢复——都交给用户态了所以它并不比Socket“简单”。我最开始用共享内存时踩过不止一次坑最典型的就是忘记在进程退出时删除共享段结果线上系统隔几天就报“无法创建共享内存段”。现在我做共享内存方案一定会先把四个问题想清楚谁创建、谁删除、谁持锁、崩溃怎么恢复。想清楚这四件事再动手写代码后面基本不会出大乱子。如果你正准备在自己的服务里引入共享内存建议先从一个最简单的读写进程对开始把数据结构、同步原语和清理流程全部跑通再谈优化吞吐你会发现这是一把真正可靠、而且好用的快刀。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑