写在前面
本文讲零拷贝(Zero-Copy)——把数据从磁盘发到网络,能少拷贝几次就少几次。这是 Nginx 静态文件飞快、Kafka 高吞吐的关键技术之一。
一、传统读写的开销
场景:服务器从磁盘读一个文件,通过网络发给客户端。
1.1 传统方式
1
2
3
4
| // 传统 4 步
char buf[4096];
read(fd, buf, 4096); // 磁盘 → 用户态
write(sock, buf, 4096); // 用户态 → 网络
|
看似两行,实际发生了4 次数据拷贝 + 4 次上下文切换:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| 内核空间 用户空间
┌─────────┐ ┌──────────┐ ┌──────────┐
│ 磁盘 │──DMA──→│ 内核缓冲区 │──CPU拷贝─→│ 用户buf │ read()
└─────────┘ └──────────┘ └──────────┘
↑ │
(步骤1,2) CPU拷贝
↓
┌──────────┐ ┌──────────┐
│ Socket │←─CPU拷贝─│ 用户buf │ write()
│ 缓冲区 │ └──────────┘
└─────┬────┘
DMA
┌─────▼────┐
│ 网卡 │
└──────────┘
(步骤3,4)
4 次拷贝:
1. 磁盘 → 内核缓冲区(DMA)
2. 内核缓冲区 → 用户 buf(CPU) ← 多余!用户根本没处理数据
3. 用户 buf → Socket 缓冲区(CPU) ← 多余!
4. Socket 缓冲区 → 网卡(DMA)
4 次上下文切换:read(2次) + write(2次)
|
1
2
3
4
5
6
| 问题:
数据从磁盘到网卡,根本不需要经过用户空间
但传统 read+write 强制数据绕道用户态
白白多了 2 次 CPU 拷贝 + 2 次上下文切换
零拷贝的目标:消除这 2 次多余的 CPU 拷贝
|
二、mmap + write(减少一次拷贝)
mmap 把内核缓冲区和用户空间映射到同一块内存,省去内核→用户的拷贝。
1
2
3
| char *p = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0); // 映射
write(sock, p, size); // 直接从映射区写
munmap(p, size);
|
1
2
3
4
5
6
7
8
| 拷贝流程:
磁盘 →(DMA)→ 内核缓冲区(= 用户映射区,共享)
内核缓冲区 →(CPU)→ Socket 缓冲区 →(DMA)→ 网卡
省了「内核缓冲区→用户buf」这次拷贝
剩 3 次拷贝(2 DMA + 1 CPU),4 次上下文切换
适合:用户需要读数据内容(如处理后再发)
|
三、sendfile(真正的零拷贝)
Linux 2.1 引入,专门为"文件→Socket"传输设计。数据全程不进用户空间。
1
2
| #include <sys/sendfile.h>
sendfile(sock, fd, &offset, size); // 一步到位
|
1
2
3
4
5
6
7
8
| 拷贝流程(原始 sendfile):
磁盘 →(DMA)→ 内核缓冲区 →(CPU)→ Socket 缓冲区 →(DMA)→ 网卡
数据不进用户态!
2 次拷贝(2 DMA)+ 1 次 CPU 拷贝
2 次上下文切换(一次系统调用)
对比传统:4次拷贝+4次切换 → 3次拷贝+2次切换
|
SG-DMA 优化(Linux 2.4+,终极零拷贝)
如果网卡支持 SG-DMA(Scatter-Gather DMA),连那 1 次 CPU 拷贝都省了:
1
2
3
4
5
6
7
| 拷贝流程(SG-DMA):
磁盘 →(DMA)→ 内核缓冲区
内核只把「缓冲区地址 + 长度」描述符发给 Socket 缓冲区
网卡根据描述符直接从内核缓冲区 DMA → 网卡
全程只有 2 次 DMA 拷贝,0 次 CPU 拷贝!
这就是真正的"零拷贝"(CPU 零拷贝)
|
1
2
| Nginx 的 sendfile on; 就是开启这个
静态文件传输极快的根本原因
|
四、splice(管道零拷贝)
sendfile 只能文件→Socket。splice 更通用,任意两个 fd 之间(通过管道)零拷贝。
1
2
3
4
| int pipefd[2];
pipe(pipefd);
splice(fd, &offset, pipefd[1], NULL, size, SPLICE_F_MOVE); // 文件 → 管道
splice(pipefd[0], NULL, sock, NULL, size, SPLICE_F_MOVE); // 管道 → Socket
|
1
2
| 适合:两个非 Socket 的 fd 间传输(如文件→文件、管道中转)
本质:用内核管道缓冲区做中转,避免数据进用户态
|
五、对比总结
1
2
3
4
5
6
7
| 方式 CPU拷贝 DMA拷贝 上下文切换 数据进用户态
────────────────────────────────────────────────────────────────
传统 read+write 2 2 4 是
mmap + write 1 2 4 是(映射)
sendfile 1 2 2 否
sendfile + SG-DMA 0 2 2 否 ← 最优
splice 0 2 2 否
|
六、实际应用
6.1 Nginx
1
2
3
4
5
6
| http {
sendfile on; # 开启 sendfile 零拷贝
tcp_nopush on; # 配合 sendfile,等数据攒够再发
}
# 静态文件传输直接走 sendfile,磁盘→网卡不进用户态
|
6.2 Kafka
1
2
3
4
5
6
7
| Kafka 高吞吐的关键之一:消费者拉消息用 sendfile
消息存在磁盘日志(顺序写,快)
消费者拉取 → 直接 sendfile 从磁盘文件发到网络
不经过 JVM 堆,无 GC 压力,无内存拷贝
这让 Kafka 能轻松百万级 TPS
|
6.3 Java 的零拷贝 API
1
2
3
4
5
| Java NIO:
FileChannel.transferTo() → 底层就是 sendfile
MappedByteBuffer → 底层是 mmap
Kafka、Netty 都用这些实现零拷贝
|
七、.NET 的零拷贝
1
2
3
4
5
6
7
8
9
10
| .NET 用 Stream 的 CopyToAsync 在内部尽量优化
但不是直接对应 sendfile
ASP.NET Core 的静态文件中间件:
用一些平台优化(如 Linux 下用 sendfile 语义)
+ Pipelines 减少分配
Span<T> / Memory<T>:
不是网络零拷贝,但提供了"零拷贝切片"内存视图
避免切片字符串/数组时的拷贝
|
八、小结
- 传统读写:4 次拷贝(2 CPU + 2 DMA)+ 4 次切换,数据多余绕道用户态
- mmap:内核/用户共享内存,省 1 次 CPU 拷贝(适合需要读内容)
- sendfile:文件→Socket 不进用户态,省拷贝 + 切换
- sendfile + SG-DMA:CPU 零拷贝,只剩 2 次 DMA(最优)
- splice:任意两 fd 间零拷贝(通过管道)
- 应用:Nginx sendfile、Kafka transferTo、Java FileChannel.transferTo
零拷贝 + I/O 多路复用 + Reactor = 高性能网络服务器三件套。下一篇讲内存管理(虚拟内存、页表、malloc)。