同一个 token 一次 poll 来两个事件
主线位置:事件主线的最后一段,用户遍历
Events的那个循环。poll交回来的每一项是平台的一个原始事件,epoll 一个 fd 一项,kqueue 一个方向一项;循环要对两种形状都成立。
flowchart TB ready["socket 可读且可写"] --> ep["epoll: 1 项 IN 加 OUT"] ready --> kq["kqueue: WRITE、READ 各 1 项"] ep --> one["1 个 Event, 两位都真"] kq --> two["2 个 Event, 同一个 token"] one --> loopnode["用户的事件循环"] two --> loopnode loopnode --> bug1["else if: Linux 丢写"] loopnode --> bug2["unwrap 查表: macOS 崩"] loopnode --> okn["两位都看 查不到跳过"]
为什么会这样,Token、Interest 与 Event 已经讲过并跑过:一个 kevent 只属于一个过滤器,is_readable 和 is_writable 不可能同时为真;一个 epoll_event 可以同时带 EPOLLIN|EPOLLOUT。那一页的 capacity.rs 还看到了 macOS 上同一个 token 在一批里出现两次。这一页看事件循环会因此怎么坏、怎么写才两边都对,最后是容量为 0 这个相关的边角。
本页实验的平台:M2 Max、macOS、rustc 1.99.0;Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。都是 --release,依赖 mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }。
现象一:macOS 上 unwrap 崩
一个常见的写法:连接放在 HashMap<Token, TcpStream> 里,收到事件就按 token 取出连接,不管是哪个方向,一律读到 WouldBlock,读到 0 字节就删掉连接。src/bin/twice.rs 让对端写两个字节后关闭,再注册 READABLE | WRITABLE:
// One connection registered R|W, peer sends "hi" and closes before the first poll.
// The handler reads to WouldBlock / EOF and removes the connection on EOF.
use std::collections::HashMap;
use std::io::{ErrorKind, Read, Write};
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
fn main() -> std::io::Result<()> {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
let mut c = std::net::TcpStream::connect(l.local_addr()?)?;
let (s, _) = l.accept()?;
s.set_nonblocking(true)?;
c.write_all(b"hi")?;
drop(c);
std::thread::sleep(Duration::from_millis(50));
let mut poll = Poll::new()?;
let mut conns: HashMap<Token, TcpStream> = HashMap::new();
let mut s = TcpStream::from_std(s);
poll.registry().register(&mut s, Token(1), Interest::READABLE | Interest::WRITABLE)?;
conns.insert(Token(1), s);
let mut events = Events::with_capacity(16);
poll.poll(&mut events, Some(Duration::from_millis(100)))?;
println!("os={} poll: {} event(s)", std::env::consts::OS, events.iter().count());
for e in &events {
println!(" token={} readable={} writable={} read_closed={}",
e.token().0, e.is_readable(), e.is_writable(), e.is_read_closed());
let conn = conns.get_mut(&e.token()).unwrap(); // assumes: one event per token
let mut buf = [0u8; 64];
loop {
match conn.read(&mut buf) {
Ok(0) => {
println!(" EOF, remove token {}", e.token().0);
let mut conn = conns.remove(&e.token()).unwrap();
poll.registry().deregister(&mut conn)?;
break;
}
Ok(n) => println!(" read {n} bytes"),
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => return Err(err),
}
}
}
Ok(())
}
Linux 上跑 3 次,输出相同,一切正常:
os=linux poll: 1 event(s)
token=1 readable=true writable=true read_closed=true
read 2 bytes
EOF, remove token 1
macOS 上跑 3 次,3 次都 panic(退出码 101),除了线程号,输出相同:
os=macos poll: 2 event(s)
token=1 readable=false writable=true read_closed=false
read 2 bytes
EOF, remove token 1
token=1 readable=true writable=false read_closed=true
thread 'main' (2280392) panicked at src/bin/twice.rs:31:42:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
第一个事件是 EVFILT_WRITE,处理函数不看方向,照样读,读到了数据和 EOF,删掉连接。第二个事件是同一个 token 的 EVFILT_READ,它在 poll 返回时就已经在 Events 里了,deregister 管不到已经取回用户态的事件。到它的时候表里已经没有 Token(1)。
这里的后果是 panic,算是好的。如果 token 是 slab 下标,而处理第一个事件时又 accept 了一个新连接、复用了这个下标,第二个事件就会被当成新连接的事件处理。
现象二:Linux 上丢了写
反方向的错误只在 Linux 上出现。src/bin/elseif.rs 里,服务端有一条回复等着写,处理函数用 if readable { 读 } else if writable { 写 }:
// Peer sends "hi"; we have a reply waiting. Handler: `if readable {..} else if writable {..}`.
use std::io::{ErrorKind, Read, Write};
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
fn main() -> std::io::Result<()> {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
let mut c = std::net::TcpStream::connect(l.local_addr()?)?;
let (s, _) = l.accept()?;
s.set_nonblocking(true)?;
c.write_all(b"hi")?;
std::thread::sleep(Duration::from_millis(50));
let mut poll = Poll::new()?;
let mut s = TcpStream::from_std(s);
poll.registry().register(&mut s, Token(1), Interest::READABLE | Interest::WRITABLE)?;
let mut reply_sent = false;
let mut events = Events::with_capacity(16);
for round in 1..=3 {
poll.poll(&mut events, Some(Duration::from_millis(100)))?;
println!("os={} poll #{round}: {} event(s)", std::env::consts::OS, events.iter().count());
for e in &events {
println!(" token={} readable={} writable={}", e.token().0, e.is_readable(), e.is_writable());
if e.is_readable() {
let mut buf = [0u8; 64];
loop {
match s.read(&mut buf) {
Ok(n) if n > 0 => println!(" read {n} bytes"),
Ok(_) => break,
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => return Err(err),
}
}
} else if e.is_writable() && !reply_sent {
s.write_all(b"reply")?;
reply_sent = true;
println!(" wrote reply");
}
}
}
println!("reply_sent={reply_sent}");
Ok(())
}
macOS 上跑 3 次,输出相同,回复发出去了:
os=macos poll #1: 2 event(s)
token=1 readable=false writable=true
wrote reply
token=1 readable=true writable=false
read 2 bytes
os=macos poll #2: 1 event(s)
token=1 readable=false writable=true
os=macos poll #3: 0 event(s)
reply_sent=true
第 2 次 poll 的那个 WRITE 是写过数据之后的又一次可写通知,和 kqueue 一页「实验:一个 socket 的一生」第 2 步相同。
elseif.rs 在 Linux 上,最后一行 reply_sent 是什么?提示:第一次 poll 时 socket 既可读又可写,而 mio 在 epoll 上总是带 EPOLLET。
出处
两种形状都是文档允许的。Poll::poll 的文档写着:
/// A single call to `poll` may result in multiple readiness events being
/// returned for a single event source. For example, if a TCP socket becomes
/// both readable and writable, 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, one with [`readable`] set
/// and one with [`writable`] set.
mio 不把平台的形状抹平。Events 在 kqueue 上就是 Events(Vec<Event>),Event 透明地包着内核写进来的那个结构,poll 返回之后没有任何后处理;kqueue 一页的「event 模块:一个 filter 一个方向」讲了 is_readable / is_writable 各查哪个 filter。在 epoll 上,一个 fd 在内核里只有一项,select 取回的一个 epoll_event 就带着这一项此刻所有的就绪位。
问: mio 为什么不在 kqueue 上把同一个 token 的读、写两个事件合成一个,跟 epoll 一样?
答: 合不了,也不划算。kqueue 上的 Event 就是一个 kevent,只有一个 filter 字段,装不下两个方向;要合并就得另造一种事件类型,poll 之后再扫一遍、改写缓冲区,每次 poll 都付这个代价。反过来,事件循环只要按下面的写法处理,两种形状都对,不需要 mio 替它合并。
怎么修
两条规则,两边就都对了:
- 每个事件都把
is_readable()和is_writable()各看一遍,用两个独立的if,不用else if。一个事件只有一个方向时,另一个if什么也不做。 - 按 token 找不到对象时跳过这个事件,不要
unwrap。同一批里排在后面的事件,可能属于前面刚处理掉的连接。
mio 自己的 examples/tcp_server.rs 就是这么写的,查不到连接的分支注释是「Sporadic events happen, we can safely ignore them.」,处理函数里 is_writable 和 is_readable 也是两个独立的 if。
src/bin/fixed.rs:对端写两个字节后 shutdown(Write)(这样服务端还能把回复写回去),服务端有一条回复等着写:
// Same setup, a handler that works with 1 or 2 events per token:
// look up the token without unwrap, and check both bits of every event.
use std::collections::HashMap;
use std::io::{ErrorKind, Read, Write};
use std::net::Shutdown;
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
struct Conn {
stream: TcpStream,
reply_pending: bool,
}
fn main() -> std::io::Result<()> {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
let mut c = std::net::TcpStream::connect(l.local_addr()?)?;
let (s, _) = l.accept()?;
s.set_nonblocking(true)?;
c.write_all(b"hi")?;
c.shutdown(Shutdown::Write)?;
std::thread::sleep(Duration::from_millis(50));
let mut poll = Poll::new()?;
let mut conns: HashMap<Token, Conn> = HashMap::new();
let mut stream = TcpStream::from_std(s);
poll.registry().register(&mut stream, Token(1), Interest::READABLE | Interest::WRITABLE)?;
conns.insert(Token(1), Conn { stream, reply_pending: true });
let mut events = Events::with_capacity(16);
poll.poll(&mut events, Some(Duration::from_millis(100)))?;
println!("os={} poll: {} event(s)", std::env::consts::OS, events.iter().count());
for e in &events {
println!(" token={} readable={} writable={}", e.token().0, e.is_readable(), e.is_writable());
let Some(conn) = conns.get_mut(&e.token()) else {
println!(" token already removed, event ignored");
continue;
};
if e.is_writable() && conn.reply_pending {
conn.stream.write_all(b"reply")?;
conn.reply_pending = false;
println!(" wrote reply");
}
if e.is_readable() {
let mut buf = [0u8; 64];
let mut eof = false;
loop {
match conn.stream.read(&mut buf) {
Ok(0) => { eof = true; break; }
Ok(n) => println!(" read {n} bytes"),
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => return Err(err),
}
}
if eof {
let mut conn = conns.remove(&e.token()).unwrap();
poll.registry().deregister(&mut conn.stream)?;
println!(" EOF, removed token {}", e.token().0);
}
}
}
let mut got = String::new();
c.read_to_string(&mut got)?;
println!("client got {got:?}");
Ok(())
}
两边各跑 3 次,输出相同:
os=macos poll: 2 event(s)
token=1 readable=false writable=true
wrote reply
token=1 readable=true writable=false
read 2 bytes
EOF, removed token 1
client got "reply"
os=linux poll: 1 event(s)
token=1 readable=true writable=true
wrote reply
read 2 bytes
EOF, removed token 1
client got "reply"
macOS 上两个事件各做一半,Linux 上一个事件做完两件事,结果一样。这次写的事件先到,「token already removed」那个分支没有走到;把 twice.rs 的处理函数换成这种写法,它就是接住第二个事件的那一行。
tokio 怎么做
tokio 的 I/O 驱动正好满足这两条。turn 里的 for event in events.iter() 对每个事件分别用 Ready::from_mio 把 is_readable、is_writable、is_read_closed… 各自翻译成一位,再交给 dispatch_event,后者用 curr | ready 把这些位或进 ScheduledIo 的就绪状态。一个事件带两位,还是两个事件各带一位,或完之后的状态相同,等着读和等着写的任务都会被唤醒(就绪状态的布局见 tokio 卷的 ScheduledIo 与就绪状态)。
「token 查不到」在 tokio 里也不会发生:token 是 ScheduledIo 的地址,注销之后驱动还攥着这份 Arc,等下一轮 turn 开头才释放,所以同一批里排在后面的事件指向的内存仍然有效(tokio 卷反应器的「注销:先交给驱动,下一轮再放」)。这条保证有一个例外,是注册失败的那条路径,见 register 报 Invalid argument,事件照样来。
相关的边角:Events::with_capacity(0)
Events 的容量在 epoll 和 kqueue 上就是一次 poll 的 maxevents,在 poll(2) 后备上不起作用(Token、Interest 与 Event、poll(2) 后备)。容量为 0 时三个后端三种表现。src/bin/zero_loop.rs 是一个用容量 0 的事件循环,超时 100 ms,跑 300 ms:
// An event loop with Events::with_capacity(0): poll for 300 ms with a 100 ms timeout.
use std::time::{Duration, Instant};
use mio::net::TcpListener;
use mio::{Events, Interest, Poll, Token};
fn main() -> std::io::Result<()> {
let mut poll = Poll::new()?;
let mut l = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
poll.registry().register(&mut l, Token(1), Interest::READABLE)?;
let _c = std::net::TcpStream::connect(l.local_addr()?)?; // the listener is readable
let mut events = Events::with_capacity(0);
let (mut calls, mut got) = (0u64, 0usize);
let start = Instant::now();
while start.elapsed() < Duration::from_millis(300) {
calls += 1;
if let Err(e) = poll.poll(&mut events, Some(Duration::from_millis(100))) {
println!("poll #{calls}: {e:?}");
break;
}
got += events.iter().count();
}
println!("{} capacity={}: {calls} poll call(s) in {}ms, {got} event(s)",
std::env::consts::OS, events.capacity(), start.elapsed().as_millis());
Ok(())
}
macOS,3 次(调用次数每次略有不同):
macos capacity=0: 1424583 poll call(s) in 300ms, 0 event(s)
macos capacity=0: 1425597 poll call(s) in 300ms, 0 event(s)
macos capacity=0: 1424078 poll call(s) in 300ms, 0 event(s)
Linux(epoll),3 次输出相同;加上 strace -e trace=epoll_pwait,ppoll,poll 再跑一次(第一行 ppoll 是 Rust 标准库启动时检查 0、1、2 号 fd):
poll #1: Os { code: 22, kind: InvalidInput, message: "Invalid argument" }
linux capacity=0: 1 poll call(s) in 0ms, 0 event(s)
ppoll([{fd=0, events=0}, {fd=1, events=0}, {fd=2, events=0}], 3, {tv_sec=0, tv_nsec=0}, NULL, 8) = 0 (Timeout)
epoll_pwait(3, 0x8, 0, 100, NULL, 8) = -1 EINVAL (Invalid argument)
poll #1: Os { code: 22, kind: InvalidInput, message: "Invalid argument" }
linux capacity=0: 1 poll call(s) in 0ms, 0 event(s)
+++ exited with 0 +++
Linux 上用 RUSTFLAGS="--cfg mio_unsupported_force_poll_poll" 编译(poll(2) 后备),3 次的计时分别是 310、307、311 ms,其余相同;strace 那次:
ppoll([{fd=0, events=0}, {fd=1, events=0}, {fd=2, events=0}], 3, {tv_sec=0, tv_nsec=0}, NULL, 8) = 0 (Timeout)
ppoll([{fd=3, events=POLLIN}, {fd=4, events=POLLIN|POLLRDHUP}], 2, {tv_sec=0, tv_nsec=100000000}, NULL, 8) = 1 ([{fd=4, revents=POLLIN}], left {tv_sec=0, tv_nsec=99999125})
ppoll([{fd=3, events=POLLIN}, {fd=4, events=POLLRDHUP}], 2, {tv_sec=0, tv_nsec=100000000}, NULL, 8) = 0 (Timeout)
ppoll([{fd=3, events=POLLIN}, {fd=4, events=POLLRDHUP}], 2, {tv_sec=0, tv_nsec=100000000}, NULL, 8) = 0 (Timeout)
ppoll([{fd=3, events=POLLIN}, {fd=4, events=POLLRDHUP}], 2, {tv_sec=0, tv_nsec=100000000}, NULL, 8) = 0 (Timeout)
linux capacity=4: 4 poll call(s) in 316ms, 1 event(s)
+++ exited with 0 +++
| 后端 | poll 的结果 | 事件循环的样子 |
|---|---|---|
| kqueue(macOS) | Ok,立刻返回,0 个事件 | 忙转:300 ms 里约 142 万次 poll,CPU 跑满,事件永远取不到 |
| epoll(Linux) | Err,EINVAL | 第一次就报错;循环如果「出错就继续」,同样是忙转 |
poll(2) 后备 | Ok,事件照常返回 | 正常:select 先 reserve 再 push,容量从 0 长到了 4 |
最后一行的 capacity=4 说明 poll(2) 后备不但不看容量,还会把 Events 撑大(4 是 Vec 第一次分配时的最小容量)。所以「容量就是一次最多取几个」只在 epoll 和 kqueue 上成立,不能拿它来限流。
tokio 在建运行时的时候就挡住了 0:Builder::max_io_events_per_tick 第一行是 assert!(capacity > 0, "max_io_events_per_tick must be non-zero")。exp-mio-pit3-tokio/src/bin/zero_tick.rs 只有一行 tokio::runtime::Builder::new_current_thread().enable_io().max_io_events_per_tick(0).build(),运行结果:
thread 'main' (2330280) panicked at src/bin/zero_tick.rs:3:69:
max_io_events_per_tick must be non-zero
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
直接用 mio 时,Events::with_capacity 的参数如果来自配置,自己检查一下不为 0。
怎么确认自己没踩到
- 在事件循环里搜
else if和按 token 查表的unwrap()/expect()/[]下标。前者在 Linux 上会丢方向,后者在 macOS 上会崩。 - 在 macOS 上跑一遍「对端写完立即关闭」的测试,就像
twice.rs:如果处理函数会删连接,这是最容易让同一批里第二个事件扑空的场景。 - 别靠
Events的容量做限流或公平性控制:poll(2)后备上它不起作用。容量不要为 0。
动脑筋答案
elseif.rs 在 Linux 上跑 3 次,输出相同:
os=linux poll #1: 1 event(s)
token=1 readable=true writable=true
read 2 bytes
os=linux poll #2: 0 event(s)
os=linux poll #3: 0 event(s)
reply_sent=false
答案是 false,回复永远发不出去。
- 第一次
poll只有一个事件,读写两位都在。if e.is_readable()成立,读完;else if那一支被跳过。 - 这个事件已经交付了「可写」这次边沿。
EPOLLET下,发送缓冲区没有再发生变化,内核就不会再报可写,所以后两次poll都是 0 个事件。程序没有报错,只是卡住了,等对端超时。 - macOS 上同样的代码能工作,只是因为 kqueue 把两个方向拆成了两个事件,
else if恰好没有机会跳过什么。一个只在 macOS 上测过的事件循环,到 Linux 生产环境才会暴露这个问题。
小结
- 同时关注读写的 source 都就绪时,Linux 一次
poll给 1 个事件(两位都真),macOS 给 2 个(同一个 token,各一个方向);Poll::poll的文档两种都允许,mio 不合并。 - 「一个 token 一个事件」的循环:macOS 上第一个事件删掉连接后,第二个事件按 token 查表扑空,
unwrap就 panic;token 被复用时会更糟。 if readable {} else if writable {}:Linux 上一个事件两位都真,写的那支被跳过;边沿触发下不会再来可写事件,回复永远发不出去。- 修法:每个事件两位分别用独立的
if处理;按 token 查不到就跳过。mio 的tcp_server示例和 tokio 的驱动都是这样(tokio 把每个事件的就绪位或进ScheduledIo)。 Events::with_capacity(0):kqueue 立即返回 0 个事件(忙转),epoll 每次EINVAL,poll(2)后备照常返回事件并把容量撑大。tokio 用assert!拒绝 0。