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

panjf2000發表於2020-12-28

部落格原文

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

導言

如今的網路應用早已從 CPU 密集型轉向了 I/O 密集型,網路伺服器大多是基於 C-S 模型,也即 客戶端 - 服務端 模型,客戶端需要和服務端進行大量的網路通訊,這也決定了現代網路應用的效能瓶頸:I/O。

傳統的 Linux 作業系統的標準 I/O 介面是基於資料拷貝操作的,即 I/O 操作會導致資料在作業系統核心地址空間的緩衝區和使用者程式地址空間定義的緩衝區之間進行傳輸。設定緩衝區最大的好處是可以減少磁碟 I/O 的操作,如果所請求的資料已經存放在作業系統的高速緩衝儲存器中,那麼就不需要再進行實際的物理磁碟 I/O 操作;然而傳統的 Linux I/O 在資料傳輸過程中的資料拷貝操作深度依賴 CPU,也就是說 I/O 過程需要 CPU 去執行資料拷貝的操作,因此導致了極大的系統開銷,限制了作業系統有效進行資料傳輸操作的能力。

I/O 是決定網路伺服器效能瓶頸的關鍵,而傳統的 Linux I/O 機制又會導致大量的資料拷貝操作,損耗效能,所以我們亟需一種新的技術來解決資料大量拷貝的問題,這個答案就是零拷貝(Zero-copy)。

計算機儲存器

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

儲存器是計算機的核心部件之一,在完全理想的狀態下,儲存器應該要同時具備以下三種特性:

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

但是現實往往是殘酷的,我們目前的計算機技術無法同時滿足上述的三個條件,於是現代計算機的儲存器設計採用了一種分層次的結構:

從頂至底,現代計算機裡的儲存器型別分別有:暫存器、快取記憶體、主存和磁碟,這些儲存器的速度逐級遞減而容量逐級遞增。存取速度最快的是暫存器,因為暫存器的製作材料和 CPU 是相同的,所以速度和 CPU 一樣快,CPU 訪問暫存器是沒有時延的,然而因為價格昂貴,因此容量也極小,一般 32 位的 CPU 配備的暫存器容量是 32✖️32 Bit,64 位的 CPU 則是 64✖️64 Bit,不管是 32 位還是 64 位,暫存器容量都小於 1 KB,且暫存器也必須通過軟體自行管理。

第二層是快取記憶體,也即我們平時瞭解的 CPU 快取記憶體 L1、L2、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 模式的流程,可以發現第 6、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 底層系統的設計原理,更能對讀者們在以後優化改進自己的程式設計過程中能夠有所啟發。

參考&延伸閱讀

相關文章