fastcat — 使用 Splice 實作更快的 cat
很多人叫我再寫一篇關於知名 Unix 指令內部運作的文章。好吧,其實根本沒人要求,但拿來當開場白還不錯。我相信你已經讀過之前關於 yes 和 ls 的文章——都非常精彩。
總之,今天要談的是 cat,這個指令原本用來串接檔案——不過更常見的用法,是被拿來把檔案內容印到螢幕上。
# Concatenate files, the intended purpose
cat input1.txt input2.txt input3.txt > output.txt
# Print file to screen, the most common use-case
cat myfile實作 cat
以下是一個用 Ruby 寫的陽春版 cat:
#!/usr/bin/env ruby
def cat(args)
args.each do |arg|
IO.foreach(arg) do |line|
puts line
end
end
end
cat(ARGV)這個程式會逐一走訪每個檔案,並一行一行地印出內容。超簡單!不過等一下,這個工具到底有多快呢?
為了做效能測試,我快速建立了一個 2 GB 的隨機檔案。
讓我們用超好用的 pv(Pipe Viewer)工具,來比較一下我們這個陽春版實作和系統內建版本的效能。所有測試都是在快取已預熱(檔案已在記憶體中)的狀態下,取五次執行的平均值。
# Ruby 2.5.1
> ./rubycat myfile | pv -r > /dev/null
[196MiB/s]還不算太差吧?那跟我系統內建的 cat 比起來又如何呢?
cat myfile | pv -r > /dev/null
[1.90GiB/s]哎呀,GNU cat 比我們這個小小的 Ruby cat 快了十倍。💎🐈🐌
讓我們的 Ruby cat 再快一點
我們的陽春 Ruby 程式碼還可以稍微調整一下。結果發現,行緩衝(line buffering)到最後反而會拖慢效能1:
#!/usr/bin/env ruby
def cat(args)
args.each do |arg|
IO.copy_stream(arg, STDOUT)
end
end
cat(ARGV)rubycat myfile | pv -r > /dev/null
[1.81GiB/s]哇……我們根本沒怎麼用力,就已經快要追上那個自 1971 年以來就不斷被最佳化的工具了。🎉
不過在太過慶祝之前,讓我們看看能不能再更快一點。
Splice
一開始讓我想寫這篇關於 cat 的文章的,是 Hacker News 上使用者 wahern 的這則留言:
我很驚訝 GNU yes 和 GNU cat 都沒有使用 splice(2)。
這個叫 splice 的東西,難道能讓印出檔案更快嗎?——我被勾起了好奇心。
Splice 最早是在 2006 年被引入 Linux 核心,有一篇來自 Linus Torvalds(林納斯·托瓦茲)本人的精彩總結,不過我更喜歡 manpage 上的說明:
splice() 會在兩個檔案描述子之間搬移資料,而不會在核心位址空間與使用者位址空間之間進行拷貝。它會從檔案描述子
fd_in傳輸最多len位元組的資料到檔案描述子fd_out,其中一個檔案描述子必須指向 pipe。
如果你真的想深入研究,這裡有來自 Linux 核心的對應原始碼,不過我們現在不需要了解所有細枝末節。相反地,我們只要看看 C 語言實作中的標頭就好:
#include <fcntl.h>
ssize_t splice (int fd_in, loff_t *off_in, int fd_out,
loff_t *off_out, size_t len,
unsigned int flags);再進一步簡化,以下是我們如何把整個 src 檔案複製到 dst 的寫法:
const ssize_t r = splice (src, NULL, dst, NULL, size, 0);這件事最酷的地方在於,一切都在 Linux 核心內部完成,這表示我們完全不會把任何一個位元組拷貝到使用者空間(也就是我們的程式執行所在的地方)。理想上,splice 是透過重新對映記憶體分頁來運作,實際上並不會真的拷貝任何資料,因此可能提升 I/O 效能(參考資料)。

在 Rust 中使用 splice
我必須說,我不是 C 程式設計師,而我偏好 Rust,因為它提供了更安全的介面。以下是在 Rust 中同樣的東西:
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn splice(
fd_in: RawFd,
off_in: Option<&mut libc::loff_t>,
fd_out: RawFd,
off_out: Option<&mut libc::loff_t>,
len: usize,
flags: SpliceFFlags,
) -> Result<usize>現在,這些 Linux 綁定並不是我自己實作的。相反地,我只是使用了一個叫做 nix 的函式庫,它為 *nix API 提供了對 Rust 友善的綁定。
不過有一個但書:我們其實沒辦法直接把檔案拷貝到標準輸出,因為 splice 要求其中一個檔案描述子必須是 pipe。解法是建立一個 pipe,它由讀取端和寫入端(rd 和 wr)組成。我們先把檔案用 pipe 送進寫入端,然後再從 pipe 讀取並把資料推送到 stdout。
你可以看到我使用了一個相對較大的 16384 位元組(214)緩衝區來提升效能。
extern crate nix;
use std::env;
use std::fs::File;
use std::io;
use std::os::unix::io::AsRawFd;
use nix::fcntl::{splice, SpliceFFlags};
use nix::unistd::pipe;
const BUF_SIZE: usize = 16384;
fn main() {
for path in env::args().skip(1) {
let input = File::open(&path).expect(&format!("fcat: {}: No such file or directory", path));
let (rd, wr) = pipe().unwrap();
let stdout = io::stdout();
let _handle = stdout.lock();
loop {
let res = splice(
input.as_raw_fd(),
None,
wr,
None,
BUF_SIZE,
SpliceFFlags::empty(),
).unwrap();
if res == 0 {
// We read 0 bytes from the input,
// which means we're done copying.
break;
}
let _res = splice(
rd,
None,
stdout.as_raw_fd(),
None,
BUF_SIZE,
SpliceFFlags::empty(),
).unwrap();
}
}
}那麼,這有多快呢?
fcat myfile | pv -r > /dev/null
[5.90GiB/s]天啊。這比系統的 cat 快了三倍多。
作業系統支援
- Linux 和 Android 皆完整支援。
- OpenBSD 也有某種稱為
sosplice的 splice 實作。不過我沒有測試過。 - 在 macOS 上,最接近 splice 的是它的老大哥 sendfile,它可以在核心內將檔案傳送到 socket。可惜的是,它不支援從檔案到檔案的傳送。2 還有一個
copyfile,它有類似的介面,但可惜它並非 zero-copy(零拷貝)。(一開始我以為是,但我錯了。) - Windows 不提供 zero-copy 的檔案對檔案傳輸(僅提供使用 TransmitFile API 的檔案對 socket 傳輸)。
儘管如此,在具備產品等級的實作中,可以在支援的系統上啟用 splice 支援,同時使用通用實作作為備援。
不錯,但我到底為什麼會需要這個?
我也不知道。大概你也不需要,因為你的瓶頸在別的地方。不過話說回來,很多人會用 cat 把資料用管線傳給另一個處理程序,例如
# Count all lines in C files
cat *.c | wc -l或
cat kittens.txt | grep "dog"在這種情況下,如果你發現 cat 成了瓶頸,可以試試 fcat(但首先,試著完全避免使用 cat)。
再多做一些工作,fcat 也可以用來直接將封包從一張網路卡導向另一張網路卡,類似 netcat。
心得
- 越接近底層硬體,我們好不容易建立起來的抽象層就越容易瓦解,我們又回到了低階系統程式設計。
- 除了快速的 cat,也有慢速 cat 的使用情境:老舊電腦。為此,有——你猜對了——slowcat。
話說回來,我還是不明白為什麼 GNU cat 在 Linux 上不使用 splice。🤔 fcat 的原始碼就在 Github 上。歡迎貢獻!
註解
隨機一篇部落格