搞明白什麼是零拷貝,就是這麼簡單

大家好,我是風箏

我們總會在各種地方看到零拷貝,那零拷貝到底是個什麼東西。

接下來,讓我們來理一理啊。

拷貝說的是計算機裏的 I/O 操作,也就是數據的讀寫操作。計算機可是一個複雜的傢伙,包括軟件和硬件兩大部分,軟件主要指操作系統、驅動程序和應用程序。硬件那就多了,CPU、內存、硬盤等等一大堆東西。

這麼複雜的設備要進行讀寫操作,其中繁瑣和複雜程度可想而知。

傳統 I/O 的讀寫過程

如果要了解零拷貝,那就必須要知道一般情況下,計算機是如何讀寫數據的,我把這種情況稱爲傳統 I/O。

數據讀寫的發起者是計算機中的應用程序,比如我們常用的瀏覽器、辦公軟件、音視頻軟件等。

而數據的來源呢,一般是硬盤、外部存儲設備或者是網絡套接字(也就是網絡上的數據通過網口 + 網卡的處理)。

過程本來是很複雜的,所以大學課程裏要通過《操作系統》、《計算機組成原理》來專門講計算機的軟硬件。

簡化版讀操作流程

那麼細的沒辦法講來,所以,我們把這個讀寫過程簡化一下,忽略大多數細節,只講流程。

上圖是應用程序進行一次讀操作的過程。

  1. 應用程序先發起讀操作,準備讀取數據了;

  2. 內核將數據從硬盤或外部存儲讀取到內核緩衝區;

  3. 內核將數據從內核緩衝區拷貝到用戶緩衝區;

  4. 應用程序讀取用戶緩衝區的數據進行處理加工;

詳細的讀寫操作流程

下面是一個更詳細的 I/O 讀寫過程。這個圖可好用極了,我會藉助這個圖來釐清 I/O 操作的一些基礎但非常重要的概念。

先看一下這個圖,上面紅粉色部分是讀操作,下面藍色部分是寫操作。

如果一下子看着有點兒迷糊的話,沒關係,看看下面幾個概念就清楚了。

應用程序

就是安裝在操作系統上的各種應用。

系統內核

系統內核是一些列計算機的核心資源的集合,不僅包括 CPU、總線這些硬件設備,也包括進程管理、文件管理、內存管理、設備驅動、系統調用等一些列功能。

外部存儲

外部存儲就是指硬盤、U 盤等外部存儲介質。

內核態

用戶態

這裏的用戶可以理解爲應用程序,這個用戶是對於計算機的內核而言的,對於內核來說,系統上的各種應用程序會發出指令來調用內核的資源,這時候,應用程序就是內核的用戶。

模式切換

計算機爲了安全性考慮,區分了內核態和用戶態,應用程序不能直接調用內核資源,必須要切換到內核態之後,讓內核來調用,內核調用完資源,再返回給應用程序,這個時候,系統在切換會用戶態,應用程序在用戶態下才能處理數據。

上述過程其實一次讀和一次寫都分別發生了兩次模式切換。

內核緩衝區

內核緩衝區指內存中專門用來給內核直接使用的內存空間。可以把它理解爲應用程序和外部存儲進行數據交互的一箇中間介質。

應用程序想要讀外部數據,要從這裏讀。應用程序想要寫入外部存儲,要通過內核緩衝區。

用戶緩衝區

用戶緩衝區可以理解爲應用程序可以直接讀寫的內存空間。因爲應用程序沒法直接到內核讀寫數據, 所以應用程序想要處理數據,必須先通過用戶緩衝區。

磁盤緩衝區

磁盤緩衝區是計算機內存中用於暫存從磁盤讀取的數據或將數據寫入磁盤之前的臨時存儲區域。它是一種優化磁盤 I/O 操作的機制,通過利用內存的快速訪問速度,減少對慢速磁盤的頻繁訪問,提高數據讀取和寫入的性能和效率。

PageCache

再說數據讀寫操作流程

上面弄明白了這幾個概念後,再回過頭看一下那個流程圖,是不是就清楚多了。

讀操作
  1. 首先應用程序向內核發起讀請求,這時候進行一次模式切換了,從用戶態切換到內核態;

  2. 內核向外部存儲或網絡套接字發起讀操作;

  3. 將數據寫入磁盤緩衝區;

  4. 系統內核將數據從磁盤緩衝區拷貝到內核緩衝區,順便再將一份(或者一部分)拷貝到 PageCache;

  5. 內核將數據拷貝到用戶緩衝區,供應用程序處理。此時又進行一次模態切換,從內核態切換回用戶態;

寫操作
  1. 應用程序向內核發起寫請求,這時候進行一次模式切換了,從用戶態切換到內核態;

  2. 內核將要寫入的數據從用戶緩衝區拷貝到 PageCache,同時將數據拷貝到內核緩衝區;

  3. 然後內核將數據寫入到磁盤緩衝區,從而寫入磁盤,或者直接寫入網絡套接字。

瓶頸在哪裏

但是傳統 I/O 有它的瓶頸,這纔是零拷貝技術出現的緣由。瓶頸是啥呢,當然是性能問題,太慢了。尤其是在高併發場景下,I/O 性能經常會卡脖子。

那是什麼地方耗時了呢?

數據拷貝

在傳統 I/O 中,數據的傳輸通常涉及多次數據拷貝。數據需要從應用程序的用戶緩衝區複製到內核緩衝區,然後再從內核緩衝區複製到設備或網絡緩衝區。這些數據拷貝過程導致了多次內存訪問和數據複製,消耗了大量的 CPU 時間和內存帶寬。

用戶態和內核態的切換

由於數據要經過內核緩衝區,導致數據在用戶態和內核態之間來回切換,切換過程中會有上下文的切換,如此一來,大大增加了處理數據的複雜性和時間開銷。

每一次操作耗費的時間雖然很小,但是當併發量高了以後,積少成多,也是不小的開銷。所以要提高性能、減少開銷就要從以上兩個問題下手了。

這時候,零拷貝技術就出來解決問題了。

什麼是零拷貝

問題出來數據拷貝和模態切換上。

但既然是 I/O 操作,不可能沒有數據拷貝的,只能減少拷貝的次數,還有就是儘量將數據存儲在離應用程序(用戶緩衝區)更近的地方。

而區分用戶態和內核態有其他更重要的原因,不可能單純爲了 I/O 效率就改變這種設計吧。那也只能儘量減少切換的次數。

零拷貝的理想狀態就是操作數據不用拷貝,但是顯示情況下並不一定真的就是一次複製操作都沒有,而是儘量減少拷貝操作的次數。

要實現零拷貝,應該從下面這三個方面入手:

  1. 儘量減少數據在各個存儲區域的複製操作,例如從磁盤緩衝區到內核緩衝區等;

  2. 儘量減少用戶態和內核態的切換次數及上下文切換;

  3. 使用一些優化手段,例如對需要操作的數據先緩存起來,內核中的 PageCache 就是這個作用;

實現零拷貝方案

直接內存訪問(DMA)

DMA 是一種硬件特性,允許外設(如網絡適配器、磁盤控制器等)直接訪問系統內存,而無需通過 CPU 的介入。在數據傳輸時,DMA 可以直接將數據從內存傳輸到外設,或者從外設傳輸數據到內存,避免了數據在用戶態和內核態之間的多次拷貝。

如上圖所示,內核將數據讀取的大部分數據讀取操作都交個了 DMA 控制器,而空出來的資源就可以去處理其他的任務了。

sendfile

一些操作系統(例如 Linux)提供了特殊的系統調用,如 sendfile,在網絡傳輸文件時實現零拷貝。通過 sendfile,應用程序可以直接將文件數據從文件系統傳輸到網絡套接字或者目標文件,而無需經過用戶緩衝區和內核緩衝區。

如果不用 sendfile,如果將 A 文件寫入 B 文件。

  1. 需要先將 A 文件的數據拷貝到內核緩衝區,再從內核緩衝區拷貝到用戶緩衝區;

  2. 然後內核再將用戶緩衝區的數據拷貝到內核緩衝區,之後才能寫入到 B 文件;

而用了 sendfile,用戶緩衝區和內核緩衝區的拷貝都不用了,節省了一大部分的開銷。

共享內存

使用共享內存技術,應用程序和內核可以共享同一塊內存區域,避免在用戶態和內核態之間進行數據拷貝。應用程序可以直接將數據寫入共享內存,然後內核可以直接從共享內存中讀取數據進行傳輸,或者反之。

通過共享一塊兒內存區域,實現數據的共享。就像程序中的引用對象一樣,實際上就是一個指針、一個地址。

內存映射文件(Memory-mapped Files)

內存映射文件直接將磁盤文件映射到應用程序的地址空間,使得應用程序可以直接在內存中讀取和寫入文件數據,這樣一來,對映射內容的修改就是直接的反應到實際的文件中。

當文件數據需要傳輸時,內核可以直接從內存映射區域讀取數據進行傳輸,避免了數據在用戶態和內核態之間的額外拷貝。

雖然看上去感覺和共享內存沒什麼差別,但是兩者的實現方式完全不同,一個是共享地址,一個是映射文件內容。

Java 實現零拷貝的方式

Java 標準的 IO 庫是沒有零拷貝方式的實現的,標準 IO 就相當於上面所說的傳統模式。只是在 Java 推出的 NIO 中,才包含了一套新的 I/O 類,如 ByteBufferChannel,它們可以在一定程度上實現零拷貝。

ByteBuffer:可以直接操作字節數據,避免了數據在用戶態和內核態之間的複製。

Channel:支持直接將數據從文件通道或網絡通道傳輸到另一個通道,實現文件和網絡的零拷貝傳輸。

藉助這兩種對象,結合 NIO 中的 API,我們就能在 Java 中實現零拷貝了。

首先我們先用傳統 IO 寫一個方法,用來和後面的 NIO 作對比,這個程序的目的很簡單,就是將一個 100M 左右的 PDF 文件從一個目錄拷貝到另一個目錄。

public static void ioCopy() {
  try {
    File sourceFile = new File(SOURCE_FILE_PATH);
    File targetFile = new File(TARGET_FILE_PATH);
    try (FileInputStream fis = new FileInputStream(sourceFile);
         FileOutputStream fos = new FileOutputStream(targetFile)) {
      byte[] buffer = new byte[1024];
      int bytesRead;
      while ((bytesRead = fis.read(buffer)) != -1) {
        fos.write(buffer, 0, bytesRead);
      }
    }
    System.out.println("傳輸 " + formatFileSize(sourceFile.length()) + " 字節到目標文件");
  } catch (IOException e) {
    e.printStackTrace();
  }
}

下面是這個拷貝程序的執行結果,109.92M,耗時 1.29 秒。

傳輸 109.92 M 字節到目標文件 耗時: 1.290 秒

FileChannel.transferTo() 和 transferFrom()

FileChannel 是一個用於文件讀寫、映射和操作的通道,同時它在併發環境下是線程安全的,基於 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以創建並打開一個文件通道。FileChannel 定義了 transferFrom() 和 transferTo() 兩個抽象方法,它通過在通道和通道之間建立連接實現數據傳輸的。

這兩個方法首選用 sendfile 方式,只要當前操作系統支持,就用 sendfile,例如 Linux 或 MacOS。如果系統不支持,例如 windows,則採用內存映射文件的方式實現。

transferTo()

下面是一個 transferTo 的例子,仍然是拷貝那個 100M 左右的 PDF,我的系統是 MacOS。

public static void nioTransferTo() {
  try {
    File sourceFile = new File(SOURCE_FILE_PATH);
    File targetFile = new File(TARGET_FILE_PATH);
    try (FileChannel sourceChannel = new RandomAccessFile(sourceFile, "r").getChannel();
         FileChannel targetChannel = new RandomAccessFile(targetFile, "rw").getChannel()) {
      long transferredBytes = sourceChannel.transferTo(0, sourceChannel.size(), targetChannel);

      System.out.println("傳輸 " + formatFileSize(transferredBytes) + " 字節到目標文件");
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

只耗時 0.536 秒,快了一倍。

傳輸 109.92 M 字節到目標文件 耗時: 0.536 秒

transferFrom()

下面是一個 transferFrom 的例子,仍然是拷貝那個 100M 左右的 PDF,我的系統是 MacOS。

public static void nioTransferFrom() {
  try {
    File sourceFile = new File(SOURCE_FILE_PATH);
    File targetFile = new File(TARGET_FILE_PATH);

    try (FileChannel sourceChannel = new RandomAccessFile(sourceFile, "r").getChannel();
         FileChannel targetChannel = new RandomAccessFile(targetFile, "rw").getChannel()) {
      long transferredBytes = targetChannel.transferFrom(sourceChannel, 0, sourceChannel.size());
      System.out.println("傳輸 " + formatFileSize(transferredBytes) + " 字節到目標文件");
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

執行時間:

傳輸 109.92 M 字節到目標文件 耗時: 0.603 秒

Memory-Mapped Files

Java 的 NIO 也支持內存映射文件(Memory-mapped Files),通過 FileChannel.map() 實現。

下面是一個 FileChannel.map()的例子,仍然是拷貝那個 100M 左右的 PDF,我的系統是 MacOS。

    public static void nioMap(){
        try {
            File sourceFile = new File(SOURCE_FILE_PATH);
            File targetFile = new File(TARGET_FILE_PATH);

            try (FileChannel sourceChannel = new RandomAccessFile(sourceFile, "r").getChannel();
                 FileChannel targetChannel = new RandomAccessFile(targetFile, "rw").getChannel()) {
                long fileSize = sourceChannel.size();
                MappedByteBuffer buffer = sourceChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);
                targetChannel.write(buffer);
                System.out.println("傳輸 " + formatFileSize(fileSize) + " 字節到目標文件");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

執行時間:

傳輸 109.92 M 字節到目標文件 耗時: 0.663 秒

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ULVCvSLIGvj3VtY5prtxGw