从readv到splice:Linux零拷贝IO演进与选型实践

发布时间:2026/10/26 22:10:54
从readv到splice:Linux零拷贝IO演进与选型实践

前阵子帮人看一个文件下发服务的性能问题,机器 CPU 吃满、网卡却只跑了一半带宽。抓了下火焰图,热点不在业务逻辑上,全耗在read和write这对老朋友身上——数据从磁盘进内核、内核拷到用户缓冲区、用户缓冲区再拷回内核、最后进网卡,一来一回四次搬运,业务代码只是那个搬砖的人。把这条链路换成sendfile之后,同样的机器、同样的配置,CPU 占用掉了一大截。这件事让我又把readv、writev、sendfile、mmap、splice、tee这组高级 IO 函数翻出来重新捋了一遍,它们平时散落在 man page 里看着零碎,其实是一条完整的演进线:从"少调几次系统调用",到"少拷几次内存",再到"根本别让数据进用户态"。

这篇就按这条线讲。如果你写过网络服务、做过文件网关、调过日志采集,下面这些函数迟早会撞上;如果你刚把read/write用熟,正好可以看看它们在什么场景下会拖后腿。我会给出可直接编译运行的代码片段、参数怎么算、以及几个我真实踩过的坑,比如writev的短写、sendfile为什么不能拿管道当输入、mmap遇到文件被截断时那个莫名其妙的SIGBUS。

1. 先把"多拷一次"这件事算清楚

1.1 一次 read + write 到底浪费了什么

很多人对"零拷贝"的理解停留在名词上,觉得是个营销词。真要把账算明白,得从一次最普通的文件发送说起。假设要从磁盘读一个文件发到 socket,传统写法是这样:

char buf[65536]; ssize_t n; while ((n = read(file_fd, buf, sizeof(buf))) > 0) { ssize_t off = 0; while (off < n) { ssize_t w = write(sock_fd, buf + off, n - off); if (w <= 0) { /* 错误处理 */ } off += w; } }

这段代码在内核里发生的事,用开发者能感知的语言描述是四步:第一步,DMA 把磁盘数据搬进内核页缓存;第二步,CPU 把页缓存里的数据拷到你的buf;第三步,write又把buf拷进 socket 的内核发送缓冲区;第四步,DMA 把发送缓冲区的数据推上网卡。两次 CPU 参与的拷贝、两次系统调用、两次用户态内核态切换,外加一次 64KB 栈/堆缓冲区的内存占用。带宽上去了以后,第二步和第三步就是纯纯的浪费——数据内容一个字都没改,凭什么要在用户态走一趟。

这里还有个隐形成本容易忽略:buf的大小直接决定了系统调用次数。用 64KB 缓冲发 1GB 文件,就是 16384 次read加至少 16384 次write,每次系统调用按 1 微秒算(还是乐观估计),光切换开销就三十多毫秒,实际在有锁竞争、有调度的情况下只会更糟。所以后面所有高级 IO 函数的动机都可以归成两句话:减少系统调用次数,减少 CPU 参与的内存拷贝次数。readv/writev解决前者,sendfile/mmap/splice解决后者,tee解决"我想顺手复制一份但不想多拷"的问题。

1.2 六个函数的定位与选择地图

先把坐标系建起来,后面每节都是在填这张表的细节。这六个函数不是互相替代关系,它们各自守着一个场景:

函数解决的核心问题关键约束典型场景
readv/writev一次系统调用读写多块不连续缓冲区iovcnt不超过IOV_MAX(通常 1024)协议头 + 负载一次写出、分散的内存块读入
sendfile文件到 socket 在内核内完成搬运输入 fd 必须支持mmap,不能是管道/socket静态文件服务、下载服务
mmap把文件当内存访问,省掉 read 拷贝偏移必须页对齐,长度按页向上取整随机读大文件、索引文件、共享内存
splice借助管道在两个 fd 间零拷贝搬运两端必须有一端是管道代理转发、日志落盘
tee复制管道数据但不消耗源两端都必须是管道旁路采样、流量镜像、调试抓包

我的经验判断顺序是这样的:如果数据源是普通文件、目标是 socket,优先sendfile;如果是两个 fd 之间搬,且其中一端能凑出管道,用splice;如果要在用户态处理数据但不想整块读进来,用mmap;如果只是嫌系统调用多、数据本来就要在用户态处理,readv/writev就够了。绝大多数业务代码能用writev就解决 80% 的问题,剩下 20% 才轮到零拷贝,别一上来就splice,调试成本不是一个量级。

2. readv 与 writev:把多次系统调用压成一次

2.1 数据结构长什么样,内核怎么理解它

readv和writev的接口很朴素,摊开看就三行:

#include <sys/uio.h> ssize_t readv(int fd, const struct iovec *iov, int iovcnt); ssize_t writev(int fd, const struct iovec *iov, int iovcnt); struct iovec { void *iov_base; /* 缓冲区起始地址 */ size_t iov_len; /* 这块有多长 */ };

核心是那个struct iovec数组,它本质就是一张"内存块清单":告诉内核"我要读的东西请按顺序摊在这几块内存上",或者"我要写的东西是这几块内存拼起来的,你按顺序发"。内核拿到数组之后不需要你在用户态先把数据拼成一整块,它自己按顺序遍历拷贝,你在用户态省掉一次memcpy,同时省掉 N-1 次系统调用。

为什么这个省法很值?想一个很具体的场景:你要发一个 JSON 消息,前面有 4 字节长度头、8 字节消息 ID、后面才是 2KB 的 body。用write你得先把它们memcpy进一个连续缓冲区再一次性发;用writev你直接把三块内存的地址填进去,一次调用发完,中间那次memcpy没了。数据量越大、块数越多,收益越明显。

这里有个非常重要的语义细节:writev的"原子性"只在内核层面成立,不代表网络对端能一次收到全部数据。内核对writev的处理是遍历iov依次拷贝,这个动作在一次系统调用内完成,不会被别的进程的写操作插进来(对于普通文件的追加写尤其重要);但它返回的字节数可能小于你请求的总长度。TCP 发送缓冲区满了、磁盘写满了、被信号打断了,都会造成短写。

2.2 实战:把协议头和负载合成一次写出

下面这个片段是我在几个项目里都复用过的最小骨架,可以直接抄:

#define _GNU_SOURCE #include <sys/uio.h> #include <unistd.h> #include <errno.h> #include <string.h> /* 把 iov 数组整体写出去,处理短写与 EINTR */ static int writev_all(int fd, const struct iovec *iov, int iovcnt) { int idx = 0; /* 当前正在写的第几块 */ size_t offset = 0; /* 当前块内已写偏移 */ while (idx < iovcnt) { struct iovec cur[IOV_MAX]; int n = iovcnt - idx; if (n > IOV_MAX) n = IOV_MAX; for (int i = 0; i < n; i++) { cur[i].iov_base = (char *)iov[idx + i].iov_base + (i == 0 ? offset : 0); cur[i].iov_len = iov[idx + i].iov_len - (i == 0 ? offset : 0); } ssize_t w = writev(fd, cur, n); if (w < 0) { if (errno == EINTR) continue; /* 被信号打断,重来 */ if (errno == EAGAIN || errno == EWOULDBLOCK) { /* 非阻塞 fd:交给外层 poll/epoll 等待可写 */ return -2; } return -1; } /* 按返回字节数推进游标 */ ssize_t remain = w; while (remain > 0 && idx < iovcnt) { size_t left = iov[idx].iov_len - offset; if ((size_t)remain >= left) { remain -= left; idx++; offset = 0; } else { offset += remain; remain = 0; } } } return 0; }

这段代码里有三个坑,都是我真的踩过的,值得单独说:

第一,短写必须按字节推进游标,不能只判断"写没写完"。很多人图省事写成if (w != total) 重写全部,在 socket 上就是无限重发已经发出去的数据,对端会收到重复内容,协议直接错乱。正确做法是拿返回值去逐块扣减。

第二,IOV_MAX是有限制的。Linux 上sysconf(_SC_IOV_MAX)一般返回 1024,内核里的UIO_MAXIOV也是 1024,超过这个数的iovcnt直接EINVAL。这不是理论值——我在一个批量下发多条消息的服务里就撞过,一次攒了 3000 多个iovec想一把梭,结果全失败。处理办法就是上面代码里的分批:一次最多喂 1024 个,循环推进。

第三,iov_len之和不能超过SSIZE_MAX,Linux 上单次读写还有个MAX_RW_COUNT(大约 0x7ffff000,不到 2GB)的上限。单次请求超过这个量会被内核截断,writev只写一部分就返回了,你以为发出去了其实没有。所以别指望一次writev干掉几个 G,按 MB 级别分批是稳妥做法。

2.3readv什么时候真的有用

writev的用处一眼就能看出来,readv相对容易被忽略。它的真实价值在"你提前知道数据会被拆成几块"的场景。最典型的是自定义协议的头部解析:你知道前 16 字节是定长头,后面是变长体,那么就可以一次readv把头读到栈上的结构体、把体读到堆上的大缓冲,一次系统调用拿到两段数据,而不需要先read头部、算出长度、再read一次体。

struct msg_hdr { uint32_t magic; uint32_t len; uint64_t seq; }; /* 这里假设 body 的容量已经按业务上限预分配好 */ ssize_t n = readv(fd, (struct iovec[]) { { .iov_base = &hdr, .iov_len = sizeof(hdr) }, { .iov_base = body, .iov_len = body_cap }, }, 2);

不过要注意,readv的返回值是实际读到的总字节数,你没法从返回值直接知道"头读满了没有、体读了多少"。要么自己按sizeof(hdr)做判断,要么先确认n >= sizeof(hdr)再算 body 长度。而且流式 socket 上读到的数据可能不足一个完整的头,这时候老老实实退回循环读,别硬套readv。我的习惯是:只有在"要读的块数和大小都确定"的时候才用readv,长度可变、边界不确定的协议解析,用带状态机的普通read反而更清楚。

注意:readv和writev对管道是原子的,前提是单次操作的数据量不超过PIPE_BUF(Linux 上是 4096 字节)。超过这个量就不再保证原子性,多进程写同一个管道时可能出现交错。

3. sendfile:让数据老老实实待在内核里

3.1 内核路径发生了什么变化

前面算过,传统read + write有两次 CPU 拷贝。sendfile把这两次砍掉,路径变成:DMA 把磁盘数据搬进页缓存,数据仍然留在内核里,直接由内核把它交给 socket 的发送队列,DMA 发出去。用户态全程没参与,缓冲区也不用你准备。

#include <sys/sendfile.h> ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

参数顺序容易记反:先出后进。out_fd是目标(通常是 socket),in_fd是源(通常是文件),offset是个指针,传NULL表示从文件当前偏移开始读并自动推进,传非NULL则用你给的值并且函数返回后会把新偏移写回去,count是本次最多搬多少字节。

这个offset指针的设计很实用:多个线程共享同一个fd读文件时,NULL会让它们互相抢文件偏移,而传各自的局部变量就不会互相干扰。我在一个多线程下载服务里就是靠这个规避了lseek加锁的麻烦。

3.2 一个能跑的静态文件下发骨架

#include <sys/sendfile.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> #define CHUNK (1 << 20) /* 单次最多搬 1MB */ int send_file_to_socket(int sock_fd, const char *path) { int fd = open(path, O_RDONLY); if (fd < 0) return -1; struct stat st; if (fstat(fd, &st) < 0) { close(fd); return -1; } off_t offset = 0; off_t remain = st.st_size; while (remain > 0) { size_t count = remain > CHUNK ? CHUNK : (size_t)remain; ssize_t n = sendfile(sock_fd, fd, &offset, count); if (n < 0) { if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) { /* 非阻塞 socket:等可写事件再来 */ return -2; } close(fd); return -1; /* 注意:这里可能已经发出去一部分了 */ } if (n == 0) break; /* 文件被截断或读到结尾 */ remain -= n; } close(fd); return 0; }

为什么 chunk 选 1MB 而不是越大越好?我实测过 64KB、256KB、1MB、8MB 几档:64KB 时系统调用次数多,CPU 明显高;1MB 到 8MB 之间吞吐差异很小,但大 chunk 会拉长单次调用占用 socket 发送缓冲的时间,高并发下首字节延迟(TTFB)变差,小文件混杂的流量里尤其明显。最后我基本固定用 256KB 到 1MB 这一档,兼顾吞吐和延迟。这个值没有标准答案,跟你网卡、内核版本、并发数都有关系,建议自己压一遍。

另一个必须处理的是错误状态。sendfile返回 -1 的时候,可能已经发出去了若干字节,这时候如果直接给客户端返回一个 500,就会出现"响应头说 200、body 只发了一半"的诡异现象。生产代码里要么记录已发偏移做续传,要么在发头之前先把文件打开、fstat成功,把出错概率压到最低。

3.3 边界在哪里:哪些 fd 用不了

sendfile的限制比想象中多,我在两个地方栽过:

第一,in_fd必须支持mmap。普通文件可以,管道不行,socket 不行。有人想用它做"从 socket 转发到 socket",一次都不行。错误码是EINVAL,报错信息还特别含糊,容易误判成参数类型写错。

第二,老内核里out_fd必须是 socket。2.6.33 之后放开了,普通文件也行,但如果你在维护老环境,这个限制会咬人。而这个场景其实有更合适的替代品copy_file_range,两个普通文件之间拷贝它能做得更干净。

第三,sendfile不处理 TLS。这是最容易被忽视的一点:链路加密之后,数据必须经过用户态的加密库处理,sendfile直接把明文推到 socket 上就废了。所以带 TLS 的服务里,零拷贝基本用不上,这也是很多 HTTPS 服务 CPU 高的根因之一,属于架构层面的取舍,不是代码写得不好。

第四,sendfile不保证发完。跟writev一样存在短写,必须循环,而且offset只在调用成功时更新,出错时要自己判断该不该推进。

提示:如果用sendfile传超过 2GB 的文件,注意off_t的位宽。编译时加-D_FILE_OFFSET_BITS=64,否则 32 位环境下偏移会在 2GB 处绕回,症状是文件传到一半突然从头开始,非常难查。

4. mmap:把文件当成一块内存来用

4.1 映射的三种姿势与页对齐

mmap的思路更激进:既然内核里已经有页缓存了,为什么不直接把这块物理内存映射到进程地址空间,让指针访问就等于读文件?这样read那次拷贝彻底消失,用户态直接读内核页缓存。

#include <sys/mman.h> void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); int munmap(void *addr, size_t length);

关键参数就三个要理解透:

  • prot:PROT_READ、PROT_WRITE、PROT_EXEC。只读映射就只给读权限,别图省事给读写,PROT_WRITE加MAP_SHARED会真的改到文件上。
  • flags:MAP_PRIVATE是写时复制,改了不影响文件;MAP_SHARED是共享,改了会落盘(或至少落到页缓存并最终同步)。做只读文件访问就用MAP_PRIVATE,安全。
  • offset:必须是页大小的整数倍。这是最常见的报错来源,传个 100 进去直接EINVAL。正确做法是offset & ~(page_size - 1)向下对齐,然后在返回的指针上加回余数。

length不用页对齐,内核会自动向上取整到整页,但注意最后一页里超出文件末尾的部分访问是未定义的,读出来可能是 0 也可能触发SIGBUS。

4.2 实测:什么情况下 mmap 真比 read 快

先说结论,避免大家误用:mmap 不是万能加速器,小文件上它比read慢。原因很直白,mmap加munmap是两次系统调用,还要处理缺页中断,而read一次就能把几 KB 数据拷进栈。我做过一个粗略对比(同一台机器、同一个 ext4 分区、文件常驻页缓存、单线程循环读):

场景read 循环mmap 后 memcpy
4KB 小文件,随机读更快明显更慢,系统调用开销占主导
1MB 文件,整块读接近略快
100MB 文件,只读其中 10% 的随机片段慢明显更快,省掉无效拷贝
100MB 文件,顺序全量读接近接近,差距在 10% 以内

这组数字只能当量级参考,不同内核、不同文件系统、有没有开MAP_POPULATE都会变。但它说明了一个判断标准:收益来自"读得少但文件大"或者"访问模式随机但局部集中"。典型的受益者是数据库索引文件、倒排索引、大 JSON 里只取几个字段、以及需要反复扫描的内存映射型存储引擎。

顺序全量读反而用不上它,因为read有内核的预读(readahead)加持,顺序场景本来就不吃亏。

4.3 代码骨架与三个致命坑

#define _GNU_SOURCE #include <sys/mman.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int map_and_scan(const char *path) { int fd = open(path, O_RDONLY); if (fd < 0) return -1; struct stat st; if (fstat(fd, &st) < 0 || st.st_size == 0) { close(fd); return -1; } void *p = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); if (p == MAP_FAILED) { close(fd); return -1; } /* 顺序扫一遍,内核会按需触发缺页 */ const char *cur = (const char *)p; size_t len = (size_t)st.st_size; for (size_t i = 0; i < len; i++) { if (cur[i] == '\n') { /* 业务处理 */ } } munmap(p, (size_t)st.st_size); close(fd); return 0; }

坑一:文件被截断导致SIGBUS。映射建立之后,如果别的进程把文件truncate成更小,你再去访问原来那一段地址,内核找不到对应页,直接给进程发SIGBUS,这个信号默认行为是终止进程,而且没法用返回值判断,try/catch式的错误处理在这里完全失效。我在一个日志轮转的场合遇到过:轮转进程把文件清空,读进程拿着旧映射继续读,整组 worker 全崩。解决办法只有两个——要么在业务上保证映射期间文件不被改动(比如先flock),要么给SIGBUS装 handler 做优雅退出,没有第三条路。

坑二:msync别忘。用MAP_SHARED写完,数据在页缓存里,什么时候落盘由内核决定。如果进程崩了或者机器掉电,可能丢。要确定性落盘就msync(addr, len, MS_SYNC),但它是全量刷,很慢,别在热路径里调。

坑三:madvise和MAP_POPULATE的取舍。默认映射是惰性的,第一次访问才触发缺页,海量小缺页会拖慢启动。对"马上就要全量扫一遍"的场景,mmap时带MAP_POPULATE可以让内核一次性把页表建好,省掉几万次缺页中断;对"只随机访问一小部分"的场景,加上它就纯属浪费内存和启动时间。另外madvise(p, len, MADV_SEQUENTIAL)会告诉内核你的访问模式是顺序的,预读会更激进,实测在顺序扫描大文件时能明显减少缺页次数。

注意:mmap的映射区不占堆空间,但占虚拟地址空间。32 位进程映射几个大文件很容易把地址空间吃光,症状是mmap返回ENOMEM而系统内存还很空。

5. splice 与 tee:把管道当成传送带

5.1 splice 的核心约束:必须有一端是管道

splice的设计非常"Linux":它不直接在两端的 fd 之间搬数据,而是要求中间经过一个管道。这个设计一开始让人觉得多此一举,但理解之后会发现很聪明——管道本身在内核里就是一串页引用,splice做的事情本质是把页的引用从一个队列挪到另一个队列,根本不搬数据内容。

#define _GNU_SOURCE #include <fcntl.h> ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags); ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

约束记住两条就够:splice的两端至少有一端是管道,tee的两端必须都是管道。想直接从文件 fd 搬到 socket fd,EINVAL等着你。正确姿势是两段式:文件 → 管道 → socket。

flags里常用的三个:

  • SPLICE_F_MOVE:提示内核尽量移动页而不是拷贝,属于优化建议,内核可以不理。
  • SPLICE_F_NONBLOCK:别被名字骗了,它只是让操作"尽量不阻塞",如果 fd 本身是阻塞的,仍然可能阻塞。
  • SPLICE_F_MORE:告诉内核后面还有数据,对 TCP 来说有助于减少小包。

5.2 写一个零拷贝转发骨架

下面这段是"从文件转发到 socket"的最小可用版本,我把它当作模板改过好几个版本:

#define _GNU_SOURCE #include <fcntl.h> #include <unistd.h> #include <errno.h> #define PIPE_SZ (1 << 16) /* 管道容量先给 64KB,可按需调大 */ int splice_file_to_socket(int file_fd, int sock_fd, off_t *file_off, size_t total) { int p[2]; if (pipe(p) < 0) return -1; /* 调大管道容量,减少往返次数;失败也无所谓,用默认值 */ fcntl(p[1], F_SETPIPE_SZ, PIPE_SZ); size_t left = total; int ret = 0; while (left > 0) { /* 第一段:文件 -> 管道 */ ssize_t n = splice(file_fd, file_off, p[1], NULL, left < PIPE_SZ ? left : PIPE_SZ, SPLICE_F_MOVE); if (n < 0) { if (errno == EINTR) continue; ret = -1; break; } if (n == 0) break; /* 第二段:管道 -> socket,可能只写出一部分 */ size_t remain = (size_t)n; while (remain > 0) { ssize_t w = splice(p[0], NULL, sock_fd, NULL, remain, SPLICE_F_MOVE | SPLICE_F_MORE); if (w < 0) { if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) { /* 非阻塞场景这里要转去等可写事件 */ ret = -2; goto out; } ret = -1; goto out; } remain -= (size_t)w; left -= (size_t)w; } } out: close(p[0]); close(p[1]); return ret; }

管道容量这个参数值得单独讲。默认管道是 64KB,意味着每次从文件搬 64KB 就要停下来等 socket 消费,往返次数多。fcntl(p[1], F_SETPIPE_SZ, size)可以调大,上限受/proc/sys/fs/pipe-max-size限制(很多发行版默认 1MB),非特权用户只能调到这个上限以内,超出会EPERM。我一般设到 256KB 到 1MB 之间,再大收益就不明显了,反而增加内存占用——每个连接一个管道,几万连接就是几 GB 的差别。

还有一点必须提醒:splice从管道往 socket 写的时候,如果 socket 是阻塞模式,可能会卡在那里等对端收数据。这在高并发下是灾难,会拖死整个事件循环。所以真正上生产的时候,我会把 socket 设成非阻塞,配合 epoll 做状态机:管道里有多少数据、socket 能不能写,都记在连接结构体里,由事件驱动推进。

5.3 tee:想偷看一份数据又不想消耗它

tee的行为可以用一句话概括:把管道 A 里的数据复制一份到管道 B,A 里的数据保持不动。注意是"复制引用",不是拷贝内容,所以它依然是零拷贝的。这个函数最大的用处是旁路观察——比如你有一个转发链路,想在不影响主链路的前提下把数据抓一份出来做审计、做统计、或者落盘做离线分析。

/* 假设 upstream 是主数据管道,我们在旁边接一根观察管道 */ ssize_t n = tee(main_pipe[0], watch_pipe[1], 4096, SPLICE_F_NONBLOCK); if (n > 0) { /* watch_pipe[0] 里现在有一份副本,主链路的数据还在 */ }

几个实操上的限制别踩:

第一,tee复制出来的数据只能走非文件的目的地。因为复制的是页引用,如果允许直接写文件,页的生命周期管理会变得很麻烦,所以内核要求fd_out必须是管道。想落盘?再splice一次从观察管道到文件。

第二,观察管道一定要持续消费。如果你tee出来一份却没人读,管道满了之后tee返回EAGAIN,主链路其实不受影响(这是好事),但你的观察数据就丢了。做采样统计的话这反而是可接受的行为——丢就丢,样本够了就行。

第三,tee和splice组合起来有先后顺序讲究。想既转发又保存,正确顺序是先tee再splice;反过来先把数据splice走了,源管道就空了,tee什么也拿不到。这个顺序我在第一次写流量采集的时候搞反过,白白浪费了半天排查时间。

6. 怎么选:一张对照表加上我的判断习惯

6.1 场景对照表

把前面几节压缩成一张能直接查的表:

你的需求首选方案备选关键注意点
多个不连续缓冲写一个 socketwritev先memcpy拼再write处理短写、IOV_MAX上限
定长头 + 变长体一次读入readv两次read返回值要自己拆
静态文件下发到 socketsendfilesplice两段式不能带 TLS、注意 2GB 位宽
大文件随机访问mmappread页对齐、SIGBUS、madvise
fd 到 fd 转发(一端可用管道)splice用户态read/write管道容量、非阻塞状态机
转发同时留一份副本splice+teesendfile+ 用户态拷先tee后splice,观察端要消费
两个普通文件间拷贝copy_file_rangesendfile比sendfile更贴合场景

6.2 我在项目里的判断顺序

真到写代码的时候,我不会一上来就对着表查,而是先问三个问题。

数据要不要在用户态处理?要加密、要压缩、要改内容、要算签名,那零拷贝基本无解,老老实实read/write,能用writev把分散的块合起来就够了。这一步能筛掉一大半场景。

有没有 TLS?有的话sendfile、splice全部失效,因为数据必须过加密库。这也是为什么很多号称"零拷贝"的服务在开了 HTTPS 之后性能打回原形。

数据源是不是普通文件?是文件且目标是 socket,sendfile;是文件但目标是另一个文件,copy_file_range;都不是,才考虑splice。

顺手说个性能上的直觉参考:我做过的对比里,把 1GB 文件通过本机回环发给另一个进程,传统read/write的 CPU 占用大致是sendfile的两倍多,splice跟sendfile在同一量级,差距在 10% 以内。也就是说,如果你的瓶颈是 CPU 拷贝,sendfile就够用了,没必要为了几个百分点上splice再写一套管道状态机,维护成本远大于那点收益。这个判断我吃过亏,早年间为了"零拷贝"四个字硬上splice,结果代码里多出几百行管道生命周期管理,最后发现瓶颈其实在磁盘 IOPS 上。

7. 排查实录:那些报错码背后的真问题

7.1 常见问题速查表

现象最可能的原因排查手段处理办法
writev返回EINVALiovcnt超过IOV_MAX,或iov_len太大打印iovcnt和总长度分批,单批不超过 1024 项
writev部分返回socket 缓冲满检查 fd 是否非阻塞按返回值推进游标,循环写
对端收到重复数据短写后从头重发抓包看 sequence改游标推进逻辑
sendfile返回EINVALin_fd不支持mmap(管道/socket)打印 fd 类型换成splice或用户态中转
sendfile发到 2GB 处错乱off_t是 32 位检查编译宏加-D_FILE_OFFSET_BITS=64
mmap返回EINVALoffset未页对齐打印 offset 和sysconf(_SC_PAGESIZE)向下对齐后加回偏移
进程收到SIGBUS映射期间文件被截断查有没有轮转/清理任务加锁或装 handler
splice返回EINVAL两端都不是管道打印两个 fd 类型改成两段式
F_SETPIPE_SZ返回EPERM超过pipe-max-size上限读/proc/sys/fs/pipe-max-size降到上限以内
tee返回EAGAIN观察管道已满没人读看观察端消费速度加消费线程或允许丢样本

7.2 几个花了很久才定位的坑

坑一:sendfile加非阻塞 socket 的半包灾难。早期我把 socket 设成非阻塞,sendfile返回EAGAIN之后没记录偏移,直接跳去等 epoll 可写,结果下次从同一个offset又发了一遍,客户端收到的文件比原文件更大。这个 bug 只在网络抖动时出现,本地测试永远复现不了。教训是:sendfile的offset指针就是你的进度条,出错时先看它有没有被更新,再决定要不要推进。

坑二:mmap映射空文件直接失败。st_size == 0的时候mmap会返回EINVAL,因为长度为零。空文件在很多业务里是合法状态(比如刚创建还没写入的日志),代码里必须单独处理,别让它走进映射分支。

坑三:splice的len传太大,表现是"只搬了一部分"。单次splice到管道的量不能超过管道剩余空间,你传 1MB 但管道只有 64KB 空闲,它只会搬 64KB 然后返回。这不叫出错,属于正常行为,但如果你按"返回值必须等于请求量"来判断成功,就会误判成失败。正确写法是把它当短写处理。

坑四:madvise(MADV_DONTNEED)用错地方。这个调用会丢弃指定范围的页缓存,下次访问重新从磁盘读。它的本意是释放不再需要的内存,但如果用在一个还在被其他进程映射的共享文件上,会影响别人。我见过有人把它当"手动 GC"到处加,结果把整个服务的读性能拖垮。

7.3 一份可以照着做的验证清单

代码上线之前,我一般会过一遍这几条:

  1. 所有短写场景都模拟过吗?把 socket 发送缓冲区调小到几 KB,跑一遍大文件传输,看数据是否完整(用sha256sum对比最直接)。
  2. IOV_MAX边界测过吗?构造 1024、1025、3000 个iovec各跑一次。
  3. 大文件超过 2GB 测过吗?用truncate -s 3G造个稀疏文件,走一遍sendfile。
  4. 文件在传输过程中被truncate或删除,行为是否符合预期?
  5. 用strace -c -f看一遍系统调用统计,确认调用次数真的降下来了,而不是代码写复杂了但次数没变——这一点很关键,我见过改完之后调用次数反而变多的案例。

strace那条特别值得做。判断零拷贝有没有生效,不要靠感觉,看两个指标:CPU 的sys时间占比是否下降,相同流量下系统调用次数是否减少。如果sys时间没变,那多半是你以为的零拷贝路径根本没走到,比如某处又退回了用户态中转。

最后分享一个小技巧:调这类代码的时候,我会把perf top挂在旁边看copy_user_enhanced_fast_string这个符号。它在榜上占比高,就说明用户态拷贝还在,零拷贝没生效;占比掉下去,才算真把数据留在了内核里。这个观察比看任何文档都直接。


我在实际项目里的体会是,这六个函数里真正值得先啃的只有两个:writev和sendfile。前者几乎没有副作用,任何有"多块拼起来发"场景的代码都能无脑换;后者在静态文件场景收益极大,限制也明确,踩一次坑就记住了。mmap和splice属于"收益大但要挑场景",用之前先确认瓶颈真的在拷贝上,别为了架构好看给自己加一堆状态机。至于tee,把它当成一个调试和旁路的工具就好,主链路里尽量少用,管道生命周期管理的心智负担比省下来的那次拷贝贵得多。