从零写一个迷你 mio
主线位置:两条主线都自己走一遍。注册主线:
register把Token和由Interest算出的掩码交给一次epoll_ctl(EPOLL_CTL_ADD),掩码总带EPOLLET;事件主线:poll把Events的缓冲区和容量交给一次epoll_wait,内核写了几项就是几个Event,token()读回data.u64。Waker是一个注册成可读的 eventfd,wake往里写 8 字节。
flowchart TB bind["TcpListener::bind"] --> sock["socket 带 NONBLOCK"] sock --> reg["register"] reg --> ctl["epoll_ctl ADD:掩码加 token"] wnew["Waker::new"] --> efd["eventfd2"] efd --> reg pollnode["Poll::poll"] --> wait["epoll_wait 写进 Events"] wait --> ev["Event:token 和标志位"] ev --> io["accept4、recv、send 到 EAGAIN"] io --> pollnode wake["wake"] --> wr["write 8 字节"] wr --> wait
本页只讲思路和关键代码。迷你 mio 是一个 292 行的库(去掉空行和注释约 226 行),只依赖 libc,整个文件套在 #![cfg(target_os = "linux")] 里。对照程序 mio_echo 依赖 mio,用来跑同一个 echo 服务器做比较:
[dependencies]
libc = "0.2"
mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
只支持 Linux epoll 和 IPv4 TCP。kqueue 支持、poll(2) 后备实现和配套作业放在付费课程里。
syscall!:和 mio 同一个宏
/// Call a libc function; a negative return becomes `io::Error::last_os_error()`.
macro_rules! syscall {
($fn:ident ( $($arg:expr),* $(,)? )) => {{
let res = unsafe { libc::$fn($($arg),*) };
if res < 0 { Err(io::Error::last_os_error()) } else { Ok(res) }
}};
}
这就是 mio syscall! 的形状:返回值小于 0 就把 errno 包成 io::Error,没有重试。EAGAIN 经 last_os_error 自动变成 ErrorKind::WouldBlock,EINTR 变成 Interrupted,原样交给调用方(Poll 与 Registry 的 EINTR 一节)。
Token 与 Interest
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Token(pub usize);
/// Non-empty set of interests: an empty registration is a `deregister`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Interest(NonZeroU8);
const fn bit(b: u8) -> Interest {
match NonZeroU8::new(b) {
Some(n) => Interest(n),
None => panic!("an interest bit is never zero"),
}
}
impl Interest {
pub const READABLE: Interest = bit(0b01);
pub const WRITABLE: Interest = bit(0b10);
// ... is_readable / is_writable,BitOr 是两个 NonZeroU8 按位或
}
/// Always edge-triggered, like mio since 0.7.
fn interests_to_epoll(interests: Interest) -> u32 {
let mut kind = libc::EPOLLET;
if interests.is_readable() {
kind |= libc::EPOLLIN | libc::EPOLLRDHUP;
}
if interests.is_writable() {
kind |= libc::EPOLLOUT;
}
kind as u32
}
Token和 mio 的Token一字不差。 它不需要任何别的东西,因为它不在 mio 里被查找,只被内核原样存取(Token、Interest 与 Event)。Interest用NonZeroU8。 和 mio 的Interest一样,空的关注集合构造不出来;常量在const fn里构造,零值那条分支只在编译期求值,不可能走到。mio 还有平台专有的AIO、LIO、PRIORITY,mini 只要读和写。- 掩码从
EPOLLET起步。 照抄 mio 的interests_to_epoll,少了EPOLLPRI那一支。关注可读就带EPOLLRDHUP,下面的is_read_closed要用它(epoll 的「为什么关注可读就要 EPOLLRDHUP」)。
Poll 与 Registry:一个 fd,没有表
pub struct Poll {
registry: Registry,
}
/// One epoll fd. No table of fds or tokens: the kernel keeps them.
pub struct Registry {
ep: OwnedFd,
}
impl Poll {
pub fn new() -> io::Result<Poll> {
let ep = syscall!(epoll_create1(libc::EPOLL_CLOEXEC))?;
// SAFETY: a fresh fd that nobody else owns
Ok(Poll { registry: Registry { ep: unsafe { OwnedFd::from_raw_fd(ep) } } })
}
// ...
}
impl Registry {
fn ctl(&self, op: libc::c_int, fd: RawFd, ev: Option<(Token, Interest)>) -> io::Result<()> {
let mut event = ev.map(|(token, interests)| libc::epoll_event {
events: interests_to_epoll(interests),
u64: token.0 as u64,
});
let ptr = event.as_mut().map_or(ptr::null_mut(), |e| e as *mut libc::epoll_event);
syscall!(epoll_ctl(self.ep.as_raw_fd(), op, fd, ptr)).map(drop)
}
pub fn register<S: AsRawFd>(&self, s: &S, token: Token, interests: Interest) -> io::Result<()> {
self.ctl(libc::EPOLL_CTL_ADD, s.as_raw_fd(), Some((token, interests)))
}
// reregister 是 EPOLL_CTL_MOD,deregister 是 EPOLL_CTL_DEL 加空指针
}
这一段对应 mio 的三层:Poll 包着 Registry,Registry 包着 epoll Selector,Selector 在 release 下就是一个 OwnedFd。mini 把后两层合成一层,字段还是那一个 fd。
- 注册就是一次系统调用。
token.0 as u64进了epoll_event的u64,之后 mini 再也不管它。mio 的 epollregister也只有这一个syscall!。 register只要&self。 epoll 允许别的线程在epoll_wait期间epoll_ctl,用户态没有要保护的状态,所以不需要锁,也不需要&mut(mio 为什么要&mut,见下面的框)。- 只认
AsRawFd。 mio 的register收&mut S where S: event::Source,经IoSource在 debug 下查SelectorId再到 selector(Source 与 IoSource);mini 没有这层,效果相当于 mio 的SourceFd,所有误用都交给内核报:重复注册是EEXIST,没注册就deregister是ENOENT,和 mio 在 Linux release 下的结果相同(Poll 与 Registry 的契约表)。
问: mini 的 register 收 &S,mio 的 Registry::register 收 &mut S。epoll 上明明不需要独占,mio 为什么要求?
答: 因为 event::Source 是跨平台的接口,要照顾需要用户态记账的平台。改成 &mut 的 #1180(0.7.0)提交说明是:一般不需要并发地注册同一个事件源,虽然多数平台上可以,但在需要用户态记账的平台(比如 Windows)上会让事情更复杂(Source 与 IoSource)。poll(2) 后备的 register 要往 IoSource 里写一个 Box<InternalState>,Windows 要建 SockState。mini 只有 epoll,每次注册都是一次无状态的 epoll_ctl,&S 就够了。mini 的 echo 服务器照抄 mio 版,写的是 register(&mut stream, ...),也能编译:&mut TcpStream 在这里被自动转成 &TcpStream。
poll:epoll_wait 写进 Events
/// Capacity is `maxevents`: one poll returns at most this many.
pub struct Events(Vec<libc::epoll_event>);
impl Poll {
pub fn poll(&mut self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
// Round up to whole ms (a 0.5 ms timeout must not become "don't wait"); clamp, don't wrap
let timeout = timeout
.map(|to| {
let ms = to.checked_add(Duration::from_nanos(999_999)).unwrap_or(to).as_millis();
libc::c_int::try_from(ms).unwrap_or(libc::c_int::MAX)
})
.unwrap_or(-1);
let buf = &mut events.0;
buf.clear();
let cap = libc::c_int::try_from(buf.capacity()).unwrap_or(libc::c_int::MAX);
let n = syscall!(epoll_wait(self.registry.ep.as_raw_fd(), buf.as_mut_ptr(), cap, timeout))?;
// SAFETY: the kernel initialized the first n entries, n <= capacity
unsafe { buf.set_len(n as usize) };
Ok(())
}
}
和 mio 的 epoll select 是同一个形状,三处都是有原因的:
- 超时向上取整。
as_millis是截断的,不加 999 999 纳秒,Some(500µs)会变成 0,poll就成了忙转;Duration::ZERO加上去仍不到 1 毫秒,显式的零超时不受影响(Poll 与 Registry)。 - 放不进
c_int就夹住,不要as。 1.0.0 到 1.2.0 的 mio 用的是as libc::c_int,超过约 24.8 天的超时被截成负数(永远等)或很小的数(提前返回),1.2.1 的 #1948 改成try_from加unwrap_or(c_int::MAX)(同上一页的冷知识)。mini 直接写成修好的样子。 - 容量就是
maxevents。Events是Vec<epoll_event>,先clear,把容量交给内核,内核写了n项就set_len(n)。poll的路上不分配,一次最多取这么多,没取完的留在内核里(Token、Interest 与 Event)。
Event 是 #[repr(transparent)] 包着 epoll_event,Events::iter 把每一项的引用直接转成 &Event,和 mio 的 Event 一样不拷贝:
impl Event {
pub fn token(&self) -> Token {
Token(self.0.u64 as usize)
}
fn has(&self, flag: libc::c_int) -> bool {
self.0.events as libc::c_int & flag != 0
}
pub fn is_readable(&self) -> bool {
self.has(libc::EPOLLIN)
}
// ... is_writable 看 EPOLLOUT,is_error 看 EPOLLERR
pub fn is_read_closed(&self) -> bool {
self.has(libc::EPOLLHUP) || (self.has(libc::EPOLLIN) && self.has(libc::EPOLLRDHUP))
}
}
is_read_closed 照抄 mio 的 epoll 版:两个方向都关了(EPOLLHUP),或者收到 FIN(EPOLLIN 和 EPOLLRDHUP 同时出现)。mio 的 is_readable 还算上 EPOLLPRI,mini 不注册 EPOLLPRI,省掉了。
src/bin/quiz_cap0.rs 对 mini 和 mio 各做一遍:建 Poll、Events::with_capacity(0),建一个 Waker 并 wake() 一次(保证有一个就绪事件),再 poll 100ms:
use std::time::Duration;
fn main() {
let mut poll = exp_mio_handson::Poll::new().unwrap();
let mut events = exp_mio_handson::Events::with_capacity(0);
let waker = exp_mio_handson::Waker::new(poll.registry(), exp_mio_handson::Token(1)).unwrap();
waker.wake().unwrap();
println!("mini: {:?}", poll.poll(&mut events, Some(Duration::from_millis(100))));
let mut poll = mio::Poll::new().unwrap();
let mut events = mio::Events::with_capacity(0);
let waker = mio::Waker::new(poll.registry(), mio::Token(1)).unwrap();
waker.wake().unwrap();
println!("mio: {:?}", poll.poll(&mut events, Some(Duration::from_millis(100))));
}
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。两行各打印 Ok(()) 还是一个错误?
Waker:一个 eventfd
/// An eventfd registered as READABLE; wake() adds 1 to its counter.
pub struct Waker {
fd: OwnedFd,
}
impl Waker {
pub fn new(registry: &Registry, token: Token) -> io::Result<Waker> {
let fd = syscall!(eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK))?;
// SAFETY: fresh fd
let waker = Waker { fd: unsafe { OwnedFd::from_raw_fd(fd) } };
registry.register(&waker.fd, token, Interest::READABLE)?;
Ok(waker)
}
pub fn wake(&self) -> io::Result<()> {
let one = 1u64.to_ne_bytes();
match syscall!(write(self.fd.as_raw_fd(), one.as_ptr().cast(), 8)) {
Ok(_) => Ok(()),
// Only when the counter would overflow: drain it, then try again
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
let mut buf = [0u8; 8];
syscall!(read(self.fd.as_raw_fd(), buf.as_mut_ptr().cast(), 8))?;
self.wake()
}
Err(e) => Err(e),
}
}
}
和 mio 的 eventfd Waker::new、wake 一样,三点:
- 它就是一个普通的注册。 eventfd 以
READABLE进 epoll,标志和 socket 一样带着EPOLLRDHUP|EPOLLET,token 是调用方给的那个。 - 从来不读。 注册时带了
EPOLLET,每次write都会让 epoll 重新报一次,计数器停在几都不要紧;只有计数器快溢出、write返回EAGAIN时才读一次清零(Waker 实测 3 次wake后计数 3,poll之后仍是 3)。 - 没有「一个
Poll一个Waker」的检查。 每个Waker是自己的 eventfd,建两个两个都能用,mio 在 Linux release 下也是这样;mio 的 debug 检查守的是 kqueue 和poll(2)上的限制(取舍汇总 的最后一节)。
TcpListener 与 TcpStream:一出生就是非阻塞
impl TcpListener {
/// socket (non-blocking from birth), SO_REUSEADDR, bind, listen(128): same four steps as mio.
pub fn bind(addr: SocketAddrV4) -> io::Result<TcpListener> {
let ty = libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC;
let fd = syscall!(socket(libc::AF_INET, ty, 0))?;
// SAFETY: fresh fd; dropping it closes the socket on any error below
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
let on: libc::c_int = 1;
let len = size_of::<libc::c_int>() as libc::socklen_t;
let on_ptr = (&on as *const libc::c_int).cast();
syscall!(setsockopt(fd.as_raw_fd(), libc::SOL_SOCKET, libc::SO_REUSEADDR, on_ptr, len))?;
let sin = to_sockaddr(addr);
let sin_ptr = (&sin as *const libc::sockaddr_in).cast();
let sin_len = size_of::<libc::sockaddr_in>() as libc::socklen_t;
syscall!(bind(fd.as_raw_fd(), sin_ptr, sin_len))?;
syscall!(listen(fd.as_raw_fd(), 128))?;
Ok(TcpListener { fd })
}
/// accept4 sets O_NONBLOCK on the new fd: Linux's accept does not inherit it.
pub fn accept(&self) -> io::Result<(TcpStream, SocketAddrV4)> {
// SAFETY: as above
let mut sin: libc::sockaddr_in = unsafe { zeroed() };
let mut len = size_of::<libc::sockaddr_in>() as libc::socklen_t;
let sin_ptr = (&mut sin as *mut libc::sockaddr_in).cast();
let flags = libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC;
let fd = syscall!(accept4(self.fd.as_raw_fd(), sin_ptr, &mut len, flags))?;
// SAFETY: fresh fd
Ok((TcpStream { fd: unsafe { OwnedFd::from_raw_fd(fd) } }, from_sockaddr(&sin)))
}
}
- 四步和 mio 一样。 mio 的
TcpListener::bind文档就列了这四步:建 socket、设SO_REUSEADDR、bind、listen,backlog 用和 std 一样的 128(TCP)。 - 非阻塞在
socket这一次调用里就设好。 Linux 的socket类型参数可以带SOCK_NONBLOCK|SOCK_CLOEXEC,mio 的new_socket在 Linux 上这么做;不带的话要像 macOS 那样事后补两次fcntl(一个事件循环 的「macOS 上换成了什么」)。 accept4不能换成accept。 Linux 的accept不把监听 socket 的O_NONBLOCK带给新 fd,新连接会是阻塞的,读干净后的那次recv会卡住整个事件循环。mio 的accept在 Linux 上也用accept4(一个事件循环 的冷知识讲了 x86 Android 上的例外)。- 出错时不漏 fd。
socket一返回就交给OwnedFd,后面任何一步?返回,fd 都随它关掉。
TcpStream 的读写直接是 recv 和 send:
impl Read for TcpStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = syscall!(recv(self.fd.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len(), 0))?;
Ok(n as usize)
}
}
impl Write for TcpStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// MSG_NOSIGNAL: a closed peer gives EPIPE instead of killing us with SIGPIPE (std does the same)
let n = syscall!(send(self.fd.as_raw_fd(), buf.as_ptr().cast(), buf.len(), libc::MSG_NOSIGNAL))?;
Ok(n as usize)
}
// flush 什么都不做
}
mio 的 TcpStream 的 Read 是 do_io(|inner| inner.read(buf)),epoll 上 do_io 什么也不加,最后是 std 的 recv / send(Source 与 IoSource)。mini 跳过 do_io 这一层,因为 epoll 上不需要重新武装;换到 poll(2) 或 Windows,这正是缺的那一步(poll(2) 后备)。两个类型都没有 Drop:OwnedFd 关 fd,不顺手 deregister,和 mio 在 epoll 上一样。
跑一下:同一个 echo 服务器
一个事件循环 的 echo 服务器原样拿来:两个线程,主线程是事件循环,客户端线程用阻塞的 std socket 连两次、各发一句、半关闭第一条、再 wake()。复制成 src/bin/mio_echo.rs(依赖 mio)和 src/bin/mini_echo.rs(依赖 mini),两份源码的全部差别:
3c3
< use std::net::SocketAddr;
---
> use std::net::SocketAddrV4;
7,8c7
< use mio::net::{TcpListener, TcpStream};
< use mio::{Events, Interest, Poll, Token, Waker};
---
> use exp_mio_handson::{Events, Interest, Poll, TcpListener, TcpStream, Token, Waker};
134c133
< fn run_client(addr: SocketAddr, waker: &Waker) -> io::Result<std::net::TcpStream> {
---
> fn run_client(addr: SocketAddrV4, waker: &Waker) -> io::Result<std::net::TcpStream> {
mini 只支持 IPv4,bind 和 local_addr 用 SocketAddrV4,其余一字未改。交叉编译成 aarch64-unknown-linux-musl,在容器里各跑 3 次。mini 的第 1 次:
[server] poll returned 1 event(s)
[server] accepted 127.0.0.1:57622 as Token(2)
[server] accepted 127.0.0.1:57630 as Token(3)
[server] poll returned 1 event(s)
[server] Token(2) readable=true writable=false read_closed=false closed=false
[client] got back "hello from client 0"
[server] poll returned 1 event(s)
[server] Token(3) readable=true writable=false read_closed=false closed=false
[client] got back "hello from client 1"
[server] poll returned 1 event(s)
[server] Token(2) readable=true writable=false read_closed=true closed=true
[server] Token(2) deregistered
[client] c0 read 0 byte(s): server closed it; waking server
[server] poll returned 1 event(s)
[server] woken, shutting down
[server] Token(3) deregistered at shutdown
[server] done
6 次运行除端口号外逐行相同,也和全景篇里 mio 在 Linux、macOS 上的输出相同:五次 poll,第一次一个事件 accept 两个连接,半关闭时 read_closed=true。
strace:和 mio 逐行对照
和全景篇同样的方法:strace -ff -o,每个线程一个文件,过滤条件是 epoll_create1,epoll_ctl,epoll_pwait,epoll_wait,eventfd2,accept4,socket,bind,listen,connect,read,write,close,setsockopt,shutdown,recvfrom,sendto,getsockname(比全景篇多一个 getsockname,看 local_addr)。两个程序各跑 3 次,把端口号和栈地址换成占位符之后,同一个程序 3 次的文件逐行相同。mini 的服务器线程,第 1 次,50 行全文:
epoll_create1(EPOLL_CLOEXEC) = 3
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 4
setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(4, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
listen(4, 128) = 0
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK) = 5
epoll_ctl(3, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(34731), sin_addr=inet_addr("127.0.0.1")}, [16]) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0}], 128, -1, NULL, 8) = 1
write(1, "[server] poll returned 1 event(s"..., 34) = 34
accept4(4, {sa_family=AF_INET, sin_port=htons(39592), sin_addr=inet_addr("127.0.0.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 8
epoll_ctl(3, EPOLL_CTL_ADD, 8, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x2}) = 0
write(1, "[server] accepted 127.0.0.1:3959"..., 46) = 46
accept4(4, {sa_family=AF_INET, sin_port=htons(39602), sin_addr=inet_addr("127.0.0.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 9
epoll_ctl(3, EPOLL_CTL_ADD, 9, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x3}) = 0
write(1, "[server] accepted 127.0.0.1:3960"..., 46) = 46
accept4(4, 0xfffff29aa848, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
epoll_pwait(3, [{events=EPOLLIN, data=0x2}], 128, -1, NULL, 8) = 1
write(1, "[server] poll returned 1 event(s"..., 34) = 34
recvfrom(8, "hello from client 0", 4096, 0, NULL, NULL) = 19
recvfrom(8, 0xfffff29a9810, 4096, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
sendto(8, "hello from client 0", 19, MSG_NOSIGNAL, NULL, 0) = 19
write(1, "[server] Token(2) readable=true "..., 78) = 78
epoll_pwait(3, [{events=EPOLLIN, data=0x3}], 128, -1, NULL, 8) = 1
write(1, "[server] poll returned 1 event(s"..., 34) = 34
recvfrom(9, "hello from client 1", 4096, 0, NULL, NULL) = 19
recvfrom(9, 0xfffff29a9810, 4096, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
sendto(9, "hello from client 1", 19, MSG_NOSIGNAL, NULL, 0) = 19
write(1, "[server] Token(3) readable=true "..., 78) = 78
epoll_pwait(3, [{events=EPOLLIN|EPOLLRDHUP, data=0x2}], 128, -1, NULL, 8) = 1
write(1, "[server] poll returned 1 event(s"..., 34) = 34
recvfrom(8, "", 4096, 0, NULL, NULL) = 0
write(1, "[server] Token(2) readable=true "..., 76) = 76
epoll_ctl(3, EPOLL_CTL_DEL, 8, NULL) = 0
write(1, "[server] Token(2) deregistered\n", 31) = 31
close(8) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0x1}], 128, -1, NULL, 8) = 1
write(1, "[server] poll returned 1 event(s"..., 34) = 34
write(1, "[server] woken, shutting down\n", 30) = 30
epoll_ctl(3, EPOLL_CTL_DEL, 9, NULL) = 0
write(1, "[server] Token(3) deregistered a"..., 43) = 43
close(9) = 0
epoll_ctl(3, EPOLL_CTL_DEL, 4, NULL) = 0
close(4) = 0
close(7) = 0
write(1, "[server] done\n", 14) = 14
close(5) = 0
close(3) = 0
+++ exited with 0 +++
把 mio 的同一个文件和它做 diff(端口和地址已换成占位符,< 是 mio,> 是 mini),全部差别只有 4 行:
9c9
< getsockname(4, {sa_family=AF_INET, sin_port=htons(P), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
---
> getsockname(4, {sa_family=AF_INET, sin_port=htons(P), sin_addr=inet_addr("127.0.0.1")}, [16]) = 0
12c12
< accept4(4, {sa_family=AF_INET, sin_port=htons(P), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 8
---
> accept4(4, {sa_family=AF_INET, sin_port=htons(P), sin_addr=inet_addr("127.0.0.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 8
15c15
< accept4(4, {sa_family=AF_INET, sin_port=htons(P), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 9
---
> accept4(4, {sa_family=AF_INET, sin_port=htons(P), sin_addr=inet_addr("127.0.0.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 9
18c18
< accept4(4, ADDR, [128], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
---
> accept4(4, ADDR, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
客户端线程的 16 行两边完全相同(它只用 std,外加最后那次 write(5, "\1\0\0\0\0\0\0\0", 8) 的 wake())。
- 相同的部分就是 mio 在 Linux 上的全部工作。 建实例、四步
bind、每次注册一次epoll_ctl、每次poll一次epoll_pwait(aarch64 上 musl 的epoll_wait就是它,epoll)、accept4和recvfrom到EAGAIN、EPOLL_CTL_DEL、eventfd 的一次写。mini 这 292 行做到的,就是这 50 行里的每一个系统调用和它的每一个参数。 - 唯一的差别是地址缓冲区的长度。
[128 => 16]是 strace 的写法:传进去 128,内核改成 16。mio 的accept用sockaddr_storage(128 字节),因为同一个函数也要接 IPv6;getsockname来自 std 的local_addr,也是sockaddr_storage。mini 只有 IPv4,用 16 字节的sockaddr_in。 - 读写的系统调用名字也一样。 mini 调
libc::recv/libc::send,mio 经 std 调同样的函数;aarch64 上没有recv/send系统调用,musl 用recvfrom/sendto实现,MSG_NOSIGNAL两边都带着。
和 mio 比:少了什么
下表每一行是 mini 缺的一样东西:
| 缺的 | mini 现在怎样 | mio 怎么做 | 见 |
|---|---|---|---|
| 其他平台 | 只有 epoll | kqueue、poll(2)、event ports、IOCP + AFD,用 cfg_attr(path = ...) 在编译期挑一个文件 | 源码地图 |
Source 和 IoSource | register 只认 AsRawFd | event::Source 三个方法要 &mut self;IoSource 包住 std 类型,do_io 在 poll(2)、Windows 上重新武装 | Source 与 IoSource |
| debug 检查 | 误用全靠内核报错 | debug 下 SelectorId 和 has_waker 在系统调用之前拦下 | Poll 与 Registry |
try_clone | 没有 | F_DUPFD_CLOEXEC 复制 epoll fd,给别的线程注册 | epoll |
Interest 的其他位 | 只有读写 | PRIORITY(EPOLLPRI)、AIO、LIO,按平台 cfg | Token、Interest 与 Event |
Event 的其他方法 | 四个 is_* | is_priority、is_write_closed 等,以及 {:#?} 打印原始标志位 | epoll |
IPv6、connect | 只有 IPv4 的监听和接受 | sockaddr_storage;非阻塞 connect 把 EINPROGRESS 当成 Ok | TCP |
| 其他类型 | 只有 TCP | UdpSocket、Unix 域套接字、pipe | UDP 与 Unix 域套接字 |
| 验证 | 一个 echo 程序和一份 strace | 17 个测试文件,Linux、macOS、Windows、FreeBSD 上跑,poll(2) 后备和 Miri 也挡合并 | 源码地图 |
前三行是 mini 能这么短的原因:只有一个平台,epoll 上 IoSourceState 本来就是零大小的,mio 的那几层在这里本来就什么都不做。换一个平台,这几层就都要回来,kqueue 支持、poll(2) 后备实现和配套作业在付费课程里。
动脑筋答案
跑 3 次,输出相同:
mini: Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
mio: Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
- 两个都是
EINVAL,明明已经有一个就绪事件。strace(-e trace=epoll_create1,epoll_ctl,epoll_pwait,eventfd2,write)里两边的epoll_pwait都是这一行(第二次的 epoll fd 是 5):epoll_pwait(3, 0x8, 0, 100, NULL, 8) = -1 EINVAL (Invalid argument)。 - 第三个参数
maxevents是 0,epoll_wait(2) 要求它大于 0,否则返回EINVAL。第二个参数0x8是空Vec的悬空指针,值等于epoll_event在 aarch64 上的对齐。 - mini 和 mio 都把容量原样交给内核,所以连错误都一样。macOS 的
kevent接受nevents为 0、立即返回,同一段 mio 代码在那里变成忙等(Token、Interest 与 Event、常见坑:一次 poll 的事件数)。
小结
- 一个 Linux 版的迷你 mio 只需要一个 epoll fd:
register是一次epoll_ctl,Token放进data.u64、原样取回,没有任何用户态的表;Interest用NonZeroU8,掩码从EPOLLET起步,读加EPOLLRDHUP。 poll照抄 mio 的三处细节:超时向上取整到毫秒、放不进c_int就夹住(#1948 修的就是这里)、Events的容量就是maxevents,内核直接写进Vec。Waker是注册成可读的 eventfd,wake写 8 字节,从不读回;socket 在socket和accept4这一次调用里就设成非阻塞,send带MSG_NOSIGNAL。- 同一个 echo 服务器跑在 mini 和 mio 上,输出相同;服务器线程 50 行 strace 只差 4 行,都是地址缓冲区的长度(mio 用 128 字节的
sockaddr_storage,mini 用 16 字节的sockaddr_in)。 - mini 能这么短,是因为 epoll 上 mio 的
IoSource、do_io、selector 选择都是直通;kqueue、poll(2)后备这些需要它们的平台留给课程。