1. 程式人生 > >Netty基礎系列(5) --零拷貝底層分析

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堆記憶體的角度出發(堆外記憶體與零拷貝),以及本篇從操作體統底層出發來講解零拷貝。足以說明零拷貝的重要性,各位可千萬得重視喲,就算你覺得不重要,面試也是會經常被問到,如果你能把上面的流程講明白,我相信一定也是一大亮