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)

这个程序会遍历每个文件,逐行打印其内容。简单得很!不过等等,这个工具到底有多快呢?

我快速创建了一个 2GB 的随机文件来做基准测试。

我们用超好用的 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 代码还可以稍微优化一下。事实证明,行缓存在最后会拖慢性能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 本人写过一篇不错的总结,不过我更喜欢 man 手册里的描述:

splice() 在两个文件描述符之间移动数据,而无需在内核地址空间和用户地址空间之间拷贝。它最多将 len 字节的数据从文件描述符 fd_in 传输到文件描述符 fd_out,其中一个文件描述符必须指向管道。

如果你真的想深入研究,这里有来自 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 性能(参考)。

文件图标来自 Noun Project 的 Aleksandr Vector。终端图标来自 Noun Project 的 useiconic.com。
来源:File 图标来自 Noun Project 的 Aleksandr Vector。Terminal 图标来自 Noun Project 的 useiconic.com。

在 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 要求其中一个文件描述符必须是管道。变通的办法是创建一个管道,它由读取端和写入端(rdwr)组成。我们先把文件内容通过管道送入写入端,然后再从管道中读取数据并推送到 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,它可以在内核内把文件发送到套接字。可惜的是,它不支持文件到文件的发送。2 还有 copyfile,它的接口类似,但遗憾的是,它并不是零拷贝。(一开始我以为是,但我错了。)
  • Windows 不提供零拷贝的文件到文件传输(仅支持通过 TransmitFile API 进行文件到套接字的传输)。

不过,在生产级的实现中,可以在支持 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 进行翻译

评论