C++ 零拷贝(Zero-Copy)技术:`sendfile`, `splice` 在网络编程中的应用

哈喽,各位好!今天咱们来聊聊C++里的零拷贝技术,这玩意听起来玄乎,其实就是想方设法让数据搬运的时候少折腾几次,提高效率。重点说说sendfilesplice这俩哥们儿在网络编程中的应用。

一、啥是零拷贝?为啥需要它?

想象一下,你辛辛苦苦烤了一块披萨(数据),想送给朋友(网络),正常的流程是:

  1. 你得先把披萨从烤箱(硬盘)里拿出来,放到你的餐盘(内核缓冲区)。
  2. 然后你再从餐盘里把披萨切好,装到外卖盒(用户缓冲区)。
  3. 最后,外卖员(网络协议栈)再把披萨从外卖盒里拿走,送到你朋友家。

这中间是不是折腾了三道? 零拷贝技术,就是想减少这些不必要的折腾,最好是直接把烤箱里的披萨“咻”的一声送到朋友家,中间啥也不用管。

为啥要这么费劲?因为拷贝数据很耗资源啊!CPU得忙活,内存带宽也得占用。在高并发的网络应用里,这可不是闹着玩的,一点点浪费都会被放大成灾难。

二、传统的I/O操作:拷贝拷贝再拷贝

传统的I/O操作,比如read()write(),数据至少要在用户空间和内核空间之间拷贝两次:

  1. read():数据从硬盘拷贝到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区。
  2. write():数据从用户缓冲区拷贝到内核缓冲区,再从内核缓冲区拷贝到网卡发送。

用代码来演示一下:

#include <iostream>
#include <fstream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>

const int BUFFER_SIZE = 4096;

int main() {
    // 1. 创建socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket failed");
        return 1;
    }

    // 2. 绑定地址
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);  // 端口号

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        close(server_fd);
        return 1;
    }

    // 3. 监听连接
    if (listen(server_fd, 3) < 0) {
        perror("listen failed");
        close(server_fd);
        return 1;
    }

    std::cout << "Server listening on port 8080..." << std::endl;

    // 4. 接受连接
    int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&address);
    if (new_socket < 0) {
        perror("accept failed");
        close(server_fd);
        return 1;
    }

    // 5. 打开文件
    std::ifstream file("example.txt", std::ios::binary);
    if (!file.is_open()) {
        perror("Failed to open file");
        close(new_socket);
        close(server_fd);
        return 1;
    }

    // 6. 传统方式发送文件
    char buffer[BUFFER_SIZE];
    while (file.read(buffer, BUFFER_SIZE)) {
        ssize_t bytes_read = file.gcount();
        ssize_t bytes_sent = send(new_socket, buffer, bytes_read, 0);
        if (bytes_sent == -1) {
            perror("send failed");
            break;
        }
    }

    // 7. 关闭连接和文件
    file.close();
    close(new_socket);
    close(server_fd);

    std::cout << "File sent successfully!" << std::endl;

    return 0;
}

在这个例子中,example.txt的内容会先从硬盘拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区buffer,最后再拷贝到内核缓冲区,由socket发送出去。 哎,这小小的文件,就得经历好几道拷贝,太累了。

三、sendfile:减少一次拷贝

sendfile系统调用,就是为了解决这个问题而生的。它可以直接把数据从一个文件描述符(比如硬盘上的文件)拷贝到另一个文件描述符(比如socket),避免了用户空间和内核空间之间的一次拷贝。

简单来说,sendfile让内核直接操作数据,省去了用户进程的参与。它大致的流程是:

  1. 在内核空间中,直接将数据从硬盘读取到内核缓冲区。
  2. 将内核缓冲区的数据直接拷贝到socket缓冲区。
  3. 通过网络协议栈发送数据。

修改上面的代码,使用sendfile

#include <iostream>
#include <fstream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <sys/sendfile.h>
#include <fcntl.h> // for open

int main() {
    // 1. 创建socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket failed");
        return 1;
    }

    // 2. 绑定地址
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);  // 端口号

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        close(server_fd);
        return 1;
    }

    // 3. 监听连接
    if (listen(server_fd, 3) < 0) {
        perror("listen failed");
        close(server_fd);
        return 1;
    }

    std::cout << "Server listening on port 8080..." << std::endl;

    // 4. 接受连接
    int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&address);
    if (new_socket < 0) {
        perror("accept failed");
        close(server_fd);
        return 1;
    }

    // 5. 打开文件
    int file_fd = open("example.txt", O_RDONLY);
    if (file_fd == -1) {
        perror("Failed to open file");
        close(new_socket);
        close(server_fd);
        return 1;
    }

    // 6. 使用sendfile发送文件
    off_t offset = 0;
    struct stat file_stat;
    fstat(file_fd, &file_stat);
    ssize_t bytes_sent = sendfile(new_socket, file_fd, &offset, file_stat.st_size);
    if (bytes_sent == -1) {
        perror("sendfile failed");
    }

    // 7. 关闭连接和文件
    close(file_fd);
    close(new_socket);
    close(server_fd);

    std::cout << "File sent successfully using sendfile!" << std::endl;

    return 0;
}

看到了吗?我们用sendfile(new_socket, file_fd, &offset, file_stat.st_size)代替了原来的read()send()file_fd是打开的文件描述符,new_socket是客户端socket描述符,offset是指文件读取的起始位置,file_stat.st_size是要发送的文件大小。 这样一来,数据就直接从硬盘拷贝到socket缓冲区了,省了一次从内核到用户空间的拷贝。

sendfile的优点:

  • 减少了一次数据拷贝,提高了性能。
  • 减少了CPU的使用,降低了系统负载。
  • 代码更简洁。

sendfile的缺点:

  • 只能用于文件到socket的传输。
  • 某些老旧的内核版本可能不支持。

四、splice:更灵活的零拷贝

splice系统调用比sendfile更强大,它可以在任意两个文件描述符之间移动数据,而不需要用户空间参与。它通过内核空间的pipe来实现数据的传输。

splice的流程大致如下:

  1. 使用pipe()创建两个文件描述符,一个用于读,一个用于写。
  2. 使用splice()将数据从输入文件描述符移动到pipe的写端。
  3. 使用splice()将数据从pipe的读端移动到输出文件描述符。
#include <iostream>
#include <fstream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
#include <sys/stat.h>

const int BUFFER_SIZE = 4096;

int main() {
    // 1. 创建socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket failed");
        return 1;
    }

    // 2. 绑定地址
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);  // 端口号

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        close(server_fd);
        return 1;
    }

    // 3. 监听连接
    if (listen(server_fd, 3) < 0) {
        perror("listen failed");
        close(server_fd);
        return 1;
    }

    std::cout << "Server listening on port 8080..." << std::endl;

    // 4. 接受连接
    int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&address);
    if (new_socket < 0) {
        perror("accept failed");
        close(server_fd);
        return 1;
    }

    // 5. 打开文件
    int file_fd = open("example.txt", O_RDONLY);
    if (file_fd == -1) {
        perror("Failed to open file");
        close(new_socket);
        close(server_fd);
        return 1;
    }

    // 6. 创建pipe
    int pipe_fd[2];
    if (pipe(pipe_fd) == -1) {
        perror("pipe failed");
        close(file_fd);
        close(new_socket);
        close(server_fd);
        return 1;
    }

    // 7. 获取文件大小
    struct stat file_stat;
    if (fstat(file_fd, &file_stat) == -1) {
        perror("fstat failed");
        close(file_fd);
        close(new_socket);
        close(server_fd);
        close(pipe_fd[0]);
        close(pipe_fd[1]);
        return 1;
    }
    off_t file_size = file_stat.st_size;
    off_t offset = 0;

    // 8. 使用splice发送文件
    while (offset < file_size) {
        ssize_t bytes_to_transfer = std::min((off_t)BUFFER_SIZE, file_size - offset);

        // 从文件到pipe
        ssize_t bytes_splice_in = splice(file_fd, &offset, pipe_fd[1], NULL, bytes_to_transfer, 0);
        if (bytes_splice_in == -1) {
            perror("splice in failed");
            break;
        }

        // 从pipe到socket
        ssize_t bytes_splice_out = splice(pipe_fd[0], NULL, new_socket, NULL, bytes_splice_in, 0);
        if (bytes_splice_out == -1) {
            perror("splice out failed");
            break;
        }

        offset += bytes_splice_out;
    }

    // 9. 关闭文件和连接
    close(file_fd);
    close(new_socket);
    close(server_fd);
    close(pipe_fd[0]);
    close(pipe_fd[1]);

    std::cout << "File sent successfully using splice!" << std::endl;

    return 0;
}

在这个例子中,我们先创建了一个pipe,然后用splice()把数据从文件拷贝到pipe的写端,再用splice()把数据从pipe的读端拷贝到socket。虽然看起来多了几个步骤,但是splice的优势在于它的灵活性,它可以用于各种不同的场景,比如:

  • 将数据从一个socket拷贝到另一个socket(代理服务器)。
  • 将数据从一个管道拷贝到另一个管道。
  • 在用户空间和内核空间之间进行更复杂的数据处理。

splice的优点:

  • 更加灵活,可以用于各种不同的场景。
  • 仍然避免了用户空间和内核空间之间的数据拷贝。

splice的缺点:

  • 代码相对复杂。
  • 需要创建和管理pipe。

五、tee:锦上添花

tee系统调用通常与splice配合使用。它可以将数据从一个文件描述符拷贝到两个文件描述符,而不需要用户空间参与。 想象一下,你要把一份文件同时发送给两个人,用tee就可以很方便地实现。

六、总结:选择合适的零拷贝技术

技术 优点 缺点 适用场景
传统I/O 简单易懂,兼容性好 效率低,拷贝次数多 小型应用,对性能要求不高
sendfile 减少了一次拷贝,效率较高,代码简洁 只能用于文件到socket的传输,兼容性可能存在问题 文件服务器,静态资源服务器
splice 更加灵活,可以用于各种不同的场景,仍然避免拷贝 代码相对复杂,需要创建和管理pipe 代理服务器,数据转发,需要灵活处理数据的场景
tee 可以将数据拷贝到多个文件描述符 通常与splice配合使用 需要同时将数据发送到多个目标的场景

选择哪种零拷贝技术,取决于你的具体需求。如果只是简单地发送文件,sendfile就足够了。如果需要更灵活的数据处理,splice可能更适合你。 如果需要将数据同时发送到多个目标,tee可以助你一臂之力。

零拷贝技术是提高网络应用性能的重要手段。希望通过今天的讲解,大家能够对这些技术有一个更深入的了解,并在实际项目中灵活运用,让你的程序跑得更快、更稳!

记住,没有银弹!选择最适合你的,才是最好的。 祝大家编程愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注