A Little Story About the `yes` Unix Command

Matthias Endler

关于 Unix 命令 `yes` 的一个小故事

你所知道的最简单的 Unix 命令是什么?
echo,它会把字符串打印到 stdout;还有 true,它总是以退出码 0 终止。

在这一系列简单的 Unix 命令中,还有 yes。如果不带参数执行它,你会得到由换行分隔的无限个 y:

y
y
y
y
(……你明白我的意思了)

起初看起来毫无用处的东西,后来却相当有帮助:

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 的 manpage 中学到这个词的。
  • 我使用 unwrap_or 从参数中获取 expletive。如果没有设置该参数,就使用“y”作为默认值。
  • 默认参数通过 into() 从字符串切片(&str)转换成堆上的拥有所有权的字符串(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 subreddit后不久,用户 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);
}

这可完全是另一回事了!

  • 我们准备好一个填充完毕的字符串缓冲区,在每次循环中重复使用。
  • Stdout 受锁保护。因此,我们不再不断获取和释放它,而是始终持有它。
  • 我们使用平台原生的 std::ffi::OsStringstd::borrow::Cow,以避免不必要的分配。

我唯一能做的贡献,就是移除了一个多余的 mut。😅

经验总结

看似简单的程序 yes,最终证明并没有那么简单。它利用输出缓冲和内存对齐来提升性能。重新实现 Unix 工具很有趣,也让我更加欣赏那些让计算机运行得更快的巧妙技巧。

原文由 Matthias Endler 发布

本文章由 openai/gpt-5.6-luna 进行翻译