和 tokio mpsc、flume 对照
主线位置:主线 1 的横向比较。同样是「一条消息从发送方到接收方」,crossbeam 让线程
park,tokio 让任务返回Pending、由Waker叫醒;flume 两种都支持。
flowchart LR
subgraph thread["线程阻塞"]
cb["crossbeam:Context + park"]
std["std mpsc:同一份移植"]
end
subgraph task["任务挂起"]
tk["tokio mpsc:AtomicWaker + Pending"]
end
fl["flume:两种都有"]
cb --- fl
fl --- tk
tk -->|"blocking_send / blocking_recv"| bo["在当前线程上 block_on"]
对照表
flume 只按公开 API 列,不读源码。
| crossbeam-channel 0.5.17 | std mpsc(1.99.1) | tokio mpsc 1.54.1 | flume 0.11.1 | |
|---|---|---|---|---|
| 生产者 / 消费者 | MPMC,Receiver: Clone | MPSC(内部是 MPMC,mpsc 不给 Receiver 实现 Clone) | MPSC | MPMC |
| 等待方式 | thread::park / unpark(Context 与 Waker) | 同左 | 任务返回 Pending,接收端一个 AtomicWaker,有界的发送端排在信号量的等待链表上 | 同步和 async 两套接口 |
| 等待前自旋 | Backoff 自旋 + 让出约 11 次 | 不自旋 | 不自旋(任务直接让出) | 不读源码,不列 |
| 有界的背压 | 环形缓冲,每槽一个 stamp(array) | 同左 | 块链表 + batch_semaphore 许可(mpsc) | 有界 / 无界都有 |
| 无界的结构 | 31 槽一块的链表,CAS 抢序号(list) | 同左 | 32 槽一块,fetch_add 抢序号,读完的块复用 | — |
| 零容量 | bounded(0),会合 | sync_channel(0) | 不支持(channel(0) panic) | bounded(0) |
| select | select! / Select,阻塞式 | 无 | 用 tokio::select! 组合 future | 有(Selector,需 select feature) |
| 定时 channel | after / at / tick / never | 无 | 用 tokio::time | 无 |
| async | 无 | 无 | 只有 async;blocking_send / blocking_recv 给同步代码用 | send_async / recv_async |
| 在 async 里阻塞 | 会占住运行时的工作线程(tokio 卷阻塞在异步里) | 同左 | blocking_* 在运行时内调用直接 panic | 用 *_async 就不阻塞 |
tokio 的 blocking_recv 就是 crate::future::block_on(self.recv()):在当前线程上建一个停车器,把 recv() 这个 future 轮询到完成。所以它和 crossbeam 一样是「线程睡觉」,只是每次调用都要过一遍 future 的轮询和 Waker 的注册。在运行时的工作线程上调用会 panic(「Cannot block the current thread from within a runtime」),这是故意的。
吞吐(E6)
exp-crossbeam/channel-throughput/。每轮 200 万条 usize(zero 为 20 万条),生产者均分;1P1C、4P1C 由主线程接收,4P4C 再起 3 个接收线程。每个进程 5 遍取中位数,整张表在不同时段跑 3 次,下表是 3 次中位数的中位数(单位 Mmsg/s)。release,M2 Max 8P + 4E,macOS 不能绑核;测量前 1 分钟负载约 3,见末尾。
| 实现 | 1P1C | 4P1C | 4P4C |
|---|---|---|---|
crossbeam bounded(1024) | 35.2 | 15.9 | 29.4 |
crossbeam unbounded() | 71.1 | 58.6 | 57.8 |
crossbeam bounded(0) | 0.72 | 0.30 | 0.22 |
std sync_channel(1024) | 101.3 | 19.8 | — |
std channel() | 74.9 | 10.0 | — |
std sync_channel(0) | 0.73 | 0.30 | — |
flume bounded(1024) | 28.2 | 7.7 | 13.8 |
flume unbounded() | 35.3 | 22.2 | 21.9 |
tokio async channel(1024) | 15.5 | 7.6 | — |
tokio async unbounded_channel() | 34.3 | 6.8 | — |
tokio blocking_*,channel(1024) | 10.7 | 5.3 | — |
tokio blocking_recv,unbounded_channel() | 13.2 | 5.2 | — |
三次整表测量开始前的 1 分钟负载分别是 2.93、2.47、2.95(表跑起来以后,实验自己的线程会把负载推到 4–9)。三次之间大多数格子差别在 10% 以内,波动最大的是 tokio blocking_* 的 1P1C(7.9 / 11.0 / 10.7 和 9.5 / 13.2 / 14.0)。
tokio 有两组,不是同一种比较:
tokio async:new_multi_thread().worker_threads(p + 1)的运行时里,生产者和接收者都是任务,send().await/recv().await。这是 tokio mpsc 的本来用法,它和线程阻塞的 channel 比的是两种调度模型,包括运行时调度任务的开销。tokio blocking_*:普通线程上blocking_send/blocking_recv(无界的发送不会等,直接send),形状和 crossbeam 那几行一样,但每次操作都要构造 future、在当前线程block_on,这条路径本来就是给「同步代码偶尔要和 async 世界交换数据」用的,不是为吞吐设计的。
读这张表要注意:
- 这是单一消息类型(
usize)、空循环收发的极限吞吐,消费者什么也不做。消费者每条消息干一点活,channel 本身的开销占比就小得多,几种实现的差距会缩小。 - 1P1C 有界的 std 远快于 crossbeam、4P1C 无界的 crossbeam 远快于 std,这两处在同源的代码之间,原因的一部分已经定位到具体源码行,见 vs-std;本页不再解释。
- crossbeam 和 std 的 zero 都是 0.2–0.73,比有缓冲的低约 50 倍,原因在 zero。
- 4P4C 只有 MPMC 的实现能测(crossbeam、flume)。
小结
- crossbeam 和 std 是同一份代码的两个版本,阻塞都是
park;tokio 是任务模型,靠Waker;flume 两种接口都有。 - crossbeam 独有的是
select!和定时 channel;std 什么都没加;tokio 用tokio::select!和tokio::time组合出同样的东西。 - 本机极限吞吐上,crossbeam 的无界 channel 在多生产者时最快;有界 1P1C 是 std 最快;tokio 在运行时内的 async 收发比线程阻塞的实现低,
blocking_*更低,二者都不是同一种比较。 - 所有数字只代表空消费者、
usize消息、本机这一种条件。