零拷贝技术理解

概念

“零拷贝”指计算机执行 I/O 操作时 CPU 不需要将数据从一个存储区域复制到另一个存储区域,通常用于在通过网络传输文件时减少内存带宽、进程上下文切换以及 CPU 的拷贝时间,用来优化读写 IO 操作(零拷贝并不意味着真的没有复制,而是减少不必要的数据拷贝)。

本文介绍两种常见的零拷贝技术,mmap()sendfile()

传统 I/O

Untitled

每一次 I/O 读写(读磁盘写网卡)都需要经历 4 次用户态和内核态上下文,即每次对 read()/write() 调用和返回时。完成 4 次内存拷贝: I/O 设备 -> 内核空间 -> 用户空间 -> 内核空间 -> 其它 I/O 设备,其中包含 2 次 CPU 拷贝。

CPU 拷贝是代价很大的操作,特别是那些频繁 I/O 的场景,更是会因为 CPU 拷贝而损失掉很多性能,我们需要进一步优化,降低、甚至是完全避免 CPU 拷贝。

注:DMA 全称是 Direct Memory Access,也即直接存储器存取,是一种用来提供在外设和存储器之间或者存储器和存储器之间的高速数据传输。整个过程无须 CPU 参与,数据直接通过 DMA 控制器进行快速地移动拷贝,节省 CPU 的资源去做其他工作。

零拷贝技术

mmap()

Untitled

Linux 内核中的 mmap() 函数可以代替 read()/write() 操作,实现用户空间和内核空间共享一个缓存数据。这种方式避免了内核空间与用户空间的数据交换,并且能够读文件也能写文件。

mmap() 让你把文件的某一段,当作内存一样来访问。将磁盘文件映射到物理内存,再将进程虚拟空间映射到那块物理内存。在真正的读写发生前,mmap() 并不分配物理地址空间,即没有发生数据拷贝,只是占有进程的虚拟地址空间。当进程真正发生读写时,MMU 在地址映射表中是无法找到与地址空间相对应的物理地址的,也就是 MMU 失败,就会触发缺页中断。内核将文件的这一页数据读入到内核高速缓冲区中,并更新进程的页表,使页表指向内核缓冲中的这一页。之后有其他的进程再次访问这一页的时候,该页已经在内存中了,内核只需要将进程的页表登记并且指向内核的页高速缓冲区即可。

Untitled

mmap() 这种方式,有两个优点:一是节省内存空间,因为用户进程上的这一段内存是虚拟的,并不真正占据物理内存,只是映射到文件所在的内核缓冲区上,因此可以节省一半的内存占用;二是省去了一次 CPU 拷贝,对比传统的 Linux I/O 读写,数据不需要再经过用户进程进行转发了,而是直接在内核里就完成了拷贝。

mmap() 的拷贝次数是 2 次 DMA 拷贝,1 次 CPU 拷贝,加起来一共 3 次拷贝操作,比传统的 I/O 方式节省了一次 CPU 拷贝以及一半的内存,不过因为 mmap() 也是一个系统调用,因此用户态和内核态的切换还是 4 次。

既然 mmap 这么好用,那为什么不直接替换掉传统的 I/O 函数呢?
一个是 mmap 仅支持读写磁盘文件,并且 mmap 是 POSIX 标准下的系统函数,只在 Linux、MacOS等系统下存在。另外 mmap 的对齐方式是 page 为大小的,有存在内存内部碎片的可能(调用的时候 length没有对齐),所以 mmap 不适合小文件,所以并不是所有场景 mmap 都比传统 IO 优秀,具体可以根据性能测试结果来选择。

sendfile()

Untitled

在 Linux 内核 2.1 版本中,引入了一个新的系统调用 sendfile()

从功能上来看,这个系统调用将 mmap() + write() 这两个系统调用合二为一,实现了一样效果的同时还简化了用户接口。

基于 sendfile(), 整个数据传输过程中共发生 2 次 DMA 拷贝和 1 次 CPU 拷贝,这个和 mmap() + write() 相同,但是因为 sendfile() 只是一次系统调用,因此比前者少了一次用户态和内核态的上下文切换开销。

sendfile() 的最初设计并不是用来处理大文件的,因此如果需要处理很大的文件的话,可以使用另一个系统调用 sendfile64(),它支持对更大的文件内容进行寻址和偏移。

应用

Netty

Netty 的零拷贝并非特指一项技术,而是多个细节方面的实现,和操作系统中的 Zero Copy 有着本质的区别。其更多是表现在用户空间层面对数据操作的优化。

Netty中的零拷贝主要体现在以下几个方面 :

  • ByteBuf 的 slice 操作并不会拷贝一份新的 ByteBuf 内存空间,而是直接借用原来的 ByteBuf ,只是独立地保存读写索引。
  • Netty 提供了 CompositeByteBuf 类,可以将多个 ByteBuf 组合成一个逻辑上的 ByteBuf 。
  • Netty 的 FileRegion 中包装了 NIO 的 FileChannel.transferTo()方法,该方法在底层系统支持的情况下会调用 sendfile() 方法,从而在传输文件时避免了用户态的内存拷贝。
  • Netty 的 PooledDirectByteBuf 等类中封装了 NIO 的 DirectByteBuffer ,而 DirectByteBuffer 是直接在 jvm 堆外分配的内存,省去了堆外内存向堆内存拷贝的开销。

Kafka

Kafka 作为高性能 MQ,其主要的功能是将消息发送到消费者,而 Kafka 的消息数据是持久化到每个 Partition下的 .log 文件中的,当需要消费已经持久化的消息时,势必需要从磁盘中将数据读取到内存中,并通过网卡发送给消费者。这里 Kafka 采用的是 sendfile() 方式进行零拷贝优化。

另外,Kafka 使用了 mmap() 读写 index 文件。

RocketMQ

同 Kafka 类似,RocketMQ 对消息消费场景进行了零拷贝优化,不同的地方在于,RocketMQ 采用的是 mmap() 的方法对持久化的消息文件 commitlog 进行优化。

这里有一个问题,为什么不和 Kafka 一样采用 sendfile() 呢?github 上也有对该问题的讨论,有观点认为 RocketMQ 是以一个 commitlog 保存多个 Topic 的消息,服务端需要做查找过滤,也就是需要对磁盘文件读取出来的结果做加工再返回,而 Kafka 消息持久化文件是 partition 级别的,不需要处理直接返回给客户端就行,所以用 sendfile() 会更合适。

参考

linux-io-and-zero-copy

详解Netty Zero Copy机制

磁盘读写优化技术研究

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

Scroll to Top