資料整理: jserv
本文探討 Linux 核心中 Zero-Copy 機制的演進,包括:
sendfile()
:最早支援零複製的系統呼叫,主要用於檔案與 socket 間傳輸,搭配 scatter-gather 與 DMA 機制可減少資料搬移與 context switchsplice()
:擴展 Zero-Copy 至任意二個檔案描述子之間的資料搬移,透過管線緩衝區作為中介,提供比 sendfile()
更具彈性與通用性的資料通道copy_file_range()
:設計於檔案系統內部資料複製之用,允許同一檔案系統內以零複製方式完成區段資料複製,並支援檔案系統層級的加速機制,例如 CoW這些機制在現代 Linux 系統中取代特化實作,成為構建高效傳輸應用,反映於網頁伺服器、檔案同步工具、資料庫備份等的基礎建設。
在 Linux 核心的發展歷程中,曾一度面臨是否該因應特定應用需求而修改核心架構的抉擇。Linus Torvalds 在 2001 年 10 月說過:
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 2.4 時代的技術現實遠非理想:多處效能瓶頸橫亙其間,包括無法有效支援 SMP、執行緒實作尚未過渡至 NPTL,以及與 POSIX 行為仍存在落差。為回應當時廣泛部署於 Linux 上的 Apache HTTP 伺服器效能問題,開發者一度選擇在核心內建實驗性 Web 加速器 khttpd 和 TUX,直接在核心模式中服務靜態網頁。這種「特化加速」策略雖短期奏效,卻引發對核心角色定位的反思。
從 Linux 2.6 起,核心設計逐步回歸系統性與結構性思維。重點不再是為特定應用堆疊特例化功能,而是建立通用且可組合的基礎機制。例如 O(1) 與 CFS 排程器引入新模型,提升整體執行效率。同樣地,splice()
和 tee()
系統呼叫的加入 (自 Linux v2.6.17) 則提供高效的資料搬移通道,讓過去如 khttpd
所做的網頁加速需求得以在使用者空間中,以更一致、彈性的方式實現,無需再仰賴核心內建的特化服務。
延伸閱讀: Linux 核心設計: 發展動態回顧
這一設計轉向也成就後續如 sendfile, splice, copy_file_range 等 Zero-Copy 的系統呼叫,成為現代 Linux 高效資料傳輸機制的基石。它們不僅取代早期非必要的特化,也重新確認核心應致力於提供抽象、可組合的能力,而非針對單一應用場景進行特化的實作。
早期如 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()
已提供 Zero-Copy 傳輸能力的前提下,splice()
為何仍需設計成依賴 2 次系統呼叫與 1 個管道?這種設計是否形同退步?
表面上看,sendfile()
只需 1 次系統呼叫,便能完成從來源檔案至 socket 的資料搬移,而 splice()
卻需搭配建立的管道與 2 次呼叫來完成同樣的任務,並額外引入 context switch。然而,這種設計其實來自兩者的發展背景與設計哲學的差異。
sendfile()
原本是為靜態資料傳輸 (例如網頁伺服器回應靜態檔案) 而設計,傳輸來源固定為檔案,目的則限於 socket,目標是簡化應用邏輯並發揮硬體特性 (如 DMA、scatter-gather) 來達成高效傳輸。這使得 sendfile()
在特定場景下非常有效,但可擴展性不足,難以支援任意裝置與資料流向。
splice()
的出現,正是為了解決這種靜態介面的侷限。自 Linux v2.6.17 引入 splice()
,允許任意 2 個檔案描述子之間進行資料搬移,無論來源與目的是否為檔案、socket、FIFO 或字元裝置,只要其中之一為管道,即可完成傳輸。這項設計雖需明確分成「來源 管道」與「管道 目的端」二次呼叫,但換來的是完整的通用性與模組性。其內部透過 pipe buffer 搬移資料頁框的參考資訊,而非實際複製內容,因此仍具備 Zero-Copy 的核心特性。
事實上,自 Linux v2.6.33 起,sendfile()
的內部實作便已改為建構在 splice()
之上,統一底層資料搬移邏輯,使系統更容易維護與擴充。同樣的資料流向若透過 splice()
實作,可達到與 sendfile()
相同的效能,但具備更多彈性,例如支援從 socket 讀取、輸出至檔案,或在中途插入 tee 系統呼叫複製分支等操作。
至於為何 splice()
的設計未整合為單一系統呼叫,關鍵在於管道作為中介的必要性尚未完全解除。Linux 核心開發社群曾考慮移除此限制,允許 splice()
在任意檔案描述子之間直接運作,但由於涉及大量相容性與子系統互通問題,至今仍未正式納入主線。
在對效能高度敏感、且適合使用 splice()
的場景中,頻繁建立與銷毀管道緩衝區(pipe buffer)將帶來額外開銷。為降低這類成本,HAProxy 採取實用的最佳化策略:預先配置一組可重複使用的管道,構成管道緩衝池 (pipe buffer pool)。每次呼叫 splice()
時,從緩衝池中取得可用管道,傳輸結束後再歸還,藉此避免反覆分配與釋放資源所造成的效能損耗。
雖然 splice()
的典型用法牽涉 3 次系統呼叫,比 sendfile()
多,但若管道得以重複利用,初始化所需的 pipe 或 pipe2()
呼叫次數將被有效攤平。當 splice()
的使用頻率足夠高時,這額外的系統呼叫幾乎不再構成負擔。此折衷設計展現出在實務中透過資源重用達成效能與彈性的平衡。
copy_file_range
系統呼叫自 Linux v4.5 引入 copy_file_range 系統呼叫,提供在同一檔案系統內進行檔案間資料複製的 Zero-Copy 傳輸機制,避免資料在核心空間與使用者空間之間的反覆搬移。
copy_file_range()
的原型宣告如下:
與 sendfile()
相同,copy_file_range()
亦為核心內部資料搬移操作,能在不跨越核心與使用者空間的情況下完成資料複製,因此同樣具備零複製的特性。不過,相較於 sendfile()
,copy_file_range()
提供更細緻的控制能力,例如同時指定來源與目的檔案的偏移量與複製範圍,並不僅限於 socket 作為輸出端。
sendfile()
最初設計僅支援將資料從檔案傳送至 socket,直到 Linux v2.6.33 才開放支援任意類型的檔案描述子,但其介面設計仍偏向串流導向的單向資料輸出。而 copy_file_range()
從一開始就針對「檔案對檔案」的資料搬移設計,不僅語意明確,也為底層檔案系統提供了資料複製與共享的加速通道。
藉由呼叫 copy_file_range()
,檔案系統可選擇是否啟用資料共享(如 reflink)或伺服器端直接複製 (如 NFS server-side copy),使資料傳輸下沉至硬體層級,而非僅由 CPU 主導。
Linux 核心中的 copy_file_range()
實際由 VFS 層的 vfs_copy_file_range()
實作,採用以下流程:
file_operations
中的 .copy_file_range
或 .remap_file_range
:
splice()
作為資料搬移的後備方案以 Btrfs 為例,其 .remap_file_range
實作即以 btrfs_clone_files()
為主體,使用 reflink 建立新 inode 與原資料的共享關係。在多數情況下,這並不會造成實體資料的搬移,而僅產生 metadata 上的引用修改,效率遠優於傳統複製。
自 Linux v5.3 起,copy_file_range()
開始允許跨檔案系統搬移資料,此時會退回使用 splice()
實作。不過由於早期出現多起與一致性、對齊與錯誤處理相關的問題,自 Linux v5.19 起施加新限制:僅允許在相同檔案系統類型間執行該操作,從而提高安全性與行為一致性。
cat
UNIX 工具的 cat 是 "concatenate" 的意思,該命令具體作用為
concatenate files and print on the standard output
透過上述 sendfile 和 splice 系統呼叫,我們可實作更快的 cat,專案: fastcat
用 bench 工具測量:
cat