天天看點

【性能】零拷貝

零拷貝概念

零拷貝就是一種避免 CPU 将資料從一塊存儲拷貝到另外一塊存儲的技術。針對作業系統中的裝置驅動程式、檔案系統以及網絡協定堆棧而出現的各種零拷貝技術極大地提升了特定應用程式的性能,并且使得這些應用程式可以更加有效地利用系統資源。這種性能的提升就是通過在資料拷貝進行的同時,允許 CPU 執行其他的任務來實作的。

零拷貝技術可以減少資料拷貝和共享總線操作的次數,消除傳輸資料在存儲器之間不必要的中間拷貝次數,進而有效地提高資料傳輸效率。而且,零拷貝技術減少了使用者應用程式位址空間和作業系統核心位址空間之間因為上下文切換而帶來的開銷。進行大量的資料拷貝操作其實是一件簡單的任務,從作業系統的角度來說,如果 CPU 一直被占用着去執行這項簡單的任務,那麼這将會是很浪費資源的;如果有其他比較簡單的系統部件可以代勞這件事情,進而使得 CPU 解脫出來可以做别的事情,那麼系統資源的利用則會更加有效。綜上所述,零拷貝技術的目标可以概括如下:

  1. 避免資料拷貝

    ①避免作業系統核心緩沖區之間進行資料拷貝操作。

    ②避免作業系統核心和使用者應用程式位址空間這兩者之間進行資料拷貝操作。

    ③使用者應用程式可以避開作業系統直接通路硬體存儲。

    ④資料傳輸盡量讓 DMA 來做。

  2. 綜合目标

    ①避免不必要的系統調用和上下文切換。

    ②需要拷貝的資料可以先被緩存起來。

    ③對資料進行處理盡量讓硬體來做。

【性能】零拷貝
需要注意,它不能用于實作了資料加密或者壓縮的檔案系統上,隻有傳輸檔案的原始内容。這類原始内容也包括加密了的檔案内容。

 多次資料拷貝

從上圖中可以看出,共産生了四次資料拷貝,即使使用了DMA來處理了與硬體的通訊,CPU仍然需要處理兩次資料拷貝,與此同時,在使用者态與核心态也發生了多次上下文切換,無疑也加重了CPU負擔。

在此過程中,我們沒有對檔案内容做任何修改,那麼在核心空間和使用者空間來回拷貝資料無疑就是一種浪費,而零拷貝主要就是為了解決這種低效性。

傳統IO的執行流程

比如想實作一個下載下傳功能,服務端的任務就是:将伺服器主機磁盤中的檔案從已連接配接的socket中發出去,關鍵代碼如下:

while((n = read(diskfd, buf, BUF_SIZE)) > 0)
    write(sockfd, buf , n);      

傳統的IO流程包括read以及write的過程

  1. read:将資料從磁盤讀取到核心緩存區中,在拷貝到使用者緩沖區
  2. write:先将資料寫入到socket緩沖區中,最後寫入網卡裝置

流程圖如下:

【性能】零拷貝

1.應用程式調用read函數,向作業系統發起IO調用,上下文從使用者态切換至核心态

2.DMA控制器把資料從磁盤中讀取到核心緩沖區

3.CPU把核心緩沖區資料拷貝到使用者應用緩沖區,上下文從核心态切換至使用者态,此時read函數傳回

4.使用者應用程序通過write函數,發起IO調用,上下文從使用者态切換至核心态

5.CPU将緩沖區的資料拷貝到socket緩沖區

6.DMA控制器将資料從socket緩沖區拷貝到網卡裝置,上下文從核心态切換至使用者态,此時write函數傳回

從流程圖中可以看出傳統的IO流程包括***4次上下文的切換***,4次拷貝資料(兩次CPU拷貝以及兩次DMA拷貝)

DMA技術

DMA,英文全稱是Direct Memory Access,即直接記憶體通路。DMA本質上是一塊主機闆上獨立的晶片,允許外設裝置和記憶體存儲器之間直接進行IO資料傳輸,其過程不需要CPU的參與。

簡單的說它就是幫住CPU轉發一下IO請求以及拷貝資料,那為什麼需要它呢?其實主要是效率問題。它幫忙CPU做事情,這時候,CPU就可以閑下來去做别的事情,提高了CPU的利用效率。大白話解釋就是,CPU老哥太忙太累啦,是以他找了個小弟(名叫DMA) ,替他完成一部分的拷貝工作,這樣CPU老哥就能着手去做其他事情。

下面看下DMA具體是做了哪些工作

【性能】零拷貝

1.使用者應用程式調read函數,向作業系統發起IO調用,進入阻塞狀态等待資料傳回。

2.CPU接到指令後,對DMA控制器發起指令排程。

3.DMA收到請求後,将請求發送給磁盤。

4.磁盤将資料放入磁盤控制緩沖區并通知DMA。

5.DMA将資料從磁盤控制器緩沖區拷貝到核心緩沖區。

6.DMA向CPU發送資料讀完的信号,CPU負責将資料從核心緩沖區拷貝到使用者緩沖區。

7.使用者應用程序由核心态切回使用者态,解除阻塞狀态。

java提供的零拷貝方式

mmap

Java NIO有一個MappedByteBuffer的類可以用來實作記憶體映射。它的底層是調用的linux核心的mmap的API。

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);
            //資料傳輸
            writeChannel.write(data);
            readChannel.close();
            writeChannel.close();
        }catch (Exception e){
            System.out.println(e.getMessage());
        }
    }
}      

使用mmap替代read很明顯減少了一次拷貝,當拷貝資料量很大時,無疑提升了效率。但是使用mmap是有代價的。當你使用mmap時,你可能會遇到一些隐藏的陷阱。例如,當你的程式map了一個檔案,但是當這個檔案被另一個程序截斷(truncate)時, write系統調用會因為通路非法位址而被SIGBUS信号終止。SIGBUS信号預設會殺死你的程序并産生一個coredump,如果你的伺服器這樣被中止了,那會産生一筆損失。

通常我們使用以下解決方案避免這種問題:

  1. 為SIGBUS信号建立信号處理程式

    當遇到SIGBUS信号時,信号處理程式簡單地傳回,write系統調用在被中斷之前會傳回已經寫入的位元組數,并且errno會被設定成success,但是這是一種糟糕的處理辦法,因為你并沒有解決問題的實質核心。

  2. 使用檔案租借鎖

    通常我們使用這種方法,在檔案描述符上使用租借鎖,我們為檔案向核心申請一個租借鎖,當其它程序想要截斷這個檔案時,核心會向我們發送一個實時的RT_SIGNAL_LEASE信号,告訴我們核心正在破壞你加持在檔案上的讀寫鎖。這樣在程式通路非法記憶體并且被SIGBUS殺死之前,你的write系統調用會被中斷。write會傳回已經寫入的位元組數,并且置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

FileChannel的transferTo()/transferFrom(),底層就是sendfile() 系統調用函數。Kafka 這個開源項目就用到它,平時面試的時候,回答面試官為什麼這麼快,就可以提到零拷貝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);
            //資料傳輸
            readChannel.transferTo(position, len, writeChannel);
            readChannel.close();
            writeChannel.close();
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}      

從2.1版核心開始,Linux引入了sendfile來簡化操作:

#include<sys/sendfile.h>


ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);      

系統調用sendfile()在代表輸入檔案的描述符in_fd和代表輸出檔案的描述符out_fd之間傳送檔案内容(位元組)。描述符out_fd必須指向一個套接字,而in_fd指向的檔案必須是可以mmap的。這些局限限制了sendfile的使用,使sendfile隻能将資料從檔案傳遞到套接字上,反之則不行。使用sendfile不僅減少了資料拷貝的次數,還減少了上下文切換,資料傳送始終隻發生在kernel space。

【性能】零拷貝

在我們調用sendfile時,如果有其它程序截斷了檔案會發生什麼呢?假設我們沒有設定任何信号處理程式,sendfile調用僅僅傳回它在被中斷之前已經傳輸的位元組數,errno會被置為success。如果我們在調用sendfile之前給檔案加了鎖,sendfile的行為仍然和之前相同,我們還會收到RT_SIGNAL_LEASE的信号。

目前為止,我們已經減少了資料拷貝的次數了,但是仍然存在一次拷貝,就是頁緩存到socket緩存的拷貝。那麼能不能把這個拷貝也省略呢?

借助于硬體上的幫助,我們是可以辦到的。之前我們是把頁緩存的資料拷貝到socket緩存中,實際上,我們僅僅需要把緩沖區描述符傳到socket緩沖區,再把資料長度傳過去,這樣DMA控制器直接将頁緩存中的資料打包發送到網絡中就可以了。

總結一下,sendfile系統調用利用DMA引擎将檔案内容拷貝到核心緩沖區去,然後将帶有檔案位置和長度資訊的緩沖區描述符添加socket緩沖區去,這一步不會将核心中的資料拷貝到socket緩沖區中,DMA引擎會将核心緩沖區的資料拷貝到協定引擎中去,避免了最後一次拷貝。不過這一種收集拷貝功能是需要硬體以及驅動程式支援的。

【性能】零拷貝

無論是傳統的 I/O 方式,還是引入了零拷貝之後,2 次 DMA copy是都少不了的。因為兩次 DMA 都是依賴硬體完成的。是以,所謂的零拷貝,都是為了減少 CPU copy 及減少了上下文的切換。

下圖展示了各種零拷貝技術的對比圖:

【性能】零拷貝

Netty 的零拷貝

主要包含三個方面:

(1)Netty 的接收和發送 ByteBuffer 采用 DIRECT BUFFERS ,使用堆外直接記憶體進行 Socket 讀寫,不需要進行位元組緩沖區的二次拷貝。如果使用傳統的堆記憶體 ( HEAP BUFFERS)進行 Socket 讀寫, JVM 會将堆記憶體 Buffer 拷貝一份到直接内 存中,然後才寫入 Socket 中。相比于堆外直接記憶體,消息在發送過程中多了一次緩 沖區的記憶體拷貝。

(2)Netty 提供了組合 Buffer 對象,可以聚合多個 ByteBuffer 對象,使用者可以像操作一個 Buffer 那樣友善的對組合 Buffer 進行操作,避免了傳統通過記憶體拷貝的方式 将幾個 小 Buffer 合并成一個大的 Buffer 。

(3)Netty 的檔案傳輸采用了 transferTo 方法,它可以直接将檔案緩沖區的資料發送到目标 Channel ,避免了傳統通過循環 write 方式導緻的記憶體拷貝問題。

零拷貝機制是Netty高性能的一個原因,之前都是說netty的線程模型,責任鍊,說說netty底層的優化,優化就是netty自己的一個緩沖區。

【性能】零拷貝

(一)Netty自己的ByteBuf

介紹

ByteBuf 是為解決 ByteBuffer的問題和滿足網絡應用程式開發人員的日常需求而設計的。

對比JDK byteBuffer的缺點

無法動态擴容

長度是固定的,不能動态擴充和收縮,當資料大于ByteBuffer容量時,會發生索引越界異常。

API 使用複雜

讀寫的時候需要手工調用flip()和rewind()等方法,使用時需要非常謹慎的考慮這些API,否則容出現錯誤。

Netty的ByteBuf 操作

ByteBuf三個重要屬性:capacity容量,readerIndex讀取位置,writerIndex 寫入位置。提供了兩個指針變量來支援順序和寫操作,分别是讀操作readerIndex 和寫操作writeIndex。

常見的方法定義

  • 随機通路索引 getByte
  • 順序讀 read*
  • 順序寫 write*
  • 清除已讀内容discardReadBytes
  • 清除緩沖區 clear
  • 搜尋操作
  • 标記和重置
  • 引用計數和釋放

緩沖區是如何被兩個指針分割成三個區域的

  • discardable bytes 已讀可丢棄區域
  • readable bytes 可讀區域
  • writable bytes 待寫區域

ByteBuf 動态擴容

capacity 預設值:256位元組,最大值:Integer.MAX_VALUE(2GB)

write 方法調用時,通過AbstractByteBuf.ensureWritable進行檢查。

容量計算方法:AbstractByteBufAllocator.calculateNewCapacity(新capacity的最小要求,capacity最大值)

根據新的capacity的最小值要求,對應有兩套計算方法

沒超過4兆:從64位元組開發,每次增加一倍,直至計算出來的newCapacity滿足新容量最小要求。示例:目前大小256,已寫250,繼續寫10位元組資料,需要的容量最小要求是261,則新容量是6422*2=512

超過4兆:新容量 = 新容量最小要求/4兆 * 4兆 +4兆

示例:目前大小3兆,已寫3兆,繼續寫2兆資料,需要的容量最小要求是5兆, 則新容量是9兆(不能超過最大值)

選擇合适的 ByteBuf 實作

在實際使用中都是通過 ByteBufAllocator 配置設定器進行申請,同時配置設定器具有記憶體管理的功能。

【性能】零拷貝
  • unsafe 用到了 Unsafe 工具類,Unsafe 是 Java 保留的一個底層工具包,safe 則沒有用到 unsafe 工具類。
  • unsafe 意味着不安全的操作,但是更底層的操作會帶來性能提升和特殊功能,Netty 中會盡力使用 unsafe。
  • Java 語言很重要的特性是“一次編寫導出運作”,是以它針對底層的記憶體或其他操作,做了很多封裝。而 unsafe 提供了一系列操作底層的方法,可能會導緻不相容或者不可知的異常。
  • unpool 每次申請緩沖區時會建立一個,并不會複用,使用 Unpooled 工具類可以建立 unpool 的緩沖區。
  • Netty 沒有給出很便捷的 pool 類型的緩沖區的建立方法。使用 ChannelConfig.getAllocator() 時,擷取到的配置設定器是預設支援記憶體複用的。
  • pooledByteBuf對象、記憶體
  • PoolThreadCache: PooledByteBufAllocator 執行個體維護了一個線程變量。
  • 多種分類的MemoryRegionCache數組用作記憶體緩存,MemoryRegionCache内部是連結清單,隊列裡面存Chunk。
  • Pool Chunk裡面維護了記憶體引用,記憶體複用的做法就是把buf的memory指向Chunck的memory。

Netty 的零拷貝機制,是一種應用層的實作。

拷貝方式

一般的數組合并,會建立一個大的數組,然後将需要合并的數組放進去。

Netty 的 CompositeButyBuf 将多個 ByteBuf 合并為一個邏輯上的 ByteBuf,避免了各個 ByteBuf 之間的拷貝。

wrappedBuffer 方法将 byte[] 數組包裝成 ByteBuf 對象

slice 方法将一個 ByteBuf 對象切分成多個 ByteBuf 對象

執行個體

PS:API操作便捷性,動态擴容,多種ByteBuf實作,高效的零拷貝機制(邏輯上邊的設計)上邊的所有就是nettyByteBuf所做的工作,性能提升,操作性增強。

關注公衆号 soft張三豐 

繼續閱讀