為什麼管線有時會「卡住」:緩衝處理
原文由 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 (無法關閉緩衝)
我目前想到的就這些,很多 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 會把緩衝區 flush 掉,grep 去搜尋 example.com,然後我就能看到所有錯過的輸出。
但在現實世界中,實際發生的是所有程式都被砍掉,而 tcpdump 緩衝區裡的輸出就這樣遺失了。
我覺得這個問題大概是無可避免的——我花了一點時間用 strace 觀察運作方式,發現 grep 無論如何都會比 tcpdump 更早收到 SIGINT,所以就算 tcpdump 想把緩衝區 flush 掉,grep 也早就已經結束了。
再深入研究一下,其實有個變通辦法:如果你找到 tcpdump 的 PID 並執行 kill -TERM $PID,那麼 tcpdump 就會把緩衝區 flush 掉,讓你看到輸出。雖然有點麻煩,但我測試過,好像是可行的。
重新導向到檔案時也會緩衝
不只是管線,這樣寫也一樣會緩衝:
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_BUFFER,就像 NO_COLOR 那樣?
設計上要做得好似乎有點棘手;Mark 提到 NetBSD 有叫做 STDBUF、STDBUF1 等的環境變數,讓你可以大幅控制緩衝行為,但我想大多數開發者應該不會想為了處理一個相對輕微的邊緣情況,就去實作那麼多不同的環境變數。
我也很好奇,有沒有程式會自動在經過一段時間(比如 1 秒)後就把輸出緩衝區 flush 掉。理論上感覺好像不錯,但我想不到有任何程式是這樣做的,所以我想應該是有一些缺點吧。
我省略掉的內容
這篇文章有些東西我沒有談到,因為最近這些文章已經變得有點太長了,而且說真的,有誰真的想讀一篇長達 3000 字、專門講緩衝的文章啊?
- 行緩衝和完全無緩衝輸出之間的差異
- 輸出到 stderr 和輸出到 stdout 的緩衝有何不同
- 這篇文章只討論發生在程式內部的緩衝,你的作業系統的 TTY 驅動程式有時也會做一點點緩衝
- 除了「你要寫入管線」之外,其他需要 flush 輸出的原因
隨機一篇部落格
留言
登入後參與討論