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


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

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 之間的拷貝。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM