74、零拷贝技术:sendfile()、splice()、mmap()、减少数据拷贝

做网络编程这么多年,我遇到过最头疼的问题之一就是「数据拷贝」。

你想想看,一个简单的文件发送,数据要在内核和用户态之间来回倒腾好几次。每次拷贝都消耗CPU周期,占用内存带宽。说白了,这就是性能杀手。

零拷贝技术,就是来解决这个问题的。它的核心思想很简单:避免不必要的数据拷贝。今天我就把三种主流方案——sendfile()splice()mmap()——给你讲透。

传统I/O的数据拷贝问题

先看一个典型场景:从磁盘读文件,然后通过socket发送出去。

传统做法是:

read(file_fd, buf, len);   // 磁盘 → 内核缓冲区 → 用户缓冲区
write(sock_fd, buf, len);  // 用户缓冲区 → 内核缓冲区 → 网卡

这里发生了多少次拷贝?我数给你看:

  1. DMA从磁盘拷贝到内核缓冲区
  2. CPU从内核缓冲区拷贝到用户缓冲区
  3. CPU从用户缓冲区拷贝到socket内核缓冲区
  4. DMA从socket内核缓冲区拷贝到网卡

一共4次拷贝,其中2次还涉及CPU参与。对于大文件传输,这开销相当可观。

核心问题:用户态缓冲区成了「中间商」,数据根本不需要在那里停留。

mmap():减少一次CPU拷贝

mmap()是我最早接触的零拷贝方案。它把文件映射到进程地址空间,省去了从内核到用户态的拷贝。

// 使用mmap实现文件发送
void send_file_mmap(int sock_fd, const char *filename) {
    int fd = open(filename, O_RDONLY);
    struct stat st;
    fstat(fd, &st);
    
    // 映射文件到用户空间
    char *mapped = mmap(NULL, st.st_size, PROT_READ, 
                        MAP_PRIVATE, fd, 0);
    
    // 直接发送映射区域
    send(sock_fd, mapped, st.st_size, 0);
    
    munmap(mapped, st.st_size);
    close(fd);
}

流程变成了:

  1. DMA从磁盘拷贝到内核缓冲区
  2. CPU从内核缓冲区拷贝到socket缓冲区(注意:没有用户态拷贝了)
  3. DMA从socket缓冲区拷贝到网卡

从4次降到了3次,CPU拷贝从2次降到1次。

我的经验:mmap()适合小文件或频繁访问的场景。但要注意,映射大文件时可能占用大量虚拟地址空间。我曾经在32位系统上吃过这个亏——地址空间不够用了。

避坑指南:mmap()的MAP_SHARED和MAP_PRIVATE区别很大。共享映射修改会写回文件,私有映射则触发写时拷贝。我见过有人用MAP_SHARED做只读操作,虽然没问题,但语义不对。

sendfile():真正的零拷贝

sendfile()是我个人最推荐的方式。它直接在两个文件描述符之间传输数据,完全不需要经过用户态。

// 使用sendfile实现文件发送
ssize_t send_file_sendfile(int out_fd, int in_fd, off_t *offset, size_t count) {
    return sendfile(out_fd, in_fd, offset, count);
}

// 实际使用示例
void handle_client(int client_fd) {
    int file_fd = open("large_file.dat", O_RDONLY);
    off_t offset = 0;
    size_t remaining = file_size;
    
    while (remaining > 0) {
        ssize_t sent = sendfile(client_fd, file_fd, &offset, remaining);
        if (sent <= 0) break;
        remaining -= sent;
    }
    
    close(file_fd);
}

拷贝流程:

  1. DMA从磁盘拷贝到内核缓冲区
  2. DMA从内核缓冲区直接拷贝到网卡(CPU不参与!)

只有2次拷贝,而且都是DMA完成的。CPU完全解放了。

关键点:sendfile()要求in_fd必须是支持mmap的文件,out_fd必须是socket。这是Linux 2.6.33之后的行为。

splice():更灵活的零拷贝

splice()是sendfile的泛化版本。它可以在任意两个文件描述符之间移动数据,不限于文件到socket。

// 使用splice实现管道到socket的传输
int pipe_fd[2];
pipe(pipe_fd);

// 从文件读取到管道
ssize_t n = splice(file_fd, NULL, pipe_fd[1], NULL, 4096, 0);

// 从管道写入socket
n = splice(pipe_fd[0], NULL, sock_fd, NULL, n, 0);

splice的工作原理:

  • 它操作的是「管道缓冲区」,而不是用户缓冲区
  • 数据在内核空间内完成移动
  • 支持任意两个文件描述符组合
特性 sendfile() splice() mmap()
适用场景 文件→socket 任意fd→任意fd 文件→用户空间
CPU拷贝次数 0 0 1
总拷贝次数 2 2 3
需要管道
用户态参与 有映射

我的建议:如果只是文件传输,用sendfile()就够了。splice()更适合复杂的数据流处理场景,比如代理服务器中转发数据。

零拷贝技术对比图

零拷贝技术对比 传统方式:4次拷贝(2次CPU参与) 磁盘 → [内核缓冲区] → [用户缓冲区] → [socket缓冲区] → 网卡 CPU参与:内核↔用户、用户↔socket mmap():3次拷贝(1次CPU参与) 磁盘 → [内核缓冲区] → [socket缓冲区] → 网卡 CPU参与:内核↔socket(用户态通过映射直接访问) sendfile()/splice():2次拷贝(0次CPU参与) 磁盘 → [内核缓冲区] → 网卡 CPU完全解放,全部由DMA完成 性能提升方向 高CPU开销 低CPU开销

实际项目中的选择策略

我在做高性能文件服务器时,总结了一套选择策略:

  1. 文件→socket:无脑用sendfile()。简单、高效、内核优化最成熟。
  2. 需要修改数据:用mmap()。比如你要在发送前加个HTTP头,mmap后直接操作内存。
  3. 复杂数据流:用splice()。比如代理服务器中,从A socket读到B socket。
  4. 小文件(<4KB):传统read/write反而更快。因为零拷贝有上下文切换开销。

我曾经踩过的坑:在Nginx中配置sendfile后,发现小文件传输反而变慢了。后来查资料才知道,sendfile每次调用都有系统调用开销。对于小文件,传统方式用缓冲区合并反而更优。

性能测试数据

我做过一个简单测试,传输100MB文件:

方法 CPU占用 耗时(ms) 拷贝次数
传统read+write 45% 320 4
mmap+send 28% 210 3
sendfile 12% 180 2
splice 14% 185 2

数据很直观:sendfile和splice在CPU占用上优势明显。对于高并发服务器,这省下来的CPU可以处理更多连接。

补充一点:现代网卡支持DMA scatter/gather,sendfile可以做到真正的「零拷贝」——数据从磁盘到网卡完全不经过CPU。但前提是硬件支持。

嗯,零拷贝技术就讲到这里。核心就是一句话:能不拷就不拷,能少拷就少拷。实际开发中,根据场景选对方案,性能提升立竿见影。


公众号:蓝海资料掘金营,微信deep3321