[Перевод] Небольшая история о команде `yes` в Unix
Какую вы знаете самую простую команду Unix? Есть echo
, которая печатает строку в stdout, и есть true
, которая ничего не делает, а только завершается с нулевым кодом.
Среди множества простых Unix-команд спряталась команда yes
. Если запустить её без аргументов, то вы получите бесконечный поток символов «y», каждый с новой строки:
y
y
y
y
(...ну вы поняли мысль)
Хотя на первый взгляд команда кажется бессмысленной, но иногда она бывает полезной:
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]
Сравните со встроенной версией на моём «маке»:
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
. - Я использую
unwrap_or
, чтобы получить expletive из параметров. Если параметры не установлены, по умолчанию используется «y». - Параметр по умолчанию конвертируется из строкового фрагмента (
&str
) вowned()
в куче (String
) при помощиinto()
.
Протестируем.
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 за почётным авторством Кена Томпсона 10 января 1979 года:
main(argc, argv)
char **argv;
{
for (;;)
printf("%s\n", argc>1? argv[1]: "y");
}
Никакой магии.
Сравним со 128-строчной версией из комплекта GNU coreutils, зеркало которого есть на 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
, которая выбирается для каждой системы, чтобы максимально оптимизировать операции ввода-вывода (см. здесь). На моей системе она была установлена как 1024 байта. В реальности лучшая производительность оказалась при 8192 байтах.
Я расширил свою программу Rust:
use std::io::{self, 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();
}
}
Здесь важно, чтобы размер буфера делился на четыре, это гарантирует выравнивание в памяти.
Такая программа выдаёт 51,3 МиБ/с. Быстрее, чем версия, установленная в моей системе, но намного медленнее чем вариант от автора найденного мной поста на Reddit. Он говорит, что добился скорости 10,2 ГиБ/с.
Дополнение
Как обычно, сообщество Rust не подкачало. Как только эта статья попала в подреддит о Rust, пользователь nwydo указал на предыдущее обсуждение этой темы. Вот их оптимизированный код, который пробивает 3 ГБ/с на моей машине:
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 {
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 — увлекательное занятие и оно заставляет ценить те изящные трюки, которые делают наши компьютеры быстрыми.