关于 Unix 命令 `yes` 的小故事
原文由 Matthias Endler 于 发布,订阅该博客
你知道的最简单的 Unix 命令是什么?
有 echo,它会向标准输出打印一个字符串,还有 true,它总是以退出码 0 结束。
在这一系列简单的 Unix 命令中,还有 yes。如果不带参数执行它,你会得到一串无限的 y,每个都以换行分隔:
y
y
y
y
(...you get the idea)起初看似毫无用处的功能,后来却被证明相当实用:
yes | sh boring_installation.sh有没有安装过那种需要你不断输入 “y” 并按回车才能继续的程序?yes 来救场了!它会尽职尽责地完成任务,让你可以继续安心看Pootie Tang。
编写 yes
下面是用……呃……BASIC 写的简易版本。
10 PRINT "y"
20 GOTO 10同样的功能用 Python 实现如下:
while True:
print("y")很简单,对吧?先别急!
事实证明,这个程序相当慢。
python yes.py | pv -r > /dev/null
[4.17MiB/s]和我 Mac 上自带的版本对比一下:
yes | pv -r > /dev/null
[34.2MiB/s]于是我尝试用 Rust 写一个更快的版本。这是我的第一次尝试:
use std::env;
fn main() {
let expletive = env::args().nth(1).unwrap_or("y".into());
loop {
println!("{}", expletive);
}
}一些说明:
- 我们要在循环中打印的字符串是第一个命令行参数,名为expletive。这个词是我从
yes的手册页中学到的。 - 我使用
unwrap_or来从参数中获取expletive。如果没有提供该参数,就使用 “y” 作为默认值。 - 默认参数会通过
into()从字符串切片(&str)转换成堆上的 owned 字符串(String)。
来测试一下。
cargo run --release | pv -r > /dev/null
Compiling yes v0.1.0
Finished release [optimized] target(s) in 1.0 secs
Running `target/release/yes`
[2.35MiB/s]哎呀,看起来并没有好多少,甚至比 Python 版本还要慢!这引起了我的注意,于是我去找了 C 语言实现的源代码。
这是该程序的最初版本,随 Version 7 Unix 一同发布,由 Ken Thompson 在 1979 年 1 月 10 日编写,可谓大名鼎鼎:
main(argc, argv)
char **argv;
{
for (;;)
printf("%s\n", argc>1? argv[1]: "y");
}没什么神奇之处。
再来看看来自 GNU coreutils 的 128 行版本,镜像托管在 Github 上。25 年过去了,它仍在积极开发中!最后一次代码修改就在大约一年前。它的速度非常快:
# brew install coreutils
gyes | pv -r > /dev/null
[854MiB/s]关键部分在末尾:
/* Repeatedly output the buffer until there is a write error; then fail. */
while (full_write (STDOUT_FILENO, buf, bufused) == bufused)
continue;啊哈!原来他们只是用缓冲区来加速写入操作。缓冲区大小由一个名为 BUFSIZ 的常量定义,在各个系统上会选择合适的值以使 I/O 更高效(见这里)。在我的系统上,这个值被定义为 1024 字节。而我用 8192 字节时性能反而更好。
我扩展了我的 Rust 程序:
use std::env;
use std::io::{self, BufWriter, Write};
const BUFSIZE: usize = 8192;
fn main() {
let expletive = env::args().nth(1).unwrap_or("y".into());
let mut writer = BufWriter::with_capacity(BUFSIZE, io::stdout());
loop {
writeln!(writer, "{}", expletive).unwrap();
}
}关键在于,缓冲区大小是 4 的倍数,以确保内存对齐。
运行这个版本后,我得到了 51.3MiB/s 的速度。比系统自带的版本快,但仍远慢于我在这篇 Reddit 帖子中找到的结果,作者在其中提到了 10.2GiB/s。
更新
Rust 社区再一次没有让人失望。
这篇文章刚发布到 Rust 分区,用户 nwydo 就指出了之前关于同一话题的讨论。这是他们优化后的代码,在我的机器上突破了 3GB/s 大关:
use std::env;
use std::io::{self, Write};
use std::process;
use std::borrow::Cow;
use std::ffi::OsString;
pub const BUFFER_CAPACITY: usize = 64 * 1024;
pub fn to_bytes(os_str: OsString) -> Vec<u8> {
use std::os::unix::ffi::OsStringExt;
os_str.into_vec()
}
fn fill_up_buffer<'a>(buffer: &'a mut [u8], output: &'a [u8]) -> &'a [u8] {
if output.len() > buffer.len() / 2 {
return output;
}
let mut buffer_size = output.len();
buffer[..buffer_size].clone_from_slice(output);
while buffer_size < buffer.len() / 2 {
let (left, right) = buffer.split_at_mut(buffer_size);
right[..buffer_size].clone_from_slice(left);
buffer_size *= 2;
}
&buffer[..buffer_size]
}
fn write(output: &[u8]) {
let stdout = io::stdout();
let mut locked = stdout.lock();
let mut buffer = [0u8; BUFFER_CAPACITY];
let filled = fill_up_buffer(&mut buffer, output);
while locked.write_all(filled).is_ok() {}
}
fn main() {
write(&env::args_os().nth(1).map(to_bytes).map_or(
Cow::Borrowed(&b"y\n"[..],
),
|mut arg| {
arg.push(b'\n');
Cow::Owned(arg)
},
));
process::exit(1);
}这就完全是另一回事了!
- 我们预先准备一个填满内容的字符串缓冲区,在每次循环中重复使用。
- 标准输出由锁保护。因此,我们不再反复获取和释放它,而是一直持有它。
- 我们使用平台原生的
std::ffi::OsString和std::borrow::Cow来避免不必要的内存分配。
我唯一能贡献的,就是去掉了多余的 mut。😅
经验总结
这个看似简单的 yes 程序,实际上并不简单。它利用输出缓冲和内存对齐来提升性能。重新实现 Unix 工具很有趣,也让我更加体会到那些让计算机飞速运转的精巧技巧。
随机一篇博客
评论
登录后参与讨论