Linux I/O 原理和 Zero-copy 技術全面揭祕

博客原文

https://strikefreedom.top/lin...html

導言

現在的網絡應用早已從 CPU 密集型轉向了 I/O 密集型,網絡服務器大可能是基於 C-S 模型,也即 客戶端 - 服務端 模型,客戶端須要和服務端進行大量的網絡通訊,這也決定了現代網絡應用的性能瓶頸:I/O。java

傳統的 Linux 操做系統的標準 I/O 接口是基於數據拷貝操做的,即 I/O 操做會致使數據在操做系統內核地址空間的緩衝區和用戶進程地址空間定義的緩衝區之間進行傳輸。設置緩衝區最大的好處是能夠減小磁盤 I/O 的操做,若是所請求的數據已經存放在操做系統的高速緩衝存儲器中,那麼就不須要再進行實際的物理磁盤 I/O 操做;然而傳統的 Linux I/O 在數據傳輸過程當中的數據拷貝操做深度依賴 CPU,也就是說 I/O 過程須要 CPU 去執行數據拷貝的操做,所以致使了極大的系統開銷,限制了操做系統有效進行數據傳輸操做的能力。node

I/O 是決定網絡服務器性能瓶頸的關鍵,而傳統的 Linux I/O 機制又會致使大量的數據拷貝操做,損耗性能,因此咱們亟需一種新的技術來解決數據大量拷貝的問題,這個答案就是零拷貝(Zero-copy)。linux

計算機存儲器

既然要分析 Linux I/O,就不能不瞭解計算機的各種存儲器。ios

存儲器是計算機的核心部件之一,在徹底理想的狀態下,存儲器應該要同時具有如下三種特性:算法

  1. 速度足夠快:存儲器的存取速度應當快於 CPU 執行一條指令,這樣 CPU 的效率纔不會受限於存儲器
  2. 容量足夠大:容量可以存儲計算機所需的所有數據
  3. 價格足夠便宜:價格低廉,全部類型的計算機都能配備

可是現實每每是殘酷的,咱們目前的計算機技術沒法同時知足上述的三個條件,因而現代計算機的存儲器設計採用了一種分層次的結構:編程

從頂至底,現代計算機裏的存儲器類型分別有:寄存器、高速緩存、主存和磁盤,這些存儲器的速度逐級遞減而容量逐級遞增。存取速度最快的是寄存器,由於寄存器的製做材料和 CPU 是相同的,因此速度和 CPU 同樣快,CPU 訪問寄存器是沒有時延的,然而由於價格昂貴,所以容量也極小,通常 32 位的 CPU 配備的寄存器容量是 32✖️32 Bit,64 位的 CPU 則是 64✖️64 Bit,無論是 32 位仍是 64 位,寄存器容量都小於 1 KB,且寄存器也必須經過軟件自行管理。緩存

第二層是高速緩存,也即咱們平時瞭解的 CPU 高速緩存 L一、L二、L3,通常 L1 是每一個 CPU 獨享,L3 是所有 CPU 共享,而 L2 則根據不一樣的架構設計會被設計成獨享或者共享兩種模式之一,好比 Intel 的多核芯片採用的是共享 L2 模式而 AMD 的多核芯片則採用的是獨享 L2 模式。安全

第三層則是主存,也即主內存,一般稱做隨機訪問存儲器(Random Access Memory, RAM)。是與 CPU 直接交換數據的內部存儲器。它能夠隨時讀寫(刷新時除外),並且速度很快,一般做爲操做系統或其餘正在運行中的程序的臨時資料存儲介質。服務器

最後則是磁盤,磁盤和主存相比,每一個二進制位的成本低了兩個數量級,所以容量比之會大得多,動輒上 GB、TB,而問題是訪問速度則比主存慢了大概三個數量級。機械硬盤速度慢主要是由於機械臂須要不斷在金屬盤片之間移動,等待磁盤扇區旋轉至磁頭之下,而後才能進行讀寫操做,所以效率很低。

主內存是操做系統進行 I/O 操做的重中之重,絕大部分的工做都是在用戶進程和內核的內存緩衝區裏完成的,所以咱們接下來須要提早學習一些主存的相關原理。

物理內存

咱們平時一直說起的物理內存就是上文中對應的第三種計算機存儲器,RAM 主存,它在計算機中之內存條的形式存在,嵌在主板的內存槽上,用來加載各式各樣的程序與數據以供 CPU 直接運行和使用。

虛擬內存

在計算機領域有一句如同摩西十誡般神聖的哲言:"計算機科學領域的任何問題均可以經過增長一個間接的中間層來解決",從內存管理、網絡模型、併發調度甚至是硬件架構,都能看到這句哲言在閃爍着光芒,而虛擬內存則是這一哲言的完美實踐之一。

虛擬內存是現代計算機中的一個很是重要的存儲器抽象,主要是用來解決應用程序日益增加的內存使用需求:現代物理內存的容量增加已經很是快速了,然而仍是跟不上應用程序對主存需求的增加速度,對於應用程序來講內存仍是不夠用,所以便須要一種方法來解決這二者之間的容量差矛盾。

計算機對多程序內存訪問的管理經歷了 靜態重定位 --> 動態重定位 --> 交換(swapping)技術 --> 虛擬內存,最原始的多程序內存訪問是直接訪問絕對內存地址,這種方式幾乎是徹底不可用的方案,由於若是每個程序都直接訪問物理內存地址的話,好比兩個程序併發執行如下指令的時候:

mov cx, 2
mov bx, 1000H
mov ds, bx
mov [0], cx

...

mov ax, [0]
add ax, ax

這一段彙編表示在地址 1000:0 處存入數值 2,而後在後面的邏輯中把該地址的值取出來乘以 2,最終存入 ax 寄存器的值就是 4,若是第二個程序存入 cx 寄存器裏的值是 3,那麼併發執行的時候,第一個程序最終從 ax 寄存器裏獲得的值就多是 6,這就徹底錯誤了,獲得髒數據還頂多算程序結果錯誤,要是其餘程序往特定的地址裏寫入一些危險的指令而被另外一個程序取出來執行,還可能會致使整個系統的崩潰。因此,爲了確保進程間互不干擾,每個用戶進程都須要實時知曉當前其餘進程在使用哪些內存地址,這對於寫程序的人來講無疑是一場噩夢。

所以,操做絕對內存地址是徹底不可行的方案,那就只能用操做相對內存地址,咱們知道每一個進程都會有本身的進程地址,從 0 開始,能夠經過相對地址來訪問內存,可是這一樣有問題,仍是前面相似的問題,好比有兩個大小爲 16KB 的程序 A 和 B,如今它們都被加載進了內存,內存地址段分別是 0 ~ 16384,16384 ~ 32768。A 的第一條指令是 jmp 1024,而在地址 1024 處是一條 mov 指令,下一條指令是 add,基於前面的 mov 指令作加法運算,與此同時,B 的第一條指令是 jmp 1028,原本在 B 的相對地址 1028 處應該也是一條 mov 去操做本身的內存地址上的值,可是因爲這兩個程序共享了段寄存器,所以雖然他們使用了各自的相對地址,可是依然操做的仍是絕對內存地址,因而 B 就會跳去執行 add 指令,這時候就會由於非法的內存操做而 crash。

有一種靜態重定位的技術能夠解決這個問題,它的工做原理很是簡單粗暴:當 B 程序被加載到地址 16384 處以後,把 B 的全部相對內存地址都加上 16384,這樣的話當 B 執行 jmp 1028 之時,其實執行的是 jmp 1028+16384,就能夠跳轉到正確的內存地址處去執行正確的指令了,可是這種技術並不通用,並且還會對程序裝載進內存的性能有影響。

再日後,就發展出來了存儲器抽象:地址空間,就好像進程是 CPU 的抽象,地址空間則是存儲器的抽象,每一個進程都會分配獨享的地址空間,可是獨享的地址空間又帶來了新的問題:如何實現不一樣進程的相同相對地址指向不一樣的物理地址?最開始是使用動態重定位技術來實現,這是用一種相對簡單的地址空間到物理內存的映射方法。基本原理就是爲每個 CPU 配備兩個特殊的硬件寄存器:基址寄存器和界限寄存器,用來動態保存每個程序的起始物理內存地址和長度,好比前文中的 A,B 兩個程序,當 A 運行時基址寄存器和界限寄存器就會分別存入 0 和 16384,而當 B 運行時則兩個寄存器又會分別存入 16384 和 32768。而後每次訪問指定的內存地址時,CPU 會在把地址發往內存總線以前自動把基址寄存器裏的值加到該內存地址上,獲得一個真正的物理內存地址,同時還會根據界限寄存器裏的值檢查該地址是否溢出,如果,則產生錯誤停止程序,動態重定位技術解決了靜態重定位技術形成的程序裝載速度慢的問題,可是也有新問題:每次訪問內存都須要進行加法和比較運算,比較運算自己能夠很快,可是加法運算因爲進位傳遞時間的問題,除非使用特殊的電路,不然會比較慢。

而後就是 交換(swapping)技術,這種技術簡單來講就是動態地把程序在內存和磁盤之間進行交換保存,要運行一個進程的時候就把程序的代碼段和數據段調入內存,而後再把程序封存,存入磁盤,如此反覆。爲何要這麼麻煩?由於前面那兩種重定位技術的前提條件是計算機內存足夠大,可以把全部要運行的進程地址空間都加載進主存,纔可以併發運行這些進程,可是現實每每不是如此,內存的大小老是有限的,全部就須要另外一類方法來處理內存超載的狀況,第一種即是簡單的交換技術:

先把進程 A 換入內存,而後啓動進程 B 和 C,也換入內存,接着 A 被從內存交換到磁盤,而後又有新的進程 D 調入內存,用了 A 退出以後空出來的內存空間,最後 A 又被從新換入內存,因爲內存佈局已經發生了變化,因此 A 在換入內存之時會經過軟件或者在運行期間經過硬件(基址寄存器和界限寄存器)對其內存地址進行重定位,多數狀況下都是經過硬件。

另外一種處理內存超載的技術就是虛擬內存技術了,它比交換(swapping)技術更復雜而又更高效,是目前最新應用最普遍的存儲器抽象技術:

虛擬內存的核心原理是:爲每一個程序設置一段"連續"的虛擬地址空間,把這個地址空間分割成多個具備連續地址範圍的頁 (page),並把這些頁和物理內存作映射,在程序運行期間動態映射到物理內存。當程序引用到一段在物理內存的地址空間時,由硬件馬上執行必要的映射;而當程序引用到一段不在物理內存中的地址空間時,由操做系統負責將缺失的部分裝入物理內存並從新執行失敗的指令:

虛擬地址空間按照固定大小劃分紅被稱爲頁(page)的若干單元,物理內存中對應的則是頁框(page frame)。這二者通常來講是同樣的大小,如上圖中的是 4KB,不過實際上計算機系統中通常是 512 字節到 1 GB,這就是虛擬內存的分頁技術。由於是虛擬內存空間,每一個進程分配的大小是 4GB (32 位架構),而實際上固然不可能給全部在運行中的進程都分配 4GB 的物理內存,因此虛擬內存技術還須要利用到前面介紹的交換(swapping)技術,在進程運行期間只分配映射當前使用到的內存,暫時不使用的數據則寫回磁盤做爲副本保存,須要用的時候再讀入內存,動態地在磁盤和內存之間交換數據。

其實虛擬內存技術從某種角度來看的話,很像是糅合了基址寄存器和界限寄存器以後的新技術。它使得整個進程的地址空間能夠經過較小的單元映射到物理內存,而不須要爲程序的代碼和數據地址進行重定位。

進程在運行期間產生的內存地址都是虛擬地址,若是計算機沒有引入虛擬內存這種存儲器抽象技術的話,則 CPU 會把這些地址直接發送到內存地址總線上,直接訪問和虛擬地址相同值的物理地址;若是使用虛擬內存技術的話,CPU 則是把這些虛擬地址經過地址總線送到內存管理單元(Memory Management Unit,MMU),MMU 將虛擬地址映射爲物理地址以後再經過內存總線去訪問物理內存:

虛擬地址(好比 16 位地址 8196=0010 000000000100)分爲兩部分:虛擬頁號(高位部分)和偏移量(低位部分),虛擬地址轉換成物理地址是經過頁表(page table)來實現的,頁表由頁表項構成,頁表項中保存了頁框號、修改位、訪問位、保護位和 "在/不在" 位等信息,從數學角度來講頁表就是一個函數,入參是虛擬頁號,輸出是物理頁框號,獲得物理頁框號以後複製到寄存器的高三位中,最後直接把 12 位的偏移量複製到寄存器的末 12 位構成 15 位的物理地址,便可以把該寄存器的存儲的物理內存地址發送到內存總線:

在 MMU 進行地址轉換時,若是頁表項的 "在/不在" 位是 0,則表示該頁面並無映射到真實的物理頁框,則會引起一個缺頁中斷,CPU 陷入操做系統內核,接着操做系統就會經過頁面置換算法選擇一個頁面將其換出 (swap),以便爲即將調入的新頁面騰出位置,若是要換出的頁面的頁表項裏的修改位已經被設置過,也就是被更新過,則這是一個髒頁 (dirty page),須要寫回磁盤更新改頁面在磁盤上的副本,若是該頁面是"乾淨"的,也就是沒有被修改過,則直接用調入的新頁面覆蓋掉被換出的舊頁面便可。

最後,還須要瞭解的一個概念是轉換檢測緩衝器(Translation Lookaside Buffer,TLB),也叫快表,是用來加速虛擬地址映射的,由於虛擬內存的分頁機制,頁表通常是保存內存中的一塊固定的存儲區,致使進程經過 MMU 訪問內存比直接訪問內存多了一次內存訪問,性能至少降低一半,所以須要引入加速機制,即 TLB 快表,TLB 能夠簡單地理解成頁表的高速緩存,保存了最高頻被訪問的頁表項,因爲通常是硬件實現的,所以速度極快,MMU收到虛擬地址時通常會先經過硬件 TLB 查詢對應的頁表號,若命中且該頁表項的訪問操做合法,則直接從 TLB 取出對應的物理頁框號返回,若不命中則穿透到內存頁表裏查詢,而且會用這個從內存頁表裏查詢到最新頁表項替換到現有 TLB 裏的其中一個,以備下次緩存命中。

至此,咱們介紹完了包含虛擬內存在內的多項計算機存儲器抽象技術,虛擬內存的其餘內容好比針對大內存的多級頁表、倒排頁表,以及處理缺頁中斷的頁面置換算法等等,之後有機會再單獨寫一篇文章介紹,或者各位讀者也能夠先行去查閱相關資料瞭解,這裏就再也不深刻了。

用戶態和內核態

通常來講,咱們在編寫程序操做 Linux I/O 之時十有八九是在用戶空間和內核空間之間傳輸數據,所以有必要先了解一下 Linux 的用戶態和內核態的概念。

首先是用戶態和內核態:

從宏觀上來看,Linux 操做系統的體系架構分爲用戶態和內核態(或者用戶空間和內核)。內核從本質上看是一種軟件 —— 控制計算機的硬件資源,並提供上層應用程序 (進程) 運行的環境。用戶態即上層應用程序 (進程) 的運行空間,應用程序 (進程) 的執行必須依託於內核提供的資源,這其中包括但不限於 CPU 資源、存儲資源、I/O 資源等等。

現代操做系統都是採用虛擬存儲器,那麼對 32 位操做系統而言,它的尋址空間(虛擬存儲空間)爲 2^32 B = 4G。操做系統的核心是內核,獨立於普通的應用程序,能夠訪問受保護的內存空間,也有訪問底層硬件設備的全部權限。爲了保證用戶進程不能直接操做內核(kernel),保證內核的安全,操心繫統將虛擬空間劃分爲兩部分,一部分爲內核空間,一部分爲用戶空間。針對 Linux 操做系統而言,將最高的 1G 字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF),供內核使用,稱爲內核空間,而將較低的 3G 字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個進程使用,稱爲用戶空間。

由於操做系統的資源是有限的,若是訪問資源的操做過多,必然會消耗過多的系統資源,並且若是不對這些操做加以區分,極可能形成資源訪問的衝突。因此,爲了減小有限資源的訪問和使用衝突,Unix/Linux 的設計哲學之一就是:對不一樣的操做賦予不一樣的執行等級,就是所謂特權的概念。簡單說就是有多大能力作多大的事,與系統相關的一些特別關鍵的操做必須由最高特權的程序來完成。Intel 的 x86 架構的 CPU 提供了 0 到 3 四個特權級,數字越小,特權越高,Linux 操做系統中主要採用了 0 和 3 兩個特權級,分別對應的就是內核態和用戶態。運行於用戶態的進程能夠執行的操做和訪問的資源都會受到極大的限制,而運行在內核態的進程則能夠執行任何操做而且在資源的使用上沒有限制。不少程序開始時運行於用戶態,但在執行的過程當中,一些操做須要在內核權限下才能執行,這就涉及到一個從用戶態切換到內核態的過程。好比 C 函數庫中的內存分配函數 malloc(),它具體是使用 sbrk() 系統調用來分配內存,當 malloc 調用 sbrk() 的時候就涉及一次從用戶態到內核態的切換,相似的函數還有 printf(),調用的是 wirte() 系統調用來輸出字符串,等等。

用戶進程在系統中運行時,大部分時間是處在用戶態空間裏的,在其須要操做系統幫助完成一些用戶態沒有特權和能力完成的操做時就須要切換到內核態。那麼用戶進程如何切換到內核態去使用那些內核資源呢?答案是:1) 系統調用(trap),2) 異常(exception)和 3) 中斷(interrupt)。

  • 系統調用:用戶進程主動發起的操做。用戶態進程發起系統調用主動要求切換到內核態,陷入內核以後,由操做系統來操做系統資源,完成以後再返回到進程。
  • 異常:被動的操做,且用戶進程沒法預測其發生的時機。當用戶進程在運行期間發生了異常(好比某條指令出了問題),這時會觸發由當前運行進程切換處處理此異常的內核相關進程中,也便是切換到了內核態。異常包括程序運算引發的各類錯誤如除 0、緩衝區溢出、缺頁等。
  • 中斷:當外圍設備完成用戶請求的操做後,會向 CPU 發出相應的中斷信號,這時 CPU 會暫停執行下一條即將要執行的指令而轉到與中斷信號對應的處理程序去執行,若是前面執行的指令是用戶態下的程序,那麼轉換的過程天然就會是從用戶態到內核態的切換。中斷包括 I/O 中斷、外部信號中斷、各類定時器引發的時鐘中斷等。中斷和異常相似,都是經過中斷向量表來找到相應的處理程序進行處理。區別在於,中斷來自處理器外部,不是由任何一條專門的指令形成,而異常是執行當前指令的結果。

經過上面的分析,咱們能夠得出 Linux 的內部層級可分爲三大部分:

  1. 用戶空間;
  2. 內核空間;
  3. 硬件。

Linux I/O

I/O 緩衝區

在 Linux 中,當程序調用各種文件操做函數後,用戶數據(User Data)到達磁盤(Disk)的流程如上圖所示。

圖中描述了 Linux 中文件操做函數的層級關係和內存緩存層的存在位置,中間的黑色實線是用戶態和內核態的分界線。

read(2)/write(2) 是 Linux 系統中最基本的 I/O 讀寫系統調用,咱們開發操做 I/O 的程序時一定會接觸到它們,而在這兩個系統調用和真實的磁盤讀寫之間存在一層稱爲 Kernel buffer cache 的緩衝區緩存。在 Linux 中 I/O 緩存其實能夠細分爲兩個:Page CacheBuffer Cache,這兩個實際上是一體兩面,共同組成了 Linux 的內核緩衝區(Kernel Buffer Cache):

  • 讀磁盤:內核會先檢查 Page Cache 裏是否是已經緩存了這個數據,如果,直接從這個內存緩衝區裏讀取返回,若否,則穿透到磁盤去讀取,而後再緩存在 Page Cache 裏,以備下次緩存命中;
  • 寫磁盤:內核直接把數據寫入 Page Cache,並把對應的頁標記爲 dirty,添加到 dirty list 裏,而後就直接返回,內核會按期把 dirty list 的頁緩存 flush 到磁盤,保證頁緩存和磁盤的最終一致性。

Page Cache 會經過頁面置換算法如 LRU 按期淘汰舊的頁面,加載新的頁面。能夠看出,所謂 I/O 緩衝區緩存就是在內核和磁盤、網卡等外設之間的一層緩衝區,用來提高讀寫性能的。

在 Linux 還不支持虛擬內存技術以前,尚未頁的概念,所以 Buffer Cache 是基於操做系統讀寫磁盤的最小單位 -- 塊(block)來進行的,全部的磁盤塊操做都是經過 Buffer Cache 來加速,Linux 引入虛擬內存的機制來管理內存後,頁成爲虛擬內存管理的最小單位,所以也引入了 Page Cache 來緩存 Linux 文件內容,主要用來做爲文件系統上的文件數據的緩存,提高讀寫性能,常見的是針對文件的 read()/write() 操做,另外也包括了經過 mmap() 映射以後的塊設備,也就是說,事實上 Page Cache 負責了大部分的塊設備文件的緩存工做。而 Buffer Cache 用來在系統對塊設備進行讀寫的時候,對塊進行數據緩存的系統來使用,實際上負責全部對磁盤的 I/O 訪問:

由於 Buffer Cache 是對粒度更細的設備塊的緩存,而 Page Cache 是基於虛擬內存的頁單元緩存,所以仍是會基於 Buffer Cache,也就是說若是是緩存文件內容數據就會在內存裏緩存兩份相同的數據,這就會致使同一份文件保存了兩份,冗餘且低效。另一個問題是,調用 write 後,有效數據是在 Buffer Cache 中,而非 Page Cache 中。這就致使 mmap 訪問的文件數據可能存在不一致問題。爲了規避這個問題,全部基於磁盤文件系統的 write,都須要調用 update_vm_cache() 函數,該操做會把調用 write 以後的 Buffer Cache 更新到 Page Cache 去。因爲有這些設計上的弊端,所以在 Linux 2.4 版本以後,kernel 就將二者進行了統一,Buffer Cache 再也不以獨立的形式存在,而是以融合的方式存在於 Page Cache 中:

融合以後就能夠統一操做 Page CacheBuffer Cache:處理文件 I/O 緩存交給 Page Cache,而當底層 RAW device 刷新數據時以 Buffer Cache 的塊單位來實際處理。

I/O 模式

在 Linux 或者其餘 Unix-like 操做系統裏,I/O 模式通常有三種:

  1. 程序控制 I/O
  2. 中斷驅動 I/O
  3. DMA I/O

下面我分別詳細地講解一下這三種 I/O 模式。

程序控制 I/O

這是最簡單的一種 I/O 模式,也叫忙等待或者輪詢:用戶經過發起一個系統調用,陷入內核態,內核將系統調用翻譯成一個對應設備驅動程序的過程調用,接着設備驅動程序會啓動 I/O 不斷循環去檢查該設備,看看是否已經就緒,通常經過返回碼來表示,I/O 結束以後,設備驅動程序會把數據送到指定的地方並返回,切回用戶態。

好比發起系統調用 read()

中斷驅動 I/O

第二種 I/O 模式是利用中斷來實現的:

流程以下:

  1. 用戶進程發起一個 read() 系統調用讀取磁盤文件,陷入內核態並由其所在的 CPU 經過設備驅動程序向設備寄存器寫入一個通知信號,告知設備控制器 (咱們這裏是磁盤控制器)要讀取數據;
  2. 磁盤控制器啓動磁盤讀取的過程,把數據從磁盤拷貝到磁盤控制器緩衝區裏;
  3. 完成拷貝以後磁盤控制器會經過總線發送一箇中斷信號到中斷控制器,若是此時中斷控制器手頭還有正在處理的中斷或者有一個和該中斷信號同時到達的更高優先級的中斷,則這個中斷信號將被忽略,而磁盤控制器會在後面持續發送中斷信號直至中斷控制器受理;
  4. 中斷控制器收到磁盤控制器的中斷信號以後會經過地址總線存入一個磁盤設備的編號,表示此次中斷須要關注的設備是磁盤;
  5. 中斷控制器向 CPU 置起一個磁盤中斷信號;
  6. CPU 收到中斷信號以後中止當前的工做,把當前的 PC/PSW 等寄存器壓入堆棧保存現場,而後從地址總線取出設備編號,經過編號找到中斷向量所包含的中斷服務的入口地址,壓入 PC 寄存器,開始運行磁盤中斷服務,把數據從磁盤控制器的緩衝區拷貝到主存裏的內核緩衝區;
  7. 最後 CPU 再把數據從內核緩衝區拷貝到用戶緩衝區,完成讀取操做,read() 返回,切換回用戶態。

DMA I/O

併發系統的性能高低究其根本,是取決於如何對 CPU 資源的高效調度和使用,而回頭看前面的中斷驅動 I/O 模式的流程,能夠發現第 六、7 步的數據拷貝工做都是由 CPU 親自完成的,也就是在這兩次數據拷貝階段中 CPU 是徹底被佔用而不能處理其餘工做的,那麼這裏明顯是有優化空間的;第 7 步的數據拷貝是從內核緩衝區到用戶緩衝區,都是在主存裏,因此這一步只能由 CPU 親自完成,可是第 6 步的數據拷貝,是從磁盤控制器的緩衝區到主存,是兩個設備之間的數據傳輸,這一步並不是必定要 CPU 來完成,能夠藉助 DMA 來完成,減輕 CPU 的負擔。

DMA 全稱是 Direct Memory Access,也即直接存儲器存取,是一種用來提供在外設和存儲器之間或者存儲器和存儲器之間的高速數據傳輸。整個過程無須 CPU 參與,數據直接經過 DMA 控制器進行快速地移動拷貝,節省 CPU 的資源去作其餘工做。

目前,大部分的計算機都配備了 DMA 控制器,而 DMA 技術也支持大部分的外設和存儲器。藉助於 DMA 機制,計算機的 I/O 過程就能更加高效:

DMA 控制器內部包含若干個能夠被 CPU 讀寫的寄存器:一個主存地址寄存器 MAR(存放要交換數據的主存地址)、一個外設地址寄存器 ADR(存放 I/O 設備的設備碼,或者是設備信息存儲區的尋址信息)、一個字節數寄存器 WC(對傳送數據的總字數進行統計)、和一個或多個控制寄存器。

  1. 用戶進程發起一個 read() 系統調用讀取磁盤文件,陷入內核態並由其所在的 CPU 經過設置 DMA 控制器的寄存器對它進行編程:把內核緩衝區和磁盤文件的地址分別寫入 MAR 和 ADR 寄存器,而後把指望讀取的字節數寫入 WC 寄存器,啓動 DMA 控制器;
  2. DMA 控制器根據 ADR 寄存器裏的信息知道此次 I/O 須要讀取的外設是磁盤的某個地址,便向磁盤控制器發出一個命令,通知它從磁盤讀取數據到其內部的緩衝區裏;
  3. 磁盤控制器啓動磁盤讀取的過程,把數據從磁盤拷貝到磁盤控制器緩衝區裏,並對緩衝區內數據的校驗和進行檢驗,若是數據是有效的,那麼 DMA 就能夠開始了;
  4. DMA 控制器經過總線向磁盤控制器發出一個讀請求信號從而發起 DMA 傳輸,這個信號和前面的中斷驅動 I/O 小節裏 CPU 發給磁盤控制器的讀請求是同樣的,它並不知道或者並不關心這個讀請求是來自 CPU 仍是 DMA 控制器;
  5. 緊接着 DMA 控制器將引導磁盤控制器將數據傳輸到 MAR 寄存器裏的地址,也就是內核緩衝區;
  6. 數據傳輸完成以後,返回一個 ack 給 DMA 控制器,WC 寄存器裏的值會減去相應的數據長度,若是 WC 還不爲 0,則重複第 4 步到第 6 步,一直到 WC 裏的字節數等於 0;
  7. 收到 ack 信號的 DMA 控制器會經過總線發送一箇中斷信號到中斷控制器,若是此時中斷控制器手頭還有正在處理的中斷或者有一個和該中斷信號同時到達的更高優先級的中斷,則這個中斷信號將被忽略,而 DMA 控制器會在後面持續發送中斷信號直至中斷控制器受理;
  8. 中斷控制器收到磁盤控制器的中斷信號以後會經過地址總線存入一個主存設備的編號,表示此次中斷須要關注的設備是主存;
  9. 中斷控制器向 CPU 置起一個 DMA 中斷的信號;
  10. CPU 收到中斷信號以後中止當前的工做,把當前的 PC/PSW 等寄存器壓入堆棧保存現場,而後從地址總線取出設備編號,經過編號找到中斷向量所包含的中斷服務的入口地址,壓入 PC 寄存器,開始運行 DMA 中斷服務,把數據從內核緩衝區拷貝到用戶緩衝區,完成讀取操做,read() 返回,切換回用戶態。

傳統 I/O 讀寫模式

Linux 中傳統的 I/O 讀寫是經過 read()/write() 系統調用完成的,read() 把數據從存儲器 (磁盤、網卡等) 讀取到用戶緩衝區,write() 則是把數據從用戶緩衝區寫出到存儲器:

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

一次完整的讀磁盤文件而後寫出到網卡的底層傳輸過程以下:

能夠清楚看到這裏一共觸發了 4 次用戶態和內核態的上下文切換,分別是 read()/write() 調用和返回時的切換,2 次 DMA 拷貝,2 次 CPU 拷貝,加起來一共 4 次拷貝操做。

經過引入 DMA,咱們已經把 Linux 的 I/O 過程當中的 CPU 拷貝次數從 4 次減小到了 2 次,可是 CPU 拷貝依然是代價很大的操做,對系統性能的影響仍是很大,特別是那些頻繁 I/O 的場景,更是會由於 CPU 拷貝而損失掉不少性能,咱們須要進一步優化,下降、甚至是徹底避免 CPU 拷貝。

零拷貝 (Zero-copy)

Zero-copy 是什麼?

Wikipedia 的解釋以下:

" Zero-copy" describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.

零拷貝技術是指計算機執行操做時,CPU不須要先將數據從某處內存複製到另外一個特定區域。這種技術一般用於經過網絡傳輸文件時節省CPU週期和內存帶寬

Zero-copy 能作什麼?

  • 減小甚至徹底避免操做系統內核和用戶應用程序地址空間這二者之間進行數據拷貝操做,從而減小用戶態 -- 內核態上下文切換帶來的系統開銷。
  • 減小甚至徹底避免操做系統內核緩衝區之間進行數據拷貝操做。
  • 幫助用戶進程繞開操做系統內核空間直接訪問硬件存儲接口操做數據。
  • 利用 DMA 而非 CPU 來完成硬件接口和內核緩衝區之間的數據拷貝,從而解放 CPU,使之能去執行其餘的任務,提高系統性能。

Zero-copy 的實現方式有哪些?

從 zero-copy 這個概念被提出以來,相關的實現技術便猶如雨後春筍,層出不窮。可是截至目前爲止,並無任何一種 zero-copy 技術能知足全部的場景需求,仍是計算機領域那句無比經典的名言:"There is no silver bullet"!

而在 Linux 平臺上,一樣也有不少的 zero-copy 技術,新舊各不一樣,可能存在於不一樣的內核版本里,不少技術可能有了很大的改進或者被更新的實現方式所替代,這些不一樣的實現技術按照其核心思想能夠概括成大體的如下三類:

  • 減小甚至避免用戶空間和內核空間之間的數據拷貝:在一些場景下,用戶進程在數據傳輸過程當中並不須要對數據進行訪問和處理,那麼數據在 Linux 的 Page Cache 和用戶進程的緩衝區之間的傳輸就徹底能夠避免,讓數據拷貝徹底在內核裏進行,甚至能夠經過更巧妙的方式避免在內核裏的數據拷貝。這一類實現通常是經過增長新的系統調用來完成的,好比 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 繞過內核的直接 I/O:容許在用戶態進程繞過內核直接和硬件進行數據傳輸,內核在傳輸過程當中只負責一些管理和輔助的工做。這種方式其實和第一種有點相似,也是試圖避免用戶空間和內核空間之間的數據傳輸,只是第一種方式是把數據傳輸過程放在內核態完成,而這種方式則是直接繞過內核和硬件通訊,效果相似但原理徹底不一樣。
  • 內核緩衝區和用戶緩衝區之間的傳輸優化:這種方式側重於在用戶進程的緩衝區和操做系統的頁緩存之間的 CPU 拷貝的優化。這種方法延續了以往那種傳統的通訊方式,但更靈活。

減小甚至避免用戶空間和內核空間之間的數據拷貝

mmap()
#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

一種簡單的實現方案是在一次讀寫過程當中用 Linux 的另外一個系統調用 mmap() 替換原先的 read()mmap() 也便是內存映射(memory map):把用戶進程空間的一段內存緩衝區(user buffer)映射到文件所在的內核緩衝區(kernel buffer)上。

利用 mmap() 替換 read(),配合 write() 調用的整個流程以下:

  1. 用戶進程調用 mmap(),從用戶態陷入內核態,將內核緩衝區映射到用戶緩存區;
  2. DMA 控制器將數據從硬盤拷貝到內核緩衝區;
  3. mmap() 返回,上下文從內核態切換回用戶態;
  4. 用戶進程調用 write(),嘗試把文件數據寫到內核裏的套接字緩衝區,再次陷入內核態;
  5. CPU 將內核緩衝區中的數據拷貝到的套接字緩衝區;
  6. DMA 控制器將數據從套接字緩衝區拷貝到網卡完成數據傳輸;
  7. write() 返回,上下文從內核態切換回用戶態。

經過這種方式,有兩個優勢:一是節省內存空間,由於用戶進程上的這一段內存是虛擬的,並不真正佔據物理內存,只是映射到文件所在的內核緩衝區上,所以能夠節省一半的內存佔用;二是省去了一次 CPU 拷貝,對比傳統的 Linux I/O 讀寫,數據不須要再通過用戶進程進行轉發了,而是直接在內核裏就完成了拷貝。因此使用 mmap() 以後的拷貝次數是 2 次 DMA 拷貝,1 次 CPU 拷貝,加起來一共 3 次拷貝操做,比傳統的 I/O 方式節省了一次 CPU 拷貝以及一半的內存,不過由於 mmap() 也是一個系統調用,所以用戶態和內核態的切換仍是 4 次。

mmap() 由於既節省 CPU 拷貝次數又節省內存,因此比較適合大文件傳輸的場景。雖然 mmap() 徹底是符合 POSIX 標準的,可是它也不是完美的,由於它並不老是能達到理想的數據傳輸性能。首先是由於數據數據傳輸過程當中依然須要一次 CPU 拷貝,其次是內存映射技術是一個開銷很大的虛擬存儲操做:這種操做須要修改頁表以及用內核緩衝區裏的文件數據汰換掉當前 TLB 裏的緩存以維持虛擬內存映射的一致性。可是,由於內存映射一般針對的是相對較大的數據區域,因此對於相同大小的數據來講,內存映射所帶來的開銷遠遠低於 CPU 拷貝所帶來的開銷。此外,使用 mmap() 還可能會遇到一些須要值得關注的特殊狀況,例如,在 mmap() --> write() 這兩個系統調用的整個傳輸過程當中,若是有其餘的進程忽然截斷了這個文件,那麼這時用戶進程就會由於訪問非法地址而被一個從總線傳來的 SIGBUS 中斷信號殺死而且產生一個 core dump。有兩種解決辦法:

  1. 設置一個信號處理器,專門用來處理 SIGBUS 信號,這個處理器直接返回, write() 就能夠正常返回已寫入的字節數而不會被 SIGBUS 中斷,errno 錯誤碼也會被設置成 success。然而這其實是一個掩耳盜鈴的解決方案,由於 BIGBUS 信號的帶來的信息是系統發生了一些很嚴重的錯誤,而咱們卻選擇忽略掉它,通常不建議採用這種方式。
  2. 經過內核的文件租借鎖(這是 Linux 的叫法,Windows 上稱之爲機會鎖)來解決這個問題,這種方法相對來講更好一些。咱們能夠經過內核對文件描述符上讀/寫的租借鎖,當另一個進程嘗試對當前用戶進程正在進行傳輸的文件進行截斷的時候,內核會發送給用戶一個實時信號:RT_SIGNAL_LEASE 信號,這個信號會告訴用戶內核正在破壞你加在那個文件上的讀/寫租借鎖,這時 write() 系統調用會被中斷,而且當前用戶進程會被 SIGBUS 信號殺死,返回值則是中斷前寫的字節數,errno 一樣會被設置爲 success。文件租借鎖須要在對文件進行內存映射以前設置,最後在用戶進程結束以前釋放掉。
sendfile()

在 Linux 內核 2.1 版本中,引入了一個新的系統調用 sendfile()

#include <sys/sendfile.h>

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

從功能上來看,這個系統調用將 mmap() + write() 這兩個系統調用合二爲一,實現了同樣效果的同時還簡化了用戶接口,其餘的一些 Unix-like 的系統像 BSD、Solaris 和 AIX 等也有相似的實現,甚至 Windows 上也有一個功能相似的 API 函數 TransmitFile

out_fd 和 in_fd 分別表明了寫入和讀出的文件描述符,in_fd 必須是一個指向文件的文件描述符,且要能支持類 mmap() 內存映射,不能是 Socket 類型,而 out_fd 在 Linux 內核 2.6.33 版本以前只能是一個指向 Socket 的文件描述符,從 2.6.33 以後則能夠是任意類型的文件描述符。off_t 是一個表明了 in_fd 偏移量的指針,指示 sendfile() 該從 in_fd 的哪一個位置開始讀取,函數返回後,這個指針會被更新成 sendfile() 最後讀取的字節位置處,代表這次調用共讀取了多少文件數據,最後的 count 參數則是這次調用須要傳輸的字節總數。

使用 sendfile() 完成一次數據讀寫的流程以下:

  1. 用戶進程調用 sendfile() 從用戶態陷入內核態;
  2. DMA 控制器將數據從硬盤拷貝到內核緩衝區;
  3. CPU 將內核緩衝區中的數據拷貝到套接字緩衝區;
  4. DMA 控制器將數據從套接字緩衝區拷貝到網卡完成數據傳輸;
  5. sendfile() 返回,上下文從內核態切換回用戶態。

基於 sendfile(), 整個數據傳輸過程當中共發生 2 次 DMA 拷貝和 1 次 CPU 拷貝,這個和 mmap() + write() 相同,可是由於 sendfile() 只是一次系統調用,所以比前者少了一次用戶態和內核態的上下文切換開銷。讀到這裏,聰明的讀者應該會開始提問了:"sendfile() 會不會遇到和 mmap() + write() 類似的文件截斷問題呢?",很不幸,答案是確定的。sendfile() 同樣會有文件截斷的問題,但欣慰的是,sendfile() 不只比 mmap() + write() 在接口使用上更加簡潔,並且處理文件截斷時也更加優雅:若是 sendfile() 過程當中遭遇文件截斷,則 sendfile() 系統調用會被中斷殺死以前返回給用戶進程其中斷前所傳輸的字節數,errno 會被設置爲 success,無需用戶提早設置信號處理器,固然你要設置一個進行個性化處理也能夠,也不須要像以前那樣提早給文件描述符設置一個租借鎖,由於最終結果仍是同樣的。

sendfile() 相較於 mmap() 的另外一個優點在於數據在傳輸過程當中始終沒有越過用戶態和內核態的邊界,所以極大地減小了存儲管理的開銷。即使如此,sendfile() 依然是一個適用性很窄的技術,最適合的場景基本也就是一個靜態文件服務器了。並且根據 Linus 在 2001 年和其餘內核維護者的郵件列表內容,其實當初之因此決定在 Linux 上實現 sendfile() 僅僅是由於在其餘操做系統平臺上已經率先實現了,並且有大名鼎鼎的 Apache Web 服務器已經在使用了,爲了兼容 Apache Web 服務器才決定在 Linux 上也實現這個技術,並且 sendfile() 實現上的簡潔性也和 Linux 內核的其餘部分集成得很好,因此 Linus 也就贊成了這個提案。

然而 sendfile() 自己是有很大問題的,從不一樣的角度來看的話主要是:

  1. 首先一個是這個接口並無進行標準化,致使 sendfile() 在 Linux 上的接口實現和其餘類 Unix 系統的實現並不相同;
  2. 其次因爲網絡傳輸的異步性,很難在接收端實現和 sendfile() 對接的技術,所以接收端一直沒有實現對應的這種技術;
  3. 最後從性能方面考量,由於 sendfile() 在把磁盤文件從內核緩衝區(page cache)傳輸到到套接字緩衝區的過程當中依然須要 CPU 參與,這就很難避免 CPU 的高速緩存被傳輸的數據所污染。

此外,須要說明下,sendfile() 的最初設計並非用來處理大文件的,所以若是須要處理很大的文件的話,能夠使用另外一個系統調用 sendfile64(),它支持對更大的文件內容進行尋址和偏移。

sendfile() with DMA Scatter/Gather Copy

上一小節介紹的 sendfile() 技術已經把一次數據讀寫過程當中的 CPU 拷貝的下降至只有 1 次了,可是人永遠是貪心和不滿足的,如今若是想要把這僅有的一次 CPU 拷貝也去除掉,有沒有辦法呢?

固然有!經過引入一個新硬件上的支持,咱們能夠把這個僅剩的一次 CPU 拷貝也給抹掉:Linux 在內核 2.4 版本里引入了 DMA 的 scatter/gather -- 分散/收集功能,並修改了 sendfile() 的代碼使之和 DMA 適配。scatter 使得 DMA 拷貝能夠再也不須要把數據存儲在一片連續的內存空間上,而是容許離散存儲,gather 則可以讓 DMA 控制器根據少許的元信息:一個包含了內存地址和數據大小的緩衝區描述符,收集存儲在各處的數據,最終還原成一個完整的網絡包,直接拷貝到網卡而非套接字緩衝區,避免了最後一次的 CPU 拷貝:

sendfile() + DMA gather 的數據傳輸過程以下:

  1. 用戶進程調用 sendfile(),從用戶態陷入內核態;
  2. DMA 控制器使用 scatter 功能把數據從硬盤拷貝到內核緩衝區進行離散存儲;
  3. CPU 把包含內存地址和數據長度的緩衝區描述符拷貝到套接字緩衝區,DMA 控制器可以根據這些信息生成網絡包數據分組的報頭和報尾
  4. DMA 控制器根據緩衝區描述符裏的內存地址和數據大小,使用 scatter-gather 功能開始從內核緩衝區收集離散的數據並組包,最後直接把網絡包數據拷貝到網卡完成數據傳輸;
  5. sendfile() 返回,上下文從內核態切換回用戶態。

基於這種方案,咱們就能夠把這僅剩的惟一一次 CPU 拷貝也給去除了(嚴格來講仍是會有一次,可是由於此次 CPU 拷貝的只是那些微乎其微的元信息,開銷幾乎能夠忽略不計),理論上,數據傳輸過程就再也沒有 CPU 的參與了,也所以 CPU 的高速緩存再不會被污染了,也再也不須要 CPU 來計算數據校驗和了,CPU 能夠去執行其餘的業務計算任務,同時和 DMA 的 I/O 任務並行,此舉能極大地提高系統性能。

splice()

sendfile() + DMA Scatter/Gather 的零拷貝方案雖然高效,可是也有兩個缺點:

  1. 這種方案須要引入新的硬件支持;
  2. 雖然 sendfile() 的輸出文件描述符在 Linux kernel 2.6.33 版本以後已經能夠支持任意類型的文件描述符,可是輸入文件描述符依然只能指向文件。

這兩個缺點限制了 sendfile() + DMA Scatter/Gather 方案的適用場景。爲此,Linux 在 2.6.17 版本引入了一個新的系統調用 splice(),它在功能上和 sendfile() 很是類似,可是可以實如今任意類型的兩個文件描述符時之間傳輸數據;而在底層實現上,splice()又比 sendfile() 少了一次 CPU 拷貝,也就是等同於 sendfile() + DMA Scatter/Gather,徹底去除了數據傳輸過程當中的 CPU 拷貝。

splice() 系統調用函數定義以下:

#include <fcntl.h>
#include <unistd.h>

int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

fd_in 和 fd_out 也是分別表明了輸入端和輸出端的文件描述符,這兩個文件描述符必須有一個是指向管道設備的,這也是一個不太友好的限制,雖然 Linux 內核開發的官方從這個系統調用推出之時就承諾將來可能會重構去掉這個限制,然而他們許下這個承諾以後就如同石沉大海,現在 14 年過去了,依舊杳無音訊...

off_in 和 off_out 則分別是 fd_in 和 fd_out 的偏移量指針,指示內核從哪裏讀取和寫入數據,len 則指示了這次調用但願傳輸的字節數,最後的 flags 是系統調用的標記選項位掩碼,用來設置系統調用的行爲屬性的,由如下 0 個或者多個值經過『或』操做組合而成:

  • SPLICE_F_MOVE:指示 splice() 嘗試僅僅是移動內存頁面而不是複製,設置了這個值不表明就必定不會複製內存頁面,複製仍是移動取決於內核可否從管道中移動內存頁面,或者管道中的內存頁面是不是完整的;這個標記的初始實現有不少 bug,因此從 Linux 2.6.21 版本開始就已經無效了,但仍是保留了下來,由於在將來的版本里可能會從新被實現。
  • SPLICE_F_NONBLOCK:指示 splice() 不要阻塞 I/O,也就是使得 splice() 調用成爲一個非阻塞調用,能夠用來實現異步數據傳輸,不過須要注意的是,數據傳輸的兩個文件描述符也最好是預先經過 O_NONBLOCK 標記成非阻塞 I/O,否則 splice() 調用仍是有可能被阻塞。
  • SPLICE_F_MORE:通知內核下一個 splice() 系統調用將會有更多的數據傳輸過來,這個標記對於輸出端是 socket 的場景很是有用。

splice() 是基於 Linux 的管道緩衝區 (pipe buffer) 機制實現的,因此 splice() 的兩個入參文件描述符纔要求必須有一個是管道設備,一個典型的 splice() 用法是:

int pfd[2];

pipe(pfd);

ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE);
assert(bytes != -1);

bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE);
assert(bytes != -1);

數據傳輸過程圖:

使用 splice() 完成一次磁盤文件到網卡的讀寫過程以下:

  1. 用戶進程調用 pipe(),從用戶態陷入內核態,建立匿名單向管道,pipe() 返回,上下文從內核態切換回用戶態;
  2. 用戶進程調用 splice(),從用戶態陷入內核態;
  3. DMA 控制器將數據從硬盤拷貝到內核緩衝區,從管道的寫入端"拷貝"進管道,splice() 返回,上下文從內核態回到用戶態;
  4. 用戶進程再次調用 splice(),從用戶態陷入內核態;
  5. 內核把數據從管道的讀取端"拷貝"到套接字緩衝區,DMA 控制器將數據從套接字緩衝區拷貝到網卡;
  6. splice() 返回,上下文從內核態切換回用戶態。

相信看完上面的讀寫流程以後,讀者確定會很是困惑:說好的 splice()sendfile() 的改進版呢?sendfile() 好歹只須要一次系統調用,splice() 竟然須要三次,這也就罷了,竟然中間還搞出來一個管道,並且還要在內核空間拷貝兩次,這算個毛的改進啊?

我最開始瞭解 splice() 的時候,也是這個反應,可是深刻學習它以後,才漸漸知曉箇中奧妙,且聽我細細道來:

先來了解一下 pipe buffer 管道,管道是 Linux 上用來供進程之間通訊的信道,管道有兩個端:寫入端和讀出端,從進程的視角來看,管道表現爲一個 FIFO 字節流環形隊列:

管道本質上是一個內存中的文件,也就是本質上仍是基於 Linux 的 VFS,用戶進程能夠經過 pipe() 系統調用建立一個匿名管道,建立完成以後會有兩個 VFS 的 file 結構體的 inode 分別指向其寫入端和讀出端,並返回對應的兩個文件描述符,用戶進程經過這兩個文件描述符讀寫管道;管道的容量單位是一個虛擬內存的頁,也就是 4KB,總大小通常是 16 個頁,基於其環形結構,管道的頁能夠循環使用,提升內存利用率。 Linux 中以 pipe_buffer 結構體封裝管道頁,file 結構體裏的 inode 字段裏會保存一個 pipe_inode_info 結構體指代管道,其中會保存不少讀寫管道時所需的元信息,環形隊列的頭部指針頁,讀寫時的同步機制如互斥鎖、等待隊列等:

struct pipe_buffer {
    struct page *page; // 內存頁結構
    unsigned int offset, len; // 偏移量,長度
    const struct pipe_buf_operations *ops;
    unsigned int flags;
    unsigned long private;
};

struct pipe_inode_info {
    struct mutex mutex;
    wait_queue_head_t wait;
    unsigned int nrbufs, curbuf, buffers;
    unsigned int readers;
    unsigned int writers;
    unsigned int files;
    unsigned int waiting_writers;
    unsigned int r_counter;
    unsigned int w_counter;
    struct page *tmp_page;
    struct fasync_struct *fasync_readers;
    struct fasync_struct *fasync_writers;
    struct pipe_buffer *bufs;
    struct user_struct *user;
};

pipe_buffer 中保存了數據在內存中的頁、偏移量和長度,以這三個值來定位數據,注意這裏的頁不是虛擬內存的頁,而用的是物理內存的頁框,由於管道時跨進程的信道,所以不能使用虛擬內存來表示,只能使用物理內存的頁框定位數據;管道的正常讀寫操做是經過 pipe_write()/pipe_read() 來完成的,經過把數據讀取/寫入環形隊列的 pipe_buffer 來完成數據傳輸。

splice() 是基於 pipe buffer 實現的,可是它在經過管道傳輸數據的時候倒是零拷貝,由於它在寫入讀出時並無使用 pipe_write()/pipe_read() 真正地在管道緩衝區寫入讀出數據,而是經過把數據在內存緩衝區中的物理內存頁框指針、偏移量和長度賦值給前文說起的 pipe_buffer 中對應的三個字段來完成數據的"拷貝",也就是其實只拷貝了數據的內存地址等元信息。

splice() 在 Linux 內核源碼中的內部實現是 do_splice() 函數,而寫入讀出管道則分別是經過 do_splice_to()do_splice_from(),這裏咱們重點來解析下寫入管道的源碼,也就是 do_splice_to(),我如今手頭的 Linux 內核版本是 v4.8.17,咱們就基於這個版原本分析,至於讀出的源碼函數 do_splice_from(),原理是相通的,你們觸類旁通便可。

splice() 寫入數據到管道的調用鏈式:do_splice() --> do_splice_to() --> splice_read()

static long do_splice(struct file *in, loff_t __user *off_in,
              struct file *out, loff_t __user *off_out,
              size_t len, unsigned int flags)
{
...

    // 判斷是寫出 fd 是一個管道設備,則進入數據寫入的邏輯
    if (opipe) {
        if (off_out)
            return -ESPIPE;
        if (off_in) {
            if (!(in->f_mode & FMODE_PREAD))
                return -EINVAL;
            if (copy_from_user(&offset, off_in, sizeof(loff_t)))
                return -EFAULT;
        } else {
            offset = in->f_pos;
        }

        // 調用 do_splice_to 把文件內容寫入管道
        ret = do_splice_to(in, &offset, opipe, len, flags);

        if (!off_in)
            in->f_pos = offset;
        else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
            ret = -EFAULT;

        return ret;
    }

    return -EINVAL;
}

進入 do_splice_to() 以後,再調用 splice_read()

static long do_splice_to(struct file *in, loff_t *ppos,
             struct pipe_inode_info *pipe, size_t len,
             unsigned int flags)
{
    ssize_t (*splice_read)(struct file *, loff_t *,
                   struct pipe_inode_info *, size_t, unsigned int);
    int ret;

    if (unlikely(!(in->f_mode & FMODE_READ)))
        return -EBADF;

    ret = rw_verify_area(READ, in, ppos, len);
    if (unlikely(ret < 0))
        return ret;

    if (unlikely(len > MAX_RW_COUNT))
        len = MAX_RW_COUNT;

    // 判斷文件的文件的 file 結構體的 f_op 中有沒有可供使用的、支持 splice 的 splice_read 函數指針
    // 由於是 splice() 調用,所以內核會提早給這個函數指針指派一個可用的函數
    if (in->f_op->splice_read)
        splice_read = in->f_op->splice_read;
    else
        splice_read = default_file_splice_read;

    return splice_read(in, ppos, pipe, len, flags);
}

in->f_op->splice_read 這個函數指針根據文件描述符的類型不一樣有不一樣的實現,好比這裏的 in 是一個文件,所以是 generic_file_splice_read(),若是是 socket 的話,則是 sock_splice_read(),其餘的類型也會有對應的實現,總之咱們這裏將使用的是 generic_file_splice_read() 函數,這個函數會繼續調用內部函數 __generic_file_splice_read 完成如下工做:

  1. 在 page cache 頁緩存裏進行搜尋,看看咱們要讀取這個文件內容是否已經在緩存裏了,若是是則直接用,不然若是不存在或者只有部分數據在緩存中,則分配一些新的內存頁並進行讀入數據操做,同時會增長頁框的引用計數;
  2. 基於這些內存頁,初始化 splice_pipe_desc 結構,這個結構保存會保存文件數據的地址元信息,包含有物理內存頁框地址,偏移、數據長度,也就是 pipe_buffer 所需的三個定位數據的值;
  3. 最後,調用 splice_to_pipe(),splice_pipe_desc 結構體實例是函數入參。
ssize_t splice_to_pipe(struct pipe_inode_info *pipe, struct splice_pipe_desc *spd)
{
...

    for (;;) {
        if (!pipe->readers) {
            send_sig(SIGPIPE, current, 0);
            if (!ret)
                ret = -EPIPE;
            break;
        }

        if (pipe->nrbufs < pipe->buffers) {
            int newbuf = (pipe->curbuf + pipe->nrbufs) & (pipe->buffers - 1);
            struct pipe_buffer *buf = pipe->bufs + newbuf;

            // 寫入數據到管道,沒有真正拷貝數據,而是內存地址指針的移動,
            // 把物理頁框、偏移量和數據長度賦值給 pipe_buffer 完成數據入隊操做
            buf->page = spd->pages[page_nr];
            buf->offset = spd->partial[page_nr].offset;
            buf->len = spd->partial[page_nr].len;
            buf->private = spd->partial[page_nr].private;
            buf->ops = spd->ops;
            if (spd->flags & SPLICE_F_GIFT)
                buf->flags |= PIPE_BUF_FLAG_GIFT;

            pipe->nrbufs++;
            page_nr++;
            ret += buf->len;

            if (pipe->files)
                do_wakeup = 1;

            if (!--spd->nr_pages)
                break;
            if (pipe->nrbufs < pipe->buffers)
                continue;

            break;
        }

    ...
}

這裏能夠清楚地看到 splice() 所謂的寫入數據到管道其實並無真正地拷貝數據,而是玩了個 tricky 的操做:只進行內存地址指針的拷貝而不真正去拷貝數據。因此,數據 splice() 在內核中並無進行真正的數據拷貝,所以 splice() 系統調用也是零拷貝。

還有一點須要注意,前面說過管道的容量是 16 個內存頁,也就是 16 * 4KB = 64 KB,也就是說一次往管道里寫數據的時候最好不要超過 64 KB,不然的話會 splice() 會阻塞住,除非在建立管道的時候使用的是 pipe2() 並經過傳入 O_NONBLOCK 屬性將管道設置爲非阻塞。

即便 splice() 經過內存地址指針避免了真正的拷貝開銷,可是算起來它還要使用額外的管道來完成數據傳輸,也就是比 sendfile() 多了兩次系統調用,這不是又增長了上下文切換的開銷嗎?爲何不直接在內核建立管道並調用那兩次 splice(),而後只暴露給用戶一次系統調用呢?實際上由於 splice() 利用管道而非硬件來完成零拷貝的實現比 sendfile() + DMA Scatter/Gather 的門檻更低,所以後來的 sendfile() 的底層實現就已經替換成 splice() 了。

至於說 splice() 自己的 API 爲何仍是這種使用模式,那是由於 Linux 內核開發團隊一直想把基於管道的這個限制去掉,但不知道由於什麼一直擱置,因此這個 API 也就一直沒變化,只能等內核團隊哪天想起來了這一茬,而後重構一下使之再也不依賴管道,在那以前,使用 splice() 依然仍是須要額外建立管道來做爲中間緩衝,若是你的業務場景很適合使用 splice(),但又是性能敏感的,不想頻繁地建立銷燬 pipe buffer 管道緩衝區,那麼能夠參考一下 HAProxy 使用 splice() 時採用的優化方案:預先分配一個 pipe buffer pool 緩存管道,每次調用 spclie() 的時候去緩存池裏取一個管道,用完就放回去,循環利用,提高性能。

send() with MSG_ZEROCOPY

Linux 內核在 2017 年的 v4.14 版本接受了來自 Google 工程師 Willem de Bruijn 在 TCP 網絡報文的通用發送接口 send() 中實現的 zero-copy 功能 (MSG_ZEROCOPY) 的 patch,經過這個新功能,用戶進程就可以把用戶緩衝區的數據經過零拷貝的方式通過內核空間發送到網絡套接字中去,這個新技術和前文介紹的幾種零拷貝方式相比更加先進,由於前面幾種零拷貝技術都是要求用戶進程不能處理加工數據而是直接轉發到目標文件描述符中去的。Willem de Bruijn 在他的論文裏給出的壓測數據是:採用 netperf 大包發送測試,性能提高 39%,而線上環境的數據發送性能則提高了 5%~8%,官方文檔陳述說這個特性一般只在發送 10KB 左右大包的場景下才會有顯著的性能提高。一開始這個特性只支持 TCP,到內核 v5.0 版本以後才支持 UDP。

這個功能的使用模式以下:

if (setsockopt(socket_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
        error(1, errno, "setsockopt zerocopy");

ret = send(socket_fd, buffer, sizeof(buffer), MSG_ZEROCOPY);

首先第一步,先給要發送數據的 socket 設置一個 SOCK_ZEROCOPY option,而後在調用 send() 發送數據時再設置一個 MSG_ZEROCOPY option,其實理論上來講只須要調用 setsockopt() 或者 send() 時傳遞這個 zero-copy 的 option 便可,二者選其一,可是這裏卻要設置同一個 option 兩次,官方的說法是爲了兼容 send() API 之前的設計上的一個錯誤:send() 之前的實現會忽略掉未知的 option,爲了兼容那些可能已經不當心設置了 MSG_ZEROCOPY option 的程序,故而設計成了兩步設置。不過我猜還有一種可能:就是給使用者提供更靈活的使用模式,由於這個新功能只在大包場景下才可能會有顯著的性能提高,可是現實場景是很複雜的,不只僅是所有大包或者所有小包的場景,有多是大包小包混合的場景,所以使用者能夠先調用 setsockopt() 設置 SOCK_ZEROCOPY option,而後再根據實際業務場景中的網絡包尺寸選擇是否要在調用 send() 時使用 MSG_ZEROCOPY 進行 zero-copy 傳輸。

由於 send() 多是異步發送數據,所以使用 MSG_ZEROCOPY 有一個須要特別注意的點是:調用 send() 以後不能馬上重用或釋放 buffer,由於 buffer 中的數據不必定已經被內核讀走了,因此還須要從 socket 關聯的錯誤隊列裏讀取一下通知消息,看看 buffer 中的數據是否已經被內核讀走了:

pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
        error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
        error(1, errno, "recvmsg");

read_notification(msg);


uint32_t read_notification(struct msghdr *msg)
{
    struct sock_extended_err *serr;
    struct cmsghdr *cm;
    
    cm = CMSG_FIRSTHDR(msg);
    if (cm->cmsg_level != SOL_IP &&
        cm->cmsg_type != IP_RECVERR)
            error(1, 0, "cmsg");
    
    serr = (void *) CMSG_DATA(cm);
    if (serr->ee_errno != 0 ||
        serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)
            error(1, 0, "serr");
    
    return serr->ee _ data;
}

這個技術是基於 redhat 紅帽在 2010 年給 Linux 內核提交的 virtio-net zero-copy 技術之上實現的,至於底層原理,簡單來講就是經過 send() 把數據在用戶緩衝區中的分段指針發送到 socket 中去,利用 page pinning 頁鎖定機制鎖住用戶緩衝區的內存頁,而後利用 DMA 直接在用戶緩衝區經過內存地址指針進行數據讀取,實現零拷貝;具體的細節能夠經過閱讀 Willem de Bruijn 的論文 (PDF) 深刻了解。

目前來講,這種技術的主要缺陷有:

  1. 只適用於大文件 (10KB 左右) 的場景,小文件場景由於 page pinning 頁鎖定和等待緩衝區釋放的通知消息這些機制,甚至可能比直接 CPU 拷貝更耗時;
  2. 由於可能異步發送數據,須要額外調用 poll()recvmsg() 系統調用等待 buffer 被釋放的通知消息,增長代碼複雜度,以及會致使屢次用戶態和內核態的上下文切換;
  3. MSG_ZEROCOPY 目前只支持發送端,接收端暫不支持。

繞過內核的直接 I/O

能夠看出,前面種種的 zero-copy 的方法,都是在千方百計地優化減小或者去掉用戶態和內核態之間以及內核態和內核態之間的數據拷貝,爲了實現避免這些拷貝可謂是八仙過海,各顯神通,採用了各類各樣的手段,那麼若是咱們換個思路:其實這麼費勁地去消除這些拷貝不就是由於有內核在摻和嗎?若是咱們繞過內核直接進行 I/O 不就沒有這些煩人的拷貝問題了嗎?這就是繞過內核直接 I/O 技術:

這種方案有兩種實現方式:

  1. 用戶直接訪問硬件
  2. 內核控制訪問硬件
用戶直接訪問硬件

這種技術賦予用戶進程直接訪問硬件設備的權限,這讓用戶進程能有直接讀寫硬件設備,在數據傳輸過程當中只須要內核作一些虛擬內存配置相關的工做。這種無需數據拷貝和內核干預的直接 I/O,理論上是最高效的數據傳輸技術,可是正如前面所說的那樣,並不存在能解決一切問題的銀彈,這種直接 I/O 技術雖然有可能很是高效,可是它的適用性也很是窄,目前只適用於諸如 MPI 高性能通訊、叢集計算系統中的遠程共享內存等有限的場景。

這種技術實際上破壞了現代計算機操做系統最重要的概念之一 —— 硬件抽象,咱們以前提過,抽象是計算機領域最最核心的設計思路,正式因爲有了抽象和分層,各個層級才能沒必要去關心不少底層細節從而專一於真正的工做,才使得系統的運做更加高效和快速。此外,網卡一般使用功能較弱的 CPU,例如只包含簡單指令集的 MIPS 架構處理器(沒有沒必要要的功能,如浮點數計算等),也沒有太多的內存來容納複雜的軟件。所以,一般只有那些基於以太網之上的專用協議會使用這種技術,這些專用協議的設計要比遠比 TCP/IP 簡單得多,並且多用於局域網環境中,在這種環境中,數據包丟失和損壞不多發生,所以沒有必要進行復雜的數據包確認和流量控制機制。並且這種技術還須要定製的網卡,因此它是高度依賴硬件的。

與傳統的通訊設計相比,直接硬件訪問技術給程序設計帶來了各類限制:因爲設備之間的數據傳輸是經過 DMA 完成的,所以用戶空間的數據緩衝區內存頁必須進行 page pinning(頁鎖定),這是爲了防止其物理頁框地址被交換到磁盤或者被移動到新的地址而致使 DMA 去拷貝數據的時候在指定的地址找不到內存頁從而引起缺頁錯誤,而頁鎖定的開銷並不比 CPU 拷貝小,因此爲了不頻繁的頁鎖定系統調用,應用程序必須分配和註冊一個持久的內存池,用於數據緩衝。

用戶直接訪問硬件的技術能夠獲得極高的 I/O 性能,可是其應用領域和適用場景也極其的有限,如集羣或網絡存儲系統中的節點通訊。它須要定製的硬件和專門設計的應用程序,但相應地對操做系統內核的改動比較小,能夠很容易地之內核模塊或設備驅動程序的形式實現出來。直接訪問硬件還可能會帶來嚴重的安全問題,由於用戶進程擁有直接訪問硬件的極高權限,因此若是你的程序設計沒有作好的話,可能會消耗原本就有限的硬件資源或者進行非法地址訪問,可能也會所以間接地影響其餘正在使用同一設備的應用程序,而由於繞開了內核,因此也沒法讓內核替你去控制和管理。

內核控制訪問硬件

相較於用戶直接訪問硬件技術,經過內核控制的直接訪問硬件技術更加的安全,它比前者在數據傳輸過程當中會多幹預一點,但也僅僅是做爲一個代理人這樣的角色,不會參與到實際的數據傳輸過程,內核會控制 DMA 引擎去替用戶進程作緩衝區的數據傳輸工做。一樣的,這種方式也是高度依賴硬件的,好比一些集成了專有網絡棧協議的網卡。這種技術的一個優點就是用戶集成去 I/O 時的接口不會改變,就和普通的 read()/write() 系統調用那樣使用便可,全部的髒活累活都在內核裏完成,用戶接口友好度很高,不過須要注意的是,使用這種技術的過程當中若是發生了什麼不可預知的意外從而致使沒法使用這種技術進行數據傳輸的話,則內核會自動切換爲最傳統 I/O 模式,也就是性能最差的那種模式。

這種技術也有着和用戶直接訪問硬件技術同樣的問題:DMA 傳輸數據的過程當中,用戶進程的緩衝區內存頁必須進行 page pinning 頁鎖定,數據傳輸完成後才能解鎖。CPU 高速緩存內保存的多個內存地址也會被沖刷掉以保證 DMA 傳輸先後的數據一致性。這些機制有可能會致使數據傳輸的性能變得更差,由於 read()/write() 系統調用的語義並不能提早通知 CPU 用戶緩衝區要參與 DMA 數據傳輸傳輸,所以也就沒法像內核緩衝區那樣可依提早加載進高速緩存,提升性能。因爲用戶緩衝區的內存頁可能分佈在物理內存中的任意位置,所以一些實現很差的 DMA 控制器引擎可能會有尋址限制從而致使沒法訪問這些內存區域。一些技術好比 AMD64 架構中的 IOMMU,容許經過將 DMA 地址從新映射到內存中的物理地址來解決這些限制,但反過來又可能會致使可移植性問題,由於其餘的處理器架構,甚至是 Intel 64 位 x86 架構的變種 EM64T 都不具有這樣的特性單元。此外,還可能存在其餘限制,好比 DMA 傳輸的數據對齊問題,又會致使沒法訪問用戶進程指定的任意緩衝區內存地址。

內核緩衝區和用戶緩衝區之間的傳輸優化

到目前爲止,咱們討論的 zero-copy 技術都是基於減小甚至是避免用戶空間和內核空間之間的 CPU 數據拷貝的,雖然有一些技術很是高效,可是大多都有適用性很窄的問題,好比 sendfile()splice() 這些,效率很高,可是都只適用於那些用戶進程不須要直接處理數據的場景,好比靜態文件服務器或者是直接轉發數據的代理服務器。

如今咱們已經知道,硬件設備之間的數據能夠經過 DMA 進行傳輸,然而卻並無這樣的傳輸機制能夠應用於用戶緩衝區和內核緩衝區之間的數據傳輸。不過另外一方面,普遍應用在現代的 CPU 架構和操做系統上的虛擬內存機制代表,經過在不一樣的虛擬地址上從新映射頁面能夠實如今用戶進程和內核之間虛擬複製和共享內存,儘管一次傳輸的內存顆粒度相對較大:4KB 或 8KB。

所以若是要在實如今用戶進程內處理數據(這種場景比直接轉發數據更加常見)以後再發送出去的話,用戶空間和內核空間的數據傳輸就是不可避免的,既然避無可避,那就只能選擇優化了,所以本章節咱們要介紹兩種優化用戶空間和內核空間數據傳輸的技術:

  1. 動態重映射與寫時拷貝 (Copy-on-Write)
  2. 緩衝區共享 (Buffer Sharing)
動態重映射與寫時拷貝 (Copy-on-Write)

前面咱們介紹過利用內存映射技術來減小數據在用戶空間和內核空間之間的複製,一般簡單模式下,用戶進程是對共享的緩衝區進行同步阻塞讀寫的,這樣不會有 data race 問題,可是這種模式下效率並不高,而提高效率的一種方法就是異步地對共享緩衝區進行讀寫,而這樣的話就必須引入保護機制來避免數據衝突問題,寫時複製 (Copy on Write) 就是這樣的一種技術。

寫入時複製Copy-on-writeCOW)是一種計算機 程序設計領域的優化策略。其核心思想是,若是有多個調用者(callers)同時請求相同資源(如內存或磁盤上的數據存儲),他們會共同獲取相同的指針指向相同的資源,直到某個調用者試圖修改資源的內容時,系統纔會真正複製一份專用副本(private copy)給該調用者,而其餘調用者所見到的最初的資源仍然保持不變。這過程對其餘的調用者都是 透明的。此做法主要的優勢是若是調用者沒有修改該資源,就不會有副本(private copy)被建立,所以多個調用者只是讀取操做時能夠共享同一份資源。

舉一個例子,引入了 COW 技術以後,用戶進程讀取磁盤文件進行數據處理最後寫到網卡,首先使用內存映射技術讓用戶緩衝區和內核緩衝區共享了一段內存地址並標記爲只讀 (read-only),避免數據拷貝,而當要把數據寫到網卡的時候,用戶進程選擇了異步寫的方式,系統調用會直接返回,數據傳輸就會在內核裏異步進行,而用戶進程就能夠繼續其餘的工做,而且共享緩衝區的內容能夠隨時再進行讀取,效率很高,可是若是該進程又嘗試往共享緩衝區寫入數據,則會產生一個 COW 事件,讓試圖寫入數據的進程把數據複製到本身的緩衝區去修改,這裏只須要複製要修改的內存頁便可,無需全部數據都複製過去,而若是其餘訪問該共享內存的進程不須要修改數據則能夠永遠不須要進行數據拷貝。

COW 是一種建構在虛擬內存衝映射技術之上的技術,所以它須要 MMU 的硬件支持,MMU 會記錄當前哪些內存頁被標記成只讀,當有進程嘗試往這些內存頁中寫數據的時候,MMU 就會拋一個異常給操做系統內核,內核處理該異常時爲該進程分配一份物理內存並複製數據到此內存地址,從新向 MMU 發出執行該進程的寫操做。

COW 最大的優點是節省內存和減小數據拷貝,不過倒是經過增長操做系統內核 I/O 過程複雜性做爲代價的。當肯定採用 COW 來複制頁面時,重要的是注意空閒頁面的分配位置。許多操做系統爲這類請求提供了一個空閒的頁面池。當進程的堆棧或堆要擴展時或有寫時複製頁面須要管理時,一般分配這些空閒頁面。操做系統分配這些頁面一般採用稱爲按需填零的技術。按需填零頁面在須要分配以前先填零,所以會清除裏面舊的內容。

侷限性

COW 這種零拷貝技術比較適用於那種多讀少寫從而使得 COW 事件發生較少的場景,由於 COW 事件所帶來的系統開銷要遠遠高於一次 CPU 拷貝所產生的。此外,在實際應用的過程當中,爲了不頻繁的內存映射,能夠重複使用同一段內存緩衝區,所以,你不須要在只用過一次共享緩衝區以後就解除掉內存頁的映射關係,而是重複循環使用,從而提高性能,不過這種內存頁映射的持久化並不會減小因爲頁表往返移動和 TLB 沖刷所帶來的系統開銷,由於每次接收到 COW 事件以後對內存頁而進行加鎖或者解鎖的時候,頁面的只讀標誌 (read-ony) 都要被更改成 (write-only)。

緩衝區共享 (Buffer Sharing)

從前面的介紹能夠看出,傳統的 Linux I/O接口,都是基於複製/拷貝的:數據須要在操做系統內核空間和用戶空間的緩衝區之間進行拷貝。在進行 I/O 操做以前,用戶進程須要預先分配好一個內存緩衝區,使用 read() 系統調用時,內核會將從存儲器或者網卡等設備讀入的數據拷貝到這個用戶緩衝區裏;而使用 write() 系統調用時,則是把用戶內存緩衝區的數據拷貝至內核緩衝區。

爲了實現這種傳統的 I/O 模式,Linux 必需要在每個 I/O 操做時都進行內存虛擬映射和解除。這種內存頁重映射的機制的效率嚴重受限於緩存體系結構、MMU 地址轉換速度和 TLB 命中率。若是可以避免處理 I/O 請求的虛擬地址轉換和 TLB 刷新所帶來的開銷,則有可能極大地提高 I/O 性能。而緩衝區共享就是用來解決上述問題的一種技術。

最先支持 Buffer Sharing 的操做系統是 Solaris。後來,Linux 也逐步支持了這種 Buffer Sharing 的技術,但時至今日依然不夠完整和成熟。

操做系統內核開發者們實現了一種叫 fbufs 的緩衝區共享的框架,也即快速緩衝區( Fast Buffers ),使用一個 fbuf 緩衝區做爲數據傳輸的最小單位,使用這種技術須要調用新的操做系統 API,用戶區和內核區、內核區之間的數據都必須嚴格地在 fbufs 這個體系下進行通訊。fbufs 爲每個用戶進程分配一個 buffer pool,裏面會儲存預分配 (也能夠使用的時候再分配) 好的 buffers,這些 buffers 會被同時映射到用戶內存空間和內核內存空間。fbufs 只需經過一次虛擬內存映射操做便可建立緩衝區,有效地消除那些由存儲一致性維護所引起的大多數性能損耗。

傳統的 Linux I/O 接口是經過把數據在用戶緩衝區和內核緩衝區之間進行拷貝傳輸來完成的,這種數據傳輸過程當中須要進行大量的數據拷貝,同時因爲虛擬內存技術的存在,I/O 過程當中還須要頻繁地經過 MMU 進行虛擬內存地址到物理內存地址的轉換,高速緩存的汰換以及 TLB 的刷新,這些操做均會致使性能的損耗。而若是利用 fbufs 框架來實現數據傳輸的話,首先能夠把 buffers 都緩存到 pool 裏循環利用,而不須要每次都去從新分配,並且緩存下來的不止有 buffers 自己,並且還會把虛擬內存地址到物理內存地址的映射關係也緩存下來,也就能夠避免每次都進行地址轉換,從發送接收數據的層面來講,用戶進程和 I/O 子系統好比設備驅動程序、網卡等能夠直接傳輸整個緩衝區自己而不是其中的數據內容,也能夠理解成是傳輸內存地址指針,這樣就就避免了大量的數據內容拷貝:用戶進程/ IO 子系統經過發送一個個的 fbuf 寫出數據到內核而非直接傳遞數據內容,相對應的,用戶進程/ IO 子系統經過接收一個個的 fbuf 而從內核讀入數據,這樣就能減小傳統的 read()/write() 系統調用帶來的數據拷貝開銷:

  1. 發送方用戶進程調用 uf_allocate 從本身的 buffer pool 獲取一個 fbuf 緩衝區,往其中填充內容以後調用 uf_write 向內核區發送指向 fbuf 的文件描述符;
  2. I/O 子系統接收到 fbuf 以後,調用 uf_allocb 從接收方用戶進程的 buffer pool 獲取一個 fubf 並用接收到的數據進行填充,而後向用戶區發送指向 fbuf 的文件描述符;
  3. 接收方用戶進程調用 uf_get 接收到 fbuf,讀取數據進行處理,完成以後調用 uf_deallocate 把 fbuf 放回本身的 buffer pool。

fbufs 的缺陷

共享緩衝區技術的實現須要依賴於用戶進程、操做系統內核、以及 I/O 子系統 (設備驅動程序,文件系統等)之間協同工做。好比,設計得很差的用戶進程容易就會修改已經發送出去的 fbuf 從而污染數據,更要命的是這種問題很難 debug。雖然這個技術的設計方案很是精彩,可是它的門檻和限制卻不比前面介紹的其餘技術少:首先會對操做系統 API 形成變更,須要使用新的一些 API 調用,其次還須要設備驅動程序配合改動,還有因爲是內存共享,內核須要很當心謹慎地實現對這部分共享的內存進行數據保護和同步的機制,而這種併發的同步機制是很是容易出 bug 的從而又增長了內核的代碼複雜度,等等。所以這一類的技術還遠遠沒有到發展成熟和普遍應用的階段,目前大多數的實現都還處於實驗階段。

總結

本文中我主要講解了 Linux I/O 底層原理,而後介紹並解析了 Linux 中的 Zero-copy 技術,並給出了 Linux 對 I/O 模塊的優化和改進思路。

Linux 的 Zero-copy 技術能夠概括成如下三大類:

  • 減小甚至避免用戶空間和內核空間之間的數據拷貝:在一些場景下,用戶進程在數據傳輸過程當中並不須要對數據進行訪問和處理,那麼數據在 Linux 的 Page Cache 和用戶進程的緩衝區之間的傳輸就徹底能夠避免,讓數據拷貝徹底在內核裏進行,甚至能夠經過更巧妙的方式避免在內核裏的數據拷貝。這一類實現通常是是經過增長新的系統調用來完成的,好比 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 繞過內核的直接 I/O:容許在用戶態進程繞過內核直接和硬件進行數據傳輸,內核在傳輸過程當中只負責一些管理和輔助的工做。這種方式其實和第一種有點相似,也是試圖避免用戶空間和內核空間之間的數據傳輸,只是第一種方式是把數據傳輸過程放在內核態完成,而這種方式則是直接繞過內核和硬件通訊,效果相似但原理徹底不一樣。
  • 內核緩衝區和用戶緩衝區之間的傳輸優化:這種方式側重於在用戶進程的緩衝區和操做系統的頁緩存之間的 CPU 拷貝的優化。這種方法延續了以往那種傳統的通訊方式,但更靈活。

本文從虛擬內存、I/O 緩衝區,用戶態&內核態以及 I/O 模式等等知識點全面而又詳盡地剖析了 Linux 系統的 I/O 底層原理,分析了 Linux 傳統的 I/O 模式的弊端,進而引入 Linux Zero-copy 零拷貝技術的介紹和原理解析,經過將零拷貝技術和傳統的 I/O 模式進行區分和對比,帶領讀者經歷了 Linux I/O 的演化歷史,經過幫助讀者理解 Linux 內核對 I/O 模塊的優化改進思路,相信不只僅是讓讀者瞭解 Linux 底層系統的設計原理,更能對讀者們在之後優化改進本身的程序設計過程當中可以有所啓發。

參考&延伸閱讀

相關文章
相關標籤/搜索