在通过IO进行数据读写时(例如从文件读取数据),需要进行多次的数据拷贝,有些拷贝是通过DMA的方式进行的,有些拷贝是CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方,这种方式效率较低。那所谓的零拷贝就是指在进行IO读写时,尽量减少拷贝次数,尤其是cpu拷贝。
零拷贝主要是由操作系统来支持,和java api无关。
在详细介绍零拷贝前,先需要了解以下个概念:DMA、NIO Gather & Scatter 和mmap
直接内存访问(Direct Memory Access,DMA)是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA是一种快速的数据传送方式。很多硬件的系统会使用DMA,包含硬盘控制器、绘图显卡、网卡和声卡。
分散读取(Scatter)指从Channel中读取的数据“分散”到多个Buffer中。按照缓冲区的顺序,从Channel中读取的数据依次将Buffer填满。
聚集写入(Gather)指将多个Buffer中的数据“聚集”到Channel中。按照缓冲区的顺序,写入position和limit之间的数据到Channel中去。
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);
}
这些代码在操作系统层面执行的过程如下图:
JVM发出read() 系统调用。
OS上下文切换到内核模式(第一次上下文切换)并将数据读取到内核空间缓冲区。(第一次拷贝:hardware —-> kernel buffer)
OS内核然后将数据复制到用户空间缓冲区(第二次拷贝: kernel buffer ——> user buffer),然后read系统调用返回。而系统调用的返回又会导致一次内核空间到用户空间的上下文切换(第二次上下文切换)。
JVM处理代码逻辑并发送write()系统调用。
OS上下文切换到内核模式(第三次上下文切换)并从用户空间缓冲区复制数据到内核空间缓冲区(第三次拷贝: user buffer ——> kernel buffer)。
write系统调用返回,导致内核空间到用户空间的再次上下文切换(第四次上下文切换)。将内核空间缓冲区中的数据写到hardware(第四次拷贝: kernel buffer ——> hardware)。
总的来说,传统的I/O操作进行了4次用户空间与内核空间的上下文切换,以及4次数据拷贝。显然在这个用例中,从内核空间到用户空间内存的复制是完全不必要的,因为除了将数据转储到不同的buffer之外,我们没有做任何其他的事情。所以,我们能不能直接从hardware读取数据到kernel buffer后,再从kernel buffer写到目标地点不就好了。为了解决这种不必要的数据复制,操作系统出现了零拷贝的概念。注意,不同的操作系统对零拷贝的实现各不相同。在这里我们介绍linux下的零拷贝实现。
整个过程,用户空间缓冲区只是临时存放数据的载体,不对数据操作,这是一种极其浪费电脑资源的行为。追求高性能、低延迟的瓶颈比较大。
mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝
通过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 文件系统缓冲区的操作。
Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换
通过SendFile方式实现上述的场景,伪代码如下:
SocketChannel socketChannel = SocketChannel.open();
FileChannel fileChannel = new FileInputStream("xxxx/xxx.txt").getChannel();
fileChannel.transferTo(0,fileChannel.size(),socketChannel);
linux2.4版本前操作系统层面的流程如下:
发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard driver ——> kernel buffer)。
然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)。
sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)。
通过sendfile实现的零拷贝I/O只使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝。你可能会说操作系统仍然需要在内核内存空间中复制数据(kernel buffer —>socket buffer)。** 是的,但从操作系统的角度来看,这已经是零拷贝**,因为没有数据从内核空间复制到用户空间。 内核需要复制的原因是因为通用硬件DMA访问需要连续的内存空间(因此需要缓冲区)。 但是,如果硬件支持scatter-and-gather,这是可以避免的。
Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图和小结:
这里其实有 一次cpu 拷贝 kernel buffer -> socket buffer 但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略
linux2.4版本后(支持scatter-and-gather)操作系统层面的流程如下:
发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive —> kernel buffer)。
没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket缓冲区当中。该描述符包含了两方面的信息:a)kernel buffer的内存地址;b)kernel buffer的偏移量。(注意:这个时候kernel buffer存储了所有的数据内容,socket buffer存储了数据的位置索引,后续protocol engine进行dma拷贝时,会从两个buffer去读,这也就是nio的gather语法)
sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝。
带有DMA收集拷贝功能的sendfile实现的I/O只使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝。这样一来我们就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换。
mmap 适合小数据量读写,sendFile 适合大文件传输。
mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
/**
* @ClassName: OldServer
* @Author: 86151
* @Date: 2021/7/27 15:36
* @Description: TODO
*/
public class OldServer {
public static void main(String[] args) {
/*开启客户端的监听*/
try {
ServerSocket serverSocket = new ServerSocket(9999);
while (true){
/*持续监听*/
Socket ac = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(ac.getInputStream());
byte[] bytes = new byte[4096];
while (true){
int count = dataInputStream.read(bytes, 0, bytes.length);
if (count==-1){
break;
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* @ClassName: OldClien
* @Author: 86151
* @Date: 2021/7/27 15:44
* @Description: TODO
*/
public class OldClient{
public static void main(String[] args) {
try {
Socket socket = new Socket("localhost", 9999);
/*获取输入流*/
FileInputStream fileInputStream = new FileInputStream("d://NettyTEST/test.rar");
/*输出流*/
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
/*创建一个发送数据的数组*/
byte[] bytes = new byte[4096];
long total=0;
long readNum;
long startTime=System.currentTimeMillis();
while((readNum=fileInputStream.read(bytes))>0){
total+=readNum;
dataOutputStream.write(bytes);
}
System.out.println("发送总字节数: " + total + ", 耗时: " + (System.currentTimeMillis() - startTime));
/*关闭相应的输入流与输出流*/
dataOutputStream.close();
fileInputStream.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//服务器
public class NewIOServer {
public static void main(String[] args) throws Exception {
InetSocketAddress address = new InetSocketAddress(7001);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(address);
//创建buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
int readcount = 0;
while (-1 != readcount) {
try {
readcount = socketChannel.read(byteBuffer);
}catch (Exception ex) {
// ex.printStackTrace();
break;
}
//
byteBuffer.rewind(); //倒带 position = 0 mark 作废
}
}
}
}
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "protoc-3.6.1-win32.zip";
//得到一个文件channel
FileChannel fileChannel = new FileInputStream(filename).getChannel();
//准备发送
long startTime = System.currentTimeMillis();
//在linux下一个transferTo 方法就可以完成传输
//在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件, 而且要主要
//传输时的位置 =》 课后思考...
//transferTo 底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() - startTime));
//关闭
fileChannel.close();
}
}
零拷贝是操作系统层面的一种实现,我们在网络编程中,利用操作系统的这一特性可以大大提高数据的传输速率。这也是目前网络编程框架中都会采用的方式,理解好零拷贝,有助于我们学习Netty等网络通信框架的底层原理。