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`].
就绪模型的好处是缓冲区一直在用户手里:收到「可读」之后用哪块内存去读、读多少,都由用户决定,没有数据可读的连接不占缓冲区。完成模型反过来,每个等待中的读都要先交出一块缓冲区。
就绪通知像商场美食广场的取餐器:你点完餐拿一个取餐器(register,取餐器上的号码就是 Token),回座位等。取餐器一响(poll 返回事件),你自己端着托盘(缓冲区)去窗口取(read)。窗口可能已经被别人挤满(伪事件),那就回去等下一次响(WouldBlock)。
三个系统选择器
Poll 文档的「Implementation notes」列出了每个系统用哪个选择器:
| 系统 | 选择器 |
|---|---|
| Linux、Android、illumos、Redox | epoll |
| macOS、iOS、FreeBSD、NetBSD、OpenBSD、DragonFly BSD | kqueue |
| Solaris | event ports |
| Windows | IOCP |
这张表没列出的 Unix(AIX、Haiku、Hurd、QNX 等,见 poll.rs 开头那段 cfg)用 poll(2) 后备实现,见 poll(2) 后备。本卷主线只讲 Linux 和 macOS 两个。三种主流选择器在「注册」和「等待」两步上的差别:
| epoll | kqueue | IOCP | |
|---|---|---|---|
| 建实例 | epoll_create1 | kqueue | CreateIoCompletionPort |
| 注册 | epoll_ctl(ADD, fd, events),一个 fd 一项 | kevent 提交 changelist,读、写是两个过滤器(EVFILT_READ、EVFILT_WRITE),各占一项 | 没有「注册关注」这一步,每次发起异步操作时带上 OVERLAPPED |
| 等待 | epoll_wait | 同一个 kevent,changelist 传空 | GetQueuedCompletionStatusEx |
| 用户数据 | epoll_event.data.u64 | kevent.udata | OVERLAPPED 指针 |
| 边沿触发 | EPOLLET | EV_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 两章细讲。
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 读和写是两个过滤器,各自产生事件。
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(())
}
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.0 | EventLoop 移进 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。
| 事情 | mio | tokio |
|---|---|---|
| 等事件 | Poll::poll → epoll_wait / kevent | 驱动的 turn() 调它,见 反应器 |
| Token 的含义 | 一个原样存取的 usize | ScheduledIo 的地址,见 反应器 的「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三样东西,自己负责就绪状态、任务唤醒和定时器。