零拷貝I:用戶模式視角 原 薦

英文原文地址:http://www.linuxjournal.com/article/6345。內容是關於 Zero Copy(零拷貝) 的詳細介紹。在RocketMQ的Consumer 消費消息過程,使用了零拷貝技術。作用是即使被頻繁調用,文件傳輸效率也很高。

 

    到目前爲止,幾乎每個人或多或少都聽過Linux下所謂的"零拷貝"功能,但我經常遇到一些對這個概念沒有充分理解的人。因此,我決定寫一些文章來深入研究這個問題,希望能讓大家認識到這個很有用的特性。在本文中,我們從用戶模式的角度來看零拷貝,因此故意省略了內核級別的細節信息。

 

    什麼是"零拷貝"?

    爲了更好地理解問題的解決方案,我們首先需要理解問題本身。讓我們來看一下網絡服務器守護進程一個簡單過程所涉及的內容,該過程通過網絡將存儲在文件中的數據提供給客戶端。這裏是一些示例代碼:

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

    看起來很簡單;你可能會認爲只有這兩個系統調用沒有太大的開銷。實際上,事實遠非如此。在這兩個調用的背後,數據就已經被拷貝至少四次,並且幾乎已經執行了許多從用戶態到內核態的切換。(實際上這個過程要複雜得多,但我想保持簡單。)。爲了更好地理解所涉及的過程,請看 圖1.上半部分顯示上下文切換,下半部分顯示覆制操作。

                   

                                      圖 1. 兩次系統調用中的拷貝過程

    第一步:系統調用 read 導致從用戶態切換到內核態的上下文切換。第1次複製本由DMA引擎執行,DMA引擎從磁盤讀取文件內容,並將它們存儲到內核空間緩衝區中。

    第二步:將數據從內核緩衝區複製到用戶緩衝區(第2次複製),並返回 read 系統調用。從調用返回導致從內核態切換回到用戶態。現在數據存儲在用戶地址空間緩衝區中,程序可以繼續往下執行。

    第三步:系統調用 write 會導致從用戶態到內核態的上下文切換。第3次複製數據,再次將數據放入內核地址空間緩衝區。不過這次,數據被放入一個不同的緩衝區,一個專門與套接字關聯的緩衝區。

    第四步: write 系統調用返回,第四次進行上下文切換-從內核態切換回用戶態。當DMA引擎獨立和異步地將數據從內核緩衝區傳遞到協議引擎的時候,會發送第4次數據複製。你可能會問自己,“你說的獨立和異步地是什麼意思?在調用返回之前是不是傳輸了數據?”調用返回,事實上並不保證傳輸成功;它甚至不能保證傳輸什麼時候開始。它只是簡單意味着以太網驅動程序在其隊列中有自由描述符並已接受我們的數據進行傳輸。在我們之前可能有許多數據包排隊。除非驅動程序/硬件實現優先級環或隊列,否則數據以先進先出的方式傳輸。(圖1中的叉狀的 DMA copy 說明了最後一個副本可以被延遲)。

 

    正如你所看到的,實際上不需要進行大量的數據拷貝。可以減少一些拷貝,來減輕系統開銷並提高性能。作爲一名驅動程序開發人員,我使用過很多硬件的高級特性。某些硬件可以完全繞過主存儲器並將數據直接傳輸到另外一個設置。這個特性消除了系統內存中的數據副本,這是一個很好事情,但並不是所有的硬件都支持它。此外還存在來自磁盤的數據必須爲網絡重新打包的問題,這增加了一些複雜性。爲了減少系統開銷,我們可以從減少內核和用戶緩衝區之間的一些複製開始。

 

    減少拷貝次數的一種方法是跳過 read 系統調用,改爲 mmap 系統調用。例如:

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

    爲了更好地理解上面講的過程,請看 圖2.上下文切換保持不變。

                    

                                  圖 2. mmap 系統調用中的拷貝過程

    第一步:mmap 系統調用,使得文件內容被DMA引擎複製到內核緩衝區中。然後與用戶進程共享這塊緩衝區,從而不用在內核和用戶存儲空間之間進行任何拷貝。

    第二步:write 系統調用使內核將數據從原始內核緩衝區複製到與套接字關聯的內核緩衝區中。

    第三步:第3次數據拷貝發生在DMA引擎將數據次內核套接字緩衝區傳遞到協議引擎時。

 

    通過使用 mmap 系統調用替換掉 read,我們減少了內核複製操作的一半。當傳輸大量數據的時候,這會產生相當好的效果。然而,這種提高並非沒有代價的;使用 mmap + write 方法時存在着隱藏的陷阱。當內存映射文件然後調用write而另一個進程截斷同一文件時,你就會掉進去。你的 write 系統調用將被總線錯誤信號 SIGBUS 給中斷,因爲你執行了錯誤的內存訪問。該信號的默認行爲是終止進程並生成 dump core 文件-這對網絡服務器來說不是所期望的操作。有兩種方法可以解決這個問題。

 

    第一種方法是爲 SIGBUS 信號設置一個信號處理程序,然後在處理程序中調用return。通過這樣做,寫入系統調用返回它在被中斷之前寫入的字節數並且 errno 設置爲成功。不過這是一個糟糕的解決方案 - 治標不治本。因爲 SIGBUS 發出信號就表明該過程有嚴重錯誤,不鼓勵採用這個解決方案。

    第二種方法涉及內核中的文件租用(在微軟的Windows中稱爲"機會鎖定")。這是解決此問題的正確方法。通過在文件描述符上使用租用,你可以在特定文件上使用內核。然後,你可以直接向內核申請 read 或者 write 租約。當另外一個進程試圖截斷你正在傳輸的文件時,內核會向你發送一個實時信號 - RT_SIGNAL_LEASE 信號。信號告訴你內核正在破壞該文件的 read 或者 write 租約。在程序訪問無效地址並被 SIGBUS 信號殺死之前,你的 write 調用將被中斷。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;
}

    你應該在 mmap 操作文件之前獲取租約,並在完成後中斷租約。這是通過使用租約類型 F_UNLCK 調用 fcntl F_SETLEASE 來實現的。

 

    Sendfile

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

sendfile(socket, file, len);

    爲了更好地連接sendfile中發生了什麼,請看圖3。

                    

                                  圖 3. 使用Sendfile替換Read和Write

    第一步:sendfile 系統調用把文件內容從DMA引擎拷貝到內核緩衝區。然後內核將數據拷貝到與套接字關聯的內核緩衝區中。

    第二步:第3次拷貝發生在DMA引擎將數據次內核套接字緩衝區傳遞到協議引擎時。

 

    你可以會問:如果另外一個進程截斷我們使用 sendfile 系統調用傳輸的文件會發生什麼。如果我們不註冊任何信號處理程序,sendfile 調用只會返回它在被中斷之前傳輸的字節數,並且 errno 將被設置爲成功。

    但是,如果我們在調用 sendfile 之前已經從文件內核獲得租約,則行爲和返回狀態完全相同。我們還在 sendfile 調用返回之前獲取 RT_SIGNAL_LEASE 信號。

 

    到目前爲止,我們已經能夠避免讓內核多次拷貝數據了,但我們仍然至少需要一次拷貝。這可以避免嗎?當然,在硬件的幫助下。爲了減少內核中所有的數據拷貝操作,我們需要一個支持收集操作的網絡接口。這只是意味着等待傳輸的數據不需要在連續的存儲空間中,它可以分散在各種存儲位置。在內核版本2.4中,套接字緩衝區的描述符被修改了以適應這些要求 - 在Linux下稱爲零拷貝。這種方法不僅減少了多次上下文切換,還消除了處理器完成的數據複製。對於用戶級應用程序,不需要任何改動,代碼還是一樣的,如下所示:

sendfile(socket, file, len);

    爲了更好地理解上述過程,請看圖4。

                   

                                  圖 4. 支持收集的硬件可以從多個內存位置組裝數據,從而消除了另一個副本

    第一步:sendfile 系統調用使得文件內容被DMA引擎拷貝到內核緩衝區。

    第二步:沒有數據被拷貝到套接字緩衝區。相反,只有具有關於數據的下落和長度信息的描述符被附加到套接字緩衝區。DMA引擎將數據直接從內核緩衝區傳遞到協議引擎,從而消除了最後剩下的一次數據拷貝。

 

    因爲數據實際上仍然是從磁盤複製到內存,從內存複製到線路,所以有些人可能認爲這不是真正的零複製。但是,從操作系統的角度來看,這就是零複製,因爲內核緩衝區直接的數據沒有被重複拷貝,使用零複製的時候,除了拷貝避免之外,還可以獲得其他的性能優勢,例如更少的上下文切換,更少的CPU數據高速緩存污染,以及沒有CPU校驗和計算。

    現在我們知道了什麼是零拷貝,讓我們把理論付諸實踐並編寫一些代碼。你可以從 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 */

    除了基本套接字操作所需要的常規頭文件 <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協議族的流套接字。作爲在服務器模式下運行的一部分,我們需要某種類型的數據,傳輸到客戶端,因此我們打開我們的數據文件。我們使用 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。選擇此端口號的原因是因爲它高於需要root訪問系統的端口範圍。

    這是服務器執行分支:

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

    作爲服務器,我們需要爲套接字描述符分配一個地址。這是通過 bind 系統調用來實現的,它爲套接字描述符(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 系統調用從掛起連接隊列上的第一個連接請求,創建新的連接套接字。調用的返回值是新創建的連接的描述符;套接字現在可以進行 read/write 或 poll/select 系統調用:

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

    在客戶端套接字描述符上創建連接,因此我們可以開始將數據傳輸到遠程系統。我們通過 sendfile 系統調用來實現這一點,該調用是在Linux下通過以下方式原型化的:

extern ssize_t
sendfile (int __out_fd, int __in_fd, off_t *offset, size_t __count) __THROW;

    前兩個參數是文件描述符。第三個參數指向 sendfile 應該開始發送數據的偏移量。第四個參數是我們要傳輸的字節數。爲了使 sendfile 傳輸使用零複製功能,你需要從網卡獲得內存收集操作支持。還需要實現校驗和的協議和功能,例如TCP或UDP。如果你的NIC已經過時且不支持這些功能,你仍然可以使用 sendfile 來傳輸文件。不同之處在於內核會在傳輸之前合併緩衝區。

 

    移植性問題

    通常,系統調用 sendfile 的一個問題是缺少標準實現,就像開放系統調用一樣。Linux,Solaris或HP-UX中的Sendfile實現完全不同。這對於希望在其網絡數據傳輸代碼中使用零拷貝的開發人員來說是個問題。

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

    第二個區別是Linux沒有實現向量傳輸。Solaris和HP-UX的Sendfile具有額外的參數,可以消除與正在傳輸的數據添加頭部的開銷。

 

    展望

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

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

 

    結論

    儘管現在的sendfile有一些缺點,但零複製sendfile確實是一個不錯的功能,我希望你通過看完這篇文章後就能開始在你的程序中使用它。如果你對這個主題有更深入的興趣,請留意我的第二篇文章-"零拷貝II: 內核視角",在這裏我將深入研究零拷貝的內核內部機制。

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