Unixコマンド `yes` にまつわる小さな物語
原文は Matthias Endler により に公開されました。 このブログを購読する
あなたが知っている一番シンプルなUnixコマンドは何だろう?
文字列を標準出力に出力するechoや、常に終了コード0で終了するtrueがある。
そんなシンプルなUnixコマンドの仲間にyesもある。引数なしで実行すると、改行で区切られた無限の「y」のストリームが得られる。
y
y
y
y
(...you get the idea)一見すると無意味に思えるが、実はかなり役に立つ。
yes | sh boring_installation.sh「y」と入力してEnterを押さないと先に進まないプログラムをインストールしたことはないだろうか?そんなときこそ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」を使う。 - デフォルトのパラメータは
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とともにリリースされ、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);
}これはもう次元が違う!
- ループごとに再利用される、内容が詰められた文字列バッファをあらかじめ用意しておく。
- 標準出力はロックで保護されている。そのため、取得と解放を繰り返すのではなく、ずっと保持し続ける。
- 不要なアロケーションを避けるために、プラットフォームネイティブな
std::ffi::OsStringとstd::borrow::Cowを使っている。
私が貢献できたのは、不要なmutを取り除いたことだけだった。😅
学んだこと
一見些細なプログラムに見えるyesも、実はそう単純ではないことがわかった。パフォーマンス向上のために出力のバッファリングやメモリアライメントを活用しているのだ。Unixツールを再実装するのは楽しく、コンピュータを速くしている巧妙な仕掛けへの感謝が深まる。
記事をランダムに読む
コメント
ログインしてコメントする