---
tags: LINUX KERNEL, LKI
---
# 以 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 所為
* 自 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)
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://hackmd.io/_uploads/BJRfideQc.jpg)
以 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)
![](https://i.imgur.com/PzAjpy6.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)
```
---
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
```