【性能】零拷貝
零拷貝概念
避免數(shù)據(jù)拷貝
①避免操作系統(tǒng)內(nèi)核緩沖區(qū)之間進行數(shù)據(jù)拷貝操作。
②避免操作系統(tǒng)內(nèi)核和用戶應用程序地址空間這兩者之間進行數(shù)據(jù)拷貝操作。
③用戶應用程序可以避開操作系統(tǒng)直接訪問硬件存儲。
④數(shù)據(jù)傳輸盡量讓 DMA 來做。綜合目標
①避免不必要的系統(tǒng)調(diào)用和上下文切換。
②需要拷貝的數(shù)據(jù)可以先被緩存起來。
③對數(shù)據(jù)進行處理盡量讓硬件來做。

需要注意,它不能用于實現(xiàn)了數(shù)據(jù)加密或者壓縮的文件系統(tǒng)上,只有傳輸文件的原始內(nèi)容。這類原始內(nèi)容也包括加密了的文件內(nèi)容。
傳統(tǒng)IO的執(zhí)行流程
比如想實現(xiàn)一個下載功能,服務端的任務就是:將服務器主機磁盤中的文件從已連接的socket中發(fā)出去,關鍵代碼如下:
while((n = read(diskfd, buf, BUF_SIZE)) > 0)write(sockfd, buf , n);

1.應用程序調(diào)用read函數(shù),向操作系統(tǒng)發(fā)起IO調(diào)用,上下文從用戶態(tài)切換至內(nèi)核態(tài)
2.DMA控制器把數(shù)據(jù)從磁盤中讀取到內(nèi)核緩沖區(qū)
3.CPU把內(nèi)核緩沖區(qū)數(shù)據(jù)拷貝到用戶應用緩沖區(qū),上下文從內(nèi)核態(tài)切換至用戶態(tài),此時read函數(shù)返回
4.用戶應用進程通過write函數(shù),發(fā)起IO調(diào)用,上下文從用戶態(tài)切換至內(nèi)核態(tài)
5.CPU將緩沖區(qū)的數(shù)據(jù)拷貝到socket緩沖區(qū)
6.DMA控制器將數(shù)據(jù)從socket緩沖區(qū)拷貝到網(wǎng)卡設備,上下文從內(nèi)核態(tài)切換至用戶態(tài),此時write函數(shù)返回
從流程圖中可以看出傳統(tǒng)的IO流程包括***4次上下文的切換***,4次拷貝數(shù)據(jù)(兩次CPU拷貝以及兩次DMA拷貝)
DMA技術
DMA,英文全稱是Direct Memory Access,即直接內(nèi)存訪問。DMA本質(zhì)上是一塊主板上獨立的芯片,允許外設設備和內(nèi)存存儲器之間直接進行IO數(shù)據(jù)傳輸,其過程不需要CPU的參與。
簡單的說它就是幫住CPU轉發(fā)一下IO請求以及拷貝數(shù)據(jù),那為什么需要它呢?其實主要是效率問題。它幫忙CPU做事情,這時候,CPU就可以閑下來去做別的事情,提高了CPU的利用效率。大白話解釋就是,CPU老哥太忙太累啦,所以他找了個小弟(名叫DMA) ,替他完成一部分的拷貝工作,這樣CPU老哥就能著手去做其他事情。
下面看下DMA具體是做了哪些工作

1.用戶應用程序調(diào)read函數(shù),向操作系統(tǒng)發(fā)起IO調(diào)用,進入阻塞狀態(tài)等待數(shù)據(jù)返回。
2.CPU接到指令后,對DMA控制器發(fā)起指令調(diào)度。
3.DMA收到請求后,將請求發(fā)送給磁盤。
4.磁盤將數(shù)據(jù)放入磁盤控制緩沖區(qū)并通知DMA。
5.DMA將數(shù)據(jù)從磁盤控制器緩沖區(qū)拷貝到內(nèi)核緩沖區(qū)。
6.DMA向CPU發(fā)送數(shù)據(jù)讀完的信號,CPU負責將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶緩沖區(qū)。
7.用戶應用進程由內(nèi)核態(tài)切回用戶態(tài),解除阻塞狀態(tài)。
java提供的零拷貝方式
mmap
public class MmapTest {public static void main(String[] args) {try {FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);//數(shù)據(jù)傳輸writeChannel.write(data);readChannel.close();writeChannel.close();}catch (Exception e){System.out.println(e.getMessage());}}}
為SIGBUS信號建立信號處理程序
當遇到SIGBUS信號時,信號處理程序簡單地返回,write系統(tǒng)調(diào)用在被中斷之前會返回已經(jīng)寫入的字節(jié)數(shù),并且errno會被設置成success,但是這是一種糟糕的處理辦法,因為你并沒有解決問題的實質(zhì)核心。使用文件租借鎖
通常我們使用這種方法,在文件描述符上使用租借鎖,我們?yōu)槲募騼?nèi)核申請一個租借鎖,當其它進程想要截斷這個文件時,內(nèi)核會向我們發(fā)送一個實時的RT_SIGNAL_LEASE信號,告訴我們內(nèi)核正在破壞你加持在文件上的讀寫鎖。這樣在程序訪問非法內(nèi)存并且被SIGBUS殺死之前,你的write系統(tǒng)調(diào)用會被中斷。write會返回已經(jīng)寫入的字節(jié)數(shù),并且置errno為success。
我們應該在mmap文件之前加鎖,并且在操作完文件后解鎖if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {perror("kernel lease set signal");return -1;}/* l_type can be F_RDLCK F_WRLCK 加鎖*//* l_type can be F_UNLCK 解鎖*/if(fcntl(diskfd, F_SETLEASE, l_type)){perror("kernel lease set type");return -1;}
sendfile
public class SendFileTest {public static void main(String[] args) {try {FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);long len = readChannel.size();long position = readChannel.position();FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);//數(shù)據(jù)傳輸readChannel.transferTo(position, len, writeChannel);readChannel.close();writeChannel.close();} catch (Exception e) {System.out.println(e.getMessage());}}}
從2.1版內(nèi)核開始,Linux引入了sendfile來簡化操作:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
系統(tǒng)調(diào)用sendfile()在代表輸入文件的描述符in_fd和代表輸出文件的描述符out_fd之間傳送文件內(nèi)容(字節(jié))。描述符out_fd必須指向一個套接字,而in_fd指向的文件必須是可以mmap的。這些局限限制了sendfile的使用,使sendfile只能將數(shù)據(jù)從文件傳遞到套接字上,反之則不行。使用sendfile不僅減少了數(shù)據(jù)拷貝的次數(shù),還減少了上下文切換,數(shù)據(jù)傳送始終只發(fā)生在kernel space。

在我們調(diào)用sendfile時,如果有其它進程截斷了文件會發(fā)生什么呢?假設我們沒有設置任何信號處理程序,sendfile調(diào)用僅僅返回它在被中斷之前已經(jīng)傳輸?shù)淖止?jié)數(shù),errno會被置為success。如果我們在調(diào)用sendfile之前給文件加了鎖,sendfile的行為仍然和之前相同,我們還會收到RT_SIGNAL_LEASE的信號。
目前為止,我們已經(jīng)減少了數(shù)據(jù)拷貝的次數(shù)了,但是仍然存在一次拷貝,就是頁緩存到socket緩存的拷貝。那么能不能把這個拷貝也省略呢?
借助于硬件上的幫助,我們是可以辦到的。之前我們是把頁緩存的數(shù)據(jù)拷貝到socket緩存中,實際上,我們僅僅需要把緩沖區(qū)描述符傳到socket緩沖區(qū),再把數(shù)據(jù)長度傳過去,這樣DMA控制器直接將頁緩存中的數(shù)據(jù)打包發(fā)送到網(wǎng)絡中就可以了。
總結一下,sendfile系統(tǒng)調(diào)用利用DMA引擎將文件內(nèi)容拷貝到內(nèi)核緩沖區(qū)去,然后將帶有文件位置和長度信息的緩沖區(qū)描述符添加socket緩沖區(qū)去,這一步不會將內(nèi)核中的數(shù)據(jù)拷貝到socket緩沖區(qū)中,DMA引擎會將內(nèi)核緩沖區(qū)的數(shù)據(jù)拷貝到協(xié)議引擎中去,避免了最后一次拷貝。不過這一種收集拷貝功能是需要硬件以及驅(qū)動程序支持的。

無論是傳統(tǒng)的 I/O 方式,還是引入了零拷貝之后,2 次 DMA copy是都少不了的。因為兩次 DMA 都是依賴硬件完成的。所以,所謂的零拷貝,都是為了減少 CPU copy 及減少了上下文的切換。
下圖展示了各種零拷貝技術的對比圖:

Netty 的零拷貝
主要包含三個方面:
(1)Netty 的接收和發(fā)送 ByteBuffer 采用 DIRECT BUFFERS ,使用堆外直接內(nèi)存進行 Socket 讀寫,不需要進行字節(jié)緩沖區(qū)的二次拷貝。如果使用傳統(tǒng)的堆內(nèi)存 ( HEAP BUFFERS)進行 Socket 讀寫, JVM 會將堆內(nèi)存 Buffer 拷貝一份到直接內(nèi) 存中,然后才寫入 Socket 中。相比于堆外直接內(nèi)存,消息在發(fā)送過程中多了一次緩 沖區(qū)的內(nèi)存拷貝。
(2)Netty 提供了組合 Buffer 對象,可以聚合多個 ByteBuffer 對象,用戶可以像操作一個 Buffer 那樣方便的對組合 Buffer 進行操作,避免了傳統(tǒng)通過內(nèi)存拷貝的方式 將幾個 小 Buffer 合并成一個大的 Buffer 。
(3)Netty 的文件傳輸采用了 transferTo 方法,它可以直接將文件緩沖區(qū)的數(shù)據(jù)發(fā)送到目標 Channel ,避免了傳統(tǒng)通過循環(huán) write 方式導致的內(nèi)存拷貝問題。
零拷貝機制是Netty高性能的一個原因,之前都是說netty的線程模型,責任鏈,說說netty底層的優(yōu)化,優(yōu)化就是netty自己的一個緩沖區(qū)。

(一)Netty自己的ByteBuf
介紹
ByteBuf 是為解決 ByteBuffer的問題和滿足網(wǎng)絡應用程序開發(fā)人員的日常需求而設計的。
對比JDK byteBuffer的缺點
無法動態(tài)擴容
長度是固定的,不能動態(tài)擴展和收縮,當數(shù)據(jù)大于ByteBuffer容量時,會發(fā)生索引越界異常。
API 使用復雜
讀寫的時候需要手工調(diào)用flip()和rewind()等方法,使用時需要非常謹慎的考慮這些API,否則容出現(xiàn)錯誤。
Netty的ByteBuf 操作
ByteBuf三個重要屬性:capacity容量,readerIndex讀取位置,writerIndex 寫入位置。提供了兩個指針變量來支持順序和寫操作,分別是讀操作readerIndex 和寫操作writeIndex。
常見的方法定義
隨機訪問索引 getByte
順序讀 read*
順序?qū)?write*
清除已讀內(nèi)容discardReadBytes
清除緩沖區(qū) clear
搜索操作
標記和重置
引用計數(shù)和釋放
緩沖區(qū)是如何被兩個指針分割成三個區(qū)域的
discardable bytes 已讀可丟棄區(qū)域
readable bytes 可讀區(qū)域
writable bytes 待寫區(qū)域
ByteBuf 動態(tài)擴容
capacity 默認值:256字節(jié),最大值:Integer.MAX_VALUE(2GB)
write 方法調(diào)用時,通過AbstractByteBuf.ensureWritable進行檢查。
容量計算方法:AbstractByteBufAllocator.calculateNewCapacity(新capacity的最小要求,capacity最大值)
根據(jù)新的capacity的最小值要求,對應有兩套計算方法
沒超過4兆:從64字節(jié)開發(fā),每次增加一倍,直至計算出來的newCapacity滿足新容量最小要求。示例:當前大小256,已寫250,繼續(xù)寫10字節(jié)數(shù)據(jù),需要的容量最小要求是261,則新容量是6422*2=512
超過4兆:新容量 = 新容量最小要求/4兆 * 4兆 +4兆
示例:當前大小3兆,已寫3兆,繼續(xù)寫2兆數(shù)據(jù),需要的容量最小要求是5兆, 則新容量是9兆(不能超過最大值)
選擇合適的 ByteBuf 實現(xiàn)
在實際使用中都是通過 ByteBufAllocator 分配器進行申請,同時分配器具有內(nèi)存管理的功能。

unsafe 用到了 Unsafe 工具類,Unsafe 是 Java 保留的一個底層工具包,safe 則沒有用到 unsafe 工具類。
unsafe 意味著不安全的操作,但是更底層的操作會帶來性能提升和特殊功能,Netty 中會盡力使用 unsafe。
Java 語言很重要的特性是“一次編寫導出運行”,所以它針對底層的內(nèi)存或其他操作,做了很多封裝。而 unsafe 提供了一系列操作底層的方法,可能會導致不兼容或者不可知的異常。
unpool 每次申請緩沖區(qū)時會新建一個,并不會復用,使用 Unpooled 工具類可以創(chuàng)建 unpool 的緩沖區(qū)。
Netty 沒有給出很便捷的 pool 類型的緩沖區(qū)的創(chuàng)建方法。使用 ChannelConfig.getAllocator() 時,獲取到的分配器是默認支持內(nèi)存復用的。
pooledByteBuf對象、內(nèi)存
PoolThreadCache: PooledByteBufAllocator 實例維護了一個線程變量。
多種分類的MemoryRegionCache數(shù)組用作內(nèi)存緩存,MemoryRegionCache內(nèi)部是鏈表,隊列里面存Chunk。
Pool Chunk里面維護了內(nèi)存引用,內(nèi)存復用的做法就是把buf的memory指向Chunck的memory。
Netty 的零拷貝機制,是一種應用層的實現(xiàn)。
拷貝方式
一般的數(shù)組合并,會創(chuàng)建一個大的數(shù)組,然后將需要合并的數(shù)組放進去。
Netty 的 CompositeButyBuf 將多個 ByteBuf 合并為一個邏輯上的 ByteBuf,避免了各個 ByteBuf 之間的拷貝。
wrappedBuffer 方法將 byte[] 數(shù)組包裝成 ByteBuf 對象
slice 方法將一個 ByteBuf 對象切分成多個 ByteBuf 對象
實例
PS:API操作便捷性,動態(tài)擴容,多種ByteBuf實現(xiàn),高效的零拷貝機制(邏輯上邊的設計)上邊的所有就是nettyByteBuf所做的工作,性能提升,操作性增強。
