打赏

NIO中的ZeroCopy

前文提到网络IO可以使用多路复用技术,而文件IO无法使用多路复用,但是文件IO可以通过减少底层数据拷贝的次数来提升性能,而这个减少底层数据拷贝次数的技术,就叫做ZeroCopy。 

操作系统层面的ZeroCopy

这一节,从《Zero Copy I: User-Mode Perspective》而来,这篇文章的链接地址见参考资料。

Copying in Two Sample System Calls

 

 

传统的操作系统层面的IO操作,简单来看,就是2个系统调用,read()和write()。

其大致的流程如下(为了更好理解,编个代码,S代表切换动作,C代表复制动作):

1、进程(JVM)发出read()请求;

2、操作系统从用户态切换到内核态(S1),执行内核态的read()调用,从硬件(磁盘、网络等)读取数据;

3、内核通过DMA(Direct Memory Access,直接存储器访问)将数据读取复制到内核缓冲区(C1);

4、操作系统将数据从内核态复制到用户态(C2),进行一次上下文切换转到用户态(S2),read()操作完成;

5、业务逻辑处理;

6、处理完成后,进程(JVM)发出write()请求,将写出的数据复制到内核网络缓冲区(C3),上下文切换到内核态(S3);

7、操作系统内核通过DMA将网络缓冲区数据复制到协议引擎(C4),写出数据到硬件(磁盘、网络等);

8、操作完成,从内核态切换到用户态(S4),write()方法结束。

整个过程大概有4次上下文切换和4次数据复制的过程,其中有2次不必要的数据复制,这2次就是C2和C3,这2次复制,就是数据原封不动的COPY,所以没有存在的必要。这个模型很容易就能看出还有可以改善的空间,即通过消除不必要的复制来减少系统开销以提升性能,所以就会有如下第一次改进。 

Calling mmap

 

 

通过mmap()代替read(),虽然上下文切换的次数没有变化,但是原先的2次不必要的复制减少了1次,即C2不存在了,取而代之的是内核态和用户态的缓冲区数据共享,但是这个方案依然存在1次不必要的数据复制(C3),同时由于在内存映射文件的过程中,另一个进程可能将同一个文件截断,此时调用write方法,总线错误信号SIGBUS将中断写入系统调用,因为此时执行了错误的内存访问,该信号的默认行为是杀死进程并转储核心,这不是高性能网络最理想的操作。接下来进入第二次改进。 

Replacing Read and Write with Sendfile

 

 

在Linux2.1中,引入了sendfile系统调用,以简化网络上和两个本地文件之间的数据传输。sendfile的引入不仅减少了数据复制,还减少了上下文切换。

从上图,我们可以看出,上下文切换只有2次,原先的C2和C3复制过程已经消除了,数据通过DMA复制到内核缓冲区后,CPU可以直接将数据复制到网络缓冲区,消除C2和C3的过程中顺便消除其对应的上下文切换,但是此处内核态中仍然有1次数据复制的过程。所以会有第三次改进,彻底消除掉内核态中不必要的复制。

Hardware that supports gather can assemble data from multiple memory locations, eliminating another copy

 

 

在这次改进中,sendfile系统调用DMA引擎将文件内容复制到内核缓冲区中。

此时没有数据复制到套接字缓冲区中,而是仅将具有有关数据的位置和偏移量信息的描述符附加到套接字缓冲区。DMA引擎将数据直接从内核缓冲区传递到协议引擎(DMA gather),从而消除了内核中那次从内核缓冲区复制数据到套接字缓冲区的过程。由于实际上数据仍然是从磁盘复制到内存以及从内存复制到网络的线路,因此有人可能会认为这不是真正的ZeroCopy。但是,从操作系统的角度来看,这就是ZeroCopy,因为在内核缓冲区之间不再有复制数据的过程。当使用ZeroCopy时,除了避免拷贝外,还可以享受其他性能优势,例如更少的上下文切换、更少的CPU数据缓存污染以及无需CPU校验和计算。

NIO如何体现ZeroCopy

实际上,Java中的ZeroCopy采取的是何种技术实现,是取决于操作系统的,只有操作系统提供了,作为上层应用的Java才能启用底层的ZeroCopy。

MappedByteBuffer  DirectByteBuffer FileChannel.map

前文中关于Buffer有一个知识点未提及,就是缓冲区是可以划分为直接缓冲区和非直接缓冲区的,通常非直接缓冲区意味着系统在隐含地进行下面的操作:

1.创建一个临时的直接缓冲区;

2.将非直接缓冲区的内容复制到该临时直接缓冲区中;

3.使用临时直接缓冲区执行底层I/O 操作;

而直接缓冲区就没有如上的隐式操作。

非直接缓冲区的这个操作就和read()、write()模式中的数据拷贝有点像,而NIO中的MappedByteBuffer,通过FileChannel.map方法来获取,实质上就是一个直接缓冲区,同样DirectByteBuffer继承自MappedByteBuffer,也是直接缓冲区,都减少了上述复制的过程,在我看来或多或少体现了ZeroCopy技术。

FileChannel.transferTo/transferFrom

transferTo()和transferFrom()方法允许将一个通道交叉连接到另一个通道,而不需要通过一个中间缓冲区来传递数据。不需要中间缓冲区的意思,就是不需要在用户态和内核态来回复制,同时通道间的内核态数据也无需复制。这里也体现了ZeroCopy技术。

扩展

谈完了NIO中的ZeroCopy,再看看Java技术栈中其他采用ZeroCopy技术的一些案例。

Netty

涉及到了NIO,怎么能不涉及到Netty呢?

Netty的文件传输调用FileRegion包装的transferTo方法,可以直接将文件缓冲区的数据发送到目标Channel,而FileRegion底层调用就是NIO中FileChannel的transferTo函数。

Netty通过内置的复合缓冲区类型(CompositeByteBuf)实现了透明的ZeroCopy,CompositeByteBuf可以聚合多个ByteBuf对象,用户可以像操作一个ByteBuf那样方便的对组合ByteBuf进行操作,避免了传统通过内存拷贝的方式将几个小ByteBuf合并成一个大的ByteBuf的过程。

Netty通过Unpooled.wrappedBuffer方法来将bytes包装成为一个 UnpooledHeapByteBuf对象,而在包装的过程中,是不会有拷贝操作的,即生成的ByteBuf对象是和bytes数组共用了同一个存储空间, 对 bytes 的修改也会反映到 ByteBuf对象中。

slice操作和wrap操作刚好相反,Unpooled.wrappedBuffer可以将多个 ByteBuf 合并为一个, 而 slice 操作可以将一个 ByteBuf 切片为多个共享一个存储区域的 ByteBuf对象。

消息中间件

消息中间件的一个核心技术点就是ZeroCopy技术,如Kafka、RocketMQ等都采用了ZeroCopy技术。

看看ZeroCopy的性能

未采用ZeroCopy技术的服务端:

 

public class OldIOServer {
    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(8899);

        while (true) {
            Socket socket = serverSocket.accept();
            DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

            byte[] bytes = new byte[4096];
            while (true) {
                int readCount = dataInputStream.read(bytes,0,bytes.length);

                if(-1 == readCount) {
                    break;
                }
            }


        }
    }
}

未采用ZeroCopy技术的客户端:

 

public class OldIOClient {
    public static void main(String[] args) throws Exception{
        Socket socket = new Socket("localhost",8899);

        String fileName = "C:\\LenovoDrivers\\Drivers\\Intel(R) UHD Graphics 620_25.20.100.6472@42.7z";
        InputStream inputStream = new FileInputStream(fileName);

        DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());

        byte[] bytes = new byte[4096];
        long readCount;
        long total = 0;

        long startTime = System.currentTimeMillis();

        while ((readCount = inputStream.read(bytes)) >= 0) {
            total += readCount;

            dataOutputStream.write(bytes);
        }

        System.out.println("发送总字节数:" + total + ",耗时:" + (System.currentTimeMillis() - startTime));
        dataOutputStream.close();
        socket.close();
        inputStream.close();
    }
}

 

采用ZeroCopy技术的服务端:

 

public class NewIOServer {
    public static void main(String[] args) throws Exception {
        InetSocketAddress inetSocketAddress = new InetSocketAddress(8899);

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.setReuseAddress(true);
        serverSocket.bind(inetSocketAddress);

        ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

        while (true) {
            SocketChannel socketChannel = serverSocketChannel.accept();
            socketChannel.configureBlocking(true);

            int readCount = 0;
            while(-1 != readCount) {
                try{
                    readCount = socketChannel.read(byteBuffer);
                }catch(Exception ex) {
                    ex.printStackTrace();
                }

                byteBuffer.rewind();
            }
            socketChannel.close();

        }
    }
}

 

采用ZeroCopy技术的客户端:

public class NewIOClient {
    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost",8899));
        socketChannel.configureBlocking(true);

        String fileName = "C:\\LenovoDrivers\\Drivers\\Intel(R) UHD Graphics 620_25.20.100.6472@42.7z";
        FileChannel fileChannel = new FileInputStream(fileName).getChannel();
        long startTime = System.currentTimeMillis();
        long readCount = 0;
        long fileSize = fileChannel.size();
        int maxCount = 8388608;  // ZeroCopy和平台相关 我的win10每次传输到这个数字之后就停止了
        while (readCount != fileSize) {
            readCount += fileChannel.transferTo(readCount, maxCount, socketChannel);
        }
        System.out.println("发送总字节数:" + readCount + ",耗时:" + (System.currentTimeMillis() - startTime));
        socketChannel.close();
        fileChannel.close();
    }
}

 

最后的执行结果图:

 

 

 

 

最后总结一下:

1、开篇从操作系统层面的ZeroCopy技术的演化开始引出,从read/write到mmap,再到sendfile,最后是sendfile在Linux2.4之后的进一步优化,通过gather技术彻底消除内核态的拷贝过程,gather(另一个是scatter) 是NIO中Channel的一个特性,在这里协议引擎这个Channel对于内存中的内核缓冲区、套接字缓冲区就起到了gather(收集)的效果;

2、NIO中或者其他扩展技术栈中体现ZeroCopy的几个点,有的是显式的调用,有的是隐式的实现;

3、最后通过一个CASE,看了下ZeroCopy技术采用和不采用的结果差异,虽然不能科学的说明,但是可以让我们有一个基本的认识,代码中对于Windows平台的数据传输的限制(8388608)应该也可以说明ZeroCopy技术的平台相关性,即只有操作系统提供了,作为上层应用的Java才能启用底层的ZeroCopy技术。

 

 

参考资料:

https://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy1/index.html

https://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy2/

https://www.linuxjournal.com/article/6345

https://xunnanxu.github.io/2016/09/10/It-s-all-about-buffers-zero-copy-mmap-and-Java-NIO/

 

posted @ 2020-03-22 15:14  lingjiango  阅读(535)  评论(0编辑  收藏  举报