零拷貝(Zero-copy) 淺析及其應用

rickiyang發表於2020-07-08

相信大家都有過面經歷,如果跟面試官聊到了作業系統,聊到了檔案操作,可能會問你普通的檔案讀寫流程,它有什麼缺點,你知道有什麼改進的措施。我們經常聽說 零拷貝,每次可能只是背誦一些面試要點就過去了,今天我們就從檔案讀寫說起一步一步深入零拷貝。

Linux 檔案系統簡介

說到檔案讀寫,為了增強代入感我們還是先回顧或者說是瞭解一下基本的 Linux 核心相關知識。

系統呼叫

作業系統的主要功能是為管理硬體資源和為應用程式開發人員提供良好的環境,但是計算機系統的各種硬體資源是有限的,因此為了保證每一個程式都能安全的執行。處理器設有兩種模式:使用者模式核心模式。一些容易發生安全問題的操作都被限制在只有核心模式下才可以執行,例如 I/O 操作,修改基址暫存器內容等。

當我們處在使用者態但是卻不得不呼叫核心態下一些操作的時候這時候可以利用Linux提供的一些轉換介面喚起操作,而連線使用者模式和核心模式的介面稱之為 系統呼叫

應用程式程式碼執行在使用者模式下,當應用程式需要實現核心模式下的指令時,先向作業系統傳送呼叫請求。作業系統收到請求後,執行系統呼叫介面,使處理器進入核心模式。當處理器處理完系統呼叫操作後,作業系統會讓處理器返回使用者模式,繼續執行使用者程式碼。

程式的虛擬地址空間可分為兩部分,核心空間使用者空間。核心空間中存放的是核心程式碼和資料,而程式的使用者空間中存放的是使用者程式的程式碼和資料。不管是核心空間還是使用者空間,它們都處於虛擬空間中,都是對實體地址的對映

虛擬檔案系統

一個作業系統可以支援多種底層不同的檔案系統(比如 NTFS, FAT, ext3, ext4),為了給核心和使用者程式提供統一的檔案系統檢視,Linux 在使用者程式和底層檔案系統之間加入了一個抽象層,即虛擬檔案系統( Virtual File System, VFS ),程式所有的檔案操作都通過 VFS,由 VFS 來適配各種底層不同的檔案系統,完成實際的檔案操作。

通俗的說,VFS 就是定義了一個通用檔案系統的介面層和適配層,一方面為使用者程式提供了一組統一的訪問檔案,目錄和其他物件的統一方法,另一方面又要和不同的底層檔案系統進行適配。如圖所示:

1

虛擬檔案系統主要模組
  1. 超級塊(super_block),用於儲存一個檔案系統的所有後設資料,相當於這個檔案系統的資訊庫,為其他的模組提供資訊。因此一個超級塊可代表一個檔案系統。檔案系統的任意後設資料修改都要修改超級塊。超級塊物件是常駐記憶體並被快取的。
  2. 目錄項模組,管理路徑的目錄項。比如一個路徑 /usr/local/hello.txt,那麼目錄項有 usr, local, hello.txt。目錄項的塊,儲存的是這個目錄下的所有的檔案的 inode 號 和 檔名 等資訊。其內部是樹形結構,作業系統檢索一個檔案,都是從根目錄開始,按層次解析路徑中的所有目錄,直到定位到檔案。
  3. inode 模組,管理一個具體的檔案,是檔案的唯一標識,一個檔案對應一個 inode。通過 inode 可以方便的找到檔案在磁碟扇區的位置。同時 inode 模組可連結到 address_space 模組,方便查詢自身檔案資料是否已經快取。
  4. 開啟檔案列表模組,包含所有核心已經開啟的檔案。已經開啟的檔案物件由 open 系統呼叫在核心中建立,也叫檔案控制程式碼。開啟檔案列表模組中包含一個列表,每個列表表項是一個結構體 struct file,結構體中的資訊用來表示開啟的一個檔案的各種狀態引數。
  5. file_operations 模組。這個模組中維護一個資料結構,是一系列函式指標的集合,其中包含所有可以使用的系統呼叫函式,例如 open、read、write、mmap 等。每個開啟檔案(開啟檔案列表模組的一個表項)都可以連線到 file_operations 模組,從而對任何已開啟的檔案,通過系統呼叫函式,實現各種操作。
  6. address_space 模組,它表示一個檔案在頁快取中已經快取了的物理頁。它是頁快取和外部裝置中檔案系統的橋樑。如果將檔案系統可以理解成資料來源,那麼 address_space 可以說關聯了記憶體系統和檔案系統。我們會在後面繼續討論。

3

I/O 緩衝區

概念

如快取記憶體(cache)產生的原理類似,在 I/O 過程中,讀取磁碟的速度相對記憶體讀取速度要慢的多。因此為了能夠加快處理資料的速度,需要將讀取過的資料快取在記憶體裡。而這些快取在記憶體裡的資料就是高速緩衝區(buffer cache),下面簡稱為 buffer

具體來說,buffer 是一個用於儲存速度不同步的裝置或優先順序不同的裝置之間傳輸資料的區域。一方面,通過緩衝區,可以使程式之間的相互等待變少,從而使從速度慢的裝置讀入資料時,速度快的裝置的操作程式不發生間斷。另一方面,可以保護硬碟或減少網路傳輸的次數。

Buffer 和 Cache

buffer 和 cache 是兩個不同的概念:

cache 是快取記憶體,用於 CPU 和記憶體之間的緩衝;

buffer是 I/O 快取,用於記憶體和硬碟的緩衝。

簡單的說,cache 是加速 ,而 buffer 是緩衝 ,前者解決讀的問題,儲存從磁碟上讀出的資料,後者是解決寫的問題,儲存即將要寫入到磁碟上的資料。

Buffer Cache和 Page Cache

buffer cache 和 page cache 都是為了處理裝置和記憶體互動時高速訪問的問題。

buffer cache可稱為塊緩衝器,page cache可稱為頁緩衝器。

在 Linux 不支援虛擬記憶體機制之前,還沒有頁的概念,因此緩衝區以塊為單位對裝置進行。在 Linux 採用虛擬記憶體的機制來管理記憶體後,頁是虛擬記憶體管理的最小單位,開始採用頁緩衝的機制來緩衝記憶體。Linux2.6 之後核心將這兩個快取整合,頁和塊可以相互對映,同時頁快取 page cache 面向的是虛擬記憶體,塊 I/O 快取 Buffer cache 是面向塊裝置。需要強調的是頁快取和塊快取對程式來說就是一個儲存系統,程式不需要關注底層的裝置的讀寫。

buffer cache 和page cache 兩者最大的區別是快取的粒度。buffer cache 面向的是檔案系統的塊,而核心的記憶體管理元件採用了比檔案系統的塊更高階別的抽象:頁(page),其處理的效能更高。因此和記憶體管理互動的快取元件,都使用頁快取。

Page Cache 頁快取是面向檔案,面向記憶體的。通俗來說,它位於記憶體和檔案之間緩衝區,檔案 I/O 操作實際上只和 page cache 互動,不直接和記憶體互動。page cache 可以用在所有以檔案為單元的場景下,比如網路檔案系統等等。page cache 通過一系列的資料結構,比如 inode, address_space, struct page,實現將一個檔案對映到頁的級別:

  1. struct page 結構標誌一個實體記憶體頁,通過 page + offset 就可以將此頁幀定位到一個檔案中的具體位置。同時 struct page 還有以下重要引數:

    1. 標誌位 flags 來記錄該頁是否是髒頁,是否正在被寫回等等;
    2. mapping 指向了地址空間 address_space,表示這個頁是一個頁快取中的頁,和一個檔案的地址空間對應;
    3. index 記錄這個頁在檔案中的頁偏移量;
  2. 檔案系統的 inode 實際維護了這個檔案所有的( block )的塊號,通過對檔案偏移量 offset 取模可以很快定位到這個偏移量所在的檔案系統的塊號,磁碟的扇區號。同樣,通過對檔案偏移量 offset 進行取模可以計算出偏移量所在的頁的偏移量。

  3. page cache 快取元件抽象了地址空間 address_space 這個概念來作為檔案系統和頁快取的中間橋樑。地址空間 address_space 通過指標可以方便的獲取檔案 inode 和 struct page 的資訊,所以可以很方便地定位到一個檔案的 offset 在各個元件中的位置,即通過:檔案位元組偏移量 --> 頁偏移量 --> 檔案系統塊號 block --> 磁碟扇區號

  4. 頁快取實際上就是採用了一個基數樹結構將一個檔案的內容組織起來存放在實體記憶體 struct page 中。一個檔案 inode 對應一個地址空間 address_space。而一個 address_space 對應一個頁快取基數樹,它們之間的關係如下:
    2

檔案讀寫基本流程

讀檔案

  1. 程式呼叫庫函式向核心發起讀檔案請求;

  2. 核心通過檢查程式的檔案描述符定位到虛擬檔案系統的已開啟檔案列表項;

  3. 呼叫該檔案可用的系統呼叫函式 read()

  4. read() 函式通過檔案表項鍊接到目錄項模組,根據傳入的檔案路徑,在目錄項模組中檢索,找到該檔案的 inode

  5. inode 中,通過檔案內容偏移量計算出要讀取的頁;

  6. 通過 inode 找到檔案對應的 address_space

  7. address_space 中訪問該檔案的頁快取樹,查詢對應的頁快取結點:

    1. 如果頁快取命中,那麼直接返回檔案內容;
    2. 如果頁快取缺失,那麼產生一個頁缺失異常,建立一個頁快取頁,同時通過inode 找到檔案該頁的磁碟地址,讀取相應的頁填充該快取頁;
    3. 重新進行第 6 步查詢頁快取;
  8. 檔案內容讀取成功。

總結一下:inode 管磁碟,address_space 接記憶體,兩者互相指標連結。

Inode 是檔案系統(VFS)下的概念,通過 一個 inode 對應一個檔案 使得檔案管理按照類似索引的這種樹形結構進行管理,通過 inode 快速的找到檔案在磁碟扇區的位置;但是這種管理機制並不能滿足讀寫的要求,因為我們修改檔案的時候是先修改記憶體裡的,所以就有了頁快取機制,作為記憶體與檔案的緩衝區。
address_space 模組表示一個檔案在頁快取中已經快取了的物理頁。它是頁快取和外部裝置中檔案系統的橋樑。如果將檔案系統可以理解成資料來源,那麼 address_space 可以說關聯了記憶體系統和檔案系統。

寫檔案

前5步和讀檔案一致,在 address_space 中查詢對應頁的頁快取是否存在;

  1. 如果頁快取命中,直接把檔案內容修改更新在頁快取的頁中,寫檔案就結束了。這時候檔案修改位於頁快取,並沒有寫回到磁碟檔案中去。
  2. 如果頁快取缺失,那麼產生一個頁缺失異常,建立一個頁快取頁,同時通過 inode 找到檔案該頁的磁碟地址,讀取相應的頁填充該快取頁。此時快取頁命中,進行第 6 步。
  3. 一個頁快取中的頁如果被修改,那麼會被標記成髒頁,髒頁需要寫回到磁碟中的檔案塊。有兩種方式可以把髒頁寫回磁碟:
    1. 手動呼叫 sync() 或者 fsync() 系統呼叫把髒頁寫回;
    2. pdflush 程式會定時把髒頁寫回到磁碟。

同時注意,髒頁不能被置換出記憶體,如果髒頁正在被寫回,那麼會被設定寫回標記,這時候該頁就被上鎖,其他寫請求被阻塞直到鎖釋放。

Linux I/O 讀寫方式

Linux 提供了輪詢、I/O 中斷以及 DMA 傳輸這 3 種磁碟與主存之間的資料傳輸機制。其中輪詢方式是基於死迴圈對 I/O 埠進行不斷檢測。I/O 中斷方式是指當資料到達時,磁碟主動向 CPU 發起中斷請求,由 CPU 自身負責資料的傳輸過程。 DMA 傳輸則在 I/O 中斷的基礎上引入了 DMA 磁碟控制器,由 DMA 磁碟控制器負責資料的傳輸,降低了 I/O 中斷操作對 CPU 資源的大量消耗。

I/O 中斷

在 DMA 技術出現之前,應用程式與磁碟之間的 I/O 操作都是通過 CPU 的中斷完成的。每次使用者程式讀取磁碟資料時,都需要 CPU 中斷,然後發起 I/O 請求等待資料讀取和拷貝完成,每次的 I/O 中斷都導致 CPU 的上下文切換。

4

使用 I/O 中斷方式讀取資料步驟:

  1. 使用者程式向 CPU 發起 read 系統呼叫讀取資料,由使用者態切換為核心態,然後一直阻塞等待資料的返回;
  2. CPU 在接收到指令以後對磁碟發起 I/O 請求,將磁碟資料先放入磁碟控制器緩衝區;
  3. 資料準備完成以後,磁碟向 CPU 發起 I/O 中斷;
  4. CPU 收到 I/O 中斷以後將磁碟緩衝區中的資料拷貝到核心緩衝區,然後再從核心緩衝區拷貝到使用者緩衝區;
  5. 使用者程式由核心態切換回使用者態,解除阻塞狀態,然後等待 CPU 的下一個執行時間鍾。
DMA

DMA(Direct Memory Access)即直接儲存器存取,是指外部裝置不通過 CPU 而直接與系統記憶體交換資料的介面技術。

要把外設的資料讀入記憶體或把記憶體的資料傳送到外設,一般都要通過 CPU 控制完成,如 CPU 程式查詢或中斷方式。利用中斷進行資料傳送,可以大大提高 CPU 的利用率。但是採用中斷傳送有它的缺點,對於一個高速 I/O 設 備以及批量交換資料的情況,如果中斷 I/O 操作帶來的將是效能的損耗。對於這種型別的操作如果可以找一個第三方來執行資料拷貝而 I/O 還繼續執行資料讀取主流程任務是最好的。DMA 在外設與記憶體間直接進行資料交換,而不通過 CPU,這樣資料傳送的速度就取決於儲存器和外設的工作速度。

通常系統的匯流排是由 CPU 管理的。在 DMA 方式時,就希望 CPU 把這些匯流排讓出來,即 CPU 連到這些匯流排上的線處於第三態:高阻狀態,而由 DMA 控制器接管,控制傳送的位元組數,判斷 DMA 是否結束,以及發出 DMA 結束訊號。DMA 控制器必須有以下功能:

  1. 能向 CPU 發出系統保持(HOLD)訊號,提出匯流排接管請求;
  2. 當 CPU 發出允許接管訊號後,負責對匯流排的控制,進入 DMA 方式;
  3. 能對儲存器定址及能修改地址指標,實現對記憶體的讀寫操作;
  4. 能決定本次 DMA 傳送的位元組數,判斷 DMA 傳送是否結束;
  5. 發出 DMA 結束訊號,使 CPU 恢復正常工作狀態。

有了DMA之後的資料讀取方式就變了:

5

CPU 從繁重的 I/O 操作中解脫,資料讀取操作的流程如下:

  1. 使用者程式向 CPU 發起 read 系統呼叫讀取資料,由使用者態切換為核心態,然後一直阻塞等待資料的返回;
  2. CPU 在接收到指令以後對 DMA 磁碟控制器發起排程指令;
  3. DMA 磁碟控制器對磁碟發起 I/O 請求,將磁碟資料先放入磁碟控制器緩衝區,CPU 全程不參與此過程;
  4. 資料讀取完成後,DMA 磁碟控制器會接受到磁碟的通知,將資料從磁碟控制器緩衝區拷貝到核心緩衝區;
  5. DMA 磁碟控制器向 CPU 發出資料讀完的訊號,由 CPU 負責將資料從核心緩衝區拷貝到使用者緩衝區;
  6. 使用者程式由核心態切換回使用者態,解除阻塞狀態,然後等待 CPU 的下一個執行時間鍾。

傳統 I/O 存在哪些問題

在 Linux 系統中,傳統的訪問方式是通過 write()read() 兩個系統呼叫實現的,通過 read() 函式讀取檔案到到快取區中,然後通過 write() 方法把快取中的資料輸出到網路埠,虛擬碼如下:

read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);

圖分別對應傳統 I/O 操作的資料讀寫流程,整個過程涉及 2 次 CPU 拷貝、2 次 DMA 拷貝總共 4 次拷貝,以及 4 次上下文切換,下面簡單地闡述一下相關的概念。

6

關鍵名詞解釋:

上下文切換:當使用者程式向核心發起系統呼叫時,CPU 將使用者程式從使用者態切換到核心態;當系統呼叫返回時,CPU 將使用者程式從核心態切換回使用者態。

CPU 拷貝:由 CPU 直接處理資料的傳送,資料拷貝時會一直佔用 CPU 的資源。

DMA 拷貝:由 CPU 向 DMA 磁碟控制器下達指令,讓 DMA 控制器來處理資料的傳送,資料傳送完畢再把資訊反饋給 CPU,從而減輕了 CPU 資源的佔有率。

當應用程式執行 read 系統呼叫讀取一塊資料的時候,如果這塊資料已經存在於使用者程式的頁記憶體中,就直接從記憶體中讀取資料;如果資料不存在,則先將資料從磁碟載入資料到核心空間的讀快取(read buffer)中,再從讀快取拷貝到使用者程式的頁記憶體中。

read(file_fd, tmp_buf, len);

基於傳統的 I/O 讀取方式,read 系統呼叫會觸發 2 次上下文切換,1 次 DMA 拷貝和 1 次 CPU 拷貝,發起資料讀取的流程如下:

  1. 使用者程式通過read()函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態 (kernel space);
  2. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer);
  3. CPU 將讀緩衝區 (read buffer) 中的資料拷貝到使用者空間 (user space) 的使用者緩衝區 (user buffer)。
  4. 上下文從核心態 (kernel space) 切換回使用者態 (user space),read 呼叫執行返回。
傳統寫操作

當應用程式準備好資料,執行 write 系統呼叫傳送網路資料時,先將資料從使用者空間的頁快取拷貝到核心空間的網路緩衝區(socket buffer)中,然後再將寫快取中的資料拷貝到網路卡裝置完成資料傳送。

write(socket_fd, tmp_buf, len);

基於傳統的 I/O 寫入方式,write() 系統呼叫會觸發 2 次上下文切換,1 次 CPU 拷貝和 1 次 DMA 拷貝,使用者程式傳送網路資料的流程如下:

  1. 使用者程式通過 write() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space)。
  2. CPU 將使用者緩衝區 (user buffer) 中的資料拷貝到核心空間 (kernel space) 的網路緩衝區 (socket buffer)。
  3. CPU 利用 DMA 控制器將資料從網路緩衝區 (socket buffer) 拷貝到網路卡進行資料傳輸。
  4. 上下文從核心態 (kernel space) 切換回使用者態 (user space),write 系統呼叫執行返回。
零拷貝方式

在 Linux 中零拷貝技術主要有 3 個實現思路:使用者態直接 I/O、減少資料拷貝次數以及寫時複製技術。

  • 使用者態直接 I/O:應用程式可以直接訪問硬體儲存,作業系統核心只是輔助資料傳輸。這種方式依舊存在使用者空間和核心空間的上下文切換,硬體上的資料直接拷貝至了使用者空間,不經過核心空間。因此,直接 I/O 不存在核心空間緩衝區和使用者空間緩衝區之間的資料拷貝。
  • 減少資料拷貝次數:在資料傳輸過程中,避免資料在使用者空間緩衝區和系統核心空間緩衝區之間的CPU拷貝,以及資料在系統核心空間內的CPU拷貝,這也是當前主流零拷貝技術的實現思路。
  • 寫時複製技術:寫時複製指的是當多個程式共享同一塊資料時,如果其中一個程式需要對這份資料進行修改,那麼將其拷貝到自己的程式地址空間中,如果只是資料讀取操作則不需要進行拷貝操作。

13

使用者態直接 I/O

使用者態直接 I/O 使得應用程式或執行在使用者態(user space)下的庫函式直接訪問硬體裝置,資料直接跨過核心進行傳輸,核心在資料傳輸過程除了進行必要的虛擬儲存配置工作之外,不參與任何其他工作,這種方式能夠直接繞過核心,極大提高了效能。

7

缺點:

  1. 這種方法只能適用於那些不需要核心緩衝區處理的應用程式,這些應用程式通常在程式地址空間有自己的資料快取機制,稱為自快取應用程式,如資料庫管理系統就是一個代表。
  2. 這種方法直接操作磁碟 I/O,由於 CPU 和磁碟 I/O 之間的執行時間差距,會造成資源的浪費,解決這個問題需要和非同步 I/O 結合使用。
mmap + write

一種零拷貝方式是使用 mmap + write 代替原來的 read + write 方式,減少了 1 次 CPU 拷貝操作。mmap 是 Linux 提供的一種記憶體對映檔案方法,即將一個程式的地址空間中的一段虛擬地址對映到磁碟檔案地址,mmap + write 的虛擬碼如下:

tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);

使用 mmap 的目的是將核心中讀緩衝區(read buffer)的地址與使用者空間的緩衝區(user buffer)進行對映,從而實現核心緩衝區與應用程式記憶體的共享,省去了將資料從核心讀緩衝區(read buffer)拷貝到使用者緩衝區(user buffer)的過程,然而核心讀緩衝區(read buffer)仍需將資料到核心寫緩衝區(socket buffer),大致的流程如下圖所示:

8

基於 mmap + write 系統呼叫的零拷貝方式,整個拷貝過程會發生 4 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程式通過 mmap() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space);
  2. 將使用者程式的核心空間的讀緩衝區 (read buffer) 與使用者空間的快取區 (user buffer) 進行記憶體地址對映;
  3. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer);
  4. 上下文從核心態 (kernel space) 切換回使用者態 (user space),mmap 系統呼叫執行返回;
  5. 使用者程式通過 write() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space);
  6. CPU 將讀緩衝區 (read buffer) 中的資料拷貝到的網路緩衝區 (socket buffer) ;
  7. CPU 利用 DMA 控制器將資料從網路緩衝區 (socket buffer) 拷貝到網路卡進行資料傳輸;
  8. 上下文從核心態 (kernel space) 切換回使用者態 (user space) ,write 系統呼叫執行返回;

缺陷:

mmap 主要的用處是提高 I/O 效能,特別是針對大檔案。對於小檔案,記憶體對映檔案反而會導致碎片空間的浪費,因為記憶體對映總是要對齊頁邊界,最小單位是 4 KB,一個 5 KB 的檔案將會對映佔用 8 KB 記憶體,也就會浪費 3 KB 記憶體。

另外 mmap 隱藏著一個陷阱,當使用 mmap 對映一個檔案時,如果這個檔案被另一個程式所截獲,那麼 write 系統呼叫會因為訪問非法地址被 SIGBUS 訊號終止,SIGBUS 預設會殺死程式併產生一個 coredump,如果伺服器被這樣終止那損失就可能不小。

解決這個問題通常使用檔案的租借鎖:首先為檔案申請一個租借鎖,當其他程式想要截斷這個檔案時,核心會傳送一個實時的 RT_SIGNAL_LEASE 訊號,告訴當前程式有程式在試圖破壞檔案,這樣 write 在被 SIGBUS 殺死之前,會被中斷,返回已經寫入的位元組數,並設定 errno 為 success。

通常的做法是在 mmap 之前加鎖,操作完之後解鎖。

sendfile

sendfile 系統呼叫在 Linux 核心版本 2.1 中被引入,目的是簡化通過網路在兩個通道之間進行的資料傳輸過程。sendfile 系統呼叫的引入,不僅減少了 CPU 拷貝的次數,還減少了上下文切換的次數,它的虛擬碼如下:

sendfile(socket_fd, file_fd, len);

通過 sendfile 系統呼叫,資料可以直接在核心空間內部進行 I/O 傳輸,從而省去了資料在使用者空間和核心空間之間的來回拷貝。與 mmap 記憶體對映方式不同的是, sendfile 呼叫中 I/O 資料對使用者空間是完全不可見的。也就是說,這是一次完全意義上的資料傳輸過程。

9

基於 sendfile 系統呼叫的零拷貝方式,整個拷貝過程會發生 2 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程式通過 sendfile() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space)。
  2. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer)。
  3. CPU 將讀緩衝區 (read buffer) 中的資料拷貝到的網路緩衝區 (socket buffer)。
  4. CPU 利用 DMA 控制器將資料從網路緩衝區 (socket buffer) 拷貝到網路卡進行資料傳輸。
  5. 上下文從核心態 (kernel space) 切換回使用者態 (user space),sendfile 系統呼叫執行返回。

相比較於 mmap 記憶體對映的方式,sendfile 少了 2 次上下文切換,但是仍然有 1 次 CPU 拷貝操作。sendfile 存在的問題是使用者程式不能對資料進行修改,而只是單純地完成了一次資料傳輸過程。

缺點:

只能適用於那些不需要使用者態處理的應用程式。

sendfile + DMA gather copy

常規 sendfile 還有一次核心態的拷貝操作,能不能也把這次拷貝給去掉呢?

答案就是這種 DMA 輔助的 sendfile。

Linux 2.4 版本的核心對 sendfile 系統呼叫進行修改,為 DMA 拷貝引入了 gather 操作。它將核心空間 (kernel space) 的讀緩衝區 (read buffer) 中對應的資料描述資訊 (記憶體地址、地址偏移量) 記錄到相應的網路緩衝區( (socket buffer) 中,由 DMA 根據記憶體地址、地址偏移量將資料批量地從讀緩衝區 (read buffer) 拷貝到網路卡裝置中,這樣就省去了核心空間中僅剩的 1 次 CPU 拷貝操作,sendfile 的虛擬碼如下:

sendfile(socket_fd, file_fd, len);

在硬體的支援下,sendfile 拷貝方式不再從核心緩衝區的資料拷貝到 socket 緩衝區,取而代之的僅僅是緩衝區檔案描述符和資料長度的拷貝,這樣 DMA 引擎直接利用 gather 操作將頁快取中資料打包傳送到網路中即可,本質就是和虛擬記憶體對映的思路類似。

10

基於 sendfile + DMA gather copy 系統呼叫的零拷貝方式,整個拷貝過程會發生 2 次上下文切換、0 次 CPU 拷貝以及 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程式通過 sendfile() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space)。
  2. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer)。
  3. CPU 把讀緩衝區 (read buffer) 的檔案描述符(file descriptor)和資料長度拷貝到網路緩衝區(socket buffer)。
  4. 基於已拷貝的檔案描述符 (file descriptor) 和資料長度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地將資料從核心的讀緩衝區 (read buffer) 拷貝到網路卡進行資料傳輸。
  5. 上下文從核心態 (kernel space) 切換回使用者態 (user space),sendfile 系統呼叫執行返回。

sendfile + DMA gather copy 拷貝方式同樣存在使用者程式不能對資料進行修改的問題,而且本身需要硬體的支援,它只適用於將資料從檔案拷貝到 socket 套接字上的傳輸過程。

splice

sendfile 只適用於將資料從檔案拷貝到 socket 套接字上,同時需要硬體的支援,這也限定了它的使用範圍。Linux 在 2.6.17 版本引入 splice 系統呼叫,不僅不需要硬體支援,還實現了兩個檔案描述符之間的資料零拷貝。splice 的虛擬碼如下:

splice(fd_in, off_in, fd_out, off_out, len, flags);

splice 系統呼叫可以在核心空間的讀緩衝區 (read buffer) 和網路緩衝區 (socket buffer) 之間建立管道 (pipeline),從而避免了兩者之間的 CPU 拷貝操作。

11

基於 splice 系統呼叫的零拷貝方式,整個拷貝過程會發生 2 次上下文切換,0 次 CPU 拷貝以及 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程式通過 splice() 函式向核心(kernel)發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space);
  2. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer);
  3. CPU 在核心空間的讀緩衝區 (read buffer) 和網路緩衝區(socket buffer)之間建立管道 (pipeline);
  4. CPU 利用 DMA 控制器將資料從網路緩衝區 (socket buffer) 拷貝到網路卡進行資料傳輸;
  5. 上下文從核心態 (kernel space) 切換回使用者態 (user space),splice 系統呼叫執行返回。

splice 拷貝方式也同樣存在使用者程式不能對資料進行修改的問題。除此之外,它使用了 Linux 的管道緩衝機制,可以用於任意兩個檔案描述符中傳輸資料,但是它的兩個檔案描述符引數中有一個必須是管道裝置。

寫時複製

在某些情況下,核心緩衝區可能被多個程式所共享,如果某個程式想要這個共享區進行 write 操作,由於 write 不提供任何的鎖操作,那麼就會對共享區中的資料造成破壞,寫時複製的引入就是 Linux 用來保護資料的。

寫時複製指的是當多個程式共享同一塊資料時,如果其中一個程式需要對這份資料進行修改,那麼就需要將其拷貝到自己的程式地址空間中。這樣做並不影響其他程式對這塊資料的操作,每個程式要修改的時候才會進行拷貝,所以叫寫時拷貝。這種方法在某種程度上能夠降低系統開銷,如果某個程式永遠不會對所訪問的資料進行更改,那麼也就永遠不需要拷貝。

缺點:

需要 MMU 的支援,MMU 需要知道程式地址空間中哪些頁面是隻讀的,當需要往這些頁面寫資料時,發出一個異常給作業系統核心,核心會分配新的儲存空間來供寫入的需求。

緩衝區共享

緩衝區共享方式完全改寫了傳統的 I/O 操作,傳統的 Linux I/O 介面支援資料在應用程式地址空間和作業系統核心之間交換,這種交換操作導致所有的資料都需要進行拷貝。

如果採用 fbufs 這種方法,需要交換的是包含資料的緩衝區,這樣就消除了多餘的拷貝操作。應用程式將 fbuf 傳遞給作業系統核心,這樣就能減少傳統的 write 系統呼叫所產生的資料拷貝開銷。

同樣的應用程式通過 fbuf 來接收資料,這樣也可以減少傳統 read 系統呼叫所產生的資料拷貝開銷。

fbuf 的思想是每個程式都維護著一個緩衝區池,這個緩衝區池能被同時對映到使用者空間 (user space) 和核心態 (kernel space),核心和使用者共享這個緩衝區池,這樣就避免了一系列的拷貝操作。

12

缺點:

緩衝區共享的難度在於管理共享緩衝區池需要應用程式、網路軟體以及裝置驅動程式之間的緊密合作,而且如何改寫 API 目前還處於試驗階段並不成熟。

Linux零拷貝對比

無論是傳統 I/O 拷貝方式還是引入零拷貝的方式,2 次 DMA Copy 是都少不了的,因為兩次 DMA 都是依賴硬體完成的。下面從 CPU 拷貝次數、DMA 拷貝次數以及系統呼叫幾個方面總結一下上述幾種 I/O 拷貝方式的差別。

拷貝方式 CPU拷貝 DMA拷貝 系統呼叫 上下文切換
傳統方式(read + write) 2 2 read / write 4
記憶體對映(mmap + write) 1 2 mmap / write 4
sendfile 1 2 sendfile 2
sendfile + DMA gather copy 0 2 sendfile 2
splice 0 2 splice 2

零拷貝應用

Java NIO 中的零拷貝 - MappedByteBuffer

MappedByteBuffer 是 NIO 基於記憶體對映 (mmap) 這種零拷貝方式的提供的一種實現,它繼承自 ByteBuffer。FileChannel 定義了一個 map() 方法,它可以把一個檔案從 position 位置開始的 size 大小的區域對映為記憶體映像檔案。抽象方法 map() 方法在 FileChannel 中的定義如下:

public abstract MappedByteBuffer map(MapMode mode, long position, long size)
        throws IOException;
  • mode:限定記憶體對映區域(MappedByteBuffer)對記憶體映像檔案的訪問模式,包括只可讀(READ_ONLY)、可讀可寫(READ_WRITE)和寫時拷貝(PRIVATE)三種模式。
  • position:檔案對映的起始地址,對應記憶體對映區域(MappedByteBuffer)的首地址。
  • size:檔案對映的位元組長度,從 position 往後的位元組數,對應記憶體對映區域(MappedByteBuffer)的大小。

MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三個重要的方法:

  • fore():對於處於 READ_WRITE 模式下的緩衝區,把對緩衝區內容的修改強制重新整理到本地檔案。
  • load():將緩衝區的內容載入實體記憶體中,並返回這個緩衝區的引用。
  • isLoaded():如果緩衝區的內容在實體記憶體中,則返回 true,否則返回 false。

下面給出一個利用 MappedByteBuffer 對檔案進行讀寫的使用示例:

private final static String CONTENT = "我要測試零拷貝寫入資料";
private final static String FILE_NAME = "/Users/yangyue/Downloads/1.txt";

public static void main(String[] args) {

  Path path = Paths.get(FILE_NAME);
  byte[] bytes = CONTENT.getBytes(Charset.forName("UTF-8"));
  try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ,
                                                  StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
    MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_WRITE, 0, bytes.length);
    if (mappedByteBuffer != null) {
      mappedByteBuffer.put(bytes);
      mappedByteBuffer.force();
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

開啟檔案通道 fileChannel 並提供讀許可權、寫許可權和資料清空許可權,通過 fileChannel 對映到一個可寫的記憶體緩衝區 mappedByteBuffer,將目標資料寫入 mappedByteBuffer,通過 force() 方法把緩衝區更改的內容強制寫入本地檔案。

測試讀檔案:

public static void read(){
  Path path = Paths.get(FILE_NAME);
  int length = CONTENT.getBytes(Charset.forName("UTF-8")).length;
  try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
    MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_ONLY, 0, length);
    if (mappedByteBuffer != null) {
      byte[] bytes = new byte[length];
      mappedByteBuffer.get(bytes);
      String content = new String(bytes, StandardCharsets.UTF_8);
      System.out.println(content);
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

map() 方法是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現,下面是和記憶體對映相關的核心程式碼:

public MappedByteBuffer map(MapMode var1, long var2, long var4) throws IOException {

  ......

    if (var4 == 0L) {
      var7 = 0L;
      FileDescriptor var38 = new FileDescriptor();
      if (this.writable && var6 != 0) {
        var17 = Util.newMappedByteBuffer(0, 0L, var38, (Runnable)null);
        return var17;
      }

      var17 = Util.newMappedByteBufferR(0, 0L, var38, (Runnable)null);
      return var17;
    }

  var12 = (int)(var2 % allocationGranularity);
  long var36 = var2 - (long)var12;
  var10 = var4 + (long)var12;

  try {
    var7 = this.map0(var6, var36, var10);
  } catch (OutOfMemoryError var31) {
    System.gc();

    try {
      Thread.sleep(100L);
    } catch (InterruptedException var30) {
      Thread.currentThread().interrupt();
    }

    try {
      var7 = this.map0(var6, var36, var10);
    } catch (OutOfMemoryError var29) {
      throw new IOException("Map failed", var29);
    }
  }
  
  FileDescriptor var13;
  try {
    var13 = this.nd.duplicateForMapping(this.fd);
  } catch (IOException var28) {
    unmap0(var7, var10);
    throw var28;
  }

  assert IOStatus.checkAll(var7);

  assert var7 % allocationGranularity == 0L;

  int var35 = (int)var4;
  FileChannelImpl.Unmapper var15 = new FileChannelImpl.Unmapper(var7, var10, var35, var13);
  if (this.writable && var6 != 0) {
    var37 = Util.newMappedByteBuffer(var35, var7 + (long)var12, var13, var15);
    return var37;
  } else {
    var37 = Util.newMappedByteBufferR(var35, var7 + (long)var12, var13, var15);
    return var37;
  }
  ......


}

map() 方法通過本地方法 map0() 為檔案分配一塊虛擬記憶體,作為它的記憶體對映區域,然後返回這塊記憶體對映區域的起始地址。

檔案對映需要在 Java 堆中建立一個 MappedByteBuffer 的例項。如果第一次檔案對映導致 OOM,則手動觸發垃圾回收,休眠 100ms 後再嘗試對映,如果失敗則丟擲異常。

通過 Util 的 newMappedByteBuffer (可讀可寫)方法或者 newMappedByteBufferR(僅讀) 方法方法反射建立一個 DirectByteBuffer 例項,其中 DirectByteBuffer 是 MappedByteBuffer 的子類。

map() 方法返回的是記憶體對映區域的起始地址,通過(起始地址 + 偏移量)就可以獲取指定記憶體的資料。這樣一定程度上替代了 read()write() 方法,底層直接採用 sun.misc.Unsafe 類的 getByte() putByte()方法對資料進行讀寫。

private native long map0(int prot, long position, long mapSize) throws IOException;

上面是本地方法(native method) map0 的定義,它通過 JNI(Java Native Interface)呼叫底層 C 的實現,這個 native 函式(Java_sun_nio_ch_FileChannelImpl_map0)的實現位於 JDK 原始碼包下的 native/sun/nio/ch/FileChannelImpl.c 這個原始檔裡面:https://github.com/openjdk/jdk/blob/a619f36d115f1c6ebda15d7165de95dc44ebb1fd/src/java.base/windows/native/libnio/ch/FileChannelImpl.c

MappedByteBuffer 的特點和不足之處:

  • MappedByteBuffer 使用是堆外的虛擬記憶體,因此分配(map)的記憶體大小不受 JVM 的 -Xmx 引數限制,但是也是有大小限制的。
  • 如果當檔案超出 Integer.MAX_VALUE 位元組限制時,可以通過 position 引數重新 map 檔案後面的內容。
  • MappedByteBuffer 在處理大檔案時效能的確很高,但也存在記憶體佔用、檔案關閉不確定等問題,被其開啟的檔案只有在垃圾回收的才會被關閉,而且這個時間點是不確定的。
  • MappedByteBuffer 提供了檔案對映記憶體的 mmap() 方法,也提供了釋放對映記憶體的 unmap() 方法。然而 unmap() 是 FileChannelImpl 中的私有方法,無法直接顯示呼叫。因此,使用者程式需要通過 Java 反射的呼叫 sun.misc.Cleaner 類的 clean() 方法手動釋放對映佔用的記憶體區域。

DirectByteBuffer

DirectByteBuffer 是 Java NIO 用於實現堆外記憶體的一個很重要的類,而 Netty 用 DirectByteBuffer 作為PooledDirectByteBufUnpooledDirectByteBuf 的內部資料容器(區別於 HeapByteBuf 直接用 byte[] 作為資料容器)。

DirectByteBuffer 的物件引用位於 Java 記憶體模型的堆裡面,JVM 可以對 DirectByteBuffer 的物件進行記憶體分配和回收管理,一般使用 DirectByteBuffer 的靜態方法 allocateDirect() 建立 DirectByteBuffer 例項並分配記憶體。

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

DirectByteBuffer 記憶體分配是呼叫底層的 Unsafe 類提供的基礎方法 allocateMemory()直接分配堆外記憶體:

DirectByteBuffer(int cap) {                   // package-private

  super(-1, 0, cap, cap);
  boolean pa = VM.isDirectMemoryPageAligned();
  int ps = Bits.pageSize();
  long size = Math.max(1L, (long)cap + (pa ? ps : 0));
  Bits.reserveMemory(size, cap);

  long base = 0;
  try {
    base = unsafe.allocateMemory(size);
  } catch (OutOfMemoryError x) {
    Bits.unreserveMemory(size, cap);
    throw x;
  }
  unsafe.setMemory(base, size, (byte) 0);
  if (pa && (base % ps != 0)) {
    // Round up to page boundary
    address = base + ps - (base & (ps - 1));
  } else {
    address = base;
  }
  cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
  att = null;



}

那麼 DirectByteBuffer 和零拷貝有什麼關係?我們看一下 DirectByteBuffer 的類名:

class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer {
  
}

可以看到她繼承了 MappedByteBuffer,而 MappedByteBuffer 的 map() 方法會通過 Util.newMappedByteBuffer() 來建立一個緩衝區例項。

基於 sendfile 實現的 FileChannel

FileChannel 是一個用於檔案讀寫、對映和操作的通道,同時它在併發環境下是執行緒安全的,基於 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以建立並開啟一個檔案通道。FileChannel 定義了 transferFrom() transferTo() 兩個抽象方法,它通過在通道和通道之間建立連線實現資料傳輸的。

transferTo():通過 FileChannel 把檔案裡面的源資料寫入一個 WritableByteChannel 的目的通道。

transferFrom():把一個源通道 ReadableByteChannel 中的資料讀取到當前 FileChannel 的檔案裡面。

這兩個方法也是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現。transferTo() transferFrom() 底層都是基於 sendfile 實現資料傳輸的,其中 FileChannelImpl.java 定義了 3 個常量,用於標示當前作業系統的核心是否支援 sendfile 以及 sendfile 的相關特性。

private static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;

transferSupported:用於標記當前的系統核心是否支援sendfile()呼叫,預設為 true。

pipeSupported:用於標記當前的系統核心是否支援檔案描述符(fd)基於管道(pipe)的sendfile()呼叫,預設為 true。

fileSupported:用於標記當前的系統核心是否支援檔案描述符(fd)基於檔案(file)的 sendfile() 呼叫,預設為 true。

Netty零拷貝

Netty 中的零拷貝和上面提到的作業系統層面上的零拷貝不太一樣, 我們所說的 Netty 零拷貝完全是基於(Java 層面)使用者態的,它的更多的是偏向於資料操作優化這樣的概念,具體表現在以下幾個方面:

  • Netty 通過 DefaultFileRegion 類對 java.nio.channels.FileChanneltranferTo() 方法進行包裝,在檔案傳輸時可以將檔案緩衝區的資料直接傳送到目的通道(Channel);
  • ByteBuf 可以通過 wrap 操作把位元組陣列、ByteBuf、ByteBuffer 包裝成一個 ByteBuf 物件, 進而避免了拷貝操作;
  • ByteBuf 支援 slice 操作, 因此可以將 ByteBuf 分解為多個共享同一個儲存區域的 ByteBuf,避免了記憶體的拷貝;
  • Netty 提供了 CompositeByteBuf 類,它可以將多個 ByteBuf 合併為一個邏輯上的 ByteBuf,避免了各個 ByteBuf 之間的拷貝。

相關文章