https://m.toutiao.com/is/Jx4qMgr/
作者:allanpan,騰訊 IEG 后臺開發(fā)工程師
三萬字長文從虛擬內(nèi)存、I/O 緩沖區(qū),用戶態(tài)&內(nèi)核態(tài)以及 I/O 模式等等知識點全面而又詳盡地剖析 Linux 系統(tǒng)的 I/O 底層原理,分析了 Linux 傳統(tǒng)的 I/O 模式的弊端,進而引入 Linux Zero-copy 零拷貝技術(shù)的介紹和原理解析,將零拷貝技術(shù)和傳統(tǒng)的 I/O 模式進行區(qū)分和對比,幫助讀者理解 Linux 內(nèi)核對 I/O 模塊的優(yōu)化改進思路。全網(wǎng)最深度和詳盡的 Linux I/O 及零拷貝技術(shù)的解析文章
如今的網(wǎng)絡(luò)應(yīng)用早已從 CPU 密集型轉(zhuǎn)向了 I/O 密集型,網(wǎng)絡(luò)服務(wù)器大多是基于 C-S 模型,也即 客戶端 - 服務(wù)端 模型,客戶端需要和服務(wù)端進行大量的網(wǎng)絡(luò)通信,這也決定了現(xiàn)代網(wǎng)絡(luò)應(yīng)用的性能瓶頸:I/O。
傳統(tǒng)的 Linux 操作系統(tǒng)的標準 I/O 接口是基于數(shù)據(jù)拷貝操作的,即 I/O 操作會導致數(shù)據(jù)在操作系統(tǒng)內(nèi)核地址空間的緩沖區(qū)和用戶進程地址空間定義的緩沖區(qū)之間進行傳輸。設(shè)置緩沖區(qū)最大的好處是可以減少磁盤 I/O 的操作,如果所請求的數(shù)據(jù)已經(jīng)存放在操作系統(tǒng)的高速緩沖存儲器中,那么就不需要再進行實際的物理磁盤 I/O 操作;然而傳統(tǒng)的 Linux I/O 在數(shù)據(jù)傳輸過程中的數(shù)據(jù)拷貝操作深度依賴 CPU,也就是說 I/O 過程需要 CPU 去執(zhí)行數(shù)據(jù)拷貝的操作,因此導致了極大的系統(tǒng)開銷,限制了操作系統(tǒng)有效進行數(shù)據(jù)傳輸操作的能力。
I/O 是決定網(wǎng)絡(luò)服務(wù)器性能瓶頸的關(guān)鍵,而傳統(tǒng)的 Linux I/O 機制又會導致大量的數(shù)據(jù)拷貝操作,損耗性能,所以我們亟需一種新的技術(shù)來解決數(shù)據(jù)大量拷貝的問題,這個答案就是零拷貝(Zero-copy)。
既然要分析 Linux I/O,就不能不了解計算機的各類存儲器。
存儲器是計算機的核心部件之一,在完全理想的狀態(tài)下,存儲器應(yīng)該要同時具備以下三種特性:
但是現(xiàn)實往往是殘酷的,我們目前的計算機技術(shù)無法同時滿足上述的三個條件,于是現(xiàn)代計算機的存儲器設(shè)計采用了一種分層次的結(jié)構(gòu):
從頂至底,現(xiàn)代計算機里的存儲器類型分別有:寄存器、高速緩存、主存和磁盤,這些存儲器的速度逐級遞減而容量逐級遞增。存取速度最快的是寄存器,因為寄存器的制作材料和 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 則根據(jù)不同的架構(gòu)設(shè)計會被設(shè)計成獨享或者共享兩種模式之一,比如 Intel 的多核芯片采用的是共享 L2 模式而 AMD 的多核芯片則采用的是獨享 L2 模式。
第三層則是主存,也即主內(nèi)存,通常稱作隨機訪問存儲器(Random Access Memory, RAM)。是與 CPU 直接交換數(shù)據(jù)的內(nèi)部存儲器。它可以隨時讀寫(刷新時除外),而且速度很快,通常作為操作系統(tǒng)或其他正在運行中的程序的臨時資料存儲介質(zhì)。
最后則是磁盤,磁盤和主存相比,每個二進制位的成本低了兩個數(shù)量級,因此容量比之會大得多,動輒上 GB、TB,而問題是訪問速度則比主存慢了大概三個數(shù)量級。機械硬盤速度慢主要是因為機械臂需要不斷在金屬盤片之間移動,等待磁盤扇區(qū)旋轉(zhuǎn)至磁頭之下,然后才能進行讀寫操作,因此效率很低。
主內(nèi)存是操作系統(tǒng)進行 I/O 操作的重中之重,絕大部分的工作都是在用戶進程和內(nèi)核的內(nèi)存緩沖區(qū)里完成的,因此我們接下來需要提前學習一些主存的相關(guān)原理。
我們平時一直提及的物理內(nèi)存就是上文中對應(yīng)的第三種計算機存儲器,RAM 主存,它在計算機中以內(nèi)存條的形式存在,嵌在主板的內(nèi)存槽上,用來加載各式各樣的程序與數(shù)據(jù)以供 CPU 直接運行和使用。
在計算機領(lǐng)域有一句如同摩西十誡般神圣的哲言:'計算機科學領(lǐng)域的任何問題都可以通過增加一個間接的中間層來解決',從內(nèi)存管理、網(wǎng)絡(luò)模型、并發(fā)調(diào)度甚至是硬件架構(gòu),都能看到這句哲言在閃爍著光芒,而虛擬內(nèi)存則是這一哲言的完美實踐之一。
虛擬內(nèi)存是現(xiàn)代計算機中的一個非常重要的存儲器抽象,主要是用來解決應(yīng)用程序日益增長的內(nèi)存使用需求:現(xiàn)代物理內(nèi)存的容量增長已經(jīng)非常快速了,然而還是跟不上應(yīng)用程序?qū)χ鞔嫘枨蟮脑鲩L速度,對于應(yīng)用程序來說內(nèi)存還是不夠用,因此便需要一種方法來解決這兩者之間的容量差矛盾。
計算機對多程序內(nèi)存訪問的管理經(jīng)歷了 靜態(tài)重定位 --> 動態(tài)重定位 --> 交換(swapping)技術(shù) --> 虛擬內(nèi)存,最原始的多程序內(nèi)存訪問是直接訪問絕對內(nèi)存地址,這種方式幾乎是完全不可用的方案,因為如果每一個程序都直接訪問物理內(nèi)存地址的話,比如兩個程序并發(fā)執(zhí)行以下指令的時候:
mov cx, 2mov bx, 1000Hmov ds, bxmov [0], cx...mov ax, [0]add ax, ax
這一段匯編表示在地址 1000:0 處存入數(shù)值 2,然后在后面的邏輯中把該地址的值取出來乘以 2,最終存入 ax 寄存器的值就是 4,如果第二個程序存入 cx 寄存器里的值是 3,那么并發(fā)執(zhí)行的時候,第一個程序最終從 ax 寄存器里得到的值就可能是 6,這就完全錯誤了,得到臟數(shù)據(jù)還頂多算程序結(jié)果錯誤,要是其他程序往特定的地址里寫入一些危險的指令而被另一個程序取出來執(zhí)行,還可能會導致整個系統(tǒng)的崩潰。所以,為了確保進程間互不干擾,每一個用戶進程都需要實時知曉當前其他進程在使用哪些內(nèi)存地址,這對于寫程序的人來說無疑是一場噩夢。
因此,操作絕對內(nèi)存地址是完全不可行的方案,那就只能用操作相對內(nèi)存地址,我們知道每個進程都會有自己的進程地址,從 0 開始,可以通過相對地址來訪問內(nèi)存,但是這同樣有問題,還是前面類似的問題,比如有兩個大小為 16KB 的程序 A 和 B,現(xiàn)在它們都被加載進了內(nèi)存,內(nèi)存地址段分別是 0 ~ 16384,16384 ~ 32768。A 的第一條指令是 jmp 1024,而在地址 1024 處是一條 mov 指令,下一條指令是 add,基于前面的 mov 指令做加法運算,與此同時,B 的第一條指令是 jmp 1028,本來在 B 的相對地址 1028 處應(yīng)該也是一條 mov 去操作自己的內(nèi)存地址上的值,但是由于這兩個程序共享了段寄存器,因此雖然他們使用了各自的相對地址,但是依然操作的還是絕對內(nèi)存地址,于是 B 就會跳去執(zhí)行 add 指令,這時候就會因為非法的內(nèi)存操作而 crash。
有一種靜態(tài)重定位的技術(shù)可以解決這個問題,它的工作原理非常簡單粗暴:當 B 程序被加載到地址 16384 處之后,把 B 的所有相對內(nèi)存地址都加上 16384,這樣的話當 B 執(zhí)行 jmp 1028 之時,其實執(zhí)行的是 jmp 1028+16384,就可以跳轉(zhuǎn)到正確的內(nèi)存地址處去執(zhí)行正確的指令了,但是這種技術(shù)并不通用,而且還會對程序裝載進內(nèi)存的性能有影響。
再往后,就發(fā)展出來了存儲器抽象:地址空間,就好像進程是 CPU 的抽象,地址空間則是存儲器的抽象,每個進程都會分配獨享的地址空間,但是獨享的地址空間又帶來了新的問題:如何實現(xiàn)不同進程的相同相對地址指向不同的物理地址?最開始是使用動態(tài)重定位技術(shù)來實現(xiàn),這是用一種相對簡單的地址空間到物理內(nèi)存的映射方法。
基本原理就是為每一個 CPU 配備兩個特殊的硬件寄存器:基址寄存器和界限寄存器,用來動態(tài)保存每一個程序的起始物理內(nèi)存地址和長度,比如前文中的 A,B 兩個程序,當 A 運行時基址寄存器和界限寄存器就會分別存入 0 和 16384,而當 B 運行時則兩個寄存器又會分別存入 16384 和 32768。然后每次訪問指定的內(nèi)存地址時,CPU 會在把地址發(fā)往內(nèi)存總線之前自動把基址寄存器里的值加到該內(nèi)存地址上,得到一個真正的物理內(nèi)存地址,同時還會根據(jù)界限寄存器里的值檢查該地址是否溢出,若是,則產(chǎn)生錯誤中止程序,動態(tài)重定位技術(shù)解決了靜態(tài)重定位技術(shù)造成的程序裝載速度慢的問題,但是也有新問題:每次訪問內(nèi)存都需要進行加法和比較運算,比較運算本身可以很快,但是加法運算由于進位傳遞時間的問題,除非使用特殊的電路,否則會比較慢。
然后就是 交換(swapping)技術(shù),這種技術(shù)簡單來說就是動態(tài)地把程序在內(nèi)存和磁盤之間進行交換保存,要運行一個進程的時候就把程序的代碼段和數(shù)據(jù)段調(diào)入內(nèi)存,然后再把程序封存,存入磁盤,如此反復。為什么要這么麻煩?因為前面那兩種重定位技術(shù)的前提條件是計算機內(nèi)存足夠大,能夠把所有要運行的進程地址空間都加載進主存,才能夠并發(fā)運行這些進程,但是現(xiàn)實往往不是如此,內(nèi)存的大小總是有限的,所有就需要另一類方法來處理內(nèi)存超載的情況,第一種便是簡單的交換技術(shù):
先把進程 A 換入內(nèi)存,然后啟動進程 B 和 C,也換入內(nèi)存,接著 A 被從內(nèi)存交換到磁盤,然后又有新的進程 D 調(diào)入內(nèi)存,用了 A 退出之后空出來的內(nèi)存空間,最后 A 又被重新?lián)Q入內(nèi)存,由于內(nèi)存布局已經(jīng)發(fā)生了變化,所以 A 在換入內(nèi)存之時會通過軟件或者在運行期間通過硬件(基址寄存器和界限寄存器)對其內(nèi)存地址進行重定位,多數(shù)情況下都是通過硬件。
另一種處理內(nèi)存超載的技術(shù)就是虛擬內(nèi)存技術(shù)了,它比交換(swapping)技術(shù)更復雜而又更高效,是目前最新應(yīng)用最廣泛的存儲器抽象技術(shù):
虛擬內(nèi)存的核心原理是:為每個程序設(shè)置一段'連續(xù)'的虛擬地址空間,把這個地址空間分割成多個具有連續(xù)地址范圍的頁 (page),并把這些頁和物理內(nèi)存做映射,在程序運行期間動態(tài)映射到物理內(nèi)存。當程序引用到一段在物理內(nèi)存的地址空間時,由硬件立刻執(zhí)行必要的映射;而當程序引用到一段不在物理內(nèi)存中的地址空間時,由操作系統(tǒng)負責將缺失的部分裝入物理內(nèi)存并重新執(zhí)行失敗的指令:
虛擬地址空間按照固定大小劃分成被稱為頁(page)的若干單元,物理內(nèi)存中對應(yīng)的則是頁框(page frame)。這兩者一般來說是一樣的大小,如上圖中的是 4KB,不過實際上計算機系統(tǒng)中一般是 512 字節(jié)到 1 GB,這就是虛擬內(nèi)存的分頁技術(shù)。因為是虛擬內(nèi)存空間,每個進程分配的大小是 4GB (32 位架構(gòu)),而實際上當然不可能給所有在運行中的進程都分配 4GB 的物理內(nèi)存,所以虛擬內(nèi)存技術(shù)還需要利用到前面介紹的交換(swapping)技術(shù),在進程運行期間只分配映射當前使用到的內(nèi)存,暫時不使用的數(shù)據(jù)則寫回磁盤作為副本保存,需要用的時候再讀入內(nèi)存,動態(tài)地在磁盤和內(nèi)存之間交換數(shù)據(jù)。
其實虛擬內(nèi)存技術(shù)從某種角度來看的話,很像是糅合了基址寄存器和界限寄存器之后的新技術(shù)。它使得整個進程的地址空間可以通過較小的單元映射到物理內(nèi)存,而不需要為程序的代碼和數(shù)據(jù)地址進行重定位。
進程在運行期間產(chǎn)生的內(nèi)存地址都是虛擬地址,如果計算機沒有引入虛擬內(nèi)存這種存儲器抽象技術(shù)的話,則 CPU 會把這些地址直接發(fā)送到內(nèi)存地址總線上,直接訪問和虛擬地址相同值的物理地址;如果使用虛擬內(nèi)存技術(shù)的話,CPU 則是把這些虛擬地址通過地址總線送到內(nèi)存管理單元(Memory Management Unit,MMU),MMU 將虛擬地址映射為物理地址之后再通過內(nèi)存總線去訪問物理內(nèi)存:
虛擬地址(比如 16 位地址 8196=0010 000000000100)分為兩部分:虛擬頁號(高位部分)和偏移量(低位部分),虛擬地址轉(zhuǎn)換成物理地址是通過頁表(page table)來實現(xiàn)的,頁表由頁表項構(gòu)成,頁表項中保存了頁框號、修改位、訪問位、保護位和 '在/不在' 位等信息,從數(shù)學角度來說頁表就是一個函數(shù),入?yún)⑹翘摂M頁號,輸出是物理頁框號,得到物理頁框號之后復制到寄存器的高三位中,最后直接把 12 位的偏移量復制到寄存器的末 12 位構(gòu)成 15 位的物理地址,即可以把該寄存器的存儲的物理內(nèi)存地址發(fā)送到內(nèi)存總線:
在 MMU 進行地址轉(zhuǎn)換時,如果頁表項的 '在/不在' 位是 0,則表示該頁面并沒有映射到真實的物理頁框,則會引發(fā)一個缺頁中斷,CPU 陷入操作系統(tǒng)內(nèi)核,接著操作系統(tǒng)就會通過頁面置換算法選擇一個頁面將其換出 (swap),以便為即將調(diào)入的新頁面騰出位置,如果要換出的頁面的頁表項里的修改位已經(jīng)被設(shè)置過,也就是被更新過,則這是一個臟頁 (dirty page),需要寫回磁盤更新改頁面在磁盤上的副本,如果該頁面是'干凈'的,也就是沒有被修改過,則直接用調(diào)入的新頁面覆蓋掉被換出的舊頁面即可。
最后,還需要了解的一個概念是轉(zhuǎn)換檢測緩沖器(Translation Lookaside Buffer,TLB),也叫快表,是用來加速虛擬地址映射的,因為虛擬內(nèi)存的分頁機制,頁表一般是保存內(nèi)存中的一塊固定的存儲區(qū),導致進程通過 MMU 訪問內(nèi)存比直接訪問內(nèi)存多了一次內(nèi)存訪問,性能至少下降一半,因此需要引入加速機制,即 TLB 快表,TLB 可以簡單地理解成頁表的高速緩存,保存了最高頻被訪問的頁表項,由于一般是硬件實現(xiàn)的,因此速度極快,MMU 收到虛擬地址時一般會先通過硬件 TLB 查詢對應(yīng)的頁表號,若命中且該頁表項的訪問操作合法,則直接從 TLB 取出對應(yīng)的物理頁框號返回,若不命中則穿透到內(nèi)存頁表里查詢,并且會用這個從內(nèi)存頁表里查詢到最新頁表項替換到現(xiàn)有 TLB 里的其中一個,以備下次緩存命中。
至此,我們介紹完了包含虛擬內(nèi)存在內(nèi)的多項計算機存儲器抽象技術(shù),虛擬內(nèi)存的其他內(nèi)容比如針對大內(nèi)存的多級頁表、倒排頁表,以及處理缺頁中斷的頁面置換算法等等,以后有機會再單獨寫一篇文章介紹,或者各位讀者也可以先行去查閱相關(guān)資料了解,這里就不再深入了。
一般來說,我們在編寫程序操作 Linux I/O 之時十有八九是在用戶空間和內(nèi)核空間之間傳輸數(shù)據(jù),因此有必要先了解一下 Linux 的用戶態(tài)和內(nèi)核態(tài)的概念。
首先是用戶態(tài)和內(nèi)核態(tài):
從宏觀上來看,Linux 操作系統(tǒng)的體系架構(gòu)分為用戶態(tài)和內(nèi)核態(tài)(或者用戶空間和內(nèi)核)。內(nèi)核從本質(zhì)上看是一種軟件 —— 控制計算機的硬件資源,并提供上層應(yīng)用程序 (進程) 運行的環(huán)境。用戶態(tài)即上層應(yīng)用程序 (進程) 的運行空間,應(yīng)用程序 (進程) 的執(zhí)行必須依托于內(nèi)核提供的資源,這其中包括但不限于 CPU 資源、存儲資源、I/O 資源等等。
現(xiàn)代操作系統(tǒng)都是采用虛擬存儲器,那么對 32 位操作系統(tǒng)而言,它的尋址空間(虛擬存儲空間)為 2^32 B = 4G。操作系統(tǒng)的核心是內(nèi)核,獨立于普通的應(yīng)用程序,可以訪問受保護的內(nèi)存空間,也有訪問底層硬件設(shè)備的所有權(quán)限。為了保證用戶進程不能直接操作內(nèi)核(kernel),保證內(nèi)核的安全,操心系統(tǒng)將虛擬空間劃分為兩部分,一部分為內(nèi)核空間,一部分為用戶空間。針對 Linux 操作系統(tǒng)而言,將最高的 1G 字節(jié)(從虛擬地址 0xC0000000 到 0xFFFFFFFF),供內(nèi)核使用,稱為內(nèi)核空間,而將較低的 3G 字節(jié)(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個進程使用,稱為用戶空間。
因為操作系統(tǒng)的資源是有限的,如果訪問資源的操作過多,必然會消耗過多的系統(tǒng)資源,而且如果不對這些操作加以區(qū)分,很可能造成資源訪問的沖突。所以,為了減少有限資源的訪問和使用沖突,Unix/Linux 的設(shè)計哲學之一就是:對不同的操作賦予不同的執(zhí)行等級,就是所謂特權(quán)的概念。簡單說就是有多大能力做多大的事,與系統(tǒng)相關(guān)的一些特別關(guān)鍵的操作必須由最高特權(quán)的程序來完成。Intel 的 x86 架構(gòu)的 CPU 提供了 0 到 3 四個特權(quán)級,數(shù)字越小,特權(quán)越高,Linux 操作系統(tǒng)中主要采用了 0 和 3 兩個特權(quán)級,分別對應(yīng)的就是內(nèi)核態(tài)和用戶態(tài)。
運行于用戶態(tài)的進程可以執(zhí)行的操作和訪問的資源都會受到極大的限制,而運行在內(nèi)核態(tài)的進程則可以執(zhí)行任何操作并且在資源的使用上沒有限制。很多程序開始時運行于用戶態(tài),但在執(zhí)行的過程中,一些操作需要在內(nèi)核權(quán)限下才能執(zhí)行,這就涉及到一個從用戶態(tài)切換到內(nèi)核態(tài)的過程。比如 C 函數(shù)庫中的內(nèi)存分配函數(shù) malloc(),它具體是使用 sbrk() 系統(tǒng)調(diào)用來分配內(nèi)存,當 malloc 調(diào)用 sbrk() 的時候就涉及一次從用戶態(tài)到內(nèi)核態(tài)的切換,類似的函數(shù)還有 printf(),調(diào)用的是 wirte() 系統(tǒng)調(diào)用來輸出字符串,等等。
用戶進程在系統(tǒng)中運行時,大部分時間是處在用戶態(tài)空間里的,在其需要操作系統(tǒng)幫助完成一些用戶態(tài)沒有特權(quán)和能力完成的操作時就需要切換到內(nèi)核態(tài)。那么用戶進程如何切換到內(nèi)核態(tài)去使用那些內(nèi)核資源呢?答案是:1) 系統(tǒng)調(diào)用(trap),2) 異常(exception)和 3) 中斷(interrupt)。
通過上面的分析,我們可以得出 Linux 的內(nèi)部層級可分為三大部分:
在 Linux 中,當程序調(diào)用各類文件操作函數(shù)后,用戶數(shù)據(jù)(User Data)到達磁盤(Disk)的流程如上圖所示。
圖中描述了 Linux 中文件操作函數(shù)的層級關(guān)系和內(nèi)存緩存層的存在位置,中間的黑色實線是用戶態(tài)和內(nèi)核態(tài)的分界線。
read(2)/write(2) 是 Linux 系統(tǒng)中最基本的 I/O 讀寫系統(tǒng)調(diào)用,我們開發(fā)操作 I/O 的程序時必定會接觸到它們,而在這兩個系統(tǒng)調(diào)用和真實的磁盤讀寫之間存在一層稱為 Kernel buffer cache 的緩沖區(qū)緩存。在 Linux 中 I/O 緩存其實可以細分為兩個:Page Cache 和 Buffer Cache,這兩個其實是一體兩面,共同組成了 Linux 的內(nèi)核緩沖區(qū)(Kernel Buffer Cache):
Page Cache 會通過頁面置換算法如 LRU 定期淘汰舊的頁面,加載新的頁面??梢钥闯?,所謂 I/O 緩沖區(qū)緩存就是在內(nèi)核和磁盤、網(wǎng)卡等外設(shè)之間的一層緩沖區(qū),用來提升讀寫性能的。
在 Linux 還不支持虛擬內(nèi)存技術(shù)之前,還沒有頁的概念,因此 Buffer Cache 是基于操作系統(tǒng)讀寫磁盤的最小單位 -- 塊(block)來進行的,所有的磁盤塊操作都是通過 Buffer Cache 來加速,Linux 引入虛擬內(nèi)存的機制來管理內(nèi)存后,頁成為虛擬內(nèi)存管理的最小單位,因此也引入了 Page Cache 來緩存 Linux 文件內(nèi)容,主要用來作為文件系統(tǒng)上的文件數(shù)據(jù)的緩存,提升讀寫性能,常見的是針對文件的 read()/write() 操作,另外也包括了通過 mmap() 映射之后的塊設(shè)備,也就是說,事實上 Page Cache 負責了大部分的塊設(shè)備文件的緩存工作。而 Buffer Cache 用來在系統(tǒng)對塊設(shè)備進行讀寫的時候,對塊進行數(shù)據(jù)緩存的系統(tǒng)來使用,實際上負責所有對磁盤的 I/O 訪問:
因為 Buffer Cache 是對粒度更細的設(shè)備塊的緩存,而 Page Cache 是基于虛擬內(nèi)存的頁單元緩存,因此還是會基于 Buffer Cache,也就是說如果是緩存文件內(nèi)容數(shù)據(jù)就會在內(nèi)存里緩存兩份相同的數(shù)據(jù),這就會導致同一份文件保存了兩份,冗余且低效。另外一個問題是,調(diào)用 write 后,有效數(shù)據(jù)是在 Buffer Cache 中,而非 Page Cache 中。這就導致 mmap訪問的文件數(shù)據(jù)可能存在不一致問題。為了規(guī)避這個問題,所有基于磁盤文件系統(tǒng)的 write,都需要調(diào)用 update_vm_cache() 函數(shù),該操作會把調(diào)用 write 之后的 Buffer Cache 更新到 Page Cache 去。由于有這些設(shè)計上的弊端,因此在 Linux 2.4 版本之后,kernel 就將兩者進行了統(tǒng)一,Buffer Cache 不再以獨立的形式存在,而是以融合的方式存在于 Page Cache 中:
融合之后就可以統(tǒng)一操作 Page Cache 和 Buffer Cache:處理文件 I/O 緩存交給 Page Cache,而當?shù)讓?RAW device 刷新數(shù)據(jù)時以 Buffer Cache 的塊單位來實際處理。
在 Linux 或者其他 Unix-like 操作系統(tǒng)里,I/O 模式一般有三種:
下面我分別詳細地講解一下這三種 I/O 模式。
這是最簡單的一種 I/O 模式,也叫忙等待或者輪詢:用戶通過發(fā)起一個系統(tǒng)調(diào)用,陷入內(nèi)核態(tài),內(nèi)核將系統(tǒng)調(diào)用翻譯成一個對應(yīng)設(shè)備驅(qū)動程序的過程調(diào)用,接著設(shè)備驅(qū)動程序會啟動 I/O 不斷循環(huán)去檢查該設(shè)備,看看是否已經(jīng)就緒,一般通過返回碼來表示,I/O 結(jié)束之后,設(shè)備驅(qū)動程序會把數(shù)據(jù)送到指定的地方并返回,切回用戶態(tài)。
比如發(fā)起系統(tǒng)調(diào)用 read():
第二種 I/O 模式是利用中斷來實現(xiàn)的:
流程如下:
并發(fā)系統(tǒng)的性能高低究其根本,是取決于如何對 CPU 資源的高效調(diào)度和使用,而回頭看前面的中斷驅(qū)動 I/O 模式的流程,可以發(fā)現(xiàn)第 6、7 步的數(shù)據(jù)拷貝工作都是由 CPU 親自完成的,也就是在這兩次數(shù)據(jù)拷貝階段中 CPU 是完全被占用而不能處理其他工作的,那么這里明顯是有優(yōu)化空間的;第 7 步的數(shù)據(jù)拷貝是從內(nèi)核緩沖區(qū)到用戶緩沖區(qū),都是在主存里,所以這一步只能由 CPU 親自完成,但是第 6 步的數(shù)據(jù)拷貝,是從磁盤控制器的緩沖區(qū)到主存,是兩個設(shè)備之間的數(shù)據(jù)傳輸,這一步并非一定要 CPU 來完成,可以借助 DMA 來完成,減輕 CPU 的負擔。
DMA 全稱是 Direct Memory Access,也即直接存儲器存取,是一種用來提供在外設(shè)和存儲器之間或者存儲器和存儲器之間的高速數(shù)據(jù)傳輸。整個過程無須 CPU 參與,數(shù)據(jù)直接通過 DMA 控制器進行快速地移動拷貝,節(jié)省 CPU 的資源去做其他工作。
目前,大部分的計算機都配備了 DMA 控制器,而 DMA 技術(shù)也支持大部分的外設(shè)和存儲器。借助于 DMA 機制,計算機的 I/O 過程就能更加高效:
DMA 控制器內(nèi)部包含若干個可以被 CPU 讀寫的寄存器:一個主存地址寄存器 MAR(存放要交換數(shù)據(jù)的主存地址)、一個外設(shè)地址寄存器 ADR(存放 I/O 設(shè)備的設(shè)備碼,或者是設(shè)備信息存儲區(qū)的尋址信息)、一個字節(jié)數(shù)寄存器 WC(對傳送數(shù)據(jù)的總字數(shù)進行統(tǒng)計)、和一個或多個控制寄存器。
Linux 中傳統(tǒng)的 I/O 讀寫是通過 read()/write() 系統(tǒng)調(diào)用完成的,read() 把數(shù)據(jù)從存儲器 (磁盤、網(wǎng)卡等) 讀取到用戶緩沖區(qū),write() 則是把數(shù)據(jù)從用戶緩沖區(qū)寫出到存儲器:
#include <unistd.h>ssize_t read(int fd, void *buf, size_t count);ssize_t write(int fd, const void *buf, size_t count);
一次完整的讀磁盤文件然后寫出到網(wǎng)卡的底層傳輸過程如下:
可以清楚看到這里一共觸發(fā)了 4 次用戶態(tài)和內(nèi)核態(tài)的上下文切換,分別是 read()/write() 調(diào)用和返回時的切換,2 次 DMA 拷貝,2 次 CPU 拷貝,加起來一共 4 次拷貝操作。
通過引入 DMA,我們已經(jīng)把 Linux 的 I/O 過程中的 CPU 拷貝次數(shù)從 4 次減少到了 2 次,但是 CPU 拷貝依然是代價很大的操作,對系統(tǒng)性能的影響還是很大,特別是那些頻繁 I/O 的場景,更是會因為 CPU 拷貝而損失掉很多性能,我們需要進一步優(yōu)化,降低、甚至是完全避免 CPU 拷貝。
本文中我主要講解了 Linux I/O 底層原理,下篇將介紹并解析 Linux 中的 Zero-copy 技術(shù),并給出了 Linux 對 I/O 模塊的優(yōu)化和改進思路。