到目前為止,幾乎所有人都聽說過Linux下所謂的零拷貝功能,但我經常遇到對這個主題沒有充分了解的人。因此,我決定撰寫一些文章,深入研究這個問題,希望能夠揭開這個有用的特征。在本文中,我們從用戶模式應用程序的角度來看零拷貝,因此故意省略了內核級別的詳細信息。
什么是零拷貝?
為了更好地理解問題的解決方案,我們首先需要了解問題本身。讓我們來看一下網絡服務器的簡單過程所涉及的內容,該過程通過網絡將存儲在文件中的數據提供給客戶端。這是一些示例代碼:
~~~
read(file,tmp_buf,len);
write(socket,tmp_buf,len);
~~~
看起來很簡單;你會認為只有那兩個系統調用沒有太多的開銷。實際上,這不可能是事實。在這兩個調用之后,數據已被復制至少四次,并且幾乎已經執行了多個用戶/內核上下文切換。(實際上這個過程要復雜得多,但我想保持簡單)。為了更好地了解所涉及的過程,請查看圖1.頂部顯示上下文切換,底部顯示復制操作。

圖1.兩個示例系統調用中的復制
第一步:讀取系統調用導致從用戶模式到內核模式的上下文切換。第一個副本由DMA引擎執行,DMA引擎從磁盤讀取文件內容并將它們存儲到內核地址空間緩沖區中。
第二步:將數據從內核緩沖區復制到用戶緩沖區,并返回讀取系統調用。從調用返回導致從內核切換到用戶模式的上下文。現在數據存儲在用戶地址空間緩沖區中,它可以再次開始。
第三步:寫系統調用導致從用戶模式到內核模式的上下文切換。執行第三個副本以再次將數據放入內核地址空間緩沖區。但是這一次,數據被放入一個不同的緩沖區,一個與套接字相關的緩沖區。
第四步:寫入系統調用返回,創建第四個上下文切換。獨立和異步地,當DMA引擎將數據從內核緩沖區傳遞到協議引擎時,會發生第四個副本。你可能會問自己,“你是什么意思獨立和異步?在呼叫返回之前是不是傳輸了數據?“呼叫返回,實際上并不保證傳輸;它甚至不能保證傳輸的開始。它只是意味著以太網驅動程序在其隊列中有自由描述符并已接受我們的數據進行傳輸。在我們之前可能有許多數據包排隊。除非驅動程序/硬件實現優先級環或隊列,否則數據以先進先出的方式傳輸。(圖1中的分叉DMA副本說明了最后一個副本可以延遲的事實)。
正如您所看到的,實際上并不需要進行大量的數據復制。可以消除一些重復,以減少開銷并提高性能。作為驅動程序開發人員,我使用具有一些非常高級功能的硬件。某些硬件可以完全繞過主存儲器并將數據直接傳輸到另一個設備。此功能消除了系統內存中的副本,并且是一件好事,但并非所有硬件都支持它。還存在必須為網絡重新打包來自磁盤的數據的問題,這引入了一些復雜性。為了消除開銷,我們可以從消除內核和用戶緩沖區之間的一些復制開始。
消除副本的一種方法是跳過調用read而不是調用mmap。例如:
~~~
tmp_buf = mmap(file,len);
write(socket,tmp_buf,len);
~~~
為了更好地了解所涉及的過程,請參見圖2.上下文切換保持不變。

圖2.調用mmap
第一步:mmap系統調用導致文件內容被DMA引擎復制到內核緩沖區。然后與用戶進程共享緩沖區,而不在內核和用戶存儲空間之間執行任何復制。
第二步:寫入系統調用使內核將數據從原始內核緩沖區復制到與套接字關聯的內核緩沖區中。
第三步:第三個副本發生在DMA引擎將數據從內核套接字緩沖區傳遞到協議引擎時。
通過使用mmap而不是read,我們減少了內核復制數據量的一半。當傳輸大量數據時,這會產生相當好的結果。然而,這種改進并非沒有代價;使用mmap + write方法時存在隱藏的陷阱。當內存映射文件然后調用write而另一個進程截斷同一文件時,您將陷入其中一個。您的寫入系統調用將被總線錯誤信號SIGBUS中斷,因為您執行了錯誤的內存訪問。該信號的默認行為是終止進程并轉儲核心 - 而不是網絡服務器最理想的操作。有兩種方法可以解決這個問題。
第一種方法是為SIGBUS信號安裝信號處理程序,然后在處理程序中簡單地調用return。通過這樣做,write系統調用返回它在被中斷之前寫入的字節數并且errno設置為成功。讓我指出,這將是一個糟糕的解決方案,一個治療癥狀,而不是問題的原因。因為SIGBUS發出信號表明該過程嚴重錯誤,我不鼓勵將其作為解決方案。
第二個解決方案涉及內核中的文件租用(在Microsoft Windows中稱為“機會鎖定”)。這是解決此問題的正確方法。通過在文件描述符上使用租用,您可以在特定文件上使用內核。然后,您可以從內核請求讀/寫租約。當另一個進程試圖截斷您正在傳輸的文件時,內核會向您發送一個實時信號RT\_SIGNAL\_LEASE信號。它告訴您內核正在破壞該文件的寫入或讀取租約。您的寫入調用在程序訪問無效地址之前被中斷,并被SIGBUS信號殺死。寫調用的返回值是中斷前寫入的字節數,errno將設置為成功。下面是一些示例代碼,展示了如何從內核獲得租約:
~~~
if(fcntl(fd,F_SETSIG,RT_SIGNAL_LEASE)== -1){
perror(“內核租約設置信號”);
返回-1;
}
/ * l_type可以是F_RDLCK F_WRLCK * /
if(fcntl(fd,F_SETLEASE,l_type)){
perror(“內核租約集類型”);
返回-1;
}
~~~
您應該在獲取文件之前獲得租約,并在完成后中斷租約。這是通過使用租約類型F\_UNLCK調用fcntl F\_SETLEASE來實現的。
發送文件
在內核版本2.1中,引入了sendfile系統調用以簡化通過網絡和兩個本地文件之間的數據傳輸。sendfile的引入不僅減少了數據復制,還減少了上下文切換。像這樣使用它:
~~~
sendfile(socket,file,len);
~~~
為了更好地了解所涉及的過程,請查看圖3

圖3.用Sendfile替換讀寫
第一步:sendfile系統調用導致文件內容被DMA引擎復制到內核緩沖區。然后,內核將數據復制到與套接字關聯的內核緩沖區中。
第二步:第三個副本發生在DMA引擎將數據從內核套接字緩沖區傳遞到協議引擎時。
您可能想知道如果另一個進程截斷我們使用sendfile系統調用傳輸的文件會發生什么。如果我們沒有注冊任何信號處理程序,sendfile調用只會返回它在被中斷之前傳輸的字節數,并且errno將被設置為成功。
但是,如果我們在調用sendfile之前從文件內核獲得租約,則行為和返回狀態完全相同。我們還在sendfile調用返回之前獲得RT\_SIGNAL\_LEASE信號。
到目前為止,我們已經能夠避免讓內核生成多個副本,但我們仍然只留下一個副本。這可以避免嗎?當然,在硬件的幫助下。為了消除內核完成的所有數據復制,我們需要一個支持收集操作的網絡接口。這僅僅意味著等待傳輸的數據不需要在連續的存儲器中;它可以分散在各種存儲位置。在內核版本2.4中,修改了套接字緩沖區描述符以適應這些要求 - 在Linux下稱為零拷貝。這種方法不僅減少了多個上下文切換,還消除了處理器完成的數據復制。對于用戶級應用程序,沒有任何更改,因此代碼仍然如下所示:
~~~
sendfile(socket,file,len);
~~~
為了更好地了解所涉及的過程,請查看圖4。

圖4.支持收集的硬件可以從多個內存位置組裝數據,從而消除了另一個副本。
第一步:sendfile系統調用導致文件內容被DMA引擎復制到內核緩沖區。
第二步:沒有數據被復制到套接字緩沖區。相反,只有具有關于數據的下落和長度信息的描述符被附加到套接字緩沖區。DMA引擎將數據直接從內核緩沖區傳遞到協議引擎,從而消除了剩余的最終副本。
因為數據實際上仍然是從磁盤復制到內存,從內存復制到線路,所以有些人可能會認為這不是真正的零拷貝。但是,從操作系統的角度來看,這是零拷貝,因為內核緩沖區之間的數據不會重復。使用零拷貝時,除了復制避免之外,還可以獲得其他性能優勢,例如更少的上下文切換,更少的CPU數據高速緩存污染以及無CPU校驗和計算。
- c語言
- 基礎知識
- 變量和常量
- 宏定義和預處理
- 隨機數
- register變量
- errno全局變量
- 靜態變量
- 類型
- 數組
- 類型轉換
- vs中c4996錯誤
- 數據類型和長度
- 二進制數,八進制數和十六進制數
- 位域
- typedef定義類型
- 函數和編譯
- 函數調用慣例
- 函數進棧和出棧
- 函數
- 編譯
- sizeof
- main函數接收參數
- 宏函數
- 目標文件和可執行文件有什么
- 強符號和弱符號
- 什么是鏈接
- 符號
- 強引用和弱引用
- 字符串處理函數
- sscanf
- 查找子字符串
- 字符串指針
- qt
- MFC
- 指針
- 簡介
- 指針詳解
- 案例
- 指針數組
- 偏移量
- 間接賦值
- 易錯點
- 二級指針
- 結構體指針
- 字節對齊
- 函數指針
- 指針例子
- main接收用戶輸入
- 內存布局
- 內存分區
- 空間開辟和釋放
- 堆空間操作字符串
- 內存處理函數
- 內存分頁
- 內存模型
- 棧
- 棧溢出攻擊
- 內存泄露
- 大小端存儲法
- 寄存器
- 結構體
- 共用體
- 枚舉
- 文件操作
- 文件到底是什么
- 文件打開和關閉
- 文件的順序讀寫
- 文件的隨機讀寫
- 文件復制
- FILE和緩沖區
- 文件大小
- 插入,刪除,更改文件內容
- typeid
- 內部鏈接和外部鏈接
- 動態庫
- 調試器
- 調試的概念
- vs調試
- 多文件編程
- extern關鍵字
- 頭文件規范
- 標準庫以及標準頭文件
- 頭文件只包含一次
- static
- 多線程
- 簡介
- 創建線程threads.h
- 創建線程pthread
- gdb
- 簡介
- mac使用gdb
- setjump和longjump
- 零拷貝
- gc
- 調試器原理
- c++
- c++簡介
- c++對c的擴展
- ::作用域運算符
- 名字控制
- cpp對c的增強
- const
- 變量定義數組
- 盡量以const替換#define
- 引用
- 內聯函數
- 函數默認參數
- 函數占位參數
- 函數重載
- extern "C"
- 類和對象
- 類封裝
- 構造和析構
- 深淺拷貝
- explicit關鍵字
- 動態對象創建
- 靜態成員
- 對象模型
- this
- 友元
- 單例
- 繼承
- 多態
- 運算符重載
- 賦值重載
- 指針運算符(*,->)重載
- 前置和后置++
- 左移<<運算符重載
- 函數調用符重載
- 總結
- bool重載
- 模板
- 簡介
- 普通函數和模板函數調用
- 模板的局限性
- 類模板
- 復數的模板類
- 類模板作為參數
- 類模板繼承
- 類模板類內和類外實現
- 類模板和友元函數
- 類模板實現數組
- 類型轉換
- 異常
- 異常基本語法
- 異常的接口聲明
- 異常的棧解旋
- 異常的多態
- 標準異常庫
- 自定義異常
- io
- 流的概念和類庫結構
- 標準io流
- 標準輸入流
- 標準輸出流
- 文件讀寫
- STL
- 簡介
- string容器
- vector容器
- deque容器
- stack容器
- queue容器
- list容器
- set/multiset容器
- map/multimap容器
- pair對組
- 深淺拷貝問題
- 使用時機
- 常用算法
- 函數對象
- 謂詞
- 內建函數對象
- 函數對象適配器
- 空間適配器
- 常用遍歷算法
- 查找算法
- 排序算法
- 拷貝和替換算法
- 算術生成算法
- 集合算法
- gcc
- GDB
- makefile
- visualstudio
- VisualAssistX
- 各種插件
- utf8編碼
- 制作安裝項目
- 編譯模式
- 內存對齊
- 快捷鍵
- 自動補全
- 查看c++類內存布局
- FFmpeg
- ffmpeg架構
- 命令的基本格式
- 分解與復用
- 處理原始數據
- 錄屏和音
- 濾鏡
- 水印
- 音視頻的拼接與裁剪
- 視頻圖片轉換
- 直播
- ffplay
- 常見問題
- 多媒體文件處理
- ffmpeg代碼結構
- 日志系統
- 處理流數據
- linux
- 系統調用
- 常用IO函數
- 文件操作函數
- 文件描述符復制
- 目錄相關操作
- 時間相關函數
- 進程
- valgrind
- 進程通信
- 信號
- 信號產生函數
- 信號集
- 信號捕捉
- SIGCHLD信號
- 不可重入函數和可重入函數
- 進程組
- 會話
- 守護進程
- 線程
- 線程屬性
- 互斥鎖
- 讀寫鎖
- 條件變量
- 信號量
- 網絡
- 分層模型
- 協議格式
- TCP協議
- socket
- socket概念
- 網絡字節序
- ip地址轉換函數
- sockaddr數據結構
- 網絡套接字函數
- socket模型創建流程圖
- socket函數
- bind函數
- listen函數
- accept函數
- connect函數
- C/S模型-TCP
- 出錯處理封裝函數
- 多進程并發服務器
- 多線程并發服務器
- 多路I/O復用服務器
- select
- poll
- epoll
- epoll事件
- epoll例子
- epoll反應堆思想
- udp
- socket IPC(本地套接字domain)
- 其他常用函數
- libevent
- libevent簡介