有必要了解一下Linux中零拷貝原理

本文導讀:

  • 什麼是零拷貝
  • 傳統 IO 數據拷貝原理
  • 什麼是 DMA
  • sendfile 數據零拷貝原理
  • mmap 數據零拷貝原理
  • Java 中 NIO 零拷貝實現
  • Java IO 與 NIO 實戰案例分析


什麼是零拷貝

關於零拷貝,WIKI 上給出的定義如下:

「Zero-copy」 describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.

所謂「零拷貝」描述的是計算機操作系統當中,CPU不執行將數據從一個內存區域,拷貝到另外一個內存區域的任務。通過網絡傳輸文件時,這樣通常可以節省 CPU 週期和內存帶寬。

從描述中已經瞭解到零拷貝技術給我們帶來的好處:

1、節省了 CPU 週期,空出的 CPU 可以完成更多其他的任務

2、減少了內存區域之間數據拷貝,節省內存帶寬

3、減少用戶態和內核態之間數據拷貝,提升數據傳輸效率

4、應用零拷貝技術,減少用戶態和內核態之間的上下文切換

傳統 IO 數據拷貝原理

在正式分析零拷貝機制原理之前,我們先來看下傳統 IO 在數據拷貝的基本原理,從數據拷貝 (I/O 拷貝) 的次數以及上下文切換的次數進行對比分析。

傳統 IO:

有必要了解一下Linux中零拷貝原理 | NIO零拷貝技術實踐

1、JVM 進程內發起 read() 系統調用,操作系統由用戶態空間切換到內核態空間(第一次上下文切換

2、通過 DMA 引擎建數據從磁盤拷貝到內核態空間的輸入的 socket 緩衝區中(第一次拷貝

3、將內核態空間緩衝區的數據原封不動的拷貝到用戶態空間的緩存區中(第二次拷貝),同時內核態空間切換到用戶態空間(第二次上下文切換),read() 系統調用結束

4、JVM 進程內業務邏輯代碼執行

5、JVM 進程內發起 write() 系統調用

6、操作系統由用戶態空間切換到內核態空間(第三次上下文切換),將用戶態空間的緩存區數據原封不動的拷貝到內核態空間輸出的 socket 緩存區中(第三次拷貝

7、write() 系統調用返回,操作系統由內核態空間切換到用戶態空間(第四次上下文切換),通過 DMA 引擎將數據從內核態空間的 socket 緩存區數據拷貝到協議引擎中(

第四次拷貝

傳統 IO 方式,一共在用戶態空間與內核態空間之間發生了 4 次上下文的切換,4 次數據的拷貝過程,其中包括 2 次 DMA 拷貝和 2 次 I/O 拷貝(內核態與用戶應用程序之間發生的拷貝)。

內核空間緩衝區的一大用處是為了減少磁盤I/O操作,因為它會從磁盤中預讀更多的數據到緩衝區中。而使用 BufferedInputStream 的用處是減少 「系統調用」。

什麼是DMA

DMA(Direct Memory Access)—直接內存訪問 :DMA是允許外設組件將 I/O 數據直接傳送到主存儲器中並且傳輸不需要 CPU 的參與,以此將 CPU 解放出來去完成其他的事情。

sendfile 數據零拷貝原理

sendfile 數據零拷貝:

顯然,在傳統 IO 中,用戶態空間與內核態空間之間的複製是完全不必要的,因為用戶態空間僅僅起到了一種數據轉存媒介的作用,除此之外沒有做任何事情。

Linux 提供了 sendfile() 用來減少我們前面提到的數據拷貝和的上下文切換次數。

如下圖所示:

有必要了解一下Linux中零拷貝原理 | NIO零拷貝技術實踐

1、發起 sendfile() 系統調用,操作系統由用戶態空間切換到內核態空間(第一次上下文切換

2、通過 DMA 引擎將數據從磁盤拷貝到內核態空間的輸入的 socket 緩衝區中(第一次拷貝

3、將數據從內核空間拷貝到與之關聯的 socket 緩衝區(第二次拷貝

4、將 socket 緩衝區的數據拷貝到協議引擎中(第三次拷貝

5、sendfile() 系統調用結束,操作系統由用戶態空間切換到內核態空間(第二次上下文切換

根據以上過程,一共有 2 次的上下文切換,3 次的 I/O 拷貝。我們看到從用戶空間到內核空間並沒有出現數據拷貝,從操作系統角度來看,這個就是零拷貝。內核空間出現了複製的原因: 通常的硬件在通過DMA訪問時期望的是連續的內存空間。

支持 scatter-gather 特性的 sendfile 數據零拷貝:

有必要了解一下Linux中零拷貝原理 | NIO零拷貝技術實踐

這次相比 sendfile() 數據零拷貝,減少了一次從內核空間到與之相關的 socket 緩衝區的數據拷貝。

基本流程:

1、發起 sendfile() 系統調用,操作系統由用戶態空間切換到內核態空間(第一次上下文切換

2、通過 DMA 引擎將數據從磁盤拷貝到內核態空間的輸入的 socket 緩衝區中(第一次拷貝

3、將描述符信息會拷貝到相應的 socket 緩衝區當中,該描述符包含了兩方面的信息:

a) kernel buffer的內存地址;

b) kernel buffer的偏移量。

4、DMA gather copy 根據 socket 緩衝區中描述符提供的位置和偏移量信息直接將內核空間緩衝區中的數據拷貝到協議引擎上(第二次拷貝),這樣就避免了最後一次 I/O 數據拷貝。

5、sendfile() 系統調用結束,操作系統由用戶態空間切換到內核態空間(第二次上下文切換

下面這個圖更進一步理解:

有必要了解一下Linux中零拷貝原理 | NIO零拷貝技術實踐

Linux/Unix 操作系統下可以通過下面命令查看是否支持 scatter-gather 特性。

ethtool -k eth0 | grep scatter-gatherscatter-gather: on

許多的 web server 都已經支持了零拷貝技術,比如 Apache、Tomcat。

sendfile 零拷貝消除了所有內核空間緩衝區與用戶空間緩衝區之間的數據拷貝過程,因此 sendfile 零拷貝 I/O 的實現是完成在內核空間中完成的,這對於應用程序來說就無法對數據進行操作了。

mmap 數據零拷貝原理

如果需要對數據做操作,Linux 提供了mmap 零拷貝來實現。

mmap 零拷貝:

有必要了解一下Linux中零拷貝原理 | NIO零拷貝技術實踐

通過上圖看到,一共發生了 4 次的上下文切換,3 次的 I/O 拷貝,包括 2 次 DMA 拷貝和 1 次的 I/O 拷貝,相比於傳統 IO 減少了一次 I/O 拷貝。使用 mmap() 讀取文件時,只會發生第一次從磁盤數據拷貝到 OS 文件系統緩衝區的操作。

1)在什麼場景下使用 mmap() 去訪問文件會更高效?

對文件執行隨機訪問時,如果使用 read() 或 write(),則意味著較低的 cache 命中率。這種情況下使用 mmap() 通常將更高效。

多個進程同時訪問同一個文件時(無論是順序訪問還是隨機訪問),如果使用mmap(),那麼操作系統緩衝區的文件內容可以在多個進程之間共享,從操作系統角度來看,使用 mmap() 可以大大節省內存。

2)什麼場景下沒有使用 mmap() 的必要?

訪問小文件時,直接使用 read() 或 write() 將更加高效。

單個進程對文件執行順序訪問時 (sequential access),使用 mmap() 幾乎不會帶來性能上的提升。譬如說,使用 read() 順序讀取文件時,文件系統會使用 read-ahead 的方式提前將文件內容緩存到文件系統的緩衝區,因此使用 read() 將很大程度上可以命中緩存。

Java 中 NIO 零拷貝實現


Java NIO 中的通道(Channel)相當於操作系統的內核空間(kernel space)的緩衝區,而緩衝區(Buffer)對應的相當於操作系統的用戶空間(user space)中的用戶緩衝區(user buffer)。

  • 通道(Channel)是全雙工的(雙向傳輸),它既可能是讀緩衝區(read buffer),也可能是網絡緩衝區(socket buffer)。
  • 緩衝區(Buffer)分為堆內存(HeapBuffer)和堆外內存(DirectBuffer),這是通過 malloc() 分配出來的用戶態內存。


Java NIO 引入了用於通道的緩衝區的 ByteBuffer。


ByteBuffer有三個主要的實現:

1、HeapByteBuffer

調用 ByteBuffer.allocate() 方法時使用到 HeapByteBuffer。這個緩存區域是在 JVM 進程的堆上分配的,可以獲得如GC支持和緩存優化的優勢。

但它不是頁面對齊的,這意味著若需通過JNI與本地代碼交談,JVM將不得不復制到對齊的緩衝區空間。


2、DirectByteBuffer

調用 ByteBuffer.allocateDirect() 方法時使用。 JVM 會使用 malloc() 在堆空間之外分配內存空間。 由於它的內存空間不由 JVM 管理,所以你的內存空間是頁面對齊的,不受GC影響。但需要自己管理這個內存,注意分配和釋放內存來防止內存洩漏。


3、MappedByteBuffer

調用 FileChannel.map() 時使用。與DirectByteBuffer類似,這也是 JVM 堆外部分配內存空間。它基本上作為操作系統 mmap() 系統調用的包裝函數,以便代碼直接操作映射的物理內存數據。

Java IO 與 NIO 實戰案例分析

下面我們通過代碼示例來對比下傳統 IO 與使用了零拷貝技術的 NIO 之間的差異。

我們通過服務端開啟 socket 監聽,然後客戶端連接的服務端進行數據的傳輸,數據傳輸文件大小為 237M。

零拷貝技術的 NIO,這裡咱們通過剛剛介紹的 HeapByteBuffer 來實戰對比一下。

1、構建傳統IO的socket服務端,監聽8898端口。

public class OldIOServer {
public static void main(String[] args) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(8898)) {
while (true) {
Socket socket = serverSocket.accept();
DataInputStream inputStream = new DataInputStream(socket.getInputStream());
byte[] bytes = new byte[4096];
// 從socket中讀取字節數據
while (true) {
// 讀取的字節數大小,-1則表示數據已被讀完
int readCount = inputStream.read(bytes, 0, bytes.length);
if (-1 == readCount) {
break;
}
}
}
}
}
}

2、構建傳統 IO 的客戶端,連接服務端的 8898 端口,並從磁盤讀取 237M 的數據文件向服務端 socket 中發起寫請求。

public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 8898)); // 連接服務端socket 8899端口
// 設置一個大的文件, 237M
try (FileInputStream fileInputStream = new FileInputStream(new File("/Users/david/Downloads/jdk-8u144-macosx-x64.dmg"));
// 定義一個輸出流
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());) {
// 讀取文件數據

// 定義byte緩存
byte[] buffer = new byte[4096];
int readCount; // 每一次讀取的字節數
int total = 0; // 讀取的總字節數
long startTime = System.currentTimeMillis();
while ((readCount = fileInputStream.read(buffer)) > 0) {
total += readCount; //累加字節數
dataOutputStream.write(buffer); // 寫入到輸出流中
}
System.out.println("發送的總字節數:" + total + ", 耗時:" + (System.currentTimeMillis() - startTime));
}
}
}

運行結果:發送的總字節數:237607747,耗時:450 (400~600毫秒之間)

接下來,我們通過使用 JDK 提供的 NIO 的方式實現數據傳輸與上述傳統 IO 做對比。

1、構建基於 NIO 的服務端,監聽 8899 端口。

public class NewIOServer {
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8899));
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false); // 這裡設置為阻塞模式
int readCount = socketChannel.read(byteBuffer);
while (-1 != readCount) {
readCount = socketChannel.read(byteBuffer);
// 這裡一定要調用下rewind方法,將position重置為0開始位置
byteBuffer.rewind();
}
}

}
}

2、構建基於 NIO 的客戶端,連接NIO的服務端 8899 端口,通過

FileChannel.transferTo 傳輸 237M 的數據文件。

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 = "/Users/david/Downloads/jdk-8u144-macosx-x64.dmg";
FileInputStream fileInputStream = new FileInputStream(fileName);
FileChannel fileChannel = fileInputStream.getChannel();
long startTime = System.currentTimeMillis();
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 目標channel
System.out.println("發送的總字節數:" + transferCount + ",耗時:" + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}

運行結果:發送的總字節數:237607747,耗時:161(100到300毫秒之間)

結合運行結果,基於 NIO 零拷貝技術要比傳統 IO 傳輸效率高 3倍多。所以,後續當設計大文件數據傳輸時可以優先採用類似 NIO 的方式實現。

這裡我們使用了 FileChannel,其中調用的 transferTo() 方法將數據從 FileChannel傳輸到其他的 channel 中,如果操作系統底層支持的話 transferTo、transferFrom 會使用相關的零拷貝技術來實現數據的傳輸。所以,這裡是否使用零拷貝必須依賴於底層的系統實現。

FileChannel.transferTo 方法:

public abstract long transferTo(long position,
long count,
WritableByteChannel target) throws IOException

將字節從此通道的文件傳輸到給定的可寫入字節通道。

試圖讀取從此通道的文件中給定 position 處開始的 count 個字節,並將其寫入目標通道。

此方法的調用不一定傳輸所有請求的字節;

是否傳輸取決於通道的性質和狀態。

如果此通道的文件從給定的 position 處開始所包含的字節數小於 count 個字節,或者如果目標通道是非阻塞的並且其輸出緩衝區中的自由空間少於 count 個字節,則所傳輸的字節數要小於請求的字節數。

此方法不修改此通道的位置。

如果給定的位置大於該文件的當前大小,則不傳輸任何字節。

如果目標通道中有該位置,則從該位置開始寫入各字節,然後將該位置增加寫入的字節數。

與從此通道讀取並將內容寫入目標通道的簡單循環語句相比,此方法可能高效得多。

很多操作系統可將字節直接從文件系統緩存傳輸到目標通道,而無需實際複製各字節。

參數:

position - 文件中的位置,從此位置開始傳輸;

必須為非負數

count - 要傳輸的最大字節數;

必須為非負數

target - 目標通道

返回:實際已傳輸的字節數,可能為零

FileChannel.transferFrom 方法:

public abstract long transferFrom(ReadableByteChannel src,
long position,
long count) throws IOException

將字節從給定的可讀取字節通道傳輸到此通道的文件中。

試著從源通道中最多讀取 count 個字節,並將其寫入到此通道的文件中從給定 position 處開始的位置。

此方法的調用不一定傳輸所有請求的字節;

是否傳輸取決於通道的性質和狀態。

如果源通道的剩餘空間小於 count 個字節,或者如果源通道是非阻塞的並且其輸入緩衝區中直接可用的空間小於 count 個字節,則所傳輸的字節數要小於請求的字節數。

此方法不修改此通道的位置。

如果給定的位置大於該文件的當前大小,則不傳輸任何字節。

如果該位置在源通道中,則從該位置開始讀取各字節,然後將該位置增加讀取的字節數。

與從源通道讀

取並將內容寫入此通道的簡單循環語句相比,此方法可能高效得多。

很多操作系統可將字節直接從源通道傳輸到文件系統緩存,而無需實際複製各字節。

參數:

src - 源通道

position - 文件中的位置,從此位置開始傳輸;

必須為非負數

count - 要傳輸的最大字節數;

必須為非負數

返回:實際已傳輸的字節數,可能為零

發生相應的異常的情況:

異常拋出:

IllegalArgumentException - 如果關於參數的前提不成立

NonReadableChannelException - 如果不允許從此通道進行讀取操作

NonWritableChannelException - 如果目標通道不允許進行寫入操作

ClosedChannelException - 如果此通道或目標通道已關閉

AsynchronousCloseException - 如果正在進行傳輸時另一個線程關閉了任一通道

ClosedByInterruptException - 如果正在進行傳輸時另一個線程中斷了當前線程,因此關閉了兩個通道並將當前線程設置為中斷

IOException - 如果發生其他 I/O 錯誤

"


分享到:


相關文章: