網上關於 “零拷貝” 原理相關的文章滿天飛,但你知道如何使用零拷貝嗎?

零拷貝是中間件相關面試中必考題,本文就和大家一起來總結一下 NIO 拷貝的原理,並結合 Netty 代碼,從代碼實現層面近距離觀摩如何使用 java 實現零拷貝。

1、零拷貝實現原理

“零拷貝” 其實包括兩個層面的含義:

在 IO 編程領域,當然是拷貝的次數越少越好,逐步優化,將其拷貝次數將爲 0,最大化的提高性能。

那接下來我們循序漸進來看一下如何減少數據複製。

接下來我們將以 RocketMQ 消息發送、消息讀取場景來闡述 IO 讀寫過程中可能需要進行的數據複製與上下文切換。

1.1 傳統的 IO 讀流程

一次傳統的 IO 讀序列流程如下所示:java 應用中,如果要將從文件中讀取數據,其基本的流程如下所示:

  1. 當 broker 收到拉取請求時發起一次 read 系統調用,此時操作系統會進行一次上下文的切換,從用態間切換到內核態

  2. 通過直接存儲訪問器 (DMA) 從磁盤將數據加載到內核緩存區DMA Copy,這個階段不需要 CPU 參與,如果是阻塞型 IO,該過程用戶線程會處於阻塞狀態)

  3. 然後在 CPU 的控制下,將內核緩存區的數據 copy 到用戶空間的緩存區 (由於這個是操作系統級別的行爲,通常這裏指的內存緩存區,通常使用的是堆外內存),這裏將發生一次 CPU 複製與一次上下文切換(從內核態切換到用戶態)

  4. 堆外內存中的數據複製到應用程序的堆內存,供應用程序使用,本次複製需要經過 CPU 控制。

  5. 將數據加載到堆空間,需要傳輸到網卡,這個過程又要進入到內核空間,然後複製到 sockebuffer,然後進入網卡協議引擎,從而進入到網絡傳輸中。該部分會在接下來會詳細介紹。

溫馨提示:RocketMQ 底層的工作機制並不是上述模型,是經過優化後的讀寫模型,本文將循序漸進的介紹優化過程。

1.2 傳統的 IO 寫流程

一次傳統的 IO 寫入流程如下圖所示:核心關鍵步驟如下:

  1. 在 broker 收到消息時首先會在堆空間中創建一個堆緩存區,用於存儲用戶需要寫入的數據,然後需要將 jvm 堆內存中數據複製到操作系統內存 (CPU COPY)

  2. 發起 write 系統調用,將用戶空間中的數據複製到內存緩存區,** 此過程發生一次上下文切換(用戶態切換到內核態)** 並進行一次 CPU Copy。

  3. 通過直接存儲訪問器 (DMA) 將內核空間的數據寫入到磁盤,並返回結果,此過程發生一次 DMA Copy 與一次上下文切換(內核態切換到用戶態)

1.3 讀寫優化技巧

從上面兩張流程圖,我們不能看出讀寫處理流程中存在太多複製,同樣的數據需要被複制多次,造成性能損耗,故 IO 讀寫通常的優化方向主要爲:減少複製次數、減少用戶態 / 內核態切換次數。

1.3.1 引入堆外內存

jvm 堆空間中數據要發送到內核緩存區,通常需要先將 jvm 堆空間中的數據拷貝到系統內存(一個非官方的理解,用 C 語言實現的本地方法調用中,首先需要將堆空間中數據拷貝到 C 語言相關的存儲結構),故提高性能的第一個措施:使用堆外內存。

不過堆外內存中的數據,通常還是需要從堆空間中獲取,從這個角度來看,貌似提升的性能有限。

1.3.2 引入內存映射 (MMap 與 write)

通過引入內存映射機制,減少用戶空間與內核空間之間的數據複製,如下圖所示:內存映射的核心思想就是將內核緩存區、用戶空間緩存區映射到同一個物理地址上,可以減少用戶緩存區與內核緩存區之間的數據拷貝

但由於內存映射機制並不會減少上下文切換次數。

1.3.3 大名鼎鼎鼎 sendfile

在 Linux 2.1 內核引入了 sendfile 函數用於將文件通過 socket 傳送

注意 sendfile 的傳播方向:使用於將文件中的內容直接傳播到 Socket,通常使用客戶端從服務端文件中讀取數據,在服務端內部實現零拷貝。

在 1.3.1 中介紹客戶端從服務端讀取消息的過程中,並沒有展開介紹從服務端寫入到客戶端網絡中的過程,接下來看看 sendfile 的數據拷貝圖解:sendfile 的主要特點是在內核空間中通過 DMA 將數據從磁盤文件拷貝到內核緩存區,然後可以直接將內核緩存區中的數據在 CPU 控制下將數據複製到 socket 緩存區,最終在 DMA 的控制下將 socketbufer 中拷貝到協議引擎,然後經網卡傳輸到目標端。

sendfile 的優勢 (特點):

1.3.4 Linux Gather

Linux2.4 內核引入了 gather 機制,用以消除最後一次 CPU 拷貝,即不再將內核緩存區中的數據拷貝到 socketbuffer,而是將內存緩存區中的內存地址、需要讀取數據的長度寫入到 socketbuffer 中,然後 DMA 直接根據 socketbuffer 中存儲的內存地址,直接從內核緩存區中的數據拷貝到協議引擎(注意,這次拷貝由 DMA 控制)。

從而實現真正的零拷貝。

2、結合 Netty 談零拷貝實戰

上面講述了 “零拷貝” 的實現原理,接下來將嘗試從 Netty 源碼去探究在代碼層面如何使用 “零拷貝”

從網上的資料可以得知,在 java nio 提供的類庫中真正能運用底層操作系統的零拷貝機制只有 FileChannel 的 transferTo,而在 Netty 中也不出意料的對這種方式進行了封裝,其類圖如下:其主要的核心要點是 FileRegion 的 transferTo 方法,我們結合該方法再來介紹 DefaultFileRegion 各個核心屬性的含義。上述代碼並不複雜,我們不難得出如下觀點:

接下來我們看一下 FileRegion 的 transferTo 在 netty 中的調用鏈,從而推斷一下 Netty 中的零拷貝的觸發要點。在 Netty 中代表兩個類型的通道:

在 Netty 中調用通道 Channel 的 flush 或 writeAndFlush 方法,都會最終觸發底層通道的網絡寫事件,如果待寫入的對象是 FileRegion,則會觸發零拷貝機制,接下來我們對兩個簡單介紹一下:

2.1 EpollSocketChannel 通道零拷貝

寫入的入口函數爲如下:核心思想爲:如果待寫入的消息是 DefaultFileRegion,EpollSocketChannel 將直接調用 sendfile 函數進行數據傳遞;如果是 FileRegion 類型,則按照約定調用 FileRegion 的 transferTo 進行數據傳遞,這種方式是否真正進行零拷貝取決於 FileRegion 的 transferTo 中是否調用了 FileChannel 的 transferTo 方法。

溫馨提示:本文並沒有打算詳細分析 Epoll 機制以及編程實踐。

2.2 NioSocketChannel 通道零拷貝實現

實現入口爲:從這裏可知,NioSocketChannel 就是中規中矩的調用 FileRegion 的 transferTo 方法,是否真正實現了零拷貝,取決於底層是否調用了 FileChannel 的 transferTo 方法。

2.3 零拷貝實踐總結

從 Netty 的實現中我們基本可以得出結論:是否是零拷貝,判斷的依據是是否調用了 FileChannel 的 transferTo 方法,更準備的表述是底層是否調用了操作系統的 sendfile 函數,並且操作系統底層還需要支持 gather 機制,即 linux 的內核版本不低於 2.4。

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