和 std::sync::mpmc 对照
主线位置:主线 1 的另一份实现。std 的
sync_channel(n).send()走的是同一条路:array::Channel::send→start_send→ 满了注册、park;差别在每一步的细节上,本页逐个列出来,并用实验测出哪些细节影响吞吐。
flowchart TB cb["crossbeam-channel"] -->|"1.67.0 移植(rust-lang/rust#93563)"| std["std::sync::mpmc(内部模块)"] std --> mpsc["std::sync::mpsc:包一层,只暴露单消费者"] std --> unstable["std::sync::mpmc 公开 API:unstable mpmc_channel"] cb --> d1["std 删掉:select、at / tick / never、Backoff::is_completed"] cb --> d2["std 改写:阻塞前不自旋、CAS 失败后重读、park 用 Thread 句柄"] cb --> d3["std 多出:有界 channel 接收端 drop 时丢弃消息"]
移植关系
Rust 1.67.0 的 release notes(RELEASES.md)写明 std::sync::mpsc 的实现换成了基于 crossbeam-channel 的版本,对应 rust-lang/rust#93563。本机 rustc 1.99.1 的 rust-src 里:
sync/mpsc.rs:149use crate::sync::mpmc;,Receiver(181 行)、Sender(340 行)、SyncSender(399 行)各只有一个字段inner: mpmc::Receiver<T>/mpmc::Sender<T>。mpsc是mpmc的包装,只是不给Receiver实现Clone。sync/mpmc/本身是pub mod,但标着#[unstable(feature = "mpmc_channel", issue = "126840")],稳定版不能直接用。mpmc/下的文件和 crossbeam 一一对应:array.rs、list.rs、zero.rs、context.rs、waker.rs、counter.rs、select.rs、utils.rs、error.rs、mod.rs(相当于 crossbeam 的channel.rs)。
本机没有 rust 仓库的 git 历史,std 一侧「哪次提交改了什么」本页不展开,只对照两份源码的现状:crossbeam-channel 0.5.17(2920c984)与 rust-src 1.99.1。
逐文件 diff
diff -w(忽略空白)的行数,「std 独有」和「crossbeam 独有」分别是 < 行和 > 行:
| 文件 | std 行数 | crossbeam 行数 | std 独有 | crossbeam 独有 | 主要差别 |
|---|---|---|---|---|---|
array.rs | 569 | 655 | 137 | 223 | 阻塞前自旋、CAS 失败处理、disconnect_receivers + discard_all_messages、SelectHandle、64 位索引 |
list.rs | 668 | 788 | 77 | 197 | 同上的自旋与 CAS;Box::new_zeroed vs alloc_zeroed;SelectHandle |
zero.rs | 325 | 486 | 43 | 204 | std 删了 empty_on_heap、SelectHandle、start_send / start_recv |
waker.rs | 209 | 287 | 27 | 105 | std 删了 can_select、watch / unwatch;线程 id 的取法 |
context.rs | 159 | 178 | 26 | 45 | std 删了 selected、wait_packet;wait_until 变成 unsafe fn |
counter.rs | 136 | 144 | 9 | 17 | 只有格式和 unsafe 块写法 |
select.rs | 71 | 1299 | 7 | 1235 | std 只剩 Token、Operation、Selected |
utils.rs | 137 | 73 | 117 | 53 | 两边内容完全不同:std 放 CachePadded 和 Backoff,crossbeam 放 shuffle、sleep_until、Mutex |
逐项说明:
① std 没有 select。 std select.rs 只有 71 行,SelectHandle、Select、SelectedOperation、run_select 和两个宏都没有。连带地,Waker 删了 can_select / watch / unwatch,Context 删了 selected / wait_packet,zero 删了堆上的 Packet 和 start_send / start_recv(std 的 zero 只在 send / recv 里配对)。
② std 没有 at / tick / never。 Sender / Receiver 都只有 Array、List、Zero 三种 flavor。Token 里还留着 zero: ZeroToken 字段,标着 #[allow(dead_code)]。
③ Backoff 是 std 自带的一份,曲线不同。 std utils.rs:113:
crossbeam Backoff | std Backoff | |
|---|---|---|
| CAS 失败后 | spin:1 << min(step, 6) 次,即 1、2、4……64 | spin_light:min(step, 6)² 次,即 0、1、4……36 |
| 等别人写完 | snooze:step ≤ 6 时 1 << step 次自旋,之后 yield_now | spin_heavy:step ≤ 6 时 step² 次自旋,之后 yield_now |
| 「该阻塞了」 | is_completed():step > 10 | 没有 |
std 没有 is_completed,因为它的唯一用途,即 send / recv 阻塞前的自旋循环,在 std 里被删掉了(下一条)。
④ 阻塞前不自旋。 crossbeam array 的 send 和 recv、list 的 recv,在注册之前有一个「start_* 失败就 snooze,直到 is_completed()」的内层循环,最多试 12 次。std 的 array send 只试一次 start_send,失败就直接 Context::with 注册、park。
⑤ CAS 失败后的处理。 crossbeam 在 start_send / start_recv 里 CAS 失败时直接用返回的当前值(Err(t) => { tail = t; backoff.spin(); });std 先退避,再重新 load 一次(std array.rs:171-172、list.rs:254-256):
// std list.rs start_send
Err(_) => {
backoff.spin_light();
tail = self.tail.index.load(Ordering::Acquire);
block = self.tail.block.load(Ordering::Acquire);
}
⑥ 有界 channel 的接收端 drop 时丢弃消息。 std array 有 disconnect_receivers 和 discard_all_messages,最后一个接收端 drop 时立即析构缓冲里的消息,也因此 std array 没有 Drop impl。crossbeam 0.5.17 的 array 只有一个 disconnect,消息要等发送端也走光(实验 E8:crossbeam bounded(16) 接收端 drop 后析构 0 条,std sync_channel(16) 析构 10 条,见 array)。这是 std 先有、crossbeam main 上的 #1121 才补上的一处。
⑦ 线程标识和 park。 std 的 Context::new 用 thread::current_or_unnamed()(std 内部 API,线程句柄还没建立时也能用),线程 id 是 current_thread_id:一个线程本地 u8 的地址,注释说 u8 没有析构函数,线程析构期间也能访问,而 thread::current() 不行。crossbeam 用 thread::current().id(),缓存在线程本地。std 的 wait_until 是 unsafe fn,调用 Thread 句柄上 std 内部的 unsafe park / park_timeout(要求「只能在句柄所属的线程上调用」),crossbeam 用公开的 thread::park()。
⑧ 锁的 poison。 std 的 SyncWaker 和 zero 用 Mutex::lock().unwrap(),会传播 poison;crossbeam 0.5.16 起用忽略 poison 的 utils::Mutex。
⑨ 索引类型与 CachePadded。 std 写成 Atomic<usize>(std 内部的泛型别名),没有 crossbeam 0.5.17 的「32 位平台上有 64 位原子就用 u64」。std utils.rs 里的 CachePadded 是删减版:x86_64 / aarch64 / powerpc64 为 128,arm / mips 为 32,s390x 为 256,其余 64;没有 crossbeam 的 arm64ec、sparc、hexagon、m68k 分支(见 CachePadded)。
⑩ 两边共有的修复。 list 的 discard_all_messages 两条读 head.block 的路径,std 也都是 swap(null),并且比 crossbeam 多一段注释解释原因(「must maintain the invariant that if a deallocation of head.block is attempted then it must also be set to NULL」),对应 crossbeam 的 #1084 加 #1187。std 的 Block::new 直接用 Box::new_zeroed,crossbeam 因为 MSRV 用 alloc_zeroed 加 handle_alloc_error(#1146 / #1147)。0.5.14 删掉 Context::wait_until 开头的乐观自旋(8144fbb4)以后,两边这个函数的结构一致。
实验 E6:同源的代码,吞吐差 3 到 6 倍
规划阶段的粗测(exp-crossbeam/survey/,每轮 200 万条 usize)发现两处反常。本批重测(exp-crossbeam/channel-throughput/,每个进程 5 次取中位数,整表在不同时段跑 3 次取中位数,测量前 1 分钟负载 2.5–3;完整表见 compare):
| 形状 | crossbeam bounded(1024) | std sync_channel(1024) | crossbeam unbounded | std channel |
|---|---|---|---|---|
| 1P1C | 35.2 Mmsg/s | 101.3 | 71.1 | 74.9 |
| 4P1C | 15.9 | 19.8 | 58.6 | 10.0 |
- ① 1P1C 有界:std 比 crossbeam 快约 3 倍(规划时测的是 2 倍)。
- ② 4P1C 无界:crossbeam 比 std 快约 6 倍(规划时约 5 倍)。
另外两格(1P1C 无界、4P1C 有界)两边接近。算法同源,差别只能来自上面那些细节。
方法:把 std 复制出来,一次改一项
exp-crossbeam/std-mpmc-copy/ 把 rust-src 1.99.1 的 mpmc/ 下 array.rs、list.rs、context.rs、counter.rs、select.rs、utils.rs、waker.rs 原样复制进一个普通 crate(文件头注明来源 rust-lang/rust 1.99.1 library/std/src/sync/mpmc 与许可 MIT OR Apache-2.0),为了能在 std 之外编译,替换了这些:
| std 原文 | 复制版 | 影响 |
|---|---|---|
crate::... 路径 | std::... | 无 |
Atomic<usize> / Atomic<bool> / Atomic<*mut T>(内部泛型别名) | AtomicUsize / AtomicBool / AtomicPtr<T> | 无,同一类型 |
thread::current_or_unnamed() | thread::current() | 只在每线程第一次阻塞时调一次 |
unsafe { self.inner.thread.park() } / park_timeout | thread::park() / thread::park_timeout(即 crossbeam 的写法) | 每次 park 多一次取当前线程 |
error.rs(含 unstable 的 SendTimeoutError) | 自写,重新导出 std::sync::mpsc 的错误类型 | 无 |
mod.rs(1455 行) | 自写约 100 行:只有 channel / sync_channel(n > 0)、send、recv、clone、drop,分发方式照抄 | 无 zero flavor |
select.rs 的 Token | 删掉 zero 字段 | Token 少 8 字节 |
再用 Cargo feature 把每一项 crossbeam 写法做成可开关的变体,每次只开一项:
| 变体 | 改了什么 |
|---|---|
cb_backoff | Backoff 换成 crossbeam-utils 的(spin_light → spin,spin_heavy → snooze) |
cb_preblock | array 的 send / recv、list 的 recv 加回「snooze 到 is_completed()」的阻塞前循环(用 crossbeam-utils 的 Backoff) |
cb_cas | 四处 CAS 失败分支改成 crossbeam 的写法:用 CAS 返回的值,block 仍重读,然后 spin |
cas_value_only | 只用 CAS 返回的值、不重读索引,退避仍在前(std 的顺序) |
stats | 统计每轮 park 次数和 SyncWaker::notify 进入加锁慢路径的次数(只在慢路径上加一次 Relaxed 自增),用于解释 |
每一轮是「基线 vs 改一项」背靠背、交替跑 3 次,每次一个进程 5 遍取中位数。下表每格是 3 次的中位数(逐次的数在实验目录 results/ 下)。第一遍(results/bisect-1.txt)第 1–5 轮的基线和变体两边都带 stats;第二遍(confirm-1.txt、confirm-2.txt)两边都不带,每一对开跑前等 1 分钟负载降到 3 以下,结论相同。第一遍测量时 1 分钟负载 2.8 到 6,其中包括实验自己的 2 到 5 个忙线程;本机同时有其他实验在跑。下表取第一遍。
第 0 轮:复制版和真 std 一致吗
| 形状 | 真 std | 复制版 | 差 |
|---|---|---|---|
| 1P1C 有界 | 80.4 | 85.5 | 复制版快约 6% |
| 1P1C 无界 | 76.0 | 71.6 | 复制版慢约 6% |
| 4P1C 有界 | 21.5 | 21.4 | 相同 |
| 4P1C 无界 | 10.4 | 10.3 | 相同 |
第二遍(每对开跑前负载低于 3):1P1C 有界真 std 76.8、复制版 84.8(复制版快约 10%),4P1C 无界 10.6 对 10.3。两处反常所在的格子(1P1C 有界、4P1C 无界),复制版和真 std 的差在 10% 左右以内,复制版在 1P1C 有界上稳定地略快,原因没有查(候选是 park 的替换),反常本身在复制版上同样存在(复制版 1P1C 有界 85 对 crossbeam 34,4P1C 无界 10 对 58)。替换没有把要找的东西替换掉,可以在复制版上二分。
第 1–5 轮
| 轮 | 变体 | 1P1C 有界(基线 → 变体) | 4P1C 无界(基线 → 变体) |
|---|---|---|---|
| 1 | cb_backoff | 79.0 → 86.5(三次互有高低,在波动内) | 10.0 → 9.7 |
| 2 | cb_preblock | 81.0 → 54.8(三次分别降 28%、44%、31%) | 9.7 → 6.9 |
| 3 | cb_cas | 76.0 → 79.7(波动内) | 9.8 → 43.8(三次分别是 4.1、4.5、4.5 倍) |
| 4 | 三项全开 vs 真 crossbeam | 64.2 vs 34.4 | 30.9 vs 59.7 |
| 5 | cas_value_only | 88.4 → 79.0(波动内) | 9.8 → 40.2 |
第二遍(两边都不带 stats,每对开跑前 1 分钟负载低于 3):第 2 轮 1P1C 有界 78.5 / 88.5 / 84.6 → 52.6 / 55.0 / 54.4,4P1C 无界 10.7 / 10.2 / 10.7 → 7.1 / 6.9 / 7.1;第 3 轮 4P1C 无界 10.4 / 10.6 / 10.8 → 38.3 / 46.9 / 41.3;第 5 轮 4P1C 无界 10.6 / 10.8 / 11.0 → 43.3 / 42.9 / 42.0;第 4 轮三项全开对真 crossbeam,1P1C 有界 64.6 / 62.0 / 61.2 对 33.5 / 30.3 / 33.4,4P1C 无界 32.3 / 33.6 / 31.5 对 58.5 / 54.2 / 58.0;第 1 轮仍无一致方向(第三对两边同时掉到 47,疑似外部干扰)。
stats 变体给出的计数(每轮 200 万条;基线列取第 2、3 轮里基线进程的三次,范围是最小到最大):
| 1P1C 有界 基线 | 1P1C 有界 cb_preblock | 4P1C 无界 基线 | 4P1C 无界 cb_preblock | 4P1C 无界 cb_cas | |
|---|---|---|---|---|---|
| park 次数 | 67–89 | 20–36 | 348–652 | 0 | 253–466 |
notify 走加锁路径的次数 | 300–465 | 39–80 | 37874–80852 | 0 | 8710–13874 |
结论
反常 ②(4P1C 无界,crossbeam 快约 6 倍)的主要来源找到了:std list 的 start_send 在 CAS 失败后重新 load 一次 tail.index。 只改这一处(第 5 轮,退避的顺序都不动),复制版从 9.8 升到 40 左右,是 4 倍多;第 3 轮的完整 crossbeam 写法结果相同。4 个生产者同时 CAS 同一个 tail.index,失败很常见;失败后 CAS 指令已经带回了当前值,再 load 一次是对一条正被其他核反复写的缓存行多发一次读请求。为什么这一次读的代价这么大(为什么不是一点点而是 4 倍),本实验没有测到硬件层面,属推测:读请求把缓存行拉成共享态,紧接着的 CAS 又要把它升级成独占态,多了一轮一致性往返,在 4 个核同时争抢时放大。std 的 array 也有同样的重读(Relaxed),但 4P1C 有界下生产者大部分时间被「满」挡住,tail 的争抢没那么激烈,两边吞吐接近。
反常 ①(1P1C 有界,std 快约 3 倍)的一部分来源找到了:std 删掉的阻塞前自旋。 把它加回复制版(第 2 轮),1P1C 有界从 81 降到 55,三次都降了约三成或更多,方向和幅度都朝 crossbeam 靠。stats 显示加回自旋后 park 次数和加锁的 notify 都少了,但吞吐反而降了:在这个形状下,「消费者睡着、生产者攒一批再叫醒」比「消费者一直自旋盯着」更快。为什么更快,本实验没有直接证据;推测是自旋的消费者紧跟在生产者后面,每个槽的 stamp 和 head / tail 所在的缓存行在两个核之间逐条来回,而睡着的消费者醒来后可以连续读一批。
没查清的部分:三项全开以后,复制版和真 crossbeam 仍差约 2 倍,而且方向相反(1P1C 有界复制版 65、crossbeam 34;4P1C 无界复制版 31、crossbeam 60)。另外两个形状(1P1C 无界、4P1C 有界)三项全开后与 crossbeam 相差在 10% 以内。已排除的:
- 退避曲线(第 1 轮,单独改无可见影响);
- 两份代码里其余的源码差别已逐项对过:
Counter、Sender/Receiver的分发、SyncWaker的逻辑相同;64 位平台上AtomicU64与AtomicUsize相同;CachePadded在 aarch64 上都是 128 字节; - 复制带来的走样(第 0 轮,约 10% 以内,远小于要找的 2 到 6 倍)。
还没排除的候选:crossbeam 的 Token 多 4 个字段、线程 id 的取法、utils::Mutex 的包装层、两份代码在编译单元和内联上的差别。5 轮用完,原因未查明。这也说明「组合」不可加:单独加回自旋让 4P1C 无界变慢(9.7 → 6.9),但 crossbeam 带着同样的自旋是最快的;这类基准对每条消息几纳秒的差异很敏感,换一个地方的小改动可能让生产者和消费者进入另一种节奏。
所以本页不下「std 的实现更快」或「crossbeam 更快」的结论:两边在不同形状上各有快的,差距的来源一部分已经定位到具体的源码行,剩下的只给出数字。
反向验证
- 复制版与真 std 在四个形状上差约 10% 以内(第 0 轮,两遍):复制没有改掉反常。
cb_cas和cas_value_only单独测 1P1C 有界都无变化:只有一个生产者时 CAS 不会因竞争失败,这一处改动理应不起作用,结果也确实不起作用。cb_backoff在两个形状上都无变化:如果「改一项」的方法本身有系统偏差(比如交替顺序、重新编译),这一轮也会显出变化。
小结
std::sync::mpsc从 1.67.0(rust-lang/rust#93563)起是std::sync::mpmc的包装,后者是 crossbeam-channel 的移植,公开 API 仍是 unstable 的mpmc_channel。- std 删掉了 select、at / tick / never、
Backoff::is_completed,select.rs只剩 71 行;改写了阻塞前自旋(删)、CAS 失败处理(重读)、线程 id 与park(内部 API)、锁(传播 poison)。 - std 的有界 channel 在接收端全走时立即丢弃消息,crossbeam 0.5.17 不会,
main的 #1121 才补上。 - E6 两处反常:4P1C 无界 crossbeam 快约 6 倍,主要来自 std list 在 CAS 失败后多
load一次tail.index(单改这一处,复制版 9.8 → 40);1P1C 有界 std 快约 3 倍,一部分来自 std 删掉了阻塞前自旋(加回去,复制版 81 → 55)。 - 三项都改回后仍有约 2 倍的残差,方向相反;退避曲线和复制走样已排除,其余原因未查明。