I/O 模型 入门
我们服务器要处理几千个并发连接。怎么问内核哪个数据就绪是最大单一架构选择。 5 个 section 把画面搭出来:阻塞 vs 非阻塞,基础区分;select / poll / epoll,多路复用的演变 + 可交互对比;io_uring —— 现代共享 ring buffer 替代;零拷贝把戏(sendfile / splice / DMA-buf)给高吞吐数据搬运; 最后是速查表。
阻塞 vs 非阻塞 —— 基础选择
每个 I/O syscall 要么阻塞(工作完成时返回)、要么非阻塞(立即返回,带工作状态)。 这个选择级联进整个服务器架构。
阻塞的 read(fd, buf, n) 在 socket 上: 有数据就拷然后返回。没有就让调用线程睡到数据到或连接关。 从应用视角看,调用「就 work」;线程隐式挂起、I/O 完成时恢复。编程简单 —— 但挂起的线程是个内核资源,你等的时候没法在同一线程上处理另一个连接。
非阻塞读(fd 上设 O_NONBLOCK、或通过 fcntl) 在同 socket 上:有数据就拷然后返回。没有就立即返回,errno = EAGAIN。 应用得决定怎么办 —— 过会儿再试、用 select/epoll/io_uring poll 就绪、或者放弃。
为啥这决定架构
阻塞 I/O 下,自然并发原语是「每请求一线程」:每个连接的 handler 线程在自己的读上阻塞。 简单但重 —— 内核线程(process-thread primer)每机器封顶几千。
非阻塞 I/O 下,自然原语是事件循环: 一个线程(或一小池)循环过就绪 fd,对每个做点工作,然后问内核接下来哪些 fd 就绪。 这就是让单进程能处理 10 万+ 连接的东西。
假装的代价
很多高级 runtime 把非阻塞 I/O 藏在看起来阻塞的 API 后面。Go goroutine 在conn.Read 上阻塞,但 runtime 在背后把它翻译成非阻塞 + 在 epoll 上 park。 Java 虚拟线程(Loom)一样。Rust async / await 同理。 程序员写看起来同步的代码;runtime 把许多这种「阻塞」调用多路复用到由 epoll 或 io_uring 支撑的小内核线程池上。
这种透明的代价:每个没有 async 等价物的阻塞调用(很多 runtime 里的文件系统调用、 第三方 C 库)钉住底层内核线程。如果你 10 个 OS 线程的 goroutine 池有 1 个阻塞在慢open(),你丢了 10% I/O 容量。Go 用 workaround(单独的 syscall 池); JVM 的 Loom 也是。
3 个中间模型
- 多进程(Apache prefork)—— 每请求 fork 一个进程。隔离,但每请求代价巨大。
- 线程池 + 队列 —— 有界线程从请求队列拉。资源用量有界, 但一个请求拖一个线程时有 head-of-line 阻塞。
- Reactor 模式 —— 单线程跑事件循环,把事件分发给 handler。 基于 select/epoll 事件循环的现代变种。Node.js、nginx、Twisted 用。
要点。「阻塞 I/O:线程睡到工作完成;每连接一线程封顶几千。 非阻塞 I/O:没就绪 syscall 立即返回 EAGAIN;一个事件循环能处理 10 万+ 连接。 高级 runtime(Go、Java Loom、Rust async)把非阻塞 I/O 藏在阻塞形状的 API 后面, 但底下依赖 epoll/io_uring —— 任何真阻塞的、调进未包装 C 代码,都钉住内核线程。」
select → poll → epoll —— 多路复用的演变
单线程看几千 socket 是经典高吞吐服务器设计。3 个 syscall 在 25 年里演变出来, 高效地干这件事 —— 每个都修了前一个的扩展问题。
工作:单线程持着 N 个打开的 socket,想在它们任一可读 / 可写时被唤醒。3 代 API:
select() —— 1980 年代
调用方传 3 个 fd_set 位图(读就绪、写就绪、异常)。 内核阻塞到任何 fd 就绪,然后返回位图,就绪 fd 的位被设。 调用方扫位图找哪些要处理。
问题:位图有编译时固定大小(FD_SETSIZE,通常 1024)。位图每次调用做用户→内核→用户的拷贝。 用户态每次调用都得扫整张位图找就绪 fd。过 ~1000 fd 后吞吐崩。
poll() —— 1980 年代,BSD 的回答
调用方传 pollfd struct 数组(fd + 想要的事件 + 收到的事件)。无固定大小。 其他问题一样:内核每次调用走整个数组检查就绪,用户态走整个数组找就绪的。每次调用 O(N)。
epoll —— 2002 Linux
让「C10K」(每服务器 1 万并发连接)可行的突破。3 个 syscall:
epoll_create—— 创建 epoll 实例,返回代表它的 fd。epoll_ctl—— 从被观察集里加 / 改 / 删 fd。每 fd 生命周期调一次,不是每次唤醒调。epoll_wait—— 阻塞到任何注册的 fd 就绪,只返回就绪的(不返回整个集)。
关键胜利:内核跨调用维护集(无拷贝)、只返回就绪 fd(O(active),不 O(N))。 1 万连接、任何瞬间只 100 个活跃,epoll 每次唤醒做 ~100 单位工作;select 会做 1 万。
水平触发 vs 边沿触发
水平触发(LT) —— epoll 报任何当前就绪的 fd,每次唤醒。 没排干 socket 的话,下次 epoll_wait 再报一次。默认;最易用。
边沿触发(ET) —— epoll 只在 fd 就绪状态变化时报。 报一次后,你必须读到 EAGAIN(排干 socket)否则会漏下次数据。 略快(稳定就绪 fd 不重复唤醒)但更难正确用。 性能关键服务器(HAProxy、Envoy)用。
kqueue —— BSD 平行演变
FreeBSD/macOS 用 kqueue 解了同问题(1999,比 epoll 早两年)。 API 更通用 —— kqueue 能观察任意事件(文件改动、进程事件、定时器、信号)、不只 fd。 性能跟 epoll 可比。可移植的跨平台抽象(libevent、libev、libuv)隐藏你底下跑的是 kqueue 还是 epoll。
要点。「单线程多路复用几千 fd 需要 O(active) 的唤醒机制、不是 O(N)。 select / poll 是 O(N);epoll(Linux)和 kqueue(BSD/macOS)是 O(active)。 epoll 是每个现代 Linux 事件循环的基础(Node.js、nginx、Redis、Go runtime)。 水平触发是简单默认;边沿触发省一点延迟,代价是每次唤醒都要完全排干。」
io_uring —— 超越 epoll
epoll 告诉你 fd 何时就绪,你仍然得做 syscall 读它写它。io_uring 把那个 syscall 也消掉。 结果是 Linux 自 1980 年代以来最接近根本性 I/O 重新设计的东西。
io_uring(Linux 5.1,2019)围绕用户代码和内核之间共享的 2 个 ring buffer 构建:
- 提交队列(SQ) —— 用户代码把 I/O 请求(从这个 fd 读到这个 buffer、 在这个 socket 上 accept 连接,等等)写到 ring 槽里。每条目是固定大小的sqe,描述一个操作。
- 完成队列(CQ) —— 内核把已完成操作的结果写到 ring 槽里。 每条目是固定大小的 cqe,描述一个已完成操作。
两个 ring 都映射进内核和用户地址空间。用户代码写 SQ 条目、更新 SQ tail 指针, 无 syscall。内核轮询或通过 io_uring_enter 被通知 (一次 syscall 潜在提交多个操作);处理操作;写 CQ 条目;更新 CQ head 指针。 用户代码读 CQ 条目,无 syscall。
它改变什么
epoll 下,每个 I/O 操作需要一个 syscall(实际读或写)加上周期性 epoll_wait 调用。 io_uring 下,你能用零或一个总 syscall 提交和收割几千个操作。 4 KB 随机磁盘读 benchmark 上,io_uring 在同硬件下达到 epoll + read 的 ~2-3× 吞吐, 因为 syscall 开销消失了。
它还原生支持 epoll 永远做不了的事:文件系统 I/O(epoll 只在 socket、管道等上工作)、 buffered 操作、固定 buffer(预注册以减少每操作拷贝)、内核轮询 (内核在单独线程里轮询 SQ,负载下连 io_uring_enter syscall 也消除)。
在哪用
- fio —— 存储 benchmark 工具,比大多数生产代码先长出 io_uring 后端。
- libuv(Node.js) —— 近期版本可选 io_uring 后端。
- Tokio(Rust) —— async runtime,通过
tokio-uring可选 io_uring 后端。 - Postgres 17+ —— 给某些 I/O 路径用 io_uring。
- 生产服务器 —— 采纳比 benchmark 暗示的慢。安全 CVE(2022-2023 年几次) 让一些发行版(Google、ChromeOS)对非特权用户禁了 io_uring。 API 也比 epoll 复杂,性能胜利只在过 10 万 op/sec 后有意义。
心智模型转变
epoll 说:「告诉我 fd X 何时就绪,我自己来做 I/O」。 io_uring 说:「这里是一列操作,做它们、告诉我什么时候完成」。 前者是基于就绪(你做工作);后者是基于完成(内核做工作然后回报)。 基于完成扩展更好但更难塞进现有 reactor codebase。
要点。「io_uring 通过用户代码和内核共享提交 / 完成 ring buffer 消除每操作 syscall。把模型从基于就绪(epoll)切到基于完成,在高速率下更好地并行化和摊销开销。 采纳是真的但比 benchmark 暗示的慢,因为 API 复杂、安全顾虑、以及对大多数工作负载 「epoll 已经够快」的问题。」
零拷贝 —— 不拷贝就搬数据
经典的 read-then-write 循环拷字节两次:设备 → 内核 buffer → 用户 buffer → 内核 buffer → 设备。 零拷贝技术消掉一个或多个拷贝,显著改善静态文件服务工作负载的吞吐。
考虑通过 HTTP 服务 100 MB 文件。朴素代码是:
for (;;) {
n = read(file_fd, buf, sizeof(buf)); // 文件 → 内核 page cache → 用户 buf
if (n <= 0) break;
write(socket_fd, buf, n); // 用户 buf → 内核 socket buf → NIC
}每块两次拷贝、两次 syscall。100 MB 文件 + 64 KB 块:1600 次 syscall + 200 MB CPU 驱动的内存拷贝。
sendfile()
sendfile(out_fd, in_fd, offset, count) 告诉内核直接把字节从文件的 page cache 搬到 socket 的发送队列,没有用户空间拷贝。一次 syscall 替代 N 对 read+write、 内存拷贝减半。这就是 nginx、Apache、任何静态文件 CDN 在底下用的、给响应 body 用的。
在支持 DMA scatter-gather 的 NIC 上,sendfile 能是真正的零拷贝操作: NIC 通过 DMA 直接从内核 page cache 读数据,从不经过 CPU 中转。
splice() 和 tee()
splice(in_fd, in_offset, out_fd, out_offset, count, flags)是 sendfile 更通用的表亲:通过中间内核管道在任意两个 fd 之间搬字节, 完全不碰用户空间。给代理用(从一个 socket 读、写到另一个,不通过用户代码拷)。tee() 在两个管道间复制字节 —— 用于日志或分支。
MSG_ZEROCOPY
socket 上的 send(),MSG_ZEROCOPY flag 告诉内核 保持用户 buffer 钉住、直接从它 DMA、不拷贝到内核 buffer。 用户代码字节发出去、buffer 可复用时,通过 socket 的错误队列拿到异步通知。 给大 send 省了用户→内核拷贝;代价是通知处理和「buffer 不能早复用或释放」约束。
io_uring 注册 buffer
io_uring(上一节)支持预注册 buffer(IORING_REGISTER_BUFFERS): 用户事先给内核一列 buffer;后续 I/O 操作按索引引用它们。 内核一次性钉住(每操作无钉住代价)、知道它们能直接 DMA 进。 这是在高吞吐 async 服务器里拿到零拷贝的现代方式。
零拷贝什么时候帮、什么时候不帮
- 帮:服务大静态文件(sendfile 给 nginx 比 read+write 的应用吞吐优势); 高吞吐代理(splice 给连接到连接的水管);视频 / 文件服务器(DMA scatter-gather 到 NIC)。
- 不帮:小消息,syscall 开销反正主导;读写之间需要变换的数据 (加密、编码)—— 你得拷贝才能变换;做随机小读的数据库 —— page cache 已经吸收了。
- 现代替代:DPDK / AF_XDP —— 用户空间完全绕过内核网络栈、直接 poll NIC。 极端性能场景用(云厂商 load balancer、金融交易系统)。 不同范式 —— 放弃通用性换原始吞吐。
要点。「朴素 read+write 循环每块做 2 次拷贝 + 2 次 syscall。 sendfile 和 splice 在 fd 之间搬字节、无用户空间卷入、消拷贝半减 syscall。 MSG_ZEROCOPY 和 io_uring 注册 buffer 钉住用户内存好让 NIC 直接 DMA。 静态文件服务和代理上胜利大;小消息或变换过的数据上可忽略。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。
阻塞和非阻塞 I/O 的区别?
阻塞:syscall 挂起线程到 I/O 完成。非阻塞:syscall 立即返回、没就绪就 EAGAIN。 阻塞写起来更简单但封顶几千并发连接(每个一个内核线程)。非阻塞需要事件循环 (select/epoll/io_uring),但让单线程能处理 10 万+ 连接。
为啥 epoll 比 poll 快?
poll 是 O(N):内核和用户态每次调用都扫整个 fd 集,即便只有几个就绪。 epoll 是 O(active):内核通过 epoll_ctl 跨调用维护集,epoll_wait 只返回就绪的。 1 万连接、任时 100 个活跃,epoll 每次调用做 100 单位工作,而不是 1 万。
水平触发 vs 边沿触发 —— 什么时候选哪个?
水平触发是安全默认:epoll 在每次唤醒报任何当前就绪的 fd。 边沿触发只报状态变化,所以每次唤醒你必须完全排干 socket(读到 EAGAIN), 否则漏下次数据。ET 在高速率场景下略快(HAProxy、Envoy), 因为消除稳定就绪 fd 的重复唤醒;LT 是大多数应用代码该用的。
io_uring 解了 epoll 解不了的什么问题?
epoll 告诉你 fd 何时就绪;你仍然做 syscall 真去读或写。io_uring 在用户和内核之间 共享提交和完成 ring buffer,所以 1000 个 I/O 操作能用 0 或 1 个总 syscall 完成。 转变是从基于就绪(你做工作)到基于完成(内核做、通知)。它还支持文件系统 I/O (epoll 不支持)和注册 buffer 给零拷贝。
sendfile 什么时候比 read + write 快?
总是,对于把字节从文件搬到 socket 不需要变换的场景。 read+write 拷两次、做两次 syscall;sendfile 一次 syscall 把字节从 page cache 搬到 socket buffer(或者通过 DMA scatter-gather 直接到 NIC),无用户空间卷入。 nginx 和 Apache 给静态文件响应在底下用它。需要在读和写之间变换字节时不帮 —— 加密、gzip —— 因为那样你反正得把它们带进用户空间。
C10K 问题是什么、什么解了它?
Dan Kegel(~1999)识别的「每服务器 1 万并发连接」挑战。 早期服务器用每连接一进程或一线程,因内存和调度器开销在每机器几百连接封顶。 被 O(active) 多路复用原语支撑的事件循环解了 —— Linux 上 epoll、BSD 上 kqueue。 C10K 解了之后,下一个挑战(C10M,每服务器 1000 万)驱动了 DPDK、AF_XDP、io_uring。
Code review 红旗
- 新代码里 select()。大多数系统 FD_SETSIZE 封顶 1024,每次调用 O(N) 扫。 Linux 上用 epoll、BSD 上 kqueue、或者可移植抽象(libuv)。
- 边沿触发 epoll 没排到 EAGAIN。会漏数据,看起来像消息悄无声息丢。要么仔细排干,要么换水平触发。
- 从 goroutine / async task 调阻塞 syscall。钉住内核线程;同 runtime 线程上的其他 goroutine 阻塞到它返回。 用 async 等价物或推给 worker 线程。
- 给大静态文件用 read+write 循环。用 sendfile。Apache 和 nginx 这么做;你的自定义文件服务器也该这么做。
- 同 fd 上混阻塞和非阻塞。跑事件循环时忘了对 accept 的 socket 设 O_NONBLOCK,意味着一个慢 client 能在下次读上 卡住整个循环。