简介:这份资源是面向C++后端与Linux系统编程学习者的共享内存通信实践代码,围绕“共享内存+信号量”这一经典IPC模型展开。作者先以C语言过程调用方式实现基础版shmfifo,再将其重构为C++类ShmFifo,把一块共享内存与mutex、full、empty三个信号量封装在一起,并分别编写读写测试程序,便于读者对比两种实现思路、理解封装与同步机制。压缩包共8个文件,包含5个cpp源文件、2个头文件和1个Makefile,分别承担类实现、读写测试、IPC辅助与编译构建等职责,整体仅3KB,结构紧凑、便于快速阅读与本地编译验证。目前已有288人学习下载,适合作为操作系统课程实验、面试八股复习或IPC练手的参考素材,读者可从中掌握信号量协调下的生产者—消费者模型、共享内存映射与释放流程,以及C到C++封装迁移的完整思路。
1. 共享内存加信号量到底怎么配合:一份能直接编译的 ShmFifo 源码拆解
进程间通信这件事,做过 Linux 服务端开发的人多少都踩过坑。管道和消息队列用起来简单,但数据量一大,拷贝开销就上来了;socket 灵活,可本机通信绕一圈协议栈总觉得亏。真正追求吞吐的场景,绕不开共享内存。可共享内存本身只给了一块裸地址,多个进程同时读写,没有同步机制就是灾难现场。这份 ShmFifo 源代码(C++ 版)解决的正是这个组合问题:用共享内存做数据载体,用信号量做读写指针的互斥与同步,封装成一个先进先出的环形缓冲区。它适合谁?适合已经会写基本 C++、想搞明白无锁队列之外另一种经典 IPC 方案的人,也适合手头有个多进程采集或转发任务、需要一个能直接编译跑起来的参考实现的人。下面我按自己拆包的习惯,把这份源码从结构到编译、从参数到排错走一遍。
2. 拆开 ShmFifo 的骨架:环形缓冲区、信号量和共享内存怎么拼在一起
2.1 三个核心角色:Shm、Sem、Fifo 各管什么
这份源码的命名很直白,ShmFifo 就是 Shared Memory FIFO 的缩写。拆开看,它内部至少有三个逻辑角色在协作。第一个是共享内存段本身,通常用shm_open配合mmap映射到进程地址空间,或者用 System V 的shmget/shmat。第二个是信号量,用来保护读写索引,防止两个进程同时移动同一个指针。第三个是环形缓冲区结构,也就是 FIFO 的本体,数据从尾部写入、从头部读出,写满后要么阻塞要么覆盖,取决于实现策略。
我一般会先看头文件里结构体的定义,因为那决定了整个 FIFO 的内存布局。一个典型的共享内存 FIFO 控制块长这样:一个写索引write_index、一个读索引read_index、一个缓冲区大小capacity、一个数据区起始偏移,外加两个信号量句柄或者信号量名字。这些字段必须放在共享内存里,因为每个进程都要看到同一份索引值。如果索引放在各自进程的栈上,那同步就无从谈起。
这里有个容易忽略的点:信号量本身也可以放在共享内存里,用sem_init的pshared参数设为 1,表示进程间共享。但更常见的做法是用命名信号量sem_open,这样进程不需要共享同一块内存就能拿到同一个信号量。源码里具体用哪种,决定了你编译时要不要额外链接-pthread和-lrt。
2.2 环形缓冲区的读写逻辑:为什么索引要取模
环形缓冲区的核心就一句话:写指针追上读指针表示满,读指针追上写指针表示空。但实现时有几个细节决定成败。第一,索引递增后要对容量取模,保证永远落在合法范围内。第二,判断满和空不能只看指针相等,否则满和空的状态无法区分。常见做法是留一个空槽,或者额外维护一个计数器count。
我拆过的实现里,用计数器的方式更直观:写入时count加一,读出时count减一。count等于容量就是满,等于零就是空。但计数器本身也是共享数据,必须和索引一起被信号量保护。如果源码里用的是「留空槽」方案,那容量为 N 的缓冲区实际只能存 N-1 个元素,这个边界在压测时特别容易暴露。
下面这段伪代码展示了写入侧的核心逻辑,我按常见实现补全了信号量操作:
// 写入侧:先拿空位信号量,再拿互斥信号量 sem_wait(&sem_empty); // 等待有空位,满时阻塞 sem_wait(&sem_mutex); // 进入临界区,保护索引 buffer[write_index] = data; // 拷贝数据到共享内存 write_index = (write_index + 1) % capacity; // 环形推进 sem_post(&sem_mutex); // 退出临界区 sem_post(&sem_full); // 通知读者有数据了逻辑说明:sem_empty初始值设为容量,每写一个减一,保证写满时阻塞而不是覆盖。sem_mutex初始值为 1,充当二元信号量。sem_full初始值为 0,读者在它上面等待。参数上,capacity必须是正整数,且如果采用留空槽方案,实际可用容量要减一。sem_wait和sem_post的配对不能乱,否则会出现死锁或者信号量计数漂移。
2.3 共享内存的创建与映射:shm_open 和 mmap 的配合
共享内存的创建通常分两步:先shm_open拿到一个文件描述符,再ftruncate设置大小,最后mmap映射到地址空间。名字必须以斜杠开头,比如/my_fifo,而且不同进程要用同一个名字才能看到同一块内存。这里有个血泪经验:shm_open创建的对象在系统里是持久的,程序异常退出不会自动清理,下次再跑可能拿到旧数据。所以初始化时通常要判断是不是首次创建,或者干脆在启动脚本里先shm_unlink再创建。
int fd = shm_open("/shm_fifo_demo", O_CREAT | O_RDWR, 0666); if (fd == -1) { perror("shm_open"); return -1; } if (ftruncate(fd, sizeof(ShmFifoHeader) + capacity) == -1) { perror("ftruncate"); return -1; } void* addr = mmap(nullptr, total_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (addr == MAP_FAILED) { perror("mmap"); return -1; }逻辑说明:O_CREAT表示不存在就创建,0666是权限位。ftruncate把共享内存对象扩展到指定字节数,不调用它直接 mmap 会得到总线错误。MAP_SHARED是关键,用MAP_PRIVATE的话写操作不会反映到其他进程。mmap返回的地址每个进程可能不同,但指向的物理页是同一块,所以结构体里不要存绝对指针,要存偏移量。
3. 从源码到可执行文件:编译、初始化和双进程联调
3.1 编译命令与依赖库:-lrt 和 -pthread 缺一不可
拿到源码后第一步是编译。这份 ShmFifo 用到了 POSIX 共享内存和信号量,在 Linux 上这两个功能分别在librt和libpthread里。如果你的编译命令只写了g++ main.cpp -o demo,大概率会报undefined reference to shm_open或者sem_open。正确的命令要显式链接:
g++ -std=c++11 -O2 -Wall \ shm_fifo.cpp writer.cpp reader.cpp \ -o shm_fifo_demo \ -lrt -pthread参数说明:-std=c++11是因为源码里可能用了nullptr或者std::atomic。-O2在压测时能减少优化带来的干扰。-Wall打开警告,共享内存代码里指针类型不匹配的警告往往预示着严重 bug。-lrt提供shm_open、sem_open等实时扩展接口,-pthread提供线程和信号量支持。顺序上库要放在源文件后面,否则链接器可能找不到符号。
如果源码里用的是 System V 的shmget和semget,那-lrt可以省掉,但-pthread通常还是要的。编译前最好先看一眼源码开头的#include,<sys/mman.h>和<semaphore.h>基本能确认是 POSIX 路线。
3.2 初始化顺序:先建共享内存还是先建信号量
初始化顺序是个容易被忽视但会直接导致挂死的问题。我一般的做法是:先创建共享内存并映射,再在共享内存的控制块里初始化信号量。如果信号量是命名信号量,那顺序可以反过来,但要注意sem_open的O_CREAT和O_EXCL标志配合,避免重复初始化。
// 初始化侧:只在第一个进程里执行 ShmFifoHeader* header = (ShmFifoHeader*)addr; header->capacity = capacity; header->write_index = 0; header->read_index = 0; header->count = 0; // 进程间共享的信号量,pshared 必须为 1 sem_init(&header->sem_mutex, 1, 1); sem_init(&header->sem_empty, 1, capacity); sem_init(&header->sem_full, 1, 0);逻辑说明:sem_init的第二个参数pshared设为 1 表示信号量放在共享内存里、可以被多个进程操作。如果误设为 0,那它只在当前进程内有效,另一个进程sem_wait时会直接报错或者行为未定义。sem_empty初值等于容量,sem_full初值为 0,这是生产者-消费者模型的标准起手式。初始化完成后,后续进程只需要shm_open加mmap,然后直接使用已经初始化好的信号量,不能再调sem_init。
提示:如果程序异常退出导致共享内存残留,可以用
shm_unlink("/shm_fifo_demo")清理,或者重启机器。命名信号量用sem_unlink清理。
3.3 双进程联调:一个写一个读,怎么验证数据没丢
编译出可执行文件后,最直接的验证方式是开两个终端,一个跑写进程,一个跑读进程。写进程循环写入递增的整数,读进程循环读出并打印,最后比对写入序列和读出序列是否一致。如果读出的数据出现跳变、重复或者卡死,说明同步逻辑有问题。
# 终端 1:启动读进程,先跑起来等数据 ./shm_fifo_demo reader # 终端 2:启动写进程,写入 10000 条 ./shm_fifo_demo writer 10000验证时重点看三个指标:第一,读出的数据是否严格按写入顺序,FIFO 不允许乱序。第二,写进程在缓冲区满时是否阻塞而不是覆盖,可以在写进程里加打印观察。第三,两个进程都退出后,共享内存里的count是否归零。如果读进程读得慢,写进程应该被sem_empty挡住,而不是继续写导致数据被覆盖。
我习惯在联调时把容量设小一点,比如 8 或者 16,这样很容易触发满和空的边界。容量设成 1024 以上,问题往往被缓冲掩盖,压测时才暴露,那时候排查成本就高了。
4. 参数调优与边界处理:容量、阻塞策略和超时怎么定
4.1 容量设置:太小丢吞吐,太大吃内存
容量是这份源码里最直观的参数,但它直接影响行为和资源占用。容量太小,生产者频繁阻塞,上下文切换开销上升,吞吐下降。容量太大,共享内存占用增加,而且缓存局部性变差,读写指针跨页跳转可能引起 TLB 抖动。我一般按「生产者最大突发量」来估:如果生产者每秒写 10 万条、每条 64 字节,消费者偶尔卡顿 100 毫秒,那容量至少要能缓冲 1 万条,也就是 640KB 左右。
但容量不是越大越好。共享内存虽然不占堆,但它占物理内存页,系统内存紧张时会被换出,反而更慢。而且容量大了之后,count计数器的读写频率不变,但每次操作跨越的内存范围更大,对 CPU 缓存不友好。常见做法是容量取 2 的幂次,这样取模可以用位与运算代替,index & (capacity - 1)比index % capacity快不少,前提是容量确实是 2 的幂。
4.2 阻塞还是非阻塞:sem_trywait 的使用场景
默认的sem_wait是阻塞的,缓冲区满时写进程会挂起,空时读进程会挂起。这在大多数生产者-消费者场景里没问题,但如果你的进程还要处理其他任务,比如响应控制命令或者定时上报心跳,阻塞就可能让整个进程失去响应。这时候可以用sem_trywait做非阻塞尝试,拿不到信号量就返回错误码,进程可以去干别的事。
if (sem_trywait(&header->sem_empty) == 0) { // 拿到空位,执行写入 sem_wait(&header->sem_mutex); // ... 写入逻辑 sem_post(&header->sem_mutex); sem_post(&header->sem_full); } else { // 缓冲区满,走降级逻辑:丢弃、重试或上报 handle_full(); }逻辑说明:sem_trywait成功返回 0,失败返回 -1 并设置errno为EAGAIN。注意非阻塞模式下不能保证一定拿到空位,所以写入逻辑必须放在成功分支里。如果源码里没有提供非阻塞接口,可以自己包一层,但要注意sem_trywait和sem_wait混用时信号量计数的正确性。
4.3 超时处理:sem_timedwait 和时钟选择
比非阻塞更实用的是带超时的等待。sem_timedwait允许你指定一个绝对时间点,超时后返回 -1 并设置errno为ETIMEDOUT。这在写进程需要定期检查退出标志时特别有用。但这里有个坑:sem_timedwait用的是CLOCK_REALTIME,系统时间被调整时会影响超时判断。如果对时间敏感,可以考虑用sem_clockwait指定CLOCK_MONOTONIC,不过这个接口的可移植性稍差。
struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); ts.tv_sec += 1; // 超时 1 秒 if (sem_timedwait(&header->sem_empty, &ts) == -1) { if (errno == ETIMEDOUT) { // 超时,检查退出标志 if (should_exit) break; } }参数说明:ts必须是绝对时间,不是相对时间。很多人第一次用会直接填tv_sec = 1,结果发现立刻超时,因为那个时间点早就过了。正确做法是先clock_gettime拿当前时间,再加上你想要的秒数。超时时间设多长取决于业务容忍度,我一般设 100 毫秒到 1 秒之间,太短会频繁空转,太长退出响应慢。
5. 避坑与排查:共享内存 FIFO 最容易翻车的五个地方
5.1 现象:两个进程都跑起来了,但读进程一直阻塞,写进程也阻塞
原因:信号量初值不对,或者初始化顺序错了。最常见的是sem_empty初值设成了 0,写进程一上来就等空位,永远等不到。另一种可能是信号量没有放在共享内存里,两个进程各自初始化了自己的信号量,互不可见。
解决:检查sem_init的pshared参数是否为 1,检查sem_empty初值是否等于容量。如果是命名信号量,确认两个进程用的是同一个名字,并且sem_open的O_CREAT只在第一个进程里带。
5.2 现象:读出的数据偶尔错乱,顺序不对或者内容被覆盖
原因:读写索引没有正确取模,或者count计数器的更新不在临界区内。还有一种可能是mmap用了MAP_PRIVATE,两个进程看到的是各自的副本,索引不同步。
解决:确认mmap的 flags 包含MAP_SHARED。确认所有对write_index、read_index、count的修改都在sem_mutex保护下。取模运算检查容量是否为 0,除零会直接崩溃。
5.3 现象:程序正常退出后,再次运行读到上次的旧数据
原因:共享内存对象是持久的,shm_open带O_CREAT时如果对象已存在,不会重新初始化。信号量同理,sem_open也会复用已有的。
解决:在初始化代码里判断是否首次创建,可以用O_EXCL标志尝试独占创建,失败则说明已存在。或者在程序启动脚本里先执行shm_unlink和sem_unlink。生产环境更稳妥的做法是给共享内存名字加进程 ID 或时间戳后缀,避免冲突。
5.4 现象:压测时吞吐上不去,CPU 占用却很高
原因:信号量争用严重,或者容量太小导致频繁阻塞唤醒。每次sem_wait和sem_post都涉及系统调用,频率太高时开销不可忽略。
解决:适当增大容量,减少阻塞次数。如果生产者消费者速度匹配较好,可以考虑批量写入,一次拿多个空位。另外检查是不是在临界区里做了耗时操作,比如打印日志或者内存拷贝,临界区应该尽可能短。
5.5 现象:编译报错 undefined reference to sem_open
原因:没有链接-lrt和-pthread,或者链接顺序不对。
解决:把-lrt -pthread放在编译命令的最后,源文件之后。如果用的是 CMake,在target_link_libraries里加上rt和pthread。某些嵌入式环境可能没有librt,那就只能改用 System V 的信号量接口。
6. 进阶技巧:把 ShmFifo 包成 RAII 类并加一个自检模式
源码能跑通只是第一步,真正放到项目里用,我建议做两件事。第一件是用 RAII 把共享内存和信号量的生命周期管起来,构造时创建或打开,析构时根据角色决定是否unlink。这样即使中间抛异常,也不会留下残留的共享内存对象。第二件是加一个自检模式,启动时先写一条已知数据再读出来比对,确认映射和同步都正常,再进入正式流程。
class ShmFifoGuard { public: ShmFifoGuard(const std::string& name, size_t cap, bool create) : name_(name), addr_(nullptr), fd_(-1) { fd_ = shm_open(name_.c_str(), create ? (O_CREAT | O_RDWR) : O_RDWR, 0666); if (fd_ == -1) throw std::runtime_error("shm_open failed"); if (create) { if (ftruncate(fd_, sizeof(Header) + cap) == -1) throw std::runtime_error("ftruncate failed"); } addr_ = mmap(nullptr, sizeof(Header) + cap, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0); if (addr_ == MAP_FAILED) throw std::runtime_error("mmap failed"); } ~ShmFifoGuard() { if (addr_) munmap(addr_, sizeof(Header) + cap_); if (fd_ != -1) close(fd_); if (create_) shm_unlink(name_.c_str()); } private: std::string name_; void* addr_; int fd_; size_t cap_; bool create_; };逻辑说明:构造函数根据create标志决定是创建还是打开已有对象。析构函数里munmap和close是必须的,shm_unlink只在创建者角色里调用,避免读者把还在用的内存删掉。自检模式可以在构造函数末尾加一段:写入一个魔数,读出来比对,不一致就抛异常。这个习惯帮我省过很多次调试时间,尤其是在容器环境里,共享内存的挂载和权限经常和预期不一样。
从那以后我每次把共享内存相关的代码交给别人之前,都强制走一遍自检模式加双进程联调,确认边界和清理逻辑都没问题再放手。希望帮到你。
本文还有配套的精品资源,点击获取