Rust in Everything
基于 mio 1.2.4

tokio 的 I/O 驱动怎么调 mio

主线位置:两条主线的调用方。注册主线的起点是 tokio 的 PollEvented::new,它调一次 Registry::register;事件主线的循环就是 tokio 的 Driver::turn,它调一次 Poll::poll,再把每个 Event 的 token 当指针用。

flowchart TB
  newd["Driver::new"] --> p1["Poll::new"]
  newd --> p2["Waker::new,Token 0"]
  newd --> p3["Registry::try_clone"]
  sig["信号驱动"] --> p4["register,Token 1"]
  pe["PollEvented::new"] --> p5["register R W,地址作 token"]
  turnn["Driver::turn"] --> p6["poll,容量 1024"]
  p6 --> disp["按 token 找 ScheduledIo"]
  dropn["PollEvented drop"] --> p7["deregister,再 close"]

tokio 那一侧(ScheduledIo 的就绪位、任务怎么挂起和唤醒、turn() 什么时候被调)在 tokio 卷的反应器、ScheduledIo 与就绪状态、从 TcpStream::read 到挂起里讲过。那一卷读的是 tokio 1.54.1;本站锁的是 1.52.3(见在本站中)。这一页只看 tokio 1.52.3 对 mio 发出的每一个调用,以及它们在 strace 里是什么样子,最后对照 1.54.1 看哪里变了。

tokio 1.52.3 的代码引自 ~/.cargo/registry 里的 crate 源码,标出文件和行号;和 1.54.1 相同的部分链到 tokio 卷用的 tokio-1.54.1 tag。

用了 mio 的哪几个 feature

本站开的是 tokio 的 full。其中 net、process、signal 三个 feature 各自打开 mio/os-poll、mio/os-ext、mio/net,io-uring(只在 tokio_unstable 下有意义)打开前两个;rt、time、sync 这些不碰 mio。mio 的依赖声明是 optional = true、default-features = false,所以 tokio 不开这几个 feature 时根本不编译 mio,开了也不带 mio 的 log。

结果是 tokio 用到了 mio 平台无关层的全部类型(Poll、Registry、Waker、Events、Token、Interest),以及 mio::net 的 TCP、UDP、UDS 类型、mio::unix::SourceFd(AsyncFd 和 1.52.3 里子进程的标准输入输出用)和 mio::unix::pipe(tokio::net::unix::pipe 用)。

Driver::new:三个调用

tokio 1.52.3 src/runtime/io/driver.rs 第 117 行,去掉 io-uring 的两个字段:

    pub(crate) fn new(nevents: usize) -> io::Result<(Driver, Handle)> {
        let poll = mio::Poll::new()?;
        #[cfg(not(target_os = "wasi"))]
        let waker = mio::Waker::new(poll.registry(), TOKEN_WAKEUP)?;
        let registry = poll.registry().try_clone()?;

        let driver = Driver {
            signal_ready: false,
            events: mio::Events::with_capacity(nevents),
            poll,
        };

        let (registrations, synced) = RegistrationSet::new();

        let handle = Handle {
            registry,
            registrations,
            synced: Mutex::new(synced),
            #[cfg(not(target_os = "wasi"))]
            waker,
            metrics: IoDriverMetrics::default(),
            // ...
        };

        Ok((driver, handle))
    }

1.54.1 的 Driver::new 多一个参数 nevents_busy,多建一个 events_busy 缓冲区,三个 mio 调用和顺序不变。

tokio 的调用mio 里落到哪Linux 上的系统调用
mio::Poll::new()Poll::new → epoll 的 Selector::newepoll_create1(EPOLL_CLOEXEC)
mio::Waker::new(poll.registry(), TOKEN_WAKEUP)Waker::new → eventfd 版 Waker::neweventfd2 + epoll_ctl(ADD, data=0)
poll.registry().try_clone()Registry::try_clone → Selector::try_clonefcntl(F_DUPFD_CLOEXEC)
mio::Events::with_capacity(nevents)Events::with_capacity,只分配一个 Vec无

nevents 默认 1024(tokio 1.52.3 src/runtime/builder.rs 第 288 行),Builder::max_io_events_per_tick 可改。本站的运行时是 dioxus-server 0.7.9 建的(launch.rs 里的 block_on:Builder::new_multi_thread().enable_all().build()),没有改这一项。

几点:

  • Poll 留在 Driver,Registry 的克隆放进 Handle。 Poll::poll 要 &mut self,只有拿到驱动的那个线程能等事件;注册要从任意线程发起,所以 Handle 拿的是 try_clone 出来的 Registry。在 epoll 上它是同一个 epoll 实例的第二个 fd,见 Poll 与 Registry 的「try_clone」一节。
  • Waker 在 try_clone 之前创建,用的是原来那个 Registry。 两者指向同一个 epoll 实例,用哪个都一样;debug 下「一个 Poll 一个 Waker」的检查(has_waker)在 try_clone 时用 Arc::clone 共享,tokio 只建一个 Waker,不会触发它。见 Waker。
  • macOS 上这三步是 kqueue() 加一次 fcntl(F_SETFD, FD_CLOEXEC);mio 1.2.1 的 kqueue Waker::new 先 try_clone 一份 kqueue fd,再用一次 kevent 做 setup_waker(1.2.4 去掉了这次 setup,见怎么读这一卷的「1.2.1 到 1.2.4」);Registry::try_clone 再复制一份 kqueue fd。

紧接着,开了 signal feature 的运行时会建信号驱动(tokio 1.52.3 src/runtime/signal/mod.rs):全局有一对 UnixStream(socketpair),每个运行时把读端 try_clone 一份,用 TOKEN_SIGNAL(Token(1))、READABLE 注册进来。本站代码里没有用 tokio::signal,这一份注册照样存在,因为它跟着 feature 走,不跟着用法走。

注册:每个 socket 一次,token 是地址

TcpListener::bind、accept、TcpStream::connect 拿到 mio 的 socket 之后都包进 PollEvented。PollEvented::new 固定用两个方向:

    pub(crate) fn new(io: E) -> io::Result<Self> {
        PollEvented::new_with_interest(io, Interest::READABLE | Interest::WRITABLE)
    }

它经过 Registration::new_with_interest_and_handle 到驱动的 add_source。tokio 1.52.3 src/runtime/io/driver.rs 第 266 行:

    pub(super) fn add_source(
        &self,
        source: &mut impl mio::event::Source,
        interest: Interest,
    ) -> io::Result<Arc<ScheduledIo>> {
        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);
        }

        // TODO: move this logic to `RegistrationSet` and use a `CountedLinkedList`
        self.metrics.incr_fd_count();

        Ok(scheduled_io)
    }

和 1.54.1 的 add_source 逐字相同。对 mio 来说这就是一次普通的 Registry::register,三个参数:

  • source:mio::net::TcpStream 这类 IoSource 包装的类型,AsyncFd 则是 SourceFd。debug 下的 SelectorId 检查对前者生效,见 Source 与 IoSource。
  • token:ScheduledIo::token 返回的 ScheduledIo 地址(1.52.3 里也是这样)。mio 原样把它写进 epoll_event.data.u64 或 kevent.udata,见 Token、Interest 与 Event。地址按 128 字节对齐,不会撞上 0 和 1 两个保留值,见反应器的「token 就是指针」。
  • interest:Interest::to_mio 把 tokio 的 Interest 换成 mio 的。tokio 的 ERROR 在 mio 里没有对应位(错误总会报告),只关注错误时用 READABLE 占位,因为 mio 的 Interest 不能为空。

READABLE | WRITABLE 到了 epoll 上是 EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET(epoll 的「掩码」一节),到了 kqueue 上是一次 kevent 提交 EVFILT_WRITE、EVFILT_READ 两项(kqueue)。tokio 1.52.3 的代码里没有对 mio Registry::reregister 的调用:两个方向一开始就都注册了,以后只在自己的 ScheduledIo 里清就绪位。(唯一叫这个名字的 PollEvented::reregister 只在 Linux 的子进程回收里用,做的是 deregister 再向当前运行时重新 register。)

注册失败时 add_source 把 ScheduledIo 从链表里摘掉并释放,返回 Err。这在 epoll 上没有问题,一次 epoll_ctl 要么成功要么什么都没留下;kqueue 上 register 可能只生效一半,这时内核里留着一个指向已释放内存的 token,见 Linux 与 macOS。

turn():一次 poll,按地址分发

tokio 1.52.3 src/runtime/io/driver.rs 第 179 行的 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();

        let events = &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);
                let ptr = super::EXPOSE_IO.from_exposed_addr(token.0);

                // 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.
                let io: &ScheduledIo = unsafe { &*ptr };

                io.set_readiness(Tick::Set, |curr| curr | ready);
                io.wake(ready);

                ready_count += 1;
            }
        }
        // ...
    }

对 mio 只有一次调用:Poll::poll。它和 mio 文档里的用法有三处对得上:

  • events 每次复用。 容量 1024,poll 先清空再填,填满了就只取这么多,剩下的留在内核里下一次再取(Token、Interest 与 Event 的「Events」一节)。strace 里 epoll_pwait 的第三个参数就是这个 1024。
  • Interrupted 当成一次空的 poll。 mio 不重试 EINTR(Poll 与 Registry 的「EINTR」一节),tokio 照文档的意思处理掉了;其他错误直接 panic,后面会用到这一行。
  • 事件只用来置位,不直接做 I/O。 Ready::from_mio 逐个调 Event 的 is_readable、is_writable、is_read_closed、is_write_closed、is_error(Linux 上还有 is_priority),按位或进 ScheduledIo。真正的读写由被唤醒的任务去做,读到 WouldBlock 时清掉对应的位,这就是边沿触发要求的「读到 WouldBlock 为止」,在 tokio 里由 ScheduledIo 的 tick 和清位保证(ScheduledIo 与就绪状态)。

max_wait 是 None 时 strace 里的超时是 -1,0 超时就是 0。1.54.1 的 turn 把分发挪进了 dispatch_event,并在 0 超时时可以换用 events_busy,对 mio 的调用还是这一次 poll。

注销:先 deregister,再 close

PollEvented 的 Drop(1.52.3 相同):

impl<E: Source> Drop for PollEvented<E> {
    fn drop(&mut self) {
        if let Some(mut io) = self.io.take() {
            // Ignore errors
            let _ = self.registration.deregister(&mut io);
        }
    }
}

deregister 到驱动的 deregister_source,对 mio 是一次 Registry::deregister,epoll 上是 EPOLL_CTL_DEL。io 是这个函数里的局部变量,函数返回时它被 drop,fd 这时才关掉。顺序很要紧:fd 还开着时 EPOLL_CTL_DEL 一定找得到注册项;先关 fd 的话,有 dup 出来的副本时 epoll 里的项删不掉,旧 token 会继续冒出来(Poll 与 Registry 的「drop 之前没有 deregister」)。对 tokio 来说旧 token 就是一个可能已经释放的地址。

ScheduledIo 本身不在这里释放,先放进 pending_release,等下一次 turn() 开头的 release_pending_registrations 再放,因为驱动可能正拿着上一轮 poll 返回的、带这个地址的事件(反应器的「注销」一节)。

这里 1.52.3 和 1.54.1 有一处不同。1.52.3 第 293 行:

    pub(super) fn deregister_source(
        &self,
        registration: &Arc<ScheduledIo>,
        source: &mut impl Source,
    ) -> io::Result<()> {
        // Deregister the source with the OS poller **first**
        // Cleanup ALWAYS happens
        let os_result = self.registry.deregister(source);

        if self
            .registrations
            .deregister(&mut self.synced.lock(), registration)
        {
            self.unpark();
        }

        self.metrics.dec_fd_count();

        os_result // Return error after cleanup
    }

1.54.1 的 deregister_source 是 self.registry.deregister(source)?;:内核那边注销失败就提前返回,宁可让 ScheduledIo 泄漏也不释放。1.52.3 里的「Cleanup ALWAYS happens」来自 tokio 的 #7773(1.50.0),#8540 把它撤回,发布在 1.51.5、1.53.2 和 1.54.0,1.52.x 没有带上。来龙去脉在反应器的冷知识里。它只在 deregister 失败时起作用,而 tokio 自己的 socket 类型拥有 fd、在 fd 还开着时注销,EPOLL_CTL_DEL 不会失败;会失败的是调用方先关了 fd 的 AsyncFd。本站不用 AsyncFd,下一页再说。

实验:一个 echo 连接的全部 epoll 调用

src/bin/echo.rs:一个 tokio echo 服务器,客户端是同一进程里的一个 std 线程(阻塞 I/O),连上、写 hello、读回来、shutdown(Write)、读到 0。运行时照本站的样子用多线程、enable_all,只留一个 worker 让输出好读:

// A tokio echo server plus a blocking std client thread, in one process.
// Usage: echo [connections]   (default 1)
use std::io::{Read, Write};

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

fn main() -> std::io::Result<()> {
  let conns: usize = std::env::args().nth(1).map_or(1, |s| s.parse().unwrap());
  // Like the site (dioxus-server's block_on): multi-thread runtime, enable_all.
  // One worker keeps the trace readable.
  let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(1)
    .enable_all()
    .build()?;
  rt.block_on(async move {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let addr = listener.local_addr()?;
    let client = std::thread::spawn(move || -> std::io::Result<String> {
      let mut last = String::new();
      for _ in 0..conns {
        let mut s = std::net::TcpStream::connect(addr)?;
        s.write_all(b"hello")?;
        let mut buf = [0u8; 5];
        s.read_exact(&mut buf)?;
        s.shutdown(std::net::Shutdown::Write)?;
        let n = s.read(&mut buf)?;
        last = format!("echoed {:?}, then read {n}", String::from_utf8_lossy(&buf));
      }
      Ok(last)
    });
    let mut tasks = Vec::new();
    for _ in 0..conns {
      let (mut sock, _) = listener.accept().await?;
      tasks.push(tokio::spawn(async move {
        let mut buf = [0u8; 64];
        loop {
          let n = sock.read(&mut buf).await.unwrap();
          if n == 0 {
            break;
          }
          sock.write_all(&buf[..n]).await.unwrap();
        }
      }));
    }
    for t in tasks {
      t.await.unwrap();
    }
    let last = client.join().unwrap()?;
    println!("os={} conns={conns} last client: {last}", std::env::consts::OS);
    Ok::<_, std::io::Error>(())
  })?;
  #[cfg(tokio_unstable)]
  {
    let m = rt.metrics();
    println!(
      "io_driver: fd_registered={} fd_deregistered={} ready_events={}",
      m.io_driver_fd_registered_count(),
      m.io_driver_fd_deregistered_count(),
      m.io_driver_ready_count()
    );
  }
  Ok(())
}

依赖和本站的锁文件一致:

tokio = { version = "=1.52.3", features = ["full"] }
mio = { version = "=1.2.1", features = ["os-poll", "net", "os-ext"] }

mio = "=1.2.1" 是为了把 tokio 用的 mio 也钉在 1.2.1(同一个 semver 范围里 Cargo 只选一个版本)。Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0、--release,在 strace -f -e trace=epoll_create1,epoll_ctl,epoll_pwait,eventfd2,accept4,socket,socketpair,bind,listen,connect,read,write,recvfrom,sendto,shutdown,close,fcntl 下跑。pid 3 是主线程(block_on),pid 4 是 worker,pid 5 是客户端线程:

epoll_create1(EPOLL_CLOEXEC)            = 3
eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK)   = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
fcntl(3, F_DUPFD_CLOEXEC, 3)            = 5
fcntl(5, F_SETFD, FD_CLOEXEC)           = 0
socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0, [6, 7]) = 0
fcntl(6, F_DUPFD_CLOEXEC, 3)            = 8
fcntl(8, F_SETFD, FD_CLOEXEC)           = 0
epoll_ctl(5, EPOLL_CTL_ADD, 8, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
strace: Process 4 attached
[pid     3] socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 9
[pid     3] bind(9, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
[pid     3] listen(9, 128)              = 0
[pid     3] epoll_ctl(5, EPOLL_CTL_ADD, 9, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffff9b9d8c00}) = 0
[pid     4] epoll_pwait(3strace: Process 5 attached
 <unfinished ...>
[pid     5] socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 10
[pid     5] connect(10, {sa_family=AF_INET, sin_port=htons(45053), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
[pid     4] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0xffff9b9d8c00}], 1024, -1, NULL, 8) = 1
[pid     5] sendto(10, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid     3] accept4(9 <unfinished ...>
[pid     5] recvfrom(10 <unfinished ...>
[pid     4] epoll_pwait(3 <unfinished ...>
[pid     3] <... accept4 resumed>, {sa_family=AF_INET, sin_port=htons(48136), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid     3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffff9b9d8d80}) = 0
[pid     4] <... epoll_pwait resumed>, [{events=EPOLLIN|EPOLLOUT, data=0xffff9b9d8d80}], 1024, -1, NULL, 8) = 1
[pid     3] write(4, "\1\0\0\0\0\0\0\0", 8 <unfinished ...>
[pid     4] recvfrom(11 <unfinished ...>
[pid     3] <... write resumed>)        = 8
[pid     4] <... recvfrom resumed>, "hello", 64, 0, NULL, NULL) = 5
[pid     4] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0 <unfinished ...>
[pid     5] <... recvfrom resumed>, "hello", 5, 0, NULL, NULL) = 5
[pid     4] <... sendto resumed>)       = 5
[pid     5] shutdown(10, SHUT_WR <unfinished ...>
[pid     4] epoll_pwait(3 <unfinished ...>
[pid     5] <... shutdown resumed>)     = 0
[pid     4] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0}, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffff9b9d8d80}], 1024, -1, NULL, 8) = 2
[pid     5] recvfrom(10 <unfinished ...>
[pid     4] recvfrom(11, "", 64, 0, NULL, NULL) = 0
[pid     4] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid     4] close(11 <unfinished ...>
[pid     5] <... recvfrom resumed>, "", 5, 0, NULL, NULL) = 0
[pid     4] <... close resumed>)        = 0
[pid     5] close(10)                   = 0
[pid     4] epoll_pwait(3 <unfinished ...>
[pid     5] +++ exited with 0 +++
[pid     3] write(1, "os=linux conns=1 last client: ec"..., 58os=linux conns=1 last client: echoed "hello", then read 0
) = 58
[pid     3] epoll_ctl(5, EPOLL_CTL_DEL, 9, NULL) = 0
[pid     3] close(9)                    = 0
[pid     3] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid     4] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0}], 1024, -1, NULL, 8) = 1
[pid     4] close(3)                    = 0
[pid     4] close(8)                    = 0
[pid     4] close(5)                    = 0
[pid     4] close(4)                    = 0
[pid     4] +++ exited with 0 +++
+++ exited with 0 +++

又跑了 2 次,每种系统调用的次数相同,只有 epoll_pwait 一次是 4 次、一次是 5 次:唤醒事件和 EPOLLRDHUP 有时在同一次 epoll_pwait 里返回(上面这次),有时分两次。逐段对照:

  1. 前 9 行是建运行时。 epoll_create1 到 fcntl(3, F_DUPFD_CLOEXEC, 3) 是 Driver::new 的三步:fd 3 是 Poll,fd 4 是 Waker 的 eventfd,用 data=0(TOKEN_WAKEUP)注册,fd 5 是 Handle 里的 Registry。之后所有 epoll_ctl 都发在 fd 5 上,所有 epoll_pwait 都发在 fd 3 上,两者是同一个 epoll 实例。socketpair 加 fcntl(6, …) = 8 加 data=0x1 那一行是信号驱动:全局的 socket 对,读端复制一份,用 TOKEN_SIGNAL 注册。紧跟在两个 F_DUPFD_CLOEXEC 后面的 F_SETFD 不是 mio 发的:mio 和 std 只调一次 fcntl(见 epoll 的「try_clone」一节),这一行来自静态链接的 musl。
  2. listener 也注册了两个方向。 TcpListener::bind 是 mio 的 bind(socket、bind、listen(9, 128),见 TCP),然后 PollEvented::new 用 READABLE | WRITABLE 注册,data 是一个堆地址。监听 socket 永远不会可写,多注册的 EPOLLOUT 不会产生事件。
  3. 一个连接:accept4、ADD、epoll_pwait、读写、epoll_pwait、读到 0、DEL、close。 accept 发生在主线程(listener.accept() 在 block_on 的 future 里);新 socket 的 data 是另一个地址。tokio::spawn 把任务交给 worker,worker 正睡在 epoll_pwait 里,所以主线程往 eventfd 写一个 1(write(4, …)),这是 Handle::unpark 调的 Waker::wake。客户端 shutdown 之后,worker 拿到 EPOLLIN|EPOLLOUT|EPOLLRDHUP,读到 0,任务结束,PollEvented 被 drop:先 EPOLL_CTL_DEL,再 close(11)。
  4. 收尾。 listener 同样先 DEL 再 close;运行时关闭时再写一次 eventfd 把 worker 叫醒,worker 关掉 fd 3、8、5、4。信号驱动的 fd 8 没有 DEL,epoll 的两个 fd 也随之关闭,不需要。

客户端连 100 次(echo 100),strace -c -f 只看五个调用(跑了三次,调用次数相同,耗时不同,这是第一次;recvfrom、sendto 里有一半是客户端线程的):

os=linux conns=100 last client: echoed "hello", then read 0
 48.76    0.008801          22       400           recvfrom
 25.93    0.004680          15       301           epoll_pwait
 11.02    0.001989           9       200           sendto
  8.64    0.001560           7       199        99 accept4
  5.64    0.001018           4       204           epoll_ctl

按连接摊下来:epoll_ctl 204 = 100 × 2(ADD、DEL)+ 4(启动时 3 次 ADD、listener 1 次 DEL);epoll_pwait 约 3 次;accept4 每个连接成功 1 次,另有 99 次 EAGAIN:每 accept 一个,循环马上再 accept,下一个客户端还没连上。

macOS:数不到系统调用,数得到事件

macOS 上没有 strace,dtruss 要 root,这里用不了;但 tokio 在 tokio_unstable 下有驱动的计数器:io_driver_ready_count 在 turn() 里每分发一个非保留 token 的事件加一(上面代码里的 ready_count)。同一个 echo.rs 用 RUSTFLAGS="--cfg tokio_unstable" 构建,两个平台各跑 1 个和 100 个连接,每种 3 次,输出都相同:

os=macos conns=1 last client: echoed "hello", then read 0
io_driver: fd_registered=2 fd_deregistered=2 ready_events=5
os=macos conns=100 last client: echoed "hello", then read 0
io_driver: fd_registered=101 fd_deregistered=101 ready_events=500
os=linux conns=1 last client: echoed "hello", then read 0
io_driver: fd_registered=2 fd_deregistered=2 ready_events=3
os=linux conns=100 last client: echoed "hello", then read 0
io_driver: fd_registered=101 fd_deregistered=101 ready_events=300

(macOS 一行是 M2 Max、macOS、rustc 1.99.0;current_thread 版本的 echo_ct.rs 计数相同。)每个连接 Linux 3 个事件、macOS 5 个。哪 5 个,用 mio 1.2.1 直接重放同一个连接(src/bin/mio_seq.rs:listener 和 stream 都按 tokio 的方式注册 READABLE | WRITABLE,每一步之后 poll 一次,列出所有事件):

// The echo connection from echo.rs, replayed on mio 1.2.1 directly (the way
// tokio registers: listener and stream both READABLE | WRITABLE), printing
// every event of every poll.
use std::io::{Read, Write};
use std::time::Duration;

use mio::net::TcpListener;
use mio::{Events, Interest, Poll, Token};

const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);

fn poll_once(poll: &mut Poll, events: &mut Events, step: &str) {
  poll.poll(events, Some(Duration::from_millis(100))).unwrap();
  let list: Vec<String> = events
    .iter()
    .map(|e| {
      let mut f = Vec::new();
      if e.is_readable() { f.push("R"); }
      if e.is_writable() { f.push("W"); }
      if e.is_read_closed() { f.push("RC"); }
      if e.is_write_closed() { f.push("WC"); }
      if e.is_error() { f.push("ERR"); }
      format!("{}:{}", e.token().0, f.join("|"))
    })
    .collect();
  println!("  {step:<28} {} event(s) {list:?}", list.len());
}

fn main() {
  println!("os={}", std::env::consts::OS);
  let mut poll = Poll::new().unwrap();
  let mut events = Events::with_capacity(1024);
  let mut l = TcpListener::bind("127.0.0.1:0".parse().unwrap()).unwrap();
  poll.registry().register(&mut l, Token(10), RW).unwrap();
  let mut c = std::net::TcpStream::connect(l.local_addr().unwrap()).unwrap();
  c.write_all(b"hello").unwrap();
  poll_once(&mut poll, &mut events, "connect + client writes");
  let (mut s, _) = l.accept().unwrap();
  poll.registry().register(&mut s, Token(11), RW).unwrap();
  poll_once(&mut poll, &mut events, "accepted, registered");
  let mut buf = [0u8; 64];
  let n = s.read(&mut buf).unwrap();
  s.write_all(&buf[..n]).unwrap();
  let mut back = [0u8; 5];
  c.read_exact(&mut back).unwrap();
  poll_once(&mut poll, &mut events, "echoed, client read it");
  c.shutdown(std::net::Shutdown::Write).unwrap();
  poll_once(&mut poll, &mut events, "client shutdown(Write)");
  println!("  read after FIN: {:?}", s.read(&mut buf).map_err(|e| e.kind()));
}

两个平台各 3 次,输出相同:

os=macos
  connect + client writes      1 event(s) ["10:R"]
  accepted, registered         2 event(s) ["11:W", "11:R"]
  echoed, client read it       1 event(s) ["11:W"]
  client shutdown(Write)       1 event(s) ["11:R|RC"]
  read after FIN: Ok(0)
os=linux
  connect + client writes      1 event(s) ["10:R"]
  accepted, registered         1 event(s) ["11:R|W"]
  echoed, client read it       0 event(s) []
  client shutdown(Write)       1 event(s) ["11:R|W|RC"]
  read after FIN: Ok(0)

macOS 多出来的两个:一是读写两个方向在 kqueue 里是两个过滤器,各报一条(kqueue 的「event 模块」一节);二是写回 hello 之后、客户端读走数据、发送缓冲区空出来时,EV_CLEAR 的 EVFILT_WRITE 又触发了一次,epoll 这时什么都不报。对 tokio 来说这两个都是把已经置上的 WRITABLE 位再或一次,多一次 wake 调用,没有任务在等写时什么也不发生。所以开发机上的事件数比生产多,但任务看到的就绪状态相同。

和 tokio 1.54.1 比

把 tokio 1.52.3 和 1.54.1 的 src/runtime/io/、src/io/、src/net/tcp/ 逐个 diff,和 mio 调用有关的改动是这几处:

改动1.52.31.54.1对 mio 的调用
max_io_events_per_busy_tick(#8410)一个 Events另有 events_busy,0 超时的 poll 用它0 超时时 poll 的容量可以更小
deregister_source(#8540)内核注销失败也释放 ScheduledIo失败就返回,不释放调用不变,只改失败后的处理
accept 出来的流假定就绪(#8435)第一次读写前等驱动的第一个事件assume_ready(READABLE | WRITABLE)第一次读之前可能少一次 epoll_pwait
max_io_events_per_tick(0)(#8476)接受assert!(capacity > 0)见下一页

Driver::new 的三个调用、add_source、turn() 里那一次 poll、PollEvented 的 Drop 都没有变。

第三项能在 strace 里看到。echo.rs 的多线程版本里看不出来:worker 本来就睡在 epoll_pwait 里,新 socket 的事件总是先于任务被处理,两个版本跑出来的系统调用一样(各 3 次)。换成 current_thread 运行时(src/bin/echo_ct.rs,只把 Builder::new_multi_thread().worker_threads(1) 换成 Builder::new_current_thread()),accept 和读在同一个线程上接连发生。依赖分别是上面那组和 tokio = "=1.54.1"、mio = "=1.2.4",strace -f -e trace=epoll_ctl,epoll_pwait,accept4,recvfrom,sendto,write,close。1.52.3,从 accept4 开始:

[pid     3] accept4(9 <unfinished ...>
[pid     4] sendto(10, "hello", 5, MSG_NOSIGNAL, NULL, 0 <unfinished ...>
[pid     3] <... accept4 resumed>, {sa_family=AF_INET, sin_port=htons(44108), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid     3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffffb3fd8c00} <unfinished ...>
[pid     4] <... sendto resumed>)       = 5
[pid     3] <... epoll_ctl resumed>)    = 0
[pid     4] recvfrom(10 <unfinished ...>
[pid     3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT, data=0xffffb3fd8c00}], 1024, -1, NULL, 8) = 1
[pid     3] recvfrom(11, "hello", 64, 0, NULL, NULL) = 5
[pid     3] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid     4] <... recvfrom resumed>, "hello", 5, 0, NULL, NULL) = 5
[pid     3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffffb3fd8c00}], 1024, -1, NULL, 8) = 1
[pid     3] recvfrom(11 <unfinished ...>
[pid     4] recvfrom(10 <unfinished ...>
[pid     3] <... recvfrom resumed>, "", 64, 0, NULL, NULL) = 0
[pid     3] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid     3] close(11)                   = 0

1.54.1:

[pid     3] accept4(9 <unfinished ...>
[pid     4] sendto(10, "hello", 5, MSG_NOSIGNAL, NULL, 0 <unfinished ...>
[pid     3] <... accept4 resumed>, {sa_family=AF_INET, sin_port=htons(53254), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid     4] <... sendto resumed>)       = 5
[pid     3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffffa9419b80} <unfinished ...>
[pid     4] recvfrom(10 <unfinished ...>
[pid     3] <... epoll_ctl resumed>)    = 0
[pid     3] recvfrom(11, "hello", 64, 0, NULL, NULL) = 5
[pid     3] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid     4] <... recvfrom resumed>, "hello", 5, 0, NULL, NULL) = 5
[pid     3] epoll_pwait(3, [{events=EPOLLOUT, data=0xffffa9419b80}], 1024, -1, NULL, 8) = 1
[pid     3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffffa9419b80}], 1024, -1, NULL, 8) = 1
[pid     4] recvfrom(10 <unfinished ...>
[pid     3] recvfrom(11, "", 64, 0, NULL, NULL) = 0
[pid     3] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid     3] close(11)                   = 0

各跑 3 次,服务端线程(pid 3)的调用顺序:

echo_ct152 1: accept4 epoll_ctl epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_ct152 2: accept4 epoll_ctl epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_ct152 3: accept4 epoll_ctl epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_ct154 1: accept4 epoll_ctl recvfrom sendto epoll_pwait epoll_pwait recvfrom epoll_ctl close 
echo_ct154 2: accept4 epoll_ctl recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_ct154 3: accept4 epoll_ctl recvfrom sendto epoll_pwait epoll_pwait recvfrom epoll_ctl close 

1.52.3 的新 ScheduledIo 就绪位是空的,第一次 read 只能先挂起,等 epoll_pwait 报出注册时的那个 EPOLLIN|EPOLLOUT。1.54.1 在 new_accepted 里调 assume_ready,直接去 recvfrom,数据已经在了。之后那次只有 EPOLLOUT 的事件,是 epoll 在报告注册时的状态(这时数据已经读走),对任务没有用;有时它和 EPOLLRDHUP 合在一次 epoll_pwait 里(第 2 次)。机制和这个改动的来历见 ScheduledIo 与就绪状态。

❗IMPORTANT
动脑筋:客户端连上之后等 50 ms 再写,1.54.1 的服务端线程会多出哪个系统调用?

src/bin/echo_late.rs 和 echo_ct.rs 只差一行:客户端 connect 之后、write_all 之前 std::thread::sleep(Duration::from_millis(50))。1.54.1 仍然假定新连接可读,可这时数据还没到。服务端线程从 accept4 到 close(11) 的调用序列是什么?和 1.52.3 比呢?

动脑筋答案

两个版本各跑 3 次,服务端线程的序列每次都一样:

echo_late152 1: accept4 epoll_ctl epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_late154 1: accept4 epoll_ctl recvfrom epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_late152 2: accept4 epoll_ctl epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_late154 2: accept4 epoll_ctl recvfrom epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_late152 3: accept4 epoll_ctl epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 
echo_late154 3: accept4 epoll_ctl recvfrom epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close 

1.54.1 多了一次 recvfrom,就在 epoll_ctl 之后。strace 原文:

[pid     3] accept4(9, {sa_family=AF_INET, sin_port=htons(41144), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid     3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffffa6e43b80}) = 0
[pid     3] recvfrom(11, 0xffffa6e43d58, 64, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
[pid     3] epoll_pwait(3, [{events=EPOLLOUT, data=0xffffa6e43b80}], 1024, -1, NULL, 8) = 1
[pid     3] epoll_pwait(3 <unfinished ...>
[pid     3] <... epoll_pwait resumed>, [{events=EPOLLIN|EPOLLOUT, data=0xffffa6e43b80}], 1024, -1, NULL, 8) = 1
[pid     3] recvfrom(11 <unfinished ...>
[pid     3] <... recvfrom resumed>, "hello", 64, 0, NULL, NULL) = 5
[pid     3] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid     3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffffa6e43b80}], 1024, -1, NULL, 8) = 1
[pid     3] recvfrom(11, "", 64, 0, NULL, NULL) = 0
[pid     3] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid     3] close(11)                   = 0
  • 假定错了,代价是一次返回 EAGAIN 的 recvfrom:mio 把它原样交给 tokio,tokio 清掉 READABLE 位,任务挂起。这正是 assume_ready 注释里说的「A wrong guess costs one WouldBlock, which clears the readiness again」。
  • 之后两个版本走的是同一条事件主线:第一次 epoll_pwait 只有 EPOLLOUT(注册时的状态,没有数据),任务没有醒来读;第二次 EPOLLIN|EPOLLOUT 是数据到了,才 recvfrom。
  • 1.52.3 一直等事件,所以一次 EAGAIN 都没有,epoll_pwait 次数两边相同。

小结

  • 本站的 tokio 1.52.3 对 mio 只有五个入口:Poll::new、Waker::new(Token(0))、Registry::try_clone(都在 Driver::new 里),每个 socket 一次 register 和一次 deregister,以及 turn() 里每次一个 poll。
  • 每个 socket 都以 READABLE | WRITABLE 注册一次,token 是 ScheduledIo 的地址,从不 reregister;signal feature 让运行时启动时多注册一个 Token(1) 的 socket。
  • drop 时先 EPOLL_CTL_DEL 再 close,ScheduledIo 留到下一轮 turn() 才释放;1.52.3 在内核注销失败时也释放它(#7773),1.54.1 撤回了。
  • Linux 上一个 echo 连接是 1 次 accept4、2 次 epoll_ctl、约 3 次 epoll_pwait、3 个事件;macOS 上 5 个事件,多出来的是分开报告的写方向和发送缓冲区空出后的再次可写,任务看到的就绪状态相同。
  • 1.54.1 对 accept 出来的流先假定就绪,第一次读之前少一次 epoll_pwait;猜错的代价是一次 EAGAIN。

💬 关联讨论

发起讨论
加载中...