Bash命令替换输出结果不一致,有些奇怪

5

由于某些与本问题无关的原因,我正在通过命令替换在单独的子shell中而不是直接运行Java服务器的Bash脚本,并且在后台运行。意图是让子命令将Java服务器的进程ID作为其标准输出返回。相关代码如下:

launch_daemon()
{
  /bin/bash <<EOF
     $JAVA_HOME/bin/java $JAVA_OPTS -jar $JAR_FILE daemon $PWD/config/cl.yml <&- &
     pid=\$!
     echo \${pid} > $PID_FILE
     echo \${pid}   
EOF
}

daemon_pid=$(launch_daemon)

echo ${daemon_pid} > check.out

问题涉及的Java守护程序在初始化时如果遇到问题,会将错误信息打印到标准错误输出并退出;否则,它将关闭标准输出和标准错误输出,并继续执行。脚本的后面部分(未显示)包含了检查服务器进程是否正在运行的代码。现在来看看问题所在。

每当我检查上述的$PID_FILE文件时,它都包含了正确的进程ID。

但是当我检查check.out文件时,有时它会包含正确的ID,而有时它会在同一行上重复两次进程ID,用一个空格字符隔开,如下所示:

34056 34056

我在上面的脚本中使用变量$daemon_pid来检查服务器是否正在运行。如果该变量包含重复的pid,这将完全打乱测试,并且错误地认为服务器未运行。在我的运行CentOS Linux的服务器上调整脚本(例如插入更多echo语句)似乎会将行为翻转回$daemon_pid仅包含一次进程ID的正确行为,但如果我认为已经修复该问题并将此脚本提交到我的源代码库并进行构建和部署,则会开始看到相同的错误行为。
目前,我通过假设$daemon_pid可能存在问题,并将其传递给awk来解决此问题。
mypid=$(echo ${daemon_pid} | awk '{ gsub(" +.*",""); print $0 }')

那么 $mypid 变量始终包含正确的进程 ID,一切都很好,但不用说我想知道为什么它会这样行事。在你问之前,我已经查找了很多次,但是所涉及的 Java 服务器在关闭标准输出之前并未将其进程 ID 打印到标准输出中。

非常感谢专家的意见。


1
有没有任何服务监控。如果有,它可能会同时启动另一个服务。显然,我只是想排除一些可能性,而不是回答问题。 - Elliott Frisch
1
@user2456600:你的launch_daemon函数中肯定存在竞争条件,因为如果/bin/bash在你的java服务器成功分离之前终止,那么java服务器将被杀死。这不会导致重复的pid,但会导致无意义的pid。无论如何,我相信你已经测试过了,但是尝试添加>/dev/null并进行验证也不会有什么损失。 - rici
1
我坚持“标准输入/输出通道应该是打开的”。然而,这可能不是你所遇到问题的原因。我的猜测是PID_FILE并没有始终被设置;当它没有被设置时,在daemon_pid中可能会得到两个PID。我强调这只是猜测;我们无法看到足够的代码来确定是否可能发生这种情况,或者如何可能发生这种情况。 - Jonathan Leffler
1
@user2456600:除非是交互式的,否则bash不会打印该消息,并且如果stdin被重定向(如<<EOF),它不会认为自己是交互式的,除非您指定了-i。无论如何,如果它确实打印了该消息,我认为它会进入stderr。但这仍然是一种可能性。 - rici
2
当用一个什么都不做的简单脚本替换Java时,观察到相同的行为,因此Java是无关紧要的。我怀疑这是Bash中的一个错误,与子Bash没有刷新输出流有关,但我还不能看到细节。 - William Pursell
显示剩余20条评论
1个回答

4

在@WilliamPursell的提示下,我查找了bash源代码。说实话,我不知道这是否是一个错误;我只能说它似乎与一种有问题的用例产生了不幸的交互。

TL;DR: 你可以通过从脚本中删除<&-来解决问题。

关闭stdin最多是令人质疑的,不仅因为@JonathanLeffler提到的原因("程序有权使用已打开的标准输入"),更重要的是因为stdin正在被进程本身使用,在后台关闭它会导致竞争条件。

为了看清发生了什么,请考虑以下相当奇怪的脚本,可能称为Duff's Bash Device,除了我不确定即使邓夫也会赞成之外:(此外,如所呈现的那样,它并不是那么有用。但某个地方的某些黑客可能已经使用了它。或者,如果没有,他们现在会看到它。)

/bin/bash <<EOF
if (($1<8)); then head -n-$1 > /dev/null; fi
echo eight
echo seven
echo six
echo five
echo four
echo three
echo two
echo one
EOF

为了使这个方法起作用,bashhead都必须准备共享stdin,包括共享文件位置。这意味着bash需要确保它刷新其读取缓冲区(或不使用缓冲区),head需要确保它将光标定位到它所使用的输入部分的末尾。
(这种技巧之所以有效,是因为bash通过将here-documents复制到临时文件中来处理它们。如果它使用管道,则head无法向后查找。)
现在,如果head在后台运行会发生什么?答案是,“几乎任何事情都可能发生”,因为bashhead正在竞争读取相同的文件描述符。在后台运行head将是一个非常糟糕的想法,甚至比最初的技巧更糟糕,因为后者至少是可预测的。
现在,让我们回到实际的程序,简化为其要点:
/bin/bash <<EOF
cmd <&- &
echo \$!
EOF

这个程序的第二行 (cmd <&- &) 创建了一个单独的进程 (在后台运行)。在该进程中,它关闭了 stdin,然后调用了 cmd
同时,前台进程继续从 stdin 读取命令(它的 stdin 文件描述符没有被关闭,所以没问题),这导致它执行了 echo 命令。
现在问题来了: bash 知道它需要共享 stdin,所以它不能只关闭 stdin。 它需要确保 stdin 的文件位置指向正确的位置,即使它可能已经提前读取了一定量的输入缓冲区。因此,在关闭 stdin 之前,它会将其回溯到当前命令行的末尾。[1]
如果这个回溯发生在前台 bash 执行 echo 命令之前,那么就没有问题。如果它发生在前台 bash 处理 here-document 完成后,也没有问题。但是如果它发生在 echo 正在工作时 呢? 在这种情况下,在 echo 完成后,bash 将重新读取 echo 命令,因为 stdin 已经被倒回了,所以 echo 将再次被执行。
这正是 OP 中发生的事情。有时,后台的回溯完成的时间恰好不对,导致 echo \${pid} 被执行两次。实际上,它还会导致 echo \${pid} > $PID_FILE 也被执行两次,但是该行是幂等的;如果它是 echo \${pid} >> $PID_FILE,那么双重执行将是可见的。
因此,解决方案很简单: 从服务器启动行中删除 <&-,并可选地将其替换为 </dev/null,以确保服务器无法从 stdin 读取。
check_bash_input (redirector);
close_buffered_fd (redirector);

第一个调用执行 lseek,第二个调用执行 close。我使用 strace -f 查看了行为,然后在代码中搜索了一个看起来合理的 lseek,但我没有去验证调试器。


哇,由于某种原因,在我完成了解决方法后,我就离开了,但从未收到过这个答案的通知。这是最全面的,感谢您深入挖掘 :-) - user2456600
现在我回来检查我的问题,又从中学到了一个简化awk hack的有用技巧。我感到很惭愧。 - user2456600

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