架构系列七(理解零拷贝)

238 阅读9分钟

1.引子

这两天有朋友说面试的时候,被面试官问到了关于零拷贝,一时语塞没有回答上来。于是给我发了个消息:到底啥是零拷贝?

我想干脆借助这个机会,跟大家分享一下关于零拷贝的一些事情。一来面试用的上还是次要的,更重要的是有助于我们去理解掌握一些靠近底层原理的东西。

虽然大多数小伙伴都跟我一样,工作内容主要是业务系统的开发,俗称CRUD boy或者API工程师,但这并不妨碍我们拥有追求的权利,对,我们要做一个有追求的工程师!

好吧,那就让我们开始吧!争取一文搞懂零拷贝!

2.案例

2.1.啥是零拷贝

我们知道,一个应用程序运行起来,从操作系统层面来看,我们说它是一个进程。进程是拥有资源和程序执行的最小单位,顺便提一下进程与线程的区别,有些小伙伴容易把它们混淆了。

进程与线程本质的区别:进程是拥有资源的基本单位,线程是调度的基本单位,任务执行过程中,进程给线程提供虚拟内存、全局变量等资源

我们继续回到进程,一个进程的运行空间又分为用户空间、与内核空间。用户空间就是我们常说的用户态运行,内核空间则是内核态运行。那么它为什么要这么区分呢?

答案是出于安全方面的考虑,内核空间就像人类的心脏,涉及到操作系统运行的稳定性。可不能让谁都能随便访问,那太危险了。

但是又不能不访问,比如说我们读写文件,需要操作磁盘,你不能不让我访问吧!

于是,操作系统说了访问可以,你提出申请我来帮你做后面的事情。这里打了个比方,所谓提出申请我来帮你做后面的事情,即我们平常说的系统调用

系统调用比如说读取文件内容,进程用户空间提交一个read调用的请求,那么实际准备数据读取文件的事情,都在内核空间与磁盘交互完成。我们可以这么去理解。下面我们来看一个图:

image.png

从上图我们看到,应用程序一次读取文件内容的操作,涉及到:用户空间、内核空间、设备(磁盘),具体处理流程

  • 用户空间,发起系统调用read,进程发生上下文切换:由用户态切换到内核态
  • 内核空间询问并等待磁盘准备好数据
  • 内核空间将数据从磁盘,拷贝到内核缓冲区中
  • 进程发生上下文切换:由内核态切换到用户态
  • 系统调用read返回,将数据从内核空间拷贝到用户空间

你看,应用程序一次读取文件内容,发生了两次进程上下文切换,两次数据拷贝。这也是为什么我们说减少系统调用,是系统性能优化的一个方向。不是没有原因的!

那么该如何优化呢?减少进程上下文切换,减少数据拷贝次数。具体我们描述一下

  • 有没有什么办法,减少用户空间、内核空间之间的上下文切换带来的性能损失?
  • 有没有什么办法,减少不必要的数据拷贝带来的性能损失?

答案是:零拷贝。我们来看一下零拷贝的流程图

image.png

从上图我们看到,应用程序一次文件读取内容,零拷贝的流程,与传统read流程差不多,都涉及到:用户空间、内核空间、设备。其中

  • 内核空间,与磁盘的交互没有差异
  • 主要差异,在第四步,零拷贝中减少了一次从内核空间、到用户空间的数据拷贝操作

另外图上我们简化了操作流程,在大多数涉及到IO的应用场景中,有两类场景非常多

  • 完整的本地IO读写操作,即读取文件内容--->处理文件内容--->写入文件内容
  • 网络IO操作,即读取文件内容--->通过网络接口Socket--->发送到远程服务器

这样当涉及到完整的IO操作的时候,通过零拷贝可以减少两次上下文切换,两次数据在用户空间、与磁盘空间之间的拷贝,从而提升应用程序性能

最后我们再看一个完整IO操作流程,以网络IO操作为例

image.png

我们看到,整个操作流程中,只发生了两次上下文切换

  • 系统调用sendfile,从用户空间,切换到内核空间
  • 系统调用返回,从内核空间,切换到用户空间

整个操作流程中,不再需要数据在内核空间、与用户空间之间的拷贝,全部在内核空间完成

  • 只需要将数据从设备,拷贝达到内核缓冲区中
  • 数据直接从内核缓冲区中,拷贝到ocket缓冲区,通过网络接口发送出去
  • 将数据直接从内核缓冲区中,写回设备

你看,这就是零拷贝,你应该可以理解了。

2.2.零拷贝示例

理解了零拷贝,下面我们通过案例直观展示一下零拷贝对IO性能的提升。毕竟talk is cheap,show me your code!

代码才是王道,我们的案例是这样的,我准备了一个文件,有443兆,如图:

image.png

然后我们分别通过阻塞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倍,这即是零拷贝给我们带来的收益。在实际应用中,是否能使用零拷贝主要取决与操作系统底层是否支持,当然目前主流的操作系统都支持,这个我们不用担心。

最后这篇文章我就分享到这里了,期望能给你带来一些收获!