Try   HackMD

I/O 模型演化: 以 sendfile 和 splice 系統呼叫達到 Zero-Copy

資料整理: jserv

本文探討 Linux 核心中 Zero-Copy 機制的演進,包括:

  • sendfile():最早支援零複製的系統呼叫,主要用於檔案與 socket 間傳輸,搭配 scatter-gather 與 DMA 機制可減少資料搬移與 context switch
  • splice():擴展 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 高效資料傳輸機制的基石。它們不僅取代早期非必要的特化,也重新確認核心應致力於提供抽象、可組合的能力,而非針對單一應用場景進行特化的實作。

sendfile 系統呼叫

早期如 khttpd 的核心常駐 HTTP 伺服器,是為了讓 Linux 核心直接提供靜態網頁服務,藉此減少核心與使用者空間之間的 context switch 與資料複製的成本。sendfile 系統呼叫的出現,使得這類設計不再必要,因為應用程式可以直接委由核心搬移資料,避免不必要的 CPU 操作與記憶體資源消耗。

Linux 應用程式在讀取檔案並透過網路傳送資料的典型流程如下,其中 U 表示使用者空間,K 表示核心空間:

  • U: open()
  • K: do_sys_open()
  • K: do_filp_open() : 根據路徑定位檔案,並從對應的檔案系統取得 struct file
  • K: 回傳檔案描述子 (file descriptor) 給使用者空間
  • U: malloc() : 配置記憶體作為資料暫存區 (buffer)
  • U: read(file, buffer)
  • K: vfs_read() : 呼叫 VFS 的檔案讀取函式
  • K: file->f_op->read() : 呼叫對應檔案系統的低階讀取函式
  • K: copy_to_user(buffer) : 將資料從核心緩衝區複製到使用者空間的 buffer

接著將資料透過網路傳送:

  • U: write(socket, buffer) : 將使用者空間中的 buffer 資料寫入 socket
  • K: copy_from_user(buffer) : 將 buffer 資料複製回核心空間
  • K: 資料進入 socket 傳輸佇列,交由網路子系統處理傳送作業

Linux 核心提供 sendfile 系統呼叫 (註:BSD 系列作業系統亦提供名為 sendfile 的系統呼叫,但其語意與參數略有差異)。使用者只需指定來源檔案與目標 socket 的檔案描述元,核心即可在不經由使用者空間的情況下,直接將檔案內容從儲存裝置傳送出去,省去多餘的資料複製與 context switch。

為了比較其行為差異,以下以傳統的 read() 搭配 write() 的方式為例,說明未使用 sendfile 時的資料搬移流程:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

流程示意:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • 步驟 1 到 2:當程式呼叫 read() (通常由 glibc 或其他 C 函式庫提供包裝)時,系統呼叫會觸發。核心首先透過 DMA 將檔案資料載入至核心緩衝區,接著使用 CPU 將該資料複製至使用者空間的暫存區 tmp_buf
  • 步驟 3 到 4:當程式呼叫 write() 傳送資料至 socket 時,系統呼叫再次觸發。核心透過 CPU 將 tmp_buf 的內容從使用者空間複製回核心空間的 socket 傳輸緩衝區。最後,再由 DMA 將資料從 socket buffer 傳送至目的端

這段資料流歷經 4 次資料搬移:

  1. DMA:儲存裝置
    核心 buffer
  2. CPU:核心 buffer
    使用者空間 buffer
  3. CPU:使用者空間 buffer
    socket buffer
  4. DMA:socket buffer
    網路裝置傳送

討論:

  • 從整體流程可觀察到,資料在不同階段被重複複製。當傳輸資料量增加,這類冗餘搬移會對系統效能造成明顯衝擊,不僅增加記憶體耗用,也提高 CPU 的負載。若能移除其中不必要的複製,將有助於減少資源使用並改善效能表現
  • 從硬體角度而言,透過記憶體進行資料暫存其實並非不可或缺。理論上可將檔案內容直接傳送至網路裝置,完全略過中間緩衝區。不過此類傳輸模式仰賴底層硬體支援,例如具備 DMA 傳輸能力與 scatter-gather 功能的網路裝置,並非所有系統皆具備這些條件

是否能減少使用者空間 buffer 的使用?可以。例如,藉由 mmap 取代 read(),使資料直接映射到使用者空間:

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

流程示意:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • 步驟 1 到 2:mmap() 執行後,與 read() 類似,檔案資料會透過 DMA 傳入核心緩衝區。但與 read() 不同的是,mmap() 不會將這些資料再複製到使用者空間,而是透過記憶體映射,讓使用者空間的 buffer 與核心空間的緩衝區共享同一塊實體記憶體頁面,因此可省去一次 CPU 複製
  • 步驟 3 到 4:呼叫 write() 後,資料會從共享的 buffer 經由 CPU 複製到 socket buffer,再透過 DMA 傳送至客戶端

總計為 3 次資料搬移。

討論:

  • 使用 mmap() 會產生額外的成本,包括虛擬記憶體管理 (VMM) 開銷,以及多個行程存取時的同步問題
  • 若處理的是小型檔案,直接使用 read()write() 通常更有效率
  • 單一行程以順序方式讀取檔案時,mmap() 幾乎不會帶來效能提升,甚至可能產生反效果。相對地,read() 可搭配檔案系統的 read-ahead 機制,提早將資料載入快取,有助於提升 cache 命中率
  • 若使用 mmap() 加上 write() 傳送資料,而系統中另有其他行程對同一檔案進行寫入,可能導致非預期的記憶體存取行為,觸發 SIGBUS (非預期的記憶體存取操作) 訊號,進而造成應用程式異常終止與 core dump
  • 為避免上述風險,必須對 SIGBUS 做適當處理,或針對資料存取模式設計明確的使用規範

sendfile 系統呼叫的設計,正是為了解決前述使用 read()write() 所造成的多重資料複製與 context switch 負擔。透過以下簡單的呼叫,即可由核心直接完成資料搬移:

sendfile(socket, file, len);

流程示意:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • 步驟 1:sendfile() 執行後,檔案資料首先經由 DMA 傳入核心緩衝區,接著由 CPU 將資料複製至 socket buffer
  • 步驟 2:資料從 socket buffer 再透過 DMA 傳送至客戶端

整體資料搬移次數為 2 次 DMA 複製與 1 次 CPU 複製,共計 3 次。

討論:

  • 雖然 sendfile 已明顯減少資料複製與 context switch 次數,但傳輸過程中仍會產生一份 socket buffer 的額外資料。這部分是否有機會省略?只要底層硬體支援必要機制,即可進一步達成。關鍵在於 scatter-gather I/O,該機制允許資料來源不需位於連續記憶體空間,能分散於多個區段,由裝置直接聚合 (gather) 處理後送出
  • 配合支援 scatter-gather 的網路裝置,sendfile 可落實 Zero-Copy:核心空間的資料不再複製至 socket buffer,而是由網路裝置直接從核心緩衝區讀取資料並傳送至對端
  • 應用程式端無須任何修改,仍然透過標準的 sendfile 系統呼叫介面,即可自動受益於此項效能最佳化

可用以下命令檢查網路裝置是否支援 scatter-gather,以 Intel 公司出品的 I211-AT 乙太網路控制器來說:

$ ethtool -k enp5s0 | grep scatter-gather

參考輸出:

scatter-gather: on
	tx-scatter-gather: on
	tx-scatter-gather-fraglist: off [fixed]

Zero-Copy 流程示意:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • 步驟 1:sendfile() 執行後,檔案資料透過 DMA 傳入核心空間 buffer。此時不再複製到 socket buffer,而是僅記錄各 buffer 的位址與長度
  • 步驟 2:傳輸階段仍由 DMA 處理,資料直接從核心緩衝區送往網路裝置,由其完成封包發送

整體過程完全免除 CPU 的搬移工作,資料僅透過 DMA 在裝置與記憶體之間傳輸,達成真正的 Zero-Copy。

splice 系統呼叫

sendfile 最初僅支援將資料從檔案傳送至 socket,且若要達成零複製(Zero-Copy),通常仰賴底層硬體支援 (例如 scatter-gather DMA)。為了解決這些限制,自 Linux v2.6.17 引入 splice 系統呼叫以來,允許二個檔案描述子之間的資料傳輸,可不經過使用者空間,達成通用型的 Zero-Copy。

原型宣告如下:

splice(fd_in, off_in, fd_out, off_out, len, flags);

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

相較於 sendfilesplice 不限於檔案與 socket 之間的傳輸,也不依賴硬體支援,只需透過 Linux 核心內部的管線緩衝區 (pipe buffer) 即可運作。在 Linux v2.6.33 之後,sendfile 的適用對象已不再侷限於 socket,亦可支援任意檔案描述子,但其使用情境仍偏向單向、封裝式的傳輸。相對地,splice 提供更大的彈性與通用性。

splice 達成 Zero-Copy 的流程特性如下:

  • 2 次 context switch (進入與離開核心模式)
  • 0 次 CPU 複製 (不經 CPU 搬移資料)
  • 2 次 DMA 複製 (一次從來源讀取,一次傳送至目的端)

資料處理過程如下:

  1. 使用者程式呼叫 splice(),系統由使用者模式切換至核心模式
  2. DMA 控制器將資料從來源裝置或主記憶體複製到核心的管線緩衝區
  3. 核心在來源與目的端之間建立資料通路,並透過 pipe 達成中介傳輸
  4. 再次透過 DMA 控制器,將資料從管線緩衝區傳送至目的端裝置
  5. 系統呼叫結束,context 切換回使用者模式
  6. 資料全程未進入使用者空間,因此無法被應用程式檢查或修改

需要注意的是,splice 要求其兩個檔案描述子中至少一方為管線裝置 (pipe),Linux 核心利用管線緩衝區作為中介區域,使任意支援 splice 的來源與目的裝置間的資料搬移變得可行且高效。

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

讀者或許會好奇:在 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() 多,但若管道得以重複利用,初始化所需的 pipepipe2() 呼叫次數將被有效攤平。當 splice() 的使用頻率足夠高時,這額外的系統呼叫幾乎不再構成負擔。此折衷設計展現出在實務中透過資源重用達成效能與彈性的平衡。

專為檔案系統設計的 copy_file_range 系統呼叫

自 Linux v4.5 引入 copy_file_range 系統呼叫,提供在同一檔案系統內進行檔案間資料複製的 Zero-Copy 傳輸機制,避免資料在核心空間與使用者空間之間的反覆搬移。

copy_file_range() 的原型宣告如下:

#define _GNU_SOURCE
#define _FILE_OFFSET_BITS 64
#include <unistd.h>

ssize_t copy_file_range(int fd_in, off_t *off_in,
                        int fd_out, off_t *off_out,
                        size_t len, unsigned int flags);

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() 實作,採用以下流程:

  1. 優先查詢檔案系統是否實作 file_operations 中的 .copy_file_range.remap_file_range
    • 前者為實際資料複製
    • 後者則多實作為資料複製(clone),即不真正搬移資料,而是透過 CoW 建立引用 (例如 Btrfs 的 reflink 機制)
  2. 若上述介面皆未實作,或不支援跨檔案系統搬移,則自動退回使用 splice() 作為資料搬移的後備方案

以 Btrfs 為例,其 .remap_file_range 實作即以 btrfs_clone_files() 為主體,使用 reflink 建立新 inode 與原資料的共享關係。在多數情況下,這並不會造成實體資料的搬移,而僅產生 metadata 上的引用修改,效率遠優於傳統複製。

自 Linux v5.3 起,copy_file_range() 開始允許跨檔案系統搬移資料,此時會退回使用 splice() 實作。不過由於早期出現多起與一致性、對齊與錯誤處理相關的問題,自 Linux v5.19 起施加新限制:僅允許在相同檔案系統類型間執行該操作,從而提高安全性與行為一致性。

案例:運用 sendfile 和 splice 來實作快速的 cat

UNIX 工具的 cat 是 "concatenate" 的意思,該命令具體作用為

concatenate files and print on the standard output

透過上述 sendfile 和 splice 系統呼叫,我們可實作更快的 cat,專案: fastcat

image

bench 工具測量:

  • simple
bench './simple 100-0.txt'
benchmarking ./simple 100-0.txt
time                 176.2 ms   (169.3 ms .. 189.5 ms)
                     0.995 R²   (0.985 R² .. 1.000 R²)
mean                 175.6 ms   (173.4 ms .. 179.7 ms)
std dev              4.361 ms   (1.680 ms .. 6.660 ms)
variance introduced by outliers: 12% (moderately inflated)
  • GNU coreutils 內附的 cat
bench 'cat 100-0.txt'
benchmarking cat 100-0.txt
time                 2.691 ms   (2.658 ms .. 2.719 ms)
                     0.999 R²   (0.999 R² .. 1.000 R²)
mean                 2.662 ms   (2.650 ms .. 2.675 ms)
std dev              41.02 μs   (32.51 μs .. 50.45 μs)
  • fastcat
bench './fastcat 100-0.txt'
benchmarking ./fastcat 100-0.txt
time                 1.520 ms   (1.479 ms .. 1.566 ms)
                     0.995 R²   (0.991 R² .. 0.999 R²)
mean                 1.514 ms   (1.497 ms .. 1.532 ms)
std dev              63.00 μs   (46.37 μs .. 84.38 μs)
variance introduced by outliers: 29% (moderately inflated)

參考資訊