Why pipes sometimes get "stuck": buffering

Julia Evans

파이프가 때때로 ‘멈춘 것처럼’ 보이는 이유: 버퍼링

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

몇 년 동안 나를 괴롭혔지만 몇 주 전까지 제대로 이해하지 못했던, 약간 까다로운 터미널 문제가 있다. 예를 들어 로그 파일에서 특정 출력이 나타나는지 확인하려고 이런 명령어를 실행한다고 해보자:

tail -f /some/log/file | grep thing1 | grep thing2

로그 파일에 줄이 비교적 천천히 추가되고 있다면, 내가 보게 되는 결과는… 아무것도 없다는 것이다! 로그 파일에 일치하는 내용이 있든 없든 상관없이, 출력이 전혀 나오지 않는다.

나는 이걸 그냥 “어, 파이프가 가끔 막혀서 출력을 안 보여주는구나, 이상하네”라고 넘겨버렸고, 대신 grep thing1 /some/log/file | grep thing2 처럼 실행해서 해결하곤 했다. 그러면 잘 동작했다.

그래서 지난 몇 달간 터미널을 깊게 파고들면서, 마침내 이 현상이 왜 일어나는지 정확히 알게 되어 정말 신났다.

왜 이런 일이 생기는가: 버퍼링

“파이프가 멈춘다”는 현상이 가끔 일어나는 이유는, 프로그램이 파이프나 파일에 쓰기 전에 출력을 버퍼링하는 것이 아주 흔하기 때문이다. 그러니까 파이프 자체는 정상적으로 동작하고 있는데, 프로그램이 애초에 데이터를 파이프에 쓰지 않은 것이 문제인 셈이다.

이는 성능상의 이유 때문이다. 출력이 생길 때마다 즉시 쓰는 것은 시스템 콜을 더 많이 쓰게 되므로, 8KB 정도의 데이터가 쌓일 때까지(혹은 프로그램이 종료될 때까지) 모아뒀다가 한 번에 파이프에 쓰는 것이 더 효율적이다.

이 예시에서는:

tail -f /some/log/file | grep thing1 | grep thing2

문제는 grep thing1이 8KB 분량의 데이터가 쌓일 때까지 일치하는 결과를 계속 모아두는데, 그 일이 실제로는 절대 일어나지 않을 수도 있다는 점이다.

터미널에 쓸 때는 프로그램이 버퍼링하지 않는다

내가 이 현상을 그토록 혼란스럽게 느꼈던 이유 중 하나는, tail -f file | grep thing은 완벽하게 동작하는데 두 번째 grep을 추가하는 순간 동작하지 않게 되기 때문이다!! 이유는 grep이 버퍼링을 처리하는 방식이 터미널에 쓰고 있는지 여부에 따라 달라지기 때문이다.

grep(그리고 다른 많은 프로그램)이 출력 버퍼링 방식을 결정하는 방법은 이렇다:

  • isatty 함수를 이용해 stdout이 터미널인지 확인한다
    • 터미널이라면 라인 버퍼링을 사용한다 (한 줄이 생기는 즉시 바로 출력한다)
    • 그렇지 않다면 “블록 버퍼링”을 사용한다 – 출력할 데이터가 최소 8KB 정도 쌓여야만 출력한다

그래서 grep이 터미널에 직접 쓰고 있다면 한 줄이 출력되자마자 바로 볼 수 있지만, 파이프에 쓰고 있다면 볼 수 없게 된다.

물론 버퍼 크기가 모든 프로그램에서 항상 8KB인 것은 아니고 구현에 따라 다르다. grep의 경우 버퍼링은 libc가 처리하며, libc의 버퍼 크기는 BUFSIZ 변수에 정의되어 있다. glibc에서 그 부분이 정의된 곳은 여기다.

(덧붙이자면, “프로그램은 터미널에 출력할 때 8KB 출력 버퍼를 사용하지 않는다”는 게 무슨 터미널 물리 법칙 같은 건 아니다. 프로그램이 원한다면 터미널에 출력할 때도 8KB 버퍼를 쓸 수는 있다. 다만 그렇게 한다면 굉장히 이상한 것이고, 그렇게 동작하는 프로그램은 떠오르지 않는다)

버퍼링하는 명령어와 버퍼링하지 않는 명령어

이 버퍼링 동작에서 짜증나는 점 중 하나는, 파이프에 쓸 때 어떤 명령어가 출력을 버퍼링하는지 일일이 기억해야 한다는 것이다.

출력을 버퍼링 하지 않는 몇몇 명령어:

  • tail
  • cat
  • tee

그 외의 거의 모든 명령어는 출력을 버퍼링할 거라고 보면 된다. 특히 배치 처리에 쓰일 가능성이 높은 명령어라면 더욱 그렇다. 파이프에 쓸 때 출력을 버퍼링하는 흔한 명령어들과, 블록 버퍼링을 끄는 플래그를 함께 정리해봤다.

  • grep (--line-buffered)
  • sed (-u)
  • awk (거기엔 fflush() 함수가 있다)
  • tcpdump (-l)
  • jq (-u)
  • tr (-u)
  • cut (버퍼링을 끌 수 없다)

내가 떠올릴 수 있는 건 이 정도가 전부다. sort 같은 많은 유닉스 명령어들도 출력을 버퍼링할 수도 있고 아닐 수도 있지만, 어차피 sort는 입력을 다 받을 때까지 아무 것도 할 수 없으니 별로 중요하지 않다.

그리고 Mac OS 버전과 GNU 버전 둘 다 최대한 테스트해보긴 했지만, 변형이 워낙 많아서 틀린 부분이 있을 수도 있다.

기본 “print” 문이 버퍼링되는 프로그래밍 언어

또, 기본 print 문이 파이프에 쓸 때 출력을 버퍼링하는 프로그래밍 언어 몇 가지와, 원한다면 버퍼링을 끄는 방법도 정리해봤다:

  • C (setvbuf로 비활성화)
  • Python (python -uPYTHONUNBUFFERED=1, 혹은 sys.stdout.reconfigure(line_buffering=False), 혹은 print(x, flush=True)로 비활성화)
  • Ruby (STDOUT.sync = true로 비활성화)
  • Perl ($| = 1로 비활성화)

이런 언어들이 이렇게 설계된 이유는, 배치 처리를 할 때 기본 print 함수가 빠르게 동작하도록 하기 위해서라고 생각한다.

또 출력이 버퍼링되는지 여부는 어떻게 출력하느냐에 따라 달라질 수도 있다. 예를 들어 C++에서 cout << "hello\n"은 파이프에 쓸 때 버퍼링되지만, cout << "hello" << endl은 출력을 플러시한다.

파이프에서 Ctrl-C를 누르면 버퍼 내용이 사라진다

example.com에 대한 DNS 요청을 지켜보는 임시방편으로 이 명령어를 실행했는데 tcpdump-l을 주는 걸 잊었다고 해보자:

sudo tcpdump -ni any port 53 | grep example.com

이때 Ctrl-C를 누르면 무슨 일이 일어날까? 마법 같은 완벽한 세상에서 내가 바라는 일은, tcpdump가 버퍼를 플러시하고 grepexample.com을 검색해서 놓쳤던 출력을 모두 보여주는 것이다.

하지만 현실에서는 모든 프로그램이 그냥 종료되고 tcpdump 버퍼에 있던 출력은 사라져 버린다.

이 문제는 아마 피할 수 없는 것 같다. strace로 동작 방식을 조금 살펴봤는데, 어차피 greptcpdump보다 먼저 SIGINT를 받으므로, tcpdump가 버퍼를 플러시하려고 해도 grep은 이미 죽은 상태다.

조금 더 조사해보니 우회 방법은 있다. tcpdump의 PID를 찾아서 kill -TERM $PID를 실행하면, tcpdump가 버퍼를 플러시해서 출력을 볼 수 있다. 좀 번거롭긴 하지만 테스트해보니 동작하는 것 같았다.

파일로 리다이렉션해도 버퍼링된다

파이프만의 문제가 아니다. 이것도 버퍼링된다:

sudo tcpdump -ni any port 53 > output.txt

다만 파일로 리다이렉션하는 경우에는 “Ctrl-C가 버퍼 내용을 완전히 날려버린다”는 문제가 생기지 않는다. 내 경험상 보통은 프로그램이 종료되기 전에 버퍼 내용이 파일에 기록되는 등, 기대한 대로 동작하는 경우가 많았다. 항상 믿을 수 있는지는 100% 확신할 수 없다.

버퍼링을 피하는 여러 가지 방법

좋아, 해결책을 이야기해보자. 이런 명령어를 실행했다고 해보자:

tail -f /some/log/file | grep thing1 | grep thing2

마스토돈에서 사람들에게 실제로 어떻게 해결하는지 물어봤는데, 기본적으로 5가지 접근법이 나왔다. 소개해보겠다:

해결책 1: 빨리 끝나는 프로그램을 실행하기

지금까지 나의 해결책은 “파이프에 천천히 쓰는 명령어” 상황 자체를 아예 피하고, 대신 이렇게 빨리 끝나는 프로그램을 실행하는 것이었다:

cat /some/log/file | grep thing1 | grep thing2 | tail

이건 원래 명령어와 같은 동작을 하는 건 아니지만, 이런 이상한 버퍼링 문제를 고민하지 않아도 된다는 장점이 있다.

(grep thing1 /some/log/file처럼 해도 되지만, 나는 종종 굳이 “불필요한” cat을 쓰는 걸 선호한다)

해결책 2: grep의 “라인 버퍼” 플래그 기억하기

grep에 버퍼링을 피하는 플래그가 있다는 걸 기억해두고 이렇게 넘길 수도 있다:

tail -f /some/log/file | grep --line-buffered thing1 | grep thing2

해결책 3: awk 사용하기

어떤 사람들은 특히 grep을 여러 번 쓰는 상황이라면 단일 awk로 다시 쓰겠다고 했다. 예를 들면 이렇게:

tail -f /some/log/file |  awk '/thing1/ && /thing2/'

혹은 이렇게 더 복잡한 grep을 쓰기도 한다:

tail -f /some/log/file |  grep -E 'thing1.*thing2'

(awk도 버퍼링을 하므로, 이 방법이 동작하려면 awk가 파이프라인의 마지막 명령어여야 한다)

해결책 4: 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라는 정말 좋은 글이 있다.

해결책 5: unbuffer 사용하기

unbuffer program은 프로그램의 출력을 강제로 TTY처럼 만들며, 이는 프로그램이 일반 TTY에서처럼(버퍼링 감소, 컬러 출력 등) 동작하게 된다는 뜻이다. 이 예시에서는 이렇게 쓸 수 있다:

tail -f /some/log/file | unbuffer grep thing1 | grep thing2

stdbuf와 달리 항상 동작하지만, 원치 않는 부작용이 있을 수 있다. 예를 들어 grep thing1이 일치 항목을 컬러로 칠하게 될 수도 있다.

unbuffer를 설치하려면 expect 패키지에 들어 있다.

내가 아는 해결책은 여기까지!

어떤 것이 “최고”라고 말하기는 좀 어렵다. 개인적으로는 항상 동작한다는 걸 아니까 unbuffer를 가장 많이 쓰게 될 것 같다.

다른 해결책을 알게 되면 이 글에 추가해보겠다.

이 문제가 얼마나 자주 생기는지는 잘 모르겠다

이렇게 파이프로 데이터가 천천히 조금씩 들어오는 프로그램을 쓰는 일은 내게는 그리 흔하지 않은 것 같다. 보통 파이프를 쓸 때는 많은 데이터가 아주 빠르게 기록되고, 파이프라인의 모든 단계에서 처리된 뒤, 모든 것이 종료된다. 지금 당장 떠오르는 예는 다음과 같다:

  • tcpdump
  • tail -f
  • kubectl logs 같은 다른 방식으로 로그 파일 지켜보기
  • 느린 연산의 출력

버퍼링을 끄는 환경 변수가 있다면 어떨까?

파이썬의 PYTHONUNBUFFERED처럼 버퍼링을 끄는 표준 환경 변수가 있다면 멋질 것 같다. 이 아이디어는 2018년 Mark Dominus가 쓴 개의 블로그 글에서 얻었다. NO_COLOR처럼 NO_BUFFER 같은 건 어떨까?

설계가 제대로 되려면 까다로워 보인다. Mark는 NetBSD에 환경 변수 STDBUF, STDBUF1 등으로 불리는 게 있어서 버퍼링을 아주 세세하게 제어할 수 있다고 지적하지만, 비교적 사소한 엣지 케이스를 처리하려고 여러 환경 변수를 구현하고 싶어 하는 개발자는 많지 않을 것 같다.

또 일정 시간(예를 들어 1초)이 지나면 출력 버퍼를 자동으로 플러시하는 프로그램이 있는지 궁금하기도 하다. 이론상으로는 좋아 보이지만, 그렇게 동작하는 프로그램이 떠오르지 않으니 단점이 있을 거라고 짐작한다.

다루지 않은 내용

이 글에서 다루지 않은 것들도 있는데, 요즘 글이 점점 길어지고 있고 솔직히 누가 버퍼링에 대해 3000자짜리 글을 정말 읽고 싶어 하겠나?

  • 라인 버퍼링과 완전히 버퍼링되지 않은 출력의 차이
  • stderr로의 버퍼링이 stdout으로의 버퍼링과 어떻게 다른지
  • 이 글은 프로그램 내부에서 일어나는 버퍼링에 대해서만 다룬다. 운영체제의 TTY 드라이버도 가끔 약간의 버퍼링을 한다
  • “파이프에 쓰고 있다”는 이유 외에 출력을 플러시해야 할 수도 있는 다른 이유들

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

댓글