这是一个关于我的一些糟糕的API设计是如何导致一些丑陋的竞赛条件的故事,这些条件非常棘手,难以破解。我写这个故事是为了警告其他人!代码本身是用Haskell写的,但这些教训适用于任何使用Unix风格进程的人。
介绍typed-process
我同时维护着Haskell中的process 库,它是启动子进程的标准方式,以及typed-process 库,它探索了对该API的一些改进,以使其更加方便用户使用。该API有两种主要类型:ProcessConfig 定义了启动进程的设置(命令名、环境变量等),而Process 代表了一个可以被交互的运行中的子进程。有了这些,我们就有了一些基本的API用法,看起来像这样:
let processConfig = proc "some-executable" ["--flag1", "--flag2"]
process <- startProcess processConfig
hPut (getStdin process) "Input to the process"
output <- hGet (getStdout process)
helperFunction output
hPut (getStdin process) "quit" -- tell the process to quit
exitCode <- waitExitCode process
logInfo $ "Process exited with code " <> displayShow exitCode
这并不是很好的工作代码,但它很好地表达了这个想法。
异常安全
上面的代码有一个问题:它不是异常安全的。比方说,helperFunction 调用失败,出现了运行时异常。子进程将永远不会收到"quit" ,我们将永远不会等待子进程结束,最终我们将得到一个闲置的进程,摆弄着它的拇指,永远无法退出。(你可能认为这是一个僵尸进程,但是僵尸在Unix世界里有一个特殊的、不同的含义。)
Haskell生态系统,像其他许多系统一样,有一种提供异常安全的方法。我们称它为括号模式。你使用辅助函数bracket ,将资源分配和清理动作结合在一起,并保证当你的块完成时,清理动作被调用,无论块如何完成。
为了使其发挥作用,我们需要一个stopProcess 函数。这个函数是智能的:如果进程已经退出,stopProcess 不会做任何事情。然而,如果进程仍在运行,stopProcess 向它发送一个SIGTERM 信号,对于大多数行为良好的程序来说,这将导致它退出。(Unix进程实际上可以处理SIGTERM ,并继续运行,但对于我们的情况,我们将假装它是一个进程的死刑判决)。
因此,让我们用bracket 重写上面的代码:
let processConfig = proc "some-executable" ["--flag1", "--flag2"]
bracket (startProcess processConfig) stopProcess $ \proccess -> do
hPut (getStdin process) "Input to the process"
output <- hGet (getStdout process)
helperFunction output
hPut (getStdin process) "quit" -- tell the process to quit
exitCode <- waitExitCode process
logInfo ("Process exited with code " <> displayShow exitCode)
就这样,我们有了类型安全,并避免了进程的失控。很好!
让我们来看看上面的案例。如果区块中的任何一个动作抛出一个运行时异常,bracket 将触发stopProcess ,导致SIGTERM 被发送到子程序。另一方面,如果没有异常发生,我们知道,由于waitExitCode 的调用,子进程已经退出了,因此stopProcess 将是一个无用的操作。这正是我们想要的行为。
按照Haskell的最佳实践,我们可以将这个bracket 的调用捕捉到一个名为withProcess 的辅助函数中:
withProcess config = bracket (startProcess config) stopProcess
let processConfig = proc "some-executable" ["--flag1", "--flag2"]
withProcess processConfig $ \proccess -> do
hPut (getStdin process) "Input to the process"
output <- hGet (getStdout process)
helperFunction output
hPut (getStdin process) "quit" -- tell the process to quit
exitCode <- waitExitCode process
logInfo ("Process exited with code " <> displayShow exitCode)
异常安全已经实现了!
最后,再补充一点。在处理子进程的过程中,一个常见的模式是检查退出代码是否成功,如果是其他情况,则抛出一个异常。我们有一个辅助函数withProcess_ ,它也可以执行出口代码检查。这本质上看起来像:
withProcess_ config = bracket
(startProcess config)
(\process -> do
stopProcess process
checkExitCode process)
玩猫
我们将犯一个Unix的大忌:当我们实际上没有把两个不同的文件合并在一起时,使用cat 可执行文件。请原谅我,这是有原因的。
下面是一个完全可运行的Haskell脚本。你可以安装Stack,把代码复制到Main.hs ,然后运行stack Main.hs 来运行它。该程序做了以下工作:
- 定义了一个进程配置,其中。
- 孩子的标准输入是一个新的管道
- 孩子的标准输出是一个新的管道
- 子程序的命令行是
cat,没有参数。
- 用以下方式启动进程
withProcess_ - 当进程运行时,并发运行两个Haskell线程。
- 线程1将通过标准输入向子程序发送字符串
Hello World!\n,然后关闭管道。 - 线程2将从子程序的标准输出中捕获所有内容,直到管道被关闭
- 线程1将通过标准输入向子程序发送字符串
- 打印从子进程捕获的输出到父进程的标准输出流(也就是我测试中的终端):
#!/usr/bin/env stack
-- stack --resolver lts-13.26 script
{-# LANGUAGE OverloadedStrings #-}
import Control.Concurrent.Async (concurrently)
import qualified Data.ByteString as B
import System.IO (hClose, stdout)
import System.Process.Typed
main :: IO ()
main = do
let config = setStdin createPipe
$ setStdout createPipe
$ proc "cat" []
((), output) <- withProcess_ config $ \process -> concurrently
(do B.hPut (getStdin process) "Hello World!\n"
hClose (getStdin process))
(do B.hGetContents (getStdout process))
B.hPut stdout output
当我在OS X上运行这个程序时,我相当可靠地得到了预期的输出。
$ stack Main.hs
Hello World!
然而,当我在Linux上运行这个程序时,我经常会得到以下结果:
$ stack Main.hs
Main.hs: Received ExitFailure (-15) when running
Raw command: cat
当然,并不总是这样,但经常如此。所以现在我们有一个奇怪的退出失败和一些非确定性,而这似乎是一个非常简单的程序。这是什么原因呢?
ExitFailure (-15)
首先要确定的是这个负的退出代码是什么。Haskell和其他一些生态系统一样,使用负的退出代码来表示进程因信号而退出。在这个例子中,这意味着子进程 (cat) 因信号号15而死亡,也就是SIGTERM 。这当然很有趣......我们以前在哪里看到过SIGTERM ?对,在stopProcess 。
但是,stopProcess 发送信号并不完全合理,因为它只有在子进程的标准输出管道被关闭后才会这样做。而我们知道,cat 在关闭标准输出管道的同时退出......对吗?
竞争条件!
希望我上面吓人的斜体字有一点帮助。不,事实证明,管道的关闭和子程序的退出并不是同时进行的。事实上,我们的cat 进程最终会做如下的事情:
readfrom stdin- 如果有更多的数据:
write到stdout并返回到步骤1 - 如果没有更多的数据,则退出循环并继续进行步骤4
- 关闭
stdin - 关闭
stdout - 退出,退出代码为0(表示成功)。
与此同时,父进程将在子进程的stdout 管道的读取端反复调用read ,一旦该read 指示文件结束(EOF),该块将退出,withProcess_ 将做两件事:
- 调用
stopProcess - 调用
checkExitCode,以确保该进程成功退出
有多种事件的交错发生。成功的情况是这样的
- 子程序关闭
stdout - 子进程以退出代码0退出
- 父代收到EOF on
read - 母体调用
stopProcess,这是一个无用功(孩子已经退出了)。 checkExitCode得到退出代码0,并感到高兴
然而,在不同的进程时机下,也有可能得到。
- 子程序关闭
stdout - 母体收到EOF
read - 母体调用
stopProcess,它向子体发送一个SIGTERM - 子代没有机会返回退出代码0,它已经死了
checkExitCode看到孩子由于 而退出,并抛出一个异常。SIGTERM
这似乎是一个角落,但它已经咬了我两次:第一次是在一个测试套件中,第二次是在新的Stack版本中的一个主要烦恼。
该怪谁呢?
嗯,通常来说,应该责备的人是我自己。

Unix进程API的使用可能很棘手,但它有清晰的文档,执行得很好。而且我认为,我对withProcess_ 的使用是正确的抽象方式。不,问题在于withProcess_ 的实现。 让我们再来看看这个过程:
- 启动一个进程
- 与进程一起运行一些块
- 然而该块退出(正常或异常),调用
stopProcess,然后确保有一个成功的退出代码。
在我们上面的第一个用法中,我们在块中调用了waitExitCode ,这就保证了在成功的情况下stopProcess ,最后总是作为一个no-op。一切都很顺利。问题是我假设cat的管道关闭与子进程的退出是一样的。我们知道这是不对的。然而,考虑到这个错误对我造成了两次打击,可以说我创建了一个鼓励滥用的API。
相反,对于withProcess_ ,我认为这是一个更好的实现。
- 启动一个进程
- 在该进程中运行一些块
- 如果该块抛出了一个异常,则通过以下方式终止子进程
stopProcess - 如果该块成功,等待进程退出,然后检查其退出代码是否成功
通过这样的行为调整,上面调用cat 的代码是安全的,我可以在晚上睡得更好。
弃用
推出一套新的行为,在运行时默默地(意思是:没有编译时的改变)修改行为是很危险的。使用withProcess_ 的人可能正是依赖于其当前的行为。因此,不替换当前的withProcess_ 行为,推出的策略是。
- 引入一个新的函数
withProcessTerm_,它的行为与今天的withProcess_相同。 - 引入一个新的函数
withProcessWait_,该函数具有我刚才描述的新行为 - 废弃
withProcess_,并发出信息,说明调用者应该使用其中一个替代函数。
这将鼓励typed-process 的用户分析他们对withProcess_ 的使用,看看他们是否容易受到这里所描述的错误的影响,并选择适当的替换。