fastcat——使用 Splice 实现的更快 `cat`
很多人让我再写一篇介绍知名 Unix 命令内部原理的文章。好吧,其实根本没人提过,但这倒是个不错的开场。我相信你已经读过前面介绍 yes 和 ls 的文章了——简直史诗级。
总之,今天我们来聊聊 cat。它的用途是拼接文件——或者更常见地说,被滥用来把文件内容打印到屏幕上。
# 拼接文件,这是它设计之初的用途
cat input1.txt input2.txt input3.txt > output.txt
# 将文件打印到屏幕,这是最常见的用法
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 代码。事实证明,行缓冲最终会损害性能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 Kernel,Linus Torvalds(林纳斯·托瓦兹)本人还写过一篇不错的总结,但我更喜欢 manpage 中的描述:
splice() 在两个文件描述符之间移动数据,不在内核地址空间和用户地址空间之间进行复制。它将最多
len字节的数据从文件描述符fd_in传输到文件描述符fd_out,其中一个文件描述符必须指向管道。
如果你真的想深入了解,这里有对应的 Linux Kernel 源代码,但现在我们不需要掌握所有琐碎细节。相反,我们只需查看 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 Kernel 内部,这意味着我们不会向用户空间(也就是程序运行的地方)复制哪怕一个字节。理想情况下,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 bindings,而是直接使用了一个叫作 nix 的库,它为 *nix API 提供了适合 Rust 的 bindings。
但这里有一个限制:我们不能真的把文件直接复制到标准输出,因为 splice 要求其中一个文件描述符必须是管道。解决办法是创建一个管道,它由读端和写端(rd 和 wr)组成。我们把文件传入写端,然后从管道读取数据,再将其推送到 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,它可以在 Kernel 内部将文件发送到 socket。遗憾的是,它不支持从文件发送到文件。2 另外还有
copyfile,它的接口类似,但遗憾的是,它不是零拷贝(我一开始以为是,但我错了)。 - Windows 不提供文件到文件的零拷贝传输(只提供使用 TransmitFile API 的文件到 socket 传输)。
不过,在一个生产级实现中,可以在支持 splice 的系统上启用它,同时使用通用实现作为后备方案。
不错,但我到底为什么会需要它?
我也不知道。你大概不需要,因为你的瓶颈可能在别的地方。话虽如此,很多人会使用 cat,将数据通过管道传给另一个进程,例如:
# 统计所有 C 文件中的行数
cat *.c | wc -l或者:
cat kittens.txt | grep "dog"在这种情况下,如果你发现 cat 成了瓶颈,可以试试 fcat(但首先,尽量完全避免使用 cat)。
再多做一些工作,fcat 还可以用来直接将数据包从一块网卡转发到另一块网卡,类似于 netcat。
经验教训
- 我们越接近裸机,来之不易的抽象就越容易崩塌,而我们也会回到低级系统编程。
- 除了快速的 cat 之外,慢速的 cat 也有使用场景:老旧计算机。为此,有一个——你猜到了——slowcat。
话虽如此,我还是不知道 GNU cat 为什么不在 Linux 上使用 splice。🤔 fcat 的源代码在 Github 上。欢迎贡献!
脚注
随机一篇博客