从 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。一路往下:
| 层 | 函数 | 做什么 |
|---|---|---|
| 用户 API | AsyncReadExt::read 返回的 Read future 的 poll | 包一个 ReadBuf,调 poll_read |
TcpStream | AsyncRead::poll_read → poll_read_priv | 转给 PollEvented |
PollEvented | poll_read | 循环:等就绪 → read → 看结果 |
Registration | poll_read_ready → poll_ready | 扣协作预算,问 ScheduledIo |
ScheduledIo | poll_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)),
}
}
}
循环体分三种结局:
poll_read_ready返回Pending。ready!直接把Pending往上传,poll_read结束。这是挂起的唯一出口。read返回WouldBlock。 就绪位是「过期」的:上一轮的数据已经读完了。带着evt里的 tick 清位,回到循环开头再问一次。如果这期间驱动没有新事件,第二次问就会存下 waker、返回Pending;如果有,tick 对不上,清位被放弃,第二次问直接返回「可读」,再试一次read。read读到数据。 返回Ready。如果把缓冲区读满了,就不清位:socket 里可能还有数据,下次read直接去读,不用等事件。
第三种情况里有一个优化:在 epoll 和 kqueue 上,如果读到的字节数比缓冲区小(0 < n < len),说明内核缓冲区已经被读空,直接清位。不清的话,下一次 read 一定会得到 WouldBlock,白白多一次系统调用。Windows 和 poll 后端是水平触发的,这个推断不成立,所以用 cfg 限定了平台(issue #5866)。n == 0 不清:那是 EOF,READ_CLOSED 本来也不会被清。
「读不满就清位」是 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 点不会还回来。
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 与唤醒)。之后发生的事情都在别的地方:
- worker 没活了,去 park。 多线程运行时里抢到驱动锁的那个 worker 进
park_driver,一路调到io::Driver::turn(None)(有定时器时是Some(到期时间)),睡在kevent/epoll_wait上(任务窃取与空闲、驱动栈与 park)。如果所有 worker 都很忙,就靠每 61 个任务一次的 0 超时turn(反应器 最后一节)。 - 内核报告可读。
poll返回,turn()拿到这个 socket 的事件,token 转成&ScheduledIo,调dispatch_event。 - 置位、唤醒。
set_readiness(Tick::Set, |curr| curr | READABLE)把 tick 加 1;wake(READABLE)拿锁取出reader槽里的 waker,放锁后调用。 - 任务进队列。 waker 是任务的 waker,任务状态转成 notified,进这个 worker 的 LIFO 槽或本地队列(worker 主循环)。
- 任务再次被 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 时走快路径读到数据。