1.引子
这两天有朋友说面试的时候,被面试官问到了关于零拷贝,一时语塞没有回答上来。于是给我发了个消息:到底啥是零拷贝?
我想干脆借助这个机会,跟大家分享一下关于零拷贝的一些事情。一来面试用的上还是次要的,更重要的是有助于我们去理解掌握一些靠近底层原理的东西。
虽然大多数小伙伴都跟我一样,工作内容主要是业务系统的开发,俗称CRUD boy或者API工程师,但这并不妨碍我们拥有追求的权利,对,我们要做一个有追求的工程师!
好吧,那就让我们开始吧!争取一文搞懂零拷贝!
2.案例
2.1.啥是零拷贝
我们知道,一个应用程序运行起来,从操作系统层面来看,我们说它是一个进程。进程是拥有资源和程序执行的最小单位,顺便提一下进程与线程的区别,有些小伙伴容易把它们混淆了。
进程与线程本质的区别:进程是拥有资源的基本单位,线程是调度的基本单位,任务执行过程中,进程给线程提供虚拟内存、全局变量等资源
我们继续回到进程,一个进程的运行空间又分为用户空间、与内核空间。用户空间就是我们常说的用户态运行,内核空间则是内核态运行。那么它为什么要这么区分呢?
答案是出于安全方面的考虑,内核空间就像人类的心脏,涉及到操作系统运行的稳定性。可不能让谁都能随便访问,那太危险了。
但是又不能不访问,比如说我们读写文件,需要操作磁盘,你不能不让我访问吧!
于是,操作系统说了访问可以,你提出申请我来帮你做后面的事情。这里打了个比方,所谓提出申请我来帮你做后面的事情,即我们平常说的系统调用。
系统调用比如说读取文件内容,进程用户空间提交一个read调用的请求,那么实际准备数据读取文件的事情,都在内核空间与磁盘交互完成。我们可以这么去理解。下面我们来看一个图:
从上图我们看到,应用程序一次读取文件内容的操作,涉及到:用户空间、内核空间、设备(磁盘),具体处理流程
- 用户空间,发起系统调用read,进程发生上下文切换:由用户态切换到内核态
- 内核空间询问并等待磁盘准备好数据
- 内核空间将数据从磁盘,拷贝到内核缓冲区中
- 进程发生上下文切换:由内核态切换到用户态
- 系统调用read返回,将数据从内核空间拷贝到用户空间
你看,应用程序一次读取文件内容,发生了两次进程上下文切换,两次数据拷贝。这也是为什么我们说减少系统调用,是系统性能优化的一个方向。不是没有原因的!
那么该如何优化呢?减少进程上下文切换,减少数据拷贝次数。具体我们描述一下
- 有没有什么办法,减少用户空间、内核空间之间的上下文切换带来的性能损失?
- 有没有什么办法,减少不必要的数据拷贝带来的性能损失?
答案是:零拷贝。我们来看一下零拷贝的流程图
从上图我们看到,应用程序一次文件读取内容,零拷贝的流程,与传统read流程差不多,都涉及到:用户空间、内核空间、设备。其中
- 内核空间,与磁盘的交互没有差异
- 主要差异,在第四步,零拷贝中减少了一次从内核空间、到用户空间的数据拷贝操作
另外图上我们简化了操作流程,在大多数涉及到IO的应用场景中,有两类场景非常多
- 完整的本地IO读写操作,即读取文件内容--->处理文件内容--->写入文件内容
- 网络IO操作,即读取文件内容--->通过网络接口Socket--->发送到远程服务器
这样当涉及到完整的IO操作的时候,通过零拷贝可以减少两次上下文切换,两次数据在用户空间、与磁盘空间之间的拷贝,从而提升应用程序性能。
最后我们再看一个完整IO操作流程,以网络IO操作为例
我们看到,整个操作流程中,只发生了两次上下文切换
- 系统调用sendfile,从用户空间,切换到内核空间
- 系统调用返回,从内核空间,切换到用户空间
整个操作流程中,不再需要数据在内核空间、与用户空间之间的拷贝,全部在内核空间完成
- 只需要将数据从设备,拷贝达到内核缓冲区中
- 数据直接从内核缓冲区中,拷贝到ocket缓冲区,通过网络接口发送出去
- 将数据直接从内核缓冲区中,写回设备
你看,这就是零拷贝,你应该可以理解了。
2.2.零拷贝示例
理解了零拷贝,下面我们通过案例直观展示一下零拷贝对IO性能的提升。毕竟talk is cheap,show me your code!
代码才是王道,我们的案例是这样的,我准备了一个文件,有443兆,如图:
然后我们分别通过阻塞bio,与非阻塞nio方式读取文件内容,通过网络接口发送的案例,直观对比非零拷贝,与零拷贝之间的性能差异。
2.2.1.非零拷贝
2.2.1.1.服务端代码
/**
* 阻塞bio服务端,演示非零拷贝
*
* @author ThinkPad
* @version 1.0
* @date 2021/6/12 9:42
*/
public class BioServer {
public static void main(String[] args) {
// 1.定义服务端socket,与input流
ServerSocket serverSocket = null;
DataInputStream inputStream = null;
try {
// 2.服务端绑定监听 9527端口
serverSocket = new ServerSocket(9527);
while(true){
// 3.阻塞等待客户端连接
Socket socket = serverSocket.accept();
// 4.获取socket输入流,读取客户端发送数据
inputStream = new DataInputStream(socket.getInputStream());
byte[] array = new byte[4096];
// 5.服务端读取到数据,不做任何处理(我们的关注点主要在客户端)
// 当count == -1,读取客户端数据结束
int count = inputStream.read(array, 0, array.length);
while(count != -1){
count = inputStream.read(array, 0, array.length);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if(inputStream != null) {inputStream.close();}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
2.2.1.2.客户端代码
/**
* 阻塞bio客户端,演示非零拷贝
*
* @author ThinkPad
* @version 1.0
* @date 2021/6/12 9:42
*/
public class BioClient {
public static void main(String[] args) {
// 1.定义客户端socket,与input、output流
Socket socket = null;
InputStream inputStream = null;
DataOutputStream outputStream = null;
try {
// 2.初始化本地文件,得到input流
String fileName = "D:\zerocopy\bigfile.mp4";
inputStream = new FileInputStream(fileName);
// 3.连接本地socket服务的9527端口
socket = new Socket("127.0.0.1", 9527);
outputStream = new DataOutputStream(socket.getOutputStream());
// 4.记时开始
long start = System.currentTimeMillis();
// 5.读取文件数据,并通过output发送到服务端
byte[] array = new byte[4096];
int readCount = 0;
int total = 0;
while((readCount = inputStream.read(array)) >= 0){
total += readCount;
outputStream.write(array);
}
// 6.数据发送完毕,统计发送数据量,与耗时
long end = System.currentTimeMillis();
System.out.println("非零拷贝发送总字节数:[" + total + "]byte,耗时:[" + (end - start) + "]毫秒.");
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if(outputStream != null){ outputStream.close();}
if(inputStream != null){ inputStream.close();}
if(socket != null){ socket.close();}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
2.2.1.3.执行结果
第一次执行结果:非零拷贝发送总字节数:[465404568]byte,耗时:[6109]毫秒.
第二次执行结果:非零拷贝发送总字节数:[465404568]byte,耗时:[6031]毫秒.
第三次执行结果:非零拷贝发送总字节数:[465404568]byte,耗时:[6084]毫秒.
2.2.2.零拷贝
2.2.2.1.服务端代码
/**
* 非阻塞nio服务端,演示零拷贝
*
* @author ThinkPad
* @version 1.0
* @date 2021/6/12 10:03
*/
public class NioServer {
public static void main(String[] args) {
// 1.定义服务端socket 通道
ServerSocketChannel serverSocketChannel = null;
try {
// 2.打开通道,获取服务端socket
serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
// 3.服务端绑定9527端口
InetSocketAddress address = new InetSocketAddress(9527);
serverSocket.setReuseAddress(true);
serverSocket.bind(address);
// 4.定义字节缓冲区
ByteBuffer buffer = ByteBuffer.allocate(4096);
while(true){
// 5.获取客户端通道,将客户端配置为阻塞模式
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(true);
// 6.服务端读取到数据,不做任何处理(我们的关注点主要在客户端)
int readCount = 0;
while(readCount != -1){
readCount = socketChannel.read(buffer);
buffer.rewind();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
2.2.2.2.客户端代码
/**
* 非阻塞nio客户端,演示零拷贝
*
* @author ThinkPad
* @version 1.0
* @date 2021/6/12 10:03
*/
public class NioClient {
public static void main(String[] args) throws Exception{
// 1.定义客户端socket通道、配置阻塞模式,连接本机9527服务
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(true);
socketChannel.connect(new InetSocketAddress("127.0.0.1",9527));
// 2.获取文件通道FileChannel,【FileChannel的transferTo方法支持零拷贝】
String fileName = "D:\zerocopy\bigfile.mp4";
FileChannel channel = new FileInputStream(fileName).getChannel();
// 3.计时开始
long start = System.currentTimeMillis();
// 4.零拷贝获取文件内容,发送到服务端
int position = 0;
long fileSize = channel.size();
while(0 < fileSize){
long count = channel.transferTo(position, fileSize, socketChannel);
if(count > 0){
position += count;
fileSize -= count;
}
}
// 6.数据发送完毕,统计发送数据量,与耗时
long end = System.currentTimeMillis();
System.out.println("零拷贝发送总字节数:[" + position + "]byte,耗时:[" + (end - start) + "]毫秒.");
// 7.释放资源
channel.close();
socketChannel.close();
}
}
2.2.2.3.执行结果
第一次执行结果:零拷贝发送总字节数:[465404568]byte,耗时:[533]毫秒.
第二次执行级果:零拷贝发送总字节数:[465404568]byte,耗时:[522]毫秒.
第三次执行结果:零拷贝发送总字节数:[465404568]byte,耗时:[514]毫秒.
对比非零拷贝、零拷贝案例执行结果
- 第一次执行结果:非零拷贝发送总字节数:[465404568]byte,耗时:[6109]毫秒.
- 第一次执行结果:零拷贝发送总字节数:[465404568]byte,耗时:[533]毫秒.
在我们当前的案例中,性能相差10倍,这即是零拷贝给我们带来的收益。在实际应用中,是否能使用零拷贝主要取决与操作系统底层是否支持,当然目前主流的操作系统都支持,这个我们不用担心。
最后这篇文章我就分享到这里了,期望能给你带来一些收获!