登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  linux

Linux sendfile 和 splice 怎么选:文件到网络的零拷贝边界与 strace 验收

来源:17golang原创

时间:2026-08-26 03:58:59 467浏览 收藏

静态文件接口突然变慢时,很多排查会先把线程数和缓存调大,却没看数据到底经过了几次用户态拷贝。Linux 的 sendfilesplice 都能减少这类搬运,但它们解决的不是同一个问题:前者适合“文件直接发给 socket”,后者适合“让 pipe 参与数据流转”。

要点速览:
  • 文件到 TCP socket 的单一路径优先看 sendfile,接口更短,代码和验收成本也低。
  • splice 至少要求数据流的一端是 pipe,适合把多个文件描述符串成可组合的内核路径。
  • 两者都可能短写、遇到 EAGAIN 或因文件系统和目标描述符不兼容而回退,不能只看函数名判断成功。
  • strace -e trace=sendfile,splice,read,write 观察实际系统调用,再用累计字节数核对结果。

Linux read write 用户态拷贝与 sendfile 文件到 socket 内核路径对比

先把“零拷贝”放回文件发送现场

假设一个下载接口要把 /srv/files/report.tar 发送到客户端。传统写法通常是应用调用 read 把文件读进用户缓冲区,再调用 writesend 交给 socket。数据至少在内核缓冲区和用户缓冲区之间往返一次,应用还要自己处理缓冲区大小、剩余字节和非阻塞写。

sendfile(out_fd, in_fd, offset, count) 把输出描述符放在前面,输入描述符放在后面。直接文件下载时可以把 socket 作为 out_fd、普通文件作为 in_fd,让内核完成主要的数据搬运。Linux man-pages 也明确提醒:一次成功调用不保证发送完 count,调用方必须准备好继续发送。

sendfile 适合一条直线,splice 适合可组合的管道

可以先用数据路径而不是“哪个更快”来做选择:

场景优先方案判断依据
普通文件直接发到 TCP socketsendfile输入是文件,输出是 socket,路径单一
文件、socket 与多个处理阶段要串接splice需要 pipe 作为内核中的中转站
两端都是普通文件先评估其他复制接口不要为了“零拷贝”强行塞入 pipe
非阻塞 socket任一方案都要循环检查短写、EAGAIN 和取消逻辑

splice 的关键限制是 pipe:它在两个文件描述符之间移动数据,但至少一端必须是 pipe。这个约束看起来麻烦,却换来了组合能力。例如应用可以先把 socket 数据 splice 到 pipe,再把 pipe 内容 splice 到文件;也可以在多个 pipe 之间配合 tee 做分流。若需求只是文件下载,这种额外结构反而会增加关闭顺序和异常路径。

最小实现先处理短写和偏移量

直接文件到 socket 时,sendfile 的循环大致是下面这样。示例刻意保留返回值检查,避免把“一次调用返回正数”误认为完整发送:

off_t offset = 0;
struct stat st;
fstat(file_fd, &st);

while (offset  1024 * 1024 ? 1024 * 1024 : (size_t)left;
    ssize_t n = sendfile(socket_fd, file_fd, &offset, chunk);
    if (n > 0) continue;
    if (n == -1 && (errno == EINTR)) continue;
    if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
    perror("sendfile");
    break;
}

这里的 offset 由系统调用更新,文件描述符自身的当前位置不一定是你想要的业务状态。非阻塞模式下遇到 EAGAIN 不是失败结束,而是等待 socket 可写后继续;事件循环必须保留当前偏移量。

splice 的 pipe 边界决定了代码形状

如果中间确实需要 pipe,先创建一对描述符,再让数据在 pipe 两端移动。下面展示的是文件到 socket 的组合路径:文件先进入 pipe,pipe 再进入 socket。

int p[2];
pipe2(p, O_NONBLOCK);
off_t offset = 0;

ssize_t moved = splice(file_fd, &offset, p[1], NULL, 64 * 1024, 0);
if (moved > 0) {
    ssize_t sent = splice(p[0], NULL, socket_fd, NULL, moved, SPLICE_F_MOVE);
    /* sent 可能小于 moved,剩余数据必须留在 pipe 中继续处理 */
}

示例中的 movedsent 是两段不同的进度。第二次调用短写时,不能简单丢掉 moved - sent 字节;它们仍然在 pipe 里,下一轮应该先继续消费 pipe,再决定是否向文件端补数据。pipe 的读写端也要在生命周期结束时分别关闭,否则对端可能永远等不到 EOF。

Linux splice 通过 pipe 连接文件和 socket 并用 strace 验收短写的工程示意

用 strace 证明实际走了哪条路径

性能改造验收时,先用小文件跑通,再观察系统调用序列。命令中的过滤项只保留本次比较有关的调用:

strace -f -tt -e trace=sendfile,splice,read,write \
  ./download-server 2>trace.log

grep -E 'sendfile|splice|read\(|write\(' trace.log

如果实现确实使用 sendfile,日志里应该能看到它的调用和返回字节数;如果是 pipe 方案,则应看到成对的 splice。不要只因为出现了 sendfile 就认为没有用户态读写:错误回退、协议头发送、日志输出仍可能出现 readwrite。验收要同时对照响应文件大小、累计发送量和系统调用返回值。

哪些情况下不要急着改成零拷贝

  • 文件需要在发送前解压、加密、转码或修改内容时,应用本来就要触碰字节,直接 sendfile 的收益会收窄。
  • 目标描述符不是 socket,或者文件系统对当前路径支持不好时,要准备 EINVALENOSYS 等回退路径。
  • TLS 终止在应用进程内时,明文文件通常还要进入加密库,系统调用减少不等于网络链路完全绕过用户态。
  • 非阻塞事件循环没有保存偏移量、pipe 剩余量和关闭状态时,改造后更容易出现截断文件或连接悬挂。

最稳妥的做法是把普通 read/write 作为可验证的后备实现,先记录基线吞吐、CPU 和 p95 延迟,再逐个替换路径。没有基线,就很难判断优化是否只是改变了调用形态。

常见问题

sendfile 和 splice 哪个一定更快?

没有“一定”。数据路径、文件系统、socket 阻塞方式、TLS 和内核版本都会影响结果。先按描述符关系选,再用同一文件、同一并发和同一网络条件做压测。

splice 为什么总要创建 pipe?

Linux 的 splice 接口要求两个描述符中至少有一个是 pipe。pipe 是它把不同数据源组合成内核数据流的边界,也是代码需要额外维护剩余量和关闭顺序的原因。

sendfile 返回小于文件大小就是失败吗?

不是。成功调用可以只发送一部分,非阻塞 socket 还可能因暂时不可写而停下。应该根据返回值推进偏移量,等待可写事件后继续。

怎么确认零拷贝改造没有偷偷回退?

用 strace 看实际系统调用,再核对累计字节、文件大小和错误分支。若出现 EINVALENOSYS,记录回退原因,不要把普通 read/write 的结果伪装成 sendfile 成功。

把选择写成一条可验收的规则

文件直接发 socket,就先用 sendfile;需要 pipe 连接多个描述符或做内核内数据编排,再考虑 splice。两种接口都必须循环处理短写,并为非阻塞、信号中断和不兼容描述符准备清晰的回退。最后用 strace 观察路径,用响应大小和累计字节验收结果,优化才算真正落地。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>