跳转到正文
zeno's blog
返回

mio(五):关键设计决策

专题: mio

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 不能被其他地方同时借用
});

问题:

  1. Closure 的生命周期约束:存储在 Registry 内部的 closure 必须是 'static(因为你不知道它什么时候被调用)。这意味着它不能借用任何局部变量——只能 move 或使用 Arc<Mutex<>>

  2. 类型擦除的成本:要存储不同类型的 closure,需要 Box<dyn FnMut(Event)>——堆分配 + 动态分发。每个注册的 socket 都要一次堆分配。

  3. 回调地狱:在 Rust 中嵌套 closure 比 C++ 更痛苦,因为每层 closure 都会引入新的 borrow 和 ownership 约束。

Token 的优雅

registry.register(&mut socket, Token(42), Interest::READABLE)?;

// 事件循环中
match event.token() {
    Token(42) => { /* 自由地借用任何你需要的数据 */ }
}

Token 是 Copyusize——零开销、无生命周期、无堆分配。事件分发变成一个普通的 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())?; // 电平触发

这导致了大量问题:

  1. 用户不理解两者的区别,随便选一个,然后 bug 频出
  2. 库作者不知道用户会选哪个模式,必须两种都支持
  3. 不同平台的 level-triggered 行为不完全一致

mio 0.7+ 的决定:只保留边缘触发

从 0.7 开始,PollOpt 被删除。所有平台强制边缘触发:

理由:

  1. 性能:边缘触发减少了虚假唤醒。一个大量可读的 socket 在电平触发下会让每次 poll 都返回,即使你还没准备好处理它。
  2. tokio 只用边缘触发:作为 mio 最重要的用户,tokio 从未使用过电平触发模式。
  3. 简化 API:少一个维度的选择 = 少一类 bug。
  4. 跨平台一致性:电平触发在 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 类型(TcpStreamTcpListenerUdpSocket 等)在创建时强制设置为 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 的事件

为什么这很重要

  1. 线程安全无争用:每个 Poll 有自己的 epoll fd / kqueue fd / IOCP handle,不需要全局锁
  2. 生命周期明确:Poll 被 drop,其上所有注册全部失效。没有悬挂引用
  3. 可测试性:每个测试可以创建自己的 Poll,互不干扰
  4. 嵌套/组合:多个库可以各自维护自己的 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?

  1. Source 不持有 Registry 引用deregister() 需要 &Registry,但 Source 不知道自己被注册到了哪个 Registry。要让 Source 持有 Registry 引用,就需要引入生命周期参数或 Arc,极大增加 API 复杂度。

  2. 跨平台行为不一致:Linux epoll 在 fd 关闭时会自动移除它(因为 epoll 基于 fd)。但 kqueue 可能不会。自动 deregister 会掩盖这种平台差异。

  3. 显式优于隐式:Rust 哲学。用户应该知道自己在做什么,主动管理 I/O 源的生命周期。

决策 6:虚假唤醒是 API 契约的一部分

mio 文档明确声明:

Poll::poll may 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

为什么允许虚假唤醒

  1. 平台差异:某些 OS 在某些条件下会报告虚假就绪(例如 TCP 连接的 RST 到达时可能触发 readable 事件,但读取会失败)
  2. 性能:严格保证无虚假唤醒需要额外的内核交互,不值得
  3. 简化实现: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 设计决策的对比总结

维度mioAsio原因
事件分发Token(整数 match)Handler(回调函数对象)Rust 的 ownership 使回调很难用
触发模式强制边缘触发内部根据平台选择简化 API + 性能
I/O 模式强制 non-blocking同步/异步都支持mio 只为事件循环服务
全局状态io_context 可以共享Rust 避免全局可变状态
buffer 管理不做Proactor 需要提前提交 bufferReactor 模式不需要
生命周期管理显式 register/deregisterHandler 绑定到操作避免 Source 持有 Registry 引用
虚假唤醒API 契约允许Proactor 模式下不存在(操作要么完成要么没完成)Reactor 的固有特性

分享这篇文章:

上一篇
整洁架构(四):微服务时代的落地-从理想到妥协
下一篇
整洁架构(三):方法设计-每层用自己的语言命名和传参