A Little Story About the `yes` Unix Command

Matthias Endler

`yes`コマンドについてのちょっとした話

知っている中で、いちばん単純なUnixコマンドは何でしょう?
文字列をstdoutに出力するechoや、常に終了コード0で終了するtrueがあります。

単純なUnixコマンドの仲間には、yesもあります。引数なしで実行すると、改行で区切られた「y」が無限に出力されます。

y
y
y
y
(...もうお分かりですね)

最初は無意味に見えますが、実はかなり便利です。

yes | sh boring_installation.sh

続行するために「y」を入力してEnterキーを押さなければならないプログラムをインストールしたことはありませんか?そんなときこそyesの出番です!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のmanページで覚えました。
  • 引数からexpletiveを取得するためにunwrap_orを使っています。引数が指定されていない場合は、デフォルトとして「y」を使います。
  • デフォルト値の文字列スライス(&str)は、into()を使ってヒープ上の所有文字列(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とともにリリースされたこのプログラムの最初期のバージョンです。1979年1月10日にKen Thompsonが書いたものとして有名です。

main(argc, argv)
char **argv;
{
  for (;;)
    printf("%s\n", argc>1? argv[1]: "y");
}

ここには魔法のようなものは何もありません。

それを、GitHubにミラーされているGNU coreutils版の128行の実装と比べてみてください。25年経った今も、活発に開発が続いているのです!最後にコードが変更されたのは約1年前です。かなり速いですね。

# 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);
}

これはもう、まったく別物です!

私が貢献できたのは、不要なmut削除したことだけでした。😅

学んだこと

単純なプログラムであるyesは、結局のところ、それほど単純ではありませんでした。出力バッファリングとメモリアライメントを使って、性能を向上させています。Unixツールを再実装するのは楽しいですし、コンピューターを高速に動かしている巧妙な工夫にも気づかされます。

原文は Matthias Endler により に公開されました。

この記事は「gpt-5.6-luna」を使用して翻訳されました。