基于mmap的共享内存环形队列ShmFifo设计与实现
简介一套基于C的共享内存FIFO实现源码面向熟悉Linux进程间通信的开发者演示如何将1块共享内存与互斥信号量、满信号量、空信号量封装为ShmFifo类并提供读写、释放及进程交互的完整示例。包内共8个文件包括5个cpp实现与测试文件、2个h头文件以及1个Makefile整体仅3KB结构紧凑、便于快速剖析类封装与信号量协同逻辑。已有286人学习下载适合正在学习共享内存、信号量或C封装实践的读者参考。代码在C语言版本基础上演进而来通过ipc.cpp、shmfifo.cpp等模块展示初始化、读写同步与资源回收流程可直接编译运行并对照验证有助于理解多进程数据交换的经典实现也能为后续扩展成更复杂消息队列提供起点。 先交代一个背景。我这边有个后台服务主进程负责采集和预处理几个工作进程负责计算和落盘中间隔着一层数据搬运。早期用的是 Unix domain socket数据量大起来之后 CPU 一直压在 sys 上面内核态到用户态来回拷贝延迟数字看着扎眼。后来我把中间这层换成了共享内存 环形队列也就是今天想拆开聊的 ShmFifoC 版一套基于 mmap 实现的跨进程 FIFO。这个方向对 C 后端开发、中间件开发或者对 IPC 性能敏感的朋友都有参考价值就算你目前只是准备面试里面涉及的进程共享锁、环形缓冲、内存映射这几个点也值得顺手捋一遍属于典型的高频考点。下面从设计到代码再到实战踩坑完整过一遍。1. 设计思路拆解共享内存上的环形队列是怎么搭起来的1.1 为什么不用管道和 Socket偏偏选了共享内存先说清楚一个最底层的问题什么时候该用共享内存做 IPC。管道、消息队列、Socket 这类传统 IPC数据从发送进程到接收进程至少要经过两次内核态拷贝。发送方把数据从用户态缓冲区拷到内核缓冲区接收方再从内核缓冲区拷到用户态缓冲区中间还伴随上下文切换和系统调用。在小数据量、低频次场景下这个开销无所谓但一旦吞吐上来sys 占用率会肉眼可见地飙升延迟也会变得不稳定。共享内存的思路简单粗暴操作系统把同一块物理内存映射到多个进程的虚拟地址空间进程之间直接读写这块内存就行了数据不需要经过内核中转整个过程只有一次 memcpy从生产者缓冲区拷到共享区。从内核态交互变成了纯粹的用户态操作延迟和 CPU 占用都会明显下降。ShmFifo 就是基于这个思路在共享内存上实现一个先进先出的环形队列让生产者和消费者像操作普通数组一样完成数据交换。1.2 环形缓冲区为什么适合做 FIFO线性队列最大的问题是头部弹出数据后空间无法复用需要频繁搬移数据。环形缓冲区解决了这个问题底层是一块固定大小的连续内存逻辑上首尾相接写入时从尾指针位置开始读走时头指针向前推进空间自然循环复用。ShmFifo 选择环形结构还有一个工程上的理由共享内存一旦创建大小基本就固定了虽然可以用 ftruncate mremap 调整但这属于重操作不适合放在高频路径里。环形队列配合固定容量正好契合共享内存“分配一次、反复使用”的定位。1.3 跨进程同步为什么锁和条件变量要特殊处理共享内存本身不提供同步机制。多个进程同时读写同一块内存必须引入互斥和唤醒语义。我在第一版里直接用了 pthread_mutex_t 和 pthread_cond_t心想这有啥难的加锁解锁就完事了。结果第一次联调就出了问题进程 A 写入数据后进程 B 一直等不到唤醒信号。原因在于默认创建的互斥锁和条件变量是进程内共享的放在共享内存里并不生效必须在初始化时显式设置PTHREAD_PROCESS_SHARED属性。这一步没有配置好锁和条件变量就只能在同一进程的线程之间工作跨进程场景下完全失灵。这个坑我会在后面的初始化代码里专门标注。2. 核心数据结构与共享内存布局2.1 共享内存里的元信息该放什么ShmFifo 在共享内存头部放了一个元信息结构体记录了队列的核心状态。设计这个结构体时要注意几点字段要对齐访问要原子化或者受锁保护还要预留足够的扩展空间。我这边常用的布局如下struct ShmMeta { uint32_t magic; // 魔数用于校验共享内存是否被正确初始化 uint32_t capacity; // 环形区容量字节为单位必须是2的幂 uint32_t read_pos; // 当前读位置 uint32_t write_pos; // 当前写位置 int is_owner; // 标记当前打开者是否为初始化者 pthread_mutex_t mtx; // 进程间互斥锁 pthread_cond_t not_empty; // 数据可读条件变量 char reserved[32]; // 留给后续扩展 };这里read_pos和write_pos都用uint32_t让它们自然回绕避免用size_t在高位扩展时产生歧义。magic字段用来做初始化校验进程打开共享内存后先检查 magic 是否符合预期防止拿到一块没初始化完或者被写坏的内存。2.2 容量为什么要选 2 的幂环形缓冲区取余操作pos % capacity在通用场景下没问题但取模本身是个代价不低的整数除法。如果容量设置为 2 的幂就能把取余优化成位运算pos (capacity - 1)在高频写入路径上这个优化效果很明显。我一般把默认容量设为 1MB 或者 4MB代码里通过宏来控制constexpr size_t kDefaultCapacity 1 20; // 1MB static_assert((kDefaultCapacity (kDefaultCapacity - 1)) 0, capacity must be power of 2);2.3 数据区与元信息的对齐问题还有一个容易忽视的点追求性能时数据区起始地址最好按 64 字节对齐这样每次 memcpy 的起始地址对齐到 cache line 边界能减少伪共享和内存访问开销。共享内存整块布局是[元信息区][数据区]我通常在 ftruncate 之前就把总大小算好size_t total_size sizeof(ShmMeta) kDefaultCapacity; // 对数据区做 64 字节对齐 size_t data_offset (sizeof(ShmMeta) 63) ~(size_t)63;data_offset就是数据区的起始偏移后续所有读写都从base data_offset开始。这个偏移不需要写进元信息只要所有进程使用同一套计算规则就能保持一致但为了保险起见我在元信息里也存了一份data_offset字段避免未来版本调整时老数据不兼容。3. 核心模块实现初始化、写入与读取3.1 共享内存的创建与映射创建阶段的核心逻辑是“区分创建者和加入者”。第一个进程负责创建共享内存对象、设置容量、初始化锁和条件变量后续进程只负责打开并映射。如何区分这两类进程我的做法是用shm_open配合O_EXCL标志bool ShmFifo::init() { int fd; bool created false; // 先尝试独占创建如果已经存在则打开已有对象 fd shm_open(name_.c_str(), O_CREAT | O_EXCL | O_RDWR, 0666); if (fd 0) { created true; if (ftruncate(fd, (off_t)total_size_) ! 0) { close(fd); return false; } } else { if (errno ! EEXIST) return false; fd shm_open(name_.c_str(), O_RDWR, 0666); if (fd 0) return false; } base_ static_castchar*(mmap(nullptr, total_size_, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)); close(fd); if (base_ MAP_FAILED) return false; meta_ reinterpret_castShmMeta*(base_); data_ base_ data_offset_; if (created) { init_meta(); } else { // 校验魔数防止内存未初始化就被访问 if (meta_-magic ! kMagic) return false; } return true; }MAP_SHARED这个标志是关键。如果误用成MAP_PRIVATE写入只会反映到当前进程的内存页其他进程完全看不到这是一个很容易踩的坑。3.2 元信息初始化细节创建者调用init_meta()来做一次性初始化。这里最值得注意的就是进程共享属性的设置void ShmFifo::init_meta() { memset(meta_, 0, sizeof(ShmMeta)); meta_-magic kMagic; meta_-capacity (uint32_t)capacity_; meta_-read_pos 0; meta_-write_pos 0; meta_-data_offset (uint32_t)data_offset_; pthread_mutexattr_t mattr; pthread_mutexattr_init(mattr); pthread_mutexattr_setpshared(mattr, PTHREAD_PROCESS_SHARED); pthread_mutex_init(meta_-mtx, mattr); pthread_mutexattr_destroy(mattr); pthread_condattr_t cattr; pthread_condattr_init(cattr); pthread_condattr_setpshared(cattr, PTHREAD_PROCESS_SHARED); pthread_cond_init(meta_-not_empty, cattr); pthread_condattr_destroy(cattr); }PTHREAD_PROCESS_SHARED决定了锁和条件变量能跨进程使用。这个属性一旦漏掉后续所有跨进程同步都会失效而且表现很隐蔽往往是“偶尔能用、经常卡死”的随机现象排查起来相当难受。3.3 写入接口实现写入时先加锁计算剩余空间然后分两段写入因为环形缓冲区可能出现“尾部空间不够、数据要绕到头部”的情况ssize_t ShmFifo::write(const void* src, size_t len) { if (!src || len 0) return 0; pthread_mutex_lock(meta_-mtx); uint32_t cap meta_-capacity; uint32_t used meta_-write_pos - meta_-read_pos; if (used len cap) { // 空间不足这里选择返回错误也可以实现“覆盖最旧数据”策略 pthread_mutex_unlock(meta_-mtx); errno EAGAIN; return -1; } const char* p static_castconst char*(src); uint32_t write_pos meta_-write_pos (cap - 1); size_t first_chunk std::min(len, (size_t)(cap - write_pos)); memcpy(data_ write_pos, p, first_chunk); if (len first_chunk) { memcpy(data_, p first_chunk, len - first_chunk); } meta_-write_pos (uint32_t)len; pthread_cond_broadcast(meta_-not_empty); pthread_mutex_unlock(meta_-mtx); return (ssize_t)len; }这里的used write_pos - read_pos用了无符号整数减法即使 write_pos 已经回绕过一次只要读写之间没有超过 2^32 字节计算结果就是正确长度。读者可能注意到write_pos没有对容量取模存回元信息而是让它一直自增到溢出回绕这是环形缓冲区常用的“无符号回绕”技巧配合取余操作就能定位实际内存偏移。3.4 读取接口实现读取的逻辑和写入对称核心差异是“可能阻塞等待数据”。我用条件变量实现阻塞语义避免消费者忙轮询浪费 CPUssize_t ShmFifo::read(void* dst, size_t len, bool block) { pthread_mutex_lock(meta_-mtx); while (meta_-read_pos meta_-write_pos) { if (!block) { pthread_mutex_unlock(meta_-mtx); return 0; // 无数据且非阻塞直接返回 } pthread_cond_wait(meta_-not_empty, meta_-mtx); } uint32_t cap meta_-capacity; uint32_t used meta_-write_pos - meta_-read_pos; size_t to_read std::min(len, (size_t)used); uint32_t read_pos meta_-read_pos (cap - 1); size_t first_chunk std::min(to_read, (size_t)(cap - read_pos)); char* p static_castchar*(dst); memcpy(p, data_ read_pos, first_chunk); if (to_read first_chunk) { memcpy(p first_chunk, data_, to_read - first_chunk); } meta_-read_pos (uint32_t)to_read; pthread_mutex_unlock(meta_-mtx); return (ssize_t)to_read; }读取完成后我没有额外发条件变量信号因为当前版本是单生产者单消费者模型消费者拿走数据后生产者只要在下次写入前判断空间是否足够即可。如果是多消费者场景这里需要再补一个not_full条件变量唤醒等待写入的生产者。3.5 资源回收与清理资源回收是共享内存最容易出问题的一环。进程退出或崩溃时如果只是munmap共享内存对象依然存在于/dev/shm下残留对象可能导致下次启动拿到脏数据。完整清理逻辑ShmFifo::~ShmFifo() { if (base_ base_ ! MAP_FAILED) { munmap(base_, total_size_); base_ nullptr; } if (meta_ meta_-is_owner) { shm_unlink(name_.c_str()); } }这里有个设计取舍shm_unlink只是摘除路径名不会立刻释放已映射的内存所以正在使用该共享内存的进程不受影响。我用is_owner标记避免每个进程退出时都去 unlink只有最初的创建者负责清理命名对象。4. 踩坑实录多进程共享内存容易翻车的 4 个细节4.1 进程崩溃导致锁死锁普通互斥锁有一个致命问题如果持有锁的进程突然崩溃段错误、被 kill -9内核不会自动释放这个用户态锁其他进程会永久阻塞在pthread_mutex_lock上。我在压测时用kill -9杀生产者消费者立刻卡死整个链路瘫痪。解决方案是把互斥锁改成 robust 互斥锁。初始化时加一个属性pthread_mutexattr_setrobust(mattr, PTHREAD_MUTEX_ROBUST);加锁后要额外判断返回值int rc pthread_mutex_lock(meta_-mtx); if (rc EOWNERDEAD) { // 上一个持有者崩溃锁状态已损坏需要恢复 pthread_mutex_consistent(meta_-mtx); // 数据状态可能不一致最安全的选择是重置队列 meta_-read_pos 0; meta_-write_pos 0; }这样即使生产者崩溃消费者也能感知到异常并恢复。代价是 CPU 开销稍有上升但换来的稳定性完全值得。4.2 初始化竞态谁来做“第一个初始化的人”我早期用meta_-initialized布尔值来标记是否已完成初始化发现高并发下多个进程同时打开共享内存时会出现两个进程同时初始化锁的情况锁内存被重复初始化行为完全不可预测。后来改成方案创建者身份由shm_open的O_CREAT | O_EXCL返回值决定只有真正创建共享内存对象的那个进程执行init_meta()。其他进程通过EEXIST分支打开已有对象直接校验 magic 后使用。这样就从入口上避免了竞态。4.3 头尾指针回绕与 ABA 问题有朋友问过write_pos和read_pos都是无符号 32 位会不会出现“读位置追上写位置”的歧义确实会但只发生在队列完全满的时候used capacity和used 0在数值上没法区分都是write_pos read_pos。我的处理是预留一个单元的“空洞”即实际最多存capacity - 1字节。写入前判断used len capacity - 1这样队列永远不会出现“全满”状态读写位置相等就唯一表示“空”。代价是损失一个字节的容量对于 1MB 的队列可以忽略。另外这里提到的 ABA 问题不同于无锁栈里的 ABA。更多是指环形缓冲中位置回绕后内存复用导致读写方看到的指针状态“看似没变、实则不同”。用无符号回绕 容量取 2 的幂可以把这个风险降到最低但如果是无锁读写还需要借助 sequence number 来规避这块展开讲又是一大篇等下次单独开一篇聊。4.4 伪共享与性能瓶颈多进程读写同一个队列时read_pos和write_pos如果落在同一个 cache line 上生产者和消费者会互相竞争同一块缓存行的写权限性能下降非常明显。这是典型的伪共享问题。解决方法是在元数据布局上做手脚把读位置和写位置分隔到不同的缓存行struct ShmMeta { alignas(64) std::atomicuint32_t read_pos; alignas(64) std::atomicuint32_t write_pos; // 其他字段... };我实测过在 8 核机器上两个进程高强度交换数据伪共享优化前后吞吐差距大约在 30% 到 50%。“无关数据不要放同一个缓存行”这句老话在这个场景里体现得淋漓尽致。还有一个小细节mtx锁本身也会和读写位置竞争缓存行所以我在布局时用 reserved 字段做了物理隔离把锁放到独立缓存行区域。5. 实测效果与扩展方向5.1 性能对比共享内存 FIFO vs Unix Socket我在一台普通 Linux 机器上做过一组对比测试交换 1KB 消息各发 10 万条指标Unix Domain SocketShmFifo单条平均延迟约 8 微秒约 1.5 微秒P99 延迟约 35 微秒约 3 微秒总耗时约 850 毫秒约 160 毫秒CPU 使用率偏高存在系统调用稳定用户态操作这个数据虽然简陋但不影响结论共享内存方案在延迟和吞吐上都有明显优势尤其是在小消息、高频率的场景下省掉的那几次系统调用立竿见影。大块数据传输时共享内存优势更夸张因为 memcpy 基本能跑到内存带宽上限。5.2 面试视角这个项目能带出哪些知识点ShmFifo 这个项目在面试里足够当“深挖型项目”来聊。面试官顺着项目往下问通常会覆盖这些点进程间通信方式对比、共享内存原理、mmap 与传统 read/write 的区别、环形缓冲区实现、多线程/多进程同步、锁的粒度、缓存行伪共享、条件变量与信号量的选择。我建议准备这个项目的朋友把每个决策点从“怎么做的”上升到“为什么这么做”。比如为什么用互斥锁而不是自旋锁因为消费者阻塞等待时大概率要休眠自旋会浪费 CPU。为什么条件变量要配互斥锁因为条件变量本身不携带状态必须先有锁保护共享变量再通过条件变量休眠和唤醒。这些问题想清楚了面试时对这个项目的描述会很有说服力。5.3 能力扩展无锁化、多读多写与跨平台基础版本的 ShmFifo 采用互斥锁同步性能已经够用。如果要进一步压榨性能可以往无锁方向演进单生产者单消费者场景下生产者和消费者分别持有一个原子变量通过内存屏障保证顺序可以实现完全无锁读写这也是很多高性能中间件的通用做法。多生产者多消费者场景则复杂得多通常需要对队列做分片或者引入细粒度的槽位锁。跨平台方面Linux 上我用的是shm_open系列Windows 上对应的 API 是CreateFileMappingMapViewOfFile核心思路一致只是接口不同。因为整个类的对外接口抽象得比较干净切换平台时只需要重写底层内存映射部分上层的 FIFO 逻辑和同步逻辑不用动。我在实际项目里还基于 ShmFifo 封装过一层“共享内存对象池”来传递大块视频帧数据队列本身只传引用计数和索引数据零拷贝。效果同样理想。这类思路可以继续衍生队列里传的不只是裸字节还可以是固定大小的结构体、事件描述符甚至是智能指针语义的索引。只要能保证共享内存生命周期管理和多进程同步正确ShmFifo 就能变成一个非常趁手的底层工具。本文还有配套的精品资源点击获取