反应器
主线位置:IO 主线的「worker 空闲时 park 在 IO 驱动上 → mio 报告就绪 → wake」。这一章讲拿到驱动的那个线程在
turn()里做了什么。
flowchart TB
enter["turn(max_wait)"] --> rel[释放上一轮注销的 ScheduledIo]
rel --> pick{"max_wait 是 0 且设了忙批量?"}
pick -->|是| busy[用 events_busy]
pick -->|否| full["用 events(默认 1024)"]
busy --> poll["mio Poll::poll"]
full --> poll
poll --> each[逐个看事件的 token]
each -->|"Token(0)"| nop[唤醒用,什么也不做]
each -->|"Token(1)"| sig["signal_ready = true"]
each -->|其他| disp["dispatch_event:置就绪位、wake"]
Driver 和 Handle:独占的一半和共享的一半
Driver 和 Handle,去掉 io-uring 的两个字段:
/// I/O driver, backed by Mio.
pub(crate) struct Driver {
/// True when an event with the signal token is received
signal_ready: bool,
/// Reuse the `mio::Events` value across calls to poll.
events: mio::Events,
/// Buffer for polls that do not wait, if
/// `Builder::max_io_events_per_busy_tick` is set.
events_busy: Option<mio::Events>,
/// The system event queue.
poll: mio::Poll,
}
/// A reference to an I/O driver.
pub(crate) struct Handle {
/// Registers I/O resources.
registry: mio::Registry,
/// Tracks all registrations
registrations: RegistrationSet,
/// State that should be synchronized
synced: Mutex<registration_set::Synced>,
/// Used to wake up the reactor from a call to `turn`.
/// Not supported on `Wasi` due to lack of threading support.
#[cfg(not(target_os = "wasi"))]
waker: mio::Waker,
pub(crate) metrics: IoDriverMetrics,
// ...
}
两半的分法按「谁在用」切:
| 谁持有 | 拿来做什么 | |
|---|---|---|
Driver | 同一时刻只有一个线程,&mut self | mio::Poll 和事件缓冲区,只有 turn() 用 |
Handle | 运行时句柄里共享,Send + Sync | 注册、注销 socket;从别的线程叫醒正在 turn() 的线程 |
mio::Poll::poll 要 &mut self,所以等事件这件事天然只能有一个线程在做。多线程运行时里,Driver 放在所有 worker 共享的一把 TryLock 里,抢到的 worker 去等事件,抢不到的睡在自己的条件变量上(见任务窃取与空闲)。注册用的 mio::Registry 是从 Poll 里 try_clone 出来的,在 epoll 和 kqueue 上它和 Poll 指向同一个内核对象,所以任务可以在任何线程上注册 socket,不用碰 Driver。
events 的容量来自 Driver::new 的 nevents,默认 1024(builder.rs 第 311 行),可以用 max_io_events_per_tick 改。它是一次 poll 最多取回的事件数,不是上限:没取完的事件留在内核里,下一次 poll 再拿。events_busy 是另一个缓冲区,后面讲。
两个保留的 token
const TOKEN_WAKEUP: mio::Token = mio::Token(0);
const TOKEN_SIGNAL: mio::Token = mio::Token(1);
Token(0):唤醒。Driver::new用它创建一个mio::Waker,存在Handle里。Handle::unpark触发它,正睡在epoll_wait/kevent上的线程就会带着一个Token(0)事件返回;如果当时没人在睡,下一次poll会立即返回。turn()看到这个 token 什么都不做,它的作用就是让poll返回。Token(1):信号。 开了signalfeature 时,信号驱动把一个UnixStream的读端用这个 token 注册进来(register_signal_receiver)。信号处理函数往写端写一个字节,turn()只把signal_ready置成true。真正去读管道、分发信号的是外面一层的信号驱动:它在io.park()返回后调process,先用consume_signal_ready看一眼这个标志。
其他所有 token 都是 ScheduledIo 的地址。地址不会是 0 或 1:ScheduledIo 在 x86_64 和 aarch64 上按 128 字节对齐,在任何平台上至少按 16 字节对齐,所以不会和这两个保留值撞上。
turn():一次事件循环
Driver::turn,去掉 wasi 和 io-uring 的分支:
fn turn(&mut self, handle: &Handle, max_wait: Option<Duration>) {
debug_assert!(!handle.registrations.is_shutdown(&handle.synced.lock()));
handle.release_pending_registrations();
// A poll that does not wait takes the busy batch. Events it leaves
// behind stay queued in the kernel, so the next poll returns them.
let events = match (&mut self.events_busy, max_wait) {
(Some(busy), Some(wait)) if wait.is_zero() => busy,
_ => &mut self.events,
};
// Block waiting for an event to happen, peeling out how many events
// happened.
match self.poll.poll(events, max_wait) {
Ok(()) => {}
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
// ...
Err(e) => panic!("unexpected error when polling the I/O driver: {e:?}"),
}
// Process all the events that came in, dispatching appropriately
let mut ready_count = 0;
for event in events.iter() {
let token = event.token();
if token == TOKEN_WAKEUP {
// Nothing to do, the event is used to unblock the I/O driver
} else if token == TOKEN_SIGNAL {
self.signal_ready = true;
} else {
let ready = Ready::from_mio(event);
// Safety: we ensure that the pointers used as tokens are not freed
// until they are both deregistered from mio **and** we know the I/O
// driver is not concurrently polling. The I/O driver holds ownership of
// an `Arc<ScheduledIo>` so we can safely cast this to a ref.
unsafe { dispatch_event(token, ready) };
ready_count += 1;
}
}
// ...
handle.metrics.incr_ready_count_by(ready_count);
}
按顺序:
- 释放上一轮注销的
ScheduledIo。 原因见下面「注销」一节。 - 选缓冲区。 只有「不等待」(
max_wait是 0)并且设了max_io_events_per_busy_tick时才用小的events_busy。 poll。max_wait由外层决定:None是一直睡到有事件;Some(d)是时间驱动算出来的、到下一个定时器到期的时间;Some(0)是忙的 worker 顺手查一下(见本页最后一节)。被信号打断(EINTR)不算错,其他错误直接 panic。- 分发。 每个事件先用
Ready::from_mio把 mio 的事件翻译成 tokio 的Ready位(可读、可写、读关闭、写关闭、错误,Linux 上还有 priority),再交给dispatch_event。
turn() 自己不调度任何任务。它调的 wake() 就是任务的 waker,任务怎么进队列由调度器决定:在 worker 线程上、Core 还在 Context 里时,进这个 worker 的 LIFO 槽和本地队列(见 worker 主循环 里 park_internal 那一节)。
token 就是指针
注册时 token 怎么来,见 ScheduledIo::token:
pub(crate) fn token(&self) -> mio::Token {
mio::Token(super::EXPOSE_IO.expose_provenance(self))
}
事件回来时怎么找回去,见 dispatch_event:
pub(super) unsafe fn dispatch_event(token: mio::Token, ready: Ready) {
let ptr = super::EXPOSE_IO.from_exposed_addr(token.0);
// SAFETY: the caller guarantees that the token refers to a live `ScheduledIo`.
let io: &ScheduledIo = unsafe { &*ptr };
io.set_readiness(Tick::Set, |curr| curr | ready);
io.wake(ready);
}
EXPOSE_IO 是一个 PtrExposeDomain。正常编译时它就是 ptr as usize 和 addr as *const T 两次转换,没有任何开销;在 miri 下它换成一张 BTreeMap,记下每个暴露出去的地址对应的指针,让 miri 能按严格来源(strict provenance)规则检查。
所以从事件到 ScheduledIo 没有查表,一次转换就到。代价是一条安全约束:内核那边还可能带着这个地址回来的时候,ScheduledIo 不能被释放。下一节的注销流程就是为这条约束设计的。
问: ScheduledIo 本来就放在 Arc 里。dispatch_event 拿到地址以后,先给引用计数加一,用完再减,不就不怕它被释放了吗?
答: 加一也要先去那块内存里改计数器。地址如果已经被释放,加一这一步本身就是 use-after-free。引用计数能保证的是「我手里有一份引用时,对象不会消失」;从一个裸地址出发,它回答不了「对象现在还在不在」。所以 tokio 换了个办法:驱动在 registrations 链表里一直攥着一份 Arc,turn() 里 dispatch_event 上方的 SAFETY 注释说的就是这一份。这份引用什么时候能放,由下一节的注销流程决定。
源码里 ScheduledIo 上方的注释还写着「Stored in the I/O driver resource slab」。这是老版本留下的:1.30 之前 ScheduledIo 放在 slab 里,token 是 24 位的 slab 地址加 7 位代数,#5833 把 slab 换成了 Arc 加单独分配,token 从此就是地址。
注册和注销
RegistrationSet
驱动用 RegistrationSet 记住所有注册过的 ScheduledIo。真正的数据在锁里的 Synced:
pub(super) struct RegistrationSet {
num_pending_release: AtomicUsize,
}
pub(super) struct Synced {
// True when the I/O driver shutdown. At this point, no more registrations
// should be added to the set.
is_shutdown: bool,
// List of all registrations tracked by the set
registrations: LinkedList<Arc<ScheduledIo>>,
// Registrations that are pending drop. When a `Registration` is dropped, it
// stores its `ScheduledIo` in this list. The I/O driver is responsible for
// dropping it. This ensures the `ScheduledIo` is not freed while it can
// still be included in an I/O event.
pending_release: Vec<Arc<ScheduledIo>>,
}
registrations是一条侵入式链表,每个节点持有一份Arc<ScheduledIo>。驱动靠它保证:只要还在链表里,ScheduledIo就活着,不管用户那边的TcpStream是不是已经 drop 了。关闭时也靠它找到所有 socket。pending_release是已经注销、等待驱动释放的那些。num_pending_release放在锁外面,是pending_release长度的一份拷贝,让turn()不拿锁就能知道有没有活要干。
注册
let scheduled_io = self.registrations.allocate(&mut self.synced.lock())?;
let token = scheduled_io.token();
// we should remove the `scheduled_io` from the `registrations` set if registering
// the `source` with the OS fails. Otherwise it will leak the `scheduled_io`.
if let Err(e) = self.registry.register(source, token, interest.to_mio()) {
// safety: `scheduled_io` is part of the `registrations` set.
unsafe {
self.registrations
.remove(&mut self.synced.lock(), &scheduled_io)
};
return Err(e);
}
allocate 新建一个 Arc<ScheduledIo>,克隆一份挂进链表,另一份返回给调用方,最后存进 Registration。驱动已经关闭时直接返回错误。
先进链表,再向内核注册。顺序反过来的话,register 成功之后、进链表之前如果来了一个事件,驱动就会拿着一个没人保活的地址。
mio 在 epoll 上用 EPOLLET,在 kqueue 上用 EV_CLEAR,也就是边沿触发:一次「从不可读变成可读」只报告一次。TcpStream 只注册一次,读写两个方向一起(PollEvented::new 用的是 READABLE | WRITABLE),之后不再修改兴趣集。边沿触发对 tokio 的要求是:只有亲眼看到 WouldBlock,才能认为「不可读了」。下一章的 tick 就是为这件事服务的。
注销:先交给驱动,下一轮再放
TcpStream drop 时,PollEvented 的 Drop 调 Registration::deregister,走到 Handle::deregister_source:
// Deregister the source with the OS poller **first**.
//
// If `deregister` fails (for example, because the caller closed the raw
// file descriptor before dropping `AsyncFd`), we must NOT release the
// `ScheduledIo` registration. On Linux, if the file descriptor was
// duplicated before the original fd number was closed, the open file
// description remains registered in epoll with the `ScheduledIo` token
// pointer even though `EPOLL_CTL_DEL` fails with `EBADF`.
self.registry.deregister(source)?;
if self
.registrations
.deregister(&mut self.synced.lock(), registration)
{
self.unpark();
}
deregister_source 里那个 ? 的意思是:从内核注销失败就直接返回,ScheduledIo 不进 pending_release,一直留在链表里,不释放。2026 年 1 月的 #7773(1.50.0)把这当成泄漏来修:用户先关了 fd 再 drop AsyncFd,注销必然失败,ScheduledIo 越积越多。它改成不管内核那边成不成功都照常释放,PR 给的理由是「注销失败,说明操作系统本来就不再跟踪它」。
这条推理在 Linux 上不成立。按 #8540 的说明,epoll 的登记挂在内核里的「打开文件描述」上,不是挂在 fd 号码上。fd 如果先被 dup 过,关掉原来的号码以后,EPOLL_CTL_DEL 返回 EBADF,但副本让那份打开文件描述还活着,epoll 里的登记和它带着的 ScheduledIo 地址也都还在。下一次就绪事件到来,驱动就会访问已释放的内存。issue #8537 是一份安全报告,复现程序只用了标准库和 tokio 的安全 API。#8540 把 #7773 整个撤回,随 1.51.5 发布(也进了 1.53.2 和 1.54.0),代码上方那段注释就是这时写的。
撤回只挡住了常见情况,#8540 之后的 #8587(1.51.6,也进了 1.53.3 和 1.54.0)自己这么说。它列出两条根治的路:epoll 的用户数据里不再存指针,改存一个查表用的键;或者让 AsyncFd 遵守 I/O 安全。它选了后者:AsyncFd::new 被标成弃用,换成 unsafe 的 AsyncFd::register,由调用方保证 fd 在 AsyncFd 活着期间一直打开、一直指向同一份打开文件描述。token 仍然是地址。
RegistrationSet::deregister 不释放任何东西,只是把 Arc 再克隆一份放进 pending_release:
pub(super) fn deregister(&self, synced: &mut Synced, registration: &Arc<ScheduledIo>) -> bool {
synced.pending_release.push(registration.clone());
let len = synced.pending_release.len();
self.num_pending_release.store(len, Release);
len == NOTIFY_AFTER
}
为什么不当场从链表里摘掉?因为驱动可能正在另一个线程上分发事件。设想这个顺序:
| 驱动线程 | 任务线程 |
|---|---|
poll 返回,events 里有这个 socket 的事件 | |
drop TcpStream:从 epoll 删掉 fd,从链表摘掉,Arc 归零,内存释放 | |
dispatch_event 把 token 转成指针,解引用 |
最后一步就是 use-after-free。从 epoll 删掉 fd 只能保证以后的 poll 不再报告它,管不了已经拷进 events 缓冲区的那一个。
所以真正的释放推迟到下一次 turn() 开头的 release_pending_registrations。那时上一轮的 events 已经分发完了,新一轮的 poll 还没开始,而 fd 早已从内核注销,不会再出现在新的事件里。turn() 拿着 &mut Driver,同一时刻只有一个线程在跑它,所以「上一轮分发完」是确定的。release 把 pending_release 整个取走,逐个从链表摘下,两份 Arc 都放掉,ScheduledIo 才真正释放。
问: 前面 add_source 里,向内核注册失败时直接 remove,当场把 ScheduledIo 从链表摘掉了。注销却要推迟到下一轮,这两处是不是矛盾?
答: 不矛盾。推迟是为了等「内核可能已经带着这个地址发出来的事件」分发完。注册失败时,内核从来没收下这个 token,不会有任何事件带着它,没什么可等的。这段 remove 是 #6329(1.37.0)加的,在那之前注册失败的 ScheduledIo 会一直留在链表里。PR 里提到也可以调 deregister 让驱动下一轮再放,但注册既然失败了,不会有这个 source 的事件,直接删是安全的。
还剩一个问题:如果驱动一直睡在 poll 上(没有 IO、也没有定时器),pending_release 会一直攒着。NOTIFY_AFTER 是 16:攒到第 16 个时 deregister 返回 true,deregister_source 调 unpark 叫醒驱动,让它转一圈把这批释放掉。注释说这个数「有点随意」。
registration_set.rs 底部有一个 loom 测试 deregister_races_with_prior_event_delivery,模拟的正是上面表格里的竞争:驱动线程分发一个已经取回的事件,同时任务线程注销并 drop 自己的 Arc。
关闭
运行时关闭时,Driver::shutdown 拿锁把 is_shutdown 置位、清空 pending_release、把链表里所有 ScheduledIo 取出来,放锁之后逐个调 ScheduledIo::shutdown:设置关闭位,唤醒所有等待者。等待者醒来看到关闭位,返回一个「运行时正在关闭」的错误,而不是永远挂着。
忙的 worker 多久查一次 IO
worker 只有在没活干时才会 park,turn() 才会用 None 或定时器的超时去睡。一个一直有任务跑的 worker 永远不会自己停下来看 IO。如果驱动只在空闲时运行,满载的运行时里网络事件就会一直没人收。
解决办法是 tick 计数:两种调度器都每跑 event_interval 个任务,就用 0 超时 park 一次,也就是调一次 turn(Some(0))。poll 不等待,有事件就分发,没有就立即返回。默认值在两个构造函数里都是 61(new_current_thread、new_multi_thread),注释说这个数「相当随意,大概是从 Go 抄来的」。多线程 worker 的 maintenance 已经在 worker 主循环 里读过;current_thread 的 block_on 循环是一个 for _ in 0..event_interval,跑满就调 park_yield(current_thread/mod.rs 第 879 行)。
实验:写入之后要等多少个任务
一个 current_thread 运行时,队列里始终有一个「忙任务」:它自增计数、空转 20µs、再 spawn 一个自己。另一个任务等 socket 可读。一个普通线程往 socket 里写 1 字节,记下写入那一刻的计数;读者的 waker 被调用时再记一次。两次计数之差,就是「数据到了,但 worker 还在忙别的」期间跑掉的任务数。每种 event_interval 测 200 轮:
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering::SeqCst};
use std::sync::Arc;
use std::task::{Wake, Waker};
use std::time::Duration;
use tokio::net::TcpListener;
// 已经 poll 过的「忙任务」个数
static POLLS: AtomicU64 = AtomicU64::new(0);
// 一个忙任务:自增计数,空转一小会儿,再 spawn 一个自己,然后结束。
// 运行队列里因此一直有活,worker 永远不会因为没事做而 park。
fn busy() {
tokio::spawn(async {
POLLS.fetch_add(1, SeqCst);
let t = std::time::Instant::now();
while t.elapsed() < Duration::from_micros(20) {}
busy();
});
}
struct Mark(Waker, Arc<AtomicU64>);
impl Wake for Mark {
fn wake(self: Arc<Self>) {
self.wake_by_ref();
}
fn wake_by_ref(self: &Arc<Self>) {
self.1.store(POLLS.load(SeqCst), SeqCst);
self.0.wake_by_ref();
}
}
fn run(interval: u32) {
let rt = tokio::runtime::Builder::new_current_thread()
.event_interval(interval)
.enable_all()
.build()
.unwrap();
let mut gaps = Vec::new();
rt.block_on(async {
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();
let mut peer = peer.join().unwrap();
peer.set_nodelay(true).unwrap();
let s = Arc::new(s);
let mut b = [0u8; 8];
let _ = s.try_read(&mut b); // 清掉 accept 时假定的可读
busy();
// 写端是一个普通线程:收到信号后等 1ms,记下当时已经跑了多少个忙任务,再写 1 字节
let (go_tx, go_rx) = std::sync::mpsc::channel::<()>();
let (at_tx, at_rx) = std::sync::mpsc::channel::<u64>();
std::thread::spawn(move || {
for () in go_rx {
std::thread::sleep(Duration::from_millis(1));
let at = POLLS.load(SeqCst);
peer.write_all(b"x").unwrap();
at_tx.send(at).unwrap();
}
});
for _ in 0..200 {
let woke_at = Arc::new(AtomicU64::new(0));
let s2 = s.clone();
let w2 = woke_at.clone();
let reader = tokio::spawn(async move {
std::future::poll_fn(|cx| {
let waker = Waker::from(Arc::new(Mark(cx.waker().clone(), w2.clone())));
let mut cx2 = std::task::Context::from_waker(&waker);
s2.poll_read_ready(&mut cx2)
})
.await
.unwrap();
POLLS.load(SeqCst)
});
go_tx.send(()).unwrap();
let ran_at = reader.await.unwrap();
let wrote_at = at_rx.recv().unwrap();
let woke = woke_at.load(SeqCst);
assert!(woke >= wrote_at, "读者没有经过 wake 就返回了");
gaps.push((woke - wrote_at, ran_at - woke));
// 读走这 1 字节,再读一次拿到 WouldBlock,把就绪位清掉,下一轮才会真的挂起
let mut b = [0u8; 8];
assert_eq!(s.try_read(&mut b).unwrap(), 1);
assert!(s.try_read(&mut b).is_err());
}
});
let max_wake = gaps.iter().map(|g| g.0).max().unwrap();
let min_wake = gaps.iter().map(|g| g.0).min().unwrap();
let avg_wake = gaps.iter().map(|g| g.0).sum::<u64>() as f64 / gaps.len() as f64;
let max_run = gaps.iter().map(|g| g.1).max().unwrap();
println!(
"event_interval = {interval:>2}: 写入到 wake 之间跑了 {min_wake}..={max_wake} 个任务(平均 {avg_wake:.1}),wake 到读者被 poll 之间最多 {max_run} 个"
);
}
fn main() {
run(61);
run(8);
}
macOS(kqueue)上的输出:
event_interval = 61: 写入到 wake 之间跑了 1..=62 个任务(平均 46.5),wake 到读者被 poll 之间最多 1 个
event_interval = 8: 写入到 wake 之间跑了 0..=9 个任务(平均 4.8),wake 到读者被 poll 之间最多 1 个
- 数据到达之后,最多再跑大约
event_interval个任务,worker 就会查一次驱动、调到读者的 waker。上限比event_interval多出一两个,是因为写线程先读计数、再写 socket,两步之间 worker 还在跑。多跑几次,上限在 61 到 63、9 到 11 之间。 - wake 之后读者进队尾,排在唯一的忙任务后面,所以「wake 到被 poll」最多 1 个。
- 每个忙任务 20µs,61 个就是 1.2ms 多。这就是满载时 IO 事件的额外延迟量级:
event_interval越小,IO 越及时,查驱动的开销越频繁。
忙时取多少事件:max_io_events_per_busy_tick
0 超时那一次 turn() 唤醒的任务全都落进这个 worker 的本地队列。多线程 worker 的本地队列只有 256 个槽,一次取回 1024 个事件、唤醒上千个任务,多出来的就会溢出到注入队列。max_io_events_per_busy_tick 给「不等待」的 poll 单独设一个小一点的缓冲区,就是 turn() 第二步里的 events_busy。剩下的事件留在内核里,下一次 poll 再取。它的文档建议 max_io_events_per_tick 本身也不要超过 256。不设时两种 poll 用同一个缓冲区。
turn() 用 max_wait.is_zero() 判断「忙」,所以定时器已经到期时的 park(超时算出来是 0)也算忙:文档的解释是,worker 接下来马上要跑那个定时器的任务。
小结
Driver拿着mio::Poll和事件缓冲区,同一时刻只有一个线程在用;Handle拿着Registry和注册表,所有线程共享。Token(0)只用来把睡在poll上的线程叫醒,Token(1)是信号管道;其余 token 都是ScheduledIo的地址,事件回来一次指针转换就找到状态。turn():释放上一轮注销的ScheduledIo,poll一次,对每个事件置就绪位并唤醒等待者。它不调度任务,只调 waker。- 注销时 fd 立即从内核删掉,
ScheduledIo推迟到下一次turn()开头才释放,避免分发已取回的事件时解引用到已释放的内存;攒到 16 个就主动叫醒驱动。 - 忙的 worker 每 61 个任务用 0 超时查一次驱动。实验里,满载时数据到达后最多再跑约 61 个任务才会被发现。