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

前言

零拷貝(Zero-copy)技術指在計算機執行操做時,CPU 不須要先將數據從一個內存區域複製到另外一個內存區域,從而能夠減小上下文切換以及 CPU 的拷貝時間。它的做用是在數據報從網絡設備到用戶程序空間傳遞的過程當中,減小數據拷貝次數,減小系統調用,實現 CPU 的零參與,完全消除 CPU 在這方面的負載。實現零拷貝用到的最主要技術是 DMA 數據傳輸技術和內存區域映射技術。java

  • 零拷貝機制能夠減小數據在內核緩衝區和用戶進程緩衝區之間反覆的 I/O 拷貝操做。
  • 零拷貝機制能夠減小用戶進程地址空間和內核地址空間之間由於上下文切換而帶來的 CPU 開銷。

正文

1. 物理內存和虛擬內存

因爲操做系統的進程與進程之間是共享 CPU 和內存資源的,所以須要一套完善的內存管理機制防止進程之間內存泄漏的問題。爲了更加有效地管理內存並減小出錯,現代操做系統提供了一種對主存的抽象概念,便是虛擬內存(Virtual Memory)。虛擬內存爲每一個進程提供了一個一致的、私有的地址空間,它讓每一個進程產生了一種本身在獨享主存的錯覺(每一個進程擁有一片連續完整的內存空間)。linux

1.1. 物理內存

物理內存(Physical memory)是相對於虛擬內存(Virtual Memory)而言的。物理內存指經過物理內存條而得到的內存空間,而虛擬內存則是指將硬盤的一塊區域劃分來做爲內存。內存主要做用是在計算機運行時爲操做系統和各類程序提供臨時儲存。在應用中,天然是顧名思義,物理上,真實存在的插在主板內存槽上的內存條的容量的大小。算法

1.2. 虛擬內存

虛擬內存是計算機系統內存管理的一種技術。 它使得應用程序認爲它擁有連續的可用的內存(一個連續完整的地址空間)。而實際上,虛擬內存一般是被分隔成多個物理內存碎片,還有部分暫時存儲在外部磁盤存儲器上,在須要時進行數據交換,加載到物理內存中來。 目前,大多數操做系統都使用了虛擬內存,如 Windows 系統的虛擬內存、Linux 系統的交換空間等等。數據庫

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

當進程執行一個程序時,須要先從先內存中讀取該進程的指令,而後執行,獲取指令時用到的就是虛擬地址。這個虛擬地址是程序連接時肯定的(內核加載並初始化進程時會調整動態庫的地址範圍)。爲了獲取到實際的數據,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 系統中,內核模塊運行在內核空間,對應的進程處於內核態;而用戶程序運行在用戶空間,對應的進程處於用戶態。bash

內核進程和用戶進程所佔的虛擬內存比例是 1:3,而 Linux x86_32 系統的尋址空間(虛擬存儲空間)爲 4G(2的32次方),將最高的 1G 的字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF)供內核進程使用,稱爲內核空間;而較低的 3G 的字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個用戶進程使用,稱爲用戶空間。下圖是一個進程的用戶空間和內核空間的內存佈局:

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 命令查看主機配置時,它會對內核的文件子系統發起系統調用。

  • 內核空間能夠訪問全部的 CPU 指令和全部的內存空間、I/O 空間和硬件設備。
  • 用戶空間只能訪問受限的資源,若是須要特殊權限,能夠經過系統調用獲取相應的資源。
  • 用戶空間容許頁面中斷,而內核空間則不容許。
  • 內核空間和用戶空間是針對線性地址空間的。
  • x86 CPU中用戶空間是 0 - 3G 的地址範圍,內核空間是 3G - 4G 的地址範圍。x86_64 CPU 用戶空間地址範圍爲0x0000000000000000 – 0x00007fffffffffff,內核地址空間爲 0xffff880000000000 - 最大地址。
  • 全部內核進程(線程)共用一個地址空間,而用戶進程都有各自的地址空間。

有了用戶空間和內核空間的劃分後,Linux 內部層級結構能夠分爲三部分,從最底層到最上層依次是硬件、內核空間和用戶空間,以下圖所示:

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 的上下文切換。

  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 技術。

整個數據傳輸操做在一個 DMA 控制器的控制下進行的。CPU 除了在數據傳輸開始和結束時作一點處理外(開始和結束時候要作中斷處理),在傳輸過程當中 CPU 能夠繼續進行其餘的工做。這樣在大部分時間裏,CPU 計算和 I/O 操做都處於並行操做,使整個計算機系統的效率大大提升。

有了 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 次上下文切換,下面簡單地闡述一下相關的概念。

  • 上下文切換:當用戶程序向內核發起系統調用時,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)下的庫函數直接訪問硬件設備,數據直接跨過內核進行傳輸,內核在數據傳輸過程除了進行必要的虛擬存儲配置工做以外,不參與任何其餘工做,這種方式可以直接繞過內核,極大提升了性能。

用戶態直接 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),大體的流程以下圖所示:

基於 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 數據對用戶空間是徹底不可見的。也就是說,這是一次徹底意義上的數據傳輸過程。

基於 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 操做將頁緩存中數據打包發送到網絡中便可,本質就是和虛擬內存映射的思路相似。

基於 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 拷貝操做。

基於 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),內核和用戶共享這個緩衝區池,這樣就避免了一系列的拷貝操做。

緩衝區共享的難度在於管理共享緩衝區池須要應用程序、網絡軟件以及設備驅動程序之間的緊密合做,並且如何改寫 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 兩種消息隊列在零拷貝實現方式上的區別。

本賬號將持續分享後端技術乾貨,包括虛擬機基礎,多線程編程,高性能框架,異步、緩存和消息中間件,分佈式和微服務,架構學習和進階等學習資料和文章。

相關文章
相關標籤/搜索