天天看點

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

作者:架構思考
之前我們提及過零拷貝技術,見文章《「運維」Linux 零拷貝技術》。今天從零拷貝技術閃入了解Linux-I/O。

一、前言

存儲器是計算機的核心部件之一,在完全理想的狀态下,存儲器應該要同時具備以下三種特性:第一,速度足夠快:存儲器的存取速度應當快于CPU執行一條指令,這樣CPU的效率才不會受限于存儲器;第二,容量足夠大:容量能夠存儲計算機所需的全部資料;第三,價格足夠便宜:價格低廉,所有類型的計算機都能配備。

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

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

從頂至底,現代計算機裡的存儲器類型分别有:寄存器、高速緩存、主存和磁盤,這些存儲器的速度逐級遞減而容量逐級遞增。

存取速度最快的是寄存器,因為寄存器的制作材料和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直接交換資料的内部存儲器。它可以随時讀寫(重新整理時除外),而且速度很快,通常作為作業系統或其他正在運作中的程式的臨時資料存儲媒體。

至于磁盤則是圖中離使用者最遠的一層了,讀寫速度相差記憶體上百倍;另一方面自然針對磁盤操作的優化也非常多,如零拷貝、direct I/O、異步I/O等等,這些優化的目的都是為了提高系統的吞吐量;另外作業系統核心中也有磁盤高速緩存區、PageCache、TLB等,可以有效的減少磁盤的通路次數。

現實情況中,大部分系統在由小變大的過程中,最先出現瓶頸的就是I/O,尤其是在現代網絡應用從CPU密集型轉向了I/O密集型的大背景下,I/O越發成為大多數應用的性能瓶頸。

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

這篇文章就從檔案傳輸場景,以及零拷貝技術深究Linux I/O的發展過程、優化手段以及實際應用。

二、需要了解的詞

DMA:DMA,全稱Direct Memory Access,即直接存儲器通路,是為了避免CPU在磁盤操作時承擔過多的中斷負載而設計的;在磁盤操作中,CPU可将總線控制權交給DMA控制器,由DMA輸出讀寫指令,直接控制RAM與I/O接口進行DMA傳輸,無需CPU直接控制傳輸,也沒有中斷處理方式那樣保留現場和恢複現場過程,使得CPU的效率大大提高。

MMU:Memory Management Unit—記憶體管理單元,主要實作:

競争通路保護管理需求:需要嚴格的通路保護,動态管理哪些記憶體頁/段或區,為哪些應用程式所用。這屬于資源的競争通路管理需求;

高效的翻譯轉換管理需求:需要實作快速高效的映射翻譯轉換,否則系統的運作效率将會低下;

高效的虛實記憶體交換需求:需要在實際的虛拟記憶體與實體記憶體進行記憶體頁/段交換過程中快速高效。

Page Cache:為了避免每次讀寫檔案時,都需要對硬碟進行讀寫操作,Linux 核心使用頁緩存(Page Cache)機制來對檔案中的資料進行緩存。

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

此外,由于讀取磁盤資料的時候,需要找到資料所在的位置,但是對于機械磁盤來說,就是通過磁頭旋轉到資料所在的扇區,再開始「順序」讀取資料,但是旋轉磁頭這個實體動作是非常耗時的,為了降低它的影響,PageCache 使用了「預讀功能」。

比如,假設read方法每次隻會讀32KB的位元組,雖然read剛開始隻會讀0~32KB的位元組,但核心會把其後面的32~64KB也讀取到PageCache,這樣後面讀取32~64KB的成本就很低,如果在32~64KB淘汰出PageCache 前,有程序讀取到它了,收益就非常大。

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

虛拟記憶體為每個程序提供了一個一緻的、私有且連續完整的記憶體空間;所有現代作業系統都使用虛拟記憶體,使用虛拟位址取代實體位址,主要有以下幾點好處:

第一點,利用上述的第一條特性可以優化,可以把核心空間和使用者空間的虛拟位址映射到同一個實體位址,這樣在I/O操作時就不需要來回複制了。

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

第二點,多個虛拟記憶體可以指向同一個實體位址;第三點,虛拟記憶體空間可以遠遠大于實體記憶體空間;第四點,應用層面可管理連續的記憶體空間,減少出錯。

NFS檔案系統:網絡檔案系統是FreeBSD支援的檔案系統中的一種,也被稱為NFS;NFS允許一個系統在網絡上與它人共享目錄和檔案,通過使用 NFS,使用者和程式可以象通路本地檔案一樣通路遠端系統上的檔案。

Copy-on-write寫入時複制(Copy-on-write,COW)是一種計算機程式設計領域的優化政策。其核心思想是,如果有多個調用者(callers)同時請求相同資源(如記憶體或磁盤上的資料存儲),他們會共同擷取相同的指針指向相同的資源,直到某個調用者試圖修改資源的内容時,系統才會真正複制一份專用副本(private copy)給該調用者,而其他調用者所見到的最初的資源仍然保持不變。這過程對其他的調用者都是透明的。此作法主要的優點是如果調用者沒有修改該資源,就不會有副本(private copy)被建立,是以多個調用者隻是讀取操作時可以共享同一份資源。

三、為什麼要有DMA

在沒有DMA技術前,I/O的過程是這樣的:首先,CPU發出對應的指令給磁盤控制器,然後傳回;其次,磁盤控制器收到指令後,于是就開始準備資料,會把資料放入到磁盤控制器的内部緩沖區中,然後産生一個中斷;最後,CPU收到中斷信号後,停下手頭的工作,接着把磁盤控制器的緩沖區的資料一次一個位元組地讀進自己的寄存器,然後再把寄存器裡的資料寫入到記憶體,而在資料傳輸的期間CPU是被阻塞的狀态,無法執行其他任務。

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

整個資料的傳輸過程,都要需要CPU親自參與拷貝資料,而且這時CPU是被阻塞的;簡單的搬運幾個字元資料那沒問題,但是如果我們用千兆網卡或者硬碟傳輸大量資料的時候,都用CPU來搬運的話,肯定忙不過來。

計算機科學家們發現了事情的嚴重性後,于是就發明了DMA技術,也就是直接記憶體通路(Direct Memory Access)技術。簡單了解就是,在進行I/O 裝置和記憶體的資料傳輸的時候,資料搬運的工作全部交給DMA控制器,而 CPU不再參與任何與資料搬運相關的事情,這樣CPU就可以去處理别的事務。

具體流程如下圖:

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

首先,使用者程序調用read方法,向作業系統發出I/O請求,請求讀取資料到自己的記憶體緩沖區中,程序進入阻塞狀态;其次,作業系統收到請求後,進一步将I/O請求發送DMA,釋放CPU;再次,DMA進一步将I/O請求發送給磁盤;從次,磁盤收到DMA的I/O請求,把資料從磁盤讀取到磁盤控制器的緩沖區中,當磁盤控制器的緩沖區被讀滿後,向DMA發起中斷信号,告知自己緩沖區已滿;最後,DMA收到磁盤的信号,将磁盤控制器緩沖區中的資料拷貝到核心緩沖區中,此時不占用CPU,CPU依然可以執行其它事務;另外,當DMA讀取了足夠多的資料,就會發送中斷信号給CPU;除此之外,CPU收到中斷信号,将資料從核心拷貝到使用者空間,系統調用傳回。

在有了DMA後,整個資料傳輸的過程,CPU不再參與與磁盤互動的資料搬運工作,而是全程由DMA完成,但是CPU在這個過程中也是必不可少的,因為傳輸什麼資料,從哪裡傳輸到哪裡,都需要CPU來告訴DMA控制器。

早期DMA隻存在在主機闆上,如今由于I/O裝置越來越多,資料傳輸的需求也不盡相同,是以每個I/O裝置裡面都有自己的DMA控制器。

四、傳統檔案傳輸的缺陷

有了DMA後,我們的磁盤I/O就一勞永逸了嗎?并不是的;拿我們比較熟悉的下載下傳檔案舉例,服務端要提供此功能,比較直覺的方式就是:将磁盤中的檔案讀出到記憶體,再通過網絡協定發送給用戶端。

具體的I/O工作方式是,資料讀取和寫入是從使用者空間到核心空間來回複制,而核心空間的資料是通過作業系統層面的I/O接口從磁盤讀取或寫入。

代碼通常如下,一般會需要兩個系統調用:

read(file, tmp_buf, len)

write(socket, tmp_buf, len)           

代碼很簡單,雖然就兩行代碼,但是這裡面發生了不少的事情:

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

這其中有:4次使用者态與核心态的上下文切換兩次系統調用read()和write()中,每次系統調用都得先從使用者态切換到核心态,等核心完成任務後,再從核心态切換回使用者态;上下文切換的成本并不小,一次切換需要耗時幾十納秒到幾微秒,在高并發場景下很容易成為性能瓶頸。(參考線程切換和協程切換的成本差别)

4次資料拷貝兩次由DMA完成拷貝,另外兩次則是由CPU完成拷貝;我們隻是搬運一份資料,結果卻搬運了4次,過多的資料拷貝無疑會消耗額外的資源,大大降低了系統性能。

是以,要想提高檔案傳輸的性能,就需要減少使用者态與核心态的上下文切換和記憶體拷貝的次數。

如何優化傳統檔案傳輸——減少「使用者态與核心态的上下文切換」:讀取磁盤資料的時候,之是以要發生上下文切換,這是因為使用者空間沒有權限操作磁盤或網卡,核心的權限最高,這些操作裝置的過程都需要交由作業系統核心來完成,是以一般要通過核心去完成某些任務的時候,就需要使用作業系統提供的系統調用函數。

而一次系統調用必然會發生2次上下文切換:首先從使用者态切換到核心态,當核心執行完任務後,再切換回使用者态交由程序代碼執行。

減少「資料拷貝」次數:前面提到,傳統的檔案傳輸方式會曆經4次資料拷貝;但很明顯的可以看到:從核心的讀緩沖區拷貝到使用者的緩沖區和從使用者的緩沖區裡拷貝到socket的緩沖區」這兩步是沒有必要的。

因為在下載下傳檔案,或者說廣義的檔案傳輸場景中,我們并不需要在使用者空間對資料進行再加工,是以資料并不需要回到使用者空間中。

五、零拷貝

那麼零拷貝技術就應運而生了,它就是為了解決我們在上面提到的場景——跨過與使用者态互動的過程,直接将資料從檔案系統移動到網絡接口而産生的技術。

零拷貝實作原理:零拷貝技術實作的方式通常有3種:mmap+write、sendfile、splice。

  • mmap + write

在前面我們知道,read()系統調用的過程中會把核心緩沖區的資料拷貝到使用者的緩沖區裡,于是為了省去這一步,我們可以用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()系統調用函數會在調用程序的虛拟位址空間中建立一個新映射,直接把核心緩沖區裡的資料「映射」到使用者空間,這樣,作業系統核心與使用者空間就不需要再進行任何的資料拷貝操作。

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

具體過程如下:首先,應用程序調用了mmap()後,DMA會把磁盤的資料拷貝到核心的緩沖區裡,應用程序跟作業系統核心「共享」這個緩沖區;其次,應用程序再調用write(),作業系統直接将核心緩沖區的資料拷貝到 socket緩沖區中,這一切都發生在核心态,由CPU來搬運資料;最後,把核心的socket緩沖區裡的資料,拷貝到網卡的緩沖區裡,這個過程是由DMA搬運的。

我們可以看到,通過使用mmap()來代替read(),可以減少一次資料拷貝的過程。但這還不是最理想的零拷貝,因為仍然需要通過CPU把核心緩沖區的資料拷貝到socket緩沖區裡,且仍然需要4次上下文切換,因為系統調用還是2次。

  • sendfile

在Linux核心版本2.1中,提供了一個專門發送檔案的系統調用函數sendfile()如下:

#include <sys/socket.h>

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

它的前兩個參數分别是目的端和源端的檔案描述符,後面兩個參數是源端的偏移量和複制資料的長度,傳回值是實際複制資料的長度。

首先,它可以替代前面的read()和write()這兩個系統調用,這樣就可以減少一次系統調用,也就減少了2次上下文切換的開銷。其次,該系統調用,可以直接把核心緩沖區裡的資料拷貝到socket緩沖區裡,不再拷貝到使用者态,這樣就隻有2次上下文切換,和3次資料拷貝。如下圖:

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

帶有scatter/gather的sendfile 方式:Linux 2.4核心進行了優化,提供了帶有scatter/gather的sendfile操作,這個操作可以把最後一次CPU COPY去除。其原理就是在核心空間Read BUffer和Socket Buffer不做資料複制,而是将Read Buffer的記憶體位址、偏移量記錄到相應的Socket Buffer中,這樣就不需要複制。其本質和虛拟記憶體的解決方法思路一緻,就是記憶體位址的記錄。

你可以在你的Linux系統通過下面這個指令,檢視網卡是否支援scatter-gather特性:

$ ethtool -k eth0 | grep scatter-gather

scatter-gather: on           

于是,從Linux核心2.4版本開始起,對于支援網卡支援SG-DMA技術的情況下,sendfile()系統調用的過程發生了點變化,具體過程如下:

第一步,通過DMA将磁盤上的資料拷貝到核心緩沖區裡;第二步,緩沖區描述符和資料長度傳到socket緩沖區,這樣網卡的SG-DMA 控制器就可以直接将核心緩存中的資料拷貝到網卡的緩沖區裡,此過程不需要将資料從作業系統核心緩沖區拷貝到socket緩沖區中,這樣就減少了一次資料拷貝。是以,這個過程之中,隻進行了2次資料拷貝,如下圖:

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解
  • splice 方式

splice調用和sendfile非常相似,使用者應用程式必須擁有兩個已經打開的檔案描述符,一個表示輸入裝置,一個表示輸出裝置。與sendfile不同的是,splice允許任意兩個檔案互相連接配接,而并不隻是檔案與socket進行資料傳輸。對于從一個檔案描述符發送資料到socket這種特例來說,一直都是使用sendfile系統調用,而splice一直以來就隻是一種機制,它并不僅限于sendfile的功能。也就是說sendfile是splice的一個子集。

splice()是基于Linux的管道緩沖區(pipe buffer)機制實作的,是以splice()的兩個入參檔案描述符要求必須有一個是管道裝置。

使用splice()完成一次磁盤檔案到網卡的讀寫過程如下:

首先,使用者程序調用pipe(),從使用者态陷入核心态;建立匿名單向管道,pipe()傳回,上下文從核心态切換回使用者态;其次,使用者程序調用splice(),從使用者态陷入核心态;再次,DMA控制器将資料從硬碟拷貝到核心緩沖區,從管道的寫入端"拷貝"進管道,splice()傳回,上下文從核心态回到使用者态;從次,使用者程序再次調用splice(),從使用者态陷入核心态;最後,核心把資料從管道的讀取端拷貝到socket緩沖區,DMA控制器将資料從socket緩沖區拷貝到網卡;另外,splice()傳回,上下文從核心态切換回使用者态。

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

在Linux2.6.17版本引入了splice,而在Linux 2.6.23版本中,sendfile機制的實作已經沒有了,但是其API及相應的功能還在,隻不過API及相應的功能是利用了splice機制來實作的。和sendfile不同的是,splice不需要硬體支援。

六、零拷貝的實際應用

1、Kafka

事實上,Kafka這個開源項目,就利用了「零拷貝」技術,進而大幅提升了 I/O的吞吐率,這也是Kafka在處理海量資料為什麼這麼快的原因之一。如果你追溯Kafka檔案傳輸的代碼,你會發現,最終它調用了Java NIO庫裡的 transferTo方法:

@Overridepublic
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、零拷貝還是最優選嗎

在大檔案傳輸的場景下,零拷貝技術并不是最優選擇;因為在零拷貝的任何一種實作中,都會有「DMA将資料從磁盤拷貝到核心緩存區——Page Cache」這一步,但是,在傳輸大檔案(GB級别的檔案)的時候,PageCache會不起作用,那就白白浪費DMA多做的一次資料拷貝,造成性能的降低,即使使用了PageCache的零拷貝也會損失性能。

這是因為在大檔案傳輸場景下,每當使用者通路這些大檔案的時候,核心就會把它們載入PageCache中,PageCache空間很快被這些大檔案占滿;且由于檔案太大,可能某些部分的檔案資料被再次通路的機率比較低,這樣就會帶來2個問題:PageCache由于長時間被大檔案占據,其他「熱點」的小檔案可能就無法充分使用到PageCache,于是這樣磁盤讀寫的性能就會下降了;PageCache中的大檔案資料,由于沒有享受到緩存帶來的好處,但卻耗費DMA多拷貝到PageCache一次。

2、異步I/O+direct I/O

那麼大檔案傳輸場景下我們該選擇什麼方案呢?讓我們先來回顧一下我們在文章開頭介紹DMA時最早提到過的同步I/O:

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

這裡的同步展現在當程序調用read方法讀取檔案時,程序實際上會阻塞在 read方法調用,因為要等待磁盤資料的傳回,并且我們當然不希望程序在讀取大檔案時被阻塞,對于阻塞的問題,可以用異步I/O來解決,即:

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

它把讀操作分為兩部分:前半部分,核心向磁盤發起讀請求,但是可以不等待資料就位就傳回,于是程序此時可以處理其他任務;後半部分,當核心将磁盤中的資料拷貝到程序緩沖區後,程序将接收到核心的通知,再去處理資料。

而且,我們可以發現,異步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,就無法享受核心的這兩點的優化:核心的I/O排程算法會緩存盡可能多的I/O請求在PageCache中,最後「合并」成一個更大的I/O請求再發給磁盤,這樣做是為了減少磁盤的尋址操作;核心也會「預讀」後續的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以前,總傳輸大小、使用者的對齊緩沖區起始位址、檔案偏移量必須都是邏輯檔案系統的資料塊大小的倍數,這裡說的資料塊(block)是一個邏輯概念,是檔案系統捆綁一定數量的連續扇區而來,是以通常稱為 “檔案系統邏輯塊”,可通過以下指令擷取:

blockdev --getss           

Linux2.6以後對齊的基數變為實體上的存儲媒體的sector size扇區大小,對應實體存儲媒體的最小存儲粒度,可通過以下指令擷取:

blockdev --getpbsz           

帶來這個限制的原因也很簡單,記憶體對齊這件小事通常是核心來處理的,而O_DIRECT繞過了核心空間,那麼核心處理的所有事情都需要使用者自己來處理。

第二點,O_DIRECT 平台不相容。這應該是大部分跨平台應用需要注意到的點,O_DIRECT本身就是Linux中才有的東西,在語言層面/應用層面需要考慮這裡的相容性保證,比如在Windows下其實也有類似的機制FILE_FLAG_NO_BUFFERIN用法類似;再比如macOS下的F_NOCACHE雖然類似O_DIRECT,但實際使用中也有差距。

第三點,不要并發地運作 fork 和 O_DIRECT I/O。如果O_DIRECT I/O中使用到的記憶體buffer是一段私有的映射(虛拟記憶體),如任何使用上文中提到過的mmap并以MAP_PRIVATE flag 聲明的虛拟記憶體,那麼相關的O_DIRECT I/O(不管是異步 I/O / 其它子線程中的 I/O)都必須在調用fork系統調用前執行完畢;否則會造成資料污染或産生未定義的行為。

以下情況這個限制不存在:相關的記憶體buffer是使用shmat配置設定或是使用mmap以MAP_SHARED flag聲明的;相關的記憶體buffer是使用madvise以MADV_DONTFORK聲明的(注意這種方式下該記憶體buffer在子程序中不可用)。

第四點,避免對同一檔案混合使用 O_DIRECT 和普通 I/O。在應用層需要避免對同一檔案(尤其是對同一檔案的相同偏移區間内)混合使用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種對齊規則:io偏移扇區對齊,長度扇區對齊,記憶體 buffer 位址扇區對齊;前兩個還比較好滿足,但是配置設定的記憶體位址僅憑原生的手段是無法直接達成的。

先對比一下c語言,libc庫是調用posix_memalign 直接配置設定出符合要求的記憶體塊,但Golang中要怎麼實作呢?在Golang中,io的buffer其實就是位元組數組,自然是用make來配置設定,如下:

buffer := make([]byte, 4096)           

但buffer中的data位元組數組首位址并不一定是對齊的。方法也很簡單,就是先配置設定一個比預期要大的記憶體塊,然後在這個記憶體塊裡找對齊位置;這是一個任何語言皆通用的方法,在 Go 裡也是可用的。

比如,我現在需要一個4096大小的記憶體塊,要求位址按照 512 對齊,可以這樣做:先配置設定4096+512大小的記憶體塊,假設得到的記憶體塊首位址是 p1;然後在[p1, p1+512] 這個位址範圍找,一定能找到512對齊的位址p2;傳回 p2,使用者能正常使用 [p2, p2+4096] 這個範圍的記憶體塊而不越界。以上就是基本原理了,具體實作如下:

// 從 block 首位址往後找到符合 AlignSize 對齊的位址并傳回

// 這裡很巧妙的使用了位運算,性能upup

func 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 := 0

   if 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模式打開檔案:

// 建立句柄
fp, err := directio.OpenFile(file, os.O_RDONLY, 0666)           

讀資料:

// 建立位址按照 4k 對齊的記憶體塊
buffer := directio.AlignedBlock(directio.BlockSize)
// 把檔案資料讀到記憶體塊中
_, err := io.ReadFull(fp, buffer)           

八、核心緩沖區和使用者緩沖區之間的傳輸優化

到目前為止,我們讨論的zero-copy技術都是基于減少甚至是避免使用者空間和核心空間之間的CPU資料拷貝的,雖然有一些技術非常高效,但是大多都有适用性很窄的問題,比如 sendfile()、splice() 這些,效率很高,但是都隻适用于那些使用者程序不需要再處理資料的場景,比如靜态檔案伺服器或者是直接轉發資料的代理伺服器。

前面提到過的虛拟記憶體機制和mmap等都表明,通過在不同的虛拟位址上重新映射頁面可以實作在使用者程序和核心之間虛拟複制和共享記憶體;是以如果要在實作在使用者程序内處理資料(這種場景比直接轉發資料更加常見)之後再發送出去的話,使用者空間和核心空間的資料傳輸就是不可避免的,既然避無可避,那就隻能選擇優化了。

兩種優化使用者空間和核心空間資料傳輸的技術:動态重映射與寫時拷貝 (Copy-on-Write)、緩沖區共享(Buffer Sharing)。

1、寫時拷貝 (Copy-on-Write)

前面提到過過利用記憶體映射(mmap)來減少資料在使用者空間和核心空間之間的複制,通常使用者程序是對共享的緩沖區進行同步阻塞讀寫的,這樣不會有線程安全問題,但是很明顯這種模式下效率并不高,而提升效率的一種方法就是異步地對共享緩沖區進行讀寫,而這樣的話就必須引入保護機制來避免資料沖突問題,COW(Copy on Write) 就是這樣的一種技術。

COW是一種建立在虛拟記憶體重映射技術之上的技術,是以它需要 MMU 的硬體支援,MMU會記錄目前哪些記憶體頁被标記成隻讀,當有程序嘗試往這些記憶體頁中寫資料的時候,MMU 就會抛一個異常給作業系統核心,核心處理該異常時為該程序配置設定一份實體記憶體并複制資料到此記憶體位址,重新向 MMU 發出執行該程序的寫操作。

下圖為COW在Linux中的應用之一:fork/clone,fork出的子程序共享父程序的實體空間,當父子程序有記憶體寫入操作時,read-only記憶體頁發生中斷,将觸發的異常的記憶體頁複制一份(其餘的頁還是共享父程序的)。

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

局限性:COW這種零拷貝技術比較适用于那種多讀少寫進而使得COW事件發生較少的場景,而在其它場景下反而可能造成負優化,因為COW事件所帶來的系統開銷要遠遠高于一次CPU拷貝所産生的。

此外,在實際應用的過程中,為了避免頻繁的記憶體映射,可以重複使用同一段記憶體緩沖區,是以,你不需要在隻用過一次共享緩沖區之後就解除掉記憶體頁的映射關系,而是重複循環使用,進而提升性能。

但這種記憶體頁映射的持久化并不會減少由于頁表往返移動/換頁和TLB flush所帶來的系統開銷,因為每次接收到COW事件之後對記憶體頁而進行加鎖或者解鎖的時候,記憶體頁的隻讀标志 (read-ony) 都要被更改為 (write-only)。

COW的實際應用——Redis的持久化機制:Redis作為典型的記憶體型應用,一定是有核心緩沖區和使用者緩沖區之間的傳輸優化的。

Redis的持久化機制中,如果采用bgsave或者bgrewriteaof 指令,那麼會 fork 一個子程序來将資料存到磁盤中。總體來說Redis的讀操作是比寫操作多的(在正确的使用場景下),是以這種情況下使用COW可以減少 fork() 操作的阻塞時間。

語言層面的應用

寫時複制的思想在很多語言中也有應用,相比于傳統的深層複制,能帶來很大性能提升;比如C++98标準下的 std::string 就采用了寫時複制的實作:

std::string x("Hello");

std::string y = x;  // x、y 共享相同的 buffer

y += ", World!";    // 寫時複制,此時 y 使用一個新的 buffer

                    // x 依然使用舊的 buffer           

Golang中的string,slice也使用了類似的思想,在複制/切片等操作時都不會改變底層數組的指向,變量共享同一個底層數組,僅當進行append / 修改等操作時才可能進行真正的copy(append時如果超過了目前切片的容量,就需要配置設定新的記憶體)。

2、緩沖區共享(Buffer Sharing)

從前面的介紹可以看出,傳統的Linux I/O接口,都是基于複制/拷貝的:資料需要在作業系統核心空間和使用者空間的緩沖區之間進行拷貝。在進行I/O操作之前,使用者程序需要預先配置設定好一個記憶體緩沖區,使用read()系統調用時,核心會将從存儲器或者網卡等裝置讀入的資料拷貝到這個使用者緩沖區裡。而使用write()系統調用時,則是把使用者記憶體緩沖區的資料拷貝至核心緩沖區。

為了實作這種傳統的I/O模式,Linux必須要在每一個I/O操作時都進行記憶體虛拟映射和解除。這種記憶體頁重映射的機制的效率嚴重受限于緩存體系結構、MMU位址轉換速度和TLB命中率。如果能夠避免處理I/O請求的虛拟位址轉換和TLB重新整理所帶來的開銷,則有可能極大地提升I/O性能。而緩沖區共享就是用來解決上述問題的一種技術(說實話我覺得有些套娃的味道了)。

作業系統核心開發者們實作了一種叫fbufs的緩沖區共享的架構,也即快速緩沖區(Fast Buffers),使用一個fbuf緩沖區作為資料傳輸的最小機關。使用這種技術需要調用新的作業系統API,使用者區和核心區、核心區之間的資料都必須嚴格地在fbufs這個體系下進行通信。fbufs為每一個使用者程序配置設定一個 buffer pool,裡面會儲存預配置設定(也可以使用的時候再配置設定)好的 buffers,這些buffers會被同時映射到使用者記憶體空間和核心記憶體空間。fbufs隻需通過一次虛拟記憶體映射操作即可建立緩沖區,有效地消除那些由存儲一緻性維護所引發的大多數性能損耗。

共享緩沖區技術的實作需要依賴于使用者程序、作業系統核心、以及I/O子系統(裝置驅動程式,檔案系統等)之間協同工作。比如,設計得不好的使用者程序容易就會修改已經發送出去的fbuf進而污染資料,更要命的是這種問題很難debug。雖然這個技術的設計方案非常精彩,但是它的門檻和限制卻不比前面介紹的其他技術少:首先會對作業系統API造成變動,需要使用新的一些API調用,其次還需要裝置驅動程式配合改動,還有由于是記憶體共享,核心需要很小心謹慎地實作對這部分共享的記憶體進行資料保護和同步的機制,而這種并發的同步機制是非常容易出bug的進而又增加了核心的代碼複雜度,等等。是以這一類的技術還遠遠沒有到發展成熟和廣泛應用的階段,目前大多數的實作都還處于實驗階段。

總結

從早期的I/O到DMA,解決了阻塞CPU的問題;而為了省去I/O過程中不必要的上下文切換和資料拷貝過程,零拷貝技術就出現了。所謂的零拷貝(Zero-copy)技術,就是完完全全不需要在記憶體層面拷貝資料,省去CPU搬運資料的過程。

零拷貝技術的檔案傳輸方式相比傳統檔案傳輸的方式,減少了2次上下文切換和資料拷貝次數,隻需要2次上下文切換和資料拷貝次數,就可以完成檔案的傳輸,而且2次的資料拷貝過程,都不需要通過CPU,2次都是由DMA來搬運。總體來看,零拷貝技術至少可以把檔案傳輸的性能提高一倍以上,以下是各方案詳細的成本對比:

「運維」從Linux零拷貝深入了解Linux-I/O萬字圖解

零拷貝技術是基于PageCache的,PageCache會緩存最近通路的資料,提升了通路緩存資料的性能,同時,為了解決機械硬碟尋址慢的問題,它還協助 I/O排程算法實作了I/O合并與預讀,這也是順序讀比随機讀性能好的原因之一;這些優勢,進一步提升了零拷貝的性能。

但當面對大檔案傳輸時,不能使用零拷貝,因為可能由于PageCache被大檔案占據,導緻「熱點」小檔案無法利用到PageCache的問題,并且大檔案的緩存命中率不高,這時就需要使用「異步I/O+direct I/O」的方式;在使用direct I/O時也需要注意許多的坑點,畢竟連Linus也會被O_DIRECT 'disturbed'到。

而在更廣泛的場景下,我們還需要注意到核心緩沖區和使用者緩沖區之間的傳輸優化,這種方式側重于在使用者程序的緩沖區和作業系統的頁緩存之間的CPU 拷貝的優化,延續了以往那種傳統的通信方式,但更靈活。

I/O相關的各類優化自然也已經深入到了日常我們接觸到的語言、中間件以及資料庫的方方面面,通過了解和學習這些技術和思想,也能對日後自己的程式設計以及性能優化上有所啟發。

不要忘記開頭引言中提及的另一篇技術文章。

文章來源:騰訊程式員_https://mp.weixin.qq.com/s?__biz=MzI2NDU4OTExOQ==&mid=2247570454&idx=2&sn=fe5976e862b193d03de576d0761553d9&chksm=eaa9ce46ddde475058a75c8830ca36d8f233758168e7ac42a7e636b307b7b46a1825599cd1be&scene=21#wechat_redirect