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

導言

如今的網絡應用早已從 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,而問題是訪問速度則比主存慢了大概三個數量級。機械硬盤速度慢主要是因爲機械臂需要不斷在金屬盤片之間移動,等待磁盤扇區旋轉至磁頭之下,然後才能進行讀寫操作,因此效率很低。

物理內存

我們平時一直提及的物理內存就是上文中對應的第三種計算機存儲器,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 運行時則兩個寄存器又會分別存入 32768 和 16384。然後每次訪問指定的內存地址時,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)。

通過上面的分析,我們可以得出 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 會通過頁面置換算法如 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,則重複第 2 步到第 6 步,一直到 WC 裏的字節數等於 0;

  7. 完成拷貝之後磁盤控制器會通過總線發送一箇中斷信號到中斷控制器,如果此時中斷控制器手頭還有正在處理的中斷或者有一個和該中斷信號同時到達的更高優先級的中斷,則這個中斷信號將被忽略,而磁盤控制器會在後面持續發送中斷信號直至中斷控制器受理;

  8. 中斷控制器收到磁盤控制器的中斷信號之後會通過地址總線存入一個磁盤設備的編號,表示是哪個設備需要被關注;

  9. CPU 收到中斷信號之後停止當前的工作,把當前的 PC/PSW 等寄存器壓入堆棧保存現場,然後從地址總線取出設備編號,通過編號找到中斷向量所包含的中斷服務的入口地址,壓入 PC 寄存器,開始運行磁盤中斷服務,把數據從磁盤控制器的緩衝區拷貝到主存裏的內核緩衝區;

  10. 最後 CPU 再把數據從內核緩衝區拷貝到用戶緩衝區,完成讀取操作,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 能做什麼?

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

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

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

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

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() 是基於 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 技術可以歸納成以下三大類:

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

參考 & 延伸閱讀

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/b3J1E-o9cElvIOH4E2BBSw