fastcat - A Faster `cat` Implementation Using Splice

Matthias Endler

fastcat —— 使用 Splice 打造更快的 cat 實作

原文由 Matthias Endler 發布,訂閱此部落格

很多人敲碗要我再寫一篇關於知名 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)工具,比較一下這個陽春版實作跟系統內建版本的執行速度。所有測試都是在 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 效能(參考資料)。

File icon by Aleksandr Vector from the Noun Project. Terminal icon by useiconic.com from the Noun Project.
來源:File icon by Aleksandr Vector from the Noun Project. Terminal icon by useiconic.com from 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,介面類似,但可惜它並非零拷貝。(一開始我以為是,但 我搞錯了。)
  • 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 上。歡迎貢獻!

註腳

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

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

留言