A Little Story About the `yes` Unix Command

Matthias Endler

關於 Unix 指令 `yes` 的小故事

你知道最簡單的 Unix 指令是什麼嗎?
echo,會把字串印到 stdout,還有 true,永遠會以結束代碼 0 結束。

在一系列簡單的 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 的 manpage 學來的。
  • 我使用 unwrap_or 來從參數中取得 expletive。如果參數沒有設定,我們就使用「y」作為預設值。
  • 預設參數會透過 into() 從字串切片(&str)轉換為位於堆積(heap)上的自有(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;

啊哈!所以他們只是使用 buffer(緩衝區)來讓寫入操作更快。緩衝區大小由一個名為 BUFSIZ 的常數定義,該常數在每個系統上都會被選擇成讓 I/O 更有效率(參見這裡)。在我的系統上,它被定義為 1024 bytes。我實際上使用 8192 bytes 時效能更好。

我擴充了我的 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();
    }
}

重點是,buffer 的大小是 4 的倍數,以確保memory alignment(記憶體對齊)

執行這個版本讓我達到了 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 程式,結果證明一點也不簡單。它使用 output buffering(輸出緩衝) 和 memory alignment 來提升效能。重新實作 Unix 工具很有趣,也讓我更加欣賞那些讓電腦變快的巧妙技巧。

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯