跳转到正文
zeno's blog
返回

mio(七):陷阱与常见错误

专题: mio

Table of contents

Open Table of contents

TL;DR

mio 的 7 个致命陷阱:不 drain 数据导致事件丢失、不处理 WouldBlock、忘记 deregister 导致资源泄漏、reregister 的覆盖语义、跨 Poll 使用 Source、在 poll 线程做阻塞操作、忽略 EINTR。建在 mio 之上的抽象还有 3 个额外陷阱。所有这些都源于 mio 的核心设计决策:边缘触发 + 非阻塞 + 手动管理。


陷阱 1:不 drain 数据(边缘触发的最大 footgun)

问题

边缘触发只在状态变化时通知一次。如果你收到 readable 事件后只读了一部分数据,剩余的数据不会再触发新事件——除非有新数据到达。

// 错误代码
if event.is_readable() {
    let mut buf = [0; 1024];
    let n = stream.read(&mut buf)?;  // 只读一次
    process(&buf[..n]);
    // 如果 socket buffer 里有 4096 字节,你只读了 1024
    // 剩下的 3072 字节可能永远不会被通知
}

正确做法

if event.is_readable() {
    let mut buf = [0; 4096];
    loop {
        match stream.read(&mut buf) {
            Ok(0) => {
                // 对端关闭
                break;
            }
            Ok(n) => {
                process(&buf[..n]);
                // 继续读,可能还有更多数据
            }
            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
                // 所有数据都读完了
                break;
            }
            Err(e) => return Err(e),
        }
    }
}

为什么这个问题特别阴险

陷阱 2:不处理 WouldBlock

问题

mio 的 API 契约明确允许虚假唤醒(spurious wakeup)。is_readable() == true 不保证 read() 能成功返回数据。

// 错误代码
if event.is_readable() {
    let n = stream.read(&mut buf)?;
    // 如果是虚假唤醒,read() 返回 Err(WouldBlock)
    // 这里的 ? 会把 WouldBlock 当作真正的错误向上传播
}

正确做法

if event.is_readable() {
    match stream.read(&mut buf) {
        Ok(n) => { /* 真的有数据 */ }
        Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
            // 虚假唤醒,忽略。这是正常行为。
        }
        Err(e) => return Err(e), // 真正的错误
    }
}

WouldBlock 出现的场景

  1. 虚假唤醒:OS 报告了就绪但数据还没准备好
  2. 竞态:在你检查事件和调用 read 之间,另一个操作消耗了数据(多线程场景)
  3. TCP RST:对端 reset 连接,触发 readable 事件,但读取会返回错误而不是 WouldBlock
  4. OOB 数据:带外数据触发 readable,但普通 read 不会读到 OOB 数据

陷阱 3:忘记 deregister 导致资源泄漏

问题

Source trait 文档明确指出:

All event::Sources, unless otherwise specified, need to be deregistered before being dropped for them to not leak resources.

// 错误代码
fn handle_connection(poll: &Poll, mut stream: TcpStream) {
    poll.registry().register(&mut stream, token, Interest::READABLE)?;
    // ... 使用 stream ...
    // stream 被 drop,但没有 deregister
    // 在某些平台上(特别是 kqueue 和 IOCP),这会泄漏内核资源
}

跨平台行为差异

平台Drop 时不 deregister 的后果
Linux (epoll)通常安全——fd 关闭时 epoll 自动移除该 fd 的监听
macOS (kqueue)可能泄漏 kevent 注册
Windows (IOCP)可能泄漏 SockState 和 AFD 资源

正确做法

// 方法 1:在 Drop 前手动 deregister
poll.registry().deregister(&mut stream)?;
drop(stream);

// 方法 2:封装在 RAII 结构中
struct ManagedStream {
    stream: TcpStream,
    registry: Registry,  // clone 的 Registry
    token: Token,
}

impl Drop for ManagedStream {
    fn drop(&mut self) {
        let _ = self.registry.deregister(&mut self.stream);
    }
}

陷阱 4:reregister 的覆盖语义

问题

reregister() 完全替换之前的 Interest,不是追加。

// 初始注册:读 + 写
registry.register(&mut stream, token, Interest::READABLE | Interest::WRITABLE)?;

// "添加" priority interest... 实际上丢失了 readable 和 writable!
registry.reregister(&mut stream, token, Interest::PRIORITY)?;
// 现在只监听 priority 事件,读写事件都没了

正确做法

// 如果要追加 interest,必须手动组合
let new_interest = current_interest | Interest::PRIORITY;
registry.reregister(&mut stream, token, new_interest)?;

// 这意味着你需要自己追踪每个 source 当前的 interest

对比 epoll

epoll 的 EPOLL_CTL_MOD 也是完全覆盖的——所以 mio 的行为与底层一致。但不了解 epoll 的用户容易踩这个坑。

陷阱 5:跨 Poll 使用 Source

问题

一个 Source 只能注册到一个 Poll。注册到 Poll A 的 Source,用 Poll B 的 Registry 操作它是未定义行为。

let poll_a = Poll::new()?;
let poll_b = Poll::new()?;

let mut stream = TcpStream::connect(addr)?;
poll_a.registry().register(&mut stream, token, Interest::READABLE)?;

// 未定义行为!
poll_b.registry().reregister(&mut stream, token, Interest::WRITABLE)?;

检测

mio 在 debug 模式下 会通过 SelectorId 检测这个错误并 panic。但 release 模式下不检测——这是性能优化(避免每次操作都做原子比较)。

陷阱中的陷阱

Registry::try_clone() 返回的是同一个 Poll 的 Registry 克隆——用克隆的 Registry 操作 Source 是安全的。但如果你混淆了不同 Poll 的 Registry 和克隆的 Registry,bug 会极其隐蔽。

陷阱 6:在 poll 线程做阻塞操作

问题

poll.poll() 要求 &mut self,意味着 poll 线程在处理事件时不会阻塞在 poll() 上——但如果事件处理代码本身执行了阻塞操作(DNS 查询、文件 I/O、数据库调用),整个事件循环就停滞了。

loop {
    poll.poll(&mut events, None)?;
    for event in events.iter() {
        match event.token() {
            CLIENT => {
                let mut buf = [0; 4096];
                stream.read(&mut buf)?;

                // 灾难:阻塞的 DNS 查询
                let addrs = std::net::ToSocketAddrs::to_socket_addrs("example.com:80")?;

                // 灾难:阻塞的文件 I/O
                std::fs::write("log.txt", &buf)?;

                // 在这些阻塞操作期间,所有其他 socket 的事件都无法被处理
            }
        }
    }
}

正确做法

陷阱 7:不处理 EINTR

问题

系统调用可以被信号中断,返回 EINTR(在 Rust 中表现为 io::ErrorKind::Interrupted)。

// 不完整的代码
poll.poll(&mut events, None)?;  // 如果被信号中断会返回 Err

// 不完整的代码
let n = stream.read(&mut buf)?; // 如果被信号中断会返回 Err

正确做法

// poll 层面
loop {
    match poll.poll(&mut events, timeout) {
        Ok(()) => break,
        Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
        Err(e) => return Err(e),
    }
}

// read 层面
loop {
    match stream.read(&mut buf) {
        Ok(n) => { /* 处理 */ break; }
        Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
        Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, // 重试
        Err(e) => return Err(e),
    }
}

什么时候会遇到 EINTR

在生产环境中,如果你不处理 EINTR,服务会在毫无征兆的情况下退出。


在 mio 之上构建抽象的 3 个额外陷阱

陷阱 A:Token 空间管理

如果你用递增计数器分配 Token,在长期运行的服务中 Token 可能溢出 usize

// 在 64 位系统上几乎不会溢出(2^64 个连接需要数亿年)
// 但在 32 位系统上,2^32 ≈ 42 亿个连接 → 高并发服务可能在数天内溢出
let mut next_token: usize = 0;
fn alloc() -> Token {
    next_token += 1; // 32 位系统上可能溢出
    Token(next_token)
}

更好的做法:使用 slab crate,它会回收已释放的 key。

陷阱 B:连接关闭时的事件排序

当一个连接关闭时(对端发送 FIN),你可能在同一次 poll() 中收到该连接的 readable 事件和 read_closed 事件。处理顺序不当会导致数据丢失:

// 错误:先检查关闭再读数据 → 丢失最后一批数据
if event.is_read_closed() {
    connections.remove(&event.token());
    continue;
}
if event.is_readable() {
    // 如果上面已经 remove 了连接,这里就找不到了
}

// 正确:先读完所有数据,再处理关闭
if event.is_readable() {
    // drain 所有数据...
    // 如果 read() 返回 Ok(0),说明连接关闭
}

陷阱 C:原始 fd 上的外部 I/O

mio 文档有一个关键警告:

Performing I/O operations outside of Mio on these types (via the raw fd) has unspecified behaviour.

如果你通过 AsRawFd 拿到 fd 然后直接用 libc 做 I/O,mio 的内部状态可能不一致。这在 Windows 上尤其严重——IOCP 的所有操作都必须通过 mio 进行,否则内部的 SockState 和 AFD 轮询状态会崩溃。

// 危险:绕过 mio 直接操作 fd
let fd = stream.as_raw_fd();
unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
// mio 不知道你读了数据
// edge-triggered 可能不会再次通知你
// Windows 上直接崩溃

自检清单

在使用 mio 之前,确保你能回答以下问题:

如果有任何一条不确定,考虑使用 tokio。tokio 在内部处理了上述所有问题。


分享这篇文章:

上一篇
DDD(一):领域驱动设计概览
下一篇
mio(六):代码示例-TCP Echo Server