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 函数检查标准输出是否为终端
    • 如果是终端,就使用行缓冲(每得到一行就立刻打印)
    • 否则,就使用“块缓冲”——只有攒够大约 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 这样的很多 Unix 命令是否会缓冲其实并不重要,因为 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++ 中,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 种思路。如下:

方案一:运行一个能很快结束的程序

从历史上看,我的解决办法就是完全避开“命令缓慢地向管道写入”这种情况,转而运行一个会很快结束的程序,比如:

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

这和原来那条命令做的事情并不完全一样,但确实可以让你不用去纠结这些奇怪的缓冲问题。

(你也可以用 grep thing1 /some/log/file,但我常常更喜欢用一个“多余的” cat

方案二:记住 grep 的“行缓冲”参数

你可以记住 grep 有一个避免缓冲的参数,像这样传进去:

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

方案三:使用 awk

有人说,如果是遇到多个 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 包里。

这就是我目前知道的所有解决办法!

很难说哪一种是“最好”的,我个人最有可能用的是 unbuffer,因为我知道它总是有效的。

如果我学到了更多解决办法,会尝试补充到这篇文章里。

我也不太确定这种情况有多常见

我觉得,平时很少会遇到程序这样慢慢地向管道中一点点吐数据的情况,通常我用管道时,都是一大批数据很快被写入,然后被管道中的所有程序处理完,接着全部退出。我目前能想到的例子只有:

  • tcpdump
  • tail -f
  • 用其他方式查看日志文件,比如用 kubectl logs
  • 慢速计算的输出

如果有一个环境变量能关闭缓冲会怎么样?

我觉得如果能有一个标准的、用来关闭缓冲的环境变量就好了,就像 Python 中的 PYTHONUNBUFFERED 那样。这个想法来自 Mark Dominus 在 2018 年写的几篇博客文章。也许可以叫 NO_BUFFER,就像 NO_COLOR 那样?

这个设计似乎很难做到完美;Mark 指出,NetBSD 有名为 STDBUFSTDBUF1 等的环境变量,可以让你对缓冲有非常精细的控制,但我猜大多数开发者都不想为了一个相对边缘的情况去实现那么多不同的环境变量。

我还好奇,是否有程序会在一段时间(比如 1 秒)后自动刷新输出缓冲区。理论上这似乎会很不错,但我想不出有哪个程序是这么做的,所以我猜应该是有一些缺点。

本文未涉及的内容

这篇文章里有些内容我没有展开,因为最近这些文章已经写得挺长了,说真的,有谁真的想读 3000 字全讲缓冲的内容呢?

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

本文章由 muse-spark-1.2-contributor 进行翻译

评论