无锁环形缓冲区CRingBuffer设计:从加锁到无锁的性能优化实践

发布时间:2026/10/20 8:46:39
无锁环形缓冲区CRingBuffer设计:从加锁到无锁的性能优化实践
做后台服务的人十有八九都遇到过这类场景一个线程在高速地产出数据包另一个线程必须以尽量低的延迟消费它双方速度稍有波动就得相互等。我之前在做一个采集模块时最开始直接用互斥锁加普通数组结果吞吐量一上去就出现明显的延迟抖动CPU 占用率高得离谱用性能剖析工具一看锁竞争的时间占了大头。后来花了几天时间自己设计了一个高性能线程安全环形缓冲区也就是标题里的 CRingBuffer把主要路径上的锁全部去掉了性能直接翻了几倍。这篇文章就把我整个设计和应用的过程记录下来包括数据结构怎么定义、内存序怎么选、缓存行对齐怎么做以及我在实际开发中踩过的那些坑。这篇内容适合谁看如果你正在处理生产者-消费者模型或者你对无锁编程、并发数据结构感兴趣又或者你只是想找一个轻量级、不用引入第三方组件的缓冲方案那这篇都值得读一读。我尽量用大白话讲清楚原理代码也全部可以直接落地复现。1. 从真实业务场景说起什么情况下我非要用环形缓冲区1.1 生产者-消费者模型的经典痛点我在好几个项目里都做过类似的功能一个模块负责采集数据生产者另一个模块负责处理数据消费者。最原始的方案就是一把锁加一个动态数组写端加锁 push读端加锁 pop。听起来简单但实际跑起来问题不少。首先是锁的竞争成本。两个线程频繁访问同一把锁意味着有很大的概率发生上下文切换。每次切换少说几百纳秒频繁切换时 CPU 的大量时间都花在调度器身上而不是在真正的业务逻辑上。其次是缓存命中率差。动态数组在 push 和 pop 时经常需要扩容或者搬迁元素而且数组中间的元素移动还会让 CPU 缓存大量失效。还有一点是内存分配频繁。如果用标准库队列容器每次 push 都要分配内存这个开销在超高吞吐场景下非常明显。所以我在设计时就给自己定了一个目标写路径上零锁、零系统调用、零动态内存分配让两个线程用最快的速度把数据交接出去。1.2 环形缓冲区是如何解决这些痛点的环形缓冲区的核心思路特别朴素提前分配一块固定大小的连续内存维护两个指针一个指向写入位置一个指向读取位置。写入时数据写到写指针处然后写指针前进读取时从读指针处拿数据然后读指针前进。当指针到达缓冲区末尾时回绕到开头继续逻辑上形成一个环。它的优点非常明显内存是预先分配的写入过程不需要申请内存也不会有碎片数据在连续内存上缓存友好指针的移动只是简单的整数加减和取模运算开销极低。更重要的是如果能保证同一时刻只有一个生产者和一个消费者环形缓冲区根本不需要加锁。生产者只修改自己的写指针消费者只修改自己的读指针双方各自读对方的指针来判断空和满只要处理好“读指针的更新对生产者可见”和“写指针的更新对消费者可见”就能做到完全无锁。2. CRingBuffer 核心数据结构与关键定义2.1 结构体的设计与内存布局我自己的实现是用 C 语言写的因为 C 语言在内存布局控制上最自由而且很多嵌入式场景根本不能用 C 标准库。核心结构体长这样#include stdatomic.h #include stdbool.h #include stddef.h #define CACHE_LINE_SIZE 64 typedef struct CRingBuffer { unsigned char *buffer; // 数据存储区 size_t capacity; // 缓冲区容量必须是 2 的幂 size_t size; // 单个元素大小 size_t mask; // capacity - 1用于快速取模 bool overwrite; // 是否允许覆盖最旧数据 _Atomic size_t head; // 生产者维护下一个写入位置 char head_padding[CACHE_LINE_SIZE - sizeof(size_t)]; _Atomic size_t tail; // 消费者维护下一个读取位置 char tail_padding[CACHE_LINE_SIZE - sizeof(size_t)]; } CRingBuffer;注意中间那几个padding数组它们不是随手加着玩的。head和tail被隔开到不同的缓存行上避免伪共享。这部分后面我会单独展开讲属于无锁队列性能的关键点之一。缓冲区数据区和结构体分开分配这样可以让数据区与结构体各自对齐到缓存行边界int CRingBuffer_Init(CRingBuffer *rb, size_t size, size_t capacity_elements, bool overwrite) { if (rb NULL || size 0 || capacity_elements 2) { return -1; } // 向上取整到 2 的幂 size_t cap 1; while (cap capacity_elements) { cap 1; } rb-buffer (unsigned char *)aligned_alloc(CACHE_LINE_SIZE, cap * size); if (rb-buffer NULL) { return -1; } rb-capacity cap; rb-size size; rb-mask cap - 1; rb-overwrite overwrite; atomic_init(rb-head, 0); atomic_init(rb-tail, 0); return 0; }有一点要提前说明实现环形缓冲区一般有两种组织方式一种是存定长元素每个槽位固定大小读写时按元素个数操作另一种是存变长字节维护的是字节偏移。我这里用的是前者因为定长元素更容易做无锁化语义也清晰。如果你要处理的是长度不等的消息可以在这个基础上每个槽位里放一个指针或者消息头原理是相通的。2.2 容量取 2 的幂一个看似奇怪却极其重要的约束很多第一次接触环形缓冲区的人都会问一个问题为什么容量非得是 2 的幂原因是取模运算太贵了。在 C 语言里index % capacity在容量不是编译期常量时会编译成一条除法指令耗时大约是几十个时钟周期。而如果容量是 2 的幂index mask只是一条位与指令耗时几乎可以忽略。在高吞吐场景下这看起来毫厘之间的差异会被放大每写入一个元素要做一次取模每读取一个元素也要做一次取模每秒百万级操作量时省下来的指令数相当可观。除此之外2 的幂容量还有一个额外好处回绕逻辑变得非常简单而且利于缓存行对齐。容量是 2 的幂意味着数据区的边界正好落在 2 的幂字节边界上这对某些架构上的地址对齐和预取是有益的。所以当有人问我我的 CRingBuffer 能不能支持任意容量答案是能但性能会打折。我建议的做法是初始化时向上取整到下一个 2 的幂最多浪费一半空间但能换来稳定的性能。2.3 两种工作模式覆盖式与非覆盖式环形缓冲区有两种经典的工作模式需要在设计阶段就想清楚第一种是非覆盖模式。缓冲区满时写入失败返回一个错误码。生产者要么等待要么丢数据。这种模式适合数据不允许丢失的场景比如日志落盘前的缓冲或者交易系统里的指令队列。第二种是覆盖模式。缓冲区满时新数据直接覆盖最旧的数据。这种模式适合实时性优先的场景比如音频采样、视频帧缓冲、遥测数据流。消费者如果来不及处理旧数据被覆盖是可以接受甚至是被期望的。两种模式在无锁实现里差别很大。覆盖模式下生产者写入前需要先判断是否有空位如果没有它需要跳过消费端的读指针直接覆盖旧数据但又要保证覆盖的位置不能是自己还没写完的数据逻辑上要谨慎处理。我在实现里用一个bool字段来标识模式在判断空闲空间的函数里分流static inline size_t CRingBuffer_FreeSpace(const CRingBuffer *rb) { size_t h atomic_load_explicit(rb-head, memory_order_relaxed); size_t t atomic_load_explicit(rb-tail, memory_order_acquire); if (h t) { return rb-capacity - (h - t); } else { return t - h - 1; } }注意我减了一个元素这是环形缓冲区常见的惯例让缓冲区最多存capacity - 1个元素而不是容量本身。为什么因为如果满的时候允许头和尾完全相等就没办法区分“空”和“满”了。虽然可以使用额外的计数变量来区分但那会引入额外的操作和一致性开销。为了在 SPSC 场景下保持极简我宁可浪费一个槽位。3. 无锁化的正确姿势原子变量、内存序与缓存优化3.1 加锁为什么慢锁的开销拆解要理解为什么我费尽心思做无锁设计得先看看一把锁到底贵在哪里。很多人只听说“加锁慢”但慢在哪个环节并不清楚。锁的获取和释放涉及到原子操作、内存屏障还有可能触发系统调用。在最坏的情况下如果两个线程同时抢一把锁其中一个要陷入内核态被挂起等锁释放后再被唤醒。这个过程叫做上下文切换代价极高。上下文切换除了保存和恢复寄存器之外还会污染 TLB 和各级缓存后续代码跑起来要重新预热。即便竞争不激烈每次加锁解锁都要执行至少两个原子指令这会强制缓存一致性协议对相关缓存行做同步。在 SMP 系统上一个原子操作动辄几百纳秒相当于几十条普通指令的时间。所以说锁的主要开销不只是操作系统调度还有它强制引入的缓存同步。无锁编程的思路就是尽量只对必要的少量字节做原子操作而不是保护一整片数据区域。3.2 原子变量与内存序的通俗理解在 C11 标准里原子操作和内存序是配套出现的。内存序这东西刚接触时很容易懵我用生活里的场景来类比。假设你和朋友约好你在家里写好信放到桌上然后发消息告诉对方“信已经放好了”。对方收到消息后再过来取信。在这个场景里“写信放到桌上”就是数据写入“发消息”就是更新head指针。对方“收到消息”就是读取head指针“取信”就是读数据。普通编程里你和朋友之间没有“发消息”这个同步动作你写完信就以为对方看到了这在多核 CPU 上是不成立的。CPU 和编译器为了优化指令执行顺序可能会让你“写信”和“发消息”的顺序反过来或者对方先“取信”再“确认消息”。内存序就是用来约束这种乱序行为的工具。我的实现中核心用到了两种memory_order_release用在生产者更新head的时候。意思是这个写入之前的所有读写操作都不能被重排到这个写入之后。memory_order_acquire用在消费者读取head的时候。意思是这个读取之后的读写操作都不能被重排到这个读取之前。翻译成刚才信件的类比生产者先写信然后用 release 发布消息意思是这条消息一旦发出大家就知道信已经写好消费者用 acquire 接收消息意思是既然收到了消息那信的内容一定已经就位。两者配合正好在数据和生产指针之间建立一个传递关系。如果是用比较老的 GCC 项目没有stdatomic.h的环境也可以用 GCC 自带的__atomic_load_n(rb-head, __ATOMIC_ACQUIRE)这类内置函数语义完全一样。3.3 伪共享问题性能杀手之一什么是伪共享我先说现象。我最初实现 CRingBuffer 时没加那两个 padding 数组跑一个简单的转发测试吞吐量和加锁版本差不多完全没有体会到无锁优化的威力。我一度怀疑是不是自己的无锁逻辑有问题。后来用性能工具逐行分析才发现问题出在 CPU 缓存上。现代 CPU 读取内存是以缓存行为单位一个缓存行通常 64 字节。head和tail在结构体里很可能被分配到同一个缓存行。生产者修改head时会让这个缓存行在其他核上的副本失效消费者修改tail时同样会让生产者的核上的副本失效。两边就不停地跨核心同步其实根本没共享的数据这种开销比真的共享数据还可怕所以叫伪共享。解决思路也很直接让 head 和 tail 分别对齐到不同缓存行中间垫上 padding 就行了。一个朴素的版本是这样但要提示一下sizeof(size_t)不同平台不一样所以直接写死 64 字节偏移在 32 位平台上可能仍然会重合严谨一点应该用编译期判断。不过在我的目标平台上64 字节缓存行配 8 字节size_tpadding 是没问题的。我还遇到过更隐蔽的情况数据区本身也占用了缓存行资源如果缓冲区容量小数据区和某个指针挨得太近也可能引发类似的干扰。所以我的aligned_alloc也把数据区对齐到了缓存行边界。4. 完整实现SPSC 无锁环形缓冲区的编码实战4.1 初始化与销毁前面给出了初始化函数销毁就更简单了void CRingBuffer_Destroy(CRingBuffer *rb) { if (rb NULL) { return; } free(rb-buffer); rb-buffer NULL; rb-capacity 0; rb-size 0; rb-mask 0; }这里有一个细节原子变量销毁时不需要额外处理因为整个结构体都被宿主管理。但如果你把 CRCingBuffer 放进某个不会被释放的模块级静态区就不需要调用销毁函数反而要小心不要重复释放。4.2 单生产者单消费者写入的核心流程SPSC 场景下生产者代码可以做到极致的简洁。关键是尽量少做原子操作。我的目标数据路径上每个元素只做一次原子的 load 和一次原子的 store。先看判断是否有空间的函数static inline size_t CRingBuffer_Count(const CRingBuffer *rb) { size_t h atomic_load_explicit(rb-head, memory_order_relaxed); size_t t atomic_load_explicit(rb-tail, memory_order_acquire); return h - t; }这里有个关键点读head用 relaxed因为这是自己写的变量单生产者只有一个线程写relaxed 足够了读tail用 acquire因为要确保如果消费者已经更新了 tail那么它之前释放的槽位都应该立即可用。写入函数int CRingBuffer_Write(CRingBuffer *rb, const void *item) { size_t h atomic_load_explicit(rb-head, memory_order_relaxed); size_t t atomic_load_explicit(rb-tail, memory_order_acquire); // 检查是否已满 if (h - t rb-capacity - 1) { if (rb-overwrite false) { return -1; // 缓冲区满写入失败 } // 覆盖模式下直接跳过最旧数据 t atomic_load_explicit(rb-tail, memory_order_relaxed); } memcpy(rb-buffer (h rb-mask) * rb-size, item, rb-size); // 发布让消费者能看到这次写入 atomic_store_explicit(rb-head, h 1, memory_order_release); return 0; }很多人可能注意到了一个看似不合逻辑的点为什么head一直往上加不取模因为这里头是一个数学上比较巧妙的技巧。我让head和tail都单调递增而不是回绕 0 到capacity-1。只有当访问缓冲区时才做 mask运算。这样避免了一个大麻烦判断空满变得极其简单直接比较大小的差不需要考虑回绕边界条件。这个设计代价是理论上两个指针会一直增大最终溢出。但size_t是 64 位要溢出至少需要写入 18 亿亿个元素这种场景在实际中根本达不到可以忽略。另一个细节是覆盖模式下的t更新。覆盖写入时我宁可直接丢弃最旧的数据但为了保证缓冲区结构不被写穿需要把消费者的位置也往前推。严格地讲在这里直接再次加载 tail 是有一点风险的因为消费者有可能同时也在读取数据。更稳的做法是让覆盖写入由单消费者单生产者的约束来保证即覆盖只在消费者不在同时读时执行。我的项目里这两个模式是不同的产品配置覆盖率高的场景消费者频率低所以可接受。4.3 读取的核心流程读取在消费者线程中执行同样是极简路径int CRingBuffer_Read(CRingBuffer *rb, void *item) { size_t h atomic_load_explicit(rb-head, memory_order_acquire); size_t t atomic_load_explicit(rb-tail, memory_order_relaxed); if (h t) { return -1; // 缓冲区空 } memcpy(item, rb-buffer (t rb-mask) * rb-size, rb-size); // 告诉生产者这块空间我已经消费掉了 atomic_store_explicit(rb-tail, t 1, memory_order_release); return 0; }这里的内存序选择和写侧正好对称。读取head用 acquire因为需要确保看到生产者发布的所有数据读取tail用 relaxed因为这是自己写的变量无并发。更新tail用 release这样生产者 acquire 读 tail 时能看到我们释放的空间。要特别强调的一点是读指针的更新一定要在数据复制完之后。如果你先把 tail 往前推再 memcpy那生产者可能会立刻覆盖你还没读完的数据造成经典的数据竞争。这个顺序我吃了不少教训后面在踩坑列表里细说。4.4 批量传输接口高性能系统真正需要的如果每次只读写一个元素吞吐量再高也有天花板。生产者和消费者之间的协作频次直接影响着性能上限。所以我强烈建议增加批量接口。批量写入的思路是一次性计算可写空间然后连续写入多个元素最后只更新一次 headsize_t CRingBuffer_WriteBatch(CRingBuffer *rb, const void *items, size_t count) { size_t h atomic_load_explicit(rb-head, memory_order_relaxed); size_t t atomic_load_explicit(rb-tail, memory_order_acquire); size_t free_space rb-capacity - 1 - (h - t); if (count free_space) { count free_space; } size_t off h rb-mask; // 分两段拷贝从 off 到缓冲区末尾以及从缓冲开头到剩余部分 size_t first count; if (off first rb-capacity) { first rb-capacity - off; } memcpy(rb-buffer off * rb-size, items, first * rb-size); memcpy(rb-buffer, (const unsigned char *)items first * rb-size, (count - first) * rb-size); atomic_store_explicit(rb-head, h count, memory_order_release); return count; }批量读取的思路完全对称。这里有个核心收益原来要 n 次原子 store现在只要 1 次。原子操作虽然比锁快但它仍然有开销批量接口可以把这部分开销摊薄到多个元素上实测在高吞吐链路里能再提升一大截。5. 扩展从 SPSC 到 MPSC、MPMC 的取舍5.1 多生产者场景的挑战我的 CRingBuffer 默认是 SPSC 模型也就是单生产者单消费者。这是最理想的情况因为双方各写各的指标互不干扰完全不需要锁。但实际项目里经常会问如果多个线程都要往里写怎么办最简单的方案是在生产者的入口加一把锁内部还是走我写好的快速路径。这个方案叫“粗粒度锁 无锁核心”实现成本低吞吐通常也能接受因为锁只保护生产者的竞争读路径完全无锁。如果想要真正的多生产者无锁那就需要用 CAS 操作来竞争分配写入槽位。核心思路是多个生产者试图通过 CAS 将 head 往后推进拿到一段空间后再写入数据。这里面的难点在于数据写入和 head 更新不能原子地同步两个生产者 A 和 B 竞争时A 抢到槽位 0-9B 抢到槽位 10-19但 B 可能先写完数据先更新了 head 到 20此时消费者会看到 0-19 的数据但其中 0-9 还没写完读到了半截数据。解决的常用手段是在每个槽位里加一个状态标志生产者写完数据后标记“完整”消费者检查到槽位完整才读取。这本质上又引入了新的同步变量和复杂度。所以我的结论很明确MPSC 场景能避免就避免除非你的数据写入逻辑足够简单且对延迟要求极端苛刻。架构上完全可以通过分片、打散等办法把多生产者问题降级成多个 SPSC。5.2 单生产者单消费者的正确性边界有人可能会问SPSC 模型下如果生产者和消费者真的同一时刻操作没有锁怎么保证数据安全核心在于它们操作的是不同的变量。生产者修改 head读取 tail消费者修改 tail读取 head。它们从不同时修改同一个变量也就没有必要互相互斥。真正的同步关系通过内存序来保证生产者写完数据后store head (release)。这个 release 把之前的 memcpy 以及对 buffer 的写操作都释放给了消费者。消费者load head (acquire)拿到新值能确认缓冲区中的数据已经完整写入。整个过程里没有哪一步需要生产者等待消费者也没有哪一步需要消费者等待生产者。唯一的“等待”只是数据层面上的读写顺序约束这种约束完全由原子变量的序关系来保证。5.3 与常见替代方案对比在做技术选型时我们项目里还考虑过其他方案方案优点缺点适用场景mutex 队列容器实现简单通用性好锁开销大分配次数多并发低、对延迟不敏感消息队列中间件功能丰富可持久化引入额外依赖延迟高跨进程、分布式场景无锁队列库性能好功能可靠依赖面板或额外选项需要成熟方案的团队自研 CRingBuffer极简、可控、极高吞吐需要自己保证正确性SPSC 为核心的高频场景我这边最终选择自研是因为依赖越少越好而且 SPSC 模型非常适合我的转发链路性能优势可以最大化。6. 性能实测加锁版本 vs 无锁版本6.1 测试方法与测试环境空谈无锁快是不行的我实测了一组数据。测试环境一台常见的 x86_64 服务器8 核 CPU内存 DDR4编译器 GCC 11编译选项-O3 -marchnative。测试程序创建一条环形缓冲区容量 65536 个槽位每个槽位 64 字节一个生产者线程不断写入数据一个消费者线程不断读取数据跑 10 秒统计总元素数。对照版本是用pthread_mutex保护同样的缓冲区结构其余逻辑完全一样。每组数据跑 3 次取中位数。6.2 实测结果与分析我整理了一个简化版的数据表方案单元素吞吐每秒单元素耗时估算加锁版本约 320 万约 310 纳秒无锁版本单元素接口约 2450 万约 40 纳秒无锁版本批量 64 个元素约 3.2 亿每元素约 3.1 纳秒这个差距挺直观的。加锁版本单元素耗时 310 纳秒主要花在锁操作和上下文切换上无锁版本降到 40 纳秒大概对应几次原子操作加内存拷贝。批量接口更好因为一次原子操作能摊薄到 64 个元素上。还有一个细节值得记录加锁版本两个线程数量增加到 4 个时性能下降更为明显锁竞争和上下文切换更频繁。而无锁版本因为根本不存在竞争多核扩展性天然就好。当然这种对比不完全公平因为加锁版本里锁保护了整个缓冲区的逻辑但这也正是我想说明的点无锁设计不仅仅是优化而是从根上规避了锁的代价。6.3 什么时候无锁不是最优解无锁并不永远是正确答案。有一种情况让我最终选择做两种模式共存缓冲区经常处于空状态生产者和消费者之间几乎没有重叠时间。在这种情况下加锁版本的锁其实没怎么竞争性能差异可能不太大而无锁版本需要处理复杂的边界条件代码维护成本高。还有一种情况是缓冲区写入频率极低比如一天才几千次。无限压榨这几千次操作的延迟意义不大反而引入无锁代码的复杂度不值得。所以我通常会建议先用简单方案等到性能不够了再优化不要一上来就无锁化。7. 踩坑记录我在实际开发中遇到的典型问题7.1 内存序设置错误导致的数据可见性 bug这是我踩过最隐蔽的一个坑。有一版我图省事把生产者的store head和消费者的load head都用成了memory_order_relaxed测试结果偶尔会出现消费者读到已经更新的 head却看不到对应的 buffer 数据读出来全是一堆脏数据。而且问题不定期复现完全不可预测。原因就是数据缓冲区的写入和对 head 的更新之间失去 happened-before 关系。改用 release/acquire 之后问题彻底消失。这次经历让我深刻理解了无锁编程里内存序不是可选项而是正确性的一部分。如果拿不准宁可先把序放宽再用专门工具去验证但绝对不要依赖运气。7.2 没有对齐结构体导致的性能幻觉有一版我把 head 和 tail 的 padding 写错了长度是 60结果 tail 还是在同一个缓存行里。当时我没意识到测出来无锁版本性能只有加锁版本的两倍觉得不对劲。后来用性能计数器排查发现cache miss非常高两个核在疯狂同步缓存行。解决方案就是我上面写的分别给 head 和 tail 后面垫了 64-sizeof(size_t)的字节。这个改动极其简单却带来了 3 倍以上的性能提升。所以无锁队列的性能瓶颈往往不在原子操作本身而在缓存布局。7.3 初始化时未把 size 取到 2 的幂导致的掉帧最开始我允许用户传任意容量内部用% capacity取模。我在一个高吞吐日志模块里用这个版本发现 CPU 占用始终高出一截。分析后定位到取模运算生成了除法指令。把容量限制为 2 的幂并用位与替换后模块整体 CPU 占用下降了大概 5%。这个收益不算大但胜在几乎零成本。7.4 一个从 64 位平台移植到 32 位平台时发现的坑我最初在 64 位平台上写代码size_t是 8 字节head和tail的自然对齐没有问题。后来要移植到一个嵌入式 32 位平台时发现_Atomic size_t的对齐要求可能只有 4 字节而我又用了 64 字节缓存行对齐导致结构体布局变化函数接口参数传递也出现混淆。最终我加了一个静态断言要求sizeof(size_t) 4才允许编译然后在初始化函数里做了平台相关的大小对齐分配才彻底解决。这种事在跨平台时很容易被忽略建议任何做底层组件的人都要在初始化时检查原子变量的自然对齐要求不要想当然地认为所有平台都一样。写在最后的经验体会这套设计在我实际项目里运行了很长一段时间给我最大的体会是无锁编程能不能做好取决于你愿不愿意逼自己去理解内存序和缓存一致性而不是靠一堆死记硬背的模板。每一步关键的取舍背后都有原因容量取 2 的幂是为了避免除法原子变量用 acquire/release 是为了建立正确的同步关系缓存行 padding 是为了规避伪共享。这些知识单独看都不复杂但组合在一起就能形成一个高性能、低延迟的数据通道。如果你正在设计自己的缓冲区我建议你不要急着抄代码而是先想清楚你的生产者和消费者的数量关系再决定要不要无锁化。只要方向对了后面所有细节都会顺理成章。