操作系统内功 · 第 46 篇|splice 与管道零拷贝:sendfile 之外的第三条路

sendfile 之外的第三条路为什么存在

第 6 篇讲过 sendfile:一次系统调用把文件内容直送套接字,数据不出内核,省掉了用户态两次拷贝与上下文切换。但 sendfile 的接口形状决定了它的天花板:输入端必须是支持页缓存的文件,输出端基本被限定为套接字,两个套接字之间不能互拷,管道更不能当中转站。更致命的是,sendfile 是一条封闭的高速路——数据沿途不允许任何加工,一旦需要在转发途中做 TLS 加密、压缩或者内容改写,这条路就走不通了,只能退回 read 加 write 的老路。

splice 在内核 2.6.17 年代登场,思路与 sendfile 一脉相承却更开放:数据的本质如果只是一段对齐的页,那拷贝根本不必发生,需要搬运的只是「这一页归谁管」的记账信息。splice 把这个想法落到内核管道上——管道不再只是进程间通信的原语,而被升级成内核里通用的零拷贝中转站:文件、套接字、管道、用户内存四方之间,都可以通过它以页为单位搬运数据引用。理解 splice,先要理解它真正的舞台:管道缓冲区。

这条「第三条路」的定位值得先立正:它不是 sendfile 的替代品,而是把零拷贝从「文件到套接字」这一个固定场景,泛化成任意两个内核可共享页的端点之间的通用能力。泛化的代价是接口更细、使用更繁琐——splice 需要应用自己编排搬运循环,而 sendfile 一个调用就全包了。天下没有既灵活又省事的好事,选哪条路,取决于你的数据要不要中途被看一眼。

回望历史能看清这条路的必然。sendfile 出现之前,文件到网络的路径是 read 加 write 的两段拷贝;sendfile 把「文件到套接字」这一条高频路径固化成一个专用调用;splice 则往前再抽象一步,把「页引用搬运」做成通用机制。三者是同一思想的三个版本,覆盖面依次扩大、专用度依次下降。也正因为通用,splice 的接口必须显式地管理循环、标志与阻塞行为,把 sendfile 隐藏的细节重新交还给应用。抽象的层级决定了手感,这不是设计缺陷,而是通用性的定价。

管道缓冲区:splice 真正的舞台

内核管道对象内部是一个环形缓冲区,由一组管道插槽构成,每个插槽持有一个物理页指针、偏移与长度,默认十六个插槽、每槽一页,总容量约 64KB。普通 write 进管道时,数据被复制进这些页;但 splice 走的是另一条路:它把数据源——比如页缓存里的文件页——的引用直接挂到插槽上,管道里流转的从头到尾只是页引用,内容一个字节都没有动过。这是理解 splice 全部行为的关键:管道变成了一个「页引用的传送带」。

splice(fd_in, fd_out, len, flags) 的语义因此可以读作两段动作:先把输入端的页引用挪进管道,再把管道里的页引用挪给输出端。配套还有两个兄弟调用:tee 把一个管道里的页引用复制到另一个管道,同一份数据有了两个下游,天然适合「转发一路、审计留一路」的旁路需求;vmsplice 则反着来,把用户态内存的页挂进管道,供内核或对端取用。三个调用共用一套标志位:SPLICE_F_MOVE 提示内核尽量移动页所有权而非复制,SPLICE_F_MORE 提示对端后面还有数据可以做合并,SPLICE_F_NONBLOCK 管控阻塞行为。

那么 splice 是不是真的零拷贝?答案是有条件的。当数据驻留在可共享的页上——典型是页缓存里的文件页——splice 只移动引用,CPU 全程不触碰内容,这是真零拷贝。当源头是用户态内存时,vmsplice 若页对齐并配合 SPLICE_F_GIFT 赠与语义,可以引用用户页;不对齐、小块、跨页的杂碎数据,内核会老实复制。还有一个工程上必须记住的事实:管道容量只有 64KB 量级,大文件要循环分段搬运——splice 返回本次搬完的字节数,应用循环调用直到数据读尽,这也是它与 sendfile「一次调用全包」的手感差异。

页引用的移动还有一层生命周期含义:一旦文件页的引用被挂进管道并送往网络,内核通过引用计数保证这页在被网络栈消费完之前不会被回收或复用,应用即使随后修改甚至截断文件,管道里那份数据依然是发出时的内容。反过来这也是一个坑:大量数据长期滞留在管道里不消费,页缓存会被这些被引用的页钉住,内存回收无从下手——零拷贝的代价之一是把内存管理的责任转移给了使用者的纪律。

管道插槽还有一个容易被忽略的细节:每个插槽引用的既可以是完整的页,也可以是页内的一段区间,因此小数据块挂进管道并不浪费整页——但引用「别人的页」时,偏移与长度必须落在该页边界之内,这也是为什么跨页的杂碎数据会退化成复制。理解这一点,写零拷贝转发代码时就会自觉地对齐缓冲,把「能引用」的数据形态最大化,让复制路径退化为异常而不是常态。

与 sendfile 的实测对比与灵活用法

纯静态文件直发场景,sendfile 与 splice 的性能处在同一档:两者都是页引用搬运,差异只在路径细节与调用开销。splice 的真正价值在灵活性上。第一,它能做套接字到套接字的中转:TCP 代理把上游连接的数据经管道搬到下游连接,全程内容不过用户态,代理进程的 CPU 占用显著下降——这正是许多轻量代理与网关在 Linux 上的核心优化点。第二,tee 让「一份数据、两路下游」成为可能:日志旁路、流量镜像、协议审计,都建立在引用复制而非数据复制之上,镜像流的边际成本接近于零。第三,管道作为中转站天然解耦了读写节奏,配合 NONBLOCK 与事件循环,可以做出优雅的背压控制。

但要清楚 splice 救不了什么。TLS 加密、压缩、协议解析都必须触碰数据内容,零拷贝无从谈起——这也是为什么启用 HTTPS 的网关反而要关掉 sendfile 之类的旁路:数据无论如何都要进用户态过加密层。Kafka 的消费路径坚持 sendfile,前提正是消费者直接拿原始字节、消息内容不经服务端加工;一旦服务端需要改写内容,这套优化就整个失效。选型的分界线从来不是「哪个系统调用更快」,而是「数据是否需要被看一眼」。

代码形态上,splice 的典型用法是一个双段循环:先把文件 splice 进管道拿到本段字节数,再把同样字节数从管道 splice 到套接字,循环往复直到文件读完。管道在这里既是搬运通道也是节流阀——下游套接字发送缓冲满时,管道写满,splice 阻塞或返回 EAGAIN,上游文件读取自然停住,背压自动成立。与事件循环结合时,把两端都注册成可写可读事件,配合 NONBLOCK 标志,就能在单线程里同时伺候成百上千条零拷贝转发链路。

工程验收时给两条提醒。其一,splice 的收益要在高吞吐场景才显著,小流量接口用它属于过度设计,代码可读性的代价不划算。其二,不同内核版本对 SPLICE_F_MOVE 等标志的尊重程度有差异,部分路径会静默退化成复制,性能验收要以实测为准:用 perf 或第 34 篇的观测工具对比启用前后的 CPU 占用与拷贝热点,比看文档靠谱。内核主线多年间也在持续修缮这些路径,老结论注意核实时效。

落地故事可以再讲一个:审计旁路。合规要求所有出网流量留档,传统做法是在代理里把每份数据读出来复制一份写进日志系统——代理 CPU 翻倍,延迟抬头。用 tee 重构后的形态是:主转发链路照旧搬运页引用,中途 tee 一份进审计管道,审计进程异步消费、落盘;数据本体依旧一份,主路性能几乎无损。这类「主路保性能、旁路保合规」的结构,正是管道中转站设计的用武之地。同样的思路也服务于流量镜像与灰度复制,本质都是引用的复用而非数据的复制。

写零拷贝代码的错误处理清单也要备齐。splice 可能返回 EAGAIN(管道满且设置了非阻塞)、EINTR(被信号打断,重试即可)、零返回(对端关闭);fd 的阻塞属性要与标志一致,混用最容易出玄学问题。SPLICE_F_MORE 只是给协议栈的合并提示,不保证任何具体行为,别把它当功能开关。上线前的固定动作:用大文件、慢对端、异常断连三种边界各打一轮,确认循环退出条件完备——零拷贝循环写漏一个退出分支,就是 CPU 空转的无限循环。

选型指南:三条路各自的地盘

把文件到网络的几条路径放在一起看。内容无需加工的大文件直发:sendfile 最省事,一个调用解决,静态资源服务与消息系统的消费路径都属此类;需要套接字互转、分流旁路、精细化背压:splice 上场,代理与网关的基础转发层是主场;数据量小、或者需要在转发时修改内容:老实的 read 加 write 或者 mmap 加 write,零拷贝的复杂度不值得。还有一类边缘场景:vmsplice 让用户态程序把装配好的数据包「赠与」内核,高速抓包与发包框架曾用它省掉最后一次复制,但这类用法对内存对齐与生命周期管理要求苛刻,属于专家级优化,一般项目不必触碰。

再补一张负面清单。容器与云环境里要注意:splice 的收益在虚拟化网络路径上可能被 virtio 之类的转发开销稀释,压测要在目标环境做;加密流量一律回到用户态,任何零拷贝都不适用;此外 splice 不支持某些特殊文件类型作为端点,动手前先确认你的输入输出两端都在支持列表里,否则会在运行期收到不支持的错误。把这些约束写进架构评审清单,比事后救火便宜得多。

还有一类中间形态值得一提:部分加工。内容只需改头部几个字节(比如给响应注入一个标记头),可以把页引用搬运到「改动点」之前,之后的小段回归常规读写——混合模式在真实网关里并不少见。判断标准始终如一:数据中被触碰的比例越低,零拷贝段的收益占比越高;全量触碰就全量放弃,部分触碰就部分保留。机械地对整条链路做零与非零的二选一,会漏掉这类渐进优化的空间。

最后给一版实测口径的预期管理。同机千兆内网、大文件静态直发,sendfile 与 splice 的吞吐差距通常在误差范围内,CPU 占用都显著低于常规读写基线;套接字到套接字的转发场景没有 sendfile 可用,splice 相对用户态转发通常有两位数百分比的 CPU 节省;小包高频场景收益最薄,系统调用次数才是主要矛盾,此时合并小写、批量搬运比纠结零拷贝更有价值。数字因内核版本、网卡与负载而异,结论以自己环境的压测为准——这既是零拷贝调优的纪律,也是所有性能工作的纪律。

小结

splice 的核心贡献是把内核管道从 IPC 原语升级成通用零拷贝中转站:页引用在文件、套接字、管道、用户内存之间流转,数据本体不复制。它的零拷贝是有条件的——数据必须驻留在可共享页上;它的灵活性是 sendfile 给不了的——套接字互转、tee 分流、天然背压,让它在代理与网关场景站稳了脚跟。

选型的分界线是数据是否需要被加工:不加工走 sendfile 或 splice,要加工就回归常规读写。管道 64KB 的容量决定了 splice 必然是循环分段的手感,但背压控制反而是意外收获。页引用被钉住的副作用也提醒我们,零拷贝把内存管理的纪律要求转嫁给了使用者。

零拷贝优化永远排在架构之后:先确认内容不被触碰,再谈页引用搬运。这条纪律能帮团队避开大半性能优化的无用功,也让系统在十年后回看时依然站得住。

工具与机制会继续演进,但搬引用而非搬数据的思想不会过时:从用户态网络栈到各种消息传输优化,后续所有零拷贝框架都在这个思想上生长。理解了这一句,任何新的传输层技术都不再神秘,看一眼接口形态就能判断它处在哪一档。这就是底层机制学习的复利:学会一次,受用十年。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?