Rust in Everything
基于 mio 1.2.4

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注册到 poll2poll2 收到事件
debug(两个平台)AlreadyExists「I/O source already registered with a `Registry`」同一个报错否
Linux releaseAlreadyExists「File exists (os error 17)」Ok是
macOS releaseOkOk是
  • 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,内核当作修改)。

📝NOTE
冷知识:这道检查在 0.6 里 release 也有,0.7 起只剩 debug

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 a Poll instance was previously registered with that Poll instance, then a call to deregister has 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 会怎样?

❗IMPORTANT
动脑筋:同一个 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 收不到事件。

💬 关联讨论

发起讨论
加载中...