上个月给公司的文件服务做压测,我发现一个很有意思的现象:四核机器负载不高,但 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 通常是 64KB(65536 字节),不同内核版本有差异,但基本都在这个量级。这个值是可以调整的,调整接口是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 是实际就绪的数量。
这里我用一个表格把三兄弟放一起对比,看一眼就明白:
| 特性 | select | poll | epoll |
|---|---|---|---|
| fds 上限 | 1024(默认) | 无固定上限 | 无固定上限 |
| fd 集合传递 | 每次全量拷贝 | 每次全量拷贝 | 内核长期维护 |
| 内核扫描方式 | 全量扫描 | 全量扫描 | 回调触发 |
| 复杂度 | O(n) | O(n) | O(k) |
| 跨平台 | Linux/Windows | 基本 Unix | Linux 专属 |
2.3 实战中 epoll 的规则:LT/ET 与事件处理的坑
epoll 有两个触发模式,这个必须分清。**水平触发(LT)**是默认模式:只要 fd 还有数据没读完,epoll_wait就每次都会报告它。**边沿触发(ET)**是更高效的模式:只在状态变化(从无数据变为有数据)时通知一次,之后内核默认你读完了,不再报告。
ET 模式看着省事,实际上是个坑王。服务端如果用了 ET,read()必须循环读,直到返回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 的维护者)主导设计。它核心思路是:在用户态和内核态之间共享一块内存区域,用两个环形队列来沟通。
- SQ(Submission Queue):用户把要做的 I/O 请求塞进提交队列,比如“读 fd 3 的 1024 字节到某块内存”。
- CQ(Completion 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 看看自己在干什么。