Rust in Everything
基于 tokio 1.54.1

反应器

主线位置: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 selfmio::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);

(driver.rs 第 108 行)

  • Token(0):唤醒。 Driver::new 用它创建一个 mio::Waker,存在 Handle 里。Handle::unpark 触发它,正睡在 epoll_wait/kevent 上的线程就会带着一个 Token(0) 事件返回;如果当时没人在睡,下一次 poll 会立即返回。turn() 看到这个 token 什么都不做,它的作用就是让 poll 返回。
  • Token(1):信号。 开了 signal feature 时,信号驱动把一个 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);
}

按顺序:

  1. 释放上一轮注销的 ScheduledIo。 原因见下面「注销」一节。
  2. 选缓冲区。 只有「不等待」(max_wait 是 0)并且设了 max_io_events_per_busy_tick 时才用小的 events_busy。
  3. poll。 max_wait 由外层决定:None 是一直睡到有事件;Some(d) 是时间驱动算出来的、到下一个定时器到期的时间;Some(0) 是忙的 worker 顺手查一下(见本页最后一节)。被信号打断(EINTR)不算错,其他错误直接 panic。
  4. 分发。 每个事件先用 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 不能被释放。下一节的注销流程就是为这条约束设计的。

📝NOTE
没有蠢问题

问: 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() 不拿锁就能知道有没有活要干。

注册

Handle::add_source:

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();
}
📝NOTE
冷知识:注销失败时宁可泄漏,这条规则被改掉过一次又改了回来

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 才真正释放。

📝NOTE
没有蠢问题

问: 前面 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 个任务才会被发现。

💬 关联讨论

发起讨论
加载中...