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

  • 2019 年 10 月 3 日
  • 筆記

前言

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