深入剖析Linux IO原理和幾種零拷貝機制的實現

零壹技術棧發表於2019-09-20

前言

零拷貝(Zero-copy)技術指在計算機執行操作時,CPU 不需要先將資料從一個記憶體區域複製到另一個記憶體區域,從而可以減少上下文切換以及 CPU 的拷貝時間。它的作用是在資料包從網路裝置到使用者程式空間傳遞的過程中,減少資料拷貝次數,減少系統呼叫,實現 CPU 的零參與,徹底消除 CPU 在這方面的負載。實現零拷貝用到的最主要技術是 DMA 資料傳輸技術和記憶體區域對映技術。

  • 零拷貝機制可以減少資料在核心緩衝區和使用者程式緩衝區之間反覆的 I/O 拷貝操作。
  • 零拷貝機制可以減少使用者程式地址空間和核心地址空間之間因為上下文切換而帶來的 CPU 開銷。

正文

1. 實體記憶體和虛擬記憶體

由於作業系統的程式與程式之間是共享 CPU 和記憶體資源的,因此需要一套完善的記憶體管理機制防止程式之間記憶體洩漏的問題。為了更加有效地管理記憶體並減少出錯,現代作業系統提供了一種對主存的抽象概念,即是虛擬記憶體(Virtual Memory)。虛擬記憶體為每個程式提供了一個一致的、私有的地址空間,它讓每個程式產生了一種自己在獨享主存的錯覺(每個程式擁有一片連續完整的記憶體空間)。

1.1. 實體記憶體

實體記憶體(Physical memory)是相對於虛擬記憶體(Virtual Memory)而言的。實體記憶體指通過實體記憶體條而獲得的記憶體空間,而虛擬記憶體則是指將硬碟的一塊區域劃分來作為記憶體。記憶體主要作用是在計算機執行時為作業系統和各種程式提供臨時儲存。在應用中,自然是顧名思義,物理上,真實存在的插在主機板記憶體槽上的記憶體條的容量的大小。

1.2. 虛擬記憶體

虛擬記憶體是計算機系統記憶體管理的一種技術。 它使得應用程式認為它擁有連續的可用的記憶體(一個連續完整的地址空間)。而實際上,虛擬記憶體通常是被分隔成多個實體記憶體碎片,還有部分暫時儲存在外部磁碟儲存器上,在需要時進行資料交換,載入到實體記憶體中來。 目前,大多數作業系統都使用了虛擬記憶體,如 Windows 系統的虛擬記憶體、Linux 系統的交換空間等等。

虛擬記憶體地址和使用者程式緊密相關,一般來說不同程式裡的同一個虛擬地址指向的實體地址是不一樣的,所以離開程式談虛擬記憶體沒有任何意義。每個程式所能使用的虛擬地址大小和 CPU 位數有關。在 32 位的系統上,虛擬地址空間大小是 2 ^ 32 = 4G,在 64位系統上,虛擬地址空間大小是 2 ^ 64 = 2 ^ 34G,而實際的實體記憶體可能遠遠小於虛擬記憶體的大小。每個使用者程式維護了一個單獨的頁表(Page Table),虛擬記憶體和實體記憶體就是通過這個頁表實現地址空間的對映的。下面給出兩個程式 A、B 各自的虛擬記憶體空間以及對應的實體記憶體之間的地址對映示意圖:

深入剖析Linux IO原理和幾種零拷貝機制的實現

當程式執行一個程式時,需要先從先記憶體中讀取該程式的指令,然後執行,獲取指令時用到的就是虛擬地址。這個虛擬地址是程式連結時確定的(核心載入並初始化程式時會調整動態庫的地址範圍)。為了獲取到實際的資料,CPU 需要將虛擬地址轉換成實體地址,CPU 轉換地址時需要用到程式的頁表(Page Table),而頁表(Page Table)裡面的資料由作業系統維護。

其中頁表(Page Table)可以簡單的理解為單個記憶體對映(Memory Mapping)的連結串列(當然實際結構很複雜),裡面的每個記憶體對映(Memory Mapping)都將一塊虛擬地址對映到一個特定的地址空間(實體記憶體或者磁碟儲存空間)。每個程式擁有自己的頁表(Page Table),和其它程式的頁表(Page Table)沒有關係。

通過上面的介紹,我們可以簡單的將使用者程式申請並訪問實體記憶體(或磁碟儲存空間)的過程總結如下:

  1. 使用者程式向作業系統發出記憶體申請請求
  2. 系統會檢查程式的虛擬地址空間是否被用完,如果有剩餘,給程式分配虛擬地址
  3. 系統為這塊虛擬地址建立的記憶體對映(Memory Mapping),並將它放進該程式的頁表(Page Table)
  4. 系統返回虛擬地址給使用者程式,使用者程式開始訪問該虛擬地址
  5. CPU 根據虛擬地址在此程式的頁表(Page Table)中找到了相應的記憶體對映(Memory Mapping),但是這個記憶體對映(Memory Mapping)沒有和實體記憶體關聯,於是產生缺頁中斷
  6. 作業系統收到缺頁中斷後,分配真正的實體記憶體並將它關聯到頁表相應的記憶體對映(Memory Mapping)。中斷處理完成後 CPU 就可以訪問記憶體了
  7. 當然缺頁中斷不是每次都會發生,只有系統覺得有必要延遲分配記憶體的時候才用的著,也即很多時候在上面的第 3 步系統會分配真正的實體記憶體並和記憶體對映(Memory Mapping)進行關聯。

在使用者程式和實體記憶體(磁碟儲存器)之間引入虛擬記憶體主要有以下的優點:

  • 地址空間:提供更大的地址空間,並且地址空間是連續的,使得程式編寫、連結更加簡單
  • 程式隔離:不同程式的虛擬地址之間沒有關係,所以一個程式的操作不會對其它程式造成影響
  • 資料保護:每塊虛擬記憶體都有相應的讀寫屬性,這樣就能保護程式的程式碼段不被修改,資料塊不能被執行等,增加了系統的安全性
  • 記憶體對映:有了虛擬記憶體之後,可以直接對映磁碟上的檔案(可執行檔案或動態庫)到虛擬地址空間。這樣可以做到實體記憶體延時分配,只有在需要讀相應的檔案的時候,才將它真正的從磁碟上載入到記憶體中來,而在記憶體吃緊的時候又可以將這部分記憶體清空掉,提高實體記憶體利用效率,並且所有這些對應用程式是都透明的
  • 共享記憶體:比如動態庫只需要在記憶體中儲存一份,然後將它對映到不同程式的虛擬地址空間中,讓程式覺得自己獨佔了這個檔案。程式間的記憶體共享也可以通過對映同一塊實體記憶體到程式的不同虛擬地址空間來實現共享
  • 實體記憶體管理:實體地址空間全部由作業系統管理,程式無法直接分配和回收,從而系統可以更好的利用記憶體,平衡程式間對記憶體的需求

2. 核心空間和使用者空間

作業系統的核心是核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體裝置的許可權。為了避免使用者程式直接操作核心,保證核心安全,作業系統將虛擬記憶體劃分為兩部分,一部分是核心空間(Kernel-space),一部分是使用者空間(User-space)。 在 Linux 系統中,核心模組執行在核心空間,對應的程式處於核心態;而使用者程式執行在使用者空間,對應的程式處於使用者態。

核心程式和使用者程式所佔的虛擬記憶體比例是 1:3,而 Linux x86_32 系統的定址空間(虛擬儲存空間)為 4G(2的32次方),將最高的 1G 的位元組(從虛擬地址 0xC0000000 到 0xFFFFFFFF)供核心程式使用,稱為核心空間;而較低的 3G 的位元組(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個使用者程式使用,稱為使用者空間。下圖是一個程式的使用者空間和核心空間的記憶體佈局:

深入剖析Linux IO原理和幾種零拷貝機制的實現

2.1. 核心空間

核心空間總是駐留在記憶體中,它是為作業系統的核心保留的。應用程式是不允許直接在該區域進行讀寫或直接呼叫核心程式碼定義的函式的。上圖左側區域為核心程式對應的虛擬記憶體,按訪問許可權可以分為程式私有和程式共享兩塊區域。

  • 程式私有的虛擬記憶體:每個程式都有單獨的核心棧、頁表、task 結構以及 mem_map 結構等。
  • 程式共享的虛擬記憶體:屬於所有程式共享的記憶體區域,包括物理儲存器、核心資料和核心程式碼區域。

2.2. 使用者空間

每個普通的使用者程式都有一個單獨的使用者空間,處於使用者態的程式不能訪問核心空間中的資料,也不能直接呼叫核心函式的 ,因此要進行系統呼叫的時候,就要將程式切換到核心態才行。使用者空間包括以下幾個記憶體區域:

  • 執行時棧:由編譯器自動釋放,存放函式的引數值,區域性變數和方法返回值等。每當一個函式被呼叫時,該函式的返回型別和一些呼叫的資訊被儲存到棧頂,呼叫結束後呼叫資訊會被彈出彈出並釋放掉記憶體。棧區是從高地址位向低地址位增長的,是一塊連續的內在區域,最大容量是由系統預先定義好的,申請的棧空間超過這個界限時會提示溢位,使用者能從棧中獲取的空間較小。
  • 執行時堆:用於存放程式執行中被動態分配的記憶體段,位於 BSS 和棧中間的地址位。由卡發人員申請分配(malloc)和釋放(free)。堆是從低地址位向高地址位增長,採用鏈式儲存結構。頻繁地 malloc/free 造成記憶體空間的不連續,產生大量碎片。當申請堆空間時,庫函式按照一定的演算法搜尋可用的足夠大的空間。因此堆的效率比棧要低的多。
  • 程式碼段:存放 CPU 可以執行的機器指令,該部分記憶體只能讀不能寫。通常程式碼區是共享的,即其它執行程式可呼叫它。假如機器中有數個程式執行相同的一個程式,那麼它們就可以使用同一個程式碼段。
  • 未初始化的資料段:存放未初始化的全域性變數,BSS 的資料在程式開始執行之前被初始化為 0 或 NULL。
  • 已初始化的資料段:存放已初始化的全域性變數,包括靜態全域性變數、靜態區域性變數以及常量。
  • 記憶體對映區域:例如將動態庫,共享記憶體等虛擬空間的記憶體對映到物理空間的記憶體,一般是 mmap 函式所分配的虛擬記憶體空間。

3. Linux的內部層級結構

核心態可以執行任意命令,呼叫系統的一切資源,而使用者態只能執行簡單的運算,不能直接呼叫系統資源。使用者態必須通過系統介面(System Call),才能向核心發出指令。比如,當使用者程式啟動一個 bash 時,它會通過 getpid() 對核心的 pid 服務發起系統呼叫,獲取當前使用者程式的 ID;當使用者程式通過 cat 命令檢視主機配置時,它會對核心的檔案子系統發起系統呼叫。

深入剖析Linux IO原理和幾種零拷貝機制的實現

  • 核心空間可以訪問所有的 CPU 指令和所有的記憶體空間、I/O 空間和硬體裝置。
  • 使用者空間只能訪問受限的資源,如果需要特殊許可權,可以通過系統呼叫獲取相應的資源。
  • 使用者空間允許頁面中斷,而核心空間則不允許。
  • 核心空間和使用者空間是針對線性地址空間的。
  • x86 CPU中使用者空間是 0 - 3G 的地址範圍,核心空間是 3G - 4G 的地址範圍。x86_64 CPU 使用者空間地址範圍為0x0000000000000000 – 0x00007fffffffffff,核心地址空間為 0xffff880000000000 - 最大地址。
  • 所有核心程式(執行緒)共用一個地址空間,而使用者程式都有各自的地址空間。

有了使用者空間和核心空間的劃分後,Linux 內部層級結構可以分為三部分,從最底層到最上層依次是硬體、核心空間和使用者空間,如下圖所示:

深入剖析Linux IO原理和幾種零拷貝機制的實現

4. Linux I/O讀寫方式

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

4.1. I/O中斷原理

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

深入剖析Linux IO原理和幾種零拷貝機制的實現

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

4.2. DMA傳輸原理

DMA 的全稱叫直接記憶體存取(Direct Memory Access),是一種允許外圍裝置(硬體子系統)直接訪問系統主記憶體的機制。也就是說,基於 DMA 訪問方式,系統主記憶體於硬碟或網路卡之間的資料傳輸可以繞開 CPU 的全程排程。目前大多數的硬體裝置,包括磁碟控制器、網路卡、顯示卡以及音效卡等都支援 DMA 技術。

深入剖析Linux IO原理和幾種零拷貝機制的實現
整個資料傳輸操作在一個 DMA 控制器的控制下進行的。CPU 除了在資料傳輸開始和結束時做一點處理外(開始和結束時候要做中斷處理),在傳輸過程中 CPU 可以繼續進行其他的工作。這樣在大部分時間裡,CPU 計算和 I/O 操作都處於並行操作,使整個計算機系統的效率大大提高。

深入剖析Linux IO原理和幾種零拷貝機制的實現

有了 DMA 磁碟控制器接管資料讀寫請求以後,CPU 從繁重的 I/O 操作中解脫,資料讀取操作的流程如下:

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

5. 傳統I/O方式

為了更好的理解零拷貝解決的問題,我們首先了解一下傳統 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 次上下文切換,下面簡單地闡述一下相關的概念。

深入剖析Linux IO原理和幾種零拷貝機制的實現

  • 上下文切換:當使用者程式向核心發起系統呼叫時,CPU 將使用者程式從使用者態切換到核心態;當系統呼叫返回時,CPU 將使用者程式從核心態切換回使用者態。
  • CPU拷貝:由 CPU 直接處理資料的傳送,資料拷貝時會一直佔用 CPU 的資源。
  • DMA拷貝:由 CPU 向DMA磁碟控制器下達指令,讓 DMA 控制器來處理資料的傳送,資料傳送完畢再把資訊反饋給 CPU,從而減輕了 CPU 資源的佔有率。

5.1. 傳統讀操作

當應用程式執行 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 呼叫執行返回。

5.2. 傳統寫操作

當應用程式準備好資料,執行 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 系統呼叫執行返回。

6. 零拷貝方式

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

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

6.1. 使用者態直接I/O

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

深入剖析Linux IO原理和幾種零拷貝機制的實現

使用者態直接 I/O 只能適用於不需要核心緩衝區處理的應用程式,這些應用程式通常在程式地址空間有自己的資料快取機制,稱為自快取應用程式,如資料庫管理系統就是一個代表。其次,這種零拷貝機制會直接操作磁碟 I/O,由於 CPU 和磁碟 I/O 之間的執行時間差距,會造成大量資源的浪費,解決方案是配合非同步 I/O 使用。

6.2. 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),大致的流程如下圖所示:

深入剖析Linux IO原理和幾種零拷貝機制的實現

基於 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 的拷貝雖然減少了 1 次拷貝,提升了效率,但也存在一些隱藏的問題。當 mmap 一個檔案時,如果這個檔案被另一個程式所截獲,那麼 write 系統呼叫會因為訪問非法地址被 SIGBUS 訊號終止,SIGBUS 預設會殺死程式併產生一個 coredump,伺服器可能因此被終止。

6.3. sendfile

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

sendfile(socket_fd, file_fd, len);
複製程式碼

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

深入剖析Linux IO原理和幾種零拷貝機制的實現

基於 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 存在的問題是使用者程式不能對資料進行修改,而只是單純地完成了一次資料傳輸過程。

6.4. sendfile + DMA gather copy

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 操作將頁快取中資料打包傳送到網路中即可,本質就是和虛擬記憶體對映的思路類似。

深入剖析Linux IO原理和幾種零拷貝機制的實現

基於 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 套接字上的傳輸過程。

6.5. 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 拷貝操作。

深入剖析Linux IO原理和幾種零拷貝機制的實現

基於 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 的管道緩衝機制,可以用於任意兩個檔案描述符中傳輸資料,但是它的兩個檔案描述符引數中有一個必須是管道裝置。

6.6. 寫時複製

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

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

6.7. 緩衝區共享

緩衝區共享方式完全改寫了傳統的 I/O 操作,因為傳統 I/O 介面都是基於資料拷貝進行的,要避免拷貝就得去掉原先的那套介面並重新改寫,所以這種方法是比較全面的零拷貝技術,目前比較成熟的一個方案是在 Solaris 上實現的 fbuf(Fast Buffer,快速緩衝區)。

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

深入剖析Linux IO原理和幾種零拷貝機制的實現

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

7. 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

8. Java NIO零拷貝實現

在 Java NIO 中的通道(Channel)就相當於作業系統的核心空間(kernel space)的緩衝區,而緩衝區(Buffer)對應的相當於作業系統的使用者空間(user space)中的使用者緩衝區(user buffer)。

  • 通道(Channel)是全雙工的(雙向傳輸),它既可能是讀緩衝區(read buffer),也可能是網路緩衝區(socket buffer)。
  • 緩衝區(Buffer)分為堆記憶體(HeapBuffer)和堆外記憶體(DirectBuffer),這是通過 malloc() 分配出來的使用者態記憶體。

堆外記憶體(DirectBuffer)在使用後需要應用程式手動回收,而堆記憶體(HeapBuffer)的資料在 GC 時可能會被自動回收。因此,在使用 HeapBuffer 讀寫資料時,為了避免緩衝區資料因為 GC 而丟失,NIO 會先把 HeapBuffer 內部的資料拷貝到一個臨時的 DirectBuffer 中的本地記憶體(native memory),這個拷貝涉及到 sun.misc.Unsafe.copyMemory() 的呼叫,背後的實現原理與 memcpy() 類似。 最後,將臨時生成的 DirectBuffer 內部的資料的記憶體地址傳給 I/O 呼叫函式,這樣就避免了再去訪問 Java 物件處理 I/O 讀寫。

8.1. 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 = "Zero copy implemented by MappedByteBuffer";
private final static String FILE_NAME = "/mmap.txt";
private final static String CHARSET = "UTF-8";

複製程式碼
  • 寫檔案資料:開啟檔案通道 fileChannel 並提供讀許可權、寫許可權和資料清空許可權,通過 fileChannel 對映到一個可寫的記憶體緩衝區 mappedByteBuffer,將目標資料寫入 mappedByteBuffer,通過 force() 方法把緩衝區更改的內容強制寫入本地檔案。
@Test
public void writeToFileByMappedByteBuffer() {
    Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
    byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
    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 中的位元組陣列即可得到檔案資料。
@Test
public void readFromFileByMappedByteBuffer() {
    Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
    int length = CONTENT.getBytes(Charset.forName(CHARSET)).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);
            assertEquals(content, "Zero copy implemented by MappedByteBuffer");
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

複製程式碼

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

public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
    int pagePosition = (int)(position % allocationGranularity);
    long mapPosition = position - pagePosition;
    long mapSize = size + pagePosition;
    try {
        addr = map0(imode, mapPosition, mapSize);
    } catch (OutOfMemoryError x) {
        System.gc();
        try {
            Thread.sleep(100);
        } catch (InterruptedException y) {
            Thread.currentThread().interrupt();
        }
        try {
            addr = map0(imode, mapPosition, mapSize);
        } catch (OutOfMemoryError y) {
            throw new IOException("Map failed", y);
        }
    }
    
    int isize = (int)size;
    Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
    if ((!writable) || (imode == MAP_RO)) {
    	return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);
    } else {
    	return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);
    }
}

複製程式碼

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

  1. 檔案對映需要在 Java 堆中建立一個 MappedByteBuffer 的例項。如果第一次檔案對映導致 OOM,則手動觸發垃圾回收,休眠 100ms 後再嘗試對映,如果失敗則丟擲異常。
  2. 通過 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 這個原始檔裡面。

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                                     jint prot, jlong off, jlong len)
{
    void *mapAddress = 0;
    jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
    jint fd = fdval(env, fdo);
    int protections = 0;
    int flags = 0;

    if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
        protections = PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
        protections = PROT_WRITE | PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
        protections =  PROT_WRITE | PROT_READ;
        flags = MAP_PRIVATE;
    }

    mapAddress = mmap64(
        0,                    /* Let OS decide location */
        len,                  /* Number of bytes to map */
        protections,          /* File permissions */
        flags,                /* Changes are shared */
        fd,                   /* File descriptor of mapped file */
        off);                 /* Offset into file */

    if (mapAddress == MAP_FAILED) {
        if (errno == ENOMEM) {
            JNU_ThrowOutOfMemoryError(env, "Map failed");
            return IOS_THROWN;
        }
        return handle(env, -1, "Map failed");
    }

    return ((jlong) (unsigned long) mapAddress);
}

複製程式碼

可以看出 map0() 函式最終是通過 mmap64() 這個函式對 Linux 底層核心發出記憶體對映的呼叫, mmap64() 函式的原型如下:

#include <sys/mman.h>

void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off64_t offset);

複製程式碼

下面詳細介紹一下 mmap64() 函式各個引數的含義以及引數可選值:

  • addr:檔案在使用者程式空間的記憶體對映區中的起始地址,是一個建議的引數,通常可設定為 0 或 NULL,此時由核心去決定真實的起始地址。當 flags 為 MAP_FIXED 時,addr 就是一個必選的引數,即需要提供一個存在的地址。
  • len:檔案需要進行記憶體對映的位元組長度
  • prot:控制使用者程式對記憶體對映區的訪問許可權
    • PROT_READ:讀許可權
    • PROT_WRITE:寫許可權
    • PROT_EXEC:執行許可權
    • PROT_NONE:無許可權
  • flags:控制記憶體對映區的修改是否被多個程式共享
    • MAP_PRIVATE:對記憶體對映區資料的修改不會反映到真正的檔案,資料修改發生時採用寫時複製機制
    • MAP_SHARED:對記憶體對映區的修改會同步到真正的檔案,修改對共享此記憶體對映區的程式是可見的
    • MAP_FIXED:不建議使用,這種模式下 addr 引數指定的必須的提供一個存在的 addr 引數
  • fd:檔案描述符。每次 map 操作會導致檔案的引用計數加 1,每次 unmap 操作或者結束程式會導致引用計數減 1
  • offset:檔案偏移量。進行對映的檔案位置,從檔案起始地址向後的位移量

下面總結一下 MappedByteBuffer 的特點和不足之處:

  • MappedByteBuffer 使用是堆外的虛擬記憶體,因此分配(map)的記憶體大小不受 JVM 的 -Xmx 引數限制,但是也是有大小限制的。
  • 如果當檔案超出 Integer.MAX_VALUE 位元組限制時,可以通過 position 引數重新 map 檔案後面的內容。
  • MappedByteBuffer 在處理大檔案時效能的確很高,但也存記憶體佔用、檔案關閉不確定等問題,被其開啟的檔案只有在垃圾回收的才會被關閉,而且這個時間點是不確定的。
  • MappedByteBuffer 提供了檔案對映記憶體的 mmap() 方法,也提供了釋放對映記憶體的 unmap() 方法。然而 unmap() 是 FileChannelImpl 中的私有方法,無法直接顯示呼叫。因此,使用者程式需要通過 Java 反射的呼叫 sun.misc.Cleaner 類的 clean() 方法手動釋放對映佔用的記憶體區域。
public static void clean(final Object buffer) throws Exception {
    AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
        try {
            Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
            getCleanerMethod.setAccessible(true);
            Cleaner cleaner = (Cleaner) getCleanerMethod.invoke(buffer, new Object[0]);
            cleaner.clean();
        } catch(Exception e) {
            e.printStackTrace();
        }
    });
}

複製程式碼

8.2. DirectByteBuffer

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

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

複製程式碼

DirectByteBuffer 內部的位元組緩衝區位在於堆外的(使用者態)直接記憶體,它是通過 Unsafe 的本地方法 allocateMemory() 進行記憶體分配,底層呼叫的是作業系統的 malloc() 函式。

DirectByteBuffer(int cap) {
    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)) {
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

複製程式碼

除此之外,初始化 DirectByteBuffer 時還會建立一個 Deallocator 執行緒,並通過 Cleaner 的 freeMemory() 方法來對直接記憶體進行回收操作,freeMemory() 底層呼叫的是作業系統的 free() 函式。

private static class Deallocator implements Runnable {
    private static Unsafe unsafe = Unsafe.getUnsafe();

    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
        assert (address != 0);
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    public void run() {
        if (address == 0) {
            return;
        }
        unsafe.freeMemory(address);
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }
}

複製程式碼

由於使用 DirectByteBuffer 分配的是系統本地的記憶體,不在 JVM 的管控範圍之內,因此直接記憶體的回收和堆記憶體的回收不同,直接記憶體如果使用不當,很容易造成 OutOfMemoryError。

說了這麼多,那麼 DirectByteBuffer 和零拷貝有什麼關係?前面有提到在 MappedByteBuffer 進行記憶體對映時,它的 map() 方法會通過 Util.newMappedByteBuffer() 來建立一個緩衝區例項,初始化的程式碼如下:

static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd,
                                            Runnable unmapper) {
    MappedByteBuffer dbb;
    if (directByteBufferConstructor == null)
        initDBBConstructor();
    try {
        dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
            new Object[] { new Integer(size), new Long(addr), fd, unmapper });
    } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
        throw new InternalError(e);
    }
    return dbb;
}

private static void initDBBRConstructor() {
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            try {
                Class<?> cl = Class.forName("java.nio.DirectByteBufferR");
                Constructor<?> ctor = cl.getDeclaredConstructor(
                    new Class<?>[] { int.class, long.class, FileDescriptor.class,
                                    Runnable.class });
                ctor.setAccessible(true);
                directByteBufferRConstructor = ctor;
            } catch (ClassNotFoundException | NoSuchMethodException |
                     IllegalArgumentException | ClassCastException x) {
                throw new InternalError(x);
            }
            return null;
        }});
}

複製程式碼

DirectByteBuffer 是 MappedByteBuffer 的具體實現類。實際上,Util.newMappedByteBuffer() 方法通過反射機制獲取 DirectByteBuffer 的構造器,然後建立一個 DirectByteBuffer 的例項,對應的是一個單獨用於記憶體對映的構造方法:

protected DirectByteBuffer(int cap, long addr, FileDescriptor fd, Runnable unmapper) {
    super(-1, 0, cap, cap, fd);
    address = addr;
    cleaner = Cleaner.create(this, unmapper);
    att = null;
}

複製程式碼

因此,除了允許分配作業系統的直接記憶體以外,DirectByteBuffer 本身也具有檔案記憶體對映的功能,這裡不做過多說明。我們需要關注的是,DirectByteBuffer 在 MappedByteBuffer 的基礎上提供了記憶體映像檔案的隨機讀取 get() 和寫入 write() 的操作。

  • 記憶體映像檔案的隨機讀操作
public byte get() {
    return ((unsafe.getByte(ix(nextGetIndex()))));
}

public byte get(int i) {
    return ((unsafe.getByte(ix(checkIndex(i)))));
}

複製程式碼
  • 記憶體映像檔案的隨機寫操作
public ByteBuffer put(byte x) {
    unsafe.putByte(ix(nextPutIndex()), ((x)));
    return this;
}

public ByteBuffer put(int i, byte x) {
    unsafe.putByte(ix(checkIndex(i)), ((x)));
    return this;
}

複製程式碼

記憶體映像檔案的隨機讀寫都是藉助 ix() 方法實現定位的, ix() 方法通過記憶體對映空間的記憶體首地址(address)和給定偏移量 i 計算出指標地址,然後由 unsafe 類的 get() 和 put() 方法和對指標指向的資料進行讀取或寫入。

private long ix(int i) {
    return address + ((long)i << 0);
}

複製程式碼

8.3. FileChannel

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

  • transferTo():通過 FileChannel 把檔案裡面的源資料寫入一個 WritableByteChannel 的目的通道。
public abstract long transferTo(long position, long count, WritableByteChannel target)
        throws IOException;

複製程式碼
  • transferFrom():把一個源通道 ReadableByteChannel 中的資料讀取到當前 FileChannel 的檔案裡面。
public abstract long transferFrom(ReadableByteChannel src, long position, long count)
        throws IOException;

複製程式碼

下面給出 FileChannel 利用 transferTo() 和 transferFrom() 方法進行資料傳輸的使用示例:

private static final String CONTENT = "Zero copy implemented by FileChannel";
private static final String SOURCE_FILE = "/source.txt";
private static final String TARGET_FILE = "/target.txt";
private static final String CHARSET = "UTF-8";

複製程式碼

首先在類載入根路徑下建立 source.txt 和 target.txt 兩個檔案,對原始檔 source.txt 檔案寫入初始化資料。

@Before
public void setup() {
    Path source = Paths.get(getClassPath(SOURCE_FILE));
    byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
    try (FileChannel fromChannel = FileChannel.open(source, StandardOpenOption.READ,
            StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
        fromChannel.write(ByteBuffer.wrap(bytes));
    } catch (IOException e) {
        e.printStackTrace();
    }
}

複製程式碼

對於 transferTo() 方法而言,目的通道 toChannel 可以是任意的單向位元組寫通道 WritableByteChannel;而對於 transferFrom() 方法而言,源通道 fromChannel 可以是任意的單向位元組讀通道 ReadableByteChannel。其中,FileChannel、SocketChannel 和 DatagramChannel 等通道實現了 WritableByteChannel 和 ReadableByteChannel 介面,都是同時支援讀寫的雙向通道。為了方便測試,下面給出基於 FileChannel 完成 channel-to-channel 的資料傳輸示例。

  • 通過 transferTo() 將 fromChannel 中的資料拷貝到 toChannel
@Test
public void transferTo() throws Exception {
    try (FileChannel fromChannel = new RandomAccessFile(
             getClassPath(SOURCE_FILE), "rw").getChannel();
         FileChannel toChannel = new RandomAccessFile(
             getClassPath(TARGET_FILE), "rw").getChannel()) {
        long position = 0L;
        long offset = fromChannel.size();
        fromChannel.transferTo(position, offset, toChannel);
    }
}

複製程式碼
  • 通過 transferFrom() 將 fromChannel 中的資料拷貝到 toChannel
@Test
public void transferFrom() throws Exception {
    try (FileChannel fromChannel = new RandomAccessFile(
             getClassPath(SOURCE_FILE), "rw").getChannel();
         FileChannel toChannel = new RandomAccessFile(
             getClassPath(TARGET_FILE), "rw").getChannel()) {
        long position = 0L;
        long offset = fromChannel.size();
        toChannel.transferFrom(fromChannel, position, offset);
    }
}

複製程式碼

下面介紹 transferTo() 和 transferFrom() 方法的底層實現原理,這兩個方法也是 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。

下面以 transferTo() 的原始碼實現為例。FileChannelImpl 首先執行 transferToDirectly() 方法,以 sendfile 的零拷貝方式嘗試資料拷貝。如果系統核心不支援 sendfile,進一步執行 transferToTrustedChannel() 方法,以 mmap 的零拷貝方式進行記憶體對映,這種情況下目的通道必須是 FileChannelImpl 或者 SelChImpl 型別。如果以上兩步都失敗了,則執行 transferToArbitraryChannel() 方法,基於傳統的 I/O 方式完成讀寫,具體步驟是初始化一個臨時的 DirectBuffer,將源通道 FileChannel 的資料讀取到 DirectBuffer,再寫入目的通道 WritableByteChannel 裡面。

public long transferTo(long position, long count, WritableByteChannel target)
        throws IOException {
    // 計算檔案的大小
    long sz = size();
    // 校驗起始位置
    if (position > sz)
        return 0;
    int icount = (int)Math.min(count, Integer.MAX_VALUE);
    // 校驗偏移量
    if ((sz - position) < icount)
        icount = (int)(sz - position);

    long n;

    if ((n = transferToDirectly(position, icount, target)) >= 0)
        return n;

    if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
        return n;

    return transferToArbitraryChannel(position, icount, target);
}

複製程式碼

接下來重點分析一下 transferToDirectly() 方法的實現,也就是 transferTo() 通過 sendfile 實現零拷貝的精髓所在。可以看到,transferToDirectlyInternal() 方法先獲取到目的通道 WritableByteChannel 的檔案描述符 targetFD,獲取同步鎖然後執行 transferToDirectlyInternal() 方法。

private long transferToDirectly(long position, int icount, WritableByteChannel target)
        throws IOException {
    // 省略從target獲取targetFD的過程
    if (nd.transferToDirectlyNeedsPositionLock()) {
        synchronized (positionLock) {
            long pos = position();
            try {
                return transferToDirectlyInternal(position, icount,
                        target, targetFD);
            } finally {
                position(pos);
            }
        }
    } else {
        return transferToDirectlyInternal(position, icount, target, targetFD);
    }
}

複製程式碼

最終由 transferToDirectlyInternal() 呼叫本地方法 transferTo0() ,嘗試以 sendfile 的方式進行資料傳輸。如果系統核心完全不支援 sendfile,比如 Windows 作業系統,則返回 UNSUPPORTED 並把 transferSupported 標識為 false。如果系統核心不支援 sendfile 的一些特性,比如說低版本的 Linux 核心不支援 DMA gather copy 操作,則返回 UNSUPPORTED_CASE 並把 pipeSupported 或者 fileSupported 標識為 false。

private long transferToDirectlyInternal(long position, int icount,
                                        WritableByteChannel target,
                                        FileDescriptor targetFD) throws IOException {
    assert !nd.transferToDirectlyNeedsPositionLock() ||
            Thread.holdsLock(positionLock);

    long n = -1;
    int ti = -1;
    try {
        begin();
        ti = threads.add();
        if (!isOpen())
            return -1;
        do {
            n = transferTo0(fd, position, icount, targetFD);
        } while ((n == IOStatus.INTERRUPTED) && isOpen());
        if (n == IOStatus.UNSUPPORTED_CASE) {
            if (target instanceof SinkChannelImpl)
                pipeSupported = false;
            if (target instanceof FileChannelImpl)
                fileSupported = false;
            return IOStatus.UNSUPPORTED_CASE;
        }
        if (n == IOStatus.UNSUPPORTED) {
            transferSupported = false;
            return IOStatus.UNSUPPORTED;
        }
        return IOStatus.normalize(n);
    } finally {
        threads.remove(ti);
        end (n > -1);
    }
}

複製程式碼

本地方法(native method)transferTo0() 通過 JNI(Java Native Interface)呼叫底層 C 的函式,這個 native 函式(Java_sun_nio_ch_FileChannelImpl_transferTo0)同樣位於 JDK 原始碼包下的 native/sun/nio/ch/FileChannelImpl.c 原始檔裡面。JNI 函式 Java_sun_nio_ch_FileChannelImpl_transferTo0() 基於條件編譯對不同的系統進行預編譯,下面是 JDK 基於 Linux 系統核心對 transferTo() 提供的呼叫封裝。

#if defined(__linux__) || defined(__solaris__)
#include <sys/sendfile.h>
#elif defined(_AIX)
#include <sys/socket.h>
#elif defined(_ALLBSD_SOURCE)
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/uio.h>

#define lseek64 lseek
#define mmap64 mmap
#endif

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                                            jobject srcFDO,
                                            jlong position, jlong count,
                                            jobject dstFDO)
{
    jint srcFD = fdval(env, srcFDO);
    jint dstFD = fdval(env, dstFDO);

#if defined(__linux__)
    off64_t offset = (off64_t)position;
    jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
    return n;
#elif defined(__solaris__)
    result = sendfilev64(dstFD, &sfv, 1, &numBytes);	
    return result;
#elif defined(__APPLE__)
    result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0);
    return result;
#endif
}

複製程式碼

對 Linux、Solaris 以及 Apple 系統而言,transferTo0() 函式底層會執行 sendfile64 這個系統呼叫完成零拷貝操作,sendfile64() 函式的原型如下:

#include <sys/sendfile.h>

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

複製程式碼

下面簡單介紹一下 sendfile64() 函式各個引數的含義:

  • out_fd:待寫入的檔案描述符
  • in_fd:待讀取的檔案描述符
  • offset:指定 in_fd 對應檔案流的讀取位置,如果為空,則預設從起始位置開始
  • count:指定在檔案描述符 in_fd 和 out_fd 之間傳輸的位元組數

在 Linux 2.6.3 之前,out_fd 必須是一個 socket,而從 Linux 2.6.3 以後,out_fd 可以是任何檔案。也就是說,sendfile64() 函式不僅可以進行網路檔案傳輸,還可以對本地檔案實現零拷貝操作。

9. 其它的零拷貝實現

9.1. Netty零拷貝

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

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

其中第 1 條屬於作業系統層面的零拷貝操作,後面 3 條只能算使用者層面的資料操作優化。

9.2. RocketMQ和Kafka對比

RocketMQ 選擇了 mmap + write 這種零拷貝方式,適用於業務級訊息這種小塊檔案的資料持久化和傳輸;而 Kafka 採用的是 sendfile 這種零拷貝方式,適用於系統日誌訊息這種高吞吐量的大塊檔案的資料持久化和傳輸。但是值得注意的一點是,Kafka 的索引檔案使用的是 mmap + write 方式,資料檔案使用的是 sendfile 方式。

訊息佇列 零拷貝方式 優點 缺點
RocketMQ mmap + write 適用於小塊檔案傳輸,頻繁呼叫時,效率很高 不能很好的利用 DMA 方式,會比 sendfile 多消耗 CPU,記憶體安全性控制複雜,需要避免 JVM Crash 問題
Kafka sendfile 可以利用 DMA 方式,消耗 CPU 較少,大塊檔案傳輸效率高,無記憶體安全性問題 小塊檔案效率低於 mmap 方式,只能是 BIO 方式傳輸,不能使用 NIO 方式

小結

本文開篇詳述了 Linux 作業系統中的實體記憶體和虛擬記憶體,核心空間和使用者空間的概念以及 Linux 內部的層級結構。在此基礎上,進一步分析和對比傳統 I/O 方式和零拷貝方式的區別,然後介紹了 Linux 核心提供的幾種零拷貝實現,包括記憶體對映 mmap、sendfile、sendfile + DMA gather copy 以及 splice 幾種機制,並從系統呼叫和拷貝次數層面對它們進行了對比。接下來從原始碼著手分析了 Java NIO 對零拷貝的實現,主要包括基於記憶體對映(mmap)方式的 MappedByteBuffer 以及基於 sendfile 方式的 FileChannel。最後在篇末簡單的闡述了一下 Netty 中的零拷貝機制,以及 RocketMQ 和 Kafka 兩種訊息佇列在零拷貝實現方式上的區別。

深入剖析Linux IO原理和幾種零拷貝機制的實現

本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。

相關文章