Why pipes sometimes get "stuck": buffering

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 是否為終端機
    • 如果是終端機,就使用行緩衝(line buffering)(一有完整的一行就馬上印出)
    • 否則就使用「區塊緩衝」(block buffering)——只有在累積到至少約 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(無法停用緩衝)

以上就是我能想到的全部,還有很多 Unix 指令(像是 sort)可能會也可能不會緩衝輸出,但其實無關緊要,因為 sort 本來就要等到接收完所有輸入後才能做任何事。

另外,我已經盡力測試了這些指令的 Mac OS 和 GNU 版本,但版本差異很多,我可能有弄錯的地方。

預設的「print」敘述會進行緩衝的程式語言

另外,以下是幾個預設的 print 敘述在寫入管線時會緩衝輸出的程式語言,以及如果你想停用緩衝的一些方法:

  • C(用 setvbuf 停用)
  • Python(用 python -u、或 PYTHONUNBUFFERED=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 則會立即 flush 輸出。

當你在管線中按下 Ctrl-C 時,緩衝區的內容會遺失

假設你正用這個指令當作一個權宜的方法,來監看對 example.com 的 DNS 請求,而你忘了傳遞 -l 給 tcpdump:

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

當你按下 Ctrl-C 時會發生什麼事?在一個魔法般完美的世界裡,我希望發生的是 tcpdump 會先清空緩衝區,grep 會去搜尋 example.com,然後我就能看到所有錯過的輸出。

但在現實世界中,實際發生的是所有程式都被終止,而 tcpdump 緩衝區裡的輸出就這樣遺失了。

我認為這個問題大概是無法避免的——我花了一點時間用 strace 來觀察運作方式,發現 grep 無論如何都會比 tcpdump 更早收到 SIGINT,所以就算 tcpdump 試圖清空緩衝區,grep 也早就已經結束了。

經過進一步研究後,發現有一個變通方法:如果你找到 tcpdump 的 PID 並執行 kill -TERM $PID,那麼 tcpdump 就會清空緩衝區,讓你能看到輸出。這有點麻煩,但我測試過似乎可行。

重新導向到檔案時也會緩衝

不只是管線,這樣也會緩衝:

sudo tcpdump -ni any port 53 > output.txt

不過,重新導向到檔案並沒有同樣「按下 Ctrl-C 就會完全摧毀緩衝區內容」的問題——以我的經驗來說,它通常會更符合你的預期,在程式結束前把緩衝區的內容寫入檔案。我不太確定這是不是每次都能依靠的行為。

避免緩衝的多種潛在方法

好了,來談談解決方法。假設你執行了這個指令:

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

我在 Mastodon 上問大家在實務上會怎麼解決這個問題,結果有 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 這樣不同的方式監看日誌檔
  • 緩慢運算的輸出

如果有一個可以用來停用緩衝的環境變數會怎麼樣?

我覺得如果有一個標準的環境變數可以關閉緩衝會很酷,就像 Python 中的 PYTHONUNBUFFERED 那樣。這個想法來自 Mark Dominus(馬克·多米納斯)在 2018 年的幾篇部落格文章。也許可以像 NO_COLOR 那樣弄個 NO_BUFFER

這個設計要做對似乎很棘手;馬克·多米納斯指出,NETBSD 有名為 STDBUFSTDBUF1 等的環境變數,讓你可以對緩衝進行非常精細的控制,但我猜大多數開發者不會想為了處理一個相對輕微的邊緣案例,就實作這麼多不同的環境變數。

我也很好奇是否有任何程式會在經過一段時間(例如 1 秒)後自動清空輸出緩衝區。理論上感覺這樣會不錯,但我想不到有任何程式這樣做,所以猜想應該是有一些缺點。

本文省略的內容

有些東西我在這篇文章裡沒有談,因為最近這些文章已經變得有點長,而且說真的,有誰真的想讀 3000 字的緩衝解說呢?

  • 行緩衝與完全無緩衝輸出之間的差異
  • 緩衝到 stderr 與緩衝到 stdout 有何不同
  • 這篇文章只討論發生在程式內部的緩衝,你的作業系統的 TTY 驅動程式有時也會做一點點緩衝
  • 除了「你正在寫入管線」之外,其他你可能需要清空輸出的原因

原文由 Julia Evans 發布

本文章由 muse-spark-1.2-contributor 進行翻譯