fastcat - A Faster `cat` Implementation Using Splice

Matthias Endler

fastcat — 使用 Splice 實作更快的 cat

很多人叫我再寫一篇關於知名 Unix 指令內部運作的文章。好吧,其實根本沒人要求,但拿來當開場白還不錯。我相信你已經讀過之前關於 yesls 的文章——都非常精彩。

總之,今天要談的是 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 效能(參考資料)。

由 Aleksandr Vector 於 the Noun Project 提供的檔案圖示。由 useiconic.com 於 the Noun Project 提供的終端機圖示。
來源:檔案圖示由 Aleksandr Vector 來自 the Noun Project。終端機圖示由 useiconic.com 來自 the Noun Project。

在 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,它由讀取端和寫入端(rdwr)組成。我們先把檔案用 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 快了三倍多

作業系統支援

  • LinuxAndroid 皆完整支援。
  • 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 上。歡迎貢獻!

註解

1. 感謝讀者 Freeky 讓這段程式碼更符合慣用寫法。
2. 感謝讀者 masklinn 提供的提示。

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯