可读事件读到 0 字节,或者事件里只有错误位
主线位置:事件主线的末段。
poll返回之后,用户按Event的is_*决定去读还是去写,读写的结果才是事实。这一页的坑都出在「is_*说的」和「读写拿到的」对不上的地方。
flowchart TB
ev["poll 返回一个事件"] --> rd{"可读、关闭或出错"}
rd -->|是| readnode["读到 WouldBlock 为止"]
readnode --> n{结果}
n -->|"Ok(0)"| eof["对端关写,注销后关闭"]
n -->|"Err(e)"| err["出错,e 就是原因"]
n -->|WouldBlock| back["回去 poll"]
rd -->|否| wr{"可写或写关闭"}
wr -->|是| writenode["去写,或 take_error"]
每个 is_* 在 epoll 和 kqueue 上查哪些位、九种连接状态下的实测标志位,在 Token、Interest 与 Event;epoll 上为什么关注可读才注册 EPOLLRDHUP,在 epoll;UDP 连到关闭端口的 strace,在 UDP 与 Unix 域套接字。这一页把它们按「程序里会写错的地方」重新排一遍,并补上那几页没跑过的情况:读到 0 之后再读、先 recv 后 take_error、未连接的 UDP、tokio 的处理。
现象
三种,都不报错,只是程序的判断和实际状态对不上:
- 可读,读到 0。
is_readable()为真,read返回Ok(0)。把「读到的字节数」直接加进缓冲的代码什么也不会发生;只在WouldBlock时才停的读循环会一直转下去,因为之后每次read都是Ok(0)。 - 事件里只有错误位。
connect过的 UDP socket 发到一个没人监听的端口,Linux 上来的事件只有EPOLLERR,is_readable()为假。只在is_readable()时才去recv的代码永远不会知道对端不在,这个 socket 就一直挂着。macOS 上同样的代码没问题。 - 只关注写,对端关了写。 Linux 上来一个只有
EPOLLOUT的事件,像是「又可写了」;macOS 上什么都不来。两边都看不出对端已经关了写。
复现
src/bin/read_closed.rs,四种情况各用一个新的 Poll,打印原始标志位(raw)和为真的 is_*(bools),两个辅助函数和 Token、Interest 与 Event 的实验相同:
use std::io::{ErrorKind, Read, Write};
use std::net::Shutdown;
use std::time::Duration;
use exp_mio_pit2::{bools, raw};
use mio::net::{TcpListener, UdpSocket};
use mio::{Events, Interest, Poll, Token};
fn show(poll: &mut Poll, title: &str) {
let mut events = Events::with_capacity(8);
poll.poll(&mut events, Some(Duration::from_millis(200))).unwrap();
println!("{title}: {} event(s)", events.iter().count());
for e in &events {
println!(" {} => {}", raw(e), bools(e));
}
}
fn closed_udp_port() -> std::net::SocketAddr {
std::net::UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap()
}
fn main() -> std::io::Result<()> {
println!("os={}", std::env::consts::OS);
let l = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
// 1. peer sends 2 bytes and then FIN
let mut peer = std::net::TcpStream::connect(l.local_addr()?)?;
std::thread::sleep(Duration::from_millis(20));
let (mut s, _) = l.accept()?;
let mut poll = Poll::new()?;
poll.registry().register(&mut s, Token(1), Interest::READABLE)?;
peer.write_all(b"hi")?;
peer.shutdown(Shutdown::Write)?;
show(&mut poll, "1. peer writes \"hi\", then shutdown(Write)");
// a loop that only stops at WouldBlock, capped at 4 reads
let mut buf = [0u8; 16];
for i in 1..=4 {
match s.read(&mut buf) {
Ok(n) => println!(" read #{i}: Ok({n})"),
Err(e) if e.kind() == ErrorKind::WouldBlock => {
println!(" read #{i}: WouldBlock");
break;
}
Err(e) => println!(" read #{i}: Err({e})"),
}
}
show(&mut poll, " poll again");
// 2. connected UDP socket, peer port closed, READABLE only; recv before take_error
let mut poll = Poll::new()?;
let mut u = UdpSocket::bind("127.0.0.1:0".parse().unwrap())?;
u.connect(closed_udp_port())?;
poll.registry().register(&mut u, Token(2), Interest::READABLE)?;
u.send(b"anyone?")?;
show(&mut poll, "2. connected UDP, send to a closed port");
println!(" recv = {:?}", u.recv(&mut buf).map_err(|e| e.kind()));
println!(" take_error = {:?}", u.take_error()?.map(|e| e.kind()));
println!(" recv = {:?}", u.recv(&mut buf).map_err(|e| e.kind()));
// 3. unconnected UDP socket, send_to a closed port
let mut poll = Poll::new()?;
let mut u = UdpSocket::bind("127.0.0.1:0".parse().unwrap())?;
poll.registry().register(&mut u, Token(3), Interest::READABLE)?;
u.send_to(b"anyone?", closed_udp_port())?;
show(&mut poll, "3. unconnected UDP, send_to a closed port");
println!(" take_error = {:?}", u.take_error()?.map(|e| e.kind()));
// 4. WRITABLE only, peer shutdown(Write)
let peer = std::net::TcpStream::connect(l.local_addr()?)?;
std::thread::sleep(Duration::from_millis(20));
let (mut s, _) = l.accept()?;
let mut poll = Poll::new()?;
poll.registry().register(&mut s, Token(4), Interest::WRITABLE)?;
show(&mut poll, "4. WRITABLE only, initial");
peer.shutdown(Shutdown::Write)?;
show(&mut poll, " peer shutdown(Write)");
Ok(())
}
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,--release,跑 16 次,逐字节相同:
os=linux
1. peer writes "hi", then shutdown(Write): 1 event(s)
events=IN|RDHUP data=0x1 => readable read_closed
read #1: Ok(2)
read #2: Ok(0)
read #3: Ok(0)
read #4: Ok(0)
poll again: 0 event(s)
2. connected UDP, send to a closed port: 1 event(s)
events=ERR data=0x2 => error write_closed
recv = Err(ConnectionRefused)
take_error = None
recv = Err(WouldBlock)
3. unconnected UDP, send_to a closed port: 0 event(s)
take_error = None
4. WRITABLE only, initial: 1 event(s)
events=OUT data=0x4 => writable
peer shutdown(Write): 1 event(s)
events=OUT data=0x4 => writable
M2 Max、macOS、rustc 1.99.0,--release,其中一次:
os=macos
1. peer writes "hi", then shutdown(Write): 1 event(s)
filter=READ flags=EOF|CLEAR fflags=0 data=2 udata=0x1 => readable read_closed
read #1: Ok(2)
read #2: Ok(0)
read #3: Ok(0)
read #4: Ok(0)
poll again: 1 event(s)
filter=READ flags=EOF|CLEAR fflags=0 data=0 udata=0x1 => readable read_closed
2. connected UDP, send to a closed port: 1 event(s)
filter=READ flags=CLEAR fflags=0 data=0 udata=0x2 => readable
recv = Err(ConnectionRefused)
take_error = None
recv = Err(WouldBlock)
3. unconnected UDP, send_to a closed port: 0 event(s)
take_error = None
4. WRITABLE only, initial: 1 event(s)
filter=WRITE flags=CLEAR fflags=0 data=146988 udata=0x4 => writable
peer shutdown(Write): 0 event(s)
macOS 上第 1 种情况不稳定,另外数了 30 次,四次 read 都是 Ok(2)、Ok(0)、Ok(0)、Ok(0),变的是事件:
| 次数 | 第一个事件 | 读完之后再 poll |
|---|---|---|
| 16 | EOF,readable read_closed | 又一个 EOF 事件,data=0 |
| 11 | EOF,readable read_closed | 没有事件 |
| 3 | 没有 EOF,只有 readable | 一个 EOF 事件,data=0 |
第 2 到第 4 种情况在全部 15 次完整运行里逐字相同。
逐条看
1. Ok(0) 才是关闭,is_read_closed 只是提示
对端 shutdown(Write) 之后,两个平台的第一个事件通常都带关闭标记(Linux IN|RDHUP,macOS EV_EOF),read 先拿到剩下的 2 字节,再拿到 Ok(0),之后每次都是 Ok(0):EOF 不是一次性的数据,是 socket 的状态。所以:
- 读循环要把
Ok(0)当成一个终止条件,和WouldBlock并列。只认WouldBlock的循环在这里不会停(上面把它截在 4 次);反过来,读到Ok(0)之前就回去poll,就是没读到 WouldBlock 就回去 poll,程序卡住那个坑。 - macOS 的 30 次里有 3 次,第一个事件没有
EV_EOF,is_read_closed()为假,可紧接着的read #2照样是Ok(0):取事件那一刻 FIN 还没到,读的时候已经到了。is_read_closed的文档说的是「best effort」,只保证「false-positives will not occur」,不保证一定报。拿它决定「要不要去读」可以,拿它决定「连接是不是关了」不行。 - 读到
Ok(0)之后,Linux 上不会再有事件(边沿触发,状态不再变化);macOS 上 30 次里有 16 次在已经报过EV_EOF之后又来了一个EV_EOF事件。处理关闭的代码要经得起同一个 token 再来一次,比如关闭时先deregister,并且在连接表里查不到这个 token 时直接忽略。
is_read_closed 在两个平台上查的位(epoll、kqueue):
pub fn is_read_closed(event: &Event) -> bool {
// Both halves of the socket have closed
event.events as libc::c_int & libc::EPOLLHUP != 0
// Socket has received FIN or called shutdown(SHUT_RD)
|| (event.events as libc::c_int & libc::EPOLLIN != 0
&& event.events as libc::c_int & libc::EPOLLRDHUP != 0)
}
pub fn is_read_closed(event: &Event) -> bool {
event.0.filter == libc::EVFILT_READ && event.0.flags & libc::EV_EOF != 0
}
问: epoll 那段写的是 event.events as libc::c_int & libc::EPOLLHUP != 0,没有括号。在 C 里这是 events & (EPOLLHUP != 0),这不是个 bug 吗?
答: 在 Rust 里不是。Rust 的 & 优先级高于 == / !=(和 C 相反),as 又高于 &,所以这一行就是 ((events as c_int) & EPOLLHUP) != 0。上面 Linux 的输出里 IN|RDHUP 判成了 read_closed,说明它按预期工作。2026 年有人提过一个「修复运算符优先级」的 PR #1943,给每个按位与加上括号,被以「This is not an issue」关闭,没有合并。
2. 只有错误位的事件:先读一次
connect 过的 UDP socket 发出的数据报被对端主机以 ICMP 端口不可达拒绝,内核在 socket 上记下 ECONNREFUSED。Linux 把它报成一个只有 EPOLLERR 的事件;mio 的 is_readable 只看 EPOLLIN | EPOLLPRI,所以为假;is_error 为真;is_write_closed 走「恰好只有 EPOLLERR」那一支,也为真。macOS 给的是一个 data=0、不带 EV_EOF 的 EVFILT_READ,只有 is_readable 为真,is_error 为假。注册时只要了 READABLE,EPOLLERR 照样来,因为内核总是报它(epoll)。
两个平台给的位不一样,错误本身是一样的,而且只能取一次:
- 先
recv:两边都返回Err(ConnectionRefused),这一次读把挂起的错误交出来并清掉了,之后的take_error是None,再recv是WouldBlock。 - 先
take_error(UDP 与 Unix 域套接字 的实验):两边都是Some(ConnectionRefused),之后的recv是WouldBlock。
所以对一个只有错误位的事件,「去读一次」和「take_error」都能拿到原因,二选一即可;什么都不做才是坑。第 3 种情况说明这只发生在 connect 过的 socket 上:没有 connect、用 send_to 发出去的,两个平台都没有事件,take_error 也是 None:在这个实验里,未连接的 socket 拿不到这个错误。
上面 Linux 的 UDP 事件里,write_closed 为真而 read_closed 为假,这个不对称是有意保留的。is_write_closed 的「恰好只有 EPOLLERR」一支来自 PR #1350(「Report EPOLLERR as is_write_closed」,0.7.1),提交说明引了 epoll_ctl(2) 手册对 EPOLLERR 的描述(「This event is also reported for the write end of a pipe when the read end has been closed.」),又说管道读端关闭时实际见过多种组合,「And now we've also seen just EPOLLERR, without EPOLLOUT.」2020 年 12 月有人提了对称的 PR #1415「Report EPOLLERR as is_read_closed」,维护者的回复是「This is already reported as Event::is_error, so I'm not sure it should be included in is_read_closed」,另一位补充「Checking for it on is_writed_closed makes sense because that is mentioned in epoll_ctl docs in the EPOLLERR section」,并请作者给出一个确实需要它的测试用例。没有后续,PR 当月被关闭,没有合并。所以到 1.2.4,单独的 EPOLLERR 只能从 is_error() 和 is_write_closed() 看出来,只看读方向的代码看不到它。
3. 只关注写:对端关写看不出来
第 4 种情况是 epoll 一页的动脑筋,这次重跑,结果相同:只用 WRITABLE 注册时,mio 在 epoll 上不注册 EPOLLIN、EPOLLRDHUP,对端的 FIN 让 socket 状态变了一下,边沿触发报出当前的全部就绪位,只有 OUT;kqueue 只装了 EVFILT_WRITE,对端关的是它的写方向、我方的读方向,写过滤器没有变化,就不报。两边都没有 read_closed。要知道对端关没关,必须关注 READABLE,并按上一节读到 Ok(0)。
is_write_closed 表示本端不能再写了(本端 shutdown(Write)、对端 RST、管道读端关了),不表示对端关了它的写方向;而且它在 epoll 上会漏报本端 shutdown(Write)(Token、Interest 与 Event 的第 4 项)。
各平台一览
Token、Interest 与 Event 的 flags.rs 这次在两个平台上各重跑了 3 次,输出和那一页逐字相同。和本页三种情况合在一起,只看决定「要不要去读、是不是出错」的三个方法:
| 场景 | Linux:readable / read_closed / error | macOS:readable / read_closed / error |
|---|---|---|
| 对端写数据 | 是 / 否 / 否 | 是 / 否 / 否 |
对端 shutdown(Write) | 是 / 是 / 否 | 是 / 是(偶尔第一个事件没有) / 否 |
| 对端 RST、连接被拒 | 是 / 是 / 是 | 读写各一个事件,都带 error(fflags 是错误码) |
| 管道写端关闭(读端关注可读) | 否 / 是 / 否 | 是 / 是 / 否 |
connect 过的 UDP,对端端口关闭 | 否 / 否 / 是 | 是 / 否 / 否 |
只关注写,对端 shutdown(Write) | 一个只有 writable 的事件 | 没有事件 |
加粗的三格是同一个程序在两个平台上走不同分支的地方。没有哪一个 is_* 能单独回答「该不该去读」。
怎么修
- 把「去读一次」的条件放宽。
is_readable() || is_read_closed() || is_error()任一为真,就去读到WouldBlock为止。读会把三种情况都变成确定的结果:数据、Ok(0)、Err(e)。 - 读循环有三个出口。
Ok(0):对端关了写,处理完剩下的数据后deregister再关闭(或者shutdown自己的写方向);Err(e)(WouldBlock除外):e就是原因,关闭;WouldBlock:回去poll。不要在Ok(0)之后继续读。 - UDP:
connect过的 socket,出错事件也要处理。 要么recv一次拿到Err,要么take_error。未连接的 socket 收不到这种错误,要靠自己的超时判断对端不在。 - 想知道对端关了,就关注
READABLE。 只关注WRITABLE的 socket,在 Linux 上只会看到一个看不出原因的writable,在 macOS 上什么也看不到。 - 关闭处理要幂等。 macOS 上读到
Ok(0)之后还可能再来一个EV_EOF事件。
tokio
tokio 把这些都收进了就绪状态。Ready::from_mio 把 is_read_closed、is_write_closed、is_error 分别映射成 READ_CLOSED、WRITE_CLOSED、ERROR,Ready::from_interest 让关注 READABLE 的等待者把 READ_CLOSED 也算作就绪,所以 tokio 的读在对端关闭时一定会被叫醒(tokio 卷 ScheduledIo 与就绪状态)。poll_read 里清就绪位的条件是 0 < n && n < len:读到 0 不清,之后的读立刻再返回 0,AsyncRead 把它交给上层当 EOF。
UDP 那一条,tokio 直到 1.51.1 才补上。UdpSocket::recv 现在等的是 Interest::READABLE | Interest::ERROR,来自 PR #8001(「Surface errors from SO_ERROR on recv for UDP sockets on Linux」),说明里写着改成这样「ensures error-only readiness wakes the operation」。在这之前只等 READABLE。tokio 注册 UDP socket 时读写都要,Linux 上这次事件是 EPOLLOUT|EPOLLERR(见下面的 strace),映射成 WRITABLE | WRITE_CLOSED | ERROR,和读者等的 READABLE | READ_CLOSED 没有交集,读者不会被叫醒。src/bin/tokio_udp.rs:
tokio = { version = "=1.54.1", features = ["rt", "net", "time", "macros"] }
use std::time::Duration;
use tokio::net::UdpSocket;
// connected UDP socket, peer port closed: does recv().await return?
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::io::Result<()> {
println!("os={}", std::env::consts::OS);
let closed = std::net::UdpSocket::bind("127.0.0.1:0")?.local_addr()?;
let u = UdpSocket::bind("127.0.0.1:0").await?;
u.connect(closed).await?;
u.send(b"anyone?").await?;
let mut buf = [0u8; 64];
let r = tokio::time::timeout(Duration::from_secs(1), u.recv(&mut buf)).await;
println!("recv().await within 1s: {:?}", r.map(|r| r.map_err(|e| e.kind())));
Ok(())
}
tokio 1.54.1,Linux 和 macOS 各 3 次,相同:
recv().await within 1s: Ok(Err(ConnectionRefused))
同一个程序换成 tokio = "=1.51.0"(另一个实验 crate,mio 同样是 1.2.4),macOS 3 次仍是 Ok(Err(ConnectionRefused)),Linux 3 次都是:
os=linux
recv().await within 1s: Err(Elapsed(()))
recv().await 等满 1 秒超时,错误一直挂在 socket 上没人取。本站锁的 tokio 是 1.52.3,已经包含这个修复。tokio 1.54.1 的 Linux strace 里,tokio 以 READABLE | WRITABLE 注册 UDP socket(注册走的 fd 5 是驱动 try_clone 出来的 Registry,和 fd 3 是同一个 epoll 实例),那次事件是 EPOLLOUT|EPOLLERR,接着一次 recvfrom 返回 ECONNREFUSED:
epoll_ctl(5, EPOLL_CTL_ADD, 6, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffffb6338b00}) = 0
connect(6, {sa_family=AF_INET, sin_port=htons(46355), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
epoll_pwait(3, [{events=EPOLLOUT, data=0xffffb6338b00}], 1024, -1, NULL, 8) = 1
sendto(6, "anyone?", 7, MSG_NOSIGNAL, NULL, 0) = 7
epoll_pwait(3, [{events=EPOLLOUT|EPOLLERR, data=0xffffb6338b00}, {events=EPOLLIN, data=0}], 1024, 959, NULL, 8) = 2
recvfrom(6, 0xfffff2651eb8, 64, 0, NULL, NULL) = -1 ECONNREFUSED (Connection refused)
(strace -f -e trace=epoll_ctl,epoll_pwait,recvfrom,sendto,getsockopt,connect 输出的第 3 到 8 行;data=0 那一项是 tokio 驱动的 TOKEN_WAKEUP。)
怎么确认自己没踩到
- 在 Linux 上跑这三种关闭。 对端
shutdown(Write)、对端 RST、connect过的 UDP 发到关闭端口,各写一个测试,断言连接在有限时间内被识别为关闭或出错。上面加粗的格子说明,只在 macOS 上测会漏掉 UDP 和管道两种。 - 数读循环的次数。 对端关闭后,一次事件对应的读循环应该在
Ok(0)处停下,读的次数是「数据块数 + 1」;读的次数随时间增长,就是Ok(0)没被当成出口。 - 搜
if event.is_readable()。 它单独出现、旁边没有is_read_closed()和is_error()的地方,就是 Linux 上会漏掉管道HUP和 UDPEPOLLERR的地方。
小结
read返回Ok(0)才是对端关了写,而且之后一直是Ok(0);读循环要把它当出口。is_read_closed是尽力而为:macOS 上 30 次里有 3 次第一个事件没带EV_EOF,另有 16 次读到 0 之后又来一个EV_EOF事件。connect过的 UDP 发到关闭端口:Linux 只有EPOLLERR(is_readable假,is_error、is_write_closed真),macOS 是一个普通的可读事件。错误只能取一次,recv或take_error都行;未连接的 UDP 两边都没有事件。- 只关注
WRITABLE,对端关写:Linux 一个只有OUT的事件,macOS 没有事件。 - 修法:
is_readable || is_read_closed || is_error都去读,读到WouldBlock、Ok(0)或Err为止。tokio 用READ_CLOSED叫醒读者、读到 0 不清就绪;UDP 的recv从 1.51.1(#8001)起也等ERROR,1.51.0 在 Linux 上会一直等。