Netty基礎系列(5) --零拷貝底層分析


前言

上一節(堆外內存與零拷貝)當中我們從jvm堆內存的視角解釋了一波零拷貝原理,但是僅僅這樣還是不夠的。

為了徹底搞懂零拷貝,我們趁熱打鐵,接着上一節來繼續講解零拷貝的底層原理。

感受一下NIO的速度

之前的章節中我們說過,Nio並不能解決網絡傳輸的速度。但是為什么很多人卻說Nio的速度比傳統IO快呢?

沒錯,zero copy。我們先拋出一個案例,然后根據案例來講解底層原理。

首先,我們實現一個IO的服務端接受數據,然后分別用傳統IO傳輸方式和NIO傳輸方式來直觀對比傳輸相同大小的文件所耗費的時間。

服務端代碼如下:

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());

            try {
                byte[] byteArray = new byte[4096];

                while (true) {
                    int readCount = dataInputStream.read(byteArray, 0, byteArray.length);

                    if (-1 == readCount) {
                        break;
                    }
                }
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
    }
}

這個是最普通的socket編程的服務端,沒什么好多說的。就是綁定本地的8899端口,死循環不斷接受數據。

傳統IO傳輸

public class OldIOClient {

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

        String fileName = "C:\\Users\\Administrator\\Desktop\\test.zip";  //大小兩百M的文件
        InputStream inputStream = new FileInputStream(fileName);

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

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

        long startTime = System.currentTimeMillis();

        while ((readCount = inputStream.read(buffer)) >= 0) {
            total += readCount;
            dataOutputStream.write(buffer);
        }

        System.out.println("發送總字節數: " + total + ", 耗時: " + (System.currentTimeMillis() - startTime));

        dataOutputStream.close();
        socket.close();
        inputStream.close();
    }
}

客戶端向服務端發送一個119M大小的文件。計算一下耗時用了多久

由於我的筆記本性能太渣,大概平均每次消耗的時間大概是 500ms左右。值得注意的是,我們客戶端和服務端分配的緩存大小都是4096個字節。如果將這個字節分配的更小一點,那么所耗時間將會更多。因為上述傳統的IO實際表現並不是我們想象的那樣直接將文件讀到內存,然后發送。

實際情況是什么樣的呢?我們在后續分析。

NIO傳輸

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:\\Users\\Administrator\\Desktop\\test.zip"; //大小200M的文件

        FileChannel fileChannel = new FileInputStream(fileName).getChannel();

        long startTime = System.currentTimeMillis();

        long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); //1

        System.out.println("發送總字節數:" + transferCount + ",耗時: " + (System.currentTimeMillis() - startTime));

        fileChannel.close();
    }
}

NIO編程不熟的同學沒關系,后面會有一篇專門的章節來講。

這里我們來關注一下注釋1關於FileChannel的transferTo方法。(方法的doc文檔很長。我刪除了很多,只看重點)

    /**
     * Transfers bytes from this channel's file to the given writable byte
     * channel.
     *
     * <p> This method is potentially much more efficient than a simple loop
     * that reads from this channel and writes to the target channel.  Many
     * operating systems can transfer bytes directly from the filesystem cache
     * to the target channel without actually copying them.  </p>
     */
    public abstract long transferTo(long position, long count,
                                    WritableByteChannel target)
        throws IOException;

翻譯一下:

將文件channel的數據寫到指定的channel

這個方法可能比簡單的將數據從一個channel循環讀到另一個channel更有效,
許多操作系統可以直接從文件系統緩存傳輸字節到目標通道,**而不實際復制它們**。

意思是我們調用FileChannel的transferTo方法就實現了零拷貝(想實現零拷貝並不止這一種方法,有更優雅的方法,這里只是作為一個演示)。當然也要看你操作系統支不支持底層zero copy。因為這部分工作其實是操作系統來完成的。

我的電腦平均執行下來大概在200ms左右。比傳統IO快了300ms。

底層原理

大家也可以用自己的電腦運行一下上述代碼,看看NIO傳輸一個文件比IO傳輸一個文件快多少。

在上訴代碼中,樓主這里指定的緩存只有4096個字節,而傳送的文件大小有125581592個字節。

在前面我們分析過,對於傳統的IO而言,讀取的緩存滿了以后會有兩次零拷貝過程。那么換算下來傳輸這個文件大概在內存中進行了6w多次無意義的內存拷貝,這6w多次拷貝在我的筆記本上大概所耗費的時間就是300ms左右。這就是導致NIO比傳統IO快的更本原因。

傳統IO底層時序圖

由上圖我們可以看到。當我們想將磁盤中的數據通過網絡發送的時候

  1. 底層調用的了sendfile()方法,然后切換用戶態(User space)->內核態(Kemel space)。
  2. 從本地磁盤獲取數據。獲取的數據存儲在內核態的內存空間內。
  3. 將數據復制到用戶態內存空間里。
  4. 切換內核態->用戶態。
  5. 用戶操作數據,這里就是我們編寫的java代碼的具體操作。
  6. 調用操作系統的write()方法,將數據復制到內核態的socket buffer中。
  7. 切換用戶態->內核態。
  8. 發送數據。
  9. 發送完畢以后,切換內核態->用戶態。繼續執行我們編寫的java代碼。

由上圖可以看出。傳統的IO發送一次數據,進行了兩次“無意義”的內存拷貝。雖然內存拷貝對於整個IO來說耗時是可以忽略不計的。但是操作達到一定次數以后,就像我們上面案例的代碼。就會由量變引起質變。導致速率大大降低。


linux2.4版本前的NIO時序圖

  1. 底層調用的了sendfile()方法,然后切換用戶態(User space)->內核態(Kemel space)。
  2. 從本地磁盤獲取數據。獲取的數據存儲在內核態的內存空間內。
  3. 將內核緩存中的數據拷貝到socket緩沖中。
  4. 將socket緩存的數據發送。
  5. 發送完畢以后,切換內核態->用戶態。繼續執行我們編寫的java代碼。

可以看出,即便我們使用了NIO,其實在我們的緩存中依舊會有一次內存拷貝。拷貝到socket buffer(也就是發送緩存區)中。

到這里我們可以看到,用戶態已經不需要再緩存數據了。也就是少了用戶態和系統態之間的數據拷貝過程。也少了兩次用戶態與內核態上下文切換的過程。但是還是不夠完美。因為在底層還是執行了一次拷貝。

要想實現真真意義上的零拷貝,還是需要操作系統的支持,操作系統支持那就支持。不支持你代碼寫出花了也不會支持。所以在linux2.4版本以后,零拷貝進化為以下模式。

linux2.4版本后的NIO時序圖

這里的步驟與上面的步驟是類似的。看圖可以看出,到這里內存中才真正意義上實現了零拷貝。

很多人就會發問了。為什么少了一次內核緩存的數據拷貝到socket緩存的操作?

不急,聽我慢慢道來~

我們再來看另一張NIO的流程圖:

上面這個圖稍稍有點復雜,都看到這里了,別半途而廢。多看幾遍是能看懂的!

首先第一條黑線我們可以看出,在NIO只切換了兩次用戶態與內核態之間的上下文切換。

我們重點看這張圖下面的部分。

首先我們將硬盤(hard drive)上的數據復制到內核態緩存中(kemel buffer)。然后發生了一次拷貝(CPU copy)到socket緩存中(socket buffer)。最后再通過協議引擎將數據發送出去。

在linux2.4版本前的的確是這樣。但是!!!!

在linux2.4版本以后,上圖中的從內核態緩存中(kemel buffer)的拷貝到socket緩存中(socket buffer)的就不再是數據了。而是對內核態緩存中數據的描述符(也就是指針)。協議引擎發送數據的時候其實是通過socket緩存中的描述符。找到了內核態緩存中的數據。再將數據發送出去。這樣就實現了真正的零拷貝。

總結

我們花了兩篇文章,一篇從jvm堆內存的角度出發(堆外內存與零拷貝),以及本篇從操作體統底層出發來講解零拷貝。足以說明零拷貝的重要性,各位可千萬得重視喲,就算你覺得不重要,面試也是會經常被問到,如果你能把上面的流程講明白,我相信一定也是一大亮點~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM