零拷贝

如何利用零拷贝技术优化性能,减低负荷

零拷贝

零拷贝(Zero-Copy) 是一种通过减少数据在用户空间与内核空间之间的拷贝次数,来提升数据传输效率的关键技术。其核心思想是避免不必要的数据复制操作,让数据直接在内核空间中完成流转,从而降低 CPU 开销、减少内存带宽占用,特别适用于高吞吐量的场景(如大文件传输、高性能服务器等)。

一、零拷贝的核心价值

传统数据传输(如 “读取文件并发送到网络”)的流程通常包含 4 次拷贝和 2 次用户态 / 内核态切换:

  1. 数据从磁盘拷贝到内核缓冲区(DMA 拷贝);

  2. 数据从内核缓冲区拷贝到用户缓冲区(CPU 拷贝);

  3. 数据从用户缓冲区拷贝到 Socket 内核缓冲区(CPU 拷贝);

  4. 数据从 Socket 内核缓冲区拷贝到网络接口(DMA 拷贝)。

零拷贝技术通过省略步骤 2 和 3 中的 CPU 拷贝,显著提升效率。

二、C++ 中常用的零拷贝函数及用法

零拷贝的实现依赖于操作系统提供的系统调用,不同平台(Linux/Windows)函数不同,以下以 Linux 为主(最常用):

1. sendfile:文件到网络的直接传输

sendfile 是 Linux 内核 2.1 版本后提供的零拷贝函数,专门用于将文件数据直接从文件描述符传输到 Socket 描述符,全程在内核空间完成,完全避免用户空间参与。

函数原型

1
2
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

参数说明

  • out_fd:目标文件描述符(通常是 Socket 的 fd);

  • in_fd:源文件描述符(必须是支持mmap的文件,不能是 Socket);

  • offset:文件读取的起始偏移量(若为NULL,则从当前位置开始);

  • count:要传输的字节数。

返回值

  • 成功:实际传输的字节数;

  • 失败:-1(需通过errno判断错误原因)。

示例代码(用sendfile发送文件到网络):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <sys/socket.h>
#include <sys/sendfile.h>
#include <fcntl.h>
#include <unistd.h>
#include <iostream>

int main() {
// 1. 创建Socket(假设已完成绑定、监听、 accept,得到client_fd)
int client_fd = ...; // 已连接的客户端Socket

// 2. 打开要发送的文件
int file_fd = open("large_file.dat", O_RDONLY);
if (file_fd == -1) {
perror("open failed");
return -1;
}

// 3. 获取文件大小(简化示例)
off_t file_size = lseek(file_fd, 0, SEEK_END);
lseek(file_fd, 0, SEEK_SET); // 回到文件开头

// 4. 使用sendfile传输文件
off_t offset = 0;
ssize_t total_sent = 0;
while (total_sent < file_size) {
ssize_t sent = sendfile(client_fd, file_fd, &offset, file_size - total_sent);
if (sent == -1) {
perror("sendfile failed");
close(file_fd);
close(client_fd);
return -1;
}
total_sent += sent;
}

std::cout << "File sent successfully, total: " << total_sent << " bytes\n";

// 5. 关闭资源
close(file_fd);
close(client_fd);
return 0;
}

2. mmap + write:内存映射的间接零拷贝

mmap 将文件直接映射到用户空间的内存地址,此时用户空间可以直接访问内核缓冲区的数据(无需拷贝到用户缓冲区),再通过write将数据发送到 Socket。虽然仍有一次 “用户空间到 Socket 内核缓冲区” 的拷贝,但比传统方式少一次拷贝。

函数原型

1
2
3
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length); // 解除映射

参数说明

  • addr:映射的起始地址(通常为NULL,由系统自动分配);

  • length:映射的字节数;

  • prot:内存保护标志(如PROT_READ表示可读);

  • flags:映射属性(如MAP_SHARED表示修改会同步到文件);

  • fd:文件描述符;

  • offset:文件映射的起始偏移量。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <iostream>

int main() {
int client_fd = ...; // 已连接的客户端Socket
int file_fd = open("large_file.dat", O_RDONLY);
if (file_fd == -1) { /* 错误处理 */ }

// 获取文件大小
off_t file_size = lseek(file_fd, 0, SEEK_END);

// 映射文件到用户空间
void *addr = mmap(NULL, file_size, PROT_READ, MAP_SHARED, file_fd, 0);
if (addr == MAP_FAILED) {
perror("mmap failed");
close(file_fd);
return -1;
}

// 直接通过write发送映射的内存(内部仍有一次内核拷贝)
ssize_t sent = write(client_fd, addr, file_size);
if (sent == -1) { /* 错误处理 */ }

// 解除映射并关闭资源
munmap(addr, file_size);
close(file_fd);
close(client_fd);
return 0;
}

3. splice:任意文件描述符间的零拷贝

splice 是 Linux 2.6.17 后提供的更灵活的零拷贝函数,支持在两个文件描述符(可以是管道、Socket、文件等)之间直接传输数据,全程在内核空间完成,且无需用户空间参与。

函数原型

1
2
#include <fcntl.h>
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/fd_out:输入 / 输出文件描述符(至少有一个必须是管道);

  • off_in/off_out:输入 / 输出的偏移量(NULL表示使用当前位置);

  • len:传输的字节数;

  • flags:控制标志(如SPLICE_F_MOVE表示尽可能移动数据而非复制)。

示例代码(用splice在两个 Socket 间转发数据):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <fcntl.h>
#include <unistd.h>
#include <iostream>

int main() {
int sock1 = ...; // 源Socket
int sock2 = ...; // 目标Socket

// 创建管道(splice要求至少一个fd是管道)
int pipefd[2];
if (pipe(pipefd) == -1) { /* 错误处理 */ }

const size_t BUF_SIZE = 4096;
ssize_t n;

// 从sock1读取数据到管道,再从管道写入sock2
while ((n = splice(sock1, NULL, pipefd[1], NULL, BUF_SIZE, SPLICE_F_MOVE)) > 0) {
ssize_t written = splice(pipefd[0], NULL, sock2, NULL, n, SPLICE_F_MOVE);
if (written != n) { /* 错误处理 */ }
}

close(pipefd[0]);
close(pipefd[1]);
close(sock1);
close(sock2);
return 0;
}
控制标志
  1. SPLICE_F_MOVE

  • 作用:尝试以 “移动” 方式传输数据,而非复制。

  • 行为:告知内核尽可能重用页面(如将页面所有权从源文件描述符转移到目标),减少内存复制开销。

  • 注意:这只是一个 “建议”,内核可能因条件限制(如页面被其他进程引用)而退化为复制方式。

  1. SPLICE_F_NONBLOCK

  • 作用:使 splice() 操作非阻塞执行。

  • 行为:若操作无法立即完成(如数据未就绪),函数会立即返回 -1 并设置 errnoEAGAINEWOULDBLOCK,而非阻塞等待。

  • 注意:需确保相关文件描述符本身支持非阻塞操作(如通过 fcntl() 设置 O_NONBLOCK)。

  1. SPLICE_F_MORE

  • 作用:提示内核后续还有更多数据要传输。

  • 行为:内核可据此优化 I/O 调度(如延迟提交数据,合并成更大的 I/O 操作),提升效率。

  • 适用场景:连续多次调用 splice() 传输大量数据时使用。

  1. SPLICE_F_GIFT

  • 作用:表示传输的数据是给内核的 “礼物”,用户态不再需要访问这些数据。

  • 行为:内核可自由处理数据页面(如直接丢弃),通常用于管道写端向内核传递数据的场景。

  • 限制:仅适用于从用户态文件描述符(如匿名管道)向内核态传输数据的情况。

4.Windows 平台:TransmitFile

Windows 提供的TransmitFile函数类似 Linux 的sendfile,用于将文件数据直接发送到 Socket,实现零拷贝。

函数原型

1
2
3
4
5
6
7
8
9
10
#include <windows.h>
BOOL TransmitFile(
SOCKET hSocket,
HANDLE hFile,
DWORD nNumberOfBytesToWrite,
DWORD nNumberOfBytesPerSend,
LPOVERLAPPED lpOverlapped,
LPTRANSMIT_FILE_BUFFERS lpTransmitBuffers,
DWORD dwFlags
);

三、注意事项

  1. 平台依赖性:零拷贝函数是操作系统相关的(如sendfile仅 Linux 有效,TransmitFile仅 Windows 有效),跨平台开发需封装适配层(如使用 Boost.Asio 等库抽象)。

  2. 适用场景限制

    • sendfile早期版本仅支持 “文件到 Socket” 的传输(无法反向或 Socket 到 Socket);
    • splice要求至少一个文件描述符是管道,灵活性受限;
    • 小数据传输可能因函数调用开销高于拷贝开销,反而效率更低。
  3. 错误处理:零拷贝函数的错误码与普通 IO 函数不同(如sendfile失败时errno可能为EINVAL表示不支持的 fd 类型),需针对性处理。

  4. 内存映射风险mmap映射的内存若被意外修改(如MAP_SHARED模式),可能导致文件数据损坏;且映射大文件可能耗尽虚拟内存。

  5. 偏移量管理sendfilemmapoffset参数需手动管理,多次调用时需确保偏移量正确递增,避免数据重复或丢失。

四、典型使用场景

  1. 静态文件服务器(如 Nginx):用sendfile直接将磁盘文件发送到网络,避免用户空间拷贝,支撑高并发下载。

  2. 大文件传输(如视频、备份数据):数据量通常达 GB 级,零拷贝可减少 90% 以上的 CPU 开销,显著提升传输速度。

  3. 代理服务器(如反向代理):用splice在客户端与后端服务器间转发数据,全程内核态处理,提升转发吞吐量。

  4. 高性能日志收集:用mmap映射日志文件,应用程序直接写入映射内存,避免频繁 IO 拷贝。