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),
}
}
}
为什么这个问题特别阴险
- 在低负载时不会触发:每个 readable 事件通常只有少量数据,一次 read 就能读完
- 在高负载时间歇性触发:多个小包被内核合并为一个大包,一次 read 读不完
- 表现为「偶尔丢消息」:极难在测试中复现
陷阱 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 出现的场景
- 虚假唤醒:OS 报告了就绪但数据还没准备好
- 竞态:在你检查事件和调用 read 之间,另一个操作消耗了数据(多线程场景)
- TCP RST:对端 reset 连接,触发 readable 事件,但读取会返回错误而不是 WouldBlock
- 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 的事件都无法被处理
}
}
}
}
正确做法
- 阻塞操作放到线程池中执行
- 完成后通过
Waker通知 poll 线程 - 或者直接使用 tokio,它的
spawn_blocking自动处理这个问题
陷阱 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
- 收到
SIGCHLD(子进程退出) - 收到
SIGWINCH(终端窗口大小变化) - 使用 profiler(发送
SIGPROF) - 使用
strace或ptrace
在生产环境中,如果你不处理 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 之前,确保你能回答以下问题:
- 我知道什么是边缘触发,我的代码在每次事件后 drain 到 WouldBlock
- 我处理了 WouldBlock(虚假唤醒)
- 我处理了 Interrupted(EINTR)
- 我在 Source drop 前调用了 deregister
- 我的 reregister 包含了完整的 Interest(不依赖追加语义)
- 我没有跨 Poll 使用 Source
- 我的事件处理代码没有阻塞操作
- 我没有通过 raw fd 绕过 mio 做 I/O
- 我的 Token 分配策略能应对长期运行
- 我先处理读事件再处理关闭事件
如果有任何一条不确定,考虑使用 tokio。tokio 在内部处理了上述所有问题。