支撐百萬併發的“零拷貝”技術,你瞭解嗎?

零拷貝(Zero-copy)技術指在計算機執行操作時,CPU 不需要先將數據從一個內存區域複製到另一個內存區域,從而可以減少上下文切換以及 CPU 的拷貝時間。

它的作用是在數據報從網絡設備到用戶程序空間傳遞的過程中,減少數據拷貝次數,減少系統調用,實現 CPU 的零參與,徹底消除 CPU 在這方面的負載。

實現零拷貝用到的最主要技術是 DMA 數據傳輸技術和內存區域映射技術:

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

物理內存和虛擬內存

由於操作系統的進程與進程之間是共享 CPU 和內存資源的,因此需要一套完善的內存管理機制防止進程之間內存洩漏的問題。

為了更加有效地管理內存並減少出錯,現代操作系統提供了一種對主存的抽象概念,即虛擬內存(Virtual Memory)。

虛擬內存為每個進程提供了一個一致的、私有的地址空間,它讓每個進程產生了一種自己在獨享主存的錯覺(每個進程擁有一片連續完整的內存空間)。

物理內存

物理內存(Physical Memory)是相對於虛擬內存(Virtual Memory)而言的。

物理內存指通過物理內存條而獲得的內存空間,而虛擬內存則是指將硬盤的一塊區域劃分來作為內存。內存主要作用是在計算機運行時為操作系統和各種程序提供臨時儲存。

在應用中,自然是顧名思義,物理上,真實存在的插在主板內存槽上的內存條的容量的大小。

虛擬內存

虛擬內存是計算機系統內存管理的一種技術。它使得應用程序認為它擁有連續的可用的內存(一個連續完整的地址空間)。

而實際上,虛擬內存通常是被分隔成多個物理內存碎片,還有部分暫時存儲在外部磁盤存儲器上,在需要時進行數據交換,加載到物理內存中來。

目前,大多數操作系統都使用了虛擬內存,如 Windows 系統的虛擬內存、Linux 系統的交換空間等等。

虛擬內存地址和用戶進程緊密相關,一般來說不同進程裡的同一個虛擬地址指向的物理地址是不一樣的,所以離開進程談虛擬內存沒有任何意義。每個進程所能使用的虛擬地址大小和 CPU 位數有關。

在 32 位的系統上,虛擬地址空間大小是 2^32=4G,在 64 位系統上,虛擬地址空間大小是 2^64=16G,而實際的物理內存可能遠遠小於虛擬內存的大小。

每個用戶進程維護了一個單獨的頁表(Page Table),虛擬內存和物理內存就是通過這個頁表實現地址空間的映射的。

下面給出兩個進程 A、B 各自的虛擬內存空間以及對應的物理內存之間的地址映射示意圖:

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


當進程執行一個程序時,需要先從內存中讀取該進程的指令,然後執行,獲取指令時用到的就是虛擬地址。

這個虛擬地址是程序鏈接時確定的(內核加載並初始化進程時會調整動態庫的地址範圍)。

為了獲取到實際的數據,CPU 需要將虛擬地址轉換成物理地址,CPU 轉換地址時需要用到進程的頁表(Page Table),而頁表(Page Table)裡面的數據由操作系統維護。

其中頁表(Page Table)可以簡單的理解為單個內存映射(Memory Mapping)的鏈表(當然實際結構很複雜)。

裡面的每個內存映射(Memory Mapping)都將一塊虛擬地址映射到一個特定的地址空間(物理內存或者磁盤存儲空間)。

每個進程擁有自己的頁表(Page Table),和其他進程的頁表(Page Table)沒有關係。

通過上面的介紹,我們可以簡單的將用戶進程申請並訪問物理內存(或磁盤存儲空間)的過程總結如下:

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

在用戶進程和物理內存(磁盤存儲器)之間引入虛擬內存主要有以下的優點:

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

內核空間和用戶空間

操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的權限。

為了避免用戶進程直接操作內核,保證內核安全,操作系統將虛擬內存劃分為兩部分,一部分是內核空間(Kernel-space),一部分是用戶空間(User-space)。

在 Linux 系統中,內核模塊運行在內核空間,對應的進程處於內核態;而用戶程序運行在用戶空間,對應的進程處於用戶態。

內核進程和用戶進程所佔的虛擬內存比例是 1:3,而 Linux x86_32 系統的尋址空間(虛擬存儲空間)為 4G(2 的 32 次方),將最高的 1G 的字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF)供內核進程使用,稱為內核空間。

而較低的 3G 的字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個用戶進程使用,稱為用戶空間。

下圖是一個進程的用戶空間和內核空間的內存佈局:

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


內核空間

內核空間總是駐留在內存中,它是為操作系統的內核保留的。應用程序是不允許直接在該區域進行讀寫或直接調用內核代碼定義的函數的。

上圖左側區域為內核進程對應的虛擬內存,按訪問權限可以分為進程私有和進程共享兩塊區域:

  • 進程私有的虛擬內存:每個進程都有單獨的內核棧、頁表、task 結構以及 mem_map 結構等。
  • 進程共享的虛擬內存:屬於所有進程共享的內存區域,包括物理存儲器、內核數據和內核代碼區域。

用戶空間

每個普通的用戶進程都有一個單獨的用戶空間,處於用戶態的進程不能訪問內核空間中的數據,也不能直接調用內核函數的 ,因此要進行系統調用的時候,就要將進程切換到內核態才行。

用戶空間包括以下幾個內存區域:

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

Linux 的內部層級結構

內核態可以執行任意命令,調用系統的一切資源,而用戶態只能執行簡單的運算,不能直接調用系統資源。用戶態必須通過系統接口(System Call),才能向內核發出指令。

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


比如,當用戶進程啟動一個 bash 時,它會通過 getpid() 對內核的 pid 服務發起系統調用,獲取當前用戶進程的 ID。

當用戶進程通過 cat 命令查看主機配置時,它會對內核的文件子系統發起系統調用:

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

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

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


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

  • 用戶進程向 CPU 發起 read 系統調用讀取數據,由用戶態切換為內核態,然後一直阻塞等待數據的返回。
  • CPU 在接收到指令以後對磁盤發起 I/O 請求,將磁盤數據先放入磁盤控制器緩衝區。
  • 數據準備完成以後,磁盤向 CPU 發起 I/O 中斷。
  • CPU 收到 I/O 中斷以後將磁盤緩衝區中的數據拷貝到內核緩衝區,然後再從內核緩衝區拷貝到用戶緩衝區。
  • 用戶進程由內核態切換回用戶態,解除阻塞狀態,然後等待 CPU 的下一個執行時間鍾。

DMA 傳輸原理

DMA 的全稱叫直接內存存取(Direct Memory Access),是一種允許外圍設備(硬件子系統)直接訪問系統主內存的機制。

也就是說,基於 DMA 訪問方式,系統主內存於硬盤或網卡之間的數據傳輸可以繞開 CPU 的全程調度。

目前大多數的硬件設備,包括磁盤控制器、網卡、顯卡以及聲卡等都支持 DMA 技術。

支撐百萬併發的“零拷貝”技術,你瞭解嗎?

整個數據傳輸操作在一個 DMA 控制器的控制下進行的。CPU 除了在數據傳輸開始和結束時做一點處理外(開始和結束時候要做中斷處理),在傳輸過程中 CPU 可以繼續進行其他的工作。

這樣在大部分時間裡,CPU 計算和 I/O 操作都處於並行操作,使整個計算機系統的效率大大提高。

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


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

  • 用戶進程向 CPU 發起 read 系統調用讀取數據,由用戶態切換為內核態,然後一直阻塞等待數據的返回。
  • CPU 在接收到指令以後對 DMA 磁盤控制器發起調度指令。
  • DMA 磁盤控制器對磁盤發起 I/O 請求,將磁盤數據先放入磁盤控制器緩衝區,CPU 全程不參與此過程。
  • 數據讀取完成後,DMA 磁盤控制器會接受到磁盤的通知,將數據從磁盤控制器緩衝區拷貝到內核緩衝區。
  • DMA 磁盤控制器向 CPU 發出數據讀完的信號,由 CPU 負責將數據從內核緩衝區拷貝到用戶緩衝區。
  • 用戶進程由內核態切換回用戶態,解除阻塞狀態,然後等待 CPU 的下一個執行時間鍾。

傳統 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 資源的佔有率。

傳統讀操作

當應用程序執行 read 系統調用讀取一塊數據的時候,如果這塊數據已經存在於用戶進程的頁內存中,就直接從內存中讀取數據。

如果數據不存在,則先將數據從磁盤加載數據到內核空間的讀緩存(read buffer)中,再從讀緩存拷貝到用戶進程的頁內存中。

read(file_fd, tmp_buf, len);

基於傳統的 I/O 讀取方式,read 系統調用會觸發 2 次上下文切換,1 次 DMA 拷貝和 1 次 CPU 拷貝。

發起數據讀取的流程如下:

  • 用戶進程通過 read() 函數向內核(kernel)發起系統調用,上下文從用戶態(user space)切換為內核態(kernel space)。
  • CPU 利用 DMA 控制器將數據從主存或硬盤拷貝到內核空間(kernel space)的讀緩衝區(read buffer)。
  • CPU 將讀緩衝區(read buffer)中的數據拷貝到用戶空間(user space)的用戶緩衝區(user buffer)。
  • 上下文從內核態(kernel space)切換回用戶態(user space),read 調用執行返回。

傳統寫操作

當應用程序準備好數據,執行 write 系統調用發送網絡數據時,先將數據從用戶空間的頁緩存拷貝到內核空間的網絡緩衝區(socket buffer)中,然後再將寫緩存中的數據拷貝到網卡設備完成數據發送。

write(socket_fd, tmp_buf, len);

基於傳統的 I/O 寫入方式,write() 系統調用會觸發 2 次上下文切換,1 次 CPU 拷貝和 1 次 DMA 拷貝。

用戶程序發送網絡數據的流程如下:

  • 用戶進程通過 write() 函數向內核(kernel)發起系統調用,上下文從用戶態(user space)切換為內核態(kernel space)。
  • CPU 將用戶緩衝區(user buffer)中的數據拷貝到內核空間(kernel space)的網絡緩衝區(socket buffer)。
  • CPU 利用 DMA 控制器將數據從網絡緩衝區(socket buffer)拷貝到網卡進行數據傳輸。
  • 上下文從內核態(kernel space)切換回用戶態(user space),write 系統調用執行返回。

零拷貝方式

在 Linux 中零拷貝技術主要有 3 個實現思路:

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

用戶態直接 I/O

用戶態直接 I/O 使得應用進程或運行在用戶態(user space)下的庫函數直接訪問硬件設備。

數據直接跨過內核進行傳輸,內核在數據傳輸過程除了進行必要的虛擬存儲配置工作之外,不參與任何其他工作,這種方式能夠直接繞過內核,極大提高了性能。

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


用戶態直接 I/O 只能適用於不需要內核緩衝區處理的應用程序,這些應用程序通常在進程地址空間有自己的數據緩存機制,稱為自緩存應用程序,如數據庫管理系統就是一個代表。

其次,這種零拷貝機制會直接操作磁盤 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),大致的流程如下圖所示:

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


基於 mmap+write 系統調用的零拷貝方式,整個拷貝過程會發生 4 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝。

用戶程序讀寫數據的流程如下:

  • 用戶進程通過 mmap() 函數向內核(kernel)發起系統調用,上下文從用戶態(user space)切換為內核態(kernel space)。
  • 將用戶進程的內核空間的讀緩衝區(read buffer)與用戶空間的緩存區(user buffer)進行內存地址映射。
  • CPU 利用 DMA 控制器將數據從主存或硬盤拷貝到內核空間(kernel space)的讀緩衝區(read buffer)。
  • 上下文從內核態(kernel space)切換回用戶態(user space),mmap 系統調用執行返回。
  • 用戶進程通過 write() 函數向內核(kernel)發起系統調用,上下文從用戶態(user space)切換為內核態(kernel space)。
  • CPU 將讀緩衝區(read buffer)中的數據拷貝到網絡緩衝區(socket buffer)。
  • CPU 利用 DMA 控制器將數據從網絡緩衝區(socket buffer)拷貝到網卡進行數據傳輸。
  • 上下文從內核態(kernel space)切換回用戶態(user space),write 系統調用執行返回。

mmap 主要的用處是提高 I/O 性能,特別是針對大文件。對於小文件,內存映射文件反而會導致碎片空間的浪費。

因為內存映射總是要對齊頁邊界,最小單位是 4 KB,一個 5 KB 的文件將會映射佔用 8 KB 內存,也就會浪費 3 KB 內存。

mmap 的拷貝雖然減少了 1 次拷貝,提升了效率,但也存在一些隱藏的問題。

當 mmap 一個文件時,如果這個文件被另一個進程所截獲,那麼 write 系統調用會因為訪問非法地址被 SIGBUS 信號終止,SIGBUS 默認會殺死進程併產生一個 coredump,服務器可能因此被終止。

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 拷貝。

用戶程序讀寫數據的流程如下:

  • 用戶進程通過 sendfile() 函數向內核(kernel)發起系統調用,上下文從用戶態(user space)切換為內核態(kernel space)。
  • CPU 利用 DMA 控制器將數據從主存或硬盤拷貝到內核空間(kernel space)的讀緩衝區(read buffer)。
  • CPU 將讀緩衝區(read buffer)中的數據拷貝到的網絡緩衝區(socket buffer)。
  • CPU 利用 DMA 控制器將數據從網絡緩衝區(socket buffer)拷貝到網卡進行數據傳輸。
  • 上下文從內核態(kernel space)切換回用戶態(user space),Sendfile 系統調用執行返回。


相比較於 mmap 內存映射的方式,Sendfile 少了 2 次上下文切換,但是仍然有 1 次 CPU 拷貝操作。

Sendfile 存在的問題是用戶程序不能對數據進行修改,而只是單純地完成了一次數據傳輸過程。

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 拷貝。

用戶程序讀寫數據的流程如下:

  • 用戶進程通過 sendfile() 函數向內核(kernel)發起系統調用,上下文從用戶態(user space)切換為內核態(kernel space)。
  • CPU 利用 DMA 控制器將數據從主存或硬盤拷貝到內核空間(kernel space)的讀緩衝區(read buffer)。
  • CPU 把讀緩衝區(read buffer)的文件描述符(file descriptor)和數據長度拷貝到網絡緩衝區(socket buffer)。
  • 基於已拷貝的文件描述符(file descriptor)和數據長度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地將數據從內核的讀緩衝區(read buffer)拷貝到網卡進行數據傳輸。
  • 上下文從內核態(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 拷貝操作。

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


基於 Splice 系統調用的零拷貝方式,整個拷貝過程會發生 2 次上下文切換,0 次 CPU 拷貝以及 2 次 DMA 拷貝。

用戶程序讀寫數據的流程如下:

  • 用戶進程通過 splice() 函數向內核(kernel)發起系統調用,上下文從用戶態(user space)切換為內核態(kernel space)。
  • CPU 利用 DMA 控制器將數據從主存或硬盤拷貝到內核空間(kernel space)的讀緩衝區(read buffer)。
  • CPU 在內核空間的讀緩衝區(read buffer)和網絡緩衝區(socket buffer)之間建立管道(pipeline)。
  • CPU 利用 DMA 控制器將數據從網絡緩衝區(socket buffer)拷貝到網卡進行數據傳輸。
  • 上下文從內核態(kernel space)切換回用戶態(user space),Splice 系統調用執行返回。

Splice 拷貝方式也同樣存在用戶程序不能對數據進行修改的問題。除此之外,它使用了 Linux 的管道緩衝機制,可以用於任意兩個文件描述符中傳輸數據,但是它的兩個文件描述符參數中有一個必須是管道設備。

寫時複製

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

寫時複製指的是當多個進程共享同一塊數據時,如果其中一個進程需要對這份數據進行修改,那麼就需要將其拷貝到自己的進程地址空間中。

這樣做並不影響其他進程對這塊數據的操作,每個進程要修改的時候才會進行拷貝,所以叫寫時拷貝。

這種方法在某種程度上能夠降低系統開銷,如果某個進程永遠不會對所訪問的數據進行更改,那麼也就永遠不需要拷貝。

緩衝區共享

緩衝區共享方式完全改寫了傳統的 I/O 操作,因為傳統 I/O 接口都是基於數據拷貝進行的,要避免拷貝就得去掉原先的那套接口並重新改寫。

所以這種方法是比較全面的零拷貝技術,目前比較成熟的一個方案是在 Solaris 上實現的 fbuf(Fast Buffer,快速緩衝區)。

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

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


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

Linux 零拷貝對比

無論是傳統 I/O 拷貝方式還是引入零拷貝的方式,2 次 DMA Copy 是都少不了的,因為兩次 DMA 都是依賴硬件完成的。

下面從 CPU 拷貝次數、DMA 拷貝次數以及系統調用幾個方面總結一下上述幾種 I/O 拷貝方式的差別:

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


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 讀寫。

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():對於處於 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 是一個用於文件讀寫、映射和操作的通道,同時它在併發環境下是線程安全的。

基於 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
#elif defined(_AIX)
#include
#elif defined(_ALLBSD_SOURCE)
#include
#include
#include
#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 
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() 函數不僅可以進行網絡文件傳輸,還可以對本地文件實現零拷貝操作。

其它的零拷貝實現

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 條只能算用戶層面的數據操作優化。

RocketMQ 和 Kafka 對比

RocketMQ 選擇了 mmap+write 這種零拷貝方式,適用於業務級消息這種小塊文件的數據持久化和傳輸。

而 Kafka 採用的是 Sendfile 這種零拷貝方式,適用於系統日誌消息這種高吞吐量的大塊文件的數據持久化和傳輸。

但是值得注意的一點是,Kafka 的索引文件使用的是 mmap+write 方式,數據文件使用的是 Sendfile 方式。

支撐百萬併發的“零拷貝”技術,你瞭解嗎?


總結

本文開篇詳述了 Linux 操作系統中的物理內存和虛擬內存,內核空間和用戶空間的概念以及 Linux 內部的層級結構。

在此基礎上,進一步分析和對比傳統 I/O 方式和零拷貝方式的區別,然後介紹了 Linux 內核提供的幾種零拷貝實現。

包括內存映射 mmap、Sendfile、Sendfile+DMA gather copy 以及 Splice 幾種機制,並從系統調用和拷貝次數層面對它們進行了對比。

接下來從源碼著手分析了 Java NIO 對零拷貝的實現,主要包括基於內存映射(mmap)方式的 MappedByteBuffer 以及基於 Sendfile 方式的 FileChannel。

最後在篇末簡單的闡述了一下 Netty 中的零拷貝機制,以及 RocketMQ 和 Kafka 兩種消息隊列在零拷貝實現方式上的區別。


分享到:


相關文章: