網上關於 “零拷貝” 原理相關的文章滿天飛,但你知道如何使用零拷貝嗎?
零拷貝是中間件相關面試中必考題,本文就和大家一起來總結一下 NIO 拷貝的原理,並結合 Netty 代碼,從代碼實現層面近距離觀摩如何使用 java 實現零拷貝。
1、零拷貝實現原理
“零拷貝” 其實包括兩個層面的含義:
-
拷貝 一份相同的數據從一個地方移動到另外一個地方的過程,叫拷貝。
-
零 希望在 IO 讀寫過程中,CPU 控制的數據拷貝到次數爲 0。
在 IO 編程領域,當然是拷貝的次數越少越好,逐步優化,將其拷貝次數將爲 0,最大化的提高性能。
那接下來我們循序漸進來看一下如何減少數據複製。
接下來我們將以 RocketMQ 消息發送、消息讀取場景來闡述 IO 讀寫過程中可能需要進行的數據複製與上下文切換。
1.1 傳統的 IO 讀流程
一次傳統的 IO 讀序列流程如下所示:
-
當 broker 收到拉取請求時發起一次 read 系統調用,此時操作系統會進行一次上下文的切換,從用態間切換到內核態。
-
通過直接存儲訪問器 (DMA) 從磁盤將數據加載到內核緩存區(DMA Copy,這個階段不需要 CPU 參與,如果是阻塞型 IO,該過程用戶線程會處於阻塞狀態)
-
然後在 CPU 的控制下,將內核緩存區的數據 copy 到用戶空間的緩存區 (由於這個是操作系統級別的行爲,通常這裏指的內存緩存區,通常使用的是堆外內存),這裏將發生一次 CPU 複製與一次上下文切換(從內核態切換到用戶態)
-
將堆外內存中的數據複製到應用程序的堆內存,供應用程序使用,本次複製需要經過 CPU 控制。
-
將數據加載到堆空間,需要傳輸到網卡,這個過程又要進入到內核空間,然後複製到 sockebuffer,然後進入網卡協議引擎,從而進入到網絡傳輸中。該部分會在接下來會詳細介紹。
溫馨提示:RocketMQ 底層的工作機制並不是上述模型,是經過優化後的讀寫模型,本文將循序漸進的介紹優化過程。
1.2 傳統的 IO 寫流程
一次傳統的 IO 寫入流程如下圖所示:
-
在 broker 收到消息時首先會在堆空間中創建一個堆緩存區,用於存儲用戶需要寫入的數據,然後需要將 jvm 堆內存中數據複製到操作系統內存 (CPU COPY)
-
發起 write 系統調用,將用戶空間中的數據複製到內存緩存區,** 此過程發生一次上下文切換(用戶態切換到內核態)** 並進行一次 CPU Copy。
-
通過直接存儲訪問器 (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 的優勢 (特點):
-
一次 sendfile 調用會只設計兩次上下文切換,比 read+write 減少兩次上下文切換。
-
一次 sendfile 會存在 3 次 copy, 其中一次 CPU 拷貝,兩次 DMA 拷貝。
1.3.4 Linux Gather
Linux2.4 內核引入了 gather 機制,用以消除最後一次 CPU 拷貝,即不再將內核緩存區中的數據拷貝到 socketbuffer,而是將內存緩存區中的內存地址、需要讀取數據的長度寫入到 socketbuffer 中,然後 DMA 直接根據 socketbuffer 中存儲的內存地址,直接從內核緩存區中的數據拷貝到協議引擎(注意,這次拷貝由 DMA 控制)。
從而實現真正的零拷貝。
2、結合 Netty 談零拷貝實戰
上面講述了 “零拷貝” 的實現原理,接下來將嘗試從 Netty 源碼去探究在代碼層面如何使用 “零拷貝”。
從網上的資料可以得知,在 java nio 提供的類庫中真正能運用底層操作系統的零拷貝機制只有 FileChannel 的 transferTo,而在 Netty 中也不出意料的對這種方式進行了封裝,其類圖如下:
-
首先介紹 DefaultFileRegion 的核心屬性含義:
-
File f 底層抽取數據來源的底層磁盤文件
-
FileChannel file 底層文件的文件通道。
-
long position 數據從通道中抽取的起始位置
-
long count 需要傳遞的總字節數
-
long transfered 已傳遞的字節數量。
-
核心要點是調用 java nio FileChannel 的 transferTo 方法,底層調用的是操作系統的 sendfile 函數,即真正的零拷貝。
-
調用一次 transferTo 方法並不一定能將需要的數據全部傳輸完成,故該方法返回已傳輸的字節數,是否需要再次調用該方法的判斷方法:已傳遞的字節數是否等於需要傳遞的總字節數(transfered == count)
接下來我們看一下 FileRegion 的 transferTo 在 netty 中的調用鏈,從而推斷一下 Netty 中的零拷貝的觸發要點。
-
EpollSocketChannel 基於 Epoll 機制進行事件的就緒選擇機制。
-
NioSocketChannel
基於 select 機制的事件就緒選擇。
在 Netty 中調用通道 Channel 的 flush 或 writeAndFlush 方法,都會最終觸發底層通道的網絡寫事件,如果待寫入的對象是 FileRegion,則會觸發零拷貝機制,接下來我們對兩個簡單介紹一下:
2.1 EpollSocketChannel 通道零拷貝
寫入的入口函數爲如下:
溫馨提示:本文並沒有打算詳細分析 Epoll 機制以及編程實踐。
2.2 NioSocketChannel 通道零拷貝實現
實現入口爲:
2.3 零拷貝實踐總結
從 Netty 的實現中我們基本可以得出結論:是否是零拷貝,判斷的依據是是否調用了 FileChannel 的 transferTo 方法,更準備的表述是底層是否調用了操作系統的 sendfile 函數,並且操作系統底層還需要支持 gather 機制,即 linux 的內核版本不低於 2.4。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/j83pq35Ts7yQ8Sjy955Ajw