摘要:通過mmap()代替read(),雖然上下文切換的次數沒有變化,但是原先的2次不必要的複製減少了1次,即C2不存在了,取而代之的是內核態和用戶態的緩衝區數據共享,但是這個方案依然存在1次不必要的數據複製(C3),同時由於在內存映射文件的過程中,另一個進程可能將同一個文件截斷,此時調用write方法,總線錯誤信號SIGBUS將中斷寫入系統調用,因爲此時執行了錯誤的內存訪問,該信號的默認行爲是殺死進程並轉儲核心,這不是高性能網絡最理想的操作。非直接緩衝區的這個操作就和read()、write()模式中的數據拷貝有點像,而NIO中的MappedByteBuffer,通過FileChannel.map方法來獲取,實質上就是一個直接緩衝區,同樣DirectByteBuffer繼承自MappedByteBuffer,也是直接緩衝區,都減少了上述複製的過程,在我看來或多或少體現了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 [email protected]";
        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 [email protected]";
        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/

相關文章