1. 程式人生 > >剖析linux下的零拷貝技術(zero-copy)

剖析linux下的零拷貝技術(zero-copy)

背景

  大多數的網路伺服器是基於server-client模式的。在這當中,下載是一個很常見的功能。此時伺服器端需要將主機磁碟上的檔案傳送到客戶端上去。傳統的 Linux 作業系統的標準 I/O 介面是基於資料拷貝操作的,即 I/O 操作會導致資料在作業系統核心地址空間的緩衝區和應用程式地址空間定義的緩衝區之間進行傳輸。那麼傳統的I/O操作過程是咋樣的呢?(下面是具體說明,以read和write為例)
這裡寫圖片描述
  在執行read操作時,作業系統首先會檢查,檔案內容是否快取在核心緩衝區,如果在核心緩衝區,則不用去磁碟中讀取檔案,而是直接將核心緩衝區的內容拷貝到使用者空間緩衝區中去。如果不是,作業系統則首先將磁碟上的資料拷貝的核心緩衝區(DMA),然後再把核心緩衝區上的內容拷貝到使用者緩衝區中。接下來,write系統呼叫再把使用者緩衝區的內容拷貝到網路堆疊相關的核心緩衝區中,最後再往對方的sockfd中些資料。並且在這個過程中還涉及到了四次的上下文切換。
  那傳統的I/O操作會帶來什麼問題呢?
  在高速網路中,大量傳統的I/O操作導致的資料拷貝工作會佔用 CPU 時間片,同時也需要佔用額外的記憶體頻寬。使cpu將大多數的時間用於I/O操作上,從而無法處理其他的任務,很大程度上影響了系統的效能,使伺服器成為效能瓶頸。而本身我們可以看出:很多資料複製操作並不是真正需要的。所以可以消除一些複製操作以減少開銷並提高效能。這就引出了我們今天要介紹的“零拷貝”技術。

概念

  零拷貝(Zero-copy)技術是指計算機執行操作時,CPU不需要先將資料從某處記憶體複製到另一個特定區域。這種技術通常用於通過網路傳輸檔案時節省CPU週期和記憶體頻寬。零拷貝技術可以減少資料拷貝和共享匯流排操作的次數,消除傳輸資料在儲存器之間不必要的中間拷貝次數,從而有效地提高資料傳輸效率。而且,零拷貝技術減少了使用者程序地址空間和核心地址空間之間因為上下文切換而帶來的開銷。

零拷貝技術的分類

直接 I/O:

  對於這種資料傳輸方式來說,應用程式可以直接訪問硬體儲存,作業系統核心只是輔助資料傳輸:這類零拷貝技術針對的是作業系統核心並不需要對資料進行直接處理的情況,資料可以在應用程式地址空間的緩衝區和磁碟之間直接進行傳輸,完全不需要 Linux 作業系統核心提供的頁快取的支援。

資料傳輸不經過使用者程序地址空間

  在資料傳輸的過程中,避免資料在作業系統核心地址空間的緩衝區和使用者應用程式地址空間的緩衝區之間進行拷貝。有的時候,應用程式在資料進行傳輸的過程中不需要對資料進行訪問,那麼,將資料從 Linux 的頁快取拷貝到使用者程序的緩衝區中就可以完全避免,傳輸的資料在頁快取中就可以得到處理。在某些特殊的情況下,這種零拷貝技術可以獲得較好的效能。Linux 中提供類似的系統呼叫主要有 mmap(),sendfile() 以及 splice()。

寫時複製

  對資料在 Linux 的頁快取和使用者程序的緩衝區之間的傳輸過程進行優化。該零拷貝技術側重於靈活地處理資料在使用者程序的緩衝區和作業系統的頁快取之間的拷貝操作。這種方法延續了傳統的通訊方式,但是更加靈活。在  Linux  中,該方法主要利用了寫時複製技術。
  
  前兩類方法的目的主要是為了避免應用程式地址空間和作業系統核心地址空間這兩者之間的緩衝區拷貝操作。這兩類零拷貝技術通常適用在某些特殊的情況下,比如要傳送的資料不需要經過作業系統核心的處理或者不需要經過應用程式的處理。第三類方法則繼承了傳統的應用程式地址空間和作業系統核心地址空間之間資料傳輸的概念,進而針對資料傳輸本身進行優化。

  在本文中,主要介紹針對資料傳輸不需要經過應用程式地址空間的零拷貝技術;即:mmap()、sendfile()、slipce(),其他兩類有興趣可自行了解。

mmap()

利用mmap()代替read()

buf = mmap(file, len);
write(sockfd, buf, len);

示意圖

這裡寫圖片描述

過程:

  ①、應用程序呼叫了 mmap() 之後,資料會先通過 DMA 拷貝到作業系統核心緩衝區中去。接著,應用程序跟作業系統共享這個緩衝區。這樣,作業系統核心和應用程序儲存空間就不需要再進行任何的資料拷貝操作。
  ②、應用程序再呼叫write(),作業系統直接將核心緩衝區的內容拷貝到socket緩衝區中,這一切都發生在核心態
  ③、socket緩衝區再把資料發往對方。

帶來的問題:

  通過使用 mmap() 來代替 read(), 已經可以減少一次資料拷貝。可以提高效率。但是,這種改進也是需要代價的,使用 mmap() 過程中。當對檔案進行了記憶體對映,然後呼叫 write() 系統呼叫,如果此時其他的程序截斷了這個檔案,那麼 write() 系統呼叫將會被匯流排錯誤訊號 SIGBUS 中斷,因為此時正在執行的是一個錯誤的儲存訪問。該訊號的預設行為是殺死程序和轉儲核心,這個結果顯然不是我們想看到的。

解決方法:

  有兩種解決方法
  ①,對SIGBUS捕捉處理
  對SIGBUS 訊號進行簡單處理並返回,這樣,write() 系統呼叫在它被中斷之前就返回已經寫入的位元組數目,errno 會被設定成 success。
  缺點:它不能反映出產生這個問題的根源所在,因為 BIGBUS 訊號只是顯示某程序發生了一些很嚴重的錯誤。
  ②,檔案租借鎖
  通過核心對檔案加租借鎖,當另外一個程序嘗試對使用者正在進行傳輸的檔案進行截斷的時候,核心會發送給使用者一個實時訊號:RT_SIGNAL_LEASE 訊號,這個訊號會告訴使用者核心破壞了使用者加在那個檔案上的租借鎖,那麼 write() 系統呼叫則會被中斷,並且程序會被 SIGBUS 訊號殺死,返回值則是中斷前寫的位元組數,errno 也會被設定為 success。
  注意:檔案租借鎖需要在對檔案進行記憶體對映之前設定。

if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
perror("kernel lease set signal");
return -1;
}
/* l_type can be F_RDLCK F_WRLCK  加鎖*/
/* l_type can be  F_UNLCK 解鎖*/
if(fcntl(diskfd, F_SETLEASE, l_type)){
    perror("kernel lease set type");
    return -1;
}

使用 mmap 並不一定能獲得理想的資料傳輸效能。資料傳輸的過程中仍然需要一次 CPU 拷貝操作,而且對映操作也是一個開銷很大的虛擬儲存操作,這種操作需要通過更改頁表以及沖刷 TLB (使得 TLB 的內容無效)來維持儲存的一致性。

sendfile()

  為了簡化使用者介面,同時減少 CPU 的拷貝次數,Linux 在版本 2.1 中引入了 sendfile() 這個系統呼叫。

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

這裡寫圖片描述

  值得一提的是:sendfile() 不僅減少了資料拷貝操作,它也減少了上下文切換。

過程

  ①,sendfile() 系統呼叫利用 DMA 引擎將檔案中的資料拷貝到作業系統核心緩衝區中,
  ②,然後資料被拷貝到與 socket 相關的核心緩衝區中去。
  ③,接下來,DMA 引擎將資料從核心 socket 緩衝區中拷貝到協議引擎中去。
  

問題

  那麼當其他的程序截斷了這個檔案,會發生什麼呢?
  若不設定訊號捕捉函式,那麼 sendfile () 系統呼叫會簡單地返回給使用者應用程式中斷前所傳輸的位元組數,errno 會被設定為 success。
  如果在呼叫 sendfile() 之前作業系統對檔案加上了租借鎖,那麼 sendfile() 的操作和返回狀態將會和 mmap()一樣。

侷限性

  sendfile() 系統呼叫不需要將資料拷貝或者對映到應用程式地址空間中去,所以 sendfile() 只是適用於應用程式地址空間不需要對所訪問資料進行處理的情況。相對於 mmap() 方法來說,因為 sendfile 傳輸的資料沒有越過使用者應用程式 / 作業系統核心的邊界線,所以 sendfile () 也極大地減少了儲存管理的開銷。
  (1),sendfile() 侷限於基於檔案服務的網路應用程式,比如 web 伺服器。據說,在 Linux 核心中實現 sendfile() 只是為了在其他平臺上使用 sendfile() 的 Apache 程式。
  (2),由於網路傳輸具有非同步性,很難在 sendfile () 系統呼叫的接收端進行配對的實現方式,所以資料傳輸的接收端一般沒有用到這種技術。
  (3),基於效能的考慮來說,sendfile () 仍然需要有一次從檔案到 socket 緩衝區的 CPU 拷貝操作,這就導致頁快取有可能會被傳輸的資料所汙染。

進一步優化

  sendfile() 技術在進行資料傳輸仍然還需要一次多餘的資料拷貝操作:即從檔案到 socket 緩衝區的 CPU 拷貝操作。想要進一步提高效能,就得將這一步也省去,那麼,應該咋樣做呢?
  這時就藉助於硬體上的幫助。需要用到一個支援收集操作的網路介面,這也就是說,待傳輸的資料可以分散在儲存的不同位置上,而不需要在連續儲存中存放。這樣一來從檔案中讀出的資料就根本不需要被拷貝到 socket 緩衝區中去,而只是需要將緩衝區描述符傳到網路協議棧中去,之後其在緩衝區中建立起資料包的相關結構,然後通過 DMA 收集拷貝功能將所有的資料結合成一個網路資料包。網絡卡的 DMA 引擎會在一次操作中從多個位置讀取包頭和資料。
###優化後的資料傳輸的過程
  (1),sendfile() 系統呼叫利用 DMA 引擎將檔案內容拷貝到核心緩衝區去;
  (2),將帶有檔案位置和長度資訊的緩衝區描述符新增到 socket 緩衝區中去,此過程不需要將資料從作業系統核心緩衝區拷貝到 socket 緩衝區中,
  (3),DMA 引擎會將資料直接從核心緩衝區拷貝到協議引擎中去,這樣就避免了最後一次資料拷貝。
  這裡寫圖片描述
  這種方法不但減少了因為多次上下文切換所帶來開銷,同時也減少了資料拷貝。

splice()

  splice() 可以被看成是類似於基於流的管道的實現,管道可以使得兩個檔案描述符相互連線,splice 的呼叫者則可以控制兩個裝置(或者協議棧)在作業系統核心中的相互連線。

適用場景

  splice() 可以在作業系統地址空間中整塊地移動資料,從而減少大多數資料拷貝操作。splice() 適用於可以確定資料傳輸路徑的使用者應用程式,它不需要利用使用者地址空間的緩衝區進行顯式的資料傳輸操作。那麼,當資料只是從一個地方傳送到另一個地方,過程中所傳輸的資料不需要經過使用者應用程式的處理的時候,spice() 就成為了一種比較好的選擇。

splice() 和 sendfile() 的區別與聯絡

聯絡:使用者應用程序必須擁有兩個已經開啟的檔案描述符,一個用於表示輸入裝置,一個用於表示輸出裝置。
區別:splice() 允許任意兩個檔案之間互相連線sendfile()只適用於檔案到 socket 進行資料傳輸。

函式說明

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

   splice()在兩個檔案描述符之間移動資料,而不在核心地址空間和使用者地址空間之間進行復制。它將檔案描述符fd_in中的len個位元組的資料傳輸到檔案描述符fd_out,其中一個描述符必須引用一個管道
  
  如果fd_in引用一個管道,那麼off_in必須為NULL。如果fd_in沒有引用管道並且off_in為NULL,則從當前檔案偏移量開始從fd_in讀取位元組,並且適當地調整當前檔案偏移量。如果fd_in沒有引用管道並且off_in不是NULL,那麼off_in必須指向一個緩衝區,該緩衝區指定從fd_in讀取位元組的起始偏移量; 在這種情況下,fd_in的當前檔案偏移量不會改變。類似的語句適用於fd_out和off_out。

   flags引數是一個位掩碼,它由零個或多個下列值組成:

SPLICE_F_NONBLOCK:   splice 操作不會被阻塞。然而,如果檔案描述符沒有被設定為不可被阻塞方式的 I/O 
                     ,那麼呼叫 splice 有可能仍然被阻塞。

SPLICE_F_MORE:       告知作業系統核心下一個 splice 系統呼叫將會有更多的資料傳來。

SPLICE_F_MOVE:       如果輸出是檔案,這個值則會使得作業系統核心嘗試從輸入管道緩衝區直接將資料讀入
                      到輸出地址空間,這個資料傳輸過程沒有任何資料拷貝操作發生。如果核心不能從pipe
                      移動資料或者pipe的快取不是一個整頁面,仍然需要拷貝資料。