fastcat - splice를 활용한 더 빠른 `cat` 구현
많은 분들이 잘 알려진 유닉스 명령어의 내부 동작에 대해 글을 하나 더 써 달라고 하셨어요. 사실 아무도 요청한 적은 없지만, 도입부로는 꽤 그럴듯하잖아요. yes와 ls에 대한 이전 글들은 분명 읽어보셨을 거예요 — 정말 대단한 글이었죠.
어쨌든 오늘은 파일을 이어 붙이는 데 쓰이는 cat에 대해 이야기해 보려고 해요. 물론 대부분은 파일 내용을 화면에 출력하는 용도로 더 많이 남용하고 있지만요.
# Concatenate files, the intended purpose
cat input1.txt input2.txt input3.txt > output.txt
# Print file to screen, the most common use-case
cat myfilecat 구현하기
다음은 Ruby로 간단하게 만든 cat이에요:
#!/usr/bin/env ruby
def cat(args)
args.each do |arg|
IO.foreach(arg) do |line|
puts line
end
end
end
cat(ARGV)이 프로그램은 각 파일을 순회하면서 한 줄씩 내용을 출력해요. 정말 간단하죠! 그런데 이 도구는 과연 얼마나 빠를까요?
벤치마크를 위해 2GB짜리 무작위 파일을 하나 빠르게 만들었어요.
멋진 pv(Pipe Viewer) 도구를 이용해 저희가 만든 간단한 구현과 시스템 기본 구현의 속도를 비교해 볼게요. 모든 테스트는 웜 캐시(파일이 메모리에 올라와 있는 상태)에서 5회 실행한 평균값이에요.
# Ruby 2.5.1
> ./rubycat myfile | pv -r > /dev/null
[196MiB/s]나쁘진 않은 것 같죠? 그럼 시스템에 있는 cat과 비교하면 어떨까요?
cat myfile | pv -r > /dev/null
[1.90GiB/s]어라, GNU cat이 저희가 만든 작은 Ruby cat보다 10배나 빠르네요. 💎🐈🐌
Ruby cat을 조금 더 빠르게 만들어 보기
간단하게 짠 Ruby 코드도 조금만 손보면 개선할 수 있어요. 알고 보니 라인 버퍼링이 결국 성능을 떨어뜨리더라고요1:
#!/usr/bin/env ruby
def cat(args)
args.each do |arg|
IO.copy_stream(arg, STDOUT)
end
end
cat(ARGV)rubycat myfile | pv -r > /dev/null
[1.81GiB/s]와… 저희는 별로 힘도 들이지 않았는데, 1971년부터 최적화가 이어져 온 도구의 속도에 거의 다다랐어요. 🎉
하지만 너무 일찍 기뻐하지 말고, 혹시 더 빠르게 만들 수 있을지 한번 살펴볼게요.
Splice
제가 cat에 대해 글을 쓰게 된 원래 계기는 Hacker News에서 사용자 wahern이 남긴 이 댓글이었어요:
GNU yes나 GNU cat이 splice(2)를 사용하지 않는 게 놀랍네요.
이 splice라는 것이ファイルを 출력하는 속도를 더 빠르게 만들 수도 있을까요? — 호기심이 생겼어요.
splice는 2006년에 리눅스 커널에 처음 도입됐어요. Linus Torvalds 본인이 정리한 요약도 있지만, 저는 매뉴얼 페이지에 나온 설명을 더 좋아해요:
splice()는 커널 주소 공간과 사용자 주소 공간 사이에서 복사를 하지 않고 두 파일 디스크립터 사이에서 데이터를 이동시켜요.
fd_in파일 디스크립터에서fd_out파일 디스크립터로 최대len바이트까지 데이터를 전송하는데, 이때 두 파일 디스크립터 중 하나는 반드시 파이프를 가리켜야 해요.
더 깊이 파고들고 싶으시다면 리눅스 커널의 해당 소스 코드를 참고하시면 되지만, 지금은 모든 세부 사항을 알 필요는 없어요. 대신 C 구현의 헤더만 살펴볼게요:
#include <fcntl.h>
ssize_t splice (int fd_in, loff_t *off_in, int fd_out,
loff_t *off_out, size_t len,
unsigned int flags);좀 더 쉽게 풀어서 설명하면, 전체 src 파일을 dst로 복사하는 방법은 이렇게 생겼어요:
const ssize_t r = splice (src, NULL, dst, NULL, size, 0);이 방식의 멋진 점은 모든 과정이 리눅스 커널 내부에서 일어난다는 거예요. 즉, 사용자 공간(저희 프로그램이 실행되는 곳)으로 데이터를 한 바이트도 복사하지 않는다는 뜻이죠. 이상적으로 splice는 페이지를 재매핑하는 방식으로 동작해서 실제로 데이터를 복사하지 않기 때문에 I/O 성능이 향상될 수 있어요(참고).

Rust에서 splice 사용하기
고백하자면 저는 C 프로그래머가 아니에요. 그래서 더 안전한 인터페이스를 제공하는 Rust를 선호해요. Rust에서는 같은 기능을 이렇게 쓸 수 있어요:
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn splice(
fd_in: RawFd,
off_in: Option<&mut libc::loff_t>,
fd_out: RawFd,
off_out: Option<&mut libc::loff_t>,
len: usize,
flags: SpliceFFlags,
) -> Result<usize>참고로 리눅스 바인딩을 직접 구현한 건 아니에요. *nix API에 대해 Rust 친화적인 바인딩을 제공하는 nix라는 라이브러리를 그냥 사용했어요.
다만 한 가지 주의할 점이 있어요. splice는 파일 디스크립터 중 하나가 파이프여야 하기 때문에 파일을 표준 출력으로 바로 복사할 수는 없어요. 그래서 파이프를 하나 만들어요. 파이프는 읽는 쪽과 쓰는 쪽(rd와 wr)으로 구성되는데, 파일을 쓰는 쪽으로 파이프한 뒤 파이프에서 읽어 표준 출력으로 밀어 넣는 방식이에요.
성능 향상을 위해 16384바이트(214) 크기의 비교적 큰 버퍼를 사용한 걸 보실 수 있을 거예요.
extern crate nix;
use std::env;
use std::fs::File;
use std::io;
use std::os::unix::io::AsRawFd;
use nix::fcntl::{splice, SpliceFFlags};
use nix::unistd::pipe;
const BUF_SIZE: usize = 16384;
fn main() {
for path in env::args().skip(1) {
let input = File::open(&path).expect(&format!("fcat: {}: No such file or directory", path));
let (rd, wr) = pipe().unwrap();
let stdout = io::stdout();
let _handle = stdout.lock();
loop {
let res = splice(
input.as_raw_fd(),
None,
wr,
None,
BUF_SIZE,
SpliceFFlags::empty(),
).unwrap();
if res == 0 {
// We read 0 bytes from the input,
// which means we're done copying.
break;
}
let _res = splice(
rd,
None,
stdout.as_raw_fd(),
None,
BUF_SIZE,
SpliceFFlags::empty(),
).unwrap();
}
}
}그럼 이건 얼마나 빠를까요?
fcat myfile | pv -r > /dev/null
[5.90GiB/s]헉. 이건 시스템 cat보다 세 배 이상 빠르네요.
운영체제 지원 현황
- Linux와 Android에서는 완전히 지원돼요.
- OpenBSD에도
sosplice라는 일종의 splice 구현이 있어요. 저는 직접 테스트해보진 않았어요. - macOS에서는 splice와 가장 비슷한 것이 그 상위 호환 격인 sendfile인데, 커널 안에서 파일을 소켓으로 전송할 수 있어요. 아쉽게도 파일에서 파일로의 전송은 지원하지 않아요.2
copyfile이라는 것도 있는데 인터페이스는 비슷하지만, 아쉽게도 제로 카피가 아니에요. (처음에는 제로 카피인 줄 알았는데, 제 착각이었어요.) - Windows는 파일 간 제로 카피 전송을 제공하지 않아요(파일을 소켓으로 전송하는 TransmitFile API만 있어요).
그럼에도 실제 제품 수준의 구현이라면, 지원하는 시스템에서는 splice를 활성화하고 그렇지 않은 곳에서는 일반적인 구현을 대체 수단으로 사용하면 될 거예요.
좋긴 한데, 그래서 이게 어디에 쓸모가 있을까요?
저도 잘 모르겠어요. 아마 필요 없으실 거예요. 병목이 다른 곳에 있을 가능성이 크니까요. 그래도 많은 분들이 다음과 같이 cat을 이용해 데이터를 다른 프로세스로 파이프하곤 하죠.
# Count all lines in C files
cat *.c | wc -l또는
cat kittens.txt | grep "dog"이럴 때 cat이 병목이라고 느껴지시면 fcat을 한번 써 보세요(다만 그전에 cat 자체를 아예 안 쓰는 방법을 먼저 고민해 보세요).
조금만 더 손보면, fcat은 netcat과 비슷하게 한 네트워크 카드에서 다른 네트워크 카드로 패킷을 직접 라우팅하는 데에도 쓸 수 있을 거예요.
배운 점
- 베어 메탈에 가까워질수록 우리가 어렵게 쌓아 올린 추상화는 무너지고, 다시 로우 레벨 시스템 프로그래밍으로 돌아가게 돼요.
- 빠른 cat 말고도 느린 cat이 필요한 경우도 있어요. 바로 오래된 컴퓨터를 위해서죠. 그런 용도로는 — 짐작하셨겠지만 — slowcat이라는 게 있어요.
그래도 리눅스에서 GNU cat이 왜 splice를 사용하지 않는지는 여전히 모르겠어요. 🤔 fcat의 소스 코드는 Github에 있어요. 기여는 언제나 환영이에요!
각주
1. 이 코드를 더 관용적으로 다듬어 주신 독자 Freeky님께 감사드려요.↩
2. 힌트를 주신 독자 masklinn님께 감사드려요.↩
글을 무작위로 읽기