tokio 的 I/O 驱动怎么调 mio
主线位置:两条主线的调用方。注册主线的起点是 tokio 的
PollEvented::new,它调一次Registry::register;事件主线的循环就是 tokio 的Driver::turn,它调一次Poll::poll,再把每个Event的 token 当指针用。
flowchart TB newd["Driver::new"] --> p1["Poll::new"] newd --> p2["Waker::new,Token 0"] newd --> p3["Registry::try_clone"] sig["信号驱动"] --> p4["register,Token 1"] pe["PollEvented::new"] --> p5["register R W,地址作 token"] turnn["Driver::turn"] --> p6["poll,容量 1024"] p6 --> disp["按 token 找 ScheduledIo"] dropn["PollEvented drop"] --> p7["deregister,再 close"]
tokio 那一侧(ScheduledIo 的就绪位、任务怎么挂起和唤醒、turn() 什么时候被调)在 tokio 卷的反应器、ScheduledIo 与就绪状态、从 TcpStream::read 到挂起里讲过。那一卷读的是 tokio 1.54.1;本站锁的是 1.52.3(见在本站中)。这一页只看 tokio 1.52.3 对 mio 发出的每一个调用,以及它们在 strace 里是什么样子,最后对照 1.54.1 看哪里变了。
tokio 1.52.3 的代码引自 ~/.cargo/registry 里的 crate 源码,标出文件和行号;和 1.54.1 相同的部分链到 tokio 卷用的 tokio-1.54.1 tag。
用了 mio 的哪几个 feature
本站开的是 tokio 的 full。其中 net、process、signal 三个 feature 各自打开 mio/os-poll、mio/os-ext、mio/net,io-uring(只在 tokio_unstable 下有意义)打开前两个;rt、time、sync 这些不碰 mio。mio 的依赖声明是 optional = true、default-features = false,所以 tokio 不开这几个 feature 时根本不编译 mio,开了也不带 mio 的 log。
结果是 tokio 用到了 mio 平台无关层的全部类型(Poll、Registry、Waker、Events、Token、Interest),以及 mio::net 的 TCP、UDP、UDS 类型、mio::unix::SourceFd(AsyncFd 和 1.52.3 里子进程的标准输入输出用)和 mio::unix::pipe(tokio::net::unix::pipe 用)。
Driver::new:三个调用
tokio 1.52.3 src/runtime/io/driver.rs 第 117 行,去掉 io-uring 的两个字段:
pub(crate) fn new(nevents: usize) -> io::Result<(Driver, Handle)> {
let poll = mio::Poll::new()?;
#[cfg(not(target_os = "wasi"))]
let waker = mio::Waker::new(poll.registry(), TOKEN_WAKEUP)?;
let registry = poll.registry().try_clone()?;
let driver = Driver {
signal_ready: false,
events: mio::Events::with_capacity(nevents),
poll,
};
let (registrations, synced) = RegistrationSet::new();
let handle = Handle {
registry,
registrations,
synced: Mutex::new(synced),
#[cfg(not(target_os = "wasi"))]
waker,
metrics: IoDriverMetrics::default(),
// ...
};
Ok((driver, handle))
}
1.54.1 的 Driver::new 多一个参数 nevents_busy,多建一个 events_busy 缓冲区,三个 mio 调用和顺序不变。
| tokio 的调用 | mio 里落到哪 | Linux 上的系统调用 |
|---|---|---|
mio::Poll::new() | Poll::new → epoll 的 Selector::new | epoll_create1(EPOLL_CLOEXEC) |
mio::Waker::new(poll.registry(), TOKEN_WAKEUP) | Waker::new → eventfd 版 Waker::new | eventfd2 + epoll_ctl(ADD, data=0) |
poll.registry().try_clone() | Registry::try_clone → Selector::try_clone | fcntl(F_DUPFD_CLOEXEC) |
mio::Events::with_capacity(nevents) | Events::with_capacity,只分配一个 Vec | 无 |
nevents 默认 1024(tokio 1.52.3 src/runtime/builder.rs 第 288 行),Builder::max_io_events_per_tick 可改。本站的运行时是 dioxus-server 0.7.9 建的(launch.rs 里的 block_on:Builder::new_multi_thread().enable_all().build()),没有改这一项。
几点:
Poll留在Driver,Registry的克隆放进Handle。Poll::poll要&mut self,只有拿到驱动的那个线程能等事件;注册要从任意线程发起,所以Handle拿的是try_clone出来的Registry。在 epoll 上它是同一个 epoll 实例的第二个 fd,见 Poll 与 Registry 的「try_clone」一节。Waker在try_clone之前创建,用的是原来那个Registry。 两者指向同一个 epoll 实例,用哪个都一样;debug 下「一个Poll一个Waker」的检查(has_waker)在try_clone时用Arc::clone共享,tokio 只建一个Waker,不会触发它。见 Waker。- macOS 上这三步是
kqueue()加一次fcntl(F_SETFD, FD_CLOEXEC);mio 1.2.1 的 kqueueWaker::new先try_clone一份 kqueue fd,再用一次kevent做setup_waker(1.2.4 去掉了这次 setup,见怎么读这一卷的「1.2.1 到 1.2.4」);Registry::try_clone再复制一份 kqueue fd。
紧接着,开了 signal feature 的运行时会建信号驱动(tokio 1.52.3 src/runtime/signal/mod.rs):全局有一对 UnixStream(socketpair),每个运行时把读端 try_clone 一份,用 TOKEN_SIGNAL(Token(1))、READABLE 注册进来。本站代码里没有用 tokio::signal,这一份注册照样存在,因为它跟着 feature 走,不跟着用法走。
注册:每个 socket 一次,token 是地址
TcpListener::bind、accept、TcpStream::connect 拿到 mio 的 socket 之后都包进 PollEvented。PollEvented::new 固定用两个方向:
pub(crate) fn new(io: E) -> io::Result<Self> {
PollEvented::new_with_interest(io, Interest::READABLE | Interest::WRITABLE)
}
它经过 Registration::new_with_interest_and_handle 到驱动的 add_source。tokio 1.52.3 src/runtime/io/driver.rs 第 266 行:
pub(super) fn add_source(
&self,
source: &mut impl mio::event::Source,
interest: Interest,
) -> io::Result<Arc<ScheduledIo>> {
let scheduled_io = self.registrations.allocate(&mut self.synced.lock())?;
let token = scheduled_io.token();
// we should remove the `scheduled_io` from the `registrations` set if registering
// the `source` with the OS fails. Otherwise it will leak the `scheduled_io`.
if let Err(e) = self.registry.register(source, token, interest.to_mio()) {
// safety: `scheduled_io` is part of the `registrations` set.
unsafe {
self.registrations
.remove(&mut self.synced.lock(), &scheduled_io)
};
return Err(e);
}
// TODO: move this logic to `RegistrationSet` and use a `CountedLinkedList`
self.metrics.incr_fd_count();
Ok(scheduled_io)
}
和 1.54.1 的 add_source 逐字相同。对 mio 来说这就是一次普通的 Registry::register,三个参数:
- source:
mio::net::TcpStream这类IoSource包装的类型,AsyncFd则是SourceFd。debug 下的SelectorId检查对前者生效,见 Source 与 IoSource。 - token:
ScheduledIo::token返回的ScheduledIo地址(1.52.3 里也是这样)。mio 原样把它写进epoll_event.data.u64或kevent.udata,见 Token、Interest 与 Event。地址按 128 字节对齐,不会撞上 0 和 1 两个保留值,见反应器的「token 就是指针」。 - interest:
Interest::to_mio把 tokio 的Interest换成 mio 的。tokio 的ERROR在 mio 里没有对应位(错误总会报告),只关注错误时用READABLE占位,因为 mio 的Interest不能为空。
READABLE | WRITABLE 到了 epoll 上是 EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET(epoll 的「掩码」一节),到了 kqueue 上是一次 kevent 提交 EVFILT_WRITE、EVFILT_READ 两项(kqueue)。tokio 1.52.3 的代码里没有对 mio Registry::reregister 的调用:两个方向一开始就都注册了,以后只在自己的 ScheduledIo 里清就绪位。(唯一叫这个名字的 PollEvented::reregister 只在 Linux 的子进程回收里用,做的是 deregister 再向当前运行时重新 register。)
注册失败时 add_source 把 ScheduledIo 从链表里摘掉并释放,返回 Err。这在 epoll 上没有问题,一次 epoll_ctl 要么成功要么什么都没留下;kqueue 上 register 可能只生效一半,这时内核里留着一个指向已释放内存的 token,见 Linux 与 macOS。
turn():一次 poll,按地址分发
tokio 1.52.3 src/runtime/io/driver.rs 第 179 行的 turn,去掉 wasi 和 io-uring 的分支:
fn turn(&mut self, handle: &Handle, max_wait: Option<Duration>) {
debug_assert!(!handle.registrations.is_shutdown(&handle.synced.lock()));
handle.release_pending_registrations();
let events = &mut self.events;
// Block waiting for an event to happen, peeling out how many events
// happened.
match self.poll.poll(events, max_wait) {
Ok(()) => {}
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
// ...
Err(e) => panic!("unexpected error when polling the I/O driver: {e:?}"),
}
// Process all the events that came in, dispatching appropriately
let mut ready_count = 0;
for event in events.iter() {
let token = event.token();
if token == TOKEN_WAKEUP {
// Nothing to do, the event is used to unblock the I/O driver
} else if token == TOKEN_SIGNAL {
self.signal_ready = true;
} else {
let ready = Ready::from_mio(event);
let ptr = super::EXPOSE_IO.from_exposed_addr(token.0);
// Safety: we ensure that the pointers used as tokens are not freed
// until they are both deregistered from mio **and** we know the I/O
// driver is not concurrently polling. The I/O driver holds ownership of
// an `Arc<ScheduledIo>` so we can safely cast this to a ref.
let io: &ScheduledIo = unsafe { &*ptr };
io.set_readiness(Tick::Set, |curr| curr | ready);
io.wake(ready);
ready_count += 1;
}
}
// ...
}
对 mio 只有一次调用:Poll::poll。它和 mio 文档里的用法有三处对得上:
events每次复用。 容量 1024,poll先清空再填,填满了就只取这么多,剩下的留在内核里下一次再取(Token、Interest 与 Event 的「Events」一节)。strace 里epoll_pwait的第三个参数就是这个 1024。Interrupted当成一次空的 poll。 mio 不重试EINTR(Poll 与 Registry 的「EINTR」一节),tokio 照文档的意思处理掉了;其他错误直接 panic,后面会用到这一行。- 事件只用来置位,不直接做 I/O。
Ready::from_mio逐个调Event的is_readable、is_writable、is_read_closed、is_write_closed、is_error(Linux 上还有is_priority),按位或进ScheduledIo。真正的读写由被唤醒的任务去做,读到WouldBlock时清掉对应的位,这就是边沿触发要求的「读到WouldBlock为止」,在 tokio 里由ScheduledIo的 tick 和清位保证(ScheduledIo 与就绪状态)。
max_wait 是 None 时 strace 里的超时是 -1,0 超时就是 0。1.54.1 的 turn 把分发挪进了 dispatch_event,并在 0 超时时可以换用 events_busy,对 mio 的调用还是这一次 poll。
注销:先 deregister,再 close
PollEvented 的 Drop(1.52.3 相同):
impl<E: Source> Drop for PollEvented<E> {
fn drop(&mut self) {
if let Some(mut io) = self.io.take() {
// Ignore errors
let _ = self.registration.deregister(&mut io);
}
}
}
deregister 到驱动的 deregister_source,对 mio 是一次 Registry::deregister,epoll 上是 EPOLL_CTL_DEL。io 是这个函数里的局部变量,函数返回时它被 drop,fd 这时才关掉。顺序很要紧:fd 还开着时 EPOLL_CTL_DEL 一定找得到注册项;先关 fd 的话,有 dup 出来的副本时 epoll 里的项删不掉,旧 token 会继续冒出来(Poll 与 Registry 的「drop 之前没有 deregister」)。对 tokio 来说旧 token 就是一个可能已经释放的地址。
ScheduledIo 本身不在这里释放,先放进 pending_release,等下一次 turn() 开头的 release_pending_registrations 再放,因为驱动可能正拿着上一轮 poll 返回的、带这个地址的事件(反应器的「注销」一节)。
这里 1.52.3 和 1.54.1 有一处不同。1.52.3 第 293 行:
pub(super) fn deregister_source(
&self,
registration: &Arc<ScheduledIo>,
source: &mut impl Source,
) -> io::Result<()> {
// Deregister the source with the OS poller **first**
// Cleanup ALWAYS happens
let os_result = self.registry.deregister(source);
if self
.registrations
.deregister(&mut self.synced.lock(), registration)
{
self.unpark();
}
self.metrics.dec_fd_count();
os_result // Return error after cleanup
}
1.54.1 的 deregister_source 是 self.registry.deregister(source)?;:内核那边注销失败就提前返回,宁可让 ScheduledIo 泄漏也不释放。1.52.3 里的「Cleanup ALWAYS happens」来自 tokio 的 #7773(1.50.0),#8540 把它撤回,发布在 1.51.5、1.53.2 和 1.54.0,1.52.x 没有带上。来龙去脉在反应器的冷知识里。它只在 deregister 失败时起作用,而 tokio 自己的 socket 类型拥有 fd、在 fd 还开着时注销,EPOLL_CTL_DEL 不会失败;会失败的是调用方先关了 fd 的 AsyncFd。本站不用 AsyncFd,下一页再说。
实验:一个 echo 连接的全部 epoll 调用
src/bin/echo.rs:一个 tokio echo 服务器,客户端是同一进程里的一个 std 线程(阻塞 I/O),连上、写 hello、读回来、shutdown(Write)、读到 0。运行时照本站的样子用多线程、enable_all,只留一个 worker 让输出好读:
// A tokio echo server plus a blocking std client thread, in one process.
// Usage: echo [connections] (default 1)
use std::io::{Read, Write};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn main() -> std::io::Result<()> {
let conns: usize = std::env::args().nth(1).map_or(1, |s| s.parse().unwrap());
// Like the site (dioxus-server's block_on): multi-thread runtime, enable_all.
// One worker keeps the trace readable.
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()?;
rt.block_on(async move {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let client = std::thread::spawn(move || -> std::io::Result<String> {
let mut last = String::new();
for _ in 0..conns {
let mut s = std::net::TcpStream::connect(addr)?;
s.write_all(b"hello")?;
let mut buf = [0u8; 5];
s.read_exact(&mut buf)?;
s.shutdown(std::net::Shutdown::Write)?;
let n = s.read(&mut buf)?;
last = format!("echoed {:?}, then read {n}", String::from_utf8_lossy(&buf));
}
Ok(last)
});
let mut tasks = Vec::new();
for _ in 0..conns {
let (mut sock, _) = listener.accept().await?;
tasks.push(tokio::spawn(async move {
let mut buf = [0u8; 64];
loop {
let n = sock.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
sock.write_all(&buf[..n]).await.unwrap();
}
}));
}
for t in tasks {
t.await.unwrap();
}
let last = client.join().unwrap()?;
println!("os={} conns={conns} last client: {last}", std::env::consts::OS);
Ok::<_, std::io::Error>(())
})?;
#[cfg(tokio_unstable)]
{
let m = rt.metrics();
println!(
"io_driver: fd_registered={} fd_deregistered={} ready_events={}",
m.io_driver_fd_registered_count(),
m.io_driver_fd_deregistered_count(),
m.io_driver_ready_count()
);
}
Ok(())
}
依赖和本站的锁文件一致:
tokio = { version = "=1.52.3", features = ["full"] }
mio = { version = "=1.2.1", features = ["os-poll", "net", "os-ext"] }
mio = "=1.2.1" 是为了把 tokio 用的 mio 也钉在 1.2.1(同一个 semver 范围里 Cargo 只选一个版本)。Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0、--release,在 strace -f -e trace=epoll_create1,epoll_ctl,epoll_pwait,eventfd2,accept4,socket,socketpair,bind,listen,connect,read,write,recvfrom,sendto,shutdown,close,fcntl 下跑。pid 3 是主线程(block_on),pid 4 是 worker,pid 5 是客户端线程:
epoll_create1(EPOLL_CLOEXEC) = 3
eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK) = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
fcntl(3, F_DUPFD_CLOEXEC, 3) = 5
fcntl(5, F_SETFD, FD_CLOEXEC) = 0
socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0, [6, 7]) = 0
fcntl(6, F_DUPFD_CLOEXEC, 3) = 8
fcntl(8, F_SETFD, FD_CLOEXEC) = 0
epoll_ctl(5, EPOLL_CTL_ADD, 8, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
strace: Process 4 attached
[pid 3] socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 9
[pid 3] bind(9, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
[pid 3] listen(9, 128) = 0
[pid 3] epoll_ctl(5, EPOLL_CTL_ADD, 9, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffff9b9d8c00}) = 0
[pid 4] epoll_pwait(3strace: Process 5 attached
<unfinished ...>
[pid 5] socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 10
[pid 5] connect(10, {sa_family=AF_INET, sin_port=htons(45053), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
[pid 4] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0xffff9b9d8c00}], 1024, -1, NULL, 8) = 1
[pid 5] sendto(10, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid 3] accept4(9 <unfinished ...>
[pid 5] recvfrom(10 <unfinished ...>
[pid 4] epoll_pwait(3 <unfinished ...>
[pid 3] <... accept4 resumed>, {sa_family=AF_INET, sin_port=htons(48136), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid 3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffff9b9d8d80}) = 0
[pid 4] <... epoll_pwait resumed>, [{events=EPOLLIN|EPOLLOUT, data=0xffff9b9d8d80}], 1024, -1, NULL, 8) = 1
[pid 3] write(4, "\1\0\0\0\0\0\0\0", 8 <unfinished ...>
[pid 4] recvfrom(11 <unfinished ...>
[pid 3] <... write resumed>) = 8
[pid 4] <... recvfrom resumed>, "hello", 64, 0, NULL, NULL) = 5
[pid 4] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0 <unfinished ...>
[pid 5] <... recvfrom resumed>, "hello", 5, 0, NULL, NULL) = 5
[pid 4] <... sendto resumed>) = 5
[pid 5] shutdown(10, SHUT_WR <unfinished ...>
[pid 4] epoll_pwait(3 <unfinished ...>
[pid 5] <... shutdown resumed>) = 0
[pid 4] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0}, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffff9b9d8d80}], 1024, -1, NULL, 8) = 2
[pid 5] recvfrom(10 <unfinished ...>
[pid 4] recvfrom(11, "", 64, 0, NULL, NULL) = 0
[pid 4] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid 4] close(11 <unfinished ...>
[pid 5] <... recvfrom resumed>, "", 5, 0, NULL, NULL) = 0
[pid 4] <... close resumed>) = 0
[pid 5] close(10) = 0
[pid 4] epoll_pwait(3 <unfinished ...>
[pid 5] +++ exited with 0 +++
[pid 3] write(1, "os=linux conns=1 last client: ec"..., 58os=linux conns=1 last client: echoed "hello", then read 0
) = 58
[pid 3] epoll_ctl(5, EPOLL_CTL_DEL, 9, NULL) = 0
[pid 3] close(9) = 0
[pid 3] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 4] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0}], 1024, -1, NULL, 8) = 1
[pid 4] close(3) = 0
[pid 4] close(8) = 0
[pid 4] close(5) = 0
[pid 4] close(4) = 0
[pid 4] +++ exited with 0 +++
+++ exited with 0 +++
又跑了 2 次,每种系统调用的次数相同,只有 epoll_pwait 一次是 4 次、一次是 5 次:唤醒事件和 EPOLLRDHUP 有时在同一次 epoll_pwait 里返回(上面这次),有时分两次。逐段对照:
- 前 9 行是建运行时。
epoll_create1到fcntl(3, F_DUPFD_CLOEXEC, 3)是Driver::new的三步:fd 3 是Poll,fd 4 是Waker的 eventfd,用data=0(TOKEN_WAKEUP)注册,fd 5 是Handle里的Registry。之后所有epoll_ctl都发在 fd 5 上,所有epoll_pwait都发在 fd 3 上,两者是同一个 epoll 实例。socketpair加fcntl(6, …) = 8加data=0x1那一行是信号驱动:全局的 socket 对,读端复制一份,用TOKEN_SIGNAL注册。紧跟在两个F_DUPFD_CLOEXEC后面的F_SETFD不是 mio 发的:mio 和 std 只调一次fcntl(见 epoll 的「try_clone」一节),这一行来自静态链接的 musl。 - listener 也注册了两个方向。
TcpListener::bind是 mio 的bind(socket、bind、listen(9, 128),见 TCP),然后PollEvented::new用READABLE | WRITABLE注册,data是一个堆地址。监听 socket 永远不会可写,多注册的EPOLLOUT不会产生事件。 - 一个连接:
accept4、ADD、epoll_pwait、读写、epoll_pwait、读到 0、DEL、close。 accept 发生在主线程(listener.accept()在block_on的 future 里);新 socket 的data是另一个地址。tokio::spawn把任务交给 worker,worker 正睡在epoll_pwait里,所以主线程往 eventfd 写一个 1(write(4, …)),这是Handle::unpark调的Waker::wake。客户端shutdown之后,worker 拿到EPOLLIN|EPOLLOUT|EPOLLRDHUP,读到 0,任务结束,PollEvented被 drop:先EPOLL_CTL_DEL,再close(11)。 - 收尾。 listener 同样先
DEL再close;运行时关闭时再写一次 eventfd 把 worker 叫醒,worker 关掉 fd 3、8、5、4。信号驱动的 fd 8 没有DEL,epoll 的两个 fd 也随之关闭,不需要。
客户端连 100 次(echo 100),strace -c -f 只看五个调用(跑了三次,调用次数相同,耗时不同,这是第一次;recvfrom、sendto 里有一半是客户端线程的):
os=linux conns=100 last client: echoed "hello", then read 0
48.76 0.008801 22 400 recvfrom
25.93 0.004680 15 301 epoll_pwait
11.02 0.001989 9 200 sendto
8.64 0.001560 7 199 99 accept4
5.64 0.001018 4 204 epoll_ctl
按连接摊下来:epoll_ctl 204 = 100 × 2(ADD、DEL)+ 4(启动时 3 次 ADD、listener 1 次 DEL);epoll_pwait 约 3 次;accept4 每个连接成功 1 次,另有 99 次 EAGAIN:每 accept 一个,循环马上再 accept,下一个客户端还没连上。
macOS:数不到系统调用,数得到事件
macOS 上没有 strace,dtruss 要 root,这里用不了;但 tokio 在 tokio_unstable 下有驱动的计数器:io_driver_ready_count 在 turn() 里每分发一个非保留 token 的事件加一(上面代码里的 ready_count)。同一个 echo.rs 用 RUSTFLAGS="--cfg tokio_unstable" 构建,两个平台各跑 1 个和 100 个连接,每种 3 次,输出都相同:
os=macos conns=1 last client: echoed "hello", then read 0
io_driver: fd_registered=2 fd_deregistered=2 ready_events=5
os=macos conns=100 last client: echoed "hello", then read 0
io_driver: fd_registered=101 fd_deregistered=101 ready_events=500
os=linux conns=1 last client: echoed "hello", then read 0
io_driver: fd_registered=2 fd_deregistered=2 ready_events=3
os=linux conns=100 last client: echoed "hello", then read 0
io_driver: fd_registered=101 fd_deregistered=101 ready_events=300
(macOS 一行是 M2 Max、macOS、rustc 1.99.0;current_thread 版本的 echo_ct.rs 计数相同。)每个连接 Linux 3 个事件、macOS 5 个。哪 5 个,用 mio 1.2.1 直接重放同一个连接(src/bin/mio_seq.rs:listener 和 stream 都按 tokio 的方式注册 READABLE | WRITABLE,每一步之后 poll 一次,列出所有事件):
// The echo connection from echo.rs, replayed on mio 1.2.1 directly (the way
// tokio registers: listener and stream both READABLE | WRITABLE), printing
// every event of every poll.
use std::io::{Read, Write};
use std::time::Duration;
use mio::net::TcpListener;
use mio::{Events, Interest, Poll, Token};
const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);
fn poll_once(poll: &mut Poll, events: &mut Events, step: &str) {
poll.poll(events, Some(Duration::from_millis(100))).unwrap();
let list: Vec<String> = events
.iter()
.map(|e| {
let mut f = Vec::new();
if e.is_readable() { f.push("R"); }
if e.is_writable() { f.push("W"); }
if e.is_read_closed() { f.push("RC"); }
if e.is_write_closed() { f.push("WC"); }
if e.is_error() { f.push("ERR"); }
format!("{}:{}", e.token().0, f.join("|"))
})
.collect();
println!(" {step:<28} {} event(s) {list:?}", list.len());
}
fn main() {
println!("os={}", std::env::consts::OS);
let mut poll = Poll::new().unwrap();
let mut events = Events::with_capacity(1024);
let mut l = TcpListener::bind("127.0.0.1:0".parse().unwrap()).unwrap();
poll.registry().register(&mut l, Token(10), RW).unwrap();
let mut c = std::net::TcpStream::connect(l.local_addr().unwrap()).unwrap();
c.write_all(b"hello").unwrap();
poll_once(&mut poll, &mut events, "connect + client writes");
let (mut s, _) = l.accept().unwrap();
poll.registry().register(&mut s, Token(11), RW).unwrap();
poll_once(&mut poll, &mut events, "accepted, registered");
let mut buf = [0u8; 64];
let n = s.read(&mut buf).unwrap();
s.write_all(&buf[..n]).unwrap();
let mut back = [0u8; 5];
c.read_exact(&mut back).unwrap();
poll_once(&mut poll, &mut events, "echoed, client read it");
c.shutdown(std::net::Shutdown::Write).unwrap();
poll_once(&mut poll, &mut events, "client shutdown(Write)");
println!(" read after FIN: {:?}", s.read(&mut buf).map_err(|e| e.kind()));
}
两个平台各 3 次,输出相同:
os=macos
connect + client writes 1 event(s) ["10:R"]
accepted, registered 2 event(s) ["11:W", "11:R"]
echoed, client read it 1 event(s) ["11:W"]
client shutdown(Write) 1 event(s) ["11:R|RC"]
read after FIN: Ok(0)
os=linux
connect + client writes 1 event(s) ["10:R"]
accepted, registered 1 event(s) ["11:R|W"]
echoed, client read it 0 event(s) []
client shutdown(Write) 1 event(s) ["11:R|W|RC"]
read after FIN: Ok(0)
macOS 多出来的两个:一是读写两个方向在 kqueue 里是两个过滤器,各报一条(kqueue 的「event 模块」一节);二是写回 hello 之后、客户端读走数据、发送缓冲区空出来时,EV_CLEAR 的 EVFILT_WRITE 又触发了一次,epoll 这时什么都不报。对 tokio 来说这两个都是把已经置上的 WRITABLE 位再或一次,多一次 wake 调用,没有任务在等写时什么也不发生。所以开发机上的事件数比生产多,但任务看到的就绪状态相同。
和 tokio 1.54.1 比
把 tokio 1.52.3 和 1.54.1 的 src/runtime/io/、src/io/、src/net/tcp/ 逐个 diff,和 mio 调用有关的改动是这几处:
| 改动 | 1.52.3 | 1.54.1 | 对 mio 的调用 |
|---|---|---|---|
max_io_events_per_busy_tick(#8410) | 一个 Events | 另有 events_busy,0 超时的 poll 用它 | 0 超时时 poll 的容量可以更小 |
deregister_source(#8540) | 内核注销失败也释放 ScheduledIo | 失败就返回,不释放 | 调用不变,只改失败后的处理 |
accept 出来的流假定就绪(#8435) | 第一次读写前等驱动的第一个事件 | assume_ready(READABLE | WRITABLE) | 第一次读之前可能少一次 epoll_pwait |
max_io_events_per_tick(0)(#8476) | 接受 | assert!(capacity > 0) | 见下一页 |
Driver::new 的三个调用、add_source、turn() 里那一次 poll、PollEvented 的 Drop 都没有变。
第三项能在 strace 里看到。echo.rs 的多线程版本里看不出来:worker 本来就睡在 epoll_pwait 里,新 socket 的事件总是先于任务被处理,两个版本跑出来的系统调用一样(各 3 次)。换成 current_thread 运行时(src/bin/echo_ct.rs,只把 Builder::new_multi_thread().worker_threads(1) 换成 Builder::new_current_thread()),accept 和读在同一个线程上接连发生。依赖分别是上面那组和 tokio = "=1.54.1"、mio = "=1.2.4",strace -f -e trace=epoll_ctl,epoll_pwait,accept4,recvfrom,sendto,write,close。1.52.3,从 accept4 开始:
[pid 3] accept4(9 <unfinished ...>
[pid 4] sendto(10, "hello", 5, MSG_NOSIGNAL, NULL, 0 <unfinished ...>
[pid 3] <... accept4 resumed>, {sa_family=AF_INET, sin_port=htons(44108), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid 3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffffb3fd8c00} <unfinished ...>
[pid 4] <... sendto resumed>) = 5
[pid 3] <... epoll_ctl resumed>) = 0
[pid 4] recvfrom(10 <unfinished ...>
[pid 3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT, data=0xffffb3fd8c00}], 1024, -1, NULL, 8) = 1
[pid 3] recvfrom(11, "hello", 64, 0, NULL, NULL) = 5
[pid 3] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid 4] <... recvfrom resumed>, "hello", 5, 0, NULL, NULL) = 5
[pid 3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffffb3fd8c00}], 1024, -1, NULL, 8) = 1
[pid 3] recvfrom(11 <unfinished ...>
[pid 4] recvfrom(10 <unfinished ...>
[pid 3] <... recvfrom resumed>, "", 64, 0, NULL, NULL) = 0
[pid 3] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid 3] close(11) = 0
1.54.1:
[pid 3] accept4(9 <unfinished ...>
[pid 4] sendto(10, "hello", 5, MSG_NOSIGNAL, NULL, 0 <unfinished ...>
[pid 3] <... accept4 resumed>, {sa_family=AF_INET, sin_port=htons(53254), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid 4] <... sendto resumed>) = 5
[pid 3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffffa9419b80} <unfinished ...>
[pid 4] recvfrom(10 <unfinished ...>
[pid 3] <... epoll_ctl resumed>) = 0
[pid 3] recvfrom(11, "hello", 64, 0, NULL, NULL) = 5
[pid 3] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid 4] <... recvfrom resumed>, "hello", 5, 0, NULL, NULL) = 5
[pid 3] epoll_pwait(3, [{events=EPOLLOUT, data=0xffffa9419b80}], 1024, -1, NULL, 8) = 1
[pid 3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffffa9419b80}], 1024, -1, NULL, 8) = 1
[pid 4] recvfrom(10 <unfinished ...>
[pid 3] recvfrom(11, "", 64, 0, NULL, NULL) = 0
[pid 3] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid 3] close(11) = 0
各跑 3 次,服务端线程(pid 3)的调用顺序:
echo_ct152 1: accept4 epoll_ctl epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_ct152 2: accept4 epoll_ctl epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_ct152 3: accept4 epoll_ctl epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_ct154 1: accept4 epoll_ctl recvfrom sendto epoll_pwait epoll_pwait recvfrom epoll_ctl close
echo_ct154 2: accept4 epoll_ctl recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_ct154 3: accept4 epoll_ctl recvfrom sendto epoll_pwait epoll_pwait recvfrom epoll_ctl close
1.52.3 的新 ScheduledIo 就绪位是空的,第一次 read 只能先挂起,等 epoll_pwait 报出注册时的那个 EPOLLIN|EPOLLOUT。1.54.1 在 new_accepted 里调 assume_ready,直接去 recvfrom,数据已经在了。之后那次只有 EPOLLOUT 的事件,是 epoll 在报告注册时的状态(这时数据已经读走),对任务没有用;有时它和 EPOLLRDHUP 合在一次 epoll_pwait 里(第 2 次)。机制和这个改动的来历见 ScheduledIo 与就绪状态。
src/bin/echo_late.rs 和 echo_ct.rs 只差一行:客户端 connect 之后、write_all 之前 std::thread::sleep(Duration::from_millis(50))。1.54.1 仍然假定新连接可读,可这时数据还没到。服务端线程从 accept4 到 close(11) 的调用序列是什么?和 1.52.3 比呢?
动脑筋答案
两个版本各跑 3 次,服务端线程的序列每次都一样:
echo_late152 1: accept4 epoll_ctl epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_late154 1: accept4 epoll_ctl recvfrom epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_late152 2: accept4 epoll_ctl epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_late154 2: accept4 epoll_ctl recvfrom epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_late152 3: accept4 epoll_ctl epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
echo_late154 3: accept4 epoll_ctl recvfrom epoll_pwait epoll_pwait recvfrom sendto epoll_pwait recvfrom epoll_ctl close
1.54.1 多了一次 recvfrom,就在 epoll_ctl 之后。strace 原文:
[pid 3] accept4(9, {sa_family=AF_INET, sin_port=htons(41144), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 11
[pid 3] epoll_ctl(5, EPOLL_CTL_ADD, 11, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0xffffa6e43b80}) = 0
[pid 3] recvfrom(11, 0xffffa6e43d58, 64, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
[pid 3] epoll_pwait(3, [{events=EPOLLOUT, data=0xffffa6e43b80}], 1024, -1, NULL, 8) = 1
[pid 3] epoll_pwait(3 <unfinished ...>
[pid 3] <... epoll_pwait resumed>, [{events=EPOLLIN|EPOLLOUT, data=0xffffa6e43b80}], 1024, -1, NULL, 8) = 1
[pid 3] recvfrom(11 <unfinished ...>
[pid 3] <... recvfrom resumed>, "hello", 64, 0, NULL, NULL) = 5
[pid 3] sendto(11, "hello", 5, MSG_NOSIGNAL, NULL, 0) = 5
[pid 3] epoll_pwait(3, [{events=EPOLLIN|EPOLLOUT|EPOLLRDHUP, data=0xffffa6e43b80}], 1024, -1, NULL, 8) = 1
[pid 3] recvfrom(11, "", 64, 0, NULL, NULL) = 0
[pid 3] epoll_ctl(5, EPOLL_CTL_DEL, 11, NULL) = 0
[pid 3] close(11) = 0
- 假定错了,代价是一次返回
EAGAIN的recvfrom:mio 把它原样交给 tokio,tokio 清掉READABLE位,任务挂起。这正是assume_ready注释里说的「A wrong guess costs oneWouldBlock, which clears the readiness again」。 - 之后两个版本走的是同一条事件主线:第一次
epoll_pwait只有EPOLLOUT(注册时的状态,没有数据),任务没有醒来读;第二次EPOLLIN|EPOLLOUT是数据到了,才recvfrom。 - 1.52.3 一直等事件,所以一次
EAGAIN都没有,epoll_pwait次数两边相同。
小结
- 本站的 tokio 1.52.3 对 mio 只有五个入口:
Poll::new、Waker::new(Token(0))、Registry::try_clone(都在Driver::new里),每个 socket 一次register和一次deregister,以及turn()里每次一个poll。 - 每个 socket 都以
READABLE | WRITABLE注册一次,token 是ScheduledIo的地址,从不reregister;signalfeature 让运行时启动时多注册一个Token(1)的 socket。 - drop 时先
EPOLL_CTL_DEL再close,ScheduledIo留到下一轮turn()才释放;1.52.3 在内核注销失败时也释放它(#7773),1.54.1 撤回了。 - Linux 上一个 echo 连接是 1 次
accept4、2 次epoll_ctl、约 3 次epoll_pwait、3 个事件;macOS 上 5 个事件,多出来的是分开报告的写方向和发送缓冲区空出后的再次可写,任务看到的就绪状态相同。 - 1.54.1 对
accept出来的流先假定就绪,第一次读之前少一次epoll_pwait;猜错的代价是一次EAGAIN。