之前介绍 消息队列发展史 时,介绍过 kafka 做到高吞吐的一个优化点就是引入了「零拷贝」技术,今天从零拷贝入口了解下 linux 的 IO 过程
专业名词字典
可以等到读到后面的时候再来翻一下:
- DMA: Direct Memory Access,直接存储器访问。代替 cpu 控制磁盘 IO,避免 cpu 在磁盘操作时承担过多的中断负载
- MMU: Memory Management Unit,内存管理单元
- Page Cache : 页缓存,以数据页为单位对数据进行缓存。是内核态的缓存 ,使用了磁盘「预读」
- 虚拟内存 : 一个虚拟的内存地址,可以映射到物理地址。目的为每个进程提供一个私有连续的内存空间,可以把用户空间和内核空间的虚拟地址映射到同一个物理地址,这样就不用实际复制数据
- NFS: 网格文件系统,允许用户和程序像访问本地文件一样访问远端系统文件
- COW: Copy-on-write,写入时复制。多个调用者访问相同资源时共同持有该资源,只有有修改时才单独为修改方提供一个副本。减少对象的复制成本
磁盘 IO 中 CPU 的瓶颈
我们对存储器的美好愿景:
- 速度足够快: 存取速度只要慢于CPU,就会成为瓶颈
- 容量足够大
- 足够便宜
愿景是美好的,实际存储中做了分层架构:
- 寄存器: 速度和 cpu 一样快,cpu 访问没有时延。价格贵,容量极小
- 高速缓存: cpu中的多级缓存, 参考 CPU中的多级缓存实现及问题
- 主存: 主内存,通常称为 随机访问存储器(Random Access Memory, RAM),与 cpu 直接交换数据
- 磁盘: 读写慢,有很多优化方式,零拷贝、异步IO、direct IO 等等
缓存区可以减少磁盘 IO 操作,但传统 IO 操作深度依赖 cpu,限制了操作系统有效进行数据传输操作的能力。
怎么解放 cpu 的生产力?
在没有DMA之前,传输数据的过程如下图,cpu需要参与数据的传输过程,期间是阻塞性的
DMA 就是在 cpu 和磁盘之间增加了一层代理,把 cpu负责的磁盘数据搬运委托给下一层处理,从而可以把 cpu 解放出来
但该过程中,cpu 仍要负责内核态的 page cache 与用户态缓冲区之间的数据交换。我们来看个实际场景:读取磁盘文件并通过网络发送给客户端。大概需要两行代码:
bash
read(file, tmp_buf, len)
write(socket, tmp_buf, len)
实际的系统交互:
这个过程中发生的耗时操作:
- 4次 用户态/内核态 的切换
- 4次数据拷贝,2 次 DMA 完成,2 次 cpu 完成
为什么读取数据涉及到用户态和内核态的上下文切换?
Answer: 用户态没有权限操作磁盘和网卡,只能通过系统调用的方式,交由内核态完成
那么优化的思路就变为了:
- 尽量减少用户态和内核态的切换
- 尽量减少数据拷贝的次数
在这个背景下,「零拷贝」技术就应运而生
什么是零拷贝?
零拷贝解决的问题: 跨过与用户态交互的过程,直接将数据从文件系统移动到网络接口。常用的实现方式:
- mmap + write
- sendfile
- splice
1. mmap + write 优化读取成本
使用 mmap 代替 read 函数,mmap 会在调用进程的虚拟地址空间中创建一个新映射,直接把内核缓冲区的数据「映射」到用户空间,内核与用户空间就不会再有数据拷贝操作
优化后效果:4次上下文切换 + 2次 DMA 拷贝 + 1次 CPU 拷贝
2. sendfile 优化
linux 2.1 之后专门提供了发送文件的系统调用函数 sendfile,可以用来代替 read 和 write 两个函数,同时也不再涉及到数据的复制过程。优化后的过程如下:
优化后效果:2次上下文切换 + 2次 DMA 拷贝 + 1次 CPU 拷贝
linux 2.4 之后,提供了 scatter/gather 的 sendfile 操作,网卡的 socket buffer维护一个虚拟地址,省去一次内核态的 cpu 复制
优化后效果: 2次上下文切换 + 2次 DMA 拷贝 + 0次 CPU 拷贝
3. splice
splice并限制数据需要发送到socket,支持任意两个文件之间互相连接,不需要硬件支持。内部实现依赖 pipe buffer,因此输入/输出文件描述符必须有一个是管道设备。执行过程如下图所示:
优化后效果: 2次上下文切换 + 2次 DMA 拷贝 + 0次 CPU 拷贝
前面提到的 kafka 也利用了「零拷贝」技术,从而大幅提升了I/O,减少了数据的复制和传输时间,最终底层应用也是 sendfile。Nginx 也默认开启了零拷贝。
但是如果文件比较大,达到了GB,这时 page cache 会失效,此时怎么处理呢?
大文件传输的解决方案 - Direct I/O
我们先考虑读取文件时采用异步IO:
- 阶段1: 内核向磁盘发起请求,随后可以继续处理其他事
- 阶段2: 内核将磁盘中数据复制到进程缓冲区后,用户进程会收到内核的通知去处理数据
通过这个图可以看出来,异步IO没有经过 pageCache,而是直接将磁盘控制器缓冲区的数据复制到了用户缓冲区,原因是 pageCache 的填写过程是阻塞的,不符合异步要求。这个复制过程使用的就是 direct I/O.
内核态和用户态传输优化
前面提到的零拷贝旨在解决用户不需要感知并处理磁盘数据的场景,但是更多的场景下是需要处理数据的,那么怎么优化呢?
- COW 写时拷贝
- 缓冲区共享
1. COW 写时拷贝
主要思路:异步对共享缓存区进行读写
实现机制:基于虚拟内存重映射,并需要 MMU 硬件支持标记内存页状态,尝试在共享页写入数据时再分配一份物理内存并复制数据。
适用场景:读多写少
实际应用:Redis
2. 缓冲区共享
主要思路:避免过多的虚拟地址转换 和 TLB刷新的开销
TLB: TLB是Translation Lookaside Buffer的简称,可翻译为"地址转换后援缓冲器",也可简称为"快表"。简单地说,TLB就是页表的Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。只有在TLB无法完成地址翻译任务时,才会到内存中查询页表,这样就减少了页表查询导致的处理器性能下降
实现机制: 定义一些缓存空间,会被同时映射到用户内存空间和内核内存空间,解决存储一致性问题
大多还处于实验阶段
总结
大致总结一下本文聊的问题和解决方案:
- 同步复制阻塞CPU -> DMA
- 减少复制过程中的上下文切换和复制开销 -> 零拷贝
- 大文件传输不能使用零拷贝 -> 异步I/O + direct I/O
- 必须需要上下文切换和复制数据,想降低传输成本 -> COW
其中读写数据的性能开销汇总如下表: