subprocess.check_output与subprocess.call的性能比较

32
我已经使用subprocess.check_output()一段时间来捕获子进程的输出,但在某些情况下遇到了性能问题。我在RHEL6机器上运行此操作。
调用Python环境为linux编译和64位。我正在执行的子进程是一个shell脚本,最终通过Wine启动一个Windows python.exe进程(为什么需要这种愚蠢的操作是另一回事)。作为shell脚本的输入,我正在传输一小段Python代码,该代码会传递给python.exe。
当系统负载中等/较重(40%至70%的CPU利用率)时,我注意到使用subprocess.check_output(cmd, shell=True)可能会导致显着的延迟(长达约45秒),在子进程完成执行后,check_output命令才返回。此期间,通过ps -efH查看输出显示被调用的子进程为sh <defunct>,直到最终以正常的零退出状态返回。
相反,使用subprocess.call(cmd, shell=True)在相同的中等/较重负载下运行相同的命令将导致子进程立即返回,所有输出都打印到STDOUT / STDERR(而不是从函数调用返回)。
为什么只有在check_output()将STDOUT / STDERR输出重定向到其返回值时才会出现如此显着的延迟,而call()仅将其打印回父进程的STDOUT / STDERR没有延迟?

你尝试过在新版本的Python上或使用subprocess32模块运行相同的代码,以查看是否存在异常延迟,即旧版本中是否存在错误? - jfs
我还没有,因为我的脚本需要几个仅在2.7.x可用的包。我已经尝试过不使用完整脚本重现问题,但是还没有成功。如果我能够隔离和复制没有库依赖性的问题,我会尝试你的建议。 - greenlaw
subprocess32 可在 Python 2.7 (posix 系统) 上运行。 - jfs
2个回答

35

阅读文档,subprocess.callsubprocess.check_output都是subprocess.Popen的使用案例。一个小区别是如果子进程返回非零退出状态,则check_output会引发Python错误。更大的区别强调了关于check_output的部分(我加粗了):

整个函数签名基本上与Popen构造函数相同,除了stdout不被允许,因为它在内部使用。所有其他提供的参数都直接传递给Popen构造函数。

那么stdout是如何“在内部使用”的呢?让我们比较一下callcheck_output

call

def call(*popenargs, **kwargs):
    return Popen(*popenargs, **kwargs).wait() 

check_output

:检查并返回程序执行结果的标准输出。
def check_output(*popenargs, **kwargs):
    if 'stdout' in kwargs:
        raise ValueError('stdout argument not allowed, it will be overridden.')
    process = Popen(stdout=PIPE, *popenargs, **kwargs)
    output, unused_err = process.communicate()
    retcode = process.poll()
    if retcode:
        cmd = kwargs.get("args")
        if cmd is None:
            cmd = popenargs[0]
        raise CalledProcessError(retcode, cmd, output=output)
    return output

通信

现在我们需要看一下 Popen.communicate。这样做,我们注意到对于一个管道,communicate 做了几件事情,比返回 Popen().wait() 更花费时间,而 call 则不同。

一方面,communicate 处理 stdout=PIPE,无论您是否设置了 shell=True。显然,call 不会这样做。它只让您的 shell 输出任何内容... 这是一个安全风险,正如 Python 在这里描述的那样。

其次,在 check_output(cmd, shell=True) 的情况下(仅有一个管道)... 你的子进程发送到 stdout 的任何内容都将被 _communicate 方法中的一个线程处理。而且,Popen 必须加入该线程(等待它),然后才能等待子进程本身终止!

此外,更微不足道的是,它将 stdout 处理为一个 list,必须将其连接成一个字符串。

简而言之,即使是最少的参数,check_output 在 Python 进程中花费的时间比 call 要多得多。


我不认为这是一个安全风险;Python文档只是警告我们在使用未经过处理的输入构建命令时避免使用shell=True。但我理解你所说的关于运行check_output会增加额外复杂性的观点。我认为如果没有提供一些确切的重现案例,我不会得到完整的答案,所以你的回答是最接近的。 - greenlaw
@greenlaw:这个答案没有解释为什么会有大约45秒的延迟。而且,我怀疑线程只在Windows上使用,并且只有在重定向超过一个流时才会使用,即check_output(cmd, shell=True)不使用线程。 - jfs

3

让我们看一下代码。.check_output具有以下等待时间:

    def _internal_poll(self, _deadstate=None, _waitpid=os.waitpid,
            _WNOHANG=os.WNOHANG, _os_error=os.error, _ECHILD=errno.ECHILD):
        """Check if child process has terminated.  Returns returncode
        attribute.

        This method is called by __del__, so it cannot reference anything
        outside of the local scope (nor can any methods it calls).

        """
        if self.returncode is None:
            try:
                pid, sts = _waitpid(self.pid, _WNOHANG)
                if pid == self.pid:
                    self._handle_exitstatus(sts)
            except _os_error as e:
                if _deadstate is not None:
                    self.returncode = _deadstate
                if e.errno == _ECHILD:
                    # This happens if SIGCLD is set to be ignored or
                    # waiting for child processes has otherwise been
                    # disabled for our process.  This child is dead, we
                    # can't get the status.
                    # http://bugs.python.org/issue15756
                    self.returncode = 0
        return self.returncode

.call 使用以下代码进行等待:
    def wait(self):
        """Wait for child process to terminate.  Returns returncode
        attribute."""
        while self.returncode is None:
            try:
                pid, sts = _eintr_retry_call(os.waitpid, self.pid, 0)
            except OSError as e:
                if e.errno != errno.ECHILD:
                    raise
                # This happens if SIGCLD is set to be ignored or waiting
                # for child processes has otherwise been disabled for our
                # process.  This child is dead, we can't get the status.
                pid = self.pid
                sts = 0
            # Check the pid and loop as waitpid has been known to return
            # 0 even without WNOHANG in odd situations.  issue14396.
            if pid == self.pid:
                self._handle_exitstatus(sts)
        return self.returncode

注意与internal_poll相关的错误。可以在http://bugs.python.org/issue15756查看。这几乎正是你遇到的问题。


编辑: .call和.check_output之间的另一个潜在问题是,.check_output实际上关心stdin和stdout,并会尝试针对两个管道执行IO操作。如果你遇到了一个进程陷入僵尸状态的情况,那么可能是针对处于亡故状态的管道进行读取导致了你所经历的挂起。

在大多数情况下,僵尸状态会很快清除,但是,如果它们在系统调用中被中断(如读取或写入),则不会清除。当然,读/写系统调用本身应该尽快被中断,一旦无法执行IO,但是,你可能遇到某种竞争条件,其中事情以不良顺序被杀死。

我想到的唯一确定原因的方法是要么向子进程文件添加调试代码,要么在遇到你所经历的条件时调用python调试器并启动回溯。


2
不完全是这样...Bug的注释说明受影响的代码将无限期地挂起,而我的代码经过显著的延迟后最终会返回。 - greenlaw
@Claris:如果一个进程已经退出但其状态尚未被其父进程读取,则该进程是一个僵尸进程。在这种情况下,sh是一个僵尸进程,因为父 Python 进程在 p.stdout.read() 调用上挂起,如果 sh 生成自己的子进程并继承其 stdout,则可能会发生这种情况,例如 call('(sleep 5; echo abc) &', shell=True) 应该立即返回,但 check_output('(sleep 5; echo abc) &', shell=True) 只有在 5 秒钟后才返回。 - jfs
@greenlaw:你尝试过将SIGALRM设置为查看堆栈跟踪以便于调试目的,如果子进程挂起了吗? - jfs

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