零拷贝是网络编程的关键,很多性能优化都离不开。在 Java 程序中,常用的零拷贝有 mmap(memory map,内存映射) 和 sendFile。那么它们在 OS(操作系统) 中,到底是怎么样的一个的设计?另外我们看下NIO 中如何使用零拷贝?
1 概念介绍
- DMA(Direct Memory Access,直接存储器访问): DMA传输方式无需CPU直接控制传输,也没有中断处理方式那样保留现场和恢复现场的过程,通过硬件为RAM与I/O设备开辟一条直接传送数据的通路,使CPU的效率大为提高。
- 内核态:cpu可以访问
内存的所有数据
,包括外围设备
,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。 - 用户态:只能
受限的访问内存
,且不允许访问外围设备
,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。
所有用户程序都是运行在用户态的
,但是有时候程序确实需要做一些内核态的事情,例如从硬盘读取数据
,或者从键盘获取输入等,而唯一可以做这些事情的就是操作系统,所以此时程序就需要先操作系统请求以程序的名义来执行这些操作。
2 传统IO数据读写操作分析
下面是 Java 传统 IO 和 网络编程的一段代码,我们分析一下它经过了多少次用户态、内核态之间的切换,以及发生了几次文件拷贝。
这段代码调用 read 方法读取 test.txt 的内容, 变成字节数组,然后调用 write() 方法,将 test.txt 字节流写到 socket 中。
File file = new File("test.txt");
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);
下面的图展示了在OS底层该过程的逻辑,上半部分表示用户态和内核态的上下文切换,下半部分表示数据复制操作。
- 调用
read()
导致用户态
到内核态
的一次变化,同时,第一次复制开始
:DMA引擎
从磁盘读取 test.txt 文件,并将数据放入到内核缓冲区
; - 发生
第二次数据拷贝
,即:将内核缓冲区
的数据拷贝到用户缓冲区
,同时,发生了一次用内核态
到用户态
的上下文切换
; - 调用
write()
方法,发生第三次数据拷贝,即系统将用户缓冲区
的数据拷贝到Socket 缓冲区
。同时,又发生了一次用户态
到内核态
的上下文切换
; -
第四次拷贝
,数据异步
的从Socket 缓冲区
,使用 DMA 引擎
拷贝到网络协议引擎(protocol engine)
。这一段,不需要进行上下文切换
。write()
方法返回,再次从内核态
切换到用户态
。
通过分析我们可以看到,仅仅是一次简单的读写操作,就发生了三次 用户态 到 内核态 的切换,四次 拷贝,显然代价是非常高的。
3 零拷贝优化
这里我们首先明确一下:零拷贝并不是完全没有拷贝,从操作系统角度来看,零拷贝是没有 cpu 拷贝。
3.1 优化一:mmap(不是真正的零拷贝)
mmap 通过内存映射,将文件映射到 内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。
如上图,user buffer
和 kernel buffer
共享 test.txt
。如果你想把硬盘的 test.txt 传输到网络中,就不用先拷贝到用户空间,再从用户空间拷贝到 Socket 缓冲区了。只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但上下文切换次数依旧没有减少。
3.2 优化二:sendFile(也不是真正的零拷贝)
Linux 2.1
版本 提供了 sendFile 函数
,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。
系统调用 sendFile 函数时,数据被 DMA 引擎从文件复制到 内核缓冲区
,然后调用 write 方法时,从内核缓冲区进入到 Socket 缓冲区,这时是没有上下文切换的,因为在一个用户空间。最后,数据从 Socket 缓冲区进入到 协议栈。此时,数据经过了 3 次拷贝
,3 次上下文切换
。
3.3 优化三:sendFile 2.0(真正的零拷贝)
Linux 在 2.4 版本中
,做了一些修改,避免了
从 内核缓冲区
拷贝到 Socket buffer
的操作,直接拷贝到网络协议栈
,从而再一次减少了数据拷贝。
现在,text.txt 要从文件进入到 网络协议栈
,只需 2 次拷贝:
- 第一次使用 DMA 引擎从文件拷贝到内核缓冲区;
- 第二次从内核缓冲区将数据拷贝到网络协议栈
注意:还会有一些信息,例如 offset 和 length 信息,会拷贝到 SocketBuffer
,但是拷贝的信息很少,基本无消耗。
3.4 总结:mmap 和 sendFile 对比
我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)。
零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
mmap 和 sendFile 的区别
- mmap 适合小数据量读写,sendFile 适合大文件传输。
- mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
4 NIO 零拷贝案例
客户端发送文件代码
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();
}
}
服务器端接收文件代码
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 作废
}
}
}
}