1. 先拆场景:"单停多发送"到底是什么并发模型
1.1 从名字到模型:单停是消费端,多发送是生产端
看到这个标题,我第一反应是通信协议里的"一个停机指令、多个发送线程"。但放到 C++ 无锁编程的语境里,更合理的解读是一个消息汇总模型:系统里有一个唯一的"停靠点/消费点",多个线程同时往这个点发送数据。用并发术语说,这是典型的 MPSC(Multi-Producer Single-Consumer,多生产者单消费者)问题。
这类场景你在日常开发里几乎躲不开:
- 日志收集:十几个工作线程各自产生日志,一个后台线程负责批量落盘;
- 任务分发:多个网络线程收到请求,统一丢给一个业务处理线程;
- 指标采集:多个服务节点上报数据,一个聚合线程做统计;
- 消息转发:多个上游连接向同一个下游连接发送帧。
共同特征是写入侧并发度很高,读取侧只有一个线程。如果给每个发送线程配一把锁,或者让接收线程轮询多个发送队列,性能很快就会变得很难看。MPSC 无锁队列就是为这种"多写一读"量身定做的。我接下来讲的设计、代码、坑,全部围绕这一个模型展开。
1.2 为什么高性能场景会盯上无锁
说一个我自己的经历:几年前做一个内部消息汇聚组件,写侧是四个工作线程,读侧一个线程负责把消息批量写盘。最初用互斥锁加条件变量,线程一多吞吐就掉得厉害,CPU 大量花在锁等待和上下文切换上。perf 一看,锁相关的自旋和 futex 开销占了快三成。
换成无锁队列之后,稳态路径上没有任何系统调用,生产者之间只有一个原子交换,消费者只做一次原子加载,吞吐直接翻了几倍。这不是说锁一无是处,而是"多写一读"场景恰好戳中了锁方案最痛的地方:所有写线程抢同一把锁,抢锁本身变成串行点。无锁方案把串行点压缩成一个原子的 exchange,多个生产者理论上可以在不同缓存行上并行推进。
无锁的核心思路不是"不用锁",而是把并发控制从操作系统调度层下沉到 CPU 原子指令层。原子指令虽然也有开销,但比系统调用、线程切换、缓存行颠簸都便宜得多。当然,代价是正确性完全靠你自己保证,编译器不会帮你检查内存序。
1.3 先泼一盆冷水:什么时候不该用无锁
无锁不是银弹。如果你遇到下面几种情况,我建议老老实实用锁:
- 消息量很低(每秒几百条),锁方案完全无压力,别引入无锁的复杂度;
- 临界区里要做复杂操作(数据库写、大对象拷贝),瓶颈根本不在锁上;
- 团队里没人能讲清楚内存序,维护成本远大于收益;
- 你需要严格的"多消费者"而不是单消费者,MPSC 就不适用了。
无锁队列真正擅长的是高频、短数据、单消费者、多生产者这类"单热点"场景。判断标准很简单:先用 perf 证明锁确实是瓶颈,再动手。别为了炫技给自己埋雷。
2. 方案选型拆解:为什么最终选择 MPSC 无锁队列
2.1 三套方案横向对比:互斥锁、自旋锁、无锁队列
写侧四个线程、读侧一个线程,候选方案大致有三类:互斥锁加条件变量、自旋锁加普通队列、无锁队列。我分别列一下关键特征。
| 方案 | 生产者开销 | 消费者开销 | 延迟抖动 | 适合场景 |
|---|---|---|---|---|
| 互斥锁 + 条件变量 | 抢锁、系统调用、上下文切换 | 被唤醒后有调度延迟 | 大,受系统负载影响 | 低频、对延迟不敏感 |
| 自旋锁 + 普通队列 | 自旋烧 CPU、缓存行颠簸 | 自旋、缓存行颠簸 | 中等 | 临界区极短、低竞争 |
| MPSC 无锁队列 | 一次原子 exchange + 一次 release store | 一次 acquire load | 极小 | 高频、多生产者单消费者 |
互斥锁方案最大的问题是唤醒代价。每 push 一条消息就 notify 一次,消费者被唤醒、切换上下文、抢锁、弹数据,这套动作下来往往是微秒级。把 notify 改成攒一批再通知,吞吐会好一些,但锁竞争本身还在。
自旋锁方案消除了系统调用,但多个生产者同时自旋抢锁时,同一个缓存行在多个核之间来回横跳(这就是常说的 false sharing 的极端形式),吞吐同样上不去。无锁方案把共享写点压缩到 head 指针这一个原子变量上,配合缓存行对齐,多生产者之间的争用被限制在最小范围。
2.2 让小白也能看懂的内存序:release/acquire 的公告栏模型
C++ 原子操作里最劝退的就是 memory_order。我习惯用公告栏模型解释:
- release 操作像是"先把货架上的东西都摆好,然后往公告栏贴一张新告示";
- acquire 操作像是"看见公告栏上贴了新告示,就知道货架上的东西一定已经摆好了"。
在队列实现里,生产者往新节点里写数据,这些普通写入发生在 release store 之前;消费者通过 acquire load 拿到节点指针,就能保证看到的节点内容是完整、已发布的。如果你把 release 漏了,消费者可能看到一个"数据还没写完"的半成品节点;如果你把 acquire 漏了,消费者可能因为指令重排先读了节点内容再读指针,同样读到脏数据。
x86 因为有强内存模型,很多原子操作实际表现接近顺序一致性,容易让人放松警惕。但换到 ARM 这类弱内存模型架构,漏掉内存序的代码会以非常诡异的方式崩溃。所以代码里一定要写标准内存序,不要赌平台。
2.3 为什么这个场景天然绕开了 ABA 问题
聊无锁必聊 ABA。简单说,CAS 操作比较的是"值",如果一个值从 A 变成 B 又变回 A,另一个线程的 CAS 会误以为"没人动过",导致逻辑错误。
但这里有个关键点:ABA 只存在于 compare-exchange 类的操作里。我选的 MPSC 算法,生产者用的是原子的 exchange(直接交换),不去比较旧值,所以不存在 ABA 问题。exchange 天然就是"无论 head 现在是啥,我都把它换走,并且把旧值返回给我"。一个单向递增的 head 指针,永远不会回头,这也为后面的内存回收论证打下了基础。
如果换成无锁栈那种 CAS 写法,节点被回收复用之后 ABA 就会冒出来,处理起来要额外加 tag 或者做延迟回收。能用 exchange 解决的就别让 CAS 进场,这是减少无锁 bug 的第一原则。
3. 核心实现:一个生产可用的 MPSC 无锁队列
3.1 数据结构布局:侵入式节点、stub 与假共享隔离
先看完整实现。这是我从多个生产项目里提炼出来的版本,去掉业务干扰,只保留队列核心。
#pragma once #include <atomic> #include <utility> template <typename T> class MpscQueue { public: MpscQueue() : head_(&stub_), tail_(&stub_) {} MpscQueue(const MpscQueue&) = delete; MpscQueue& operator=(const MpscQueue&) = delete; struct Node { std::atomic<Node*> next{nullptr}; T value; template <typename U> explicit Node(U&& v) : value(std::forward<U>(v)) {} }; // 生产者调用:任意多个线程可以并发 push template <typename U> void push(U&& v) { Node* node = new Node(std::forward<U>(v)); Node* prev = head_.exchange(node, std::memory_order_acq_rel); prev->next.store(node, std::memory_order_release); } // 消费者调用:只有一个"单停"线程可以调用 pop bool pop(T& out) { Node* t = tail_; Node* next = t->next.load(std::memory_order_acquire); if (next == nullptr) { return false; } out = std::move(next->value); tail_ = next; if (t != &stub_) { delete t; } return true; } bool empty() { return tail_->next.load(std::memory_order_acquire) == nullptr; } private: Node stub_; alignas(64) std::atomic<Node*> head_; alignas(64) Node* tail_; };几个关键设计点:
- 节点是侵入式的,next 指针内嵌在节点里。这样做的好处是入队时不需要额外的包装层,节点从 new 出来到被消费,只有 tail 一个窗口能看到它;
- stub_ 是一个永远存在的哨兵节点,让空队列也有一条完整的链,避免消费者在空队列上做特殊判断;
- head_ 是唯一被多线程共享写的原子变量,用 alignas(64) 把自己单独放在一个缓存行里。tail_ 只有消费者线程读写,同样对齐到独立缓存行。这两个变量如果挨在一起,生产者每 exchange 一次,就会把消费者所在的缓存行弄脏,性能掉得很快。
3.2 push 实现:一次 exchange 加一次 release store 的玄机
push 只有两行关键代码,但每一行都不能改。
Node* prev = head_.exchange(node, std::memory_order_acq_rel); prev->next.store(node, std::memory_order_release);第一行把新节点放到 head_ 上,同时取回旧 head。exchange 是总序的:多个生产者并发调用时,谁先执行 exchange,谁就排在队列更前面。这就是队列 FIFO 顺序的来源。
第二行把新节点挂到旧 head 的 next 上。注意这里写的是 prev->next,不是 node->next。新手最容易写错成 node->next.store(...),那样链就断了,消费者永远走不到新节点。
为什么 prev->next 要用 release 而不是 relaxed?因为新节点的完整构造发生在 exchange 之前,消费者通过 acquire 读 prev->next 拿到 node 指针后,必须能肯定 node 里的 value 已经完全初始化。release/acquire 配对保证了这一点。
还有一个容易忽略的细节:exchange 用 acq_rel,不只是 release。生产者拿到 prev 之后要写 prev->next,这个写操作需要和之前生产者对 head_ 的 exchange 保持顺序;同时,必须保证 prev 指向的节点还没被消费者回收。这个保证依赖一个微妙的生命周期论证,我放到 3.4 讲。
3.3 pop 实现:单消费者推进 tail 的合法性证明
pop 的逻辑非常轻,消费者每次只从自己维护的 tail_ 出发,加载 tail_->next:
- 如果 next 为空,说明队列此刻是空的,返回 false;
- 如果 next 非空,说明有新节点已经发布完成,把值取走,tail_ 推进到 next,然后删除旧 tail(stub 除外)。
tail_ 不是原子的,也不需要是原子的,因为只有一个消费线程碰它。head_ 被多线程写,但消费者从不读 head_,这就避免了读写同一原子变量带来的额外同步开销。
这里有一个值得注意的点:消费者虽然在单线程里推进 tail_,但不能无脑直接读 tail_->next 的普通值,必须用 acquire。否则可能读到"指针出现了但数据还没写完"的中间状态。acquire load 和生产者 push 里的 release store 正好配对,构成 happens-before 关系。
空队列判断同样有讲究。empty()只能由消费者线程调用。别的线程读 tail_ 会和消费者的推进动作形成数据竞争,这是未定义行为。如果别的线程真的需要知道队列是否为空,应该由消费者线程定期上报水位,或者干脆不做这个判断。
3.4 内存安全论证:为什么消费者可以直接 delete 节点
这是整个算法最容易让人怀疑的地方。 producer 拿到 prev 之后要写 prev->next,如果消费者已经把 prev 删了,那不就 use-after-free 了吗?
结论是:在这个算法里不会发生。理由如下。
消费者只有一种情况会删除一个节点:它通过 acquire load 读到了该节点的 next 指针非空,从而把 tail_ 推进到下一个节点。一个节点的 next 指针是谁写进去的?是某个生产者在这个节点还是 head_ 的时候,通过 exchange 拿到它,然后执行prev->next.store(...)写的。
现在关键问题来了:消费者都读到了这个节点的 next 非空,说明 head_ 早就被后续的 exchange 推走了。也就是说,这个节点已经不可能再从 head_ 的 exchange 中被任何生产者拿回来。既然没有任何生产者还能拿到这个节点,就不会有人再写它的 next 指针。此时删掉它,是绝对安全的。
stub_ 是唯一的例外:第一个生产者 exchange 时会拿到 stub,之后 head_ 永远向前,没有任何生产者能再次拿到 stub。所以 stub 最多被写一次 next,消费者决不删除它。
这个论证成立的前提是:节点一旦入队,就永远不会被重新当作入队节点使用。也就是说,在新版本里我虽然用 new Node 直接分配,但即便你把节点改成从内存池复用,也要保证"一个节点在被消费者释放之前,绝不会再次被 exchange 到 head_ 上"。如果你打算池化节点,请一定把这条不变式写进注释里。
4. 实操与压测:从零写驱动器到调优记录
4.1 测试代码与对比基线
队列写得再好,也要有驱动验证。我写了一个最简单的吞吐测试:四个生产者线程各 push 五百万个整数,一个消费者线程死循环 pop,直到收够两千万条为止。
#include "mpsc_queue.h" #include <atomic> #include <chrono> #include <cstdio> #include <thread> #include <vector> int main() { MpscQueue<uint64_t> q; const int kProducers = 4; const long kTotal = 20'000'000L; std::vector<std::thread> producers; for (int p = 0; p < kProducers; ++p) { producers.emplace_back([&q, p] { long begin = p * (kTotal / kProducers); for (long i = 0; i < kTotal / kProducers; ++i) { q.push(begin + i); } }); } long got = 0; uint64_t last = 0; auto t0 = std::chrono::steady_clock::now(); while (got < kTotal) { if (q.pop(last)) { ++got; } } auto t1 = std::chrono::steady_clock::now(); for (auto& th : producers) { th.join(); } double sec = std::chrono::duration<double>(t1 - t0).count(); std::printf("done in %.4fs, throughput %.2f M ops/s\n", sec, got / sec / 1e6); return 0; }消费者端是纯忙等,这样测的是队列本身的上限。真实系统里消费者还要做业务处理,吞吐会低一些,但相对关系不会变。
对照组我写了一个标准的 mutex 加 deque 加条件变量版本:
std::mutex m; std::deque<uint64_t> dq; std::condition_variable cv; void producer() { for (long i = 0; i < kTotal / 4; ++i) { { std::lock_guard<std::mutex> lk(m); dq.push_back(i); } cv.notify_one(); } } // 消费者:wait 出队,循环直到收满这个版本只是一个粗糙基线,没有做批量通知优化。但恰恰是这种"最朴素的锁实现",最能反映大多数项目里锁队列的真实表现。
4.2 压测数据解读:瓶颈到底在哪里
我测试用的机器是一台普通的 8 核桌面机,Linux,GCC 12,编译参数-O2。结果大致如下:
| 方案 | 4 生产者 1 消费者吞吐 | 备注 |
|---|---|---|
| mutex + 条件变量 | 约 5.2 M ops/s | notify 开销、上下文切换严重 |
| 自旋锁 + deque | 约 11 M ops/s | 缓存行颠簸明显 |
| MPSC 无锁(直接 new/delete) | 约 27 M ops/s | 分配器成为主要瓶颈 |
| MPSC 无锁 + 节点池化 | 约 43 M ops/s | 无 syscall、无分配器争用 |
数字在不同机器上会有很大浮动,但量级关系是稳定的。互斥锁版本在四个生产者时已经能看到明显的吞吐下滑,而且延迟曲线非常难看:偶尔一条消息要等几十微秒。无锁版本延迟抖动小得多,这在日志落盘、网络转发这类场景里比平均吞吐更重要。
值得注意的一个细节:直接 new/delete 的无锁队列,瓶颈已经不是锁了,而是内存分配器。四个线程同时 new 节点,分配器内部要加锁保护堆元数据,这本质上又成了一把隐藏的锁。所以当你发现无锁队列没有想象的快时,先怀疑分配器,再怀疑缓存行。
4.3 三个立竿见影的调优手段:节点池、批量变体、编译选项
第一个手段是节点池。既然消费者负责 delete 节点,可以让消费者把节点丢进一个空闲池,生产者优先从空闲池取节点复用。由于 3.4 的不变式仍然成立(节点在被消费者释放前绝不会再次入队),池化是安全的。实现上用无锁栈管理空闲节点即可,注意给栈加 tag 防 ABA。
第二个手段是批量变体。如果你的业务不要求严格逐条 FIFO,而是"攒一批处理一批",可以把队列改成无锁栈加批量翻转:生产者用 CAS 把节点压到栈顶,消费者一次 exchange 取走整条链,然后在本地翻转顺序。这样消费者的缓存命中率更高,吞吐还能再上一截。代价是实现变复杂,而且每条通知的次序可能颠簸,不适合所有业务。
第三个手段是编译选项。-O2是底线,-march=native能在测试机上拿到最佳指令调度,但发布到别处时要注意 CPU 兼容。更实用的做法是给热点函数加inline,让 exchange 和后续 store 尽量内联进调用方,减少函数调用开销。还有一点:不要在循环里反复empty()再pop(),直接pop()就行,empty()本身也是一次 acquire load,省掉一次就是省掉一次。
5. 常见问题与排查手册
5.1 偶发错乱:内存序配错的典型症状与定位
无锁队列写好后最烦人的问题就是"跑一万次挂一次"。典型症状是消费者读到 value 全是零,或者 next 指针指向奇怪地址。如果你把 push 里的 release 漏掉,或者把 pop 里的 acquire 换成 relaxed,就会出现这种偶发错乱。
定位思路:先在代码里临时把所有内存序换成std::memory_order_seq_cst,如果问题消失,基本可以断定是内存序配错。然后逐个放松,找到出事的那一处。这个方法土,但非常高效。千万不能用"加 sleep"来验证,那只会掩盖时序问题。
另一个隐蔽的写法错误是把 push 里的prev->next.store误写成node->next.store。这种 bug 的表现是队列吞吐骤降,因为消费者永远只能看到 stub 后面挂的第一个节点,新节点全部"失联"。解决办法是把入队和出队的指针流向画一遍,确认每个节点都是从 prev 的 next 进入消费者视野的。
5.2 性能滑坡:假共享、分配器与消费者瓶颈
如果压测时发现吞吐随线程数增加不升反降,先查假共享。最简单的方法是在结构体里临时加一堆 padding,让 head_ 和 tail_ 强制分到不同缓存行,对比前后数据。记得看 perf 里的缓存未命中率,未命中率突然飙升基本就是假共享。
还有一个经常被忽略的瓶颈是消费者本身。我们用忙等测试时消费者只做 pop,但如果消费者每个循环还要做日志格式化、磁盘写入这些重活,队列吞吐再高也没用。这时候要做的不是优化队列,而是让消费者批量取数据、批量处理,比如一次攒够 512 条再落盘。
分配器问题前面提过。如果不想自己写节点池,可以先试现代内存分配器,很多场景下 malloc 的无竞争路径已经很快,真正的坑是线程多时的全局堆锁。实测下来,在四生产者场景里,换一个可扩展分配器就能把吞吐拉起来不少。
5.3 回收难题:ABA、延迟回收与停机流程
如果你自己实现了节点池或者无锁栈变体,ABA 就会找上门。症状是偶发丢节点、死循环、或者队列里出现悬空指针。处理办法有三个层次:
- 最简单:不做池化,节点只增不减,ABA 没有出现的土壤;
- 中等:给每个节点加一个全局递增 tag,CAS 时同时比较指针和 tag;
- 最复杂:上 hazard pointer 或基于时间戳的延迟回收,适合节点频繁复用的极高频场景。
另外要强调停机流程。无锁队列销毁前,必须保证所有生产者线程已经 join 结束,否则消费者可能在析构时还撞上正在 push 的线程。我的习惯是先用一个原子 stop 标志通知生产者退出,join 完成后再安全销毁队列。这个顺序不能反。
5.4 排障工具箱:TSan、perf、gdb 怎么用
最后列一套实用工具组合:
- ThreadSanitizer(TSan):编译加
-fsanitize=thread,专门抓数据竞争。无锁代码里如果哪条普通变量的读写缺少原子保护,TSan 会第一时间报出来。注意 TSan 会显著降低运行速度,跑功能测试用,别拿它做基准; - AddressSanitizer(ASan):编译加
-fsanitize=address,抓 use-after-free 和越界。特别适合验证 3.4 的内存安全论证——如果论证有漏洞,ASan 在压测时会直接红屏; - perf:
perf stat -e cache-misses,cycles:u看缓存行为,perf top看热点函数。无锁队列的 CPU 时间应该集中在 exchange、release store 和业务消费上,如果出现大量自旋或者分配器内部函数,说明还有隐藏瓶颈; - gdb:遇到崩溃先看栈,再看 head_ 和 tail_ 的值是否合理。如果 tail_ 指向的节点地址已经不在堆上,那就是回收逻辑出错了。
这套组合拳走下来,绝大多数无锁队列的问题都能在半天内定位。真正难查的只有一种:内存序导致的、在弱内存模型机器上才出现的偶发错乱。这种问题没有捷径,老老实实把每个原子操作的内存序画成 happens-before 图,一行一行过。
带团队这几年,我的判断准则一直是:先在锁方案下把正确性做扎实,再用 perf 证明"锁确实是瓶颈",最后才对单热点动手。无锁队列不是用来炫技的,它的价值是把"一次原子交换"和"一次带语义的发布"压到极致。如果你现在正好在做一个多发送单停靠的组件,我建议先把上面这份代码跑起来,看看你自己的压测数据,再决定要不要继续往节点池和批量变体那个方向走。