epoll
主线位置:两条主线在 Linux 上的终点。注册主线的
register/reregister/deregister在这里变成一次epoll_ctl(ADD/MOD/DEL),Interest在这里翻译成 epoll 的事件掩码;事件主线的poll在这里变成一次epoll_wait,内核把就绪项直接写进Events的Vec。上面一层(Registry、IoSource)在 Poll 与 Registry 和 Source 与 IoSource。
flowchart TB reg["register"] -->|EPOLL_CTL_ADD| ctl["epoll_ctl"] rereg["reregister"] -->|EPOLL_CTL_MOD| ctl dereg["deregister"] -->|EPOLL_CTL_DEL| ctl mask["interests_to_epoll"] --> ctl ctl --> kern["内核 epoll 实例"] kern -->|"epoll_wait"| evs["Events:Vec epoll_event"] evs --> fns["event 模块:查标志位"]
整个 epoll 后端是一个文件,src/sys/unix/selector/epoll.rs,246 行,没有一行是文档。sys/unix/mod.rs 用 cfg_attr(path = "selector/epoll.rs") 把它挂成 mod selector,用它的是 android、illumos、linux、redox 四个 target_os(没设 mio_unsupported_force_poll_poll 时;选择规则见源码地图)。
文件从上到下:
| 行 | 内容 | 本页 |
|---|---|---|
| 19–106 | Selector:new、try_clone、select、register、reregister、deregister | 前四节 |
| 108–127 | debug 下的 id(),AsFd / AsRawFd | 第一节 |
| 129–145 | interests_to_epoll:Interest 到事件掩码 | 「掩码」一节 |
| 147–148 | Event = libc::epoll_event,Events = Vec<Event> | 「event 模块」一节 |
| 150–238 | event 模块:token、各个 is_*、debug_details | 最后两节 |
| 240–246 | Waker 和 IoSourceState 从别处 use 进来 | 只提一句 |
最后几行说明 epoll 后端对另外两件事没有特殊要求:Waker 用 sys::unix::waker 里按平台选的那个(Linux 上是 eventfd,见 Waker),IoSourceState 用无状态的 stateless_io_source.rs(见 Source 与 IoSource)。
Selector:一个 epoll fd
#[derive(Debug)]
pub struct Selector {
#[cfg(debug_assertions)]
id: usize,
ep: OwnedFd,
}
impl Selector {
pub fn new() -> io::Result<Selector> {
// SAFETY: `epoll_create1(2)` ensures the fd is valid.
let ep = unsafe { OwnedFd::from_raw_fd(syscall!(epoll_create1(libc::EPOLL_CLOEXEC))?) };
Ok(Selector {
#[cfg(debug_assertions)]
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
ep,
})
}
release 下 Selector 就是一个 OwnedFd,没有锁,没有 fd 到 token 的表,也没有 Drop:OwnedFd 被 drop 时自己 close。id 是 debug 下给 IoSource 认 selector 用的(见 Source 与 IoSource),由文件开头的 NEXT_ID 从 1 开始发号,id() 访问器只在 cfg_io_source!(开了 net,或 Unix 上开了 os-ext)时编译。
epoll_create1 的参数只有一个 flag,mio 只传 EPOLL_CLOEXEC:exec 之后子进程里不会留着这个 fd。strace 里就是一行 epoll_create1(EPOLL_CLOEXEC) = 3(一个事件循环第一行)。
这一行是 1.0.0 才变得这么短的。PR #1741(「Remove outdated work arounds」)里的提交「Remove epoll_create1 missing work around」删掉了一段回退:epoll_create1 返回 ENOSYS 时改用 epoll_create(1024) 再 fcntl(F_SETFD, FD_CLOEXEC)。理由是「This was introduces in Linux Linux 2.6.27, glibc 2.9 and Android API level 21. Since both are equal or lower than our supported versions we don't need this fallback any longer.」再往前,0.8.x 在 Android 上还用过 syscall(SYS_epoll_create1, O_CLOEXEC) 绕开 libc 里缺失的函数,同一批清理里也删了。ep 从 RawFd 换成 OwnedFd、手写的 Drop 随之消失,是 1.0.0 的 PR #1745(「Implement I/O safety traits」)。
try_clone:F_DUPFD_CLOEXEC
try_clone 交给 OwnedFd::try_clone,id 原样复制:
pub fn try_clone(&self) -> io::Result<Selector> {
self.ep.try_clone().map(|ep| Selector {
// It's the same selector, so we use the same id.
#[cfg(debug_assertions)]
id: self.id,
ep,
})
}
std 的 BorrowedFd::try_clone_to_owned(OwnedFd::try_clone 转给它)在 Linux 上是一次 fcntl:
// Avoid using file descriptors below 3 as they are used for stdio
let fd = cvt(unsafe { libc::fcntl(self.as_raw_fd(), cmd, 3) })?;
cmd 是 F_DUPFD_CLOEXEC:复制 fd,同时原子地设上 close-on-exec,新 fd 号不小于 3。strace 里是 fcntl(3, F_DUPFD_CLOEXEC, 3) = 4,新旧两个 fd 指向同一个 epoll 实例,别的线程拿着克隆出来的 Registry 注册,阻塞在原 fd 上的 epoll_wait 也能看到。这些在 Poll 与 Registry 用 strace 验证过,这里不再重复。Waker::new 不走这条路:Linux 上的 eventfd Waker 直接用传进来的 selector 注册。
最早 try_clone 是 syscall!(dup(self.ep))。dup 出来的 fd 不带 close-on-exec,tokio 那边因此报了 tokio-rs/tokio#3809。PR #1491(「Set the close-on-exec flag for the duplicate epoll_fd」)的说明是「The close-on-exec flag (FD_CLOEXEC; see fcntl(2)) for the duplicate descriptor created by dup(2) is off. We can use fcntl + F_DUPFD_CLOEXEC to dup the epoll_fd to fix this issue.」它在主干上首次发布于 0.8.0;0.7 分支上的移植版本是 0.7.12,写成了 fcntl(self.ep, libc::F_DUPFD_CLOEXEC),只有两个参数。
fcntl 是可变参数函数,F_DUPFD_CLOEXEC 要的第三个参数是新 fd 号的下限。issue #1497(「F_DUPFD_CLOEXEC doesn't work on macOS 11.3 (v0.7.12)」)报告 kqueue 版同样的写法在 macOS 上返回 Invalid argument,而 tokio 的 Waker 要克隆 selector,开了 net 就跑不起来。修复提交「Fix Selector::try_clone」的解释是「Because we didn't provide a value it would take whatever value was left in the register from whatever code used it before the system call.」0.7.12 在 CHANGELOG 里被标成 yanked,0.7.13 的条目是「Fix Registry::try_clone invalid usage of F_DUPFD_CLOEXEC (#1497)」。修复补上了一个 LOWEST_FD = 3 常量,注释说 0、1、2 通常是标准输入输出,有的程序会想当然地这么假设,所以复制出来的 fd 不要落在这三个号上。1.0.0 换成 OwnedFd::try_clone 之后这个常量没有了,但 std 用的还是同一个 3,理由也是同一个。
select:epoll_wait,strace 里是 epoll_pwait
select 做三件事:把 Option<Duration> 换成毫秒数的 c_int(None 是 -1,向上取整,超过 c_int::MAX 就截断);events.clear();调 epoll_wait(ep, events.as_mut_ptr(), events.capacity() as i32, timeout),返回几项就 set_len 几项。超时换算和 1.2.1 修的 24.8 天问题(#1948)在 Poll 与 Registry 有完整的代码和实验,EINTR 原样返回也在那一页;容量就是 maxevents、容量为 0 时 EINVAL,在 Token、Interest 与 Event。
这里只补一件事:代码调的是 libc::epoll_wait,可本卷所有 strace 输出里都是 epoll_pwait(…, NULL, 8)。原因在 aarch64 Linux 的系统调用表里:它没有 epoll_wait 这个号,只有 epoll_pwait,musl 的 epoll_wait 就用 epoll_pwait 加一个空信号掩码实现(最后的 8 是信号集的字节数)。libc crate 的常量表跟着系统调用表走,src/bin/no_epoll_wait.rs 对两个常量各取一次:
fn main() {
println!("epoll_pwait = {}", libc::SYS_epoll_pwait);
println!("epoll_wait = {}", libc::SYS_epoll_wait);
}
交叉编译到 aarch64-unknown-linux-musl,rustc 1.99.0:
error[E0425]: cannot find value `SYS_epoll_wait` in crate `libc`
--> src/bin/no_epoll_wait.rs:3:38
|
3 | println!("epoll_wait = {}", libc::SYS_epoll_wait);
| ^^^^^^^^^^^^^^
|
::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.191/src/unix/linux_like/linux/musl/b64/aarch64/mod.rs:323:1
|
323 | pub const SYS_epoll_pwait: c_long = 22;
| --------------------------------- similarly named constant `SYS_epoll_pwait` defined here
|
help: a constant with a similar name exists
|
3 | println!("epoll_wait = {}", libc::SYS_epoll_pwait);
| +
For more information about this error, try `rustc --explain E0425`.
error: could not compile `exp-mio-epoll` (bin "no_epoll_wait") due to 1 previous error
信号掩码为空的 epoll_pwait 和 epoll_wait 行为相同,所以这只影响 strace 里看到的名字,不影响 mio 的语义。
register、reregister、deregister:ADD、MOD、DEL
三个方法各是一次 epoll_ctl。register 和 deregister:
pub fn register(&self, fd: RawFd, token: Token, interests: Interest) -> io::Result<()> {
let mut event = libc::epoll_event {
events: interests_to_epoll(interests),
u64: usize::from(token) as u64,
#[cfg(target_os = "redox")]
_pad: 0,
};
let ep = self.ep.as_raw_fd();
syscall!(epoll_ctl(ep, libc::EPOLL_CTL_ADD, fd, &mut event)).map(|_| ())
}
cfg_any_os_ext! {
pub fn reregister(&self, fd: RawFd, token: Token, interests: Interest) -> io::Result<()> {
// ... 和 register 逐字相同,只是 EPOLL_CTL_MOD
}
pub fn deregister(&self, fd: RawFd) -> io::Result<()> {
let ep = self.ep.as_raw_fd();
syscall!(epoll_ctl(ep, libc::EPOLL_CTL_DEL, fd, ptr::null_mut())).map(|_| ())
}
}
| 方法 | epoll_ctl 操作 | 第四个参数 | 内核拒绝的情况 |
|---|---|---|---|
register | EPOLL_CTL_ADD | {events: 掩码, u64: token} | fd 已在集合里:EEXIST;普通文件:EPERM |
reregister | EPOLL_CTL_MOD | 同上,整个替换 | fd 不在集合里:ENOENT |
deregister | EPOLL_CTL_DEL | NULL | fd 不在集合里:ENOENT |
最后一列的错误都是前面几章的实验里跑出来的(重复注册和未注册的情况见 Poll 与 Registry 的误用表,普通文件的 EPERM 见 Source 与 IoSource)。mio 不检查、不翻译,syscall! 把 errno 原样包成 io::Error。
几处细节:
token和掩码都整个交给内核。MOD不是「在原来的基础上改」,而是用新的epoll_event整个替换,这正是reregister文档说的「fully override the previous values」。token 也一起换,下面的实验里同一个 fd 先后用了三个 token。_pad只在 Redox 上有。 libc 里 Redox 的epoll_event多一个_pad: u64字段,Linux 的只有events和u64两个字段,Rust 的结构体字面量必须写全字段,所以要 cfg 一下。这是 0.8.2 加 Redox 支持(PR #1553)时加的。reregister和deregister包在cfg_any_os_ext!里,register不包。这个宏展开成#[cfg(any(feature = "os-ext", feature = "net"))]。不开这两个 feature 时,crate 里唯一会调 selector 的是 eventfdWaker的selector.register(...);reregister/deregister只被IoSource(net)和SourceFd(os-ext)调用,不包就是死代码警告。1.1.0 先用cfg_io_source!包上(PR #1887,「Fix various lints」),同一版本里又改成cfg_any_os_ext!(PR #1898),提交说明是「reregister is used by sourcefd, which is always available under os-ext, so selectors should always provide reregister when os-ext is enabled even if cfg(unix) fails」。
掩码:interests_to_epoll
interests_to_epoll 的代码已经在 Token、Interest 与 Event 贴过,结果列成表:
Interest | 掩码 |
|---|---|
READABLE | EPOLLIN | EPOLLRDHUP | EPOLLET |
WRITABLE | EPOLLOUT | EPOLLET |
READABLE | WRITABLE | EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET |
PRIORITY(Linux、Android) | EPOLLPRI | EPOLLET |
READABLE | PRIORITY | EPOLLIN | EPOLLPRI | EPOLLRDHUP | EPOLLET |
EPOLLONESHOT、EPOLLEXCLUSIVE、EPOLLWAKEUP 一个都不用。EPOLLET 无条件加上:0.7.0 的 CHANGELOG 写着「PollOpt was removed and all registrations use edge-triggers.」(PR #975)。0.6 的 ioevent_to_epoll 还要看 PollOpt 决定加 EPOLLET、EPOLLONESHOT,或者在水平触发时把 EPOLLET 去掉;现在这个函数第一行就是 let mut kind = EPOLLET;。EPOLLPRI 来自 0.8.6 加的 Interest::PRIORITY,来历见 Token、Interest 与 Event 的冷知识。
EPOLLERR 和 EPOLLHUP 不在任何一行里,因为不需要:内核总会报告这两位,不管注册时要没要。Token、Interest 与 Event 的标志位实验里有现成的证据:只注册了 READABLE(EPOLLIN|EPOLLRDHUP|EPOLLET)的管道读端收到了 events=HUP,只注册了 WRITABLE(EPOLLOUT|EPOLLET)的写端收到了 OUT|ERR。
为什么关注可读就要 EPOLLRDHUP
EPOLLRDHUP 和那两位不同,不注册就不报。不报的后果是:对端发来 FIN 时,内核只给一个 EPOLLIN,和「来了数据」一模一样。src/bin/rdhup.rs 把同一个服务端 socket 同时放进两个 epoll 实例,一个是 mio 的,一个是用 libc 直接建的、只要 EPOLLIN|EPOLLET,然后分别让对端写 1 字节、或者 shutdown(Write):
use std::io::Write;
use std::net::Shutdown;
use std::os::fd::AsRawFd;
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
// The same server socket sits in two epoll instances: mio's
// (EPOLLIN|EPOLLRDHUP|EPOLLET) and a raw one without EPOLLRDHUP.
fn raw_wait(ep: i32) -> String {
let mut ev = [libc::epoll_event { events: 0, u64: 0 }; 4];
let n = unsafe { libc::epoll_wait(ep, ev.as_mut_ptr(), 4, 100) };
let names = [(libc::EPOLLIN, "IN"), (libc::EPOLLOUT, "OUT"), (libc::EPOLLRDHUP, "RDHUP"), (libc::EPOLLHUP, "HUP"), (libc::EPOLLERR, "ERR")];
(0..n as usize)
.map(|i| {
let bits = ev[i].events as i32;
names.iter().filter(|(b, _)| bits & b != 0).map(|(_, s)| *s).collect::<Vec<_>>().join("|")
})
.collect::<Vec<_>>()
.join(", ")
}
fn mio_wait(poll: &mut Poll) -> String {
let mut events = Events::with_capacity(4);
poll.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
events
.iter()
.map(|e| format!("readable={} read_closed={}", e.is_readable(), e.is_read_closed()))
.collect::<Vec<_>>()
.join(", ")
}
fn main() -> std::io::Result<()> {
for case in ["peer writes 1 byte", "peer shutdown(Write)"] {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
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);
let mut poll = Poll::new()?;
poll.registry().register(&mut s, Token(1), Interest::READABLE)?;
let ep = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
let mut ev = libc::epoll_event { events: (libc::EPOLLIN | libc::EPOLLET) as u32, u64: 1 };
assert_eq!(unsafe { libc::epoll_ctl(ep, libc::EPOLL_CTL_ADD, s.as_raw_fd(), &mut ev) }, 0);
if case.starts_with("peer writes") {
c.write_all(b"x")?;
} else {
c.shutdown(Shutdown::Write)?;
}
eprintln!("{case}:");
eprintln!(" raw epoll (IN|ET) -> {}", raw_wait(ep));
eprintln!(" mio (IN|RDHUP|ET) -> {}", mio_wait(&mut poll));
unsafe { libc::close(ep) };
}
Ok(())
}
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,--release,strace -e trace=epoll_create1,epoll_ctl,epoll_pwait,shutdown,sendto,recvfrom,跑了 3 次,输出相同:
epoll_create1(EPOLL_CLOEXEC) = 6
epoll_ctl(6, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_create1(EPOLL_CLOEXEC) = 7
epoll_ctl(7, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLET, data=0x1}) = 0
sendto(4, "x", 1, MSG_NOSIGNAL, NULL, 0) = 1
peer writes 1 byte:
epoll_pwait(7, [{events=EPOLLIN, data=0x1}], 4, 100, NULL, 8) = 1
raw epoll (IN|ET) -> IN
epoll_pwait(6, [{events=EPOLLIN, data=0x1}], 4, 100, NULL, 8) = 1
mio (IN|RDHUP|ET) -> readable=true read_closed=false
epoll_create1(EPOLL_CLOEXEC) = 6
epoll_ctl(6, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_create1(EPOLL_CLOEXEC) = 7
epoll_ctl(7, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLET, data=0x1}) = 0
shutdown(4, SHUT_WR) = 0
peer shutdown(Write):
epoll_pwait(7, [{events=EPOLLIN, data=0x1}], 4, 100, NULL, 8) = 1
raw epoll (IN|ET) -> IN
epoll_pwait(6, [{events=EPOLLIN|EPOLLRDHUP, data=0x1}], 4, 100, NULL, 8) = 1
mio (IN|RDHUP|ET) -> readable=true read_closed=true
没注册 EPOLLRDHUP 的那个实例,两种情况都只拿到 EPOLLIN;mio 的实例在 FIN 那一次多了 EPOLLRDHUP,is_read_closed() 因此为真。没有这一位,调用方只能去 read 一次,读到 0 才知道对端关了写;有了它,is_read_closed() 可以在读之前就告诉调用方「这次读到头会是 EOF」。tokio 把它映射成 Ready::READ_CLOSED(见 tokio 卷的 ScheduledIo 与就绪状态)。
注意 mio 只在关注可读时才加这一位,而 is_read_closed 的 epoll 实现又要求 EPOLLIN 和 EPOLLRDHUP 同时出现,两件事是配套的:只有注册了 EPOLLIN 的 source 才可能同时拿到这两位。
0.6.16 及以前,ioevent_to_epoll 在用户关注 UnixReady::hup() 时加 EPOLLRDHUP,事件回来时 EPOLLRDHUP 和 EPOLLHUP 都被报告成 hup。PR #939(「Don't report RDHUP as HUP」,首次发布于 0.6.17)指出这是错的:「EPOLLRDHUP only means that the TCP stream has been half-closed and such a half-closed stream can still be written to.」tokio 把 hup 当成「两个方向都关了」,结果是「tokio infinitely attempts to write to a half-closed socket that's returning WouldBlock when it's write buffer is full」,表现为 CPU 占满。这个 PR 把注册和报告 EPOLLRDHUP 的代码都删了,并在说明里留了一句「if handling of EPOLLRDHUP is desired to be retained, I believe it should be implemented as another readiness kind」。
0.7.0 的 PR #1110(「Add read_close and write_close readiness events」)就是这么做的:新增 is_read_closed / is_write_closed,「EPOLLRDHUP is now a registered event when there is interest in Interest::READABLE」,is_read_closed 查「EPOLLHUP」或「EPOLLIN and EPOLLRDHUP」。这就是今天 kind = kind | EPOLLIN | EPOLLRDHUP; 这一行和 is_read_closed 的来历。同一份说明还记下了 epoll 上 is_write_closed 难做的原因:本端 shutdown(Write) 之后「only results in a EPOLLOUT, which is not very helpful!」,因为「there is no EPOLLRDHUP equivalent event for when the SEND_SHUTDOWN flag is set」。
实验:一个 socket 的一生
src/bin/lifecycle.rs 把一个已连接的服务端 socket 依次走过 register、两次 reregister、PRIORITY 加带外数据、对端半关闭、deregister,每一步之后 poll 一次(100 ms 超时),用 {:?} 打印事件。输出写到 stderr,和 strace 的行按时间顺序交错:
use std::io::Read;
use std::net::Shutdown;
use std::os::fd::AsRawFd;
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
// stderr is unbuffered, so these lines interleave with strace in order
fn step(poll: &mut Poll, title: &str) {
let mut events = Events::with_capacity(8);
poll.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
eprintln!("== {title}: {} event(s)", events.iter().count());
for e in &events {
eprintln!(" {e:?}");
}
}
fn main() -> std::io::Result<()> {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
let c = std::net::TcpStream::connect(l.local_addr()?)?;
let (s, _) = l.accept()?;
s.set_nonblocking(true)?;
let mut s = TcpStream::from_std(s);
let mut poll = Poll::new()?;
eprintln!("server fd = {}", s.as_raw_fd());
poll.registry().register(&mut s, Token(1), Interest::READABLE)?;
step(&mut poll, "1. register READABLE");
poll.registry().reregister(&mut s, Token(2), Interest::READABLE | Interest::WRITABLE)?;
step(&mut poll, "2. reregister READABLE|WRITABLE as Token(2)");
step(&mut poll, "2a. poll again, nothing changed");
poll.registry().reregister(&mut s, Token(2), Interest::READABLE | Interest::WRITABLE)?;
step(&mut poll, "2b. reregister the same READABLE|WRITABLE again");
poll.registry().reregister(&mut s, Token(3), 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);
step(&mut poll, "3. reregister READABLE|PRIORITY as Token(3), peer sends 1 byte MSG_OOB");
c.shutdown(Shutdown::Write)?;
step(&mut poll, "4. peer shutdown(Write)");
let mut buf = [0u8; 16];
eprintln!(" read -> {:?}", s.read(&mut buf).map_err(|e| e.kind()));
poll.registry().deregister(&mut s)?;
drop(c); // full close from the peer: would be HUP if still registered
step(&mut poll, "5. deregister, peer closes");
Ok(())
}
Linux 6.12.28(容器内 Alpine,aarch64 musl)、rustc 1.99.0,--release,同样的 strace 参数,跑了 3 次,输出逐字节相同:
epoll_create1(EPOLL_CLOEXEC) = 6
server fd = 5
epoll_ctl(6, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_pwait(6, [], 8, 100, NULL, 8) = 0
== 1. register READABLE: 0 event(s)
epoll_ctl(6, EPOLL_CTL_MOD, 5, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0x2}) = 0
epoll_pwait(6, [{events=EPOLLOUT, data=0x2}], 8, 100, NULL, 8) = 1
== 2. reregister READABLE|WRITABLE as Token(2): 1 event(s)
Event { token: Token(2), readable: false, writable: true, error: false, read_closed: false, write_closed: false, priority: false, aio: false, lio: false }
epoll_pwait(6, [], 8, 100, NULL, 8) = 0
== 2a. poll again, nothing changed: 0 event(s)
epoll_ctl(6, EPOLL_CTL_MOD, 5, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0x2}) = 0
epoll_pwait(6, [{events=EPOLLOUT, data=0x2}], 8, 100, NULL, 8) = 1
== 2b. reregister the same READABLE|WRITABLE again: 1 event(s)
Event { token: Token(2), readable: false, writable: true, error: false, read_closed: false, write_closed: false, priority: false, aio: false, lio: false }
epoll_ctl(6, EPOLL_CTL_MOD, 5, {events=EPOLLIN|EPOLLPRI|EPOLLRDHUP|EPOLLET, data=0x3}) = 0
sendto(4, "!", 1, MSG_OOB, NULL, 0) = 1
epoll_pwait(6, [{events=EPOLLPRI, data=0x3}], 8, 100, NULL, 8) = 1
== 3. reregister READABLE|PRIORITY as Token(3), peer sends 1 byte MSG_OOB: 1 event(s)
Event { token: Token(3), readable: true, writable: false, error: false, read_closed: false, write_closed: false, priority: true, aio: false, lio: false }
shutdown(4, SHUT_WR) = 0
epoll_pwait(6, [{events=EPOLLIN|EPOLLPRI|EPOLLRDHUP, data=0x3}], 8, 100, NULL, 8) = 1
== 4. peer shutdown(Write): 1 event(s)
Event { token: Token(3), readable: true, writable: false, error: false, read_closed: true, write_closed: false, priority: true, aio: false, lio: false }
recvfrom(5, "", 16, 0, NULL, NULL) = 0
read -> Ok(0)
epoll_ctl(6, EPOLL_CTL_DEL, 5, NULL) = 0
epoll_pwait(6, [], 8, 100, NULL, 8) = 0
== 5. deregister, peer closes: 0 event(s)
+++ exited with 0 +++
逐步看:
- 第 1 步:
READABLE变成EPOLLIN|EPOLLRDHUP|EPOLLET,data=0x1。没有数据,poll拿到 0 个事件。 - 第 2 步:
reregister是EPOLL_CTL_MOD,掩码多了EPOLLOUT,data换成0x2。socket 的发送缓冲区一直是空的,可写状态从连接建立起就没有变过,但MOD之后的第一次poll立刻拿到了EPOLLOUT,token 已经是新的 2。 - 第 2a、2b 步:什么都不做再
poll一次,0 个事件,这是边沿触发;再用完全相同的参数reregister一次,又来了一个EPOLLOUT。可见EPOLL_CTL_MOD会按新的掩码把 fd 当前的就绪状态重新检查一遍,已经就绪就再报一次,和参数变没变无关。 - 第 3 步:掩码换成
EPOLLIN|EPOLLPRI|EPOLLRDHUP|EPOLLET(EPOLLOUT没了,因为MOD是整个替换)。客户端用 libc 的send(…, MSG_OOB)发 1 字节带外数据,strace 里是sendto(4, "!", 1, MSG_OOB, …)。事件只有EPOLLPRI,is_priority()和is_readable()都为真,后者是因为 epoll 的is_readable把EPOLLPRI也算进去。 - 第 4 步:对端
shutdown(SHUT_WR),事件是EPOLLIN|EPOLLPRI|EPOLLRDHUP:read_closed为真,带外数据还没读,EPOLLPRI仍在。紧接着的recvfrom返回 0,就是 EOF。带外的那 1 字节不在普通数据流里(没有设SO_OOBINLINE),所以普通read读不到它。 - 第 5 步:
deregister是epoll_ctl(6, EPOLL_CTL_DEL, 5, NULL)。之后对端彻底关闭,fd 还开着,但已经不在集合里,poll0 个事件。
问: 边沿触发不是「状态变了才报」吗?第 2b 步什么都没变,为什么 reregister 一下就又报了一次?
答: 「边沿」是 epoll 在两次 epoll_wait 之间记的:一个项被取走以后,要等到 fd 上再发生一次唤醒才重新排进就绪队列。EPOLL_CTL_MOD 换掉掩码的同时会对 fd 重新检查一次,当前已经就绪的位和新掩码对上了,就直接排进就绪队列,相当于人为造了一个边沿。所以在 Linux 上,reregister 可以拿来补救「漏读了一次 WouldBlock」的局面。但这只是 epoll 的行为,mio 的文档没有承诺它;可移植的代码仍然应该读到 WouldBlock 为止。
event 模块:epoll 上的几点
sys::Event 就是 libc::epoll_event,Events 就是 Vec<libc::epoll_event>,event 模块里每个函数都只是按位与。每个 is_* 查哪些位、和 kqueue 的对照表、管道和 RST 等场景下的实测,都在 Token、Interest 与 Event。这里只补 epoll 特有的理由:
is_error只看EPOLLERR,而EPOLLERR是内核总会报的那一位,所以不管注册时关注什么,socket 出错都能看到。具体错误码不在事件里,要用take_error(SO_ERROR)或下一次读写拿到,这一点和 kqueue 把错误码放在fflags里不同。EPOLLHUP单独就能让is_read_closed和is_write_closed同时为真。 它的含义是两个方向都关了,也是不注册就会报的一位,所以只关注WRITABLE的 source 也能靠它知道连接已经断了。is_read_closed的另一支要EPOLLIN和EPOLLRDHUP同时出现,而EPOLLRDHUP只有关注可读时才注册(上一节)。反过来,只关注WRITABLE的 source 拿不到对端半关闭的信号,见本节的动脑筋。is_write_closed有三支:EPOLLHUP;EPOLLOUT和EPOLLERR同时;以及「恰好只有EPOLLERR」,最后一支是 0.7.1 为管道读端关闭加的(#1350)。注释分别写着「Both halves of the socket have closed」「Unix pipe write end has closed」「The other side (read end) of a Unix pipe has closed.」socket 本端shutdown(Write)只产生EPOLLOUT,所以 epoll 上这个方法会漏报,而文档承诺的是「false-positives will not occur」,漏报是允许的。is_aio恒为false,注释是「Not supported in the kernel, only in libc.」:epoll 没有对应的事件位可查。
src/bin/writable_fin.rs:服务端 socket 只用 Interest::WRITABLE 注册(掩码 EPOLLOUT|EPOLLET,没有 EPOLLIN、没有 EPOLLRDHUP),先 poll 一次把初始的可写事件取走,然后对端 shutdown(Write),再 poll 一次,打印事件数和每个事件。
use std::net::Shutdown;
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
// A socket registered WRITABLE only: what does the peer's FIN produce?
fn main() -> std::io::Result<()> {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
let c = std::net::TcpStream::connect(l.local_addr()?)?;
let (s, _) = l.accept()?;
s.set_nonblocking(true)?;
let mut s = TcpStream::from_std(s);
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
poll.registry().register(&mut s, Token(1), Interest::WRITABLE)?;
poll.poll(&mut events, Some(Duration::from_millis(100)))?;
println!("{} initial: {} event(s)", std::env::consts::OS, events.iter().count());
c.shutdown(Shutdown::Write)?;
poll.poll(&mut events, Some(Duration::from_millis(100)))?;
println!("{} after peer shutdown(Write): {} event(s)", std::env::consts::OS, events.iter().count());
for e in &events {
println!(" {e:?}");
}
Ok(())
}
在 Linux 6.12.28(容器内 Alpine,aarch64 musl)和 M2 Max、macOS 上各跑一次,--release,rustc 1.99.0。第二次 poll 各拿到几个事件?如果有,read_closed 是真还是假?
debug_details:{:#?} 里的原始标志位
Event 的 Debug 实现(event.rs)平时只打印 token 和各个 is_* 的结果;用 {:#?}(alternate)时多一个 details 字段,交给平台的 debug_details:
pub fn debug_details(f: &mut fmt::Formatter<'_>, event: &Event) -> fmt::Result {
#[allow(clippy::trivially_copy_pass_by_ref)]
fn check_events(got: &u32, want: &libc::c_int) -> bool {
(*got as libc::c_int & want) != 0
}
debug_detail!(
EventsDetails(u32),
check_events,
libc::EPOLLIN,
libc::EPOLLPRI,
// ... EPOLLOUT、EPOLLRDNORM、EPOLLRDBAND、EPOLLWRNORM、EPOLLWRBAND、
// EPOLLMSG、EPOLLERR、EPOLLHUP、EPOLLET、EPOLLRDHUP、
// EPOLLONESHOT、EPOLLEXCLUSIVE、EPOLLWAKEUP
libc::EPOLL_CLOEXEC,
);
// Can't reference fields in packed structures.
let e_u64 = event.u64;
f.debug_struct("epoll_event")
.field("events", &EventsDetails(event.events))
.field("u64", &e_u64)
.finish()
}
debug_detail! 生成一个新类型,Debug 时把列表里每个常量过一遍 check_events,为真就写出常量名,用 | 连起来,一个都没有就写 (empty)。两处值得一说:
- 「Can't reference fields in packed structures.」 libc 在 x86_64(以及 x86 的 gnu 目标)上给
epoll_event加了repr(packed),和内核头文件的布局一致:u32后面紧跟u64,没有对齐填充,&event.u64是一个未对齐的引用,Rust 不允许。所以先按值拷到局部变量再打印。aarch64 上没有 packed,但代码要在所有架构上编译。 - 列表里有
EPOLL_CLOEXEC。它是epoll_create1的 flag,不是事件位,内核返回的events里不会有它。列表里的EPOLLRDNORM等位也不会出现:内核返回的位总是和注册的掩码求交(EPOLLERR、EPOLLHUP除外),而 mio 从不注册它们;EPOLLET同理,它是注册时的选项,不会出现在返回的事件里。
src/bin/details.rs 把一个 READABLE | WRITABLE 的 socket 在对端 shutdown(Write) 之后的事件用 {:#?} 打印出来:
use std::net::Shutdown;
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
fn main() -> std::io::Result<()> {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
let c = std::net::TcpStream::connect(l.local_addr()?)?;
let (s, _) = l.accept()?;
s.set_nonblocking(true)?;
let mut s = TcpStream::from_std(s);
let mut poll = Poll::new()?;
poll.registry().register(&mut s, Token(7), Interest::READABLE | Interest::WRITABLE)?;
c.shutdown(Shutdown::Write)?;
let mut events = Events::with_capacity(8);
poll.poll(&mut events, Some(Duration::from_millis(100)))?;
for e in &events {
println!("{e:#?}");
}
Ok(())
}
Linux 6.12.28(容器内 Alpine,aarch64 musl)、rustc 1.99.0,--release:
Event {
token: Token(
7,
),
readable: true,
writable: true,
error: false,
read_closed: true,
write_closed: false,
priority: false,
aio: false,
lio: false,
details: epoll_event {
events: EPOLLIN|EPOLLOUT|EPOLLRDHUP,
u64: 7,
},
}
details 就是 strace 里能看到的那两样:events 和 data。调试时不想开 strace,{:#?} 是看内核原始返回值最省事的办法。
用这个文件的其他平台
这个文件不只服务 Linux。sys/unix/mod.rs 的 cfg 把 android、illumos、redox 也指向它:
- Android:内核就是 Linux,代码完全相同。曾经的差别都在
epoll_create1(见第一节),1.0.0 起已经没有了。 - illumos:有自己的 epoll 实现,0.7.1 起用这个文件(「Add support for illumos target」)。selector 部分没有 illumos 专属的代码;那边 epoll 模拟的一个问题落在 eventfd
Waker上,每次wake之前要先reset,见 Waker。 - Redox:0.8.2 起用这个文件(#1553),只多了
epoll_event的_pad字段;Waker走 pipe 而不是 eventfd,见 Waker。
Solaris 曾经也在这个列表里(0.7 时代 selector/mod.rs 的 cfg 写着 target_os = "solaris"),后来移出去了,1.2.4 的 Solaris 用 selector/event_ports.rs。
动脑筋答案
两个平台各跑 3 次,输出相同。Linux:
linux initial: 1 event(s)
linux after peer shutdown(Write): 1 event(s)
Event { token: Token(1), readable: false, writable: true, error: false, read_closed: false, write_closed: false, priority: false, aio: false, lio: false }
M2 Max、macOS:
macos initial: 1 event(s)
macos after peer shutdown(Write): 0 event(s)
答案是 Linux 1 个、macOS 0 个;Linux 那个事件的 read_closed 是假。
- Linux:另外加 strace 跑的一次里,注册是
{events=EPOLLOUT|EPOLLET, data=0x1},两次epoll_pwait返回的都是{events=EPOLLOUT, data=0x1}。对端的 FIN 让 socket 被唤醒了一次,可掩码里没有EPOLLIN、没有EPOLLRDHUP,和掩码求交之后只剩EPOLLOUT。于是调用方收到一个「又可写了」的事件,可写状态其实没变;它真正想知道的「对端关了」却不在里面,is_read_closed()为假。 - macOS:kqueue 只注册了
EVFILT_WRITE,对端的 FIN 影响的是读方向,写过滤器上什么都没发生,所以没有事件。 - 两边都看不到对端半关闭,这是只关注
WRITABLE的必然结果,和「EPOLLRDHUP只随READABLE注册」是同一件事。要知道对端是否关闭,必须关注READABLE。
小结
epoll.rs246 行,android、illumos、linux、redox共用。Selector在 release 下就是一个OwnedFd;new是epoll_create1(EPOLL_CLOEXEC),1.0.0 删掉了epoll_create回退(#1741)。try_clone是 std 的fcntl(fd, F_DUPFD_CLOEXEC, 3)。它经历过dup不带 CLOEXEC(#1491)和 0.7.12 漏传第三个参数(#1497,0.7.13 修)两次修复。select调epoll_wait;aarch64 没有这个系统调用,musl 用epoll_pwait(…, NULL, 8)实现,所以 strace 里看到的是后者。超时与EINTR见 Poll 与 Registry。register/reregister/deregister是EPOLL_CTL_ADD/MOD/DEL,MOD整个替换掩码和 token;DEL传NULL。reregister/deregister只在开了net或os-ext时编译。- 掩码总带
EPOLLET(0.7.0 去掉PollOpt,#975);可读加EPOLLIN|EPOLLRDHUP,可写EPOLLOUT,PRIORITY加EPOLLPRI。EPOLLERR、EPOLLHUP不用注册,内核总会报。 - 不注册
EPOLLRDHUP,对端 FIN 和普通数据都只是EPOLLIN;mio 0.7.0 起关注可读就注册它,is_read_closed要EPOLLIN和EPOLLRDHUP同时出现(#1110;0.6.17 曾因把它当成HUP而删掉,#939)。 - 实测:
EPOLL_CTL_MOD会立刻按新掩码重新检查就绪状态,参数不变也会再报一次;DEL之后对端关闭不再有事件;PRIORITY加MSG_OOB得到EPOLLPRI,is_readable()和is_priority()同为真。 {:#?}打印details: epoll_event { events, u64 };x86_64 上epoll_event是 packed,所以u64要先拷出来。