Linux 与 macOS:生产和开发的差别
主线位置:两条主线在两个平台上的分叉处。注册主线在 epoll 上是一次
epoll_ctl、在 kqueue 上是一次带多项的kevent;事件主线在两边返回的事件条数和标志位不同。这一页看这些分叉有没有穿过 tokio 到达本站。
flowchart TB
diff["本卷实测的平台差别"] --> q1{"tokio 的类型会走到吗"}
q1 -->|不会| safe1["挡住:只用到交集"]
q1 -->|会| q2{"tokio 处理了吗"}
q2 -->|处理了| safe2["挡住"]
q2 -->|没有| q3{"本站走得到吗"}
q3 -->|走不到| noted["记下,不影响本站"]
q3 -->|走得到| bug["要修"]
本页的站点链接都指向提交 89a61b1;tokio 1.52.3 的代码引自 ~/.cargo/registry 里的 crate 源码,和 1.54.1 相同的部分链到 tokio-1.54.1 tag。实验程序在本卷的实验 crate 里,依赖与本站的锁文件一致(tokio = "=1.52.3"、mio = "=1.2.1"),另有一个 tokio = "=1.51.0" 的对照。平台写作「macOS」的是 M2 Max、macOS、rustc 1.99.0;「Linux」的是 Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。
两个环境
生产。 docker-compose.yml 第 58 行的 app 服务由仓库里的 Dockerfile 构建:builder 阶段是 rust:1-trixie,第 121 行跑 dx bundle --platform web --release;运行阶段是 第 136 行的 debian:trixie-slim,入口是 第 165 行的 /app/server(前面套一层 tini)。也就是一个 glibc 的 Linux release 二进制,mio 编译进来的是 epoll 后端,debug_assertions 关闭。
开发。 在 macOS 上 dx serve:服务端是 debug 构建,mio 用 kqueue 后端,debug_assertions 打开。
两边的运行时是同一段代码建的。main.rs 第 186 行调 dioxus::serve,dioxus-server 0.7.9 的 launch.rs 在没有现成运行时时用 Builder::new_multi_thread().enable_all().build(),再在 serve_router 里 TcpListener::bind。差别只有一处:serve_router 在 !cfg!(debug_assertions) 时直接 axum::serve(listener, …),debug 下自己写一个 tokio::select! 循环,一边 listener.accept() 一边收开发工具的热重载消息。两条路调的都是同一个 TcpListener::accept,对 mio 来说没有区别。
本站代码里与 I/O 驱动有关的只有这些:没有 AsyncFd、UdpSocket、UnixStream、tokio::signal、tokio::process,也没有改 max_io_events_per_tick(在 89a61b1 上 git grep 这些名字,没有结果);唯一一处 tokio::net::TcpListener 在 crates/core/src/auth/mod.rs 的测试模块里。Cargo.lock 里没有 hickory 之类的异步 DNS 解析器,reqwest 走 hyper-util 0.1.20 的 GaiResolver,它在 spawn_blocking 里调 to_socket_addrs,不经过 mio。所以下面的问题都归结为:经由 TCP socket 能不能碰到。
一张表
| 差别 | 本卷的证据 | tokio 1.52.3 挡住了吗 | 本站 |
|---|---|---|---|
| 一次 poll 的事件数:同一个 socket 的读写,Linux 一条、macOS 两条 | Token、Interest 与 Event、一次 poll 里的事件数 | 挡住:事件只是把位或进 ScheduledIo | 每个连接 Linux 3 个事件、macOS 5 个(上一页),行为相同 |
Events::with_capacity(0):Linux EINVAL、macOS 忙等 | Token、Interest 与 Event、一次 poll 里的事件数 | 没挡住:1.52.3 接受 max_io_events_per_tick(0),Linux 上驱动 panic;1.54.0 起 assert! | 没设,默认 1024 |
连着的 UDP 发到关闭的端口:Linux 只有 EPOLLERR,is_readable() 为假 | UDP 与 Unix 域套接字、读到 0 字节与错误事件 | 挡住(1.51.1 起):recv 等 READABLE | ERROR;readable() 没有 | 不用 UDP |
只关注 WRITABLE 时对端 shutdown(Write):Linux 1 个事件、macOS 0 个 | 读到 0 字节与错误事件 | 走不到:tokio 的 socket 类型都注册两个方向 | — |
同一个 fd 注册两次:Linux EEXIST、macOS Ok | Poll 与 Registry、同一个 source 注册两次 | 自己的类型走不到;AsyncFd 没挡住 | 不用 AsyncFd |
有 dup 时 drop 不注销:Linux 旧 token 继续出现 | Poll 与 Registry、drop 之前没有 deregister | 自己的类型挡住:先注销再关 fd;AsyncFd 在 1.52.3 上没挡住 | 不用 AsyncFd |
wake() 后马上 drop Waker:Linux 丢事件;第二个 Waker:kqueue 只认后一个 | wake 之后马上 drop Waker、第二个 Waker | 走不到:一个驱动一个 Waker,活到驱动关闭 | — |
kqueue 的 register 返回 Err 但读过滤器已装上 | kqueue、register 报 Invalid argument,事件照样来 | 没挡住:add_source 释放了 ScheduledIo,内核里还留着它的地址 | socket 两项都会成功,走不到 |
被拒绝的连接之后 peer_addr:Linux NotConnected、macOS EINVAL | TCP、connect 之后怎么判断连上了 | 挡住:等可写之后先 take_error | — |
debug 下 IoSource 和 has_waker 的检查 | Source 与 IoSource、Waker | 不涉及:tokio 不触发这两道检查 | 只存在于开发构建 |
EINTR 两个平台一样(都返回 Interrupted,tokio 当成空的 poll,见上一页),不在表里。下面逐条给出 tokio 那一侧的代码和实验。
挡住的
事件数
tokio 1.52.3 的 turn() 对每个事件做的是 io.set_readiness(Tick::Set, |curr| curr | ready) 再 io.wake(ready)(上一页的「turn()」一节)。macOS 把读、写分成两条报告,结果是同一个 ScheduledIo 被或两次,两次加起来和 Linux 的一条 EPOLLIN|EPOLLOUT 置上的位相同;发送缓冲区空出来时多报的那一次 EVFILT_WRITE,把已经置着的 WRITABLE 再置一次。上一页的计数器实验里,每个连接 Linux 3 个、macOS 5 个事件,两边的任务都正常跑完。代价只是开发机上多几次分发。
连着的 UDP 只报错误
Linux 上,connect 过的 UDP socket 发到没人监听的端口后,epoll 只报 EPOLLERR,mio 的 is_readable() 为假、is_error() 为真;macOS 报的是一个 EVFILT_READ(UDP 与 Unix 域套接字)。tokio 的 Ready::from_mio 把前者变成 ERROR 位,而 Interest::mask 里 READABLE 只认 READABLE | READ_CLOSED:
pub(crate) fn mask(self) -> Ready {
match self {
Interest::READABLE => Ready::READABLE | Ready::READ_CLOSED,
Interest::WRITABLE => Ready::WRITABLE | Ready::WRITE_CLOSED,
#[cfg(any(target_os = "linux", target_os = "android"))]
Interest::PRIORITY => Ready::PRIORITY | Ready::READ_CLOSED,
Interest::ERROR => Ready::ERROR,
_ => Ready::EMPTY,
}
}
只等 READABLE 的任务在 Linux 上就永远不会被这个错误叫醒。tokio 1.51.1 起,UdpSocket::recv 这类读方法等的是两者之一(1.52.3 相同):
pub async fn recv(&self, buf: &mut [u8]) -> io::Result<usize> {
self.io
.registration()
.async_io(Interest::READABLE | Interest::ERROR, || self.io.recv(buf))
.await
}
这是 tokio 的 #8001「Surface errors from SO_ERROR on recv for UDP sockets on Linux」,修的是 issue #8000「UdpSocket::recv does not return socket errors」。PR 说明写的是「Adjusted UdpSocket::recv to wait for Interest::READABLE | Interest::ERROR instead of only Interest::READABLE. This ensures error-only readiness wakes the operation so the underlying socket error is surfaced correctly.」registry 里的 1.49.0、1.51.0 的 udp.rs 里没有一处 Interest::READABLE | Interest::ERROR,1.51.1 有 7 处。
src/bin/refused.rs 在两个版本上各跑一遍,顺便看 TCP 被拒绝的连接:
// A connected UDP socket sends to a closed port; then a TCP connect to a closed port.
use std::time::Duration;
use tokio::time::timeout;
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::io::Result<()> {
// A port nobody listens on: bind, read the port, close.
let udp_port = std::net::UdpSocket::bind("127.0.0.1:0")?.local_addr()?.port();
let tcp_port = std::net::TcpListener::bind("127.0.0.1:0")?.local_addr()?.port();
let sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await?;
sock.connect(("127.0.0.1", udp_port)).await?;
sock.send(b"ping").await?;
let mut buf = [0u8; 16];
match timeout(Duration::from_millis(500), sock.recv(&mut buf)).await {
Ok(r) => println!("os={} udp recv: {r:?}", std::env::consts::OS),
Err(_) => println!("os={} udp recv: still pending after 500ms", std::env::consts::OS),
}
let r = tokio::net::TcpStream::connect(("127.0.0.1", tcp_port)).await;
println!("os={} tcp connect: {:?}", std::env::consts::OS, r.map(|_| ()));
Ok(())
}
每种组合 3 次,输出相同。tokio 1.52.3:
os=macos udp recv: Err(Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })
os=macos tcp connect: Err(Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })
os=linux udp recv: Err(Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })
os=linux tcp connect: Err(Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })
tokio 1.51.0(mio 同样是 1.2.1):
os=macos udp recv: Err(Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })
os=macos tcp connect: Err(Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })
os=linux udp recv: still pending after 500ms
os=linux tcp connect: Err(Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })
1.51.0 上这正是一个「开发机上好好的、生产上卡住」的差别:macOS 的 EVFILT_READ 把任务叫醒,recv 拿到 ECONNREFUSED;Linux 的 EPOLLERR 只置了 ERROR 位,recv 一直挂着。本站的 Cargo.toml 写的是 tokio = "1.49.0",允许的范围里包括 1.51.0 之前的版本,是 Cargo.lock 锁住了 1.52.3。同一个文件里的 readable() 仍然只等 READABLE,用它的代码在 Linux 上还会遇到这个问题。本站不用 UDP。
被拒绝的连接
同一个实验的第二行:TcpStream::connect 被拒绝,两个平台都是 ConnectionRefused。mio 层面 Linux 上 peer_addr 返回 NotConnected、macOS 返回 EINVAL(TCP),tokio 根本不问 peer_addr。connect_mio(1.52.3 相同):
pub(crate) async fn connect_mio(sys: mio::net::TcpStream) -> io::Result<TcpStream> {
let stream = TcpStream::new(sys)?;
// Once we've connected, wait for the stream to be writable as
// that's when the actual connection has been initiated. Once we're
// writable we check for `take_socket_error` to see if the connect
// actually hit an error or not.
//
// If all that succeeded then we ship everything on up.
poll_fn(|cx| stream.io.registration().poll_write_ready(cx)).await?;
if let Some(e) = stream.io.take_error()? {
return Err(e);
}
Ok(stream)
}
等可写,再 take_error,这是 mio 文档建议的顺序。
Waker 与 drop 前注销
Waker 的两个坑(wake() 之后马上 drop、一个 Poll 建第二个 Waker)在 tokio 里走不到:Driver::new 只建一个 Waker,存在 Handle 里,和驱动同生共死;Handle::unpark 每次调的都是这一个。
drop 前不注销的坑,tokio 自己的 socket 类型也走不到:PollEvented 的 Drop 在 fd 还开着时 deregister,strace 里每个连接都是 EPOLL_CTL_DEL 在 close 之前(上一页)。dup 出来的 fd 只有调用方自己 try_clone 或 into_std 才会出现,而 into_std 走的 PollEvented::into_inner 也先 deregister。
AsyncFd 是例外。它包的是调用方的 fd,调用方可以先关掉 fd 再 drop AsyncFd,这时 EPOLL_CTL_DEL 返回 EBADF;如果 fd 被 dup 过,epoll 里那一项还在。1.52.3 的 deregister_source 不管内核成不成功都释放 ScheduledIo(tokio 的 #7773,上一页「注销」一节),下一个事件就会落到已释放的内存上。这只在 Linux 上有后果:kqueue 在 close 时就删掉了这个 fd 的过滤器(Poll 与 Registry 的实验),不会再有带这个地址的事件。撤回它的 #8540 进了 1.51.5、1.53.2、1.54.0,没进 1.52.x。复现和 ASan 的输出在 drop 之前没有 deregister,来历在反应器。本站不用 AsyncFd。
没挡住、本站走不到的
max_io_events_per_tick(0)
mio 的 Events::with_capacity(0) 在 Linux 上让 epoll_wait 返回 EINVAL,在 macOS 上 kevent 立即返回 0 个事件(一次 poll 里的事件数)。tokio 1.52.3 的 Builder::max_io_events_per_tick 不检查参数:
pub fn max_io_events_per_tick(&mut self, capacity: usize) -> &mut Self {
self.nevents = capacity;
self
}
src/bin/zero_events.rs:
// tokio 1.52.3 accepts max_io_events_per_tick(0): mio::Events::with_capacity(0).
use std::time::{Duration, Instant};
fn main() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.max_io_events_per_tick(0)
.build()
.unwrap();
let start = Instant::now();
rt.block_on(async { tokio::time::sleep(Duration::from_millis(100)).await });
println!("os={} slept 100ms, returned after {:?}", std::env::consts::OS, start.elapsed());
}
各跑 3 次。macOS 三次分别返回于 100.893166ms、100.998541ms、100.993084ms,第一次的输出:
os=macos slept 100ms, returned after 100.893166ms
Linux 三次都是:
thread 'main' (1) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/io/driver.rs:196:23:
unexpected error when polling the I/O driver: Os { code: 22, kind: InvalidInput, message: "Invalid argument" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
strace 里是 epoll_pwait(3, 0x8, 0, 64, NULL, 8) = -1 EINVAL (Invalid argument):第三个参数 maxevents 是 0。panic 的位置就是 turn() 里那一行 Err(e) => panic!(...)。在开发机上能跑、一部署就启动失败;macOS 上能跑也不代表没问题,驱动每次 poll 都立即返回,等于忙等。tokio 1.54.0 的 max_io_events_per_tick 加了 assert!(capacity > 0, "max_io_events_per_tick must be non-zero")(#8476),把错误提前到构建运行时、两个平台一致。本站的运行时由 dioxus-server 用默认值建,容量 1024。
AsyncFd 注册同一个 fd 两次
AsyncFd 用 SourceFd 注册,不经过 IoSource,debug 下也没有 SelectorId 检查,结果就是平台 selector 的结果(Poll 与 Registry 的契约表)。src/bin/asyncfd_twice.rs:
// Two AsyncFd values for the same fd number.
use std::os::fd::{AsRawFd, RawFd};
struct Fd(RawFd);
impl AsRawFd for Fd {
fn as_raw_fd(&self) -> RawFd {
self.0
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
sock.set_nonblocking(true).unwrap();
let fd = sock.as_raw_fd();
let a = tokio::io::unix::AsyncFd::new(Fd(fd));
let b = tokio::io::unix::AsyncFd::new(Fd(fd));
println!("os={} first: {:?}", std::env::consts::OS, a.as_ref().map(|_| ()));
println!("os={} second: {:?}", std::env::consts::OS, b.as_ref().map(|_| ()));
}
各跑 3 次,输出相同:
os=macos first: Ok(())
os=macos second: Ok(())
os=linux first: Ok(())
os=linux second: Err(Os { code: 17, kind: AlreadyExists, message: "File exists" })
macOS 上第二次 EV_ADD 覆盖了第一次的 udata,之后这个 fd 的事件只会带第二个 ScheduledIo 的地址,第一个 AsyncFd 再也收不到事件;Linux 上第二次直接失败。同一份代码在开发机上「能注册」,到生产上报 File exists。本站不用 AsyncFd。
kqueue 注册只生效一半
mio 在 kqueue 上的 register 是一次 kevent 提交多项,各项分别成败,失败的不回滚(kqueue 的「一项失败,另一项照样生效」)。tokio 的 add_source 把 Err 理解成「什么都没注册」,于是从链表里摘掉 ScheduledIo 并释放(上一页的「注册」一节)。kqueue 里那一项读过滤器还在,udata 是刚释放的地址。register 报 Invalid argument,事件照样来用 tokio 1.54.1 的 AsyncFd::register 和 ASan 复现过;add_source 在 1.52.3 里逐字相同,下面用本站的版本再跑一次。
src/bin/kq_partial.rs:把一个 kqueue fd 交给 AsyncFd::new(默认 READABLE | WRITABLE,EVFILT_WRITE 对 kqueue fd 是 EINVAL),再让里面那个 kqueue 有一个待取事件,看驱动有没有分发:
// macOS only: AsyncFd on a kqueue fd. Registering READABLE | WRITABLE fails
// (EVFILT_WRITE on a kqueue fd is EINVAL), but the EVFILT_READ entry stays.
use std::os::fd::{AsRawFd, RawFd};
use std::time::Duration;
struct Fd(RawFd);
impl AsRawFd for Fd {
fn as_raw_fd(&self) -> RawFd {
self.0
}
}
fn main() {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let inner = unsafe { libc::kqueue() };
let r = tokio::io::unix::AsyncFd::new(Fd(inner));
println!("AsyncFd::new(kqueue fd {inner}) = {:?}", r.as_ref().map(|_| ()));
#[cfg(tokio_unstable)]
let m = tokio::runtime::Handle::current().metrics();
#[cfg(tokio_unstable)]
println!("fd_registered={} ready_events={}", m.io_driver_fd_registered_count(), m.io_driver_ready_count());
// Make the inner kqueue readable: trigger an EVFILT_USER event in it.
let mut kev: libc::kevent = unsafe { std::mem::zeroed() };
kev.ident = 1;
kev.filter = libc::EVFILT_USER;
kev.flags = libc::EV_ADD | libc::EV_CLEAR;
kev.fflags = libc::NOTE_TRIGGER;
let n = unsafe { libc::kevent(inner, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) };
println!("trigger inner kqueue: kevent = {n}");
// Let the driver poll a few times.
for _ in 0..3 {
tokio::time::sleep(Duration::from_millis(20)).await;
}
#[cfg(tokio_unstable)]
println!("fd_registered={} ready_events={}", m.io_driver_fd_registered_count(), m.io_driver_ready_count());
});
println!("done");
}
RUSTFLAGS="--cfg tokio_unstable"、--release,macOS 上 3 次输出相同:
AsyncFd::new(kqueue fd 9) = Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
fd_registered=0 ready_events=0
trigger inner kqueue: kevent = 0
fd_registered=0 ready_events=1
done
注册失败、登记的 fd 数是 0,驱动却分发了一个事件:turn() 拿着那个地址调了 set_readiness 和 wake。release 下程序看起来正常结束。用 nightly(rustc 1.101.0-nightly,2026-10-05)加 -Zsanitizer=address 跑同一个程序,进程以 134 退出,报告的头几行:
AsyncFd::new(kqueue fd 9) = Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
fd_registered=0 ready_events=0
trigger inner kqueue: kevent = 0
=================================================================
==37470==ERROR: AddressSanitizer: heap-use-after-free on address 0x613000003a90 at pc 0x000102dba478 bp 0x00016d195be0 sp 0x00016d195bd8
READ of size 8 at 0x613000003a90 thread T0
读的栈经过 ScheduledIo::set_readiness 和 Driver::turn;「freed by」的栈底是 tokio-1.52.3 的 driver.rs:290(add_source 的 return Err(e) 之后,scheduled_io 离开作用域),「previously allocated」的栈底是 driver.rs:271(self.registrations.allocate)。
这是 tokio 在 kqueue 上的一个缺口,1.54.1 也一样。触发它要求一项过滤器失败、另一项成功:TCP、UDP、UDS socket 的读写两项都会成功,本站只注册 TCP socket(加上运行时自己的信号 socket,Linux 上还有 eventfd),走不到;而且它只在 macOS 开发机上会发生,生产的 epoll 一次 epoll_ctl 要么全成要么全不成。
只在开发机上的:mio 的 debug 检查
mio 有两道只在 debug_assertions 下编译的检查:IoSource 的 SelectorId(重复注册、注册到两个 Poll 时返回「I/O source already registered with a `Registry`」,Source 与 IoSource),和 Registry::register_waker 的断言(「Only a single `Waker` can be active per `Poll` instance」,Waker)。它们的消息字符串只出现在 debug 构建里。在本机的 dx serve 产物和 dx bundle --release 产物里找这两个字符串(这两个文件是开发机上最近一次构建留下的,不是 89a61b1 的精确产物):
for f in ~/.target/dx/app/debug/web/server ~/.target/dx/app/release/web/server; do
echo "$f: $(grep -c -a 'already registered with a' $f) $(grep -c -a 'Only a single' $f)"
done
~/.target/dx/app/debug/web/server: 1 1
~/.target/dx/app/release/web/server: 0 0
所以开发时 mio 带着这两道检查,生产没有。tokio 不会触发它们:每个 socket 只注册一次,一个驱动只建一个 Waker。它们对本站的意义只是反过来的:如果哪天本站代码自己用 mio 或 AsyncFd 犯了重复注册的错,开发机上的 kqueue 本来会静默接受(上一节),debug 检查也只管 IoSource、不管 SourceFd,所以 AsyncFd 的那种错在开发机上两头都看不见。
问: 开发机上跑得通、生产也是同一份代码,为什么还要逐条核对平台差别?tokio 不就是为了抹平这些才存在的吗?
答: tokio 抹平的是它自己用到的那部分 mio 语义。它的 socket 类型都按「两个方向注册一次、读到 WouldBlock 才清位、先注销再关 fd」来用 mio,这部分在两个平台上表现一致,上面「挡住的」几条都属于这一类。但 tokio 也把 mio 的一部分原样暴露出来:AsyncFd 让调用方传任意 fd、max_io_events_per_tick 直接变成 Events 的容量、UdpSocket::readable() 只等 READABLE。从这些口子进去,mio 的平台差别就原样到了用户面前。1.51.0 的 UDP recv 是一个实例:tokio 自己的读方法里也漏过一次,修好之前,它在 macOS 上能用、在 Linux 上卡住。
小结
- 本站生产是 docker 里的 glibc Linux release 二进制(epoll,无 mio debug 检查),开发是 macOS 上的
dx servedebug 构建(kqueue,带 debug 检查);运行时都由 dioxus-server 用默认参数建。 - 被 tokio 1.52.3 挡住的:一次 poll 的事件数(Linux 每连接 3 个、macOS 5 个,只是重复置位)、连着的 UDP 只报错误(
recv等READABLE | ERROR,1.51.1 起)、被拒连接的peer_addr(tokio 用take_error)、Waker的两个坑、自己类型的 drop 前注销。 - 没挡住的:
max_io_events_per_tick(0)在 Linux 上让驱动 panic、AsyncFd重复注册在两个平台上结果相反、AsyncFd先关 fd 再 drop 在 Linux 上释放仍在 epoll 里的地址(1.52.3)、kqueue 注册只生效一半时释放仍在 kqueue 里的地址(ASan 报 use-after-free,1.54.1 也有)。 - 这几条本站都走不到:不改
max_io_events_per_tick,不用AsyncFd、UDP,只注册 TCP socket。 - 本站
Cargo.toml允许 tokio 1.49.0 起的版本,UDPrecv的修复靠Cargo.lock锁住的 1.52.3;降到 1.51.1 之前的版本(registry 里的 1.49.0、1.51.0 都没有这个修复),Linux 上的 UDPrecv会重新收不到错误。