看一遍就理解:零拷貝詳解

語言: CN / TW / HK

前言

大家好,我是 程式設計師田螺

零拷貝是老生常談的問題啦,大廠非常喜歡問。比如Kafka為什麼快,RocketMQ為什麼快等,都涉及到零拷貝知識點。最近技術討論群幾個夥伴分享了 阿里、蝦皮的面試真題 ,也都涉及到零拷貝。因此本文將跟大家一起來學習零拷貝原理。

1. 什麼是零拷貝

2. 傳統的IO執行流程

3. 零拷貝相關的知識點回顧

4. 零拷貝實現的幾種方式

5. java提供的零拷貝方式

1.什麼是零拷貝

零拷貝字面上的意思包括兩個,“零”和“拷貝”:

  • “拷貝”:就是指資料從一個儲存區域轉移到另一個儲存區域。

  • “零” :表示次數為0,它表示拷貝資料的次數為0。

合起來,那 零拷貝 就是不需要將資料從一個儲存區域複製到另一個儲存區域咯。

零拷貝是指計算機執行IO操作時,CPU不需要將資料從一個儲存區域複製到另一個儲存區域,從而可以減少上下文切換以及CPU的拷貝時間。它是一種 I/O 操作優化技術。

2. 傳統 IO 的執行流程

做服務端開發的小夥伴,檔案下載功能應該實現過不少了吧。如果你實現的是一個 web程式 ,前端請求過來,服務端的任務就是:將服務端主機磁碟中的檔案從已連線的socket發出去。關鍵實現程式碼如下:

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

傳統的IO流程,包括read和write的過程。

  • read :把資料從磁碟讀取到核心緩衝區,再拷貝到使用者緩衝區
  • write :先把資料寫入到socket緩衝區,最後寫入網絡卡裝置。

流程圖如下:

  • 使用者應用程序呼叫read函式,向作業系統發起IO呼叫, 上下文從使用者態轉為核心態(切換1)

  • DMA控制器把資料從磁碟中,讀取到核心緩衝區。

  • CPU把核心緩衝區資料,拷貝到使用者應用緩衝區, 上下文從核心態轉為使用者態(切換2) ,read函式返回

  • 使用者應用程序通過write函式,發起IO呼叫, 上下文從使用者態轉為核心態(切換3)

  • CPU將使用者緩衝區中的資料,拷貝到socket緩衝區

  • DMA控制器把資料從socket緩衝區,拷貝到網絡卡裝置, 上下文從核心態切換回使用者態(切換4) ,write函式返回

從流程圖可以看出, 傳統IO的讀寫流程 ,包括了4次上下文切換(4次使用者態和核心態的切換),4次資料拷貝( 兩次CPU拷貝以及兩次的DMA拷貝 ),什麼是DMA拷貝呢?我們一起來回顧下,零拷貝涉及的 作業系統知識點 哈。

3. 零拷貝相關的知識點回顧

3.1 核心空間和使用者空間

我們電腦上跑著的應用程式,其實是需要經過 作業系統 ,才能做一些特殊操作,如磁碟檔案讀寫、記憶體的讀寫等等。因為這些都是比較危險的操作, 不可以由應用程式亂來 ,只能交給底層作業系統來。

因此,作業系統為每個程序都分配了記憶體空間,一部分是使用者空間,一部分是核心空間。 核心空間是作業系統核心訪問的區域,是受保護的記憶體空間,而使用者空間是使用者應用程式訪問的記憶體區域。 以32位作業系統為例,它會為每一個程序都分配了 4G (2的32次方)的記憶體空間。

  • 核心空間:主要提供程序排程、記憶體分配、連線硬體資源等功能

  • 使用者空間:提供給各個程式程序的空間,它不具有訪問核心空間資源的許可權,如果應用程式需要使用到核心空間的資源,則需要通過系統呼叫來完成。程序從使用者空間切換到核心空間,完成相關操作後,再從核心空間切換回使用者空間。

3.2 什麼是使用者態、核心態

  • 如果程序運行於核心空間,被稱為程序的核心態

  • 如果程序運行於使用者空間,被稱為程序的使用者態。

3.3 什麼是上下文切換

  • 什麼是CPU上下文?

CPU 暫存器,是CPU內建的容量小、但速度極快的記憶體。而程式計數器,則是用來儲存 CPU 正在執行的指令位置、或者即將執行的下一條指令位置。它們都是 CPU 在執行任何任務前,必須的依賴環境,因此叫做CPU上下文。

  • 什麼是 CPU上下文切換

它是指,先把前一個任務的CPU上下文(也就是CPU暫存器和程式計數器)儲存起來,然後載入新任務的上下文到這些暫存器和程式計數器,最後再跳轉到程式計數器所指的新位置,執行新任務。

一般我們說的 上下文切換 ,就是指核心(作業系統的核心)在CPU上對程序或者執行緒進行切換。程序從使用者態到核心態的轉變,需要通過 系統呼叫 來完成。系統呼叫的過程,會發生 CPU上下文的切換

CPU 暫存器裡原來使用者態的指令位置,需要先儲存起來。接著,為了執行核心態程式碼,CPU 暫存器需要更新為核心態指令的新位置。最後才是跳轉到核心態執行核心任務。

3.4 虛擬記憶體

現代作業系統使用虛擬記憶體,即虛擬地址取代實體地址,使用虛擬記憶體可以有2個好處:

  • 虛擬記憶體空間可以遠遠大於實體記憶體空間

  • 多個虛擬記憶體可以指向同一個實體地址

正是 多個虛擬記憶體可以指向同一個實體地址 ,可以把核心空間和使用者空間的虛擬地址對映到同一個實體地址,這樣的話,就可以減少IO的資料拷貝次數啦,示意圖如下

3.5 DMA技術

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

我們一起來看下IO流程,DMA幫忙做了什麼事情.

  • 使用者應用程序呼叫read函式,向作業系統發起IO呼叫,進入阻塞狀態,等待資料返回。

  • CPU收到指令後,對DMA控制器發起指令排程。

  • DMA收到IO請求後,將請求傳送給磁碟;

  • 磁碟將資料放入磁碟控制緩衝區,並通知DMA

  • DMA將資料從磁碟控制器緩衝區拷貝到核心緩衝區。

  • DMA向CPU發出資料讀完的訊號,把工作交換給CPU,由CPU負責將資料從核心緩衝區拷貝到使用者緩衝區。

  • 使用者應用程序由核心態切換回使用者態,解除阻塞狀態

可以發現,DMA做的事情很清晰啦,它主要就是 幫忙CPU轉發一下IO請求,以及拷貝資料 。為什麼需要它的?

主要就是效率,它幫忙CPU做事情,這時候,CPU就可以閒下來去做別的事情,提高了CPU的利用效率。大白話解釋就是,CPU老哥太忙太累啦,所以他找了個小弟(名叫DMA) ,替他完成一部分的拷貝工作,這樣CPU老哥就能著手去做其他事情。

4. 零拷貝實現的幾種方式

零拷貝並不是沒有拷貝資料,而是減少使用者態/核心態的切換次數以及CPU拷貝的次數。零拷貝實現有多種方式,分別是

  • mmap+write

  • sendfile

  • 帶有DMA收集拷貝功能的sendfile

4.1 mmap+write實現的零拷貝

mmap 的函式原型如下:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr:指定對映的虛擬記憶體地址

  • length:對映的長度

  • prot:對映記憶體的保護模式

  • flags:指定對映的型別

  • fd:進行對映的檔案控制代碼

  • offset:檔案偏移量

前面一小節,零拷貝相關的知識點回顧,我們介紹了 虛擬記憶體 ,可以把核心空間和使用者空間的虛擬地址對映到同一個實體地址,從而減少資料拷貝次數!mmap就是用了虛擬記憶體這個特點,它將核心中的讀緩衝區與使用者空間的緩衝區進行對映,所有的IO都在核心中完成。

mmap+write 實現的零拷貝流程如下:

  • 使用者程序通過 mmap方法 向作業系統核心發起IO呼叫, 上下文從使用者態切換為核心態
  • CPU利用DMA控制器,把資料從硬碟中拷貝到核心緩衝區。

  • 上下文從核心態切換回使用者態,mmap方法返回。

  • 使用者程序通過 write 方法向作業系統核心發起IO呼叫, 上下文從使用者態切換為核心態
  • CPU將核心緩衝區的資料拷貝到的socket緩衝區。

  • CPU利用DMA控制器,把資料從socket緩衝區拷貝到網絡卡, 上下文從核心態切換回使用者態 ,write呼叫返回。

可以發現, mmap+write 實現的零拷貝,I/O發生了 4 次使用者空間與核心空間的上下文切換,以及3次資料拷貝。其中3次資料拷貝中,包括了 2次DMA拷貝和1次CPU拷貝

mmap 是將讀緩衝區的地址和使用者緩衝區的地址進行對映,核心緩衝區和應用緩衝區共享,所以節省了一次CPU拷貝‘’並且使用者程序記憶體是 虛擬的 ,只是 對映 到核心的讀緩衝區,可以節省一半的記憶體空間。

4.2 sendfile實現的零拷貝

sendfile 是Linux2.1核心版本後引入的一個系統呼叫函式,API如下:

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • out_fd:為待寫入內容的檔案描述符,一個socket描述符。,

  • in_fd:為待讀出內容的檔案描述符,必須是真實的檔案,不能是socket和管道。

  • offset:指定從讀入檔案的哪個位置開始讀,如果為NULL,表示檔案的預設起始位置。

  • count:指定在fdout和fdin之間傳輸的位元組數。

sendfile表示在兩個檔案描述符之間傳輸資料,它是在 作業系統核心 中操作的, 避免了資料從核心緩衝區和使用者緩衝區之間的拷貝操作 ,因此可以使用它來實現零拷貝。

sendfile實現的零拷貝流程如下:

sendfile實現的零拷貝
  1. 使用者程序發起sendfile系統呼叫, 上下文(切換1)從使用者態轉向核心態

  2. DMA控制器,把資料從硬碟中拷貝到核心緩衝區。

  3. CPU將讀緩衝區中資料拷貝到socket緩衝區

  4. DMA控制器,非同步把資料從socket緩衝區拷貝到網絡卡,

  5. 上下文(切換2)從核心態切換回使用者態,sendfile呼叫返回。

可以發現, sendfile 實現的零拷貝,I/O發生了 2 次使用者空間與核心空間的上下文切換,以及3次資料拷貝。其中3次資料拷貝中,包括了 2次DMA拷貝和1次CPU拷貝 。那能不能把CPU拷貝的次數減少到0次呢?有的,即 帶有DMA收集拷貝功能的sendfile

4.3 sendfile+DMA scatter/gather實現的零拷貝

linux 2.4版本之後,對 sendfile 做了優化升級,引入SG-DMA技術,其實就是對DMA拷貝加入了 scatter/gather 操作,它可以直接從核心空間緩衝區中將資料讀取到網絡卡。使用這個特點搞零拷貝,即還可以多省去 一次CPU拷貝

sendfile+DMA scatter/gather實現的零拷貝流程如下:

  1. 使用者程序發起sendfile系統呼叫, 上下文(切換1)從使用者態轉向核心態

  2. DMA控制器,把資料從硬碟中拷貝到核心緩衝區。

  3. CPU把核心緩衝區中的 檔案描述符資訊 (包括核心緩衝區的記憶體地址和偏移量)傳送到socket緩衝區

  4. DMA控制器根據檔案描述符資訊,直接把資料從核心緩衝區拷貝到網絡卡

  5. 上下文(切換2)從核心態切換回使用者態,sendfile呼叫返回。

可以發現, sendfile+DMA scatter/gather 實現的零拷貝,I/O發生了 2 次使用者空間與核心空間的上下文切換,以及2次資料拷貝。其中2次資料拷貝都是包 DMA拷貝 。這就是真正的 零拷貝(Zero-copy) 技術,全程都沒有通過CPU來搬運資料,所有的資料都是通過DMA來進行傳輸的。

5. java提供的零拷貝方式

  • Java NIO對mmap的支援

  • Java NIO對sendfile的支援

5.1 Java NIO對mmap的支援

Java NIO有一個 MappedByteBuffer 的類,可以用來實現記憶體對映。它的底層是呼叫了Linux核心的 mmap 的API。

mmap的小demo如下:

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());
        }
    }
}

5.2 Java NIO對sendfile的支援

FileChannel的 transferTo()/transferFrom() ,底層就是sendfile() 系統呼叫函式。Kafka 這個開源專案就用到它,平時面試的時候,回答面試官為什麼這麼快,就可以提到零拷貝 sendfile 這個點。

@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
   return fileChannel.transferTo(position, count, socketChannel);
}

sendfile的小demo如下:

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());
        }
    }
}