파이프라인이 가끔 "멈추는" 이유: 버퍼링 문제
(jvns.ca)tail -f /some/log/file | grep thing1 | grep thing2처럼 느리게 들어오는 출력을 여러 명령으로 잇는 파이프라인은 실제로 멈춘 것이 아니라, 중간 명령이 출력을 버퍼에 쌓아두기 때문에 비어 보일 수 있음grep과 많은 프로그램은 stdout이 터미널인지isatty로 확인하고, 터미널이면 라인 버퍼링, 파이프나 파일이면 대략 8KB 단위 블록 버퍼링을 사용함tail,cat,tee는 출력 버퍼링을 하지 않는 예지만,grep --line-buffered,sed -u,tcpdump -l,jq -u,tr -u처럼 버퍼링 완화 옵션은 명령마다 다름Ctrl-C로 파이프라인을 끊으면tcpdump같은 프로그램의 버퍼에 있던 출력이 사라질 수 있으며,kill -TERM $PID로 종료하면 버퍼가 플러시되어 출력이 보일 수 있음- 실무적 해법은 빠르게 끝나는 명령으로 바꾸기,
grep --line-buffered, 단일awk나 복잡한grep,stdbuf,unbuffer등이지만 각각 동작 조건과 부작용을 확인해야 함
파이프가 멈춘 것처럼 보이는 이유
- 로그 파일에 줄이 천천히 추가될 때 다음 파이프라인은 매칭 결과가 있어도 출력이 안 보일 수 있음
tail -f /some/log/file | grep thing1 | grep thing2
- 원인은 파이프 자체가 아니라, 중간의
grep thing1이 결과를 즉시 쓰지 않고 버퍼에 저장하기 때문임 - 프로그램이 매번 바로 쓰면 시스템 호출이 늘어나므로, 성능을 위해 어느 정도 데이터를 모은 뒤 파이프나 파일에 씀
- 이 예에서는
grep thing1이 대략 8KB 정도의 출력이 쌓일 때까지 기다릴 수 있고, 느린 로그에서는 그 조건이 사실상 영원히 오지 않을 수 있음
터미널과 파이프에서 달라지는 출력 방식
tail -f file | grep thing은 잘 동작하지만, 뒤에 두 번째grep을 붙이면 출력이 멈춘 것처럼 보일 수 있음grep과 많은 프로그램은 stdout이 터미널인지isatty함수로 확인함- stdout이 터미널이면 라인 버퍼링을 사용해 줄 단위로 즉시 출력함
- stdout이 파이프나 파일이면 블록 버퍼링을 사용해 일정 크기 이상 데이터가 쌓일 때 출력함
- 따라서
grep이 터미널에 직접 쓰면 줄이 바로 보이지만, 다음 명령으로 이어지는 파이프에 쓰면 보이지 않을 수 있음 - 버퍼 크기는 프로그램마다 다름
grep은 libc가 버퍼링을 처리하고, libc의 크기는BUFSIZ변수로 정의됨- glibc의 정의 위치는 stdio.h에 있음
- 터미널에 쓸 때 8KB 출력 버퍼를 쓰지 않는 것은 물리 법칙이 아니며, 프로그램이 원하면 그렇게 구현할 수 있지만 매우 이상한 동작에 가까움
명령마다 다른 버퍼링 동작
- 출력 버퍼링이 까다로운 이유는 어떤 명령이 파이프 출력에서 버퍼링하는지 사용자가 기억해야 하기 때문임
- 출력 버퍼링을 하지 않는 명령 예시는 다음과 같음
tailcattee
- 파이프에 쓸 때 출력 버퍼링을 하는 흔한 명령과 완화 방법은 다음과 같음
grep:--line-bufferedsed:-uawk:fflush()함수tcpdump:-ljq:-utr:-ucut: 버퍼링 비활성화 불가
sort처럼 입력을 모두 받은 뒤에야 작업할 수 있는 명령은 버퍼링 여부가 실질적으로 중요하지 않음- Mac OS와 GNU 버전을 모두 테스트하려 했지만, 변형이 많아 일부 실수가 있을 수 있음
프로그래밍 언어의 기본 출력도 버퍼링함
- 일부 프로그래밍 언어의 기본
print출력도 파이프에 쓸 때 버퍼링함 - 언어별 비활성화 방법은 다음과 같음
- C:
setvbuf - Python:
python -u,PYTHONUNBUFFERED=1,sys.stdout.reconfigure(line_buffering=False),print(x, flush=True) - Ruby:
STDOUT.sync = true - Perl:
$| = 1
- C:
- 이런 기본 동작은 배치 처리에서 기본 출력 함수를 빠르게 만들기 위한 설계로 보임
- 출력 방법에 따라 버퍼링 여부가 달라질 수 있음
- C++에서
cout << "hello\n"는 파이프에 쓸 때 버퍼링함 cout << "hello" << endl은 출력을 플러시함
- C++에서
Ctrl-C와 파일 리다이렉션에서 생기는 차이
- 다음처럼
tcpdump출력을grep에 연결하고-l을 빠뜨리면 출력이 버퍼에 남을 수 있음sudo tcpdump -ni any port 53 | grep example.com
- 이상적으로는
Ctrl-C를 눌렀을 때tcpdump가 버퍼를 플러시하고grep이 검색해 누락된 출력이 보이기를 기대할 수 있음 - 실제로는 프로그램들이 종료되면서
tcpdump버퍼에 있던 출력이 손실됨 strace로 확인했을 때grep이tcpdump보다 먼저SIGINT를 받으므로,tcpdump가 플러시하려 해도grep은 이미 죽어 있을 수 있음- 우회 방법으로
tcpdump의 PID를 찾아kill -TERM $PID를 실행하면tcpdump가 버퍼를 플러시해 출력이 보일 수 있음 - 파일 리다이렉션도 버퍼링함
sudo tcpdump -ni any port 53 > output.txt
- 다만 파일 리다이렉션은
Ctrl-C가 버퍼 내용을 완전히 날리는 문제와 다르게, 경험상 프로그램 종료 전에 버퍼 내용이 파일에 쓰이는 경우가 많음 - 이 동작을 항상 신뢰할 수 있는지는 확실하지 않음
버퍼링을 피하는 다섯 가지 방법
-
빠르게 끝나는 프로그램으로 바꾸기
- 느리게 파이프에 쓰는 상황 자체를 피하고, 빠르게 끝나는 명령으로 바꿀 수 있음
- 예시는 다음과 같음
cat /some/log/file | grep thing1 | grep thing2 | tail
- 원래
tail -f명령과 같은 동작은 아니지만, 복잡한 버퍼링 문제를 피할 수 있음
-
grep의 라인 버퍼 옵션 사용grep에는 버퍼링을 피하는 플래그가 있음- 예시는 다음과 같음
tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
-
awk나 더 복잡한grep으로 합치기- 여러
grep을 쓰는 상황은 단일awk로 바꿀 수 있음tail -f /some/log/file | awk '/thing1/ && /thing2/'
- 또는 더 복잡한 정규식의
grep으로 쓸 수 있음tail -f /some/log/file | grep -E 'thing1.*thing2'
awk도 버퍼링하므로, 이 방법이 동작하려면awk가 파이프라인의 마지막 명령이어야 함
- 여러
-
stdbuf사용stdbuf는LD_PRELOAD를 사용해 libc의 버퍼링을 끔- 출력 버퍼링을 끄는 예시는 다음과 같음
tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
LD_PRELOAD기반 해법처럼 신뢰성이 제한됨- 정적 바이너리에서는 동작하지 않음
- 프로그램이 libc 버퍼링을 쓰지 않으면 동작하지 않을 수 있음
- Mac OS에서 항상 동작하지 않음
- 관련 설명으로 Harry Marr의 How stdbuf works가 있음
-
unbuffer사용unbuffer program은 프로그램 출력이 TTY인 것처럼 강제해, 일반 TTY에서처럼 덜 버퍼링하고 색상 출력 등을 사용하게 함- 예시는 다음과 같음
tail -f /some/log/file | unbuffer grep thing1 | grep thing2
stdbuf와 달리 항상 동작하지만, 원치 않는 부작용이 있을 수 있음- 예를 들어
grep thing1이 매칭 결과에 색상을 넣을 수 있음
- 예를 들어
unbuffer는expect패키지에 들어 있음
주로 문제가 드러나는 상황과 환경 변수 아이디어
- 이런 문제는 파이프에 데이터를 천천히 흘려보내는 프로그램에서 주로 나타남
- 예시는 다음과 같음
tcpdumptail -fkubectl logs같은 방식의 로그 감시- 느린 계산의 출력
- Python의
PYTHONUNBUFFERED처럼 버퍼링을 끄는 표준 환경 변수가 있으면 좋을 수 있음 - Mark Dominus의 2018년 블로그 글과 후속 글에서 이 아이디어를 얻음
- 이름 예시로는
NO_COLOR처럼NO_BUFFER가 가능할 수 있음 - 설계는 까다로움
- NETBSD에는 많은 제어를 제공하는
STDBUF,STDBUF1등 환경 변수가 있음 - 대부분의 개발자가 비교적 작은 엣지 케이스를 위해 여러 환경 변수를 구현하고 싶어 하지는 않을 수 있음
- NETBSD에는 많은 제어를 제공하는
- 출력 버퍼를 1초 같은 주기로 자동 플러시하는 프로그램이 있는지도 궁금하지만, 그런 프로그램은 떠오르지 않으며 단점이 있을 수 있음
다루지 않은 범위
- 라인 버퍼링과 완전 무버퍼 출력의 차이는 제외됨
- stderr 버퍼링과 stdout 버퍼링의 차이는 제외됨
- 이 내용은 프로그램 내부에서 발생하는 버퍼링만 다룸
- 운영체제의 TTY 드라이버도 때때로 약간의 버퍼링을 함
- 파이프에 쓰는 상황 외에 출력을 플러시해야 하는 다른 이유는 제외됨
댓글과 토론
Hacker News 의견들
-
버퍼링된 접근은 거의 항상 바이트 임계값에 도달하거나, 최소 1바이트라도 있으면 일정 시간이 지난 뒤 플러시하는 “임계값 또는 타임아웃” 방식이어야 함
비슷한 문제를 풀기 위해 하드웨어 인터페이스에서는 흔한 방식임
이 경우 사용자 공간에서 버퍼링하는 라이브러리가 데이터를 처음 버퍼에 넣을 때 적절한 타이머를 설정해야 함. 타임아웃 값은 인자로 받거나, 사람이 느끼기엔 짧은 1~100ms 정도로 잡거나,{대역폭 / 임계값}에 비례시키거나, 시스템 호출 오버헤드가 전체 시간의 0.1%를 넘지 않게 잡는 식이 가능함
이 방식은 쓰기뿐 아니라 읽기에도 적용됨. 묶음 읽기나 병합 읽기를 한다면 비슷한 방식이 필요하지만, 효율적으로 “대기 중인 데이터”를 조회하거나 알림받는 방법이 데이터 채널에 있어야 해서 채널 설계에 더 좌우됨. 하드웨어에서는 인터럽트 병합 같은 방식이 흔함- 이 방향이 맞다고 보지만, libc가 자동 타이머를 설정하면 기대 동작이 바뀌어서 까다로운 문제가 많이 생김
입출력 오류가 쓰기 시점뿐 아니라 아무 때나 발생할 수 있고, 프로그램이 직접 타이머를 둔 곳이나 신호가 도착한 곳뿐 아니라 여러 시스템 호출이 타이머에 의해 중단될 수 있음
애플리케이션과 libc가 둘 다 타이머를 설정하면 혼란이 생길 가능성도 있음. 요즘 커널 타이머 API가 예전 기억보다 나아 보여서 덜 관련 있을 수도 있지만, 애플리케이션이 중요한 구간에서 신호를 잠시 막으면 입출력 타이머에도 영향을 줌
신호 처리 시점과 방식 때문에 입출력 구조체 접근을 더 조심해야 함 - 그런 타임아웃을 투명하게 처리하는 건 POSIX와 ISO C 제약 아래에서는 까다로울 듯함. 애플리케이션 계층의 협력이 어느 정도 필요해 보임
- 일반적인 Linux 알람은 신호 기반이라 관리가 매우 어렵고, 재스케줄링하려면 커널로 들어가야 해서 성능 영향이 있을 수 있음
io_uring과 사용자 공간 타이머를 쓰면 훨씬 잘 확장되지만, 빠른 작은 쓰기를 많이 지원하려면 여전히 꼼수가 필요함. 예를 들어 초당 약 100만 번을 넘기면 타이머 관리 비용이 점점 보이기 시작하고, 초당 1억 쓰기까지 가려면 꽤 별난 기법이 필요했음 - 동의하기 어려움. 여기서 버퍼링은 원래 해야 할 일을 하고 있음
문제의 원인은 상호작용적이어야 하는 것과 상호작용을 전제로 하지 않은 계약이 섞인 데 있음. 예를 들면tail추적 출력을 파이프로 보내는 경우임
풀어야 할 실제 문제는 없다고 봄. 하드웨어 비유를 들자면 빗물을 모아두는 물탱크이고, 가득 찼을 때만 옮기는 구조임. 어떤 예를 염두에 둔 건지 모르겠지만, 아는 한 하드웨어에서 시간 기반 플러시가 흔하진 않음
제안된 수정은 계약을 훨씬 더 복잡하게 만듦 - 예측 가능한 footgun이 더 낫다고 봄. 아이디어는 좋지만 별도 플래그여야 하고, 그러면 그 존재를 알아야 함
문제는 의미론 자체보다 의미론을 모르는 것에 있음
- 이 방향이 맞다고 보지만, libc가 자동 타이머를 설정하면 기대 동작이 바뀌어서 까다로운 문제가 많이 생김
-
NIX 시스템을 20년 넘게 다뤄왔는데도 이런 일이 생긴다는 건 알고 있지만, 출력이 왜 안 나오나 한참 puzzling한 뒤에야 매번 떠올리게 됨
-
“최근 글들이 꽤 길어지고 있는데, 버퍼링에 대한 3000단어짜리 글을 정말 읽고 싶은 사람이 있겠냐”는 부분에 대해선, 개인적으로는 읽고 싶음
- 글에 따라 다름
장황한 글이 검색 최적화를 위한 군더더기인 경우도 있다고 봄 - 이런 부분에서는 AI 요약이 꽤 도움이 된다고 봄. 글을 요약하게 한 뒤 검토하면 됨
TLDR와 NTLDR, 즉 “길어도 읽었다” 섹션을 두는 식도 가능함
- 글에 따라 다름
-
시스템 전체 CPU가 유휴 상태가 될 때마다 모든 버퍼가 플러시되면 좋겠음
버퍼링은 대체로 CPU를 아끼는 기법임. CPU가 무한하다면 모든 버퍼는 1바이트가 될 것임. 버퍼는 효율을 위해 데이터를 모아 일괄 처리하는 방식임
하지만 CPU가 유휴 상태가 되면 “나중에 할 일”이 남아 있으면 안 됨. 커널 스케줄러가 유휴 상태가 되는 즉시 모든 프로세스에 버퍼를 플러시하라는 신호를 보내야 함- 흥미로운 아이디어임. 다만 모든 프로세스에 신호를 보내는 건 엄청 비싸 보임
그 작업을 다 해놓고 다시 버퍼 플러시를 위해 시스템 호출을 하게 만드는 셈임. 커널이 사용자 공간 버퍼를 알 수 있게 해서 유휴 상태일 때 거기서 직접 가져오는 메커니즘을 추가할 수 있을지도 모름
이게 어느 정도 io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff... 같은 건가 싶음 - 멋진 아이디어지만 한꺼번에 전부 하지는 않는 편이 나아 보임. 그리고 저전력 시스템에서는 추측적으로 잠든 프로세스를 깨워서 효율을 떨어뜨릴 수도 있음
- 흥미로운 아이디어임. 다만 모든 프로세스에 신호를 보내는 건 엄청 비싸 보임
-
이 글은 무버퍼와 줄 버퍼링이라는 서로 다른 두 가지를 혼동하고 있음
무버퍼는 불필요하게 성능을 나쁘게 만들고, 여러 소스가 같은 파이프에 쓰는 경우 잘못된 출력을 만들 수 있음. 충분히 긴 줄은 어차피 섞이겠지만, 현실의 대부분 출력 줄은 서식/제어 문자와 보조 평면 문자를 포함해도 4096바이트보다 짧음
줄 버퍼링은 터미널의 기본값이고, 파이프에서도 보통 원하는 동작임. 각 명령을stdbuf -oL -eL아래에서 실행하면 됨. 줄 안 갱신을 원하는 드문 프로그램들은 이미 수동 플러시를 해야 하므로 여기서도 올바르게 동작함
stdbuf가 실제로 하는 일은 다음처럼 볼 수 있음:
env -i \command -v stdbuf` -oL -eL `command -v env`` -
예전에 이 문제에 대해 글을 쓴 적이 있음: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
버퍼링하지 않는 명령에 대해서는 구현 의존적이거나,cat의 경우에는 틀렸을 수도 있음. https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c...와-u를 보면 됨. POSIX가 이를 관리할 공식 방법을 넣지 않은 건 큰 고통임
언급되지 않은 것으로 입력 버퍼링도 있고, 이런 이상한 결과를 냄:
$ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
v1=1
v2=
이 경우 해결책은stdbuf -i0 head -1을 쓰는 것임- 파이프나
socketpair같은 곳에서 읽는 프로세스가 쓰는 프로세스에 그런 제약을 강제할 수 있다고 보지 않음.ptrace()같은 무거운 해킹을 쓰는 경우는 제외함
파이프 버퍼 크기를 조정하는 건 가능할지 몰라도, 표준 C 입출력이 이를 따라야 한다는 관례는 모름
어쨌든 이 경우stdbuf는 도움이 안 되는 것 같음:
$ ./a | stdbuf -i0 -- cat
#include
#include
int main(void) {
for (;;) {
printf("n");
usleep(100000);
}
}
- 파이프나
-
버퍼가 있는 데에는 좋은 이유가 있음. 화면에 출력을 찍는 건 버퍼에 쓰는 것에 비해 상대적으로 매우 느림
문자를 하나씩 출력하는 건 엄청 비효율적임
오래된 문제이고, UART를 다룰 때 자주 마주침. 가능한 해결책은 여러 가지가 있음: 새 줄 같은 특수 문자로 출력 끝을 표시하는 줄 기반 방식, 8KB 같은 길이에 도달할 때까지 기다리는 길이 기반 방식, X밀리초마다 출력하는 시간 기반 방식
각 방식마다 장단점이 있고, 애플리케이션에 따라 무엇이 최적인지가 달라짐. 글에서 어떤 프로그램들이 버퍼링을 쓰지 않는다고 한 부분은 틀렸다고 봄. 그 프로그램들은 명백한 길이 기반 방식을 쓰지 않을 뿐임- 인터페이스보다 한두 계층 위에서 제약을 알고 있는 방식이 가능할 때 가장 잘 작동함
줄 기반 접근이 그런 방식이지만, 어떤 문자를 쓸지에 대한 합의가 필요함. 보통은 새 줄임 - 실제 쓰기를 백엔드에서 처리하는 비용만의 문제가 아님.
/dev/null에 그렇게 많은 시스템 호출을 하는 것만으로도 성능이 크게 죽을 수 있음
- 인터페이스보다 한두 계층 위에서 제약을 알고 있는 방식이 가능할 때 가장 잘 작동함
-
Unix를 35년 넘게 해왔지만 이게 어떻게 동작하는지 완전히 이해한 적은 없었음
여러 시스템과 구성 요소에 걸친 버퍼링 동작을 전체적으로 설명해줘서 좋았고, 확실히 배운 게 있음 -
“파이프에서 Ctrl-C를 누르면 버퍼 내용이 사라진다”는 부분에 대해, 대부분의 프로그램은 SIGINT에서 버퍼를 플러시할 것 같음
다만 셸에서 그렇게 동작하려면 파이프라인의 첫 번째 프로그램에만 SIGINT를 전달해야 할 텐데, 아마 실제 동작은 그렇지 않은 듯함- 마지막 프로세스가
sigint를 받고 나머지는sigpipe를 받는 것으로 기억함
- 마지막 프로세스가