什麼是 「零拷貝」 ?

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

什麼是 「零拷貝」 ?

爲了更好的理解這個問題,咱們首先須要瞭解問題自己。來看一個網絡服務的簡單運行過程,在這個過程當中將磁盤的文件讀取到緩衝區,而後經過網絡發送給客戶端。下面是示例代碼:segmentfault

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 文件——而這對於網絡服務器程序而言不是最指望的操做。有兩種方式可用於解決該問題:socket

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

第二種方式應用了文件租借(在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/article... 下載源碼。解壓源碼須要執行 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 發佈!
相關文章
相關標籤/搜索