從Linux零拷貝深入了解Linux-I/O
導言 | 本文邀請到騰訊CSIG后臺開發(fā)工程師kevineluo從文件傳輸場景以及零拷貝技術深究Linux I/O的發(fā)展過程、優(yōu)化手段以及實際應用。I/O相關的各類優(yōu)化已經深入到了日常開發(fā)者接觸到的語言、中間件以及數據庫的方方面面。通過了解和學習相關技術和思想,開發(fā)者能對日后自己的程序設計以及性能優(yōu)化上有所啟發(fā)。
前言存儲器是計算機的核心部件之一,在完全理想的狀態(tài)下,存儲器應該要同時具備以下三種特性:第一,速度足夠快:存儲器的存取速度應當快于CPU執(zhí)行一條指令,這樣CPU的效率才不會受限于存儲器;第二,容量足夠大:容量能夠存儲計算機所需的全部數據;第三,價格足夠便宜:價格低廉,所有類型的計算機都能配備。
但是現實往往是殘酷的,我們目前的計算機技術無法同時滿足上述的三個條件,于是現代計算機的存儲器設計采用了一種分層次的結構:

從頂至底,現代計算機里的存儲器類型分別有:寄存器、高速緩存、主存和磁盤,這些存儲器的速度逐級遞減而容量逐級遞增。
存取速度最快的是寄存器,因為寄存器的制作材料和CPU是相同的,所以速度和CPU一樣快,CPU訪問寄存器是沒有時延的,然而因為價格昂貴,因此容量也極小,一般32位的CPU配備的寄存器容量是32??32Bit,64位的 CPU則是64??64Bit,不管是32位還是64位,寄存器容量都小于1KB,且寄存器也必須通過軟件自行管理。
第二層是高速緩存,也即我們平時了解的CPU高速緩存L1、L2、L3,一般 L1是每個CPU獨享,L3是全部CPU共享,而L2則根據不同的架構設計會被設計成獨享或者共享兩種模式之一,比如Intel的多核芯片采用的是共享L2模式而AMD的多核芯片則采用的是獨享L2模式。
第三層則是主存,也即主內存,通常稱作隨機訪問存儲器(Random Access Memory,RAM)。是與CPU直接交換數據的內部存儲器。它可以隨時讀寫(刷新時除外),而且速度很快,通常作為操作系統或其他正在運行中的程序的臨時資料存儲介質。
至于磁盤則是圖中離用戶最遠的一層了,讀寫速度相差內存上百倍;另一方面自然針對磁盤操作的優(yōu)化也非常多,如零拷貝、direct I/O、異步I/O等等,這些優(yōu)化的目的都是為了提高系統的吞吐量;另外操作系統內核中也有磁盤高速緩存區(qū)、PageCache、TLB等,可以有效的減少磁盤的訪問次數。
現實情況中,大部分系統在由小變大的過程中,最先出現瓶頸的就是I/O,尤其是在現代網絡應用從CPU密集型轉向了I/O密集型的大背景下,I/O越發(fā)成為大多數應用的性能瓶頸。
傳統的Linux操作系統的標準I/O接口是基于數據拷貝操作的,即I/O操作會導致數據在操作系統內核地址空間的緩沖區(qū)和用戶進程地址空間定義的緩沖區(qū)之間進行傳輸。設置緩沖區(qū)最大的好處是可以減少磁盤I/O的操作,如果所請求的數據已經存放在操作系統的高速緩沖存儲器中,那么就不需要再進行實際的物理磁盤I/O操作;然而傳統的Linux I/O在數據傳輸過程中的數據拷貝操作深度依賴CPU,也就是說I/O過程需要CPU去執(zhí)行數據拷貝的操作,因此導致了極大的系統開銷,限制了操作系統有效進行數據傳輸操作的能力。
這篇文章就從文件傳輸場景,以及零拷貝技術深究Linux I/O的發(fā)展過程、優(yōu)化手段以及實際應用。

DMA:DMA,全稱Direct Memory Access,即直接存儲器訪問,是為了避免CPU在磁盤操作時承擔過多的中斷負載而設計的;在磁盤操作中,CPU可將總線控制權交給DMA控制器,由DMA輸出讀寫命令,直接控制RAM與I/O接口進行DMA傳輸,無需CPU直接控制傳輸,也沒有中斷處理方式那樣保留現場和恢復現場過程,使得CPU的效率大大提高。
MMU:Memory Management Unit—內存管理單元,主要實現:
競爭訪問保護管理需求:需要嚴格的訪問保護,動態(tài)管理哪些內存頁/段或區(qū),為哪些應用程序所用。這屬于資源的競爭訪問管理需求;
高效的翻譯轉換管理需求:需要實現快速高效的映射翻譯轉換,否則系統的運行效率將會低下;
高效的虛實內存交換需求:需要在實際的虛擬內存與物理內存進行內存頁/段交換過程中快速高效。
Page Cache:為了避免每次讀寫文件時,都需要對硬盤進行讀寫操作,Linux 內核使用頁緩存(Page Cache)機制來對文件中的數據進行緩存。

此外,由于讀取磁盤數據的時候,需要找到數據所在的位置,但是對于機械磁盤來說,就是通過磁頭旋轉到數據所在的扇區(qū),再開始「順序」讀取數據,但是旋轉磁頭這個物理動作是非常耗時的,為了降低它的影響,PageCache 使用了「預讀功能」。
比如,假設read方法每次只會讀32KB的字節(jié),雖然read剛開始只會讀0~32KB的字節(jié),但內核會把其后面的32~64KB也讀取到PageCache,這樣后面讀取32~64KB的成本就很低,如果在32~64KB淘汰出PageCache 前,有進程讀取到它了,收益就非常大。
虛擬內存:在計算機領域有一句如同摩西十誡般神圣的哲言:"計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決",從內存管理、網絡模型、并發(fā)調度甚至是硬件架構,都能看到這句哲言在閃爍著光芒,而虛擬內存則是這一哲言的完美實踐之一。
虛擬內存為每個進程提供了一個一致的、私有且連續(xù)完整的內存空間;所有現代操作系統都使用虛擬內存,使用虛擬地址取代物理地址,主要有以下幾點好處:
第一點,利用上述的第一條特性可以優(yōu)化,可以把內核空間和用戶空間的虛擬地址映射到同一個物理地址,這樣在I/O操作時就不需要來回復制了。
第二點,多個虛擬內存可以指向同一個物理地址;第三點,虛擬內存空間可以遠遠大于物理內存空間;第四點,應用層面可管理連續(xù)的內存空間,減少出錯。
NFS文件系統:網絡文件系統是FreeBSD支持的文件系統中的一種,也被稱為NFS;NFS允許一個系統在網絡上與它人共享目錄和文件,通過使用 NFS,用戶和程序可以象訪問本地文件一樣訪問遠端系統上的文件。
Copy-on-write寫入時復制(Copy-on-write,COW)是一種計算機程序設計領域的優(yōu)化策略。其核心思想是,如果有多個調用者(callers)同時請求相同資源(如內存或磁盤上的數據存儲),他們會共同獲取相同的指針指向相同的資源,直到某個調用者試圖修改資源的內容時,系統才會真正復制一份專用副本(private copy)給該調用者,而其他調用者所見到的最初的資源仍然保持不變。這過程對其他的調用者都是透明的。此作法主要的優(yōu)點是如果調用者沒有修改該資源,就不會有副本(private copy)被創(chuàng)建,因此多個調用者只是讀取操作時可以共享同一份資源。

在沒有DMA技術前,I/O的過程是這樣的:首先,CPU發(fā)出對應的指令給磁盤控制器,然后返回;其次,磁盤控制器收到指令后,于是就開始準備數據,會把數據放入到磁盤控制器的內部緩沖區(qū)中,然后產生一個中斷;最后,CPU收到中斷信號后,停下手頭的工作,接著把磁盤控制器的緩沖區(qū)的數據一次一個字節(jié)地讀進自己的寄存器,然后再把寄存器里的數據寫入到內存,而在數據傳輸的期間CPU是被阻塞的狀態(tài),無法執(zhí)行其他任務。

整個數據的傳輸過程,都要需要CPU親自參與拷貝數據,而且這時CPU是被阻塞的;簡單的搬運幾個字符數據那沒問題,但是如果我們用千兆網卡或者硬盤傳輸大量數據的時候,都用CPU來搬運的話,肯定忙不過來。
計算機科學家們發(fā)現了事情的嚴重性后,于是就發(fā)明了DMA技術,也就是直接內存訪問(Direct Memory Access)技術。簡單理解就是,在進行I/O 設備和內存的數據傳輸的時候,數據搬運的工作全部交給DMA控制器,而 CPU不再參與任何與數據搬運相關的事情,這樣CPU就可以去處理別的事務。
具體流程如下圖:

首先,用戶進程調用read方法,向操作系統發(fā)出I/O請求,請求讀取數據到自己的內存緩沖區(qū)中,進程進入阻塞狀態(tài);其次,操作系統收到請求后,進一步將I/O請求發(fā)送DMA,釋放CPU;再次,DMA進一步將I/O請求發(fā)送給磁盤;從次,磁盤收到DMA的I/O請求,把數據從磁盤讀取到磁盤控制器的緩沖區(qū)中,當磁盤控制器的緩沖區(qū)被讀滿后,向DMA發(fā)起中斷信號,告知自己緩沖區(qū)已滿;最后,DMA收到磁盤的信號,將磁盤控制器緩沖區(qū)中的數據拷貝到內核緩沖區(qū)中,此時不占用CPU,CPU依然可以執(zhí)行其它事務;另外,當DMA讀取了足夠多的數據,就會發(fā)送中斷信號給CPU;除此之外,CPU收到中斷信號,將數據從內核拷貝到用戶空間,系統調用返回。
在有了DMA后,整個數據傳輸的過程,CPU不再參與與磁盤交互的數據搬運工作,而是全程由DMA完成,但是CPU在這個過程中也是必不可少的,因為傳輸什么數據,從哪里傳輸到哪里,都需要CPU來告訴DMA控制器。
早期DMA只存在在主板上,如今由于I/O設備越來越多,數據傳輸的需求也不盡相同,所以每個I/O設備里面都有自己的DMA控制器。
傳統文件傳輸的缺陷
有了DMA后,我們的磁盤I/O就一勞永逸了嗎?并不是的;拿我們比較熟悉的下載文件舉例,服務端要提供此功能,比較直觀的方式就是:將磁盤中的文件讀出到內存,再通過網絡協議發(fā)送給客戶端。
具體的I/O工作方式是,數據讀取和寫入是從用戶空間到內核空間來回復制,而內核空間的數據是通過操作系統層面的I/O接口從磁盤讀取或寫入。
代碼通常如下,一般會需要兩個系統調用:
read(file, tmp_buf, len)write(socket, tmp_buf, len)
代碼很簡單,雖然就兩行代碼,但是這里面發(fā)生了不少的事情:

這其中有:4次用戶態(tài)與內核態(tài)的上下文切換兩次系統調用read()和write()中,每次系統調用都得先從用戶態(tài)切換到內核態(tài),等內核完成任務后,再從內核態(tài)切換回用戶態(tài);上下文切換的成本并不小,一次切換需要耗時幾十納秒到幾微秒,在高并發(fā)場景下很容易成為性能瓶頸。(參考線程切換和協程切換的成本差別)
4次數據拷貝兩次由DMA完成拷貝,另外兩次則是由CPU完成拷貝;我們只是搬運一份數據,結果卻搬運了4次,過多的數據拷貝無疑會消耗額外的資源,大大降低了系統性能。
所以,要想提高文件傳輸的性能,就需要減少用戶態(tài)與內核態(tài)的上下文切換和內存拷貝的次數。
如何優(yōu)化傳統文件傳輸——減少「用戶態(tài)與內核態(tài)的上下文切換」:讀取磁盤數據的時候,之所以要發(fā)生上下文切換,這是因為用戶空間沒有權限操作磁盤或網卡,內核的權限最高,這些操作設備的過程都需要交由操作系統內核來完成,所以一般要通過內核去完成某些任務的時候,就需要使用操作系統提供的系統調用函數。
而一次系統調用必然會發(fā)生2次上下文切換:首先從用戶態(tài)切換到內核態(tài),當內核執(zhí)行完任務后,再切換回用戶態(tài)交由進程代碼執(zhí)行。
減少「數據拷貝」次數:前面提到,傳統的文件傳輸方式會歷經4次數據拷貝;但很明顯的可以看到:從內核的讀緩沖區(qū)拷貝到用戶的緩沖區(qū)和從用戶的緩沖區(qū)里拷貝到socket的緩沖區(qū)」這兩步是沒有必要的。
因為在下載文件,或者說廣義的文件傳輸場景中,我們并不需要在用戶空間對數據進行再加工,所以數據并不需要回到用戶空間中。

那么零拷貝技術就應運而生了,它就是為了解決我們在上面提到的場景——跨過與用戶態(tài)交互的過程,直接將數據從文件系統移動到網絡接口而產生的技術。
1)零拷貝實現原理
零拷貝技術實現的方式通常有3種:mmap+write、sendfile、splice。
mmap + write
在前面我們知道,read()系統調用的過程中會把內核緩沖區(qū)的數據拷貝到用戶的緩沖區(qū)里,于是為了省去這一步,我們可以用mmap()替換read()系統調用函數,偽代碼如下:
buf = mmap(file, len)write(sockfd, buf, len)
mmap的函數原型如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
mmap()系統調用函數會在調用進程的虛擬地址空間中創(chuàng)建一個新映射,直接把內核緩沖區(qū)里的數據「映射」到用戶空間,這樣,操作系統內核與用戶空間就不需要再進行任何的數據拷貝操作。

具體過程如下:首先,應用進程調用了mmap()后,DMA會把磁盤的數據拷貝到內核的緩沖區(qū)里,應用進程跟操作系統內核「共享」這個緩沖區(qū);其次,應用進程再調用write(),操作系統直接將內核緩沖區(qū)的數據拷貝到 socket緩沖區(qū)中,這一切都發(fā)生在內核態(tài),由CPU來搬運數據;最后,把內核的socket緩沖區(qū)里的數據,拷貝到網卡的緩沖區(qū)里,這個過程是由DMA搬運的。
我們可以看到,通過使用mmap()來代替read(),可以減少一次數據拷貝的過程。但這還不是最理想的零拷貝,因為仍然需要通過CPU把內核緩沖區(qū)的數據拷貝到socket緩沖區(qū)里,且仍然需要4次上下文切換,因為系統調用還是2次。
sendfile
在Linux內核版本2.1中,提供了一個專門發(fā)送文件的系統調用函數sendfile()如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前兩個參數分別是目的端和源端的文件描述符,后面兩個參數是源端的偏移量和復制數據的長度,返回值是實際復制數據的長度。
首先,它可以替代前面的read()和write()這兩個系統調用,這樣就可以減少一次系統調用,也就減少了2次上下文切換的開銷。其次,該系統調用,可以直接把內核緩沖區(qū)里的數據拷貝到socket緩沖區(qū)里,不再拷貝到用戶態(tài),這樣就只有2次上下文切換,和3次數據拷貝。如下圖:

帶有scatter/gather的sendfile 方式:Linux 2.4內核進行了優(yōu)化,提供了帶有scatter/gather的sendfile操作,這個操作可以把最后一次CPU COPY去除。其原理就是在內核空間Read BUffer和Socket Buffer不做數據復制,而是將Read Buffer的內存地址、偏移量記錄到相應的Socket Buffer中,這樣就不需要復制。其本質和虛擬內存的解決方法思路一致,就是內存地址的記錄。
你可以在你的Linux系統通過下面這個命令,查看網卡是否支持scatter-gather特性:
ethtool -k eth0 | grep scatter-gatherscatter-gather: on
于是,從Linux內核2.4版本開始起,對于支持網卡支持SG-DMA技術的情況下,sendfile()系統調用的過程發(fā)生了點變化,具體過程如下:
第一步,通過DMA將磁盤上的數據拷貝到內核緩沖區(qū)里;第二步,緩沖區(qū)描述符和數據長度傳到socket緩沖區(qū),這樣網卡的SG-DMA 控制器就可以直接將內核緩存中的數據拷貝到網卡的緩沖區(qū)里,此過程不需要將數據從操作系統內核緩沖區(qū)拷貝到socket緩沖區(qū)中,這樣就減少了一次數據拷貝。所以,這個過程之中,只進行了2次數據拷貝,如下圖:

splice 方式
splice調用和sendfile非常相似,用戶應用程序必須擁有兩個已經打開的文件描述符,一個表示輸入設備,一個表示輸出設備。與sendfile不同的是,splice允許任意兩個文件互相連接,而并不只是文件與socket進行數據傳輸。對于從一個文件描述符發(fā)送數據到socket這種特例來說,一直都是使用sendfile系統調用,而splice一直以來就只是一種機制,它并不僅限于sendfile的功能。也就是說sendfile是splice的一個子集。
splice()是基于Linux的管道緩沖區(qū)(pipe buffer)機制實現的,所以splice()的兩個入參文件描述符要求必須有一個是管道設備。
使用splice()完成一次磁盤文件到網卡的讀寫過程如下:
首先,用戶進程調用pipe(),從用戶態(tài)陷入內核態(tài);創(chuàng)建匿名單向管道,pipe()返回,上下文從內核態(tài)切換回用戶態(tài);其次,用戶進程調用splice(),從用戶態(tài)陷入內核態(tài);再次,DMA控制器將數據從硬盤拷貝到內核緩沖區(qū),從管道的寫入端"拷貝"進管道,splice()返回,上下文從內核態(tài)回到用戶態(tài);從次,用戶進程再次調用splice(),從用戶態(tài)陷入內核態(tài);最后,內核把數據從管道的讀取端拷貝到socket緩沖區(qū),DMA控制器將數據從socket緩沖區(qū)拷貝到網卡;另外,splice()返回,上下文從內核態(tài)切換回用戶態(tài)。

在Linux2.6.17版本引入了splice,而在Linux 2.6.23版本中,sendfile機制的實現已經沒有了,但是其API及相應的功能還在,只不過API及相應的功能是利用了splice機制來實現的。和sendfile不同的是,splice不需要硬件支持。
零拷貝的實際應用1)Kafka
事實上,Kafka這個開源項目,就利用了「零拷貝」技術,從而大幅提升了 I/O的吞吐率,這也是Kafka在處理海量數據為什么這么快的原因之一。如果你追溯Kafka文件傳輸的代碼,你會發(fā)現,最終它調用了Java NIO庫里的 transferTo方法:
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {return fileChannel.transferTo(position, count, socketChannel);}如果Linux系統支持sendfile()系統調用,那么transferTo()實際上最后就會使用到sendfile()系統調用函數。
2)Nginx
Nginx也支持零拷貝技術,一般默認是開啟零拷貝技術,這樣有利于提高文件傳輸的效率,是否開啟零拷貝技術的配置如下:
http {...sendfile on...}
大文件傳輸場景
1)零拷貝還是最優(yōu)選嗎
在大文件傳輸的場景下,零拷貝技術并不是最優(yōu)選擇;因為在零拷貝的任何一種實現中,都會有「DMA將數據從磁盤拷貝到內核緩存區(qū)——Page Cache」這一步,但是,在傳輸大文件(GB級別的文件)的時候,PageCache會不起作用,那就白白浪費DMA多做的一次數據拷貝,造成性能的降低,即使使用了PageCache的零拷貝也會損失性能。
這是因為在大文件傳輸場景下,每當用戶訪問這些大文件的時候,內核就會把它們載入PageCache中,PageCache空間很快被這些大文件占滿;且由于文件太大,可能某些部分的文件數據被再次訪問的概率比較低,這樣就會帶來2個問題:PageCache由于長時間被大文件占據,其他「熱點」的小文件可能就無法充分使用到PageCache,于是這樣磁盤讀寫的性能就會下降了;PageCache中的大文件數據,由于沒有享受到緩存帶來的好處,但卻耗費DMA多拷貝到PageCache一次。
2)異步I/O+direct I/O
那么大文件傳輸場景下我們該選擇什么方案呢?讓我們先來回顧一下我們在文章開頭介紹DMA時最早提到過的同步I/O:

這里的同步體現在當進程調用read方法讀取文件時,進程實際上會阻塞在 read方法調用,因為要等待磁盤數據的返回,并且我們當然不希望進程在讀取大文件時被阻塞,對于阻塞的問題,可以用異步I/O來解決,即:

它把讀操作分為兩部分:前半部分,內核向磁盤發(fā)起讀請求,但是可以不等待數據就位就返回,于是進程此時可以處理其他任務;后半部分,當內核將磁盤中的數據拷貝到進程緩沖區(qū)后,進程將接收到內核的通知,再去處理數據。
而且,我們可以發(fā)現,異步I/O并沒有涉及到PageCache;使用異步I/O就意味著要繞開PageCache,因為填充PageCache的過程在內核中必須阻塞。所以異步I/O中使用的是direct I/O(對比使用PageCache的buffer I/O),這樣才能不阻塞進程,立即返回。
direct I/O應用場景常見的兩種:
第一種,應用程序已經實現了磁盤數據的緩存,那么可以不需要 PageCache再次緩存,減少額外的性能損耗。在MySQL數據庫中,可以通過參數設置開啟direct I/O,默認是不開啟;第二種,傳輸大文件的時候,由于大文件難以命中PageCache緩存,而且會占滿PageCache導致「熱點」文件無法充分利用緩存,從而增大了性能開銷,因此,這時應該使用`direct I/O。
當然,由于direct I/O繞過了PageCache,就無法享受內核的這兩點的優(yōu)化:內核的I/O調度算法會緩存盡可能多的I/O請求在PageCache中,最后「合并」成一個更大的I/O請求再發(fā)給磁盤,這樣做是為了減少磁盤的尋址操作;內核也會「預讀」后續(xù)的I/O請求放在PageCache中,一樣是為了減少對磁盤的操作。
實際應用中也有類似的配置,在 nginx 中,我們可以用如下配置,來根據文件的大小來使用不同的方式傳輸:
location /video/ {sendfile on;aio on;directio 1024m;}
當文件大小大于directio值后,使用「異步 I/O + 直接 I/O」,否則使用「零拷貝技術」。
3)使用direct I/O需要注意的點
首先,貼一下我們的Linus(Linus Torvalds)對O_DIRECT的評價:
"The thing that has always disturbed me about O_DIRECT is that the whole interface is just stupid, and was probably designed by a deranged monkey on some serious mind-controlling substances." —Linus
一般來說能引得Linus開罵的東西,那是一定有很多坑的。在Linux的man page中我們可以看到O_DIRECT下有一個Note,這里我就不貼出來了。
總結一下其中需要注意的點如下:
第一點,地址對齊限制。O_DIRECT會帶來強制的地址對齊限制,這個對齊的大小也跟文件系統/存儲介質相關,并且當前沒有不依賴文件系統自身的接口提供指定文件/文件系統是否有這些限制的信息
Linux 2.6以前,總傳輸大小、用戶的對齊緩沖區(qū)起始地址、文件偏移量必須都是邏輯文件系統的數據塊大小的倍數,這里說的數據塊(block)是一個邏輯概念,是文件系統捆綁一定數量的連續(xù)扇區(qū)而來,因此通常稱為 “文件系統邏輯塊”,可通過以下命令獲?。?/span>
blockdev --getssLinux2.6以后對齊的基數變?yōu)槲锢砩系拇鎯橘|的sector size扇區(qū)大小,對應物理存儲介質的最小存儲粒度,可通過以下命令獲取:
blockdev --getpbsz帶來這個限制的原因也很簡單,內存對齊這件小事通常是內核來處理的,而O_DIRECT繞過了內核空間,那么內核處理的所有事情都需要用戶自己來處理,后臺回復“Linux I/O”獲取詳細解釋。
第二點,O_DIRECT 平臺不兼容。這應該是大部分跨平臺應用需要注意到的點,O_DIRECT本身就是Linux中才有的東西,在語言層面/應用層面需要考慮這里的兼容性保證,比如在Windows下其實也有類似的機制FILE_FLAG_NO_BUFFERIN用法類似;再比如macOS下的F_NOCACHE雖然類似O_DIRECT,但實際使用中也有差距。
第三點,不要并發(fā)地運行 fork 和 O_DIRECT I/O。如果O_DIRECT I/O中使用到的內存buffer是一段私有的映射(虛擬內存),如任何使用上文中提到過的mmap并以MAP_PRIVATE flag 聲明的虛擬內存,那么相關的O_DIRECT I/O(不管是異步 I/O / 其它子線程中的 I/O)都必須在調用fork系統調用前執(zhí)行完畢;否則會造成數據污染或產生未定義的行為。
以下情況這個限制不存在:相關的內存buffer是使用shmat分配或是使用mmap以MAP_SHARED flag聲明的;相關的內存buffer是使用madvise以MADV_DONTFORK聲明的(注意這種方式下該內存buffer在子進程中不可用)。
第四點,避免對同一文件混合使用 O_DIRECT 和普通 I/O。在應用層需要避免對同一文件(尤其是對同一文件的相同偏移區(qū)間內)混合使用O_DIRECT和普通I/O;即使我們的文件系統能夠幫我們處理和保證這里的一致性問題,總體來說整個I/O吞吐量也會比單獨使用某一種I/O方式要小。同樣的,應用層也要避免對同一文件混合使用direct I/O和mmap。
第五點,NFS 協議下的 O_DIRECT。雖然NFS文件系統就是為了讓用戶像訪問本地文件一樣去訪問網絡文件,但O_DIRECT在NFS文件系統中的表現和本地文件系統不同,比較老版本的內核或是魔改過的內核可能并不支持這種組合。
這是因為在NFS協議中并不支持傳遞flag參數到服務器,所以O_DIRECT I/O實際上只繞過了本地客戶端的Page Cache,但服務端/同步客戶端仍然會對這些I/O進行cache。
當客戶端請求服務端進行I/O同步來保證O_DIRECT的同步語義時,一些服務器的性能表現不佳(尤其是當這些I/O很小時);還有一些服務器干脆設置為欺騙客戶端,直接返回客戶端「數據已寫入存儲介質」,這樣就可以一定程度上避免I/O同步帶來的性能損失,但另一方面,當服務端斷電時就無法保證未完成I/O同步的數據的數據完整性了。Linux的NFS客戶端也沒有上面說過的地址對齊的限制。
4)在 Golang 中使用 direct I/O
direct io必須要滿足3種對齊規(guī)則:io偏移扇區(qū)對齊,長度扇區(qū)對齊,內存 buffer 地址扇區(qū)對齊;前兩個還比較好滿足,但是分配的內存地址僅憑原生的手段是無法直接達成的。
先對比一下c語言,libc庫是調用posix_memalign 直接分配出符合要求的內存塊,但Golang中要怎么實現呢?在Golang中,io的buffer其實就是字節(jié)數組,自然是用make來分配,如下:
buffer := make([]byte, 4096)
但buffer中的data字節(jié)數組首地址并不一定是對齊的。方法也很簡單,就是先分配一個比預期要大的內存塊,然后在這個內存塊里找對齊位置;這是一個任何語言皆通用的方法,在 Go 里也是可用的。
比如,我現在需要一個4096大小的內存塊,要求地址按照 512 對齊,可以這樣做:先分配4096+512大小的內存塊,假設得到的內存塊首地址是 p1;然后在[p1, p1+512] 這個地址范圍找,一定能找到512對齊的地址p2;返回 p2,用戶能正常使用 [p2, p2+4096] 這個范圍的內存塊而不越界。以上就是基本原理了,具體實現如下:
// 從 block 首地址往后找到符合 AlignSize 對齊的地址并返回// 這里很巧妙的使用了位運算,性能upupfunc alignment(block []byte, AlignSize int) int {return int(uintptr(unsafe.Pointer(&block[0])) & uintptr(AlignSize-1))}// 分配 BlockSize 大小的內存塊// 地址按 AlignSize 對齊func AlignedBlock(BlockSize int) []byte {// 分配一個大小比實際需要的稍大block := make([]byte, BlockSize+AlignSize)// 計算到下一個地址對齊點的偏移量a := alignment(block, AlignSize)offset := 0if a != 0 {offset = AlignSize - a}// 偏移指定位置,生成一個新的 block,這個 block 就滿足地址對齊了block = block[offset : offset+BlockSize]if BlockSize != 0 {// 最后做一次地址對齊校驗a = alignment(block, AlignSize)if a != 0 {log.Fatal("Failed to align block")}}return block}
所以,通過以上AlignedBlock函數分配出來的內存一定是 512 地址對齊的,唯一的缺點就是在分配較小內存塊時對齊的額外開銷顯得比較大。
開源實現:Github上就有開源的Golang direct I/O實現:ncw/directio。使用也很簡單,O_DIRECT模式打開文件:
// 創(chuàng)建句柄fp, err := directio.OpenFile(file, os.O_RDONLY, 0666)
讀數據:
// 創(chuàng)建地址按照 4k 對齊的內存塊buffer := directio.AlignedBlock(directio.BlockSize)// 把文件數據讀到內存塊中_, err := io.ReadFull(fp, buffer)

到目前為止,我們討論的zero-copy技術都是基于減少甚至是避免用戶空間和內核空間之間的CPU數據拷貝的,雖然有一些技術非常高效,但是大多都有適用性很窄的問題,比如 sendfile()、splice() 這些,效率很高,但是都只適用于那些用戶進程不需要再處理數據的場景,比如靜態(tài)文件服務器或者是直接轉發(fā)數據的代理服務器。
前面提到過的虛擬內存機制和mmap等都表明,通過在不同的虛擬地址上重新映射頁面可以實現在用戶進程和內核之間虛擬復制和共享內存;因此如果要在實現在用戶進程內處理數據(這種場景比直接轉發(fā)數據更加常見)之后再發(fā)送出去的話,用戶空間和內核空間的數據傳輸就是不可避免的,既然避無可避,那就只能選擇優(yōu)化了。
兩種優(yōu)化用戶空間和內核空間數據傳輸的技術:動態(tài)重映射與寫時拷貝 (Copy-on-Write)、緩沖區(qū)共享(Buffer Sharing)。
1)寫時拷貝 (Copy-on-Write)
前面提到過過利用內存映射(mmap)來減少數據在用戶空間和內核空間之間的復制,通常用戶進程是對共享的緩沖區(qū)進行同步阻塞讀寫的,這樣不會有線程安全問題,但是很明顯這種模式下效率并不高,而提升效率的一種方法就是異步地對共享緩沖區(qū)進行讀寫,而這樣的話就必須引入保護機制來避免數據沖突問題,COW(Copy on Write) 就是這樣的一種技術。
COW是一種建立在虛擬內存重映射技術之上的技術,因此它需要 MMU 的硬件支持,MMU會記錄當前哪些內存頁被標記成只讀,當有進程嘗試往這些內存頁中寫數據的時候,MMU 就會拋一個異常給操作系統內核,內核處理該異常時為該進程分配一份物理內存并復制數據到此內存地址,重新向 MMU 發(fā)出執(zhí)行該進程的寫操作。
下圖為COW在Linux中的應用之一:fork/clone,fork出的子進程共享父進程的物理空間,當父子進程有內存寫入操作時,read-only內存頁發(fā)生中斷,將觸發(fā)的異常的內存頁復制一份(其余的頁還是共享父進程的)。

局限性:COW這種零拷貝技術比較適用于那種多讀少寫從而使得COW事件發(fā)生較少的場景,而在其它場景下反而可能造成負優(yōu)化,因為COW事件所帶來的系統開銷要遠遠高于一次CPU拷貝所產生的。
此外,在實際應用的過程中,為了避免頻繁的內存映射,可以重復使用同一段內存緩沖區(qū),因此,你不需要在只用過一次共享緩沖區(qū)之后就解除掉內存頁的映射關系,而是重復循環(huán)使用,從而提升性能。
但這種內存頁映射的持久化并不會減少由于頁表往返移動/換頁和TLB flush所帶來的系統開銷,因為每次接收到COW事件之后對內存頁而進行加鎖或者解鎖的時候,內存頁的只讀標志 (read-ony) 都要被更改為 (write-only)。
COW的實際應用——Redis的持久化機制:Redis作為典型的內存型應用,一定是有內核緩沖區(qū)和用戶緩沖區(qū)之間的傳輸優(yōu)化的。
Redis的持久化機制中,如果采用bgsave或者bgrewriteaof 命令,那么會 fork 一個子進程來將數據存到磁盤中。總體來說Redis的讀操作是比寫操作多的(在正確的使用場景下),因此這種情況下使用COW可以減少 fork() 操作的阻塞時間。
語言層面的應用
寫時復制的思想在很多語言中也有應用,相比于傳統的深層復制,能帶來很大性能提升;比如C++98標準下的 std::string 就采用了寫時復制的實現:
std::string x("Hello");std::string y = x; // x、y 共享相同的 buffery += ", World!"; // 寫時復制,此時 y 使用一個新的 buffer// x 依然使用舊的 buffer
Golang中的string,slice也使用了類似的思想,在復制/切片等操作時都不會改變底層數組的指向,變量共享同一個底層數組,僅當進行append / 修改等操作時才可能進行真正的copy(append時如果超過了當前切片的容量,就需要分配新的內存)。
2)緩沖區(qū)共享(Buffer Sharing)
從前面的介紹可以看出,傳統的Linux I/O接口,都是基于復制/拷貝的:數據需要在操作系統內核空間和用戶空間的緩沖區(qū)之間進行拷貝。在進行I/O操作之前,用戶進程需要預先分配好一個內存緩沖區(qū),使用read()系統調用時,內核會將從存儲器或者網卡等設備讀入的數據拷貝到這個用戶緩沖區(qū)里。而使用write()系統調用時,則是把用戶內存緩沖區(qū)的數據拷貝至內核緩沖區(qū)。
為了實現這種傳統的I/O模式,Linux必須要在每一個I/O操作時都進行內存虛擬映射和解除。這種內存頁重映射的機制的效率嚴重受限于緩存體系結構、MMU地址轉換速度和TLB命中率。如果能夠避免處理I/O請求的虛擬地址轉換和TLB刷新所帶來的開銷,則有可能極大地提升I/O性能。而緩沖區(qū)共享就是用來解決上述問題的一種技術(說實話我覺得有些套娃的味道了)。
操作系統內核開發(fā)者們實現了一種叫fbufs的緩沖區(qū)共享的框架,也即快速緩沖區(qū)(Fast Buffers),使用一個fbuf緩沖區(qū)作為數據傳輸的最小單位。使用這種技術需要調用新的操作系統API,用戶區(qū)和內核區(qū)、內核區(qū)之間的數據都必須嚴格地在fbufs這個體系下進行通信。fbufs為每一個用戶進程分配一個 buffer pool,里面會儲存預分配(也可以使用的時候再分配)好的 buffers,這些buffers會被同時映射到用戶內存空間和內核內存空間。fbufs只需通過一次虛擬內存映射操作即可創(chuàng)建緩沖區(qū),有效地消除那些由存儲一致性維護所引發(fā)的大多數性能損耗。
共享緩沖區(qū)技術的實現需要依賴于用戶進程、操作系統內核、以及I/O子系統(設備驅動程序,文件系統等)之間協同工作。比如,設計得不好的用戶進程容易就會修改已經發(fā)送出去的fbuf從而污染數據,更要命的是這種問題很難debug。雖然這個技術的設計方案非常精彩,但是它的門檻和限制卻不比前面介紹的其他技術少:首先會對操作系統API造成變動,需要使用新的一些API調用,其次還需要設備驅動程序配合改動,還有由于是內存共享,內核需要很小心謹慎地實現對這部分共享的內存進行數據保護和同步的機制,而這種并發(fā)的同步機制是非常容易出bug的從而又增加了內核的代碼復雜度,等等。因此這一類的技術還遠遠沒有到發(fā)展成熟和廣泛應用的階段,目前大多數的實現都還處于實驗階段。
總結從早期的I/O到DMA,解決了阻塞CPU的問題;而為了省去I/O過程中不必要的上下文切換和數據拷貝過程,零拷貝技術就出現了。所謂的零拷貝(Zero-copy)技術,就是完完全全不需要在內存層面拷貝數據,省去CPU搬運數據的過程。
零拷貝技術的文件傳輸方式相比傳統文件傳輸的方式,減少了2次上下文切換和數據拷貝次數,只需要2次上下文切換和數據拷貝次數,就可以完成文件的傳輸,而且2次的數據拷貝過程,都不需要通過CPU,2次都是由DMA來搬運。總體來看,零拷貝技術至少可以把文件傳輸的性能提高一倍以上,以下是各方案詳細的成本對比:

零拷貝技術是基于PageCache的,PageCache會緩存最近訪問的數據,提升了訪問緩存數據的性能,同時,為了解決機械硬盤尋址慢的問題,它還協助 I/O調度算法實現了I/O合并與預讀,這也是順序讀比隨機讀性能好的原因之一;這些優(yōu)勢,進一步提升了零拷貝的性能。
但當面對大文件傳輸時,不能使用零拷貝,因為可能由于PageCache被大文件占據,導致「熱點」小文件無法利用到PageCache的問題,并且大文件的緩存命中率不高,這時就需要使用「異步I/O+direct I/O」的方式;在使用direct I/O時也需要注意許多的坑點,畢竟連Linus也會被O_DIRECT 'disturbed'到。
而在更廣泛的場景下,我們還需要注意到內核緩沖區(qū)和用戶緩沖區(qū)之間的傳輸優(yōu)化,這種方式側重于在用戶進程的緩沖區(qū)和操作系統的頁緩存之間的CPU 拷貝的優(yōu)化,延續(xù)了以往那種傳統的通信方式,但更靈活。
I/O相關的各類優(yōu)化自然也已經深入到了日常我們接觸到的語言、中間件以及數據庫的方方面面,通過了解和學習這些技術和思想,也能對日后自己的程序設計以及性能優(yōu)化上有所啟發(fā)。



工作日晚8點 看騰訊技術、學專家經驗

