Rust in Everything
基于 mio 1.2.4

mio 解决什么问题

主线位置:两条主线的起点。注册主线最后落到 epoll_ctl / kevent,事件主线落到 epoll_wait / kevent,这一页先讲为什么是这两个调用、为什么都带「边沿触发」的标志,以及 Windows 上没有这两个调用时 mio 怎么办。下一页一个事件循环用 strace 把两条线完整跑一遍。

flowchart TB
  app[用户代码] --> reg["register:Token 加 Interest"]
  reg --> sel{平台选择器}
  sel -->|Linux| ep["epoll:就绪"]
  sel -->|macOS、BSD| kq["kqueue:就绪"]
  sel -->|Windows| afd["IOCP:完成"]
  afd --> emu["AFD poll 模拟就绪"]
  ep --> ev["Events:Token 加就绪位"]
  kq --> ev
  emu --> ev
  ev --> io["用户读写到 WouldBlock"]
  io --> app

lib.rs 开头对自己的定位只有一句:

//! Mio is a fast, low-level I/O library for Rust focusing on non-blocking APIs
//! and event notification for building high performance I/O apps with as little
//! overhead as possible over the OS abstractions.

「非阻塞 API」和「事件通知」是两件事:前者是 net 模块里那些 socket 类型(创建时就设成非阻塞),后者是 Poll。lib.rs 的其余部分是一份入门指南和 feature 说明,真正讲语义的文档在 Poll 结构体上:「Portability」一节列出用户必须遵守的约定,「Implementation notes」一节给出每个系统用哪个选择器。本页的事实都来自这两处、CHANGELOG.md 和对应的源码。

两种通知模型

非阻塞 I/O 要回答一个问题:一个线程管着上千个 socket,它该在什么时候对哪个 socket 调 read?操作系统给了两种回答。

就绪通知(readiness)完成通知(completion)
内核告诉你什么这个 fd 现在读(写)不会阻塞你之前提交的那次读(写)做完了,结果是这样
谁调 read你,收到通知之后你在通知之前就提交了,内核替你做
缓冲区调 read 时才交出去,调完就拿回来提交时交给内核,完成之前不能动
代表Linux epoll、BSD 和 macOS kqueue、poll(2)Windows IOCP

mio 的 API 是就绪模型。Poll 文档的第一段就这么定义「就绪」:

/// waiting until one or more become "ready" for some class of operations; e.g.
/// reading and writing. An event source is considered ready if it is possible
/// to immediately perform a corresponding operation; e.g. [`read`] or
/// [`write`].

就绪模型的好处是缓冲区一直在用户手里:收到「可读」之后用哪块内存去读、读多少,都由用户决定,没有数据可读的连接不占缓冲区。完成模型反过来,每个等待中的读都要先交出一块缓冲区。

💡TIP
打个比方:取餐的两种方式

就绪通知像商场美食广场的取餐器:你点完餐拿一个取餐器(register,取餐器上的号码就是 Token),回座位等。取餐器一响(poll 返回事件),你自己端着托盘(缓冲区)去窗口取(read)。窗口可能已经被别人挤满(伪事件),那就回去等下一次响(WouldBlock)。

完成通知像外卖:你下单时就把自家的饭盒交给店家(提交读请求时交出缓冲区),送到时饭已经装在饭盒里了(完成事件带回读到的字节数)。饭盒在路上那段时间,你手里没有它。比方到这里为止:取餐器的边沿触发语义是「只响一次」,你要是只取了一部分就回座位,它不会因为窗口里还有你的餐而再响,这一点见下面「边沿触发」一节。

三个系统选择器

Poll 文档的「Implementation notes」列出了每个系统用哪个选择器:

系统选择器
Linux、Android、illumos、Redoxepoll
macOS、iOS、FreeBSD、NetBSD、OpenBSD、DragonFly BSDkqueue
Solarisevent ports
WindowsIOCP

这张表没列出的 Unix(AIX、Haiku、Hurd、QNX 等,见 poll.rs 开头那段 cfg)用 poll(2) 后备实现,见 poll(2) 后备。本卷主线只讲 Linux 和 macOS 两个。三种主流选择器在「注册」和「等待」两步上的差别:

epollkqueueIOCP
建实例epoll_create1kqueueCreateIoCompletionPort
注册epoll_ctl(ADD, fd, events),一个 fd 一项kevent 提交 changelist,读、写是两个过滤器(EVFILT_READ、EVFILT_WRITE),各占一项没有「注册关注」这一步,每次发起异步操作时带上 OVERLAPPED
等待epoll_wait同一个 kevent,changelist 传空GetQueuedCompletionStatusEx
用户数据epoll_event.data.u64kevent.udataOVERLAPPED 指针
边沿触发EPOLLETEV_CLEAR不适用

epoll 和 kqueue 都是就绪模型,都能把一个用户给的整数原样存进内核、随事件带回来,都支持边沿触发。mio 在这两个平台上几乎是直通:Poll 文档说「On all platforms except windows, a call to Poll::poll is mostly just a direct call to the system selector」。下一页的 strace 会看到,一次 poll 对应一次 epoll_pwait,epoll 版 Selector::select 的源码里也只有这一个 syscall!。

公共语义:就绪加边沿触发

mio 只提供一种触发方式:边沿触发。Linux 上,interests_to_epoll 从 EPOLLET 起步,再按 Interest 加位:

fn interests_to_epoll(interests: Interest) -> u32 {
    let mut kind = EPOLLET;

    if interests.is_readable() {
        kind = kind | EPOLLIN | EPOLLRDHUP;
    }

    if interests.is_writable() {
        kind |= EPOLLOUT;
    }

    if interests.is_priority() {
        kind |= EPOLLPRI;
    }

    kind as u32
}

macOS 上,kqueue 的 register 给每个过滤器都带上 EV_CLEAR:

        let flags = libc::EV_CLEAR | libc::EV_RECEIPT | libc::EV_ADD;

EV_CLEAR 的意思是事件被取走之后清掉状态,直到下一次状态变化才再报告,效果就是边沿触发。两个平台的标志各自在 epoll 和 kqueue 两章细讲。

📝NOTE
冷知识:0.7 之前,触发方式是注册时选的

0.6 的 register 多一个 PollOpt 参数,可以选 edge()、level()、oneshot()。v0.6.23 的 epoll 实现里,这一步叫 ioevent_to_epoll(interest: Ready, opts: PollOpt),起始值是 let mut kind = 0;,按 opts.is_edge() 决定加不加 EPOLLET,按 opts.is_oneshot() 决定加不加 EPOLLONESHOT。0.7 开发期间 #975「Remove PollOpt from public API」把这个参数删掉,那个函数(当时已改名 interests_to_epoll)的起始值改成了 let mut kind = EPOLLET;,也就是上面这段代码的第一行。这个改动首次发布在 0.7.0(先进了 v0.7.0-alpha.1)。CHANGELOG 0.7.0 的说法:

「PollOpt was removed and all registrations use edge-triggers. See the upgrade guide on how to process event using edge-triggers.」

0.7.0 那一节的总说明给出了这一批改动的方向:「Overall a large number of API changes have been made to reduce the complexity of the implementation and remove overhead where possible.」只留一种触发方式,Windows 和 poll(2) 后备实现就只需要模拟这一种。

只有边沿触发,用户就必须遵守一条约定。Poll 文档「Draining readiness」一节:

/// Once a readiness event is received, the corresponding operation must be
/// performed repeatedly until it returns [`WouldBlock`]. Unless this is done,
/// there is no guarantee that another readiness event will be delivered, even
/// if further data is received for the event source.

还有一条是反过来的:收到事件也不保证操作能成功(「Spurious events」一节:「Poll::poll may return readiness events even if the associated event source is not actually ready」),所以 WouldBlock 不是错误,是「回去等下一个事件」。这两条合起来,就是事件主线的最后一步「读到 WouldBlock 再回去 poll」。

公共语义只统一到这一层。同一次就绪在不同平台上被拆成几个事件,mio 不管。Poll::poll 的文档明说:一个 socket 同时可读又可写时,「it may be possible for a single readiness event to be returned with both readable and writable readiness OR two separate events may be returned」。原因在注册那一步:epoll 一个 fd 一项,kqueue 读和写是两个过滤器,各自产生事件。

❗IMPORTANT
动脑筋:同一个 socket 以 READABLE | WRITABLE 注册,注册时它已经既可读又可写。调一次 poll,Linux 和 macOS 各返回几个 Event?

先用 std 建一个监听 socket,用 mio 的 TcpStream::connect 连上去,对端 accept 之后写 4 个字节,睡 100 毫秒,再新建 Poll 注册这条连接:

use std::io::{self, Write};
use std::thread;
use std::time::Duration;

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

fn main() -> io::Result<()> {
  let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
  let mut stream = TcpStream::connect(listener.local_addr()?)?;
  let (mut peer, _) = listener.accept()?;
  peer.write_all(b"ping")?;
  // 等连接建立、数据到达:这时 stream 既可读又可写
  thread::sleep(Duration::from_millis(100));

  let mut poll = Poll::new()?;
  let mut events = Events::with_capacity(16);
  poll.registry().register(&mut stream, Token(7), Interest::READABLE | Interest::WRITABLE)?;
  poll.poll(&mut events, Some(Duration::from_secs(1)))?;

  println!("events: {}", events.iter().count());
  for e in events.iter() {
    println!("  token={:?} readable={} writable={}", e.token(), e.is_readable(), e.is_writable());
  }
  Ok(())
}
两个平台:M2 Max、macOS、rustc 1.99.0;Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。答案在本页末尾。

Windows:用 AFD 把完成变成就绪

IOCP 没有「告诉我什么时候可读」这种调用,只有「这次操作做完了」。Poll 文档承认这里要做适配:「[IOCP] uses a completion model instead of a readiness model. In this case, Poll must adapt the completion model Mio's API.」

mio 的办法是找一个「完成就等于就绪」的操作。Windows 的 socket 底下是 AFD 驱动,mio 打开 \Device\Afd\Mio 得到一个句柄,对它发 IOCTL_AFD_POLL:请求里写明「这个 socket、这些事件」,作为一个异步操作关联到完成端口上。它完成的那一刻就是 socket 上出现了其中某个事件的那一刻,完成包里带着是哪些事件。mio 的 Windows 选择器在 GetQueuedCompletionStatusEx 收到这种完成包之后,用 SockState::feed_event 把它翻译成一个就绪 Event:

        // In mio, we have to simulate Edge-triggered behavior to match API usage.
        // The strategy here is to intercept all read/write from user that could cause WouldBlock usage,
        // then reregister the socket to reset the interests.
        self.user_evts &= !afd_events;

一次 AFD poll 完成就结束了,报告过的事件还从 user_evts 里摘掉,下一次提交不会再等它们。要在用户读到 WouldBlock 之后重新等这些事件,就得把它们加回去、再提交一次。这就是注释里说的「拦截」:Windows 上的 IoSourceState::do_io 不是直通的:

        pub fn do_io<T, F, R>(&self, f: F, io: &T) -> io::Result<R>
        where
            F: FnOnce(&T) -> io::Result<R>,
        {
            let result = f(io);
            if let Err(ref e) = result {
                if e.kind() == io::ErrorKind::WouldBlock {
                    self.inner.as_ref().map_or(Ok(()), |state| {
                        state
                            .selector
                            .reregister(state.sock_state.clone(), state.token, state.interests)
                    })?;
                }
            }
            result
        }

而 epoll 和 kqueue 上的 do_io 只有一行 f(io),那个文件的第一行注释是「Both kqueue(2) and epoll(2) don't need to hold any user space state.」这也是 Poll 文档说「Windows however needs all I/O operations to go through Mio」的原因:绕过 mio 直接读写 socket,do_io 就没有机会重新提交 AFD poll。AfdGroup、SockState 的状态机在 Windows:IOCP 与 AFD。

Poll 文档那一段最后还有一句:「The most expensive part being calls to read and write require data to be copied into an intermediate buffer before it is passed to the kernel.」这句是 0.6 留下来的(v0.6.23 的 poll.rs 里有同一句)。0.6 的 Windows 实现用真正的重叠读写,TCP 写要先拷进内部的 Vec<u8>;0.7 整个重写了 Windows 实现(CHANGELOG:「The entire Windows implementation was rewritten.」),改用 AFD poll。1.2.4 里 socket 的读写直接调 std 的 read / write,不经过中间缓冲;还在做内部缓冲的只有 NamedPipe,它的文档写着「To bridge this, NamedPipe performs internal buffering」,命名管道不是 socket,走的是真正的重叠读写。

mio 不做什么

mio 的边界是「把就绪从内核取回来」。下面这些事它都不做,有些是从一开始就没做,有些是做过又删掉的。

不做缓冲。 TcpStream 的 Read 实现把调用原样转给 std:

impl Read for TcpStream {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        self.inner.do_io(|mut inner| inner.read(buf))
    }
    // ...
}

读到一半的帧、写不完的数据,都要用户自己存。下一页的 echo 服务器就自己维护了一个 pending: Vec<u8>。

不做调度。 mio 没有任务、没有 future、没有 std::task::Waker。它的 Waker 只做一件事:让另一个线程上阻塞着的 poll 返回一个事件。谁该被叫醒、叫醒之后跑什么,是上层的事。

不做定时器。 Poll::poll 只接受一个超时,mio 不维护任何定时器。早年它有过:0.6 有 mio::timer 和 mio::channel,0.6.5 起标成 deprecated(CHANGELOG 0.6.5:「Officially deprecate non-core functionality (timers, channel, etc...)」,属性上写着 note = "use mio-extras instead"),0.7.0 由 #929「remove deprecated code」删掉了 src/timer.rs、src/channel.rs 和整个 src/deprecated/(CHANGELOG 0.7.0:「All code deprecated in 0.6 was removed in 0.7.」)。

不维护 fd 到 token 的表。 Token 直接存进内核(epoll_event.data.u64、kevent.udata),事件回来时直接取出。mio 的 Poll 结构体里只有一个 Registry,Registry 里只有一个 sys::Selector(debug 下多一个 has_waker),Linux 上 Selector 就是 epoll 的 fd。token 用完之后会不会被别的连接复用、迟到的事件怎么认,都是上层的事。

0.7 之前它还做过一件现在不做的事:用户态的就绪队列。CHANGELOG 0.7.0:「Custom user-space readiness queue was removed, this includes the public Registration and SetReadiness types.」原来用户可以造一个不对应任何 fd 的 Registration,从别的线程用 SetReadiness 把它标成就绪;0.7 起这个用途只剩下 Waker。

把这些删改按版本排一下(都出自 CHANGELOG.md):

版本变化
0.6.0EventLoop 移进 deprecated 模块,主 API 换成 Poll
0.6.5定时器、channel 等「non-core functionality」标成 deprecated
0.7.0删掉 0.6 里所有 deprecated 的代码(含 Timer、channel、EventLoop);删掉 PollOpt,只剩边沿触发;删掉 Registration / SetReadiness,新增 Waker;Evented 改名为 event::Source;Windows 实现重写
0.8.0删掉 TcpSocket(「The socket2 crate provides all the functionality and more.」)
1.0删掉 mio 自己的 Unix SocketAddr,改用 std 的

Timer 是 0.7.0 删掉的,不是 1.0。1.0 的「Removed」一节只有 SocketAddr 一条。

和 tokio 的分工

上面 mio 不做的事,tokio 都做了。tokio 的 IO 驱动拿着一个 mio::Poll,它和 mio 之间的接口只有三样东西:register / deregister、poll、Waker。

事情miotokio
等事件Poll::poll → epoll_wait / kevent驱动的 turn() 调它,见 反应器
Token 的含义一个原样存取的 usizeScheduledIo 的地址,见 反应器 的「token 就是指针」
就绪状态每次 poll 给一批 Event,不记存进 ScheduledIo 的一个原子里,见 ScheduledIo 与就绪状态
读到 WouldBlock用户自己回去 poll清掉就绪位、存 waker、返回 Pending,见 从 TcpStream::read 到挂起
定时器只有 poll 的超时时间轮算出最近的到期时间,作为 poll 的超时传下去,见 驱动栈与 park
跨线程唤醒Waker::wake 让 poll 返回worker 要叫醒睡在驱动上的线程时调它
缓冲不做AsyncRead 之上的 BufReader 等,或上层协议(hyper)自己做

所以本卷讲到 tokio 时只会说「tokio 在这里调了 mio 的哪个函数」,tokio 自己怎么处理事件,回到 tokio 卷看。

动脑筋答案

macOS(kqueue),跑了 3 次,输出相同:

events: 2
  token=Token(7) readable=false writable=true
  token=Token(7) readable=true writable=false

Linux(epoll),在容器里跑了 3 次,输出相同:

events: 1
  token=Token(7) readable=true writable=true
  • Linux 上一个 fd 在 epoll 里只有一项,这一项的就绪位是 EPOLLIN|EPOLLOUT,一个 Event 同时报告可读和可写。
  • macOS 上 register 往 changelist 里放了两项(先 EVFILT_WRITE,再 EVFILT_READ),两个过滤器各自就绪,各返回一个 kevent,于是同一个 Token(7) 出现两次,每次只带一个方向。这次实验里可写在前、可读在后;mio 不保证这个顺序。
  • 两种结果都在 Poll::poll 文档允许的范围内。可移植的写法是:按 token 分发,每个 Event 都检查 is_readable() 和 is_writable(),不要假设一个 token 一次 poll 只出现一次。同一个 token 出现两次时,第二次读到 WouldBlock 也是正常的。

小结

  • 就绪模型告诉你「现在可以读写」,缓冲区留在用户手里;完成模型告诉你「读写做完了」,缓冲区在完成之前归内核。epoll、kqueue 是前者,IOCP 是后者。
  • mio 的 API 是就绪模型,只有边沿触发:epoll 上每次注册都带 EPOLLET,kqueue 上每个过滤器都带 EV_CLEAR。0.7 之前可以用 PollOpt 选水平触发或 oneshot,#975 删掉了它。
  • 用户的义务:收到事件后一直读写到 WouldBlock;收到事件不代表操作一定成功;同一次就绪在 kqueue 上可能是两个事件(实测 macOS 2 个、Linux 1 个)。
  • Windows 用 IOCTL_AFD_POLL 作为异步操作提交到 IOCP,完成即就绪;do_io 遇到 WouldBlock 时重新提交,所以 Windows 上所有 I/O 都必须经过 mio。socket 读写不做中间拷贝,Poll 文档里那句「intermediate buffer」是 0.6 的遗留,现在只有 NamedPipe 做内部缓冲。
  • mio 不做缓冲、调度、定时器,不维护 token 表。Timer 和 channel 在 0.6.5 标成 deprecated、0.7.0 删除(#929),不是 1.0。
  • tokio 用 mio 的 register、poll、Waker 三样东西,自己负责就绪状态、任务唤醒和定时器。

💬 关联讨论

发起讨论
加载中...