mpsc
主线位置:任务主线的「poll 返回
Pending→ 被唤醒」。send写完一个槽就叫醒接收任务,recv每取走一条就还给发送端一个许可。
flowchart TB
send["send(v)"] --> permit{有界?}
permit -->|是| acq["batch_semaphore acquire(1)"]
permit -->|否| cnt["计数 +2"]
acq --> push
cnt --> push["fetch_add 抢序号"]
push --> find[找到序号所在的块]
find --> write[写值,置 ready 位]
write --> wake[rx_waker.wake]
recv["recv()"] --> budget[扣预算]
budget --> pop[读 head 块的槽]
pop -->|有值| give[还一个许可]
pop -->|没值| reg[注册 waker,再读一次]
pop --> reclaim[读完的块挂回尾部]
源码分三层:
| 文件 | 内容 |
|---|---|
sync/mpsc/block.rs | 一个块:32 个槽加一个 ready 位图 |
sync/mpsc/list.rs | 块串成的链表,发送端 Tx 和接收端 Rx 两半 |
sync/mpsc/chan.rs | Chan:链表、接收端 waker、信号量、计数,放在一个 Arc 里 |
bounded.rs 和 unbounded.rs 是对外的 Sender / Receiver,只是给 chan 选了不同的信号量。
Block:32 个槽,一个位图
BLOCK_CAP 在 64 位平台上是 32,32 位平台上是 16(loom 测试时是 2)。注释要求它是 2 的幂,并且小于 usize 的位数,原因马上就能看到。
Block 是一个头加一个定长数组:
pub(crate) struct Block<T> {
/// The header fields.
header: BlockHeader<T>,
/// Array containing values pushed into the block. Values are stored in a
/// continuous array in order to improve cache line behavior when reading.
/// The values must be manually dropped.
values: Values<T>,
}
struct BlockHeader<T> {
/// The start index of this block.
///
/// Slots in this block have indices in `start_index .. start_index + BLOCK_CAP`.
start_index: usize,
/// The next block in the linked list.
next: AtomicPtr<Block<T>>,
/// Bitfield tracking slots that are ready to have their values consumed.
ready_slots: AtomicUsize,
/// The observed `tail_position` value *after* the block has been passed by
/// `block_tail`.
observed_tail_position: UnsafeCell<usize>,
}
values 是 [UnsafeCell<MaybeUninit<T>>; BLOCK_CAP],槽里有没有值不看槽本身,看 ready_slots(第 60 行):
/// Flag tracking that a block has gone through the sender's release routine.
///
/// When this is set, the receiver may consider freeing the block.
const RELEASED: usize = 1 << BLOCK_CAP;
/// Flag tracking all senders dropped.
///
/// When this flag is set, the send half of the channel has closed.
const TX_CLOSED: usize = RELEASED << 1;
/// Mask covering all bits used to track slot readiness.
const READY_MASK: usize = RELEASED - 1;
64 位平台上:
| 位 | 含义 |
|---|---|
| 0 到 31 | 每个槽一位,置位表示值已经写好 |
| 32 | RELEASED:发送端不会再碰这个块 |
| 33 | TX_CLOSED:所有发送端都已 drop |
BLOCK_CAP 必须小于 usize 的位数,就是为了在 32 个 ready 位之上再放下这两个标志。
写一个槽(Block::write)是先 ptr::write 值,再 fetch_or(1 << offset, Release) 置 ready 位。读(Block::read)反过来,先 Acquire 读位图,位已置才 ptr::read 值;位没置但 TX_CLOSED 置了,返回 Read::Closed。
Block::new 直接用 std::alloc::alloc 分配再原地写头部,不走 Box::new(Block { .. }):后者可能先在栈上构造整个块,T 很大时会栈溢出(文件末尾的测试用 2 MB 的 T 防止回归,对应 issue #5293)。
发送端:fetch_add 抢一个序号
链表的发送端 list::Tx 只有两个原子变量:
pub(crate) struct Tx<T> {
/// Tail in the `Block` mpmc list.
block_tail: AtomicPtr<Block<T>>,
/// Position to push the next message. This references a block and offset
/// into the block.
tail_position: AtomicUsize,
}
push:
pub(crate) fn push(&self, value: T) {
// First, claim a slot for the value. `Acquire` is used here to
// synchronize with the `fetch_add` in `reclaim_blocks`.
let slot_index = self.tail_position.fetch_add(1, Acquire);
// Load the current block and write the value
let block = self.find_block(slot_index);
unsafe {
// Write the value to the block
block.as_ref().write(slot_index, value);
}
}
tail_position 是一个全局递增的序号。fetch_add 返回的值就是这条消息的位置,多个发送端并发 push 时各拿各的序号,不会冲突,也不需要锁。序号拆成两部分:slot_index & !31 是所在块的 start_index,slot_index & 31 是块内偏移。
消息的顺序就是 fetch_add 的顺序。两个发送端几乎同时发,谁先抢到序号谁在前,哪怕它后写完。接收端按序号读,碰到一个已经被抢到但还没写完的槽就停下来等。
发送端进门先在取号机上拿号(fetch_add),号码定了你在队伍里的位置,跟你填单子(写值)花多久无关。登记簿每页 32 行,一行一个号;号码排到新的一页,而簿子还没这页,谁先发现谁去加一页(grow)。窗口只有一个(接收端),按号叫:叫到 57 号,57 号还在填单,窗口就等着,不会先叫 58 号。
比方到这里为止:真银行可以「过号」,mpsc 永远不过号。这就是后面 try_recv 碰到写了一半的槽时宁可阻塞线程也不跳过的原因。
find_block:沿链表找块,必要时扩容
find_block 从 block_tail 出发往后走,直到找到 start_index 对上的块。走到链表末尾还没找到,就调 Block::grow 分配一个新块,CAS 到 next 上。CAS 失败说明别的发送端已经接上了一个块,grow 不会把自己刚分配的块扔掉,而是沿着链表继续往后找空的 next 挂上去,留给以后用。
block_tail 只是一个提示,不必总指向真正的最后一块。推进它有两个条件:
- 走过的块必须是「final」的,即 32 个 ready 位全部置位(
is_final)。还有槽没写完的块,block_tail不能越过它,否则正在写这个槽的发送端可能找不到它。 distance > offset:目标块离block_tail的块数大于本条消息在块内的偏移。注释说这是为了减少争用:基本上只有写新块前几个槽的发送端会去推,后面的发送端不参与。
推进成功的那个发送端还要做一件事:
if self
.block_tail
.compare_exchange(block_ptr, next_block.as_ptr(), Release, Relaxed)
.is_ok()
{
// Synchronize with any senders
let tail_position = self.tail_position.fetch_add(0, Release);
unsafe {
block.tx_release(tail_position);
}
}
tx_release 把此刻的 tail_position 记进被越过的块的 observed_tail_position,再置 RELEASED 位。意思是:序号大于等于这个值的发送端,都是从新的 block_tail 出发找块的,不会再访问这个块。接收端读到这个序号之后,就可以放心回收它。
接收端:独占链表头
list::Rx 只有一个使用者,所以字段都是普通字段:
pub(crate) struct Rx<T> {
/// Pointer to the block being processed.
head: NonNull<Block<T>>,
/// Next slot index to process.
index: usize,
/// Pointer to the next block pending release.
free_head: NonNull<Block<T>>,
}
pop 分三步:
try_advancing_head:index进入了下一个块,就沿next把head往前挪。下一个块还没分配出来,说明还没人发,返回None。reclaim_blocks:从free_head到head之间是已经读完的块。对每一块,只有RELEASED已置、而且index已经到达observed_tail_position时才回收,否则停下,下次再说。- 读
head块里index那个槽。读到值,index加一。
块的复用:最多试 3 次
回收的块不一定释放。reclaim_block 先把块头清零,然后试着把它接回链表尾部:
let mut reused = false;
// Attempt to insert the block at the end
//
// Walk at most three times
let curr_ptr = self.block_tail.load(Acquire);
// ...
let mut curr = unsafe { NonNull::new_unchecked(curr_ptr) };
// TODO: Unify this logic with Block::grow
for _ in 0..3 {
match unsafe { curr.as_ref().try_push(&mut block, AcqRel, Acquire) } {
Ok(()) => {
reused = true;
break;
}
Err(next) => {
curr = next;
}
}
}
if !reused {
// ...
let _ = unsafe { Box::from_raw(block.as_ptr()) };
}
从 block_tail 开始,每次 try_push 尝试 CAS 它的 next;失败就跳到已有的 next 再试,一共 3 次。3 次都没找到空位就释放掉。和 Block::grow 不同,这里不会一直往后走:回收发生在接收端,接收端不该为了省一次分配在链表上走太远。
结果是 block_tail 后面最多挂 3 个备用块。用一个计数分配器看实际效果,只统计大于 2048 字节的分配(Block<[u8; 64]> 是 32 × 64 字节加 32 字节的头):
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use tokio::sync::mpsc;
struct Counting;
static BIG: AtomicUsize = AtomicUsize::new(0);
// count only allocations big enough to be a Block<[u8; 64]> (32 * 64 bytes + header)
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
if l.size() >= 32 * 64 {
BIG.fetch_add(1, Relaxed);
}
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static A: Counting = Counting;
fn main() {
println!("size_of Block for [u8;64]: ~{} bytes", 32 * 64);
let (tx, mut rx) = mpsc::unbounded_channel::<[u8; 64]>();
let base = BIG.load(Relaxed);
// 1. ping-pong: send one, receive one
for _ in 0..10_000 {
tx.send([0; 64]).unwrap();
rx.try_recv().unwrap();
}
println!("ping-pong 10000 msgs: blocks allocated = {}", BIG.load(Relaxed) - base);
// 2. burst: 1000 queued, then drained; three rounds
for round in 1..=3 {
let before = BIG.load(Relaxed);
for _ in 0..1000 {
tx.send([0; 64]).unwrap();
}
while rx.try_recv().is_ok() {}
println!("burst round {round} (1000 msgs): blocks allocated = {}", BIG.load(Relaxed) - before);
}
}
size_of Block for [u8;64]: ~2048 bytes
ping-pong 10000 msgs: blocks allocated = 1
burst round 1 (1000 msgs): blocks allocated = 30
burst round 2 (1000 msgs): blocks allocated = 28
burst round 3 (1000 msgs): blocks allocated = 29
收发交替时,读完的块马上挂回尾部,一万条消息(313 个块的量)只新分配了 1 个块。积压 1000 条(约 32 个块)再一次读完时,读完的块大多挂不回去,每轮只省下几个块。所以 channel 的内存占用随积压量增长,积压消失后回落;它不会为了复用一直攥着峰值时的所有块。
Chan:把两半装进一个 Arc
Chan:
pub(super) struct Chan<T, S> {
/// Handle to the push half of the lock-free list.
tx: CachePadded<list::Tx<T>>,
/// Receiver waker. Notified when a value is pushed into the channel.
rx_waker: CachePadded<AtomicWaker>,
/// Notifies all tasks listening for the receiver being dropped.
notify_rx_closed: Notify,
/// Coordinates access to channel's capacity.
semaphore: S,
/// Tracks the number of outstanding sender handles.
///
/// When this drops to zero, the send half of the channel is closed.
tx_count: AtomicUsize,
/// Tracks the number of outstanding weak sender handles.
tx_weak_count: AtomicUsize,
/// Only accessed by `Rx` handle.
rx_fields: UnsafeCell<RxFields<T>>,
}
tx和rx_waker是发送端最常写的两个字段,各自用CachePadded占满一条缓存行,避免和别的字段伪共享。- 只有一个接收者,所以接收者的 waker 用一个
AtomicWaker就够了,不需要等待链表。rx_fields(链表的Rx半边和rx_closed)只有接收端访问,放在UnsafeCell里,不加锁。 notify_rx_closed给Sender::closed()用:接收端关闭时notify_waiters(),所有在等的发送端一起醒。
Chan::send 就两行:self.tx.push(value),然后 self.rx_waker.wake()。容量检查不在这里,在调用它之前。
Semaphore trait:有界和无界的唯一区别
chan 对信号量只要求 5 个方法(第 40 行):
pub(crate) trait Semaphore {
fn is_idle(&self) -> bool;
fn add_permit(&self);
fn add_permits(&self, n: usize);
fn close(&self);
fn is_closed(&self) -> bool;
}
两个实现:
有界 bounded::Semaphore | 无界 unbounded::Semaphore | |
|---|---|---|
| 内容 | batch_semaphore::Semaphore 加 bound | 一个 AtomicUsize:第 0 位是关闭标志,其余位是消息数 × 2 |
| 发送前 | acquire(1),没有许可就等 | inc_num_messages:CAS 加 2,已关闭就失败 |
add_permit(收到一条) | release(1),可能叫醒一个等待的发送端 | fetch_sub(2) |
is_idle | 可用许可数等于 bound | 消息数为 0 |
close | batch_semaphore 的 close,叫醒所有等待的发送端 | fetch_or(1) |
无界的 send 是同步函数(第 559 行),只做计数和 push,不等待,也不扣预算。
问: 无界 channel 只是数一下消息数,为什么每条加 2?用一个 fetch_add(1) 加一个单独的 closed: AtomicBool 不是更直白吗?
答: 因为「没关闭」和「加一」必须是同一个原子动作。分成两个变量,发送端先读到 closed == false,接收端紧接着关闭并认定 channel 已空闲,发送端再把计数加一,就成了关闭之后又进来一条消息。把关闭标志放在第 0 位,消息数从第 1 位往上数(所以每条加 2),inc_num_messages 一次 CAS 就同时完成「检查没关」和「加一」。用 CAS 不用 fetch_add 也是这个原因:fetch_add 不管关没关都会加上去。
有界的 send 是 reserve().await 拿到一个 Permit,再 permit.send(value)。等待许可就是上一章的 Acquire future,所以:
- 发送端排队是 FIFO 的,容量空出一个,最早等的那个发送端拿到。
- 许可在接收端取走消息时才还回去,不是在发送时。容量的含义是「channel 里的消息数加上已经
reserve但还没send的许可数」不超过bound。 Permit没用就 drop,许可直接还回去(Drop for Permit),不占容量。
reserve 和 Permit 是 0.3 才有的0.2 的 Sender::send 要 &mut self。确认有容量靠 Sender::poll_ready,结果记在 Sender 自己身上,所以多个任务要发,就得各自 clone 一个 Sender。2020 年 9 月的 #2861(0.3.0)把有界 channel 换到了上一章那种侵入式等待链表的信号量上,send 改成 &self,poll_ready 换成了 async fn reserve():它返回一个 Permit,容量记在 Permit 上,不再记在 Sender 上。
try_send 用 try_acquire(1),两种失败一一对应:NoPermits 变成 TrySendError::Full,Closed 变成 TrySendError::Closed。
let (tx, mut rx) = mpsc::channel::<u32>(2);
let p1 = tx.reserve().await.unwrap();
let p2 = tx.reserve().await.unwrap();
println!("A: {:?}", tx.try_send(1));
drop(p1);
println!("B: {:?}", tx.try_send(2));
p2.send(3);
println!("C: capacity = {}", tx.capacity());
println!("D: {:?} {:?}", rx.recv().await, rx.recv().await);
println!("E: capacity = {}", tx.capacity());
实验:满了以后按顺序恢复
容量 2,先发满,再让三个发送端依次排队:
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TrySendError;
use tokio::task::yield_now;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(2);
tx.send("m0".into()).await.unwrap();
tx.send("m1".into()).await.unwrap();
println!("capacity after 2 sends: {}", tx.capacity());
match tx.try_send("x".into()) {
Err(TrySendError::Full(v)) => println!("try_send -> Full({v})"),
other => println!("try_send -> {other:?}"),
}
let mut hs = Vec::new();
for name in ["s1", "s2", "s3"] {
let tx = tx.clone();
hs.push(tokio::spawn(async move {
println!("{name}: send start");
tx.send(name.to_string()).await.unwrap();
println!("{name}: send done");
}));
yield_now().await;
}
for _ in 0..5 {
let v = rx.recv().await.unwrap();
println!("recv {v}");
yield_now().await;
}
for h in hs {
h.await.unwrap();
}
drop(rx);
match tx.try_send("y".into()) {
Err(TrySendError::Closed(v)) => println!("after drop(rx): try_send -> Closed({v})"),
other => println!("after drop(rx): try_send -> {other:?}"),
}
let r = tokio::time::timeout(Duration::from_millis(10), tx.send("z".into())).await;
println!("after drop(rx): send -> {:?}", r.map(|x| x.map_err(|e| e.0)));
}
capacity after 2 sends: 0
try_send -> Full(x)
s1: send start
s2: send start
s3: send start
recv m0
s1: send done
recv m1
s2: send done
recv s1
s3: send done
recv s2
recv s3
after drop(rx): try_send -> Closed(y)
after drop(rx): send -> Ok(Err("z"))
三个发送端都卡在 send 里。每 recv 一条,add_permit 还回 1 个许可,正好交给排在最前面的发送端:先 s1,再 s2,再 s3。接收端 drop 以后,try_send 返回 Closed 并把值还回来;send 也立刻返回错误(timeout 没有触发,是 Ok(Err("z"))),因为 close 已经把信号量关了。
recv:先扣预算,读两次
pub(crate) fn recv(&mut self, cx: &mut Context<'_>) -> Poll<Option<T>> {
use super::block::Read;
ready!(crate::trace::trace_leaf(cx));
// Keep track of task budget
let coop = ready!(crate::task::coop::poll_proceed(cx));
self.inner.rx_fields.with_mut(|rx_fields_ptr| {
let rx_fields = unsafe { &mut *rx_fields_ptr };
macro_rules! try_recv {
() => {
match rx_fields.list.pop(&self.inner.tx) {
Some(Read::Value(value)) => {
self.inner.semaphore.add_permit();
coop.made_progress();
return Ready(Some(value));
}
Some(Read::Closed) => {
// ...
debug_assert!(self.inner.semaphore.is_idle());
coop.made_progress();
return Ready(None);
}
None => {} // fall through
}
};
}
try_recv!();
self.inner.rx_waker.register_by_ref(cx.waker());
// It is possible that a value was pushed between attempting to read
// and registering the task, so we have to check the channel a
// second time here.
try_recv!();
if rx_fields.rx_closed && self.inner.semaphore.is_idle() {
coop.made_progress();
Ready(None)
} else {
Pending
}
})
}
- 第一件事是扣预算。预算用完,哪怕 channel 里有消息也返回
Pending。协作式调度预算一章的实验就是用一个 unbounded channel 测出每次 poll 正好读 128 条。 - 先读一次,没有就注册 waker,再读一次。这是防丢唤醒的标准顺序:发送端可能在第一次读和注册之间写完并调了
wake,那时 waker 还没注册,这次唤醒落空;第二次读能看到那条消息。 - 返回
None有两条路:读到TX_CLOSED(所有发送端都 drop 了),或者接收端自己close()过、而且信号量是空闲的(没有消息,也没有发出去的许可)。
try_recv 有一个不太显眼的行为:try_pop 返回 Busy,即某个发送端已经抢到序号、但还没写完,这时 try_recv 会用 CachedParkThread 阻塞当前线程,直到那条消息写完。它不返回 Empty,因为 channel 里确实有消息,只是还看不见;也不能返回值。这段等待通常极短(发送端在 fetch_add 和置 ready 位之间),但它确实是在同步地等。
try_recv0.2 里有 try_recv,但它有一个 bug:一条消息的 send 明明先于 try_recv 发生,try_recv 却可能返回「空」。最早发现它的是连接池库 deadpool 的作者,池子里还回来的对象比 max_size 还多,他提交了一个能复现的测试(#1997,后来整理成 #2020)。修起来不简单,于是 2020 年 12 月,1.0 发布前夕,#3263 干脆把它删了,PR 里写的是「等 bug 修好再加回来」。
加回来是 2021 年 9 月的 #4113(1.12.0)。新实现就是本节说的:try_pop 多了一个 Busy 结果,碰到已经抢到序号、还没写完的槽,就用 CachedParkThread 把线程停住,等那条消息写完,而不是返回「空」。
关闭和 drop
| 事件 | 做了什么 | 对方看到 |
|---|---|---|
最后一个 Sender drop(Drop for Tx) | tx_count 减到 0,list::Tx::close 再抢一个序号,在那个槽所在的块上置 TX_CLOSED;然后 wake_rx | 接收端读完之前的消息后读到 Closed,recv 返回 None |
Receiver::close()(Rx::close) | rx_closed = true,关信号量,notify_rx_closed.notify_waiters() | 新的 send 失败;等许可的发送端被叫醒并失败;Sender::closed() 完成。已经在 channel 里的消息还能读 |
Receiver drop(Drop for Rx) | 先 close(),丢掉注册的 waker,再把剩下的消息逐个 pop 出来 drop,每条还一个许可 | 同上 |
Chan 本身 drop(最后一个 Arc) | 再 pop 一遍剩余的值,free_blocks 释放所有块 | — |
关闭标志之所以放在块里而不是 Chan 里,是为了保证顺序:close 也通过 fetch_add 拿序号,它的序号一定排在所有已发送消息之后。接收端按序号往下读,读到这个槽时,前面的消息都已经读完了。
Receiver drop 时就把剩下的消息析构掉,不等 Arc<Chan> 归零。发送端可能还会活很久,这样消息里攥着的资源(比如一个别人正在等的 oneshot::Sender)在接收端消失时就释放了。
动脑筋答案
放进 #[tokio::main(flavor = "current_thread")] 里跑(M2 Max、rustc 1.99.0),输出:
A: Err("Full(..)")
B: Ok(())
C: capacity = 0
D: Some(2) Some(3)
E: capacity = 2
- A:两个
Permit已经把 2 个许可都拿走了,channel 里一条消息都没有,try_send照样是Full。容量数的是「消息数加上已 reserve 未 send 的许可数」。 - B:
drop(p1)把一个许可原样还回信号量,try_send(2)拿到它。 - C:现在 channel 里有两条消息(2 和 3),许可全在消息身上,容量 0。
- D:顺序是 2 在前、3 在后。
p2虽然更早 reserve,但序号是在send那一刻fetch_add拿的,try_send(2)先拿到。reserve 只占容量,不占位置。 - E:每
recv一条,add_permit还回一个许可,两条都取走后容量回到 2。
小结
- 块大小
BLOCK_CAP在 64 位平台是 32。ready_slots的低 32 位是每个槽的 ready 位,第 32 位RELEASED、第 33 位TX_CLOSED。 - 发送:
tail_position.fetch_add(1)拿序号,按序号找块(不够就grow),写值后置 ready 位。没有锁,消息顺序就是序号顺序。 block_tail只越过 32 个槽都写满的块,越过时记下observed_tail_position并置RELEASED,接收端读过这个位置后才回收这个块。- 回收的块从
block_tail往后最多试 3 个位置挂回去,挂不上就释放。收发交替时几乎不分配;积压时按积压量分配,积压消失后释放。 - 有界和无界只差
Semaphore实现:有界用batch_semaphore,许可在接收端取走消息时归还,发送端 FIFO 排队;无界用一个原子计数,send不等待、不扣预算。 recv先扣预算,读一次、注册 waker、再读一次。try_recv遇到写了一半的槽会短暂阻塞线程。