我們總會在各種地方看到零拷貝,那零拷貝到底是個什么東西。
接下來,讓我們來理一理啊。
拷貝說的是計算機里的 I/O 操作,也就是數據的讀寫操作。計算機可是一個復雜的家伙,包括軟件和硬件兩大部分,軟件主要指操作系統、驅動程序和應用程序。硬件那就多了,CPU、內存、硬盤等等一大堆東西。
這么復雜的設備要進行讀寫操作,其中繁瑣和復雜程度可想而知。
傳統I/O的讀寫過程
如果要了解零拷貝,那就必須要知道一般情況下,計算機是如何讀寫數據的,我把這種情況稱為傳統 I/O。
數據讀寫的發起者是計算機中的應用程序,比如我們常用的瀏覽器、辦公軟件、音視頻軟件等。
而數據的來源呢,一般是硬盤、外部存儲設備或者是網絡套接字(也就是網絡上的數據通過網口+網卡的處理)。
過程本來是很復雜的,所以大學課程里要通過《操作系統》、《計算機組成原理》來專門講計算機的軟硬件。
簡化版讀操作流程
那么細的沒辦法講來,所以,我們把這個讀寫過程簡化一下,忽略大多數細節,只講流程。
上圖是應用程序進行一次讀操作的過程。
- 應用程序先發起讀操作,準備讀取數據了;
- 內核將數據從硬盤或外部存儲讀取到內核緩沖區;
- 內核將數據從內核緩沖區拷貝到用戶緩沖區;
- 應用程序讀取用戶緩沖區的數據進行處理加工;
詳細的讀寫操作流程
下面是一個更詳細的 I/O 讀寫過程。這個圖可好用極了,我會借助這個圖來厘清 I/O 操作的一些基礎但非常重要的概念。
先看一下這個圖,上面紅粉色部分是讀操作,下面藍色部分是寫操作。
如果一下子看著有點兒迷糊的話,沒關系,看看下面幾個概念就清楚了。
應用程序
就是安裝在操作系統上的各種應用。
系統內核
系統內核是一些列計算機的核心資源的集合,不僅包括CPU、總線這些硬件設備,也包括進程管理、文件管理、內存管理、設備驅動、系統調用等一些列功能。
外部存儲
外部存儲就是指硬盤、U盤等外部存儲介質。
內核態
- 內核態是操作系統內核運行的模式,當操作系統內核執行特權指令時,處于內核態。
- 在內核態下,操作系統內核擁有最高權限,可以訪問計算機的所有硬件資源和敏感數據,執行特權指令,控制系統的整體運行。
- 內核態提供了操作系統管理和控制計算機硬件的能力,它負責處理系統調用、中斷、硬件異常等核心任務。
用戶態
這里的用戶可以理解為應用程序,這個用戶是對于計算機的內核而言的,對于內核來說,系統上的各種應用程序會發出指令來調用內核的資源,這時候,應用程序就是內核的用戶。
- 用戶態是應用程序運行的模式,當應用程序執行普通的指令時,處于用戶態。
- 在用戶態下,應用程序只能訪問自己的內存空間和受限的硬件資源,無法直接訪問操作系統的敏感數據或控制計算機的硬件設備。
- 用戶態提供了一種安全的運行環境,確保應用程序之間相互隔離,防止惡意程序對系統造成影響。
模式切換
計算機為了安全性考慮,區分了內核態和用戶態,應用程序不能直接調用內核資源,必須要切換到內核態之后,讓內核來調用,內核調用完資源,再返回給應用程序,這個時候,系統在切換會用戶態,應用程序在用戶態下才能處理數據。
上述過程其實一次讀和一次寫都分別發生了兩次模式切換。
內核緩沖區
內核緩沖區指內存中專門用來給內核直接使用的內存空間。可以把它理解為應用程序和外部存儲進行數據交互的一個中間介質。
應用程序想要讀外部數據,要從這里讀。應用程序想要寫入外部存儲,要通過內核緩沖區。
用戶緩沖區
用戶緩沖區可以理解為應用程序可以直接讀寫的內存空間。因為應用程序沒法直接到內核讀寫數據, 所以應用程序想要處理數據,必須先通過用戶緩沖區。
磁盤緩沖區
磁盤緩沖區是計算機內存中用于暫存從磁盤讀取的數據或將數據寫入磁盤之前的臨時存儲區域。它是一種優化磁盤 I/O 操作的機制,通過利用內存的快速訪問速度,減少對慢速磁盤的頻繁訪問,提高數據讀取和寫入的性能和效率。
PageCache
- PageCache 是 linux 內核對文件系統進行緩存的一種機制。它使用空閑內存來緩存從文件系統讀取的數據塊,加速文件的讀取和寫入操作。
- 當應用程序或進程讀取文件時,數據會首先從文件系統讀取到 PageCache 中。如果之后再次讀取相同的數據,就可以直接從 PageCache 中獲取,避免了再次訪問文件系統。
- 同樣,當應用程序或進程將數據寫入文件時,數據會先暫存到 PageCache 中,然后由 Linux 內核異步地將數據寫入磁盤,從而提高寫入操作的效率。
再說數據讀寫操作流程
上面弄明白了這幾個概念后,再回過頭看一下那個流程圖,是不是就清楚多了。
讀操作
- 首先應用程序向內核發起讀請求,這時候進行一次模式切換了,從用戶態切換到內核態;
- 內核向外部存儲或網絡套接字發起讀操作;
- 將數據寫入磁盤緩沖區;
- 系統內核將數據從磁盤緩沖區拷貝到內核緩沖區,順便再將一份(或者一部分)拷貝到 PageCache;
- 內核將數據拷貝到用戶緩沖區,供應用程序處理。此時又進行一次模態切換,從內核態切換回用戶態;
寫操作
- 應用程序向內核發起寫請求,這時候進行一次模式切換了,從用戶態切換到內核態;
- 內核將要寫入的數據從用戶緩沖區拷貝到 PageCache,同時將數據拷貝到內核緩沖區;
- 然后內核將數據寫入到磁盤緩沖區,從而寫入磁盤,或者直接寫入網絡套接字。
瓶頸在哪里
但是傳統I/O有它的瓶頸,這才是零拷貝技術出現的緣由。瓶頸是啥呢,當然是性能問題,太慢了。尤其是在高并發場景下,I/O性能經常會卡脖子。
那是什么地方耗時了呢?
數據拷貝
在傳統 I/O 中,數據的傳輸通常涉及多次數據拷貝。數據需要從應用程序的用戶緩沖區復制到內核緩沖區,然后再從內核緩沖區復制到設備或網絡緩沖區。這些數據拷貝過程導致了多次內存訪問和數據復制,消耗了大量的 CPU 時間和內存帶寬。
用戶態和內核態的切換
由于數據要經過內核緩沖區,導致數據在用戶態和內核態之間來回切換,切換過程中會有上下文的切換,如此一來,大大增加了處理數據的復雜性和時間開銷。
每一次操作耗費的時間雖然很小,但是當并發量高了以后,積少成多,也是不小的開銷。所以要提高性能、減少開銷就要從以上兩個問題下手了。
這時候,零拷貝技術就出來解決問題了。
什么是零拷貝
問題出來數據拷貝和模態切換上。
但既然是 I/O 操作,不可能沒有數據拷貝的,只能減少拷貝的次數,還有就是盡量將數據存儲在離應用程序(用戶緩沖區)更近的地方。
而區分用戶態和內核態有其他更重要的原因,不可能單純為了 I/O 效率就改變這種設計吧。那也只能盡量減少切換的次數。
零拷貝的理想狀態就是操作數據不用拷貝,但是顯示情況下并不一定真的就是一次復制操作都沒有,而是盡量減少拷貝操作的次數。
要實現零拷貝,應該從下面這三個方面入手:
- 盡量減少數據在各個存儲區域的復制操作,例如從磁盤緩沖區到內核緩沖區等;
- 盡量減少用戶態和內核態的切換次數及上下文切換;
- 使用一些優化手段,例如對需要操作的數據先緩存起來,內核中的 PageCache 就是這個作用;
實現零拷貝方案
直接內存訪問(DMA)
DMA 是一種硬件特性,允許外設(如網絡適配器、磁盤控制器等)直接訪問系統內存,而無需通過 CPU 的介入。在數據傳輸時,DMA 可以直接將數據從內存傳輸到外設,或者從外設傳輸數據到內存,避免了數據在用戶態和內核態之間的多次拷貝。
如上圖所示,內核將數據讀取的大部分數據讀取操作都交個了 DMA 控制器,而空出來的資源就可以去處理其他的任務了。
sendfile
一些操作系統(例如 Linux)提供了特殊的系統調用,如 sendfile,在網絡傳輸文件時實現零拷貝。通過 sendfile,應用程序可以直接將文件數據從文件系統傳輸到網絡套接字或者目標文件,而無需經過用戶緩沖區和內核緩沖區。
如果不用sendfile,如果將A文件寫入B文件。
- 需要先將A文件的數據拷貝到內核緩沖區,再從內核緩沖區拷貝到用戶緩沖區;
- 然后內核再將用戶緩沖區的數據拷貝到內核緩沖區,之后才能寫入到B文件;
而用了sendfile,用戶緩沖區和內核緩沖區的拷貝都不用了,節省了一大部分的開銷。
共享內存
使用共享內存技術,應用程序和內核可以共享同一塊內存區域,避免在用戶態和內核態之間進行數據拷貝。應用程序可以直接將數據寫入共享內存,然后內核可以直接從共享內存中讀取數據進行傳輸,或者反之。
通過共享一塊兒內存區域,實現數據的共享。就像程序中的引用對象一樣,實際上就是一個指針、一個地址。
內存映射文件(Memory-mApped Files)
內存映射文件直接將磁盤文件映射到應用程序的地址空間,使得應用程序可以直接在內存中讀取和寫入文件數據,這樣一來,對映射內容的修改就是直接的反應到實際的文件中。
當文件數據需要傳輸時,內核可以直接從內存映射區域讀取數據進行傳輸,避免了數據在用戶態和內核態之間的額外拷貝。
雖然看上去感覺和共享內存沒什么差別,但是兩者的實現方式完全不同,一個是共享地址,一個是映射文件內容。
JAVA 實現零拷貝的方式
Java 標準的 IO 庫是沒有零拷貝方式的實現的,標準IO就相當于上面所說的傳統模式。只是在 Java 推出的 NIO 中,才包含了一套新的 I/O 類,如 ByteBuffer 和 Channel,它們可以在一定程度上實現零拷貝。
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 秒