register 报 Invalid argument,事件照样来
主线位置:注册主线在 kqueue 上的最后一步。一次
register是一次带两项 changelist 的kevent,两项各自成败;Err只说明「至少有一项失败」,不说明「什么也没注册」。
flowchart TB regnode["register R|W"] --> chg["changelist: WRITE, READ"] chg --> wr["WRITE 回执 EINVAL"] chg --> rd["READ 回执 0"] wr --> errnode["返回 Err"] rd --> left["READ 留在 kqueue"] left --> ev["之后照样来事件"] errnode --> fix["调用方 deregister"] fix --> clean["两项都删掉"]
为什么一项失败不会撤销另一项,在 kqueue 的「一项失败,另一项照样生效」里已经从 kevent_register 和 check_errors 讲清楚了,那里的 kqfd.rs 也跑过 register 的三种情况。这一页回答四个用的时候才会碰到的问题:哪些 fd 会触发它;reregister、deregister 是不是也只生效一半;拿到 Err 之后该做什么;tokio 怎么处理。
本页实验在 M2 Max、macOS、rustc 1.99.0、--release 下运行(tokio 那一节另用 nightly 开 ASan),依赖:
mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
libc = "=0.2.191"
现象
把一个 Poll 的 fd(在 macOS 上就是一个 kqueue fd)注册进另一个 Poll,关注 READABLE | WRITABLE,然后让里面那个 kqueue 有一个待取事件,看外面的 poll。src/bin/nested.rs:
// Register one Poll's fd in another Poll, with R, W and R|W; then make the inner one readable.
use std::os::fd::AsRawFd;
use std::time::Duration;
use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token, Waker};
fn main() {
for (name, interest) in [
("READABLE", Interest::READABLE),
("WRITABLE", Interest::WRITABLE),
("READABLE|WRITABLE", Interest::READABLE.add(Interest::WRITABLE)),
] {
let mut outer = Poll::new().unwrap();
let inner = Poll::new().unwrap();
let r = outer.registry().register(&mut SourceFd(&inner.as_raw_fd()), Token(1), interest);
let waker = Waker::new(inner.registry(), Token(5)).unwrap();
waker.wake().unwrap();
let mut events = Events::with_capacity(8);
outer.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
let evs: Vec<String> = events.iter()
.map(|e| format!("token={} readable={} writable={}", e.token().0, e.is_readable(), e.is_writable()))
.collect();
println!("{name:<18} register {r:?}");
println!("{:<18} then poll: {} event(s) {evs:?}", "", events.iter().count());
}
}
跑 3 次,输出相同:
READABLE register Ok(())
then poll: 1 event(s) ["token=1 readable=true writable=false"]
WRITABLE register Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
then poll: 0 event(s) []
READABLE|WRITABLE register Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
then poll: 1 event(s) ["token=1 readable=true writable=false"]
第三行就是这个坑:register 返回 Err,Display 出来是 Invalid argument (os error 22),可是 Token(1) 的可读事件照样来了。kqueue fd 支持 EVFILT_READ(里面有待取事件就可读),不支持 EVFILT_WRITE;changelist 是 [WRITE, READ],WRITE 失败,READ 成功并留在外层 kqueue 里。
nested.rs 在 Linux 上,WRITABLE 那一行 register 返回什么?Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。那里的 Poll fd 是 epoll fd。
哪些 fd 会触发
部分成功需要一种 fd:EVFILT_READ 能加、EVFILT_WRITE 加不上(或者反过来)。两项都失败或都成功的 fd 只会得到干脆的结果。src/bin/probe.rs 和 probe2.rs 对一组常见的 fd 分别用 R、W、R|W 各注册一次(每次之后 deregister),最后再注册一次 R|W 看 poll:
// Which fd types accept EVFILT_READ / EVFILT_WRITE? mio register(R|W) on each.
use std::fs::{File, OpenOptions};
use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::fs::OpenOptionsExt;
use std::time::Duration;
use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token};
const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);
fn main() {
let dir = std::env::args().nth(1).expect("dir");
let file_path = format!("{dir}/regular");
std::fs::write(&file_path, b"abc").unwrap();
let fifo = format!("{dir}/fifo");
let _ = std::fs::remove_file(&fifo);
let c = std::ffi::CString::new(fifo.clone()).unwrap();
assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0);
let fifo_r = OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK).open(&fifo).unwrap();
let fifo_w = OpenOptions::new().write(true).custom_flags(libc::O_NONBLOCK).open(&fifo).unwrap();
let (pr, pw) = std::io::pipe().unwrap();
let inner = Poll::new().unwrap();
let fds: Vec<(&str, Box<dyn AsRawFd>)> = vec![
("regular file", Box::new(File::open(&file_path).unwrap())),
("directory", Box::new(File::open(&dir).unwrap())),
("fifo read end", Box::new(fifo_r)),
("fifo write end", Box::new(fifo_w)),
("pipe read end", Box::new(pr)),
("pipe write end", Box::new(pw)),
("/dev/null", Box::new(File::open("/dev/null").unwrap())),
("/dev/zero", Box::new(File::open("/dev/zero").unwrap())),
("/dev/urandom", Box::new(File::open("/dev/urandom").unwrap())),
("kqueue fd", Box::new(inner)),
];
for (name, f) in &fds {
let fd: RawFd = f.as_raw_fd();
let mut poll = Poll::new().unwrap();
let mut results = vec![];
for (iname, i) in [("R", Interest::READABLE), ("W", Interest::WRITABLE), ("R|W", RW)] {
let r = poll.registry().register(&mut SourceFd(&fd), Token(1), i);
results.push(format!("{iname}: {}", match r { Ok(()) => "Ok".to_string(), Err(e) => format!("Err({})", e.raw_os_error().unwrap()) }));
let _ = poll.registry().deregister(&mut SourceFd(&fd));
}
// after a failing R|W register, what is left?
let r = poll.registry().register(&mut SourceFd(&fd), Token(1), RW);
let mut events = Events::with_capacity(8);
poll.poll(&mut events, Some(Duration::from_millis(50))).unwrap();
let ev: Vec<String> = events.iter().map(|e| format!("{}{}", if e.is_readable() {"r"} else {""}, if e.is_writable() {"w"} else {""})).collect();
println!("{name:<15} {} | R|W then poll: ok={} events={:?}", results.join(" "), r.is_ok(), ev);
}
}
probe2.rs 是同样的循环(不做最后那次 poll),fd 换成 socket 和伪终端:std::net::TcpListener、UdpSocket、一个未连接的 socket(AF_INET, SOCK_STREAM)、posix_openpt 得到的主端和从端、UnixStream::pair 的一端。两个程序各跑 3 次,输出相同:
regular file R: Ok W: Ok R|W: Ok | R|W then poll: ok=true events=["w", "r"]
directory R: Err(22) W: Err(22) R|W: Err(22) | R|W then poll: ok=false events=[]
fifo read end R: Ok W: Ok R|W: Ok | R|W then poll: ok=true events=["w"]
fifo write end R: Ok W: Ok R|W: Ok | R|W then poll: ok=true events=["w"]
pipe read end R: Ok W: Ok R|W: Ok | R|W then poll: ok=true events=[]
pipe write end R: Ok W: Ok R|W: Ok | R|W then poll: ok=true events=["w"]
/dev/null R: Err(22) W: Err(22) R|W: Err(22) | R|W then poll: ok=false events=[]
/dev/zero R: Err(22) W: Err(22) R|W: Err(22) | R|W then poll: ok=false events=[]
/dev/urandom R: Err(22) W: Err(22) R|W: Err(22) | R|W then poll: ok=false events=[]
kqueue fd R: Ok W: Err(22) R|W: Err(22) | R|W then poll: ok=false events=[]
tcp listener R: Ok W: Ok R|W: Ok
udp socket R: Ok W: Ok R|W: Ok
tcp unconnected R: Ok W: Ok R|W: Ok
pty master R: Ok W: Ok R|W: Ok
pty slave R: Ok W: Ok R|W: Ok
unix stream R: Ok W: Ok R|W: Ok
在这台 macOS 上,16 种 fd 里只有 kqueue fd 是「读能加、写加不上」。其余的要么两个过滤器都接受(普通文件、FIFO 两端、pipe 两端、各种 socket、伪终端),要么两个都拒绝(目录、/dev/null 这类字符设备;从 /dev/null 重定向来的 stdin 也一样,见下文 stdin_cleanup.rs),R|W 的结果和单项一致。注意 FIFO 读端、pipe 读端也接受 EVFILT_WRITE,所以「只读的 fd 加不上写过滤器」这个直觉在 macOS 上不成立;源码里 does_not_register_rw 这个测试也只拿 kqueue fd 举例(「Registering kqueue fd will fail if write is requested (On anything but some versions of macOS).」)。
也就是说,现实里踩到它的路线是「把另一个事件循环的 fd 交给自己的事件循环」:mio 的 Poll 实现了 AsRawFd,很多库也会暴露自己内部的 kqueue fd 让上层嵌套;拿它用默认的读写兴趣注册,就是上面第三行。
reregister 和 deregister
reregister 总是提交两项:要的方向 EV_ADD,不要的方向 EV_DELETE,同样逐项生效。deregister 两项都是 EV_DELETE,忽略 ENOENT。src/bin/partial.rs 在同一个 kqueue fd 上走一遍,每步之后让内层 kqueue 可读,看外层收到什么,再把内层的事件取掉:
// kqueue fd in another Poll: register / reregister / deregister after an Err
use std::os::fd::AsRawFd;
use std::time::Duration;
use exp_mio_pit3::raw;
use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token, Waker};
const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);
// make the inner kqueue readable, then see what the outer poll reports
fn check(outer: &mut Poll, waker: &Waker, step: &str) {
waker.wake().unwrap();
let mut events = Events::with_capacity(8);
outer.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
println!("{step}");
println!(" then poll: {} event(s)", events.iter().count());
for e in &events {
println!(" token={} {}", e.token().0, raw(e));
}
}
fn main() {
let mut outer = Poll::new().unwrap();
let mut inner = Poll::new().unwrap();
let waker = Waker::new(inner.registry(), Token(99)).unwrap();
let fd = inner.as_raw_fd();
let reg = outer.registry().try_clone().unwrap();
let mut drain = Events::with_capacity(8);
let r = reg.register(&mut SourceFd(&fd), Token(1), RW);
check(&mut outer, &waker, &format!("1. register(Token(1), R|W) -> {r:?}"));
inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();
let r = reg.reregister(&mut SourceFd(&fd), Token(2), RW);
check(&mut outer, &waker, &format!("2. reregister(Token(2), R|W) -> {r:?}"));
inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();
let r = reg.reregister(&mut SourceFd(&fd), Token(3), Interest::WRITABLE);
check(&mut outer, &waker, &format!("3. reregister(Token(3), WRITABLE) -> {r:?}"));
inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();
let r = reg.register(&mut SourceFd(&fd), Token(4), RW);
check(&mut outer, &waker, &format!("4. register(Token(4), R|W) -> {r:?}"));
inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();
let r = reg.deregister(&mut SourceFd(&fd));
check(&mut outer, &waker, &format!("5. deregister -> {r:?}"));
}
raw(e) 把 &Event 当成 &libc::kevent 打印字段(Event 是 #[repr(transparent)] 包着 kevent 的,这依赖私有布局,只用于观察;和 kqueue 一页的同名函数相同)。跑 3 次,输出相同:
1. register(Token(1), R|W) -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
then poll: 1 event(s)
token=1 ident=4 filter=READ flags=ADD|CLEAR|RECEIPT fflags=0 data=1 udata=1
2. reregister(Token(2), R|W) -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
then poll: 1 event(s)
token=2 ident=4 filter=READ flags=ADD|CLEAR|RECEIPT fflags=0 data=1 udata=2
3. reregister(Token(3), WRITABLE) -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
then poll: 0 event(s)
4. register(Token(4), R|W) -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
then poll: 1 event(s)
token=4 ident=4 filter=READ flags=ADD|CLEAR|RECEIPT fflags=0 data=1 udata=4
5. deregister -> Ok(())
then poll: 0 event(s)
| 步 | 提交的两项 | 回执 | 结果 | 实际状态 |
|---|---|---|---|---|
| 1 | WRITE 加、READ 加 | EINVAL、0 | Err | 读方向以 Token(1) 注册 |
| 2 | WRITE 加、READ 加 | EINVAL、0 | Err | 读方向的 udata 改成了 2 |
| 3 | WRITE 加、READ 删 | EINVAL、0 | Err | 什么都没注册了 |
| 4 | 同第 1 步 | EINVAL、0 | Err | 读方向以 Token(4) 注册 |
| 5 | 两项都删 | ENOENT(忽略)、0 | Ok | 干净 |
reregister也只生效一半。 第 2 步报错,token 却已经换成了 2;第 3 步报错,读方向却已经被删了。调用方如果按「Err等于什么都没改」来维护自己的状态表,这两步之后表和内核就对不上了。deregister不会部分失败。 两项都是删除,不存在的那一项回ENOENT,正好在忽略表里。所以第 5 步对这个「一半注册」的 fd 返回Ok,之后再也没有事件。
为什么 mio 不回滚
register 看到 WRITE 失败后,要撤销 READ 只能再交一次 EV_DELETE。mio 不这样做,有一个比「省一次系统调用」更硬的原因:它不知道 READ 这一项在这次 register 之前存不存在。EV_ADD 遇到已有的项就修改(所以 macOS 上同一个 fd register 两次会成功),而 kqueue 上的 IoSourceState 是空结构,mio 不记每个 fd 注册了什么。如果 READ 本来就在(比如用户先 register(READABLE) 成功,再错误地 register(R|W)),回滚会把用户原来的注册一起删掉。kevent_register 只能如实报告第一张失败的回执(check_errors),状态留给调用方收拾。
Linux 没有这个问题。epoll 的 register 是一次 epoll_ctl,一个 fd 只有一项、带一个事件掩码,要么整项加上,要么什么也没加。
问: 既然 kqueue fd 不支持写,register 为什么不先判断一下 fd 类型,干脆只注册读?
答: 判断不了,也不该判断。mio 拿到的只是一个 RawFd,内核支持哪些过滤器只有内核知道,唯一可靠的办法就是提交了看回执。即使能判断,悄悄丢掉用户要的 WRITABLE 也会把错误藏起来:用户等一个永远不会来的可写事件。报错是对的,问题只在于报错时已经生效了一半。
怎么修
- 只要你确实需要的方向。 嵌套的事件循环 fd 只会「可读」,用
Interest::READABLE注册,就不会有失败的那一项。上面nested.rs第一行就是这样。 register/reregister返回Err时,把这个 fd 当成「状态未知」,马上deregister,并忽略它的返回值。 在 kqueue 上它会把两项都删掉、返回Ok(上面第 5 步);在 epoll 上,失败的register什么也没留下,deregister多半也报错。之后要么用正确的兴趣重新注册,要么关掉 fd。macOS 上关闭 fd 时 kqueue 也会删掉它的项(没 deregister 就 drop)。- 别在
Err之后立刻把这个 token 分给别的 source。 第 1 步之后不收拾,Token(1)会一直以读事件的身份出现,直到 fd 关闭。
src/bin/stdin_cleanup.rs 对 stdin 依次用三种兴趣 register,每次紧接着 deregister:
// (1) register stdin with R / W / R|W; (2) after a failed register, what does deregister return?
use mio::unix::SourceFd;
use mio::{Interest, Poll, Token};
fn main() {
let poll = Poll::new().unwrap();
for (name, i) in [("R", Interest::READABLE), ("W", Interest::WRITABLE), ("R|W", Interest::READABLE | Interest::WRITABLE)] {
let r = poll.registry().register(&mut SourceFd(&0), Token(1), i);
let d = poll.registry().deregister(&mut SourceFd(&0));
println!("{} stdin {name:<3}: register {:?}, then deregister {:?}", std::env::consts::OS,
r.map_err(|e| e.raw_os_error()), d.map_err(|e| e.raw_os_error()));
}
}
< /dev/null 运行,macOS 和 Linux(容器)各 3 次,输出相同:
macos stdin R : register Err(Some(22)), then deregister Ok(())
macos stdin W : register Err(Some(22)), then deregister Ok(())
macos stdin R|W: register Err(Some(22)), then deregister Ok(())
linux stdin R : register Err(Some(1)), then deregister Err(Some(1))
linux stdin W : register Err(Some(1)), then deregister Err(Some(1))
linux stdin R|W: register Err(Some(1)), then deregister Err(Some(1))
macOS 上 /dev/null 两个过滤器都不接受(EINVAL),deregister 删两个不存在的项,ENOENT 被忽略,返回 Ok。Linux 上 epoll 不接受这种 fd(EPERM),deregister 的 EPOLL_CTL_DEL 同样 EPERM。所以清理用的 deregister 不能 ?,要吞掉错误。
tokio:释放了内核还在用的 token
tokio 的 token 就是 ScheduledIo 的地址,事件回来时直接解引用(tokio 卷的反应器「token 就是指针」一节)。它的安全前提是:内核还可能带着这个地址回来时,ScheduledIo 不能被释放。Handle::add_source 在注册失败时是这样处理的:
let scheduled_io = self.registrations.allocate(&mut self.synced.lock())?;
let token = scheduled_io.token();
// we should remove the `scheduled_io` from the `registrations` set if registering
// the `source` with the OS fails. Otherwise it will leak the `scheduled_io`.
if let Err(e) = self.registry.register(source, token, interest.to_mio()) {
// safety: `scheduled_io` is part of the `registrations` set.
unsafe {
self.registrations
.remove(&mut self.synced.lock(), &scheduled_io)
};
return Err(e);
}
register 失败就把 ScheduledIo 从链表里摘掉,函数返回时最后一份 Arc 被 drop,内存释放;没有调 deregister。这在 epoll 上是对的(失败的 epoll_ctl 什么也没留下),在 kqueue 的部分成功上不对:READ 那一项还在 tokio 的 kqueue 里,udata 是刚释放的地址。而 AsyncFd::register(以及已弃用的 AsyncFd::new)默认用的正是 READABLE | WRITABLE。
exp-mio-pit3-tokio/src/bin/asyncfd.rs(tokio = { version = "=1.54.1", features = ["rt", "net", "time"] }):
// tokio AsyncFd::register on a kqueue fd (macOS): register(R|W) fails,
// but READ stays in tokio's kqueue with the token of a freed ScheduledIo.
use std::os::fd::AsRawFd;
use std::time::Duration;
use mio::{Poll, Token, Waker};
use tokio::io::unix::AsyncFd;
struct Fd(i32);
impl AsRawFd for Fd {
fn as_raw_fd(&self) -> i32 { self.0 }
}
fn main() {
let rt = tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build().unwrap();
rt.block_on(async {
let inner = Poll::new().unwrap();
let waker = Waker::new(inner.registry(), Token(0)).unwrap();
// SAFETY: `inner` outlives this block and its fd is not closed.
let r = unsafe { AsyncFd::register(Fd(inner.as_raw_fd())) };
println!("AsyncFd::register -> {:?}", r.as_ref().map(|_| ()).map_err(|e| e.to_string()));
drop(r);
// the inner kqueue becomes readable; tokio's driver gets a READ event
waker.wake().unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
println!("driver polled; still alive");
});
}
AsyncFd::register 的安全要求是 fd 有效、在 AsyncFd 存活期间不被关闭,这里都满足(inner 一直活着)。普通 --release 跑 3 次,输出相同,什么也看不出来:
AsyncFd::register -> Err("Invalid argument (os error 22)")
driver polled; still alive
用 nightly(rustc 1.101.0-nightly,2026-10-05)加 RUSTFLAGS="-Zsanitizer=address"、--target aarch64-apple-darwin 再跑 3 次,3 次都以 AddressSanitizer 报错退出(退出码 134)。下面是第一次的节选:符号用 rustc-demangle 0.1.27 还原过,... 处删掉了中间的栈帧:
AsyncFd::register -> Err("Invalid argument (os error 22)")
=================================================================
==19720==ERROR: AddressSanitizer: heap-use-after-free on address 0x613000000150 at pc 0x00010441a1e8 bp 0x00016ba75fc0 sp 0x00016ba75fb8
READ of size 8 at 0x613000000150 thread T0
#0 0x00010441a1e4 in core::sync::atomic::atomic_load::<usize, false>+0x104 (asyncfd:arm64+0x1000961e4)
...
#4 0x0001043d1d4c in <tokio::runtime::io::scheduled_io::ScheduledIo>::set_readiness::<tokio::runtime::io::driver::dispatch_event::{closure#0}>+0x148 (asyncfd:arm64+0x10004dd4c)
#5 0x0001043d0d80 in tokio::runtime::io::driver::dispatch_event+0x174 (asyncfd:arm64+0x10004cd80)
#6 0x0001043cd608 in <tokio::runtime::io::driver::Driver>::turn+0x7b0 (asyncfd:arm64+0x100049608)
...
0x613000000150 is located 208 bytes inside of 256-byte region [0x613000000080,0x613000000180)
freed by thread T0 here:
...
#8 0x000104390150 in <tokio::runtime::io::driver::Handle>::add_source::<mio::sys::unix::sourcefd::SourceFd> driver.rs:309
#9 0x000104386968 in <tokio::runtime::io::registration::Registration>::new_with_interest_and_handle::<mio::sys::unix::sourcefd::SourceFd> registration.rs:78
#10 0x000104388870 in <tokio::io::async_fd::AsyncFd<asyncfd::Fd>>::register_with_interest async_fd.rs:273
#11 0x000104388ad8 in <tokio::io::async_fd::AsyncFd<asyncfd::Fd>>::register async_fd.rs:241
#12 0x0001043895d0 in asyncfd::main::{closure#0} asyncfd.rs:20
...
SUMMARY: AddressSanitizer: heap-use-after-free (asyncfd:arm64+0x1000961e4) in core::sync::atomic::atomic_load::<usize, false>+0x104
释放发生在 add_source 里(第 309 行是函数末尾,scheduled_io 在这里被 drop),读发生在下一次 turn 的 dispatch_event 里,读的是 ScheduledIo 里的就绪状态字。没开 ASan 时,这次读写落在已经还给分配器的内存上,后果取决于那块内存后来被谁用了。
所以在 macOS 上用 tokio 包一个 kqueue fd,必须显式只要读:
// SAFETY: `inner` outlives the AsyncFd and its fd is not closed.
let afd = unsafe { AsyncFd::register_with_interest(Fd(inner.as_raw_fd()), Interest::READABLE) }.unwrap();
src/bin/asyncfd_read.rs 用这一行替换上面的注册,wake 之后 afd.readable().await,同样开 ASan 跑 3 次,退出码都是 0,输出相同:
register_with_interest(READABLE) -> Ok
readable: Ready { is_readable: true, is_writable: false, is_read_closed: false, is_write_closed: false, is_error: false }
tokio 的 TcpStream、UdpSocket、UnixStream 这些类型不受影响:socket 两个过滤器都接受,register 要么全成要么全败。受影响的只有 AsyncFd 包一个「读写只支持一半」的 fd 这一种用法。
怎么确认自己没踩到
- 在 macOS 上,找所有
register/reregister的Err分支:如果只是记日志或把错误往上抛,而 fd 还开着、token 还会被复用,就是这个坑。 - 找所有用默认兴趣(
READABLE | WRITABLE)注册非 socket fd 的地方,尤其是AsyncFd::register、AsyncFd::new。用probe.rs那样的程序对你的 fd 类型单独试一次R和W。 - 想确定一个 fd 在 kqueue 里还剩什么,最直接的办法是像
partial.rs那样让它就绪,再看poll。
动脑筋答案
nested.rs 在 Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0 上,用 strace -f -e trace=epoll_create1,epoll_ctl,epoll_pwait 跑,3 次输出相同(去掉了容器的进度行):
epoll_create1(EPOLL_CLOEXEC) = 3
epoll_create1(EPOLL_CLOEXEC) = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x5}) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0x1}], 8, 100, NULL, 8) = 1
READABLE register Ok(())
then poll: 1 event(s) ["token=1 readable=true writable=false"]
epoll_create1(EPOLL_CLOEXEC) = 3
epoll_create1(EPOLL_CLOEXEC) = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLOUT|EPOLLET, data=0x1}) = 0
epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x5}) = 0
epoll_pwait(3, [], 8, 100, NULL, 8) = 0
WRITABLE register Ok(())
then poll: 0 event(s) []
epoll_create1(EPOLL_CLOEXEC) = 3
epoll_create1(EPOLL_CLOEXEC) = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x5}) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0x1}], 8, 100, NULL, 8) = 1
READABLE|WRITABLE register Ok(())
then poll: 1 event(s) ["token=1 readable=true writable=false"]
+++ exited with 0 +++
答案是 Ok(())。
- epoll fd 可以放进另一个 epoll,
EPOLLOUT也被接受:一个 fd 一项,掩码里有什么内核就记什么,epoll fd 永远不会报可写,所以WRITABLE那一轮 0 个事件,但注册本身成功。 - 三种兴趣在 Linux 上都是一次成功的
epoll_ctl,不存在「一半」。同一个程序在 macOS 上两种报错、Linux 上全部成功,这也是这个坑在 Linux 上开发、macOS 上才暴露(或者反过来)的原因。
小结
- kqueue 的
register/reregister每个方向一项,逐项生效、不回滚;返回Err时可能已经注册了一个方向(或者,reregister时已经删掉了一个方向、换掉了 token)。 - 实测 macOS 上 16 种常见 fd 里只有 kqueue fd 是「读能加、写加不上」,触发路线是把另一个事件循环的 fd 嵌进来;目录和字符设备两项都失败,普通文件、FIFO、pipe、socket、伪终端两项都成功。
deregister不会部分失败(删不存在的项回ENOENT,被忽略),所以拿到Err之后deregister一次就能回到干净状态。- mio 不回滚,因为它不知道成功的那一项之前是否已经存在;epoll 一个 fd 一项,没有中间状态。
- tokio 1.54.1 的
add_source在Err时释放ScheduledIo却不deregister;macOS 上AsyncFd::register一个 kqueue fd,下一次事件就是 use-after-free(ASan 复现)。用register_with_interest(..., Interest::READABLE)。