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

轉載&學習文章:從Linux零拷貝深入瞭解I/O

本文將從文件傳輸場景以及零拷貝技術深究 Linux I/O 的發展過程、優化手段以及實際應用。
image

前言

存儲器是計算機的核心部件之一,在完全理想的狀態下,存儲器應該要同時具備以下三種特性:

  • 速度足夠快:存儲器的存取速度應當快於 CPU 執行一條指令,這樣 CPU 的效率纔不會受限於存儲器;
  • 容量足夠大:容量能夠存儲計算機所需的全部數據;
  • 價格足夠便宜:價格低廉,所有類型的計算機都能配備。

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

圖片

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

  • 存取速度最快的是寄存器,因爲寄存器的製作材料和 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 直接交換數據的內部存儲器。它可以隨時讀寫(刷新時除外),而且速度很快,通常作爲操作系統或其他正在運行中的程序的臨時資料存儲介質。

  • 至於磁盤則是圖中離用戶最遠的一層了,讀寫速度相差內存上百倍;另一方面自然針對磁盤操作的優化也非常多,如零拷貝direct I/O異步 I/O 等等,這些優化的目的都是爲了提高系統的吞吐量;另外操作系統內核中也有磁盤高速緩存區PageCacheTLB等,可以有效的減少磁盤的訪問次數。

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

時間花在cpu上更多就是cpu密集型,花在IO上多就是IO密集型

傳統的 Linux 操作系統的標準 I/O 接口是基於數據拷貝操作的,即 I/O 操作會導致數據在操作系統內核地址空間的緩衝區和用戶進程地址空間定義的緩衝區之間進行傳輸。設置緩衝區最大的好處是可以減少磁盤 I/O 的操作,如果所請求的數據已經存放在操作系統的高速緩衝存儲器中,那麼就不需要再進行實際的物理磁盤 I/O 操作;然而傳統的 Linux I/O 在數據傳輸過程中的數據拷貝操作深度依賴 CPU,也就是說 I/O 過程需要 CPU 去執行數據拷貝的操作,因此導致了極大的系統開銷,限制了操作系統有效進行數據傳輸操作的能力。

image-20221211223818510

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

基礎知識

DMA

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

image-20221211225014368

爲什麼要有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 控制器。

MMU

Memory Management Unit,內存管理單元,主要實現:

  • 競爭訪問保護管理需求:需要嚴格的訪問保護,動態管理哪些內存頁/段或區,爲哪些應用程序所用。這屬於資源的競爭訪問管理需求;
  • 高效的翻譯轉換管理需求:需要實現快速高效的映射翻譯轉換,否則系統的運行效率將會低下;

Page Cache

  • 爲了避免每次讀寫文件時,都需要對硬盤進行讀寫操作,Linux 內核使用頁緩存(Page Cache)機制來對文件中的數據進行緩存。

  • page cache屬於RAM

    • 圖片

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

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

虛擬內存

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

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

利用上述的第一條特性可以優化,可以把內核空間和用戶空間的虛擬地址映射到同一個物理地址,這樣在 I/O 操作時就不需要來回複製了。

圖片

  • 多個虛擬內存可以指向同一個物理地址;
  • 虛擬內存空間可以遠遠大於物理內存空間;
  • 應用層面可管理連續的內存空間,減少出錯。

NFS文件系統

網絡文件系統是 FreeBSD 支持的文件系統中的一種,也被稱爲 NFS;NFS 允許一個系統在網絡上與它人共享目錄和文件,通過使用 NFS,用戶和程序可以象訪問本地文件 一樣訪問遠端系統上的文件。

NFS協議和FTP協議:

  • FTP協議:只能上傳、下載,無法實現在線編輯等操作,安全性低,傳輸效率低,一般使用SFTP協議。
  • NFS協議:協議簡單,傳輸效率高,沒有加密功能,安全性差。

Copy-on-write

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

問題和方法

傳統文件傳輸的缺陷

有了 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 的緩衝區」這兩步是沒有必要的。

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

零拷貝

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

零拷貝實現原理

零拷貝技術實現的方式通常有 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的功能。也就是說 sendfilesplice 的一個子集。

splice() 是基於 Linux管道緩衝區 (pipe buffer) 機制實現的,所以splice()的兩個入參文件描述符要求必須有一個是管道設備。

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

  • 用戶進程調用 pipe(),從用戶態陷入內核態;創建匿名單向管道,pipe() 返回,上下文從內核態切換回用戶態;
  • 用戶進程調用 splice(),從用戶態陷入內核態;
  • DMA 控制器將數據從硬盤拷貝到內核緩衝區,從管道的寫入端"拷貝"進管道,splice()返回,上下文從內核態回到用戶態;
  • 用戶進程再次調用 splice(),從用戶態陷入內核態;
  • 內核把數據從管道的讀取端拷貝到socket緩衝區,DMA 控制器將數據從socket緩衝區拷貝到網卡;
  • splice() 返回,上下文從內核態切換回用戶態。

圖片

在 Linux 2.6.17 版本引入了 splice,而在 Linux 2.6.23 版本中, sendfile 機制的實現已經沒有了,但是其 API 及相應的功能還在,只不過 API 及相應的功能是利用了 splice 機制來實現的。

sendfile 不同的是,splice 不需要硬件支持。

零拷貝的實際應用

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() 系統調用函數。

Nginx

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

http {
...
    sendfile on
...
}

大文件傳輸場景

零拷貝還是最優選嗎

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

這是因爲在大文件傳輸場景下,每當用戶訪問這些大文件的時候,內核就會把它們載入 PageCache 中,PageCache 空間很快被這些大文件佔滿;且由於文件太大,可能某些部分的文件數據被再次訪問的概率比較低,這樣就會帶來 2 個問題:

  • PageCache 由於長時間被大文件佔據,其他「熱點」的小文件可能就無法充分使用到 PageCache,於是這樣磁盤讀寫的性能就會下降了;
  • PageCache 中的大文件數據,由於沒有享受到緩存帶來的好處,但卻耗費 DMA 多拷貝到 PageCache 一次。

異步 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」,否則使用「零拷貝技術」。

使用 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,但實際使用中也有差距(參考這個issue)。

不要併發地運行 fork 和 O_DIRECT I/O

如果O_DIRECT I/O中使用到的內存buffer是一段私有的映射(虛擬內存),如任何使用上文中提到過的mmap並以MAP_PRIVATE flag 聲明的虛擬內存,那麼相關的O_DIRECT I/O(不管是異步 I/O / 其它子線程中的 I/O)都必須在調用fork系統調用前執行完畢;否則會造成數據污染或產生未定義的行爲(實例可參考這個Page)。

以下情況這個限制不存在:

  • 相關的內存buffer是使用shmat分配或是使用mmapMAP_SHARED flag 聲明的;
  • 相關的內存buffer是使用madviseMADV_DONTFORK聲明的(注意這種方式下該內存buffer在子進程中不可用)。
避免對同一文件混合使用 O_DIRECT 和普通 I/O

在應用層需要避免對同一文件(尤其是對同一文件的相同偏移區間內)混合使用O_DIRECT和普通I/O;即使我們的文件系統能夠幫我們處理和保證這裏的一致性問題,總體來說整個I/O吞吐量也會比單獨使用某一種I/O方式要小。

同樣的,應用層也要避免對同一文件混合使用direct I/Ommap。

NFS 協議下的 O_DIRECT

雖然NFS文件系統就是爲了讓用戶像訪問本地文件一樣去訪問網絡文件,但O_DIRECTNFS文件系統中的表現和本地文件系統不同,比較老版本的內核或是魔改過的內核可能並不支持這種組合。

這是因爲在NFS協議中並不支持傳遞flag 參數到服務器,所以O_DIRECT I/O實際上只繞過了本地客戶端的Page Cache,但服務端/同步客戶端仍然會對這些I/O進行cache

當客戶端請求服務端進行I/O同步來保證O_DIRECT的同步語義時,一些服務器的性能表現不佳(尤其是當這些I/O很小時);還有一些服務器乾脆設置爲欺騙客戶端,直接返回客戶端「數據已寫入存儲介質」,這樣就可以一定程度上避免I/O同步帶來的性能損失,但另一方面,當服務端斷電時就無法保證未完成I/O同步的數據的數據完整性了。

LinuxNFS客戶端也沒有上面說過的地址對齊的限制。

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

寫時拷貝 (Copy-on-Write)

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

COW 是一種建立在虛擬內存重映射技術之上的技術,因此它需要 MMU 的硬件支持,MMU 會記錄當前哪些內存頁被標記成只讀,當有進程嘗試往這些內存頁中寫數據的時候,MMU 就會拋一個異常給操作系統內核,內核處理該異常時爲該進程分配一份物理內存並複製數據到此內存地址,重新向 MMU 發出執行該進程的寫操作。

下圖爲COWLinux中的應用之一: fork / clonefork出的子進程共享父進程的物理空間,當父子進程有內存寫入操作時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 / 修改等操作時纔可能進行真正的copyappend時如果超過了當前切片的容量,就需要分配新的內存)。

緩衝區共享 (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/ODMA,解決了阻塞CPU的問題;而爲了省去I/O過程中不必要的上下文切換和數據拷貝過程,零拷貝技術就出現了。

所謂的零拷貝(Zero-copy)技術,就是完完全全不需要在內存層面拷貝數據,省去CPU搬運數據的過程。

零拷貝技術的文件傳輸方式相比傳統文件傳輸的方式,減少了 2 次上下文切換和數據拷貝次數,只需要 2 次上下文切換和數據拷貝次數,就可以完成文件的傳輸,而且 2 次的數據拷貝過程,都不需要通過 CPU,2 次都是由 DMA 來搬運

總體來看,零拷貝技術至少可以把文件傳輸的性能提高一倍以上,以下是各方案詳細的成本對比:

CPU 拷貝 DMA 拷貝 系統調用 上下文切換 硬件依賴 支持任意類型輸入/輸出描述符
傳統方法 2 2 read/write 4
內存映射 1 2 mmap/write 4
sendfile 1 2 sendfile 2
sendfile(scatter/gather copy) 0 2 sendfile 2
splice 0 2 splice 2

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

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

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

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

逐漸看不懂了,後面再學習吧~

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