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) |
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。之后会不会卡住,要看下面这个问题。
对端只写了 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都不起作用。