drop 之后还收到旧 token 的事件
主线位置:注册主线的反方向。
register在内核里留下一项(epoll 的集合项,kqueue 的过滤器),这一项什么时候消失,决定了事件主线上还会不会冒出它的 token。drop 只关 fd,不调epoll_ctl/kevent。
flowchart TB
dropnode["drop socket"] --> closefd["close 这个 fd"]
closefd --> other{"还有别的 fd"}
other -->|没有| gone["两个平台都摘掉"]
other -->|"有:dup 或 fork"| plat{平台}
plat -->|Linux| stays["旧项还在,照报旧 token"]
plat -->|macOS| removed["过滤器随 fd 删除"]
fix["先 deregister 再 drop"] --> del["DEL 找得到那一项"]
机制在 Poll 与 Registry 的「drop 之前没有 deregister」一节里用实验讲过:epoll 的集合项挂在「打开的文件描述」上,kqueue 的过滤器挂在 fd 号上;dup 过的 fd 关掉一个,epoll 那一项还在,换成 dup 的 fd 号也删不掉,fd 号被新 socket 复用后一次 poll 里会同时出现新旧两个 token。这一页看这个坑在真实程序里是怎么被触发的,以及怎么避开。
现象
一个 socket 已经 drop 了,poll 却还返回它当初注册的 token。在 Linux 上,下面几种情况都会让 socket 在 drop 之后继续存在:
- 自己
dup过它,或者对 std 类型用过try_clone(复制出另一个 fd,见 Poll 与 Registry 的实验); fork之后子进程还没退出,子进程手里有每个 fd 的副本;- 通过
into_raw_fd把 fd 交给了别的代码,那边还开着。
token 如果是数组下标或者 Slab 的键,旧事件会被派给后来复用了这个键的连接;token 如果是指针(tokio 用的是 ScheduledIo 的地址),就是访问已释放的内存。
复现
src/bin/drop_dup.rs:每种情况用一个新的 Poll,服务端 accept 出来的 socket 用 Token(1) 注册,然后 drop 它、让对端写 1 字节、poll 100 毫秒。A 用 std::process::Command 起一个子进程;B 用 libc::fork,子进程睡 500 毫秒后退出;C 用 libc::dup;D 是修法:同样 dup,但先 deregister 再 drop。Linux 上 C 还从 /proc/self/fdinfo/<epoll fd> 里读出 epoll 实例的集合(读 fdinfo 不改变任何状态)。
mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
libc = "=0.2.191"
use std::io::{self, Write};
use std::os::fd::AsRawFd;
use std::process::Command;
use std::time::Duration;
use mio::net::{TcpListener, TcpStream};
use mio::{Events, Interest, Poll, Token};
// One registered server-side socket (Token(1)) plus its peer.
fn setup(l: &TcpListener) -> io::Result<(Poll, TcpStream, std::net::TcpStream)> {
let peer = std::net::TcpStream::connect(l.local_addr()?)?;
std::thread::sleep(Duration::from_millis(20));
let (mut s, _) = l.accept()?;
let poll = Poll::new()?;
poll.registry().register(&mut s, Token(1), Interest::READABLE)?;
Ok((poll, s, peer))
}
fn tokens(poll: &mut Poll) -> Vec<usize> {
let mut events = Events::with_capacity(8);
poll.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
events.iter().map(|e| e.token().0).collect()
}
// Linux: the epoll instance's interest list, from /proc/self/fdinfo
#[cfg(target_os = "linux")]
fn interest_list(poll: &Poll) -> String {
let info = std::fs::read_to_string(format!("/proc/self/fdinfo/{}", poll.as_raw_fd())).unwrap();
let items: Vec<&str> = info.lines().filter(|l| l.starts_with("tfd:")).collect();
format!("{items:?}")
}
#[cfg(not(target_os = "linux"))]
fn interest_list(_: &Poll) -> String {
"n/a".to_string()
}
fn cloexec(fd: i32) -> bool {
unsafe { libc::fcntl(fd, libc::F_GETFD) & libc::FD_CLOEXEC != 0 }
}
fn main() -> io::Result<()> {
println!("os={}", std::env::consts::OS);
let l = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
// A. a child process started with Command while the socket is registered
let (mut poll, s, mut peer) = setup(&l)?;
println!("A. socket fd {} has FD_CLOEXEC: {}", s.as_raw_fd(), cloexec(s.as_raw_fd()));
let mut child = Command::new("sleep").arg("1").spawn()?;
drop(s);
peer.write_all(b"x")?;
println!(" Command child alive, dropped, peer writes -> tokens {:?}", tokens(&mut poll));
child.kill()?;
child.wait()?;
// B. fork() without exec: the child holds a copy of every fd
let (mut poll, s, mut peer) = setup(&l)?;
let pid = unsafe { libc::fork() };
if pid == 0 {
std::thread::sleep(Duration::from_millis(500));
unsafe { libc::_exit(0) };
}
drop(s);
peer.write_all(b"x")?;
println!("B. forked child alive, dropped, peer writes -> tokens {:?}", tokens(&mut poll));
unsafe { libc::waitpid(pid, std::ptr::null_mut(), 0) };
let _ = peer.write_all(b"x"); // the socket is closed now: may fail
println!(" child exited, peer writes again -> tokens {:?}", tokens(&mut poll));
// C. libc::dup, then drop without deregister
let (mut poll, s, mut peer) = setup(&l)?;
let dup = unsafe { libc::dup(s.as_raw_fd()) };
drop(s);
peer.write_all(b"x")?;
println!("C. dup fd {dup} open, dropped, peer writes -> tokens {:?}", tokens(&mut poll));
println!(" interest list: {}", interest_list(&poll));
peer.write_all(b"x")?;
println!(" peer writes again -> tokens {:?}", tokens(&mut poll));
unsafe { libc::close(dup) };
// D. the fix: deregister first, then drop
let (mut poll, mut s, mut peer) = setup(&l)?;
let dup = unsafe { libc::dup(s.as_raw_fd()) };
poll.registry().deregister(&mut s)?;
drop(s);
peer.write_all(b"x")?;
println!("D. dup fd {dup} open, deregister+drop, peer writes -> tokens {:?}", tokens(&mut poll));
unsafe { libc::close(dup) };
Ok(())
}
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,--release,跑 3 次,除了 fdinfo 里的 ino 以外逐字相同,其中一次:
os=linux
A. socket fd 5 has FD_CLOEXEC: true
Command child alive, dropped, peer writes -> tokens []
B. forked child alive, dropped, peer writes -> tokens [1]
child exited, peer writes again -> tokens []
C. dup fd 11 open, dropped, peer writes -> tokens [1]
interest list: ["tfd: 9 events: 80002019 data: 1 pos:0 ino:c16 sdev:7"]
peer writes again -> tokens [1]
D. dup fd 13 open, deregister+drop, peer writes -> tokens []
M2 Max、macOS、rustc 1.99.0,--release,跑 3 次,逐字相同:
os=macos
A. socket fd 5 has FD_CLOEXEC: true
Command child alive, dropped, peer writes -> tokens []
B. forked child alive, dropped, peer writes -> tokens []
child exited, peer writes again -> tokens []
C. dup fd 11 open, dropped, peer writes -> tokens []
interest list: n/a
peer writes again -> tokens []
D. dup fd 13 open, deregister+drop, peer writes -> tokens []
逐项看 Linux:
- A:
Command起的子进程不算。 mio 的 socket 一出生就带FD_CLOEXEC(accept4(..., SOCK_CLOEXEC|SOCK_NONBLOCK),见 TCP),子进程exec时内核把它关掉,父进程 drop 之后就没有别的 fd 了。std 自己的 socket、File也都带CLOEXEC。 - B:
fork不exec就算。 子进程活着的这 500 毫秒里,父进程已经 drop 的 socket 照样给出Token(1);子进程一退出,最后一个 fd 关闭,事件就没了。预先fork出工作进程、守护进程化这类代码会走到这里。 - C:
dup之后,旧项一直在。 对端每写一次就来一次Token(1)(边沿触发,每次新数据都是一次新的变化)。fdinfo 里那一行就是证据:tfd: 9是注册时的 fd 号,它已经关了,dup 出来的是 11;events: 80002019是EPOLLET|EPOLLRDHUP|EPOLLHUP|EPOLLERR|EPOLLIN,data: 1是 token。 - D:先
deregister再 drop,dup 还开着也没有事件。deregister时 fd 9 还开着,EPOLL_CTL_DEL按(fd 9, 打开的文件描述)找得到那一项。
macOS 四种情况都是 []:kqueue 的 knote 跟着 fd 号走,close(9) 就把挂在 9 上的过滤器删了,不管 dup 出来的 11 还开着。
为什么
mio 的 socket 类型没有 Drop 实现。在 epoll 和 kqueue 上,IoSource 里的 IoSourceState 是无状态的(Source 与 IoSource),drop 就是里面的 std 类型关 fd,不碰 selector。为什么不在 drop 时自动 deregister,event::Source 的文档写着:
/// # Dropping `event::Source`s
///
/// All `event::Source`s, unless otherwise specified, need to be [deregistered]
/// before being dropped for them to not leak resources. This goes against the
/// normal drop behaviour of types in Rust which cleanup after themselves, e.g.
/// a `File` will close itself. However since deregistering needs access to
/// [`Registry`] this cannot be done while being dropped.
也就是说,「drop 前 deregister」是文档里的要求。不 deregister 而能干净收场,靠的是内核在最后一个 fd 关闭时替你清理;在 epoll 上「最后一个」指的是所有指向同一个打开的文件描述的 fd,包括别的进程里的。
「unless otherwise specified」的例外在 poll(2) 后备(和 event ports)上:那里的关注集合在用户态,IoSourceState 带着一个 InternalState,它的 Drop 自己调一次 selector.deregister(fd)。IoSource 的字段顺序是 state 在 inner 前面,所以这一步发生在 fd 关闭之前。用 RUSTFLAGS="--cfg mio_unsupported_force_poll_poll" 在 Linux 上重新编译同一个程序,3 次运行 A 到 D 全是 tokens []。
修法里的那一步 deregister,到 epoll 上就是 Selector::deregister 的一次 epoll_ctl(EPOLL_CTL_DEL, fd);kqueue 上是 deregister 对读写两个过滤器各一次 EV_DELETE。它必须在 fd 关闭之前做:原来的 fd 号关掉之后,EPOLL_CTL_DEL 返回 EBADF(tokio #8540 的说明),换成 dup 的 fd 号去删是 ENOENT(Poll 与 Registry 的实验),那一项都还在。
mio 0.6 的 Poll::deregister 文档里有一句:「Evented handles are automatically deregistered when they are dropped. It is common to never need to explicitly call deregister.」(到 0.6.23 都在。)issue #361 用 try_clone 举了反例,标题就是「epoll: after try_clone(), a stream can receive notifications even after it is dropped.」issue #753「Remove Guarantee to Deregister on Drop」提议去掉这条保证,讨论里有人指出:「if the FD is duped, epoll only deregisters once all the dups have been dropped」。0.7 重写时,PR #992(「Docs & cleanup」)加上了上面那段「need to be deregistered before being dropped」,PR #1014(把 Evented 改名为 event::Source)删掉了「automatically deregistered」那句,两者都在 0.7.0 发布。#753 随后关闭,关闭说明是「On master event::Source (previously Evented) mentions that the source need to be de-registered before dropping.」
怎么修
- drop 前先
deregister。 把 socket 从连接表里移除的那个函数里,先registry.deregister(&mut stream)?,再让它离开作用域。这是上面 D 的写法,两个平台、有没有 dup 都成立。 - 不要在
deregister之前关 fd。into_raw_fd之后自己close,或者把 fd 交给OwnedFd先 drop,都会让deregister失去对象。 - 有
fork的程序,fork 之后立刻exec,或者让子进程关掉不需要的 fd;用Command起子进程不用操心,mio 和 std 的 fd 都带CLOEXEC。 - token 别直接复用。 用
Slab下标当 token 时,一个连接关掉后它的下标很快会分给下一个连接;如果旧连接漏了deregister又有 dup 活着,旧事件会落到新连接头上。先deregister再释放下标。
tokio
tokio 的 socket 都包在 PollEvented 里,它的 Drop 先 deregister,fd 随后随值一起关闭:
impl<E: Source> Drop for PollEvented<E> {
fn drop(&mut self) {
if let Some(mut io) = self.io.take() {
// Ignore errors
let _ = self.registration.deregister(&mut io);
}
}
}
into_std 走 into_inner,同样先 deregister 再把 std 类型交出来。所以 tokio 的 socket 即使有 dup 的副本、或者 fork 出的子进程还拿着它,drop 之后旧 token 也不会回来:这就是上面的 D。
例外是 AsyncFd:fd 是调用方给的,调用方可以先关掉原来的 fd 号,这时 deregister 失败,而 dup 的副本让 epoll 那一项还活着,token 又是 ScheduledIo 的地址。deregister_source 上方的注释写的就是这个场景,1.51.5 的 #8540 让它在 deregister 失败时宁可泄漏也不释放;1.51.6 的 #8587(也进了 1.53.3 和 1.54.0)把 AsyncFd::new 标成弃用,换成 unsafe 的 AsyncFd::register,由调用方保证 fd 不提前关。来龙去脉见 tokio 卷反应器的「注销:先交给驱动,下一轮再放」。
怎么确认自己没踩到
- Linux 上看 epoll 的集合。
/proc/<pid>/fdinfo/<epoll fd>每一行tfd:是一个集合项,tfd后面是注册时的 fd 号,data是 token。上面 C 的输出里,fd 9 已经关了,它还在列表里,这就是漏了deregister。连接数稳定时,tfd:的行数应该等于活着的连接数(加上Waker)。tfd:里的 fd 号在ls -l /proc/<pid>/fd里查不到,就是这个坑;反过来,查得到也不说明没事,号码可能已经被新的 fd 复用。 - 在 macOS 上测不出来。 上面的程序在 macOS 上四种情况都没有事件;只在开发机上跑测试,这个坑会一直藏到部署到 Linux。
- 测试里造一个 dup。 对每种「关闭连接」的路径,测试里先
libc::dup一份 fd、再走关闭逻辑、再让对端写,断言poll不再返回旧 token。这条测试只在 Linux 上能变红(macOS 上恒绿),CI 里要有 Linux。
小结
- drop 只关 fd。epoll 的集合项要等指向同一个打开的文件描述的所有 fd 都关掉才消失,期间旧 token 继续触发,而且无法再用
deregister删掉;kqueue 关 fd 即删。 - 实测 Linux:
dup和未exec的fork子进程都会让旧 token 回来,Command起的子进程不会(mio 的 socket 带FD_CLOEXEC);macOS 都不会。 event::Source的文档要求 drop 前deregister;0.6 曾承诺 drop 即注销,0.7.0(#992、#1014)撤掉了这条承诺。- 修法:先
deregister,再 drop;deregister必须在 fd 关闭之前。tokio 的PollEvented就是这么做的,AsyncFd需要调用方保证 fd 不提前关。 - 检查:Linux 上
/proc/<pid>/fdinfo/<epoll fd>的tfd:行列出全部集合项。