零拷贝、mmap 和 sendfile
前面把文件 I/O、page cache、直接 I/O 这些内容顺了一遍之后,再往下看网络传输和文件发送时,我发现还有一块特别容易听懂关键词、但一串起来就开始乱的内容:
- 零拷贝
mmapsendfile- DMA
- page cache
- socket 发送缓冲
一开始我对“零拷贝”这个词的直觉是:是不是数据完全不拷贝了,直接从磁盘飞到网卡?后来越看越发现,不是这么回事。
更准确地说:
零拷贝不是完全没有数据移动,而是尽量减少 CPU 参与的数据拷贝,以及减少用户态和内核态之间那种没必要的中转。
如果把这个前提先立住,后面很多细节就顺了。
一、为什么会有零拷贝
最典型的场景其实不复杂,就是:
服务器要把一个静态文件发给客户端。
例如:
- 图片
- 视频
- HTML / CSS / JS
- 安装包
- 下载文件
如果用最普通的方式写,逻辑大概是这样:
read(file_fd, buf, len);
write(sock_fd, buf, len);单看代码没什么问题,但这条路径里有个很明显的浪费:
用户态缓冲区只是一个中转站,程序自己根本没打算修改这份数据。
既然只是“从文件里拿出来,再原样发给 socket”,那这段数据先绕到用户态一圈,其实是没有必要的。
二、传统 read + write 到底在做什么
先把这条经典路径看清楚。
假设要把文件内容发给 socket,大致的数据流可以理解成:
磁盘
↓ DMA
page cache
↓ CPU copy
用户缓冲区 buf
↓ CPU copy
socket 发送路径
↓ DMA
网卡这里我最开始容易忽略的,是中间这两步:
1. page cache -> 用户缓冲区
调用 read() 时,文件内容通常先在内核里的 page cache 中,然后再复制到用户态的 buf。
2. 用户缓冲区 -> socket 发送路径
调用 write(sock_fd, buf, len) 时,用户态缓冲区里的数据又要再交回内核网络发送路径。
所以如果程序只是做“文件中转”,那这里就多出了两次额外的 CPU copy。
除了拷贝本身,还有两次系统调用链:
read()一次write()一次
如果从态切换角度看,也就是:
- 用户态 → 内核态 → 用户态
- 用户态 → 内核态 → 用户态
所以传统 read + write 的问题,不是不能用,而是:
对于不需要修改内容的静态资源来说,用户态参与得太多了。
三、零拷贝到底在优化什么
我现在更愿意把零拷贝理解成两件事:
1. 尽量别让数据白白进用户态再回来
如果用户程序只是个搬运工,没有对数据做任何加工,那就不应该让文件内容先拷到用户缓冲区,再从用户缓冲区拷回内核。
2. 尽量减少 CPU copy 和态切换开销
也就是说,零拷贝主要优化的是:
- 少一次或两次 CPU copy
- 少一次或几次系统调用带来的态切换
- 少占用 CPU cache 和内存带宽
所以“零拷贝”这个词,如果只从字面理解,容易误会。更准确一点可以说成:
它追求的不是完全零移动,而是零额外的用户态中转、零多余的 CPU copy。
四、为什么说零拷贝不是“完全没有拷贝”
这一点我一开始也容易想歪。
数据要从文件最终发到网络,对应的物理移动本来就不可能凭空消失,例如:
- 磁盘把数据搬到内存
- 网卡再把数据从内存拿走发出去
这些移动还是存在的,只不过它们很多时候是由 DMA 完成,而不是 CPU 一字节一字节去拷。
所以更准确地说:
- 磁盘 → 内存,这种移动还在
- 内存 → 网卡,这种移动也还在
- 真正想减少的是:内核 ↔ 用户态之间那种没有必要的来回复制
所以“零拷贝”这个词里的“零”,更像是在说:
尽量做到零额外 CPU copy,而不是零数据流动。
五、mmap 在这里减少了什么
mmap 这块特别容易和 sendfile 混在一起,所以我单独拆开记。
如果是基于文件的 mmap,更准确的理解是:
它不是把文件内容再拷贝一份到用户缓冲区,而是把文件对应的页映射到进程的用户虚拟地址空间里。
这里最关键的是“映射”,不是“复制”。
1. 和 read() 的区别
如果是 read(),大致路径是:
page cache -> 用户缓冲区 buf这里有一次明确的数据复制。
如果是 mmap(),更像是:
用户虚拟地址 ----页表映射----> 文件页所以用户空间并不是拿到一份新的副本,而是通过自己的虚拟地址直接访问那批文件页。
对于普通文件映射来说,这些页通常就和 page cache 体系相关。
2. 它减少的是哪一步
如果拿 mmap + write 去和 read + write 比,真正减少的是:
page cache -> 用户缓冲区这一步 copy。
但后面的:
用户空间 -> socket 发送路径通常还是存在的。
所以我现在更愿意这样记:
mmap + write只是减少了一次 copy,它不是最彻底的零拷贝。
3. MAP_SHARED 和 MAP_PRIVATE
顺手也记一下。
MAP_SHARED
更接近共享这批文件页。修改映射区内容后,对应页可能变脏,后续可以回写到文件。
MAP_PRIVATE
开始时也可能先基于文件页映射,但如果进程去写,会触发 COW(写时复制),变成自己的私有匿名页。
所以如果是 MAP_PRIVATE,不能简单理解成“永远都在直接改 page cache”。
六、sendfile 到底在做什么
如果说 mmap + write 是少一次 copy,那 sendfile 就更进一步。
我现在对它最顺手的理解是:
sendfile就是把“先read到用户缓冲区,再write到 socket”这套两步中转,压缩成一次由内核直接完成的文件发送过程。
接口形式大致像这样:
sendfile(sock_fd, file_fd, &offset, count);也就是说:
- 输入端是文件 fd
- 输出端是 socket fd
- 用户态不再拿一块大
buf做中转
1. sendfile 的大致数据流
如果把过程粗略画出来,大致是:
磁盘
↓ DMA
page cache
↓ 页引用 / 发送描述
socket 发送路径
↓ DMA
网卡这里最核心的变化不是“数据突然不经过内存了”,而是:
文件页进入
page cache之后,不再拷到用户态缓冲区,而是由内核直接把这些页交给网络发送路径使用。
2. 它减少了什么
和传统 read + write 比,最关键的收益是:
少了两次用户态相关的 CPU copy
不再需要:
page cache -> 用户缓冲区用户缓冲区 -> socket 发送路径
少了一次系统调用链
原来是:
read()write()
现在变成:
sendfile()
所以系统调用带来的态切换也少了。
七、一次 sendfile 的数据流转过程
把这个过程完整想一遍会更清楚。
假设用户进程调用:
sendfile(sock_fd, file_fd, &offset, 65536);1. 先进入内核
用户态发起系统调用后,CPU 切到内核态。内核先根据:
file_fdsock_fd
找到对应的文件对象和 socket 对象。
2. 检查文件页是否已经在 page cache
这时内核会看:这 64KB 对应的文件页是否已经在内存中。
如果已经在
那就可以直接走后续发送路径。
如果不在
那就要先从磁盘把它们读进来:
磁盘
↓ DMA
page cache所以 sendfile 不是“磁盘直接发网卡”,它通常还是先把文件页放进 page cache。
3. 网络发送路径直接引用这些页
这一步才是关键。
sendfile 不会再让数据经过用户缓冲区,而是更像这样:
- 发送路径拿到这些
page cache页的引用 - 记录这些页从哪里开始、长度是多少
- 再配合协议头一起组织成发送描述
也就是说,发送出去的不是另一份新的数据副本,而是:
“文件内容就在这些页里,你按这批页去发。”
4. 网卡通过 DMA 取数据发出去
最终真正发上网线,还是要靠网卡从内存中把数据取走:
内存
↓ DMA
网卡所以 sendfile 优化的是“用户态中转”和“CPU copy”,不是把“内存”这个环节彻底删掉。
八、为什么 sendfile 比 mmap + write 更彻底
这个点如果不单独比较,很容易含糊。
1. read + write
路径是:
磁盘 -> page cache -> 用户 buf -> socket 发送路径 -> 网卡两次 CPU copy。
2. mmap + write
路径更接近:
磁盘 -> page cache -(映射)-> 用户虚拟地址 -> socket 发送路径 -> 网卡减少了一次:
page cache -> 用户 buf
但“用户空间 -> socket 发送路径”这一步通常还在。
3. sendfile
路径更接近:
磁盘 -> page cache -> socket 发送路径 -> 网卡也就是说,它把“用户空间中转”整段都拿掉了。
所以最稳的区分方式是:
read + write:两次 copymmap + write:少一次 copysendfile:尽量去掉整段用户态中转
九、sendfile 和 page cache 的关系
这也是一个特别容易误解的点。
很多人一听零拷贝,就会下意识觉得它是在绕过缓存。实际上不是。
更准确地说:
sendfile通常不是绕过page cache,而是复用page cache。
也就是说:
- 文件页如果不在内存里,还是要先读进
page cache - 后续发送时,内核直接利用这些文件页
所以 sendfile 的核心不是:
- 不走缓存
而是:
- 不走用户态缓冲区
这点一定要和直接 I/O 分开。
十、零拷贝和直接 I/O 不是一回事
我现在更愿意把这两个概念这样分开:
1. 直接 I/O
关注的是:
文件读写是否绕过
page cache
2. 零拷贝
关注的是:
数据在传输过程中,是否减少了用户态中转和额外的 CPU copy
所以:
- 直接 I/O 是“缓存路径”问题
- 零拷贝是“数据搬运路径”问题
它们不在一个层面上。
十一、零拷贝和阻塞 / 非阻塞、同步 / 异步也不是一回事
这几个概念也特别容易一起打包说乱。
1. 阻塞 / 非阻塞
关注的是:
当前这次调用如果条件不满足,是等待还是立刻返回。
2. 同步 / 异步
关注的是:
I/O 完成这件事,到底是不是要调用方自己等。
3. 零拷贝
关注的是:
数据在搬运时有没有多余的复制和多余的用户态中转。
所以零拷贝不等于:
- 异步
- 非阻塞
它们是不同维度的问题。
十二、什么时候最适合用零拷贝
最典型的就是:
1. 静态文件发送
例如:
- 图片
- 视频
- 前端静态资源
- 下载包
2. 用户态不需要改内容
如果程序只是把文件原样发出去,而不是:
- 压缩
- 加密
- 替换内容
- 重新组装正文
那就很适合用 sendfile 这类方式。
3. 大文件、高并发场景
文件越大、连接越多,省掉 copy 和态切换的收益就越明显。
十三、什么时候不适合
也不是所有场景都应该硬上零拷贝。
1. 需要改数据内容
如果用户态必须真的处理内容,那数据绕过用户态就没有意义了。
2. 小数据场景
数据量很小的时候,收益未必很明显。
3. 某些用户态 TLS / 加密场景
如果加密逻辑在用户空间,那数据还是得进用户态处理。
十四、小结
把这一块重新收一下,我现在更愿意这样理解:
零拷贝的核心,不是让数据完全不动,而是尽量减少那种没必要的用户态中转和 CPU copy。传统
read + write在发送静态文件时,会让文件内容先从page cache拷到用户缓冲区,再从用户缓冲区拷回内核网络发送路径,这对于不需要修改内容的场景来说是明显浪费。mmap + write可以减少一次page cache -> 用户缓冲区的复制,而sendfile更进一步,直接让内核把文件对应的page cache页交给 socket 发送路径使用,从而减少 CPU 拷贝、减少系统调用带来的态切换、减轻 cache 污染。它通常并不绕过page cache,也不等于数据完全不经过内存,更不等于和异步、非阻塞、直接 I/O 是一回事。把这些边界分开之后,零拷贝这块就会清楚很多。
