關於 Unix 指令 `yes` 的小故事
原文由 Matthias Endler 于 發布,訂閱此部落格
你知道最簡單的 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 上的自有字串(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、在 Github 上鏡像的 128 行版本比較一下。經過了 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 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();
}
}重點是,緩衝區大小是 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::OsString和std::borrow::Cow來避免不必要的配置。
我唯一能貢獻的,就是移除了不必要的 mut。😅
心得
看似微不足道的程式 yes,結果證明一點也不簡單。它運用了輸出緩衝和記憶體對齊來提升效能。重新實作 Unix 工具很有趣,也讓我更珍惜那些讓電腦變得如此快速的巧妙技巧。
隨機一篇部落格
留言
登入後參與討論