SPSC / SPMC / MPSC / MPMC 消息队列(四象限全景)
更新时间:2026-08-24。以"生产者/消费者拓扑"为坐标,完整论述四种无锁消息队列:SPSC(1:1)、SPMC(1:N)、MPSC(N:1)、MPMC(N:N)。现代高性能消息通道(网络协议栈、日志管道、实时音视频链路)几乎全部建立在这四象限之上。本文覆盖原理、C++ 实现、内存序分析与性能量化。
为什么要按四象限拆开讲
通用无锁队列(如 Michael-Scott 队列,见 lockfree-deep)解决的是 MPMC(多生产者多消费者) 最通用场景,代价是复杂度与开销都高:需要 CAS 竞争、需要复杂的内存回收。
但真实系统里大量场景是受限的生产者/消费者拓扑。四象限一览:
| 拓扑 | 生产者 | 消费者 | 典型场景 | 核心难点 | 本文章节 |
|---|---|---|---|---|---|
| SPSC | 1 | 1 | 单个采集线程 → 单个处理线程(音频帧、网络包、日志) | 无(最简) | 一、SPSC |
| SPMC | 1 | N | 单生产者广播/分发 → 多个消费线程 | 多消费者抢读 | 二·五、SPMC |
| MPSC | N | 1 | 多线程事件上报 → 单一聚合/落盘线程 | 多生产者抢写 | 二、MPSC |
| MPMC | N | N | 通用任务池 | 双向竞争 | lockfree-deep |
关键洞察:生产者/消费者数量越受限,队列就越简单、越快。SPSC 环形队列完全不需要 CAS、不需要锁、不需要内存回收——因为只有一个写者、一个读者。这正是它比 MPMC 快一个数量级的原因。SPMC 与 MPSC 是对称的镜像:难点分别落在"读"与"写"两个端点上。
一、SPSC 环形队列
1. 为什么环形缓冲 + 原子索引就够
核心思想:一个固定大小的数组,用两个索引维护:
head:消费者读到的位置(仅消费者写)tail:生产者写到的位置(仅生产者写)
每个索引只有一方写入,所以不存在"多线程同时写同一变量"的竞争。只需用 std::atomic 保证:
- 索引读写的原子性(8 字节对齐的 size_t 在 x86 上天然原子,但用 atomic 明确语义、防编译器优化)
- 数据发布的内存序:生产者先写数据,再用
release发布 tail;消费者用acquire读 tail,再读数据——保证"数据写入先于索引可见"。
2. 最小可用的 SPSC 实现
#include <atomic>
#include <vector>
#include <cstddef>
#include <optional>
template <typename T, size_t CAP> // CAP 必须是 2 的幂
class SPSCRingQueue {
static_assert((CAP & (CAP - 1)) == 0, "CAP must be power of two");
std::vector<T> buf_{CAP};
alignas(64) std::atomic<size_t> head_{0}; // 消费者写,与 tail 分离 cacheline
alignas(64) std::atomic<size_t> tail_{0}; // 生产者写
size_t mask_ = CAP - 1;
public:
// 生产者调用(仅一个线程)
bool push(const T& item) {
size_t t = tail_.load(std::memory_order_relaxed);
size_t h = head_.load(std::memory_order_acquire); // 读消费者位置
if (t - h >= CAP) return false; // 已满
buf_[t & mask_] = item; // 先写数据
tail_.store(t + 1, std::memory_order_release); // release 发布
return true;
}
// 消费者调用(仅一个线程)
std::optional<T> pop() {
size_t h = head_.load(std::memory_order_relaxed);
size_t t = tail_.load(std::memory_order_acquire); // 读生产者位置
if (h == t) return std::nullopt; // 已空
T item = buf_[h & mask_]; // 读数据
head_.store(h + 1, std::memory_order_release); // 消费完成后推进
return item;
}
};3. 这段代码为什么无锁、为什么正确
| 疑点 | 解答 |
|---|---|
| 为什么不需要锁? | 每个原子变量只有一个写者:head_ 只有消费者写、tail_ 只有生产者写。没有两个线程争抢同一写操作,无竞争即无锁。 |
push 里 tail_.load(relaxed) 安全吗? | 生产者是唯一写者,relaxed 足够保证自身顺序;head_ 用 acquire 读到最新消费者进度,决定是否满。 |
满/空判断的 t - h 环形技巧 | 索引单调递增不回绕(用 & mask_ 映射到数组下标),t - h 即当前元素数,CAP 为 2 的幂保证取模无溢出歧义。 |
| 为什么 CAP 必须 2 的幂? | 让 index & (CAP-1) 等价于 index % CAP,且保证环形下标的唯一性,避免 % 的除法开销。 |
| 内存回收问题? | 根本没有——对象常驻 buf_,无动态分配,无 ABA 问题。 |
4. 与 MPMC(MS 队列)对比
| 维度 | SPSC 环形 | MPMC(Michael-Scott) |
|---|---|---|
| 同步原语 | 原子 load/store(无 CAS) | CAS(tail->next 两步 CAS) |
| 内存回收 | 无(数组常驻) | 需要(HP/EBR/RCU) |
| 典型吞吐 | 100+ M ops/s | 20~35 M ops/s |
| 复杂度 | 低(~20 行) | 高 |
| 适用 | 单生产者单消费者流水线 | 通用任务池 |
二、MPSC:把"多生产者"接进单消费者
SPSC 只允许一个生产者。当有 N 个线程都要向同一消费者发消息时,需要 MPSC。
方案 A:N 个生产者共用一把锁入队(最简单)
// 每个 push 都加锁:简单、正确,但 N 越大竞争越严重
std::mutex mtx_;
std::queue<T> q_;
void push(T item) {
std::lock_guard lk(mtx_);
q_.push(std::move(item));
}问题:N 个生产者抢一把锁,吞吐随 N 下降(锁竞争,见 lockfree-deep 性能表)。
方案 B:boost::lockfree::queue(MPMC 通用,成熟)
// boost 无锁队列天然支持 MPMC,直接用即可
boost::lockfree::queue<T> q{1024};
q.push(item); // 任意线程方案 C:MPSC 专用链表(多生产者 CAS push,单消费者无锁 pop)
MPSC 的最优解往往不是环形(环形需要多个生产者维护同一 tail,退化成 CAS 竞争),而是单向链表 + 头插/尾插:
// 核心思想:多生产者通过 CAS 竞争"写尾",单消费者独占"读头",
// 生产者之间只有一次 CAS 竞争,消费者无竞争。
class MPSCQueue {
struct Node {
std::atomic<Node*> next_{nullptr};
T data;
};
std::atomic<Node*> tail_{nullptr}; // 多生产者 CAS 竞争点
Node* head_ = nullptr; // 消费者独占
public:
void push(T item) {
auto* n = new Node{std::move(item)};
Node* prev = tail_.load(std::memory_order_relaxed);
do {
n->next_ = prev; // 插到"队尾"(实际上 prepend)
} while (!tail_.compare_exchange_weak(
prev, n,
std::memory_order_release,
std::memory_order_relaxed)); // CAS 抢写 tail
}
// 消费者:翻转整条链表,逆序即 FIFO
T pop() {
if (!head_) head_ = tail_.exchange(nullptr); // 取走整条链
// ... 翻转 head_ 即可按序出队
}
};要点:
- 生产者间只有 tail 一个竞争点,用
CAS解决;消费者exchange(nullptr)一次性取走整条链,无竞争。 - 比环形 MPSC 快,因为消费者完全不碰生产者共享的 tail 热区。
方案对比
| 方案 | 生产者竞争 | 消费者竞争 | 复杂度 | 适用 |
|---|---|---|---|---|
| 单锁入队 | 高(锁竞争) | 无(独占) | 低 | N 小、延迟不敏感 |
| boost MPMC | 中(CAS) | 中(CAS) | 中 | 通用,想省心 |
| MPSC 链表 | 低(单点 CAS) | 无(独占) | 高 | N 大、追求吞吐 |
二·五、SPMC:单生产者、多消费者
SPMC 与 MPSC 是镜像对称的问题:MPSC 的难点在"多生产者抢写 tail",SPMC 的难点在"多消费者抢读 head"。多消费者要共享同一个读位置,天然存在竞争,无法像 SPSC 那样"每原子变量单方写"。
拓扑本质
| 生产者侧 | 消费者侧 | |
|---|---|---|
| SPMC | 单写者(无竞争) | N 个读者抢同一个读点(竞争核心) |
| 对比 MPSC | 多写者抢写尾 | 单读者独占(无竞争) |
方案 A:每个消费者一条独立 SPSC 队列(最常用、性能最好)
单生产者广播到 N 个消费者时,不要共享一个队列给所有消费者抢,而是给每个消费者一条独立的 SPSC 环。生产者 push 时复制/分发到各消费者队列;每个消费者独占自己的队列,退化为纯 SPSC(无锁、零竞争)。
// 单生产者 + N 消费者 = N 条独立 SPSC 环
class SPSCFanOut {
std::vector<std::unique_ptr<SPSCRingQueue<T, CAP>>> per_consumer_;
public:
SPSCFanOut(size_t consumers) {
for (size_t i = 0; i < consumers; ++i)
per_consumer_.push_back(std::make_unique<SPSCRingQueue<T, CAP>>());
}
void broadcast(const T& item) { // 单生产者:逐个投递
for (auto& q : per_consumer_) q->push(item);
}
SPSCRingQueue<T, CAP>* channel(size_t i) { return per_consumer_[i].get(); }
// 消费者 i 只读自己的队列,无竞争
};优点:每个消费者无竞争(纯 SPSC 速度)、生产者为单写者也无竞争;广播分发天然并行。 代价:消息要复制 N 份;内存占用 ×N。适合"一个源广播给多个订阅者"(日志分发、指标 fanout)。
方案 B:多消费者 CAS 竞争共享读点(负载均衡)
若目标是任务分发(每个消息只被一个消费者处理一次,而非广播给所有人),则共享一个队列,消费者用 CAS/原子 FAA 竞争取:
// 消费者之间用 CAS 竞争 head:谁抢到谁消费,保证每条只处理一次
// head 变为 atomic 且多消费者可写 → 从"单方写"退化为"多写者 CAS"
size_t claim() {
size_t h = head_.load(std::memory_order_relaxed);
size_t t = tail_.load(std::memory_order_acquire);
size_t claimed = 0;
while (h < t) {
if (head_.compare_exchange_weak(h, h + 1,
std::memory_order_release, std::memory_order_relaxed)) {
claimed = h;
break;
}
// 失败说明别的消费者已抢走,重读再试
}
return claimed; // 返回抢到的槽位,未抢到返回 0
}优点:每条消息只处理一次,天然负载均衡。 代价:消费者间 CAS 竞争,随 N 增大吞吐下降;且环形队列 + 多读者时"槽位是否可复用"的判断变复杂(生产者要等所有消费者都读过才能覆盖)。
方案 C:每消费者独立子队列 + 消费者自取(事件订阅常用)
类似方案 A,但消费者主动轮询/拉取而非生产者主动推。生产者只写一个"总分发器",各消费者按需拉取自己的分区数据(如 Kafka 的 partition 模型、Disruptor 的多个 Sequence)。
方案对比
| 方案 | 消费者竞争 | 消息复制 | 适用 |
|---|---|---|---|
| A:独立 SPSC 环(广播) | 无 | ×N | 一源广播多订阅者(日志/指标) |
| B:共享队列 CAS 抢读 | 中(CAS) | 无 | 任务负载均衡(每条只处理一次) |
| C:独立子队列 + 拉取 | 无 | ×N(可分区) | 订阅模型、分区并行(Kafka/Disruptor) |
选型要点:要"广播给所有人"→ 方案 A;要"每人处理一条、负载均衡"→ 方案 B;要"按 key 分区、各消费者并行处理自己的分区"→ 方案 C。广播场景千万别用共享队列,那会让所有消费者都抢同一条,既重复又慢。
三、内存序:为什么是 acquire / release
这是 SPSC 无锁正确性的核心,不能忽略。
// 生产者
buf_[t & mask_] = item; // ① 普通写:数据
tail_.store(t + 1, release); // ② release 写:发布索引
// 消费者
tail_.load(acquire); // ③ acquire 读:读到发布
T item = buf_[h & mask_]; // ④ 普通读:数据- release(生产者):保证 ① 不会重排到 ② 之后 → 消费者读到新 tail 时,数据必然已写入。
- acquire(消费者):保证 ③ 之前(数据读)不会重排到 ③ 之后 → 读到新 tail 后读到的数据是最新的。
x86 上 release/acquire 就是普通 mov(硬件强序保证),零额外开销;ARM/POWER 上会插入屏障(dmb ish)。这正是"弱内存序下测试远比写代码难"的地方(参考 lockfree-deep 工程建议第 5 条)。
错误示例(初学者常犯):生产者用 relaxed 发布 tail。这会允许编译器/CPU 把数据写入重排到索引发布之后,消费者可能读到新 tail 但数据还是旧的——数据竞争,UB。
四、工程实践与选型
1. 伪共享(false sharing)要治
head_ 和 tail_ 若在同一 cacheline,消费者推进 head 会 invalidate 生产者的 tail cacheline(互相踩),性能骤降。上面代码用 alignas(64) 分开两个原子变量,就是为隔离 cacheline(详见 false-sharing)。
2. 何时用 SPSC 而非 MPMC
| 场景特征 | 推荐 |
|---|---|
| 数据流天然是"一条管道"(采集→处理→输出) | SPSC,把管道各段解耦成 SPSC 环 |
| N 个源汇聚到 1 个消费者 | MPSC |
| 生产者/消费者数量运行时不定、通用任务池 | MPMC(boost/TBB) |
3. 选型决策树

4. 常用开源实现(生产可直接用)
- DPDK ring:C,SPSC/MPSC/SPMC/MPMC 全支持,网络转发黄金标准
- Folly
ProducerConsumerQueue:C++,SPSC 高性能实现 - Bounded MPMC(Vyukov):C++,通用有界环形 MPMC
- boost::lockfree:跨平台,MPMC,最省心
五、性能量化
在 2 核(绑核)Linux 上、64B 元素、1M 次 push/pop 的实测对比(供量级参考,具体值随硬件/编译器变化):
| 队列 | 吞吐 (M ops/s) | P99 延迟 (ns) |
|---|---|---|
std::queue + std::mutex(SPSC) | 5 | 900 |
std::queue + std::mutex(MPSC, 4 生产者) | 1.8 | 2500 |
std::queue + std::mutex(SPMC 共享队列, 4 消费者) | 1.5 | 3100 |
| SPSC 环形队列(无锁) | 120 | 25 |
| SPSC 独立环 fan-out(SPMC 广播, 4 消费者) | 90 | 35 |
| MPSC 链表(4 生产者) | 45 | 90 |
| SPMC 共享队列 CAS 抢读(4 消费者) | 22 | 320 |
boost::lockfree::queue(MPMC) | 20 | 380 |
数据来源:
demos/与 concurrency-benchmarking 测量方法;完整复现与源码见公开仓库 derekzhuo/geek-doc.cn 的experiments/spsc-mpsc-queue/。克隆后make run即可复现四象限吞吐对比。
结论:按"每原子变量单方写 / 竞争点最小化"这一主线排序,四象限吞吐大致为 SPSC ≥ SPSC fan-out(SPMC 广播)> MPSC 链表 > SPMC 共享 CAS > MPMC ≥ 有锁:
- SPSC 无锁环形比加锁快 ~20 倍,因为它零竞争;
- SPMC 若用"独立 SPSC 环广播"(方案 A)几乎不损失吞吐,用"共享队列 CAS 抢读"则掉到 ~22 M ops/s——拓扑受限 + 分区隔离 = 又快又简单;
- 关键不是"去掉锁",而是让每个原子变量只有一个写者 / 把竞争点最小化。
参考与衔接
- lockfree-deep —— MPMC 无锁队列(Michael-Scott)、CAS/内存回收分级
- memory-order —— 内存序全景:为什么需要 release/acquire
- cpp-mutex-types —— 有锁方案的互斥量选型
- concurrency-benchmarking —— 基准测试方法论
- false-sharing —— 伪共享成因与
alignas(64)隔离
一句话总结:消息队列的性能完全由生产者/消费者拓扑决定——SPSC 让每原子变量只有单方写入,无锁无 CAS 最快;MPSC 把竞争收敛到单一写 CAS 点、SPMC 用"独立 SPSC 环广播"或"单读 CAS"回避竞争、MPMC 双向 CAS 最通用也最贵;选型口诀是"拓扑越受限越简单越快,广播用 fan-out、分发用分区、别让多线程抢同一个原子变量"。