走進科學之揭開神祕的零拷貝

原文來源:https://github.com/javagrowing/JGrowing

前言

"零拷貝"這三個字,想必大家多多少少都有聽過吧,這個技術在各種開源組件中都使用了,比如kafka,rocketmq,netty,nginx等等開源框架都在其中引用了這項技術。所以今天想和大家分享一下有關於零拷貝的一些知識。


計算機中數據傳輸

在介紹零拷貝之前我想說下在計算機系統中數據傳輸的方式。數據傳輸系統的發展,為了寫這一部分又祭出了我塵封多年的計算機組成原理:

走進科學之揭開神祕的零拷貝

早期階段:

分散連接,串行工作,程序查詢。 在這個階段,CPU就像個保姆一樣,需要手把手的把數據從I/O接口讀出然後再送給主存。

走進科學之揭開神祕的零拷貝

這個階段具體流程是:

  1. CPU主動啟動I/O設備
  2. 然後CPU一直問I/O設備:老鐵你準備好了嗎,注意這裡是一直詢問。
  3. 如果I/O設備告訴了CPU說:我準備好了。CPU就從I/O接口中讀數據。
  4. 然後CPU又繼續把這個數據傳給主存,就像快遞員一樣。

這種效率很低數據傳輸過程一直佔據著CPU,CPU不能做其他更有意義的事。

接口模塊和DMA階段

接口模塊

在馮諾依曼結構中,每個部件之間均有單獨連線,不僅線多,而且導致擴展I/O設備很不容易,我們上面的早期階段就是這個體系,叫作分散連接。擴展一個I/O設備得連接很多線。所以引入了總線連接方式,將多個設備連接在同一組總線上,構成設備之間的公共傳輸通道。

走進科學之揭開神祕的零拷貝

這個也是現在我們家用電腦或者一些小型計算器的數據交換結構。

在這種模式下數據交換採用程序中斷的方式,我們上面知道我們啟動I/O設備之後一直在輪詢問I/O設備是否準備好,要是把這個階段去掉了就好了,程序中斷很好的實現了我們的夙願:

  1. CPU主動啟動I/O設備。
  2. CPU啟動之後不需要再問I/O,開始做其他事,類似異步化。
  3. I/O準備好了之後,通過總線中斷告訴CPU我已經準備好了。
  4. CPU進行讀取數據,傳輸給主存中。

DMA

雖然上面的方式雖然提高了CPU的利用率,但是在中斷的時候CPU一樣是被佔用的,為了進一步解決CPU佔用,又引入了DMA方式,在DMA方式中,主存和I/O設備之間有一條數據通路,這下主存和I/O設備之間交換數據時,就不需要再次中斷CPU。

走進科學之揭開神祕的零拷貝

一般來說我們只需要關注DMA和中斷兩種即可,下面介紹的都是用來適合大型計算機的一些,這裡只說簡單的過一下:

具有通道結構的階段

在小型計算機中採用DMA方式可以實現高速I/O設備與主機之間組成數據的交換,但在大中型計算機中,I/O配置繁多,數據傳送頻繁,若採用DMA方式會出現一系列問題。

  • 每臺I/O設備都配置專用額DMA接口,不僅增加了硬件成本,而且解決DMA和CPU訪問衝突問題,會使控制變得十分複雜。
  • CPU需要對眾多的DMA接口進行管理,同樣會影響工作效率。

所以引入了通道,通道用來管理I/O設備以及主存與I/O設備之間交換信息的部件,可以視為一種具有特殊功能的處理器。它是從屬於CPU的一個專用處理器,CPU不直接參與管理,故提高了CPU的資源利用率

具有I/O處理機的階段

輸入輸出系統發展到第四階段,出現了I/O處理機。I/O處理機又稱為外圍處理機,它獨立於主機工作,既可以完成I/O通道要完成的I/O控制,又完成格式處理,糾錯等操作。具有I/O處理機的輸出系統與CPU工作的並行度更高,這說明I.O系統對主機來說具有更大的獨立性。

小結

我們可以看到數據傳輸進化的目標是一直在減少CPU佔有,提高CPU的資源利用率。


數據拷貝

先介紹一下今天我們的需求,在磁盤中有個文件,現在需要通過網絡傳輸出去。 如果是你應該怎麼做?通過上面的一些介紹,相信你心中應該有些想法了吧。

傳統拷貝

如果我們用Java代碼實現的話用我們會有如下的的實現:偽代碼參考如下:

public static void main(String[] args) {
Socket socket = null;
File file = new File("test.file");
byte[] b = new byte[(int) file.length()];

try {
InputStream in = new FileInputStream(file);
readFully(in, b);
socket.getOutputStream().write(b);
} catch (Exception e) {

}
}
private static boolean readFully(InputStream in, byte[] b) {
int size = b.length;
int offset = 0;
int len;
for (; size > 0;) {
try {
len = in.read(b, offset, size);
if (len == -1) {
return false;
}
offset += len;
size -= len;
} catch (Exception ex) {
return false;
}
}
return true;
}

這是我們傳統的拷貝方式具體的數據流轉圖如下,PS:這裡不考慮Java中傳輸數據時需要先將堆中的數據拷貝到直接內存中。

走進科學之揭開神祕的零拷貝

可以看見我們總管需要經歷四個階段,2次DMA,2次CPU中斷,總共四次拷貝,有四次上下文切換,並且會佔用兩次CPU。

  1. CPU發指令給I/O設備的DMA,由DMA將我們磁盤中的數據傳輸到內核空間的內核buffer。
  2. 第二階段觸發我們的CPU中斷,CPU開始將將數據從kernel buffer拷貝至我們的應用緩存
  3. CPU將數據從應用緩存拷貝到內核中的socket buffer.
  4. DMA將數據從socket buffer中的數據拷貝到網卡緩存。

優點:開發成本低,適合一些對性能要求不高的,比如一些什麼管理系統這種我覺得就應該夠了

缺點:多次上下文切換,佔用多次CPU,性能比較低。

sendFile實現零拷貝

上面是零拷貝呢?在wiki中的定位:通常是指計算機在網絡上發送文件時,不需要將文件內容拷貝到用戶空間(User Space)而直接在內核空間(Kernel Space)中傳輸到網絡的方式。

在java NIO中FileChannal.transferTo()實現了操作系統的sendFile,我們可以同下面偽代碼完成上面需求:

public static void main(String[] args) {
SocketChannel socketChannel = SocketChannel.open();
FileChannel fileChannel = new FileInputStream("test").getChannel();
fileChannel.transferTo(0,fileChannel.size(),socketChannel);
}

我們通過java.nio中的channel替代了我們上面的socket和fileInputStream,從而完成了我們的零拷貝。

走進科學之揭開神祕的零拷貝

上面具體過程如下:

  1. 調用sendfie(),CPU下發指令叫DMA將磁盤數據拷貝到內核buffer中。
  2. DMA拷貝完成發出中斷請求,進行CPU拷貝,拷貝到socket buffer中。sendFile調用完成返回。 3.DMA將socket buffer拷貝至網卡buffer。

可以看見我們根本沒有把數據複製到我們的應用緩存中,所以這種方式就是零拷貝。但是這種方式依然很蛋疼,雖然減少到了只有三次數據拷貝,但是還是需要CPU中斷複製數據。為啥呢?因為DMA需要知道內存地址我才能發送數據啊。所以在Linux2.4內核中做了改進,將Kernel buffer中對應的數據描述信息(內存地址,偏移量)記錄到相應的socket緩衝區當中。 最終形成了下面的過程:

走進科學之揭開神祕的零拷貝

這種方式讓CPU全程不參與拷貝,因此效率是最好的。

在第三方開源框架中Netty,RocketMQ,kafka中都有類似的代碼,大家如果感興趣可以下來自行搜索。

mmap映射

上面我們提到了零拷貝的實現,但是我們只能將數據原封不動的發給用戶,並不能自己使用。於是Linux提供的一種訪問磁盤文件的特殊方式,可以將內存中某塊地址空間和我們要指定的磁盤文件相關聯,從而把我們對這塊內存的訪問轉換為對磁盤文件的訪問,這種技術稱為內存映射(Memory Mapping)。 我們通過這種技術將文件直接映射到用戶態的內存地址,這樣對文件的操作不再是write/read,而是直接對內存地址的操作。

在Java中依靠MappedByteBuffer進行mmap映射,具體的MappedByteBuffer可以詳情參照這篇文章:https://www.jianshu.com/p/f90866dcbffc 。


最後

自此,零拷貝的神祕面紗也被揭蓋,零拷貝只是為了減少CPU的佔用,讓CPU做更多真正業務上的事。通過這篇文章,大家可以自己下來看看Netty是怎麼做零拷貝的相信將會有更加深刻的印象。

相關推薦

推薦中...