资讯动态

Linux共享内存原理与实战:从System V API到同步排坑全解析

发布时间:2026/10/21 21:34:11 来源:尧图企业网站定制
共享内存这个东西做 Linux 下多进程开发的人迟早会碰见。尤其是写过管道、消息队列嫌它们数据拷贝太多、延迟太高之后你就会开始认真琢磨到底有没有一种方式能让两个进程像操作自己内存一样直接读写同一份数据答案就是共享内存。它把“数据交换”这件事的效率拉到极致进程通信里传输大块数据、高频读写、多读多写的场景基本都是它的主场。这篇博文我不打算讲空泛的概念直接从原理、API、手写 Demo 到排坑经验一条龙讲透。适合正在学 Linux 进程通信的同学也适合已经在实际项目里用共享内存但总是踩同步坑的开发者。你读完可以直接照着代码跑一遍再回来看问题排查部分基本就能把这块啃下来。1. 共享内存进程通信里的效率王者1.1 先盘一遍Linux进程通信的全部家当Linux 下进程间通信的方式一只手数不过来管道匿名管道和 FIFO 命名管道、信号、消息队列、信号量、共享内存再加上网络用 Socket。每种方式解决的需求不一样性能差异也很大。拿传数据举例管道和消息队列的核心动作都是“拷贝”发送方把数据从用户空间拷贝到内核空间接收方再从内核空间拷贝回自己的用户空间。一次通信至少两次拷贝数据量大一点CPU 开销立刻上来了。信号适合传通知不适合传大块数据。信号量本身不传数据只做同步和互斥。共享内存的思路完全不一样内核划出一块物理内存多个进程各自通过页表映射到同一块物理页。写入方直接往自己地址空间里写读取方立刻就能在自己地址空间里看到全程没有内核态和用户态之间的数据搬运。这也是为什么在进程通信的性能对比里共享内存的延迟通常比管道低一个数量级吞吐则高出一个数量级。对于单机内高频、大批量的数据交换共享内存基本是最优解。1.2 为什么说共享内存是效率之王把数据拷贝的次数列出来看就非常直观管道写端 write 进内核缓冲区读端 read 出内核缓冲区两次拷贝。消息队列msgsnd 进内核队列msgrcv 出内核队列两次拷贝。共享内存写端直接写物理页读端直接读物理页零次拷贝。零拷贝意味着 CPU 不需要反复搬运数据也不需要每次都陷入内核做读写系统调用。实际项目里一秒钟要传几千次几 KB 的数据包时用管道 CPU 占用率可能烧到百分之二三十共享内存能压到个位数。共享内存还有另一个管道做不到的优势天然支持多读。一个进程写多个进程同时读同一块内存读者之间互不干扰这在某些一写多读的监控、分发场景里非常有用。但代价也随之而来——内核不帮你做任何同步。管道有内核缓冲区做隔离和阻塞消息队列也有内核队列保证一条消息被完整读取共享内存则是一片裸内存谁先写谁后写、会不会读一半全靠开发者自己控制。1.3 System V和POSIX两条路线怎么选Linux 上的共享内存有两大体系System V 那一套老牌接口和 POSIX 那一套更现代的标准接口。两者功能等价但 API 风格和使用体验差别不小。System V 用的是 key_t id 的模型先用 ftok 生成一个 key再通过 shmget 创建或获取共享内存段拿到整型 ID之后用 shmat/shmdt 做挂载和卸载。这套模型在传统 Unix 上沉淀了几十年老项目里出镜率极高内核里还保留了 ipcs 这种专门查看 System V IPC 对象的命令。POSIX 共享内存则是“把共享内存当文件操作”的思路用 shm_open 打开一个以 / 开头的名字得到文件描述符用 ftruncate 设置大小再用 mmap 映射到进程地址空间。用完 munmap、shm_unlink 收尾。实际选型上我自己更常用 System V原因有三接口成熟稳定、权限控制模型清晰、ipcs/ipcrm 这类排查工具好用。但如果你在写新项目而且喜欢文件描述符的统一操作风格POSIX 也很顺手。这篇文章后面全程用 System V 做 Demo因为它在面试和考试里出现频率最高原理摸透了 POSIX 自然就会。2. 原理与API拆解搞懂共享内存到底干了什么2.1 地址映射两个进程怎么同时看到一份数据新手最容易困惑的点是两个进程不是各自有独立的地址空间吗怎么共享一块内存关键在于页表。每个进程都有完整的虚拟地址空间但这些虚拟地址只是“门牌号”。CPU 访问内存时MMU 会通过页表把虚拟地址转换成物理地址。正常情况下进程 A 的虚拟页和进程 B 的虚拟页各自映射到不同的物理页所以互相看不见。共享内存的机制就是让两个进程的虚拟页映射到同一个物理页上。A 进程往这个虚拟页对应的物理地址写数据B 进程从自己虚拟地址访问时MMU 翻译过去还是同一个物理地址自然就读到了相同内容。这里面有两点值得注意第一共享内存里各个进程的虚拟地址可以不同。比如 A 进程挂在 0x7f1234567000B 进程可能挂在 0x7f7890abc000这不影响它们访问同一个物理页。挂载时地址参数建议传 NULL让内核自己选合适的地址避免写死地址造成冲突。第二物理页本身是真实存在的内核只是把页表项指向它并没有给每个进程复制一份数据。这也是共享内存效率高的根本原因。2.2 System V共享内存shmget到shmctl逐个说清System V 共享内存的四个核心接口我按调用顺序拆开讲。第一步生成 key。key_t 本质上是一个整数用来让不同进程找到同一个共享内存段。最常见的生成方式是 ftok#include sys/ipc.h #include sys/stat.h key_t key ftok(/tmp/shm_demo, S);ftok 有两个参数路径和项目 ID。路径必须是一个真实存在的文件路径不一定非要用 /tmp也可以是当前目录下的某个文件项目 ID 是一个字符或单字节整数。内核会基于文件 inode 编号和项目 ID 组合出一个 key 值。这里有个坑如果用同一个路径、同一个项目 ID任何时候调用 ftok 生成的都是同一个 key这样多个进程才能对上号。换个路径key 就变了也就找不到同一块共享内存。我见过同事把路径写错两个进程各建各的共享内存通信一直失败查了半天。第二步创建或获取共享内存段#include sys/shm.h int shmid shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);参数含义分别是key、共享内存大小字节、权限标志。IPC_CREAT 表示不存在就创建IPC_EXCL 配合 IPC_CREAT 使用时如果已经存在则返回错误避免拿到一个旧段0666 是权限类似文件权限的 八进制。如果共享内存段已经存在只需要获取而不创建直接把 IPC_CREAT 去掉用同一个 key 调 shmget 就能拿到 ID。第三步挂载到进程地址空间void *addr shmat(shmid, NULL, 0);第二个参数 NULL 表示由内核选择合适的虚拟地址第三个参数填 SHM_RDONLY 可以只读挂载一般填 0 就是读写。返回值是进程内可访问的起始地址强转成你自己的数据结构指针即可。失败返回 (void *)-1需要检查 errno。第四步使用完解除挂载、清理共享内存shmdt(addr); shmctl(shmid, IPC_RMID, NULL);shmdt 只做“解除挂载”共享内存段依然在内核里存在别的进程还能继续访问。shmctl 配合 IPC_RMID 才是真正删除共享内存段而且删除动作发生在最后一个进程解除挂载之后。也就是说你调了 IPC_RMID只要还有进程挂着共享内存不会立刻消失这是 System V 的一个经典语义后面排坑部分会详细讲。2.3 POSIX共享内存更轻快的三板斧POSIX 共享内存的套路是“三板斧”shm_open、ftruncate、mmap。#include sys/mman.h #include fcntl.h #include unistd.h int fd shm_open(/my_shm, O_CREAT | O_RDWR, 0666); ftruncate(fd, 4096); void *addr mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);shm_open 的第一个参数必须以斜杠开头比如 /my_shm这相当于共享内存的名字。ftruncate 设置共享内存大小。mmap 的 MAP_SHARED 标志非常关键它保证对映射区域的修改会写回共享内存对象能被其他进程看到。如果错用成 MAP_PRIVATE改动只在进程内部可见共享就名存实亡了。用完释放munmap(addr, 4096); close(fd); shm_unlink(/my_shm);对比下来System V 是 key id 模型POSIX 是名字 文件描述符模型。POSIX 的好处是接口风格和普通文件操作统一配合 fstat、ftruncate 这类操作很自然System V 的好处是工具链完善出问题好排查。两者选一个深入掌握即可原理完全一样。3. 手写Demo模拟一个传感器数据采集通信3.1 场景设计与数据结构我拿一个行业里很常见的场景练手一个传感器采集进程持续产生数据另一个监控进程需要实时读取展示。这个场景非常适合用共享内存——数据块有固定结构更新频率高读取方只想看到最新数据。先定数据结构。为了贴近真实工程我定义了一个传感器数据包包含设备 ID、时间戳、8 个采样值和校验字段typedef struct { int dev_id; long ts_sec; float values[8]; unsigned int checksum; } sensor_pkg_t;共享内存大小直接给 4096 字节。注意4096 是 Linux 内存页的默认大小也是内核管理共享内存的常见粒度在这个 Demo 里足够容纳结构体还留了扩展余量。严格来说共享内存大小应该按页对齐拿 sizeof(sensor_pkg_t) 向上取整到页大小更严谨。示例代码我直接写 4096就是为了让新手先跑通再理解对齐的意义。3.2 写端实现循环写入传感器数据写端要做四件事生成 key、创建共享内存段、挂载到自己的地址空间、循环写入数据。完整代码如下#include stdio.h #include stdlib.h #include string.h #include unistd.h #include time.h #include errno.h #include sys/ipc.h #include sys/shm.h #define SHM_PATH /tmp/shm_demo #define SHM_PROJ S #define SHM_SIZE 4096 typedef struct { int dev_id; long ts_sec; float values[8]; unsigned int checksum; } sensor_pkg_t; int main(void) { key_t key ftok(SHM_PATH, SHM_PROJ); if (key -1) { perror(ftok); exit(EXIT_FAILURE); } int shmid shmget(key, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666); if (shmid -1 errno EEXIST) { shmid shmget(key, SHM_SIZE, 0666); } if (shmid -1) { perror(shmget); exit(EXIT_FAILURE); } printf(shmid %d\n, shmid); sensor_pkg_t *pkg (sensor_pkg_t *)shmat(shmid, NULL, 0); if (pkg (void *)-1) { perror(shmat); exit(EXIT_FAILURE); } for (int round 0; round 100; round) { pkg-dev_id 1; pkg-ts_sec time(NULL); for (int i 0; i 8; i) { pkg-values[i] 25.0 round * 0.1 i; } pkg-checksum pkg-dev_id (unsigned int)pkg-ts_sec; printf([writer] round %d, first value %.2f\n, round, pkg-values[0]); sleep(1); } shmdt(pkg); shmctl(shmid, IPC_RMID, NULL); return 0; }这里有个细节值得展开。shmget 第一次创建时用了 IPC_CREAT | IPC_EXCL如果共享内存段已经存在会返回 EEXIST 错误。我在代码里处理了这种冲突错误码是 EEXIST 时再用不带 IPC_CREAT 的 shmget 获取已有段。这样即使上一次运行没清理干净程序也能正常挂载并继续工作不会因为残留段重启失败。写入逻辑特意设计了 checksum 字段看起来是“数据校验”其实在这个 Demo 里它还有另一个作用——让你观察读端会不会读到半个包。后面配信号量时这个字段就是检查数据完整性的抓手。3.3 读端实现主动读取并解析读端不需要创建共享内存只做三件事生成同样的 key、获取 shmid、挂载后循环读取。代码#include stdio.h #include stdlib.h #include unistd.h #include errno.h #include sys/ipc.h #include sys/shm.h #define SHM_PATH /tmp/shm_demo #define SHM_PROJ S #define SHM_SIZE 4096 typedef struct { int dev_id; long ts_sec; float values[8]; unsigned int checksum; } sensor_pkg_t; int main(void) { key_t key ftok(SHM_PATH, SHM_PROJ); if (key -1) { perror(ftok); exit(EXIT_FAILURE); } int shmid shmget(key, SHM_SIZE, 0666); if (shmid -1) { perror(shmget); exit(EXIT_FAILURE); } sensor_pkg_t *pkg (sensor_pkg_t *)shmat(shmid, NULL, 0); if (pkg (void *)-1) { perror(shmat); exit(EXIT_FAILURE); } for (int round 0; round 100; round) { printf([reader] dev%d ts%ld first%.2f checksum%u\n, pkg-dev_id, pkg-ts_sec, pkg-values[0], pkg-checksum); sleep(1); } shmdt(pkg); return 0; }读端轮询打印数据每秒读一次。这是最原始的共享内存使用方式没有同步机制写端随时可能在更新数据的半途中读端恰好插进来读取。后面讲同步问题时会专门拿这个 Demo 说事。3.4 编译运行与验证效果两个程序分别编译gcc -o shm_writer shm_writer.c gcc -o shm_reader shm_reader.c建议开两个终端。先启动写端它会创建共享内存段再启动读端它获取同一段并读取数据。终端输出大概是这个样子终端一写端shmid 327680 [writer] round 0, first value 25.00 [writer] round 1, first value 25.10 [writer] round 2, first value 25.20终端二读端[reader] dev1 ts1700000000 first25.00 checksum1700000001 [reader] dev1 ts1700000001 first25.10 checksum1700000002 [reader] dev1 ts1700000002 first25.20 checksum1700000003运行期间可以另开一个终端用 ipcs 查看这个共享内存段的状态ipcs -m输出里会看到 key、shmid、拥有者、权限、大小、挂载进程数nattch等信息。写端退出并调用 IPC_RMID 删除后再执行 ipcs -m 就看不到这段了。如果程序异常退出共享内存段可能残留用 ipcrm -m shmid 可以手动删除。4. 坑点实录共享内存开发常见问题排障指南4.1 不解决同步会怎样数据错乱现场还原把上面的 Demo 跑起来你可能一开始觉得挺正常读端显示的数据大部分时候和写端一致。但只要你把临界区数据变大、写入耗时变长或者用多线程同时模拟多个写者问题立刻暴露。我模拟过一个放大版场景写端写入一个 1 KB 的结构体分 8 次 memcpy 填入每次 memcpy 之间故意 usleep 几十微秒。结果读端经常读到“拼接数据”——字段 A 是新一轮的数据字段 B 还是上一轮的旧数据checksum 对不上。这就是共享内存没有任何原子性保证的直接后果。一个进程在写另一个进程可能在任意时刻读到写了一半的缓冲区。小数据可能碰巧没撞上大数据一定翻车。解决办法有两个方向方向一加进程间互斥锁。Linux 下可以让 pthread 互斥锁跨进程生效pthread_mutexattr 里设置 PTHREAD_PROCESS_SHARED 属性即可#include pthread.h pthread_mutexattr_t attr; pthread_mutex_t mutex; pthread_mutexattr_init(attr); pthread_mutexattr_setpshared(attr, PTHREAD_PROCESS_SHARED); pthread_mutex_init(mutex, attr);锁的初始化必须在共享内存里完成关键是把这个 mutex 变量本身放到共享内存段的起始位置所有进程挂载后看到的都是同一个锁对象。然后把“写数据”和“读数据”的临界区分别用 pthread_mutex_lock/unlock 包起来。方向二用信号量做互斥这是传统 Unix 程序员的惯用做法下一章详细展开。核心思想一样读写双方约定好同一个信号量写之前 P 操作写完 V 操作读之前 P 操作读完 V 操作。无论选哪种都要记住共享内存自身不提供任何同步同步是开发者自己的责任。数据结构坏了、字段对不上、程序偶发崩溃十有八九就是临界区没保护。4.2 高频问题排查速查表共享内存开发遇到的问题翻来覆去就那么几类。我把实际踩过的坑整理成一张速查表方便你踩坑时快速定位。现象常见原因排查与处理ftok 返回 -1路径文件不存在或路径不可访问确认路径存在且有权限优先用进程启动时确定存在的临时目录shmget 返回 EEXIST上一次创建后未清理且此前用了 IPC_CREAT|IPC_EXCL代码里处理 EEXIST 分支改用不含 IPC_CREAT 的 shmget 获取老段或 ipcrm 手动删除shmat 返回 (void *)-1errnoEACCES共享内存权限不足shmget 创建时权限位是 0600 之类的受限值检查创建端权限位或让进程以对应用户运行也可临时改成 0666shmat 返回 ENOMEM共享内存大小设置过大超过内核限制调大 kernel.shmall/kernel.shmmax 参数或缩小共享内存段读端读到半包数据没有进程间同步机制加互斥锁或信号量这是逻辑 bug不涉及内核错误ipcs 里 nattch 一直不为 0某进程 shmat 后没有 shmdt或进程异常退出检查代码是否正确调用 shmdt排查残留进程必要时 kill 掉共享内存段被删但进程还能访问shmctl(IPC_RMID) 删除后已挂载进程的映射仍然有效这是 System V 语义删除不会立即回收最后一个进程解除挂载后才释放更新完程序里要在删除前确认所有读者已收工创建出来的共享内存大小和对不上不同进程 shmget 时传的 size 不一致System V 中 size 只在创建时生效之后 SHM_STAT 看到的是创建时的旧值所有进程保持 size 参数一致排查时记住一条主线先看 key 是否对得上再看权限是否足够最后看同步是否缺失。绝大多数问题都能用 ipcs 和 strace 定位。有一回我排查一个偶发崩溃进程一多就出现“读端访问地址非法”。后来用 gdb 挂上去发现读端挂载的共享内存地址在写端调用 shmctl(IPC_RMID) 后就失效了后续访问直接段错误。后来约定好删除共享内存之前所有读者必须先解除挂载彻底避免在用指针。4.3 性能调优从对齐到伪共享共享内存调优第一步是大小对齐。共享内存的传输单位在底层是物理页内核为每个段动态分配页表项。如果申请 100 字节内核也会给你 4096 字节的页多出来的空间被浪费。反过来如果你有 3000 字节数据那它大概率跨了两个物理页访问时可能触发两次缺页。设计阶段就把结构体大小向页大小靠齐如果需要 3000 字节直接分 4096一段容纳效率最高。第二步是注意伪共享问题。伪共享这个词一般出现在多线程里但进程间共享内存同样存在进程 A 频繁更新结构体字段 X进程 B 频繁读取字段 Y而 X 和 Y 处在同一条 64 字节缓存行上。A 写 X 会导致包含 X 的整个缓存行在 B 的 CPU 核心上失效B 每次读 Y 都要从内存重新拉数据性能被误伤。解决办法是字段按缓存行对齐。典型做法是在结构体里插入 padding 数组让高频写入的字段占据不同的 64 字节区域typedef struct { int dev_id; char pad1[60]; // 填充到 64 字节边界 float values[8]; char pad2[32]; unsigned int checksum; } sensor_pkg_t;看起来浪费了点空间但在高频读写场景下性能提升非常明显。第三步是减少无意义的挂载和卸载操作。shmat 和 shmdt 都是系统调用也有开销。如果进程长期使用共享内存启动时挂载一次、退出时卸载一次就够不要在循环里反复挂载卸载。实测对比过共享内存和管道的吞吐差异同一台机器上每 100 微秒传一个 4 KB 数据包管道模式 CPU 占用接近 20%换成共享内存后 CPU 占用直接掉到 3% 以下延迟也从几十微秒降到几微秒量级。这就是为什么大量中间件内部都用共享内存做单机数据交换。5. 进阶协同共享内存与信号量组合实战5.1 信号量加共享内存生产消费者的标准姿势共享内存负责“运输”信号量负责“交通管制”这对组合在进程通信领域是黄金搭档。信号量用 System V 那一套也顺手semget 创建信号量集合semop 做 P/V 操作semctl 做控制。融合到 Demo 里写端在写入前后分别做 P 和 V#include sys/sem.h // 假设 semid 已经创建且初始值为 1 struct sembuf ops[1]; ops[0].sem_num 0; ops[0].sem_op -1; // P 操作semval 减 1 ops[0].sem_flg SEM_UNDO; semop(semid, ops, 1); // 临界区写入共享内存 for (int i 0; i 8; i) { pkg-values[i] 25.0 round * 0.1 i; } pkg-checksum pkg-dev_id (unsigned int)pkg-ts_sec; ops[0].sem_op 1; // V 操作semval 加 1 ops[0].sem_flg SEM_UNDO; semop(semid, ops, 1);读端结构一样读之前 P读完 V。这样无论写端还是读端任意时刻只有一个进程能进入临界区操作共享内存彻底避免了半包数据和字段错乱。SEM_UNDO 这个标志很多人会忽略但它其实很关键。它的作用是如果持有信号量的进程异常退出内核会自动归还信号量防止其他进程永久阻塞。我建议自己写的代码里只要是拿信号量做互斥一律加上 SEM_UNDO 标志。创建信号量的方式int semid semget(key, 1, IPC_CREAT | 0666); semctl(semid, 0, SETVAL, 1); // 初始值设为 1注意信号量的 key 可以用 ftok 生成但项目 ID 要和共享内存区分。很多人图省事用同一个 ftok 路径和 ID结果共享内存和信号量 key 一样创建时互相干扰非常隐蔽。更高效的生产消费者模型还需要区分“缓冲区有数据了”和“缓冲区空了”那就需要两个信号量做计数一个初始为 1 表示缓冲区可以写一个初始为 0 表示还没有数据可读。生产者写之前等“可写”写完之后 V“可读”消费者读之前等“可读”读完 V“可写”这样能实现真正的阻塞等待而不是轮询。5.2 什么时候别用共享内存共享内存虽然强但不是银弹。以下场景真的不该选它跨主机通信别用共享内存。它只在单机内有效两台机器之间的数据交换还是得走 Socket 或消息队列。有人为了“高效”硬把跨主机数据映射到共享内存结果还要额外引入网络同步机制复杂度翻倍得不偿失。小数据高频通信也别急着上共享内存。一个整数一个状态标志的传递用管道或事件通知可能更合适。共享内存需要维护创建、挂载、同步、清理全套生命周期为了传 4 字节引入这一堆复杂度不划算。线程之间通信更用不上共享内存。同一进程内的线程本就共享全部地址空间直接定义全局变量加锁就行再搞一套共享内存等于脱裤子放屁。场景选错了技术再先进也是负担。还有一个反直觉的点如果数据要长期持久化共享内存也不合适。共享内存段随系统状态存在机器重启就没了。需要落盘的场景老老实实用文件或数据库。我自己的经验法则是单机内单次数据量大于几百字节、通信频率高、对延迟敏感、一次写入但多处读取这类场景直接选共享内存。其他情况先用简单方案性能瓶颈出现了再考虑优化。写到最后项目里第一次用共享内存时我犯过一个特别蠢的错误。程序跑了一晚上第二天发现所有共享内存段累积了十几个全是一起异常崩溃的进程留下的残留段。那时候才真正体会到 shmctl(IPC_RMID) 和 shmdt 的语义区别——异常退出时你挂载的那些段不会自动清理系统里全是僵尸段直到重启才消失。后来我养成了一个习惯每次写完共享内存程序先跑一次 ipcs -m 看段的数量再用 ipcrm -m 手动清理残留确认干净了才交付。还有一个经验是想清楚“谁负责创建、谁负责删除”。我现在的工程约定是专门的调度进程负责 ftok 和 shmget 创建共享内存写入进程和读取进程只做 shmat 挂载和 shmdt 卸载最后的 IPC_RMID 也由调度进程在确认所有参与者退出后执行。谁创建、谁管理、谁删除写清楚这三点共享内存项目的坑至少少一半。共享内存这东西原理就那么点东西API 也不复杂真正拉开差距的是工程细节同步怎么设计、生命周期怎么管理、数据怎么对齐。你把这篇里的代码跑熟了再把坑点过一遍接触真实项目时就不会再犯那些最常见的基础错误了。

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

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

免费获取报价 →
↑