|
考慮將一個本地文件通過socket發(fā)送出去的問題。我們通常的做法是:打開文件fd和一個socket,然后循環(huán)地從文件fd中read數(shù)據(jù),并將讀取的數(shù)據(jù)send到socket中。這樣,每次讀寫我們都需要兩次系統(tǒng)調(diào)用,并且數(shù)據(jù)會被從內(nèi)核拷貝到用戶空間(read),再從用戶空間拷貝到內(nèi)核(send)。
而sendfile就將整個發(fā)送過程封裝在一個系統(tǒng)調(diào)用中,避免了多次系統(tǒng)調(diào)用,避免了數(shù)據(jù)在內(nèi)核空間和用戶空間之間的大量拷貝。
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
雖然這個系統(tǒng)調(diào)用接收in和out兩個fd,但是有所限制,in只能是普通文件,out只能是socket(這個限制不知道后來的內(nèi)核版本有沒有放寬)。
一、什么是“零拷貝” 零拷貝(zero-copy)基本思想是:數(shù)據(jù)報從網(wǎng)絡(luò)設(shè)備到用戶程序空間傳遞的過程中,減少數(shù)據(jù)拷貝次數(shù),減少系統(tǒng)調(diào)用,實現(xiàn)CPU的零參與,徹底消除CPU在這方面的負載。實現(xiàn)零拷貝用到的最主要技術(shù)是DMA數(shù)據(jù)傳輸技術(shù)和內(nèi)存區(qū)域映射技術(shù)。 先看普通網(wǎng)絡(luò)服務守護進程的一般服務方法: 表面上看來,系統(tǒng)的負荷似乎只是兩個系統(tǒng)調(diào)用,而沒什么開銷。如果這么認為,那么這就大錯特錯了。在這兩個函數(shù)的背后,數(shù)據(jù)至少已經(jīng)被復制了 4 次,以及幾乎一樣的用戶空間到內(nèi)核空間的切換次數(shù)(上下文切換)。下圖可以很好的說明這一個過程: 
分析上圖中的步驟:
第一步,系統(tǒng)調(diào)用 read() 導致了用戶空間到內(nèi)核空間的切換。這時,DMA模塊將磁盤上的文件內(nèi)容拷貝(CMA COPY)到內(nèi)核緩沖區(qū),這就完成了第 1 次復制。
第二步,將內(nèi)核緩沖區(qū)中的內(nèi)容拷貝到用戶緩沖區(qū)(kernel buffer --> user buffer),這樣又導致了內(nèi)核空間到用戶空間的上下文切換。這時,我們需要的數(shù)據(jù)已經(jīng)存放在 tmp_buf 中,這是第 2 次復制。
第三步,在調(diào)用 write() 時,又導致了用戶空間到內(nèi)核空間的上下文切換。數(shù)據(jù)從用戶空間緩沖區(qū)再次被復制到內(nèi)核空間緩沖區(qū)。這時完成了第 3 次的復制。這里需要注意的是,這次所用的內(nèi)核緩沖區(qū)是與 socket 相關(guān)的緩沖區(qū),而非“第一步”中的緩沖區(qū)。
第四步,write() 系統(tǒng)調(diào)用返回,這時導致了第 4 次的上下文切換。這一次是 DMA 模塊將內(nèi)核中的數(shù)據(jù)(socket buffer)拷貝到協(xié)議引擎中。這些動作是與代碼的執(zhí)行是獨立并且是異步發(fā)生的。反過來說,假如是非獨立且同步的,那么函數(shù)返回時那么數(shù)據(jù)也同時被發(fā)送。事實上并非如此,函數(shù)返回并不能說明數(shù)據(jù)被發(fā)送出去了,甚至是 write() 的返回都無法保證傳輸?shù)拈_始!為什么這么說呢?這是因為,調(diào)用的返回,只是表明以太網(wǎng)驅(qū)網(wǎng)卡的動程序在其傳輸隊列中有空位,并且已經(jīng)接受我們的數(shù)據(jù)用于傳輸。這時候,有一種情況可能是,還有許多數(shù)據(jù)還排在我們這些數(shù)據(jù)的前頭,除非我們的數(shù)據(jù)擁有特權(quán)或優(yōu)先級很高(如果以太網(wǎng)驅(qū)動程序是采用隊列優(yōu)先級的方法來發(fā)送數(shù)據(jù)且我們的數(shù)據(jù)的優(yōu)先級確實很高),否則數(shù)據(jù)按照 FIFO 這種先進先出的方法傳送。所以在上圖中,紅色虛線箭頭正表示了我們的傳輸可能發(fā)生延遲,若是實線,則剛好發(fā)送而沒延遲。
正如上面所分析,整個過程中存在著數(shù)據(jù)冗余。某些冗余可以消除以減少開銷并提升性能。有些硬件支持完全繞開內(nèi)存,可以將數(shù)據(jù)直接傳遞給其它設(shè)備,這樣的特性避免了系統(tǒng)內(nèi)存中的數(shù)據(jù)副本,雖然這是一種很好的選擇,但并非所有的硬件都能如此。此外,來自硬盤的數(shù)據(jù)必須重新打包(地址連續(xù))才能用于網(wǎng)絡(luò)傳輸,這讓情況也會變得有些復雜。
為了減少開銷,我們就從消除內(nèi)核緩沖區(qū)和用戶緩沖區(qū)之間的復制入手。
消除的一種方法就是使用 mmap() 系統(tǒng)調(diào)用來替代 read() 系統(tǒng)調(diào)用,例如: tmp_buf = mmap(file, len); write(socket, tmp_buf, len); 工作原理如下圖: 
從上圖可見:
第一步,調(diào)用 mmap() 后,文件的內(nèi)容通過 DMA 模塊復制到內(nèi)核緩沖區(qū)中,該緩沖區(qū)之后與用戶進程共享,這樣一來,就無需再進行內(nèi)核緩沖區(qū)和用戶緩沖區(qū)的切換,也就是它們之間的復制就不會再發(fā)生。
第二步,在調(diào)用 write() 后,內(nèi)核緩沖區(qū)中的數(shù)據(jù)被復制到與 socket 相關(guān)聯(lián)的內(nèi)核緩沖區(qū)中。
第三步,DMA 模塊將數(shù)據(jù)由 socket 的緩沖區(qū)復制到協(xié)議引擎,這時第 3 次復制發(fā)生。
通過調(diào)用 mmap() 而不是 read(),我們已經(jīng)將內(nèi)核需要執(zhí)行的復制操作減半。當有大量的數(shù)據(jù)傳輸時,有相當好的效果。但是性能改進的同時,也潛藏著一定的代價與陷阱。比如,在對文件進行內(nèi)存映射后調(diào)用 write(),而這時有另外一個進程將映射的文件截斷,此時 write() 系統(tǒng)調(diào)用會被進程接收到的 SIGBUS 信號而中斷,SIGBUS 信號往往意味著嘗試進行非法地址訪問。對 SIGBUS 信號的默認處理方式是殺死當前進程并生成 core dump 文件 -- 這對于網(wǎng)絡(luò)服務器來說是極不期望的!
有兩種方式可以解決該問題:
第一種是為 SIGBUS 信號設(shè)置處理程序,并在處理中簡單的執(zhí)行 return 語句。這樣,write() 系統(tǒng)調(diào)用將返回被信號中斷前已寫的字節(jié)數(shù),同時設(shè)置 errno 變量。但是這樣的做法并不值得鼓勵,因為收到 SIGBUS 信號意味著發(fā)生了嚴重錯誤!
第二種是采用文件租約的方式。文件租約是指,通過對文件描述符執(zhí)行租借,你可以和內(nèi)核對某個文件達成租約,從內(nèi)核可以獲得讀/寫租約。當另外的一個進程試圖將你正在傳輸?shù)奈募財鄷r,內(nèi)核會向你發(fā)出實時信號 RT_SIGNAL_LEASE 。該信號通知你的進程,內(nèi)核即將終止你在該文件上曾經(jīng)獲得的租約。這樣,當 write() 訪問非法地址時,并即被隨后到來的 SIGBUS 殺訴之前,write() 系統(tǒng)調(diào)用會被 RT_SIGNAL_LEASE 信號中斷。write() 的返回值就是被中斷之前已寫的字節(jié)數(shù),全局變量 errno 設(shè)置為成功。下面是一段示例租約的代碼: 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() 系統(tǒng)調(diào)用),應該先獲得租約,并在 write() 結(jié)束之后結(jié)束租約。這通過在 fcntl() 函數(shù)中指定租約類型為 F_UNLCK 來實現(xiàn)。 sendfile
sendfile() 的目的是簡化通過網(wǎng)絡(luò)在兩個本地之間的數(shù)據(jù)傳輸過程。sendfile() 系統(tǒng)調(diào)用的引入,不僅減少了數(shù)據(jù)的復制,還減少了上下文切換的次數(shù)。 使用方法如下:
#include <sys/sendfile.h> ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); 它的原理如下圖所示: 
在上圖中,
第一步,sendfile() 導致文件內(nèi)容通過 DMA 模塊復制到某個內(nèi)核緩沖區(qū),之后被復制到與 soket 相關(guān)聯(lián)的的緩沖區(qū)中。
第二步,DMA 模塊將 socket 緩沖區(qū)中的數(shù)據(jù)復制到協(xié)議親引擎,這時進行第 3 次復制。
在調(diào)用 sendfile() 期間,如果有另外一個進程將文件截斷,且進程沒有為 SIGBUS 注冊任何的信號處理函數(shù)時,sendfile() 調(diào)用仍會返回進程被信號中斷前已發(fā)送的字節(jié)數(shù),并將全局變量 errno 設(shè)置為成功。然而,類似的,如果在調(diào)用 sendfile() 前,從內(nèi)核里獲得了文件租約,那么 sendfile() 在返回前也會收到 RT_SIGNAL_LEASE。
到此為止,我們已經(jīng)能夠避免內(nèi)核的多次復制了,然而我們還存在一個多余的副本,這里就是 socket buffer。那么,這個副本是否可以消除? 確實可以,但這需要特定硬件的支持。為了消除內(nèi)核產(chǎn)生的冗余數(shù)據(jù),需要網(wǎng)絡(luò)適配器支持聚合操作特性。該特性被支持意味著要發(fā)送的數(shù)據(jù)無須存放在地址連續(xù)的內(nèi)存空間中,相反可以存放在各個內(nèi)存位置。在 2.4 版本的內(nèi)核中,socket 緩沖區(qū)描述符發(fā)生了變動,以適合聚合(將零散分布的數(shù)據(jù)聚合起來發(fā)送)操作的要求 --這就是 Linux 中所謂的“零拷貝”。這種方式不但減少了多個上下文的切換,而且消除了數(shù)據(jù)冗余。從用戶層程序的角度來看,沒有發(fā)生任何改動,所有的代碼還是和以前一樣。這里所描述原理如下圖所示: 
在上圖中,
第一步,sendfile() 系統(tǒng)調(diào)用導致 DMA 模塊將文件內(nèi)容復制到內(nèi)核緩沖區(qū)中。
第二步,數(shù)據(jù)并未復制到 socket 緩沖區(qū)中,取而代之的是,只有記錄數(shù)據(jù)位置和長度的描述符被加入到 socket 緩沖區(qū)中。DMA 模塊將內(nèi)核緩沖區(qū)中的數(shù)據(jù)傳遞給協(xié)議引擎,從而消除了遺留的最后一次復制。
由于數(shù)據(jù)實際上仍然要通過從硬盤拷貝到內(nèi)存,再由內(nèi)存再發(fā)送到設(shè)備,有人可能會覺得這不是真正的“零拷貝”。然而,從操作系統(tǒng)的角度來看,這就是“零拷貝”,因為內(nèi)核空間不存在冗余數(shù)據(jù)(既不會存在將數(shù)據(jù)存放內(nèi)核一塊數(shù)據(jù)緩沖區(qū)中,又將數(shù)據(jù)存放在 socket 緩沖區(qū)中)。應用“零拷貝”特性,除了避免了重復復制外,還可以獲得其他性能的優(yōu)勢,例如更少的上下文切換,更少的 CPU cache污染和沒有必要的 CPU 必要校驗。
|