Hang Zhengyang
English ↗

Ring buffer 与 mutex 队列对比

1) 你现在“任务形态”本质上是:两端都在忙等 + payload 极轻

在当前 spsc_example.cpp 里:

  • 发送的值基本是 payload(i)=i(极轻)
  • 两个版本的瓶颈都不是“计算”,而是同步/协调本身
  • 但你把 baseline 也改成了忙等轮询(mutex queue 也是空了就 backoff),所以两边都在“用户态自旋”,差距自然不会像“用户态 ring vs 内核 park/unpark”那样夸张。
  • 结果就是:两边都在比“谁的忙等策略更划算、谁的原子/锁路径更短”,很容易打成接近。

2) mutex 版本在这个 benchmark 里“没付出它最痛的代价”

很多人期待 ring buffer 赢,是因为想象的 mutex 代价是:

  • 每条消息都 notify + wait(频繁睡眠/唤醒)
  • 进入内核、调度器参与、cache 冷掉、延迟变大

但你现在的 mutex baseline 并没有走那个“频繁 park/unpark”路径,而是:

  • consumer 轮询拿锁看队列空不空(空就 backoff)
  • producer 只是 push(没有额外 syscall)

在 macOS/ARM 上,轻竞争/短临界区的 std::mutex 路径非常快(很多时候就是原子 + 少量内核辅助),不一定比“原子 ring + 自旋”差到哪去。
所以你没制造出“mutex 的典型弱点”(大量阻塞与唤醒),自然差距拉不开。

3) ring buffer 这边仍然会遇到“空/满抖动”,自旋成本被放大

即使你增大了容量、做了缓存索引,ring buffer 仍可能出现:

  • consumer 追上 producer → 频繁 empty
  • producer 追上 consumer → 频繁 full

频繁 empty/full 时,两端会不停 yield/pause(你现在 ARM 用的是 yield hint)。
这类“空转”成本在轻 payload 下会非常显眼,直接把吞吐拖下来。
换句话说:你测的更多是“等待对方”的成本,而不是“ring 的数据结构效率”。

4) 你把 batching 放到了 ring buffer,但 baseline 仍可能“隐式更批量”

即便你限制了 mutex 侧“不 drain”,但现实里仍有两个隐式 batching:

  • 时间上的 batching:producer 连续 push 很快,consumer 即便一次只 pop 1 个,也可能很少遇到真正的空(从而少 backoff)
  • 锁的局部性:短临界区+同一个 mutex,缓存局部性很好;而 ring buffer 需要在两个线程之间持续“同步 tail/head 的进度”,一致性流量更固定、更持续。

所以最终“批量优势”不会像你预期那样完全倒向 ring buffer。

5) 绑核只能减少抖动,不能改变“任务主导项”

你加了 macOS affinity tag 后:

  • 能减少迁核、降低波动
  • 但如果任务本身主要时间花在“等待对方 + 同步协调”,绑核不会把它变成“ring buffer 碾压 mutex”这种差距,只会让结果更稳定。

你应该如何改任务,才能显著拉开差距(两条最有效)

要让 ring buffer 的优势变成“硬优势”,需要把 baseline 推到它的劣势区,或把 ring buffer 放到它的优势区:

A) 让 baseline 变成真正的“阻塞队列”成本(ring 更容易赢)

  • 每条消息:producer notify_one
  • consumer wait,醒来只 pop 1 个再立刻 wait

这样会制造大量 park/unpark、调度器参与 → ring buffer 的“用户态自旋”在吞吐上更容易赢(代价是 CPU 占用更高)。

B) 把 ring buffer 变成“稳态满负载、几乎不空不满”

  • 控制两端速率,避免空/满抖动(例如 consumer 每处理 k 条做一点固定工作,让生产/消费速度接近)
  • 让 ring buffer 主要跑在“push/pop 都是 fast-path”的区间

这时它的优势(少锁/少系统参与/缓存友好)才会显著体现。
如果你说你想要对比的是 吞吐优先 还是 延迟(p99)优先,我可以把 spsc_example.cpp 调成对应的“典型对比任务”,并把输出改成更贴合该指标的统计方式。


为什么 吞吐优先 还是 延迟(p99)优先 会需要不同任务? 以及 实际使用中rb对标的是在什么场景下打败mutex?

1) 为啥“吞吐优先”和“p99 延迟优先”需要不同任务?

因为它们优化的“代价函数”完全不同,同一个队列/同步方案在两种指标下的最优形态也不同。

吞吐(msgs/s)看的是:单位时间完成多少工作
更偏好:

  • 批处理(batching / drain):一次醒来/一次锁住,处理一堆消息,把同步成本摊薄。
  • 少系统调用/少上下文切换:减少 park/unpark、减少调度器参与。
  • 忙等也可以接受:只要总体把消息推得快,CPU 打满也未必算“差”。

所以吞吐 benchmark 往往会故意:

  • 用大 N、连续生产、允许 batch pop/push;
  • 甚至让消费者“只要有数据就一直处理”,不要每条都阻塞等待。

p99 延迟看的是:最慢那 1% 的消息要等多久
更偏好:

  • 避免忙等占 CPU(忙等会放大系统抖动:抢不到时间片、迁核、频率变化、热/冷缓存切换)。
  • 及时唤醒、及时处理:宁可吞吐稍低,也要把尾部延迟压下去。
  • 减少抖动源:少自旋、少竞争热点、少“偶发”长暂停(GC/分页/调度)。

所以 p99 benchmark 往往会故意:

  • 引入到达间隔(Poisson/固定间隔);
  • 测 end-to-end(入队时间戳→出队时间戳),统计 p50/p90/p99;
  • 让消费者在空时阻塞等待(否则忙等会把“系统噪音”变成延迟尖刺)。

一句话:

  • 吞吐:看“平均效率”,允许用批处理和忙等把系统榨干。
  • p99 延迟:看“尾部最坏情况”,更讨厌忙等和调度抖动,常用阻塞/通知来稳住尾部。

2) 实际使用中,RB(SPSC ring buffer)什么时候能打败 mutex?

把 RB 的优势说清楚:它赢的不是“永远更快”,而是赢在这些特征组合上。

RB 典型能赢的场景(它的主场)

  • 严格 SPSC:一个 producer + 一个 consumer(不是 MPSC/MPMC)。
  • 高频小消息:每条消息 payload 很轻(例如指针、整数、短结构体),同步开销占主导。
  • 核心独占/绑核/实时偏好:两线程尽量固定在核上,减少迁核和抢占。
  • 希望避免锁竞争与内核路径:
    • mutex 在竞争/唤醒时可能进入内核、产生上下文切换;
    • RB 纯用户态,稳定情况下 push/pop 基本就是几次原子读写 + 内存拷贝。
  • 可接受忙等或可控 backoff:
    例如音视频、交易撮合、网络包处理这种“宁可占 CPU 也要快”的数据通路。
  • 固定容量、可背压:环形队列满就 backpressure/丢弃/降级,系统行为可控。

典型例子:

  • 网络收包线程 → 解析线程(SPSC)
  • 音频采集线程 → 音频处理线程(SPSC)
  • 撮合输入线程 → 撮合主循环(SPSC)
  • 采样线程 → 聚合线程(SPSC)

mutex 更可能赢/更合适的场景

  • 任务稀疏、经常空:消费者长期等待,RB 忙等会浪费 CPU;mutex+cv 可以睡眠,整体更省、更稳。
  • 延迟尾部更敏感且不能占 CPU:想要更低抖动、可预测的 idle 行为(阻塞等待)。
  • 多生产者/多消费者:RB 不适用或复杂度飙升,mutex 简单可靠。
  • 消息重、批处理天然存在:payload 很重时,同步开销被淹没,mutex 的相对劣势不明显。

3) 你现在这个 benchmark 为什么难让 RB “碾压”

你这里的 baseline 如果允许 cv 唤醒后 drain/batch,吞吐会非常好;而 RB 如果出现空/满抖动,就会在忙等上花大量时间。要“让 RB 赢”,就得把任务改成它的主场:稳态高频、少空满抖动、少内核唤醒、尽量绑核。

如果你选一个目标:

  • 吞吐优先:我会把 benchmark 改成“稳态满负载 + 限制 mutex 侧 drain(每次只 pop 固定数量)+ RB batch pop/push”,并输出 Mmsg/s。
  • p99 延迟优先:我会改成“带到达间隔 + 每条消息时间戳 + 统计 p99”,并对比 RB(自旋/退避)和 mutex+cv(阻塞唤醒)的尾延迟表现。