第 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);  // 管道 → socket

splice 比 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+write4 次2 次是需要处理数据
mmap3 次1 次是随机访问、共享
sendfile2 次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 的优化)。除非你有特殊需求,否则不用手动写。

这道题考察什么:

  1. 知不知道”4 次拷贝”这个基础
  2. 能不能说清 mmap 和 sendfile 各自省掉哪次拷贝
  3. 知不知道”mmap 不是真零拷贝”这个易错点
  4. 知不知道 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"几乎总是过度优化。

这道题考察什么:

  1. 知不知道 epoll 的适用条件(多连接 + 低活跃)
  2. 能不能说出 epoll 不适用的场景
  3. 知不知道 Go 的 netpoller 已经封装了 epoll
  4. 有没有”工具匹配场景”的意识(不是”工具越先进越好”)

思考题 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 先跑谁?                                   │
│    下一章讲调度:公平、吞吐与延迟的不可能三角。                     │
└──────────────────────────────────────────────────────────────┘