土法炼钢兴趣小组的算法知识备份

【网络工程】零拷贝网络:sendfile、splice 与 MSG_ZEROCOPY

文章导航

分类入口
network
标签入口
#zero-copy#sendfile#splice#msg-zerocopy#linux-kernel#performance

目录

上一篇深入了 epoll 的内核实现。epoll 解决了”如何高效等待事件”的问题,但事件到来之后的数据搬运同样是性能瓶颈。本文聚焦”零拷贝”——如何减少数据在用户态和内核态之间的无效搬运。

一、传统数据路径:4 次拷贝的代价

一个静态文件服务器把磁盘上的文件发送给客户端,看似简单的 read() + write() 背后隐藏着惊人的开销。

1.1 传统路径的完整分析

/* 传统文件发送:read() + write() */
#include <unistd.h>
#include <sys/socket.h>
#include <fcntl.h>

void send_file_traditional(int client_fd, const char *path) {
    char buf[8192];
    int fd = open(path, O_RDONLY);

    ssize_t n;
    while ((n = read(fd, buf, sizeof(buf))) > 0) {
        /* read(): 磁盘 → 内核页缓存 → 用户缓冲区(2 次拷贝) */
        ssize_t sent = 0;
        while (sent < n) {
            ssize_t w = write(client_fd, buf + sent, n - sent);
            /* write(): 用户缓冲区 → 内核 socket 缓冲区(1 次拷贝) */
            /* DMA: socket 缓冲区 → 网卡(1 次拷贝) */
            if (w < 0) break;
            sent += w;
        }
    }
    close(fd);
}

这段代码每发送一块数据,实际经历了:

步骤 拷贝方向 拷贝方式 上下文切换
1. read() 磁盘 → 内核页缓存 DMA 用户态 → 内核态
2. read() 返回 内核页缓存 → 用户缓冲区 CPU 内核态 → 用户态
3. write() 用户缓冲区 → socket 缓冲区 CPU 用户态 → 内核态
4. write() 返回 + 发送 socket 缓冲区 → 网卡 DMA 内核态 → 用户态

4 次数据拷贝 + 4 次上下文切换。其中步骤 2 和步骤 3 是完全不必要的——数据经过用户态只是”路过”,应用程序没有修改它。

1.2 开销量化

perf 测量传统路径的 CPU 开销:

# 启动一个简单的文件服务器
python3 -c "
import socket, os
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 9000))
s.listen(1)
while True:
    c, _ = s.accept()
    with open('/tmp/testfile', 'rb') as f:
        while True:
            data = f.read(8192)
            if not data: break
            c.sendall(data)
    c.close()
" &

# 创建 1GB 测试文件
dd if=/dev/urandom of=/tmp/testfile bs=1M count=1024 2>/dev/null

# 测量 CPU 开销
perf stat -e context-switches,cpu-migrations,page-faults \
    curl -s -o /dev/null http://localhost:9000/

典型结果:每 GB 数据传输消耗约 30-50ms 的纯 CPU 拷贝时间,上下文切换次数与 read()/write() 调用次数成正比。

1.3 为什么需要零拷贝

零拷贝(Zero-Copy)的核心思想:数据不需要经过用户态时,就不要拷贝到用户态

三个层次的优化目标:

  1. 消除 CPU 拷贝:让 DMA 直接在内核缓冲区之间搬运数据
  2. 减少上下文切换:用一个系统调用替代 read() + write() 两次调用
  3. 减少内存占用:不再需要用户态的中间缓冲区
传统路径(4 次拷贝):
磁盘 ──DMA──→ 页缓存 ──CPU──→ 用户buf ──CPU──→ socket buf ──DMA──→ 网卡

sendfile 路径(2-3 次拷贝):
磁盘 ──DMA──→ 页缓存 ──CPU/gather──→ socket buf ──DMA──→ 网卡

splice 路径(2 次拷贝):
磁盘 ──DMA──→ 页缓存 ──ref──→ pipe buf ──ref──→ socket buf ──DMA──→ 网卡

二、sendfile:最经典的零拷贝

sendfile() 是 Linux 最早引入的零拷贝系统调用(Linux 2.2),专门用于”从文件描述符发送数据到 socket”的场景。

2.1 基本用法

#include <sys/sendfile.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>

/* 使用 sendfile 发送文件 */
ssize_t send_file_zero_copy(int client_fd, const char *path) {
    int fd = open(path, O_RDONLY);
    if (fd < 0) return -1;

    struct stat st;
    fstat(fd, &st);
    off_t offset = 0;
    ssize_t total = 0;

    while (offset < st.st_size) {
        ssize_t sent = sendfile(client_fd, fd, &offset, st.st_size - offset);
        if (sent < 0) {
            if (errno == EAGAIN || errno == EINTR) continue;
            perror("sendfile");
            break;
        }
        total += sent;
        /* offset 被自动更新 */
    }

    close(fd);
    return total;
}

/* 带 TCP_CORK 优化的文件发送 */
ssize_t send_file_with_header(int client_fd, const char *header,
                              size_t hdr_len, const char *path) {
    int fd = open(path, O_RDONLY);
    if (fd < 0) return -1;

    struct stat st;
    fstat(fd, &st);

    /* 启用 TCP_CORK:合并小包 */
    int cork = 1;
    setsockopt(client_fd, IPPROTO_TCP, TCP_CORK, &cork, sizeof(cork));

    /* 先发送 HTTP 头(传统 write) */
    write(client_fd, header, hdr_len);

    /* 再用 sendfile 发送文件体 */
    off_t offset = 0;
    while (offset < st.st_size) {
        ssize_t sent = sendfile(client_fd, fd, &offset, st.st_size - offset);
        if (sent < 0) {
            if (errno == EAGAIN || errno == EINTR) continue;
            break;
        }
    }

    /* 关闭 TCP_CORK:立即发送缓冲区中的数据 */
    cork = 0;
    setsockopt(client_fd, IPPROTO_TCP, TCP_CORK, &cork, sizeof(cork));

    close(fd);
    return st.st_size;
}

2.2 内核实现路径

sendfile() 在内核中的实现分为两种情况:

情况一:网卡不支持 scatter-gather DMA(3 次拷贝)

1. DMA: 磁盘 → 页缓存
2. CPU: 页缓存 → socket 缓冲区
3. DMA: socket 缓冲区 → 网卡

仍然有一次 CPU 拷贝,但比传统路径少了一次 CPU 拷贝和两次上下文切换。

情况二:网卡支持 scatter-gather DMA(2 次拷贝)

1. DMA:          磁盘 → 页缓存
2. gather DMA:   页缓存(仅描述符) → 网卡

内核只把页缓存的地址和长度信息写入 socket 缓冲区,网卡通过 scatter-gather DMA 直接从页缓存读取数据。真正的”零 CPU 拷贝”。

# 检查网卡是否支持 scatter-gather
ethtool -k eth0 | grep scatter
# scatter-gather: on
#     tx-scatter-gather: on
#     tx-scatter-gather-fraglist: off [fixed]

2.3 sendfile 的限制

sendfile() 有几个重要限制:

  1. 输入必须是文件in_fd 必须支持 mmap(),通常是普通文件或块设备
  2. 输出必须是 socket:Linux 2.6.33 之前,out_fd 必须是 socket;之后放宽为任何文件描述符
  3. 不能修改数据:数据直接从文件到 socket,中间无法加工(如压缩、加密)
  4. 大文件的 offset 限制:32 位系统上 off_t 可能溢出,需要用 sendfile64()
/* sendfile 不适用的场景 */

/* 场景 1:需要对数据进行转换(如 gzip 压缩) */
/* 必须用 read() 读到用户态,压缩后 write() */

/* 场景 2:从 socket 到 socket(如代理服务器) */
/* sendfile 的 in_fd 不能是 socket,需要用 splice */

/* 场景 3:需要在文件内容前后添加协议头尾 */
/* 可以用 TCP_CORK + write() + sendfile() + write() 组合 */

2.4 sendfile 性能测量

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

#define FILE_SIZE (256 * 1024 * 1024)  /* 256 MB */
#define BUF_SIZE  (64 * 1024)

static double time_diff_ms(struct timespec *start, struct timespec *end) {
    return (end->tv_sec - start->tv_sec) * 1000.0 +
           (end->tv_nsec - start->tv_nsec) / 1e6;
}

/* 传统方式 */
void bench_traditional(int sock_fd, int file_fd) {
    char *buf = malloc(BUF_SIZE);
    lseek(file_fd, 0, SEEK_SET);
    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);

    ssize_t n;
    size_t total = 0;
    while ((n = read(file_fd, buf, BUF_SIZE)) > 0) {
        ssize_t w = 0;
        while (w < n) {
            ssize_t s = write(sock_fd, buf + w, n - w);
            if (s <= 0) break;
            w += s;
        }
        total += w;
    }

    clock_gettime(CLOCK_MONOTONIC, &t1);
    printf("traditional: %zu bytes, %.2f ms\n", total, time_diff_ms(&t0, &t1));
    free(buf);
}

/* sendfile 方式 */
void bench_sendfile(int sock_fd, int file_fd) {
    struct stat st;
    fstat(file_fd, &st);
    off_t offset = 0;
    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);

    size_t total = 0;
    while (offset < st.st_size) {
        ssize_t sent = sendfile(sock_fd, file_fd, &offset, st.st_size - offset);
        if (sent <= 0) break;
        total += sent;
    }

    clock_gettime(CLOCK_MONOTONIC, &t1);
    printf("sendfile:    %zu bytes, %.2f ms\n", total, time_diff_ms(&t0, &t1));
}

/* 实际基准测试结果(256 MB 文件,本地回环):
 *
 * 方式           耗时(ms)    CPU 占用    上下文切换
 * traditional    185.3      38%        8192
 * sendfile       112.7      12%        2
 *
 * sendfile 快约 39%,CPU 占用降低 68%
 */

三、splice:管道中介的零拷贝

splice() 是 Linux 2.6.17 引入的更通用的零拷贝系统调用。它利用内核管道(pipe)作为中介,在任意两个文件描述符之间移动数据,无需经过用户态。

3.1 splice 的基本语义

#include <fcntl.h>

/* splice() 原型 */
ssize_t splice(int fd_in, loff_t *off_in,
               int fd_out, loff_t *off_out,
               size_t len, unsigned int flags);
/*
 * 要求:fd_in 或 fd_out 中至少有一个是 pipe
 * flags:
 *   SPLICE_F_MOVE      尝试移动页面而非拷贝(内核可能忽略)
 *   SPLICE_F_NONBLOCK  非阻塞操作
 *   SPLICE_F_MORE       后续还有更多数据(类似 TCP_CORK)
 */

splice() 的核心约束:至少一端必须是管道。这不是设计缺陷,而是刻意的架构选择——管道缓冲区(pipe buffer)作为内核中的中间层,持有页面的引用而不拷贝数据。

3.2 文件到 socket 的 splice

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <sys/stat.h>

/* 使用 splice 实现文件到 socket 的零拷贝 */
ssize_t splice_file_to_socket(int sock_fd, int file_fd, size_t file_size) {
    int pipefd[2];
    if (pipe(pipefd) < 0) {
        perror("pipe");
        return -1;
    }

    /* 增大管道缓冲区以提高吞吐 */
    fcntl(pipefd[0], F_SETPIPE_SZ, 1024 * 1024);  /* 1 MB */

    size_t total = 0;
    loff_t offset = 0;

    while (total < file_size) {
        size_t chunk = file_size - total;
        if (chunk > 1024 * 1024) chunk = 1024 * 1024;

        /* 步骤 1:文件 → 管道(零拷贝:只传递页引用) */
        ssize_t n = splice(file_fd, &offset, pipefd[1], NULL, chunk,
                           SPLICE_F_MOVE | SPLICE_F_MORE);
        if (n <= 0) {
            if (errno == EAGAIN) continue;
            break;
        }

        /* 步骤 2:管道 → socket(零拷贝) */
        ssize_t remain = n;
        while (remain > 0) {
            ssize_t s = splice(pipefd[0], NULL, sock_fd, NULL, remain,
                               SPLICE_F_MOVE | SPLICE_F_MORE);
            if (s <= 0) {
                if (errno == EAGAIN) continue;
                goto done;
            }
            remain -= s;
        }
        total += n;
    }

done:
    close(pipefd[0]);
    close(pipefd[1]);
    return total;
}

3.3 socket 到 socket 的代理转发

splice() 相比 sendfile() 的最大优势:两端都可以是 socket,非常适合代理服务器场景。

#include <fcntl.h>
#include <unistd.h>
#include <poll.h>
#include <stdio.h>
#include <errno.h>

/* 使用 splice 实现代理转发(双向) */
struct proxy_ctx {
    int client_fd;
    int upstream_fd;
    int pipe_c2u[2];  /* client → upstream 的管道 */
    int pipe_u2c[2];  /* upstream → client 的管道 */
    size_t pipe_c2u_pending;
    size_t pipe_u2c_pending;
};

int proxy_init(struct proxy_ctx *ctx, int client_fd, int upstream_fd) {
    ctx->client_fd = client_fd;
    ctx->upstream_fd = upstream_fd;
    ctx->pipe_c2u_pending = 0;
    ctx->pipe_u2c_pending = 0;

    if (pipe(ctx->pipe_c2u) < 0 || pipe(ctx->pipe_u2c) < 0)
        return -1;

    /* 增大管道缓冲区 */
    fcntl(ctx->pipe_c2u[0], F_SETPIPE_SZ, 256 * 1024);
    fcntl(ctx->pipe_u2c[0], F_SETPIPE_SZ, 256 * 1024);
    return 0;
}

/* 单方向 splice 转发 */
int splice_forward(int from_fd, int pipe_rd, int pipe_wr,
                   int to_fd, size_t *pending) {
    /* 如果管道中没有待发送数据,先从源 fd 读入管道 */
    if (*pending == 0) {
        ssize_t n = splice(from_fd, NULL, pipe_wr, NULL, 65536,
                           SPLICE_F_NONBLOCK | SPLICE_F_MOVE);
        if (n <= 0) return (int)n;
        *pending = n;
    }

    /* 从管道写入目标 fd */
    ssize_t n = splice(pipe_rd, NULL, to_fd, NULL, *pending,
                       SPLICE_F_NONBLOCK | SPLICE_F_MOVE);
    if (n > 0) *pending -= n;
    return (int)n;
}

void proxy_cleanup(struct proxy_ctx *ctx) {
    close(ctx->pipe_c2u[0]);
    close(ctx->pipe_c2u[1]);
    close(ctx->pipe_u2c[0]);
    close(ctx->pipe_u2c[1]);
}

HAProxy 内部大量使用这种 splice 代理模式。在其配置中可以显式启用:

# haproxy.cfg
global
    # 启用 splice 零拷贝转发
    # Linux 2.6.27+ 自动启用
    tune.linux.splice  1

defaults
    # splice 在 TCP 模式下效果最好
    mode tcp
    option splice-auto    # 自动检测是否可用 splice
    option splice-request # 对请求方向使用 splice
    option splice-response # 对响应方向使用 splice

3.4 vmsplice:用户态内存的零拷贝

vmsplice()splice() 的补充,用于将用户态内存”注入”管道,再由管道 splice() 到 socket。

#include <fcntl.h>
#include <sys/uio.h>

/* vmsplice() 原型 */
ssize_t vmsplice(int fd, const struct iovec *iov,
                 unsigned long nr_segs, unsigned int flags);

/* 使用 vmsplice + splice 发送用户态数据 */
ssize_t send_user_data_zero_copy(int sock_fd, void *data, size_t len) {
    int pipefd[2];
    pipe(pipefd);

    struct iovec iov = {
        .iov_base = data,
        .iov_len = len
    };

    /* 步骤 1:用户内存 → 管道(通过页面引用) */
    ssize_t n = vmsplice(pipefd[1], &iov, 1, SPLICE_F_GIFT);
    /*
     * SPLICE_F_GIFT 表示"送给内核":
     * 内核可以直接使用这些页面而不拷贝。
     * 但调用者不能再修改这些内存!否则可能产生数据竞争。
     * 页面必须是页对齐的(通常通过 mmap 分配)。
     */

    /* 步骤 2:管道 → socket */
    ssize_t sent = splice(pipefd[0], NULL, sock_fd, NULL, n,
                          SPLICE_F_MOVE | SPLICE_F_MORE);

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

/*
 * 注意 SPLICE_F_GIFT 的安全限制:
 * - 数据必须页对齐(使用 mmap/posix_memalign 分配)
 * - 调用后不能修改数据(内核可能在 DMA 期间访问)
 * - 如果不用 SPLICE_F_GIFT,内核会拷贝一份(退化为非零拷贝)
 */

3.5 splice 与 sendfile 的对比

维度 sendfile splice
输入端 仅文件(支持 mmap 的 fd) 任意 fd(必须一端是 pipe)
输出端 socket(2.6.33 后任意 fd) 任意 fd(必须一端是 pipe)
socket→socket 不支持 支持(代理场景核心优势)
管道中介 不需要 需要创建管道对
系统调用次数 1 次 2 次(in→pipe, pipe→out)
典型场景 静态文件服务 代理转发、数据管线
内核优化 成熟(scatter-gather) 依赖管道缓冲区大小
代码复杂度 简单 中等(需管理 pipe 和 pending)

选型建议:文件到 socket 优先用 sendfile(),socket 到 socket 或需要串联多个处理阶段用 splice()

四、MSG_ZEROCOPY:用户态发送的零拷贝

Linux 4.14 引入了 MSG_ZEROCOPY 标志,允许 send() 系统调用直接使用用户态缓冲区的物理页面,避免内核拷贝。这是用户态主动生成数据场景下的零拷贝方案。

4.1 基本用法

#include <sys/socket.h>
#include <linux/errqueue.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>

/* 启用 MSG_ZEROCOPY */
int enable_zerocopy(int sock_fd) {
    int val = 1;
    return setsockopt(sock_fd, SOL_SOCKET, SO_ZEROCOPY, &val, sizeof(val));
}

/* 使用 MSG_ZEROCOPY 发送数据 */
ssize_t send_zerocopy(int sock_fd, const void *data, size_t len) {
    return send(sock_fd, data, len, MSG_ZEROCOPY);
}

/* 读取完成通知:确认哪些数据已经发送完毕 */
int recv_zerocopy_completion(int sock_fd) {
    char cbuf[128];
    struct msghdr msg = {0};
    struct iovec iov = {0};
    char dummy;

    iov.iov_base = &dummy;
    iov.iov_len = 1;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = cbuf;
    msg.msg_controllen = sizeof(cbuf);

    /* 从错误队列读取完成通知 */
    int ret = recvmsg(sock_fd, &msg, MSG_ERRQUEUE);
    if (ret < 0) {
        if (errno == EAGAIN) return 0;  /* 没有完成通知 */
        return -1;
    }

    /* 解析完成通知 */
    struct cmsghdr *cmsg;
    for (cmsg = CMSG_FIRSTHDR(&msg); cmsg;
         cmsg = CMSG_NXTHDR(&msg, cmsg)) {
        if (cmsg->cmsg_level == SOL_IP &&
            cmsg->cmsg_type == IP_RECVERR) {
            struct sock_extended_err *serr =
                (struct sock_extended_err *)CMSG_DATA(cmsg);
            if (serr->ee_errno == 0 &&
                serr->ee_origin == SO_EE_ORIGIN_ZEROCOPY) {
                printf("zerocopy completed: id %u to %u\n",
                       serr->ee_info, serr->ee_data);
                /* 现在可以安全修改/释放用户缓冲区了 */
            }
        }
    }
    return 1;
}

4.2 完整的生产级用法

#include <sys/socket.h>
#include <sys/epoll.h>
#include <linux/errqueue.h>
#include <netinet/in.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

#define CHUNK_SIZE (256 * 1024)  /* 256 KB per send */
#define MAX_INFLIGHT 64

struct zc_sender {
    int sock_fd;
    int epoll_fd;
    uint32_t next_id;
    uint32_t completed_id;
    void *buffers[MAX_INFLIGHT];
};

int zc_sender_init(struct zc_sender *s, int sock_fd) {
    s->sock_fd = sock_fd;
    s->next_id = 0;
    s->completed_id = 0;
    memset(s->buffers, 0, sizeof(s->buffers));

    /* 启用 zerocopy */
    int val = 1;
    if (setsockopt(sock_fd, SOL_SOCKET, SO_ZEROCOPY, &val, sizeof(val)) < 0) {
        perror("SO_ZEROCOPY not supported");
        return -1;
    }

    s->epoll_fd = epoll_create1(0);
    struct epoll_event ev = {
        .events = EPOLLERR,  /* 完成通知通过错误队列 */
        .data.fd = sock_fd
    };
    epoll_ctl(s->epoll_fd, EPOLL_CTL_ADD, sock_fd, &ev);
    return 0;
}

/* 发送数据(非阻塞) */
int zc_send(struct zc_sender *s, void *data, size_t len) {
    /* 检查是否有太多 inflight 数据 */
    while (s->next_id - s->completed_id >= MAX_INFLIGHT) {
        /* 必须等待一些完成通知 */
        recv_zerocopy_completion(s->sock_fd);
        s->completed_id++;
    }

    ssize_t sent = send(s->sock_fd, data, len, MSG_ZEROCOPY | MSG_DONTWAIT);
    if (sent < 0) return -1;

    /* 记录缓冲区,在收到完成通知前不能释放 */
    s->buffers[s->next_id % MAX_INFLIGHT] = data;
    s->next_id++;
    return 0;
}

4.3 MSG_ZEROCOPY 的工程限制

MSG_ZEROCOPY 不是银弹,它有明确的适用边界:

适用条件

  1. 数据量大:单次发送至少 10 KB 以上才有收益(Google 测试数据表明 10-40 KB 是盈亏平衡点)
  2. TCP 或 UDP:支持 TCP 和 UDP(Linux 5.0+ 支持 UDP)
  3. 不能发送文件:只适用于用户态生成的数据,文件用 sendfile()
  4. 缓冲区生命周期管理:用户必须在收到完成通知后才能修改/释放缓冲区

性能开销

MSG_ZEROCOPY 的隐藏开销:
1. 页面锁定(pin pages):内核必须锁定用户态页面防止被交换出去
2. 完成通知:每次发送都会产生一个错误队列消息
3. 页面解锁:发送完成后解锁页面
4. 内存碎片化:长期运行可能导致大量页面被锁定

何时不该用 MSG_ZEROCOPY

/*
 * 不适合 MSG_ZEROCOPY 的场景:
 *
 * 1. 小消息(< 10 KB)
 *    完成通知的开销 > 节省的拷贝开销
 *
 * 2. 需要立即重用缓冲区
 *    必须等完成通知后才能写入新数据
 *
 * 3. 频繁的小写入
 *    每次 send 都产生完成通知,系统调用开销大
 *
 * 4. 内存受限环境
 *    页面锁定减少了可用内存
 */

4.4 MSG_ZEROCOPY 性能数据

Google 在 2017 年分享的测试数据:

消息大小 传统 send MSG_ZEROCOPY 性能提升
1 KB 基准 -2%(更慢) 不适用
4 KB 基准 -1% 不适用
16 KB 基准 +5% 微弱
64 KB 基准 +8% 值得
256 KB 基准 +15% 显著
1 MB 基准 +25% 显著

结论:只有大消息(≥ 64 KB)才能从 MSG_ZEROCOPY 中获得有意义的性能提升

五、mmap + write:共享页缓存的方案

mmap() 提供了另一种减少拷贝的方案:将文件映射到进程地址空间,让用户态和内核共享同一份页缓存。

5.1 基本实现

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

/* 使用 mmap + write 发送文件 */
ssize_t send_file_mmap(int sock_fd, const char *path) {
    int fd = open(path, O_RDONLY);
    struct stat st;
    fstat(fd, &st);

    /* 将文件映射到进程地址空间 */
    void *addr = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (addr == MAP_FAILED) {
        close(fd);
        return -1;
    }

    /* 建议内核按顺序读取 */
    madvise(addr, st.st_size, MADV_SEQUENTIAL);

    /* write() 从映射区域发送
     * 注意:这里仍然有一次 CPU 拷贝(用户映射 → socket 缓冲区)
     * 但比 read() + write() 少一次拷贝(不需要 read 的那次)
     */
    ssize_t total = 0;
    size_t offset = 0;
    while (offset < (size_t)st.st_size) {
        ssize_t n = write(sock_fd, (char *)addr + offset, st.st_size - offset);
        if (n < 0) break;
        offset += n;
        total += n;
    }

    munmap(addr, st.st_size);
    close(fd);
    return total;
}

5.2 mmap 方案的权衡

维度 mmap + write sendfile read + write
CPU 拷贝次数 1 次 0-1 次 2 次
DMA 拷贝次数 2 次 2 次 2 次
上下文切换 2 次 2 次 4 次
用户态可读数据
内存开销 映射占虚拟地址空间 用户缓冲区
TLB 压力 高(大文件)
缺页中断风险
适用场景 需要读+发送 纯发送 通用

mmap 的隐藏成本

/*
 * mmap 的 SIGBUS 防御
 * 当映射的文件被其他进程截断时,访问超出部分会触发 SIGBUS
 */
#include <signal.h>
#include <setjmp.h>

static sigjmp_buf jmpbuf;
static volatile sig_atomic_t got_sigbus = 0;

void sigbus_handler(int sig) {
    got_sigbus = 1;
    siglongjmp(jmpbuf, 1);
}

ssize_t safe_mmap_write(int sock_fd, void *addr, size_t len) {
    struct sigaction sa = { .sa_handler = sigbus_handler };
    struct sigaction old_sa;
    sigaction(SIGBUS, &sa, &old_sa);

    ssize_t result;
    if (sigsetjmp(jmpbuf, 1) == 0) {
        result = write(sock_fd, addr, len);
    } else {
        /* SIGBUS: 文件被截断 */
        result = -1;
    }

    sigaction(SIGBUS, &old_sa, NULL);
    return result;
}

六、io_uring 零拷贝:下一代方案

Linux 5.19 引入了 io_uring 的零拷贝发送支持(IORING_OP_SEND_ZC),结合了 MSG_ZEROCOPY 的零拷贝语义和 io_uring 的异步批量提交能力。

6.1 io_uring 零拷贝发送

#include <liburing.h>
#include <string.h>
#include <stdio.h>

/* 使用 io_uring 零拷贝发送 */
int iouring_send_zc(struct io_uring *ring, int sock_fd,
                    void *data, size_t len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    if (!sqe) return -1;

    /* IORING_OP_SEND_ZC:零拷贝发送 */
    io_uring_prep_send_zc(sqe, sock_fd, data, len, 0, 0);
    sqe->user_data = (uint64_t)(uintptr_t)data;

    /* 提交 */
    io_uring_submit(ring);

    /* 等待完成 */
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(ring, &cqe);

    int result = cqe->res;

    /* 检查是否需要等待通知(CQE_F_NOTIF)*/
    if (cqe->flags & IORING_CQE_F_MORE) {
        /* 还有一个通知 CQE 待接收 */
        io_uring_cqe_seen(ring, cqe);
        io_uring_wait_cqe(ring, &cqe);
        /* 收到通知后才能释放缓冲区 */
    }

    io_uring_cqe_seen(ring, cqe);
    return result;
}

/* 批量零拷贝发送 */
int iouring_send_zc_batch(struct io_uring *ring, int sock_fd,
                          struct iovec *iovs, int nr_iovs) {
    for (int i = 0; i < nr_iovs; i++) {
        struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
        io_uring_prep_send_zc(sqe, sock_fd,
                              iovs[i].iov_base, iovs[i].iov_len, 0, 0);
        sqe->user_data = i;
    }

    /* 一次提交所有请求 */
    io_uring_submit(ring);

    /* 等待所有完成 */
    for (int i = 0; i < nr_iovs; i++) {
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(ring, &cqe);
        if (cqe->res < 0) {
            fprintf(stderr, "send_zc[%d] failed: %d\n", i, cqe->res);
        }
        io_uring_cqe_seen(ring, cqe);
    }
    return 0;
}

6.2 io_uring 的优势

MSG_ZEROCOPY 相比,io_uring 零拷贝的改进:

维度 MSG_ZEROCOPY io_uring SEND_ZC
完成通知 错误队列(recvmsg) CQE 链(更统一)
批量提交 不支持 支持(多个 SQE 一次提交)
异步模型 需要 epoll + errqueue 原生异步
内核态开销 两次系统调用(send + recvmsg) 一次系统调用(enter)
与文件 I/O 统一 是(同一个 ring)

io_uring 零拷贝是目前最先进的用户态数据零拷贝方案,特别适合需要同时处理网络 I/O 和磁盘 I/O 的场景(如数据库、消息队列)。

七、生产系统的零拷贝实践

7.1 Kafka 的零拷贝

Kafka 是零拷贝技术的经典应用案例。它的高吞吐量很大程度上归功于对 sendfile() 的使用。

Kafka 的数据路径:

生产者 → Broker 写入:
  网络 → socket 缓冲区 → 页缓存 → 磁盘(顺序写入)

Broker → 消费者读取:
  磁盘 → 页缓存 → sendfile() → socket → 网络(零拷贝)

Kafka 在 Java 中通过 FileChannel.transferTo() 调用 sendfile()

// Kafka 的核心发送路径(简化)
// org.apache.kafka.common.network.TransportLayer

public class PlaintextTransportLayer implements TransportLayer {
    private final SocketChannel socketChannel;

    @Override
    public long transferFrom(FileChannel fileChannel,
                             long position, long count) throws IOException {
        // 内部调用 sendfile()
        return fileChannel.transferTo(position, count, socketChannel);
    }
}

// org.apache.kafka.common.record.FileRecords
public class FileRecords extends AbstractRecords {
    private final FileChannel channel;

    // 发送记录给消费者
    public long writeTo(TransportLayer destChannel,
                        long offset, int length) throws IOException {
        // 直接从文件传输到 socket,零拷贝
        return destChannel.transferFrom(channel, offset, length);
    }
}

为什么 Kafka 的零拷贝特别有效

  1. 消息不经过 Broker 应用层:Broker 不解析、不修改消息内容,只是从磁盘搬到 socket
  2. 顺序 I/O:Kafka 的日志是追加写入的,读取时也是顺序的,页缓存命中率极高
  3. 批量传输:消费者一次拉取大量消息,sendfile() 一次传输整个文件段
# 测量 Kafka 的零拷贝效果
# 在 Kafka Broker 上观察 CPU 占用
vmstat 1

# 对比启用/禁用零拷贝的吞吐
# server.properties:
# 默认启用零拷贝
# 如果消费者使用 SSL/TLS,零拷贝会自动禁用
# 因为 TLS 加密需要在用户态处理数据

7.2 Nginx 的零拷贝

Nginx 在静态文件服务中使用 sendfile()

# nginx.conf
http {
    # 启用 sendfile
    sendfile on;

    # 配合 tcp_nopush 优化
    # 等效于 TCP_CORK:合并 HTTP 头和文件数据
    tcp_nopush on;

    # 发送完文件后立即关闭 TCP_CORK
    tcp_nodelay on;

    # sendfile 的最大块大小(防止 worker 被单个大文件阻塞)
    sendfile_max_chunk 512k;

    # AIO + sendfile 组合(Linux 上的异步文件读取)
    aio on;
    directio 4m;  # 大于 4MB 的文件用 directio
    # 小文件走 sendfile(利用页缓存)
    # 大文件走 directio + aio(避免污染页缓存)

    server {
        location /static/ {
            root /var/www;
            # sendfile 默认已启用
        }

        location /large-files/ {
            root /var/www;
            # 大文件下载:调大 sendfile_max_chunk 并启用限速
            sendfile_max_chunk 1m;
            limit_rate 5m;  # 限速 5 MB/s
        }
    }
}

Nginx 的 sendfile + tcp_nopush 组合

不用 tcp_nopush:
[HTTP 头(小包)] → 发送
[文件数据]        → 发送(可能与头分成两个 TCP 段)

使用 tcp_nopush:
[HTTP 头 + 文件数据] → 合并为一个或连续的 TCP 段

7.3 网络存储的零拷贝

NVMe-oF(NVMe over Fabrics)使用 RDMA 实现主机间的零拷贝数据传输:

传统 iSCSI 路径:
磁盘 → 页缓存 → iSCSI 协议栈 → TCP/IP → 网卡
(多次拷贝 + 协议开销)

NVMe-oF over RDMA 路径:
NVMe 设备 → RDMA 网卡(DMA)→ 远端 RDMA 网卡 → 远端内存
(一次 DMA,无 CPU 参与)

7.4 数据库的零拷贝

/*
 * PostgreSQL 的 WAL 发送(流复制)
 * pg_basebackup 使用 sendfile 传输数据文件
 *
 * 但 WAL 流复制不使用 sendfile:
 * 因为 WAL 记录需要在用户态解析和过滤
 */

/*
 * RocksDB 的 SST 文件传输
 * 在分布式 KV 存储中,SST 文件的远程复制可以使用 sendfile
 * 因为 SST 文件是不可变的,不需要用户态处理
 */

八、零拷贝的限制与陷阱

8.1 TLS 加密破坏零拷贝

零拷贝最大的敌人是数据加工。一旦数据需要在用户态修改(如 TLS 加密),零拷贝就失效了。

HTTP 明文服务(零拷贝有效):
磁盘 ──DMA──→ 页缓存 ──sendfile──→ socket ──DMA──→ 网卡

HTTPS 服务(零拷贝失效):
磁盘 ──DMA──→ 页缓存 ──read──→ 用户态 ──TLS加密──→ 加密数据
──write──→ socket ──DMA──→ 网卡

kTLS(Kernel TLS)的救赎

Linux 4.13 引入了 kTLS,将 TLS 的数据加密(record layer)放到内核中处理,恢复零拷贝能力:

#include <linux/tls.h>
#include <netinet/tcp.h>

/* 配置 kTLS */
int setup_ktls(int sock_fd) {
    /* 先在用户态完成 TLS 握手(协商密钥) */
    /* 然后将加密密钥传给内核 */

    struct tls12_crypto_info_aes_gcm_128 crypto_info;
    crypto_info.info.version = TLS_1_2_VERSION;
    crypto_info.info.cipher_type = TLS_CIPHER_AES_GCM_128;
    /* 设置 key, iv, salt, rec_seq... */

    /* 启用内核 TLS 发送方向 */
    setsockopt(sock_fd, SOL_TCP, TCP_ULP, "tls", sizeof("tls"));
    setsockopt(sock_fd, SOL_TLS, TLS_TX, &crypto_info, sizeof(crypto_info));

    /* 现在可以用 sendfile() 了——内核负责加密 */
    return 0;
}
# 检查内核是否支持 kTLS
modprobe tls
cat /proc/net/tls_stat
# TlsCurrTxSw                     0
# TlsCurrRxSw                     0
# TlsTxSw                         0
# TlsRxSw                         0
# TlsCurrTxDevice                 0
# TlsCurrRxDevice                 0

Nginx 从 1.21.4 开始支持 kTLS:

# 启用 kTLS 后,HTTPS 也能使用 sendfile
ssl_conf_command Options KTLS;
sendfile on;  # 配合 kTLS 生效

8.2 压缩与零拷贝的冲突

# gzip 压缩与 sendfile 冲突
# Nginx 启用 gzip 时自动禁用 sendfile(针对压缩的响应)
gzip on;
sendfile on;
# 对于需要压缩的内容类型,sendfile 不生效
# 对于不压缩的内容(如图片、视频),sendfile 有效

解决方案:预压缩

# 使用 gzip_static 模块:提供预压缩的文件
gzip_static on;
sendfile on;

# 预压缩脚本
# find /var/www/static -name '*.js' -o -name '*.css' | \
#     xargs -P4 -I{} gzip -k -9 {}
# 生成 .js.gz / .css.gz 文件
# Nginx 检测到 Accept-Encoding: gzip 时直接 sendfile 发送 .gz 文件

8.3 小文件的零拷贝开销

零拷贝技术对小文件可能适得其反:

文件大小 vs 零拷贝收益:

< 4 KB:  sendfile 的系统调用开销 > 节省的拷贝开销
          read/write 可能更快(数据已在 L1/L2 缓存中)

4-16 KB: 盈亏平衡区间,取决于系统负载

> 16 KB:  sendfile 明显优于 read/write

> 1 MB:   sendfile 优势显著,CPU 节省 30-50%

Nginx 的做法是用 sendfile_max_chunk 限制单次传输量,并对大文件和小文件使用不同策略:

# 小文件(< directio 阈值):sendfile + 页缓存
# 大文件(>= directio 阈值):directio + aio,绕过页缓存
directio 4m;
sendfile on;
aio threads;

8.4 容器环境的注意事项

在容器化环境中使用零拷贝需要注意:

# 1. overlay2 文件系统对 sendfile 的影响
# Docker 使用 overlay2 时,sendfile 可能退化为拷贝
# 因为 overlay2 的某些操作路径不支持 splice

# 解决方案:将静态文件放在 bind mount 的卷上
docker run -v /host/static:/var/www/static:ro nginx

# 2. cgroup v2 的 I/O 限制
# 零拷贝操作可能绕过 cgroup 的 I/O 计量
# 导致容器的 I/O 使用量统计不准确

# 3. seccomp 策略
# 某些容器安全策略可能限制 sendfile/splice 系统调用
# Docker 默认 seccomp 配置允许 sendfile 和 splice

九、零拷贝技术选型决策树

需要传输数据
│
├── 数据来源是文件?
│   ├── 是 → 需要修改数据内容?
│   │   ├── 是 → 使用 mmap + 修改 + write
│   │   │       或 read + 处理 + write
│   │   └── 否 → 目标是 socket?
│   │       ├── 是 → 需要 TLS?
│   │       │   ├── 是 → kTLS 可用?
│   │       │   │   ├── 是 → sendfile + kTLS
│   │       │   │   └── 否 → read + TLS加密 + write
│   │       │   └── 否 → sendfile(首选)
│   │       └── 否 → splice(file → pipe → fd)
│   │
│   └── 否(数据在用户态内存)
│       ├── 数据量 > 64 KB?
│       │   ├── 是 → io_uring SEND_ZC(首选)
│       │   │       或 MSG_ZEROCOPY
│       │   └── 否 → 普通 send()
│       └── socket → socket(代理)?
│           └── splice(socket → pipe → socket)

各方案总结对比

技术 内核版本 CPU 拷贝 系统调用 适用场景 限制
read + write 全部 2 次 2 次/chunk 通用 性能最差
mmap + write 全部 1 次 1 次/chunk 需要读+发 TLB 压力、SIGBUS
sendfile 2.2+ 0-1 次 1 次 文件→socket 不能修改数据
splice 2.6.17+ 0 次 2 次 任意 fd 对 需要 pipe 中介
vmsplice 2.6.17+ 0 次 2 次 用户内存→fd 页对齐、GIFT 限制
MSG_ZEROCOPY 4.14+ 0 次 1 次+通知 大消息发送 ≥64KB 才有收益
io_uring ZC 5.19+ 0 次 1 次 大消息发送 需要 liburing
kTLS+sendfile 4.13+ 0 次 1 次 HTTPS 文件服务 内核 TLS 支持

性能基准测试脚本

#!/bin/bash
# bench-zero-copy.sh - 零拷贝性能对比测试

FILE_SIZE=${1:-256M}
TESTFILE="/tmp/zerocopy-bench"
PORT_BASE=9000

echo "=== 零拷贝性能基准测试 ==="
echo "文件大小: $FILE_SIZE"

# 生成测试文件
dd if=/dev/zero of=$TESTFILE bs=1M count=$(echo $FILE_SIZE | sed 's/M//') 2>/dev/null

# 使用 Python 快速对比
python3 << 'PYEOF'
import os, socket, time, mmap

TESTFILE = "/tmp/zerocopy-bench"
ITERATIONS = 5
filesize = os.path.getsize(TESTFILE)

def bench(name, func):
    times = []
    for _ in range(ITERATIONS):
        srv = socket.socket()
        srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        srv.bind(('127.0.0.1', 0))
        srv.listen(1)
        port = srv.getsockname()[1]

        cli = socket.socket()
        cli.connect(('127.0.0.1', port))
        conn, _ = srv.accept()

        # 丢弃接收端数据
        conn.setblocking(False)

        t0 = time.monotonic()
        func(cli, TESTFILE, filesize)
        elapsed = (time.monotonic() - t0) * 1000

        cli.close()
        conn.close()
        srv.close()
        times.append(elapsed)

    avg = sum(times) / len(times)
    print(f"{name:20s}: {avg:8.2f} ms  "
          f"({filesize/1024/1024/avg*1000:.0f} MB/s)")

def traditional(sock, path, size):
    with open(path, 'rb') as f:
        while True:
            data = f.read(65536)
            if not data:
                break
            sock.sendall(data)

def with_sendfile(sock, path, size):
    with open(path, 'rb') as f:
        offset = 0
        while offset < size:
            sent = os.sendfile(sock.fileno(), f.fileno(), offset, size - offset)
            offset += sent

def with_mmap(sock, path, size):
    with open(path, 'rb') as f:
        mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
        sock.sendall(mm)
        mm.close()

bench("read+write", traditional)
bench("sendfile", with_sendfile)
bench("mmap+write", with_mmap)
PYEOF

rm -f $TESTFILE

十、工程实战:构建零拷贝文件服务器

10.1 完整的零拷贝文件服务器

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

#define MAX_EVENTS 1024
#define MAX_PATH 4096

struct client_ctx {
    int fd;
    int file_fd;
    off_t offset;
    off_t file_size;
    enum { STATE_READ_REQ, STATE_SEND_HEADER, STATE_SEND_FILE } state;
    char req_buf[4096];
    size_t req_len;
};

/* 解析请求路径(极简 HTTP 解析) */
int parse_request(const char *req, char *path, size_t path_len) {
    if (strncmp(req, "GET ", 4) != 0) return -1;
    const char *p = req + 4;
    const char *end = strchr(p, ' ');
    if (!end) return -1;
    size_t len = end - p;
    if (len >= path_len) return -1;
    memcpy(path, p, len);
    path[len] = '\0';
    return 0;
}

/* 发送 HTTP 响应头 */
int send_response_header(int fd, off_t file_size, const char *mime) {
    char header[512];
    int len = snprintf(header, sizeof(header),
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %ld\r\n"
        "Connection: close\r\n"
        "\r\n", mime, (long)file_size);

    /* 启用 TCP_CORK,和后续 sendfile 合并发送 */
    int cork = 1;
    setsockopt(fd, IPPROTO_TCP, TCP_CORK, &cork, sizeof(cork));

    return write(fd, header, len);
}

/* 发送文件(非阻塞 sendfile) */
int do_sendfile(struct client_ctx *ctx) {
    while (ctx->offset < ctx->file_size) {
        ssize_t sent = sendfile(ctx->fd, ctx->file_fd,
                                &ctx->offset,
                                ctx->file_size - ctx->offset);
        if (sent < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                return 0;  /* 等待下次 EPOLLOUT */
            }
            return -1;
        }
        if (sent == 0) break;
    }

    /* 发送完毕,关闭 TCP_CORK */
    int cork = 0;
    setsockopt(ctx->fd, IPPROTO_TCP, TCP_CORK, &cork, sizeof(cork));
    return 1;  /* 完成 */
}

int main(int argc, char *argv[]) {
    const char *root = argc > 1 ? argv[1] : "/var/www";
    int port = argc > 2 ? atoi(argv[2]) : 8080;

    int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(port),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, 1024);

    int epoll_fd = epoll_create1(0);
    struct epoll_event ev = { .events = EPOLLIN, .data.ptr = NULL };
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

    struct epoll_event events[MAX_EVENTS];
    printf("Zero-copy file server on port %d, root=%s\n", port, root);

    while (1) {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            if (events[i].data.ptr == NULL) {
                /* 新连接 */
                int client_fd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK);
                if (client_fd < 0) continue;

                struct client_ctx *ctx = calloc(1, sizeof(*ctx));
                ctx->fd = client_fd;
                ctx->state = STATE_READ_REQ;

                ev.events = EPOLLIN | EPOLLET;
                ev.data.ptr = ctx;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
            } else {
                struct client_ctx *ctx = events[i].data.ptr;

                if (ctx->state == STATE_READ_REQ) {
                    ssize_t n = read(ctx->fd, ctx->req_buf + ctx->req_len,
                                     sizeof(ctx->req_buf) - ctx->req_len - 1);
                    if (n <= 0) goto cleanup;
                    ctx->req_len += n;
                    ctx->req_buf[ctx->req_len] = '\0';

                    if (!strstr(ctx->req_buf, "\r\n\r\n")) continue;

                    char path[MAX_PATH];
                    if (parse_request(ctx->req_buf, path, sizeof(path)) < 0)
                        goto cleanup;

                    char fullpath[MAX_PATH];
                    snprintf(fullpath, sizeof(fullpath), "%s%s", root, path);

                    ctx->file_fd = open(fullpath, O_RDONLY);
                    if (ctx->file_fd < 0) goto cleanup;

                    struct stat st;
                    fstat(ctx->file_fd, &st);
                    ctx->file_size = st.st_size;
                    ctx->offset = 0;

                    send_response_header(ctx->fd, st.st_size,
                                         "application/octet-stream");
                    ctx->state = STATE_SEND_FILE;

                    ev.events = EPOLLOUT | EPOLLET;
                    ev.data.ptr = ctx;
                    epoll_ctl(epoll_fd, EPOLL_CTL_MOD, ctx->fd, &ev);
                }
                else if (ctx->state == STATE_SEND_FILE) {
                    int ret = do_sendfile(ctx);
                    if (ret != 0) goto cleanup;
                }
                continue;

            cleanup:
                epoll_ctl(epoll_fd, EPOLL_CTL_DEL, ctx->fd, NULL);
                close(ctx->fd);
                if (ctx->file_fd > 0) close(ctx->file_fd);
                free(ctx);
            }
        }
    }
    return 0;
}

10.2 编译与测试

# 编译
gcc -O2 -o zerocopy-server zero-copy-server.c

# 创建测试内容
mkdir -p /tmp/www
dd if=/dev/urandom of=/tmp/www/100mb.bin bs=1M count=100

# 启动服务器
./zerocopy-server /tmp/www 8080 &

# 使用 wrk 进行基准测试
wrk -t4 -c100 -d30s http://localhost:8080/100mb.bin

# 使用 perf 分析 CPU 热点
perf top -p $(pgrep zerocopy-server)
# 预期:sendfile 相关的函数占比很低
# 主要 CPU 消耗在 epoll_wait 和网络协议栈

参考文献

  1. Jens Axboe, “Splice and tee,” Linux Kernel Documentation, 2006.
  2. Jonathan Corbet, “Zero-copy networking,” LWN.net, 2017.
  3. Willem de Bruijn, “MSG_ZEROCOPY,” Linux Kernel Patch Series, 2017.
  4. Jay Kreps, “The Log: What every software engineer should know about real-time data’s unifying abstraction,” LinkedIn Engineering, 2013.
  5. Nginx Documentation, “Module ngx_http_core_module: sendfile,” nginx.org.
  6. Linux man-pages, sendfile(2), splice(2), vmsplice(2), tee(2).
  7. Jens Axboe, “io_uring zero copy send,” Linux Kernel Mailing List, 2022.

上一篇: epoll 深度剖析:ET/LT 模式、源码分析与性能特征 下一篇: DPDK 与用户态网络栈:内核旁路的工程实践

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-07-29 · network

【网络工程】DNS 性能优化:预取、TTL 策略与本地缓存

DNS 解析延迟直接影响用户体验和服务可用性。本文从浏览器 DNS Prefetch、服务端预解析、TTL 策略设计、本地 DNS 缓存部署(systemd-resolved / dnsmasq / CoreDNS)四个维度,系统性地分析 DNS 性能优化的工程实践,包含延迟量化、缓存命中率提升和故障切换加速的完整方案。


By .