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)这个程序会遍历每个文件,逐行打印其内容。简单得很!不过等等,这个工具到底有多快呢?
我快速创建了一个 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 性能(参考)。

在 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 要求其中一个文件描述符必须是管道。变通的办法是创建一个管道,它由读取端和写入端(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,它可以在内核内把文件发送到套接字。可惜的是,它不支持文件到文件的发送。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 上。欢迎贡献!
脚注
随机一篇博客
评论
登录后参与讨论