# 以 sendfile 和 splice 系統呼叫達到 Zero-Copy ## 特殊系統呼叫的定位 在 [in-kernel HTTP server](https://hackmd.io/@sysprog/kernel-web-server) 中,提到: 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 所為 4. 不過 Linux 2.6 以來,核心發展重新找到自己的方向,沒必要透過特化需求去克服局部的效能瓶頸 5. Linux-2.6 核心的發展策略是基於整體思維,像是新的排程演算法 (O(1) scheduler 和後續的 CFS),又像是 linux-2.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 這樣的實作可讓 Linux 核心成為提供讀取靜態網站頁面的服務,減少核心和使用者層級之間的 context switch 和資料複製。 Linux 應用程式在讀取和傳送網路資料的流程,簡化為以下 (K 表示核心, U 表示使用者層級): * U: open() * K: do_sys_open() * K: do_filp_open() - 找到檔案,並從所在的檔案系統取得 struct file * K: 回傳檔案物件 * U: malloc() - 準備記憶體空間作為 buffer * U: read(file, buffer) * K: vfs_read(file) - 標準 VFS 的檔案讀取操作 * K: `file->f_op->read()` - 透過資料所在的檔案系統,使用提供的低階 read 操作 * K: copy_to_user(buffer) - 將檔案資料從實體儲存裝置讀出來後,複製一份到使用者層級的 buffer 接著是將讀到的資料透過網路傳送出去: * U: write(Socket, buffer) - 將 buffer 內資料傳送出去 * K: copy_from_user(buffer) - 將使用者層級的 buffer 複製回去核心空間 * K: Send data - 傳送資料 Linux 核心提供 [sendfile](http://man7.org/linux/man-pages/man2/sendfile.2.html) 系統呼叫 (註: BSD 家族也有作業系統提供 `sendfile`,但是語意有出入),於是我們只要指定 socket file description 和將要送出去的檔案給 sendfile,後者即可在核心模式中,將檔案自儲存裝置讀取複製出來,不再經過任何多餘核心和使用者層級的資料複製,直接從指定的裝置輸出。 以下逐步探討個別系統呼叫的行為。舉例來說,伺服器讀取一個檔案,然後把檔案資料經由 socket 將資料傳送給客戶端。程式碼包含以下: ```cpp read(file, tmp_buf, len); write(socket, tmp_buf, len); ``` 流程示意: ![](https://i.imgur.com/1RoFRKx.png) * 步驟 `1` 到 `2`: 當執行 read 函式 (由 glibc 或相似的 libc 提供) 後,read 系統呼叫被觸發,檔案資料會經由 DMA 傳遞於核心模式內的 buffer,然後再由 CPU 將檔案資料搬到使用者層級的 buffer (tmp_buf) 中。 * 步驟 `3` 到 `4`: 執行 write 函式後,write 系統呼叫被觸發,藉由 CPU 將使用者層級的 buffer 的資料搬到 socket buffer 裡頭,資料傳遞到 socket buffer 後,會再經由 DMA,將資料送出去給客戶端。 * 合計 4 次資料複製 討論: * 整個流程中不難發現資料是重覆複製,在資料量增大時,會對效能產生衝擊。若能把這些部份改掉,那就可減少記憶體的消耗並增加效能。 * 以硬體的角度來看,透過記憶體進行資料暫存的操作其實可省去,直接將檔案資料傳遞到網路裝置 —— 但並非所有的硬體都支援。 是否可減少使用者層級 buffer 的操作呢?可以,例如透過 [mmap](http://man7.org/linux/man-pages/man2/mmap.2.html) 來取代 read: ```cpp tmp_buf = mmap(file, len); write(socket, tmp_buf, len); ``` 流程示意: ![](https://i.imgur.com/9madsOS.png) * 步驟 `1` 到 `2`: mmap 執行後,一如上述 read 系統呼叫,將檔案資料經由 DMA 複製一份到核心模式的 buffer,但不同的是,read 需要將核心 buffer 複製到使用者層級的 buffer,mmap 卻不會,因為 mmap 的使用者 buffer 和核心模式的 buffer 共享同一塊硬體分頁 (page),因此 mmap 可減少一次 CPU copy。 * 步驟 `3` 到 `4`: write 執行後,把核心模式的 buffer 經由 CPU 複製到 socket buffer,再經由 DMA 傳輸到客戶端。 * 合計 3 次資料複製 討論: * 使用 mmap 會付出可觀的成本,一是來自虛擬記憶體管理 (VMM) 的代價,另一是同步議題 * 存取小檔案時,直接用 read() 或 write() 更有效率 * 單個行程對檔案進行循序存取 (sequential access) 時,用 mmap()幾乎不會有任何效能提升 (甚至有反效果)。反過來說,用 read 循序讀取檔案時,檔案系統會使用 read-ahead 的方式,提前將檔案內容快取到檔案系統的緩衝區,因此使用 read() 可提升 cache hit。 * 當使用 mmap + write 時,系統同時又有另外一個程式對同一個檔案執行 write 時,將會觸發 SIGBUS 訊號 —— 因為產生非預期的記憶體存取操作,這個訊號對應的處理行為是,系統砍掉你的程式,並產生 core dump * 所以你不得不因此對 SIGBUS 訊號做事後補救或者提出資料存取的規範。 sendfile 系統呼叫的提出,就是克服上述問題,取代 read/write 兩個操作,並減少 context switch 和資料複製的次數: ```cpp sendfile(socket, file, len); ``` 流程示意: ![](https://i.imgur.com/Xco4OR0.png) * 步驟 `1`: sendfile 執行後,檔案資料會經由 DMA 傳遞給核心 buffer,再由 CPU 複製到socket buffer * 步驟 `2`: 將 socket buffer 的資料經由 DMA 傳遞到客戶端,所以執行 2 次 DMA Copy 及 1 次的 CPU Copy * 合計 3 次的資料複製 討論: * 儘管改善資料複製和 context switch 成本,但仍有一份重複的資料,也就是 socket buffer,後者是否也能略去呢?只要硬體提供必要機制,即可做到,即 scatter-gather (聚合) 的功能,該功能主要的目的是,即將傳遞資料的一端不必要求存放的資料位址是連續的記憶體空間,換言之,可分散在記憶體的各個位置,隨後由硬體介入處理。 * 上述的組合就構成 Zero-Copy —— 不僅減少 context switch 且也減少 buffer 的使用,上層的應用程不需要做任何的變動,依舊使用 sendfile 系統呼叫。 可用以下命令查詢網路卡是否支持 scatter-gather 特性: ```shell $ ethtool -k enp5s0 | grep scatter-gather ``` 參考輸出: ``` scatter-gather: on tx-scatter-gather: on tx-scatter-gather-fraglist: off [fixed] ``` Zero-Copy 流程示意: ![](https://i.imgur.com/hLvVzAG.png) * 步驟 `1`: sendfile 執行後,檔案資料經由 DMA 傳給核心模式的 buffer,但已不會再把資料複製到 socket buffer,後者只需理會有哪些核心模式的 buffer 的地址及資料長度,因此用 apend 模式。 * 步驟 `2`: 資料傳給客戶端也用 DMA,但來源變成核心模式 buffer。 因此,不需要 CPU 去搬動資料,而是純粹 DMA 搬資料來實現 Zero-Copy。 ## splice 系統呼叫 sendfile 只適用於將資料從檔案拷貝到 socket,同時需要硬體的支援,Linux 核心自 `v2.6.17` 引入 splice 系統呼叫,不僅不需要硬體支援,還提供兩個 file descriptor (fd) 之間的資料 Zero-Copy。在 Linux 核心 2.6.33 後的版本,sendfile 已不再受限於複製資料到 socket 的操作,可用於任意的檔案。 ```cpp splice(fd_in, off_in, fd_out, off_out, len, flags); ``` ![](https://i.imgur.com/9ynwis3.png) 以 splice 系統呼叫達到的 Zero-Copy,過程中的表現: * 2 次 context switch * 0 次 CPU 複製 * 2 次 DMA 複製 使用者程式讀寫資料的流程如下: 1. 使用者程式經由 splice() 函式向核心發起系統呼叫 $\to$ 從使用者模式切換為核心模式 2. CPU 利用 DMA 控制器將資料從主記憶體或儲存裝置複製到核心空間的 read buffer 3. CPU 在核心空間的 read buffer 和 socket buffer 之間建立管線(pipe)。 4. CPU 利用 DMA 控制器將資料從 socket buffer 複製到網路裝置進行資料傳輸 5. context 從核心模式切換回使用者模式,splice 系統呼叫執行返回 6. splice 複製方式也同樣存在使用者程式不能對資料進行修改的問題。此外,它用 Linux 的管線緩衝機制,可用於任意兩個 fd 中傳輸資料,但它的兩個 fd 參數中有一個必為管線裝置。 ## 運用 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) ``` --- shell 特殊變數: * `$?` : 上個執行過的命令所返回的狀態值 * `$$` : 目前 shell 的 process ID ```shell $ ls -l /proc/$$/fd ``` 參考輸出: ``` lrwx------ 1 0 -> /dev/pts/0 lrwx------ 1 1 -> /dev/pts/0 lrwx------ 1 2 -> /dev/pts/0 lrwx------ 1 255 -> /dev/pts/0 l-wx------ 1 3 -> /dev/null ```