MzKyle头像
关注
C/C++---零拷贝实现封面图

C/C++---零拷贝实现

一、零拷贝理论基础

1.1 定义

零拷贝是指在数据传输过程中,避免CPU参与数据在内存之间的逐字节拷贝,将数据搬运工作完全交给DMA控制器和硬件设备,同时最小化用户态与内核态之间的上下文切换次数。

核心纠正:零拷贝不是完全没有拷贝,而是消除所有不必要的CPU拷贝。数据仍然会在硬件设备和内存之间通过DMA进行拷贝,但这些拷贝不占用CPU资源。

1.2 底层硬件与内核机制

所有零拷贝技术都建立在以下三个基础之上:

  1. DMA(直接内存访问):硬件设备可以直接读写系统内存,无需CPU干预
  2. 虚拟内存与页表:通过页表映射实现同一块物理内存的多个虚拟地址映射
  3. Scatter-Gather I/O:网卡/磁盘支持从多个不连续的内存位置收集数据并一次性传输

二、Linux内核零拷贝系统调用

2.1 mmap + write:基础零拷贝(减少1次CPU拷贝)

官方定义(man 2 mmap):将文件或设备映射到进程的虚拟地址空间,使得应用程序可以像访问内存一样访问文件内容。

函数签名

#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);

传输流程(文件→网络):

  1. 磁盘 → 内核页缓存(DMA拷贝)
  2. 内核页缓存 → 映射到用户态虚拟地址空间(无拷贝,仅修改页表)
  3. 用户态直接访问内核页缓存数据
  4. 内核页缓存 → Socket缓冲区(CPU拷贝)
  5. Socket缓冲区 → 网卡(DMA拷贝)

优点

  • 实现简单,跨平台支持(POSIX标准)
  • 适合需要对数据进行少量处理的场景

限制

  • 仍然存在1次CPU拷贝(内核→Socket缓冲区)
  • 映射大小受限于进程虚拟地址空间
  • 存在页错误开销

C++示例

int file_fd = open("large_file.bin", O_RDONLY);
struct stat st;
fstat(file_fd, &st);

// 映射整个文件到用户态虚拟地址空间
char* data = static_cast<char*>(mmap(nullptr, st.st_size, PROT_READ, MAP_PRIVATE, file_fd, 0));
close(file_fd);

// 直接发送映射的数据(内核会从页缓存直接读取)
write(socket_fd, data, st.st_size);

munmap(data, st.st_size);

2.2 sendfile:经典文件到网络零拷贝(0次CPU拷贝)

定义(man 2 sendfile):在两个文件描述符之间传输数据,全程在内核态完成,无需数据进入用户态。

函数签名

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

传输流程(Linux 2.4+,支持scatter-gather):

  1. 磁盘 → 内核页缓存(DMA拷贝)
  2. 内核直接构造Socket缓冲区描述符,指向页缓存中的数据(无拷贝
  3. 网卡通过DMA从页缓存直接读取数据并发送(DMA拷贝)

关键改进:Linux 2.4版本修改了Socket缓冲区结构,支持分散-聚集I/O,从而消除了最后一次CPU拷贝。

优点

  • 完全没有CPU数据拷贝
  • 上下文切换次数从4次减少到2次
  • 性能提升显著(大文件传输CPU使用率降低50%以上)

限制

  • 只能用于文件描述符到Socket的传输
  • 不能对数据进行任何修改
  • 输入文件描述符必须支持mmap(普通文件,不支持管道、Socket)

C++示例

int file_fd = open("large_file.bin", O_RDONLY);
struct stat st;
fstat(file_fd, &st);//获取 file_fd 对应文件的状态信息,并保存到 st 里面

off_t offset = 0;
// 直接将文件数据发送到Socket,数据不进入用户态
ssize_t sent = sendfile(socket_fd, file_fd, &offset, st.st_size);

close(file_fd);

file_fd 本身存不了文件元信息,文件元信息在内核里,用户程序要用 fstat() 拿出来

2.3 splice/tee/vmsplice:管道零拷贝机制(Linux 2.6+)

定义(man 2 splice):在两个文件描述符之间移动数据,通过管道缓冲区作为中介,无需数据进入用户态。

函数签名

#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);
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
ssize_t vmsplice(int fd, const struct iovec* iov, size_t nr_segs, unsigned int flags);

核心思想:利用Linux管道的内部实现(基于环形缓冲区和页引用计数),通过页引用而非数据拷贝来传输数据。

splice工作原理

  1. 创建一个管道(两个文件描述符:pipefd[0]读端,pipefd[1]写端)
  2. 使用splice将数据从输入文件描述符移动到管道写端(内核直接将页引用添加到管道缓冲区)
  3. 使用splice将数据从管道读端移动到输出文件描述符(内核直接将页引用从管道缓冲区移动到输出缓冲区)

vmsplice特殊功能:将用户态内存页直接"赠与"内核管道,实现用户态→内核态的零拷贝传输。需要使用SPLICE_F_GIFT标志,且赠与后用户态不能再访问该内存。

适用场景

  • 任意两个文件描述符之间的零拷贝传输(文件→文件、文件→Socket、Socket→文件、Socket→Socket)
  • 数据转发代理
  • 日志收集系统

C++示例(Socket→文件零拷贝)

int pipefd[2];
pipe(pipefd);

// 将Socket数据零拷贝到管道
ssize_t bytes = splice(socket_fd, nullptr, pipefd[1], nullptr, 65536, SPLICE_F_MOVE);

// 将管道数据零拷贝到文件
splice(pipefd[0], nullptr, file_fd, nullptr, bytes, SPLICE_F_MOVE);

close(pipefd[0]);
close(pipefd[1]);

2.4 copy_file_range:文件到文件零拷贝(Linux 4.5+)

定义(man 2 copy_file_range):在两个普通文件之间复制数据,无需数据进入用户态,内核直接在页缓存之间复制数据。

函数签名

#include <sys/syscall.h>
#include <unistd.h>
ssize_t copy_file_range(int fd_in, loff_t* off_in, int fd_out, loff_t* off_out, size_t len, unsigned int flags);

优点

  • 比splice更简单,不需要创建管道
  • 内核可以优化文件系统内部的复制(如Btrfs、XFS的reflink支持)
  • 支持稀疏文件

限制

  • 只能用于普通文件之间的复制
  • 不支持跨文件系统复制(早期版本)

2.5 MSG_ZEROCOPY:通用套接字零拷贝(Linux 4.14+)

定义(Linux内核文档networking/msg_zerocopy.rst):允许对TCP、UDP和VSOCK套接字的发送操作避免数据拷贝,将用户态内存页直接映射到内核网络栈。

工作原理

  1. 应用程序通过setsockopt启用SO_ZEROCOPY选项
  2. 调用sendmsg时传递MSG_ZE使用splice将数据从管道读端移动到输出文件描述符ROCOPY标志
  3. 内核锁定用户态内存页,直接将页引用添加到Socket缓冲区
  4. 网卡通过DMA从用户态内存直接读取数据并发送
  5. 数据发送完成后,内核通过套接字错误队列发送完成通知
  6. 应用程序收到通知后可以释放或重用内存页

关键注意事项

  • 系统调用返回不代表数据已发送完成,必须等待完成通知
  • 内存页必须是页对齐的
  • 仅对大于约10KB的缓冲区有效(否则页锁定和通知开销超过拷贝开销)
  • 不支持TCP的紧急数据

C++示例

// 启用零拷贝选项
int one = 1;
setsockopt(socket_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one));

// 准备页对齐的缓冲区
void* buf;
posix_memalign(&buf, sysconf(_SC_PAGESIZE), 65536);
// 填充数据...

// 零拷贝发送
struct iovec iov = {buf, 65536};
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;

ssize_t sent = sendmsg(socket_fd, &msg, MSG_ZEROCOPY);

// 等待完成通知
struct pollfd pfd = {socket_fd, POLLERR, 0};
poll(&pfd, 1, -1);

// 读取完成通知
char control[1024];
msg.msg_control = control;
msg.msg_controllen = sizeof(control);

recvmsg(socket_fd, &msg, MSG_ERRQUEUE);

// 现在可以安全释放缓冲区
free(buf);

2.6 O_DIRECT:直接IO零拷贝

定义(man 2 open):打开文件时使用O_DIRECT标志,绕过内核页缓存,直接在用户态内存和磁盘设备之间进行DMA传输。

传输流程

  1. 用户态缓冲区 → 磁盘(DMA拷贝)
  2. 磁盘 → 用户态缓冲区(DMA拷贝)

优点

  • 完全绕过内核页缓存,避免缓存污染
  • 对于大文件顺序读写,性能接近磁盘物理极限
  • 适合数据库等自己管理缓存的应用

严格限制

  • 缓冲区必须是512字节(或磁盘逻辑块大小)对齐的
  • 传输长度必须是块大小的整数倍
  • 文件偏移量必须是块大小的整数倍
  • 不支持异步IO(早期版本)

C++示例

// 打开文件使用O_DIRECT标志
int file_fd = open("large_file.bin", O_RDONLY | O_DIRECT);

// 分配对齐的缓冲区
void* buf;
posix_memalign(&buf, 512, 4096);

// 直接从磁盘读取到用户态缓冲区,不经过页缓存
read(file_fd, buf, 4096);

close(file_fd);
free(buf);

三、C++标准库中的零拷贝支持

3.1 C++11/14:移动语义与右值引用

C++11引入的移动语义是语言层面的零拷贝技术,允许将一个对象的资源所有权转移给另一个对象,而无需拷贝数据。

示例

std::vector<char> create_large_buffer() {
    std::vector<char> buf(1024 * 1024); // 分配1MB内存
    // 填充数据...
    return buf; // 移动构造,零拷贝
}

std::vector<char> data = create_large_buffer(); // 零拷贝

3.2 C++20:std::span与零拷贝视图

std::span是C++20引入的一个非拥有性的连续内存视图,允许在不拷贝数据的情况下访问连续内存区域。

示例

#include <span>

void process_data(std::span<const char> data) {
    // 直接访问原始数据,零拷贝
    for (char c : data) {
        // 处理...
    }
}

std::vector<char> buf(1024);
process_data(buf); // 零拷贝,创建span视图

3.3 C++20:std::ranges与零拷贝算法

C++20的范围库提供了一系列零拷贝算法,通过视图(view)来转换数据,而不创建中间副本。

示例

#include <ranges>
#include <vector>

std::vector<int> numbers = {1, 2, 3, 4, 5};

// 零拷贝转换,不创建中间容器
auto even_squares = numbers 
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * x; });

// 遍历视图时才计算结果
for (int x : even_squares) {
    // 处理...
}

3.4 C++23:std::generator与零拷贝流处理

C++23的协程生成器允许以零拷贝的方式处理流式数据,逐个生成元素而不将整个数据集加载到内存中。

四、主流C++框架中的零拷贝实现

4.1 Boost.Asio

Boost.Asio的缓冲区设计是零拷贝的典范:

  • boost::asio::buffer()只创建一个指向现有内存的视图,不拷贝数据
  • 支持scatter-gather I/O,可以一次性发送多个不连续的缓冲区
  • 可以与sendfilesplice等系统调用结合使用

示例

#include <boost/asio.hpp>

boost::asio::io_context io_context;
boost::asio::ip::tcp::socket socket(io_context);

std::vector<char> data(1024);
// 零拷贝发送,Asio只创建缓冲区视图
boost::asio::async_write(socket, boost::asio::buffer(data),
    [](const boost::system::error_code& ec, std::size_t bytes_transferred) {
        // 处理完成
    });

4.2 Seastar

Seastar是专为高性能服务器设计的C++框架,其零拷贝实现达到了极致:

  • 自带用户态TCP/IP栈,完全绕过内核网络栈
  • 零拷贝网络传输:直接从应用程序内存发送数据到网卡
  • 零拷贝存储API:直接DMA访问磁盘设备
  • 无共享架构,避免锁开销

Seastar零拷贝流示例
函数里面又嵌套了两个 lambda 回调函数

  1. 获取文件大小 f.size()
  2. 创建文件输入流 file_input_stream(f)
  3. 获取 socket 输出流 s.output()
  4. transfer_to_stream() 把文件内容写到 socket
  5. 返回 future,表示异步发送任务
#include <seastar/core/reactor.hh>
#include <seastar/core/file.hh>
#include <seastar/net/api.hh>

seastar::future<> send_file(seastar::net::socket s, seastar::file f) {
    return f.size().then([&s, f](uint64_t size) mutable {
        return seastar::file_input_stream(f).then([&s, size](seastar::input_stream<char> in) mutable {
            return seastar::transfer_to_stream(std::move(in), s.output(), size);
        });
    });
}

4.3 muduo

muduo是陈硕开发的高性能C++网络库,其零拷贝实现包括:

  • 支持sendfile系统调用,用于静态文件传输
  • 零拷贝缓冲区设计:Buffer类使用链表管理多个内存块,避免数据拷贝
  • 支持scatter-gather I/O

五、高级零拷贝技术

5.1 RDMA(远程直接内存访问)

RDMA允许一台计算机直接访问另一台计算机的内存,无需操作系统内核干预,实现零拷贝、低延迟、高带宽的网络传输。

C++ RDMA库

  • libibverbs:InfiniBand Verbs API
  • UCX:统一通信X库
  • libfabric:OpenFabrics接口

5.2 用户态协议栈

用户态协议栈完全在用户态实现TCP/IP协议,绕过内核网络栈,实现零拷贝网络传输:

  • DPDK:数据平面开发套件
  • AF_XDP:Linux内核提供的高速数据包处理接口
  • F-Stack:基于FreeBSD协议栈的用户态网络栈

5.3 零拷贝序列化框架

传统的序列化框架(如Protobuf)需要将数据从内存中的对象格式拷贝到序列化后的字节流格式。零拷贝序列化框架通过设计内存布局与网络传输格式一致,实现零拷贝序列化:

  • Cap’n Proto:内存布局与网络传输格式完全一致
  • FlatBuffers:支持直接访问序列化后的数据,无需解析和拷贝

六、性能对比与最佳实践

6.1 不同零拷贝技术性能对比

技术CPU拷贝次数上下文切换次数适用场景性能提升
传统read/write24通用场景基准
mmap + write14需要少量数据处理20-30%
sendfile02文件→网络传输50-70%
splice02任意文件描述符之间50-70%
MSG_ZEROCOPY02通用套接字发送40-60%
O_DIRECT02大文件顺序读写60-80%
RDMA00高性能集群通信90%+

6.2 最佳实践

  1. 选择合适的零拷贝技术

    • 静态文件服务器:使用sendfile
    • 数据转发代理:使用splice
    • 大文件传输:使用sendfileO_DIRECT
    • 通用网络应用:使用MSG_ZEROCOPY(仅对大缓冲区)
    • 高性能集群:使用RDMA
  2. 避免常见陷阱

    • 不要对小数据使用零拷贝(开销超过收益)
    • 注意内存对齐要求(特别是O_DIRECTMSG_ZEROCOPY
    • 正确处理完成通知(MSG_ZEROCOPY
    • 避免同时使用mmapO_DIRECT访问同一个文件
  3. 结合其他优化技术

    • 使用内存池减少内存分配开销
    • 使用异步I/O提高并发性能
    • 使用大页(HugeTLB)减少页表开销

七、C++零拷贝文件服务器示例

#include <iostream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/sendfile.h>

const int PORT = 8080;
const int BUFFER_SIZE = 4096;

void handle_client(int client_socket) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = read(client_socket, buffer, BUFFER_SIZE - 1);
    if (bytes_read <= 0) {
        close(client_socket);
        return;
    }
    buffer[bytes_read] = '\0';

    // 解析HTTP请求,获取文件名
    std::string request(buffer);
    size_t pos = request.find("GET ");
    if (pos == std::string::npos) {
        close(client_socket);
        return;
    }
    pos += 4;
    size_t end_pos = request.find(" ", pos);
    if (end_pos == std::string::npos) {
        close(client_socket);
        return;
    }
    std::string filename = request.substr(pos, end_pos - pos);
    if (filename == "/") {
        filename = "/index.html";
    }
    filename = "." + filename;

    // 打开文件
    int file_fd = open(filename.c_str(), O_RDONLY);
    if (file_fd == -1) {
        // 文件不存在,返回404
        const char* response = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
        write(client_socket, response, strlen(response));
        close(client_socket);
        return;
    }

    // 获取文件大小
    struct stat st;
    fstat(file_fd, &st);

    // 发送HTTP响应头
    std::string response = "HTTP/1.1 200 OK\r\nContent-Length: " + std::to_string(st.st_size) + "\r\n\r\n";
    write(client_socket, response.c_str(), response.size());

    // 使用sendfile零拷贝发送文件内容
    off_t offset = 0;
    ssize_t sent = sendfile(client_socket, file_fd, &offset, st.st_size);

    close(file_fd);
    close(client_socket);
}

int main() {
    int server_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (server_socket == -1) {
        perror("socket");
        return 1;
    }

    int opt = 1;
    setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    if (bind(server_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(server_socket);
        return 1;
    }

    if (listen(server_socket, 10) == -1) {
        perror("listen");
        close(server_socket);
        return 1;
    }

    std::cout << "Server listening on port " << PORT << std::endl;

    while (true) {
        struct sockaddr_in client_addr;
        socklen_t client_addr_len = sizeof(client_addr);
        int client_socket = accept(server_socket, (struct sockaddr*)&client_addr, &client_addr_len);
        if (client_socket == -1) {
            perror("accept");
            continue;
        }

        handle_client(client_socket);
    }

    close(server_socket);
    return 0;
}

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2301_80079642/article/details/161586083

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--