A Little Story About the `yes` Unix Command

Matthias Endler

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

원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

당신이 아는 가장 간단한 유닉스 명령어는 무엇인가요?
echo는 문자열을 stdout으로 출력하고, true는 항상 종료 코드 0으로 종료됩니다.

단순한 유닉스 명령어들 중에는 yes도 있습니다. 인자 없이 실행하면 줄바꿈으로 구분된 무한한 y 스트림이 쏟아집니다:

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

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

yes | sh boring_installation.sh

계속 진행하려면 “y”를 입력하고 엔터를 눌러야 하는 프로그램을 설치해 본 적 있나요? 그럴 때 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]

그래서 러스트로 더 빠른 버전을 만들어 보기로 했습니다. 첫 번째 시도는 이렇습니다:

use std::env;

fn main() {
  let expletive = env::args().nth(1).unwrap_or("y".into());
  loop {
    println!("{}", expletive);
  }
}

간단히 설명하자면:

  • 루프 안에서 출력하려는 문자열은 첫 번째 커맨드라인 인수이며 이름은 expletive입니다. 이 단어는 yes 맨페이지에서 배웠습니다.
  • expletive를 파라미터에서 가져오기 위해 unwrap_or를 사용합니다. 파라미터가 없을 경우 기본값으로 “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일 켄 톰프슨이 작성한 것으로 유명합니다:

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바이트에서 더 좋은 성능이 나왔습니다.

러스트 프로그램을 확장해 봤습니다:

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에는 한참 못 미칩니다.

업데이트

역시나 러스트 커뮤니티는 실망시키지 않았습니다.
이 게시물이 러스트 서브레딧에 올라오자마자, 사용자 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는 알고 보니 전혀 사소하지 않았습니다. 성능을 높이기 위해 출력 버퍼링과 메모리 정렬을 활용합니다. 유닉스 도구를 다시 구현해 보는 건 재미있고, 우리 컴퓨터를 빠르게 만드는 기발한 트릭들에 새삼 감사하게 됩니다.

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

댓글