--- tags: LINUX KERNEL, LKI --- # 以 sendfile 和 splice 系統呼叫達到 Zero-Copy ## 特殊系統呼叫的定位 〈[Linux 核心設計: 發展動態回顧](https://hackmd.io/@sysprog/linux-dev-review)〉提到: 1. Linux 核心差一點就走進 Microsoft Windows 的「為需求而升級」的發展道路,從而偏離原先 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." 2. Linux 2.4 時代,核心提供一個名為 khttpd 的核心內部的 web 伺服器 (in-kernel httpd)。當時 Linux 的效能不夠好,且無法有效利用 SMP 的優勢、thread 的實現還是很拙劣 (不是 NPTL),很多方面沒有達到 POSIX 的性能期待,因此當時的開發者就鑽進瞭解決性能瓶頸的牛角尖上 3. 有鑑於 Apache 伺服器多半在 Linux 上運作,且 web 伺服器是如此普遍也重要,因此效能瓶頸往往是 Apache 本身,因此面對這個如此「特化」又不得不理會的需求,開發者們只好就事論事地將 web 伺服器單獨加速,也就是在核心內部中實做一個 web 加速器 * 如果按照這條路走下去,Linux 或許還會把圖形處理搬到核心內部,一如 Windows NT 所為 * 自 Windows NT 以來,為了效率考量,字型處理 (font engine) 是實作於核心內部 (!),因此也招來 [CVE-2015-3052](https://vulners.com/cve/CVE-2015-3052) (BLEND vulnerability) 的安全漏洞,從而影響到[核心模式](https://googleprojectzero.blogspot.com/2015/08/one-font-vulnerability-to-rule-them-all.html) Linux v2.6 以來,核心發展重新找到自己的方向,沒必要透過特化需求去克服局部的效能瓶頸,也就是說,核心的發展策略是基於整體思維,像是新的排程演算法 (O(1) scheduler 和後續的 CFS),又像是 Linux v2.6.17 引入的 [splice](http://man7.org/linux/man-pages/man2/splice.2.html) 和 [tee](http://man7.org/linux/man-pages/man2/tee.2.html) 系統呼叫,都讓 khttpd 沒有存在的必要。 ## 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); ``` 流程示意: ![image](https://hackmd.io/_uploads/H13oKleyxg.png) - 步驟 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); ``` 流程示意: ![image](https://hackmd.io/_uploads/rJQUoegkle.png) - 步驟 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); ``` 流程示意: ![image](https://hackmd.io/_uploads/HyvKoeg1lx.png) - 步驟 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 流程示意: ![image](https://hackmd.io/_uploads/r1Iqseg1gg.png) - 步驟 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); ``` ![image](https://hackmd.io/_uploads/r1oijelyge.png) 相較於 `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 與 splice 的設計考量 `sendfile` 的設計初衷是針對靜態資料傳送場景,例如 HTTP 伺服器回應靜態檔案,強調簡單、快速與最小化應用層參與。其 API 為單一系統呼叫,即可隱含完成讀取與傳送,降低開發複雜度。 然而,`sendfile` 的結構較為固定,難以處理其他類型裝置或雙向資料傳輸需求。因此 Linux 核心進一步設計出 `splice`,作為一種更通用的解法,搭配 `tee()`、`vmsplice()` 等系統呼叫,構成以管線為中心的資料通道機制。這些機制支援非同步、高並行、大量資料在裝置間搬移的需求,並保有 Zero-Copy 的效率。 這類演化反映出 Linux 核心設計從針對特定用途的優化邁向模組化與可組合的資料傳輸機制,在保持高效能的同時,也擴展了系統呼叫的適用彈性。 ## 運用 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) ![image](https://hackmd.io/_uploads/S1O3jgeJxl.png) 用 [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) ```