Rust in Everything
基于 tokio 1.54.1

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.rsChan:链表、接收端 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每个槽一位,置位表示值已经写好
32RELEASED:发送端不会再碰这个块
33TX_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 的顺序。两个发送端几乎同时发,谁先抢到序号谁在前,哪怕它后写完。接收端按序号读,碰到一个已经被抢到但还没写完的槽就停下来等。

💡TIP
打个比方:银行的取号机

发送端进门先在取号机上拿号(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 分三步:

  1. try_advancing_head:index 进入了下一个块,就沿 next 把 head 往前挪。下一个块还没分配出来,说明还没人发,返回 None。
  2. reclaim_blocks:从 free_head 到 head 之间是已经读完的块。对每一块,只有 RELEASED 已置、而且 index 已经到达 observed_tail_position 时才回收,否则停下,下次再说。
  3. 读 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
closebatch_semaphore 的 close,叫醒所有等待的发送端fetch_or(1)

无界的 send 是同步函数(第 559 行),只做计数和 push,不等待,也不扣预算。

📝NOTE
没有蠢问题

问: 无界 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),不占容量。
📝NOTE
冷知识: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。

❗IMPORTANT
动脑筋:A 到 E 各打印什么?
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());
提示:channel 里一条消息都还没有的时候,容量就可能已经是 0。答案在本页末尾。

实验:满了以后按顺序恢复

容量 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:先扣预算,读两次

Rx::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 位之间),但它确实是在同步地等。

📝NOTE
冷知识:tokio 1.0 发布时没有 try_recv

0.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 遇到写了一半的槽会短暂阻塞线程。

💬 关联讨论

发起讨论
加载中...