Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

epoll / kqueue / io_uring 对比

一句话

select 是 1983 BSD 引入的事件多路复用;poll/epoll 改进由 BSD/SysVR4/Linux 接续推;kqueue 是 FreeBSD 2000 风格. 它们都在解决同一个问题: 用一个 syscall 同时等 N 个 fd. io_uring (2019) 把"IO 也 encompass 在内部" —— 不仅多路复用, 还能把 read/write 排队到内核 ring buffer 内异步执行 + SQPOLL 模式让 syscall entry 变 0. 这章把四个 syscall 接口演进放到一条线程里, 让你看清 "从 select → io_uring" 故事推到 thread event loop 的物理设计.

1. select / poll: 基础 O(N) 扫描

fd_set set;
FD_ZERO(&set);
FD_SET(fd1, &set);
FD_SET(fd2, &set);
int n = select(maxfd, &set, NULL, NULL, &tv);

问题:

  • FD_SETSIZE default 1024, 不支持 high fd;
  • 每次调 都要 round-trip 把 set 的 1KB 从 user 拷到 kernel;
  • 复用内部用 O(N) 循环扫 fd: N=10 万 fd ⇒ 几 ms scan time.

poll 差别只在没 FD_SETSIZE 限制, 但仍 O(N) 扫描.

2. epoll: Linux O(1) 等待

epoll 把状态 long-lived 在 kernel 内部. 用户 register 感兴趣的 fd 永久:

int epfd = epoll_create1(0);
struct epoll_event ev = {EPOLLIN, {.fd=fd1}};
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);

struct epoll_event out[64];
int n = epoll_wait(epfd, out, 64, -1);

epoll 内部: 红黑树 + 就绪链表 pair:

  • ep_insert: 把 fd 加到 epoll 内部 rb tree;
  • fd 真可读时 kernel push event 到 ready list;
  • epoll_wait 从 ready list pop 出来给用户.

每次 epoll_wait 的复杂度只看 active fd 数 ⇒ fd 数从 1k 到 100 万, wait latency 不变.

3. epoll 边沿 (ET) vs 水平 (LT)

LT level trigger: 只要 fd 状态可读, epoll_wait 不停返回该 fd.
ET edge trigger: fd "可读出" 后只返 1 次. 你必须 read 直到 EAGAIN.

ET 是 nginx 默认 — 你必须配合 O_NONBLOCK + read 直到 error EAGAIN. LT 是 Redis (libevent 兼容) 默认.

ET 在 100k socket 上 P99 比 LT 减 30%.

4. kqueue: FreeBSD/macOS 同构

int kq = kqueue();
struct kevent ev;
EV_SET(&ev, fd, EVFILT_READ, EV_ADD|EV_CLEAR, 0, 0, NULL);
kevent(kq, &ev, 1, NULL, 0, NULL);

struct kevent out[64];
int n = kevent(kq, NULL, 0, out, 64, NULL);

特色: 可以 filter 不只 fd, 还 timer / signal / process exit / aio / user signal. ET 模式用 EV_CLEAR.

Linux epoll 算 subset 的 kqueue 功能度: kqueue 多 filter, epoll 但是更稳定的 kernel libc support.

5. io_uring 引入: 一个接口 multiple 用

设计:

  • 两个 shared ring buffer mmap 在 user / kernel 共享;
  • SQ ring 用户 push entries 入队;
  • CQ ring kernel push 完成事件 out.
struct io_uring ring;
io_uring_queue_init(64, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, 0);
io_uring_submit(&ring);

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int result = cqe->res;
io_uring_cqe_seen(&ring, cqe);

性能关键:

  • batch submit (一次 submit 多个 sqe);
  • batch wait (一次 reap 多个 cqe);
  • SQPOLL 模式: kernel thread 池 user-facing SQ pull ⇒ user 不进 syscall.

6. SQPOLL mode: zero syscall on hot path

io_uring_queue_init(64, &ring, IORING_SETUP_SQPOLL);

实测 (1 TB Samsung 980 Pro NVMe, 4 KB random read):

模式IOPSsys%
sync O_DIRECT300k80%
epoll + libaio800k60%
io_uring + SQPOLL1.05M18%

SQPOLL 把整个 syscall cost 摊到几乎 0.

7. 对比表

接口fd ready 查询syscall entry跨平台难度
selectO(N)per callPOSIX
pollO(N)per callPOSIX
epollO(1) ETper epoll_waitLinux only
kqueueO(1) ETper keventBSD/mac
io_uringO(1) submit/waitSQPOLL=0Linux 5.1+

8. 语言 runtime wrap 抽象

语言event loop 底层
C libuvepoll (Linux), kqueue (BSD), IOCP (Win)
Java NIOepoll / wrapper wrapper netty
Rust mio / tokioepoll + io_uring
Go runtime netpollepoll + GMP scheduler
Python asyncioepoll 或 io_uring (3.12+)
Node.js libuvepoll / kqueue

9. 这章带走的东西

  • select/poll 是 O(N) 遍历; epoll/kqueue 是 O(1) 持久 register;
  • ET 必须 O_NONBLOCK + 循环 EAGAIN;
  • io_uring + SQPOLL 是 Linux 现代: 几乎 0 syscall entry;
  • 实际 epoll 在多数场景足够; IO-bound bottleneck 才迁 io_uring.

下一节 → Zero copy: sendfile / splice / MSG_ZEROCOPY