I/O source already registered with a Registry
主线位置:注册主线的入口。
Registry::register转给IoSource,debug 构建里先过SelectorId这道检查,再到epoll_ctl(EPOLL_CTL_ADD)/kevent(EV_ADD)。同一个误用,在三处被三种方式处理。
flowchart TB
reg["第二次 register"] --> dbg{"debug 且是 IoSource"}
dbg -->|是| err1["mio 报 AlreadyExists"]
dbg -->|"否:release 或 SourceFd"| plat{平台}
plat -->|"Linux 同一个 Poll"| eexist["EEXIST,os error 17"]
plat -->|"Linux 另一个 Poll"| ok1["Ok,两边都收事件"]
plat -->|macOS| ok2["Ok,token 被改写"]
register 的文档写着「Consecutive calls to register is unspecified behavior」,注册到第二个 Poll 也不允许(「it is bound to that Poll instance for the lifetime of the event source」)。unspecified 的结果取决于构建方式和平台,所以这个坑有三副面孔。完整的误用矩阵(重复 register、没注册就 reregister、deregister 两次……)和 strace 在 Poll 与 Registry 的「register / reregister / deregister 的约定」;SelectorId 的实现和「失败也会改编号」的后遗症在 Source 与 IoSource。这一页只看踩到时的样子、修法和 tokio。
本页实验在两个平台上运行:M2 Max、macOS、rustc 1.99.0;Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。debug 构建用 cargo build,release 用 --release。依赖:
mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
tokio = { version = "=1.54.1", features = ["rt", "net", "macros", "io-util", "time"] }
三副面孔
src/bin/two_polls.rs(和 Source 与 IoSource 里的同名程序相同):同一个 TcpListener 先注册到 poll1,再注册一次到 poll1,再注册到 poll2;另用 SourceFd 把它的 fd 注册到 poll3。然后连一次,三个 Poll 各等 200ms。
use std::net::TcpStream as StdStream;
use std::os::fd::AsRawFd;
use std::time::Duration;
use mio::net::TcpListener;
use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token};
fn show(label: &str, r: std::io::Result<()>) {
match r {
Ok(()) => println!("{label}: Ok"),
Err(e) => println!("{label}: Err(kind={:?}) {e}", e.kind()),
}
}
fn drain(name: &str, poll: &mut Poll, events: &mut Events) {
poll.poll(events, Some(Duration::from_millis(200))).unwrap();
let tokens: Vec<usize> = events.iter().map(|e| e.token().0).collect();
println!("{name}: {} event(s), tokens {tokens:?}", tokens.len());
}
fn main() {
println!("debug_assertions = {}", cfg!(debug_assertions));
let mut poll1 = Poll::new().unwrap();
let mut poll2 = Poll::new().unwrap();
let mut poll3 = Poll::new().unwrap();
let mut events = Events::with_capacity(8);
let mut listener = TcpListener::bind("127.0.0.1:0".parse().unwrap()).unwrap();
let addr = listener.local_addr().unwrap();
let fd = listener.as_raw_fd();
show("register -> poll1", poll1.registry().register(&mut listener, Token(1), Interest::READABLE));
show("register -> poll1 again", poll1.registry().register(&mut listener, Token(1), Interest::READABLE));
show("register -> poll2", poll2.registry().register(&mut listener, Token(2), Interest::READABLE));
show("SourceFd -> poll3", poll3.registry().register(&mut SourceFd(&fd), Token(3), Interest::READABLE));
let _client = StdStream::connect(addr).unwrap();
drain("poll1", &mut poll1, &mut events);
drain("poll2", &mut poll2, &mut events);
drain("poll3", &mut poll3, &mut events);
}
debug 构建,Linux 和 macOS 输出逐字相同:
debug_assertions = true
register -> poll1: Ok
register -> poll1 again: Err(kind=AlreadyExists) I/O source already registered with a `Registry`
register -> poll2: Err(kind=AlreadyExists) I/O source already registered with a `Registry`
SourceFd -> poll3: Ok
poll1: 1 event(s), tokens [1]
poll2: 0 event(s), tokens []
poll3: 1 event(s), tokens [3]
Linux,release:
debug_assertions = false
register -> poll1: Ok
register -> poll1 again: Err(kind=AlreadyExists) File exists (os error 17)
register -> poll2: Ok
SourceFd -> poll3: Ok
poll1: 1 event(s), tokens [1]
poll2: 1 event(s), tokens [2]
poll3: 1 event(s), tokens [3]
macOS,release:
debug_assertions = false
register -> poll1: Ok
register -> poll1 again: Ok
register -> poll2: Ok
SourceFd -> poll3: Ok
poll1: 1 event(s), tokens [1]
poll2: 1 event(s), tokens [2]
poll3: 1 event(s), tokens [3]
每种组合跑 3 次,输出相同。对照着看:
| 情况 | 第二次注册到 poll1 | 注册到 poll2 | poll2 收到事件 |
|---|---|---|---|
| debug(两个平台) | AlreadyExists「I/O source already registered with a `Registry`」 | 同一个报错 | 否 |
| Linux release | AlreadyExists「File exists (os error 17)」 | Ok | 是 |
| macOS release | Ok | Ok | 是 |
- debug 下的报错是 mio 自己写的,在系统调用之前返回;「再注册一次到同一个
Poll」和「注册到另一个Poll」得到一模一样的消息,光看消息分不出是哪一种。 - Linux release 只拦住同一个
Poll。 对同一个 epoll 实例重复EPOLL_CTL_ADD得到EEXIST;把同一个 fd 加进另一个 epoll 实例是合法的,poll1和poll2都收到了这次连接。两个ErrorKind都是AlreadyExists,按 kind 匹配的代码在 debug 和 Linux release 下行为一致,只有消息不同。 - macOS release 什么都不报。 kqueue 的
EV_ADD对已存在的过滤器就是修改,连udata(token)一起改掉,见 Poll 与 Registry 的矩阵:「register T1, register T2」之后事件带的是Token(2)。 SourceFd永远不受 debug 检查。 它不是IoSource,没有SelectorId,poll3那一行三种构建都是Ok。
所以在 macOS 上开发、用 release 跑测试,这个误用完全看不见;到了 Linux 生产环境,它才变成一个 File exists 错误,或者(两个 Poll 时)两个事件循环各自收到同一个 socket 的事件。
报错从哪里来
IoSource 的 register 在调平台 selector 之前先 associate,这一行只在 debug 下编译:
#[cfg(debug_assertions)]
self.selector_id.associate(registry)?;
self.state
.register(registry, token, interests, self.inner.as_raw_fd())
associate 只问「编号是不是 0」,不问是不是同一个 selector:
let previous_id = self.id.swap(registry_id, Ordering::AcqRel);
if previous_id == Self::UNASSOCIATED {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"I/O source already registered with a `Registry`",
))
}
release 下这几行不存在,register 直接落到 epoll 的 register(EPOLL_CTL_ADD,内核给 EEXIST)或 kqueue 的 register(EV_ADD | EV_CLEAR | EV_RECEIPT,内核当作修改)。
0.6 的 TcpStream、TcpListener 里都有一个 selector_id 字段,不带任何 cfg。v0.6.23 的 SelectorId::associate_selector 只拦「注册到另一个 Poll」,同一个 Poll 放行,消息是 "socket already registered"(ErrorKind::Other),release 下照样检查。
issue #999「Remove SelectorId」提议整个删掉:「Currently it only seems to be used check if a Evented type isn't (re)registered with different selectors」。删掉它的 PR #1002 写的理由是「Now TcpStream, TcpListener and UdpSocket are just a file descriptor on unix.」,但没有合并;合并的是 PR #1004「Only use SelectorId when debug assertions are enabled」(「Continuation of #1002」),同时加了一个只在 release 下跑的类型大小测试。它首次发布在 0.7.0。于是 1.2.4 的 mio::net 类型在 release 下就是一个 fd,检查只在 debug 下存在,代价就是上面那张表的后两行。
修法
- 换 token 或关注集合,用
reregister。 它是EPOLL_CTL_MOD,参数「fully override the previous values」。 - 要重新
register,先deregister。 文档要求的正是这个顺序:「if a source being registered with aPollinstance was previously registered with thatPollinstance, then a call toderegisterhas already occurred」。 - 不要把一个 source 交给两个
Poll。 文档说注册之后就绑定在那个Poll上,deregister之后也一样。两个事件循环都需要一个 socket 的数据时,让一个循环拥有它,再把数据传给另一个;两个边沿触发的循环各自读同一个 socket,谁读到WouldBlock、谁丢事件都说不准。 - 自己用
SourceFd时,自己记账。 mio 不会替SourceFd检查,也不维护 fd 到 token 的表;同一个 fd 包两次SourceFd再注册,就是 release 下的那两种结果。
tokio 怎么做
tokio 从结构上避开了这个坑。PollEvented::new_with_interest_and_handle 按值拿走 mio 的 IO 对象,构造时注册一次,之后对象只在 PollEvented 里,用户拿不到 &mut 去再注册;Drop 时 deregister。注册落到驱动的 add_source:先分配一个 ScheduledIo,它的地址就是 token(反应器 的「token 就是指针」),register 失败就把它从集合里删掉再返回错误:
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()) {
tokio 1.54.1 的源码里没有任何地方调用 mio 的 Registry::reregister:TcpStream 注册时就带 READABLE | WRITABLE,之后不再改(反应器)。唯一一处「换一个运行时再注册」是 Linux 上的 pidfd reaper,PollEvented::reregister 的做法是先 deregister、再新建一个 Registration,正好是文档要求的顺序。
所以用 tokio 时,注册这一步能看到的错误有两类:
- mio 或内核的错误原样往上传。 比如
TcpStream::from_std返回的io::Result里就是register的错误;AsyncFd::register返回AsyncFdRegisterError,它的Display/Debug就是里面那个io::Error。 - tokio 自己的错误:运行时已经在关闭时,
allocate在调 mio 之前就返回RUNTIME_SHUTTING_DOWN_ERROR,消息是「A Tokio 1.x context was found, but it is being shutdown.」
例外是 AsyncFd。AsyncFd::register_with_interest 注册的是 &mut SourceFd(&fd):每次都是一个新的 SourceFd,没有 SelectorId,debug 检查拦不住。同一个 fd 交给两个 AsyncFd 会怎样?
src/bin/asyncfd_twice.rs:一个 UnixStream::pair(),把 a 的 fd 号包进一个只实现了 AsRawFd 的小结构体,连续 AsyncFd::register_with_interest 两次(READABLE),然后从 b 写 1 字节,先等第一个 AsyncFd 的 readable(),再等第二个,各给 500ms。
use std::io::Write;
use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::net::UnixStream;
use std::time::Duration;
use tokio::io::Interest;
use tokio::io::unix::AsyncFd;
use tokio::time::timeout;
// a bare fd number; the UnixStream `a` below owns the fd and outlives both AsyncFds
struct Fd(RawFd);
impl AsRawFd for Fd {
fn as_raw_fd(&self) -> RawFd {
self.0
}
}
async fn wait(name: &str, fd: &AsyncFd<Fd>) {
match timeout(Duration::from_millis(500), fd.readable()).await {
Ok(Ok(_guard)) => println!("{name}.readable(): ready"),
Ok(Err(e)) => println!("{name}.readable(): Err({e})"),
Err(_) => println!("{name}.readable(): timed out after 500ms"),
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("os={} debug_assertions={}", std::env::consts::OS, cfg!(debug_assertions));
let (a, mut b) = UnixStream::pair().unwrap();
a.set_nonblocking(true).unwrap();
let fd = a.as_raw_fd();
// SAFETY: `a` keeps the fd open until the end of main, after both AsyncFds are dropped
let first = unsafe { AsyncFd::register_with_interest(Fd(fd), Interest::READABLE) }.unwrap();
println!("first register: Ok");
let second = unsafe { AsyncFd::register_with_interest(Fd(fd), Interest::READABLE) };
match &second {
Ok(_) => println!("second register: Ok"),
Err(e) => println!("second register: Err({e:?})"),
}
b.write_all(b"x").unwrap();
wait("first ", &first).await;
if let Ok(second) = &second {
wait("second", second).await;
}
drop(second);
drop(first);
drop(a);
}
Linux 和 macOS 上,debug 和 release,第二次 register 各返回什么?两个 readable() 各是 ready 还是超时?答案在页末。
怎么确认自己没踩
- 用 debug 构建跑测试。
cargo test默认就是 debug,IoSource的检查在里面;只跑--release的测试,在 macOS 上会把这个误用完全吞掉。 - 不要丢弃
register的返回值。let _ = registry.register(...)在 debug 下吞掉 mio 的报错,在 Linux release 下吞掉EEXIST。按ErrorKind::AlreadyExists判断,两种都能接住。 - 在 Linux 上用 strace 看
epoll_ctl。 同一个 epoll fd 上对同一个 fd 出现第二个EPOLL_CTL_ADD,就是重复注册;同一个 fd 出现在两个 epoll fd 的EPOLL_CTL_ADD里,就是注册到了两个Poll(Source 与 IoSource 里有两种构建的完整 strace)。 - 用
SourceFd或AsyncFd时,保证一个 fd 只有一个所有者。 这两条路径上没有任何检查,macOS 上误用的唯一症状是某个任务再也收不到事件。
动脑筋答案
macOS,debug 和 release 输出只差第一行(各 3 次,相同)。release:
os=macos debug_assertions=false
first register: Ok
second register: Ok
first .readable(): timed out after 500ms
second.readable(): ready
Linux,debug 和 release 输出只差第一行(各 3 次,相同)。release:
os=linux debug_assertions=false
first register: Ok
second register: Err(Os { code: 17, kind: AlreadyExists, message: "File exists" })
first .readable(): ready
- debug 和 release 没有区别。
AsyncFd走的是SourceFd,SelectorId的检查根本不在这条路上。 - Linux:第二次
register是内核的EEXIST,原样包在AsyncFdRegisterError里;第一个AsyncFd不受影响,照常 ready。至少你拿到了一个错误。 - macOS:第二次
register成功,kqueue 用EV_ADD把过滤器的udata改成了第二个ScheduledIo的地址。之后这个 fd 的事件都派给第二个AsyncFd,第一个永远等不到,500ms 超时;第二个立刻 ready。没有任何报错。程序里如果第一个才是真正在读的那个,它就静静地挂住了。
小结
- 同一个
IoSource第二次register,debug 下是 mio 的AlreadyExists「I/O source already registered with a `Registry`」,不区分同一个Poll还是另一个Poll,不发系统调用。 - release 下交给内核:Linux 同一个
Poll是File exists (os error 17),另一个Poll是Ok且两边都收事件;macOS 一律Ok,token 被改写。 - 这道检查在 0.6 里 release 也有、只拦另一个
Poll;0.7.0 起(#1004)只在 debug 下存在。SourceFd从来不受检查。 - 换 token、关注集合用
reregister;重新register之前先deregister;不要把一个 source 交给两个Poll。 - tokio 每个 IO 对象按值拿走、只注册一次、从不调 mio 的
reregister;AsyncFd走SourceFd,同一个 fd 包两次在 Linux 上得到EEXIST,在 macOS 上静默地让第一个AsyncFd收不到事件。