Rust in Everything
基于 mio 1.2.4

Token、Interest 与 Event

主线位置:两条主线上流动的数据。注册主线把 Token 和 Interest 交给内核(Interest 变成 epoll 的事件掩码或 kqueue 的过滤器,Token 存进内核的那一项里);事件主线从内核取回一批平台事件结构,装在 Events 里,用户通过 Event 的方法读出 token 和就绪状态。调用这两步的 register 和 poll 在 Poll 与 Registry。

flowchart TB
  tok["Token(usize)"] --> ctl["epoll_ctl / kevent"]
  int["Interest 位集合"] -->|"掩码或过滤器"| ctl
  ctl --> kern["内核里的一项:u64 / udata"]
  kern -->|"epoll_wait / kevent"| evs["Events:Vec 平台结构"]
  evs --> ev["Event:透明包装"]
  ev --> tk2["token()"]
  ev --> flags["is_readable() 等:查标志位"]

涉及的文件都很短:token.rs 140 行(几乎全是文档)、interest.rs 202 行、event/event.rs 和 event/events.rs 各 230 行。真正干活的是 epoll 和 kqueue selector 里各自的 event 模块。

Token:一个 usize,原样进出内核

Token 的全部定义:

#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Token(pub usize);

impl From<Token> for usize {
    fn from(val: Token) -> usize {
        val.0
    }
}

字段是 pub 的,没有保留值,没有校验。它怎么进内核,看两个 selector 的 register。epoll(register):

        let mut event = libc::epoll_event {
            events: interests_to_epoll(interests),
            u64: usize::from(token) as u64,
            #[cfg(target_os = "redox")]
            _pad: 0,
        };

epoll_event 的第二个字段是一个 64 位的联合体,内核不解释它,事件回来时原样带回。mio 用的是其中的 u64。kqueue 用 kevent 的 udata(一个 *mut c_void),由 kevent! 宏填进去:

macro_rules! kevent {
    ($id: expr, $filter: expr, $flags: expr, $data: expr) => {
        libc::kevent {
            ident: $id as libc::uintptr_t,
            filter: $filter as Filter,
            flags: $flags,
            udata: $data as UData,
            ..unsafe { mem::zeroed() }
        }
    };
}

register 调它时传的 $data 是 token.0。取回来的两个函数各一行,epoll 的是 Token(event.u64 as usize),kqueue 的是 Token(event.0.udata as usize)。usize 放进 u64 或指针宽度的 udata 都不会丢位。

所以 mio 里没有 fd 到 token 的表,也没有 token 到 source 的表。用户挑 token、自己维护 token 到 source 的映射(文档推荐 slab);tokio 直接把 ScheduledIo 的地址当 token(见 tokio 卷的反应器)。

实验:在 strace 和 kevent 里看见 token

src/bin/token.rs 注册两个 source,一个用 Token(42),一个用占满 64 位的 Token(0x1234_5678_9abc_def0),然后 poll 两次。raw(e) 是实验 crate 里的一个辅助函数:Event 是 #[repr(transparent)] 包着平台结构的(下一节),所以可以把 &Event 转成 &libc::epoll_event 或 &libc::kevent 读出原始字段。这依赖私有布局,只用于观察。

use std::time::Duration;

use exp_mio_poll::raw;
use mio::net::{TcpListener, TcpStream};
use mio::{Events, Interest, Poll, Token};

fn main() -> std::io::Result<()> {
  let mut poll = Poll::new()?;
  let mut listener = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
  poll.registry().register(&mut listener, Token(42), Interest::READABLE)?;
  let mut stream = TcpStream::connect(listener.local_addr()?)?;
  let big = Token(0x1234_5678_9abc_def0);
  poll.registry().register(&mut stream, big, Interest::READABLE | Interest::WRITABLE)?;

  let mut events = Events::with_capacity(8);
  for round in 1..=2 {
    poll.poll(&mut events, Some(Duration::from_millis(100)))?;
    for e in &events {
      println!("poll #{round}: Token({:#x})  {}", e.token().0, raw(e));
    }
  }
  Ok(())
}

Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,strace -e trace=epoll_ctl,epoll_pwait:

epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x2a}) = 0
epoll_ctl(3, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0x123456789abcdef0}) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0x2a}, {events=EPOLLOUT, data=0x123456789abcdef0}], 8, 100, NULL, 8) = 2
poll #1: Token(0x2a)  events=IN data=0x2a
poll #1: Token(0x123456789abcdef0)  events=OUT data=0x123456789abcdef0
epoll_pwait(3, [], 8, 100, NULL, 8)     = 0
+++ exited with 0 +++

M2 Max、macOS、rustc 1.99.0,--release,跑了 3 次,输出相同:

poll #1: Token(0x123456789abcdef0)  filter=WRITE flags=CLEAR fflags=0 data=146988 udata=0x123456789abcdef0
poll #2: Token(0x2a)  filter=READ flags=CLEAR fflags=0 data=1 udata=0x2a

data=0x2a 就是 42。epoll_ctl 写进去什么,epoll_pwait 就带回什么,udata 同理。两个平台上事件的分批不同(Linux 一次拿到两个,macOS 分两次),这和 token 无关,是两边内核什么时候认为「监听 socket 有连接」的时序不同。kqueue 事件里的 data 是过滤器自己的附加信息:EVFILT_WRITE 的 146988 是发送缓冲区的剩余空间,监听 socket 上 EVFILT_READ 的 1 是待 accept 的连接数,mio 不用它们。

Interest:非空的位集合

Interest 是一个 NonZeroU8,每种关注占一位:

#[derive(Copy, PartialEq, Eq, Clone, PartialOrd, Ord)]
pub struct Interest(NonZeroU8);

// These must be unique.
const READABLE: u8 = 0b0001;
const WRITABLE: u8 = 0b0010;
// The following are not available on all platforms.
const AIO: u8 = 0b0100;
const LIO: u8 = 0b1000;
const PRIORITY: u8 = 0b10000;

用 NonZeroU8 而不是 u8,表达的是「空的关注集合不存在」:不关心任何事件的注册没有意义,要停止接收就 deregister。这条约束落在 API 上:

  • 公开的常量只有单个位,add(BitOr 也转给它)是两个非零值按位或,结果不可能为零,所以里面可以放心地 NonZeroU8::new_unchecked。add 是 const fn,可以写 const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);。
  • remove 可能减成零,所以返回 Option<Interest>:
    pub fn remove(self, other: Interest) -> Option<Interest> {
        NonZeroU8::new(self.0.get() & !other.0.get()).map(Interest)
    }
  • Debug 实现末尾有一句 debug_assert!(one, "printing empty interests");,正常途径构造不出空集合,这句只是防御。

NonZeroU8 还顺带让 Option<Interest> 也只占一个字节(零值留给 None)。src/bin/interest.rs 在两个平台上的输出相同:

size_of::<Interest>()         = 1
size_of::<Option<Interest>>() = 1
READABLE | WRITABLE           = READABLE | WRITABLE
rw.remove(READABLE)           = Some(WRITABLE)
rw.remove(rw)                 = None
READABLE < WRITABLE           = true

最后一行来自派生的 Ord:按那个 u8 比较,没有别的含义。

平台专有的三位

五个位常量在所有平台上都定义,公开的关联常量却按平台 cfg:

常量位在哪些 target_os 上存在selector 里谁看它
Interest::READABLE0b0001全部各平台 register
Interest::WRITABLE0b0010全部各平台 register
Interest::AIO0b0100dragonfly、freebsd、ios、macos、tvos、visionos、watchoskqueue 的 register 不看;Event::is_aio 认 EVFILT_AIO 事件
Interest::LIO0b1000freebsdkqueue 的 register 不看;Event::is_lio 认 EVFILT_LIO 事件
Interest::PRIORITY0b10000linux、androidepoll 的 interests_to_epoll,加 EPOLLPRI

is_aio、is_lio、is_priority 这三个查询方法在所有平台上都有,只是在没有对应常量的平台上永远返回 false。常量本身被 cfg 掉了,写错平台是编译错误而不是运行时的静默失效。src/bin/platform_interest.rs 里同时用了 Interest::PRIORITY 和 Interest::AIO,在 macOS 上编译:

error[E0599]: no associated function or constant named `PRIORITY` found for struct `Interest` in the current scope
 --> src/bin/platform_interest.rs:4:42
  |
4 |   let _ = Interest::READABLE | Interest::PRIORITY;
  |                                          ^^^^^^^^ associated function or constant not found in `Interest`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `exp-mio-poll` (bin "platform_interest") due to 1 previous error

交叉编译到 aarch64-unknown-linux-musl:

error[E0599]: no associated function or constant named `AIO` found for struct `Interest` in the current scope
 --> src/bin/platform_interest.rs:5:42
  |
5 |   let _ = Interest::READABLE | Interest::AIO;
  |                                          ^^^ associated function or constant not found in `Interest`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `exp-mio-poll` (bin "platform_interest") due to 1 previous error

Interest 到了内核变成什么

epoll 这边是一个函数,interests_to_epoll:

fn interests_to_epoll(interests: Interest) -> u32 {
    let mut kind = EPOLLET;

    if interests.is_readable() {
        kind = kind | EPOLLIN | EPOLLRDHUP;
    }

    if interests.is_writable() {
        kind |= EPOLLOUT;
    }

    if interests.is_priority() {
        kind |= EPOLLPRI;
    }

    kind as u32
}

EPOLLET 无条件加上,这就是「mio 是边沿触发」在 Linux 上的全部实现;关注可读时顺带要 EPOLLRDHUP,下面 is_read_closed 要用它。kqueue 这边没有掩码,register 只看 is_writable() 和 is_readable(),各自对应一个 EVFILT_WRITE、EVFILT_READ 过滤器,带 EV_CLEAR(kqueue 的边沿触发)。AIO / LIO 位在这里被忽略。为什么要 EPOLLRDHUP、kqueue 一次提交两项时怎么拿错误,见 epoll 和 kqueue。

📝NOTE
冷知识:is_priority 比 Interest::PRIORITY 早出现了好几个版本

Event::is_priority 和 epoll 的「查 EPOLLPRI」早就在了,但注册时没有任何办法让内核报告 EPOLLPRI。issue #1645(「A way to add EPOLLPRI event type to the epoll selector」)的提问者在用 Video4Linux2 时需要等 POLLPRI,直接问:「Why is the is_priority function there? It is of no use at the moment, right?」PR #1647(「Add Interest::PRIORITY」,首次发布于 0.8.6)加上了 PRIORITY 位、Linux / Android 上的公开常量和 interests_to_epoll 里的 if interests.is_priority() { kind |= EPOLLPRI; },说明是「To trigger Event::is_priority on Linux and Android.」同一段说明还写了 kqueue 为什么没有:「In the future we might want to include EV_OOBAND for kqueue, but that seems to be Apple only (FreeBSD and OpenBSD don't implement it at least).」到 1.2.4,kqueue 的 is_priority 仍然是「kqueue doesn't have priority indicators」并返回 false。

Event:平台结构的透明包装

Event 只有一个字段:

#[derive(Clone)]
#[repr(transparent)]
pub struct Event {
    inner: sys::Event,
}

sys::Event 在 epoll 上是 pub type Event = libc::epoll_event;,在 kqueue 上是一个同样 #[repr(transparent)] 的新类型 Event(libc::kevent)。Events 内部的 Vec 存的就是这些平台结构,迭代时 from_sys_event_ref 把 &sys::Event 直接转成 &Event:

    pub(crate) fn from_sys_event_ref(sys_event: &sys::Event) -> &Event {
        unsafe {
            // This is safe because the memory layout of `Event` is
            // the same as `sys::Event` due to the `repr(transparent)` attribute.
            &*(sys_event as *const sys::Event as *const Event)
        }
    }

内核写进 Vec 的那块内存就是用户读的 Event,取事件的路上没有任何转换或拷贝。代价是每次调用 is_readable() 之类的方法都要现查一次标志位,而它们都只是几次按位与。

📝NOTE
冷知识:kqueue 上的 Event 直到 1.1.0 才是 Send

1.1.0 之前,kqueue 的 sys::Event 就是 pub type Event = libc::kevent;。kevent 的 udata 字段是 *mut c_void,裸指针既不 Send 也不 Sync,于是 mio::event::Event 在 macOS / BSD 上不能跨线程传递,而在 Linux(epoll_event 只有整数字段)上可以。issue #1905 报告的正是这种情况:一个事件循环库在 Linux 和 Windows 上没问题,到 macOS 上编译报「*mut c_void cannot be sent between threads safely」。PR #1906(「fix: make Event thread-safe with kevent」,首次发布于 1.1.0,CHANGELOG 写作「Make Event Send and Sync on platforms using kqueue」)把它换成 #[repr(transparent)] 的新类型 Event(libc::kevent),加上 unsafe impl Send / unsafe impl Sync 和 Deref,event 模块里的 event.filter 等也都改成了 event.0.filter。理由和文件里 Events 早就有的那段注释一样:mio 只把 udata 当整数(token)读,从不解引用。

每个 is_* 方法查哪些位

Event 的方法都是一行转发到 sys::event::*。两个 selector 的实现(epoll 的 event 模块,kqueue 的 event 模块)整理成表:

方法epoll(epoll_event.events)kqueue(kevent)
token()u64 as usizeudata as usize
is_readable()EPOLLIN 或 EPOLLPRIfilter == EVFILT_READ;在 FreeBSD 和 Apple 各平台上 EVFILT_USER 也算
is_writable()EPOLLOUTfilter == EVFILT_WRITE
is_error()EPOLLERRflags 含 EV_ERROR;或含 EV_EOF 且 fflags != 0
is_read_closed()EPOLLHUP;或 EPOLLIN 与 EPOLLRDHUP 同时filter == EVFILT_READ 且含 EV_EOF
is_write_closed()EPOLLHUP;或 EPOLLOUT 与 EPOLLERR 同时;或恰好只有 EPOLLERRfilter == EVFILT_WRITE 且含 EV_EOF
is_priority()EPOLLPRI恒为 false
is_aio()恒为 false(「Not supported in the kernel, only in libc.」)filter == EVFILT_AIO(DragonFly、FreeBSD、Apple 各平台)
is_lio()恒为 falsefilter == EVFILT_LIO(仅 FreeBSD)

几处值得对照源码看的地方:

  • kqueue 一个事件只有一个方向。 一个 kevent 只属于一个过滤器,is_readable 和 is_writable 不可能同时为真;同一个 socket 又可读又可写,就是两个事件、同一个 token。epoll 一个事件可以同时带 EPOLLIN|EPOLLOUT。
  • EVFILT_USER 算可读。 这是给 Waker 用的,注释说「On platforms that use eventfd or a unix pipe it will emit a readable event so we'll fake that here as well.」见 Waker。
  • is_read_closed 在 epoll 上要 EPOLLIN 和 EPOLLRDHUP 同时出现,只有 EPOLLRDHUP 不算;EPOLLHUP 单独就算。注册时 mio 只在关注可读时要 EPOLLRDHUP,没有关注可读的 source 只能靠 EPOLLHUP。
  • is_write_closed 的「恰好只有 EPOLLERR」 一支是 0.7.1 加的(#1350,「Report EPOLLERR as is_write_closed」)。提交说明:管道读端关闭时「we've seen a number of event variations ... including EPOLLHUP and EPOLLOUT|EPOLLERR. And now we've also seen just EPOLLERR, without EPOLLOUT.」它让 tokio 的一个进程测试失败过。
  • 文档和代码对不上的一处。 Event::is_error 文档的表格写 kqueue 检查「EV_ERROR and EV_EOF with fflags set to 0」,代码是 EV_EOF 且 fflags != 0。以代码为准:EV_EOF 时 fflags 里放的是 socket 的错误码,非零才是错误;下面实验里的 54(ECONNRESET)和 61(ECONNREFUSED)就是这样被判成 error 的。

所有「closed」方法的文档都写着「best effort」和「it is guaranteed that false-positives will not occur」:可以不报,不会误报。

实验:几种连接状态下的标志位

src/bin/flags.rs 每种场景新建一对连接,服务端的 mio TcpStream 用 READABLE | WRITABLE 注册,先 poll 一次把初始的可写事件取走,再制造一个状态变化,打印下一次 poll 的原始标志位(raw,同上一个实验)和各个 is_* 的结果(bools 把为真的方法名列出来):

use std::io::Write;
use std::net::Shutdown;
use std::os::fd::AsRawFd;
use std::time::Duration;

use exp_mio_poll::{bools, raw};
use mio::net::TcpStream;
use mio::unix::pipe;
use mio::{Events, Interest, Poll, Token};

const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);

fn drain(poll: &mut Poll, title: &str) {
  let mut events = Events::with_capacity(16);
  poll.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
  println!("{title}");
  if events.is_empty() {
    println!("    (no events)");
  }
  for e in &events {
    println!("    {}  => {}", raw(e), bools(e));
  }
}

// registered server-side stream + std client, after the initial writable event
fn setup() -> (Poll, TcpStream, std::net::TcpStream) {
  let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
  let c = std::net::TcpStream::connect(l.local_addr().unwrap()).unwrap();
  let (s, _) = l.accept().unwrap();
  s.set_nonblocking(true).unwrap();
  let mut s = TcpStream::from_std(s);
  let mut poll = Poll::new().unwrap();
  poll.registry().register(&mut s, Token(1), RW).unwrap();
  let mut events = Events::with_capacity(16);
  poll.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
  (poll, s, c)
}

fn main() -> std::io::Result<()> {
  println!("os={}", std::env::consts::OS);
  let (mut poll, _s, mut c) = setup();
  c.write_all(b"hi")?;
  drain(&mut poll, "1. peer writes 2 bytes");

  let (mut poll, _s, c) = setup();
  c.shutdown(Shutdown::Write)?;
  drain(&mut poll, "2. peer shutdown(Write) (FIN)");

  let (mut poll, _s, c) = setup();
  let linger = libc::linger { l_onoff: 1, l_linger: 0 };
  unsafe {
    libc::setsockopt(c.as_raw_fd(), libc::SOL_SOCKET, libc::SO_LINGER,
      &linger as *const _ as *const libc::c_void, size_of::<libc::linger>() as libc::socklen_t);
  }
  drop(c);
  drain(&mut poll, "3. peer closes with SO_LINGER=0 (RST)");

  let (mut poll, s, _c) = setup();
  s.shutdown(Shutdown::Write)?;
  drain(&mut poll, "4. local shutdown(Write)");

  let (mut poll, s, _c) = setup();
  s.shutdown(Shutdown::Both)?;
  drain(&mut poll, "5. local shutdown(Both)");

  // 6. connect to a port nobody listens on
  let port = {
    let l = std::net::TcpListener::bind("127.0.0.1:0")?;
    l.local_addr()?
  };
  let mut poll = Poll::new()?;
  let mut s = TcpStream::connect(port)?;
  poll.registry().register(&mut s, Token(1), RW)?;
  drain(&mut poll, "6. connect to a closed port");

  // 7/8. pipes
  let mut poll = Poll::new()?;
  let (tx, mut rx) = pipe::new()?;
  poll.registry().register(&mut rx, Token(1), Interest::READABLE)?;
  drop(tx);
  drain(&mut poll, "7. pipe: write end closed, reader registered READABLE");

  let mut poll = Poll::new()?;
  let (mut tx, rx) = pipe::new()?;
  poll.registry().register(&mut tx, Token(1), Interest::WRITABLE)?;
  let mut events = Events::with_capacity(16);
  poll.poll(&mut events, Some(Duration::from_millis(100)))?;
  drop(rx);
  drain(&mut poll, "8. pipe: read end closed, writer registered WRITABLE");

  #[cfg(target_os = "linux")]
  {
    let (mut poll, mut s, c) = setup();
    poll.registry().reregister(&mut s, Token(1), Interest::READABLE | Interest::PRIORITY)?;
    let n = unsafe { libc::send(c.as_raw_fd(), b"!".as_ptr().cast(), 1, libc::MSG_OOB) };
    assert_eq!(n, 1);
    drain(&mut poll, "9. peer sends 1 byte MSG_OOB, READABLE|PRIORITY");
    let mut buf = [0u8; 8];
    let _ = std::io::Read::read(&mut s, &mut buf);
  }
  Ok(())
}

两个平台各跑 3 次,输出逐字节相同。Linux 6.12.28(容器内 Alpine,aarch64 musl):

os=linux
1. peer writes 2 bytes
    events=IN|OUT data=0x1  => readable writable
2. peer shutdown(Write) (FIN)
    events=IN|OUT|RDHUP data=0x1  => readable writable read_closed
3. peer closes with SO_LINGER=0 (RST)
    events=IN|OUT|ERR|HUP|RDHUP data=0x1  => readable writable error read_closed write_closed
4. local shutdown(Write)
    events=OUT data=0x1  => writable
5. local shutdown(Both)
    events=IN|OUT|HUP|RDHUP data=0x1  => readable writable read_closed write_closed
6. connect to a closed port
    events=IN|OUT|ERR|HUP|RDHUP data=0x1  => readable writable error read_closed write_closed
7. pipe: write end closed, reader registered READABLE
    events=HUP data=0x1  => read_closed write_closed
8. pipe: read end closed, writer registered WRITABLE
    events=OUT|ERR data=0x1  => writable error write_closed
9. peer sends 1 byte MSG_OOB, READABLE|PRIORITY
    events=PRI data=0x1  => readable priority

M2 Max、macOS、rustc 1.99.0(第 9 项只在 Linux 上编译):

os=macos
1. peer writes 2 bytes
    filter=READ flags=CLEAR fflags=0 data=2 udata=0x1  => readable
2. peer shutdown(Write) (FIN)
    filter=READ flags=EOF|CLEAR fflags=0 data=0 udata=0x1  => readable read_closed
3. peer closes with SO_LINGER=0 (RST)
    filter=WRITE flags=EOF|CLEAR fflags=54 data=146988 udata=0x1  => writable error write_closed
    filter=READ flags=EOF|CLEAR fflags=54 data=0 udata=0x1  => readable error read_closed
4. local shutdown(Write)
    filter=WRITE flags=EOF|CLEAR fflags=0 data=146988 udata=0x1  => writable write_closed
5. local shutdown(Both)
    filter=READ flags=EOF|CLEAR fflags=0 data=0 udata=0x1  => readable read_closed
    filter=WRITE flags=EOF|CLEAR fflags=0 data=146988 udata=0x1  => writable write_closed
6. connect to a closed port
    filter=WRITE flags=EOF|CLEAR fflags=61 data=2048 udata=0x1  => writable error write_closed
    filter=READ flags=EOF|CLEAR fflags=61 data=0 udata=0x1  => readable error read_closed
7. pipe: write end closed, reader registered READABLE
    filter=READ flags=EOF|CLEAR fflags=0 data=0 udata=0x1  => readable read_closed
8. pipe: read end closed, writer registered WRITABLE
    filter=WRITE flags=EOF|CLEAR fflags=0 data=0 udata=0x1  => writable write_closed

对着表读:

  • 第 1 项:Linux 上数据到达,事件里同时有 IN 和 OUT。可写状态并没有变,但边沿触发下这个 fd 被唤醒时,内核报告的是它当前全部的就绪位。macOS 只有一个 EVFILT_READ 事件,data=2 是可读字节数,EVFILT_WRITE 没有变化就不报。
  • 第 2 项(对端 FIN):Linux 是 IN|RDHUP,满足「EPOLLIN 与 EPOLLRDHUP 同时」;macOS 是 EVFILT_READ 加 EV_EOF。两边都只有 read_closed,没有 error。此时 read 会返回 0。
  • 第 3、6 项(RST、连接被拒):Linux 一次给出全部五个位,所有 is_* 都为真;macOS 读写各一个事件,fflags 分别是 54(ECONNRESET)和 61(ECONNREFUSED),所以 error 为真。具体错误要用 take_error 或下一次读写拿,见 TCP。
  • 第 4 项(本端只关写):正是 is_write_closed 文档说的平台差异。kqueue 上 EVFILT_WRITE 带 EV_EOF,write_closed 为真;epoll 只给一个 OUT,write_closed 为假。
  • 第 7、8 项(管道):Linux 上读端在写端关闭后只收到 HUP,is_readable() 为假;macOS 上 is_readable() 为真。写端在读端关闭后,Linux 是 OUT|ERR,走的是「EPOLLOUT 与 EPOLLERR 同时」那一支,所以还带着 error。
  • 第 9 项:带外数据只产生 EPOLLPRI,没有 EPOLLIN,但 is_readable() 也为真,因为 epoll 的 is_readable 把 EPOLLPRI 算在内。Event::is_readable 的文档专门提到这一点:带外数据也会触发可读,不读它的程序在水平触发下会陷入死循环,「However because Mio uses edge-triggers it will not result in an infinite loop」。
📝NOTE
没有蠢问题

问: 第 7 项里,Linux 上管道写端关了,读端的事件 is_readable() 却是假。只在 is_readable() 时去读的代码会怎样?

答: 它永远不会去读这个管道,也就永远读不到那个表示 EOF 的 0,而边沿触发下这个 HUP 只报一次。macOS 上同样的代码没问题,因为 kqueue 的 EVFILT_READ 带着 EV_EOF 回来,is_readable() 为真。要在两个平台上都正确,处理事件时把 is_read_closed()(或 is_error())也当成「去读一次」的理由。tokio 就是这么做的:Ready::from_mio 把 is_read_closed() 映射成 READ_CLOSED,而读方向的等待把 READABLE 和 READ_CLOSED 都当成就绪(见 tokio 卷的 ScheduledIo 与就绪状态)。

Events:容量(在 epoll 和 kqueue 上)就是一次 poll 的上限

Events 包着 sys::Events,在 epoll 上是 Vec<libc::epoll_event>,在 kqueue 上是 Events(Vec<Event>)。with_capacity 就是 Vec::with_capacity,capacity() 是 Vec::capacity()。上一页看过 select:每次先 clear(),再把 capacity() 作为 maxevents(kevent 的 nevents)交给内核,最后 set_len(n)。所以:

  • 在 epoll 和 kqueue 上,容量就是一次 poll 最多取回的条数,poll 不会扩容(poll(2) 后备实现不看容量、一次全部返回,见 poll(2) 后备)。文档的说法是「At most events.capacity() events will be returned. If there are further pending readiness events, they will be returned on the next call to poll.」
  • 事件是复用缓冲区,Events 通常和 Poll 一起建一次、反复用,poll 的路上没有分配。
  • 迭代器就是下标:Iter::next 对 Vec 做 get(pos) 再 from_sys_event_ref,size_hint 和 count 直接用 len()。

实验:容量 2,五个就绪的 socket

src/bin/capacity.rs 建 5 对连接,每个对端先写 1 字节,让 5 个服务端 socket 在第一次 poll 之前就都可读(也都可写),分别用 Token(0) 到 Token(4) 注册,然后用 Events::with_capacity(2) 反复 poll,直到取不到事件为止。每个事件打印成 token 加 r / w / rw。关注集合分别用 READABLE 和 READABLE | WRITABLE 各做一遍:

use std::io::Write;
use std::time::Duration;

use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};

fn main() -> std::io::Result<()> {
  println!("os={}", std::env::consts::OS);
  let interests = [("READABLE", Interest::READABLE), ("READABLE|WRITABLE", Interest::READABLE | Interest::WRITABLE)];
  for (name, interest) in interests {
    let l = std::net::TcpListener::bind("127.0.0.1:0")?;
    let mut poll = Poll::new()?;
    let mut keep = vec![];
    for i in 0..5 {
      let mut c = std::net::TcpStream::connect(l.local_addr()?)?;
      let (s, _) = l.accept()?;
      s.set_nonblocking(true)?;
      let mut s = TcpStream::from_std(s);
      c.write_all(b"x")?; // every socket is readable (and writable) before the first poll
      poll.registry().register(&mut s, Token(i), interest)?;
      keep.push((s, c));
    }
    std::thread::sleep(Duration::from_millis(50));
    let mut events = Events::with_capacity(2);
    println!("{name}, Events::with_capacity(2), capacity()={}", events.capacity());
    for round in 1.. {
      poll.poll(&mut events, Some(Duration::from_millis(100)))?;
      let got: Vec<String> = events.iter().map(|e| {
        let rw = match (e.is_readable(), e.is_writable()) { (true, true) => "rw", (true, false) => "r", (false, true) => "w", _ => "-" };
        format!("{}{rw}", e.token().0)
      }).collect();
      println!("  poll #{round}: {} events {:?}", got.len(), got);
      if got.is_empty() { break; }
    }
  }
  Ok(())
}

Linux 6.12.28(容器内 Alpine,aarch64 musl),跑了 3 次,输出相同:

os=linux
READABLE, Events::with_capacity(2), capacity()=2
  poll #1: 2 events ["0r", "1r"]
  poll #2: 2 events ["2r", "3r"]
  poll #3: 1 events ["4r"]
  poll #4: 0 events []
READABLE|WRITABLE, Events::with_capacity(2), capacity()=2
  poll #1: 2 events ["0rw", "1rw"]
  poll #2: 2 events ["2rw", "3rw"]
  poll #3: 1 events ["4rw"]
  poll #4: 0 events []

strace 里对应的系统调用(-e trace=epoll_pwait,只摘 READABLE 那一遍):

epoll_pwait(4, [{events=EPOLLIN, data=0}, {events=EPOLLIN, data=0x1}], 2, 100, NULL, 8) = 2
epoll_pwait(4, [{events=EPOLLIN, data=0x2}, {events=EPOLLIN, data=0x3}], 2, 100, NULL, 8) = 2
epoll_pwait(4, [{events=EPOLLIN, data=0x4}], 2, 100, NULL, 8) = 1
epoll_pwait(4, [], 2, 100, NULL, 8)     = 0

第三个参数 maxevents 是 2,内核每次最多交出 2 个,剩下的留在就绪队列里,下一次 epoll_wait 接着交。第一次没取到的 2、3、4 没有丢,也没有因为边沿触发而「错过」:它们的边沿已经被内核记下,只是还没被取走。没有任何报错,代价是要多几次系统调用,排在后面的 socket 晚一点被处理。

Events::with_capacity(0) 是个极端情况,两边的反应不一样(src/bin/zero_cap.rs:注册一个已有连接等待的监听 socket,用容量 0 的 Events 以 500 ms 超时 poll):

macos capacity=0 -> Ok(()), 0 events, 0ms
epoll_pwait(3, 0x8, 0, 500, NULL, 8)    = -1 EINVAL (Invalid argument)
linux capacity=0 -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" }), 0 events, 0ms

Linux 的 epoll_wait 要求 maxevents 大于 0,直接 EINVAL;macOS 的 kevent 立刻返回 0 个事件,不等超时。放在循环里,前者每次报错,后者变成忙转。mio 不检查这个参数。

❗IMPORTANT
动脑筋:同一个程序在 macOS 上,READABLE|WRITABLE 那一遍要几次 poll 才能把事件取完?

还是上面的 capacity.rs,M2 Max、macOS、rustc 1.99.0,--release。5 个 socket 都可读也都可写,Events 的容量是 2。Linux 上两遍都是 2、2、1,三次取完。macOS 上 READABLE 那一遍也是三次。READABLE|WRITABLE 那一遍,算上最后返回 0 个事件的那次之前,一共有几次 poll 拿到了事件?

容量设多大是上层的事。tokio 默认 1024,忙的时候另用一个小缓冲区,取舍见 tokio 卷的反应器。

动脑筋答案

os=macos
READABLE, Events::with_capacity(2), capacity()=2
  poll #1: 2 events ["0r", "1r"]
  poll #2: 2 events ["2r", "3r"]
  poll #3: 1 events ["4r"]
  poll #4: 0 events []
READABLE|WRITABLE, Events::with_capacity(2), capacity()=2
  poll #1: 2 events ["0w", "0r"]
  poll #2: 2 events ["1w", "1r"]
  poll #3: 2 events ["2w", "2r"]
  poll #4: 2 events ["3w", "3r"]
  poll #5: 2 events ["4w", "4r"]
  poll #6: 0 events []

答案是 5 次。跑了 3 次,输出相同。

  • kqueue 上每个 socket 注册了 EVFILT_WRITE 和 EVFILT_READ 两个过滤器,各自产生一个事件,5 个 socket 共 10 个事件,容量 2 就要取 5 次。Linux 上每个 socket 只有一个 epoll_event,EPOLLIN|EPOLLOUT 合在一起,所以 5 个事件取 3 次。
  • 每次取到的恰好是同一个 socket 的写和读两个事件("0w", "0r"),同一个 token 在一批 Events 里出现两次。poll 文档说的就是这种情况:一个 source 可能「a single readiness event ... with both readable and writable」,也可能是「two separate events」。处理事件的代码不能假设一个 token 在一批里只出现一次。
  • 同样的 with_capacity(n),在 macOS 上能覆盖的 socket 数大约只有 Linux 的一半(对同时关注读写的 source 而言)。

小结

  • Token(pub usize) 没有任何保留值。epoll 存进 epoll_event.u64,kqueue 存进 kevent.udata,取回时原样转回 usize;strace 里能直接看到 data=<token>。mio 不维护任何映射表。
  • Interest 是 NonZeroU8:不存在空集合,remove 返回 Option,Option<Interest> 也只占 1 字节。AIO(DragonFly、FreeBSD、Apple)、LIO(FreeBSD)、PRIORITY(Linux、Android)三个常量按平台 cfg,写错平台是编译错误;PRIORITY 是 0.8.6 加的(#1647)。
  • epoll 的掩码总是带 EPOLLET,可读再加 EPOLLIN|EPOLLRDHUP,可写 EPOLLOUT,优先 EPOLLPRI;kqueue 只为读、写各提交一个带 EV_CLEAR 的过滤器。
  • Event 是 #[repr(transparent)] 包着 epoll_event / kevent,内核写的内存就是用户读的 Event。kqueue 上它 1.1.0 起才是 Send(#1906)。
  • 各个 is_* 方法逐个查标志位(见表)。kqueue 一个事件只有一个方向;is_error 的文档写反了 fflags 条件;Linux 上管道写端关闭后读端只有 HUP,is_readable() 为假,要同时看 is_read_closed()。
  • 在 epoll 和 kqueue 上,Events 的容量就是一次 poll 的 maxevents:容量 2、5 个就绪 socket,Linux 取 3 次,macOS 同时关注读写时取 5 次,同一个 token 会在一批里出现两次。容量 0 在 Linux 上是 EINVAL,在 macOS 上立刻返回。

💬 关联讨论

发起讨论
加载中...