【问题标题】:lazy (non-buffered) processing of shell pipelineshell管道的惰性(非缓冲)处理
【发布时间】:2017-07-26 12:23:23
【问题描述】:

我试图弄清楚如何对标准 UNIX shell 管道执行最懒惰的处理。例如,假设我有一个命令执行一些计算并一路输出,但计算变得越来越昂贵,因此前几行输出很快到达,但随后的行变得更慢。如果我只对前几行感兴趣,那么我想通过lazy evaluation 获得那些,在它们变得太昂贵之前尽快终止计算。

这可以通过直接的 shell 管道来实现,例如:

./expensive | head -n 2

但是,这不能以最佳方式工作。让我们用一个指数级变慢的脚本来模拟计算:

#!/bin/sh

i=1
while true; do
    echo line $i
    sleep $(( i ** 4 ))
    i=$(( i+1 ))
done

现在,当我通过 head -n 2 管道传输此脚本时,我观察到以下内容:

  • line 1 是输出。
  • 休眠一秒后,输出line 2
  • 尽管 head -n 2 已经收到两条 (\n-terminated) 行并退出,expensive 继续运行,现在在完成之前再等待 16 秒 (2 ** 4),此时管道也完成.

显然这并不像期望的那样懒惰,因为理想情况下expensive 将在head 进程收到两行后立即终止。但是,这不会发生; IIUC 它实际上在尝试写入其 third 行后终止,因为此时它尝试写入通过管道连接到 STDINhead 进程的 STDOUT,该进程已经退出,因此不再从管道读取输入。这会导致expensive 接收SIGPIPE,从而导致运行脚本的bash 解释器调用其SIGPIPE 处理程序,该处理程序默认终止运行脚本(尽管可以通过trap 命令更改)。

所以问题是,我怎样才能让expensivehead 退出时立即 退出,而不仅仅是在expensive 尝试将其第三行写入没有的管道时另一端还有听众吗?由于管道是由交互式 shell 进程构建和管理的,因此我在其中键入了 ./expensive | head -n 2 命令,推测交互式 shell 是解决此问题的任何地方,而不是对 expensivehead 的任何修改?是否有任何本机技巧或额外实用程序可以构建具有我想要的行为的管道?或者也许根本不可能在bashzsh 中实现我想要的,唯一的方法是编写我自己的管道管理器(例如在Ruby 或Python 中),它会在阅读器终止并立即终止编写器时发现?

【问题讨论】:

  • 旁白:理想情况下,.sh 扩展应该用于 shell libraries,可以在任何符合 POSIX 的 shell 中获取(.bash 用于仅与 bash 兼容的库, .zsh 用于与 zsh 兼容的库)。每当将可执行命令重写为不同的语言时,对可执行命令使用扩展​​都会带来麻烦——现在你需要更新每个调用者以调用不同名称的命令,或者你有一个误导性的名称——而且在任何情况下调用都是误导性的一个带有bash shebang 的脚本,其名称暗示sh 可以调用它。
  • 好点,谢谢!我猜这个评论来自$(( ))bash-specific 并且不符合 POSIX 的事实?
  • 实际上,$(( )) POSIX 兼容的——它只使用(( )) 进入算术上下文而不进行替换,这是一个 bashism——但是 @ 987654358@shebang 表示您将获得两个不同的解释器(或以不同模式运行的解释器),具体取决于调用。
  • 啊,明白了 - 谢谢!我已经从问题中删除了.sh 后缀。

标签: bash shell unix pipe zsh


【解决方案1】:

如果您只关心前台控制,您可以在进程替换中运行expensive;它仍然会阻塞,直到它下一次尝试写入,但head 在收到输入后立即退出(并且您的脚本的流控制可以继续)

head -n 2 < <(exec ./expensive)
# expensive still runs 16 seconds in the background, but doesn't block your program

在 bash 4.4 中,它们将其 PID 存储在 $! 中,并允许以与其他后台进程相同的方式进行进程管理。

# REQUIRES BASH 4.4 OR NEWER
exec {expensive_fd}< <(exec ./expensive); expensive_pid=$!
head -n 2 <&"$expensive_fd"  # read the content we want
exec {expensive_fd}<&-       # close the descriptor
kill "$expensive_pid"        # and kill the process

另一种方法是协同处理,其优点是只需要 bash 4.0:

# magic: store stdin and stdout FDs in an array named "expensive", and PID in expensive_PID
coproc expensive { exec ./expensive }

# read two lines from input FD...
head -n 2 <&"${expensive[0]}"

# ...and kill the process.
kill "$expensive_PID"

【讨论】:

  • 感谢您的精彩回答!但是我无法使用$! 技巧;使用 bash 4.4.12(1)-release,$! 不会设置任何内容。
  • 嗯。我正在使用 4.4.12(1) 的 MacPorts 版本,它在这里作为答案的复制粘贴工作很愉快。我可以问问你们的平台吗?
  • openSUSE Leap 42.2,也是复制粘贴的。
  • 错误。 OpenSUSE 42.2 似乎随 bash 4.3.42(1)-release 一起发布(至少,公共仓库 Docker 镜像是这样)。也就是说:$ docker run opensuse:42.2 bash -c 'echo "$BASH_VERSION"' 作为输出发出 4.3.42(1)-release
  • @AdamSpiers,...我想我已经重现了您的问题并进行了修复。作为工作表单的单行复制器:docker run bash:4.4 bash -c 'expensive() { i=1; while true; do echo "line $i"; sleep $(( i ** 4 )); i=$(( i + 1 )); done; }; exec 3&lt; &lt;(expensive); expensive_pid=$!; head -n 2 &lt;&amp;3; kill "$expensive_pid"'
【解决方案2】:

我会用 POSIX shell 来回答。

您可以做的是使用 fifo 而不是管道,并在第二个链接完成时终止第一个链接。

如果代价高昂的进程是叶进程或者它负责杀死其子进程,则可以使用简单的 kill。如果它是一个进程生成的 shell 脚本,你应该在一个进程组中运行它(可以使用 set -m)并使用进程组 kill 杀死它。

示例代码:

#!/bin/sh -e
expensive()
{
    i=1
    while true; do
        echo line $i
        sleep 0.$i     #sped it up a little
        echo >&2 slept 
        i=$(( i+1 ))
    done
}
echo >&2 NORMAL
expensive | head -n2
#line 1
#slept
#line 2
#slept

echo >&2 SPED-UP
mkfifo pipe
exec 3<>pipe 
rm pipe
set -m; expensive  >&3 & set +m
<&3 head -n 2
kill -- -$!
#line 1
#slept
#line 2

如果你运行这个,第二次运行不应该有第二个slept 行,这意味着第一个链接在head 完成的那一刻被终止,而不是在head 完成后第一个链接试图输出时。

【讨论】:

  • 酷,谢谢!虽然我注意到 bash 会进行自己奇怪的缓冲(或者可能只是合并其内置 echo 命令的输出),但我会有点谨慎地信任 slept 输出行而不是sleep 有足够大的间隔以引起注意。
猜你喜欢
  • 2014-01-30
  • 2012-01-25
  • 2020-12-30
  • 2017-09-06
  • 2019-08-12
  • 1970-01-01
  • 1970-01-01
  • 2012-01-07
  • 2013-08-30
相关资源
最近更新 更多