在 Linux 服务端的开发范畴之中,sendfile 是众人所熟知的零拷贝系统调用,常常被用来进行高效的文件传输。但是 Linux 内核存在着一整套完整的零拷贝系统调用的体系,可不单单是依靠 sendfile 来达成无拷贝的数据传输。不一样的零拷贝接口适配着不一样的业务场景,能够针对解决文件读写、数据转发、异步传输等不一样场景之下的内存拷贝损耗的问题,极大地提高了 IO 读写的效率,这可是高性能网络服务、文件服务开发的核心技术。
除了通用的那个 sendfile 之外,内核还提供了 mmap、splice、tee、vmsplice、copy_file_range 以及 io_uring 等主流的零拷贝的方案。这些系统调用有着不一样的实现原理以及适用的场景。有的是依靠内存映射来避开数据的拷贝。有的是借助管道来达成内核态的数据流转。还有的是适配异步 IO 来实现高性能的零拷贝传输。它们能够全面地覆盖本地文件复制、数据分流、用户态数据传输、异步网络通信等各种各样的开发场景。
在那传统的 Linux I/O 操作的范畴之内,主要是依靠 read()以及 write()函数来进行数据的传输。当我们想要去读取一个文件,然后再把这个文件的内容经过网络发送出去的时候,这样子的一个过程是颇为麻烦的。
存在一个应用程序需要从磁盘读取文件,之后再通过网络发送给其他设备。首先该应用程序调用 read()函数,而这一操作会引发用户态向内核态的上下文切换。在这个时候 CPU 运用 DMA 控制器把数据从磁盘拷贝到内核空间的读缓冲区。这属于 DMA 拷贝,数据直接就从磁盘到了内核缓冲区,不需要 CPU 直接去搬运数据,这样就减轻了 CPU 的负担。接着 CPU 把内核空间读缓冲区里面的数据拷贝到用户空间的缓冲区,这是 CPU 拷贝,因为数据得从内核空间到用户空间,以便让应用程序来处理这些数据。完成这一步之后,上下文就从内核态切换回用户态,read()函数返回,然后应用程序就能够在用户空间去处理很多数据。
当应用程序打算经过网络去发送数据的时候,它就会去调用 write()函数。调用 write()函数便会引发用户态到内核态的上下文进行切换。紧接着 CPU 将用户空间缓冲区之中的数据拷贝到内核空间的网络缓冲区,这是一次 CPU 所进行的拷贝操作。最后 CPU 依靠 DMA 控制器把数据从网络缓冲区拷贝到网卡设备之上,以此完成数据的发送。在之后上下文就从内核态切换回用户态,write()系统调用便执行返回。
从上面所呈现的流程当中可以看得到,传统的 I/O 存在着明显的性能方面的瓶颈情况。首先第一点就是数据拷贝的次数比较多,整个这样的一个过程当中有 4 次数据拷贝的情况,其中有 2 次是 DMA 拷贝,另外有 2 次是 CPU 拷贝。过多数量的数据拷贝就会占用掉大量的内存带宽,使得数据传输的效率变得比较低。比如说在大数据量进行传输的时候,频繁地进行数据拷贝就会使得内存带宽被大量地占据,其他很多需要用到内存带宽的操作就会受到影响,就好像是一条公路上面有太多的车子在运送货物一样,使得别的车子没有办法顺畅地通行。
其次,就是上下文切换的次数比较多,一共发生了 4 次上下文切换的情况。上下文切换的时候需要保存以及恢复进程的状态信息,而这会消耗 CPU 的资源。在高并发的场景之下,大量的上下文切换就会使得 CPU 忙着去处理这些切换的操作,而不能够高效地去开展实际的业务逻辑。就好像一个人在工作的时候,老是被各种各样的琐事给打断,没有办法集中精积极完成主要的任务,这样子就严重地影响到系统的性能。
在那传统的 Linux I/O 操作之范畴内,主要是依靠 read()以及 write()这两个函数来进行数据的传输。当我们想要去读取一个文件,然后再把文件里面的内容经过网络给发送出去的时候,这个过程那可是相当的麻烦。
存在一个应用程序需要从磁盘读取文件,之后通过网络将其发送给其他设备。首先该应用程序调用 read()函数,这一调用就会引发用户态到内核态的上下文进行切换。此时 CPU 利用 DMA 控制器把数据从磁盘拷贝到内核空间的读缓冲区。这是 DMA 拷贝,数据直接从磁盘到内核缓冲区,不需要 CPU 直接搬运数据,从而减轻了 CPU 的负担。随后 CPU 把内核空间读缓冲区里面的数据拷贝到用户空间的缓冲区,这是 CPU 拷贝,由于数据得从内核空间到用户空间,以便让应用程序来处理这些数据。完成这一步之后,上下文从内核态切换回用户态,read()函数返回,然后应用程序就可以在用户空间去处理众多的数据。
当应用程序需要通过网络来发送数据的时候,就会去调用 write()函数。而调用 write()函数就会引发从用户态到内核态的上下文进行切换。紧接着 CPU 把用户空间缓冲区当中的数据拷贝到内核空间的网络缓冲区里,这是 CPU 所进行的一次拷贝操作。随后 CPU 依靠 DMA 控制器把数据从网络缓冲区拷贝到网卡设备之上,以此来完成数据的发送。在那之后上下文就从内核态切换回用户态,write()系统调用也就执行返回了。
从上面所呈现出来的流程可以看得出来,传统的 I/O 存在着明显的性能方面的瓶颈情况。首先数据拷贝的次数是比较多的,整个过程之中有 4 次数据拷贝的情况,其中有 2 次是 DMA 拷贝,另外还有 2 次是 CPU 拷贝。过多数量的数据拷贝会占用掉大量的内存带宽,使得数据传输的效率变得比较低。比如说在大数据量进行传输的时候,频繁地进行数据拷贝就会大量地占据内存带宽,从而影响到其他很多需要用到内存带宽的操作。就好像一条公路上面有太多的车子在运货一样,使得别的车子没有办法顺畅地通行。
其次,上下文切换的次数是比较多的,总共出现了 4 次上下文切换的这种状况。在上下文进行切换的时候,是需要保存并且恢复进程状态信息的,而这会消耗掉 CPU 资源。在高并发的场景之下,大量的上下文切换就会让 CPU 忙着去处理切换的操作,没有办法高效地去开展实际的业务逻辑。就好像一个人在工作的时候总是被各种各样的琐事给打断,没有办法集中精积极完成主要的任务,这就严重地影响了系统的性能。
mmap,也就是内存映射文件的办法。它能够将文件的内核缓冲区直接映射到用户空间的虚拟地址空间。如此一来应用程序对于文件的读写操作就可以直接在用户空间通过指针来进行。不像传统的 read/write 的方式,得先把数据从内核缓冲区拷贝到用户空间,之后再去进行处理。
当去调用 mmap 函数的时候,内核就会在进程的虚拟地址空间当中去分配那么一段虚拟地址的范围。然后去建立起这个虚拟地址和文件内核缓冲区之间的映射的关系。这个映射的关系是依靠页表来进行维护的,页表记录着虚拟地址到物理地址的转换的信息。这时候文件的数据并不会马上被加载到内存里面。而是在进程第一次访问映射的区域的时候,触发缺页的中断。之后内核才会从磁盘当中去读取相应的数据块,把数据块加载到物理内存的 Page Cache 当中,同时去更新页表,让虚拟地址能够正确地映射到物理内存里面的数据。
在实现文件发送零拷贝的时候,会将 mmap 和 write 一起加以运用。应用程序运用 mmap 把文件映射到用户空间之后,就直接对映射的区域来进行读取或者是处理,随后调用 write 函数来发送数据。在这个过程当中,数据从文件内核缓冲区直接映射到用户空间,减少了内核到用户态的那一次数据拷贝。当进行 write 操作的时候,数据直接从用户空间的映射区域传输到 socket 缓冲区(如果是网络发送的情况),如此便达成了零拷贝的文件发送这种情况。
下面是一个使用 mmap 和 write 进行文件发送的简单 C 语言代码示例:
#include <stdio.h>#include <stdlib.h>#include <fcntl.h>#include <sys/mman.h>#include <sys/stat.h>#include <unistd.h>#include <string.h>#define FILE_SIZE 1024 * 1024 // 1MBint main(){ int fd_in, fd_out; void *src_map, *dst_map; struct stat sb; // 打开源文件 fd_in = open("source_file", O_RDONLY); if (fd_in == -1) { perror("open source_file"); exit(EXIT_FAILURE); } // 获取源文件大小 if (fstat(fd_in, &sb) == -1) { perror("fstat"); close(fd_in); exit(EXIT_FAILURE); } // 打开目标文件,用于写入 fd_out = open("destination_file", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR); if (fd_out == -1) { perror("open destination_file"); close(fd_in); exit(EXIT_FAILURE); } // 将源文件映射到内存 src_map = mmap(0, sb.st_size, PROT_READ, MAP_PRIVATE, fd_in, 0); if (src_map == MAP_FAILED) { perror("mmap"); close(fd_in); close(fd_out); exit(EXIT_FAILURE); } // 扩展目标文件大小,使其与源文件一致 if (lseek(fd_out, sb.st_size - 1, SEEK_SET) == -1) { perror("lseek"); close(fd_in); close(fd_out); munmap(src_map, sb.st_size); exit(EXIT_FAILURE); } if (write(fd_out, "", 1) != 1) { perror("write"); close(fd_in); close(fd_out); munmap(src_map, sb.st_size); exit(EXIT_FAILURE); } // 将目标文件映射到内存 dst_map = mmap(0, sb.st_size, PROT_WRITE, MAP_SHARED, fd_out, 0); if (dst_map == MAP_FAILED) { perror("mmap"); close(fd_in); close(fd_out); munmap(src_map, sb.st_size); exit(EXIT_FAILURE); } // 从源映射区域复制数据到目标映射区域 memcpy(dst_map, src_map, sb.st_size); // 解除映射 if (munmap(src_map, sb.st_size) == -1) { perror("munmap src"); } if (munmap(dst_map, sb.st_size) == -1) { perror("munmap dst"); } // 关闭文件描述符 close(fd_in); close(fd_out); return 0;}mmap 适用于多种场景,特别是在以下情况中优势明显:
在 Linux 系统中,sendfile () 是实现零拷贝的关键系统调用 。它的函数原型定义在<sys/sendfile.h>头文件中,形式如下:
#include <sys/sendfile.h>ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);sendfile()系统调用所得到的返回值是有着特定含义的。当成功的时候,会返回实际进行传输的字节数,这个字节数是处于 0 到 count 这个范围之内的。要是返回值是 0,通常就意味着在 offset 这个位置已经到达了输入文件的末尾。而当失败的时候,就会返回 -1 ,并且还会对全局变量 errno 进行设置来表明具体的错误原因,比如说 EBADF 就代表文件描述符无效,EINVAL 就代表参数无效,ENOMEM 就代表内存不够,EIO 就代表 I/O 错误等等。
Web 服务器之中常常会运用 sendfile()系统调用去发送静态文件。当客户端需要一个静态文件,比如 HTML 页面、CSS 文件、图片这一类的时候,Web 服务器可以利用 sendfile()直接将文件的数据从磁盘文件描述符传递到网络套接字描述符。不需要先把数据复制到用户空间的缓冲区然后再弄回到内核空间,如此便能够极大地提升文件传输的效率。举例来说 Nginx 服务器在处理静态文件请求的时候,能够启动 sendfile 功能,运用 sendfile()系统调用来实现高效的文件传输,减少服务器的 CPU 以及内存的开销,还能够提高服务器并发处理的能力。
存在这样一个应用场景,即有一个应用程序需要将磁盘上的某一个文件通过网络发送给别的设备。在使用 sendfile()函数之前,传统的做法是采用 read()函数以及 write()函数来进行操作,在这个过程当中会出现多次的数据拷贝情况,同时还会有上下文切换的现象,这样的效率是比较低的。但是当使用了 sendfile()系统调用之后,那就完全不一样。
当应用程序去调用 sendfile() 的时候,首先就会产生用户态到内核态的上下文进行切换。在这个时候 CPU 会去通知 DMA(直接内存访问)控制器把数据从磁盘往内核空间的页缓存(page cache)里面读取。DMA 控制器是能够直接去访问内存的,不需要 CPU 直接参与到数据的搬运过程当中,这一个步骤使得 CPU 的负担就得到了减轻。
接下来,数据就不用从内核空间的页缓存拷贝到用户空间的缓冲区。而是直接在 kernel 空间当中,kernel 把页缓存里数据的描述信息,比如数据的位置、长度这类的,直接复制到网络套接字的缓冲区之中。得留意在 Linux2.4 以及之后的 kernel 版本里,对 sendfile()进行了进一步的优化,并且还引入了 DMA gather 技术。通过 DMA gather,数据甚至于都不用在 kernel 空间当中进行复制,而是直接把页缓存里数据的地址还有偏移量等信息传递给网络驱动程序,然后 DMA 控制器依据这些信息直接从页缓存里读取数据,再发送到网络接口那边去。
最后,当完成数据传输之后,上下文就从内核态切换回用户态,sendfile()系统调用也就执行返回。在整个这个过程当中,数据进行了两回 DMA 拷贝,一回是从磁盘到内核页缓存,另一回是从内核页缓存到网络接口。这样就避免了数据在用户空间和内核空间之间的 CPU 拷贝,而且上下文切换的次数也减少到两回,从而就大大的提高了数据传输的效率。
在这一过程之中,DMA 是极为关键的。DMA 可以使得硬件设备,比如磁盘控制器、网卡这一类,直接对内存进行访问,而不需要 CPU 老是来进行干预。当在 sendfile()实现零拷贝的时候,DMA 得把数据从磁盘读取到内核页缓存当中,并且还要把数据从内核页缓存发送到网络接口处,如此一来 CPU 便能够拥有更多的时间去处理其他的任务,从而进一步提升系统整体的性能。
splice 乃是一种具备强大功能的零拷贝系统调用。它是借助着管道(pipe)来使得数据在内核空间当中进行移动,而并非是频繁地在用户空间以及内核空间之间去进行数据的拷贝。它的核心原理便是,splice 把管道当作是数据传输的中间桥梁,通过移动页引用来达成数据的高效传输。
在进行 splice 操作的时候,数据始终在内核态当中进行流转,不会经过用户态。当进行数据传输的时候,splice 会将源文件描述符(fd_in)和目标文件描述符(fd_out)其中的一个与管道的一端相连接。倘若 fd_in 是管道的话,那么数据就会从管道进行读取然后传输到 fd_out。要是 fd_out 是管道的话,数据就会从 fd_in 写入到管道里面。
要去把文件内容发送到网络套接字,首先得去创建一个管道。随后用 splice 这个操作把文件内容传输到管道里边去。之后再用 splice 把管道里面的数据传输到套接字之上。在这个过程当中,数据从文件内核缓冲区直接经过管道移动到套接字缓冲区,没有让用户空间参与进去,这样子就实现了零拷贝。
splice 存在着相应的要求,两端的文件描述符得最少有一个是管道。这乃是 splice 实现零拷贝的关键机制当中的其中一个。管道具备着特殊的内存管理机制,它在细胞核当中维护着环形的缓冲区,数据可以直接在细胞核空间不同的缓冲区间进行移动,不需要实际的数据拷贝操作,如此一来就极大地提升了数据传输的效率。
那要怎么去替代 sendfile ?sendfile 主要是用于在文件描述符以及套接字之间高效地传输数据,并且源文件描述符得是那种支持类似 mmap 语义的普通文件,目标通常是套接字。而 splice 的应用场景则更为宽泛,它可以在任意两个能够进行 pipe 操作的文件描述符之间传输数据,这就使得 splice 在灵活性方面是远远地超过了 sendfile 。
在某些特定的场景之中,splice 有能够很好地去替代 sendfile 的情况。比如说当需要从非普通的文件(像是管道、套接字这一类)向另外一个文件描述符来传输数据的时候,sendfile 是做不到的,但是 splice 却可以轻松地做到。再比如说在一些较为复杂的数据处理流程当中,需要在内核态对数据做一些简单的处理(比如说数据过滤、格式转换等等)之后再进行传输,splice 结合管道的特性,能够方便地在管道之中处理数据,而 sendfile 就很难做到这样的情况。
此外,splice 在某些情况下性能表现也不逊色于 sendfile 。由于它的数据传输过程完全在内核态进行,避免了用户态和内核态之间的数据拷贝,减少了系统调用开销和 CPU 的使用,在高并发和大数据量传输的场景下,能够提供更高效的数据传输服务 。下面是一个使用 splice 进行数据传输的简单 C 语言代码示例,该示例将一个文件的内容通过管道传输到另一个文件中:
#include <stdio.h>#include <stdlib.h>#include <fcntl.h>#include <unistd.h>#include <sys/socket.h>#include <sys/types.h>#include <sys/stat.h>#include <linux/fcntl.h>#define BUFFER_SIZE 4096int main(){ int pipe_fds[2]; int fd_in, fd_out; ssize_t bytes_transferred; // 创建管道 if (pipe(pipe_fds) == -1) { perror("pipe"); exit(EXIT_FAILURE); } // 打开源文件 fd_in = open("source_file", O_RDONLY); if (fd_in == -1) { perror("open source_file"); close(pipe_fds[0]); close(pipe_fds[1]); exit(EXIT_FAILURE); } // 打开目标文件 fd_out = open("destination_file", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR); if (fd_out == -1) { perror("open destination_file"); close(pipe_fds[0]); close(pipe_fds[1]); close(fd_in); exit(EXIT_FAILURE); } // 将源文件数据通过 splice 传输到管道 bytes_transferred = splice(fd_in, NULL, pipe_fds[1], NULL, BUFFER_SIZE, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); if (bytes_transferred == -1) { perror("splice to pipe"); close(pipe_fds[0]); close(pipe_fds[1]); close(fd_in); close(fd_out); exit(EXIT_FAILURE); } // 将管道数据通过 splice 传输到目标文件 bytes_transferred = splice(pipe_fds[0], NULL, fd_out, NULL, BUFFER_SIZE, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); if (bytes_transferred == -1) { perror("splice from pipe"); close(pipe_fds[0]); close(pipe_fds[1]); close(fd_in); close(fd_out); exit(EXIT_FAILURE); } // 关闭文件描述符和管道 close(pipe_fds[0]); close(pipe_fds[1]); close(fd_in); close(fd_out); return 0;}首先来进行改写:创建一个管道,运用 pipe 函数来实施管道的创建操作。pipe_fds 这个数组当中存在着两个文件描述符,pipe_fds[0]是用于从管道里面读取数据的,pipe_fds[1]是用于向管道里面写入数据的。需要说明的是,这样的改写虽然基本保留了核心内容,但在表述的流畅性和逻辑性上还有待提升,不过按照要求进行了相应的调整。开启文件。首先运用 open 函数来开启源文件,是以只读的形式。接着再使用 open 函数来开启目标文件,是以只写、创建、截断的形式。
数据进行传输的情况:首次调用 splice 操作,把源文件 fd_in 里面的数据传输到管道的写端 pipe_fds[1]之处。SPLICE_F_MOVE 所表达的含义是尽可能地去移动页面,而不是进行复制。第二次调用 splice 操作,将管道读端 pipe_fds[0]的数据传送至目标文件 fd_out 那里。在完成数据传输的进程之后,将所有已经被打开的文件描述符以及管道通通予以关闭,以此来释放相应的资源。
tee 系统调用主要是用于在管道之间对内核页引用进行复制,以实现数据的分流零拷贝之目的。当进行 tee 调用的时候,内核会在两个管道的缓冲区之间构建起映射关系。借助复制页引用的方式,将数据从一个管道分流到另一个管道,不需要去开展实际的数据拷贝这类操作。
在日志多副本转发的情境当中,tee 的优势是十分明显的。举个例子来讲,在一个大型的分布式系统里,各个节点都会产生大量的日志数据。为了达成比如日志分析、故障排查、安全审计等不同的需求,就需要把这些日志数据同时转发到多个不一样的目的地,像是日志服务器、数据分析平台、安全监控系统等等。要是采用传统的方法,就需要多次去读取日志文件并且还要进行数据的拷贝,效率很低而且开销还很大。而使用 tee 的话,只需要把日志数据写入一个管道,然后通过 tee 把这个管道里的数据分到多个其他的管道,每一个管道再去连接到不同的目的地,这样就可以实现一次读取、多次分发,而且整个过程是在内核态完成的零拷贝的操作,极大地提升了日志转发的效率以及系统的性能 。
下面是一个使用 tee 和 splice 配合实现数据分流的 C 语言代码示例,该示例将一个文件的内容读取到管道 1,然后使用 tee 将管道 1 的数据分流到管道 2 和管道 3,最后将管道 2 和管道 3 的数据分别写入两个不同的目标文件:
#include <stdio.h>#include <stdlib.h>#include <fcntl.h>#include <unistd.h>#include <sys/socket.h>#include <sys/types.h>#include <sys/stat.h>#include <linux/fcntl.h>#define BUFFER_SIZE 4096int main(){ int pipe1_fds[2], pipe2_fds[2], pipe3_fds[2]; int fd_in, fd_out1, fd_out2; ssize_t bytes_transferred; // 创建管道 1 if (pipe(pipe1_fds) == -1) { perror("pipe1"); exit(EXIT_FAILURE); } // 创建管道 2 if (pipe(pipe2_fds) == -1) { perror("pipe2"); close(pipe1_fds[0]); close(pipe1_fds[1]); exit(EXIT_FAILURE); } // 创建管道 3 if (pipe(pipe3_fds) == -1) { perror("pipe3"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); exit(EXIT_FAILURE); } // 打开源文件 fd_in = open("source_file", O_RDONLY); if (fd_in == -1) { perror("open source_file"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); exit(EXIT_FAILURE); } // 打开目标文件 1 fd_out1 = open("destination_file1", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR); if (fd_out1 == -1) { perror("open destination_file1"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); exit(EXIT_FAILURE); } // 打开目标文件 2 fd_out2 = open("destination_file2", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR); if (fd_out2 == -1) { perror("open destination_file2"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); close(fd_out1); exit(EXIT_FAILURE); } // 将源文件数据通过 splice 传输到管道 1 bytes_transferred = splice(fd_in, NULL, pipe1_fds[1], NULL, BUFFER_SIZE, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); if (bytes_transferred == -1) { perror("splice to pipe1"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); close(fd_out1); close(fd_out2); exit(EXIT_FAILURE); } // 使用 tee 将管道 1 的数据分流到管道 2 和管道 3 bytes_transferred = tee(pipe1_fds[0], pipe2_fds[1], BUFFER_SIZE, SPLICE_F_NONBLOCK); if (bytes_transferred == -1) { perror("tee to pipe2"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); close(fd_out1); close(fd_out2); exit(EXIT_FAILURE); } bytes_transferred = tee(pipe1_fds[0], pipe3_fds[1], BUFFER_SIZE, SPLICE_F_NONBLOCK); if (bytes_transferred == -1) { perror("tee to pipe3"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); close(fd_out1); close(fd_out2); exit(EXIT_FAILURE); } // 将管道 2 数据通过 splice 传输到目标文件 1 bytes_transferred = splice(pipe2_fds[0], NULL, fd_out1, NULL, BUFFER_SIZE, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); if (bytes_transferred == -1) { perror("splice from pipe2"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); close(fd_out1); close(fd_out2); exit(EXIT_FAILURE); } // 将管道 3 数据通过 splice 传输到目标文件 2 bytes_transferred = splice(pipe3_fds[0], NULL, fd_out2, NULL, BUFFER_SIZE, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); if (bytes_transferred == -1) { perror("splice from pipe3"); close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); close(fd_out1); close(fd_out2); exit(EXIT_FAILURE); } // 关闭所有文件描述符和管道 close(pipe1_fds[0]); close(pipe1_fds[1]); close(pipe2_fds[0]); close(pipe2_fds[1]); close(pipe3_fds[0]); close(pipe3_fds[1]); close(fd_in); close(fd_out1); close(fd_out2); return 0;}即将要去创建管道。那我们便依次运用 pipe 函数来创建三个管道,此三个管道分别为 pipe1_fds、pipe2_fds 以及 pipe3_fds。每一个管道均具备读端和写端的文件描述符。使用 open 函数去打开源文件的时候,是处于只读这样的方式。与此同时打开两个目标文件的时候,是以只写、创建以及截断这样的方式来进行打开的。
在数据进行传输的时候,首先先去调用 splice 这个操作,把源文件 fd_in 的数据传输到管道 1 的写端 pipe1_fds[1]。紧接着通过两次调用 tee,把管道 1 读端 pipe1_fds[0]的数据分别分流到管道 2 的写端 pipe2_fds[1]以及管道 3 的写端 pipe3_fds[1]。最后分别调用 splice,把管道 2 读端 pipe2_fds[0]的数据传输到目标文件 1 也就是 fd_out1 那里,把管道 3 读端 pipe3_fds[0]的数据传输到目标文件 2 也就是 fd_out2。在完成数据的传输之后,将所有已经打开的文件描述符以及管道全部予以关闭,从而释放系统所拥有的资源。
vmsplice 系统调用可以使得用户缓冲区和管道进行直接的映射,这是一种高效的数据传输的方式。它能够减少数据在用户空间以及内核空间之间的内存拷贝。其核心的原理就是,vmsplice 把用户空间的内存区域直接映射到管道的内核缓冲区当中,如此一来,数据就能够直接从用户空间进入到管道,不用中间的内存拷贝的操作。
具体来说,当应用程序调用 vmsplice 时,内核会在用户空间和管道之间建立映射关系 。如果是将用户数据写入管道,vmsplice 会将用户指定的内存区域(通过 iovec 结构体数组描述)映射到管道的写缓冲区 。这样,数据就可以直接从用户空间的内存位置被 “注入” 到管道中,而不需要先将数据拷贝到内核的临时缓冲区,再写入管道 。同样,当从管道读取数据到用户空间时,vmsplice 会将管道的读缓冲区映射到用户指定的内存区域,数据直接从管道传输到用户空间,避免了传统方式中数据在内核与用户空间之间的多次拷贝 。以下是一个使用 vmsplice 将用户缓冲区数据映射到管道中的 C 语言代码示例:
#include <stdio.h>#include <stdlib.h>#include <fcntl.h>#include <unistd.h>#include <sys/socket.h>#include <sys/types.h>#include <sys/stat.h>#include <linux/fcntl.h>#include <sys/uio.h>#define BUFFER_SIZE 4096#define DATA_SIZE 1024int main(){ int pipe_fds[2]; struct iovec iov; char buffer[DATA_SIZE]; ssize_t bytes_transferred; // 创建管道 if (pipe(pipe_fds) == -1) { perror("pipe"); exit(EXIT_FAILURE); } // 初始化用户缓冲区 for (int i = 0; i < DATA_SIZE; i++) { buffer[i] = 'A' + (i % 26); } // 设置 iovec 结构体,指向用户缓冲区 iov.iov_base = buffer; iov.iov_len = DATA_SIZE; // 将用户缓冲区数据通过 vmsplice 映射到管道 bytes_transferred = vmsplice(pipe_fds[1], &iov, 1, 0); if (bytes_transferred == -1) { perror("vmsplice"); close(pipe_fds[0]); close(pipe_fds[1]); exit(EXIT_FAILURE); } // 从管道读取数据并输出到标准输出 char read_buffer[BUFFER_SIZE]; ssize_t read_bytes = read(pipe_fds[0], read_buffer, BUFFER_SIZE); if (read_bytes == -1) { perror("read"); close(pipe_fds[0]); close(pipe_fds[1]); exit(EXIT_FAILURE); } write(STDOUT_FILENO, read_buffer, read_bytes); // 关闭管道 close(pipe_fds[0]); close(pipe_fds[1]); return 0;}Linux 4.5 版本当中引入了 copy_file_range 这么一个零拷贝系统调用。它,是专门用来在本地文件之间进行高效的数据拷贝的。它能够允许内核直接在文件的内核缓冲区也就是 Page Cache 之间去进行数据的复制。不需要先把数据拷贝到用户空间,然后再把数据拷贝到目标文件。
当对 copy_file_range 进行调用的时候,内核会依照源文件描述符 fd_in 以及目标文件描述符 fd_out,还有所指定的偏移量 off_in 以及 off_out。随后内核径直在 Page Cache 里面找寻对应的页面。之后将数据从源文件的页面复制到目标文件的页面。此整个过程统统在 内核态当中得以完成,这样便规避了用户态和内核态之间的数据拷贝情况,从而极大程度地提升了文件拷贝的效率。
如果底层文件系统支持一些高级特性,如 Btrfs、XFS 的 reflink 特性,copy_file_range 甚至可以不移动实际的数据块,而只是在元数据层面创建一个指向相同数据块的引用,实现近乎瞬时的文件拷贝操作(写时复制,Copy-on-Write) 。只有当文件系统不支持这些特性时,才会回退到在 Page Cache 中进行实际的数据拷贝 。下面是一个使用 copy_file_range 进行本地文件拷贝的 C 语言代码示例:
#include <stdio.h>#include <stdlib.h>#include <fcntl.h>#include <unistd.h>#include <sys/syscall.h>#include <sys/types.h>#include <sys/stat.h>#define COPY_BUFFER_SIZE 4096int main(){ int fd_in, fd_out; loff_t offset_in = 0, offset_out = 0; ssize_t bytes_copied; // 打开源文件 fd_in = open("source_file", O_RDONLY); if (fd_in == -1) { perror("open source_file"); exit(EXIT_FAILURE); } // 打开目标文件,用于写入 fd_out = open("destination_file", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR); if (fd_out == -1) { perror("open destination_file"); close(fd_in); exit(EXIT_FAILURE); } // 使用 copy_file_range 进行文件拷贝 while ((bytes_copied = syscall(SYS_copy_file_range, fd_in, &offset_in, fd_out, &offset_out, COPY_BUFFER_SIZE, 0)) > 0) { // 继续循环,直到所有数据都被拷贝 } if (bytes_copied == -1) { perror("copy_file_range"); } // 关闭文件描述符 close(fd_in); close(fd_out); return 0;}Linux 内核 5.1 版本当中引入了 io_uring 这么一个新一代的异步 I/O 框架。它是借助着环形队列来达成用户态和内核态的高效协作的。在 io_uring 里面主要存在着三个关键的组件:提交队列、完成队列以及提交队列门。用户态的线程把 I/O 请求提交到提交队列,像读文件、写文件、网络操作这类的情况。然后用户态的线程去更新提交队列门来告知内核有新的请求需要去进行处理。内核从提交队列当中把请求取出来进行处理,处理完之后把结果放到完成队列里面,这个结果包含着成功或者失败、传输字节数等等的情况。用户态的线程可以从完成队列那里去获取处理的结果。

io_uring 支持零拷贝操作,并且可以批量提交 I/O 请求,大大减少了系统调用次数和内存拷贝开销。比如在高性能文件服务器中,当大量用户同时读取文件时,使用 io_uring 可以显著提高文件读取的并发性能。使用 io_uring 通常需要结合 liburing 库(用户态封装库),下面是一个简单的异步读文件示例:
#include <stdio.h>#include <stdlib.h>#include <fcntl.h>#include <unistd.h>#include <liburing.h>#define BUF_SIZE 4096#define QUEUE_DEPTH 16int main(int argc, char *argv[]){ if (argc < 2) { fprintf(stderr, "用法: %s <文件名>\n", argv[0]); return 1; } // 初始化 io_uring 实例 struct io_uring ring; int ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0); if (ret < 0) { perror("io_uring_queue_init 失败"); return 1; } // 打开文件(只读) int fd = open(argv[1], O_RDONLY); if (fd < 0) { perror("open 失败"); io_uring_queue_exit(&ring); return 1; } // 分配缓冲区 char *buf = malloc(BUF_SIZE); if (!buf) { perror("malloc 失败"); close(fd); io_uring_queue_exit(&ring); return 1; } // 获取提交队列项(SQE) struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); if (!sqe) { perror("io_uring_get_sqe 失败"); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; } // 填充读请求(异步读文件) io_uring_prep_read(sqe, fd, buf, BUF_SIZE, 0); // 0 是文件偏移量 io_uring_sqe_set_data(sqe, buf); // 关联缓冲区,方便后续获取 // 提交请求到内核 ret = io_uring_submit(&ring); if (ret < 0) { perror("io_uring_submit 失败"); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; } // 等待并获取完成结果(CQE) struct io_uring_cqe *cqe; ret = io_uring_wait_cqe(&ring, &cqe); if (ret < 0) { perror("io_uring_wait_cqe 失败"); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; } // 处理结果 if (cqe->res < 0) { fprintf(stderr, "读文件失败: %d\n", cqe->res); } else { printf("成功读取 %d 字节,内容前 100 字节:\n", cqe->res); fwrite(buf, 1, (cqe->res > 100? 100 : cqe->res), stdout); printf("\n"); } // 标记 CQE 已处理 io_uring_cqe_seen(&ring, cqe); // 清理资源 free(buf); close(fd); io_uring_queue_exit(&ring); return 0;}在这一个例子当中,运用 io_uring 来开展异步读文件这样的操作。在提交读请求之后,线程就可以接着去做其他的任务,而不需要干等着读操作完成。当读操作完成的时候,通过完成队列去获取结果,这样子就提升了 I/O 操作的并发性能以及效率。
判断是否采用零拷贝内核 API,首先观察项目是否存在有大量数据进行传输并且对于性能有着较高要求的场景。举例来说文件服务器,需要把大量的文件通过网络发送给客户端,采用零拷贝内核 API 能够减少数据的拷贝以及上下文的切换情况,从而提升传输的效率,使得服务器可以处理更多的并发请求。在网络通信的领域当中,比如网络代理服务器,数据需要在不同的网络连接之间进行转发,零拷贝技术能够降低延迟情况,提高吞吐量情况。
大数据处理中,像 Hadoop 分布式文件系统(HDFS),数据在节点间传输频繁,零拷贝可以减少 CPU 开销,加快数据处理速度 。简单来说,如果项目中数据传输频繁,且性能瓶颈在数据拷贝和上下文切换上,就可以考虑使用零拷贝内核 API 。以一个简单的 Linux 文件服务器项目为例,用 sendfile 实现零拷贝功能。假设服务器要把指定文件发送给连接的客户端,代码如下:
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <sys/socket.h>#include <arpa/inet.h>#include <sys/types.h>#include <sys/stat.h>#include <fcntl.h>#include <sys/sendfile.h>#define PORT 8888#define BUFFER_SIZE 1024#define FILE_NAME "example.txt"int main(){ int server_socket, client_socket; struct sockaddr_in server_addr, client_addr; socklen_t client_addr_len = sizeof(client_addr); int file_fd; off_t offset = 0; struct stat file_stat; // 创建 socket server_socket = socket(AF_INET, SOCK_STREAM, 0); if (server_socket == -1) { perror("socket creation failed"); exit(EXIT_FAILURE); } // 配置服务器地址 server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = INADDR_ANY; server_addr.sin_port = htons(PORT); // 绑定 socket 到地址 if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) { perror("bind failed"); close(server_socket); exit(EXIT_FAILURE); } // 监听连接 if (listen(server_socket, 5) == -1) { perror("listen failed"); close(server_socket); exit(EXIT_FAILURE); } printf("Server is listening on port %d...\n", PORT); // 接受客户端连接 client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_addr_len); if (client_socket == -1) { perror("accept failed"); close(server_socket); exit(EXIT_FAILURE); } // 打开要发送的文件 file_fd = open(FILE_NAME, O_RDONLY); if (file_fd == -1) { perror("file open failed"); close(client_socket); close(server_socket); exit(EXIT_FAILURE); } // 获取文件状态 if (fstat(file_fd, &file_stat) == -1) { perror("fstat failed"); close(file_fd); close(client_socket); close(server_socket); exit(EXIT_FAILURE); } // 使用 sendfile 发送文件 ssize_t bytes_sent = sendfile(client_socket, file_fd, &offset, file_stat.st_size); if (bytes_sent == -1) { perror("sendfile failed"); } else { printf("File sent successfully, total bytes sent: %zd\n", bytes_sent); } // 关闭文件和 socket close(file_fd); close(client_socket); close(server_socket); return 0;}在那操作系统的范畴之中,就以 Linux 来说,可以去调整一些内核参数来对零拷贝性能加以优化。比如说/etc/sysctl.conf 文件里头的 vm.swappiness 参数,它是用来把控内存交换的倾向的。将这个值设置得低那么一点儿,像弄成 10,就能够减少不必要的内存交换,让更多的内存用来缓存数据,进而使得 I/O 性能得到提升。还有 net.core.rmem_max 和 net.core.wmem_max 参数,分别是去把控接收和发送缓冲区的最大大小的。依据服务器内存还有网络带宽,把这两个值给调大,就能够让 socket 缓冲区容纳更多的数据,减少数据传输时候的阻塞。
硬件这一方面,适配高速存储设备以及网络设备是颇为关键的。在存储设备这一块,使用 SSD(固态硬盘)相较于传统机械硬盘要好很多,SSD 读写速度比较快,能够减少数据从磁盘往内核缓冲区里面读取的时间,如此一来,就能够很好地发挥零拷贝技术的优势。在网络设备方面,选取高速网卡,比如说万兆网卡,它的数据传输速率比较高,能够让零拷贝传输的数据更快地发送出去,不会让网络带宽成为性能的瓶颈。还有得确保服务器硬件配置是合理的,内存得足够大,CPU 性能得是强劲的,这也能够给零拷贝技术提供一个良好的运行环境。
end
如果这篇文章对你有所启发,欢迎点赞、在看,转发三连。星标⭐账号,还可以第一时间收到推送,感谢你的收看,我们下期再见~
往期干货推荐