概述
在网络编程中,性能优化是一个永恒的话题。随着数据量的不断增大,传统的数据传输方式往往因为多次内存拷贝而变得效率低下。对于网络编程来说,从磁盘读取文件,然后通过网卡进行发送;或者反过来,从网卡接收数据,然后写入到磁盘中,是比较常见的两种使用场景。
在零拷贝技术出现之前,我们有两种方式来实现这个过程:一种是仅CPU方式,另一种是CPU+DMA方式。下面,我们分别进行介绍。
仅CPU方式
在仅使用CPU进行数据传输的过程中,涉及到多个步骤和系统调用。下面以从磁盘读取数据,向网卡写入数据为例,给出其主要的步骤。
1、用户程序调用read系统调用,请求从磁盘读取数据。内核接收到read调用后,将控制权从用户态切换到内核态。内核检查文件描述符的有效性,并准备从磁盘读取数据。
2、内核向磁盘控制器发送I/O读取请求,要求读取指定位置的数据。磁盘控制器开始执行读取操作,从磁盘上读取数据。
3、当磁盘完成数据读取后,磁盘控制器触发一个中断信号,通知CPU数据已经准备好。CPU接收到中断信号后,暂停当前任务,处理中断。内核的中断处理程序被调用,处理中断并获取磁盘上的数据。内核将数据从磁盘复制到内核缓冲区,通常是页缓存。
4、内核将数据从内核缓冲区复制到用户程序提供的用户空间缓冲区,并更新文件描述符的状态,将控制权从内核态切换回用户态。用户程序继续执行,此时可以访问用户空间缓冲区中的数据。
5、用户程序可能会对数据进行一些处理,比如:压缩、加密等。
6、用户程序调用write系统调用,请求将数据写入网卡。内核接收到write调用后,将控制权从用户态切换到内核态。内核检查文件描述符的有效性,并准备将数据写入网卡。
7、内核将数据从用户空间缓冲区复制到内核缓冲区,通常是套接字缓冲区。
8、内核向网卡控制器发送I/O写入请求,要求将数据发送到网络。网卡控制器开始执行写入操作,将数据发送到网络。
9、当网卡完成数据发送后,网卡控制器触发一个中断信号,通知CPU数据已经成功发送。CPU接收到中断信号后,暂停当前任务,处理中断。
10、内核的中断处理程序被调用,处理中断并确认数据已成功发送。内核更新文件描述符的状态,并将控制权从内核态切换回用户态。用户程序继续执行,此时写操作已完成。
为了便于理解上面的整个传输过程,我们可以参考下面的时序图。
可以看到,在整个传输过程中,数据经历了多次内存复制。这种传统的数据传输方式效率较低,因为每次复制都涉及到额外的CPU周期和内存带宽消耗。
CPU+DMA方式
使用CPU+DMA(直接内存访问)的方式进行数据传输可以显著减少CPU的负担,因为DMA控制器可以直接处理内存之间的数据传输,而不需要CPU频繁介入。下面仍然以从磁盘读取数据,向网卡写入数据为例,给出其主要的步骤。
1、用户程序调用read系统调用,请求从磁盘读取数据。内核接收到read调用后,将控制权从用户态切换到内核态。内核检查文件描述符的有效性,并准备从磁盘读取数据。
2、内核向磁盘控制器发送I/O读取请求,并配置DMA控制器来处理数据传输。DMA控制器开始执行读取操作,从磁盘上读取数据并直接复制到内核缓冲区,通常是页缓存。
3、当磁盘完成数据读取后,磁盘控制器触发一个中断信号,通知CPU数据已经准备好。CPU接收到中断信号后,暂停当前任务,处理中断。内核的中断处理程序被调用,确认数据已成功读取到内核缓冲区。
4、内核将数据从内核缓冲区复制到用户程序提供的用户空间缓冲区,并更新文件描述符的状态,将控制权从内核态切换回用户态。用户程序继续执行,此时可以访问用户空间缓冲区中的数据。
5、用户程序可能会对数据进行一些处理,比如:压缩、加密等。
6、用户程序调用write系统调用,请求将数据写入网卡。内核接收到write调用后,将控制权从用户态切换到内核态。内核检查文件描述符的有效性,并准备将数据写入网卡。
7、内核将数据从用户空间缓冲区复制到内核缓冲区,通常是套接字缓冲区。
8、内核向网卡控制器发送I/O写入请求,并配置DMA控制器来处理数据传输。DMA控制器开始执行写入操作,将数据从内核缓冲区直接复制到网卡的发送缓冲区。
9、当网卡完成数据发送后,网卡控制器触发一个中断信号,通知CPU数据已经成功发送。CPU接收到中断信号后,暂停当前任务,处理中断。
10、内核的中断处理程序被调用,处理中断并确认数据已成功发送。内核更新文件描述符的状态,并将控制权从内核态切换回用户态。用户程序继续执行,此时写操作已完成。
为了便于理解上面的整个传输过程,我们可以参考下面的时序图。
可以看到,数据从磁盘到内核缓冲区,以及从内核缓冲区到网卡,都是DMA控制器在工作。DMA控制器负责了大部分的数据传输工作,减少了CPU的参与次数。但仍然存在两次内存复制,分别为:数据从内核缓冲区到用户空间缓冲区、数据从用户空间缓冲区到内核缓冲区。
什么是零拷贝
为了提高数据的处理速度,并减少CPU和内存带宽的消耗,零拷贝(Zero-copy)技术应运而生。
零拷贝技术指的是操作系统或应用程序能够直接从磁盘文件系统读取数据到用户空间缓冲区,或者直接从用户空间缓冲区写入到磁盘文件系统,而无需经过内核空间的额外复制过程。这种技术减少了数据在内存中的复制次数,从而提高了数据传输效率,降低了系统的负载。
零拷贝技术通过避免不必要的数据复制操作,可以释放出更多的CPU资源来执行其他任务。同时,它还减少了数据在内存与外设之间的传输时间,加快了整体的数据传输速率。在高并发场景下,通过零拷贝技术,每个请求都能更快地完成,从而提升了整个服务的响应速度。
实现零拷贝技术,主要有以下三种方式:mmap方式、sendfile方式、splice方式。下面,我们分别进行介绍。
mmap方式
mmap函数可以将文件的一部分或全部映射到进程的虚拟地址空间,这样就可以像访问普通数组一样直接访问文件内容,而不必通过read/write进行额外的数据复制。mmap是Linux提供的一种内存映射文件的机制,它实现了将内核中读缓冲区地址与用户空间缓冲区地址进行映射,从而实现内核缓冲区与用户缓冲区的共享。这样,就减少了一次用户态和内核态的CPU拷贝,但是在内核空间内仍然有一次CPU拷贝。
mmap函数的接口原型如下。
cpp
void* mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
各个参数的含义如下。
addr:建议的映射地址。通常设置为 NULL,让内核选择一个合适的地址。
length:映射区域的长度,以字节为单位。
prot:内存保护标志,取值为PROT_READ、PROT_WRITE、PROT_EXEC等。
flags:映射选项,取值为MAP_SHARED、MAP_PRIVATE、MAP_ANONYMOUS等。
fd:文件描述符。如果flags包含MAP_ANONYMOUS,则fd可以是-1。
offset:文件中的偏移量,以字节为单位,必须是页大小的整数倍。
返回值:成功时返回映射区域的起始地址,失败时返回MAP_FAILED,并且errno被设置为相应的错误代码。
我们可以通过下面的框图来理解mmap方式时的工作原理。
可以看到,使用mmap减少了数据复制的次数,从而提高了性能。但mmap也有其局限性,比如:对大文件的支持可能会受到系统资源限制的影响,以及在某些情况下可能会增加系统的复杂性和开销。
sendfile方式
sendfile函数可以在不占用用户态内存的情况下,将文件内容直接发送到另一个文件描述符,特别适合用于高性能Web服务器。虽然mmap方式可以减少数据复制的次数,并允许应用程序直接访问文件内容,但它并不能完全消除内核态与用户态之间的状态切换。相比之下,sendfile方式在特定场景下可以更有效地减少上下文切换,因为它在内核态内部完成所有数据传输工作,从而提高了性能。
sendfile函数的接口原型如下。
cpp
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
各个参数的含义如下。
out_fd:输出文件描述符,通常是套接字。
in_fd:输入文件描述符,通常是文件。
offset:指向文件偏移量的指针。如果为NULL,则从当前文件位置开始读取。
count:要传输的最大字节数。
返回值:成功时返回实际传输的字节数,失败时返回 -1,并且errno被设置为相应的错误代码。
我们可以通过下面的框图来理解sendfile方式时的工作原理。
可以看到,sendfile方式只使用一个函数便可以完成之前的mmap + write的功能,这样就少了2次状态切换。注意:由于数据不经过用户缓冲区,因此数据无法被修改。
splice方式
splice是Linux内核提供的一种系统调用,用于在两个文件描述符之间高效地移动数据。splice的主要优点是:它可以在不使用额外的内核缓冲区的情况下,直接将数据从一个文件描述符传输到另一个文件描述符,从而实现零拷贝的数据传输。splice可以处理任意类型的文件描述符,包括:普通文件、套接字、管道等。
splice函数的接口原型如下。
cpp
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
各个参数的含义如下。
fd_in:输入文件描述符。
off_in:指向输入文件偏移量的指针。如果为NULL,则从当前文件位置开始读取。
fd_out:输出文件描述符。
off_out:指向输出文件偏移量的指针。如果为NULL,则写入到当前文件位置。
len:要传输的最大字节数。
flags:控制行为的标志位,取值为SPLICE_F_MOVE、SPLICE_F_NONBLOCK、SPLICE_F_MORE、SPLICE_F_GIFT等。
返回值:成功时返回实际传输的字节数,失败时返回 -1,并且errno被设置为相应的错误代码。
我们可以通过下面的框图来理解splice方式时的工作原理。
可以看到,splice会在内核缓冲区和套接字缓冲区之间建立管道来传输数据,避免了两者之间的CPU拷贝操作。不过,splice也有一些局限,它的两个文件描述符参数中必须有一个是管道设备。