MPMC Channel:环形缓冲的三种同步方式与阻塞唤醒的两种语义

💡 原文中文,约27200字,阅读约需65分钟。
📝

内容提要

文章比较了 Go channel 与 crossbeam 等有界环形缓冲的同步方式:一把锁、逐槽 stamp、head/tail 两阶段预留。实测表明三者都非严格无锁,停住一个线程会让其他线程睡、转或误判空。Go 1.6 起用直接交接保证 FIFO;crossbeam 用通知重试换取大缓冲下更快,但可能白醒和插队。DPDK 内存序在 C11 模型下有缺陷,2025 年才修正。

🔎

延伸解读

“无锁”标签的误导性

文章通过停顿实验表明,三种环形缓冲(一把锁、逐槽 stamp、head/tail 两阶段预留)在严格意义上都不是无锁的:一个线程停在操作中间,其他线程要么睡、要么自旋、要么误判空。因此,选择实现时不应只看“无锁”标签,而应关注线程停住时其他线程的进展方式,以及这种代价是否与部署环境匹配。

公平性与吞吐量的权衡

Go 1.6 的直接交接保证 FIFO 等待顺序,但每次操作需进出互斥锁;crossbeam 的通知重试在大缓冲下吞吐量更高,却可能让发送者被新来者无限期插队,最坏被抢先上万条。文章实测显示,容量 1024 时 retry 比 handoff 快约 2.6 到 2.9 倍,但公平性无保证。若程序依赖“先等先得”,需谨慎选择。

内存序缺陷的长期潜伏

DPDK 的 C11 实现从 2018 年到 2025 年存在默认模式下的偏序缺陷,修正后 TSan 仍报告竞争,因为等待前一个 tail 的 relaxed 读在 C11 模型下断开同步链。x86 的 TSO 模型掩盖了问题,但 ARM 等弱内存模型可能暴露。这提醒我们,内存序正确性不能仅靠 x86 测试,需借助 TSan 或形式化推理。

阻塞语义的隐藏成本

丢失唤醒是阻塞层的经典错误,文章实验显示,去掉“登记后再检查”步骤后,加宽竞争窗口时 2000 条消息中有 789 到 1088 次唤醒丢失。Go 用锁将检查和登记原子化,crossbeam 用 SeqCst 顺序保证。这类错误难以通过压力测试或 TSan 发现,需在设计和代码审查中特别注意。

❓

Q&A

Go channel 和 crossbeam-channel 到底是不是无锁的?

两者都不是严格无锁的。文章通过实验表明,当一个线程停在操作中间时,其他线程要么睡、要么转、要么误判空。Go channel 使用一把锁,停住持锁线程会让其他线程阻塞;crossbeam 的逐槽 stamp 环虽然数据路径无锁,但停住一个生产者后,其他生产者只能写满剩余槽位然后返回满,消费者则可能自旋等待。

有界环形缓冲有哪三种同步方式?它们的主要区别是什么?

三种方式为:一把锁(如 Go hchan)、逐槽 stamp(如 Vyukov 有界 MPMC、crossbeam array)、head/tail 两阶段预留(如 DPDK rte_ring)。区别在于同步粒度:锁保护整个缓冲;逐槽 stamp 在每个槽位用原子序号同步;head/tail 则用两对计数器,生产者先预留位置再发布,消费者类似。

Go 1.6 的直接交接(handoff)是什么?它解决了什么问题?

Go 1.6 起,当发送时若有等待的接收者,发送者直接将值拷贝到接收者栈上,不经过缓冲;接收时若缓冲满且有等待发送者,接收者取走队头后直接将发送者的值拷入刚腾出的槽位。这保证了 FIFO 公平性,被唤醒者醒来时操作已完成,不会白醒。

crossbeam-channel 的通知重试语义有什么优缺点?

优点是数据路径完全不碰等待列表的锁,没人在等时通知只是一次原子读,因此在大缓冲下吞吐量更高(实测比直接交接快约 2.6 到 2.9 倍)。缺点是不保证公平性,被唤醒的线程可能发现位置又被别人抢占,导致白醒和插队,最坏情况下一个发送者可能被抢先上万条消息。

DPDK rte_ring 的内存序在 C11 模型下有什么问题?何时修正的?

DPDK v25.11 之前,默认模式下的内存序存在缺陷:等待前一个 tail 时使用 relaxed 读,导致 C11 模型下同步链断裂,TSan 报告数据竞争。该问题在 2025 年 11 月 11 日的提交 a4ad0eba9d 中修正,将等待改为 acquire,并进入 v25.11。

丢失唤醒是什么?如何避免?

丢失唤醒指接收者发现 channel 为空决定睡眠,但在登记到等待队列前,发送者放入消息并检查等待队列发现无人,于是不唤醒任何人;接收者随后登记并睡下,再无人唤醒。避免方法是在登记后复查一次条件(如 crossbeam 的 is_empty 检查),或像 Go 那样用锁将检查和登记放在同一临界区。

🏷️

标签

➡️

继续阅读