翻譯轉(zhuǎn)載自:https://blog.biezhi.me/2019/01/zero-copy-user-mode-perspective.html
現(xiàn)在幾乎所有人都聽過 Linux 下的零拷貝技術(shù)捧弃,但我經(jīng)常遇到對這個問題不能深入理解的人赠叼。所以我寫了這篇文章,來深入研究這些問題违霞。本文通過用戶態(tài)程序的角度來看零拷貝嘴办,因此我有意忽略了內(nèi)核級別的實現(xiàn)。
什么是 “零拷貝” 买鸽?
為了更好的理解這個問題涧郊,我們首先需要了解問題本身。來看一個網(wǎng)絡(luò)服務(wù)的簡單運行過程眼五,在這個過程中將磁盤的文件讀取到緩沖區(qū)妆艘,然后通過網(wǎng)絡(luò)發(fā)送給客戶端。下面是示例代碼:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
這個例子看起來非常簡單看幼,你可能會認(rèn)為只有兩次系統(tǒng)調(diào)用不會產(chǎn)生太多的系統(tǒng)開銷批旺。實際上并非如此,在這兩次調(diào)用之后诵姜,數(shù)據(jù)至少被拷貝了 4 次汽煮,同時還執(zhí)行了很多次 用戶態(tài)/內(nèi)核態(tài) 的上下文切換。(實際上這個過程是非常復(fù)雜的棚唆,為了解釋我盡可能保持簡單)為了更好的理解這個過程暇赤,請查看下圖中的上下文切換,圖片上部分展示上下文切換過程宵凌,下部分展示拷貝操作鞋囊。
- 程序調(diào)用
read
產(chǎn)生一次用戶態(tài)到內(nèi)核態(tài)的上下文切換。DMA 模塊從磁盤讀取文件內(nèi)容摆寄,將其拷貝到內(nèi)核空間的緩沖區(qū)失暴,完成第 1 次拷貝坯门。 - 數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶空間緩沖區(qū)微饥,之后系統(tǒng)調(diào)用
read
返回,這回導(dǎo)致從內(nèi)核空間到用戶空間的上下文切換古戴。這個時候數(shù)據(jù)存儲在用戶空間的tmp_buf
緩沖區(qū)內(nèi)欠橘,可以后續(xù)的操作了。 - 程序調(diào)用
write
產(chǎn)生一次用戶態(tài)到內(nèi)核態(tài)的上下文切換现恼。數(shù)據(jù)從用戶空間緩沖區(qū)被拷貝到內(nèi)核空間緩沖區(qū)肃续,完成第 3 次拷貝黍檩。但是這次數(shù)據(jù)存儲在一個和socket
相關(guān)的緩沖區(qū)中,而不是第一步的緩沖區(qū)始锚。 -
write
調(diào)用返回刽酱,產(chǎn)生第 4 個上下文切換。第 4 次拷貝在 DMA 模塊將數(shù)據(jù)從內(nèi)核空間緩沖區(qū)傳遞至協(xié)議引擎的時候發(fā)生瞧捌,這與我們的代碼的執(zhí)行是獨立且異步發(fā)生的棵里。你可能會疑惑:“為何要說是獨立、異步姐呐?難道不是在write
系統(tǒng)調(diào)用返回前數(shù)據(jù)已經(jīng)被傳送了殿怜?write 系統(tǒng)調(diào)用的返回,并不意味著傳輸成功——它甚至無法保證傳輸?shù)拈_始曙砂。調(diào)用的返回头谜,只是表明以太網(wǎng)驅(qū)動程序在其傳輸隊列中有空位,并已經(jīng)接受我們的數(shù)據(jù)用于傳輸鸠澈≈妫可能有眾多的數(shù)據(jù)排在我們的數(shù)據(jù)之前。除非驅(qū)動程序或硬件采用優(yōu)先級隊列的方法笑陈,各組數(shù)據(jù)是依照FIFO的次序被傳輸?shù)?上圖中叉狀的 DMA copy 表明這最后一次拷貝可以被延后)末荐。
mmap
如你所見,上面的數(shù)據(jù)拷貝非常多新锈,我們可以減少一些重復(fù)拷貝來減少開銷甲脏,提升性能。作為一名驅(qū)動程序開發(fā)人員妹笆,我的工作圍繞著擁有先進特性的硬件展開块请。某些硬件支持完全繞開內(nèi)存,將數(shù)據(jù)直接傳送給其他設(shè)備拳缠。這個特性消除了系統(tǒng)內(nèi)存中的數(shù)據(jù)副本墩新,因此是一種很好的選擇,但并不是所有的硬件都支持窟坐。此外海渊,來自于硬盤的數(shù)據(jù)必須重新打包(地址連續(xù))才能用于網(wǎng)絡(luò)傳輸,這也引入了某些復(fù)雜性哲鸳。為了減少開銷臣疑,我們可以從消除內(nèi)核緩沖區(qū)與用戶緩沖區(qū)之間的拷貝開始。
減少數(shù)據(jù)拷貝的一種方法是將 read
調(diào)用改為 mmap
徙菠。例如:
tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);
為了方便你理解讯沈,請參考下圖的過程。
-
mmap
調(diào)用導(dǎo)致文件內(nèi)容通過 DMA 模塊拷貝到內(nèi)核緩沖區(qū)婿奔。然后與用戶進程共享緩沖區(qū)缺狠,這樣不會在內(nèi)核緩沖區(qū)和用戶空間之間產(chǎn)生任何拷貝问慎。 -
write
調(diào)用導(dǎo)致內(nèi)核將數(shù)據(jù)從原始內(nèi)核緩沖區(qū)拷貝到與socket
關(guān)聯(lián)的內(nèi)核緩沖區(qū)中。 - 第 3 次數(shù)據(jù)拷貝發(fā)生在 DMA 模塊將數(shù)據(jù)從
socket
緩沖區(qū)傳遞給協(xié)議引擎時挤茄。
通過調(diào)用 mmap
而不是 read
如叼,我們已經(jīng)將內(nèi)核拷貝數(shù)據(jù)操作減半。當(dāng)傳輸大量數(shù)據(jù)時穷劈,效果會非常好薇正。然而,這種改進并非沒有代價囚衔;使用 mmap + write
方式存在一些隱藏的陷阱挖腰。當(dāng)內(nèi)存中做文件映射后調(diào)用 write
,與此同時另一個進程截斷這個文件時练湿。此時 write
調(diào)用的進程會收到一個 SIGBUS
中斷信號猴仑,因為當(dāng)前進程訪問了非法內(nèi)存地址。這個信號默認(rèn)情況下會殺死當(dāng)前進程并生成 dump
文件——而這對于網(wǎng)絡(luò)服務(wù)器程序而言不是最期望的操作肥哎。有兩種方式可用于解決該問題:
第一種方法是處理收到的 SIGBUS
信號辽俗,然后在處理程序中簡單地調(diào)用 return
。通過這樣做篡诽,write
調(diào)用會返回它在被中斷之前寫入的字節(jié)數(shù)崖飘,并且將全局變量 errno
設(shè)置為成功。我認(rèn)為這是一個治標(biāo)不治本的解決方案杈女。因為收到 SIGBUS
信號表示程序發(fā)生了嚴(yán)重的錯誤朱浴,我不推薦使用它作為解決方案。
第二種方式應(yīng)用了文件租借(在Microsoft Windows系統(tǒng)中被稱為“機會鎖”)达椰。這才是解勸前面問題的正確方式翰蠢。通過對文件描述符執(zhí)行租借,可以同內(nèi)核就某個特定文件達成租約啰劲。從內(nèi)核可以獲得讀/寫租約梁沧。當(dāng)另外一個進程試圖將你正在傳輸?shù)奈募財鄷r,內(nèi)核會向你的進程發(fā)送實時信號——RT_SIGNAL_LEASE蝇裤。該信號通知你的進程廷支,內(nèi)核即將終止在該文件上你曾獲得的租約。這樣栓辜,在write調(diào)用訪問非法內(nèi)存地址恋拍、并被隨后接收到的SIGBUS信號殺死之前,write系統(tǒng)調(diào)用就被RT_SIGNAL_LEASE信號中斷了啃憎。write的返回值是在被中斷前已寫的字節(jié)數(shù)芝囤,全局變量errno設(shè)置為成功。下面是一段展示如何從內(nèi)核獲得租約的示例代碼辛萍。
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;
}
在對文件進行映射前悯姊,應(yīng)該先獲得租約,并在結(jié)束 write
操作后結(jié)束租約贩毕。這是通過在 fcntl
調(diào)用中指定租約類型為 F_UNLCK
來實現(xiàn)的悯许。
Sendfile
在內(nèi)核的 2.1 版本中,引入了 sendfile
系統(tǒng)調(diào)用辉阶,目的是簡化通過網(wǎng)絡(luò)和兩個本地文件之間的數(shù)據(jù)傳輸先壕。sendfile
的引入不僅減少了數(shù)據(jù)拷貝,還減少了上下文切換谆甜±牛可以這樣使用它:
sendfile(socket, file, len);
同樣的,為了理解起來方便规辱,可以看下圖的調(diào)用過程谆棺。
-
sendfile
調(diào)用會使得文件內(nèi)容通過 DMA 模塊拷貝到內(nèi)核緩沖區(qū)。然后罕袋,內(nèi)核將數(shù)據(jù)拷貝到與socket
關(guān)聯(lián)的內(nèi)核緩沖區(qū)中改淑。 - 第 3 次拷貝發(fā)生在 DMA 模塊將數(shù)據(jù)從內(nèi)核
socket
緩沖區(qū)傳遞到協(xié)議引擎時。
你可能想問當(dāng)我們使用 sendfile
調(diào)用傳輸文件時有另一個進程截斷會發(fā)生什么浴讯?如果我們沒有注冊任何信號處理程序朵夏,sendfile
調(diào)用只會返回它在被中斷之前傳輸?shù)淖止?jié)數(shù),并且全局變量 errno
被設(shè)置為成功榆纽。
但是仰猖,如果我們在調(diào)用 sendfile
之前從內(nèi)核獲得了文件租約,那么行為和返回狀態(tài)完全相同奈籽。我們會在sendfile
調(diào)用返回之前收到一個 RT_SIGNAL_LEASE
信號亮元。
到目前為止,我們已經(jīng)能夠避免讓內(nèi)核產(chǎn)生多次拷貝唠摹,但我們還有一次拷貝爆捞。這可以避免嗎勾拉?當(dāng)然,在硬件的幫助下成肘。為了避免內(nèi)核完成的所有數(shù)據(jù)拷貝双霍,我們需要一個支持收集操作的網(wǎng)絡(luò)接口染坯。這僅僅意味著等待傳輸?shù)臄?shù)據(jù)不需要在內(nèi)存中单鹿;它可以分散在各種存儲位置仲锄。在內(nèi)核 2.4 版本中儒喊,修改了 socket
緩沖區(qū)描述符以適應(yīng)這些要求 - 在 Linux 下稱為零拷貝币呵。這種方法不僅減少了多個上下文切換掸驱,還避免了處理器完成的數(shù)據(jù)拷貝毕贼。對于用戶的程序不用做什么修改鬼癣,所以代碼仍然如下所示:
sendfile(socket, file, len);
為了更好地了解所涉及的過程待秃,請查看下圖
-
sendfile
調(diào)用會導(dǎo)致文件內(nèi)容通過 DMA 模塊拷貝到內(nèi)核緩沖區(qū)。 - 沒有數(shù)據(jù)被復(fù)制到
socket
緩沖區(qū)。相反培廓,只有關(guān)于數(shù)據(jù)的位置和長度信息的描述符被附加到socket
緩沖區(qū)肩钠。DMA 模塊將數(shù)據(jù)直接從內(nèi)核緩沖區(qū)傳遞到協(xié)議引擎当纱,從而避免了剩余的最終拷貝惫东。
因為數(shù)據(jù)實際上仍然是從磁盤復(fù)制到內(nèi)存,從內(nèi)存復(fù)制到總線徐矩,所以有人可能會認(rèn)為這不是真正的零拷貝滤灯。但從操作系統(tǒng)的角度來看鳞骤,這是零拷貝豫尽,因為內(nèi)核緩沖區(qū)之間的數(shù)據(jù)不會產(chǎn)生多余的拷貝。使用零拷貝時榴嗅,除了避免拷貝外嗽测,還可以獲得其他性能優(yōu)勢唠粥,比如更少的上下文切換,更少的 CPU 高速緩存污染以及不會產(chǎn)生 CPU 校驗和計算养涮。
現(xiàn)在我們知道了什么是零拷貝贯吓,把前面的理論通過編碼來實踐悄谐。你可以從 http://www.xalien.org/articles/source/sfl-src.tgz下載源碼们陆。解壓源碼需要執(zhí)行 tar -zxvf sfl-src.tgz
坪仇,然后編譯代碼并創(chuàng)建一個隨機數(shù)據(jù)文件 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
調(diào)用的頭文件 - <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);
同樣的程序既可以充當(dāng) 服務(wù)端/發(fā)送者屯伞,也可以充當(dāng) 客戶端/接受者珠移。這里我們接收一個命令提示符參數(shù)钧惧,通過該參數(shù)將標(biāo)志 is_server
設(shè)置為以 發(fā)送方模式 運行巧婶。我們還打開了 INET
協(xié)議族的流套接字。作為在服務(wù)端運行的一部分,我們需要某種類型的數(shù)據(jù)傳輸?shù)娇蛻舳朔9矗源蜷_我們的數(shù)據(jù)文件(data.bin)。由于我們使用 sendfile
來傳輸數(shù)據(jù)场刑,所以不用讀取文件的實際內(nèi)容將其存儲在程序的緩沖區(qū)中铐懊。這是服務(wù)端地址:
/* 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]);
我們重置了服務(wù)端地址結(jié)構(gòu)并分配了端口和 IP 地址。服務(wù)端的地址作為命令行參數(shù)傳遞鹏溯,端口號寫死為 1033
,選擇這個端口號是因為它是一個允許訪問的端口范圍。
下面是服務(wù)端執(zhí)行的代碼分支:
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);
}
}
作為服務(wù)端,我們需要為 socket
描述符分配一個地址僧须。這是通過系統(tǒng)調(diào)用 bind
實現(xiàn)的,它為 socket
描述符(sd)分配一個服務(wù)器地址(sa):
if(listen(sd,1) < 0){
perror("listen");
exit(errno);
}
因為我們正在使用流套接字,所以我們必須接受傳入連接并設(shè)置連接隊列大小。我將緩沖壓隊列設(shè)置為 1,但對于等待接受的已建立連接析恢,一般會將緩沖值要設(shè)置的更高一些。在舊版本的內(nèi)核中,緩沖隊列用于防止 syn flood
攻擊。由于系統(tǒng)調(diào)用 listen
已經(jīng)修改為 僅為已建立的連接設(shè)置參數(shù)逆巍,所以不使用這個調(diào)用的緩沖隊列功能。內(nèi)核參數(shù) tcp_max_syn_backlog
代替了保護系統(tǒng)免受 syn flood
攻擊的角色:
if((client = accept(sd, NULL, NULL)) < 0){
perror("accept");
exit(errno);
}
accept
調(diào)用從掛起連接隊列上的第一個連接請求創(chuàng)建一個新的 socket
連接。調(diào)用的返回值是新創(chuàng)建的連接的描述符; socket
現(xiàn)在可以進行讀、寫或輪詢/select 了:
if((cnt = sendfile(client,fd,&off,
BUFF_SIZE)) < 0){
perror("sendfile");
exit(errno);
}
printf("Server sent %d bytes.\n", cnt);
close(client);
在客戶端 socket
描述符上建立連接,我們可以開始將數(shù)據(jù)傳輸?shù)竭h(yuǎn)端。通過 sendfile
調(diào)用來實現(xiàn)懦胞,該調(diào)用是在 Linux 下通過以下方式原型化的:
extern ssize_t
sendfile (int __out_fd, int __in_fd, off_t *offset,
size_t __count) __THROW;
- 前兩個參數(shù)是文件描述符。
- 第 3 個參數(shù)指向
sendfile
開始發(fā)送數(shù)據(jù)的偏移量胀糜。 - 第四個參數(shù)是我們要傳輸?shù)淖止?jié)數(shù)颅拦。
為了使 sendfile
傳輸使用零拷貝功能,你需要從網(wǎng)卡獲得內(nèi)存收集操作支持僚纷。還需要實現(xiàn)校驗和的協(xié)議的校驗和功能矩距,通過 TCP 或 UDP。如果你的 NIC
已過時不支持這些功能怖竭,你也可以使用 sendfile
來傳輸文件,不同之處在于內(nèi)核會在傳輸之前合并緩沖區(qū)陡蝇。
移植性問題
通常痊臭,sendfile
系統(tǒng)調(diào)用的一個問題是缺少標(biāo)準(zhǔn)實現(xiàn),就像開放系統(tǒng)調(diào)用一樣登夫。Linux广匙、Solaris 或 HP-UX 中 的 Sendfile 實現(xiàn)完全不同。這對于想通過代碼實現(xiàn)零拷貝的開發(fā)人員而言是個問題恼策。
其中一個實現(xiàn)差異是 Linux 提供了一個 sendfile
接口鸦致,用于在兩個文件描述符(文件到文件)和(文件到socket)之間傳輸數(shù)據(jù)。另一方面涣楷,HP-UX 和 Solaris 只能用于文件到 socket 的提交分唾。
第二個區(qū)別是 Linux 沒有實現(xiàn)向量傳輸。Solaris sendfile 和 HP-UX sendfile 有一些擴展參數(shù)狮斗,可以避免與正在傳輸?shù)臄?shù)據(jù)添加頭部的開銷绽乔。
展望
Linux 下的零拷貝實現(xiàn)離最終實現(xiàn)還有點距離,并且很可能在不久的將來發(fā)生變化碳褒。要添加更多功能折砸,例如看疗,sendfile 調(diào)用不支持向量傳輸,而 Samba 和 Apache 等服務(wù)器必須使用設(shè)置了 TCP_CORK
標(biāo)志的多個sendfile 調(diào)用睦授。這個標(biāo)志告訴系統(tǒng)在下一個 sendfile
調(diào)用中會有更多數(shù)據(jù)通過两芳。TCP_CORK
和TCP_NODELAY
不兼容,并且在我們想要在數(shù)據(jù)前添加或附加標(biāo)頭時使用去枷。這是一個完美的例子盗扇,其中向量調(diào)用將消除對當(dāng)前實現(xiàn)所強制的多個 sendfile
調(diào)用和延遲的需要。
當(dāng)前 sendfile 中一個相當(dāng)令人不快的限制是它在傳輸大于2GB的文件時無法使用沉填。如此大小的文件在今天并不罕見疗隶,并且在出路時復(fù)制所有數(shù)據(jù)相當(dāng)令人失望。因為在這種情況下sendfile和mmap方法都不可用翼闹,所以sendfile64在未來的內(nèi)核版本中會非常方便斑鼻。
總結(jié)
盡管有一些缺點,不過通過 sendfile
來實現(xiàn)零拷貝也很有用猎荠,我希望你在閱讀本文后可以開始在你的程序中使用它坚弱。如果想對這個主題有更深入的興趣,請留意我的第二篇文章关摇,標(biāo)題為 “零拷貝 - 內(nèi)核態(tài)分析”荒叶,我將在零拷貝的內(nèi)核內(nèi)部挖掘更多內(nèi)容。