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(阻塞唤醒)的尾延迟表现。