框架篇:Linux零拷貝機制和FileChannel

前言

大白話解釋,零拷貝就是沒有把數據從一個存儲區域拷貝到另一個存儲區域。但是沒有數據的複製,怎麼可能實現數據的傳輸呢?其實我們在java NIO、netty、kafka遇到的零拷貝,並不是不複製數據,而是減少不必要的數據拷貝次數,從而提升程式碼性能

  • 零拷貝的好處
  • 內核空間和用戶空間
  • 緩衝區和虛擬記憶體
  • 傳統的 I/O
  • mmap+write 實現的零拷貝
  • sendfile 實現的零拷貝
  • 帶有DMA收集拷貝功能的sendfile實現的零拷貝
  • java提供的零拷貝方式

關注公眾號,一起交流 :潛行前行

零拷貝的好處

  • 減少或避免不必要的CPU數據拷貝,從而釋放CPU去執行其他任務
  • 零拷貝機制能減少用戶空間和作業系統內核空間的上下文切換
  • 減少記憶體的佔用

內核空間和用戶空間

  • 內核空間:Linux自身使用的空間;主要提供進程調度、記憶體分配、連接硬體資源等功能
  • 用戶空間:提供給各個程式進程的空間;用戶空間不具有訪問內核空間資源的許可權,如果應用程式需要使用到內核空間的資源,則需要通過系統調用來完成:從用戶空間切換到內核空間,完成相關操作後再從內核空間切換回用戶空間

緩衝區和虛擬記憶體

  • 直接記憶體訪問(Direct Memory Access)(DMA)
    • 直接記憶體訪問:DMA允許外設設備和記憶體存儲器之間直接進行IO數據傳輸,其過程不需要CPU的參與

  • 緩衝區 是所有I/O的基礎,I/O 無非就是把數據移進或移出緩衝區
    • 進程發起read請求,內核先檢查內核空間緩衝區是否存在進程所需數據,如果已經存在,則直接copy數據到進程的記憶體區。如果沒有,系統則向磁碟請求數據,通過DMA寫入內核的read緩衝沖區,接著再將內核緩衝區數據copy到進程的記憶體區
    • 進程發起write請求,則是把進程的記憶體區數據copy到內核的write緩衝區,然後再通過DMA把內核緩衝區數據刷回磁碟或者網卡中
  • 虛擬記憶體:現代作業系統都使用虛擬記憶體,有如下兩個好處
    • 一個以上的虛擬地址可以指向同一個物理記憶體地址
    • 虛擬記憶體空間可大於實際可用的物理地址
  • 利用第一點特性可以把內核空間地址和用戶空間的虛擬地址映射到同一個物理地址,這樣DMA就可以填充(讀寫)對內核和用戶空間進程同時可見的緩衝區了;大致如下

傳統的 I/O

#include <unistd>
ssize_t write(int filedes, void *buf, size_t nbytes);
ssize_t read(int filedes, void *buf, size_t nbytes);
  • 如java在linux系統上,讀取一個磁碟文件,並發送到遠程端的服務

  • 1)發出read系統調用,會導致用戶空間到內核空間的上下文切換,然後再通過DMA將文件中的數據從磁碟上讀取到內核空間緩衝區
  • 2)接著將內核空間緩衝區的數據拷貝到用戶空間進程記憶體,然後read系統調用返回。而系統調用的返回又會導致一次內核空間到用戶空間的上下文切換
  • 3)write系統調用,則再次導致用戶空間到內核空間的上下文切換,將用戶空間的進程里的記憶體數據複製到內核空間的socket緩衝區(也是內核緩衝區,不過是給socket使用的),然後write系統調用返回,再次觸發上下文切換
  • 4)至於socket緩衝區到網卡的數據傳輸則是獨立非同步的過程,也就是說write系統調用的返回並不保證數據被傳輸到網卡

一共有四次用戶空間與內核空間的上下文切換。四次數據copy,分別是兩次CPU數據複製,兩次DMA數據複製

mmap+write實現的零拷貝

#include <sys/mman.h>
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)

  • 1)發出mmap系統調用,導致用戶空間到內核空間的上下文切換。然後通過DMA引擎將磁碟文件中的數據複製到內核空間緩衝區
  • 2)mmap系統調用返回,導致內核空間到用戶空間的上下文切換
  • 3)這裡不需要將數據從內核空間複製到用戶空間,因為用戶空間和內核空間共享了這個緩衝區
  • 4)發出write系統調用,導致用戶空間到內核空間的上下文切換。將數據從內核空間緩衝區複製到內核空間socket緩衝區;write系統調用返回,導致內核空間到用戶空間的上下文切換
  • 5)非同步,DMA引擎將socket緩衝區中的數據copy到網卡

通過mmap實現的零拷貝I/O進行了4次用戶空間與內核空間的上下文切換,以及3次數據拷貝;其中3次數據拷貝中包括了2次DMA拷貝和1次CPU拷貝

sendfile實現的零拷貝

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

  • 1)發出sendfile系統調用,導致用戶空間到內核空間的上下文切換,然後通過DMA引擎將磁碟文件中的內容複製到內核空間緩衝區中,接著再將數據從內核空間緩衝區複製到socket相關的緩衝區
  • 2)sendfile系統調用返回,導致內核空間到用戶空間的上下文切換。DMA非同步將內核空間socket緩衝區中的數據傳遞到網卡

通過sendfile實現的零拷貝I/O使用了2次用戶空間與內核空間的上下文切換,以及3次數據的拷貝。其中3次數據拷貝中包括了2次DMA拷貝和1次CPU拷貝

帶有DMA收集拷貝功能的sendfile實現的零拷貝

  • 從Linux 2.4版本開始,作業系統提供scatter和gather的SG-DMA方式,直接從內核空間緩衝區中將數據讀取到網卡,無需將內核空間緩衝區的數據再複製一份到socket緩衝區

  • 1)發出sendfile系統調用,導致用戶空間到內核空間的上下文切換。通過DMA引擎將磁碟文件中的內容複製到內核空間緩衝區
  • 2)這裡沒把數據複製到socket緩衝區;取而代之的是,相應的描述符資訊被複制到socket緩衝區。該描述符包含了兩種的資訊:A)內核緩衝區的記憶體地址、B)內核緩衝區的偏移量
  • 3)sendfile系統調用返回,導致內核空間到用戶空間的上下文切換。DMA根據socket緩衝區的描述符提供的地址和偏移量直接將內核緩衝區中的數據複製到網卡

帶有DMA收集拷貝功能的sendfile實現的I/O使用了2次用戶空間與內核空間的上下文切換,以及2次數據的拷貝,而且這2次的數據拷貝都是非CPU拷貝。這樣一來我們就實現了最理想的零拷貝I/O傳輸了,不需要任何一次的CPU拷貝,以及最少的上下文切換

java提供的零拷貝方式

  • java NIO的零拷貝實現是基於mmap+write方式
  • FileChannel的map方法產生的MappedByteBuffer
    FileChannel提供了map()方法,該方法可以在一個打開的文件和MappedByteBuffer之間建立一個虛擬記憶體映射,MappedByteBuffer繼承於ByteBuffer;該緩衝器的記憶體是一個文件的記憶體映射區域。map方法底層是通過mmap實現的,因此將文件記憶體從磁碟讀取到內核緩衝區後,用戶空間和內核空間共享該緩衝區。用法如下
public void main(String[] args){
    try {
        FileChannel readChannel = FileChannel.open(Paths.get("./cscw.txt"), StandardOpenOption.READ);
        FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);
       	//數據傳輸
        writeChannel.write(data);
        readChannel.close();
        writeChannel.close();
    }catch (Exception e){
        System.out.println(e.getMessage());
    }
}
  • FileChannel的transferTo、transferFrom
    如果作業系統底層支援的話,transferTo、transferFrom也會使用相關的零拷貝技術來實現數據的傳輸。用法如下
public void main(String[] args) {
    try {
        FileChannel readChannel = FileChannel.open(Paths.get("./cscw.txt"), StandardOpenOption.READ);
        FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        long len = readChannel.size();
        long position = readChannel.position();
        //數據傳輸
        readChannel.transferTo(position, len, writeChannel);
        //效果和transferTo 一樣的
        //writeChannel.transferFrom(readChannel, position, len, );
        readChannel.close();
        writeChannel.close();
    } catch (Exception e) {
        System.out.println(e.getMessage());
    }
}

歡迎指正文中錯誤

關注公眾號,一起交流

參考文章