零拷贝原理的文章网上满天飞,但你知道如何使用零拷贝吗?
零拷贝是中间件相关面试中必考题,本文就和大家一起来总结一下 NIO 拷贝的原理,并结合 Netty 代码,从代码实现层面近距离观摩如何使用 java 实现零拷贝。
1、零拷贝实现原理
**“零拷贝”**其实包括两个层面的含义:
拷贝一份相同的数据从一个地方移动到另外一个地方的过程,叫拷贝。
零希望在 IO 读写过程中,CPU 控制的数据拷贝到次数为 0。
在 IO 编程领域,当然是拷贝的次数越少越好,逐步优化,将其拷贝次数将为 0,最大化的提高性能。
那接下来我们循序渐进来看一下如何减少数据复制。
接下来我们将以 RocketMQ 消息发送、消息读取场景来阐述 IO 读写过程中可能需要进行的数据复制与上下文切换。
1.1 传统的 IO 读流程
一次传统的 IO 读序列流程如下所示:
java 应用中,如果要将从文件中读取数据,其基本的流程如下所示:
当 broker 收到拉取请求时发起一次 read 系统调用,此时操作系统会进行一次上下文的切换,从用态间切换到内核态。
通过直接存储访问器(DMA)从磁盘将数据加载到内核缓存区(DMA Copy,这个阶段不需要 CPU 参与,如果是阻塞型 IO,该过程用户线程会处于阻塞状态)
然后在 CPU 的控制下,将内核缓存区的数据 copy 到用户空间的缓存区(由于这个是操作系统级别的行为,通常这里指的内存缓存区,通常使用的是堆外内存),这里将发生一次 CPU 复制与一次上下文切换(从内核态切换到用户态)
将堆外内存中的数据复制到应用程序的堆内存,供应用程序使用,本次复制需要经过 CPU 控制。
将数据加载到堆空间,需要传输到网卡,这个过程又要进入到内核空间,然后复制到 sockebuffer,然后进入网卡协议引擎,从而进入到网络传输中。该部分会在接下来会详细介绍。
温馨提示:RocketMQ 底层的工作机制并不是上述模型,是经过优化后的读写模型,本文将循序渐进的介绍优化过程。
1.2 传统的 IO 写流程
一次传统的 IO 写入流程如下图所示:
核心关键步骤如下:
在 broker 收到消息时首先会在堆空间中创建一个堆缓存区,用于存储用户需要写入的数据,然后需要将 jvm 堆内存中数据复制到操作系统内存(CPU COPY)
发起 write 系统调用,将用户空间中的数据复制到内存缓存区,**此过程发生一次上下文切换(用户态切换到内核态)**并进行一次 CPU Copy。
通过直接存储访问器(DMA)将内核空间的数据写入到磁盘,并返回结果,此过程发生一次 DMA Copy 与一次上下文切换(内核态切换到用户态)
1.3 读写优化技巧
从上面两张流程图,我们不能看出读写处理流程中存在太多复制,同样的数据需要被复制多次,造成性能损耗,故 IO 读写通常的优化方向主要为:减少复制次数、减少用户态/内核态切换次数。
1.3.1 引入堆外内存
jvm 堆空间中数据要发送到内核缓存区,通常需要先将 jvm 堆空间中的数据拷贝到系统内存(一个非官方的理解,用 C 语言实现的本地方法调用中,首先需要将堆空间中数据拷贝到 C 语言相关的存储结构),故提高性能的第一个措施:使用堆外内存。
不过堆外内存中的数据,通常还是需要从堆空间中获取,从这个角度来看,貌似提升的性能有限。
1.3.2 引入内存映射(MMap 与 write)
通过引入内存映射机制,减少用户空间与内核空间之间的数据复制,如下图所示:
内存映射的核心思想就是将内核缓存区、用户空间缓存区映射到同一个物理地址上,可以减少用户缓存区与内核缓存区之间的数据拷贝。
但由于内存映射机制并不会减少上下文切换次数。
1.3.3 大名鼎鼎鼎 sendfile
在 Linux 2.1 内核引入了 sendfile 函数用于将文件通过 socket 传送。
注意 sendfile 的传播方向:使用于将文件中的内容直接传播到 Socket,通常使用客户端从服务端文件中读取数据,在服务端内部实现零拷贝。
在 1.3.1 中介绍客户端从服务端读取消息的过程中,并没有展开介绍从服务端写入到客户端网络中的过程,接下来看看 sendfile 的数据拷贝图解:
sendfile 的主要特点是在内核空间中通过 DMA 将数据从磁盘文件拷贝到内核缓存区,然后可以直接将内核缓存区中的数据在 CPU 控制下将数据复制到 socket 缓存区,最终在 DMA 的控制下将 socketbufer 中拷贝到协议引擎,然后经网卡传输到目标端。
sendfile 的优势(特点):
一次 sendfile 调用会只设计两次上下文切换,比 read+write 减少两次上下文切换。
一次 sendfile 会存在 3 次 copy,其中一次 CPU 拷贝,两次 DMA 拷贝。
1.3.4 Linux Gather
Linux2.4 内核引入了 gather 机制,用以消除最后一次 CPU 拷贝,即不再将内核缓存区中的数据拷贝到 socketbuffer,而是将内存缓存区中的内存地址、需要读取数据的长度写入到 socketbuffer 中,然后 DMA 直接根据 socketbuffer 中存储的内存地址,直接从内核缓存区中的数据拷贝到协议引擎(注意,这次拷贝由 DMA 控制)。
从而实现真正的零拷贝。
2、结合 Netty 谈零拷贝实战
上面讲述了“零拷贝”的实现原理,接下来将尝试从 Netty 源码去探究在代码层面如何使用“零拷贝”。
从网上的资料可以得知,在 java nio 提供的类库中真正能运用底层操作系统的零拷贝机制只有 FileChannel 的 transferTo,而在 Netty 中也不出意料的对这种方式进行了封装,其类图如下:
其主要的核心要点是 FileRegion 的 transferTo 方法,我们结合该方法再来介绍 DefaultFileRegion 各个核心属性的含义。
上述代码并不复杂,我们不难得出如下观点:
首先介绍 DefaultFileRegion 的核心属性含义:File f 底层抽取数据来源的底层磁盘文件 FileChannel file 底层文件的文件通道。long position 数据从通道中抽取的起始位置 long count 需要传递的总字节数 long transfered 已传递的字节数量。
核心要点是调用 java nio FileChannel 的 transferTo 方法,底层调用的是操作系统的 sendfile 函数,即真正的零拷贝。
调用一次 transferTo 方法并不一定能将需要的数据全部传输完成,故该方法返回已传输的字节数,是否需要再次调用该方法的判断方法:已传递的字节数是否等于需要传递的总字节数(transfered == count)
接下来我们看一下 FileRegion 的 transferTo 在 netty 中的调用链,从而推断一下 Netty 中的零拷贝的触发要点。
在 Netty 中代表两个类型的通道:
EpollSocketChannel 基于 Epoll 机制进行事件的就绪选择机制。
NioSocketChannel
基于 select 机制的事件就绪选择。
在 Netty 中调用通道 Channel 的 flush 或 writeAndFlush 方法,都会最终触发底层通道的网络写事件,如果待写入的对象是 FileRegion,则会触发零拷贝机制,接下来我们对两个简单介绍一下:
2.1 EpollSocketChannel 通道零拷贝
写入的入口函数为如下:
核心思想为:如果待写入的消息是 DefaultFileRegion,EpollSocketChannel 将直接调用 sendfile 函数进行数据传递;如果是 FileRegion 类型,则按照约定调用 FileRegion 的 transferTo 进行数据传递,这种方式是否真正进行零拷贝取决于 FileRegion 的 transferTo 中是否调用了 FileChannel 的 transferTo 方法。
温馨提示:本文并没有打算详细分析 Epoll 机制以及编程实践。
2.2 NioSocketChannel 通道零拷贝实现
实现入口为:
从这里可知,NioSocketChannel 就是中规中矩的调用 FileRegion 的 transferTo 方法,是否真正实现了零拷贝,取决于底层是否调用了 FileChannel 的 transferTo 方法。
2.3 零拷贝实践总结
从 Netty 的实现中我们基本可以得出结论:是否是零拷贝,判断的依据是是否调用了 FileChannel 的 transferTo 方法,更准备的表述是底层是否调用了操作系统的 sendfile 函数,并且操作系统底层还需要支持 gather 机制,即 linux 的内核版本不低于 2.4。
文章首发于https://www.codingw.net/posts/e587b550.html
作者简介:丁威,《RocketMQ 技术内幕》一书作者、RocketMQ 开源社区优秀布道师,公众号「中间件兴趣圈」维护者,主打成体系剖析 Java 主流中间件,已发布 Kafka、RocketMQ、Dubbo、Sentinel、Canal、ElasticJob 等中间件 15 个专栏。
版权声明: 本文为 InfoQ 作者【中间件兴趣圈】的原创文章。
原文链接:【http://xie.infoq.cn/article/f4ee562ef55349ae2917593c4】。文章转载请联系作者。
评论