零拷貝是中間件相關面試中必考題,本文就和大家一起來總結一下NIO拷貝的原理,并結合Netty代碼,從代碼實作層面近距離觀摩如何使用java實作零拷貝,
1、零拷貝實作原理
**“零拷貝”**其實包括兩個層面的含義:
- 拷貝
一份相同的資料從一個地方移動到另外一個地方的程序,叫拷貝, - 零
希望在IO讀寫程序中,CPU控制的資料拷貝到次數為0,
在IO編程領域,當然是拷貝的次數越少越好,逐步優化,將其拷貝次數將為0,最大化的提高性能,
那接下來我們循序漸進來看一下如何減少資料復制,
接下來我們將以RocketMQ訊息發送、訊息讀取場景來闡述IO讀寫程序中可能需要進行的資料復制與背景關系切換,
1.1 傳統的IO讀流程
一次傳統的IO讀序列流程如下所示:

java應用中,如果要將從檔案中讀取資料,其基本的流程如下所示:
- 當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的主要特點是在內核空間中通過DMA將資料從磁盤檔案拷貝到內核快取區,然后可以直接將內核快取區中的資料在CPU控制下將資料復制到socket快取區,最終在DMA的控制下將socketbufer中拷貝到協議引擎,然后經網卡傳輸到目標端,
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中也不出意料的對這種方式進行了封裝,其類圖如下:

其主要的核心要點是FileRegion的transferTo方法,我們結合該方法再來介紹DefaultFileRegion各個核心屬性的含義,

上述代碼并不復雜,我們不難得出如下觀點:
- 首先介紹DefaultFileRegion的核心屬性含義:
- File f
底層抽取資料來源的底層磁盤檔案 - FileChannel file
底層檔案的檔案通道, - long position
資料從通道中抽取的起始位置 - long count
需要傳遞的總位元組數 - long transfered
已傳遞的位元組數量,
- File f
- 核心要點是呼叫java nio FileChannel的transferTo方法,底層呼叫的是作業系統的sendfile函式,即真正的零拷貝,
- 呼叫一次transferTo方法并不一定能將需要的資料全部傳輸完成,故該方法回傳已傳輸的位元組數,是否需要再次呼叫該方法的判斷方法:已傳遞的位元組數是否等于需要傳遞的總位元組數(transfered == count)
接下來我們看一下FileRegion的transferTo在netty中的呼叫鏈,從而推斷一下Netty中的零拷貝的觸發要點,

在Netty中代表兩個型別的通道:
-
EpollSocketChannel
基于Epoll機制進行事件的就緒選擇機制, -
NioSocketChannel
基于select機制的事件就緒選擇,
在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,
一鍵三連(關注、點贊、留言)是對我最大的鼓勵,
各位技術朋友們,我是《RocketMQ技術內幕》一書作者,CSDN2020博客之星TOP2,熱衷于中間件領域的技術分享,維護「中間件興趣圈」公眾號,旨在成體系剖析Java主流中間件,構建完備的分布式架構體系,歡迎大家大家關注我,回復「專欄」可獲取15個專欄;回復「PDF」可獲取海量學習資料,回復「加群」可以拉你入技術交流群,零距離與BAT大廠的大神交流,

轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/353467.html
標籤:其他
