Rust in Everything
基于 mio 1.2.4

from_std 之后事件循环卡在 read 里

主线位置:注册主线的起点(fd 从哪里来)和事件主线的最后一步(读到 WouldBlock)。fd 是阻塞的,前面每一步都正常,只有最后一步变成了「在内核里等」。

flowchart TB
  acc["std accept 得到阻塞 fd"] --> fs["from_std:不改标志"]
  fs --> regnode["register 正常"]
  regnode --> ev["poll 正常返回事件"]
  ev --> r1["第一次 read 成功"]
  r1 --> r2["读干净后再 read"]
  r2 -->|非阻塞| wb["WouldBlock,回到 poll"]
  r2 -->|阻塞| blk["线程睡在 recvfrom 里"]
  blk --> loopstop["整个事件循环停住"]

TcpStream::from_std 只是把 std 的流放进 IoSource,文档写明「The conversion assumes nothing about the underlying stream; it is left up to the user to set it in non-blocking mode.」TcpListener::from_std 一样。单个流上的现象和 strace(std 的 accept4 只带 SOCK_CLOEXEC、set_nonblocking 是 ioctl(FIONBIO)、第二次 recvfrom 不返回)在 TCP 的「from_std:非阻塞由调用方负责」里跑过了。这一页看它在一个真正的事件循环里造成什么后果,怎么查,以及 tokio 怎么拦。

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

mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
libc = "=0.2.191"
tokio = { version = "=1.54.1", features = ["rt", "net", "macros", "io-util", "time"] }

复现:一个连接卡住所有连接

src/bin/from_std_loop.rs:std 的 listener 接受两个连接,conn 0 忘了 set_nonblocking,conn 1 设了。两个对端各写 6 字节,服务端是一个正常的 echo 循环:每个事件读到 WouldBlock,读到什么就写回去。对端 b 在另一个线程里等回声,1 秒等不到就打印一行、结束进程。注册之前用 fcntl(F_GETFL) 看一眼内核里的 O_NONBLOCK:

use std::io::{self, ErrorKind, Read, Write};
use std::os::fd::AsRawFd;
use std::thread;
use std::time::Duration;

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

// O_NONBLOCK as the kernel sees it
fn is_nonblocking(fd: &impl AsRawFd) -> bool {
  let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) };
  flags & libc::O_NONBLOCK != 0
}

fn main() -> io::Result<()> {
  println!("os={}", std::env::consts::OS);
  let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
  let addr = listener.local_addr()?;
  let mut peer_a = std::net::TcpStream::connect(addr)?;
  let (a, _) = listener.accept()?; // forgot set_nonblocking
  let mut peer_b = std::net::TcpStream::connect(addr)?;
  let (b, _) = listener.accept()?;
  b.set_nonblocking(true)?;

  let mut conns = vec![TcpStream::from_std(a), TcpStream::from_std(b)];
  for (i, c) in conns.iter().enumerate() {
    println!("conn {i}: O_NONBLOCK={}", is_nonblocking(c));
  }
  let mut poll = Poll::new()?;
  let mut events = Events::with_capacity(8);
  for (i, c) in conns.iter_mut().enumerate() {
    poll.registry().register(c, Token(i), Interest::READABLE)?;
  }

  peer_a.write_all(b"from a")?;
  peer_b.write_all(b"from b")?;
  thread::sleep(Duration::from_millis(100));

  // guard: peer b waits for an echo that never comes
  thread::spawn(move || {
    peer_b.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
    let mut buf = [0u8; 16];
    let r = peer_b.read(&mut buf).map_err(|e| e.kind());
    println!("guard: peer b got no echo within 1s ({r:?}), exiting");
    std::process::exit(0);
  });

  poll.poll(&mut events, None)?;
  let ready: Vec<usize> = events.iter().map(|e| e.token().0).collect();
  println!("poll: tokens {ready:?}");
  let mut buf = [0u8; 64];
  for t in ready {
    let c = &mut conns[t];
    loop {
      match c.read(&mut buf) {
        Ok(n) => {
          println!("conn {t}: read {n} byte(s), echo");
          c.write_all(&buf[..n])?;
        }
        Err(e) if e.kind() == ErrorKind::WouldBlock => {
          println!("conn {t}: WouldBlock");
          break;
        }
        Err(e) => return Err(e),
      }
    }
  }
  println!("all events handled");
  Ok(())
}

macOS 和 Linux 各跑 3 次,输出只有第一行不同。Linux:

os=linux
conn 0: O_NONBLOCK=false
conn 1: O_NONBLOCK=true
poll: tokens [0, 1]
conn 0: read 6 byte(s), echo
guard: peer b got no echo within 1s (Err(WouldBlock)), exiting

两个连接在同一次 poll 里都就绪了。conn 0 排在前面:读 6 字节、写回,然后读第二次,这一次没有返回。conn 1 的数据早就到了,poll 也报告了,但循环永远走不到它,对端 b 等了 1 秒什么也没收到。conn 0 的对端此时并没有做错任何事,它只是暂时不发数据。

Linux 上的系统调用(strace -f -o ... -e trace=epoll_ctl,epoll_pwait,recvfrom,sendto,exit_group;行首 3 是主线程,4 是看门狗线程):

3     epoll_ctl(8, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
3     epoll_ctl(8, EPOLL_CTL_ADD, 7, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
3     sendto(4, "from a", 6, MSG_NOSIGNAL, NULL, 0) = 6
3     sendto(6, "from b", 6, MSG_NOSIGNAL, NULL, 0) = 6
3     epoll_pwait(8, [{events=EPOLLIN, data=0}, {events=EPOLLIN, data=0x1}], 8, -1, NULL, 8) = 2
3     recvfrom(5, "from a", 64, 0, NULL, NULL) = 6
3     sendto(5, "from a", 6, MSG_NOSIGNAL, NULL, 0) = 6
3     recvfrom(5 <unfinished ...>
4     recvfrom(6, 0xffff81ebd8e0, 16, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
4     exit_group(0 <unfinished ...>
3     <... recvfrom resumed> <unfinished ...>) = ?
4     <... exit_group resumed>)         = ?
4     +++ exited with 0 +++
3     +++ exited with 0 +++

epoll_pwait 一次返回了两个事件(第 5 行,data=0 和 data=0x1)。fd 5 的第二个 recvfrom(第 8 行)停在 <unfinished ...>,直到看门狗 exit_group;fd 7 一次也没被读过。对比之下,看门狗线程用的 fd 6 设了读超时,它的 recvfrom 正常地返回了 EAGAIN(第 9 行)。

为什么

mio 自己建的 socket 一出生就是非阻塞的:bind、connect 用 SOCK_NONBLOCK(macOS 上事后 fcntl),accept 在 Linux 上是 accept4(SOCK_CLOEXEC|SOCK_NONBLOCK),见 TCP。from_std 是唯一的例外入口:

    pub fn from_std(stream: net::TcpStream) -> TcpStream {
        TcpStream {
            inner: IoSource::new(stream),
        }
    }

返回值不是 io::Result,函数里也没有系统调用。FromRawFd、From<OwnedFd> 都转到这里,同样不设。O_NONBLOCK 是「打开的文件描述」上的标志,mio 不读它,内核在 epoll_ctl / kevent 时也不关心它:阻塞的 fd 照样能注册、照样产生事件。区别只在读写时出现。边沿触发要求读到 WouldBlock(没读到 WouldBlock 就回去 poll),而阻塞的 fd 永远不会返回 WouldBlock,读干净之后的那一次 read 就在内核里等对端的下一批数据。

常见的阻塞 fd 来源:

来源默认设成非阻塞
std::net::TcpListener::bind / accept、TcpStream::connect阻塞set_nonblocking(true)
std::net::UdpSocket::bind阻塞set_nonblocking(true)
std::os::unix::net::*阻塞set_nonblocking(true)
子进程的 stdin / stdout / stderr、继承来的 fd阻塞mio::unix::pipe 的 set_nonblocking(见 UDP 与 Unix 域套接字)或 fcntl
socket2 建的 socket阻塞,除非建时指定set_nonblocking(true)
📝NOTE
冷知识:0.6 的转换函数会替你设非阻塞

0.6 里对应的函数叫 TcpStream::from_stream。v0.6.23 的实现第一行就是 set_nonblocking(&stream)?;,所以它返回 io::Result<TcpStream>。0.7 开发期间重写网络类型时,from_std 一度被删掉,PR #1124「Add from_std methods to TCP/Unix Stream/Listener」又把它加回来,理由是「These methods are still needed in upstream libraries where creating wrapped resources from standard types is needed」。新函数不再调用 set_nonblocking,返回值也就不再是 Result。评审里有一条意见:「Maybe add a note saying that the listener is not put in non-blocking mode as that is up to the user.」于是文档里有了「it is left up to the user to set it in non-blocking mode」这半句。这个 PR 首次发布在 0.7.0。从 0.6 升级上来、原来依赖 from_stream 替你设标志的代码,就是在这里踩坑的。

修法

from_std 之前调一次 set_nonblocking(true):

let (std_stream, _) = std_listener.accept()?;
std_stream.set_nonblocking(true)?;
let stream = mio::net::TcpStream::from_std(std_stream);

能不经过 std 就不经过:用 mio::net::TcpListener::bind、accept、TcpStream::connect 建出来的 socket 本来就是非阻塞的。监听 socket 也要设:阻塞的 listener 上,accept 到队列空之后的那一次 accept 会阻塞,和流上的 read 一样。

怎么确认自己没踩

  • 在 from_std 旁边加一个 debug 断言。 上面程序里的 is_nonblocking 就是一次 fcntl(fd, F_GETFL),看 O_NONBLOCK 位。debug_assert!(is_nonblocking(&s)) 放在 from_std 前面,测试里就会暴露。
  • 看 strace。 Linux 上 std 的 accept4 只带 SOCK_CLOEXEC,之后没有 ioctl(fd, FIONBIO, [1]) 的,就是阻塞 fd;卡住时主线程最后一个系统调用是没有返回的 recvfrom 或 accept4,而不是 epoll_pwait。
  • 测试里让对端暂时不发数据。 和上一章一样,问题只在「读干净之后再读一次」时出现;对端一直在写的测试看不出来。

tokio 怎么做

tokio 的 from_std 在注册之前多了一步 check_socket_for_blocking:

pub(crate) fn check_socket_for_blocking<S: AsFd>(s: &S) -> crate::io::Result<()> {
    #[cfg(not(tokio_allow_from_blocking_fd))]
    {
        let sock = socket2::SockRef::from(s);

        debug_assert!(
            sock.nonblocking()?,
            "Registering a blocking socket with the tokio runtime is unsupported. \
            If you wish to do anyways, please add `--cfg tokio_allow_from_blocking_fd` to your \
            RUSTFLAGS. See github.com/tokio-rs/tokio/issues/7172 for details."
        );
    }

    Ok(())
}

TcpStream::from_std、TcpListener、UdpSocket 和三种 Unix 域套接字的 from_std 都调用它。它来自 tokio 的 PR #7166(「net: debug_assert on creating a tokio socket from a blocking one」),首次发布在 tokio 1.44.0;panic 消息里的 issue #7172 讨论的是要不要在 release 下也 panic,截至 1.54.1 仍是打开的。非 Unix 平台上它是一个空函数,注释说 Windows 上读不出非阻塞标志(「we cannot retrieve the nonblocking status on windows」)。

src/bin/from_std_tokio.rs:std 的 listener 接受一个阻塞的连接,对端写 5 字节,交给 tokio::net::TcpStream::from_std,然后在 current_thread 运行时里读两次,每次套一个 500ms 的 tokio::time::timeout。另起一个 OS 线程,2 秒后结束进程。命令行参数是读缓冲的大小:

use std::io::Write;
use std::time::Duration;

use tokio::io::AsyncReadExt;
use tokio::time::timeout;

#[tokio::main(flavor = "current_thread")]
async fn main() {
  println!("os={} debug_assertions={}", std::env::consts::OS, cfg!(debug_assertions));
  let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
  let mut peer = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
  let (s, _) = listener.accept().unwrap(); // forgot set_nonblocking
  peer.write_all(b"hello").unwrap();

  // guard: the runtime thread will be stuck in the kernel, so exit from another OS thread
  std::thread::spawn(|| {
    std::thread::sleep(Duration::from_secs(2));
    println!("guard: still blocked after 2s, exiting");
    std::process::exit(0);
  });

  let mut stream = tokio::net::TcpStream::from_std(s).unwrap();
  println!("from_std: Ok");
  // 64: the first read is short; 5: the first read fills the buffer exactly
  let len: usize = std::env::args().nth(1).map_or(64, |a| a.parse().unwrap());
  println!("buffer: {len} bytes");
  let mut buf = vec![0u8; len];
  for i in 1..=2 {
    match timeout(Duration::from_millis(500), stream.read(&mut buf)).await {
      Ok(r) => println!("read #{i}: {:?}", r.map_err(|e| e.kind())),
      Err(_) => println!("read #{i}: timed out after 500ms"),
    }
  }
}

debug 构建(参数 64),macOS 跑 3 次,除线程号外相同:

os=macos debug_assertions=true

thread 'main' (2343511) panicked at src/bin/from_std_tokio.rs:22:20:
Registering a blocking socket with the tokio runtime is unsupported. If you wish to do anyways, please add `--cfg tokio_allow_from_blocking_fd` to your RUSTFLAGS. See github.com/tokio-rs/tokio/issues/7172 for details.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Linux 上 3 次输出相同,只有第一行和线程号((1))不同。check_socket_for_blocking 和 from_std 都标了 #[track_caller],panic 的位置报在调用者自己的第 22 行,而不是 tokio 内部。

release 构建里没有这道检查,from_std 返回 Ok。之后会不会卡住,要看下面这个问题。

❗IMPORTANT
动脑筋:release 下,读缓冲 64 字节和 5 字节,哪个会卡住?

对端只写了 5 字节,之后不再写。读缓冲 64 字节时,第一次读到 5 字节,第二次读呢?读缓冲正好 5 字节时呢?timeout 能不能把卡住的读救回来?答案在页末。

动脑筋答案

release 构建,macOS 和 Linux 各跑 3 次,输出只有 os= 不同。Linux:

os=linux debug_assertions=false
from_std: Ok
buffer: 64 bytes
read #1: Ok(5)
read #2: timed out after 500ms
os=linux debug_assertions=false
from_std: Ok
buffer: 5 bytes
read #1: Ok(5)
guard: still blocked after 2s, exiting
  • 64 字节不卡。 第一次读到 5 字节,比缓冲区小。tokio 在 epoll 和 kqueue 上把「读不满」当作已经读空,直接清掉就绪位(从 TcpStream::read 到挂起),第二次读就去等新事件,根本没有调 recvfrom,timeout 正常地在 500ms 后触发。阻塞的 fd 在这里被一个优化「藏」住了。
  • 5 字节卡住。 第一次正好读满缓冲区,tokio 不能断定读空了,就绪位留着;第二次读直接调 recvfrom,它在内核里阻塞。timeout 也救不回来:定时器要靠运行时线程去推进,而这个线程正睡在 recvfrom 里。current_thread 运行时上的所有任务、所有定时器都停住,直到看门狗结束进程。
  • 所以 release 下的 tokio 程序用了阻塞 socket,可能在大部分请求上都正常,只在数据长度恰好等于缓冲区(或者大于缓冲区、要读多次)时卡住。debug 下的那个 panic 是最好的发现机会。

小结

  • from_std 不设非阻塞,也不检查;阻塞的 fd 照常注册、照常产生事件、第一次读照常成功,读干净后的那一次 read / accept 阻塞住整个事件循环,同一次 poll 里就绪的其他连接都得不到处理。
  • mio 自己的 bind、accept、connect 建出来的 socket 都是非阻塞的,问题只在从 std、FromRawFd、OwnedFd、子进程 pipe 拿来的 fd 上。0.6 的 from_stream 会替你设,0.7.0 的 from_std(#1124)不再设。
  • 修法:from_std 之前 set_nonblocking(true);检查:fcntl(F_GETFL) 看 O_NONBLOCK,或在 strace 里找 ioctl(FIONBIO)。
  • tokio 1.44.0 起(#7166)的 from_std 在 debug 下对阻塞 socket panic(只在 Unix 上);release 下不检查,是否卡住取决于读不满时的清位优化,卡住时连 timeout 都不起作用。

💬 关联讨论

发起讨论
加载中...