A Little Story About the `yes` Unix Command

Matthias Endler

`yes` 유닉스 명령어에 대한 작은 이야기

당신이 아는 가장 단순한 유닉스 명령어는 무엇인가요?
문자열을 표준 출력으로 찍어내는 echo가 있고, 항상 종료 코드 0으로 종료되는 true가 있죠.

이처럼 단순한 유닉스 명령어들 중에는 yes도 있어요. 인자 없이 실행하면 개행 문자로 구분된 y의 무한 스트림이 쏟아집니다.

y
y
y
y
(...you get the idea)

처음에는 쓸모없어 보이던 것이 알고 보면 꽤 유용합니다.

yes | sh boring_installation.sh

계속 진행하려면 “y”를 입력하고 엔터를 눌러야 하는 프로그램을 설치해 본 적 있나요? 그럴 땐 yes가 구원투수죠! yes가 충실히 제 역할을 해주는 동안 여러분은 Pootie Tang이나 계속 보고 있으면 됩니다.

yes 만들기

먼저… 음… BASIC으로 작성한 가장 기본적인 버전입니다.

10 PRINT "y"
20 GOTO 10

그리고 같은 걸 파이썬으로 작성하면 이렇게 됩니다.

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 메뉴얼 페이지에서 배웠어요.
  • unwrap_or를 사용해 파라미터에서 expletive를 가져옵니다. 파라미터가 없으면 기본값으로 “y”를 사용하죠.
  • 기본 파라미터는 into()를 이용해 문자열 슬라이스(&str)에서 힙에 할당된 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]

어라, 전혀 나아지지 않았네요. 파이썬 버전보다도 더 느려요! 호기심이 생겨 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 서브레딧에 올라오자마자 사용자 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 프로그램은 알고 보니 전혀 사소하지 않았어요. 성능을 높이기 위해 출력 버퍼링과 메모리 정렬을 활용하고 있었죠. 유닉스 도구를 직접 다시 구현해 보는 건 재미있고, 우리 컴퓨터를 빠르게 만드는 영리한 요령들에 새삼 감사하게 됩니다.

원문은 Matthias Endler님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.