大家都有收发快递的经历:一个包裹从 A 城送到 B 城,传统模式需要先运到中转点取出、交接、反复装卸,这就类似数据传输里的多次拷贝。计算机的传统 I/O 传输同样存在这类开销——从磁盘读文件再通过网络发送时,数据要依次经过内核缓冲区、用户缓冲区、socket 缓冲区,最后才交给网络协议栈。多次拷贝不仅耗时,还会占用大量系统资源。而 sendfile 零拷贝技术就像更智能的快递转运,省掉了不必要的搬运环节。还不了解零拷贝的读者,可以先参考这篇《图文并茂 | 彻底搞懂零拷贝(Zero-Copy)技术》。
先搞懂:传统数据传输的拷贝次数
在深入 sendfile 零拷贝技术之前,我们不妨先看看传统方式下数据到底要经过几次拷贝。如果要从磁盘读取文件并通过网络发送出去,传统 I/O 的流程大致是这样:应用程序发起系统调用,向操作系统请求读取文件;操作系统收到请求后,把数据从磁盘读入内核缓冲区。
接着,数据从内核缓冲区被拷贝到用户缓冲区。因为应用程序运行在用户态,不能直接访问内核空间的数据,所以这一步必须由 CPU 来完成。之后,应用程序再通过系统调用把用户缓冲区的数据写入 socket 缓冲区。socket 缓冲区是内核里专门负责网络传输的区域。最后,数据从 socket 缓冲区被送到网卡,通过网络发送出去。
传统数据传输的详细拷贝过程,总共有 4 次数据搬运,其中 2 次由 CPU 拷贝:
-
磁盘到内核缓冲区:这个阶段数据通过 DMA(直接内存访问)从磁盘搬运到内核缓冲区。DMA 是硬件机制,允许磁盘等外部设备直接访问内存,不需要 CPU 干预。比如从硬盘读取视频文件时,硬盘通过 DMA 把数据直接送进内核缓冲区,CPU 此时可以处理其他任务,比如后台进程调度。
-
内核缓冲区到用户缓冲区:数据到达内核缓冲区后,由于应用程序在用户态,无法直接访问内核数据,必须通过 read 系统调用,由 CPU 把数据拷贝到用户缓冲区。这是第一次 CPU 参与的数据拷贝。例如视频播放器(运行在用户态)调用 read,让 CPU 把内核缓冲区里的视频数据拷贝到播放器自己的用户缓冲区,之后才能解码和播放。
-
用户缓冲区到 socket 缓冲区:应用程序在用户缓冲区对数据做一些处理(比如格式转换)后,需要通过网络发送时,调用 write 系统调用。这时 CPU 再次把数据从用户缓冲区拷贝到 socket 缓冲区。socket 缓冲区是内核专门用于网络数据发送的区域。这是第二次 CPU 参与的数据拷贝。继续以视频直播为例:播放器调用 write,CPU 把处理好的视频数据拷贝到 socket 缓冲区,准备发送。
-
socket 缓冲区到网卡:数据到达 socket 缓冲区后,通过 DMA 从 socket 缓冲区搬进网卡,然后经由网络发出。这个过程中 CPU 同样不参与,依然由 DMA 完成。
对应代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define BUF_SIZE 4096 // 用户态缓冲区,在应用程序内存中
int main() {
// 1. 打开本地磁盘上的视频文件
int fd_file = open("video.mp4", O_RDONLY);
if (fd_file < 0) {
perror("open file");
return -1;
}
// 2. 创建TCP socket,用于网络发送
int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket");
close(fd_file);
return -1;
}
// 简单设置服务端地址(此处省略connect连接逻辑)
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
// 用户缓冲区:位于用户态内存,应用程序可以直接读写
char user_buf[BUF_SIZE];
ssize_t n;
// 循环:读文件、发网络
while ((n = read(fd_file, user_buf, BUF_SIZE)) > 0) {
/*
read 系统调用触发流程:
1. 磁盘DMA把数据搬运到【内核文件缓冲区】(DMA,无CPU拷贝)
2. CPU把内核缓冲区数据拷贝到【user_buf 用户缓冲区】(第一次CPU拷贝)
*/
// 这里可以做业务处理:视频解析、格式转换等,操作的就是user_buf(用户态内存)
ssize_t send_len = write(sock_fd, user_buf, n);
/*
write 系统调用触发流程:
1. CPU把user_buf(用户缓冲区)的数据拷贝到【Socket内核缓冲区】(第二次CPU拷贝)
2. 内核后续通过DMA将Socket缓冲区数据搬运到网卡硬件,发往网络(DMA,无CPU拷贝)
*/
if (send_len < 0) {
perror("write socket");
break;
}
}
close(fd_file);
close(sock_fd);
return 0;
}
通过上面的分析可以看出,传统数据传输总共涉及 4 次数据拷贝:其中 2 次是 CPU 参与的拷贝(内核缓冲区到用户缓冲区、用户缓冲区到 socket 缓冲区),另外 2 次是 DMA 拷贝(磁盘到内核缓冲区、socket 缓冲区到网卡)。这种多次拷贝,尤其是 CPU 参与的拷贝,在高并发场景下会大量消耗 CPU 资源,同时频繁的数据搬运也会占用内存带宽,影响整体传输效率。正因如此,sendfile 零拷贝技术才有了用武之地。
Sendfile 零拷贝技术是什么?
sendfile 是 Linux 提供的一个系统调用,核心作用是在内核空间直接把文件数据传送到网络缓冲区,完全绕开用户空间,从而实现高效数据传输。打个比方:快递转运时包裹不需要在中转点反复装卸,sendfile 同样省掉了用户空间这次「中转」,直接把数据从内核文件缓存转移到网络缓冲区域,转运效率自然大幅提升。
当用户进程调用 sendfile 函数时,工作流程如下:
- 用户进程调用 sendfile 系统调用,CPU 从用户态切换到内核态,这是第一次上下文切换。
- 内核通过 DMA 把磁盘数据直接拷贝到内核缓冲区。DMA 让磁盘控制器等硬件设备直接访问内存,无需 CPU 干预,从而减轻 CPU 负担。
- 内核将内核缓冲区中的数据直接拷贝到 socket 缓冲区,这个过程不经过用户缓冲区,省掉了用户态与内核态之间的数据往返。
- 内核再通过 DMA 把 socket 缓冲区中的数据拷贝到网卡,经网络发送出去。
- sendfile 系统调用返回,CPU 从内核态切回用户态,这是第二次上下文切换。
关键代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/sendfile.h>
#include <fcntl.h>
#include <errno.h>
#define PORT 8080
#define FILE_NAME "test.txt" // 要发送的本地文件
#define LISTEN_BACKLOG 5
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr;
socklen_t client_addr_len = sizeof(struct sockaddr_in);
int file_fd;
off_t file_offset = 0;
off_t file_size;
// 1. 打开要发送的文件
file_fd = open(FILE_NAME, O_RDONLY);
if (file_fd == -1) {
perror("open file failed");
exit(EXIT_FAILURE);
}
// 获取文件大小(sendfile 需要知道传输长度)
file_size = lseek(file_fd, 0, SEEK_END);
lseek(file_fd, 0, SEEK_SET);
// 2. 创建 TCP 服务器 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket create failed");
close(file_fd);
exit(EXIT_FAILURE);
}
// 绑定地址和端口
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind failed");
close(server_fd);
close(file_fd);
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_fd, LISTEN_BACKLOG) == -1) {
perror("listen failed");
close(server_fd);
close(file_fd);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
printf("Waiting for client connection...\n");
// 3. 接受客户端连接
client_fd = accept(server_fd, NULL, &client_addr_len);
if (client_fd == -1) {
perror("accept failed");
close(server_fd);
close(file_fd);
exit(EXIT_FAILURE);
}
printf("Client connected!\n");
// ===================== sendfile 零拷贝核心 =====================
// 函数原型:ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
// out_fd: 套接字 fd
// in_fd: 文件 fd
// offset: 文件偏移量
// count: 要传输的字节数
// ==============================================================
ssize_t send_len = sendfile(client_fd, file_fd, &file_offset, file_size);
if (send_len == -1) {
perror("sendfile failed");
printf("errno: %d\n", errno);
} else {
printf("sendfile success! Total send bytes: %ld\n", send_len);
printf(" 零拷贝传输完成(符合你描述的 sendfile 流程)\n");
}
// 关闭资源
close(client_fd);
close(server_fd);
close(file_fd);
return0;
}
在这个示例中,首先打开要传输的文件 test.txt,获取文件描述符 in_fd;然后创建一个 socket,拿到 out_fd。通过循环调用 sendfile,把文件数据从 in_fd 传输到 out_fd,每次传 BUFFER_SIZE 字节,剩余不足则传剩余全部数据。每次调用后更新偏移量 offset,直到文件全部传完,最后关闭文件描述符。
Sendfile 零拷贝到底少了几次拷贝?
把传统数据传输和 sendfile 零拷贝放在一起对比,差异就很直观了。
传统模式下,数据从磁盘到网卡需要 4 次拷贝:2 次由 CPU 参与(内核缓冲区到用户缓冲区、用户缓冲区到 socket 缓冲区),另外 2 次由 DMA 完成(磁盘到内核缓冲区、socket 缓冲区到网卡)。
而 sendfile 零拷贝技术里,数据从磁盘到网卡只需要 2 次拷贝,而且这两次都由 DMA 完成(磁盘到内核 page cache、socket 缓冲区到网卡)。也就是说,sendfile 减少了 2 次数据拷贝,而且减少的恰恰是 CPU 参与的那两次。这对降低 CPU 负担、提升系统性能和数据传输效率意义重大。
相较于传统数据传输中内核态与用户态之间两次 CPU 拷贝的冗余,sendfile 在内核空间完成全部传输流程,有效规避了传统 I/O 的拷贝损耗。其优化机制主要体现在消除两次无效拷贝上:
(1)避免内核态到用户态的拷贝。 传统方式下,数据从磁盘读入内核缓冲区后,必须通过 read 系统调用由 CPU 从内核态拷贝到用户态,因为应用程序运行在用户态,无法直接访问内核缓冲区。而 sendfile 直接在内核空间完成传输:数据从磁盘进到内核 page cache 后,直接从 page cache 拷到 socket 缓冲区,省掉了内核态到用户态的这一次拷贝。
举个例子,文件下载服务器使用传统方式时,数据从磁盘到内核缓冲区后,还要先拷到用户态的应用缓冲区,再从用户态缓冲区拷到 socket 缓冲区发送。而用 sendfile 的话,文件数据从磁盘读入内核 page cache 后,就能直接发往网络,无需经过用户态应用缓冲区。
(2)避免用户态到 socket 缓冲区的拷贝。 传统数据传输中,应用程序在用户态处理数据后,调用 write 系统调用,由 CPU 把用户缓冲区的数据拷到 socket 缓冲区。而 sendfile 压根不让数据经过用户态,故不存在从用户态到 socket 缓冲区的这次拷贝。
以视频直播服务器为例:传统方式下,视频数据从内核缓冲区拷到直播应用的用户缓冲区后,还要再拷到 socket 缓冲区才能发送给观众;使用 sendfile 后,视频数据从磁盘读入内核 page cache,便可直接传输到 socket 缓冲区并发出,省掉用户态到 socket 缓冲区这次拷贝。
Sendfile 零拷贝的局限性
sendfile 并非万能,它也有明显短板。
(1)传输形式限制。 sendfile 主要针对「文件到 socket」的数据传送场景,最擅长把文件数据直接发给网络。在 Web 服务器静态文件发送、文件管理器传大文件等场景中,sendfile 的性能优势非常明显。但如果需要传输的是内存与内存之间的数据,比如把内存里某个数据块发到网络上,sendfile 就无能为力了——它不支持这种数据来源和传输方式。
(2)文件处理限制。 sendfile 只是把文件内容从磁盘直接送到网络,不支持压缩、修改等操作。如果传输前想先压缩文件以降低网络数据量,或者要修改文件内容,sendfile 就无法满足需求。例如发送压缩后的视频文件时,sendfile 没法直接处理,得先用其他手段压缩视频,再考虑怎么传输。这种情况下,就需要借助其他技术方案先处理文件,再选择合适路径发送。
(3)网络协议限制。 部分网络协议不支持 sendfile,典型如 UDP。UDP 是无连接、不可靠的传输协议,其特性导致与 sendfile 很难协同工作。在实时性要求较高的场景,如实时视频会议、在线游戏,通常使用 UDP 来保证数据传输的实时性;在这些场景里,sendfile 就派不上用场了。
sendfile 零拷贝通过减少数据拷贝次数——尤其是消除内核态到用户态、用户态到 socket 缓冲区这两次 CPU 拷贝——显著提升了数据传输效率,降低了 CPU 负担,在文件传输、Web 服务等领域应用广泛。不过它也不是完美方案:传输形式受限、无法处理文件修改与压缩、存在网络协议限制。实际选型时,还是要结合具体业务场景权衡是否采用 sendfile。