---
tags: LINUX KERNEL, LKI
---
# [I/O 模型演化](https://hackmd.io/@sysprog/linux-io-model): 以 sendfile 和 splice 系統呼叫達到 Zero-Copy
> 資料整理: [jserv](https://wiki.csie.ncku.edu.tw/User/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](https://en.wikipedia.org/wiki/TUX_web_server),直接在核心模式中服務靜態網頁。這種「特化加速」策略雖短期奏效,卻引發對核心角色定位的反思。
從 Linux 2.6 起,核心設計逐步回歸系統性與結構性思維。重點不再是為特定應用堆疊特例化功能,而是建立通用且可組合的基礎機制。例如 O(1) 與 CFS 排程器引入新模型,提升整體執行效率。同樣地,`splice()` 和 `tee()` 系統呼叫的加入 (自 Linux v2.6.17) 則提供高效的資料搬移通道,讓過去如 `khttpd` 所做的網頁加速需求得以在使用者空間中,以更一致、彈性的方式實現,無需再仰賴核心內建的特化服務。
> 延伸閱讀: [Linux 核心設計: 發展動態回顧](https://hackmd.io/@sysprog/linux-dev-review)
這一設計轉向也成就後續如 [sendfile](https://man7.org/linux/man-pages/man2/sendfile.2.html), [splice](https://man7.org/linux/man-pages/man2/splice.2.html), [copy_file_range](https://man7.org/linux/man-pages/man2/copy_file_range.2.html) 等 Zero-Copy 的系統呼叫,成為現代 Linux 高效資料傳輸機制的基石。它們不僅取代早期非必要的特化,也重新確認核心應致力於提供抽象、可組合的能力,而非針對單一應用場景進行特化的實作。
## sendfile 系統呼叫
早期如 khttpd 的核心常駐 HTTP 伺服器,是為了讓 Linux 核心直接提供靜態網頁服務,藉此減少核心與使用者空間之間的 context switch 與資料複製的成本。[sendfile](https://man7.org/linux/man-pages/man2/sendfile.2.html) 系統呼叫的出現,使得這類設計不再必要,因為應用程式可以直接委由核心搬移資料,避免不必要的 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`](http://man7.org/linux/man-pages/man2/sendfile.2.html) 系統呼叫 (註:BSD 系列作業系統亦提供名為 `sendfile` 的系統呼叫,但其語意與參數略有差異)。使用者只需指定來源檔案與目標 socket 的檔案描述元,核心即可在不經由使用者空間的情況下,直接將檔案內容從儲存裝置傳送出去,省去多餘的資料複製與 context switch。
為了比較其行為差異,以下以傳統的 `read()` 搭配 `write()` 的方式為例,說明未使用 `sendfile` 時的資料搬移流程:
```c
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
```
流程示意:

- 步驟 1 到 2:當程式呼叫 `read()` (通常由 glibc 或其他 C 函式庫提供包裝)時,系統呼叫會觸發。核心首先透過 DMA 將檔案資料載入至核心緩衝區,接著使用 CPU 將該資料複製至使用者空間的暫存區 `tmp_buf`
- 步驟 3 到 4:當程式呼叫 `write()` 傳送資料至 socket 時,系統呼叫再次觸發。核心透過 CPU 將 `tmp_buf` 的內容從使用者空間複製回核心空間的 socket 傳輸緩衝區。最後,再由 DMA 將資料從 socket buffer 傳送至目的端
這段資料流歷經 4 次資料搬移:
1. DMA:儲存裝置 $\to$ 核心 buffer
2. CPU:核心 buffer $\to$ 使用者空間 buffer
3. CPU:使用者空間 buffer $\to$ socket buffer
4. DMA:socket buffer $\to$ 網路裝置傳送
討論:
- 從整體流程可觀察到,資料在不同階段被重複複製。當傳輸資料量增加,這類冗餘搬移會對系統效能造成明顯衝擊,不僅增加記憶體耗用,也提高 CPU 的負載。若能移除其中不必要的複製,將有助於減少資源使用並改善效能表現
- 從硬體角度而言,透過記憶體進行資料暫存其實並非不可或缺。理論上可將檔案內容直接傳送至網路裝置,完全略過中間緩衝區。不過此類傳輸模式仰賴底層硬體支援,例如具備 DMA 傳輸能力與 scatter-gather 功能的網路裝置,並非所有系統皆具備這些條件
是否能減少使用者空間 buffer 的使用?可以。例如,藉由 [mmap](http://man7.org/linux/man-pages/man2/mmap.2.html) 取代 `read()`,使資料直接映射到使用者空間:
```c
tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);
```
流程示意:

- 步驟 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](https://man7.org/linux/man-pages/man2/sendfile.2.html) 系統呼叫的設計,正是為了解決前述使用 `read()` 和 `write()` 所造成的多重資料複製與 context switch 負擔。透過以下簡單的呼叫,即可由核心直接完成資料搬移:
```c
sendfile(socket, file, len);
```
流程示意:

- 步驟 1:`sendfile()` 執行後,檔案資料首先經由 DMA 傳入核心緩衝區,接著由 CPU 將資料複製至 socket buffer
- 步驟 2:資料從 socket buffer 再透過 DMA 傳送至客戶端
整體資料搬移次數為 2 次 DMA 複製與 1 次 CPU 複製,共計 3 次。
討論:
- 雖然 `sendfile` 已明顯減少資料複製與 context switch 次數,但傳輸過程中仍會產生一份 socket buffer 的額外資料。這部分是否有機會省略?只要底層硬體支援必要機制,即可進一步達成。關鍵在於 [scatter-gather I/O](https://www.eejournal.com/article/20170209-scatter-gather/),該機制允許資料來源不需位於連續記憶體空間,能分散於多個區段,由裝置直接聚合 (gather) 處理後送出
- 配合支援 scatter-gather 的網路裝置,`sendfile` 可落實 Zero-Copy:核心空間的資料不再複製至 socket buffer,而是由網路裝置直接從核心緩衝區讀取資料並傳送至對端
- 應用程式端無須任何修改,仍然透過標準的 `sendfile` 系統呼叫介面,即可自動受益於此項效能最佳化
可用以下命令檢查網路裝置是否支援 scatter-gather,以 Intel 公司出品的 [I211-AT 乙太網路控制器](https://www.intel.com/content/www/us/en/products/sku/64404/intel-ethernet-controller-i211at/specifications.html)來說:
```shell
$ ethtool -k enp5s0 | grep scatter-gather
```
參考輸出:
```
scatter-gather: on
tx-scatter-gather: on
tx-scatter-gather-fraglist: off [fixed]
```
Zero-Copy 流程示意:

- 步驟 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](https://man7.org/linux/man-pages/man2/splice.2.html) 系統呼叫以來,允許二個檔案描述子之間的資料傳輸,可不經過使用者空間,達成通用型的 Zero-Copy。
原型宣告如下:
```c
splice(fd_in, off_in, fd_out, off_out, len, flags);
```

相較於 `sendfile`,`splice` 不限於檔案與 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` 的來源與目的裝置間的資料搬移變得可行且高效。

讀者或許會好奇:在 `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 或字元裝置,只要其中之一為管道,即可完成傳輸。這項設計雖需明確分成「來源 $\to$ 管道」與「管道 $\to$ 目的端」二次呼叫,但換來的是完整的通用性與模組性。其內部透過 pipe buffer 搬移資料頁框的參考資訊,而非實際複製內容,因此仍具備 Zero-Copy 的核心特性。
事實上,自 Linux v2.6.33 起,`sendfile()` 的內部實作便已改為建構在 `splice()` 之上,統一底層資料搬移邏輯,使系統更容易維護與擴充。同樣的資料流向若透過 `splice()` 實作,可達到與 `sendfile()` 相同的效能,但具備更多彈性,例如支援從 socket 讀取、輸出至檔案,或在中途插入 [tee](https://man7.org/linux/man-pages/man2/tee.2.html) 系統呼叫複製分支等操作。
至於為何 `splice()` 的設計未整合為單一系統呼叫,關鍵在於管道作為中介的必要性尚未完全解除。Linux 核心開發社群曾考慮移除此限制,允許 `splice()` 在任意檔案描述子之間直接運作,但由於涉及大量相容性與子系統互通問題,至今仍未正式納入主線。
在對效能高度敏感、且適合使用 `splice()` 的場景中,頻繁建立與銷毀管道緩衝區(pipe buffer)將帶來額外開銷。為降低這類成本,[HAProxy](https://www.haproxy.org/) 採取實用的最佳化策略:預先配置一組可重複使用的管道,構成管道緩衝池 (pipe buffer pool)。每次呼叫 `splice()` 時,從緩衝池中取得可用管道,傳輸結束後再歸還,藉此避免反覆分配與釋放資源所造成的效能損耗。
雖然 `splice()` 的典型用法牽涉 3 次系統呼叫,比 `sendfile()` 多,但若管道得以重複利用,初始化所需的 [pipe](https://man7.org/linux/man-pages/man2/pipe.2.html) 或 `pipe2()` 呼叫次數將被有效攤平。當 `splice()` 的使用頻率足夠高時,這額外的系統呼叫幾乎不再構成負擔。此折衷設計展現出在實務中透過資源重用達成效能與彈性的平衡。
## 專為檔案系統設計的 `copy_file_range` 系統呼叫
自 Linux v4.5 引入 [copy_file_range](https://man7.org/linux/man-pages/man2/copy_file_range.2.html) 系統呼叫,提供在同一檔案系統內進行檔案間資料複製的 Zero-Copy 傳輸機制,避免資料在核心空間與使用者空間之間的反覆搬移。
`copy_file_range()` 的原型宣告如下:
```c
#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);
```

與 `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](http://man7.org/linux/man-pages/man1/cat.1.html) 是 "concatenate" 的意思,該命令具體作用為
> concatenate files and print on the standard output
透過上述 sendfile 和 splice 系統呼叫,我們可實作更快的 cat,專案: [fastcat](https://github.com/sysprog21/fastcat)

用 [bench](https://hackage.haskell.org/package/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)
```
## 參考資訊
* [Zero Copy I: User-Mode Perspective](https://www.linuxjournal.com/article/6345)
* [Linux I/O 堆疊與零複製技術全揭秘](https://strikefreedom.top/archives/linux-io-stack-and-zero-copy)
* [Performance Review of Zero Copy Techniques](https://www.uidaho.edu/-/media/UIdaho-Responsive/Files/engr/research/csds/publications/2012/Performance-Review-of-Zero-Copy-Techniques-2012.pdf)