把文件发送到网卡,数据为什么要搬四次
一个静态文件已经保存在磁盘上,服务端只是把它原样发送给客户端。业务代码没有解析或修改文件内容,但如果使用普通的 read() 加 write(),数据仍会经过用户缓冲区,在内核与用户空间之间来回搬运。
DMA 让存储设备和网卡可以直接与内存交换数据,零拷贝则进一步减少 CPU 参与的内存复制。理解这两项技术,关键不是记住“几次拷贝、几次切换”的结论,而是沿着数据路径分清:哪一步由设备完成,哪一步由 CPU 完成,用户程序是否真的需要拿到文件内容。
没有 DMA 时,CPU 还要负责搬运设备数据
早期使用程序控制 I/O(Programmed I/O,PIO)时,CPU 不只负责发出磁盘读写命令,还要通过设备寄存器参与数据传送。数据量一大,CPU 会花费大量时间在设备与内存之间搬运字节,期间很难同时执行其他有效计算。
DMA(Direct Memory Access,直接内存访问)改变了这部分工作。CPU 或驱动准备好内存区域、方向和长度,配置设备或 DMA 引擎并启动操作;之后由硬件在设备与内存之间传输数据。操作完成后,设备通常通过中断或轮询可见的完成状态通知 CPU。
1 | CPU:准备描述符、启动 I/O ─────────────→ 处理完成结果 |
现代存储和网络设备往往具备自己的总线主控 DMA 能力,因此不必把 DMA 控制器理解成所有数据都必须经过的一块独立芯片。IOMMU 地址映射、缓存一致性、描述符管理和完成处理仍需要 CPU 与操作系统参与;DMA 省掉的是 CPU 逐字节搬运有效载荷,并不是让 CPU 与 I/O 完全无关。
普通 read 加 write,文件内容走了怎样一条路
以 Linux 上把普通文件发送到 TCP socket 为例。先假设请求的数据不在页缓存中,存储设备和网卡都支持 DMA,应用程序使用自己的缓冲区循环调用 read() 和 write()。
第一步,read() 进入内核。文件数据由存储设备通过 DMA 送入页缓存;数据就绪后,内核再由 CPU 把它复制到进程提供的用户缓冲区。Linux 的普通文件读写和文件映射通常都经过页缓存,后续读取如果命中缓存,就不需要再次访问存储设备。
第二步,应用调用 write() 把这段用户内存写入 socket。内核通常需要把数据从用户缓冲区复制到 socket 发送路径使用的内核内存,随后网卡通过 DMA 读取待发送数据并发出网络包。
1 | 存储设备 ──DMA──→ 页缓存 ──CPU copy──→ 用户缓冲区 |
在这个简化模型里,一份有效载荷经历四次数据移动:
- 存储设备到页缓存,由设备 DMA 完成。
- 页缓存到用户缓冲区,由 CPU 完成。
- 用户缓冲区到 socket 发送内存,由 CPU 完成。
- socket 发送内存到网卡,由网卡 DMA 完成。
read() 和 write() 是两次系统调用,每次调用都包含进入内核和返回用户空间,因此通常概括为四次用户态/内核态转换。这里应当叫特权级或模式转换,不能一概称为进程上下文切换:系统调用前后仍可能是同一个线程,只是执行权限和代码位置发生了变化。线程因等待 I/O 被调度出去时,才会额外发生调度意义上的上下文切换。
这些次数也不是任何环境下都固定不变。页缓存命中时没有第一步存储 DMA;文件系统、设备、协议栈和硬件卸载能力会改变实际路径;一次系统调用还可能只处理部分数据。四次移动与四次模式转换更适合用来理解经典路径,而不是直接当作性能测量结果。
文件只是被转发时,用户缓冲区没有提供价值
如果程序需要解析、压缩、加密或改写文件内容,把数据放进用户空间是合理的。静态文件发送却不同:应用只决定发送哪个文件、从哪里开始以及发送多少字节,文件内容本身没有被业务代码读取。
此时中间的两次 CPU 复制主要是在跨越内核与用户空间边界:
1 | 页缓存 → 用户缓冲区 → socket 发送内存 |
零拷贝优化的目标,就是尽量让有效载荷留在内核管理的页面中,通过引用、映射或设备描述符继续向下传递。名称中的“零”通常指减少或消除 CPU 对完整有效载荷的内存复制,尤其是不再复制到用户缓冲区;磁盘把数据送入内存、网卡从内存取走数据这些物理传输仍然存在。
mmap 省掉 read 的复制,但 write 路径还在
mmap() 会在进程虚拟地址空间中建立文件映射。应用访问这段地址时,页表让相应虚拟页关联到文件页;如果页面尚未载入,访问会触发缺页异常,由内核把文件数据读入页缓存后建立映射。
1 | 文件页缓存 ←──页表映射──→ 进程虚拟地址 |
这里没有把某个内核虚拟地址直接交给用户程序,也没有额外创建一份完整的用户缓冲区。进程可以通过自己的虚拟地址访问同一批文件页。随后调用 write(socket, mapped_addr, length) 时,内核仍要处理用户地址,并在普通发送路径上把有效载荷复制到 socket 使用的内核内存。
在前面的假设下,mmap + write 可以把四次数据移动降为三次:存储设备 DMA 到页缓存、页缓存 CPU 复制到 socket 发送内存、网卡 DMA 读取发送数据。它消除了 read() 把页缓存复制到用户缓冲区的步骤。
模式转换的数量要看怎么统计。如果每次传输都新建映射,mmap() 和 write() 仍是两次系统调用;如果映射长期复用,后续发送不必重复调用 mmap(),但首次访问页面可能发生缺页异常。因此,把它机械地写成永远“四次切换”并不准确。
mmap 更适合程序确实需要以地址方式访问文件、访问模式有局部性,或者映射能够复用的场景。映射大文件还要考虑地址空间、缺页成本,以及文件在映射期间被截断等边界。对于不需要查看内容的纯文件转发,它仍然让应用持有了一个没有业务用途的文件映射。
sendfile 把文件到 socket 的传输留在内核
Linux 从 2.2 开始提供 sendfile()。调用方传入输出文件描述符、输入文件描述符、偏移量和长度,内核直接在两个文件描述符之间传输数据,不再要求应用先 read() 到用户缓冲区,再调用 write()。
1 | ssize_t sent = sendfile(socket_fd, file_fd, &offset, count); |
对于文件发送到 socket 的场景,普通路径可以简化为:
1 | 存储设备 ──DMA──→ 页缓存 ──内核处理──→ socket ──DMA──→ 网卡 |
应用只发起一次 sendfile(),所以正常返回路径通常只有一次进入内核和一次回到用户空间。文件内容不经过用户缓冲区,也就省掉了 read() 对应的用户空间复制。
如果发送路径仍需要把页缓存内容复制到独立的 socket 数据缓冲区,那么可以按三次数据移动理解:一次存储 DMA、一次内核中的 CPU 复制、一次网卡 DMA。相比 read + write,它减少了一次系统调用和一次完整的 CPU 数据复制。
sendfile() 成功返回的字节数可能小于请求长度,非阻塞 socket 还可能返回 EAGAIN,调用方必须根据返回值继续发送剩余部分。零拷贝接口优化了数据路径,没有改变短写、错误处理和背压这些 I/O 语义。
网卡支持 scatter-gather 后,socket 可以只保存引用
支持 scatter-gather DMA 的网卡可以根据一组“内存地址 + 长度”描述符,从多个不连续的物理内存区域读取数据。发送文件时,内核不必先把页缓存中的文件内容汇总复制到一块连续的 socket 缓冲区;socket 发送结构可以引用对应页面,网卡再通过 DMA 直接读取这些页面。
1 | 存储设备 ──DMA──→ 页缓存页面 |
在这个模型里,完整文件有效载荷只有两次物理移动:从存储设备进入内存,以及从内存进入网卡。CPU 仍要执行系统调用、维护页引用、构造协议头、提交发送描述符并处理完成事件,但不再把整段文件内容从一个内存缓冲区复制到另一个缓冲区。校验和与分段等工作是否还消耗 CPU,则取决于网卡卸载能力和协议配置。
这也是文件传输中“零拷贝”比较准确的含义:用户空间对有效载荷零复制,理想发送路径上 CPU 对有效载荷也可以零复制,而设备之间仍通过 DMA 完成两次必要的数据传输。
几种路径放在一起看
下面的计数沿用同一假设:数据最初不在页缓存,最后通过支持 DMA 的网卡发出,只计算完整有效载荷的数据移动;具体内核和硬件可能采用不同实现。
| 方式 | 系统调用路径 | 数据移动 | CPU 完整载荷复制 |
|---|---|---|---|
read + write |
两次调用,典型四次模式转换 | 4 | 2 |
mmap + write |
建立映射后调用 write,转换次数取决于映射是否复用 |
3 | 1 |
sendfile,发送路径仍复制数据 |
一次调用,典型两次模式转换 | 3 | 1 |
sendfile + scatter-gather DMA |
一次调用,典型两次模式转换 | 2 | 0 |
表里的数字只描述文件原样发送这一条经典路径。接收侧零拷贝、用户生成的数据、磁盘间复制、管道转发以及启用 TLS 后的发送都有不同接口和限制。Linux 还提供 splice()、copy_file_range() 和 MSG_ZEROCOPY 等机制,它们分别面向不同的数据来源和目标,不能因为都被称为零拷贝就互相替代。
零拷贝适合“不碰数据”的路径
当应用只负责授权、选择文件和控制发送范围时,sendfile() 可以避免让文件内容无意义地穿过用户空间。静态文件服务、下载和部分代理链路通常能从中受益。文件已经命中页缓存时,存储设备传输也会消失,收益更多来自减少 CPU 复制和系统调用开销。
如果业务必须修改有效载荷,复制可能无法完全避免。应用层压缩、内容拼装、没有内核支持的加密路径,都会要求 CPU 或加速设备读取并生成新的字节序列。此时更重要的是用实际负载测量 CPU 利用率、吞吐、缺页和缓存命中情况,而不是为了满足“零拷贝”这个名字强行选择接口。
DMA 与零拷贝处理的是两个相邻层次的问题:DMA 减少设备与内存传输时的 CPU 搬运,零拷贝减少内存缓冲区之间不必要的 CPU 复制。沿着数据路径逐段确认所有者和搬运者,比孤立记住“四次变两次”更容易判断一项优化是否真的适用于当前系统。