Linux 和 Java 的零拷貝

LZC發表於2020-06-16

大家好,我是一段躺在Linux磁碟上的資料。現在要把我從磁碟發到網路卡,需要經過以下步驟:

讀操作

讀操作

如上圖:作業系統把記憶體分為了核心空間和使用者空間。首先位於使用者空間的應用程式使用發起資料讀操作,比如JVM發起read()系統呼叫。這個時候作業系統會進行一次上下文切換:從使用者空間切換到核心空間。

然後核心空間通知磁碟,核心把我從磁碟copy到核心緩衝區。這個過程是由一個叫“DMA(Direct memory access)”的硬體來做的,所以不需要CPU的參與。

然後核心把我從核心緩衝區copy到應用程式緩衝區,這裡需要CPU的參與。

最後進行上下文切換,又換回到使用者空間的上下文。

整個讀操作的過程需要兩次上下文切換和兩次copy

寫操作

寫操作與讀操作類似,只是方向相反而已,仍然需要兩次上下文切換和兩次資料的copy。我可能會被寫到磁碟,也可能會被寫到網路卡。

寫操作

從上面的過程可以看到,如果想把我從磁碟傳送到網路卡,需要總共4次上下文切換和4次copy操作。我被作業系統在核心空間和使用者空間之間來回複製,但其實我在這期間什麼也沒有做,什麼也沒有變化,就是複製而已,所以這個IO模型太浪費作業系統資源了,我被複制這麼多次,身心疲憊。而且作業系統的資源是非常寶貴滴~

現在主流的作業系統都使用了虛擬記憶體。簡單來說,就是用虛擬地址取代實體地址,這樣做可以讓多個虛擬記憶體只想同一個實體地址,虛擬記憶體的空間可以遠遠大於實體記憶體的空間。

那如果作業系統能夠把使用者空間的應用程式緩衝區和核心空間的核心緩衝區對映到同一個實體地址,那豈不是就少了很多複製的過程?如下圖:

記憶體對映

所以為了解決這個問題,聰明的Linux開發者們寫了一些新的系統呼叫來做這個事。主要有兩種方式:

  • mmap + write
  • sendfile

mmap + write

mmap()系統呼叫首先會使用DMA copy的方式將我從磁碟讀取到核心緩衝區,然後通過記憶體對映的方式,使使用者緩衝區和核心讀緩衝區的記憶體地址為同一記憶體地址,也就是說,不需要CPU再將我從核心讀緩衝區複製到使用者緩衝區啦!

當使用write()系統呼叫的時候,CPU將我從核心緩衝區(等同於使用者緩衝區)直接寫入到需要傳送的核心緩衝區,比如網路傳送緩衝區(socket buffer),然後通過DMA的方式將我傳入到網路卡驅動程式(或磁碟)中準備傳送。

mmap + write

mmap + write的方式讀寫資料總共需要兩次系統呼叫,4次上下文切換,2次DMA Copy和1次CPU Copy。

sendfile

sendfile也是一個系統呼叫,它其實本質上就是把上述兩個系統呼叫的功能合起來,變成了一個呼叫。這樣做的好處是,作業系統只需要2次上下文切換了,減少了2次上下文切換的開銷。

gather

Linux2.4核心對sendfile進行了優化,提供了gather操作,這個操作可以把上圖中的最後一次CPU copy去掉,原理就是不復制資料,而是把資料在之前的核心緩衝區(比如圖中的案例是Read Buffer)的記憶體地址、偏移量記錄傳送給目標核心緩衝區(比如圖中案例的Socket Buffer),這樣在最後的DMA copy階段就可以拿著這個指標直接去找資料copy了。

gather

Linux的零拷貝確實能夠節約一些作業系統的資源。所以Java的NIO為了支援零拷貝,提供了一些類:

  • DirectByteBuffer
  • FileChannel

在之前的《Java NIO - Buffer》這篇文章裡大概介紹了DirectByteBuffer。ByteBuffer主要有兩種實現,一種是DirectByteBuffer, 一種是HeapByteBuffer。

其中,DirectByteBuffer直接在堆外分配記憶體,底層是直接通過JNI呼叫作業系統的NIO系統呼叫,所以效能會比較高。而HeapByteBuffer是堆內記憶體,而且資料需要多一次拷貝,所以效能比較低。

FileChannel是Java NIO提供的用於複製檔案的類,可以把檔案複製到磁碟或者網路等。

map方法其實就是採用了作業系統中的記憶體對映方式,將核心緩衝區的記憶體和使用者緩衝區的記憶體做了一個地址對映。

transferTo方法直接將當前通道內容傳輸到另一個通道,也就是說這種方式不會有核心緩衝區到使用者緩衝區的讀寫問題。底層是sendfile系統呼叫。transferFrom方法同理。

示例程式碼:

File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
FileChannel fileChannel = raf.getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("", 8080));
// 直接使用了transferTo()進行通道間的資料傳輸
fileChannel.transferTo(0, fileChannel.size(), socketChannel);

作者:公眾號_xy的技術圈

連結:www.imooc.com/article/289550

來源:慕課網

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章