如今幾乎全部人都聽過 Linux 下的零拷貝技術,但我常常遇到對這個問題不能深刻理解的人。因此我寫了這篇文章,來深刻研究這些問題。本文經過用戶態程序的角度來看零拷貝,所以我有意忽略了內核級別的實現。linux
爲了更好的理解這個問題,咱們首先須要瞭解問題自己。來看一個網絡服務的簡單運行過程,在這個過程當中將磁盤的文件讀取到緩衝區,而後經過網絡發送給客戶端。下面是示例代碼:segmentfault
read(file, tmp_buf, len); write(socket, tmp_buf, len);
這個例子看起來很是簡單,你可能會認爲只有兩次系統調用不會產生太多的系統開銷。實際上並不是如此,在這兩次調用以後,數據至少被拷貝了 4 次,同時還執行了不少次 用戶態/內核態 的上下文切換。(實際上這個過程是很是複雜的,爲了解釋我儘量保持簡單)爲了更好的理解這個過程,請查看下圖中的上下文切換,圖片上部分展現上下文切換過程,下部分展現拷貝操做。緩存
read
產生一次用戶態到內核態的上下文切換。DMA 模塊從磁盤讀取文件內容,將其拷貝到內核空間的緩衝區,完成第 1 次拷貝。read
返回,這回致使從內核空間到用戶空間的上下文切換。這個時候數據存儲在用戶空間的 tmp_buf
緩衝區內,能夠後續的操做了。write
產生一次用戶態到內核態的上下文切換。數據從用戶空間緩衝區被拷貝到內核空間緩衝區,完成第 3 次拷貝。可是此次數據存儲在一個和 socket
相關的緩衝區中,而不是第一步的緩衝區。write
調用返回,產生第 4 個上下文切換。第 4 次拷貝在 DMA 模塊將數據從內核空間緩衝區傳遞至協議引擎的時候發生,這與咱們的代碼的執行是獨立且異步發生的。你可能會疑惑:「爲什麼要說是獨立、異步?難道不是在 write
系統調用返回前數據已經被傳送了?write 系統調用的返回,並不意味着傳輸成功——它甚至沒法保證傳輸的開始。調用的返回,只是代表以太網驅動程序在其傳輸隊列中有空位,並已經接受咱們的數據用於傳輸。可能有衆多的數據排在咱們的數據以前。除非驅動程序或硬件採用優先級隊列的方法,各組數據是依照FIFO的次序被傳輸的(上圖中叉狀的 DMA copy 代表這最後一次拷貝能夠被延後)。如你所見,上面的數據拷貝很是多,咱們能夠減小一些重複拷貝來減小開銷,提高性能。做爲一名驅動程序開發人員,個人工做圍繞着擁有先進特性的硬件展開。某些硬件支持徹底繞開內存,將數據直接傳送給其餘設備。這個特性消除了系統內存中的數據副本,所以是一種很好的選擇,但並非全部的硬件都支持。此外,來自於硬盤的數據必須從新打包(地址連續)才能用於網絡傳輸,這也引入了某些複雜性。爲了減小開銷,咱們能夠從消除內核緩衝區與用戶緩衝區之間的拷貝開始。服務器
減小數據拷貝的一種方法是將 read
調用改成 mmap
。例如:網絡
tmp_buf = mmap(file, len); write(socket, tmp_buf, len);
爲了方便你理解,請參考下圖的過程。異步
mmap
調用致使文件內容經過 DMA 模塊拷貝到內核緩衝區。而後與用戶進程共享緩衝區,這樣不會在內核緩衝區和用戶空間之間產生任何拷貝。write
調用致使內核將數據從原始內核緩衝區拷貝到與 socket
關聯的內核緩衝區中。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
來實現的。編碼
在內核的 2.1 版本中,引入了 sendfile
系統調用,目的是簡化經過網絡和兩個本地文件之間的數據傳輸。sendfile
的引入不只減小了數據拷貝,還減小了上下文切換。能夠這樣使用它:
sendfile(socket, file, len);
一樣的,爲了理解起來方便,能夠看下圖的調用過程。
sendfile
調用會使得文件內容經過 DMA 模塊拷貝到內核緩衝區。而後,內核將數據拷貝到與 socket
關聯的內核緩衝區中。socket
緩衝區傳遞到協議引擎時。你可能想問當咱們使用 sendfile
調用傳輸文件時有另外一個進程截斷會發生什麼?若是咱們沒有註冊任何信號處理程序,sendfile
調用只會返回它在被中斷以前傳輸的字節數,而且全局變量 errno
被設置爲成功。
可是,若是咱們在調用 sendfile
以前從內核得到了文件租約,那麼行爲和返回狀態徹底相同。咱們會在sendfile
調用返回以前收到一個 RT_SIGNAL_LEASE
信號。
到目前爲止,咱們已經可以避免讓內核產生屢次拷貝,但咱們還有一次拷貝。這能夠避免嗎?固然,在硬件的幫助下。爲了不內核完成的全部數據拷貝,咱們須要一個支持收集操做的網絡接口。這僅僅意味着等待傳輸的數據不須要在內存中;它能夠分散在各類存儲位置。在內核 2.4 版本中,修改了 socket
緩衝區描述符以適應這些要求 - 在 Linux 下稱爲零拷貝。這種方法不只減小了多個上下文切換,還避免了處理器完成的數據拷貝。對於用戶的程序不用作什麼修改,因此代碼仍然以下所示:
sendfile(socket, file, len);
爲了更好地瞭解所涉及的過程,請查看下圖
sendfile
調用會致使文件內容經過 DMA 模塊拷貝到內核緩衝區。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;
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_CORK
和TCP_NODELAY
不兼容,而且在咱們想要在數據前添加或附加標頭時使用。這是一個完美的例子,其中向量調用將消除對當前實現所強制的多個 sendfile
調用和延遲的須要。
當前 sendfile 中一個至關使人不快的限制是它在傳輸大於2GB的文件時沒法使用。如此大小的文件在今天並不罕見,而且在出路時複製全部數據至關使人失望。由於在這種狀況下sendfile和mmap方法都不可用,因此sendfile64在將來的內核版本中會很是方便。
儘管有一些缺點,不過經過 sendfile
來實現零拷貝也頗有用,我但願你在閱讀本文後能夠開始在你的程序中使用它。若是想對這個主題有更深刻的興趣,請留意個人第二篇文章,標題爲 「零拷貝 - 內核態分析」,我將在零拷貝的內核內部挖掘更多內容。
英文原文: http://www.linuxjournal.com/article/6345
本文由博客一文多發平臺 OpenWrite 發佈!