基本的API用法

91 阅读5分钟

这是一个关于我的一些糟糕的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将从子程序的标准输出中捕获所有内容,直到管道被关闭
  • 打印从子进程捕获的输出到父进程的标准输出流(也就是我测试中的终端):
#!/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 进程最终会做如下的事情:

  1. read from stdin
  2. 如果有更多的数据:write 到stdout并返回到步骤1
  3. 如果没有更多的数据,则退出循环并继续进行步骤4
  4. 关闭stdin
  5. 关闭stdout
  6. 退出,退出代码为0(表示成功)。

与此同时,父进程将在子进程的stdout 管道的读取端反复调用read ,一旦该read 指示文件结束(EOF),该块将退出,withProcess_ 将做两件事:

  1. 调用stopProcess
  2. 调用checkExitCode ,以确保该进程成功退出

有多种事件的交错发生。成功的情况是这样的

  1. 子程序关闭stdout
  2. 子进程以退出代码0退出
  3. 父代收到EOF onread
  4. 母体调用stopProcess ,这是一个无用功(孩子已经退出了)。
  5. checkExitCode 得到退出代码0,并感到高兴

然而,在不同的进程时机下,也有可能得到。

  1. 子程序关闭stdout
  2. 母体收到EOFread
  3. 母体调用stopProcess ,它向子体发送一个SIGTERM
  4. 子代没有机会返回退出代码0,它已经死了
  5. checkExitCode 看到孩子由于 而退出,并抛出一个异常。SIGTERM

这似乎是一个角落,但它已经咬了我两次:第一次是在一个测试套件中,第二次是在新的Stack版本中的一个主要烦恼

该怪谁呢?

嗯,通常来说,应该责备的人是我自己。

It was me all the time

Unix进程API的使用可能很棘手,但它有清晰的文档,执行得很好。而且我认为,我对withProcess_使用是正确的抽象方式。不,问题在于withProcess_实现。 让我们再来看看这个过程:

  1. 启动一个进程
  2. 与进程一起运行一些块
  3. 然而该块退出(正常或异常),调用stopProcess ,然后确保有一个成功的退出代码。

在我们上面的第一个用法中,我们在块中调用了waitExitCode ,这就保证了在成功的情况下stopProcess ,最后总是作为一个no-op。一切都很顺利。问题是我假设cat的管道关闭与子进程的退出是一样的。我们知道这是不对的。然而,考虑到这个错误对我造成了两次打击,可以说我创建了一个鼓励滥用的API。

相反,对于withProcess_ ,我认为这是一个更好的实现。

  1. 启动一个进程
  2. 在该进程中运行一些块
  3. 如果该块抛出了一个异常,则通过以下方式终止子进程stopProcess
  4. 如果该块成功,等待进程退出,然后检查其退出代码是否成功

通过这样的行为调整,上面调用cat 的代码是安全的,我可以在晚上睡得更好。

弃用

推出一套新的行为,在运行时默默地(意思是:没有编译时的改变)修改行为是很危险的。使用withProcess_ 的人可能正是依赖于其当前的行为。因此,不替换当前的withProcess_ 行为,推出的策略是。

  1. 引入一个新的函数withProcessTerm_ ,它的行为与今天的withProcess_ 相同。
  2. 引入一个新的函数withProcessWait_ ,该函数具有我刚才描述的新行为
  3. 废弃withProcess_ ,并发出信息,说明调用者应该使用其中一个替代函数。

这将鼓励typed-process 的用户分析他们对withProcess_ 的使用,看看他们是否容易受到这里所描述的错误的影响,并选择适当的替换。