零拷贝是一个耳熟能详的词语,在 Linux、Kafka、RocketMQ 等知名的产品中都有使用,通常用于提升 I/O 性能。而且零拷贝也是面试过程中的高频问题,那么你知道零拷贝体现在哪些地方吗?Netty 的零拷贝技术又是如何实现的呢?
在没有 DMA 技术前,I/O 的过程是这样的:
整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。
简单理解就是,在磁盘设备和内存进行数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。
具体过程:
可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
java read(file, tmp_buf, len); write(socket, tmp_buf, len);
代码很简单,虽然就两行代码,但是这里面发生了不少的事情。
初学 Java 时,我们在学习 IO 和 网络编程时,会使用以下代码:
java File file = new File("index.html"); RandomAccessFile raf = new RandomAccessFile(file, "rw"); byte[] arr = new byte[(int) file.length()]; raf.read(arr); Socket socket = new ServerSocket(8080).accept(); socket.getOutputStream().write(arr);
我们会调用 read 方法读取 index.html 的内容—— 变成字节数组,然后调用 write 方法,将 index.html 字节流写到 socket 中,那么,我们调用这两个方法,在 OS 底层发生了什么呢?我这里借鉴了一张其他文字的图片,尝试解释这个过程。
md 先上一张图,这张图就代表了传统 IO 传输文件的流程。 读取文件的时候,会从用户态切换为内核态,同时基于 DMA 引擎将磁盘文件拷贝到内核缓冲区。 看到这里,可能你就已经懵逼了,什么是用户态和内核态,什么是 DMA 拷贝,我用大白话解释一下 首先用户态其实就是 CPU 在执行你的代码,而内核态呢,其实就是你没有那个权限去操作硬件,所以只能交给系统去调用,这个时候就是内核态。 举个例子,你的女朋友需要你修个电脑(醒醒,但凡有一粒花生米也不至于喝成这样),我换个说法,假如你同班的女同学想让你修个电脑,但是宿管阿姨不肯放你进女生宿舍,这个时候你就是用户态,你不能进女生宿舍,所以你只能让宿管阿姨(内核态)来帮你把电脑取出来。 那什么是 DMA 拷贝呢,DMA(DirectMemoryAccess,直接内存存取)其实就是因为 CPU 老哥太累了,所以找了个小弟,就是 DMA 替他完成一部分的拷贝工作,这样 CPU 就能去做其他事情了。
md 讲完了内核态和用户态还有 DMA 的大概意思,我们接着回到刚才的 IO 流程中。 1.read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU,拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU)引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。 简而言之:磁盘--->内核缓存区 总结: 1次拷贝 1次切换(用户态到内核态) 2.发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。 简而言之:内核缓存区--->用户缓存区(可以理解为java进程) 总结: 1次拷贝 1次切换(内核态到用户态) 3.发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 Socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。 简而言之:用户缓存区(可以理解为java进程)--->内核缓存区(Socket 缓冲区) 总结: 1次拷贝 1次切换 4.第四次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。 简而言之:内核缓存区(Socket 缓冲区)--->网络协议引擎 总结: 1次拷贝 5.write 方法返回,再次从内核态切换到用户态。 总结: 1次切换 那么,这里指的用户态、内核态指的是什么?上下文切换又是什么? 简单来说,用户空间指的就是用户进程的运行空间,内核空间就是内核的运行空间。 如果进程运行在内核空间就是内核态,运行在用户空间就是用户态。 为了安全起见,他们之间是互相隔离的,而在用户态和内核态之间的上下文切换也是比较耗时的。 从上面我们可以看到,一次简单的IO过程产生了4次上下文切换,这个无疑在高并发场景下会对性能产生较大的影响。 那么什么又是 DMA 拷贝呢? 因为对于一个 IO 操作而言,都是通过 CPU 发出对应的指令来完成。但是,相比 CPU 来说,IO 的速度太慢了,CPU 有大量的时间处于等待 IO 的状态。 因此就产生了 DMA(Direct Memory Access)直接内存访问技术,本质上来说他就是一块主板上独立的芯片,通过它来进行内存和 IO 设备的数据传输,从而减少 CPU 的等待时间。 但是无论谁来拷贝,频繁的拷贝耗时也是对性能的影响。 通过上面的步骤可以发现传统的 IO 操作执行,有 4 次上下文的切换和 4 次拷贝,是不是很繁琐。
md 我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
传统的数据拷贝过程为什么不是将数据直接传输到用户缓冲区呢?其实引入内核缓冲区可以充当缓存的作用,这样就可以实现文件数据的预读,提升 I/O 的性能。
但是当请求数据量大于内核缓冲区大小时,在完成一次数据的读取到发送可能要经历数倍次数的数据拷贝,这就造成严重的性能损耗。
接下来我们介绍下使用零拷贝技术之后数据传输的流程。重新回顾一遍传统数据拷贝的过程,可以发现第二次和第三次拷贝是可以去除的,DMA 引擎从文件读取数据后放入到内核缓冲区,然后可以直接从内核缓冲区传输到 Socket 缓冲区,从而减少内存拷贝的次数。
在 Linux 中系统调用 sendfile() 可以实现将数据从一个文件描述符传输到另一个文件描述符,从而实现了零拷贝技术。
在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。
mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
mmap+write 简单来说就是使用 mmap 替换了 read+write 中的 read 操作,减少了一次 CPU 的拷贝。
mmap 主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。
mmap 通过内存映射,将文件映射到用户进程缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。如下图:
md 大致过程如下: 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。 整个过程发生了 4 次用户态和内核态的上下文切换和 3 次拷贝,具体流程如下: 1.mmap 调用导致用户态到内核态的一次变化,同时,第1次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU,拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU)引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。 DMA控制器把数据从硬盘中拷贝到内核缓冲区,上下文从内核态转为用户态,mmap 调用返回。 简而言之:磁盘--->内核缓存区 同时进行了内核缓冲区的数据映射到了用户缓冲区。 总结:1次拷贝 2次切换 2.我们调用 write 方法,系统将内核缓冲区的数据拷贝到 Socket 缓冲区,发生第2次数据拷贝。 此时,又发生了一次用户态到内核态的上下文切换。 总结:1次拷贝 1次切换 3.第3次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。 简而言之:内核缓存区(Socket 缓冲区)--->网络协议引擎 总结:1次拷贝 4.write 方法返回,再次从内核态切换到用户态。 总结: 1次切换
```md 传统:磁盘-内核 内核-用户 用户-内核(socket缓存区) 内核(socket缓存区) -协议引擎(网卡) mmap:磁盘-内核 内核-内核(socket缓存区) 内核(socket缓存区) -协议引擎(网卡) mmap 通过内存映射,将文件从内核缓冲区映射到用户缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。 传统 IO 里面从内核缓冲区到用户缓冲区有一次 CPU 拷贝,从用户缓冲区到 Socket 缓冲区又有一次 CPU 拷贝。mmap 则一步到位,直接基于 CPU 将内核缓冲区的数据拷贝到了 Socket 缓冲区。 mmap 的方式节省了一次 CPU 拷贝,同时由于用户进程中的内存是虚拟的,只是映射到内核的读缓冲区,所以可以节省一半的内存空间,比较适合大文件的传输。 之所以能够减少一次拷贝,就是因为 mmap 直接将磁盘文件数据映射到内核缓冲区,这个映射的过程是基于 DMA 拷贝的,同时用户缓冲区是跟内核缓冲区共享一块映射数据的,建立共享映射之后,就不需要从内核缓冲区拷贝到用户缓冲区了。 我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。 但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
RocketMQ 中就是使用的 mmap 来提升磁盘文件的读写性能。 如你所见,3次拷贝,4次切换,拷贝切换操作太多了。如何优化这些流程? ```
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:
```cmd
ssizet sendfile(int outfd, int infd, offt *offset, size_t count); ```
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
相比 mmap 来说,sendfile 同样减少了一次 CPU 拷贝,而且还减少了 2 次上下文切换。
```java
ssizet sendfile(int outfd, int infd, offt *offset, size_t count); ```
sendfile 是 Linux2.1 内核版本后引入的一个系统调用函数。通过使用 sendfile 数据可以直接在内核空间进行传输,因此避免了用户空间和内核空间的拷贝,同时由于使用 sendfile 替代了 read+write 从而节省了一次系统调用,也就是 2 次上下文切换。
md 整个过程发生了 2 次用户态和内核态的上下文切换和 3 次拷贝,具体流程如下: 用户进程通过 sendfile() 方法向操作系统发起调用,上下文从用户态转向内核态; 总结:1次切换 DMA控制器把数据从硬盘中拷贝到内核缓冲区。 总结:1次拷贝 CPU将内核缓冲区中数据拷贝到 socket 缓冲区。注意这里是cpu拷贝 总结:1次拷贝 DMA 控制器把数据从 socket 缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile 调用返回。 总结:1次拷贝 sendfile 方法 IO 数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。
总结:mmap减少了一次拷贝 sendFile减少了1次拷贝 2次切换
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:
cmd $ ethtool -k eth0 | grep scatter-gather scatter-gather: on
Linux2.4 内核版本之后对 sendfile 做了进一步优化,通过引入新的硬件支持,这个方式叫做 DMA Scatter/Gather 分散/收集功能。
所以,这个过程之中,只进行了 2 次数据拷贝,如下图:
它将读缓冲区中的数据描述信息——内存地址和偏移量记录到 socket 缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次 CPU 拷贝的过程。
md 整个过程发生了 2 次用户态和内核态的上下文切换和 2 次拷贝,其中更重要的是完全没有 CPU 拷贝,具体流程如下: 用户进程通过 sendfile() 方法向操作系统发起调用,上下文从用户态转向内核态; DMA 控制器利用 scatter 把数据从硬盘中拷贝到内核缓冲区离散存储; CPU 把内核缓冲区中的文件描述符和数据长度发送到 socket 缓冲区;注意是发送! DMA 控制器根据文件描述符和数据长度,使用 scatter/gather 把数据从内核缓冲区拷贝到网卡; sendfile() 调用返回,上下文从内核态切换回用户态。 DMA gather和 sendfile 一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。 DMA 引擎读取文件内容并拷贝到内核缓冲区,然后并没有再拷贝到 Socket 缓冲区,只是将数据的长度以及位置信息被追加到 Socket 缓冲区,然后 DMA 引擎根据这些描述信息,直接从内核缓冲区读取数据并传输到协议引擎中,从而消除最后一次 CPU 拷贝。
```md 可以看到在图中,已经没有了用户缓冲区,因为用户缓冲区是在用户空间的,所以没有了用户缓冲区也就意味着不需要上下文切换了,就省略了这一步的从内核态切换为用户态。
同时也不需要基于 CPU 将内核缓冲区的数据拷贝到 Socket 缓冲区了,只需要从内核缓冲区拷贝一些 offset 和 length 到 Socket 缓冲区。
接着从内核态切换到用户态,从内核缓冲区直接把数据拷贝到网络协议引擎里去;同时从 Socket 缓冲区里拷贝一些 offset 和 length 到网络协议引擎里去,但是这个 offset 和 length 的量很少,几乎可以忽略。
sendFile 整个过程只有两次上下文切换和两次 DMA 拷贝,很重要的一点是这里完全不需要 CPU 来进行拷贝了,所以才叫做零拷贝,这里的拷贝指的就是操作系统的层面。
那你肯定会问,那 mmap 里面有一次 CPU 拷贝为啥也算零拷贝,只能说那不算是严格意义上的零拷贝,但是他确实是优化了普通 IO 的执行流程,就像老婆饼里也没有老婆嘛。
Kafka 和 Tomcat 内部使用就是 sendFile 这种零拷贝。 RocketMQ 选择了 mmap + write 这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输;而 Kafka 采用的是 sendfile 这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。但是值得注意的一点是,Kafka 的索引文件使用的是 mmap + write 方式,数据文件使用的是 sendfile 方式。 ```
在这个选择上:rocketMQ 在消费消息时,使用了 mmap。kafka 使用了 sendFile。
由于 CPU 和 IO 速度的差异问题,产生了 DMA 技术,通过 DMA 搬运来减少 CPU 的等待时间。
传统的 IOread+write 方式会产生 2 次 DMA 拷贝 + 2 次 CPU 拷贝,同时有 4 次上下文切换。
而通过 mmap+write 方式则产生 2 次 DMA 拷贝 + 1 次 CPU 拷贝,4 次上下文切换,通过内存映射减少了一次 CPU 拷贝,可以减少内存使用,适合大文件的传输。
sendfile 方式是新增的一个系统调用函数,产生 2 次 DMA 拷贝 + 1 次 CPU 拷贝,但是只有 2 次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对 IO 数据不可见,适用于静态文件服务器。
sendfile+DMA gather 方式产生 2 次 DMA 拷贝,没有 CPU 拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。
传统 IO 执行的话需要 4 次上下文切换(用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态)和 4 次拷贝(磁盘文件 DMA 拷贝到内核缓冲区,内核缓冲区 CPU 拷贝到用户缓冲区,用户缓冲区 CPU 拷贝到 Socket 缓冲区,Socket 缓冲区 DMA 拷贝到协议引擎)。
mmap 将磁盘文件映射到内存,支持读和写,对内存的操作会反映在磁盘文件上,适合小数据量读写,需要 4 次上下文切换(用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态)和3 次拷贝(磁盘文件DMA拷贝到内核缓冲区,内核缓冲区 CPU 拷贝到 Socket 缓冲区,Socket 缓冲区 DMA 拷贝到协议引擎)。
sendfile 是将读到内核空间的数据,转到 socket buffer,进行网络发送,适合大文件传输,只需要 2 次上下文切换(用户态 -> 内核态 -> 用户态)和 2 次拷贝(磁盘文件 DMA 拷贝到内核缓冲区,内核缓冲区 DMA 拷贝到协议引擎)
在 Java 中也使用了零拷贝技术,它就是 NIO FileChannel 类中的 transferTo() 方法,transferTo() 底层就依赖了操作系统零拷贝的机制,它可以将数据从 FileChannel 直接传输到另外一个 Channel。基于Java 层操作优化,对数组缓存对象(ByteBuf )进行封装优化,通过对ByteBuf数据建立数据视图,支持ByteBuf 对象合并,切分,当底层仅保留一份数据存储,减少不必要拷贝
从目标通道中去复制原通道数据
java @Test public void test02() throws Exception { // 1、字节输入管道 FileInputStream is = new FileInputStream("data01.txt"); FileChannel isChannel = is.getChannel(); // 2、字节输出流管道 FileOutputStream fos = new FileOutputStream("data03.txt"); FileChannel osChannel = fos.getChannel(); // 3、复制 osChannel.transferFrom(isChannel,isChannel.position(),isChannel.size()); isChannel.close(); osChannel.close(); }
把原通道数据复制到目标通道
java @Test public void test02() throws Exception { // 1、字节输入管道 FileInputStream is = new FileInputStream("data01.txt"); FileChannel isChannel = is.getChannel(); // 2、字节输出流管道 FileOutputStream fos = new FileOutputStream("data04.txt"); FileChannel osChannel = fos.getChannel(); // 3、复制 isChannel.transferTo(isChannel.position() , isChannel.size() , osChannel); isChannel.close(); osChannel.close(); }
CompositeByteBuf实际上是一个虚拟化的ByteBuf,作为一个ByteBuf特殊的子类,可以用来对多个ByteBuf统一操作,一般情况下,CompositeByteBuf对多个ByteBuf操作并不会出现复制拷贝操作,只是保存原来ByteBuf的引用。
CompositeByteBuf有1个属性:private Component[] components;
什么是Component?
java private static final class Component { final ByteBuf buf; int adjustment; int offset; int endOffset; }
原来就是把多个ByteBuf包装成Component然后统一放到数组里。
例如 CompositeByteBuf: io.netty.handler.codec.ByteToMessageDecoder#COMPOSITE_CUMULATOR
```java byte[] bytes = data.getBytes();
ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes); ```
public static ByteBuf wrappedBuffer(byte[] array) { if (array.length == 0) { return EMPTY_BUFFER; } return new UnpooledHeapByteBuf(ALLOC, array, array.length); }
Netty 中也通过在 DefaultFileRegion 中包装了 NIO 的 FileChannel.transferTo() 方法实现了零拷贝:io.netty.channel.DefaultFileRegion#transferTo
md 堆外内存生活场景: 夏日,小区周边的烧烤店铺,人满为患坐不下,店家常常怎么办? 解决思路:店铺门口摆很多桌子招待客人。 •店内 -> JVM 内部 -> 堆(heap) + 非堆(non heap) •店外 -> JVM 外部 -> 堆外(off heap) 优点: •更广阔的“空间 ”,缓解店铺内压力 -> 破除堆空间限制,减轻 GC 压力 •减少“冗余”细节(假设烧烤过程为了气氛在室外进行:烤好直接上桌:vs 烤好还要进店内)-> 避免复制 缺点: •需要搬桌子 -> 创建速度稍慢 •受城管管、风险大 -> 堆外内存受操作系统管理