第 10 章 零拷贝与 I/O 多路复用:少做无用功
上一章讲缓存——让”读”变快。 这一章讲另一件事——让”搬数据”和”等 I/O”变少。
你可能没意识到:你代码里一个看似简单的”从文件读数据,发给客户端”的操作,数据在内存里被搬了四次,其中两次纯属多余。而一个”同时处理一万个连接”的服务器,如果写错了,CPU 大部分时间在”瞎等”和”瞎搬”。
这一章,我们讲两件事:零拷贝(少搬数据)和 I/O 多路复用(少等 I/O)。它们背后是同一个哲学:少做无用功。
引子:你的代码,可能比你以为的”笨”得多
先看一段再普通不过的代码——一个静态文件服务器:
func serveFile(w http.ResponseWriter, r *http.Request) {
data, _ := os.ReadFile("bigfile.bin") // 读文件到内存
w.Write(data) // 写到网络
}就这么两行。你觉得它做了多少”搬运工作”?
答案是:数据被拷贝了四次,其中两次是纯浪费。
1. DMA 拷贝:磁盘 → 内核 page cache(必须的,数据总得从磁盘出来)
2. CPU 拷贝:page cache → 用户态 data(浪费!数据进了用户态又出去)
3. CPU 拷贝:用户态 data → socket 缓冲区(浪费!同上)
4. DMA 拷贝:socket 缓冲区 → 网卡(必须的,数据总得进网卡)
四次拷贝里,第 2、3 次是纯浪费——数据从内核搬到用户态,马上又搬回内核。如果能把它们省掉,性能能提升一大截。
这就是”零拷贝”要解决的问题。
而”少等 I/O”,是另一个故事:一个服务器要同时处理一万个连接,怎么”等”?一个个等(阻塞)?轮询?还是让内核通知你?
给你一句总纲:
性能优化的一半工作,是”别做无用功”。
无用功有两种:
- 无用的拷贝(数据搬来搬去,零拷贝解决)
- 无用的等待(CPU 空等 I/O,多路复用解决)
而它们的共同解药是:把”搬运”交给 DMA,把”等待”交给内核,让 CPU 只做”必须由它做”的事。
本章路线图
第一幕:数据搬运的真相
Q1 一次 read + write,数据被拷贝了几次?为什么有浪费?
Q2 mmap 能省掉哪次拷贝?它的代价是什么?
Q3 sendfile / splice 是怎么做到"真零拷贝"的?
第二幕:I/O 等待的真相
Q4 为什么"一个连接一个线程"在高并发下会崩?
Q5 I/O 多路复用解决的核心问题是什么?(回顾 epoll)
第三幕:工程实战
Q6 Kafka 为什么能做到那么高的吞吐?它用了哪些"少做无用功"?
Q7 Go 的 netpoller 是怎么把 epoll 藏起来的?
Q8 在你的业务里,怎么判断"该不该上零拷贝/多路复用"?
第一幕:数据搬运的真相
Q1 一次 read + write,数据被拷贝了几次?为什么有浪费?
完整拆解一次”读文件 → 发网络”的流程,这是理解零拷贝的基础。
传统方式(read + write):
char buf[4096];
int fd = open("file.bin", O_RDONLY);
read(fd, buf, 4096); // 读文件到用户态 buf
write(sockfd, buf, 4096); // 写 buf 到 socket数据经历的完整旅程:
第 1 次拷贝(DMA):磁盘 → 内核 page cache
数据从磁盘读入内核的 page cache(内核缓冲区)
由 DMA 完成,不占用 CPU
第 2 次拷贝(CPU):page cache → 用户态 buf
内核把数据从 page cache 拷贝到用户态 buf
由 CPU 完成(这是系统调用 read 的一部分)
★ 这次是浪费的!
第 3 次拷贝(CPU):用户态 buf → socket 缓冲区
用户态 write 时,内核把 buf 拷贝到 socket 的内核缓冲区
由 CPU 完成
★ 这次也是浪费的!
第 4 次拷贝(DMA):socket 缓冲区 → 网卡
数据从 socket 缓冲区发到网卡
由 DMA 完成,不占用 CPU
关键观察:
4 次拷贝里:
- 2 次 DMA 拷贝(1、4):必须的,因为数据要从"设备"进出
- 2 次 CPU 拷贝(2、3):纯浪费!
为什么第 2、3 次是浪费?
因为数据从内核搬到用户态,马上又搬回内核。
用户态代码(buf)根本没对数据做任何"有意义的处理",
只是"过了一下手"。
为什么传统的 read/write 要这么设计?
因为 read/write 的接口语义就是”数据要在用户态和内核态之间传递”:
read(fd, buf):
语义是"把数据读到我的用户态 buf"
→ 必须拷贝到用户态(否则 buf 怎么用?)
write(fd, buf):
语义是"把我的用户态 buf 写出去"
→ 必须从用户态拷贝走
所以传统 read/write 的”两次 CPU 拷贝”,是接口语义强制的。 如果你只是”转发”数据(不做修改),这两次拷贝就是浪费。
CPU 拷贝的代价有多大?
一次 4KB 的 CPU 拷贝:约 100-200ns(memcpy)
但高吞吐场景下,拷贝是瓶颈:
例:一个文件服务器,吞吐 10GB/s
4 次拷贝 = 40GB/s 的内存搬运
其中 20GB/s 是浪费的 CPU 拷贝
在内存带宽 30-50GB/s 的机器上,CPU 拷贝直接吃满内存带宽
→ 内存带宽成为瓶颈,而不是磁盘或网卡
关键认识:性能瓶颈往往不是”CPU 不够快”,而是”内存带宽被无用的拷贝占满”。
这就是”零拷贝”的价值:省掉无用的 CPU 拷贝,把内存带宽留给真正有用的搬运(DMA)。
Q2 mmap 能省掉哪次拷贝?它的代价是什么?
第 1 章讲过 mmap,这里从”零拷贝”的角度再讲它的定位。
mmap 的原理:把文件映射到用户地址空间,让用户态直接访问 page cache。
// mmap 方式
char *p = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);
// p 直接指向 page cache 里的数据(不需要拷贝到用户态)
write(sockfd, p, size); // 直接写 p(但还是要拷贝到 socket 缓冲区)mmap 省掉了哪次拷贝?
传统:磁盘 →DMA→ page cache →CPU→ 用户 buf →CPU→ socket →DMA→ 网卡
mmap:磁盘 →DMA→ page cache(=用户 p) →CPU→ socket →DMA→ 网卡
mmap 省掉了"第 2 次拷贝"(page cache → 用户 buf)
因为用户态直接访问 page cache,不需要先拷贝一份到用户态。
但 mmap 不是”真零拷贝”:
mmap 还是需要"第 3 次拷贝"(用户态 p → socket 缓冲区)
因为 write(sockfd, p) 还是要把数据从 p 拷贝到 socket 的内核缓冲区。
所以 mmap 是"少一次拷贝"(3 次),不是"零拷贝"。
mmap 的代价(第 1 章讲过,这里聚焦拷贝相关):
1. 缺页异常:mmap 后第一次访问某页,触发缺页(读磁盘)
→ 大文件 mmap 的缺页开销可能不小
2. 写回不确定性:如果是可写映射,脏页写回时机不确定
3. TLB 压力:大文件 mmap,页表项多,TLB miss 高
→ 配合大页可缓解
4. 内存占用:mmap 的文件占用 page cache,
可能挤掉其他热数据
mmap 适合什么?
1. 随机访问大文件(数据库存储引擎)
2. 进程间共享只读数据(零拷贝共享)
3. 需要"像内存一样访问文件"的场景
不适合:
1. 纯顺序转发(sendfile 更好)
2. 小文件(mmap 的优势用不上)
Q3 sendfile / splice 是怎么做到”真零拷贝”的?
sendfile:为”文件 → 网络”场景专门设计的系统调用。
// sendfile:直接把文件内容发到 socket,全程不经过用户态
sendfile(sockfd, fd, &offset, size);
// 签名:sendfile(目标 socket, 源文件, 偏移, 长度)
// 数据从 文件 直接到 socket,用户态代码完全不接触数据sendfile 的数据旅程:
传统:磁盘 →DMA→ page cache →CPU→ 用户 buf →CPU→ socket →DMA→ 网卡
sendfile:磁盘 →DMA→ page cache →DMA→ 网卡
(或者 page cache →CPU→ socket →DMA→ 网卡,视硬件而定)
sendfile 省掉了 2 次 CPU 拷贝!
数据只在"设备 ↔ 内核"之间用 DMA 搬运,
用户态完全不参与,CPU 只发指令。
sendfile 的两种实现(细节,了解即可):
1. 有 scatter-gather DMA 支持(现代网卡):
磁盘 →DMA→ page cache(存文件数据 + 元数据头)
→DMA→ 网卡
真·零 CPU 拷贝
2. 没有 scatter-gather 支持:
磁盘 →DMA→ page cache →CPU→ socket →DMA→ 网卡
(还是要一次 CPU 拷贝,但省了"用户态那一趟")
splice:更通用的”管道间零拷贝”。
// splice:在两个 fd 之间"接管道",数据不经过用户态
splice(fd_in, NULL, pipefd, NULL, size, 0); // 文件 → 管道
splice(pipefd, NULL, fd_out, NULL, size, 0); // 管道 → socketsplice 比 sendfile 更通用(不限于”文件 → socket”),但用起来更复杂(要建管道)。
零拷贝的适用条件:
sendfile / splice 的适用条件很苛刻:
1. 数据从"文件"来(不是从用户态内存来)
2. 用户态代码"不修改"数据(纯转发)
3. 数据是"完整的"(不能只发中间一段后还要处理)
只要满足"纯转发、不修改、从文件来",就能零拷贝。
一旦你需要"看一眼数据"或"修改数据",就必须经过用户态,零拷贝失效。
零拷贝的本质:把”用户态”这个中转站拿掉。
传统方式里,用户态 buf 是个”中转站”——数据从内核出来,到用户态转一圈,又回内核。 零拷贝直接把这个中转站拿掉,让数据在”内核的设备之间”直接流转(DMA)。
工程启示:你的系统里,有没有”用户态中转站”?
- 网关转发请求:请求从 A 进来,你解析一下,又原样发给 B——这中间如果做了”读 body 到内存,再写出去”,就是”用户态中转”
- 文件上传下载:如果只是”存一下再取出来”,中间的处理逻辑(比如打日志)别让数据”经手”
凡是”纯转发、不修改”的数据流,都该考虑”能不能不经过我的代码”。
第二幕:I/O 等待的真相
Q4 为什么”一个连接一个线程”在高并发下会崩?
第 2、4 章讲过线程和 I/O 范式,这里从”少等 I/O”的角度聚焦。
传统模型:一个连接一个线程(thread-per-connection)。
// Java 传统 BIO 模型
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept(); // 阻塞等待连接
new Thread(() -> { // 每个连接一个线程
handle(client); // 阻塞读写
}).start();
}问题:每个连接占一个线程,而线程有成本(第 2 章讲过)。
假设 1 万个并发连接:
1 万个线程
× 每个线程 1MB 栈(Java 默认)
= 10GB 内存(光栈就 10GB)
而且:
1 万个线程,大部分时间在"阻塞等 I/O"(等客户端发数据)
→ 线程是"闲着"的,但占着内存和调度开销
更本质的问题:线程在”阻塞等 I/O”时,是纯浪费。
一个线程处理一个连接:
1. 读请求(阻塞,等客户端发数据)→ 这段时间线程闲着
2. 处理请求(CPU 忙)
3. 写响应(阻塞,等客户端收)→ 线程又闲着
大部分时间,线程在"等",而不是"干活"。
为什么这样会崩?
1 万个连接 = 1 万个线程,但:
- 同时真正"在处理"的,可能只有 100 个(其余都在等 I/O)
- 那 9900 个"闲着"的线程,白白占着内存和调度开销
线程数越多,上下文切换越频繁(第 2 章),
而"闲着"的线程切换,是纯浪费。
所以问题不是”线程”,是”阻塞”。
关键认识:阻塞 I/O 的问题是,“等待”和”处理”耦合在同一个线程里。线程在等 I/O 时,不能干别的。
解决方案就是把”等待”和”处理”解耦:
- 让一个线程管理多个连接(多路复用)
- 哪个连接就绪了,就处理哪个(事件驱动)
这就是 I/O 多路复用的核心思想。
Q5 I/O 多路复用解决的核心问题是什么?(回顾 epoll)
第 4 章详细讲过 select/poll/epoll,这里提炼”核心问题”。
核心问题:一个线程,怎么同时”等”多个 I/O?
传统:一个线程阻塞在一个 I/O 上(read 阻塞)
多路复用:一个线程同时监控多个 fd,哪个就绪处理哪个
多路复用的价值:
没有多路复用:
1 万个连接 = 1 万个线程(每个阻塞等一个连接)
有多路复用(epoll):
1 万个连接 = 1 个线程(epoll 监控 1 万个 fd,谁就绪处理谁)
→ 内存从 10GB 降到几十 MB
→ 上下文切换从"1 万线程"降到"几个线程"
epoll 的关键(回顾):
1. 注册一次,内核记住(不用每次拷贝 fd 集合)
2. 就绪通知(fd 就绪时,内核记到就绪链表)
3. epoll_wait 只返回"就绪的" fd(O(就绪数),不是 O(总数))
本质:从"轮询所有 fd"到"事件驱动(谁就绪通知谁)"
多路复用 + 非阻塞 I/O = 高性能网络的标准范式:
1. 所有连接设为非阻塞
2. epoll 监控所有连接
3. epoll_wait 返回就绪的连接
4. 对就绪的连接,循环 read 到 EAGAIN(读完为止)
5. 处理数据
为什么必须非阻塞?
如果用阻塞 fd:
epoll 说"这个 fd 可读了",
但你 read 时,如果只读了一部分就阻塞,
会卡住整个线程(其他就绪的连接得不到处理)。
用非阻塞 fd:
read 到 EAGAIN(没数据了)就停,不会阻塞
→ 一个线程能顺畅地处理所有就绪连接
多路复用的本质:把”一对一的等待”变成”一对多的等待”。
传统:一个线程等一个 I/O(阻塞) 多路复用:一个线程等 N 个 I/O(谁就绪处理谁)
这背后是”事件驱动”思想(第 8 章讲过):注册事件 → 事件发生时通知 → 处理事件。 而不是”轮询/阻塞等待”。
而这个”一对多”的价值,取决于”N 个连接里,同时活跃的有多少”:
- 长连接 + 大部分空闲(IM、物联网)→ 多路复用价值巨大(1 线程管 10 万连接)
- 连接都活跃(大流量转发)→ 多路复用价值变小(因为都就绪,还是要逐个处理)
第三幕:工程实战
Q6 Kafka 为什么能做到那么高的吞吐?它用了哪些”少做无用功”?
Kafka 是高吞吐消息队列的标杆,它的设计完美体现了”少做无用功”的思想。
Kafka 的”少做无用功”清单:
1. 顺序写(不是随机写)。
磁盘的物理特性:
顺序写:~100MB/s(机械盘)
随机写:~1MB/s(机械盘,要寻道)
Kafka 的设计:
消息"追加写"到日志文件末尾(顺序写)
不做"更新"(消息不可变)
→ 充分利用顺序写的速度
2. 零拷贝(sendfile)。
传统:磁盘 → page cache → 用户态 → socket → 网卡(4 次拷贝)
Kafka:磁盘 → page cache → 网卡(sendfile,2 次 DMA)
Kafka 消费消息(尤其"拉取"模式)大量用 sendfile,
避免数据经过 Kafka 进程的用户态。
3. 批量(batching)。
生产者:批量发送消息(攒一批,一次发)
消费者:批量拉取(一次拉一批)
磁盘:批量写(攒一批,一次写)
批量的价值:摊薄"每条的固定开销"(网络、系统调用、磁盘 I/O)
(第 12 章会详细讲"摊销")
4. 顺序读 + 预读。
消费者顺序读一个 partition 的消息
→ 磁盘预读(readahead)自动生效
→ 读的其实是 page cache 里的数据(内存)
5. 依赖 page cache(不自己搞复杂缓存)。
Kafka 大量依赖 OS 的 page cache:
- 消息在 page cache 里,消费者读的就是内存
- 内存不够时,OS 自动回收(Kafka 不用自己管 LRU)
对比:很多消息队列自己实现缓存(复杂且容易做错),
Kafka 直接"相信 OS 的 page cache"。
6. 零拷贝的”分区 + 复制”设计。
Kafka 的分区(partition)设计:
- 一个 topic 分多个 partition
- 每个 partition 是"追加写的日志"
- 消费者按 partition 消费
这个设计的本质:
把"随机读写"变成"顺序读写"
把"共享一个大文件"变成"各自追加小文件"
Kafka 的启示:高吞吐不是”更快地做事”,是”少做无谓的事”。
- 顺序写代替随机写(少寻道)
- 零拷贝代替用户态中转(少拷贝)
- 批量代替单条(少开销)
- 依赖 page cache 代替自建缓存(少重复造轮子)
这些都不是”新奇的技巧”,而是”老老实实利用硬件的物理特性 + 少做无用功”。
这也是为什么 Kafka 的代码其实”没什么花哨”,但吞吐就是高——它把每个细节都做对了。
Q7 Go 的 netpoller 是怎么把 epoll 藏起来的?
第 4 章讲过 netpoller 的原理,这里从”少等 I/O”的角度看它的价值。
Go 的 netpoller:一个把”阻塞 I/O”变成”异步 I/O”的运行时机制。
Go 代码(阻塞式写法):
conn.Read(buf) // 看起来阻塞
底层(异步):
1. conn 的 fd 是 O_NONBLOCK 的
2. Read 没数据时,不真的阻塞线程
3. 把 goroutine 挂到 netpoller(epoll)
4. 让出 P,其他 goroutine 继续跑
5. epoll 发现数据就绪,唤醒 goroutine
netpoller 的价值:
1. 一个 OS 线程(M)能"承载"海量 goroutine 的 I/O 等待
- 因为 goroutine 等 I/O 时,不占线程
- 线程去跑别的 goroutine 了
2. 程序员用"阻塞写法"(好写),获得"异步性能"(快)
- 心智负担低,性能高
3. 自动处理 epoll 的细节(LT/ET、惊群、fd 管理)
- 程序员不用碰 epoll
netpoller 的局限(重要,容易被忽视):
netpoller 只对"网络 I/O"生效(socket),
对"文件 I/O"(普通磁盘文件)不生效!
为什么?
- epoll 对"普通文件"(磁盘)总是返回"就绪"
(磁盘文件总是"可读"的,因为读磁盘不会像读 socket 那样"等数据到来")
- 所以 netpoller 对文件 I/O 无能为力
后果:
os.File.Read(读普通文件)会真的阻塞 M(OS 线程)
→ 大量并发读文件,会导致 M 数量膨胀(第 2 章讲过)
这解释了 Go 的一个”隐性规则”:Go 的并发优势在网络 I/O,不在文件 I/O。
- 网络服务(HTTP、RPC、WebSocket)→ Go 的主场,netpoller 全异步
- 大量并发读文件 → Go 的短板,读文件会阻塞线程
所以:
- 如果你的服务是”网络密集”→ Go 的 goroutine 模型完美
- 如果是”文件密集”(大量并发读磁盘)→ 要小心线程膨胀,或用 io_uring(Go 1.25+)
Q8 在你的业务里,怎么判断”该不该上零拷贝/多路复用”?
这一章的落地总结。
判断一:你的数据流是”纯转发”吗?
如果:数据从 A 来,你不改,原样发到 B
→ 考虑零拷贝(sendfile/splice)
如果:数据要解析、修改、处理
→ 零拷贝用不了(必须经过用户态)
例:
- 文件下载服务(静态文件)→ 适合零拷贝
- 网关转发(不改 body)→ 适合 splice
- 业务处理(解析 JSON、查库、组响应)→ 不适合
判断二:你的 I/O 模式是”高并发 + 低活跃”吗?
如果:大量连接,但同一时刻只有少数活跃(长连接、IM、物联网)
→ 多路复用(epoll/Go netpoller)价值巨大
如果:连接少,或连接都很活跃
→ 多路复用价值不大,甚至阻塞模型更简单
判断三:你实际测量过瓶颈吗?
不要"预防性优化":
先压测,看瓶颈在哪:
- CPU 高?→ 可能是拷贝(看是不是 memcpy 占大头)
- 内存带宽满?→ 可能是拷贝(零拷贝)
- 线程/上下文切换多?→ 可能是模型问题(多路复用)
用 perf 看:
perf top 看哪个函数耗时
perf record + 火焰图 看调用栈
Go 里的实践:
// 1. 静态文件服务:Go 标准库的 http.FileServer 已经用了 sendfile
// (在满足条件时自动用,你不用手动写)
// 2. 纯转发:可以用 io.Copy,它内部对某些情况做了优化
// (比如 TCP -> TCP 的 splice,Go 1.x 部分支持)
// 3. 文件下载:如果只是"读文件发给客户端",用 http.ServeFile
// (它会尽量用 sendfile)
// 4. 手动零拷贝(少见,除非你真的需要):
// 用 golang.org/x/sys/unix 的 Sendfile一个决策清单:
1. 数据是"纯转发"吗?
├─ 是 → 考虑零拷贝(但先看 Go 标准库是否已自动优化)
└─ 否 → 零拷贝不适用
2. 是"高并发 + 低活跃"的 I/O 吗?
├─ 是 → 用 Go 的 netpoller(已经自动多路复用了)
└─ 否 → 阻塞模型够用
3. 测量过瓶颈吗?
├─ 没测 → 先测,别盲目优化
└─ 测了 → 针对瓶颈优化
4. 用 Go 的话:
- 网络 I/O → netpoller 已优化,别自己写 epoll
- 文件转发 → 用 http.ServeFile / io.Copy(内部有 sendfile)
- 大量并发文件读 → 注意线程膨胀,考虑 io_uring(Go 1.25+)
这一章的最终落脚点:
“少做无用功”是性能优化的底层逻辑。
- 无用的拷贝 → 零拷贝(sendfile/splice)
- 无用的等待 → 多路复用(epoll/netpoller)
- 无用的中转 → 让数据”不经过你的代码”
而判断”该不该上”,永远先问:测量了吗?瓶颈真的是这里吗?
别为了”零拷贝”而零拷贝——如果你的瓶颈在数据库查询,零拷贝帮不了你。性能优化的第一步,永远是”找到真正的瓶颈”。
思考题
思考题 1
面试官问:“什么是零拷贝?mmap 和 sendfile 有什么区别?为什么说 mmap 不是’真正的零拷贝’?“请完整回答。
参考答案
核心答案:零拷贝是”减少数据在用户态和内核态之间的拷贝次数”。mmap 少一次拷贝(3 次),sendfile 少两次(2 次,可做到纯 DMA)。
先讲清楚”传统方式”有几次拷贝:
传统 read + write(读文件发网络):
1. DMA:磁盘 → page cache
2. CPU:page cache → 用户态 buf ← 浪费
3. CPU:用户态 buf → socket 缓冲区 ← 浪费
4. DMA:socket 缓冲区 → 网卡
共 4 次,其中 2 次 CPU 拷贝是浪费(数据进用户态转一圈又回去)
mmap(少一次拷贝):
mmap + write:
1. DMA:磁盘 → page cache
2. CPU:page cache → socket 缓冲区 ← 用户态直接访问 page cache,省了第 2 次
3. DMA:socket 缓冲区 → 网卡
共 3 次,省掉了"page cache → 用户态"这一次
为什么 mmap 不是”真零拷贝”?
因为 mmap 后 write 时,数据还是要从 page cache(用户态映射)拷贝到 socket 缓冲区
(第 2 次 CPU 拷贝还在)
mmap 只是"少一次拷贝",不是"零拷贝"。
sendfile(少两次拷贝):
sendfile:
1. DMA:磁盘 → page cache
2. DMA:page cache → 网卡(scatter-gather DMA)
共 2 次,都是 DMA,CPU 不参与拷贝
(或者在某些硬件上,是 page cache →CPU→ socket,但省了用户态那一趟)
这才是"真零拷贝":数据不经过用户态,CPU 不搬数据。
三者对比表:
| 拷贝次数 | CPU 拷贝 | 用户态参与 | 适用场景 | |
|---|---|---|---|---|
| read+write | 4 次 | 2 次 | 是 | 需要处理数据 |
| mmap | 3 次 | 1 次 | 是 | 随机访问、共享 |
| sendfile | 2 次 | 0 次 | 否 | 纯转发、文件→网络 |
关键区别:
mmap:
- 用户态能"直接访问"数据(适合要读、要处理数据的场景)
- 但转发时还是有一次 CPU 拷贝
sendfile:
- 用户态完全不碰数据(适合纯转发)
- 全程 DMA,CPU 只发指令
- 但用户态"看不到"数据(不能处理)
追问:splice 是什么?
splice 是更通用的零拷贝:它在两个 fd 之间”接管道”,数据不经过用户态。
splice(fd_in, NULL, pipefd, NULL, size, 0);
splice(pipefd, NULL, fd_out, NULL, size, 0);比 sendfile 更通用(不限于”文件 → socket”),但用起来复杂。
追问:Go 里怎么用零拷贝?
Go 标准库已经帮你在合适的地方自动用了 sendfile(比如 http.ServeFile、io.Copy 对 *os.File → net.Conn 的优化)。除非你有特殊需求,否则不用手动写。
这道题考察什么:
- 知不知道”4 次拷贝”这个基础
- 能不能说清 mmap 和 sendfile 各自省掉哪次拷贝
- 知不知道”mmap 不是真零拷贝”这个易错点
- 知不知道 sendfile 的适用条件(纯转发、用户态不碰数据)
思考题 2
你的服务是一个”API 网关”,接收客户端请求,转发给后端服务,再把响应转发给客户端。请求体可能是 JSON(要解析吗?),也可能只是转发(不解析)。你会怎么设计数据转发,才能”少做无用功”?
参考答案
这是一道”零拷贝思想”在网关场景的应用题,核心是区分”要处理”和”纯转发”。
先分析:网关对请求做了什么?
网关的职责可能是:
1. 纯转发:不改 body,只是"换个目的地"转发
2. 轻度处理:读 header(路由、鉴权),body 原样转发
3. 重度处理:解析 body、修改、加字段
不同的职责,决定"数据要不要经过用户态"。
场景一:纯转发(body 完全不碰)。
如果只是"转发",body 不该经过用户态:
传统(浪费):
读 body 到用户态 buf → 写 buf 到后端连接
(body 进用户态转一圈,纯浪费)
优化(少拷贝):
用 splice / sendfile 把两个连接"接起来"
body 数据不经过用户态
Go 里:
io.Copy(backendConn, clientConn) // Go 对 TCP→TCP 的 Copy
// 在满足条件时,会用 splice(Linux)实现零拷贝
场景二:轻度处理(读 header,body 转发)。
header 很小(几 KB),body 很大(可能几 MB 到几百 MB)
关键:把"小"的 header 和"大"的 body 分开处理:
- header:读出来解析(路由、鉴权)→ 用户态处理(小,开销可忽略)
- body:不读出来,直接转发(大,避免经过用户态)
实现:
1. 读 header(io.ReadFull 读固定长度)
2. 解析 header,决定路由
3. 建立后端连接,转发 header
4. body 用 io.Copy 直接转发(不整体读入内存)
场景三:重度处理(要解析 body)。
必须读 body 到用户态,无法零拷贝。
但可以"流式处理"(边读边处理边转发),而不是"整体读入":
坏:readAll(body) → 解析 → 转发(body 整个在内存里)
好:流式读 → 边解析边写(内存占用小,延迟低)
关键设计原则(这道题的核心):
区分”数据要经过我的代码吗”:
- 要处理 → 必须经过用户态(拷贝不可避免)
- 不处理 → 让数据”绕开”我的代码(零拷贝)
而”处理”的粒度要精细:
- 只处理 header → 只读 header,body 不读
- 只处理部分字段 → 流式处理,不整体加载
核心是”让数据尽量少地、尽量短地停留在你的代码里”。
一个更具体的设计:
// 网关转发(简化)
func handleProxy(clientConn net.Conn, clientReq *http.Request) {
// 1. 解析 header(小,必要的处理)
// 路由、鉴权、改 header
// 2. 建立后端连接
backendConn, _ := net.Dial("tcp", backendAddr)
// 3. 转发请求 header(小)
clientReq.Write(backendConn)
// 4. 转发请求 body(大,不读入内存,用 io.Copy 流式转发)
// io.Copy 内部会尝试 splice 做零拷贝
go io.Copy(backendConn, clientReq.Body)
// 5. 转发响应(同理,流式转发,不读入内存)
io.Copy(clientConn, backendConn)
}一个容易忽略的点:日志和监控。
网关通常要"记录日志"(谁访问了什么),
但日志不应该让"数据经过用户态":
坏:为了记日志,把 body 读出来(破坏了零拷贝)
好:日志只记 header、状态码、耗时(这些本来就在用户态),body 不记
或者:如果确实要记 body(比如审计),那拷贝是必要的代价,
但要意识到"记 body 的审计日志 = 放弃零拷贝"。
这道题的元教训:
“少做无用功”的关键,是精细地划分”哪些数据必须经过我,哪些可以绕开我”。
网关是”数据中转站”,天然适合零拷贝。但”中转站”往往还要做点”增值”(鉴权、路由、日志),这些”增值”处理需要数据(header)在用户态。
所以正确的设计是:
- 增值处理只碰”必要的小数据”(header)
- 大块数据(body)尽量绕开
这也是为什么高性能网关(如 Envoy、Nginx)大量使用零拷贝、splice、内存池——它们把”数据不经过用户态”这件事做到了极致。
思考题 3
“epoll 是高性能网络的标准,那是不是所有网络服务都应该用 epoll?“这是个陷阱题。请说明为什么,并给出”该用什么 I/O 模型”的判断标准。
参考答案
这是陷阱题,因为 epoll 不是”永远最优”,它只在特定场景下最优。
epoll 的优势场景:
1. 大量连接(几千到几十万)
2. 大部分连接"空闲"(同一时刻只有少数活跃)
- 长连接、IM、物联网、推送服务
为什么:
- epoll 的核心价值是"一个线程管 N 个连接"
- 只有 N 很大、且大部分空闲时,这个价值才体现
- O(就绪数) 的复杂度,就绪数越少越有优势
epoll 的劣势场景:
1. 连接少(几十个)
- epoll 的复杂度(注册、事件循环)反而是负担
- 阻塞模型 + 线程池更简单
2. 连接都很活跃(每个连接都持续收发数据)
- epoll 返回"所有连接都就绪",还是要逐个处理
- 优势消失,复杂度还在
3. 单连接高吞吐(一个大文件传输)
- 就一个连接,epoll 没有"多路"可复用
- 阻塞 + 大 buffer 反而更高效
4. 需要"每个连接独立处理"的简单场景
- 比如一个简单的 RPC 服务,连接不多
- goroutine-per-connection 简单清晰
判断标准(该用什么 I/O 模型):
1. 连接数量?
├─ 少(< 100)→ 阻塞 + 线程池(或 goroutine-per-conn)
└─ 多(> 1000)→ 考虑 epoll / netpoller
2. 连接活跃度?
├─ 大部分空闲 → epoll 优势大
└─ 都很活跃 → epoll 优势小,甚至阻塞更好
3. 开发语言/框架?
├─ Go → 用 goroutine + netpoller(自动 epoll),别手写
├─ Java → 用 Netty(封装了 epoll),或虚拟线程
├─ C/C++ → 自己写 epoll(或用 libevent/libuv)
4. 性能要求?
├─ 极致性能(百万连接、饱和吞吐)→ 裸 epoll(C/C++)
└─ 够用就行 → 用框架封装好的
Go 程序员特别要注意的:
Go 的 netpoller 已经自动用了 epoll,
你用 goroutine-per-connection(阻塞写法)就是"享受 epoll 的性能"。
所以 Go 程序员"要不要用 epoll"这个问题,答案通常是:
"你已经在用了,只是你没意识到。"
别手写 epoll(除非你有极强的理由),
goroutine + net.Conn 就是最优解。
一个反直觉的结论:
对 Go 来说,"goroutine-per-connection"(阻塞写法)
和"手写 epoll 事件循环"相比,性能差距其实很小(甚至 goroutine 更好),
因为:
1. netpoller 底层就是 epoll
2. goroutine 切换极轻(比线程轻)
3. goroutine 写法的正确性更容易保证
所以 Go 里"手写 epoll"几乎总是过度优化。
这道题考察什么:
- 知不知道 epoll 的适用条件(多连接 + 低活跃)
- 能不能说出 epoll 不适用的场景
- 知不知道 Go 的 netpoller 已经封装了 epoll
- 有没有”工具匹配场景”的意识(不是”工具越先进越好”)
思考题 4(开放题)
“性能优化的本质,是少做无用功。“你认同吗?请结合本章(零拷贝、多路复用)和上一章(缓存),说说”无用功”具体有哪些形态,以及”识别无用功”的方法。
参考答案
开放题,考察你能不能把”性能优化”抽象成统一的方法论。
我认同,但补充一点:性能优化 = “少做无用功” + “把必做的工作做得更高效”。
“无用功”的形态(结合这两章):
形态一:无用的拷贝。
数据搬来搬去,但搬的过程没有产生价值。
例:
- read + write 的 2 次 CPU 拷贝(数据进用户态转一圈)
- 序列化/反序列化来回倒腾
- 字符串拼接的反复拷贝(应该用 strings.Builder)
识别:数据"经过"了某层,但这层没对它做任何"增值"
解法:零拷贝、流式处理、减少中转
形态二:无用的等待。
CPU 空等 I/O、空等锁、空等别人。
例:
- 阻塞 I/O:线程等 I/O 时闲着
- 锁竞争:线程等锁时闲着
- 同步调用:等下游响应时闲着
识别:CPU 有"空闲"或"低利用率",但系统"响应慢"
解法:异步、多路复用、并发
形态三:无用的计算。
重复计算同样的东西。
例:
- 没缓存,每次重新算(应该缓存结果)
- 重复解析、重复查询
识别:同样的输入,反复计算同样的输出
解法:缓存、预计算、记忆化
形态四:无用的”访问”。
访问了不该访问的东西(内存、磁盘、网络)。
例:
- 缓存 miss 导致的底层访问(应该提高命中率)
- 扫描全表(应该用索引)
- 拉取了不需要的字段(应该只查需要的)
识别:访问了"不会用到"的数据
解法:索引、局部性优化、按需加载
“识别无用功”的方法:
方法一:测量(最重要)
- perf top / 火焰图:看 CPU 花在哪
- 监控:看 QPS、延迟、资源利用率
- 别猜,测
方法二:问"这个操作产出什么价值"
- 这次拷贝,数据有没有被"增值"?
- 这次等待,是不是"必须的"?
- 这次计算,结果会不会"被用到"?
方法三:看"数据流"
- 数据从哪来,到哪去,中间经过几层?
- 每一层对数据做了什么?有没有"白经过"的层?
方法四:看"比例"
- 有用功 / 总功 的比例
- 比如:CPU 拷贝 / 总拷贝、有效计算 / 总计算
- 比例低,说明无用功多
“少做无用功”的层次(从易到难):
1. 算法层:避免 O(n²) 的算法、重复计算(最容易发现)
2. 结构层:缓存、索引、数据布局(让访问更高效)
3. 系统层:零拷贝、多路复用、批处理(利用系统机制)
4. 架构层:避免不必要的调用链、减少层次(最难,但收益最大)
一个统一的视角:
性能优化的本质,是”让每一份资源(CPU、内存、带宽、I/O)都花在’产生价值’的地方,而不是’搬运、等待、重复’上。”
而”识别无用功”的关键,是理解数据流和调用链:数据经过了哪些层?每层做了什么?哪些是”增值”,哪些是”白经过”?
这需要你理解系统(操作系统、网络、存储)的每一层,知道”每一层的成本”和”每一层的价值”——而这,正是这本书前几章一直在做的事。
最终总结:
"少做无用功"不是一句口号,而是一套方法:
1. 测量(找到瓶颈)
2. 分析(识别无用功:无用的拷贝/等待/计算/访问)
3. 消除(零拷贝、异步、缓存、索引)
而这套方法的前提,是理解系统的每一层,
知道"哪里会有无用功",以及"怎么消除它"。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 10 章 · 零拷贝与 I/O 多路复用:少做无用功 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【数据搬运的真相】 │
│ read+write 转发 = 4 次拷贝(2 次 DMA 必须,2 次 CPU 浪费) │
│ mmap = 3 次(省 1 次),sendfile = 2 次(全 DMA,真零拷贝) │
│ │
│ 【零拷贝的本质】 │
│ 拿掉"用户态"这个中转站,让数据在内核设备间直接流转(DMA) │
│ 适用:纯转发、不修改、从文件来 │
│ │
│ 【I/O 等待的真相】 │
│ 阻塞 I/O 的浪费:线程"等"的时候闲着 │
│ 多路复用:一个线程管 N 个连接,谁就绪处理谁(事件驱动) │
│ epoll 价值 = 多连接 + 低活跃(长连接场景) │
│ │
│ 【Kafka 的启示】 │
│ 顺序写(少寻道)+ 零拷贝(少拷贝)+ 批量(少开销) │
│ + 依赖 page cache(少造轮子) │
│ = 老老实实利用硬件特性 + 少做无用功 │
│ │
│ 【Go 的边界】 │
│ netpoller 让网络 I/O 全异步(goroutine 阻塞写法 = epoll 性能) │
│ 但文件 I/O 不经过 netpoller(读文件会阻塞线程) │
│ │
│ 【识别无用功】 │
│ 无用的拷贝(数据白经过)、无用的等待(CPU 空等) │
│ 无用的计算(重复算)、无用的访问(访问用不到的) │
│ 方法:测量 → 问"产出什么价值" → 看数据流 │
│ │
│ 【一句话】 │
│ 性能优化的一半是"别做无用功": │
│ 把搬运交给 DMA,把等待交给内核,让 CPU 只做必须由它做的事。 │
│ │
│ 【下一章预告】 │
│ 少做无用功之后,还有一个问题: │
│ 多个任务都想跑,CPU 先跑谁? │
│ 下一章讲调度:公平、吞吐与延迟的不可能三角。 │
└──────────────────────────────────────────────────────────────┘