检测popen.stdout.readline流结束

6 投票
6 回答
7773 浏览
提问于 2025-04-17 15:46

我有一个用Python写的程序,它通过 Popen 启动子进程,并几乎实时地处理它们的输出。相关的代码循环是:

def run(self, output_consumer):
    self.prepare_to_run()
    popen_args = self.get_popen_args()
    logging.debug("Calling popen with arguments %s" % popen_args)
    self.popen = subprocess.Popen(**popen_args)
    while True:
        outdata = self.popen.stdout.readline()
        if not outdata and self.popen.returncode is not None:
            # Terminate when we've read all the output and the returncode is set
            break
        output_consumer.process_output(outdata)
        self.popen.poll()  # updates returncode so we can exit the loop
    output_consumer.finish(self.popen.returncode)
    self.post_run()

def get_popen_args(self):
    return {
        'args': self.command,
        'shell': False, # Just being explicit for security's sake
        'bufsize': 0,   # More likely to see what's being printed as it happens
                        # Not guarantted since the process itself might buffer its output
                        # run `python -u` to unbuffer output of a python processes
        'cwd': self.get_cwd(),
        'env': self.get_environment(),
        'stdout': subprocess.PIPE,
        'stderr': subprocess.STDOUT,
        'close_fds': True,  # Doesn't seem to matter
    }

这个在我的生产机器上运行得很好,但在我的开发机器上,当某些子进程完成时,调用 .readline() 会卡住。也就是说,它能成功处理所有输出,包括最后一行“进程完成”的输出,但在再次调用 readline 时就一直不返回。这个方法在开发机器上对我调用的大多数子进程都能正常退出,但对于一个复杂的bash脚本,它自己又调用了很多子进程时,总是无法正常退出。

值得注意的是,popen.returncode 在输出结束前的很多行就被设置为一个非 None 的值(通常是 0)。所以我不能仅仅在这个值被设置时就跳出循环,否则我会丢失在进程结束时输出的所有内容,这些内容仍然在缓冲区中等待读取。问题是,当我在那个时候刷新缓冲区时,我无法判断是否到了结束,因为最后一次调用 readline() 会卡住。调用 read() 也会卡住。调用 read(1) 能让我获取到最后一个字符,但在最后一行之后也会卡住。popen.stdout.closed 始终是 False。我该如何判断是否到了结束呢?

所有系统都在 Ubuntu 12.04LTS 上运行 Python 2.7.3。顺便提一下,stderr 是通过 stderr=subprocess.STDOUTstdout 合并的。

为什么会有这样的差异?是因为某种原因没有关闭 stdout 吗?子子进程可能做了什么让它保持打开状态吗?可能是因为我在开发机器的终端中启动进程,而在生产环境中是通过 supervisord 作为守护进程启动的吗?这会改变管道的处理方式吗?如果是的话,我该如何使它们正常化?

6 个回答

2

如果你使用readline()或read(),程序应该不会卡住。也不需要检查返回代码或轮询。如果你知道程序已经结束,但它还是卡住了,那很可能是有一个子进程让你的管道保持打开状态,正如其他人之前提到的。

你可以做两件事来调试这个问题: * 尝试用一个简单的脚本来重现这个问题,而不是用现在这个复杂的脚本,或者 * 用strace -f -e clone,execve,exit_group来运行那个复杂的脚本,看看这个脚本在启动什么进程,以及是否有任何进程在主脚本结束后还在运行(检查主脚本调用exit_group时,如果strace还在等待,那说明有一个子进程还活着)。

2

在不知道那个“复杂的 bash 脚本”具体内容的情况下,确实有太多可能性让人很难确定问题的确切原因。

不过,既然你提到在 supervisord 下运行你的 Python 脚本是可以正常工作的,那可能是因为某个子进程在尝试从标准输入(stdin)读取数据时卡住了,或者在标准输入是一个终端(tty)时表现得不一样。我猜 supervisord 会把输入重定向到 /dev/null,也就是不让它读取任何输入。

这个简单的例子似乎在处理我的例子 test.sh 运行子进程尝试从标准输入读取的情况时表现得更好……

import os
import subprocess

f = subprocess.Popen(args='./test.sh',
                     shell=False,
                     bufsize=0,
                     stdin=open(os.devnull, 'rb'),
                     stdout=subprocess.PIPE,
                     stderr=subprocess.STDOUT,
                     close_fds=True)

while 1:
    s = f.stdout.readline()
    if not s and f.returncode is not None:
        break
    print s.strip()
    f.poll()
print "done %d" % f.returncode

另外,你也可以选择使用一种叫做 非阻塞读取 的方法,当你看到输出的最后一行显示“进程完成”时就退出,虽然这有点像是变通的办法。

3

主要的代码循环看起来没问题。可能是因为有其他进程在使用这个管道,所以它没有关闭。比如说,如果你的脚本启动了一个后台进程去写入 stdout,那么这个管道就不会关闭。你确定没有其他子进程还在运行吗?

一个建议是,当你看到 .returncode 被设置时,改变一下模式。确认主进程完成后,从缓冲区读取所有输出,但不要卡在那儿等着。你可以使用 select 来设置一个超时,从管道中读取数据。设置几秒钟的超时,这样你就可以清空缓冲区,而不会被卡在等待子进程的状态。

撰写回答