上一篇深入了 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)的核心思想:数据不需要经过用户态时,就不要拷贝到用户态。
三个层次的优化目标:
- 消除 CPU 拷贝:让 DMA 直接在内核缓冲区之间搬运数据
- 减少上下文切换:用一个系统调用替代
read()+write()两次调用 - 减少内存占用:不再需要用户态的中间缓冲区
传统路径(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() 有几个重要限制:
- 输入必须是文件:
in_fd必须支持mmap(),通常是普通文件或块设备 - 输出必须是 socket:Linux 2.6.33
之前,
out_fd必须是 socket;之后放宽为任何文件描述符 - 不能修改数据:数据直接从文件到 socket,中间无法加工(如压缩、加密)
- 大文件的 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
不是银弹,它有明确的适用边界:
适用条件:
- 数据量大:单次发送至少 10 KB 以上才有收益(Google 测试数据表明 10-40 KB 是盈亏平衡点)
- TCP 或 UDP:支持 TCP 和 UDP(Linux 5.0+ 支持 UDP)
- 不能发送文件:只适用于用户态生成的数据,文件用
sendfile() - 缓冲区生命周期管理:用户必须在收到完成通知后才能修改/释放缓冲区
性能开销:
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 的隐藏成本:
- TLB 压力:大文件映射占用大量 TLB 条目,可能导致 TLB miss 增加
- 缺页中断:首次访问映射区域时触发缺页,引入不可预测的延迟
- 信号处理:如果文件在映射期间被截断,访问越界区域会收到 SIGBUS
/*
* 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 的零拷贝特别有效:
- 消息不经过 Broker 应用层:Broker 不解析、不修改消息内容,只是从磁盘搬到 socket
- 顺序 I/O:Kafka 的日志是追加写入的,读取时也是顺序的,页缓存命中率极高
- 批量传输:消费者一次拉取大量消息,
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 0Nginx 从 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 和网络协议栈参考文献
- Jens Axboe, “Splice and tee,” Linux Kernel Documentation, 2006.
- Jonathan Corbet, “Zero-copy networking,” LWN.net, 2017.
- Willem de Bruijn, “MSG_ZEROCOPY,” Linux Kernel Patch Series, 2017.
- Jay Kreps, “The Log: What every software engineer should know about real-time data’s unifying abstraction,” LinkedIn Engineering, 2013.
- Nginx Documentation, “Module ngx_http_core_module: sendfile,” nginx.org.
- Linux man-pages, sendfile(2), splice(2), vmsplice(2), tee(2).
- Jens Axboe, “io_uring zero copy send,” Linux Kernel Mailing List, 2022.
上一篇: epoll 深度剖析:ET/LT 模式、源码分析与性能特征 下一篇: DPDK 与用户态网络栈:内核旁路的工程实践
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【网络工程】epoll 深度剖析:ET/LT 模式、源码分析与性能特征
epoll 是 Linux 高性能网络编程的基石。本文深入剖析 epoll 的内核数据结构(红黑树与就绪链表)、ET 和 LT 两种触发模式的行为差异与编程范式、惊群问题及 EPOLLEXCLUSIVE 的解决方案。
【网络工程】代理性能调优:缓冲、Keepalive 与连接复用
系统剖析反向代理层的性能瓶颈与调优方法——Proxy Buffer 内存控制、上下游 Keepalive 参数协调、HTTP/2 连接复用行为、代理层 CPU/内存/连接数监控与容量规划。
【网络工程】TCP 调优实战:内核参数与 socket 选项完全指南
TCP 内核参数和 socket 选项是网络性能的最后一道关卡。本文系统梳理 Linux TCP 参数体系,从缓冲区、Backlog 队列、Keepalive、TIME_WAIT 到拥塞控制,给出不同场景的调优模板和基准测试方法论。
【网络工程】DNS 性能优化:预取、TTL 策略与本地缓存
DNS 解析延迟直接影响用户体验和服务可用性。本文从浏览器 DNS Prefetch、服务端预解析、TTL 策略设计、本地 DNS 缓存部署(systemd-resolved / dnsmasq / CoreDNS)四个维度,系统性地分析 DNS 性能优化的工程实践,包含延迟量化、缓存命中率提升和故障切换加速的完整方案。