I/O 模型 入门
三个论断,每个都带一个数字和一张你能亲手驱动的图。一条阻塞的线程要 25.5 KiB 内核内存,外加两次 1.3 µs 的上下文切换。就绪模型把 O(n) 变成 O(ready) —— 而在一切都就绪的那一刻它就不再帮忙。完成模型删掉了每操作一次的 syscall:一批一个时一文不值,一批三十二个时值 2.6 倍。
等待,以及谁来付这笔账
每个 I/O 调用要么等答案,要么拒绝等。这一页上的每一个环、每一套就绪接口、每一次拷贝,都是从这个岔口长出来的。
套接字上的 read 干两件事:等到字节进接收队列,然后把它们拷出来。拷贝是几微秒的实打实的活。等待没有上界,而它就是整个设计问题本身。
所以先盯住等待本身。拖动滑块挪动第一个字节落地的时刻,看那条没在跑的线程被拉长去够它:
注意被挂起的那一段并不是内核在干什么。初始设置下这次调用花的 406 µs 里,6 µs 是拷贝,400 µs 是什么都没有;而这条线程为了睡过去,付了两次上下文切换 —— 2.6 µs。
非阻塞就是同一个调用把等待删掉。切到另一个模式,syscall 立刻返回 EAGAIN,而线程留下的这段时间,正好就是刚才被挂起的那一段:
看清楚模式开关到底改了什么:数据没变,套接字没变,变的只是这段空档归谁。阻塞把它交给内核,换回简单。非阻塞把它留下,于是欠下了内核原本替你回答的那个问题 —— 这个描述符什么时候值得再问一次?
阻塞不会因为写起来简单就免费。每个在等的连接要自己一条线程,每条线程是 16 KiB 内核栈加一个 task_struct。把连接数调大,看这笔固定开销:
时,一个字节还没搬就已经是 249 MiB 内核内存,外加 glibc 默认 8 MiB 栈预留掉的 78 GiB 地址空间 ——真正落地的只有 handler 碰到的那几页。推到,这些线程要 2.4 GiB 永不换出的内核内存、算上用户页一共 3.2 GiB —— 一台 16 GiB 机器的五分之一。这 25.5 KiB 的两半都是数出来的,不是估出来的:16 KiB 是 x86-64 上的 THREAD_SIZE,四页内核栈;9.5 KiB 是 6.x 内核 /proc/slabinfo 里 task_struct 那个 slab 报出来的数。
内存是看得见的账。藏起来的那笔是切换:1.3 µs 的调度器加冷掉的缓存,每个事件两次。把事件率调高,看曲线穿过那条代表整整一个核心的线:
因为两次切换是 2.6 µs,穿越点落在 —— 一条连接在压测里就能打到的量级。过了那儿,一个核心就花在换寄存器上了,应用层怎么调都碰不到它。
光有非阻塞也解决不了,因为一个无处可等的循环只能反复问。把两次询问之间的间隔收紧,看两根条互相顶:
这根滑块的两端都不是一台服务器。 时循环烧掉 125% 的核心去回答 EAGAIN; 时它用 2.5 ms 的平均延迟来回答。缺的是一条「被告知」的路径,而下一节就是这一个想法的二十五年。
一条线程,一堆套接字
「就绪」就是一条线程一次性向内核问一个关于几千个描述符的问题。三代接口回答了它。
select 在 1983 年用三张位图回答:你关心的每个描述符置一位,内核把它们还回来、就绪的位已经置上,你再扫一遍。位图是个定长结构体,麻烦就从这儿开始:
把标记,什么都不会抗议。FD_SET 是一个不做边界检查的数组宏,所以越过末端的那个描述符会写进调用者放在栈上的下一样东西。调用处不报错、不崩溃 ——只是某个局部变量被改坏了,在之后的某个地方发作。
poll 用一个由调用者定大小的数组解决了上限,却原封不动留下了真正的代价。把监视集合和其中就绪的数目对起来,读一读内核这一趟本可以走的两种走法:
监视一千个、就绪十个时,内核 1,000 次检查里有 990 次花在无话可说的描述符上 ——然后用户态又把同一个数组走了一遍,去找出是哪十个。两趟都是 O(n),n 是集合的大小,不是答案的大小。
epoll(Linux 2.5.44)只改了一件事:这个集合留在内核里。用播放键走过那三个 syscall,看每一次回来的是什么:
因为兴趣列表一直登记着,唤醒就不必再描述整个集合 ——它只报告真正触发的那些描述符。这换来的不变式值得精确写下:每次循环开头,每个描述符要么在内核的兴趣列表里,要么在循环正在排空的就绪集合里。两边都不在的那个,就是隐形的。
把集合留在内核里,也正是 epoll 不再按描述符号办事的地方。兴趣列表挂在打开文件描述上,不是挂在那个整数上。走一遍 dup,再关掉你登记过的那个号:
注意登记比描述符活得久。你以为已经关掉的套接字还在不停送事件来,而对已关闭的号做 EPOLL_CTL_DEL 只会拿到 EBADF ——你已经没法再叫出登记过的那个东西了。fork 是同一个陷阱的另一面:子进程继承了 epoll fd,于是两个进程会为同一个套接字各被唤醒一次。永远先删登记,再关描述符。
按答案的大小算而不是按集合的大小算,换来的是另一条曲线,不是更好的常数。把监视集合调大,看两条线分开:
、1% 就绪时,一次唤醒是 250 µs 对 2.8 µs —— 九十一倍。但把第二根滑块推上去,差距就合拢了:50% 就绪时 epoll 走的几乎是同一趟,因为当一切都就绪时,O(ready) 和 O(n) 是同一个东西。
一个被共享的描述符也会以同样的方式出问题。把一个监听套接字登记进 n 个工作进程,然后调大工作进程数,在一条到达的连接下把 EPOLLEXCLUSIVE 开开关关:
不带这个标志时,每个等待者都会被叫醒;只有一个抢到 accept,其余的拿到 EAGAIN 再去睡,每一个都白付了两次上下文切换。这就是 accept 惊群,而 EPOLLEXCLUSIVE(Linux 4.5)只叫醒一个等待者。它只对水平触发有效,也不是通用解:真正能扩展的是 SO_REUSEPORT —— 每个工作进程拿到自己的 accept 队列。
epoll 用两种形状给出答案,其中一种是陷阱。水平触发报告任何还能读的东西;边沿触发只报告「到达」这件事,一次。把每次读的量调小,再切换模式:
看边沿触发在 16 KiB 一读时的样子:48 KiB 留在套接字里,循环挂起,而已经到达的这些字节再也等不到下一次唤醒。连接就那么挂着,日志一声不吭。这就是整节的失效模式 —— 不变式被一个两边都不在的描述符打破,而且它是静默失败。
别再问了,直接交出去
epoll 告诉你某个描述符就绪了,然后活还得你自己干,一次一个 syscall。io_uring 把后半截删掉了。
数一数一批就绪操作要跨多少次边界。开着 KPTI 时每次 250 纳秒,就绪模型的循环要为唤醒付一次、再为每个操作各付一次,而环只为整批付一次:
注意时,三种模型的代价差不多 —— 每个操作 2.9 µs、3.1 µs、2.9 µs ——因为唤醒前后那两次上下文切换压过了一切。赢的不是机制,是批量:一批三十二个时,io_uring 跨一次边界,而 epoll 跨 33 次。第一行值得看两遍,因为它是另外两行的比较基准:每连接一线程根本没有「一批」。三十二个就绪操作就是三十二条线程,各自被唤醒、各自再挂起,所以那两次切换是按操作算的,不是按批算的 ——这就是它每个操作 2.9 µs,而多跨一次边界的 epoll 只要 339 ns 的原因。
它是用共享内存买来的。提交环同时映射进两个地址空间,所以写一个请求是一次存储,不是一次调用。沿着环拖动尾指针:
注意读数:条目已经排上了,而一次 syscall 都还没发生。真正要紧的只有两个下标 —— 你推进的 tail 和内核推进的 head —— 它们的差就是占用量。推到,环就满了;这时提交要等内核,而这正是这个模型最坏的单次操作。
完整的一轮是四拍,其中只有一拍跨界。用播放键走一遍:
因为完成项也是回到映射好的内存里,收结果又是一次读取 ——中间那一次跨界就是全部账单。这就是从就绪到完成的转变:你不再问什么就绪了,而是描述工作、等人告诉你做完了;这也是为什么 io_uring 覆盖得了普通文件,而 epoll 从来做不到。
还有一种模式,连那一次调用也拿掉。开了 IORING_SETUP_SQPOLL,一条内核线程自旋在环上,于是那次跨界根本不发生。把轮询线程打开,读一读它的价钱:
两根条一起看:应用在边界上的那份降到零,而一整个核心被占住,环忙环闲都一样。Jens Axboe 2019 年的论文在同一块设备上用这条路测到 170 万 IOPS,对比 libaio 的 60.8 万 —— 2.8 倍是真的,代价是租一个核心。
把字节搬过去
知道哪个描述符就绪了,并不说明搬运载荷要花多少钱。那笔账是用内存带宽付的。
把页缓存里的文件发到套接字上,就是一次 read 读进缓冲区、再一次 write 写出去。用播放键走一块数据,看载荷到底走了哪儿:
注意第一步时这些字节已经在内核里,第四步又回到内核里。用户缓冲区是这份数据本来不需要的一次往返,而这一来一回的每一程都是 CPU 按内存带宽一个字一个字地搬。
每一个内核接口正好削掉其中一程。把滑块从那个循环一路走到 MSG_ZEROCOPY,看 CPU 根本不走的那一跳顶上来:
sendfile 删掉了用户缓冲区,页缓存直接喂给套接字:两次拷贝变一次,两次 syscall 变一次。再加上分散聚集 DMA,网卡自己去读页缓存,CPU 根本不碰载荷。nginx 发静态文件用的就是这条路。
给它标上尺寸。按每核 10 GB/s,把文件拖大,读这三张账单:
一个 100 MiB 的文件,在那个循环下要 22 ms CPU,sendfile 下 11 ms,分散聚集下 401 µs —— 五十四倍,而且全都是拷贝,因为那 3,206 次 syscall 在总数里只占 0.8 ms。在这个尺寸上,边界是噪声,带宽是一切 ——而且你怎么拖,这个 54 都不会变,因为它里面每一项都是按字节算的。变的只有账单本身。
也正因如此,到小消息上它就反过来了。把消息缩小,直到两条线交叉:
因为 MSG_ZEROCOPY 得把页锁住、还得去套接字的错误队列上收回执,它背着一笔固定的 1,650 ns,小消息摊不掉。交叉点在 13.7 KiB,和内核文档给的约 10 KiB 很接近 ——比这更小,零拷贝比它省掉的那次拷贝还慢;在 上它慢 1.3 µs。
每一种到底赢在哪儿
三种模型,三根轴。没有哪一种处处最快,而且决定大多数争论的那两根轴,跟快慢无关。
第一根轴是内存。一条 epoll 注册大约 200 字节;一条线程是 25.5 KiB 永不换出、也永不缩小的内核内存。
把连接数调大,看一个连接一个描述符那条线平平地留在另一条正在爬走的线下面:
比例平平地停在 128 倍,咬人的是绝对值。时,线程要 249 MiB,而 2.0 MiB 就够。C10K 在 1999 年是个有名字的问题,今天不是了;解法从来不是更快的线程 —— 而是不要线程。
撑爆机器的不是那两条有颜色的线 —— 这也是最该准备好的追问。压在它们上面那条灰线是套接字缓冲区:Linux 的 tcp_rmem 默认是 4 KiB / 128 KiB / 6 MiB,两种模型都要按连接付。一万条活跃连接就是 1.2 GiB 接收缓冲 ——线程开销的五倍。不用线程买到的是它下面那根轴,不是它。
第二根轴是大家会跳过的那根。事件循环就是一条线程,所以在里面阻塞的任何东西,都在阻塞所有人。把那次阻塞调用从零拖出来:
因为循环没法抢占自己,另外 199 个已经就绪的连接要陪着等完整个调用 ——一次 的 getaddrinfo,就是它们每一个 p99 上的 10 ms。goroutine 是同一个形状:runtime 会把你 park 在它认识的那些 I/O 的 epoll 上,而它不认识的,就钉住一条内核线程。
第三根轴是:你到底被不被允许用它。io_uring 在这里最快,也最不可用:Docker 默认 seccomp 拦掉 io_uring_setup,2023 年 Google 在 ChromeOS 和 Android 上关掉了它,6.6 起的内核带了 kernel.io_uring_disabled,有的发行版把它设成 2。先把 epoll 那条路写出来。
速查
三个值得脱口而出的问题、一个真的会上线的 bug,还有五件评审时要拦下的事。
io_uring 更快吗?
只有攒批才更快。设定一起提交的操作数,读一读一个核心只做边界上的活时能撑住的上限:
时,三根条一样长 ——都在每秒 32 万到 35.5 万之间 —— 因为两次上下文切换压过了一切。一批三十二个时,完成模型到 770 万,对 290 万;而每连接一线程纹丝不动,因为它没有任何东西可以攒批。
这一节真正说的那个 bug 是什么?
边沿触发的 epoll 只报告一次「到了」。处理函数只做一次 read 就返回,装不下的那部分留在套接字里,而且不会再为它唤醒:64 KiB 一次涌进 16 KiB 的缓冲区时,下面上面那一行会永远丢下 48 KiB,而下面那一行会一直读到 EAGAIN:
/* EPOLLET, one read: 48 KiB stranded */ n = read(fd, buf, sizeof buf); /* correct: drain until EAGAIN */ while ((n = read(fd, buf, sizeof buf)) > 0) handle(buf, n);
上面那一行就是会上线的 bug:合法的 C、没有警告,在对端发得比一个缓冲区还少的每一个测试里都对。开销只有跟它旁边的活比才值得争,所以先设定一次请求干的活:
20 µs 的活 —— 一次缓存查询、一次小解析 —— 每连接一线程把请求的 12% 花在开销上,而 0.64% 就够。拖到,每种模型都在 0.3% 以下:选你的团队凌晨三点调得动的那个。
epoll 为什么比 poll 快,什么时候不快?
poll 是双份 O(n):内核走一遍整个数组,你的循环再走一遍。epoll 把集合登记着、只把触发的还回来。可一旦每次唤醒集合里大部分都就绪,那就是同一趟 ——把两种代价放在同一根轴上读,轴的上限交给你的第二只手:
值得记住的一步是 250 ns 的一次 syscall 对 :5.2 倍。这里每种模型本质上都是「怎么少切换」。拷贝 64 KiB 要 6.55 µs,比它们全都大。
这些数字是哪来的?
250 ns 和 60 ns 出自 Gregg 2018 的 KPTI 测量,1.3 µs 是 lmbench 式双线程 ping-pong,10 GB/s 是 DDR4-3200 单核越过 L3,170 万对 60.8 万 IOPS 出自 Axboe 的 io_uring 论文。25 ns/描述符和 40 ns/条目是推算,用到的图在轴上写着。
- 新代码里出现 select。FD_SETSIZE 是 1024,而那个宏根本不检查。
- 边沿触发却没有排空循环。静默丢数据;除非你量过理由,否则用水平触发。
- 事件循环里的阻塞调用。DNS、打开文件、一把锁 ——每个就绪连接都要为它付账。
- 用 read 加 write 发静态文件。两次拷贝、两倍 syscall;sendfile 只要一行。
- 小消息上开 MSG_ZEROCOPY。大约 10 KiB 以下,锁页比拷贝还贵。