从零看懂零拷贝:从 read、write 到 mmap、sendfile 与 Netty

服务器把一个文件发给浏览器,为什么要先把数据复制到程序里,再交回操作系统?如果程序只是原样转发,中间这趟搬运能不能省掉?

零拷贝(Zero-Copy)主要解决的就是这类问题:减少大块数据在内存中的重复复制,尤其是减少 CPU 为搬运数据做的工作。 它不是让数据凭空从硬盘出现在另一台电脑上,也不是所有环节都完全没有复制。

下面只用一个例子贯穿全文:服务器把一个 100 MB 文件,通过 TCP 发送给客户端。先看传统读写,再看 mmap、sendfile,最后把它们与 Java NIO、Netty 联系起来。

一、先认清:数据在哪里,谁负责搬运?

把服务器想成一个仓库,先认识几个角色:

概念 在这个例子中做什么
磁盘 / SSD 长期保存文件
内存(RAM) 暂存正在读取、处理或发送的数据
CPU 执行程序、处理协议,也可能执行内存复制
DMA(直接内存访问) 让设备在 CPU 配置好操作后,直接与内存交换数据,无须 CPU 逐字节搬运
网卡 从内存取得待发送的数据,通过网络发出去
内核(Kernel) 操作系统核心,管理文件、内存、设备与网络

内存里还要区分两个经常出现的区域:

  • 用户空间:应用程序能够按权限直接访问的地址空间,程序自己分配的读写缓冲区就在这里。
  • 内核空间:内核管理和使用的地址空间,文件缓存、网络数据结构等由它负责。

普通程序不能任意访问内核内存或直接控制硬件,所以需要通过 read()、write() 等系统调用,请内核完成相关工作。

这里的“空间”描述内存访问范围;后面会讲到的“用户态、内核态”描述 CPU 执行代码时的权限级别。二者有关联,但不是同一个概念。

DMA 负责搬运,是不是 CPU 就不用工作了?

不是。CPU 仍然要发起 I/O、准备描述信息、维护内存和处理网络协议等。DMA 省掉的是 CPU 逐字节复制的那部分工作,数据依旧会占用内存带宽和设备带宽。

所以讨论零拷贝时,要分清两件事:数据有没有传输,以及有没有由 CPU 再复制一份大块内容。

二、传统 read + write:为什么常说“四次复制”?

先限定讨论范围:以下是 Linux 普通文件经过页缓存、再发送到 TCP Socket 的常见简化路径,暂不加入用户态加密、压缩等处理。图中的箭头表示一块数据的流转过程,不代表必须一次申请 100 MB 缓冲区。

实际程序通常分块循环:读一块,再把这一块完整写出去,直到发送完毕。

第一步:磁盘 → 文件页缓存

应用调用 read()。如果文件内容尚未缓存在内存中,内核会发起磁盘读取,设备通常通过 DMA 把数据放进内存中的页缓存(Page Cache)。

1
2
3
4
5
磁盘上的文件
│
│ 设备通过 DMA 传输
▼
文件页缓存(内核管理的内存页)

页缓存就是操作系统用来缓存文件内容的内存。文件若已命中页缓存,这次请求就不必重新从磁盘读取。

第二步:页缓存 → 用户缓冲区

普通 read() 需要把内容交给应用使用,因此会把页缓存中的数据复制到应用提供的缓冲区。这是第一次大块 CPU 内存复制。

第三步:用户缓冲区 → 网络发送路径

应用调用 write() 或 send(),请内核发送这块数据。在普通 Socket 写入路径中,内核通常会将用户缓冲区的数据复制到自己管理的网络缓冲区。这是第二次大块 CPU 内存复制。

普通写入调用返回后,应用就能复用已被内核接受发送的那部分用户缓冲区;尚未写出的部分仍要继续发送。后续排队、重传等工作由网络栈管理。

第四步:网络发送数据 → 网卡

网卡通过 DMA 等机制读取待发送的内存数据,再把它发送到网络。

把完整路径连起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
磁盘
│ DMA:设备读入内存
▼
文件页缓存
│ CPU 复制 ①
▼
用户缓冲区
│ CPU 复制 ②
▼
内核网络缓冲区
│ DMA:网卡读取内存
▼
网卡 → 网络 → 客户端
环节 数据流向 主要搬运方式
1 磁盘 → 页缓存 设备 DMA
2 页缓存 → 用户缓冲区 CPU 复制
3 用户缓冲区 → 内核网络缓冲区 CPU 复制
4 内存中的待发送数据 → 网卡 网卡 DMA

常见的“四次复制”说法,把设备传输和内存复制都算进去了。更准确地说,这是四个主要数据传输环节,其中两次是 CPU 内存复制。它不是对每台机器、每次请求的硬件操作次数做精确计数。

问题也由此变得清楚:如果应用只想原样发送文件,没有检查或修改内容,那么“进用户缓冲区,再回内核”这趟来回就可能是多余的。

三、mmap:让程序映射文件页,省掉一次复制

mmap() 的核心是建立虚拟内存映射,不是直接发送文件。

对普通文件的只读映射,可以让应用通过自己的虚拟地址访问文件对应的缓存页。需要的页面可能在访问时通过缺页异常加载,建立映射并不意味着文件已被完整读入内存。

1
2
3
4
5
磁盘 → 文件页缓存
▲
│ 建立地址映射,应用可通过虚拟地址读取
│ 这里不是复制一份文件内容
应用映射区

这与 read() 的区别是:应用不必先把文件内容复制到另一个用户缓冲区,才能访问它。虽然虚拟地址不同,底层可以指向同一份文件数据页。

但如果接下来还是调用普通 Socket write() 发送映射区的数据,通常仍需要一次“映射区 → 内核网络缓冲区”的 CPU 复制。

因此,在这里比较的经典模型中:

  • read + write:通常有两次大块 CPU 复制。
  • mmap + write:通常可以减少为一次大块 CPU 复制。

mmap 适合通过内存访问方式读取文件等场景,但不等于“调用它就能零复制地把文件发到网卡”。映射管理、缺页处理和访问方式也会影响性能。

四、sendfile:让内核直接处理文件发送

对“文件原样发给 Socket”这个需求,更直接的接口是 Linux 的 sendfile()。

应用不再先读出内容,再提交内容,而是告诉内核:把这个文件从指定偏移开始的一段数据,发送到那个 Socket。

1
2
3
4
应用提交:文件 + 起始偏移 + 长度 + 目标 Socket
│
▼
内核处理读取与发送

大块文件内容不再经过应用自己的读写缓冲区。至于内核内部还能省掉多少复制,需要看实际发送路径。

情况一:绕过用户缓冲区,内核内部仍复制一次

1
2
3
4
5
6
7
8
9
10
磁盘
│ DMA
▼
文件页缓存
│ CPU 复制
▼
内核网络缓冲区
│ 网卡 DMA
▼
网卡

即使采用这条路径,数据也不必先经过用户空间,再回到内核。

情况二:引用文件页,避免大块内容复制

在文件系统、网络栈和设备能力允许时,网络发送路径可以引用已有的文件页,而不是再复制出一份完整的数据。网卡再从这些页面读取待发送内容。

1
2
3
4
5
6
7
8
9
磁盘
│ DMA:未命中页缓存时读入
▼
文件页缓存 ── 页引用 / 长度等描述信息 ──▶ 网络发送队列
│ │
└──────── 网卡按描述读取原数据页 ◀───────┘
│
▼
网卡 → 网络

分散/聚集 DMA(Scatter/Gather DMA)等能力,允许网卡从多个内存片段取得数据,不必先把所有内容复制到一块连续的大缓冲区。

注意,网络发送队列和 Socket 缓冲管理仍然存在,只是不一定保存了一份独立的文件内容副本;它们也可以管理数据页的引用。描述信息、协议头和其他控制数据仍然需要处理。

这也是“零拷贝”里“零”的常见含义:在适合的路径中,可以避免为了发送文件而对大块内容做额外 CPU 复制。 磁盘读取、网卡传输和 CPU 协议处理都还在。

一次 sendfile 就能把整个文件发送完吗?

不能这样假设。Linux 手册明确说明,成功的 sendfile() 调用也可能只传输请求字节的一部分,程序必须根据返回值推进偏移并继续处理;非阻塞发送还要正确处理 EAGAIN,等待可写后再继续,不能空转重试。

函数返回也不等于客户端应用已经收到了全部内容。使用依赖文件页引用的零拷贝发送时,还要遵守源数据的生命周期要求,避免在对端消费完成前原地改写正在传输的文件区域。

五、四次变两次:到底减少了什么切换?

面试中经常听到这句话:“传统读写有四次上下文切换,零拷贝只有两次。”

这个说法混淆了权限模式切换与线程上下文切换。

一次普通系统调用的进入和返回,可以简化为:

1
同一个线程:用户态执行 → 进入内核态执行 → 返回用户态执行

线程可以一直是同一个,并没有必然切换成另一个线程。

如果只统计一轮 read() 和 write() 的系统调用进入/返回,就会有四次用户态、内核态之间的转换;一次 sendfile() 的进入/返回则是两次。

一轮操作 系统调用进入 / 返回带来的模式转换
一次 read() + 一次 write() 共四次
一次 sendfile() 共两次

线程上下文切换则是调度器把 CPU 的执行权从一个线程交给另一个线程,需要保存、恢复相应执行现场。例如 I/O 等待导致线程阻塞时,就可能发生这种切换。

两种开销可以同时出现,但不能把每次系统调用都算成一次线程上下文切换。整个文件还可能需要多次调用,期间也可能有缺页、阻塞、抢占等事件,所以“四次变两次”只是对一轮调用的简化比较。

六、把 read/write、mmap、sendfile、splice 放在一起看

在前面限定的普通文件到 TCP Socket 场景中,可以这样比较:

方案 应用是否访问文件内容 典型大块 CPU 复制次数 主要特点
read + write 是,先读入用户缓冲区 两次 处理逻辑直接,适合需要消费或改写内容的流程
mmap + write 是,通过映射地址访问 一次 减少文件读入用户缓冲区这一步,普通发送仍可能复制
sendfile 不必访问 可为零,也可能有内部复制 适合原样发送文件,路径依平台与实现而定
splice 可以不经过用户缓冲区 依端点和实现而定 至少一端必须是管道,用管道衔接数据传输

splice() 同样能避免数据在内核空间和用户空间之间来回搬运,但它有自己的接口约束:一次调用涉及的两个文件描述符中,至少一个必须是管道。因此,类似“文件 → 管道 → Socket”的流程通常需要分别组织传输,不是直接把 read + write 换个函数名。

这个表比较的是有效载荷的典型路径,不是宣称所有实现都有固定的复制次数。排查性能时,仍要观察实际系统、文件类型和网络链路。

七、Java 和 Netty 中的零拷贝,分别指什么?

1. FileChannel.transferTo:提供高效文件传输的机会

Java NIO 的 FileChannel.transferTo() 可以把文件的一段内容传输到目标通道:

1
long transferred = fileChannel.transferTo(position, count, targetChannel);

返回值是这次实际传输的字节数,不保证等于 count。Java API 也说明,返回值可能为零;该方法不改变源文件通道自身的 position,调用者需要自行记录传输偏移。

在合适的操作系统、JDK 和目标通道组合中,底层可以利用 sendfile 等机制。它提供的是高效传输的机会,不是跨平台承诺“这一行永远零复制”。如果目标是需要处理数据的包装通道,实际执行路径也可能不同。

工程上至少要处理好这几件事:

  • 根据实际返回值更新偏移,处理部分传输。
  • 遇到零进展时,按通道类型处理背压、文件状态或回退路径,避免死循环。
  • 连接断开、取消和失败时正确清理资源;不要只根据单次调用成功判断整个文件已发送完。

2. Netty 的缓冲区零拷贝:共享或组合,而不是重新拼接

Netty 里的“零拷贝”还经常指应用层减少缓冲区复制,与 Linux sendfile 不完全是同一层面的概念。

例如,原本要把两段数据拼成一段,可能会分配新缓冲区,再把两段内容各复制一次。CompositeByteBuf 可以把多个组件组织成一个逻辑缓冲区,让调用方按整体访问,避免仅为拼接而复制内容。

1
2
3
4
5
需要复制的拼接: [消息头] + [消息体] → 复制到一块新缓冲区

组合缓冲区: [消息头] ─┐
├─→ 一个逻辑上的 CompositeByteBuf
[消息体] ─┘

slice()、duplicate() 也可以创建共享底层存储的视图,避免为截取或派生视图复制内容。共享意味着修改可能互相可见,也需要正确管理引用计数和生命周期;需要保留更久时,要使用适当的 retain 机制。

但后续操作若要求连续内存、触发合并或显式复制,仍可能发生数据复制。“创建组合缓冲区时没复制”不等于“这条网络链路从头到尾没复制”。

3. FileRegion:利用系统级文件发送能力

Netty 的 FileRegion 用来描述待传输的文件区域,适合利用底层支持的高效文件发送能力。它与 CompositeByteBuf 的关注点不同:前者处理文件传输,后者主要减少缓冲区组合时的内容复制。

同样,直接内存(Direct Buffer)和内存池也不等于端到端零拷贝:前者可以减少某些 Java 堆到本地 I/O 缓冲区的中间搬运,后者主要减少分配、回收开销;普通 Socket 发送中的内核复制不会因此自动消失。

八、什么时候适合,什么时候要换条路径?

原样发送已有文件是 sendfile 等方案很典型的使用场景,例如静态资源、下载服务和部分文件分发链路。

如果需要边发送边修改正文、进行用户态压缩或加密,程序就需要处理内容,不能简单套用前面的“文件页直接发送”路径。可以先生成好要发送的文件,也可以选择缓冲区处理和发送的流程。

HTTPS 是一个容易误判的例子:常见的用户态 TLS 实现需要先处理明文并生成加密记录,因而不能直接把文件明文绕过它送给普通 Socket。内核 TLS(kTLS)和硬件卸载可以提供不同路径,但能否利用,要看服务器软件、内核、设备和配置,不能看到 HTTPS 就一概判断“必定支持”或“绝不支持”。

最后,少复制不等于所有场景都更快。小文件、冷缓存磁盘读取、慢客户端和链路带宽,都可能成为主要瓶颈。评估时应在相同文件大小、缓存状态、并发量和加密配置下,比较 CPU 占用、吞吐和请求延迟,确认收益来自哪里。

九、面试时可以这样讲

零拷贝是减少数据搬运的一类技术。传统 Linux 缓冲文件读取再写入 Socket 的路径中,数据通常经历两次 CPU 内存复制:页缓存到用户缓冲区、用户缓冲区到内核网络缓冲区。

mmap 可以让应用映射文件页,减少前一次复制;sendfile 则让内核直接组织文件到 Socket 的传输,绕过应用读写缓冲区。在支持页引用和合适设备能力的路径中,还可以避免大块内容的内核复制。

它仍然需要设备传输、内存和 CPU 协议处理。系统调用进出造成的用户态、内核态转换,也不能直接等同于线程上下文切换。Java 的 transferTo 和 Netty 的文件传输可以利用系统能力,而 Netty 的缓冲区组合、切片则是在应用层减少复制。

参考资料