零拷贝技术详解:从传统 IO 到 mmap、sendfile 与 splice

Jony 发布于 2025-09-06 7 次阅读


AI 摘要

零拷贝技术通过减少数据在用户态与内核态间的复制次数和上下文切换来优化 IO 性能。传统 read + write 路径涉及 4 次上下文切换和 2 次 CPU 拷贝,而 mmap 可省去一次用户态拷贝,适合需解析文件内容的场景;sendfile 将文件直接发送到 socket,最适合静态文件转发,可进一步减少系统调用;splice 通过内核管道搬运页引用,适合流式转发。选型时,需根据是否“要碰数据”决定:需要内容用 mmap,纯原样发送用 sendfile,复杂编排用 splice。零拷贝并非银弹,小数据量或硬件能力有限时收益可能不明显,且调试复杂度更高。
内容纲要

零拷贝(Zero-Copy)是 Linux 性能优化里非常常见的一类技术。它的目标不是“绝对没有任何数据移动”,而是尽量减少数据在用户态和内核态之间的复制次数,降低上下文切换开销,并把更多数据搬运工作交给 DMA 或内核内部的数据引用机制完成。

在嵌入式 Linux、边缘网关、文件服务器、日志采集系统、流媒体转发程序中,这类优化尤其常见。原因很直接:CPU 性能、内存带宽和缓存容量往往都更紧张,减少一次不必要的内存复制,收益就可能是可观的。

本文从工程视角梳理零拷贝的核心原理,并结合 mmapsendfilesplice 给出代码示例和选型建议。


1. 为什么传统 IO 路径开销大

先看一个最常见的场景:把文件内容发送到 socket。

如果应用层采用传统写法:

while ((n = read(file_fd, buf, sizeof(buf))) > 0) {
    write(sock_fd, buf, n);
}

这条路径通常会经历下面几个步骤:

  1. 应用调用 read(),从用户态切换到内核态。
  2. 磁盘控制器通过 DMA 把数据搬到内核页缓存(page cache)。
  3. CPU 再把数据从内核缓冲区复制到用户缓冲区。
  4. read() 返回,回到用户态。
  5. 应用调用 write(),再次进入内核态。
  6. CPU 把数据从用户缓冲区复制到 socket 缓冲区。
  7. 网卡通过 DMA 把数据从 socket 缓冲区发送出去。
  8. write() 返回,回到用户态。

从代价上看,这条路径通常包含:

  • 4 次上下文切换
  • 4 次数据搬运
  • 其中 2 次是 DMA 搬运
  • 其中 2 次是 CPU 参与的内存复制

真正昂贵的部分往往不是系统调用本身,而是那两次 CPU 拷贝:一次从内核到用户,一次从用户到 socket。


2. 零拷贝到底在优化什么

零拷贝主要优化两件事:

  1. 减少用户态和内核态之间的数据复制
  2. 减少系统调用带来的上下文切换

因此,工程上说“零拷贝”,通常表达的是下面这层意思:

  • 数据尽量停留在内核空间中流转
  • 用户进程尽量不要显式参与中间缓冲
  • 能用 DMA 完成的搬运尽量交给 DMA
  • 能通过页映射、页引用、描述符传递完成的,就不要再做一次 CPU memcpy

需要注意的是,零拷贝并不总是意味着“0 次物理搬运”。例如从磁盘读数据到内存,本身就离不开 DMA。很多时候所谓零拷贝,指的是“避免用户态参与的那几次 CPU 拷贝”。


3. mmap:减少一次用户态拷贝

3.1 基本原理

mmap() 可以把文件映射到进程虚拟地址空间。映射建立后,用户态访问这段虚拟地址时,本质上是在访问页缓存对应的内存页。

这样一来,应用不需要先 read() 到用户缓冲区,再处理数据,而是直接“看到”内核页缓存中的内容。

当应用采用 mmap + write 把文件发到 socket 时,路径一般变成:

  1. mmap() 建立文件页缓存到用户虚拟地址的映射关系。
  2. 访问映射区时,若页未命中,触发缺页异常,内核把文件页读入页缓存。
  3. 应用调用 write(sock_fd, mapped_addr, len)
  4. 内核把映射区对应的数据复制到 socket 缓冲区。
  5. 网卡 DMA 把数据发出去。

和传统 read + write 相比,mmap 省掉了“内核缓冲区复制到用户缓冲区”这一步。

通常可以理解为:

  • 上下文切换:仍然较多
  • CPU 拷贝:从 2 次下降到 1 次
  • 优势:减少一次数据复制,适合应用还需要读数据内容的场景

3.2 适合什么场景

mmap 更适合下面这类情况:

  • 应用不仅要转发文件,还要解析内容
  • 文件会被频繁随机访问
  • 希望把文件访问接口简化成内存读写模型

它不适合所有“发文件到网络”的场景。因为如果应用根本不需要碰数据内容,只是想把文件原样发出去,那么 sendfile() 往往更直接。

3.3 mmap 示例:映射文件并发送到 socket

下面示例演示如何把文件映射到内存,再发送到 socket。示例重点是说明调用路径,不包含完整的网络建连逻辑。

#define _GNU_SOURCE
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <string.h>

static int send_with_mmap(int file_fd, int sock_fd)
{
    struct stat st;
    void *map = NULL;
    off_t offset = 0;

    if (fstat(file_fd, &st) < 0) {
        perror("fstat");
        return -1;
    }

    if (st.st_size == 0) {
        return 0;
    }

    map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, file_fd, 0);
    if (map == MAP_FAILED) {
        perror("mmap");
        return -1;
    }

    while (offset < st.st_size) {
        size_t remaining = (size_t)(st.st_size - offset);
        if (remaining > SSIZE_MAX) {
            remaining = SSIZE_MAX;
        }

        ssize_t n = write(sock_fd, (char *)map + offset, remaining);
        if (n < 0) {
            if (errno == EINTR) {
                continue;
            }
            perror("write");
            munmap(map, st.st_size);
            return -1;
        }

        if (n == 0) {
            fprintf(stderr, "write returned 0 unexpectedly\n");
            munmap(map, st.st_size);
            return -1;
        }

        offset += n;
    }

    // 映射结束后释放地址空间。
    if (munmap(map, st.st_size) < 0) {
        perror("munmap");
        return -1;
    }

    return 0;
}

3.4 这段代码要注意什么

  • mmap() 建立的是映射关系,不是立即把整个文件读入内存
  • 真正访问到某个页时,才可能触发缺页并装载数据
  • write() 仍然可能短写,所以必须循环发送
  • 对大文件做 mmap 时,要关注虚拟地址空间和页表开销
  • 如果文件会被别的线程或进程修改,要额外考虑一致性问题

4. sendfile:更适合“文件原样发送”

4.1 基本原理

sendfile() 的设计目标非常明确:直接把一个文件描述符中的数据发送到另一个文件描述符,典型场景就是“磁盘文件 -> socket”。

它最大的优势是:应用层不再需要准备用户缓冲区,也不需要执行 read()write() 这一对系统调用。数据传输尽可能在内核空间内部完成。

典型路径如下:

  1. 应用调用 sendfile(out_fd, in_fd, ...)
  2. 内核把文件数据从磁盘通过 DMA 读入页缓存
  3. 内核将页缓存数据组织到 socket 发送路径
  4. 网卡把数据发送出去

从应用视角看,系统调用由两次变成一次,上下文切换也明显减少。

4.2 sendfile 是否是真正零拷贝

这件事不能一概而论,要看内核实现和硬件能力。

  • 在较理想的路径上,sendfile 可以避免用户态缓冲区参与,接近真正意义上的零拷贝
  • 如果网卡和内核发送路径支持 scatter/gather,数据可以直接引用页缓存中的页,CPU 拷贝会进一步减少
  • 如果底层条件不满足,内核仍可能在某些阶段做额外复制

因此更稳妥的说法是:

sendfile 的核心价值是绕过用户态中转,显著减少上下文切换和 CPU 复制;至于是否达到“严格意义上的 0 次 CPU 拷贝”,要看具体平台。

4.3 sendfile 示例:静态文件发送

下面是一个更接近实际工程的写法:

#define _GNU_SOURCE
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>

static int send_with_sendfile(int file_fd, int sock_fd)
{
    struct stat st;
    off_t offset = 0;

    if (fstat(file_fd, &st) < 0) {
        perror("fstat");
        return -1;
    }

    while (offset < st.st_size) {
        size_t remaining = (size_t)(st.st_size - offset);
        if (remaining > SSIZE_MAX) {
            remaining = SSIZE_MAX;
        }

        // sendfile 的第三个参数 offset 传入的是文件偏移量地址。
        // 内核会从该偏移处开始发送,并在成功后自动更新它。
        ssize_t n = sendfile(sock_fd, file_fd, &offset, remaining);
        if (n < 0) {
            if (errno == EINTR) {
                continue;
            }
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 非阻塞 socket 下应结合 epoll/poll 等待可写后重试。
                continue;
            }
            perror("sendfile");
            return -1;
        }

        if (n == 0) {
            // 返回 0 通常意味着已经到达文件尾。
            break;
        }
    }

    return 0;
}

4.4 什么时候优先选 sendfile

优先考虑 sendfile 的场景通常是:

  • HTTP 静态文件服务器
  • 固件包下载服务
  • 日志文件透传
  • 媒体文件按原样推送

如果应用需要对数据做压缩、加密、协议重组、内容过滤,那么 sendfile 的收益就会下降,因为数据迟早还是要回到用户态参与处理。


5. splice:在内核对象之间搬运“引用”

5.1 基本原理

splice() 常被用于在文件描述符和管道之间移动数据。它的关键点不是把字节复制到用户空间,而是在内核里转移页引用或缓冲区描述信息。

常见用法不是直接“文件到 socket 一步到位”,而是分两段:

  1. file -> pipe
  2. pipe -> socket

这也是很多高性能转发程序会使用的方式。

5.2 splice 示例:文件经由 pipe 转发到 socket

#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>

static int send_with_splice(int file_fd, int sock_fd)
{
    int pipefd[2];

    if (pipe(pipefd) < 0) {
        perror("pipe");
        return -1;
    }

    for (;;) {
        // 第一步:把文件数据“接入”管道。
        // SPLICE_F_MOVE / SPLICE_F_MORE 是性能提示,不保证内核一定按提示执行。
        ssize_t in = splice(file_fd, NULL, pipefd[1], NULL, 64 * 1024,
                            SPLICE_F_MOVE | SPLICE_F_MORE);
        if (in < 0) {
            if (errno == EINTR) {
                continue;
            }
            perror("splice file->pipe");
            close(pipefd[0]);
            close(pipefd[1]);
            return -1;
        }

        if (in == 0) {
            // 文件结束。
            break;
        }

        ssize_t remaining = in;
        while (remaining > 0) {
            size_t chunk = (size_t)remaining;
            if (chunk > SSIZE_MAX) {
                chunk = SSIZE_MAX;
            }

            // 第二步:再把管道中的数据发送到 socket。
            ssize_t out = splice(pipefd[0], NULL, sock_fd, NULL, chunk,
                                 SPLICE_F_MOVE | SPLICE_F_MORE);
            if (out < 0) {
                if (errno == EINTR) {
                    continue;
                }
                perror("splice pipe->socket");
                close(pipefd[0]);
                close(pipefd[1]);
                return -1;
            }

            if (out == 0) {
                fprintf(stderr, "splice returned 0 unexpectedly\n");
                close(pipefd[0]);
                close(pipefd[1]);
                return -1;
            }

            remaining -= out;
        }
    }

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

5.3 splice 的特点

  • 可以避免数据进入用户空间
  • 适合做流式转发和管道化处理
  • sendfile 更灵活,但代码复杂度也更高
  • 是否达到严格意义上的“真正零拷贝”,依然受内核路径和底层设备能力影响

在工程上,splice 的价值往往不是“绝对更快”,而是“在不把数据拉回用户态的前提下,允许你把多个内核对象串起来”。


6. mmapsendfilesplice 应该怎么选

可以按一个很实用的原则来判断:

6.1 应用需要不要“碰数据”

如果需要读、改、解析、过滤数据:

  • 优先考虑 mmap
  • 或者常规 read/write

如果只想原样转发:

  • 优先考虑 sendfile
  • 需要更复杂的内核态数据流编排时考虑 splice

6.2 一个简单的选型表

场景 推荐方式 原因
静态文件下载 sendfile 路径最短,应用层无需中转缓冲
文件内容解析 mmap 方便随机访问和按内存方式处理
管道化转发 splice 能在 pipe 和 socket 间高效流转
小数据包频繁处理 视情况而定 系统调用和页管理开销可能抵消收益
需要压缩/加密/重组 常规 IO 或 mmap 数据终究要进入用户态处理

7. 零拷贝不是银弹

零拷贝能提升性能,但不是无条件成立,也不是所有项目都值得引入。

7.1 小数据场景未必更快

当数据量很小时:

  • mmap 的映射建立和页管理有额外开销
  • sendfile/splice 也存在系统调用成本
  • 普通 read/write 可能更简单,实际吞吐差距并不明显

7.2 硬件和驱动能力会影响效果

如果平台的 DMA、scatter/gather、网卡驱动能力有限,那么你在代码里写了“零拷贝接口”,最终路径也不一定是最理想的。

这在嵌入式平台上尤其常见。很多 SoC 的外设能力、驱动成熟度、内核裁剪情况都直接决定了优化上限。

7.3 可观测性和调试复杂度会上升

普通 read/write 的控制流很直观,日志也容易打。

而到了 mmapsendfilesplice 这类路径:

  • 问题可能出在页缓存
  • 可能出在文件偏移
  • 可能出在 socket 背压
  • 也可能出在驱动的 DMA 路径

因此,性能优化和可维护性必须一起考虑。


8. 嵌入式 Linux 项目中的几个落地建议

如果你在做嵌入式软件,下面这些建议更有实际价值:

  1. 不要先追求“零拷贝”,先确认瓶颈是不是 IO 复制。
  2. 如果业务只是“文件原样下发”,优先尝试 sendfile
  3. 如果业务要解析文件内容,例如固件头、元数据、索引块,优先考虑 mmap
  4. 如果是网关转发、录播搬运、管道串接,评估 splice
  5. 优化前后都要做压测,重点看 CPU 占用、吞吐、时延和系统抖动。
  6. 非阻塞 socket、epoll、页缓存命中率、磁盘类型、网卡队列长度,都会影响最终收益。

9. 一个常见误区

很多文章会把零拷贝简单理解为“完全不拷贝数据”,这个表述并不准确。

更准确的理解应该是:

  • 尽量避免用户态缓冲区成为中转站
  • 尽量减少 CPU 执行 memcpy
  • 尽量把数据路径缩短到“页缓存 -> 网络发送路径”

所以零拷贝的本质,不是“神奇地消灭所有搬运动作”,而是“让不必要的那几次复制消失”。


10. 总结

零拷贝的核心目标,是减少 CPU 拷贝和上下文切换,让数据尽可能在内核空间内部高效流转。

  • mmap 的优势是把文件访问变成内存访问,适合“既要传,又要看”的场景
  • sendfile 的优势是文件可以原样发送,适合静态内容分发
  • splice 的优势是内核态对象之间流转更灵活,适合构造高性能数据通路

工程里最重要的不是背出“几次拷贝、几次切换”,而是根据业务路径判断:

  • 数据是否必须进入用户态
  • 平台是否支持理想的数据通路
  • 引入复杂度后是否真的换来了收益

如果这三个问题已经明确,那么零拷贝技术就不仅是操作系统知识点,而是可以真正落地到系统优化中的工程手段。

生活太苦了
最后更新于 2026-05-06