ScheduledIo 与就绪状态
主线位置:IO 主线的「
ScheduledIo记下 waker」和「mio 报告就绪 → wake」。任务和驱动在这里交接。
flowchart TB
subgraph drv[驱动线程]
ev[mio 事件] --> set["set_readiness(Set):就绪位或上,tick 加 1"]
set --> wk["wake:取出 waker,放锁后调用"]
end
subgraph task[任务线程]
pr["poll_readiness:读原子"] -->|有就绪位| sys[系统调用]
sys -->|WouldBlock| clr["clear_readiness(Clear tick)"]
pr -->|没有| st[拿锁,存 waker,再读一次]
end
set -.-> word[("readiness:一个 usize")]
clr -.-> word
pr -.-> word
st -.-> lock[("waiters:一把 Mutex")]
wk -.-> lock
结构
ScheduledIo 只有三个字段:
pub(crate) struct ScheduledIo {
pub(super) linked_list_pointers: UnsafeCell<linked_list::Pointers<Self>>,
/// Packs the resource's readiness and I/O driver latest tick.
readiness: AtomicUsize,
waiters: Mutex<Waiters>,
}
linked_list_pointers:挂在驱动的RegistrationSet链表上用的前后指针(见反应器)。readiness:就绪状态。驱动写、任务读,任务也会写(清位)。全程无锁。waiters:等待这个 socket 的 waker。只有任务真要挂起、或驱动要唤醒时才碰这把锁。
结构体上方有一大段 cfg_attr,按 CPU 架构设置对齐:x86_64、aarch64、powerpc64 上是 128 字节,s390x 上是 256,arm、mips 等是 32,其他 64。注释给的理由是避免伪共享:每个 socket 的 readiness 被驱动线程和任务线程来回写,两个 socket 的状态如果落在同一条缓存行(Intel 从 Sandy Bridge 起一次预取一对 64 字节的行,所以按 128 算)上,互不相干的两个连接也会互相拖慢。代价是每个连接多占一些内存。
一个 usize 里的三样东西
// The `ScheduledIo::readiness` (`AtomicUsize`) is packed full of goodness.
//
// | shutdown | driver tick | readiness |
// |----------+-------------+-----------|
// | 1 bit | 15 bits | 16 bits |
const READINESS: bit::Pack = bit::Pack::least_significant(16);
const TICK: bit::Pack = READINESS.then(15);
const SHUTDOWN: bit::Pack = TICK.then(1);
bit::Pack 是一对 (mask, shift)。least_significant(16) 占最低 16 位;then(15) 接着上一个字段的最高位往上放 15 位。展开后:
| 字段 | 位 | 掩码 | 取值 |
|---|---|---|---|
READINESS | 0–15 | 0x0000_FFFF | Ready 的位集合,现在只用了低 6 位 |
TICK | 16–30 | 0x7FFF_0000 | 0 到 32767,回绕 |
SHUTDOWN | 31 | 0x8000_0000 | 驱动已关闭 |
总共 32 位,所以 32 位平台上也放得下;64 位平台上高 32 位不用。READINESS 里的位来自 io/ready.rs:
| 位 | 值 | 含义 |
|---|---|---|
READABLE | 0b1 | 可读 |
WRITABLE | 0b10 | 可写 |
READ_CLOSED | 0b100 | 读端关闭(对端关了写) |
WRITE_CLOSED | 0b1000 | 写端关闭 |
PRIORITY | 0b1_0000 | 带外数据,仅 Linux 和 Android |
ERROR | 0b10_0000 | 出错 |
读方向看的是 READABLE | READ_CLOSED,写方向看的是 WRITABLE | WRITE_CLOSED(Direction::mask)。对端关闭之后,读端会一直「就绪」,read 返回 0,这正是 EOF 该有的样子。
三样东西放在同一个原子里,是因为它们必须一起读、一起改:任务读到「可读」时要同时拿到当时的 tick,后面清位要用;驱动置位时要同时把 tick 加 1;关闭位一旦设上,任何清位都不能把它抹掉。分开放就需要锁。
set_readiness:驱动和任务都走这一个函数
pub(super) fn set_readiness(&self, tick_op: Tick, f: impl Fn(Ready) -> Ready) {
let _ = self.readiness.fetch_update(AcqRel, Acquire, |curr| {
// If the io driver is shut down, then you are only allowed to clear readiness.
debug_assert!(SHUTDOWN.unpack(curr) == 0 || matches!(tick_op, Tick::Clear(_)));
const MAX_TICK: usize = TICK.max_value() + 1;
let tick = TICK.unpack(curr);
let new_tick = match tick_op {
// Trying to clear readiness with an old event!
Tick::Clear(t) if tick != usize::from(t) => return None,
Tick::Clear(t) => usize::from(t),
Tick::Set => tick.wrapping_add(1) % MAX_TICK,
};
let ready = Ready::from_usize(READINESS.unpack(curr));
// Keep the shutdown bit, so that a clear after shutdown (a
// `WouldBlock` observed once the driver is gone) does not turn the
// next wait into a wait for an event that will never come.
let next = TICK.pack(new_tick, f(ready).as_usize());
Some(SHUTDOWN.pack(SHUTDOWN.unpack(curr), next))
});
}
fetch_update 是一个 CAS 循环:读当前值,算新值,CAS 失败就用最新值重算。闭包返回 None 时放弃,不写。
两种调用:
| 调用方 | tick_op | f | 效果 |
|---|---|---|---|
驱动 dispatch_event | Tick::Set | curr | ready | 就绪位或上新事件,tick 加 1(模 32768) |
任务 clear_readiness | Tick::Clear(t) | curr - mask | tick 还是 t 才清位,tick 不变 |
为什么要有 tick
mio 是边沿触发的(epoll 的 EPOLLET、kqueue 的 EV_CLEAR):socket 从「没数据」变成「有数据」时报告一次,之后只要数据还没读完,就不会再报。所以 tokio 的规则是:就绪位一旦置上,就一直留着,直到任务亲眼看到 WouldBlock 才清。
问题出在「看到 WouldBlock」和「清位」是两步,中间驱动可能又来了一个事件:
| 时刻 | 任务线程 | 驱动线程 | readiness |
|---|---|---|---|
| 1 | poll_readiness:可读,记下 tick = 5 | READABLE,tick 5 | |
| 2 | read() 返回 WouldBlock(缓冲区已空) | 同上 | |
| 3 | 对端又发来数据,poll 报告可读;set_readiness(Set) | READABLE,tick 6 | |
| 4 | clear_readiness:Clear(5) | 见下 |
- 有 tick:时刻 4 发现 tick 是 6 不是 5,闭包返回
None,什么也不改。任务回到循环开头,再看一次就绪位,还是可读,再read(),读到时刻 3 到达的数据。 - 没有 tick:时刻 4 把
READABLE清掉。时刻 3 的那次边沿已经被消耗了,内核不会再报。数据躺在 socket 缓冲区里,任务挂起等一个永远不会来的事件。
这是一个 ABA 问题:任务在时刻 1 和时刻 4 看到的就绪位都是 READABLE,但中间已经换了一个事件。tick 让两个「可读」可以区分。
信箱上有一面小旗(READINESS 里的 READABLE),旗杆上还有一个只会往上翻的计数器(TICK)。邮递员(驱动的 dispatch_event)每次来都把旗子竖起来,计数器翻一格(Tick::Set)。你(任务)看到旗子竖着,记下计数器的读数(ReadyEvent 里的 tick),去开信箱。信箱是空的(WouldBlock),你放下旗子之前先看一眼计数器(Tick::Clear(t)):还是刚才那个数,说明这段时间没人来过,放下旗子;数变了,说明邮递员刚来过,旗子不动,回去再开一次信箱。
tick 只有 15 位。如果读到事件和清位之间恰好来了 32768 的整数倍个事件,tick 会转一圈回到原值,误判仍然可能发生。这在实际中几乎不可能:两步之间只隔一次系统调用。
这几位的历史:
- 2023 年 11 月以前 tick 是 8 位,旁边还有 7 位给 slab 用的代数。#5833 去掉 slab 后空出了位,#6135 把 tick 扩到 15 位,提交说明直言「目前没有证据说明需要,只是位空着,不用白不用」。
- 但
ReadyEvent里的tick字段和比较时用的还是u8,扩出来的 7 位实际上没起作用。直到 2026 年 9 月的 #8331 才改成u16,同时加了测试stale_event_does_not_clear_readiness_after_u8_wraparound:读事件后再置位 256 次,用旧事件清位,就绪位必须还在。用u8比较时,256 次之后 tick 截断回原值,旧事件就会把新的就绪位清掉。
1.34.0 之前,tick 不是每个 ScheduledIo 自己的计数,而是驱动的全局计数:驱动每转一圈加 1,置位时把当时的值截成 u8 存进 ScheduledIo。issue #6133 描述了它的漏洞:一个 socket 的就绪状态停在第 0 圈,任务闲着没去碰它;这期间驱动转了 256 圈;任务因为别的原因醒来,进了读循环,恰好在「看到 WouldBlock」和「清位」之间,驱动在第 256 圈给它置位。256 截成 u8 是 0,清位以为没有新事件,把新的就绪位清掉,这个 IO 资源就挂住了。issue 还说明,把位数加宽只会让它更难发生,不能修好它。
#6134(1.34.0)把 tick 改成每个资源一份:只有这个 socket 自己被置位时才加 1,就是现在 set_readiness 里的 Tick::Set => tick.wrapping_add(1)。别的 socket 再忙,也推不动它的 tick。之前 Tick::Set 带着一个参数,是驱动传进来的全局 tick。上面说的扩到 15 位是同一版本里另外一个 PR。
clear_readiness:关闭状态不清
pub(crate) fn clear_readiness(&self, event: ReadyEvent) {
// This consumes the current readiness state **except** for closed
// states. Closed states are excluded because they are final states.
let mask_no_closed = event.ready - Ready::READ_CLOSED - Ready::WRITE_CLOSED;
self.set_readiness(Tick::Clear(event.tick), |curr| curr - mask_no_closed);
}
清的是 event.ready,也就是任务当时看到的那几位,不是全部。关闭位不清:连接关了就是关了,之后的每次读写都应该立即返回,而不是去等一个不会再来的事件。
macOS(kqueue)上,用 AsyncFd 包一个 UnixStream,分两种情况:对端写 1 字节;对端关闭写方向。每种情况跑三轮,每轮 readable().await 拿到 guard 后不读数据,直接 clear_ready():
for round in 0..3 {
match timeout(Duration::from_millis(200), fd.readable()).await {
Ok(guard) => {
let mut guard = guard.unwrap();
println!("第 {round} 轮:返回");
guard.clear_ready(); // 不读数据,直接清位
}
Err(_) => println!("第 {round} 轮:200ms 内没返回"),
}
}
set_readiness 保留 SHUTDOWN 位也是同一个道理。驱动关闭后任务仍可能调 clear_readiness,如果把关闭位清掉,下一次等待就会挂死。
还有一个反方向的入口 assume_ready:不等驱动的事件,直接把就绪位或上,tick 不变。accept 出来的 TcpStream、UnixStream 假定可读可写,UnixStream::pair 和 UnixDatagram::pair 假定可写:新接受的连接一般可写,并且往往已经带着对端发来的第一批数据,满载时第一个可读事件可能排在其他连接的事件后面很久。猜错的代价是一次 WouldBlock,然后照常清位(Registration::assume_ready 的注释)。
assume_ready 是 1.54.0 才加的,同一个 PR 还修了关闭位1.54.0 之前,accept 出来的流和别的注册一样,从空的就绪位开始,第一次读写要等驱动报告它的第一个事件。#8435 给了一组数据:一台跑满的 HTTP/2 服务器(2 核、4 个 worker,每次 poll 取的事件数按 #8410 设了上限)上,新接受的连接要等 64 到 128ms 才第一次尝试读,而这次读每次都读到了数据(追踪的 5298 个连接全是如此)。原因是新连接的事件排在所有老连接的事件后面。
这个 PR 的第一个提交修的是上面 set_readiness 里「保留关闭位」那几行。之前它只用 tick 和就绪位重新拼出整个字,驱动关闭之后的一次 clear_readiness 会把关闭位抹掉,下一次等待就会一直挂着,而不是返回关闭错误。提交说明写道,关闭前就绪位已经置上的资源本来就能触发它;accept 出来的流一律假定就绪之后,这就成了常见情况,所以先修。
Waiters:两个专用槽,加一条链表
#[derive(Debug, Default)]
struct Waiters {
/// List of all current waiters.
list: LinkedList<Waiter>,
/// Waker used for `AsyncRead`.
reader: Option<Waker>,
/// Waker used for `AsyncWrite`.
writer: Option<Waker>,
}
(Waiters)
等待一个 socket 有两种 API:
| API | 用法 | waker 存在哪 |
|---|---|---|
poll_read_ready / poll_write_ready,以及基于它的 AsyncRead、AsyncWrite | poll_xxx(&self, cx),没有 future 可以存状态 | reader 或 writer 槽,每个方向只有一个 |
readable()、writable()、ready()、async_io,以及 AsyncFd | async fn,返回一个 future | future 里自带的 Waiter 节点,挂进 list |
poll_* 风格的函数只有 &self 和 cx,没地方放链表节点,所以每个方向只给一个槽,后来的覆盖先前的。这对 AsyncRead 足够了:同一个流不会有两个任务同时读(poll_read 要 Pin<&mut Self>)。TcpStream::split 出来的读半边和写半边可以在两个任务里用,正好一个用 reader、一个用 writer。Registration 的文档把这条约束写明了:最多两个任务同时用,一个读一个写;违反它不会内存不安全,但会丢通知、任务挂住。
async fn 风格的 API 返回的 future 可以把一个 Waiter 节点放在自己身体里(future 被 pin 住,地址不变),挂进侵入式链表,所以任意多个任务可以等同一个 socket。
实验:两个任务等同一个 socket
两个任务先后挂起,等同一个 socket 可读,然后对端写入 1 字节。一轮用 poll_read_ready,一轮用 readable():
use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{TcpListener, TcpStream};
use tokio::time::sleep;
async fn pair() -> (Arc<TcpStream>, std::net::TcpStream) {
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 (s, _) = listener.accept().await.unwrap();
// accept 出来的流被假定可读;先 try_read 一次,拿到 WouldBlock,把就绪位清掉
let mut b = [0u8; 8];
assert_eq!(
s.try_read(&mut b).unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
(Arc::new(s), peer.join().unwrap())
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
for mode in ["poll_read_ready", "readable"] {
let (s, mut peer) = pair().await;
let mut tasks = Vec::new();
for name in ["A", "B"] {
let s = s.clone();
tasks.push(tokio::spawn(async move {
if mode == "poll_read_ready" {
std::future::poll_fn(|cx| s.poll_read_ready(cx)).await.unwrap();
} else {
s.readable().await.unwrap();
}
println!(" {name} 被唤醒");
}));
sleep(Duration::from_millis(10)).await; // 让 A 先挂起,B 后挂起
}
println!("{mode}:A、B 都在等,对端写入");
peer.write_all(b"x").unwrap();
sleep(Duration::from_millis(200)).await;
for (name, t) in ["A", "B"].iter().zip(&tasks) {
if !t.is_finished() {
println!(" {name} 200ms 后还在等");
}
}
for t in tasks {
t.abort();
}
}
}
输出:
poll_read_ready:A、B 都在等,对端写入
B 被唤醒
A 200ms 后还在等
readable:A、B 都在等,对端写入
B 被唤醒
A 被唤醒
poll_read_ready 那一轮,B 的 waker 覆盖了 A 留在 reader 槽里的 waker,A 再也没人叫醒。数据明明到了,A 却一直挂着,这就是 Registration 文档说的「丢通知」。readable() 那一轮,两个 Waiter 节点都在链表里,都被唤醒。B 先于 A,是因为新节点 push_front 插在表头,唤醒时从表头开始遍历。
wake:放锁之后再叫醒
ScheduledIo::wake 由驱动在 set_readiness 之后调用:
pub(super) fn wake(&self, ready: Ready) {
let mut wakers = WakeList::new();
let mut waiters = self.waiters.lock();
// check for AsyncRead slot
if ready.is_readable() {
if let Some(waker) = waiters.reader.take() {
wakers.push(waker);
}
}
// check for AsyncWrite slot
if ready.is_writable() {
if let Some(waker) = waiters.writer.take() {
wakers.push(waker);
}
}
'outer: loop {
let mut iter = waiters.list.drain_filter(|w| ready.satisfies(w.interest));
while wakers.can_push() {
match iter.next() {
Some(waiter) => {
let waiter = unsafe { &mut *waiter.as_ptr() };
if let Some(waker) = waiter.waker.take() {
waiter.is_ready = true;
wakers.push(waker);
}
}
None => {
break 'outer;
}
}
}
drop(waiters);
wakers.wake_all();
// Acquire the lock again.
waiters = self.waiters.lock();
}
// Release the lock before notifying
drop(waiters);
wakers.wake_all();
}
- 两个专用槽按方向取:可读事件只取
reader,可写事件只取writer。 - 链表里只摘下兴趣匹配的节点(
ready.satisfies(w.interest)),摘下时把节点的is_ready置为true,future 下次被 poll 时据此知道自己已经被通知过。 - waker 先收集进栈上的
WakeList,放锁之后再统一调用。注释说在锁里调 waker 可能死锁:waker 里可以是任意代码,只要它回头碰这个 socket 的waiters,就会拿同一把锁。WakeList一次最多装 32 个,等待者更多时就分批:装满、放锁、唤醒、重新拿锁、继续。
Readiness future:链表节点放在 future 里
readable() 一路调到 ScheduledIo::readiness,返回的是 Readiness:
struct Readiness<'a> {
scheduled_io: &'a ScheduledIo,
state: State,
/// Entry in the waiter `LinkedList`.
waiter: UnsafeCell<Waiter>,
}
enum State {
Init,
Waiting,
Done,
}
它的 poll 是一个三态的状态机:
stateDiagram-v2 [*] --> Init Init --> Done: 原子里已有就绪位 Init --> Done: 拿锁后再看,已有就绪位 Init --> Waiting: 存 waker,节点挂进链表 Waiting --> Waiting: is_ready 为假,更新 waker Waiting --> Done: is_ready 为真 Done --> [*]: 读原子,返回 ReadyEvent
- Init:先不拿锁读一次原子,就绪就直接返回。不就绪再拿锁、再读一次。第二次读是必要的:第一次读和拿锁之间,驱动可能已经置位并调过
wake(),那次wake()时链表里还没有这个节点,如果不再看一眼,就会错过这次事件。拿着锁读到「不就绪」之后挂进链表,驱动下一次wake()必须先拿到这把锁,一定能看到这个节点。 - Waiting:被 poll 时拿锁看
is_ready。wake()把节点从链表摘下时置的就是它。没被通知就用clone_from更新 waker(调用方这次传来的 waker 可能和上次不同),返回Pending。 - Done:重新读一次原子,返回当前的就绪位和 tick。注释说返回的 tick 可能比唤醒它的那个事件更新,就绪位也可能已经被别人清空,这都可以接受:调用方会去试系统调用,
WouldBlock了就清位,再等一轮。
Drop 拿锁把节点从链表里摘掉。future 被取消(比如在 select! 里输了)时,链表里不会留下一个指向已释放内存的节点。节点已经被 wake() 摘掉的话,再摘一次是空操作。
poll_readiness 那条路(专用槽)也有同样的「拿锁后再读一次」,下一章会读到。
动脑筋答案
完整程序(UnixStream 用的是 std 的):
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::time::Duration;
use tokio::io::unix::AsyncFd;
use tokio::time::timeout;
#[tokio::main(flavor = "current_thread")]
async fn main() {
for case in ["对端写 1 字节", "对端关闭写方向"] {
let (a, mut b) = UnixStream::pair().unwrap();
a.set_nonblocking(true).unwrap();
// SAFETY: `a` 归 AsyncFd 所有,fd 在 AsyncFd drop 之前一直打开
let fd = unsafe { AsyncFd::register(a) }.unwrap();
if case == "对端写 1 字节" {
b.write_all(b"x").unwrap();
} else {
b.shutdown(std::net::Shutdown::Write).unwrap();
}
println!("{case}:");
for round in 0..3 {
match timeout(Duration::from_millis(200), fd.readable()).await {
Ok(guard) => {
let mut guard = guard.unwrap();
let r = guard.ready();
println!(
" 第 {round} 轮:返回,is_readable={} is_read_closed={}",
r.is_readable(),
r.is_read_closed()
);
guard.clear_ready(); // 不读数据,直接清位
}
Err(_) => println!(" 第 {round} 轮:200ms 内没返回"),
}
}
}
}
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:
对端写 1 字节:
第 0 轮:返回,is_readable=true is_read_closed=false
第 1 轮:200ms 内没返回
第 2 轮:200ms 内没返回
对端关闭写方向:
第 0 轮:返回,is_readable=true is_read_closed=true
第 1 轮:返回,is_readable=true is_read_closed=true
第 2 轮:返回,is_readable=true is_read_closed=true
- 写 1 字节,第 0 轮:kqueue 报告可读,驱动置上
READABLE,readable()返回。 - 写 1 字节,第 1、2 轮:
clear_ready()走的就是clear_readiness,tick 没变,READABLE被清掉。那 1 字节还在 socket 里,但边沿触发下内核已经报告过这次「变成可读」,不会再报,readable()只能一直等。这就是 tokio 自己只在亲眼看到WouldBlock之后才清位的原因。AsyncFd把清位交给了调用方,没读到WouldBlock就清,就会这样挂住。 - 关闭写方向,三轮都返回:EOF 事件同时带来
READABLE和READ_CLOSED。clear_readiness减掉的是event.ready - READ_CLOSED - WRITE_CLOSED,只清掉READABLE,READ_CLOSED留着。readable()等的是Interest::READABLE的掩码,也就是READABLE | READ_CLOSED,有其中一位就返回,所以每轮都立即返回。 is_readable一直是true:这不说明READABLE位还在。Ready::is_readable的定义是「有READABLE或者有READ_CLOSED」,读端关闭也算可读,因为这时read会立即返回 0。从公开 API 看不到READABLE这一位本身。
小结
readiness一个usize:低 16 位就绪位,中间 15 位 tick,第 31 位关闭标志。三样东西必须一起读、一起改,所以放在一个原子里。- 驱动置位时 tick 加 1;任务清位时带着当初读到的 tick,对不上就放弃。这样「看到
WouldBlock」和「清位」之间新到的边沿事件不会被吞掉。 - 关闭位和读写关闭位都不会被清:这些是终态,之后的读写应立即返回。
poll_*风格的 API 每个方向只有一个 waker 槽,后来的覆盖先前的;readable()这类async fn把链表节点放在 future 里,任意多个任务可以等同一个 socket。wake()在锁里收集 waker,放锁后再调用,每批最多 32 个。