〈Linux 核心設計: 發展動態回顧〉提到:
"From a technical standpoint, I believe the kernel will be "more of the same" and that all the _really_interesting stuff will be going out in user space."
Linux v2.6 以來,核心發展重新找到自己的方向,沒必要透過特化需求去克服局部的效能瓶頸,也就是說,核心的發展策略是基於整體思維,像是新的排程演算法 (O(1) scheduler 和後續的 CFS),又像是 Linux v2.6.17 引入的 splice 和 tee 系統呼叫,都讓 khttpd 沒有存在的必要。
早期如 khttpd 的核心常駐 HTTP 伺服器,是為了讓 Linux 核心直接提供靜態網頁服務,藉此減少核心與使用者空間之間的 context switch 與資料複製的成本。sendfile 系統呼叫的出現,使得這類設計不再必要,因為應用程式可以直接委由核心搬移資料,避免不必要的 CPU 操作與記憶體資源消耗。
Linux 應用程式在讀取檔案並透過網路傳送資料的典型流程如下,其中 U
表示使用者空間,K
表示核心空間:
open()
do_sys_open()
do_filp_open()
: 根據路徑定位檔案,並從對應的檔案系統取得 struct file
malloc()
: 配置記憶體作為資料暫存區 (buffer)read(file, buffer)
vfs_read()
: 呼叫 VFS 的檔案讀取函式file->f_op->read()
: 呼叫對應檔案系統的低階讀取函式copy_to_user(buffer)
: 將資料從核心緩衝區複製到使用者空間的 buffer接著將資料透過網路傳送:
write(socket, buffer)
: 將使用者空間中的 buffer 資料寫入 socketcopy_from_user(buffer)
: 將 buffer 資料複製回核心空間Linux 核心提供 sendfile
系統呼叫(註:BSD 系列作業系統亦提供名為 sendfile
的系統呼叫,但其語意與參數略有差異)。使用者只需指定來源檔案與目標 socket 的檔案描述元,核心即可在不經由使用者空間的情況下,直接將檔案內容從儲存裝置傳送出去,省去多餘的資料複製與 context switch。
為了比較其行為差異,以下以傳統的 read()
搭配 write()
的方式為例,說明未使用 sendfile
時的資料搬移流程:
流程示意:
read()
(通常由 glibc 或其他 C 函式庫提供包裝)時,系統呼叫會觸發。核心首先透過 DMA 將檔案資料載入至核心緩衝區,接著使用 CPU 將該資料複製至使用者空間的暫存區 tmp_buf
write()
傳送資料至 socket 時,系統呼叫再次觸發。核心透過 CPU 將 tmp_buf
的內容從使用者空間複製回核心空間的 socket 傳輸緩衝區。最後,再由 DMA 將資料從 socket buffer 傳送至目的端這段資料流歷經 4 次資料搬移:
討論:
是否能減少使用者空間 buffer 的使用?可以。例如,藉由 mmap 取代 read()
,使資料直接映射到使用者空間:
流程示意:
mmap()
執行後,與 read()
類似,檔案資料會透過 DMA 傳入核心緩衝區。但與 read()
不同的是,mmap()
不會將這些資料再複製到使用者空間,而是透過記憶體映射,讓使用者空間的 buffer 與核心空間的緩衝區共享同一塊實體記憶體頁面,因此可省去一次 CPU 複製write()
後,資料會從共享的 buffer 經由 CPU 複製到 socket buffer,再透過 DMA 傳送至客戶端總計為 3 次資料搬移。
討論:
mmap()
會產生額外的成本,包括虛擬記憶體管理 (VMM) 開銷,以及多個行程存取時的同步問題read()
與 write()
通常更有效率mmap()
幾乎不會帶來效能提升,甚至可能產生反效果。相對地,read()
可搭配檔案系統的 read-ahead 機制,提早將資料載入快取,有助於提升 cache 命中率mmap()
加上 write()
傳送資料,而系統中另有其他行程對同一檔案進行寫入,可能導致非預期的記憶體存取行為,觸發 SIGBUS
訊號,進而造成應用程式異常終止與 core dumpSIGBUS
做適當處理,或針對資料存取模式設計明確的使用規範sendfile 系統呼叫的設計,正是為了解決前述使用 read()
和 write()
所造成的多重資料複製與 context switch 負擔。透過以下簡單的呼叫,即可由核心直接完成資料搬移:
流程示意:
sendfile()
執行後,檔案資料首先經由 DMA 傳入核心緩衝區,接著由 CPU 將資料複製至 socket buffer整體資料搬移次數為 2 次 DMA 複製與 1 次 CPU 複製,共計 3 次。
討論:
sendfile
已明顯減少資料複製與 context switch 次數,但傳輸過程中仍會產生一份 socket buffer 的額外資料。這部分是否有機會省略?只要底層硬體支援必要機制,即可進一步達成。關鍵在於 scatter-gather I/O,該機制允許資料來源不需位於連續記憶體空間,能分散於多個區段,由裝置直接聚合 (gather) 處理後送出sendfile
可落實 Zero-Copy:核心空間的資料不再複製至 socket buffer,而是由網路裝置直接從核心緩衝區讀取資料並傳送至對端sendfile
系統呼叫介面,即可自動受益於此項效能最佳化可用以下命令檢查網路裝置是否支援 scatter-gather,以 Intel 公司出品的 I211-AT 乙太網路控制器來說:
參考輸出:
Zero-Copy 流程示意:
sendfile()
執行後,檔案資料透過 DMA 傳入核心空間 buffer。此時不再複製到 socket buffer,而是僅記錄各 buffer 的位址與長度整體過程完全免除 CPU 的搬移工作,資料僅透過 DMA 在裝置與記憶體之間傳輸,達成真正的 Zero-Copy。
sendfile
最初僅支援將資料從檔案傳送至 socket,且若要達成零複製(Zero-Copy),通常仰賴底層硬體支援 (例如 scatter-gather DMA)。為了解決這些限制,自 Linux v2.6.17 引入 splice 系統呼叫以來,允許二個檔案描述子之間的資料傳輸,可不經過使用者空間,達成通用型的 Zero-Copy。
相較於 sendfile
,splice
不限於檔案與 socket 之間的傳輸,也不依賴硬體支援,只需透過 Linux 核心內部的管線緩衝區 (pipe buffer) 即可運作。在 Linux v2.6.33 之後,sendfile
的適用對象已不再侷限於 socket,亦可支援任意檔案描述子,但其使用情境仍偏向單向、封裝式的傳輸。相對地,splice
提供更大的彈性與通用性。
splice
達成 Zero-Copy 的流程特性如下:
資料流過程說明如下:
splice()
,系統由使用者模式切換至核心模式。需要注意的是,splice
要求其兩個檔案描述子中至少一方為管線裝置(pipe),這是其架構的核心基礎。Linux 核心利用管線緩衝區作為中介區域,使任意支援 splice
的來源與目的裝置間的資料搬移變得可行且高效。
sendfile
的設計初衷是針對靜態資料傳送場景,例如 HTTP 伺服器回應靜態檔案,強調簡單、快速與最小化應用層參與。其 API 為單一系統呼叫,即可隱含完成讀取與傳送,降低開發複雜度。
然而,sendfile
的結構較為固定,難以處理其他類型裝置或雙向資料傳輸需求。因此 Linux 核心進一步設計出 splice
,作為一種更通用的解法,搭配 tee()
、vmsplice()
等系統呼叫,構成以管線為中心的資料通道機制。這些機制支援非同步、高並行、大量資料在裝置間搬移的需求,並保有 Zero-Copy 的效率。
這類演化反映出 Linux 核心設計從針對特定用途的優化邁向模組化與可組合的資料傳輸機制,在保持高效能的同時,也擴展了系統呼叫的適用彈性。
cat
UNIX 工具的 cat 是 "concatenate" 的意思,該命令具體作用為
concatenate files and print on the standard output
透過上述 sendfile 和 splice 系統呼叫,我們可實作更快的 cat,專案: fastcat
用 bench 工具測量:
cat