|
|
聊 C++ 的锁自由队列和环形缓冲,先把前提放清楚:所谓 lock-free,不是“永不阻塞”,而是保证系统整体前进(progress guarantee),哪怕某个线程挂起,其他线程也能推进。很多人把无锁和无等待等同,这会在工程里导致错误预期。真正要的是在高并发、低延迟的场景里减少内核调度、锁竞争和缓存抖动。
环形缓冲(ring buffer)是实现无锁队列的常见基石。单生产者单消费者(SPSC)最简单,两个索引 head/tail,各自只由一个线程独占更新,用内存序列保证可见性就能稳定跑。这里的关键在于:生产者写入数据后发布(store-release),消费者读 head 前需要获取(load-acquire)。如果用 C++ 原子,std::atomic head, tail;push 用 tail.fetch_add,pop 用 head.fetch_add;数据槽位通常先写后发布索引。SPSC 的内存模型可被简化到 acquire/release,不必到 seq_cst,延迟更低。像 Boost.Lockfree 的 spsc_queue、Folly 的 ProducerConsumerQueue 都是这个思路。
多生产者多消费者(MPMC)就难一大截。经典做法是每个槽位携带一个序号(sequence number),参考 Dmitry Vyukov 的 bounded MPMC 队列算法:生产者根据 tail 推算目标槽位,读取其序号判断是否可写,用 CAS 抢占,写完更新序号;消费者对称地根据 head 判断是否可读。这避免了全局锁,但增加了每次操作的指令和缓存流量。C++ 实现中,std::atomic 对序号与 head/tail 都要用,CAS 循环要小心退避策略,防止活锁。这个算法在很多库有实现,见 https://www.1024cores.net/home/lock-free-algorithms/queues/bounded-mpmc-queue。
要不要用无锁?我更倾向用“优先选择无争用、次选细粒度锁、最后再上无锁”的策略。原因有三:
- 调试难:ABA 问题、内存序的微妙 bug 很隐蔽。环形缓冲的有界特性能规避一部分(不用回收),但只要你跨核,多生产者就会有复杂的伪共享和总线流量问题。
- 性能未必赢:在低并发或负载不均时,一个简单的 mutex 队列配合批处理反而更稳。无锁常在高并发、低延迟尾部(p99/p999)才显优势。
- 可维护性:团队里能长期维护原子内存序代码的人不多,换人之后风险高。
工程建议几点,都是踩坑总结:
- 强制对齐与避免伪共享:head/tail、计数器要 cacheline 对齐并隔离,使用 alignas(64) 或库里的 cacheline_aligned。把热路径写入集中在各自线程私有的字段上。
- 容量用 2 的幂:使得取模可替换为位运算,减少分支;槽位结构尽量小、平铺存储。
- 只在必要处用 seq_cst:SPSC 足够用 acquire/release;MPMC 的 CAS 成功路径可用 memory_order_acq_rel,失败读取用 relaxed 配合后续 acquire。
- 回退策略与批处理:自旋几次->pause 指令->yield,避免长时间烧核;消费者可以一次性批量 pop,降低同步频率。
- 可见性边界要写在注释里:标明哪一步“发布数据”,哪一步“消费完成”。否则以后改动很容易破坏顺序保证。
- 压测要覆盖高水位:环形缓冲接近满/空的边界行为最脆弱,发生最差 cache 争用;同时观察 p99 延迟而不是均值。
最后给几个参考实现:Folly 的队列集合 https://github.com/facebook/folly,Boost.Lockfree https://www.boost.org/doc/libs/release/doc/html/boost/lockfree.html,以及 Disruptor 的单写多读场景设计思想 https://lmax-exchange.github.io/disruptor/。我自己的实践是:确定流量模式后,优先 SPSC(天然简单且快),需要 MPMC 时先评估能否做分片成多 SPSC,再不得已才上复杂的 CAS 环形队列。很多时候,好的分区和批处理,比“无锁”三个字更能解决问题。 |
|