深入探索 linux 的零拷貝(zero-copy):底層技術原理與代碼實現
前言
I/O 或輸入 / 輸出通常意味着中央處理器 (CPU) 與外部設備(如磁盤、鼠標、鍵盤等)之間的讀寫。在深入研究零拷貝之前,有必要指出磁盤 I/O(包括磁盤設備和其他塊導向設備)和網絡 I/O 之間的區別。
磁盤 I/O 的常用接口是 read()、write() 和 seek()。同時,網絡 I/O 的接口通常是套接字相關接口。套接字接口背後發生的事情是發送方和接收方計算機創建自己的套接字,並設置發送或接收文件的連接。如今,越來越多的 應用程序已經實現了從 CPU 綁定到 I/O 綁定的轉變,這意味着 I/O 的性能通常是這些應用程序的瓶頸。
一般來說,用戶不能直接在內核操作任何數據,包括讀寫。數據必須從內核複製到用戶內存,而這個操作必須由 CPU 來完成,因此帶來了很大的性能損失。這時零拷貝就派上用場了。零拷貝的主要原理是儘可能地消除或減少 CPU 在用戶內存和內核內存之間的數據複製,從而減少相應的中斷和模式切換次數,從而提高網絡 I/O 的 I/O 性能。
先備知識
物理內存
物理內存,我們平時所稱的內存也叫隨機訪問存儲器( random-access memory )也叫 RAM 。而 RAM 分爲兩類:
-
一類是靜態 RAM( SRAM ),這類 SRAM 用於 CPU 高速緩存 L1Cache,L2Cache,L3Cache。其特點是訪問速度快,訪問速度爲 1 - 30 個時鐘週期,但是容量小,造價高。
-
另一類則是動態 RAM (DRAM),這類 DRAM 用於我們常說的主存上,其特點的是訪問速度慢(相對高速緩存),訪問速度爲 50 - 200 個時鐘週期,但是容量大,造價便宜些(相對高速緩存)。
內存由一個一個的存儲器模塊(memory module)組成,它們插在主板的擴展槽上。
常見的存儲器模塊通常以 64 位爲單位( 8 個字節)傳輸數據到存儲控制器上或者從存儲控制器傳出數據。
多個存儲器模塊連接到存儲控制器上,就聚合成了主存。
DRAM 芯片就包裝在存儲器模塊中,每個存儲器模塊中包含 8 個 DRAM 芯片,依次編號爲 0 - 7
虛擬內存
虛擬內存 使得應用程序認爲它擁有連續的可用的內存(一個連續完整的地址空間),而實際上,它通常是被分隔成多個物理內存碎片,還有部分暫時存儲在外部磁盤存儲器上,在需要時進行數據交換。
與沒有使用虛擬內存技術的系統相比,使用這種技術的系統使得大型程序的編寫變得更容易,對真正的物理內存(例如 RAM)的使用也更有效率。目前,大多數操作系統都使用了虛擬內存,如 Windows 家族的 “虛擬內存”;Linux 的“交換空間” 等。
現代處理器使用的是一種稱爲 虛擬尋址 (Virtual Addressing) 的尋址方式。使用虛擬尋址,CPU 需要將虛擬地址翻譯成物理地址,這樣才能訪問到真實的物理內存。
實際上完成虛擬地址轉換爲物理地址轉換的硬件是 CPU 中含有一個被稱爲內存管理單元(Memory Management Unit, MMU) 的硬件。如下圖所示:
通過虛擬地址訪問內存有以下優勢:
-
程序可以使用一系列相鄰的虛擬地址來訪問物理內存中不相鄰的大內存緩衝區。
-
程序可以使用一系列虛擬地址來訪問大於可用物理內存的內存緩衝區。當物理內存的供應量變小時,內存管理器會將物理內存頁(通常大小爲 4 KB)保存到磁盤文件。數據或代碼頁會根據需要在物理內存與磁盤之間移動。
-
不同進程使用的虛擬地址彼此隔離。一個進程中的代碼無法更改正在由另一進程或操作系統使用的物理內存。
內核空間和用戶空間
操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的權限。爲了避免用戶進程直接操作內核,保證內核安全,操作系統將虛擬內存劃分爲兩部分,一部分是內核空間(Kernel-space),一部分是用戶空間(User-space)。在 Linux 系統中,內核模塊運行在內核空間,對應的進程處於內核態;而用戶程序運行在用戶空間,對應的進程處於用戶態。
操作系統爲每個進程都分配了內存空間,一部分是用戶空間,一部分是內核空間。內核空間是操作系統內核訪問的區域,是受保護的內存空間,而用戶空間是用戶應用程序訪問的內存區域。
內核空間
主要提供進程調度、內存分配、連接硬件資源等功能
內核空間總是駐留在內存中,它是爲操作系統的內核保留的。應用程序是不允許直接在該區域進行讀寫或直接調用內核代碼定義的函數的。按訪問權限可以分爲進程私有和進程共享兩塊區域。
-
進程私有的虛擬內存:每個進程都有單獨的內核棧、頁表、task 結構以及 mem_map 結構等。
-
進程共享的虛擬內存:屬於所有進程共享的內存區域,包括物理存儲器、內核數據和內核代碼區域。
用戶空間
每個普通的用戶進程都有一個單獨的用戶空間,處於用戶態的進程不能訪問內核空間中的數據,也不能直接調用內核函數的 ,因此要進行系統調用的時候,就要將進程切換到內核態纔行。用戶空間包括以下幾個內存區域:
-
運行時棧:由編譯器自動釋放,存放函數的參數值,局部變量和方法返回值等。棧區是從高地址位向低地址位增長的,是一塊連續的內在區域,最大容量是由系統預先定義好的,申請的棧空間超過這個界限時會提示溢出,用戶能從棧中獲取的空間較小。
-
運行時堆:用於存放進程運行中被動態分配的內存段,位於 BSS 和棧中間的地址位。由卡發人員申請分配(malloc)和釋放(free)。堆是從低地址位向高地址位增長,採用鏈式存儲結構。頻繁地 malloc/free 造成內存空間的不連續,產生大量碎片。當申請堆空間時,庫函數按照一定的算法搜索可用的足夠大的空間。因此堆的效率比棧要低的多。
-
代碼段:存放 CPU 可以執行的機器指令,該部分內存只能讀不能寫。通常代碼區是共享的,即其它執行程序可調用它。假如機器中有數個進程運行相同的一個程序,那麼它們就可以使用同一個代碼段。
-
未初始化的數據段:存放未初始化的全局變量,BSS 的數據在程序開始執行之前被初始化爲 0 或 NULL。
-
已初始化的數據段:存放已初始化的全局變量,包括靜態全局變量、靜態局部變量以及常量。
-
內存映射區域:例如將動態庫,共享內存等虛擬空間的內存映射到物理空間的內存,一般是 mmap 函數所分配的虛擬內存空間。
DMA 傳輸
DMA 的全稱叫直接內存存取(Direct Memory Access),是一種允許外圍設備(硬件子系統)直接訪問系統主內存的機制。也就是說,基於 DMA 訪問方式,系統主內存於硬盤或網卡之間的數據傳輸可以繞開 CPU 的全程調度。目前大多數的硬件設備,包括磁盤控制器、網卡、顯卡以及聲卡等都支持 DMA 技術。
數據讀取操作的流程如下:
-
用戶進程向 CPU 發起 read() 系統調用讀取數據,由用戶態切換爲內核態,然後一直阻塞等待數據的返回。
-
CPU 在接收到指令以後對 DMA 磁盤控制器發起調度指令。
-
DMA 磁盤控制器對磁盤發起 I/O 請求,將磁盤數據先放入磁盤控制器緩衝區,CPU 全程不參與此過程。
-
數據讀取完成後,DMA 磁盤控制器會接受到磁盤的通知,將數據從磁盤控制器緩衝區拷貝到內核緩衝區。
-
DMA 磁盤控制器向 CPU 發出數據讀完的信號,由 CPU 負責將數據從內核緩衝區拷貝到用戶緩衝區。
-
用戶進程由內核態切換回用戶態,解除阻塞狀態,然後等待 CPU 的下一個執行時間鍾。
上下文切換
- 什麼是 CPU 上下文?
CPU 寄存器,是 CPU 內置的容量小、但速度極快的內存。而程序計數器,則是用來存儲 CPU 正在執行的指令位置、或者即將執行的下一條指令位置。它們都是 CPU 在運行任何任務前,必須的依賴環境,因此叫做 CPU 上下文。
- 什麼是 CPU 上下文切換?
它是指先把前一個任務的 CPU 上下文(也就是 CPU 寄存器和程序計數器)保存起來,然後加載新任務的上下文到這些寄存器和程序計數器,最後再跳轉到程序計數器所指的新位置,運行新任務。
一般我們說的上下文切換,就是指內核(操作系統的核心)在 CPU 上對進程或者線程進行切換。進程從用戶態到內核態的轉變,需要通過系統調用來完成。系統調用的過程,會發生 CPU 上下文的切換。
CPU 寄存器裏原來用戶態的指令位置,需要先保存起來。接着,爲了執行內核態代碼,CPU 寄存器需要更新爲內核態指令的新位置。最後纔是跳轉到內核態運行內核任務。
傳統的文件傳輸
如果服務端要提供文件傳輸的功能,我們能想到的最簡單的方式是:將磁盤上的文件讀取出來,然後通過網絡協議發送給客戶端。
傳統 I/O 的工作方式是,數據讀取和寫入是從用戶空間到內核空間來回複製,而內核空間的數據是通過操作系統層面的 I/O 接口從磁盤讀取或寫入。
代碼通常如下,一般會需要兩個系統調用:
代碼很簡單,雖然就兩行代碼,但是這裏面發生了不少的事情。
首先,期間共發生了 4 次用戶態與內核態的上下文切換,因爲發生了兩次系統調用,一次是 read() ,一次是 write(),每次系統調用都得先從用戶態切換到內核態,等內核完成任務後,再從內核態切換回用戶態。
上下文切換到成本並不小,一次切換需要耗時幾十納秒到幾微秒,雖然時間看上去很短,但是在高併發的場景下,這類時間容易被累積和放大,從而影響系統的性能。
其次,還發生了 4 次數據拷貝,其中兩次是 DMA 的拷貝,另外兩次則是通過 CPU 拷貝的,下面說一下這個過程:
-
第一次拷貝,把磁盤上的數據拷貝到操作系統內核的緩衝區裏,這個拷貝的過程是通過 DMA 搬運的。
-
第二次拷貝,把內核緩衝區的數據拷貝到用戶的緩衝區裏,於是我們應用程序就可以使用這部分數據了,這個拷貝到過程是由 CPU 完成的。
-
第三次拷貝,把剛纔拷貝到用戶的緩衝區裏的數據,再拷貝到內核的 socket 的緩衝區裏,這個過程依然還是由 CPU 搬運的。
-
第四次拷貝,把內核的 socket 緩衝區裏的數據,拷貝到網卡的緩衝區裏,這個過程又是由 DMA 搬運的。
我們回過頭看這個文件傳輸的過程,我們只是搬運一份數據,結果卻搬運了 4 次,過多的數據拷貝無疑會消耗 CPU 資源,大大降低了系統性能。
這種簡單又傳統的文件傳輸方式,存在冗餘的上文切換和數據拷貝,在高併發系統裏是非常糟糕的,多了很多不必要的開銷,會嚴重影響系統性能。
所以,要想提高文件傳輸的性能,就需要減少「用戶態與內核態的上下文切換」和「內存拷貝」的次數。
如何優化文件傳輸的性能?
先來看看,如何減少「用戶態與內核態的上下文切換」的次數呢?
讀取磁盤數據的時候,之所以要發生上下文切換,這是因爲用戶空間沒有權限操作磁盤或網卡,內核的權限最高,這些操作設備的過程都需要交由操作系統內核來完成,所以一般要通過內核去完成某些任務的時候,就需要使用操作系統提供的系統調用函數。
而一次系統調用必然會發生 2 次上下文切換:首先從用戶態切換到內核態,當內核執行完任務後,再切換回用戶態交由進程代碼執行。
所以,要想減少上下文切換到次數,就要減少系統調用的次數。
再來看看,如何減少「數據拷貝」的次數?
在前面我們知道了,傳統的文件傳輸方式會歷經 4 次數據拷貝,而且這裏面,「從內核的讀緩衝區拷貝到用戶的緩衝區裏,再從用戶的緩衝區裏拷貝到 socket 的緩衝區裏」,這個過程是沒有必要的。
因爲文件傳輸的應用場景中,在用戶空間我們並不會對數據「再加工」,所以數據實際上可以不用搬運到用戶空間,因此用戶的緩衝區是沒有必要存在的。
零拷貝
通過上面的分析可以看出,內核態到用戶態數據的來回拷貝是沒有意義的,數據應該可以直接從內核緩衝區直接送入 socket 緩衝區。零拷貝機制就實現了這一點。
操作系統層面減少數據拷貝次數主要是指用戶空間和內核空間的數據拷貝,因爲只有他們的拷貝是大量消耗 CPU 時間片的,而 DMA 控制器拷貝數據 CPU 參與的工作較少,只是輔助作用,所以減少 CPU 拷貝意義更大。
現實中對零拷貝的概念有 “廣義” 和“狹義”之分,廣義上是指只要減少了數據拷貝的次數,就稱之爲零拷貝;狹義上是指真正的零拷貝,就是避免了內核緩衝區和用戶空間內存之間的兩次 CPU 拷貝,
零拷貝實現方式:
Linux 實現零拷貝—— mmap 內存映射
既然是內存映射,首先來了解解下虛擬內存和物理內存的映射關係,虛擬內存是操作系統爲了方便操作而對物理內存做的抽象,他們之間是靠頁表 (Page Table) 進行關聯的,關係如下:
每個進程都有自己的 PageTable,進程的虛擬內存地址通過 PageTable 對應於物理內存,內存分配具有惰性,它的過程一般是這樣的:進程創建後新建與進程對應的 PageTable,當進程需要內存時會通過 PageTable 尋找物理內存,如果沒有找到對應的頁幀就會發生缺頁中斷,從而創建 PageTable 與物理內存的對應關係。虛擬內存不僅可以對物理內存進行擴展,還可以更方便地靈活分配,並對編程提供更友好的操作。
內存映射 (mmap) 是指用戶空間和內核空間的虛擬內存地址同時映射到同一塊物理內存,用戶態進程可以直接操作物理內存,避免用戶空間和內核空間之間的數據拷貝。
整個流程是這樣的:
-
用戶進程通過系統調用 mmap 函數進入內核態,發生第 1 次上下文切換,並建立內核緩衝區;
-
發生缺頁中斷,CPU 通知 DMA 讀取數據;
-
DMA 拷貝數據到物理內存,並建立內核緩衝區和物理內存的映射關係;
-
建立用戶空間的進程緩衝區和同一塊物理內存的映射關係,由內核態轉變爲用戶態,發生第 2 次上下文切換;
-
用戶進程進行邏輯處理後,通過系統調用 Socket send,用戶態進入內核態,發生第 3 次上下文切換;
-
系統調用 Send 創建網絡緩衝區,並拷貝內核讀緩衝區數據;
-
DMA 控制器將網絡緩衝區的數據發送網卡,並返回,由內核態進入用戶態,發生第 4 次上下文切換;
小結:
-
避免了內核空間和用戶空間的 2 次 CPU 拷貝,但增加了 1 次內核空間的 CPU 拷貝,整體上相當於只減少了 1 次 CPU 拷貝;
-
針對大文件比較適合 mmap,小文件則會造成較多的內存碎片,得不償失;
-
不能很好的利用 DMA 方式,會比 sendfile 多消耗 CPU,內存安全性控制複雜;
示例代碼
#include <iostream>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
void processMappedData(char* data, size_t size) {
// 處理映射的數據,這裏簡單打印前10個字符
std::cout << "Data in mmap: ";
for (size_t i = 0; i < std::min(size, static_cast<size_t>(10)); ++i) {
std::cout << data[i];
}
std::cout << std::endl;
}
int main() {
int fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("Failed to open file");
return 1;
}
struct stat sb;
if (fstat(fd, &sb) == -1) {
perror("Failed to get file size");
close(fd);
return 1;
}
char* data = static_cast<char*>(mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0));
if (data == MAP_FAILED) {
perror("Failed to mmap");
close(fd);
return 1;
}
// 使用零拷貝方式處理數據
processMappedData(data, sb.st_size);
// 解除映射並關閉文件
munmap(data, sb.st_size);
close(fd);
return 0;
}
Linux 實現零拷貝—— sendfile
sendfile 是在 Linux2.1 引入的,它只需要 2 次上下文切換和 1 次內核 CPU 拷貝、2 次 DMA 拷貝,函數定義如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
out_fd 爲文件描述符,in_fd 爲網絡緩衝區描述符,offset 偏移量(默認 NULL),count 文件大小。
sendfile 零拷貝的執行流程是這樣的:
-
用戶進程系統調用 senfile,由用戶態進入內核態,發生第 1 次上下文切換;
-
CPU 通知 DMA 控制器把文件數據拷貝到內核緩衝區;
-
內核空間自動調用網絡發送功能並拷貝數據到網絡緩衝區;
-
CPU 通知 DMA 控制器發送數據;
-
sendfile 系統調用結束並返回,進程由內核態進入用戶態,發生第 2 次上下文切換;
小結:
-
數據處理完全是由內核操作,減少了 2 次上下文切換,整個過程 2 次上下文切換、1 次 CPU 拷貝,2 次 DMA 拷貝;
-
優點上下文切換少,消耗 CPU 較少,大塊文件傳輸效率高,無內存安全問題;
-
缺點是小塊文件效率低亍 mmap 方式;
示例代碼
那麼有沒有什麼辦法徹底減少 CPU 拷貝次數,讓數據不在內存緩衝區和網絡緩衝區之間進行拷貝呢?答案就是 sendfile + DMA gatter
Linux 實現零拷貝—— sendfile + DMA gatter
Linux2.4 對 sendfile 進行了優化,爲 DMA 控制器引入了 gather 功能,就是在不拷貝數據到網絡緩衝區,而是將待發送數據的內存地址和偏移量等描述信息存在網絡緩衝區,DMA 根據描述信息從內核的讀緩衝區截取數據併發送。它的流程是如下:
-
用戶進程系統調用 senfile,由用戶態進入內核態,發生第 1 次上下文切換;
-
CPU 通知 DMA 控制器把文件數據拷貝到內核緩衝區;
-
把內核緩衝區地址和 sendfile 的相關參數作爲數據描述信息存在網絡緩衝區中;
-
CPU 通知 DMA 控制器,DMA 根據網絡緩衝區中的數據描述截取數據併發送;
-
sendfile 系統調用結束並返回,進程由內核態進入用戶態,發生第 2 次上下文切換;
小結:
-
需要硬件支持,如 DMA;
-
整個過程 2 次上下文切換,0 次 CPU 拷貝,2 次 DMA 拷貝,實現真正意義上的零拷貝;
-
依然不能修改數據;
Linux 實現的零拷貝—— splice
鑑於 Sendfile 的缺點,在 Linux2.6.17 中引入了 Splice,它在讀緩衝區和網絡操作緩衝區之間建立管道避免 CPU 拷貝:先將文件讀入到內核緩衝區,然後再與內核網絡緩衝區建立管道。它的函數定義:
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
它的執行流程如下
-
用戶進程系統調用 splice,由用戶態進入內核態,發生第 1 次上下文切換;
-
CPU 通知 DMA 控制器把文件數據拷貝到內核緩衝區;
-
建立內核緩衝區和網絡緩衝區的管道;
-
CPU 通知 DMA 控制器,DMA 從管道讀取數據併發送;
-
splice 系統調用結束並返回,進程由內核態進入用戶態,發生第 2 次上下文切換;
小結:
-
整個過程 2 次上下文切換,0 次 CPU 拷貝,2 次 DMA 拷貝,實現真正意義上的零拷貝;
-
依然不能修改數據;
-
fd_in 和 fd_out 必須有一個是管道;
使用零拷貝技術的項目
事實上,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() 系統調用函數。
曾經有大佬專門寫過程序測試過,在同樣的硬件條件下,傳統文件傳輸和零拷拷貝文件傳輸的性能差異,你可以看到下面這張測試數據圖,使用了零拷貝能夠縮短 65% 的時間,大幅度提升了機器傳輸數據的吞吐量。
另外,Nginx 也支持零拷貝技術,一般默認是開啓零拷貝技術,這樣有利於提高文件傳輸的效率,是否開啓零拷貝技術的配置如下:
http {
...
sendfile on
...
}
sendfile 配置的具體意思:
-
設置爲 on 表示,使用零拷貝技術來傳輸文件:sendfile ,這樣只需要 2 次上下文切換,和 2 次數據拷貝。
-
設置爲 off 表示,使用傳統的文件傳輸技術:read + write,這時就需要 4 次上下文切換,和 4 次數據拷貝。
當然,要使用 sendfile,Linux 內核版本必須要 2.1 以上的版本。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/hv5RH85bkQzcGAQzVsNY2A