Rust in Everything
基于 tokio 1.54.1

从 TcpStream::read 到挂起

主线位置:IO 主线从头到尾。前两章分别讲了驱动和 ScheduledIo,这一章把它们和 TcpStream 串起来。

sequenceDiagram
  participant T as 任务
  participant P as PollEvented
  participant S as ScheduledIo
  participant K as 内核 socket
  participant D as 驱动 turn
  T->>P: poll_read
  P->>S: poll_readiness(Read)
  S-->>P: Ready(READABLE, tick 0)
  P->>K: read
  K-->>P: WouldBlock
  P->>S: clear_readiness(tick 0)
  P->>S: poll_readiness(Read)
  S->>S: 没有就绪位,waker 存进 reader 槽
  S-->>T: Pending
  K-->>D: 数据到达,poll 返回事件
  D->>S: set_readiness(Set),tick 变 1
  D->>T: wake,任务进队列
  T->>P: 再次 poll_read
  P->>S: poll_readiness(Read)
  S-->>P: Ready(READABLE, tick 1)
  P->>K: read
  K-->>P: 5 字节
  P-->>T: Ready

图里第一次 poll_readiness 就返回了「可读」,是因为这是一个 accept 出来的流,注册时被假定可读(见 ScheduledIo 与就绪状态 里的 assume_ready)。connect 出来的流第一次看到的是空的就绪位,会直接走到「存 waker」。

调用链

用户写的是 stream.read(&mut buf).await。一路往下:

层函数做什么
用户 APIAsyncReadExt::read 返回的 Read future 的 poll包一个 ReadBuf,调 poll_read
TcpStreamAsyncRead::poll_read → poll_read_priv转给 PollEvented
PollEventedpoll_read循环:等就绪 → read → 看结果
Registrationpoll_read_ready → poll_ready扣协作预算,问 ScheduledIo
ScheduledIopoll_readiness读原子;不就绪就存 waker

TcpStream 本身只有一个字段 io: PollEvented<mio::net::TcpStream>。PollEvented 把一个 mio 的 socket 和一个 Registration 绑在一起;Registration 里是运行时句柄和 Arc<ScheduledIo>。创建 TcpStream 时 TcpStream::new 调 PollEvented::new,后者用 Handle::current() 拿到当前运行时,调 add_source 注册进驱动(见反应器)。所以在运行时外面创建 TcpStream 会 panic。

PollEvented::poll_read

pub(crate) unsafe fn poll_read<'a>(
    &'a self,
    cx: &mut Context<'_>,
    buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>>
where
    &'a E: io::Read + 'a,
{
    use std::io::Read;

    loop {
        let evt = ready!(self.registration.poll_read_ready(cx))?;

        let b = unsafe { &mut *(buf.unfilled_mut() as *mut [std::mem::MaybeUninit<u8>] as *mut [u8]) };

        // used only when the cfgs below apply
        #[allow(unused_variables)]
        let len = b.len();

        match self.io.as_ref().unwrap().read(b) {
            Ok(n) => {
                // When mio is using the epoll or kqueue selector, reading a partially full
                // buffer is sufficient to show that the socket buffer has been drained.
                //
                // This optimization does not work for level-triggered selectors such as
                // windows or when poll is used.
                //
                // Read more:
                // https://github.com/tokio-rs/tokio/issues/5866
                #[cfg(all(
                    // keep in sync with poll_write
                    not(mio_unsupported_force_poll_poll),
                    any(
                        // epoll
                        target_os = "android",
                        // ...
                        target_os = "macos",
                        // ...
                    )
                ))]
                if 0 < n && n < len {
                    self.registration.clear_readiness(evt);
                }

                // Safety: We trust `TcpStream::read` to have filled up `n` bytes in the
                // buffer.
                unsafe { buf.assume_init(n) };
                buf.advance(n);
                return Poll::Ready(Ok(()));
            },
            Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
                self.registration.clear_readiness(evt);
            }
            Err(e) => return Poll::Ready(Err(e)),
        }
    }
}

循环体分三种结局:

  1. poll_read_ready 返回 Pending。 ready! 直接把 Pending 往上传,poll_read 结束。这是挂起的唯一出口。
  2. read 返回 WouldBlock。 就绪位是「过期」的:上一轮的数据已经读完了。带着 evt 里的 tick 清位,回到循环开头再问一次。如果这期间驱动没有新事件,第二次问就会存下 waker、返回 Pending;如果有,tick 对不上,清位被放弃,第二次问直接返回「可读」,再试一次 read。
  3. read 读到数据。 返回 Ready。如果把缓冲区读满了,就不清位:socket 里可能还有数据,下次 read 直接去读,不用等事件。

第三种情况里有一个优化:在 epoll 和 kqueue 上,如果读到的字节数比缓冲区小(0 < n < len),说明内核缓冲区已经被读空,直接清位。不清的话,下一次 read 一定会得到 WouldBlock,白白多一次系统调用。Windows 和 poll 后端是水平触发的,这个推断不成立,所以用 cfg 限定了平台(issue #5866)。n == 0 不清:那是 EOF,READ_CLOSED 本来也不会被清。

📝NOTE
冷知识:这个优化先是「排除谁」,后来改成「只给谁」

「读不满就清位」是 2022 年 7 月的 #4840(1.21.0)加的。提交说明引用 epoll 的手册:一次读没填满缓冲区,就足以说明 socket 缓冲区已经读空。当时的条件是 !cfg!(windows),只排除 Windows。

2023 年有人给 mio 加一个用 poll(2) 模拟边沿触发的后端(mio_unsupported_force_poll_poll)。它和 Windows 一样,要截住一次 EAGAIN 才会重新开始盯这个 fd。#5881(1.30.0)说明,不排除它的话,读不满就清位会让 socket 卡在一次永远不返回的读上,于是条件里又多了一个 !cfg!(mio_unsupported_force_poll_poll)。提出这个问题的 issue #5866 同时指出,「排除谁」的写法很脆弱:poll 后端以后要是在别的平台上启用,tokio 这边没跟着改,mio 一处无害的改动就会让这个功能整个坏掉。2024 年 7 月的 #6668(1.39.0)关掉了这个 issue,换成现在的写法:列出确定用 epoll 或 kqueue 的平台,只在它们上面清位。写方向的同一个优化(#4970,1.22.0)到 #7872(1.50.0)才同样改成列平台。#7872 还在两处 cfg 上各加了一句「keep in sync with」,提醒改一处时别忘了另一处。

unsafe 是因为把未初始化的 MaybeUninit<u8> 切片当成 [u8] 交给了 read。TcpStream::read 只写不读,所以这样做是可以的,调用方 poll_read_priv 的注释写的就是这条保证。

poll_io:同一个循环的通用版本

Registration 里有一个泛型的 poll_io,结构和上面完全一样,只是把 read 换成调用方传进来的闭包:

fn poll_io<R>(
    &self,
    cx: &mut Context<'_>,
    direction: Direction,
    mut f: impl FnMut() -> io::Result<R>,
) -> Poll<io::Result<R>> {
    loop {
        let ev = ready!(self.poll_ready(cx, direction))?;

        match f() {
            Ok(ret) => {
                return Poll::Ready(Ok(ret));
            }
            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
                self.clear_readiness(ev);
            }
            Err(e) => return Poll::Ready(Err(e)),
        }
    }
}

UdpSocket 的 poll_recv、UnixListener::poll_accept 等走的是 poll_read_io / poll_write_io,也就是它。TcpStream 的读写没有用它,因为要做上面那个「读不满就清位」的优化,而这个优化需要知道缓冲区长度。

Registration::poll_ready:先扣预算

fn poll_ready(
    &self,
    cx: &mut Context<'_>,
    direction: Direction,
) -> Poll<io::Result<ReadyEvent>> {
    ready!(crate::trace::trace_leaf(cx));
    // Keep track of task budget
    let coop = ready!(crate::task::coop::poll_proceed(cx));
    let ev = ready!(self.shared.poll_readiness(cx, direction));

    if ev.is_shutdown {
        return Poll::Ready(Err(gone()));
    }

    coop.made_progress();
    Poll::Ready(Ok(ev))
}
  • trace_leaf 是 taskdump 用的,平时什么也不做。
  • poll_proceed 扣 1 点协作预算。预算已经用完就直接返回 Pending,连 ScheduledIo 都不看,任务被放进 defer,等 worker 查过一次驱动后再唤醒(见协作式调度预算)。所以一个 socket 一直有数据,任务也不会在一次 poll 里读到天荒地老:最多 128 次 poll_ready 就得让出。
  • poll_readiness 返回 Pending 时,coop 被 drop,预算还回去。只有拿到了就绪事件才 made_progress(),这次扣减才算数。
  • 驱动已关闭时返回错误,不让任务挂在一个不会再有事件的 socket 上。

这里扣的是「拿到一次就绪事件」,不是「读到一次数据」。一次 poll_read 如果先拿到就绪、read 得到 WouldBlock、清位后再问,会调两次 poll_ready,第一次扣的那 1 点不会还回来。

❗IMPORTANT
动脑筋:4096 字节,8 字节一读,任务要被 poll 几次?

M2 Max、macOS、current_thread 运行时。对端往一个 accept 出来的 TcpStream 写 4096 字节,等它们都进了接收缓冲区,再 spawn 一个任务:每次被 poll,就用 8 字节的缓冲区反复调 poll_read,直到它返回 Pending,或者 4096 字节全部读完。

std::future::poll_fn(|cx| {
  let mut ok = 0;
  loop {
    let mut b = [0u8; 8];
    let mut rb = ReadBuf::new(&mut b);
    match Pin::new(&mut s).poll_read(cx, &mut rb) {
      Poll::Ready(r) => {
        r.unwrap();
        ok += 1;
        total += rb.filled().len();
        if total == 4096 {
          log.push(ok);
          return Poll::Ready(());
        }
      }
      Poll::Pending => {
        log.push(ok);
        return Poll::Pending;
      }
    }
  }
})

最后打印的 log 是什么?答案在本页末尾。

ScheduledIo::poll_readiness:存 waker,再看一眼

pub(super) fn poll_readiness(
    &self,
    cx: &mut Context<'_>,
    direction: Direction,
) -> Poll<ReadyEvent> {
    let curr = self.readiness.load(Acquire);

    let ready = direction.mask() & Ready::from_usize(READINESS.unpack(curr));
    let is_shutdown = SHUTDOWN.unpack(curr) != 0;

    if ready.is_empty() && !is_shutdown {
        // Update the task info
        let mut waiters = self.waiters.lock();
        let waker = match direction {
            Direction::Read => &mut waiters.reader,
            Direction::Write => &mut waiters.writer,
        };

        // Avoid cloning the waker if one is already stored that matches the
        // current task.
        match waker {
            Some(waker) => waker.clone_from(cx.waker()),
            None => *waker = Some(cx.waker().clone()),
        }

        // Try again, in case the readiness was changed while we were
        // taking the waiters lock
        let curr = self.readiness.load(Acquire);
        let ready = direction.mask() & Ready::from_usize(READINESS.unpack(curr));
        let is_shutdown = SHUTDOWN.unpack(curr) != 0;
        if is_shutdown {
            Poll::Ready(ReadyEvent {
                tick: TICK.unpack(curr) as u16,
                ready: direction.mask(),
                is_shutdown,
            })
        } else if ready.is_empty() {
            Poll::Pending
        } else {
            Poll::Ready(ReadyEvent {
                tick: TICK.unpack(curr) as u16,
                ready,
                is_shutdown,
            })
        }
    } else {
        Poll::Ready(ReadyEvent {
            tick: TICK.unpack(curr) as u16,
            ready,
            is_shutdown,
        })
    }
}

快路径不拿锁:读一次原子,有就绪位就带着当时的 tick 返回。

慢路径的顺序是先存 waker,再读一次原子。驱动那边的顺序是先置位(set_readiness),再拿锁取 waker(wake)。两边交叉,不会丢唤醒:

驱动置位发生在任务这边结果
任务拿锁之前第二次读原子时看到就绪位返回 Ready,不挂起
任务持锁期间驱动的 wake 等锁,任务放锁后它取到刚存进去的 waker任务返回 Pending,马上被唤醒
任务放锁之后第二次读原子没看到返回 Pending;驱动 wake 时取到 waker

如果反过来先读原子、再存 waker,驱动在两步之间置位并调 wake,wake 看到的是空槽,任务随后存下 waker、返回 Pending,再也没人叫醒它。

clone_from 是一个小优化:同一个任务反复 poll 时,槽里已经是它的 waker,Waker::clone_from 在两者 will_wake 时不做任何事,省掉一次克隆加一次 drop(各是一次引用计数的原子操作)。

另一半:数据到达之后

任务返回 Pending 后,Harness::poll 把它转回空闲状态(见 poll 与唤醒)。之后发生的事情都在别的地方:

  1. worker 没活了,去 park。 多线程运行时里抢到驱动锁的那个 worker 进 park_driver,一路调到 io::Driver::turn(None)(有定时器时是 Some(到期时间)),睡在 kevent/epoll_wait 上(任务窃取与空闲、驱动栈与 park)。如果所有 worker 都很忙,就靠每 61 个任务一次的 0 超时 turn(反应器 最后一节)。
  2. 内核报告可读。 poll 返回,turn() 拿到这个 socket 的事件,token 转成 &ScheduledIo,调 dispatch_event。
  3. 置位、唤醒。 set_readiness(Tick::Set, |curr| curr | READABLE) 把 tick 加 1;wake(READABLE) 拿锁取出 reader 槽里的 waker,放锁后调用。
  4. 任务进队列。 waker 是任务的 waker,任务状态转成 notified,进这个 worker 的 LIFO 槽或本地队列(worker 主循环)。
  5. 任务再次被 poll。 又走一遍 poll_read:poll_readiness 快路径看到可读,read 读到数据,返回 Ready。

实验:看到 Pending、wake、再次 poll

用一个手写的 future 包住 TcpStream::poll_read,每次 poll 打印结果;再把任务的 waker 包一层,被调用时打印所在线程。对端用 std 的阻塞 socket,在普通线程里 50ms 后写 5 字节:

use std::future::Future;
use std::io::Write;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, ReadBuf};
use tokio::net::{TcpListener, TcpStream};

// 包住任务的 waker:被调用时打印是哪个线程在叫醒
struct LoggingWaker {
  inner: Waker,
  start: Instant,
}

impl Wake for LoggingWaker {
  fn wake(self: Arc<Self>) {
    self.wake_by_ref();
  }
  fn wake_by_ref(self: &Arc<Self>) {
    let t = std::thread::current();
    println!(
      "{:>6.1?} wake() 在线程 {}",
      self.start.elapsed(),
      t.name().unwrap_or("?")
    );
    self.inner.wake_by_ref();
  }
}

// 手写的 read future:每次 poll 打印 poll_read 的结果
struct LoggedRead<'a> {
  stream: &'a mut TcpStream,
  buf: [u8; 64],
  polls: u32,
  start: Instant,
}

impl Future for LoggedRead<'_> {
  type Output = usize;
  fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<usize> {
    let me = &mut *self;
    me.polls += 1;
    let waker = Waker::from(Arc::new(LoggingWaker {
      inner: cx.waker().clone(),
      start: me.start,
    }));
    let mut cx2 = Context::from_waker(&waker);
    let mut rb = ReadBuf::new(&mut me.buf);
    let res = Pin::new(&mut *me.stream).poll_read(&mut cx2, &mut rb);
    let name = std::thread::current().name().unwrap_or("?").to_string();
    match res {
      Poll::Pending => {
        println!("{:>6.1?} 第 {} 次 poll:Pending(线程 {name})", me.start.elapsed(), me.polls);
        Poll::Pending
      }
      Poll::Ready(r) => {
        r.unwrap();
        let n = rb.filled().len();
        println!(
          "{:>6.1?} 第 {} 次 poll:Ready,读到 {n} 字节 {:?}(线程 {name})",
          me.start.elapsed(),
          me.polls,
          String::from_utf8_lossy(rb.filled())
        );
        Poll::Ready(n)
      }
    }
  }
}

fn main() {
  let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(1)
    .enable_all()
    .build()
    .unwrap();
  rt.block_on(async {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    // 对端用 std 的阻塞 socket,放在普通线程里写,和运行时无关
    let peer = std::thread::spawn(move || std::net::TcpStream::connect(addr).unwrap());
    let (mut stream, _) = listener.accept().await.unwrap();
    let mut peer = peer.join().unwrap();

    let start = Instant::now();
    let reader = tokio::spawn(async move {
      let mut fut = LoggedRead { stream: &mut stream, buf: [0; 64], polls: 0, start };
      (&mut fut).await
    });

    std::thread::spawn(move || {
      std::thread::sleep(Duration::from_millis(50));
      println!("{:>6.1?} 对端写入 5 字节(普通线程)", start.elapsed());
      peer.write_all(b"hello").unwrap();
    });

    reader.await.unwrap();
  });
}

macOS 上的输出:

86.9µs 第 1 次 poll:Pending(线程 tokio-rt-worker)
52.2ms 对端写入 5 字节(普通线程)
52.4ms wake() 在线程 tokio-rt-worker
52.5ms 第 2 次 poll:Ready,读到 5 字节 "hello"(线程 tokio-rt-worker)
  • 第 1 次 poll 返回 Pending。流是 accept 出来的,就绪位被假定为可读,所以这次 poll 里其实先做了一次 read,得到 WouldBlock,清位,再问一次才存下 waker。也就是本页开头时序图的前半段。
  • 写入发生在普通线程上,但 wake() 是在 worker 线程上调的。唯一的 worker 此时没活干,睡在驱动上;写入让 kevent 返回,worker 在 turn() 里分发事件,调到了任务的 waker。写线程只碰了内核的 socket,不知道 tokio 的存在。
  • 从写入到 wake 约 0.2ms,到第 2 次 poll 又约 0.1ms,其中包括了 println! 本身的开销。第 2 次 poll 一次就读到了数据,没有再次挂起。

动脑筋答案

完整程序:

use std::io::Write;
use std::pin::Pin;
use std::task::Poll;
use std::time::Duration;
use tokio::io::{AsyncRead, ReadBuf};
use tokio::net::TcpListener;

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  let addr = listener.local_addr().unwrap();
  let peer = std::thread::spawn(move || std::net::TcpStream::connect(addr).unwrap());
  let (mut s, _) = listener.accept().await.unwrap();
  let mut peer = peer.join().unwrap();
  peer.write_all(&[7u8; 4096]).unwrap();
  tokio::time::sleep(Duration::from_millis(50)).await; // 等 4096 字节都进了接收缓冲区

  let reads_per_poll = tokio::spawn(async move {
    let mut total = 0;
    let mut log = Vec::new();
    std::future::poll_fn(|cx| {
      let mut ok = 0;
      loop {
        let mut b = [0u8; 8];
        let mut rb = ReadBuf::new(&mut b);
        match Pin::new(&mut s).poll_read(cx, &mut rb) {
          Poll::Ready(r) => {
            r.unwrap();
            ok += 1;
            total += rb.filled().len();
            if total == 4096 {
              log.push(ok);
              return Poll::Ready(());
            }
          }
          Poll::Pending => {
            log.push(ok);
            return Poll::Pending;
          }
        }
      }
    })
    .await;
    log
  })
  .await
  .unwrap();
  println!("每次 poll 读成功的次数:{reads_per_poll:?}");
}

M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:

每次 poll 读成功的次数:[128, 128, 128, 128]
  • 一共 4 次 poll,每次 128 次。 每次 poll_read 都经过 poll_ready,扣 1 点协作预算,拿到就绪事件后 made_progress(),这 1 点就算用掉了。任务每次被 poll 时预算是 128,第 129 次 poll_read 时 poll_proceed 直接返回 Pending,连 ScheduledIo 都没看。任务被放进 defer,下次被 poll 时预算又是 128。
  • socket 里明明还有数据,也照样让出。 前三次 Pending 都是预算造成的,不是因为没有数据。
  • 就绪位从头到尾没被清过。 4096 是 8 的整数倍,每次都读满 8 字节,不会触发「读不满就清位」,也从没遇到 WouldBlock。第 4 次 poll 读到第 512 次正好读完,程序就此返回,没有再去试第 513 次。如果继续读,那一次会得到 WouldBlock,清位之后第二次 poll_ready 才会因为没有就绪位返回 Pending。

小结

  • TcpStream::poll_read 落到 PollEvented::poll_read 里的一个循环:问就绪 → read → WouldBlock 就带着 tick 清位、再问。挂起只有一个出口:poll_ready 返回 Pending。
  • 读到数据时一般不清位;只有在边沿触发的平台上读不满缓冲区时才清,省掉一次必然的 WouldBlock。
  • poll_ready 先扣协作预算,预算用完时连就绪状态都不看就让出;Pending 时预算还回去。
  • poll_readiness 先存 waker、再读一次原子,驱动先置位、再拿锁取 waker,两边交叉不会丢唤醒。
  • 唤醒发生在 park 在驱动上的那个 worker 的 turn() 里,任务进这个 worker 的队列,再次 poll 时走快路径读到数据。

💬 关联讨论

发起讨论
加载中...