传统read/write路径共4次拷贝:dma磁盘→内核页缓存、cpu内核→用户缓冲区、cpu用户→socket缓冲区、dma socket→网卡;其中第2、3次为cpu参与的耗时拷贝,并伴随两次上下文切换。

零拷贝不是“不拷贝”,而是减少用户态参与的内存拷贝次数。Nginx 的 sendfile 实现的是内核空间内的数据搬运,真正省掉的是那一次从内核缓冲区到用户缓冲区的复制。
传统 read/write 路径到底发生了几次拷贝?
一个静态文件响应走传统路径(比如用 Lua 或自定义模块读取再 write)时,典型流程包含:
- DMA 从磁盘把数据搬进内核页缓存(第1次拷贝,无需 CPU)
- CPU 把数据从内核页缓存复制到用户态缓冲区(第2次拷贝,耗 CPU)
- 系统调用 write 触发,CPU 再把用户缓冲区数据复制到 socket 发送缓冲区(第3次拷贝)
- DMA 从 socket 缓冲区搬出数据到网卡(第4次拷贝,无需 CPU)
其中第2、3次是纯 CPU 拷贝,还伴随两次上下文切换——这正是性能瓶颈所在。
sendfile 真正跳过了哪部分?
sendfile() 系统调用让内核直接在内部完成转发,路径变成:
- DMA 从磁盘 → 内核页缓存(保留,无法绕过)
- 内核直接将页缓存地址和长度交给 socket 缓冲区(逻辑引用,无实际内存复制)
- DMA 从页缓存直送网卡(SG-DMA 支持下,连 socket 缓冲区都可跳过)
所以它“零”的是用户态内存拷贝和对应上下文切换,不是物理上一点数据都不动。最终只保留 1–2 次 DMA 拷贝,CPU 完全不碰数据体。
为什么 gzip、sub_filter 会让 sendfile 失效?
这些功能必须把内容读进用户空间才能处理:
- gzip 需要读原始字节、压缩、生成新字节流 → 必然触发 read()
- sub_filter 要扫描并替换文本 → 必须加载全文到用户缓冲区
- 一旦响应体被用户态修改,Nginx 就无法保证原始文件与发送内容一致,sendfile 的语义前提就崩了
此时 Nginx 自动回落到 read+write 路径,零拷贝链路中断。
如何确认你的配置真正在用 sendfile?
光看 sendfile on; 没用,得验证运行时行为:
- 用
strace -p $(pgrep nginx | head -1) -e trace=sendfile64,看到大量sendfile64(...)调用,且几乎无read() - 对比开启前后 top 中的 %sy(系统态 CPU),应明显上升(DMA 和内核调度增多),%us(用户态)显著下降
- 检查响应头:必须有准确
Content-Length,且不能是 chunked;文件需为本地 ext4/xfs 普通文件,未加锁、未用 O_DIRECT 打开











