零拷贝(Zero-copy)是一种优化技术,旨在避免在内存之间不必要的数据拷贝,特别是在内核空间和用户空间之间传输数据时。
传统的IO流程
cpp
// 传统读取文件并发送到网络的过程
read(file_fd, user_buffer, size); // 1. 数据从磁盘→内核缓冲区→用户缓冲区
write(socket_fd, user_buffer, size); // 2. 数据从用户缓冲区→socket缓冲区
(涉及4次拷贝、2次系统调用、4次上下文切换)
传统 I/O 的数据传输过程如下:
用户态到内核态切换:应用程序调用 read 系统调用,请求读取文件数据。此时,CPU 从用户态切换到内核态,开始执行内核中的代码。磁盘数据读取到内核缓冲区:内核通过 DMA(直接内存访问)技术,将磁盘数据直接拷贝到内核缓冲区。DMA 技术可以让硬件设备(如磁盘控制器)直接访问内存,而不需要 CPU 的干预,从而减轻 CPU 的负担。内核缓冲区数据拷贝到用户缓冲区:数据从内核缓冲区拷贝到用户空间的应用程序缓冲区。这一步需要 CPU 的参与,因为用户空间和内核空间是相互隔离的,数据不能直接在两者之间传递。内核态到用户态切换:read 系统调用返回,CPU 从内核态切换回用户态,应用程序可以处理用户缓冲区中的数据。用户态到内核态切换:应用程序调用 write 系统调用,请求将数据发送到网络。CPU 再次从用户态切换到内核态。用户缓冲区数据拷贝到 socket 缓冲区:数据从用户缓冲区拷贝到内核空间的 socket 缓冲区,准备通过网络发送出去。这一步同样需要 CPU 的参与。socket 缓冲区数据发送到网卡:内核通过 DMA 技术,将 socket 缓冲区中的数据拷贝到网卡缓冲区,然后通过网络发送出去。内核态到用户态切换:write 系统调用返回,CPU 从内核态切换回用户态,数据传输完成。
传统 I/O 在一次简单的文件传输中,就涉及了 4 次用户态与内核态的上下文切换,以及 4 次数据拷贝(其中 2 次是 DMA 拷贝,2 次是 CPU 拷贝)。上下文切换和数据拷贝都会消耗 CPU 资源和时间,在高并发的场景下,这些开销会严重影响系统的性能。

零拷贝的本质
零拷贝(zero-copy)并不是指完全不进行数据拷贝,而是一种通过操作系统内核优化,减少数据在用户空间(User Space)与内核空间(Kernel Space)之间冗余拷贝的技术,甚至完全避免不必要的 CPU 数据搬运,从而显著提升数据传输效率、降低 CPU 占用率。
核心思想
减少数据拷贝次数:传统的数据传输方式通常需要进行多次数据拷贝,而零拷贝技术通过巧妙的设计,尽可能地减少了这种不必要的复制。例如,在某些实现方式中,数据可以直接在内核空间中进行传输,避免了在用户空间和内核空间之间的来回拷贝,将传统的 4 次拷贝减少到最少 0 次 CPU 拷贝。
减少上下文切换次数:上下文切换是指 CPU 从一个任务切换到另一个任务时,需要保存和恢复任务的状态信息,这个过程会消耗一定的时间和资源。零拷贝技术通过减少系统调用的次数,从而减少了用户态和内核态之间的上下文切换次数。例如,传统的 I/O 操作需要 2 次系统调用(read/write),会导致 4 次用户态→内核态切换,而零拷贝技术可以将系统调用次数减少到 1 次,大大降低了上下文切换的开销。
避免内存冗余占用:在传统的数据传输中,数据往往需要在用户空间缓冲区中重复存储,这不仅浪费了内存资源,还增加了数据管理的复杂性。零拷贝技术通过让数据直接在内核空间中流转,避免了数据在用户空间的重复存储,提高了内存的利用率。
典例
Linux 系统中的sendfile系统调用,这是一种常见的零拷贝实现方式。在使用sendfile时,数据可以直接从内核缓冲区传输到 socket 缓冲区,而不需要经过用户空间。具体过程如下:
**用户态到内核态切换:**应用程序调用sendfile系统调用,请求将文件数据发送到网络。CPU 从用户态切换到内核态。
**磁盘数据读取到内核缓冲区:**内核通过 DMA 技术,将磁盘数据直接拷贝到内核缓冲区。
**内核缓冲区数据直接传输到 socket 缓冲区:**内核直接将内核缓冲区中的数据传输到 socket 缓冲区,而不需要经过用户空间。这一步利用了内核的特殊机制,直接在内核空间中完成数据的传输,避免了数据在用户空间和内核空间之间的拷贝。
**socket 缓冲区数据发送到网卡:**内核通过 DMA 技术,将 socket 缓冲区中的数据拷贝到网卡缓冲区,然后通过网络发送出去。
**内核态到用户态切换:**sendfile系统调用返回,CPU 从内核态切换回用户态,数据传输完成。
优势:避免操作系统内核缓冲区之间进行数据拷贝操作。避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作。用户应用程序可以避开操作系统直接访问硬件存储。数据传输尽量让 DMA 来做。将多种操作结合在一起避免不必要的系统调用和上下文切换。需要拷贝的数据可以先被缓存起来。对数据进行处理尽量让硬件来做。
DMA技术
DMA(Direct Memory Access,直接内存访问)是一种能够让硬件设备(如磁盘控制器、网卡等)直接与内存进行数据传输的技术,而不需要 CPU 全程参与数据搬运过程。在传统的数据传输方式中,CPU 需要亲自将数据从一个存储区域搬运到另一个存储区域,这就像一个人需要亲自搬着货物从一个仓库运到另一个仓库,不仅耗费体力(CPU 资源),而且效率低下。而 DMA 技术就像是引入了一辆自动搬运车,它可以自己按照设定的路线(传输参数),将货物(数据)从一个仓库(源地址)搬运到另一个仓库(目标地址),而人(CPU)则可以去做其他更重要的事情。
工作流程可以分为以下几个关键步骤:
初始化阶段:在数据传输开始之前,CPU 需要对 DMA 控制器进行初始化配置。这就好比给自动搬运车设定好出发地、目的地和搬运货物的数量。CPU 会向 DMA 控制器写入源地址(数据的来源位置,例如磁盘的某个扇区)、目标地址(数据要传输到的位置,例如内存的某个区域)以及传输数据的长度等参数 。
DMA 请求阶段:当硬件设备准备好要传输的数据时,它会向 DMA 控制器发送一个 DMA 请求信号,就像货物已经准备好了,通知自动搬运车来取货。
总线控制权获取阶段:DMA 控制器接收到请求后,会向 CPU 发送总线请求信号,请求接管系统总线的控制权。因为在同一时刻,系统总线只能被一个设备使用,所以 DMA 控制器需要先获取总线控制权,才能进行数据传输。CPU 在完成当前的总线操作后,会响应 DMA 请求,将总线控制权交给 DMA 控制器,就像人暂时放下手中与总线相关的工作,让自动搬运车使用运输通道(总线)。
数据传输阶段:在获得总线控制权后,DMA 控制器开始按照预先设定的参数,直接在硬件设备和内存之间进行数据传输。它会从源地址读取数据,然后写入到目标地址,这个过程不需要 CPU 的干预,自动搬运车按照设定路线,将货物从源仓库搬运到目标仓库。
传输完成通知阶段:当数据传输完成后,DMA 控制器会向 CPU 发送一个中断信号,通知 CPU 数据传输已经结束,就像自动搬运车完成搬运任务后,通知人可以继续其他工作了。CPU 收到中断信号后,会进行相应的处理,例如检查数据传输是否正确,或者启动下一个任务。
在这个过程中,涉及到的硬件组件主要有 DMA 控制器、硬件设备(如磁盘、网卡)和内存。DMA 控制器是整个数据传输过程的核心控制单元,它负责协调硬件设备和内存之间的数据传输;硬件设备是数据的来源或目的地;内存则是数据存储和中转的地方。它们之间通过系统总线进行数据和信号的传输,共同完成高效的数据传输任务。
PageCache
PageCache 是 Linux 内核中一种至关重要的磁盘数据缓存机制,它犹如一个智能的高速数据中转站,极大地提升了文件系统的读写性能。PageCache 主要由物理内存中的页面组成,这些页面的内容对应于磁盘上的物理块,其大小通常为 4KB。PageCache 就像一个精心管理的仓库,将磁盘上的数据缓存到内存中,使得后续对这些数据的访问能够直接从内存中快速获取,而无需频繁地访问速度较慢的磁盘。
当用户进程对文件发起读取请求时,内核会首先如同一个敏锐的侦察兵,迅速检查该文件的内容是否已被加载到内存中的 PageCache 中。如果数据在缓存中,即发生了缓存命中,这就好比在仓库中轻松找到了所需的货物,内核可以直接从内存中读取数据并返回给用户进程,这个过程非常迅速,就像从身边的架子上直接取物一样,避免了耗时的磁盘 I/O 操作。例如,当我们多次读取同一个配置文件时,第一次读取后数据被缓存到 PageCache 中,后续读取就可以直接从缓存中获取,大大提高了读取速度。然而,如果数据不在缓存中,即发生了缓存未命中,情况就会稍微复杂一些。此时,内核会如同接到紧急任务的调度员,发起磁盘 I/O 操作。内核会通过 DMA 技术,利用 DMA 控制器这个高效的搬运工,将磁盘上的数据直接传输到内存中的 PageCache 中。这个过程就像是从远方的仓库紧急调配货物到本地仓库。数据传输完成后,内核会将数据填充到 PageCache 中,并建立起文件与缓存页面之间的映射关系,就像给货物贴上标签并分类存放,以便后续访问。同时,为了进一步提高性能,内核还可能会采用预读技术,根据局部性原理,预测用户接下来可能需要访问的数据,并提前将其读取到 PageCache 中,就像提前准备好可能会用到的货物,随时待命。
延迟写(write - back)策略
在写数据时,这是一种高效的数据写入优化方式。当用户进程请求写入数据到文件时,内核并不会立即将数据写入磁盘,而是先如同将货物暂存在仓库的临时区域一样,将数据写入 PageCache 中,并将对应的页面标记为 "脏页",表示该页面中的数据与磁盘上的数据不一致。
它允许内核将多次写操作合并为一次,就像将多个小订单合并成一个大订单一起处理,减少了磁盘 I/O 的次数。例如,当我们编辑一个文档并多次保存时,数据并不会每次都立即写入磁盘,而是先缓存在 PageCache 中,等到合适的时机再统一写入磁盘。将脏页中的数据真正写入磁盘,主要有以下几种触发条件:一是时间间隔,系统会定期如每隔一段时间(例如 5 秒、30 秒等)唤醒回写线程,这个回写线程就像一个定时巡检员,扫描脏页并将其写回磁盘;二是内存压力,当系统可用内存不足时,内核需要回收 PageCache 页来分配新内存,此时会先将脏页写回磁盘,就像仓库空间不足时,需要将暂存的货物整理归位;三是脏页比例,当系统中脏页数量超过一定阈值时,也会触发回写,以保证系统的稳定性;四是显式同步,应用程序调用 fsync ()、fdatasync () 或 sync () 等系统调用时,会强制刷新文件和文件系统的脏页,将数据立即写入磁盘,这就像是紧急调用,要求立即将特定的货物发送出去;五是文件关闭或卸载时,通常也会触发相关脏页回写,确保数据的完整性。在回写过程中,回写线程会找到脏页,发起磁盘 I/O 将数据写回到磁盘文件对应的位置,就像将货物准确无误地送回远方的仓库,写成功后,该页标记为 "干净",表示数据已同步。
当应用程序调用 sendfile 系统调用来传输文件时,数据首先会从磁盘读取到 PageCache 中。如果 PageCache 中已经缓存了所需的数据,那么就可以直接从 PageCache 中获取,避免了再次从磁盘读取数据的开销。这一步就像是在本地仓库中直接找到了要发送的货物,无需再从远方仓库调配。然后,数据可以直接从 PageCache 拷贝到 socket 缓冲区,准备通过网络发送出去。在这个过程中,数据不需要经过用户空间,减少了一次 CPU 拷贝,实现了数据在内核空间的直接传输。这就好比货物从本地仓库直接被搬运到发货区,无需经过其他中间环节,大大提高了传输效率。最后,通过 DMA 技术将 socket 缓冲区中的数据传输到网卡,发送到网络中,这个过程同样减少了 CPU 的参与,提高了传输速度。
零拷贝技术的实现方式
mmap+write 是零拷贝技术的一种实现方式,它通过将文件映射到内存,实现了数据的高效传输。在传统的数据传输方式中,当应用程序需要读取文件数据并发送时,数据需要从磁盘读取到内核缓冲区,再从内核缓冲区拷贝到用户空间缓冲区,最后从用户空间缓冲区拷贝到 socket 缓冲区进行发送,这个过程涉及多次数据拷贝和上下文切换,效率较低。而 mmap+write 方式则巧妙地利用了虚拟内存的特性,减少了数据拷贝的次数。具体来说,mmap 系统调用会将文件的内容映射到进程的虚拟地址空间中,使得应用程序可以直接访问文件数据,就像访问内存中的数据一样。这个映射过程实际上是在内核空间中完成的,文件数据并没有真正拷贝到用户空间,而是通过页表机制建立了文件与内存之间的映射关系。此时,应用程序对映射内存区域的任何读写操作,实际上都是对文件的读写操作,数据的修改会直接反映到文件中。
当应用程序需要将文件数据发送到网络时,只需要调用 write 系统调用,将映射内存区域的数据写入 socket 缓冲区。由于映射内存区域与文件数据是共享的,所以这个过程中不需要再次将数据从用户空间拷贝到内核空间,只需要进行一次从内核空间的文件映射区域到 socket 缓冲区的拷贝,然后通过 DMA 技术将 socket 缓冲区的数据发送到网卡,实现数据的网络传输。
cpp
/*
将文件映射到用户空间虚拟地址
减少一次拷贝(但仍需用户空间到内核空间的拷贝)
*/
void *addr = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
write(socket_fd, addr, file_size);
munmap(addr, file_size);
mmap+write 方式的优势在于减少了一次 CPU 拷贝,即将数据从内核缓冲区拷贝到用户缓冲区的过程。这使得数据传输的效率得到了显著提升,特别是在处理大文件或者高并发的数据传输场景中,能够有效降低 CPU 的使用率,提高系统的整体性能。例如,在一个文件服务器中,当大量用户同时请求下载文件时,使用 mmap+write 方式可以快速地将文件数据发送给用户,减少了服务器的负载,提升了用户体验。
工作流程:
调用mmap函数将文件和进程虚拟地址空间映射。
将磁盘数据读取到页高速缓存。
调用write函数将页高速缓存数据直接写入套接字缓冲区。
将套接字缓冲区的数据写入网卡。
用户进程调用mmap函数,向内核发起调用,CPU 从用户态切换到内核态。建立文件物理地址和虚拟内存映射区域的映射,或者说是内核缓冲区 (页高速缓存) 和虚拟内存映射区域的映射。CPU 向磁盘 DMA 控制器发送读取指定位置和大小的指令,DMA 控制器将数据从磁盘拷贝到内核缓冲区。mmap系统调用结束返回,CPU 从内核态切换到用户态。用户进程调用write函数,向内核发起调用,CPU 从用户态切换到内核态。CPU 将页高速缓存中的数据拷贝到套接字缓冲区。CPU 向磁盘 DMA 控制器发送 DMA 写指令,DMA 控制器从套接字缓冲区调用协议栈处理,最后把数据拷贝到网卡。write系统调用结束返回,CPU 从内核态切换到用户态。

mmap对大文件传输有一定优势,但是小文件可能出现碎片,并且在多个进程同时操作文件时可能产生引发coredump的signal。
splice & tee
splice 和 tee 是 Linux 内核中另外两种实现零拷贝的方式,它们为数据在不同文件描述符之间的传输提供了更加灵活和高效的解决方案。splice 系统调用允许在两个文件描述符之间直接传输数据,而无需经过用户空间。它通过在内核空间中建立一个管道缓冲区,实现了数据的高效流动。当使用 splice 进行数据传输时,数据可以从一个文件描述符(如文件、管道、socket 等)直接 "拼接" 到另一个文件描述符中。具体来说,当调用 splice 时,内核会在两个文件描述符对应的内核缓冲区之间建立一个数据传输通道,数据通过 DMA 技术直接在这两个缓冲区之间传输,避免了数据在用户空间的拷贝和 CPU 的参与。这种方式非常适用于需要在不同设备或数据源之间进行数据传输的场景,比如将文件数据直接传输到 socket 进行网络发送,或者在两个文件之间进行数据复制等。例如,在一个网络代理服务器中,splice 可以用于将客户端发送的数据直接转发到目标服务器,提高数据转发的效率。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <assert.h>
#include <errno.h>
int main(int argc, char **argv) {
if (argc <= 2) {
printf("usage: %s ip port\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
// 创建套接字
int sock = socket(AF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
// 设置端口复用
int reuse = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
// 绑定套接字
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_port = htons(port);
inet_pton(AF_INET, ip, &address.sin_addr);
int ret = bind(sock, (struct sockaddr*)&address, sizeof(address));
assert(ret!= -1);
// 监听连接
ret = listen(sock, 5);
assert(ret!= -1);
// 接受连接
struct sockaddr_in client;
socklen_t client_addrlength = sizeof(client);
int connfd = accept(sock, (struct sockaddr*)&client, &client_addrlength);
if (connfd < 0) {
printf("errno is: %s\n", strerror(errno));
} else {
// 创建管道
int pipefd[2];
ret = pipe(pipefd);
assert(ret!= -1);
// 将客户端数据定向到管道
ret = splice(connfd, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
assert(ret!= -1);
// 将管道数据定向回客户端
ret = splice(pipefd[0], NULL, connfd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
assert(ret!= -1);
// 关闭连接
close(connfd);
}
// 关闭套接字
close(sock);
return 0;
}
splice 函数在网络编程和文件处理等领域有着广泛的应用。在网络编程中,它可以用于实现高效的网络数据转发、文件上传下载等功能。在文件处理中,splice 可用于在不同文件描述符之间快速地移动数据,例如将一个大文件的内容快速地复制到另一个文件中。
通过 splice 函数实现回显服务,是其在网络编程中的优势。与传统的数据传输方式相比,splice 减少了数据拷贝和上下文切换的开销,尤其在处理大量数据传输或高并发的网络请求时,能够显著提升网络应用的性能。它为开发者提供了一种高效、简洁的方式来实现数据的快速传输和处理,使得网络编程在数据传输方面更加高效和可靠。
tee 系统调用则主要用于在两个管道文件描述符之间复制数据,并且复制过程不会消耗数据,即源管道中的数据不会因为复制而被删除。这意味着数据可以同时被发送到多个目标,且不影响原来的数据流。tee 的工作原理是在内核空间中创建一个临时缓冲区,将源管道中的数据复制到这个缓冲区,然后再将缓冲区中的数据分别写入到目标管道中。这种特性使得 tee 非常适合用于日志记录和实时数据分析等场景。在一个分布式系统中,需要将某个关键数据的副本发送到多个不同的处理节点进行分析和处理,同时又要保证原始数据的完整性,此时就可以使用 tee 系统调用将数据复制到多个管道,分别发送到不同的节点,实现数据的多向传输和处理 。
在文件处理和网络传输发挥重要作用。对于大规模文件传输场景,如视频文件分发、大文件下载服务等,零拷贝技术可以显著提高系统的性能和吞吐量,能够更快地响应客户端请求,同时减少服务器资源的消耗。