在通过IO进行数据读写时(例如从文件读取数据),需要进行多次的数据拷贝,有些拷贝是通过DMA的方式进行的,有些拷贝是CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方,这种方式效率较低。那所谓的零拷贝就是指在进行IO读写时,尽量减少拷贝次数,尤其是cpu拷贝。
零拷贝主要是由操作系统来支持,和java api无关。
在详细介绍零拷贝前,先需要了解以下个概念:DMA、NIO Gather & Scatter 和mmap
2.1 DMA
直接内存访问(Direct Memory Access,DMA)是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA是一种快速的数据传送方式。很多硬件的系统会使用DMA,包含硬盘控制器、绘图显卡、网卡和声卡。
2.2 Gather & Scatter
分散读取(Scatter)指从Channel中读取的数据“分散”到多个Buffer中。按照缓冲区的顺序,从Channel中读取的数据依次将Buffer填满。
聚集写入(Gather)指将多个Buffer中的数据“聚集”到Channel中。按照缓冲区的顺序,写入position和limit之间的数据到Channel中去。
2.3 mmap
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图所示:
考虑如下场景:从磁盘读取数据然后把数据通过网络发送,此场景通过传统IO实现,伪代码如下:
InputStream inputStream = new FileInputStream("xxxx/xxx.txt");
DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream());
while((readCount = inputStream.read(buffer)) >= 0){
outputStream.write(buffer);
}
这些代码在操作系统层面执行的过程如下图:
1) JVM发出read() 系统调用。
2) OS上下文切换到内核模式(第一次上下文切换)并将数据读取到内核空间缓冲区。(第一次拷贝:hardware —-> kernel buffer)
3) OS内核然后将数据复制到用户空间缓冲区(第二次拷贝: kernel buffer ——> user buffer),然后read系统调用返回。而系统调用的返回又会导致一次内核空间到用户空间的上下文切换(第二次上下文切换)。
4) JVM处理代码逻辑并发送write()系统调用。
5) OS上下文切换到内核模式(第三次上下文切换)并从用户空间缓冲区复制数据到内核空间缓冲区(第三次拷贝: user buffer ——> kernel buffer)。
6) write系统调用返回,导致内核空间到用户空间的再次上下文切换(第四次上下文切换)。将内核空间缓冲区中的数据写到hardware(第四次拷贝: kernel buffer ——> hardware)。
总的来说,传统的I/O操作进行了4次用户空间与内核空间的上下文切换,以及4次数据拷贝。显然在这个用例中,从内核空间到用户空间内存的复制是完全不必要的,因为除了将数据转储到不同的buffer之外,我们没有做任何其他的事情。所以,我们能不能直接从hardware读取数据到kernel buffer后,再从kernel buffer写到目标地点不就好了。为了解决这种不必要的数据复制,操作系统出现了零拷贝的概念。注意,不同的操作系统对零拷贝的实现各不相同。在这里我们介绍linux下的零拷贝实现。
通过mmap方式实现上述的场景,伪代码如下:
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0,fileChannel.size());
DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream());
outputStream.write(mappedByteBuffer);
操作系统层面的流程如下:
通过上图看到,一共发生了 4 次的上下文切换,3 次的 I/O 拷贝,包括 2 次 DMA 拷贝和 1 次的 I/O 拷贝,相比于传统 IO 减少了一次CPU拷贝。使用 mmap() 读取文件时,只会发生第一次从磁盘数据拷贝到 OS 文件系统缓冲区的操作。
通过SendFile方式实现上述的场景,伪代码如下:
SocketChannel socketChannel = SocketChannel.open();
FileChannel fileChannel = new FileInputStream("xxxx/xxx.txt").getChannel();
fileChannel.transferTo(0,fileChannel.size(),socketChannel);
linux2.4版本前操作系统层面的流程如下:
1) 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard driver ——> kernel buffer)。
2) 然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)。
3) sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)。
通过sendfile实现的零拷贝I/O只使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝。你可能会说操作系统仍然需要在内核内存空间中复制数据(kernel buffer —>socket buffer)。 是的,但从操作系统的角度来看,这已经是零拷贝,因为没有数据从内核空间复制到用户空间。 内核需要复制的原因是因为通用硬件DMA访问需要连续的内存空间(因此需要缓冲区)。 但是,如果硬件支持scatter-and-gather,这是可以避免的。
linux2.4版本后(支持scatter-and-gather)操作系统层面的流程如下:
1) 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive —> kernel buffer)。
2) 没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket缓冲区当中。该描述符包含了两方面的信息:a)kernel buffer的内存地址;b)kernel buffer的偏移量。(注意:这个时候kernel buffer存储了所有的数据内容,socket buffer存储了数据的位置索引,后续protocol engine进行dma拷贝时,会从两个buffer去读,这也就是nio的gather语法)
3) sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝。
4) 带有DMA收集拷贝功能的sendfile实现的I/O只使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝。这样一来我们就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换。
零拷贝是操作系统底层的一种实现,我们在网络编程中,利用操作系统这一特性,可以大大提高数据传输的效率。这也是目前网络编程框架中都会采用的方式,理解好零拷贝,有助于我们进一步学习Netty等网络通信框架的底层原理。