懒惰(非缓冲)处理Shell管道

3
我正在尝试弄清如何执行最懒惰的标准UNIX shell管道处理。例如,假设我有一个命令,它在进行一些计算和输出的同时,计算变得越来越昂贵,因此前几行输出很快到达,但随后的行变得越来越慢。如果我只对前几行感兴趣,则可以通过惰性求值获得这些行,在计算变得太昂贵之前尽快终止计算。
这可以通过一个直接的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结尾的)行并退出,但expensive仍继续运行,并且现在在完成之前等待进一步的16秒(即2 ** 4),此时管道也随之完成。
显然,这并不像期望的那样懒惰,因为理想情况下,只要head进程接收到两行,expensive就会终止。然而,这并没有发生;据我所知,它实际上在尝试写入第三行后终止,因为此时它尝试写入其STDOUT,该流通过管道连接到已经退出并不再从管道读取输入的head进程。这导致expensive接收到SIGPIPE信号,从而导致运行脚本的bash解释器调用其SIGPIPE处理程序,该处理程序默认情况下终止运行脚本(尽管可以通过trap命令更改)。
所以问题是,我该如何让expensivehead退出时立即退出,而不仅仅是当expensive试图将其第三行写入不再具有侦听器的管道时?由于管道是由我键入./expensive | head -n 2命令的交互式shell进程构建和管理的,因此解决此问题的任何解决方案都应该位于交互式shell中,而不是对expensivehead进行任何修改。是否有任何本地技巧或额外实用程序可以构建我想要的行为的管道?或者也许在bashzsh中无法实现我想要的内容,唯一的方法是编写自己的管道管理器(例如使用Ruby或Python),它可以在读取器终止并立即终止写入器时发现这一点?

2
除此之外:理想情况下,应该使用 .sh 扩展名来命名 shell ,这些库可以在任何符合 POSIX 标准的 shell 中被引用(.bash 用于仅与 bash 兼容的库,.zsh 用于与 zsh 兼容的库)。将扩展名用于可执行命令会在将它们重写为不同语言时带来麻烦——现在您需要更新每个调用者以调用一个不同命名的命令,或者您有一个具有误导性名称的脚本——而且,使用 bash shebang 调用脚本并给它一个暗示 sh 可以调用它的名称是具有误导性的。 - Charles Duffy
非常好的观点,谢谢!我猜这个评论是因为$(( ))bash特有的,而不是符合POSIX标准的? - Adam Spiers
1
实际上,$(( )) 是符合 POSIX 标准的——只是使用 (( )) 进入算术上下文而不进行替换的结果是一个 bashism——但 #!/bin/bash 的 shebang 意味着根据调用方式,你会得到两个不同的解释器(或以不同模式运行的解释器)。 - Charles Duffy
明白了,谢谢!我已经从问题中删除了“.sh”后缀。 - Adam Spiers
2个回答

3

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

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

在Bash 4.4中,它们将其进程ID存储在$!中,并允许像其他后台进程一样进行进程管理。
# 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中,$!没有被设置为任何值。 - Adam Spiers
嗯,我正在使用MacPorts构建的4.4.12(1),并且它在这里作为复制和粘贴答案时正常工作。我可以问一下你的平台吗? - Charles Duffy
openSUSE Leap 42.2,也可以复制和粘贴。 - Adam Spiers
错误。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 - Charles Duffy
1
@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< <(expensive); expensive_pid=$!; head -n 2 <&3; kill "$expensive_pid"' - Charles Duffy
我没有使用42.2标准的bash;我正在使用OBS上“shells”项目中的版本(https://build.opensuse.org/package/show/shells/bash),该版本是4.4.12(1),如先前所述。但是,您的新解决方案确实有效,尽管我不知道为什么!非常感谢! - Adam Spiers

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 的小心一些。 - Adam Spiers

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接