设计取舍
主线位置:四条主线的汇总。「一条消息」上的取舍是第 4、5、8 条,「一个任务」上的是第 3 条,「一块内存的回收」上的是第 2 条,「内存序横线」上的是第 7 条。机制都在前面各章,这里每条只写做法、代价、证据,证据链到有数字的那一页。
flowchart TB ws["1 独立 crate + 发布分支"] --> c1["修复要挑过去才发布"] ebr["2 EBR"] --> c2["长期 pin 让垃圾无上界"] vol["3 deque volatile 读写"] --> c3["技术上的 UB"] park["4 channel 自己 park"] --> c4["自己保证不丢唤醒"] sel["5 select 默认随机"] --> c5["想要优先级得换宏"] ac["6 AtomicCell 任意 T"] --> c6["锁回退调用方看不见"] ver["7 可验证性"] --> c7["两套内存序写法"] nolist["8 channel 不用 epoch"] --> c8["块的释放靠槽位接力"] port["9 std 只要 mpsc"] --> c9["select、定时 flavor 留在 crossbeam"]
1. 一个工作区,多个独立发版的 crate
做法:utils、epoch、deque、channel、queue、skiplist 各自编号、各自发版,依赖方向只往下(utils 在最底层,见 源码地图);门面 crossbeam 只是重导出。发版不从 main 出,而是把修复挑(cherry-pick)到 v0.8 分支上再发(怎么读这一卷)。
换来:只要一个队列的用户(sqlx、pingora)只依赖 crossbeam-queue 和 utils,不会把 epoch、channel 一起拉进来;main 可以放破坏性改动和实验,不影响 0.8 系列。
代价:「读 main 看到的代码」和「依赖的那个版本」不是一回事。写作时 main 比发布提交多 392 个提交,其中有会改变行为的:#1121 让有界 channel 在最后一个接收端 drop 时立即丢弃缓冲消息,只在 main 上,0.5.17 没有(常见坑:接收端都走了,有界 channel 里的消息还没析构)。另一面是版本对不齐:本卷的 skiplist 只能另用 main 上的 tag。各 crate 的 CHANGELOG 是判断「我用的版本有没有某个修复」的唯一可靠依据,本站的逐条对照在 版本差异。
2. EBR,而不是 hazard pointer 或引用计数
做法:读侧只在进出临界区时写一次本地纪元加一道 SeqCst fence(pin 与 Guard),临界区里读多少指针都不额外付钱;回收延迟到所有已 pin 的参与者跟上两个纪元之后(defer 与回收)。
换来:本机单线程一次 pin 加 drop 约 4 ns,重入的 pin 几乎同价(pin 与 Guard)。hazard pointer 要为每个读到的指针发布、加屏障、再校验,引用计数要为每次读做原子读改写(为什么需要内存回收)。
代价:垃圾没有上界。一个线程长期 pin 住,全局纪元停住,所有线程退休的对象一个都不析构:实验 E3 里 20 万个 1 KiB 对象零回收,RSS 涨到约 206 MiB;这时每次 flush 反而把只装一个对象的袋子推进全局队列,涨到约 721 MiB。没有人长期 pin 时也有速度上限:不 flush 的话每次 pin 平均最多回收约 4 个对象(defer 与回收,常见坑:一个线程一直 pin 着,内存只涨不降)。
3. deque 的槽位用 volatile 读写
做法:Buffer::read / write 是对任意 T 的非原子 volatile 访问。窃取者可能读到正被主人覆盖的槽,但只要随后的 CAS 失败,读到的值(MaybeUninit)就被丢掉、不使用(扩容与回收)。
换来:对任意大小的 T 都能工作,不需要「按字节原子拷贝」这种 Rust 稳定版没有的 API;快路径上没有额外开销。
代价:按内存模型这是数据竞争,即技术上的 UB。源码注释承认它,TSan 抑制文件为 deque 的 push / steal 开了口子,Miri 在 CI 里要用 -Zmiri-preemption-rate=0 关掉抢占才能通过(怎么验证并发代码)。上游有两条用原子 memcpy 消除它的分支,都没有合并。AtomicCell 的 seqlock 读是同一个论证(AtomicCell)。
4. channel 自己 park,不用 Condvar
做法:阻塞的线程把自己挂进 SyncWaker,用 thread::park 睡;唤醒方 CAS 对方 Context 的选中状态,成功的那一个才 unpark。Context 缓存在线程本地;SyncWaker 有个 is_empty 标志,没人等时 notify 只是一次 SeqCst 读,不碰锁(Context 与 Waker)。阻塞前先用 Backoff 自旋、让出约 11 次(array)。
换来:同一套 Context 能同时挂在多个 channel 上,这是 select 的前提;Condvar 绑在一把锁上,做不到「等任意一个」。快路径(不阻塞的收发)上没有锁。
代价:丢唤醒要自己防:注册之后必须再查一次状态,is_empty 的读写必须是 SeqCst 才能和这次复查配对。动手:最小有界 channel 把复查去掉,测试会卡死。阻塞前的自旋也不是白给的:把它加回 std 的复制版,1P1C 有界吞吐从 81 降到 55 Mmsg/s(和 std::sync::mpmc 对照);在别的形状下它又是 crossbeam 更快的原因之一,所以 std 删掉它不是简单的「更好」。
5. select 默认随机公平,偏置另给入口
做法:select! 和 Select::new 先用线程本地 xorshift 洗牌,再逐个尝试;按书写顺序的版本是另外的入口 select_biased!(0.5.13)和 Select::new_biased(0.5.14)(select)。
换来:多路合并时没有分支被饿死。E7:两个始终就绪的分支各被选中约一半(50228 / 49772)。
代价:想要优先级的代码,用默认宏会得到随机结果,而且种子固定,测试里每次运行结果都一样,看不出「随机」(常见坑:select! 没有按顺序选)。
6. AtomicCell 对任意 T 给同一个 API
做法:大小和对齐都对得上 AtomicU8..AtomicU64 的 T 走原生原子指令,其余走全进程共享的 67 把 seqlock;对调用方是同一个 load / store / compare_exchange,只有 const fn is_lock_free() 能看出走的是哪条路(AtomicCell)。
换来:AtomicCell<Instant>、AtomicCell<(u32, u16)> 这类类型不用自己写锁;tick channel 的投递时间就是这么存的。
代价:「原子」不等于「无锁」,而类型签名上看不出来。E2:(u32, u16) 大小 8 但对齐 4,走锁;两个不相干的 cell 恰好映射到同一把锁时慢 8 到 9 倍。另一方面,同一实验里无竞争时锁路径每次只多约 1 ns,两线程争同一个 cell 时 u128(锁)不比 u64(原子)慢,所以代价集中在「很多走锁的 cell 被不同线程高频写」这一种情形(常见坑:AtomicCell 不是 lock-free)。
7. 为 loom、Miri、TSan 付出的源码复杂度
做法:epoch 和 utils 通过 crate::primitive 一层在 cfg(crossbeam_loom) 下换成 loom 的类型;crossbeam_sanitize 把 epoch 的袋子从 64 缩到 4;crossbeam_sanitize_thread 下把「Relaxed + fence」改写成 TSan 认识的 Acquire / Release 访问;x86 上用 CAS 代替 fence(SeqCst) 的写法在 Miri 下关掉(怎么验证并发代码、本库里的内存序)。
换来:epoch 能被 loom 检查(Collector 与 LocalHandle 里在 loom 下跑了发布版的 epoch,10 374 个交错没有发现问题,换成 unprotected() 第 21 个交错就报错);CI 能在 Linux 上跑三种 sanitizer。
代价:同一处同步在源码里有两种写法,靠 cfg 选,读的人要分清哪一种是平时编译的;TSan 版本的 try_advance 甚至要先收集一个 Vec。覆盖面也不均匀:loom 只覆盖 epoch,deque、channel、queue 不在 loom 的视野里,本卷 05 章的 Chase-Lev loom 实验只能照着源码另抄一个模型(Chase-Lev 双端队列)。
8. channel 不依赖 epoch
做法:array 的槽预先分配,不需要回收;list 的块由读完最后一个槽的接收方释放,槽上的 READ / DESTROY 两个位做接力,谁最后离开谁 free(list)。channel 的释放用自己的 Counter(发送端、接收端各计一个数加一个 destroy 标志),不用 Arc(API 与 flavor)。
换来:channel 不需要 pin,也不会受「别处有线程长期 pin 住」的影响;依赖只有 utils。
代价:协议是专门为这个结构写的,出过事:0.5.12 修泄漏时引入了双重释放,0.5.12 到 0.5.14 全部被 yank,0.5.15 才修好(list)。另外「接收端走光时丢弃消息」要每个 flavor 自己实现,list 有、array 没有,这就是第 1 条里 #1121 补的那一处。
9. std 移植时只拿走 mpsc 需要的部分
做法:Rust 1.67.0 起 std::sync::mpsc 是 crossbeam-channel 的移植,删掉了 select、at / tick / never、Backoff::is_completed,select.rs 只剩 71 行(和 std::sync::mpmc 对照)。
换来:标准库只维护 mpsc 语义需要的那部分;mpmc 公开 API 至今是 unstable。
代价:两份同源的代码开始各自演化:std 先有了「有界 channel 接收端 drop 时丢弃消息」,crossbeam 的 main 后来才补上;std 删了阻塞前的自旋、CAS 失败后多读一次,吞吐在不同形状上各有快慢(E6:4P1C 无界 crossbeam 快约 6 倍,1P1C 有界 std 快约 3 倍,来源一部分已定位,剩下约 2 倍未查明)。需要 select!、定时 channel 或多个消费者的代码,仍然只能用 crossbeam。
小结
- 独立 crate 让依赖更小,发布分支让
main的修复不一定已经在你用的版本里;判断要看 CHANGELOG。 - EBR 读侧便宜、垃圾无上界;deque 的 volatile 读写通用又快、技术上是数据竞争;
AtomicCell统一 API、锁回退看不见。这三条都是「把代价放到调用方不常看的地方」。 - channel 自己 park、自己防丢唤醒,换来
select;select 默认公平,偏置要显式选择。 - 为了能被 loom、Miri、TSan 检查,源码里有两套内存序写法,而 loom 只覆盖 epoch。
- std 只拿走了 mpsc 需要的那部分,两边此后各自演化。