这是一个困扰我多年的小众终端问题,但直到几周前我才真正理解。假设您正在运行此命令来监视日志文件中的某些特定输出:
tail -f /some/log/file | grep thing1 | grep thing2
如果日志行添加到文件的速度相对较慢,我看到的结果是……什么也没有!日志文件中是否存在匹配并不重要,只是不会有任何输出。
我将其内部化为“呃,我猜管道有时会卡住并且不向我显示输出,这很奇怪”,我只需运行grep thing1 /some/log/file | 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 缓冲区,它会如果这样做的话就太奇怪了,我想不出有任何程序有这种行为)
缓冲的命令和不缓冲的命令
这种缓冲行为的一个令人讨厌的事情是,您需要记住在写入管道时哪些命令缓冲其输出。
一些不缓冲其输出的命令:
- 尾巴
- 猫
- 球座
我认为几乎所有其他内容都会缓冲输出,特别是如果它是您可能使用它进行批处理的命令。以下是一些常见命令的列表,这些命令在写入管道时缓冲其输出,以及禁用块缓冲的标志。
- grep (
--line-buffered
) - sed (
-u
) - awk(有一个
fflush()
函数) - tcpdump (
-l
) - jq (
-u
) - tr (
-u
) - cut(不能禁用缓冲)
这些是我能想到的所有命令,许多unix命令(如sort
)可能会也可能不会缓冲它们的输出,但这并不重要,因为sort
在完成接收输入之前无法执行任何操作。
我也尽力测试了 Mac OS 和 GNU 版本,但有很多变体,我可能犯了一些错误。
默认“打印”语句缓冲的编程语言
另外,这里有一些编程语言,其中默认打印语句在写入管道时将缓冲输出,以及一些禁用缓冲的方法(如果需要):
- C(使用
setvbuf
禁用) - Python(使用
python -u
、PYTHON_UNBUFFERED=1
、sys.stdout.reconfigure(line_buffering=False)
或print(x, flush=True)
禁用) - Ruby(通过
STDOUT.sync = true
禁用) - Perl(使用
$| = 1
禁用)
我假设这些语言是这样设计的,以便在进行批处理时默认的打印功能会很快。
此外,输出是否被缓冲可能取决于您使用的打印函数,例如在 Rust print!
写入管道时会缓冲,但println!
将刷新其输出。
当您在管道上按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
也已经死了。
重定向到文件也会缓冲
它不仅仅是管道,它也会缓冲:
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 的“line buffer”标志
您可能还记得 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
,因为我知道它总是会起作用。
如果我了解更多解决方案,我会尝试将它们添加到这篇文章中。
我不太确定这种情况出现的频率
我认为对我来说,有一个程序可以将数据缓慢地滴入这样的管道中并不常见,通常,如果我使用管道,一堆数据会很快写入,由管道中的所有内容处理,然后所有内容都退出。我现在能想到的唯一例子是:
- tcp转储
tail -f
- 以不同的方式查看日志文件,例如使用
kubectl logs
- 慢速计算的输出
如果有一个环境变量来禁用缓冲怎么办?
我认为如果有一个标准环境变量来关闭缓冲,就像 Python 中的PYTHON_UNBUFFERED
那样,那就太酷了。我从 Mark Dominus 在 2018 年的几篇博客文章中得到了这个想法。也许NO_BUFFER
就像NO_COLOR一样?
这个设计似乎很难做好。 Mark 指出 NETBSD 有名为STDBUF
、 STDBUF1
等的环境变量,这为您提供了对缓冲的大量控制,但我想大多数开发人员不想实现许多不同的环境变量来处理相对较小的边缘情况。
我也很好奇是否有任何程序会在一段时间(例如 1 秒)后自动刷新其输出缓冲区。从理论上讲,这感觉很好,但我想不出有任何程序可以做到这一点,所以我想这会有一些缺点。
我遗漏的东西
有些事情我没有在这篇文章中讨论,因为这些文章最近变得很长,说真的,有人真的想阅读 3000 个关于缓冲的字吗?
- 行缓冲和完全无缓冲输出之间的区别
- 缓冲到 stderr 与缓冲到 stdout 有何不同
- 这篇文章仅涉及程序内部发生的缓冲,操作系统的 TTY 驱动程序有时也会进行一些缓冲
- 除了“正在写入管道”之外,您可能需要刷新输出的其他原因
原文: https://jvns.ca/blog/2024/11/29/why-pipes-get-stuck-buffering/