從Linux零拷貝深入瞭解Linux-I/O

圖片

導言 | 本文邀請到騰訊CSIG後臺開發工程師kevineluo從文件傳輸場景以及零拷貝技術深究Linux I/O的發展過程、優化手段以及實際應用。I/O相關的各類優化已經深入到了日常開發者接觸到的語言、中間件以及數據庫的方方面面。通過了解和學習相關技術和思想,開發者能對日後自己的程序設計以及性能優化上有所啓發。

圖片

前言

存儲器是計算機的核心部件之一,在完全理想的狀態下,存儲器應該要同時具備以下三種特性:第一,速度足夠快:存儲器的存取速度應當快於CPU執行一條指令,這樣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直接交換數據的內部存儲器。它可以隨時讀寫(刷新時除外),而且速度很快,通常作爲操作系統或其他正在運行中的程序的臨時資料存儲介質。

至於磁盤則是圖中離用戶最遠的一層了,讀寫速度相差內存上百倍;另一方面自然針對磁盤操作的優化也非常多,如零拷貝、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)機制來對文件中的數據進行緩存。

圖片

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

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

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

虛擬內存爲每個進程提供了一個一致的、私有且連續完整的內存空間;所有現代操作系統都使用虛擬內存,使用虛擬地址取代物理地址,主要有以下幾點好處:

第一點,利用上述的第一條特性可以優化,可以把內核空間和用戶空間的虛擬地址映射到同一個物理地址,這樣在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是被阻塞的狀態,無法執行其他任務。

圖片

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

計算機科學家們發現了事情的嚴重性後,於是就發明了DMA技術,也就是**直接內存訪問(Direct Memory Access)**技術。簡單理解就是,在進行I/O 設備和內存的數據傳輸的時候,數據搬運的工作全部交給DMA控制器,而 CPU不再參與任何與數據搬運相關的事情,這樣CPU就可以去處理別的事務

具體流程如下圖:

圖片

首先,用戶進程調用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)

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

圖片

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

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

所以,要想提高文件傳輸的性能,就需要減少用戶態與內核態的上下文切換內存拷貝的次數。

如何優化傳統文件傳輸——減少「用戶態與內核態的上下文切換」:讀取磁盤數據的時候,之所以要發生上下文切換,這是因爲用戶空間沒有權限操作磁盤或網卡,內核的權限最高,這些操作設備的過程都需要交由操作系統內核來完成,所以一般要通過內核去完成某些任務的時候,就需要使用操作系統提供的系統調用函數。

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

減少「數據拷貝」次數:前面提到,傳統的文件傳輸方式會歷經4次數據拷貝;但很明顯的可以看到:從內核的讀緩衝區拷貝到用戶的緩衝區從用戶的緩衝區裏拷貝到socket的緩衝區這兩步是沒有必要的。

因爲在下載文件,或者說廣義的文件傳輸場景中,我們並不需要在用戶空間對數據進行再加工,所以數據並不需要回到用戶空間中。

圖片

零拷貝

那麼零拷貝技術就應運而生了,它就是爲了解決我們在上面提到的場景——跨過與用戶態交互的過程,直接將數據從文件系統移動到網絡接口而產生的技術。

1)零拷貝實現原理

零拷貝技術實現的方式通常有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()系統調用函數會在調用進程的虛擬地址空間中創建一個新映射,直接把內核緩衝區裏的數據「映射」到用戶空間,這樣,操作系統內核與用戶空間就不需要再進行任何的數據拷貝操作。

圖片

具體過程如下:首先,應用進程調用了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次數據拷貝。如下圖:

圖片

帶有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次數據拷貝,如下圖:

圖片

  • 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()返回,上下文從內核態切換回用戶態。

圖片

在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);}

2)Nginx

Nginx也支持零拷貝技術,一般默認是開啓零拷貝技術,這樣有利於提高文件傳輸的效率,是否開啓零拷貝技術的配置如下:

http
{
...    
   sendfile on
...
}

image.png

大文件傳輸場景

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:

圖片

這裏的同步體現在當進程調用read方法讀取文件時,進程實際上會阻塞在 read方法調用,因爲要等待磁盤數據的返回,並且我們當然不希望進程在讀取大文件時被阻塞,對於阻塞的問題,可以用異步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繞過了內核空間,那麼內核處理的所有事情都需要用戶自己來處理,後臺回覆“Linux I/O”獲取詳細解釋。

第二點,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內存頁發生中斷,將觸發的異常的內存頁複製一份(其餘的頁還是共享父進程的)。

圖片

侷限性: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來搬運**。總體來看,零拷貝技術至少可以把文件傳輸的性能提高一倍以上,以下是各方案詳細的成本對比:

圖片

零拷貝技術是基於PageCache的,PageCache會緩存最近訪問的數據,提升了訪問緩存數據的性能,同時,爲了解決機械硬盤尋址慢的問題,它還協助 I/O調度算法實現了I/O合併與預讀,這也是順序讀比隨機讀性能好的原因之一;這些優勢,進一步提升了零拷貝的性能。

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

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

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

你可能感興趣的騰訊工程師作品

| 由淺入深讀透vue源碼:diff算法

| 優雅應對故障:QQ音樂怎麼做高可用架構體系?

| PB 級數據秒級分析:騰訊雲原生湖倉DLC 架構揭祕

| 詳解全網最快Go泛型跳錶【內附源碼】

技術盲盒:前端後端AI與算法運維工程師文化

公衆號後臺回覆“Linux I/O”,領本文作者推薦資料。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章