Linux I/O演进:从管道阻塞到零拷贝与io_uring
上个月给公司的文件服务做压测我发现一个很有意思的现象四核机器负载不高但 CPU 的 sys 时间占了 45%。用 perf 抓了一下热区全在copy_user_enhanced_fast_string这个函数上。说白了数据在用户态和内核态之间反复搬运还没算业务逻辑光拷贝就吃掉了一大截 CPU。这让我想起很多后端工程师常问的问题Linux I/O、管道、epoll、零拷贝这些知识点单拎出来都能搜到教程但很少有人把它们串在一起讲。今天这篇我想换个角度从管道这个最古老的原语出发一路讲到零拷贝把服务端绕不开的核心原语之间的演进逻辑梳理清楚。如果你写过网络服务、做过文件传输、或者调过线上 CPU 暴高的问题这篇文章应该能帮你在脑子里补上一条完整线索。想直接翻内核源码的可以去看看fs/pipe.c、fs/read_write.c、fs/splice.c想先建立整体框架的跟着这篇一路往下读就行。我会尽量把每个关键点的“为什么”也讲明白而不是只丢结论。1. 一切从管道说起阻塞 I/O 的原型1.1 管道是什么内核里的一小块环形缓冲区管道pipe几乎是每个 Linux 程序员认识 I/O 的起点。你在 shell 里敲ls | grep log两个进程之间就通过管道在传数据。它的本质非常朴素内核维护一块环形缓冲区暴露两个文件描述符给你——一个用来写一个用来读数据从写端进去从读端出来先进先出。我见过不少工作几年的后端工程师一说管道就只记得“shell 里的竖线”其实管道的意义远不止于命令行。它是最早把“同步等待”这个概念引入 Unix 的 I/O 原语之一也是理解后面所有 I/O 模型的基石。管道为什么是“阻塞 I/O 的原型”看它的行为就明白写端调用write()时如果管道缓冲区满了调用线程会进入睡眠直到有空间腾出来。读端调用read()时如果管道里没有数据调用线程同样会睡眠直到有数据可读。这其实是“同步阻塞”最原始的形态一次读写两个进程互相等待对方腾出空间或送来数据。1.2 管道为什么要阻塞同步模型的天性很多人问为什么管道不设计成“写不进去就返回错误”原因其实很简单对于大多数 IPC 场景发送方和接收方的处理速度天然存在差异如果双方都运行在同一个操作系统里让慢的一方通过睡眠等待快的一方是省 CPU 的最好方式。想象一下管道是一个水管写端是水龙头读端是水桶。水桶满了你还继续拧开水龙头水就会溢出来。内核的选择不是把水倒掉返回错误而是把你这个“拧水龙头的人”叫醒等水桶倒空了再继续。这在操作系统里就体现为进程被挂起放入等待队列不再占用 CPU。理解了这个“阻塞”的天性你才能真正理解后面所有 I/O 演进的动机select、epoll 要解决的是“等得太盲目”mmap、sendfile 要解决的是“搬得太辛苦”io_uring 要解决的是“叫醒太频繁”。1.3 实操视角查看与调整管道容量管道缓冲区的大小不是无限的。Linux 默认的 pipe capacity 通常是 64KB65536 字节不同内核版本有差异但基本都在这个量级。这个值是可以调整的调整接口是fcntl的F_SETPIPE_SZ命令。我在做跨进程大数据量传输时踩过一个坑两个进程通过管道传一批日志默认 64KB 缓冲很快就满写端反复被唤醒睡眠上下文切换暴涨吞吐提不上去。后来我把管道容量调大到 1MB性能立刻好了不少。调整方法很简单#include fcntl.h #include unistd.h #include stdio.h int main() { int fds[2]; pipe(fds); // 查询当前容量 int sz fcntl(fds[1], F_GETPIPE_SZ); printf(default pipe size: %d bytes\n, sz); // 尝试调整到 1MB int ret fcntl(fds[1], F_SETPIPE_SZ, 1024 * 1024); if (ret 0) { perror(F_SETPIPE_SZ); return 1; } printf(new pipe size: %d bytes\n, ret); close(fds[0]); close(fds[1]); return 0; }要注意两个限制一是单个管道最大可调整到/proc/sys/fs/pipe-max-size指定的值多数发行版默认 1MB二是系统对用户占用的管道页面总数有软限制在/proc/sys/fs/pipe-user-pages-soft里普通用户调太大可能不生效。另外管道的写端还有一个经典坑当读端已经关闭时写进程会收到 SIGPIPE 信号默认行为是直接终止进程。很多服务端程序莫名退出查到最后发现是往一个已经关闭的管道写数据。如果不想进程被杀记得忽略或捕获 SIGPIPE。2. 服务端并发分水岭从阻塞到多路复用2.1 阻塞模型撑不起并发非阻塞 I/O 上场管道把阻塞这个属性暴露得非常明显但真正让服务端工程师头疼的是网络 I/O。早期写网络服务一个连接配一个进程或线程accept()之后进程就在read()上阻塞等客户端数据。连接数一多线程数跟着涨内存、上下文切换全部失控。这就是后来被称为 C10K 问题的核心矛盾。解决思路的第一步是把文件描述符设置成非阻塞模式int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);设置之后read()在没有数据时不再睡眠而是直接返回EAGAIN。但问题接踵而来如果同时有几千个连接你该怎么知道哪个连接有数据了最初级的办法是循环调用read()轮询但每个 fd 都去碰一次系统调用CPU 全耗在无效检查上了。这时候多路复用就登场了。2.2 select/poll 的局限与 epoll 的解法select()和poll()是第一批多路复用方案思路都是把“一堆 fd”交给内核让内核告诉我们哪些 fd 就绪了。但它们的问题很明显select()有三个痛点。第一fd 集合有上限默认FD_SETSIZE是 1024第二每次调用都要把整个 fd 集合从用户态拷贝到内核态fd 多了开销巨大第三内核不知道你关心哪些 fd只能全量扫描返回时用户还得再遍历一遍。poll()解决了上限问题但核心低效点仍然在每次调用全量拷贝 fd 数组内核全量扫描所有 fd 是否有事件。复杂度是 O(n)n 是 fd 数量。epoll能成为现代 Linux 服务端的事实标准核心思路是三个在内核里维护一棵红黑树记录你关心的 fd 和对应事件避免每次调用都全量拷贝。注册回调设备驱动在数据到达时主动把就绪 fd 挂到内核就绪链表上而不是等用户来扫。只返回就绪事件epoll_wait()返回的数组里全部是 ready 的 fd复不复杂度直接变成 O(k)k 是实际就绪的数量。这里我用一个表格把三兄弟放一起对比看一眼就明白特性selectpollepollfds 上限1024默认无固定上限无固定上限fd 集合传递每次全量拷贝每次全量拷贝内核长期维护内核扫描方式全量扫描全量扫描回调触发复杂度O(n)O(n)O(k)跨平台Linux/Windows基本 UnixLinux 专属2.3 实战中 epoll 的规则LT/ET 与事件处理的坑epoll 有两个触发模式这个必须分清。**水平触发LT**是默认模式只要 fd 还有数据没读完epoll_wait就每次都会报告它。**边沿触发ET**是更高效的模式只在状态变化从无数据变为有数据时通知一次之后内核默认你读完了不再报告。ET 模式看着省事实际上是个坑王。服务端如果用了 ETread()必须循环读直到返回EAGAIN为止否则数据可能只读一半剩下部分再也没有通知事件了。我自己用 epoll 还踩过两个高频问题第一惊群问题。多线程/多进程同时epoll_wait()在同一个 epoll fd 上时一个连接进来多个线程可能同时被唤醒但最终只有一个线程能成功accept()其他全白忙。Linux 4.5 之后提供了EPOLLEXCLUSIVE标志可以避免多个等待者同时唤醒建议解决多 worker 场景时优先考虑。第二事件驱动下别忘了处理异常并清理 fd。EPOLLHUP和EPOLLERR经常被新手忽略连接对端异常断开时如果只关心EPOLLIN可能导致短连接反复触发、fd 泄漏。我一般会在处理逻辑里把错误事件也视为“这次要处理掉这个连接”的信号。3. 数据拷贝的代价read/write 与 mmap 对比3.1 一次 read/write 的背后两次系统调用几次拷贝多路复用解决了“连接多了怎么管”的问题但随着文件传输、日志归集这类 I/O 密集场景越来越多另一个瓶颈浮现了数据在内核和用户态之间反复搬运。拿一个最简单的“读文件然后通过 socket 发给客户端”来说传统路径是这样read(fd, buf, len)数据从磁盘通过 DMA 拷贝到内核的 page cache再从 page cache 通过 CPU 拷贝到用户态缓冲区。write(sockfd, buf, len)数据从用户态缓冲区通过 CPU 拷贝到内核的 socket 发送缓冲区再通过 DMA 拷贝到网卡发出。四次数据接触其中两次是 kernel 内部搬运两次是 CPU 主动拷贝。如果文件很大比如一个大视频每一层拷贝都是实打实的 CPU 开销。这也是我开头那个压测例子 CPU 全是 sys 状态的原因。你可以用strace -c去统计系统调用但更直观的是perf top。我那次排查文件下载接口perf 里排在最前面的要么是copy_user_enhanced_fast_string要么是和 page cache 相关的函数。这时候就该考虑减少拷贝了。3.2 mmap 能省一次用户态拷贝但别乱用mmap()可以把文件直接映射到进程地址空间数据在 page cache 里用户态程序可以直接读写这段内存不需要read()把数据从内核再复制一份。省掉了“page cache - 用户缓冲区”这次拷贝。但它不是银弹。我见过很多人一听说“mmap 快”就在业务代码里到处映射文件结果踩了几个坑文件被截断会导致 SIGBUS。如果别人把文件删掉或 truncate而你还在访问映射区域进程直接崩溃。真要删除文件得先用ftruncate告知大小变化或者捕获 SIGBUS 做兜底否则相当难看。映射页的脏页回写是异步的。对延迟敏感的业务来说你以为数据已经写进去了实际上内核可能在几百毫秒后才把脏页落到磁盘掉电就丢数据。需要严格持久化还是用write()配合fsync()更可控。频繁建立和解除映射的 CPU 开销不低。如果是零散小文件反复处理mmap 未必比 read 快。我给出的建议很简单mmap 适合“大文件、多次读写、读多写少”的场景比如索引文件、配置文件、共享内存队列。小文件、短生命周期、高并发打开关闭的场景老老实实用 read/write。3.3 怎么量化拷贝开销从 top 到 perf 看一眼很多朋友问我怎么判断“是不是拷贝开销过高”我自己有一套快速的体检流程先看top。如果大量 CPU 时间集中在sys而不是us说明应用频繁陷入内核。下一步用perf top -p pid看内核态热区如果看到copy_user_*、memcpy、skb_copy_datagram_iter这类函数基本可以确定是数据拷贝占大头。再进一步用strace -c -p pid抓一下系统调用分布如果read、write数量巨大且每次传输的字节数很小那问题不只是拷贝本身还有调用次数。这个时候再决定是上 mmap、sendfile还是走 io_uring 批量处理。4. 零拷贝把数据搬运交给内核与硬件4.1 sendfile从文件到 socket 的零拷贝零拷贝这个词在服务端领域被反复提到但很多人口里的“零拷贝”其实特指sendfile()系统调用。它可以实现把一个文件的内容直接发送给 socket整个过程中都不经过用户态缓冲区。传统实现里就算内核做中转至少还有两次 CPU 拷贝。sendfile()的思路是让数据始终待在 page cache 里通过 DMA 引擎和协议栈的 scatter-gather 能力直接把 page cache 里的页面组织成网络包发送。用 Nginx 的人应该很熟配置里一个sendfile on就是触发这个路径。注意sendfile()的输入 fd 必须是支持 mmap 的文件不能是 socket 或管道输出 fd 必须是 socket。而且不是所有文件系统都完美支持某些网络文件系统、加密文件系统上内核会退化成普通拷贝。生产环境要实测不要光看文档。4.2 splice 与 copy_file_range管道之外的搬运工sendfile()只能走“文件 - socket”这条路。那如果我想在两个 socket 之间转数据或者在文件和另一个文件之间做复制呢这就要看另外两个原语。splice()的特别之处在于它设计了“管道作为中转站”但中转过程只在内核空间完成不需要进入用户态。我最早用 splice 是做一个 TCP 端口转发代理两个 socket 之间搬数据CPU 占用比 read/write 循环低了 30% 以上。用法上注意一点splice()的两个 fd 里至少有一个是管道不然返回 EINVAL。copy_file_range()是给“文件到文件”的复制用的。以前复制大文件是反复 read write现在一个系统调用搞定内核内部完成数据迁移。适合做服务端的日志轮转、备份、数据归档。需要内核 4.5跨文件系统场景支持不完整可能返回 EXDEV这时候还得退回到普通拷贝。4.3 零拷贝不是银弹适用场景与边界零拷贝听起来美好但我必须泼一盆冷水它不是万能的而且经常有边界条件。第一应用层需要对数据做加工时零拷贝基本没用。比如你要在文件内容前面加一个 HTTP 头或者在传输前做压缩、加密、格式转换数据必须进入用户态处理sendfile 这条路直接断了。第二小文件场景收益不大。零拷贝节省的是 CPU 拷贝时间但如果文件本来就只有几 KB系统调用的次数、DMA 引擎的调度开销可能比省下的拷贝更贵。我一般以 64KB 为分界线大于这个值再考虑 sendfile。第三注意内核版本和文件系统能力。不是任何内核版本都支持sendfile()与 DMA 结合某些虚拟化环境下的半虚拟化网卡也不支持 scatter-gather此时 sendfile 的内核路径还是会做内存拷贝只是少了用户态到内核态的切换而已。5. 走向异步io_uring 与下一个阶段5.1 压死骆驼的最后一根稻草系统调用本身到了这一步我们已经解决了“阻塞空等”和“数据拷贝”两大问题但细心的读者可能已经发现不管用哪种方式每一步 I/O 操作仍然本质上是同步系统调用。sendfile()发出后内核要等数据准备好、发出去调用线程在大部分时间里是阻塞等待的状态。对高并发服务端来说这种“一次调用等一个结果”的模型限制了 I/O 吞吐的进一步拉高。系统调用本身也有开销涉及用户态到内核态的切换、寄存器的保存恢复、TLB 刷新一次两次不痛一秒钟几十万次就成了大头。这正是 io_uring 登场的背景。它解决的不再是“拷贝几次”的问题而是“能不能把一堆 I/O 请求交给内核后线程先干别的等成果批量拿回来”的问题。5.2 io_uring 怎么工作SQ/CQ 与提交队列io_uring 从 Linux 5.1 开始合入主内核主线由 Jens Axboe就是当年 block layer 的维护者主导设计。它核心思路是在用户态和内核态之间共享一块内存区域用两个环形队列来沟通。SQSubmission Queue用户把要做的 I/O 请求塞进提交队列比如“读 fd 3 的 1024 字节到某块内存”。CQCompletion Queue内核完成请求后把结果放到完成队列。两个队列都用无锁或近乎无锁的方式操作用户通过一个io_uring_enter()系统调用批量提交和收割极大降低了系统调用次数。更进一步的还有IORING_SETUP_SQPOLL模式内核起一个专用线程在后台轮询提交队列连io_uring_enter()都可以省掉应用只负责写 SQ、读 CQ真正做到了“应用不阻塞系统调用接近零”。5.3 服务端能用 io_uring 做什么我在实际项目里用 io_uring 主要做两个方向一是磁盘 I/O 密集的 storage 服务。传统实现里为了不阻塞主线程要么起一堆线程用pread/pwrite阻塞读要么折腾O_DIRECT 用户态 AIO。有了 io_uring可以一个线程同时管理几百个磁盘 I/O 请求CPU 不再被空等浪费。二是网络转发/代理场景。可以用 io_uring 管理 accept、read、send把连接生命周期里的所有 I/O 都做成异步事件整个服务可以用少量线程支撑海量连接。但我也要劝一句不要为了技术炫技把所有业务都切成 io_uring。它的编程模型和传统同步 I/O 差别很大调试复杂度高依赖内核版本和 liburing 库团队维护成本不低。普通的 HTTP 服务靠 epoll sendfile 已经能打得很漂亮如果确定 I/O 密度极高再上 io_uring 才划算。常见的入门方式是用liburing这个库API 更友好。简单提一个最小链路io_uring_queue_init()初始化 -io_uring_get_sqe()拿请求 -io_uring_prep_read/write填充 -io_uring_submit()提交 -io_uring_wait_cqe()等完成。相比直接手写 io_uring 的系统调用liburing 会帮你处理内存屏障和队列索引的难题。讲到这里“从管道到零拷贝”这条线算是串完了。我个人在实际项目里最大的体感是技术演进背后永远是“减少等待、减少拷贝、减少系统调用”这三件事。服务端性能调优别一上来就追零拷贝、io_uring 这些看着高级的东西先把阻塞改成多路复用把日志打印、序列化这类隐性 I/O 消耗理清楚收益往往比堆新特性大得多。真到了每天要处理几 GB 静态资源下载的场景再让 sendfile 登场也不迟。你手头那台机器 CPU 的 sys 时间还压不下去的话先拿 strace 看看自己在干什么。