Why pipes sometimes get "stuck": buffering

Julia Evans

为什么管道有时会“卡住”:缓冲

这是一个困扰了我多年的小众终端问题,直到几周前我才真正搞明白。假设你运行这样一条命令来监视日志文件中的某些特定输出:

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

如果日志行添加到文件中的速度比较慢,我看到的结果就是……什么都没有!不管日志文件里有没有匹配项,就是没有任何输出。

我把这个现象归结为“呃,我猜管道有时候就是会卡住,不显示输出,真奇怪”,然后我的应对办法就是改用 grep thing1 /some/log/file | grep thing2,这样就能正常工作。

所以,在过去几个月深入钻研终端的过程中,能终于弄清楚这件事发生的确切原因,我感到非常兴奋。

为什么会这样:缓冲

“管道卡住”的原因在于:程序在把输出写入管道或文件之前先进行缓冲(buffering)是非常普遍的做法。所以管道本身工作正常,问题在于程序根本没有把数据写进管道!

这是出于性能考虑:一有输出就立刻写入会使用更多系统调用,所以更高效的做法是把数据攒起来,等攒够大约 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 -uPYTHONUNBUFFERED=1sys.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 刷新它的缓冲区,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_BUFFER,就像 NO_COLOR 那样?

这个设计似乎很难做好;Mark 指出 NetBSD 有名为 STDBUFSTDBUF1 等的环境变量,让你能对缓冲进行大量控制,但我想大多数开发者并不想为了一个相对次要的边缘情况去实现那么多不同的环境变量。

我还好奇有没有程序会自动每隔一段时间(比如 1 秒)就刷新输出缓冲区。理论上感觉这会很不错,但我想不出有任何程序这么做,所以我想应该有一些缺点。

我没讲的内容

有些东西我在这篇文章里没有讲,因为这些文章最近变得相当长,而且说真的,真有人想读 3000 字关于缓冲的内容吗?

  • 行缓冲与完全无缓冲输出的区别
  • 写入 stderr 的缓冲与写入 stdout 的缓冲有何不同
  • 本文只讲发生在程序内部的缓冲,你的操作系统的 TTY 驱动程序有时也会做一点缓冲
  • 除了“你在写入管道”之外,其他可能需要刷新输出的原因

原文由 Julia Evans 发布

本文章由 stealth/ox-alpha 进行翻译