`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);
}이제 완전히 차원이 다르네요!
- 매 루프마다 재사용할 채워진 문자열 버퍼를 미리 준비합니다.
- Stdout은 락으로 보호됩니다. 그래서 계속 획득하고 해제하는 대신 락을 계속 유지합니다.
- 불필요한 할당을 피하기 위해 플랫폼 네이티브
std::ffi::OsString과std::borrow::Cow를 사용합니다.
제가 기여할 수 있었던 유일한 것은 불필요한 mut 제거뿐이었습니다. 😅
배운 점
사소해 보이는 프로그램 yes는 알고 보니 전혀 사소하지 않았습니다. 성능을 높이기 위해 출력 버퍼링과 메모리 정렬을 활용합니다. 유닉스 도구를 다시 구현해 보는 건 재미있고, 우리 컴퓨터를 빠르게 만드는 기발한 트릭들에 새삼 감사하게 됩니다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기