我们在Java NIO,Netty,Kafka等框架中经常见到零拷贝,通常作为其性能优异的一个重要表现。
下面从 I/O 的几个概念开始,进而再分析零拷贝。
1、I/O 概念
1.1 缓冲区
缓冲区是所有 I/O 的基础,I/O 讲的无非就是把数据移进或移出缓冲区;进程执行 I/O 操作,就是向操作系统发出请求,让它要么把内核缓冲区的数据排干(写),要么填充内核缓冲区(读)。
下图是一个java进程发起Read请求的流程图:
-
- 进程发起 Read 请求之后,内核接收到 Read 请求之后,会先检查内核空间Read缓冲区中是否已经存在进程所需要的数据,
- 1.1 如果已经存在,则直接把数据 Copy 给进程的缓冲区;
- 1.2 如果不存在,内核随即向磁盘控制器DMA发出命令,要求从磁盘读取数据,磁盘控制器DMA把数据直接写入内核 Read 缓冲区;
- 接下来就是内核将数据 Copy 到进程的缓冲区;
如果进程发起 Write 请求,同样需要把用户缓冲区里面的数据 Copy 到内核的 Socket 缓冲区里面,然后再通过 DMA 把数据 Copy 到网卡中,发送出去。
如下图所示:
从读写过程中可以很明显的看出,每次都需要把内核空间的数据拷贝到用户空间(读),或者把用户空间的数据拷贝到内核空间(写)中,挺浪费空间的。
零拷贝的出现就是为了解决这种问题的
1.2 虚拟内存
所有现代操作系统都使用虚拟内存,使用虚拟的地址取代物理地址,这样做的好处是:
- 多个虚拟地址可以指向同一个物理内存地址。
- 虚拟内存空间可大于实际可用的物理地址。
利用第一条特性可以把内核空间地址和用户空间的虚拟地址映射到同一个物理地址,这样 DMA 就可以填充对内核和用户空间进程同时可见的缓冲区了。
大致如下图所示:
这样就省去了内核与用户空间的往来拷贝,从而可以提升性能。
2、零拷贝实现方式之mmap+write
mmap 是一种内存映射文件的方法(I/O读取),即将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系,就是上面所说的虚拟内存。
DMA加载磁盘数据到kernel buffer后,用户buffer和内核缓冲区(kernel buffer)进行映射,数据在用户缓冲区和内核缓存区的copy就能省略。
但是如果我们是直接从磁盘读取数据,然后写入网卡时,还是需要从内核空间kernel buffer 把数据copy 到 内核空间socket buffer。
mmap 把文件映射到用户空间里的虚拟内存,省去了从内核缓冲区复制到用户空间的过程,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,才会将这些数据copy到内核缓存区。
应用程序调用了mmap()之后,数据会先通过DMA拷贝到操作系统内核的缓冲区。接着,应用程序跟操作系统共享这个缓冲区。这样,操作系统内核和应用程序存储空间就不需要再进行任何的数据拷贝操作。
也就是说内存映射文件MMAP只有一次页缓存的复制,读时从磁盘文件复制到页缓存(page cache),写时从页缓存flush到磁盘文件,默认30s。MMAP与操作系统的Pagecache打交道。
普通文件IO需要复制两次,内存映射文件mmap复制一次,普通文件IO是堆内操作,内存映射文件是堆外操作
3、零拷贝实现方式之Sendfile
Sendfile 系统调用在Linux内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。
Sendfile 系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数,大致如下图所示:
数据传送只发生在内核空间,所以减少了一次上下文切换;但是还是存在一次 Copy,能不能把这一次 Copy 也省略掉?
Linux2.4 内核中做了改进,将内核 buffer 中对应的数据描述信息(内存地址,偏移量)记录到相应的 Socket 缓冲区当中,这样连内核空间中的一次 CPU Copy 也省掉了,当DMA copy数据时,可以根据socket buffer中的内存地址和偏移量直接从kernel buffer中读取数据
sendfile()系统调用利用DMA引擎将文件中的数据拷贝到操作系统内核缓冲区中,接下来,DMA引擎将数据从内核socket缓冲区中拷贝到协议引擎
sendfile()系统调用不需要将数据拷贝或映射到应用程序地址空间,所以sendfile()只适用于应用程序地址空间不需要对所访问数据进行处理的情况。比如apache、nginx等web服务器使用sendfile传输静态文件
4、Kafka中的零拷贝
Kafka中的零拷贝主要体现在一下两个方面:
- 生产者发送消息,并写入kafka broker节点的过程中,采用mmap文件映射的方式,DMA将网卡中的数据映射到kernel buffer中(即写入pagecache),然后再由系统写入磁盘。
是通过MappedByteBuffer类实现的
- 消费者从kafka broker读取数据时,采用的是sendfile方式,DMA将磁盘文件读到内核buffer之后,直接转到socket buffer进行网络发送。
Kafka速度的秘诀在于,它把所有的消息都变成一个的文件。通过mmap提高I/O速度,写入数据的时候它是末尾添加所以速度最优;读取数据的时候配合sendfile直接暴力输出
5、Netty中的零拷贝
Kafka中的零拷贝主要体现在一下三个方面:
Direct Buffers
Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后再由直接内存拷贝到网卡接口层(Socket)。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。——类似于Sendfile方式Composite Buffers
传统的ByteBuffer,如果需要将两个ByteBuffer中的数据组合到一起,我们需要首先创建一个size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用Netty提供的组合ByteBuf,就可以避免这样的操作,因为CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。
- FileChannel.transferTo
Netty中使用了java NIO FileChannel的transferTo方法,该方法依赖于操作系统实现零拷贝,它可以直接将文件缓冲区的数据发送到目标Channel(Sendfile方式),避免了传统通过循环write方式导致的内存拷贝问题。
6、java NIO中的零拷贝——transferTo
transferTo()的实现方式就是通过系统调用sendfile(),如下图数据流向