尧图精选

System V共享内存详解:从零拷贝原理到API实战

🕒 发布时间:2026/10/1 18:34:15 📁 来源:尧图网络
做 Linux 系统下的服务端开发迟早会撞上多个进程怎么高效传数据这道坎。管道笨、消息队列重、socket 远——你要么看着数据在内核里被拷来拷去要么就干脆把同一块物理内存同时映射给两个进程后者就是进程间通信里最出名的那张王牌共享内存。System V IPC 体系里的共享内存接口shmget / shmat / shmdt / shmctl从早期 Unix 时代活到了今天依然是数据库内核、消息中间件和高性能网关里的常客。这篇文章我会从零讲透 System V 共享内存底层原理、四个核心 API 的用法和坑、一个能直接跑的双进程示例再把权限、内核参数和一线排查经验一并交代清楚。无论是刚开始学 Linux 进程间通信的新手还是要在生产环境里评估 IPC 方案的工程师都能从这里拿到可以直接用的东西。1. 为什么偏偏是共享内存IPC 家族里的零拷贝路线1.1 先摆平 IPC 选型的底层逻辑进程间通信在 Linux 上常见的几条路线匿名管道/命名管道、System V 消息队列、共享内存 信号量、Unix domain socket、网络 socket。这些路线的本质差异其实就一句话数据需不需要经过内核搬运。管道是 write 到内核缓冲区read 再从内核缓冲区拷回用户态消息队列也一样只是多了按 mtype 分类取消息的粒度socket 更不用提协议栈处理一层叠一层。共享内存则完全不同你在用户态往某个地址写一个 int另一个进程从同一物理页映射过来的地址直接读中间没有任何一次系统调用介入搬数据。正因为这样共享内存在延迟和吞吐两个维度上都明显领先。它不解决什么时候数据算读完的问题——那是同步机制的事——但单论把数据从 A 进程搬到 B 进程这个动作共享内存就是 IPC 里的天花板。IPC 方式是否需要内核拷贝典型延迟量级适合场景匿名管道需要用户态到内核态再到用户态微秒级偏上父子进程简单流式传输System V 消息队列需要按消息拷贝略高于管道按类型取消息的异步场景Unix domain socket需要中等本机客户端/服务端兼容网络编程习惯共享内存不需要亚微秒级大量数据高频互访、低延迟实测里单机进程间通过共享内存搬运 1MB 数据比走管道通常能快一个数量级CPU 占用还更低。这不是玄学是零拷贝三个字换来的硬收益。1.2 一张物理页多张页表底层原理其实很直接物理内存只有一份每个进程的页表各自维护虚拟地址 - 物理页的映射。正常情况下两个进程的页表各指各的物理页共享内存呢就是内核对同一组物理页同时登记进了多个进程的页表。用大白话讲就像一栋楼里有两户人家各有一扇门门后其实是同一个房间。A 在房间放个笔记本B 穿过自己的门也能看到这本子不需要有人搬着本子在两扇门之间跑。这就是零拷贝的直观含义。System V 共享内存段在内核里有自己独立的 id 空间和引用计数所以它不会跟着某个进程退出就自动销毁。这带来两个特性一是共享内存段能活得很久适合放长生命周期的数据二是你必须显式管理它的生命周期忘了清理就是实打实的内存泄漏。1.3 共享内存的边界速度的代价是没人管同步零拷贝是最大优势代价也藏在这两个进程同时读写同一块内存如果不做任何同步数据错乱只是时间问题。共享内存本身没有 lock、没有条件变量、没有消息边界。你要么自己用信号量保护临界区要么用原子操作配合内存屏障要么接受单写多读这种天然安全的模式。所以业内实际用法几乎都是共享内存 信号量绑在一起共享内存负责搬运数据信号量负责什么时候可以读、什么时候可以写。这套组合拳我在第 5 节专门讲。2. 四个 API 讲透 System V 共享内存的全貌System V 共享内存总共只有四个核心接口shmget、shmat、shmdt、shmctl。掌握这四兄弟整个体系就算拿下了。2.1 从一个 key 开始ftok 的作用与坑shmget 的第一个参数不是字符串名字而是一个 key_t说白了就是一个整数。两个进程只要传同一个 key就能找到同一段共享内存。那怎么生成一个两边一致的 key标准做法是 ftok#include sys/ipc.h #include sys/stat.h key_t key ftok(/tmp/shm_demo, A); if (key -1) { perror(ftok); exit(EXIT_FAILURE); }ftok 内部是用路径所在文件系统的 inode 编号 次序号proj_id 低 8 位算出来的。正常情况下两个进程传同样的路径和同样的 proj_id算出来的 key 就一致。坑也在这你换了路径、路径文件被删除重建inode 变了、或者 proj_id 改了一位算出来的 key 就不一样另一端 shmget 要么失败要么创建出一段新的共享内存。常见的表现就是我明明创建了怎么另一端报错说找不到生产代码里我见过两种风格一种写死 key 常量比如 0x1234另一种用 ftok 在统一配置路径下生成。写死 key 的好处是稳定可预期坏处是可能与别的应用冲突ftok 的好处是不同模块用不同 proj_id 就能隔离但路径文件务必选一个几乎不会动的位置。你甚至可以先用 ipcs 或 ipcrm 检查有没有 key 冲突。提示ftok 生成的不是全局唯一值。不同目录文件可能算出相同的 key所以应用层最好统一约定路径和 proj_id 的分配策略。2.2 shmget创建还是获取size 与权限的一次谈判shmget 的完整签名int shmget(key_t key, size_t size, int shmflg);key上面说的共享内存整数标识。size新段的最小字节数。如果这段已经存在size 会被忽略真实分配时内核会向上取整到页大小。shmflg权限位 控制标志位。权限位和文件权限一个套路0644、0666、0600。控制标志最常用两个IPC_CREAT不存在就创建存在就直接返回。IPC_CREAT | IPC_EXCL不存在才创建存在就返回 -1 并置 errno 为 EEXIST适合用来保证这段是我创建的。返回的是 shmid一个整数句柄。注意shmid 不是 key。key 是跨进程的全局名字shmid 只是当前系统内部分配的引用标识。用 ipcs 看的时候key 列和 shmid 列经常不一样别混。一个常见陷阱获取已存在的段时size 传多少都无所谓但如果你不确定是否存在时用 IPC_CREAT 创建size 传 0 就会报 EINVAL。更稳的做法是这样int shmid shmget(key, sizeof(shared_data), IPC_CREAT | IPC_EXCL | 0666); if (shmid -1 errno EEXIST) { shmid shmget(key, sizeof(shared_data), 0666); }2.3 shmat 与 shmdt把共享内存挂进自己的地址空间shmget 只是拿到了一个身份证你还没有一块能直接用的地址。挂接操作是 shmatvoid *shmat(int shmid, const void *shmaddr, int shmflg);shmaddr 我基本永远传 NULL让内核自己挑一个进程内不冲突的虚拟地址。内核会在进程地址空间的空闲区域里找一段足够大的空间映射过去。自己指定地址太容易踩到已有映射除非你很清楚自己在干什么。shmflg 里比较常用的是 SHM_RDONLY表示只读挂接往只读区域写会触发段错误。SHM_RND 在 shmaddr 非空时自动把地址向下对齐到页边界一般用不到。shmat 最重要的判断细节失败返回的不是 NULL而是(void *) -1。我见过不少新手写if (shm NULL)判断错误然后拿着一块错误地址继续操作。一定要用if (shm (void *) -1) { perror(shmat); exit(EXIT_FAILURE); }挂接成功后这块地址跟 malloc 出来的内存一样直接用。用完记得 shmdt 摘除if (shmdt(shm) -1) { perror(shmdt); }shmdt 只是解除当前进程的映射不删段。返回 -1 的常见原因是地址不是有效的共享内存挂接地址。2.4 shmctl查询、设置与销毁管理环节轮到 shmctlint shmctl(int shmid, int cmd, struct shmid_ds *buf);cmd 用三个就够IPC_STAT把内核里维护的段信息拷到 buf拿到 uid、mode、size、nattch当前挂接进程数、atime、dtime 等。IPC_SET把 buf 里的 uid、mode 回写内核用来改属主或权限。改前最好先用 IPC_STAT 拉一份当前值。IPC_RMID删除段。注意是标记删除会立即拒绝新的 shmat但已有挂接不会马上断开Linux 等引用计数归零所有进程都 detach后才真正释放。用 IPC_STAT 看状态的片段struct shmid_ds ds; if (shmctl(shmid, IPC_STAT, ds) -1) { perror(shmctl); return; } printf(size%zu nattch%hu\n, ds.shm_segsz, ds.shm_nattch);3. 亲手跑通一个双进程共享内存 Demo3.1 演示场景与环境准备这次我用一个最简单的写进程 读进程场景writer 创建共享内存并写入一段消息和一个序号reader 从里面读出来。先在当前目录准备好两个文件writer.c、reader.c。环境就是普通 Linux 发行版Ubuntu/Debian/CentOS 都行不需要 root。先确认环境支持 System V IPCipcs -m能输出表头或部分段信息就说明内核 IPC 模块是开着的正常发行版默认都开启。3.2 writer / reader 的完整代码与编译writer.c#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/ipc.h #include sys/shm.h #include errno.h #define SHM_KEY 0x123456 typedef struct { int seq; char message[256]; } shared_data; int main(void) { int shmid; shared_data *data; shmid shmget(SHM_KEY, sizeof(shared_data), IPC_CREAT | IPC_EXCL | 0666); if (shmid -1) { if (errno EEXIST) { shmid shmget(SHM_KEY, sizeof(shared_data), 0666); } else { perror(shmget); exit(EXIT_FAILURE); } } data (shared_data *) shmat(shmid, NULL, 0); if (data (void *) -1) { perror(shmat); exit(EXIT_FAILURE); } >#include stdio.h #include stdlib.h #include sys/ipc.h #include sys/shm.h #define SHM_KEY 0x123456 typedef struct { int seq; char message[256]; } shared_data; int main(void) { int shmid; shared_data *data; shmid shmget(SHM_KEY, sizeof(shared_data), 0666); if (shmid -1) { perror(shmget); exit(EXIT_FAILURE); } data (shared_data *) shmat(shmid, NULL, 0); if (data (void *) -1) { perror(shmat); exit(EXIT_FAILURE); } printf(reader: get seq%d message\%s\\n,>gcc -Wall -O2 -o writer writer.c gcc -Wall -O2 -o reader reader.c3.3 运行结果与 ipcs 观测准备两个终端。终端 A 里启动 writer./writer在 sleep 的 10 秒内到终端 B 里运行 reader./reader如果不想开两个终端也可以./writer sleep 1 ./reader注意别等 writer 退出后才跑 reader它会报shmget: No such file or directory——因为段已经被 IPC_RMID 删了。这也是一个很好的时序教学点共享内存的生命周期完全由创建方控制读方必须保证段还在、权限够、key 对这三个条件同时满足。reader 输出reader: get seq42 messagehello from pid 12345writer 端 print 的内容和这个一致说明两个进程通过同一块物理内存互通了数据。想看得更细就用 ipcs -mipcs -m你会看到类似key shmid owner perms bytes nattch status 0x00123456 3866625 yourname 666 268 2 dest几个字段的观察要点bytes 显示的是创建时传入的 sizeof(shared_data)实际物理页按 4096 对齐nattch 在 writer 启动后是 1reader 挂接后变成 2status 出现 dest说明这个段已被标记为 IPC_RMID等所有进程 detach 后自动销毁。用完清理残留段ipcrm -m 3866625如果你更习惯用 key也可以ipcrm -M 0x00123456不同发行版对 key 格式的写法略有差异最稳的办法是用 shmid。4. 内存共享需要管理员权限吗权限模型与高频错误排查这个话题被问得很多先给结论创建和使用共享内存通常不需要 root。真正需要 root 的场景只有两个——一是要操作别人创建的段但权限不匹配二是要修改内核里关于共享内存的全局限制参数。其余情况下普通用户在自己能访问的段上创建、挂接、读写、删除权限模型和普通文件完全一样。4.1 权限位、属主与 EACCES 的常见现场shmget 的权限位和文件权限一致owner / group / other 三组 rwx。比如 0666 表示所有用户可读写0644 表示属主可读写其他人只能读。挂接时用 SHM_RDONLY 强制只读和权限位叠加生效。我觉得我能访问但 shmget 返回 EACCES是最常见的第一现场。排查套路ipcs -m看 owner 和 perms确认自己是不是该段的属主。如果段是 root 建的权限是 0600那你确实没权限要么让 root 用 IPC_SET 放宽要么换 key 各用各的。多用户协作的场景建议统一用 0660 并把用户加进同一个组而不是 0666——0666 等于把共享数据暴露给系统里任何用户。所以准确答案是同一用户自己搞定自己的段不需要管理员跨用户的权限分配需要协调但不一定需要管理员只有改内核参数那条路才必须 root。4.2 高频错误码速查与定位思路errno常见场景解决方向EACCES权限位不够 / 不是属主且权限拒绝检查 owner、mode用 IPC_STAT 查看EEXIST已存在同 key 段但你用了 IPC_EXCL先处理旧段或走复用分支ENOENT没加 IPC_CREAT 且段不存在key 不一致、段已被删、还没创建EINVALsize 传 0 / 小于已有段 / shmaddr 无效校验参数获取已有段时别传小于现段的 sizeENOMEM超过 SHMMAX/SHMALL 或物理内存不足减小段大小或调内核参数EIDRM段已被标记删除IPC_RMID 后上游流程没同步好检查启动和清理代码排这类问题我的固定姿势先ipcs -m看全局状态再用一小段 IPC_STAT 代码打印权限和引用计数。绝大多数共享内存问题都不是 API 用错而是生命周期和权限没对齐。4.3 内核参数shmmax、shmall 在哪里改三个最常碰到的系统约束SHMMNI系统里共享内存段总数上限默认通常是 4096。SHMMAX单个段最大字节数。很多数据库安装文档会要求调大它。SHMALL共享内存总页数上限页是指 4KB 大小。查看与临时修改cat /proc/sys/kernel/shmmax sysctl -w kernel.shmmax1073741824临时修改重启失效持久化要写进 /etc/sysctl.conf 或 /etc/sysctl.d/ 下的文件echo kernel.shmmax 1073741824 /etc/sysctl.conf sysctl -p提示修改前先确认物理内存。SHMMAX 设成 1GB 不意味着一次性分配 1GB只是允许单个段最大到 1GB。真正吃内存的是你实际 shmget 的总和。ipcs -l可以一次性看到这些约束的当前值。5. 拿进生产环境之前这些细节是分水岭5.1 共享内存里绝不能放指针这是我强调最多的一条。两个进程挂接后的虚拟地址是各自分配的进程 A 的 data 指向 0x7f1234进程 B 的可能指向 0x7f5678。如果你在共享内存里存了一个指针比如指向共享区内的某个字段A 把它写成进程 A 的虚拟地址B 拿着它去访问在 B 的进程空间里这个地址很可能是别的映射轻则读到垃圾重则直接段错误。正确做法用相对偏移把指针换成 off_t 类型的偏移量对端用 base offset 访问。或者用固定长度数组 下标定位。下标不受虚拟地址影响天然可靠。结构体字段类型尽量用 int32_t / uint64_t 这类固定宽度类型别用 int、long 这种在不同平台上宽度可能变化的类型。我用过一次把char *str直接写进共享内存结构体的实现对端解析时拿到一个非法地址排查到深夜。从那以后我写共享内存储存的结构只允许三种东西定长数组、数值、紧凑结构体。5.2 同步不归共享内存管信号量配合的两种姿势共享内存只解决共享不解决同步。生产里最常见的两种协同模式第一种是互斥模式多个写者 多个读者共享区读改写需要完全串行。用 System V 信号量当互斥量struct sembuf p_op {0, -1, 0}; struct sembuf v_op {0, 1, 0}; semop(semid, p_op, 1); // P 操作尝试拿锁 strcpy(data-message, hello); semop(semid, v_op, 1); // V 操作释放锁第二种是生产者消费者模式读写不同的资源槽位用两个信号量分别表示空槽数和满槽数。共享内存里维护一个环形缓冲区生产者 P(空槽) 写入后 V(满槽)消费者反过来。这是共享内存做消息队列的经典原型。如果项目里已经在用 pthread也可以用 pthread 条件变量 互斥锁做进程间同步但记得初始化时设置PTHREAD_PROCESS_SHARED属性否则锁只在进程内有效。说个实操心得如果数据结构简单、单写多读甚至可以不引入信号量靠seq 序号 内存屏障做发布/订阅写者先改数据再递增 seq读者先读数据再校验 seq。但前提是写者对数据的修改对读者可见需要编译器和 CPU 架构配合真要用务必先搞清内存序别自己拍脑袋。5.3 生命周期治理进程崩溃后谁清理System V 共享内存的命比进程长。进程退出段还在。写测试程序无所谓生产上这就是隐患段越来越多、内存越占越满、最后 shmget 返回 ENOSPC。我的落地做法启动脚本里主动清理一次同名 key 的残留段。用ipcrm -M key最省事。daemon 退出逻辑里统一 shmctl(IPC_RMID)在 SIGTERM/SIGINT 信号处理器里也补一份。兜底写个定时脚本检查 nattch 长期为 0、且 key 属于自身应用的段拉出来 ipcrm。还有前面提到的IPC_RMID 不立即释放细节如果你想确保段在所有人退出后自动销毁用shmctl(shmid, IPC_RMID, NULL)配合所有进程都正常 shmdt 就行如果 nattch 不为 0老实的做法是先通知对端进程主动 detach别在不停机的情况下硬删。5.4 System V 还是 POSIX 共享内存新项目免不了纠结要不要用 POSIX 的 shm_open mmap。简单对比维度System VPOSIX标识方式key_t 整数文件路径名/dev/shm 下创建接口shmgetshm_open ftruncate映射方式shmatmmap管理工具ipcs / ipcrmls /dev/shm、rm删除语义引用计数归零后释放unlink 后映射继续存在兼容性老牌、跨 Unix 广泛较新但主流系统都支持我的经验是没有历史包袱时POSIX 接口更顺手mmap 的语义跟文件操作统一但如果你维护的中间件一水儿是 System V很多开源数据库和消息队列确实如此没必要为了新而混用两套。最怕的是系统里 System V 和 POSIX 各开一块清理脚本和监控要维护两套工具链。另外注意一个坑POSIX 共享内存在 Linux 上默认落在 /dev/shm占用的是 tmpfs 内存如果挂载太小shm_open 会报 ENOSPC得去调挂载大小。System V 没有这个挂载维度反过来它的限制在内核参数里没那么直观。两边都有各自的内存根因陷阱选型时心里有数。就我个人习惯而言核心数据的进程间共享我优先用 System V因为它生命周期明确、ipcs 观测能力强需要跟文件语义结合、或者要频繁动态创建销毁的临时共享用 POSIX 更省事。两者都是正经方案关键是把边界条件和清理逻辑想清楚别让选型洁癖妨碍你先把功能跑通。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →