做后台服务的人,十有八九都遇到过这类场景:一个线程在高速地产出数据包,另一个线程必须以尽量低的延迟消费它,双方速度稍有波动就得相互等。我之前在做一个采集模块时,最开始直接用互斥锁加普通数组,结果吞吐量一上去就出现明显的延迟抖动,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_t,padding 是没问题的。
我还遇到过更隐蔽的情况:数据区本身也占用了缓存行资源,如果缓冲区容量小,数据区和某个指针挨得太近也可能引发类似的干扰。所以我的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 批量传输接口:高性能系统真正需要的
如果每次只读写一个元素,吞吐量再高也有天花板。生产者和消费者之间的协作频次,直接影响着性能上限。所以我强烈建议增加批量接口。
批量写入的思路是,一次性计算可写空间,然后连续写入多个元素,最后只更新一次 head:
size_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-9,B 抢到槽位 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 -march=native。测试程序创建一条环形缓冲区,容量 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 是为了规避伪共享。这些知识单独看都不复杂,但组合在一起,就能形成一个高性能、低延迟的数据通道。
如果你正在设计自己的缓冲区,我建议你不要急着抄代码,而是先想清楚你的生产者和消费者的数量关系,再决定要不要无锁化。只要方向对了,后面所有细节都会顺理成章。