Rust in Everything
基于 mio 1.2.4

从零写一个迷你 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 的 epoll register 也只有这一个 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 的契约表)。
📝NOTE
没有蠢问题

问: 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,省掉了。

❗IMPORTANT
动脑筋:容量为 0 的 Events,poll 一次会怎样?

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 怎么做见
其他平台只有 epollkqueue、poll(2)、event ports、IOCP + AFD,用 cfg_attr(path = ...) 在编译期挑一个文件源码地图
Source 和 IoSourceregister 只认 AsRawFdevent::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,按平台 cfgToken、Interest 与 Event
Event 的其他方法四个 is_*is_priority、is_write_closed 等,以及 {:#?} 打印原始标志位epoll
IPv6、connect只有 IPv4 的监听和接受sockaddr_storage;非阻塞 connect 把 EINPROGRESS 当成 OkTCP
其他类型只有 TCPUdpSocket、Unix 域套接字、pipeUDP 与 Unix 域套接字
验证一个 echo 程序和一份 strace17 个测试文件,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) 后备这些需要它们的平台留给课程。

💬 关联讨论

发起讨论
加载中...