Table of contents
Open Table of contents
TL;DR
mio 的每一个设计决策都是为 Rust 的 ownership 系统量身定做的。Token 替代回调是为了避免 closure 生命周期问题;强制边缘触发是为了简化 API 和提高性能;强制 non-blocking 是为了避免意外阻塞事件循环;没有全局状态是为了让 Poll 的生命周期完全由用户控制。
决策 1:Token 替代回调
C++ Asio 的回调模型
// Asio: 每个异步操作绑定一个 handler
socket.async_read_some(buffer,
[&](boost::system::error_code ec, std::size_t length) {
// 这个 lambda 会被 Asio 内部存储
// 当读操作完成时被调用
});
Asio 的 handler 是 类型擦除的函数对象,内部通过 allocator 分配内存来存储它们。handler 的生命周期由 Asio 运行时管理——它在操作完成或取消时销毁 handler。
为什么 Rust 不能用这个模式
如果 mio 用 closure 做回调:
// 假设的 mio 回调 API(不存在)
registry.register_with_callback(&mut socket, Interest::READABLE, |event| {
// 这个 closure 捕获了什么?
// 如果捕获了 &socket,那 socket 必须比 registry 活得久
// 如果捕获了 &mut buffer,那 buffer 也必须比 registry 活得久
// 而且 buffer 不能被其他地方同时借用
});
问题:
-
Closure 的生命周期约束:存储在 Registry 内部的 closure 必须是
'static(因为你不知道它什么时候被调用)。这意味着它不能借用任何局部变量——只能move或使用Arc<Mutex<>>。 -
类型擦除的成本:要存储不同类型的 closure,需要
Box<dyn FnMut(Event)>——堆分配 + 动态分发。每个注册的 socket 都要一次堆分配。 -
回调地狱:在 Rust 中嵌套 closure 比 C++ 更痛苦,因为每层 closure 都会引入新的 borrow 和 ownership 约束。
Token 的优雅
registry.register(&mut socket, Token(42), Interest::READABLE)?;
// 事件循环中
match event.token() {
Token(42) => { /* 自由地借用任何你需要的数据 */ }
}
Token 是 Copy 的 usize——零开销、无生命周期、无堆分配。事件分发变成一个普通的 match 语句,你在 match arm 中可以自由访问当前作用域的所有变量。
与 slab 配合
Token 模式还天然适配 slab crate(一个基于 Vec 的 O(1) 分配器):
let mut connections: Slab<TcpStream> = Slab::new();
// 注册新连接
let entry = connections.vacant_entry();
let token = Token(entry.key()); // slab 的 key 就是 Token
registry.register(&mut stream, token, Interest::READABLE)?;
entry.insert(stream);
// 事件到达时
let connection = &mut connections[event.token().0];
决策 2:强制边缘触发
什么是边缘触发 vs 电平触发
| 模式 | 通知时机 | 类比 |
|---|---|---|
| 电平触发 (level-triggered) | 只要 fd 处于就绪状态,每次 poll 都通知 | 门铃一直响到你开门 |
| 边缘触发 (edge-triggered) | 仅在状态变化时通知一次 | 门铃响一次,不管你开不开门 |
mio 0.6 的 PollOpt
mio 0.6 允许用户选择:
// mio 0.6(已废弃)
poll.register(&socket, token, Ready::readable(), PollOpt::edge())?; // 边缘触发
poll.register(&socket, token, Ready::readable(), PollOpt::level())?; // 电平触发
这导致了大量问题:
- 用户不理解两者的区别,随便选一个,然后 bug 频出
- 库作者不知道用户会选哪个模式,必须两种都支持
- 不同平台的 level-triggered 行为不完全一致
mio 0.7+ 的决定:只保留边缘触发
从 0.7 开始,PollOpt 被删除。所有平台强制边缘触发:
- Linux: 始终设置
EPOLLET - macOS: 始终设置
EV_CLEAR - Windows: AFD 层模拟边缘触发(event 交付后清除标志)
理由:
- 性能:边缘触发减少了虚假唤醒。一个大量可读的 socket 在电平触发下会让每次 poll 都返回,即使你还没准备好处理它。
- tokio 只用边缘触发:作为 mio 最重要的用户,tokio 从未使用过电平触发模式。
- 简化 API:少一个维度的选择 = 少一类 bug。
- 跨平台一致性:电平触发在 Windows IOCP 上模拟更复杂,行为也更难保证一致。
边缘触发的代价
用户必须遵守「drain to WouldBlock」规则:
// 正确:drain 所有数据
loop {
match stream.read(&mut buf) {
Ok(0) => { /* 连接关闭 */ break; }
Ok(n) => { /* 处理 n 字节 */ }
Err(ref e) if e.kind() == WouldBlock => break, // 没有更多数据了
Err(e) => return Err(e),
}
}
// 错误:只读一次
// 如果 buffer 不够大,剩余的数据可能永远不会被通知
let n = stream.read(&mut buf)?;
决策 3:强制 Non-blocking I/O
mio 的所有 I/O 类型(TcpStream、TcpListener、UdpSocket 等)在创建时强制设置为 non-blocking 模式。
// mio::net::TcpListener::bind() 内部
// 创建 socket 后设置 non-blocking
socket.set_nonblocking(true)?;
from_std() 构造函数的文档明确要求:
The caller is responsible for ensuring that the socket is in non-blocking mode.
为什么不允许 blocking I/O
在事件循环中调用阻塞 I/O 是灾难性的——它会阻塞整个循环,所有其他 socket 的事件都无法被处理。mio 通过类型系统强制避免这个错误:你无法用 mio::net::TcpStream 执行阻塞读写,因为它始终是 non-blocking 的。
与 Asio 的对比
Asio 同时提供同步和异步 API:
// Asio 同步(阻塞)
size_t n = socket.read_some(buffer);
// Asio 异步(非阻塞)
socket.async_read_some(buffer, handler);
mio 只有非阻塞——没有 mio::net::TcpStream::blocking_read()。这是刻意的限制:mio 是为事件循环设计的,如果你要阻塞 I/O,用 std::net。
决策 4:无全局状态,显式 Poll 所有权
mio 没有任何全局状态、全局注册表或全局事件循环。
// 每个 Poll 是独立的,互不影响
let poll1 = Poll::new()?;
let poll2 = Poll::new()?;
// socket 注册到哪个 Poll,只有那个 Poll 能收到事件
poll1.registry().register(&mut socket, token, Interest::READABLE)?;
// poll2 永远不会收到 socket 的事件
为什么这很重要
- 线程安全无争用:每个 Poll 有自己的 epoll fd / kqueue fd / IOCP handle,不需要全局锁
- 生命周期明确:Poll 被 drop,其上所有注册全部失效。没有悬挂引用
- 可测试性:每个测试可以创建自己的 Poll,互不干扰
- 嵌套/组合:多个库可以各自维护自己的 Poll,不冲突
Debug 模式的跨 Poll 检测
mio 在 debug 模式下会检测错误使用:
// 创建两个 Poll
let poll1 = Poll::new()?;
let poll2 = Poll::new()?;
// 注册到 poll1
poll1.registry().register(&mut socket, token, Interest::READABLE)?;
// 尝试用 poll2 的 registry reregister → debug 模式下 panic
poll2.registry().reregister(&mut socket, token, Interest::WRITABLE)?;
// ^^ 未定义行为,debug 模式下会被 SelectorId 检测到
SelectorId 是一个原子计数器,每个 Poll::new() 分配一个唯一 ID。注册时记录 selector ID,后续操作会验证 ID 匹配。
决策 5:Source 不在 Drop 中自动 deregister
// Source trait 没有 Drop 集成
// 这是合法的但会泄漏资源:
{
let mut stream = TcpStream::connect(addr)?;
registry.register(&mut stream, token, Interest::READABLE)?;
// stream 被 drop,但没有 deregister
// epoll 会自动清理(因为 fd 关闭时 epoll 会移除它)
// 但 kqueue/IOCP 可能不会自动清理
}
为什么不在 Drop 中自动 deregister?
-
Source 不持有 Registry 引用:
deregister()需要&Registry,但 Source 不知道自己被注册到了哪个 Registry。要让 Source 持有 Registry 引用,就需要引入生命周期参数或 Arc,极大增加 API 复杂度。 -
跨平台行为不一致:Linux epoll 在 fd 关闭时会自动移除它(因为 epoll 基于 fd)。但 kqueue 可能不会。自动 deregister 会掩盖这种平台差异。
-
显式优于隐式:Rust 哲学。用户应该知道自己在做什么,主动管理 I/O 源的生命周期。
决策 6:虚假唤醒是 API 契约的一部分
mio 文档明确声明:
Poll::pollmay return readiness events even if the associated event source is not actually ready.
这意味着 is_readable() == true 不保证 read() 不会返回 WouldBlock。用户代码 必须 处理 WouldBlock:
// 正确
match stream.read(&mut buf) {
Ok(n) => { /* 有数据 */ }
Err(ref e) if e.kind() == WouldBlock => { /* 虚假唤醒,没关系 */ }
Err(e) => return Err(e),
}
// 错误:假设 readable 事件意味着一定能读到数据
// 在某些平台上可能 panic 或返回意外错误
let n = stream.read(&mut buf)?; // 没有处理 WouldBlock
为什么允许虚假唤醒
- 平台差异:某些 OS 在某些条件下会报告虚假就绪(例如 TCP 连接的 RST 到达时可能触发 readable 事件,但读取会失败)
- 性能:严格保证无虚假唤醒需要额外的内核交互,不值得
- 简化实现:mio 不需要在内部过滤事件,直接透传 OS 返回的结果
决策 7:reregister 完全覆盖而非增量修改
// 注册:读 + 写
registry.register(&mut stream, token, Interest::READABLE | Interest::WRITABLE)?;
// reregister:只传读 → 写被取消
registry.reregister(&mut stream, token, Interest::READABLE)?;
// 现在 stream 只会收到读事件,写事件不再触发
这个 “完全覆盖” 语义避免了「我到底注册了哪些 interest?」的状态追踪问题。每次 reregister 都是一个完整的声明:「从现在起,我只关心这些事件。」
与 Asio 设计决策的对比总结
| 维度 | mio | Asio | 原因 |
|---|---|---|---|
| 事件分发 | Token(整数 match) | Handler(回调函数对象) | Rust 的 ownership 使回调很难用 |
| 触发模式 | 强制边缘触发 | 内部根据平台选择 | 简化 API + 性能 |
| I/O 模式 | 强制 non-blocking | 同步/异步都支持 | mio 只为事件循环服务 |
| 全局状态 | 无 | io_context 可以共享 | Rust 避免全局可变状态 |
| buffer 管理 | 不做 | Proactor 需要提前提交 buffer | Reactor 模式不需要 |
| 生命周期管理 | 显式 register/deregister | Handler 绑定到操作 | 避免 Source 持有 Registry 引用 |
| 虚假唤醒 | API 契约允许 | Proactor 模式下不存在(操作要么完成要么没完成) | Reactor 的固有特性 |