Rust in Everything
基于 mio 1.2.4

没读到 WouldBlock 就回去 poll,程序卡住

主线位置:事件主线的最后一步。poll 返回事件之后,用户要对 source 做非阻塞 I/O 直到 WouldBlock,才能回到 poll。少了这一步,下一次 poll 就等不到这个 fd 的事件。

flowchart TB
  ev["poll 返回可读事件"] --> rd["read 一次"]
  rd --> q{"读到 WouldBlock 了吗"}
  q -->|是| back["回到 poll,下次有新数据会再报"]
  q -->|"否,直接回 poll"| stuck["内核已报过这次变化"]
  stuck --> hang["poll 一直等,数据留在内核里"]

这个坑没有报错,只有现象:程序停在 poll 里,CPU 是 0,对端在等回复。mio 只提供边沿触发,为什么这样设计、0.7 之前怎么选触发方式,见 mio 解决什么问题;这一页只看踩到时是什么样、怎么修、怎么查。

本页实验在两个平台上运行:M2 Max、macOS、rustc 1.99.0;Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。都是 --release,依赖:

mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }

复现

src/bin/not_draining.rs 有两个场景,命令行参数选择行为:once 是每个事件只做一次 read / accept,drain 是一直做到 WouldBlock。

  • 流:对端一次写 8 字节,服务端用 4 字节的缓冲区读。
  • 监听 socket:第一次 poll 之前,两个客户端已经连上。

为了让卡住的程序能结束,每次 poll 都带 1 秒超时;超时之后再直接读(accept)一次,看数据(连接)是不是一直在那里。

use std::io::{self, ErrorKind, Read, Write};
use std::time::Duration;

use mio::net::{TcpListener, TcpStream};
use mio::{Events, Interest, Poll, Token};

// one poll with a 1s timeout: the guard that keeps a hung loop from hanging the program
fn wait(poll: &mut Poll, events: &mut Events) -> io::Result<usize> {
  poll.poll(events, Some(Duration::from_secs(1)))?;
  Ok(events.iter().count())
}

fn stream_case(drain: bool) -> io::Result<()> {
  println!("== stream: peer sends 8 bytes, server reads with a 4-byte buffer");
  let std_listener = std::net::TcpListener::bind("127.0.0.1:0")?;
  let mut peer = std::net::TcpStream::connect(std_listener.local_addr()?)?;
  let (s, _) = std_listener.accept()?;
  s.set_nonblocking(true)?;
  let mut stream = TcpStream::from_std(s);

  let mut poll = Poll::new()?;
  let mut events = Events::with_capacity(8);
  poll.registry().register(&mut stream, Token(1), Interest::READABLE)?;
  peer.write_all(b"abcdefgh")?;

  let mut got = 0;
  let mut buf = [0u8; 4];
  while got < 8 {
    let n = wait(&mut poll, &mut events)?;
    if n == 0 {
      println!("poll: 0 event(s) after 1s, server has {got}/8 bytes");
      let n = stream.read(&mut buf)?;
      println!("read anyway: {n} byte(s) {:?}", String::from_utf8_lossy(&buf[..n]));
      return Ok(());
    }
    println!("poll: {n} event(s)");
    loop {
      match stream.read(&mut buf) {
        Ok(0) => break,
        Ok(n) => {
          got += n;
          println!("  read {n} byte(s) {:?}", String::from_utf8_lossy(&buf[..n]));
        }
        Err(e) if e.kind() == ErrorKind::WouldBlock => {
          println!("  read: WouldBlock");
          break;
        }
        Err(e) => return Err(e),
      }
      if !drain {
        break;
      }
    }
  }
  println!("server has {got}/8 bytes");
  Ok(())
}

fn listener_case(drain: bool) -> io::Result<()> {
  println!("== listener: two clients connect before the first poll");
  let mut listener = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
  let addr = listener.local_addr()?;
  let mut poll = Poll::new()?;
  let mut events = Events::with_capacity(8);
  poll.registry().register(&mut listener, Token(0), Interest::READABLE)?;
  let _c1 = std::net::TcpStream::connect(addr)?;
  let _c2 = std::net::TcpStream::connect(addr)?;
  // let both handshakes finish on the server side before the first poll
  std::thread::sleep(Duration::from_millis(100));

  let mut accepted = Vec::new();
  while accepted.len() < 2 {
    let n = wait(&mut poll, &mut events)?;
    if n == 0 {
      println!("poll: 0 event(s) after 1s, accepted {}/2", accepted.len());
      let r = listener.accept().map(|_| "a connection").map_err(|e| e.kind());
      println!("accept anyway: {r:?}");
      return Ok(());
    }
    println!("poll: {n} event(s)");
    loop {
      match listener.accept() {
        Ok((s, _)) => {
          println!("  accept: Ok");
          accepted.push(s);
        }
        Err(e) if e.kind() == ErrorKind::WouldBlock => {
          println!("  accept: WouldBlock");
          break;
        }
        Err(e) => return Err(e),
      }
      if !drain {
        break;
      }
    }
  }
  println!("accepted {}/2", accepted.len());
  Ok(())
}

fn main() -> io::Result<()> {
  let drain = std::env::args().nth(1).as_deref() == Some("drain");
  println!("os={} mode={}", std::env::consts::OS, if drain { "drain" } else { "once" });
  stream_case(drain)?;
  listener_case(drain)?;
  Ok(())
}

once 模式,macOS,跑 5 次,输出相同:

os=macos mode=once
== stream: peer sends 8 bytes, server reads with a 4-byte buffer
poll: 1 event(s)
  read 4 byte(s) "abcd"
poll: 0 event(s) after 1s, server has 4/8 bytes
read anyway: 4 byte(s) "efgh"
== listener: two clients connect before the first poll
poll: 1 event(s)
  accept: Ok
poll: 0 event(s) after 1s, accepted 1/2
accept anyway: Ok("a connection")

Linux,跑 3 次,输出相同:

os=linux mode=once
== stream: peer sends 8 bytes, server reads with a 4-byte buffer
poll: 1 event(s)
  read 4 byte(s) "abcd"
poll: 0 event(s) after 1s, server has 4/8 bytes
read anyway: 4 byte(s) "efgh"
== listener: two clients connect before the first poll
poll: 1 event(s)
  accept: Ok
poll: 0 event(s) after 1s, accepted 1/2
accept anyway: Ok("a connection")

两个平台只有第一行不同。

  • 流:第一个事件读走 abcd,回到 poll,等满 1 秒也没有事件,服务端停在 4/8。超时之后直接 read,efgh 一读就有:数据一直在内核的接收缓冲里,只是不会再有事件告诉你。
  • 监听 socket:一个事件,accept 一个,第二个连接在 accept 队列里等着,poll 却再也不报。真实的服务器里,这就是「偶尔有一个客户端连上了却一直没人理」,直到下一个客户端连进来,产生新的一次变化,排在前面的那个才被顺带 accept 走。

drain 模式,两个平台的输出也只差第一行(macOS 5 次、Linux 3 次,各自相同)。Linux:

os=linux mode=drain
== stream: peer sends 8 bytes, server reads with a 4-byte buffer
poll: 1 event(s)
  read 4 byte(s) "abcd"
  read 4 byte(s) "efgh"
  read: WouldBlock
server has 8/8 bytes
== listener: two clients connect before the first poll
poll: 1 event(s)
  accept: Ok
  accept: Ok
  accept: WouldBlock
accepted 2/2

一个事件里读两次、accept 两次,第三次得到 WouldBlock,事情就做完了,不需要第二个事件。

Linux 上 once 模式的系统调用(strace -o ... -e trace=epoll_ctl,epoll_pwait,recvfrom,accept4,write,3 次除端口外相同):

write(1, "os=linux mode=once\n", 19)    = 19
write(1, "== stream: peer sends 8 bytes, s"..., 65) = 65
accept4(3, {sa_family=AF_INET, sin_port=htons(53422), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC) = 5
epoll_ctl(6, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_pwait(6, [{events=EPOLLIN, data=0x1}], 8, 1000, NULL, 8) = 1
write(1, "poll: 1 event(s)\n", 17)      = 17
recvfrom(5, "abcd", 4, 0, NULL, NULL)   = 4
write(1, "  read 4 byte(s) \"abcd\"\n", 24) = 24
epoll_pwait(6, [], 8, 1000, NULL, 8)    = 0
write(1, "poll: 0 event(s) after 1s, serve"..., 48) = 48
recvfrom(5, "efgh", 4, 0, NULL, NULL)   = 4
write(1, "read anyway: 4 byte(s) \"efgh\"\n", 30) = 30
write(1, "== listener: two clients connect"..., 55) = 55
epoll_ctl(4, EPOLL_CTL_ADD, 3, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
epoll_pwait(4, [{events=EPOLLIN, data=0}], 8, 1000, NULL, 8) = 1
write(1, "poll: 1 event(s)\n", 17)      = 17
accept4(3, {sa_family=AF_INET, sin_port=htons(56786), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 7
write(1, "  accept: Ok\n", 13)          = 13
epoll_pwait(4, [], 8, 1000, NULL, 8)    = 0
write(1, "poll: 0 event(s) after 1s, accep"..., 40) = 40
accept4(3, {sa_family=AF_INET, sin_port=htons(56800), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 8
write(1, "accept anyway: Ok(\"a connection\""..., 34) = 34
+++ exited with 0 +++

两处 epoll_pwait(..., 1000, ...) = 0(第 9、19 行)就是卡住的地方:注册的标志里有 EPOLLET(第 4、14 行),前一次事件之后 fd 上没有新的变化,内核没有东西可报。紧跟在超时后面的 recvfrom 和 accept4(第 11、21 行)都立刻成功。读干净的程序在回到 epoll_pwait 之前,最后一个系统调用应当是一个 -1 EAGAIN;这里没有。

为什么

注册时就决定了是边沿触发。Linux 上 interests_to_epoll 从 let mut kind = EPOLLET; 起步;macOS 上 register 给每个过滤器都带 EV_CLEAR。两者都只在 fd 的状态发生变化时报告:接收缓冲从空变成非空,或者有新数据到达;accept 队列有新连接进来。上面两个场景里,8 字节和两个连接都在第一次 poll 之前到齐,只有一次变化,所以只有一个事件。

mio 在 epoll 和 kqueue 上不记任何就绪状态,do_io 只是调用闭包:

    pub(crate) fn do_io<T, F, R>(&self, f: F, io: &T) -> io::Result<R>
    where
        F: FnOnce(&T) -> io::Result<R>,
    {
        // We don't hold state, so we can just call the function and
        // return.
        f(io)
    }

所以「还剩多少没读」只有内核知道,mio 不会替你补一个事件。用户的义务写在 Poll 文档的「Draining readiness」一节:「Once a readiness event is received, the corresponding operation must be performed repeatedly until it returns [WouldBlock]. Unless this is done, there is no guarantee that another readiness event will be delivered, even if further data is received for the event source.」

最后半句「even if further data is received」是这个坑难查的原因。在 epoll 和 kqueue 上,对端再写一次就是一次新变化,事件会再来,没读干净的程序被这次事件救了回来;协议是一问一答、对端在等你回复时,就没有下一次写,程序就卡住。在 poll(2) 后备上连这一次也救不回来:报出事件后关注位被摘掉,只有 do_io 看到 WouldBlock 才放回去,对端再写多少都不报,见 poll(2) 后备 的 partial_read 实验。把本页的程序用 --cfg mio_unsupported_force_poll_poll 编译、在 Linux 上跑,once 和 drain 两种模式的输出与 epoll 逐字相同(各 3 次):这两个场景里对端都不再写,三种 selector 都卡住。

📝NOTE
没有蠢问题

问: 我的缓冲区开到 64 KiB,一次 read 返回的字节数比缓冲区小,说明内核里已经没数据了。还要再读一次、专门拿一个 WouldBlock 吗?

答: 在 epoll 和 kqueue 上,「读不满」确实说明这次读的时候接收缓冲已经空了,tokio 就利用了这一点省掉一次系统调用(见下文)。但 mio 的文档只承诺「读到 WouldBlock」这一条,而且有两个平台靠 WouldBlock 本身干活:poll(2) 后备和 Windows 的 do_io 都是在看到 WouldBlock 时才重新武装(Windows 是重新提交 AFD poll,见 mio 解决什么问题)。tokio 的这个优化也用 cfg 排除了这两种情况。要写可移植的代码,就读到 WouldBlock。另外,监听 socket 没有「读不满」这种信号,只能 accept 到 WouldBlock。

修法

每个事件都把对应的操作做到 WouldBlock:

  • 读:循环 read,直到 WouldBlock(回到 poll)或 Ok(0)(对端关闭写方向,见 可读却读到 0 字节)。
  • accept:循环到 WouldBlock。上面 drain 模式就是这样。
  • 写:有待发数据时写到全部写完或 WouldBlock;WouldBlock 时把剩下的存起来,等下一个可写事件。
  • 一个事件里所有方向都要处理。 同一个 Event 可能既可读又可写;在 kqueue 上同一个 token 还会分成两个事件,见 同一个 token 一次 poll 来两个事件。

完整的写法(accept 循环、read_all 到 WouldBlock、pending 写缓冲)在 一个事件循环 的 echo 服务器里。

如果为了公平不想在一个连接上一直读(对端写得比你读得快,一个连接就能霸占整个循环),可以读到一定量就停,但必须自己记住「这个连接还没读干净」,下一轮先处理它,并且这一轮的 poll 用 Some(Duration::ZERO) 做超时,不能阻塞。这正是 tokio 用就绪位做的事。

tokio 怎么做

tokio 把就绪状态存在每个 ScheduledIo 的一个原子里,规则是:驱动收到事件就置位,只有任务亲眼看到 WouldBlock 才清位(ScheduledIo 与就绪状态)。置位和清位之间可能又来了新事件,所以清位带着 tick,tick 变了就不清,那一页讲了它为什么需要 tick、1.34.0 之前的漏洞。

PollEvented::poll_read 是一个循环:先等就绪位,再 read;WouldBlock 就 clear_readiness(evt),回到开头再问一次,这次没有就绪位才存 waker、返回 Pending(从 TcpStream::read 到挂起)。读到数据时不清位,所以一次 read().await 只读一块也没关系:就绪位还在,下一次 read().await 直接去读,不等新事件。上面那个坑在 tokio 的 TcpStream 上不存在。唯一的例外是读不满时清位的优化,它的 cfg 只列了 epoll 和 kqueue 平台、并且排除了 mio_unsupported_force_poll_poll,上面「没有蠢问题」说的就是它。

AsyncFd 把清位交给了调用方:readable().await 拿到 guard 后,什么时候 clear_ready() 由你决定。没读到 WouldBlock 就 clear_ready(),就是本页的坑搬到了 tokio 里,ScheduledIo 与就绪状态 末尾的实验跑过:那 1 字节还在 socket 里,readable() 一直等。用 AsyncFd 时,只在 WouldBlock 之后清位,或者直接用 try_io,它在闭包返回 WouldBlock 时替你清。

怎么确认自己没踩

  • 看 strace。 每个 fd 在两次 epoll_pwait 之间的最后一个 recvfrom / accept4 / sendto,应当是 -1 EAGAIN。像上面第 7、17 行那样读了一次就回到 epoll_pwait 的,就是没读干净。
  • 用小缓冲区测试。 把读缓冲调到 1 到 4 字节、请求调到比它大,一次 read 就一定读不完。读到 WouldBlock 的循环照样能跑通,只读一次的立刻卡住。
  • 测试里让对端不再写。 一问一答、对端等你回复,是最容易暴露这个坑的形状;对端持续写的测试会被新事件「救」回来。
  • 用 --cfg mio_unsupported_force_poll_poll 再跑一遍测试(Linux 上可用)。它比 epoll 严:没读到 WouldBlock 时对端再写也不报,见 poll(2) 后备。

小结

  • 边沿触发只在状态变化时报一次。一次事件只 read 一块、只 accept 一个,剩下的数据或连接就等不到事件,Linux 和 macOS 都卡在 poll 里,数据一直在内核里。
  • 对端再写会产生新事件,把程序救回来,所以这个坑在一问一答的协议里最容易出现;poll(2) 后备上对端再写也不行。
  • 修法:每个事件都把读、写、accept 做到 WouldBlock;要公平就自己记住未读完的连接,poll 不阻塞。
  • tokio 的就绪位只在看到 WouldBlock 后才清,TcpStream 上不会踩;AsyncFd 的 clear_ready() 由调用方决定,没读到 WouldBlock 就清会卡住。

💬 关联讨论

发起讨论
加载中...