2121 字
约 7 分钟
17
S04-A24 · 零拷贝:`sendfile` 省了两次拷贝,代价是什么

一个"文件下载"接口从"读进内存再写出去"改成 FileChannel.transferTo,CPU 从 40% 降到 8%。三个月后加了一个需求:"下载时先算一下文件的 sha256 校验值"——于是又在应用里读了一遍文件,零拷贝的收益立刻消失一半。

零拷贝(sendfile)的本质是"让数据不经过用户态":内核直接把"文件页"送到"socket 缓冲"。这个前提决定了"它能用在哪些场景":静态文件分发、大附件下载、视频切片——而"要处理数据"的场景一律不适用。
而它有个前提:应用不需要看数据——一旦"要读、要算、要改",就得把数据"拉回用户态"。

1. 传统路径与零拷贝的差别

路径 拷贝次数
传统(read+write) 4 次(磁盘→内核→用户→内核→网卡缓冲)
sendfile 2 次(磁盘→内核页缓存→网卡)

少掉的两次是"用户态进出"——这是 CPU 与内存带宽的双重节省。这个"双重"体现在两个指标上:CPU 时间(少两次拷贝指令)与内存带宽(少两次整块读写)——于是"高带宽文件服务"能"多用几张网卡"。

2. "零拷贝"失效的三种情形

情形 原因
要"计算校验值/加密" 必须读数据
要"改写内容"(如加头) 必须读+写
目标不是 socket(如写另一个文件) 平台/接口限制

第一项是"最常被产品需求打破"的:"下载前校验"看起来是个小需求,而它把"零拷贝"变成"多读一遍"。

3. "既要校验又要零拷贝"的三种做法

做法 代价
先算哈希再传(两遍 IO) 慢(一遍磁盘读)
边传边算(DigestOutputStream) 也"进了用户态"(等于放弃零拷贝)
事前一并算好(入库时存哈希) 最快(需改数据模型)

第三项是"正解":**哈希"在写入时算一次、存下来"**第三项的"代价"是"数据模型改动":要加一列 sha256、并且"写入路径"要算一次——而它带来"下载时零成本校验",是"一次投入、长期受益"。
——于是"下载时只查表",零拷贝保住。

// 三种"边传边算"的写法(都放弃零拷贝,但比"两遍 IO"好)
try (var in = Files.newInputStream(p);
     var digest = new DigestInputStream(in, MessageDigest.getInstance("SHA-256"));
     var out = response.getOutputStream()) {
    in.transferTo(out);                    // 一次遍历,边读边算
}

"循环 transferTo"是"大文件"的必需写法,而它只有四行:

// 大文件的正确写法(每次最多 8MB,直到搬完)
long pos = 0, size = Files.size(path), CHUNK = 8L << 20;
try (var ch = FileChannel.open(path, READ)) {
    while (pos < size) {
        long n = ch.transferTo(pos, Math.min(CHUNK, size - pos), socketChannel);
        if (n <= 0) break;
        pos += n;
    }
}
// 判据:pos == size 才算"完整发送",否则要记日志告警

4. "transferTo"的三个限制

限制 说明
单次上限(约 2GB) 需"循环调用"
只在"文件 → socket"最优 其它组合不保证
平台支持 部分平台退化为"普通拷贝"

第一项的"表现"很微妙:响应"成功返回",而客户端"只收到一半"——因为"部分传输"在 HTTP 层"看起来正常"(Content-Length 若没设,客户端会"以为结束")。
第一项是"真实坑":transferTo 一次最多搬"约 2GB"(受 size_t/实现限制)——于是"大于 2GB 的文件"要"循环调用",否则"只发了一半"。

"页缓存"这件事决定"第一次下载"与"后面几次"的差别:

// 三种"缓存状态"下的路径
首次读(冷):磁盘 → 页缓存 → 网卡(仍 2 次,但磁盘慢)
二次读(热):页缓存 → 网卡(免磁盘)
大文件(> 内存):反复"冷读",收益只剩"少两次 CPU 拷贝"
// 判据:文件大小 vs 可用内存——"能装下"才有"热"的可能

5. "零拷贝"在生产里的三个前提

前提 说明
文件在"页缓存"或"磁盘直读" 冷文件仍要"磁盘→内核"
不做 TLS 时最省 TLS 要"用户态加解密"
不被代理二次拷贝 中间层可能"读一遍"

第二项是"最容易被忽略"的:**HTTPS 下"内核不能替你加密"**第二项的"量化"是"TLS 让零拷贝收益从 ~70% 降到 ~20%":你仍然省了"一次用户态往返",但"加解密"要在用户态做——于是"大文件走 HTTPS"的收益主要来自"省一次拷贝",而不是"完全不进用户态"。
——于是"零拷贝"退化成"内核到用户态、加密、再发"。所以"零拷贝"在"HTTP + 静态大文件"上收益最大。

"CPU 没降"要先"分段量",否则会"改错位置":

// 三处"看一眼"(不必上 profiler)
top -H -p <pid>                 # 线程级:是"下载线程"忙,还是"加密线程"忙
cat /proc/<pid>/stat | awk '{print "us="$14" sy="$15}'   # 用户态/内核态时间
ss -ti | head                   # 连接级:是否有"重传/窗口小"
// 判据:us 高 → 应用在算;sy 高 → 内核在搬;两者都低 → 瓶颈在别处

6. 排错:五种"零拷贝相关问题"

现象 成因 处理方向
CPU 没降 走了 TLS / 中间层 分段测量
文件只传了一半 超过单次上限 循环 transferTo
内存仍高 有"读入内存"的分支 查调用链
大文件慢 冷页缓存 预热/直接 IO
校验改造后变慢 两遍 IO 存哈希

"中间层"这条最容易被漏:反向代理/网关会"读一遍再写",于是"源站零拷贝"在"整条链路"上并不零拷贝:

// 三种链路(同一文件)
客户端 ← 源站(零拷贝:2 次)                          → 收益最大
客户端 ← nginx ← 源站(nginx 也零拷贝:每跳 2 次)      → 仍然好
客户端 ← nginx ← 源站(nginx "读进内存改写":多 2 次)  → 收益被吃掉
// 判据:链路上"有没有哪一跳"必须看数据

7. 边界:什么情况"普通读写"更好

场景 为什么 做法
小文件(< 100KB) 拷贝成本可忽略 普通读写
需要加密/压缩 必须过用户态 普通读写
要"流式处理"(转码) 数据要改 普通读写
目标不是 socket 零拷贝不适用 普通读写

最后一条与"验收"有关的边界:"零拷贝是否生效"要"分段量":"内核态 CPU 占比"(si)与"用户态"(us)分开看——us 下降而 si 上升是"正常";两者都没动,说明"根本没走零拷贝"。
判断口径一句话:"这份数据,应用"要不要看"。不看(纯转发),零拷贝收益最大;要看(校验、加密、转码、加水印),它就是"一次额外的用户态往返",收益归零。

再补一个"反例"经验:"用零拷贝优化"最常见的失败,是"以为自己在零拷贝、其实没有"——中间件(如某些 web 容器)会"先读进 ByteBuffer",于是"你调了 transferTo,而框架又拷贝了一次"。所以"优化前后"必须"量 us 与 sy",而不是"看代码里有没有那个方法调用"。

而"量过再改"这件事,本身就是"性能优化"的通用纪律:先证明"原来没生效",再谈"改完有没有用"。

8. 自测

  1. 传统路径与 sendfile 的拷贝次数差在哪两次?省下的是什么?
  2. "下载前校验"为什么会让零拷贝失效?三种做法里哪一种"保住零拷贝"?
  3. transferTo 的"单次上限"会造成什么现象?怎么解决?
  4. 为什么"HTTPS 下零拷贝收益变小"?它退化成什么?
  5. "小文件"为什么不必零拷贝?
  6. 判断"该不该零拷贝"的那个问题是什么?
S04-A24 · 零拷贝:`sendfile` 省了两次拷贝,代价是什么
https://www.clxhxhhr.top/posts/6670/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。