4. sendfile零拷贝:数据从磁盘到网卡的直达快车

各位同学,今天我们来聊聊sendfile。说实话,这个系统调用是我在量化交易系统里用得最频繁的零拷贝技术之一。为什么?因为行情数据的分发场景,简直就是为sendfile量身定做的。

4.1 sendfile系统调用详解

先看它的原型,很简单:

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

参数不多,我一个个说:

  • out_fd:目标文件描述符,通常是socket
  • in_fd:源文件描述符,必须是支持mmap的文件
  • offset:从文件哪个位置开始读,传NULL就从当前位置
  • count:要传输的字节数

嗯,这里有个坑要注意——in_fd必须是真实的文件,不能是socket或者管道。我刚开始用的时候就在这栽过跟头。

4.2 数据从磁盘到网卡的直接路径

传统方式下,数据要经过:磁盘 → 内核缓冲区 → 用户缓冲区 → 内核socket缓冲区 → 网卡。你想想看,这中间数据被拷贝了四次,CPU还得参与两次上下文切换。

而sendfile的路径是这样的:

磁盘 → 内核缓冲区 → 网卡

只有两次拷贝,而且CPU基本不参与数据搬运。为什么能做到?因为DMA控制器直接接管了磁盘到内核缓冲区的传输,然后内核又把数据直接从缓冲区推到网卡。

下面这张图能帮你直观理解:

sendfile零拷贝数据流 磁盘 历史行情数据 内核缓冲区 Page Cache 网卡 TCP协议栈 DMA拷贝 DMA拷贝 传统方式 vs sendfile 传统:磁盘 → 内核缓冲区 → 用户缓冲区 → 内核Socket缓冲区 → 网卡 4次拷贝 + 2次上下文切换 + CPU参与 sendfile:磁盘 → 内核缓冲区 → 网卡 2次拷贝 + 0次上下文切换 + CPU不参与

4.3 sendfile vs mmap+write

很多同学会问:那mmap+write不也能实现零拷贝吗?没错,但两者有本质区别。

对比维度 sendfile mmap+write
拷贝次数 2次(DMA) 3次(1次CPU + 2次DMA)
上下文切换 1次系统调用 2次系统调用
用户空间参与 完全不需要 需要映射和写入
适用场景 文件→socket 文件→文件、文件→socket
大文件性能 优秀 一般(缺页中断多)

我个人习惯是:只要是从文件往socket发数据,无脑选sendfile。mmap+write更适合那种你需要对数据做点处理的场景,比如加个时间戳或者过滤一下。

4.4 sendfile的局限性

说实话,sendfile也不是万能的。我在项目中遇到过几个坑:

  • 只能从文件到socket:你不能用它做socket到socket的传输
  • 不支持非阻塞socket:如果socket缓冲区满了,sendfile会阻塞
  • 大文件问题:一次传输超过2GB?32位系统上会出问题
  • 无法修改数据:数据从磁盘到网卡,你碰都碰不到
⚠️ 我曾经踩过的坑:有一次在32位系统上分发历史行情,文件大小刚好超过2GB,sendfile返回了EINVAL。查了半天才发现是off_t类型溢出了。后来改用64位系统或者分片传输才解决。

4.5 在TCP协议栈中的优化

Linux内核从2.6.33版本开始,对sendfile做了个重要优化——SG-DMA(Scatter-Gather DMA)。说白了,就是网卡支持直接从内核缓冲区分散读取数据,不需要先合并再发送。

这个优化让sendfile的性能又上了一个台阶。原来需要先把数据从Page Cache拷贝到socket缓冲区,现在网卡直接去Page Cache里拿。嗯,这才是真正的零拷贝。

4.6 实战:用sendfile分发历史行情数据

好了,理论说完了,我们直接上代码。这是一个典型的历史行情分发服务:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/sendfile.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <sys/stat.h>

#define PORT 8080
#define BUFFER_SIZE 4096

int main() {
    int server_fd, client_fd;
    struct sockaddr_in address;
    int opt = 1;
    socklen_t addrlen = sizeof(address);
    
    // 创建socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);
    
    printf("历史行情分发服务启动,端口:%d\n", PORT);
    
    while (1) {
        client_fd = accept(server_fd, (struct sockaddr *)&address, &addrlen);
        
        // 收到客户端请求,打开行情文件
        int file_fd = open("historical_quotes.dat", O_RDONLY);
        if (file_fd < 0) {
            perror("文件打开失败");
            close(client_fd);
            continue;
        }
        
        struct stat file_stat;
        fstat(file_fd, &file_stat);
        off_t offset = 0;
        ssize_t remaining = file_stat.st_size;
        
        // 核心:用sendfile发送文件
        while (remaining > 0) {
            ssize_t sent = sendfile(client_fd, file_fd, &offset, remaining);
            if (sent <= 0) {
                perror("sendfile发送失败");
                break;
            }
            remaining -= sent;
        }
        
        printf("已发送 %ld 字节的历史行情数据\n", file_stat.st_size);
        
        close(file_fd);
        close(client_fd);
    }
    
    return 0;
}
💡 实战小技巧:我在生产环境里,会把历史行情文件提前加载到Page Cache里(用posix_fadvise设置WILLNEED),这样sendfile分发时基本不会触发磁盘I/O,性能直接拉满。

这段代码看起来简单,但背后就是sendfile的精髓——数据从磁盘到网卡,一路绿灯,没有任何不必要的拷贝。你想想看,如果每次客户端请求都要把文件读到用户空间再发出去,那CPU得忙成什么样?

好了,sendfile的内容就这些。记住它的核心:文件到socket的专用通道,零拷贝、高性能、低延迟。在量化交易系统里,历史行情回放、数据分发这些场景,用sendfile就对了。


专注资料整理