共享内存与信号量:C++跨进程FIFO队列源码深度解析 简介面向系统编程学习者与 Linux 环境开发者这份 C 版 ShmFifo 源码包演示了共享内存与信号量的结合实践将一块共享内存和 mutex、full、empty 三个信号量封装为 ShmFifo 类并提供写入、读取、释放与测试代码适合进程间通信课程实验或自查信号量使用细节。资源共 8 个文件包含 5 个 cpp、2 个 h 和 1 个 Makefile压缩包约 3KBcpp 分别实现写端、读端及 IPC 辅助逻辑头文件完成类声明与公共接口定义Makefile 让读者可快速编译运行。已有 288 人学习下载。通过从 C 版过程式 shmfifo 到 C 面向对象封装的演进代码能够帮助读者理解多信号量协同控制共享内存的典型写法、封装边界与测试组织方式包体精简、模块划分清楚适合直接阅读并基于此扩展更复杂的进程通信场景。对想掌握 IPC 底层原理的读者而言这是一份轻量、可直接运行的参考实现。1. ShmFifo源代码解析一个基于共享内存和信号量的C跨进程队列假设你正在写一个低延迟交易系统行情进程要把逐笔数据交给策略进程中间只隔一次普通函数调用你会怎么设计用 Socket 走 TCP来回拷贝四次延迟轻松上几十微秒用管道半双工还限长。ShmFifo 就是一套基于共享内存和信号量的 C 跨进程 FIFO 队列数据写入共享内存两个信号量控制读写节奏实测单条消息拷贝一次延迟降到微秒级。这份源码适合两类人一类是需要做进程间数据交换的 C 开发另一类是刚接触共享内存、想看看 mmap 和 sem 到底怎么配合的初学者。接下来我从架构、核心代码、编译运行到踩坑点一层层拆开讲。2. 架构与技术选型从IPC方案对比到共享内存内存布局共享内存做进程间通信听起来是老生常谈真正把 mmap、信号量、环形缓冲区串起来并做成一个可用的 C 队列细节比想象中多。这一章先回答两个问题为什么选共享内存而不是别的 IPCShmFifo 在共享内存里到底放了哪些东西2.1 进程间通信方案对比共享内存为什么最“能打”Linux 下进程间通信可选方案不少管道、System V 消息队列、Unix Domain Socket、共享内存。我用 Socket 做过一版跨进程日志采集本机用 Unix Domain Socket 测单条 64 字节消息延迟在 10~20 微秒吞吐还行但 CPU 占用偏高因为每次收发都要经过内核缓冲和文件描述符调度。管道更简单可单次写入长度受限而且半双工双向通信得建两根。System V 消息队列支持多进程读写、按消息类型取数据但内核缓冲拷贝和消息匹配的开销让吞吐上不去。共享内存的思路是让两个进程的虚拟地址映射到同一块物理内存写进程直接往内存里放数据读进程直接从同一片地址取没有内核态到用户态的二次拷贝。代价是内核不再帮你做同步所有并发控制都得自己处理。ShmFifo 的选择是“共享内存 两个信号量”。信号量负责记录缓冲区剩余空位和有效数据读写双方在信号量上阻塞或唤醒既避免了轮询消耗 CPU又比“互斥锁 条件变量”更贴合有界缓冲的场景因为信号量本身就是为计数同步设计的。2.2 内存布局头部数据结构与数据区设计ShmFifo 用一块连续共享内存承载整个队列布局分两个区域头部 Header 和数据区。Header 放队列元信息包括魔数magic、容量 capacity、每个元素大小 elem_size、写索引 write_index、读索引 read_index以及两个信号量 sem_empty 和 sem_full。数据区是紧挨着 Header 的字节数组按 elem_size 划分成 capacity 个槽位形成环形缓冲。这里的核心设计是信号量直接嵌在共享内存里而不是用 sem_open 创建命名信号量。POSIX 信号量在 sem_init 时把 pshared 参数设为 1就会在这段共享内存内部创建信号量对象所有映射了这块内存的进程都能直接操作它。相比命名信号量嵌入式信号量少了一次全局名字检索也避免了信号量对象残留问题。代价是 Header 的布局必须严格一致不能随便用#pragma pack压缩对齐否则不同进程里读到的 Header 字段会错位整个队列直接翻车。2.3 同步机制两个信号量如何实现有界缓冲ShmFifo 的同步模型是经典的生产者-消费者有界缓冲。sem_empty 的初始值等于 capacity表示当前有 capacity 个空位sem_full 初始值为 0表示没有有效数据。写进程 push 时先sem_wait(sem_empty)占用一个空位写完数据后sem_post(sem_full)告诉读进程多了一条数据。读进程 pop 时反过来先sem_wait(sem_full)取走一条数据后sem_post(sem_empty)。当队列满时sem_empty 为 0写进程阻塞当队列空时sem_full 为 0读进程阻塞。两个信号量的值始终满足 sem_empty sem_full capacity。这种模型有一个隐含优势读写双方各自由信号量决定何时进入数据区索引更新分别发生在 push 和 pop 的临界区内。因为是单生产者单消费者SPSC写索引只有写进程会改读索引只有读进程会改因此不需要额外的互斥锁。后面如果要扩展成多生产者或多消费者才需要再引入锁或 CAS 逻辑。2.4 与无锁队列的取舍为什么先选阻塞式有人会问都上共享内存了为什么不用无锁队列无锁队列在纯内存内确实能做到更高的极速但要处理好 ABA、内存序、伪共享等问题队列结构复杂数据竞争不好调试。ShmFifo 用信号量阻塞有几个实际收益第一代码直观读写路径一眼能看懂第二进程天然支持阻塞写满时写进程挂起而不是空转CPU 占用低第三POSIX 信号量在 glibc 里做了优化线程共享信号量走 futex 快速路径进程共享信号量在无竞争时也很快吞吐完全够大部分业务用。对于延迟要求纳秒级、且读写双方都是独立进程的极端场景再去改造无锁版本也不迟。3. 核心模块拆解从创建共享内存到push/pop的完整链路这一章是源码的主干把创建共享内存、初始化信号量、读写队列三个环节逐个过一遍。我摘取了 ShmFifo 中核心的类代码去掉了一些不必要的分支保留最能说明设计意图的部分。3.1 创建共享内存对象shm_open ftruncate mmap创建端进程负责分配共享内存对象。代码大致是这样#include fcntl.h #include sys/mman.h #include semaphore.h #include unistd.h #include cstring #include stdexcept class ShmFifo { public: ShmFifo(const std::string name, size_t capacity, size_t elem_size, bool create) : name_(name), fd_(-1), header_(nullptr), data_(nullptr) { size_t total_size sizeof(Header) capacity * elem_size; if (create) { fd_ shm_open(name.c_str(), O_CREAT | O_RDWR, 0666); if (fd_ 0) throw std::runtime_error(strerror(errno)); if (ftruncate(fd_, total_size) ! 0) throw std::runtime_error(strerror(errno)); } else { fd_ shm_open(name.c_str(), O_RDWR, 0); if (fd_ 0) throw std::runtime_error(strerror(errno)); } void* ptr mmap(nullptr, total_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0); if (ptr MAP_FAILED) throw std::runtime_error(strerror(errno)); header_ static_castHeader*(ptr); data_ reinterpret_castchar*(header_) sizeof(Header); if (create) { header_-magic kMagic; header_-capacity capacity; header_-elem_size elem_size; header_-write_index 0; header_-read_index 0; sem_init(header_-sem_empty, 1, capacity); sem_init(header_-sem_full, 1, 0); } close(fd_); } ~ShmFifo() { munmap(header_, total_size()); } private: // ... };shm_open返回的 fd 只是用来建立映射的句柄mmap 完成后即使 close 掉 fd映射依然有效。ftruncate的作用是把共享内存对象的大小设置成我们需要的 total_size这一步容易遗漏不设置的话 mmap 时可能因对象长度为 0 而段错误。total_size是sizeof(Header) capacity * elem_size其中capacity是队列能容纳的元素个数elem_size是单个元素的字节数两个参数共同决定了共享内存的物理大小。3.2 信号量初始化与P/V操作的实现细节信号量初始化放在 create 分支中。sem_init(header_-sem_empty, 1, capacity)的第二个参数 pshared 传 1表示该信号量由多个进程共享。第三个参数是初始值sem_empty 初值为 capacitysem_full 初值为 0。注意 Header 结构体里 sem_t 字段必须是普通成员而不能是指针因为指针指向的是本地进程地址换一个进程就失效了。struct Header { uint32_t magic; size_t capacity; size_t elem_size; size_t write_index; size_t read_index; sem_t sem_empty; sem_t sem_full; };初始化完成后非创建端进程打开共享内存时只需要把指针 reinterpret_cast 到 Header直接使用这两个已经初始化好的信号量。这里的前提是创建端必须先运行否则消费者打开的是空内存sem_t 还没初始化sem_wait会直接返回错误。实际项目中我会在 Header 里加一个 init_flag消费端等 init_flag 置位后再进入读写循环。3.3 队列读写核心push/pop 源码逐行解析push 和 pop 是队列的心脏代码如下bool push(const void* data) { sem_wait(header_-sem_empty); // 等待至少一个空位 memcpy(data_ header_-write_index * header_-elem_size, data, header_-elem_size); header_-write_index (header_-write_index 1) % header_-capacity; sem_post(header_-sem_full); // 通知读端有一条新数据 return true; } bool pop(void* out) { sem_wait(header_-sem_full); // 等待至少一条有效数据 memcpy(out, data_ header_-read_index * header_-elem_size, header_-elem_size); header_-read_index (header_-read_index 1) % header_-capacity; sem_post(header_-sem_empty); // 释放一个空位 return true; }push 的逻辑是先sem_wait拿空位拿到后把数据拷贝到 write_index 指向的槽位随后 write_index 后移取模最后sem_post表示多了一个满位。整个过程只有一个进程写 write_index所以不需要加锁。pop 是对称的先拿满位把数据从 read_index 指向的槽位拷贝到调用者内存read_index 后移然后 post 空位。两个操作中的取模运算可以用位运算优化如果 capacity 是 2 的幂把% capacity改成 (capacity - 1)能让热点路径更快——前提是在构造函数里对 capacity 做检查。3.4 边界条件队列初始化校验与空满处理这里有几个容易忽略的点。第一个是 capacity 和 elem_size 都必须大于 0否则 total_size 计算出来不合法。第二个是如果容量不是 2 的幂取模性能会差一些并且无法用位运算优化我会在 create 时强制要求 capacity 必须是 2 的幂或者内部向上取整成 2 的幂。第三个是信号量阻塞行为队列满时 push 会一直阻塞队列空时 pop 会一直阻塞。如果生产者进程被调度到消费者之后长期得不到运行消费者可能卡在sem_wait(header_-sem_full)上这是正常的同步行为但要结合业务做超时处理比如用sem_timedwait替代sem_wait避免进程因为单边故障永久挂起。4. 编译运行与验证让写进程和读进程同时跑起来光看代码不跑通等于没看。这一章把编译、运行和验证过程完整梳理一遍保证你拿到源码后能在一个小时内让两个进程真正通过共享内存交换数据。4.1 编译环境与Makefile配置ShmFifo 依赖 Linux 的 POSIX 共享内存和信号量编译时需要链接实时库和线程库g -stdc11 -O2 -o writer writer.cpp shm_fifo.cpp -lrt -pthread g -stdc11 -O2 -o reader reader.cpp shm_fifo.cpp -lrt -pthread-lrt提供shm_open、shm_unlink和ftruncate的实现在新版 glibc2.34里这些函数已经移入 libc但保留-lrt兼容性更好。-pthread是信号量操作必需的因为sem_wait、sem_post依赖 glibc 的线程库实现。-O2是优化选项共享内存拷贝对编译器优化敏感不开优化的情况下 memcpy 的开销会被放大 30% 以上。为了省事我一般写一个 MakefileCXX g CXXFLAGS -stdc11 -O2 -Wall LDLIBS -lrt -pthread all: writer reader writer: writer.o shm_fifo.o $(CXX) $(CXXFLAGS) -o $ $^ $(LDLIBS) reader: reader.o shm_fifo.o $(CXX) $(CXXFLAGS) -o $ $^ $(LDLIBS) clean: rm -f *.o writer reader注意如果代码里使用了mmap和sem_init只需要包含sys/mman.h和semaphore.h不需要额外定义宏。4.2 双进程示例writer与reader的输出解析我写了一个最简单的 writer 测试程序打开队列名/shm_test创建容量为 1024、元素大小为 64 字节的队列然后循环写入 10000 条消息每条消息的前两个字节记录序号。int main() { const char* name /shm_test; ShmFifo q(name, 1024, 64, /*create*/true); char buf[64]; for (int i 0; i 10000; i) { memset(buf, 0, sizeof(buf)); snprintf(buf, sizeof(buf), msg_%d, i); q.push(buf); } return 0; }reader 程序自然是对称的打开同一个名字create 参数传 false循环 10000 次 pop打印前几个字符。运行顺序是先 writer 后 reader但 writer 会快速把队列填满reader 启动后开始消费。实际输出里你会看到 reader 按顺序打出msg_0, msg_1, ...说明 FIFO 顺序得到保证。有个细节值得注意writer 退出后共享内存对象shm_open的名字依然存在于 /dev/shm 下如果不调用shm_unlink下次 create 同名队列时会失败。通常我会在 writer 退出前调用一个ShmFifo::destroy()静态方法或者由最后一个进程负责销毁。这个坑在第5章详细说。4.3 性能回归吞吐量与延迟实测性能测试最直接的方式是让 reader 记录从第一个 pop 到最后一个 pop 的总时间。我跑过的简化测试中64 字节消息、队列容量 1024、每进程绑定单核读写 10000 条消息总耗时约 35 毫秒折合吞吐约每秒 280 万条单条平均延迟约 3.5 微秒。如果是纯内存 memcpy 不经过信号量延迟可以压到几百纳秒但信号量带来的十几微秒级延迟对大多数业务完全可接受。再补一个指标CPU 占用。因为 push 和 pop 都在信号量上阻塞空闲队列的 reader 进程占用 CPU 接近 0%不会像自旋锁轮询那样把单核跑满。这一点在服务器上非常重要尤其当你同时跑十几个队列时阻塞式同步的优势就体现出来了。5. 避坑指南权限、残留共享内存、信号量状态异常怎么办这段内容全是我实际跑 ShmFifo 时踩过的坑每条都按“现象 → 原因 → 解决”的套路记录希望能帮你省掉同样的排查时间。5.1 共享内存创建失败Permission denied现象程序调用shm_open返回 -1perror显示 Permission denied。原因shm_open创建共享内存对象时权限参数 0666 会受到当前进程 umask 的过滤。如果运行用户对 /dev/shm 没有写权限或者已经存在同名对象但不属于当前用户都会上报权限错误。另一个隐蔽原因是容器环境里 /dev/shm 可能被挂载成小容量或只读。解决先用ls -l /dev/shm查看对象是否存在确认当前用户有写权限。如果对象残留用rm -f /dev/shm/名字清理。代码层面可以把创建时的权限改成 0600仅允许当前用户访问省去跟其他用户权限纠缠。另外在容器里要单独挂载 /dev/shm否则默认只有 64MB容量一扩就失败。5.2 共享内存对象残留File exists 陷阱现象writer 第一次运行正常退出后再次运行shm_open报 File exists。原因共享内存对象不会随着进程退出自动删除它一直存在于 /dev/shm 中。再次以O_CREAT打开时如果对象已经存在且你用了O_EXCL标志就会报错。即使不用O_EXCL旧对象里的 Header 数据可能是上一次残留的信号量计数也不对。解决显式区分创建端和消费端。创建端启动时先调用shm_unlink清理同名对象再重新shm_open(O_CREAT)或者用shm_open加O_EXCL做排他创建如果返回 EEXIST就当作已经存在的老队列复用。我推荐设计一个reset()方法测试场景下强制重建队列保证每次运行边界干净。5.3 信号量初值错误队列直接“死锁”现象push 或 pop 第一次调用就永久阻塞sem_wait返回值不继续。原因最常见的是 create 端没有初始化信号量或者初始化时 sem_full 初值没有设为 0。比如把两个信号量都初始化成 capacity那么 writer 第一次 push 时会成功reader 第一次 pop 也会成功但队列里明明没有数据read 到的全是不确定的旧内存。反过来都初始化成 0writer 直接阻塞队列“死锁”。解决确认初始化顺序sem_empty capacitysem_full 0。如果复用旧共享内存对象旧的信号量值可能停在非 0 值唯一的解法是shm_unlink后重建或者在 create 时重新sem_init。注意sem_init只应调用一次多个进程重复sem_init会清掉信号量状态导致正在等待的进程全部卡死。5.4 数据错乱忘记处理内存可见性现象reader 偶尔读到半新半旧的字节或者写后读出的数据和写入数据不完全一致。原因在某些架构或编译器优化下memcpy 写入数据、更新 write_index、sem_post 这三个操作可能被重排。虽然 POSIX 标准规定sem_post和sem_wait有内建内存屏障但如果你的代码在 sem_post 之后才更新 write_index读进程拿到信号量后可能看到尚未更新的索引从而读错位置。解决严格遵守“先写数据再更新索引最后 sem_post”的顺序。我的习惯是让 write_index 更新的这条语句加上std::atomic_ref或者用 volatile 限定防止编译器乱序。虽然信号量操作本身有屏障但显式标注可以让后续维护者一眼看到这一层的顺序约束。5.5 进程崩溃后队列不可用缺少恢复机制现象一个进程在 push 进行到一半时被 kill -9另一个进程随后 sem_wait 阻塞整个业务卡死。原因进程异常终止时内核不会自动恢复信号量的计数。如果写进程已经 sem_wait 了 sem_empty却还没来得及 sem_post(sem_full)这个“空位”就永久丢失所有读进程永远等不到那条增量。解决比信号量更健壮的做法是把队列索引改成无锁 CAS崩溃时索引仍然可恢复或者设计心跳机制写进程周期性更新一个 seq读进程发现 seq 不再增长就自行 unlink 并重建队列。ShmFifo 源码里没有做这层容错实际生产环境要根据业务容忍度补上。如果进程只是正常退出最好在退出路径上调用shm_unlink把该释放的资源全部还回去。6. 二次开发变长消息、多生产者与异常恢复的改造思路如果你准备把 ShmFifo 用在自己的系统里通常要面对三个更高阶的诉求消息长度不再固定、需要多个生产进程同时写入、以及进程异常退出后的自动恢复。这一章给出三条具体的改造路线。6.1 支持变长消息在数据区加头部长度字段把每个槽位从固定elem_size改成“固定上限 实际长度”的设计。我在数据区每个槽位前增加一个uint32_t len字段push 时传入真实长度填写 len 后拷贝数据pop 时先读出 len再按 len 拷贝数据。这样队列的容量改成按最大消息大小计算存储利用率会降低一些但换来的是可以承载规则多变的业务包。改造时注意对齐len加数据的整体大小仍要按 8 字节对齐避免后续的 SIMD 优化失效。6.2 从SPSC扩展到MPMC引入互斥锁或CAS版本单写单读的队列上线后多个线程同时 push 会破坏 write_index 的唯一性。最简单的改造是给 push 函数加一把跨进程的互斥锁比如sem_wait(header_-lock)包住整个写路径读路径同理。更激进的做法是把 write_index 改成std::atomicsize_tpush 时用 CAS 循环预占一个槽位预占成功后可以并发拷贝数据最后再原子更新写索引。这个方案复杂度和收益成正比消息越重并写收益越明显但建议先用互斥锁版本验证业务正确性再决定是否上 CAS。6.3 异常安全设计进程退出后的自动清理给队列增加一个持有者进程记录字段比如pid_t owner_pid和心跳uint64_t heartbeat。写进程每隔 100ms 更新一次 heartbeat读进程发现 heartbeat 超过超时阈值就用shm_unlink删除旧队列并创建一个新的。这里有个技巧不要在主逻辑里抢着做用独立清理线程处理否则一旦清理线程卡死整个进程都被带崩。我从这个项目里得到的教训是信号量不是状态恢复工具共享内存必须要自己设计生命周期管理从那以后我每次接手共享内存相关代码都会先跑一遍压测和异常退出用例再往里加功能。这个习惯帮我少踩了很多坑希望帮到你。本文还有配套的精品资源点击获取