做网关和服务端程序的人大概都有这么一段经历最初所有 IO 都是read读进缓冲区、处理一下、再write出去跑小流量时一切正常一旦把单机吞吐拉起来、或者需要转发大文件CPU 里sys那一栏就开始飙strace一看全是密密麻麻的read/write。这个时候你才意识到Linux 给了另一组更贴近底层的手段readv、writev、sendfile、mmap、splice、tee。它们不是read/write的语法糖而是在减少数据拷贝次数、减少系统调用次数、改变数据流转路径这三个方向上做的工程优化常被统称为“高级 IO”或者“零拷贝家族”。这篇就按实际写代码的顺序把这六个函数逐个拆开各自的语义边界在哪、参数怎么填、返回值怎么判、什么场合适用、什么场合用了反而更慢以及我在压测和线上排查中踩过的那些不太容易在文档里看到的坑。适合已经能熟练写read/write、想进一步把数据通路压扁的读者也适合只想搞清楚“零拷贝到底零在哪”的同学。1. 先算清楚账为什么值得把 read/write 换掉在动手用这些函数之前有必要先明白普通read/write的成本结构。否则很容易陷入一个误区——看到“零拷贝”三个字就觉得一定更快结果在不合适的场景引入mmap内存占用涨了一截性能没变。1.1 一次普通 read 的数据旅程假设从磁盘读一个文件、经过用户态处理后写到网络。以最朴素的写法走一遍用户进程发起read触发一次系统调用CPU 从用户态切到内核态模式切换。内核通过文件系统定位数据发起 DMA 把数据从磁盘读进内核页缓存第 1 次拷贝DMA 拷贝不占 CPU。内核把页缓存里的数据copy_to_user到用户提供的缓冲区第 2 次拷贝CPU 拷贝。系统调用返回切回用户态。进程处理数据。调用write再次进入内核态把用户缓冲区数据拷进内核 socket 发送缓冲区第 3 次拷贝CPU 拷贝。协议栈封装后DMA 把数据从 socket 缓冲区搬到网卡第 4 次拷贝DMA 拷贝。于是完整的四次拷贝里有两次是纯 CPU 参与的、和业务逻辑毫无关系的搬运同时还伴随 2 次模式切换、2 次系统调用。文件越大、QPS 越高这部分“无效功”占比就越夸张。这里有个容易被忽略的点页缓存那一层是绕不开的。即便你用mmap第一次访问也仍然要触发缺页把磁盘数据页读进来。所谓“零拷贝”从来不是“一次拷贝都没有”而是“消除用户态与内核态之间那次多余的 CPU 拷贝”。理解这一点后面很多取舍就顺理成章了。1.2 系统调用的固定开销到底有多大很多人对“减少系统调用次数”没概念觉得一次调用能有多贵。实测下来在常见的 x86_64 服务器上一次不做任何事的空系统调用比如getpid大约几十纳秒但read/write这种涉及地址空间检查、文件描述符查找、权限校验的调用单次开销通常落在 0.5 微秒到 2 微秒这个区间还要算上模式切换带来的 TLB 和分支预测器扰动。这意味着如果一次业务处理要发 10 个小包每个包都用一次write光系统调用就吃掉十几微秒而真正传的数据可能只有几百字节。这也是writev存在的最直接理由——把 10 次调用压成 1 次。另一个维度是拷贝成本。内存带宽虽然是 GB/s 级别但 CPU 拷贝会污染 cache。一个 64KB 的缓冲区被完整拷贝一遍L1/L2 里原本热的数据基本被挤干净后续业务逻辑又重新从内存加载。这种“间接损失”比拷贝本身更难量化但在高并发服务上非常真实。1.3 六个函数的分工地图先把全景摆出来后面逐节展开。这些函数并不是互相替代的关系各自有明确的适用边界函数核心作用主要收益关键限制readv一次调用读入多个分散缓冲区减少系统调用次数避免先读到临时大缓冲再拆分iov 数量上限IOV_MAX通常 1024writev一次调用写出多个分散缓冲区同上天然适合“头部正文尾部”拼包可能短写需要循环处理剩余 iovsendfile内核内部直接把文件数据送到另一个 fd省去用户态参与减少 2 次 CPU 拷贝in_fd需支持 mmapcount有单次上限mmap把文件映射进进程地址空间随机访问友好大文件不必整块读入页对齐、SIGBUS 风险、内存统计失真splice通过管道在两个 fd 之间搬运数据数据不进用户态适合代理中转两个 fd 至少一个是管道tee把管道数据复制一份到另一个管道一份数据多处消费源数据不消耗两端必须都是管道看这张表能得出一个结论readv/writev解决的是“调用次数”sendfile/splice解决的是“拷贝次数”mmap解决的是“访问模式”tee解决的是“数据分发”。选型的时候先问自己瓶颈在哪别一上来就上零拷贝。2. readv 与 writev把零散缓冲区一次交出去readv和writev是这六个里最容易落地、风险最低的两个改动量小、语义清晰几乎可以在任何项目里无痛替换掉一部分read/write循环。2.1 iovec 结构与返回值的正确读法两个函数共用struct iovec#include sys/uio.h struct iovec { void *iov_base; /* 缓冲区起始地址 */ size_t iov_len; /* 缓冲区长度 */ }; ssize_t readv(int fd, const struct iovec *iov, int iovcnt); ssize_t writev(int fd, const struct iovec *iov, int iovcnt);iovcnt的上限是IOV_MAXLinux 上一般等于 1024程序里别硬编码用sysconf(_SC_IOV_MAX)取。有人图省事直接传 4096writev返回 -1 并置errno EINVAL排查半天才发现是数量超限。返回值的语义需要特别小心这里和read是不一样的readv返回实际读到的总字节数这个总量会按iov[0]、iov[1]的顺序依次填满。当返回值为n时你只能用n去累减每个 iov 的iov_len来判断哪些缓冲区被填了多少。内核会修改你传入的iov_len字段吗答案是会——readv在返回前不会改你传的数组但有些平台实现会更稳妥的做法是自己用返回值推算别依赖数组被改写。writev返回实际写出的总字节数可能小于所有 iov 长度之和短写此时内核保证是从iov[0]开始连续写的所以你只需要从头部往后扣减即可。注意readv在管道、socket 上是“尽力而为”只要有数据就返回不会等所有 iov 填满。把它当成“一次读满所有缓冲区”的保证是常见误解。2.2 readv 的真实用途不是读满而是省调用很多教程把readv讲成“分散读”重点放在“把一次读取的数据分散到多个缓冲区”。但实际工程里它更常见的价值是固定头部 变长负载这种协议结构的解析。举个例子一个自定义二进制协议前 16 字节是固定头魔数、版本、长度、校验后面是变长 payload。传统写法是先read16 字节读头解析出长度再malloc一块 payload 缓冲区再read一次。两次系统调用两次模式切换。用readv可以这么写struct proto_hdr { uint32_t magic; uint16_t version; uint32_t len; uint32_t crc; uint16_t flags; } __attribute__((packed)); static int read_one_msg(int fd, struct proto_hdr *hdr, char **out, size_t *out_len) { struct iovec iov[2]; iov[0].iov_base hdr; iov[0].iov_len sizeof(*hdr); iov[1].iov_base NULL; /* 还不知道长度先占位 */ iov[1].iov_len 0; ssize_t n readv(fd, iov, 1); /* 第一轮只读头 */ if (n 0) return (int)n; if ((size_t)n sizeof(*hdr)) return -2; /* 头不完整交给上层的收包循环 */ if (hdr-len 0 || hdr-len (16u 20)) return -3; /* 长度做上限校验 */ char *buf malloc(hdr-len); if (!buf) return -4; size_t got 0; while (got hdr-len) { ssize_t m read(fd, buf got, hdr-len - got); if (m 0) { if (errno EINTR) continue; free(buf); return -5; } if (m 0) { free(buf); return -6; } /* 对端关闭 */ got (size_t)m; } *out buf; *out_len hdr-len; return 0; }看到这里你可能会问这不还是两次调用吗用readv图什么真正的收益在头已经预读进缓冲区的场景——比如你自己维护了一个收包环形缓冲头部的 16 字节其实早就在内存里了这时候一次readv把“剩余头部 剩余 payload”一起要过来就省掉一次调用。我在一个协议网关里做过这样的改造一次请求原本要走 3 次read头、扩展头、body改成readv后平均变成 1.2 次单核 QPS 提升了接近 18%。这个数字不同的业务差异很大但方向是确定的。2.3 writev 的拼包场景与短写循环writev的收益比readv更直接。HTTP 响应、Redis 协议响应、自定义 RPC 帧几乎都是“固定头 变长体”的结构天然适合writev。int send_response(int fd, const char *status_line, const char *headers, const char *body, size_t body_len) { struct iovec iov[3]; int cnt 0; iov[cnt].iov_base (void *)status_line; iov[cnt].iov_len strlen(status_line); cnt; iov[cnt].iov_base (void *)headers; iov[cnt].iov_len strlen(headers); cnt; if (body_len 0) { iov[cnt].iov_base (void *)body; iov[cnt].iov_len body_len; cnt; } return writev_all(fd, iov, cnt); }短写循环是关键千万别漏static int writev_all(int fd, struct iovec *iov, int iovcnt) { int idx 0; while (idx iovcnt) { ssize_t n writev(fd, iov idx, iovcnt - idx); if (n 0) { if (errno EINTR) continue; if (errno EAGAIN || errno EWOULDBLOCK) { /* 非阻塞 fd交给上层事件循环这里简单返回让调用方重试 */ return -1; } return -1; } if (n 0) return -1; /* 理论上不该发生 */ size_t left (size_t)n; while (idx iovcnt left iov[idx].iov_len) { left - iov[idx].iov_len; idx; } if (idx iovcnt left 0) { iov[idx].iov_base (char *)iov[idx].iov_base left; iov[idx].iov_len - left; } } return 0; }这段扣减逻辑值得在脑子里过一遍writev保证从iov[0]开始顺序写所以成功写出n字节后前面若干个 iov 要么被完整写掉、要么正好停在某个 iov 中间。上面那段while就是做这件事。提示非阻塞 socket 上writev返回EAGAIN时你的 iov 数组和其中被部分消费的状态必须保存下来等可写事件到来再继续。这就意味着业务层需要一个“发送队列”结构这往往是改造中最麻烦的部分比调用writev本身复杂得多。2.4 实操心得iov 数组千万别指向栈上临时对象一个非常隐蔽的问题writev是异步语义尤其在非阻塞 部分写的情况下如果你把 iov 指向一个已经出栈的局部变量或者指向一个随后被free的缓冲区就会出现数据错乱甚至段错误而且现象是随机的极难复现。我的做法是凡是需要跨事件循环保存的 iov一律用堆分配并且和发送队列节点绑在一起节点释放时统一free。同时iov_base指向的 payload 也必须有明确的生命周期归属绝不能是某个临时std::string的c_str()。3. sendfile零拷贝里最实用的那一个如果只能选一个“高级 IO”函数落地我会毫不犹豫选sendfile。语义简单、改动量小、收益明确特别适合静态文件服务和反向代理。3.1 路径差异少掉的那两次拷贝在哪对比第一节的readwrite路径sendfile把流程变成进入内核DMA 把文件数据读入页缓存DMA 拷贝。内核把页缓存数据拷贝到 socket 缓冲区早期版本是 CPU 拷贝现在支持sendfile 网卡 scatter-gather 时甚至能直接引用。DMA 把 socket 缓冲区数据搬到网卡DMA 拷贝。关键变化是数据全程没有进入过用户态。省掉了copy_to_user和copy_from_user两次 CPU 拷贝同时省掉一次系统调用返回再进入的模式切换。这也解释了sendfile的本质约束你没法在处理过程中碰数据。想改内容、加密、压缩那就不适合。3.2 参数细节与那个 2GB 的坑#include sys/sendfile.h ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);几个必须记住的点in_fd必须是支持mmap类操作的 fd普通文件可以socket 不行。out_fd在 Linux 2.6.33 之前必须是 socket之后放宽到任意支持 splice-write 的 fd也就是说普通文件也可以作为输出sendfile可以实现内核内的文件到文件拷贝。offset为NULL时从in_fd当前文件偏移开始读并且会推进文件偏移非NULL时从*offset开始读且不回退in_fd的当前偏移函数返回后*offset被更新。最坑的一条单次count上限是0x7ffff000约 2.14GB。传更大的值不会报错但实际只传这么多。大文件必须循环。int send_file_range(int out_fd, int in_fd, off_t start, off_t len) { off_t off start; off_t left len; while (left 0) { size_t chunk (left 0x7ffff000) ? (size_t)0x7ffff000 : (size_t)left; ssize_t n sendfile(out_fd, in_fd, off, chunk); if (n 0) { if (errno EINTR) continue; if (errno EAGAIN || errno EWOULDBLOCK) { /* 非阻塞 socket 缓冲区满需要 poll 可写后重试 */ return -1; } return -1; } if (n 0) break; /* 文件被截断或已到 EOF */ left - n; } return 0; }3.3 什么时候 sendfile 反而拖后腿不是所有场景都值得上sendfile。我碰到过两类反例第一类是小文件 高 QPS。几百字节的响应sendfile相比writev的优势几乎为零反而因为多了一次open/fstat或者额外维护 fd 缓存的逻辑而变慢。这类场景老老实实用writev拼一个完整响应更划算。第二类是文件被频繁修改。sendfile走页缓存如果文件正在被写入你读到的可能是“写了一半”的内容。虽然内核有相应的一致性处理但在热更新配置、日志轮转这类场景下容易读到脏数据。稳妥做法是发之前fstat校验st_size和st_mtime或者干脆用“写临时文件 rename原子替换”的方式更新。我用一个简单的静态文件服务压过一组数据作为感性认识本机 ext4 loopback1MB 文件8 并发单位是秒级完成的请求数占比方式吞吐相对值用户态 CPU 占比备注readwrite64KB 缓冲1.0高基线readwrite1MB 缓冲1.35高缓冲区大反而 cache 污染sendfile2.1明显低收益随文件增大更明显mmapwrite1.8中大文件随机访问场景更好这些数字在不同机器、不同文件系统、不同网卡上差别很大别当成结论照搬但它能说明一个趋势文件越大sendfile的优势越明显小文件场景差距会缩小到噪音级别。4. mmap把文件当内存用mmap是这几个函数里“威力最大也最容易出事”的一个。用对了行云流水用错了就是随机段错误和莫名其妙的内存增长。4.1 映射参数与页对齐的硬约束#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);几个约束必须刻在脑子里offset必须是系统页大小sysconf(_SC_PAGESIZE)通常是 4096的整数倍否则EINVAL。length会被内核向上取整到页边界。也就是说映射 1 字节实际占用一页。length 0直接EINVAL。空文件是经典陷阱代码里必须先判断st_size。PROT_READ/PROT_WRITE要和open的打开模式匹配只读打开却要求PROT_WRITE会得到EACCES。MAP_SHARED的写入对其他进程可见MAP_PRIVATE是写时复制COW写不落盘。一个足够用的只读映射模板int mmap_read_file(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; } if (st.st_size 0) { /* 空文件必须单独处理 */ close(fd); return -2; } size_t len (size_t)st.st_size; void *p mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); if (p MAP_FAILED) { close(fd); return -1; } /* 建议告诉内核访问模式帮助预读策略 */ madvise(p, len, MADV_SEQUENTIAL); /* ... 业务处理p 可以当普通指针遍历 ... */ munmap(p, len); close(fd); /* munmap 之后就可以 close 了 */ return 0; }注意最后两行的顺序munmap之后close是安全的映射本身持有文件引用关掉 fd 不影响已建立的映射。但反过来先close再访问映射也没问题——这一点很多人搞混以为 fd 一关映射就失效了。4.2 缺页、写时复制与内存统计的错觉mmap之后内存并不会立刻占用。只有在真正访问某一页时才触发缺页中断从磁盘读入。这叫按需调页。好处是启动快、内存用得省坏处是延迟不可预测——某次访问正好赶上缺页可能突然卡几毫秒。如果确定要顺序扫描整个文件加一句madvise(MADV_SEQUENTIAL)或MADV_WILLNEED能显著减少缺页抖动。MADV_WILLNEED会让内核提前发起读适合“我知道马上要用”。另一个常见误解是内存统计。mmap映射的页会计入进程的 RSS但不一定都是“独占”的——MAP_SHARED的页在多个进程间共享实际物理内存只有一份。用top看到 RSS 暴涨就慌往往是自己吓自己。真正要看的是/proc/self/smaps里的Pss和Shared_Clean/Shared_Dirty。MAP_PRIVATE则涉及写时复制。读的时候共享页缓存一写就复制一份私有页。这意味着用一个MAP_PRIVATE映射去“就地修改”大文件会产生大量匿名页内存可能翻倍。要落盘的修改必须用MAP_SHARED并且msync。4.3 mmap 和 read 的取舍这两者不是替代关系而是适配不同访问模式维度read 缓冲区mmap顺序读大文件好配合预读好但要madvise随机跳读差每次都要定位好直接指针偏移小文件、一次性好简单直接略重有映射开销需要修改内容简单改完再写需MAP_SHAREDmsync文件可能被截断安全返回 0 或短读危险SIGBUS32 位程序大文件受限于缓冲区大小受限于地址空间需分段映射内存可预测性强弱RSS 随访问增长4.4 SIGBUSmmap 最疼的那个坑文件被截断后访问映射区会直接收到 SIGBUS进程当场死掉。这不是errno能处理的是一个信号。日志服务、缓存文件、热更新配置全都有可能踩到。我遇到过一次某个索引文件用mmap加载另一个进程做清理时把它truncate了读进程在访问越界页时直接崩溃连 core 都没留。排查了很久才定位。处理方式有三种按可靠性排序不用共享文件做映射改用read或者映射到私有副本。捕获 SIGBUS用sigsetjmp/siglongjmp跳出来。能做但很脏信号处理函数里能做的事极其有限。协议层保证写入方永远“写新文件 rename”不做truncate读取方持有 fd通过fstat定期校验st_size不一致就重新映射。注意rename是原子的但旧文件在被替换后依然存在于磁盘上直到最后一个持有它的 fd 关闭。这对mmap场景非常友好——你映射的旧 inode 不会因为你rename而失效。4.5 一个真实用途大文件切片的随机访问mmap最舒服的场景是需要反复随机访问的大文件比如倒排索引、字典文件、二进制快照。传统read每次都要lseek 读块 拼装mmap之后整个文件就是一个char *偏移计算直接用指针算术代码量和 CPU 都省。我在一个词条查询模块里做过对比文件 800MB随机查 10 万次。原方案用pread每次读 512 字节平均耗时约 3.2 微秒改成mmap后平均约 0.9 微秒而且代码从三十多行缩到十行。代价是进程 RSS 显示占用接近文件大小虽然大部分是共享的 clean page内核内存紧张时会被回收。5. splice 与 tee管道是中转站前面几个函数的共同点是“要么绕不开文件要么需要用户态参与”。splice和tee则是为中转转发这个场景设计的数据从一个 fd 到另一个 fd全程不进用户态。5.1 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);硬性要求fd_in和fd_out中至少有一个必须是管道。两个都是普通文件不行EINVAL。这就是splice不能直接替代sendfile的原因——它需要一个管道做中转。也正是这个中转管道让splice有了sendfile做不到的能力socket 到 socket 的转发。反向代理、消息网关、日志采集这类“读一端写另一端”的场景用splice可以把数据完全留在内核里。flags常用三个SPLICE_F_MOVE尝试移动页而不是拷贝页。对支持它的 fd 有效是“更零拷贝”的提示。SPLICE_F_NONBLOCK非阻塞管道空或满时返回EAGAIN。SPLICE_F_MORE提示后续还有数据对 socket 输出有意义。注意两个off_in/off_out指针如果对应的 fd 是管道必须传NULL。传了非 NULL 会ESPIPE。这是我见过最多的参数错误。5.2 管道的容量决定了中转效率splice一次最多搬运多少取决于管道缓冲区大小。默认管道是 16 页64KB可以用fcntl调整int p[2]; if (pipe(p) 0) return -1; int sz fcntl(p[0], F_SETPIPE_SZ, 1 20); /* 尝试 1MB */ if (sz 0) { /* 可能超过 /proc/sys/fs/pipe-max-size回退到默认即可 */ }这个上限受/proc/sys/fs/pipe-max-size约束默认 1MB非特权用户改不了更大的值。管道太小会导致splice频繁往返对吞吐影响不小管道太大则每个连接都要占 1MB 内核内存连接数一多就很吓人。我一般按“单连接 QPS × 平均包大小”估算512KB 是个比较平衡的取值。5.3 tee一次输入两份输出ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);tee要求两个 fd都是管道。它把fd_in里的数据复制到fd_out但不消耗fd_in的数据——源数据还能被继续读。这个特性的用途很明确一边正常转发一边旁路抓取。比如一个流量镜像模块主链路把数据splice到下游同时用tee复制一份到旁路管道交给日志或审计模块。整个过程中数据都没进用户态。需要提醒的是tee本身不消耗源数据所以你必须在合适的时候从源管道读走数据否则管道满了之后tee返回EAGAIN整个链路就卡住了。这个“消费节奏”的设计是使用tee时最容易出错的地方。5.4 实操用 splice 做文件到文件的内核内拷贝#define _GNU_SOURCE #include fcntl.h #include unistd.h #include errno.h int splice_copy(const char *src, const char *dst, size_t pipe_sz) { int in open(src, O_RDONLY); if (in 0) return -1; int out open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (out 0) { close(in); return -1; } int p[2]; if (pipe(p) 0) { close(in); close(out); return -1; } if (pipe_sz 0) { if (fcntl(p[0], F_SETPIPE_SZ, (int)pipe_sz) 0) { /* 设置失败不致命用默认大小继续 */ } } loff_t off_in 0; int ret 0; for (;;) { ssize_t n splice(in, off_in, p[1], NULL, (size_t)1 16, SPLICE_F_MOVE); if (n 0) { if (errno EINTR) continue; ret -1; break; } if (n 0) break; /* 读完了 */ ssize_t left n; while (left 0) { ssize_t m splice(p[0], NULL, out, NULL, (size_t)left, SPLICE_F_MOVE); if (m 0) { if (errno EINTR) continue; ret -1; goto done; } if (m 0) { ret -1; goto done; } left - m; } } done: close(p[0]); close(p[1]); close(in); close(out); return ret; }这里off_out传了NULL因为输出是全新建的文件用 fd 自己的偏移推进就行。如果目标文件需要写到指定位置那就得传off_out。实测这个拷贝方式的用户态 CPU 占用比read/write版本低一个数量级左右同样是几十 GB 的文件CPU 时间差距非常明显但在 SSD 上墙钟时间的提升不算夸张因为瓶颈更多在磁盘。它的价值主要在 CPU 敏感的场景。6. 组合拳与选型速查单独看每个函数都比较清楚真到写代码的时候难点反而在“该用哪个组合”。6.1 几种典型场景的推荐写法场景推荐方案理由HTTP 静态文件响应sendfile一行搞定省两次拷贝自定义协议响应拼包writev头体分离缓存友好大文件随机读取mmap 指针访问免去反复lseek大文件顺序处理read 大缓冲区或mmapMADV_SEQUENTIAL各有优势看是否需要逐块处理TCP 转发 / 代理splice双向数据不进用户态流量镜像旁路teesplice一份数据两处消费文件到 socket 且需要修改内容read 修改 writev零拷贝方案都会绕过修改小文件高频访问writev或sendfile fd 缓存mmap的映射开销占比太高6.2 一个常见的组合mmap 读 writev 写有些场景需要读文件、改一小部分内容、再发出去。这时候“零拷贝”全都用不了但可以这么组合mmap映射源文件做零拷贝读取不用先整块读进用户缓冲改动的部分复制到临时缓冲最后用writev把“未改动的映射区”和“改动后的缓冲”拼成一次写出。这样能把拷贝量压到最小同时系统调用次数也少。6.3 常见问题速查现象可能原因处理方式writev返回EINVALiovcnt超过IOV_MAX分批发送或合并相邻 iovsendfile只发了 2GB单次count上限循环每次不超过0x7ffff000sendfile返回EINVALin_fd是 socket 或不支持 mmap换splice或read/writemmap返回EINVALoffset未页对齐或length为 0对齐偏移判空文件访问映射区收到 SIGBUS文件被截断改用read或写新文件后renamesplice返回ESPIPE管道 fd 传了非 NULL offset管道侧 offset 一律传NULLsplice返回EINVAL两端都不是管道中间加一个管道中转数据只写出一部分短写未处理加循环按返回值扣减 iov非阻塞下频繁EAGAIN写入侧没做可写事件驱动挂到事件循环保存未发送状态mmap后 RSS 巨大大量缺页 MAP_PRIVATE写入检查是否需要MAP_SHARED或用madvise(DONTNEED)7. 踩过的坑逐条记下来这一节不按函数组织按“我实际被折腾过的顺序”写都是文档里不太会强调的东西。7.1 短读短写永远要处理没有例外不管你用read、readv、write、writev返回值小于请求量都是常态。socket 上尤其明显接收缓冲区里有多少就给多少。我见过不少代码写着read(fd, buf, 1024)然后就当 1024 字节处理了在测试环境跑得好好的上了生产遇到分片就崩。正确的姿势是定义一个read_full/write_full的封装把重试逻辑收在一处所有业务代码都调封装。这样即使后来加了 EINTR、EAGAIN 的处理也只需要改一个地方。7.2 EINTR 不是罕见事件很多老代码不处理EINTR理由是“没装信号处理函数就不会被打断”。但只要进程里装了任何一个信号处理器定时器、SIGCHLD、SIGUSR1 做配置重载系统调用就可能被打断。我用SIGUSR1触发配置热更新结果sendfile大文件传输中途断开排查了半天才发现是EINTR没重试。统一处理模式就是do { n syscall(...); } while (n 0 errno EINTR);对所有可能被中断的调用都套上。7.3 大页与对齐对 mmap 性能的影响如果映射的区域需要频繁访问madvise(MADV_HUGEPAGE)在某些场景下能减少 TLB miss。但这依赖透明大页配置不是所有环境都开启而且会带来内存碎片和抖动。我在线上试过一次收益不稳定最后撤掉了——除非压测数据明确支持否则不建议随手打开。对页对齐这件事倒是要认真mmap的offset如果不是 4096 的整数倍直接报错。做分片映射的时候比如把 10GB 文件切成 1GB 一段映射每段的 offset 必须是页对齐的否则最后一段会失败。7.4 sendfile 与 TLS 的天然冲突这是个业务上很容易忽略的点sendfile要求数据原样从页缓存进 socket。一旦你要加密就必须先加密再发那sendfile就用不了得退回read 加密 writev。所以在设计“要不要上 TLS”的时候顺便也要考虑“零拷贝还能不能用”。同样的道理适用于压缩、内容改写、水印、审计内容——只要需要碰数据sendfile/splice这条路就断了。这不是缺陷是设计使然。7.5 别忘了 fadvise 和 madvise这两个不算“高级 IO 函数”但和它们配合使用效果很好。posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL)告诉内核“接下来要顺序读”能改善预读行为POSIX_FADV_DONTNEED则可以在读完后让页缓存尽快释放避免大文件扫描把缓存全冲掉、影响其他业务。madvise(MADV_DONTNEED)用于映射区可以让某段页被回收。做“流式处理大文件、处理完一段就丢一段”的时候很有用能把 RSS 稳住避免看起来像内存泄漏。7.6 关于“零拷贝”这个词的一点个人看法最后说个心态问题。很多团队一听“零拷贝”就想全量改造结果代码复杂度暴涨、边界情况一堆性能提升还不如把锁粒度改小、把缓冲区大小调对。我的实际经验是先 profile再改造。用perf top看清楚 CPU 到底花在哪用strace -c统计系统调用的次数和耗时分布。如果sys时间占比不到 20%那把read换成sendfile的收益大概率被噪声淹没如果sys高但read/write次数并不多那问题可能在大缓冲区拷贝反而是缩小缓冲区或者上splice更有效。我在一个转发型服务上的顺序是先把单次转发拆成多次read/write的循环合并成readv/writev改动最小收益立竿见影再看是否能把整条链路换成splice收益更大但需要重构状态机。分两步走每一步都能独立验证收益也都能独立回滚这比一次性大改靠谱得多。