什么是 “零拷貝” ?


現在幾乎所有人都聽過 Linux 下的零拷貝技術,但我經常遇到對這個問題不能深入理解的人。所以我寫了這篇文章,來深入研究這些問題。本文通過用戶態程序的角度來看零拷貝,因此我有意忽略了內核級別的實現。

什么是 “零拷貝” ?

為了更好的理解這個問題,我們首先需要了解問題本身。來看一個網絡服務的簡單運行過程,在這個過程中將磁盤的文件讀取到緩沖區,然后通過網絡發送給客戶端。下面是示例代碼:

read(file, tmp_buf, len); 
write(socket, tmp_buf, len);

這個例子看起來非常簡單,你可能會認為只有兩次系統調用不會產生太多的系統開銷。實際上並非如此,在這兩次調用之后,數據至少被拷貝了 4 次,同時還執行了很多次 用戶態/內核態 的上下文切換。(實際上這個過程是非常復雜的,為了解釋我盡可能保持簡單)為了更好的理解這個過程,請查看下圖中的上下文切換,圖片上部分展示上下文切換過程,下部分展示拷貝操作。

兩次系統調用

  1. 程序調用 read 產生一次用戶態到內核態的上下文切換。DMA 模塊從磁盤讀取文件內容,將其拷貝到內核空間的緩沖區,完成第 1 次拷貝。
  2. 數據從內核緩沖區拷貝到用戶空間緩沖區,之后系統調用 read 返回,這回導致從內核空間到用戶空間的上下文切換。這個時候數據存儲在用戶空間的 tmp_buf 緩沖區內,可以后續的操作了。
  3. 程序調用 write 產生一次用戶態到內核態的上下文切換。數據從用戶空間緩沖區被拷貝到內核空間緩沖區,完成第 3 次拷貝。但是這次數據存儲在一個和 socket 相關的緩沖區中,而不是第一步的緩沖區。
  4. write 調用返回,產生第 4 個上下文切換。第 4 次拷貝在 DMA 模塊將數據從內核空間緩沖區傳遞至協議引擎的時候發生,這與我們的代碼的執行是獨立且異步發生的。你可能會疑惑:“為何要說是獨立、異步?難道不是在 write 系統調用返回前數據已經被傳送了?write 系統調用的返回,並不意味着傳輸成功——它甚至無法保證傳輸的開始。調用的返回,只是表明以太網驅動程序在其傳輸隊列中有空位,並已經接受我們的數據用於傳輸。可能有眾多的數據排在我們的數據之前。除非驅動程序或硬件采用優先級隊列的方法,各組數據是依照FIFO的次序被傳輸的(上圖中叉狀的 DMA copy 表明這最后一次拷貝可以被延后)。

mmap

如你所見,上面的數據拷貝非常多,我們可以減少一些重復拷貝來減少開銷,提升性能。作為一名驅動程序開發人員,我的工作圍繞着擁有先進特性的硬件展開。某些硬件支持完全繞開內存,將數據直接傳送給其他設備。這個特性消除了系統內存中的數據副本,因此是一種很好的選擇,但並不是所有的硬件都支持。此外,來自於硬盤的數據必須重新打包(地址連續)才能用於網絡傳輸,這也引入了某些復雜性。為了減少開銷,我們可以從消除內核緩沖區與用戶緩沖區之間的拷貝開始。

減少數據拷貝的一種方法是將 read 調用改為 mmap。例如:

tmp_buf = mmap(file, len); 
write(socket, tmp_buf, len);

為了方便你理解,請參考下圖的過程。

mmap調用

  1. mmap 調用導致文件內容通過 DMA 模塊拷貝到內核緩沖區。然后與用戶進程共享緩沖區,這樣不會在內核緩沖區和用戶空間之間產生任何拷貝。
  2. write 調用導致內核將數據從原始內核緩沖區拷貝到與 socket 關聯的內核緩沖區中。
  3. 第 3 次數據拷貝發生在 DMA 模塊將數據從 socket 緩沖區傳遞給協議引擎時。

通過調用 mmap 而不是 read,我們已經將內核拷貝數據操作減半。當傳輸大量數據時,效果會非常好。然而,這種改進並非沒有代價;使用 mmap + write 方式存在一些隱藏的陷阱。當內存中做文件映射后調用 write,與此同時另一個進程截斷這個文件時。此時 write 調用的進程會收到一個 SIGBUS 中斷信號,因為當前進程訪問了非法內存地址。這個信號默認情況下會殺死當前進程並生成 dump 文件——而這對於網絡服務器程序而言不是最期望的操作。有兩種方式可用於解決該問題:

第一種方法是處理收到的 SIGBUS 信號,然后在處理程序中簡單地調用 return。通過這樣做,write 調用會返回它在被中斷之前寫入的字節數,並且將全局變量 errno 設置為成功。我認為這是一個治標不治本的解決方案。因為收到 SIGBUS 信號表示程序發生了嚴重的錯誤,我不推薦使用它作為解決方案。

第二種方式應用了文件租借(在Microsoft Windows系統中被稱為“機會鎖”)。這才是解勸前面問題的正確方式。通過對文件描述符執行租借,可以同內核就某個特定文件達成租約。從內核可以獲得讀/寫租約。當另外一個進程試圖將你正在傳輸的文件截斷時,內核會向你的進程發送實時信號——RT_SIGNAL_LEASE。該信號通知你的進程,內核即將終止在該文件上你曾獲得的租約。這樣,在write調用訪問非法內存地址、並被隨后接收到的SIGBUS信號殺死之前,write系統調用就被RT_SIGNAL_LEASE信號中斷了。write的返回值是在被中斷前已寫的字節數,全局變量errno設置為成功。下面是一段展示如何從內核獲得租約的示例代碼。

if (fcntl(fd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
    perror("kernel lease set signal");
    return -1;
}
/* l_type can be F_RDLCK F_WRLCK */
if (fcntl(fd, F_SETLEASE, l_type)) {
    perror("kernel lease set type");
    return -1;
}

在對文件進行映射前,應該先獲得租約,並在結束 write 操作后結束租約。這是通過在 fcntl 調用中指定租約類型為 F_UNLCK 來實現的。

Sendfile

在內核的 2.1 版本中,引入了 sendfile 系統調用,目的是簡化通過網絡和兩個本地文件之間的數據傳輸。sendfile 的引入不僅減少了數據拷貝,還減少了上下文切換。可以這樣使用它:

sendfile(socket, file, len);

同樣的,為了理解起來方便,可以看下圖的調用過程。

sendfile代替讀寫

  1. sendfile 調用會使得文件內容通過 DMA 模塊拷貝到內核緩沖區。然后,內核將數據拷貝到與 socket 關聯的內核緩沖區中。
  2. 第 3 次拷貝發生在 DMA 模塊將數據從內核 socket 緩沖區傳遞到協議引擎時。

你可能想問當我們使用 sendfile 調用傳輸文件時有另一個進程截斷會發生什么?如果我們沒有注冊任何信號處理程序,sendfile 調用只會返回它在被中斷之前傳輸的字節數,並且全局變量 errno 被設置為成功。

但是,如果我們在調用 sendfile 之前從內核獲得了文件租約,那么行為和返回狀態完全相同。我們會在sendfile 調用返回之前收到一個 RT_SIGNAL_LEASE 信號。

到目前為止,我們已經能夠避免讓內核產生多次拷貝,但我們還有一次拷貝。這可以避免嗎?當然,在硬件的幫助下。為了避免內核完成的所有數據拷貝,我們需要一個支持收集操作的網絡接口。這僅僅意味着等待傳輸的數據不需要在內存中;它可以分散在各種存儲位置。在內核 2.4 版本中,修改了 socket 緩沖區描述符以適應這些要求 - 在 Linux 下稱為零拷貝。這種方法不僅減少了多個上下文切換,還避免了處理器完成的數據拷貝。對於用戶的程序不用做什么修改,所以代碼仍然如下所示:

sendfile(socket, file, len);

為了更好地了解所涉及的過程,請查看下圖

sendfile代替讀寫

  1. sendfile 調用會導致文件內容通過 DMA 模塊拷貝到內核緩沖區。
  2. 沒有數據被復制到 socket 緩沖區。相反,只有關於數據的位置和長度信息的描述符被附加到 socket 緩沖區。DMA 模塊將數據直接從內核緩沖區傳遞到協議引擎,從而避免了剩余的最終拷貝。

因為數據實際上仍然是從磁盤復制到內存,從內存復制到總線,所以有人可能會認為這不是真正的零拷貝。但從操作系統的角度來看,這是零拷貝,因為內核緩沖區之間的數據不會產生多余的拷貝。使用零拷貝時,除了避免拷貝外,還可以獲得其他性能優勢,比如更少的上下文切換,更少的 CPU 高速緩存污染以及不會產生 CPU 校驗和計算。

現在我們知道了什么是零拷貝,把前面的理論通過編碼來實踐。你可以從 http://www.xalien.org/articles/source/sfl-src.tgz 下載源碼。解壓源碼需要執行 tar -zxvf sfl-src.tgz,然后編譯代碼並創建一個隨機數據文件 data.bin,接下來使用 make 運行。

查看頭文件:

/* sfl.c sendfile example program
Dragan Stancevic <
header name                 function / variable
-------------------------------------------------*/
#include <stdio.h>          /* printf, perror */
#include <fcntl.h>          /* open */
#include <unistd.h>         /* close */
#include <errno.h>          /* errno */
#include <string.h>         /* memset */
#include <sys/socket.h>     /* socket */
#include <netinet/in.h>     /* sockaddr_in */
#include <sys/sendfile.h>   /* sendfile */
#include <arpa/inet.h>      /* inet_addr */
#define BUFF_SIZE (10*1024) /* size of the tmp buffer */

除了 socket 操作需要的頭文件 <sys/socket.h><netinet/in.h> 之外,我們還需要 sendfile 調用的頭文件 - <sys/sendfile.h>

/* are we sending or receiving */
if(argv[1][0] == 's') is_server++;
/* open descriptors */
sd = socket(PF_INET, SOCK_STREAM, 0);
if(is_server) fd = open("data.bin", O_RDONLY);

同樣的程序既可以充當 服務端/發送者,也可以充當 客戶端/接受者。這里我們接收一個命令提示符參數,通過該參數將標志 is_server 設置為以 發送方模式 運行。我們還打開了 INET 協議族的流套接字。作為在服務端運行的一部分,我們需要某種類型的數據傳輸到客戶端,所以打開我們的數據文件(data.bin)。由於我們使用 sendfile 來傳輸數據,所以不用讀取文件的實際內容將其存儲在程序的緩沖區中。這是服務端地址:

/* clear the memory */
memset(&sa, 0, sizeof(struct sockaddr_in));
/* initialize structure */
sa.sin_family = PF_INET;
sa.sin_port = htons(1033);
sa.sin_addr.s_addr = inet_addr(argv[2]);

我們重置了服務端地址結構並分配了端口和 IP 地址。服務端的地址作為命令行參數傳遞,端口號寫死為 1033,選擇這個端口號是因為它是一個允許訪問的端口范圍。

下面是服務端執行的代碼分支:

if(is_server){
    int client; /* new client socket */
    printf("Server binding to [%s]\n", argv[2]);
    if(bind(sd, (struct sockaddr *)&sa,
                      sizeof(sa)) < 0){
        perror("bind");
        exit(errno);
    }
}

作為服務端,我們需要為 socket 描述符分配一個地址。這是通過系統調用 bind 實現的,它為 socket 描述符(sd)分配一個服務器地址(sa):

if(listen(sd,1) < 0){
    perror("listen");
    exit(errno);
}

因為我們正在使用流套接字,所以我們必須接受傳入連接並設置連接隊列大小。我將緩沖壓隊列設置為 1,但對於等待接受的已建立連接,一般會將緩沖值要設置的更高一些。在舊版本的內核中,緩沖隊列用於防止 syn flood 攻擊。由於系統調用 listen 已經修改為 僅為已建立的連接設置參數,所以不使用這個調用的緩沖隊列功能。內核參數 tcp_max_syn_backlog 代替了保護系統免受 syn flood 攻擊的角色:

if((client = accept(sd, NULL, NULL)) < 0){
    perror("accept");
    exit(errno);
}

accept 調用從掛起連接隊列上的第一個連接請求創建一個新的 socket 連接。調用的返回值是新創建的連接的描述符; socket 現在可以進行讀、寫或輪詢/select 了:

if((cnt = sendfile(client,fd,&off,
                          BUFF_SIZE)) < 0){
    perror("sendfile");
    exit(errno);
}
printf("Server sent %d bytes.\n", cnt);
close(client);

在客戶端 socket 描述符上建立連接,我們可以開始將數據傳輸到遠端。通過 sendfile 調用來實現,該調用是在 Linux 下通過以下方式原型化的:

extern ssize_t
sendfile (int __out_fd, int __in_fd, off_t *offset,
          size_t __count) __THROW;
  • 前兩個參數是文件描述符。
  • 第 3 個參數指向 sendfile 開始發送數據的偏移量。
  • 第四個參數是我們要傳輸的字節數。

為了使 sendfile 傳輸使用零拷貝功能,你需要從網卡獲得內存收集操作支持。還需要實現校驗和的協議的校驗和功能,通過 TCP 或 UDP。如果你的 NIC 已過時不支持這些功能,你也可以使用 sendfile 來傳輸文件,不同之處在於內核會在傳輸之前合並緩沖區。

移植性問題

通常,sendfile 系統調用的一個問題是缺少標准實現,就像開放系統調用一樣。Linux、Solaris 或 HP-UX 中 的 Sendfile 實現完全不同。這對於想通過代碼實現零拷貝的開發人員而言是個問題。

其中一個實現差異是 Linux 提供了一個 sendfile 接口,用於在兩個文件描述符(文件到文件)和(文件到socket)之間傳輸數據。另一方面,HP-UX 和 Solaris 只能用於文件到 socket 的提交。

第二個區別是 Linux 沒有實現向量傳輸。Solaris sendfile 和 HP-UX sendfile 有一些擴展參數,可以避免與正在傳輸的數據添加頭部的開銷。

展望

Linux 下的零拷貝實現離最終實現還有點距離,並且很可能在不久的將來發生變化。要添加更多功能,例如,sendfile 調用不支持向量傳輸,而 Samba 和 Apache 等服務器必須使用設置了 TCP_CORK 標志的多個sendfile 調用。這個標志告訴系統在下一個 sendfile 調用中會有更多數據通過。TCP_CORKTCP_NODELAY 不兼容,並且在我們想要在數據前添加或附加標頭時使用。這是一個完美的例子,其中向量調用將消除對當前實現所強制的多個 sendfile 調用和延遲的需要。

當前 sendfile 中一個相當令人不快的限制是它在傳輸大於2GB的文件時無法使用。如此大小的文件在今天並不罕見,並且在出路時復制所有數據相當令人失望。因為在這種情況下sendfile和mmap方法都不可用,所以sendfile64在未來的內核版本中會非常方便。

總結

盡管有一些缺點,不過通過 sendfile 來實現零拷貝也很有用,我希望你在閱讀本文后可以開始在你的程序中使用它。如果想對這個主題有更深入的興趣,請留意我的第二篇文章,標題為 “零拷貝 - 內核態分析”,我將在零拷貝的內核內部挖掘更多內容。

英文原文:http://www.linuxjournal.com/article/6345

本文由博客一文多發平台 OpenWrite 發布!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM