fastcat —— 使用 Splice 打造更快的 cat 實作
原文由 Matthias Endler 于 發布,訂閱此部落格
很多人敲碗要我再寫一篇關於知名 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)工具,比較一下這個陽春版實作跟系統內建版本的執行速度。所有測試都是在 warm cache(檔案已在記憶體中)的狀態下跑五次取平均。
# 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() 會在兩個檔案描述符之間搬移資料,而不需要在核心位址空間與使用者位址空間之間進行複製。它會將最多
len個位元組的資料,從檔案描述符fd_in傳輸到檔案描述符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 核心內部完成,這代表我們完全不會把任何一個位元組複製到 userspace(也就是我們的程式執行所在的地方)。理想上,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,介面類似,但可惜它並非零拷貝。(一開始我以為是,但 我搞錯了。) - Windows 並未提供零拷貝的檔案對檔案傳輸(只有使用 TransmitFile API 的檔案對 socket 傳輸)。
儘管如此,在正式上線等級的實作中,可以在支援 splice 的系統上啟用 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 上。歡迎貢獻!
註腳
隨機一篇部落格
留言
登入後參與討論