前言
Linux 系统安全性更高的原因之一就是系统是区分用户态、内核态。如果想要进行硬件调用操作,必须要切换到内核态空间。对于用户进程来说,是没办法操作任何操作系统硬件的。
如果当前应用程序需要进行一个文件读写操作(例如 MySQL 写数据到磁盘),该操作需要将数据写入到磁盘,其实就需要转换成内核态,首先将数据写入到 Kernel Buffer Cache的Page Cache
Linux 2.4 之前,Kernel Buffer Cache区域属于 Page Cache 和 Buffer Cache 组合;Linux 2.4 之后 Buffer Cache 已经和 Page Cache 统一了,Buffer Cache 现在只是 Page Cache 的一个视图(用于块设备元数据),不再是两块独立的内存
磁盘文件读写操作单位是单页 Page Cache(4KB大小),数据按照 Page 页写入到 Page Cache 之后就是 Dirty Page,然后通过 flush 方式写入到磁盘 Disk。
如果是读磁盘操作,会先看 Page Cache 中是否有对应数据,有直接返回;没有就从磁盘加载到 Page Cache,然后从 Page Cache 进行命中返回,Page Cache 会根据 LRU 算法来进行 Page 的定期淘汰(跟 Redis 内存淘汰策略类似)。如下图:用户数据从用户态到内核态到磁盘的大致流程。
传统 Linux IO:read() + wirte()
2次 CPU Copy + 2次 DMA Copy
传统 Linux 系统的 IO 读写主要就是两个系统调用函数read() + write(),其实可以这么理解,只要是涉及到磁盘写入、读取操作,本质背后就是调用这两个系统函数罢了,不管是MySQL、Redis、Kafka 等其他程序写磁盘操作。
如下图:一次IO读写操作操作是需要 4 次用户态、内核态上下文切换,就是发生在系统调用 read()、write() 调用时候进行切换,经历2次CPU Copy + 2次 DMA Copy,DMA 控制器是不需要占用CPU资源的,也就是说从磁盘中读写数据操作是不需要CPU占用等待的,所以就相当于2次CPU Copy 需要额外占用资源,尤其是IO密集型应用场景,频繁地 CPU Copy 操作会损失大量的很多性能。所以接下来考虑到如何避免掉 CPU Copy 操作,Zero-Copy 零拷贝 就是可以做到。
零拷贝 Zero Copy
零拷贝大白话来说,就是不需要数据从内核态进行 CPU Copy 到用户态,更不需要从用户态 CPU Copy 到内核态,直接就从内核态 Kernel Buffer Cache 同步到 Socket Buffer 网卡缓存,然后基于 DMA Copy 进行网卡数据发送,这样甚至连 CPU Copy 的操作都不需要了,尽量避免 CPU 参与的数据拷贝,尤其避免数据在“内核态 ↔ 用户态”之间来回复制。
mmap() + write()
1次 CPU Copy + 2次 DMA Copy
一种简单的 Zero-Copy 实现方案就是通过系统调用 mmap() 替换原本的 read(),mmap 是内存映射,相当于把用户态 User Buffer 中的内存缓冲区映射到内核态 Kernel Buffer 缓冲区,这样就减少了一次 CPU Copy,整体流程大致如下:用户进程系统调用mmap进入内核态,内核缓冲区映射用户缓冲区。将硬盘数据基于 DMA Copy 把数据复制到内核缓冲区,然后系统调用 write 开始进行 CPU Copy到套接字缓冲区(Socket Buffer),然后在基于 DMA Copy 到网卡进行数据传输。
对比传统 Linux IO,不仅少了一次CPU Copy,而且还节省了一半的内存区域,只需要从 Kernel Buffer Cache 进行 CPU Copy 到 Socket Buffer 就行了,用户态内核态切换次数还是4次。
sendfile()
Linux 2.1 版本引入系统调用 sendfile(),sendfile 的本质是 read + write 合并成一次系统调用,上下文切换减半,它相当于是 mmap + wirte 二合一。
扩展:Page Cache 和 Buffer Pool Page Cache
有一个容易混淆的概念,前面一直在说 OS 的 Page Cache(4KB),那 MySQL InnoDB 的 Buffer Pool Page(16KB) 两者什么关系?
OS Page Cache(4KB) 是通用的,无论什么文件都按 4KB 切块存;InnoDB Buffer Pool(16KB) 只缓存数据库页。一个 InnoDB 的 16KB Page,在 OS 层对应 4 个连续的 4KB Page Cache 页,MySQL 读一条数据的完整链路是这样的:
SQL 查询 ↓ Buffer Pool 未命中(16KB 的数据页) ↓ InnoDB 调用 read() 向 OS 要数据 ↓ OS 检查 Page Cache(对应 4 个 4KB 页) ↓ 未命中 DMA Copy:磁盘 → Page Cache ↓ CPU Copy:Page Cache → Buffer Pool ↓ InnoDB 在 16KB 页里定位到具体行,返回给 SQL 执行器
总结对比
| 对比项 | read/write | mmap + write | sendfile |
|---|---|---|---|
| 核心思路 | 先读到用户态,再写回内核 | 把内核文件页映射给用户访问,再写 socket | 内核直接把文件发到 socket |
| 是否经过用户缓冲区 | 会 | 不走传统用户缓冲区拷贝,但用户可通过映射访问 | 不会 |
| 典型路径 | 内核 -> 用户 -> 内核 | 内核页缓存映射给用户,再进入网络发送路径 | 内核 -> 内核 |
| CPU copy 数量 | 通常 2 次明显 CPU copy | 比 read/write 少 1 次 | 最少 |
| 系统调用 | read + write | mmap + write | sendfile |
| 应用是否直接拿到数据 | 会 | 会看到映射数据 | 不会 |
| 是否属于零拷贝 | 否 | 部分算,常叫半零拷贝 | 是,最典型 |
| 适合场景 | 通用业务处理 | 文件映射、随机访问 | 文件直接发网络、纯转发 |
| 优点 | 通用、简单、灵活 | 少一次复制,访问文件高效 | CPU 占用更低,吞吐更高 |
| 缺点 | 拷贝多,CPU 开销更大 | 语义更复杂,不是最彻底 | 适用场景没那么通用 |