第12章 数据搬运的艺术:splice / sendfile / 零拷贝 / io_uring
学习目标
- 看清代理的本质开销:字节在两个 socket 间搬运时,到底发生了几次拷贝、几次系统调用
- 掌握零拷贝技术谱系:
sendfile、splice、MSG_ZEROCOPY、io_uring、eBPFsockmap - 理解
splice为何是 TCP 代理零拷贝的关键,以及 Go 的io.Copy如何自动用它 - 想清楚一个反直觉的事实:L7 代理为什么享受不到零拷贝
前置知识
- 第21章 Go 手写代理(
io.Copy自动 splice 的伏笔) - 第09章 L4 代理、系统手册 · DPDK 与用户态网络栈
原理
代理的本质开销:朴素搬运有多贵
一个 L4 代理的核心就是"把 socket A 的数据搬到 socket B"。最朴素的写法(read 进用户缓冲再 write 出去)代价惊人:
朴素 read()/write() 单个方向:
网卡 ──DMA──▶ 内核 socket 接收缓冲
│ ① CPU 拷贝(copy_to_user)
▼
用户态 buffer ← read() 系统调用 #1
│ ② CPU 拷贝(copy_from_user)
▼
内核 socket 发送缓冲 ← write() 系统调用 #2
│
──DMA──▶ 网卡
代价:2 次 CPU 拷贝 + 2 次系统调用(用户/内核态切换)/ 每个方向
代理是双向的,再 ×2
数据只是路过代理,根本不需要进用户态——这两次 CPU 拷贝和两次上下文切换纯属浪费。零拷贝技术就是来消除它们的。
零拷贝技术谱系
| 技术 | 能做什么 | 适用代理场景 |
|---|---|---|
sendfile(2) | 文件 → socket,省一次拷贝 | 反向代理回源/发静态文件 |
splice(2) | socket ↔ socket(经内核管道),数据不进用户态 | TCP 代理零拷贝的核心 |
tee(2) | 复制管道数据(不消费) | 流量镜像 |
MSG_ZEROCOPY | send() 时不拷贝用户缓冲 | 大块发送 |
io_uring | 异步批量 IO,减少系统调用 | 高并发代理新范式 |
eBPF sockmap/sk_msg | 内核里把 socket A 数据直接 redirect 到 socket B | 同机 sidecar 加速(Cilium) |
| DPDK / 用户态栈 | 完全绕过内核栈,轮询收包 | 极致性能、特种场景 |
splice:TCP 代理零拷贝的关键
splice 在两个文件描述符间移动数据,要求其中一端是管道(pipe)。socket→socket 因此需要一个内核管道做中转,但数据始终在内核空间,不进用户态:
splice 搬运 socket A → socket B:
socket A ──splice──▶ [内核管道 pipe] ──splice──▶ socket B
全程在内核,0 次 CPU 拷贝,0 次 copy_to/from_user
代理只是用系统调用"指挥"内核搬数据,自己碰不到字节
这就是 L4 代理高性能的秘密。Go 的 io.Copy(tcpDst, tcpSrc) 在 Linux 上会自动走 splice(TCPConn.ReadFrom → poll.Splice,Go 1.11+),所以 第21章 那行 io.Copy 不只是简洁,还顺带零拷贝——你白嫖了内核优化。
深一层:splice 为什么是"零拷贝"——页引用级真相
"数据不进用户态"还不够精确,真相在**页(page)**这一层:
- Linux 的管道内部是一圈
struct pipe_buffer,每个指向一个内核内存页struct page,而不是自己存字节。 splice移动的是页的引用(refcount),不是拷贝页里的数据。socket 收到的数据本就在内核sk_buff(skb)的页里:splice(socket→pipe)把 skb 的页"挂"到 pipe_buffer(加引用),splice(pipe→socket)再把页引用挂到对端发送队列——CPU 全程没碰过 payload 字节。
skb 页 ─splice→ pipe_buffer 指向同一页 ─splice→ 发送 skb 指向同一页
移动的是"指针 + refcount",不是 4KB 数据本身
这也解释了 splice 的前提与退化:
- 数据必须已在内核页里(来自 DMA 的 skb / page cache)——所以 socket↔socket、file→socket 适用
- skb 若非线性/分片,可能要先线性化(产生一次拷贝)
- 小包下 per-splice 的系统调用 + 页管理固定开销占比高,未必比
read/write划算(见本章排错表)
sendfile:文件 → socket 的零拷贝
sendfile 专为"把文件内容发到 socket"优化(如 Nginx 发静态文件、反向代理回传文件):
传统:read(file)→user buf→write(socket) 2 次拷贝
sendfile:file ──内核直送──▶ socket 省去用户态往返
注意 sendfile 的源端必须是文件(可 mmap),不能 socket→socket——那是 splice 的活。Nginx 的 sendfile on; 即用它。Kafka 的高吞吐也大量依赖 sendfile(见 Kafka 手册)。
io_uring:用异步批量摊薄系统调用
零拷贝省的是"拷贝",io_uring 省的是"系统调用次数"。传统每次 IO 一个 syscall,高并发下上下文切换成为瓶颈。io_uring 用一对内核/用户共享的环形队列(提交队列 SQ + 完成队列 CQ),批量提交、批量收割,甚至能配合零拷贝:
用户态:往 SQ 塞一批 IO 请求 ──┐
├─ 一次 io_uring_enter 提交 N 个
内核:处理完写入 CQ ──────────┘
用户态:从 CQ 一次收割 N 个结果
新一代高性能代理/网络框架(部分 Rust 生态、实验性 Nginx 模块)正在拥抱 io_uring。注意它对内核版本敏感(5.1+,越新越好),且早期有过安全 CVE,生产需谨慎评估。
eBPF sockmap:同机两 socket 直连
当代理和后端在同一台机器(典型:sidecar 与业务容器),数据本可以不走完整 TCP/IP 栈。eBPF 的 sockmap/sk_msg 能在内核里把 socket A 收到的数据直接 redirect 到 socket B,短路掉本机回环的协议栈处理:
普通:业务容器 socket → 本机 TCP/IP 栈 → sidecar socket
sockmap:业务容器 socket ──eBPF 内核直转──▶ sidecar socket (短路协议栈)
这是 Cilium 给 Service Mesh sidecar 加速的手段之一(第11章、网络手册 · eBPF 深度实践)。
反直觉:L7 代理为什么享受不到零拷贝
零拷贝的前提是数据只是路过、不需要看也不需要改。但 L7 代理的整个价值就在于解析和改写应用层内容(第10章)——它必须把字节读进用户态才能解析 HTTP、改 Header、做路由。
L4 透传:字节路过 → splice 零拷贝 ✅
L7 解析:字节必须进用户态解析/改写 → 无法零拷贝 ❌(这是 L7 慢的根因之一)
所以存在一个性能与能力的根本权衡(呼应 第02章):
- 要零拷贝的极致性能 → L4 透传,但看不懂内容
- 要 L7 的路由/改写能力 → 放弃零拷贝,数据必进用户态
折中:有些代理在"不需要改 body"时,对 body 部分用 splice(只把 header 读进用户态解析),body 零拷贝直通——Envoy/Nginx 在特定路径上有类似优化。
️ 实现 / 命令
实验一:strace 抓出 Go 代理在用 splice
# 跑第21章的 Go TCP 代理,过一笔流量,strace 看系统调用
strace -f -e trace=splice,read,write -p $(pgrep -f http_proxy) 2>&1 | head
# 你会看到大量 splice(...) 调用,而不是 read()/write() 搬运 body
# splice(5, NULL, 7, NULL, 65536, SPLICE_F_MOVE|SPLICE_F_NONBLOCK) = 65536
看到 splice 而非成对的 read/write,就证明零拷贝生效了。
实验二:对比 sendfile 开关对 Nginx 的影响
# 发静态文件,对比 sendfile on/off 的 CPU 与吞吐
location /static/ {
sendfile on; # 文件→socket 零拷贝
tcp_nopush on; # 配合 sendfile,攒满一个包再发
root /data;
}
# 压测对比(sendfile on vs off),看 CPU sys% 差异
sendfile on: 吞吐更高、sys CPU 更低
sendfile off: 多了 read/write 往返,sys CPU 上升
实验三:观察零拷贝退化
# TLS 终止后,数据必须解密进用户态 → splice 失效,退回 read/write
# 验证:对一个做 TLS 终止的 nginx strace,看不到 body 的 splice
strace -f -e trace=splice,read,write -p $(pgrep nginx) 2>&1 | head
# TLS 终止路径上是 read/write(要解密),印证"看内容就没法零拷贝"
排错
| 现象 | 根因 | 解决 |
|---|---|---|
| 期望零拷贝但 CPU 仍高 | 路径上有 TLS 终止/内容改写 | 改写就没法零拷贝;能透传就透传 |
splice 不生效 | 非 socket→socket、或平台不支持 | 确认是 TCP↔TCP 透传场景 |
| 小包场景 splice 反而慢 | 小包下 splice 固定开销占比高 | 小包用普通 read/write 或合并 |
| io_uring 崩溃/不可用 | 内核太老或被安全策略禁用 | 升级内核(5.10+),评估 CVE |
| sendfile 发的文件被改写后不更新 | 页缓存/sendfile 与 mmap 一致性 | 大文件谨慎,必要时关 sendfile |
| sidecar 本机转发延迟高 | 走了完整回环协议栈 | 用 eBPF sockmap 短路(Cilium) |
本章小结
- 朴素
read/write搬运每方向要 2 次 CPU 拷贝 + 2 次系统调用,数据本不必进用户态。 splice让 socket↔socket 在内核零拷贝,是 L4 代理高性能的核心;Go 的io.Copy自动用它。sendfile优化文件→socket;io_uring摊薄系统调用;eBPFsockmap短路同机两 socket。- L7 代理享受不到零拷贝——因为解析/改写内容必须把字节读进用户态,这是"能力 vs 性能"的根本权衡。
层级与转发篇至此完结——你已经从 L4/L7 的能力差异,一路深入到内核搬运字节的最底层。下一篇 第四篇·组件横评 进入实战:把 Nginx、HAProxy、Envoy、Squid、mitmproxy、frp、Clash 等主流代理逐一拆解,每个都给最小可跑配置。