使用select和pty与子进程一起捕获输出时出现挂起的问题

8

我想编写一个Python程序,能够与其他程序交互,即发送stdin并接收stdout数据。但我不能使用pexpect(尽管它的某些设计受到了启发)。我目前使用的过程如下:

  1. 附加一个pty到子进程的stdout
  2. 循环检查subprocess.poll,直到子进程退出
    • 当stdout中有数据可用时,立即将该数据写入当前stdout。
  3. 完成!

我已经原型化了一些代码(如下),虽然可以运行,但似乎有一个毛病一直困扰着我。在子进程完成后,如果不在调用select.select时指定超时,父进程就会挂起。我真的不希望设置超时。这似乎有点不利于代码的优化。然而,我试图解决这个问题的所有其他方法都不起作用。Pexpect似乎通过使用os.execvpty.fork而不是subprocess.Popenpty.openpty来解决这个问题,但这不是我所偏好的解决方案。我在检查子进程的生命状态方面是否做错了什么?我的方法是否不正确?

如下为我使用的代码。我在Mac OS X 10.6.8上使用它,但我也需要它可以在Ubuntu 12.04上运行。

这是子进程运行器runner.py

import subprocess
import select
import pty
import os
import sys

def main():
    master, slave = pty.openpty()

    process = subprocess.Popen(['python', 'outputter.py'], 
            stdin=subprocess.PIPE, 
            stdout=slave, stderr=slave, close_fds=True)

    while process.poll() is None:
        # Just FYI timeout is the last argument to select.select
        rlist, wlist, xlist = select.select([master], [], [])
        for f in rlist:
            output = os.read(f, 1000) # This is used because it doesn't block
            sys.stdout.write(output)
            sys.stdout.flush()
    print "**ALL COMPLETED**"

if __name__ == '__main__':
    main()

这是子进程代码outputter.py。其中奇怪的随机部分只是为了模拟程序在随机时间间隔输出数据。如果你愿意的话,可以去掉它。这应该没有关系。
import time
import sys
import random

def main():
    lines = ['hello', 'there', 'what', 'are', 'you', 'doing']
    for line in lines:
        sys.stdout.write(line + random.choice(['', '\n']))
        sys.stdout.flush()
        time.sleep(random.choice([1,2,3,4,5])/20.0)
    sys.stdout.write("\ndone\n")
    sys.stdout.flush()

if __name__ == '__main__':
    main()

感谢您提供的任何帮助!

额外说明

使用pty是因为我想确保stdout不被缓冲。

4个回答

12

首先,与您所述的相反,os.read 是阻塞的。但是,在使用 select 之后,它不再阻塞。此外,对于已关闭的文件描述符执行 os.read 将始终返回一个空字符串,您可能需要进行检查。

然而,真正的问题在于主设备描述符从未关闭,因此最终的 select 是将会阻塞的。在一种罕见的竞争条件下,子进程在 selectprocess.poll() 之间退出,您的程序可以完美地退出。但是大多数情况下,select 将永远阻塞。

如果按照 izhak 建议的安装信号处理程序,则会出现严重问题;每当子进程终止时,信号处理程序都会运行。运行信号处理程序之后,该线程中的原始系统调用无法继续,因此该系统调用将返回非零 errno,这通常会导致 python 抛出一些随机异常。现在,如果在程序的其他地方使用了某个库,该库具有任何不知道如何处理此类异常的阻塞系统调用,那么您就遇到了大麻烦(例如任何 os.read 在成功的 select 后都可能抛出异常)。

在随机抛出异常和稍微轮询之间进行权衡,我认为设置 select 的超时时间并不是一个坏主意。无论如何,您的进程仍然几乎是系统上唯一的(缓慢)轮询进程。


感谢您的出色解释。过了一会儿,我意识到最好设置一个超时时间。我尝试了izhak的解决方案,但是在这样做后,我看到了一些非常奇怪的行为。这对我帮助很大! - ravenac95
为了我的自我提高,您能解释一下为什么我的答案不够好吗?它应该让您避免使用任何超时。 - the paul
我已经在相关问题的答案中实现了您的建议。 - jfs

9
有很多可以改变的方法让你的代码正确。我能想到最简单的方法就是在 fork 之后关闭父进程的从属 fd 副本,这样当子进程退出并关闭自己的从属 fd 时,父进程的 select.select() 将会标记主 fd 可读,在随后的 os.read() 中将返回空结果,你的程序将完成。(直到两个从属 fd 均关闭,pty 主机才会看到从属端已关闭。)
所以,只需要一行:
os.close(slave)

..放在subprocess.Popen调用后面,应该能解决你的问题。

但是,根据你的具体需求,可能有更好的答案。正如其他人所指出的,你不需要使用pty来避免缓冲。你可以使用裸露的os.pipe()代替pty.openpty()(并且完全相同地处理返回值)。裸露的操作系统管道永远不会缓存;如果子进程没有缓存其输出,则你的select()os.read()调用也不会看到缓存。然而,你仍然需要os.close(slave)这行代码。

但是有可能你确实需要pty出于不同的原因。如果你的某些子程序大部分时间都期望以交互方式运行,那么它们可能会检查它们的标准输入是否为pty,并根据答案采取不同的行动(许多常见的实用程序都这样做)。如果你真的想让子进程认为为其分配了终端,则pty模块是正确的选择。根据你将如何运行runner.py,你可能需要从使用subprocess切换到使用pty.fork(),以便子进程具有其会话ID和预先打开的pty(或者查看pty.py的源代码以了解它所做的并在你的子进程对象的preexec_fn可调用中重复适当的部分)。


事实上,从未关闭从属描述符,这是我的疏忽。然而,仅有这一行还不够,因为os.read对子进程被杀死的反应是errno = EIO,因此所有读取都必须受到try-except的保护,检查errno = EIO及其原因。 - Antti Haapala -- Слава Україні
嗯,在从管道读取数据时不应该出现EIO错误。在读取端,根据POSIX语义,你只会得到一个短读取(在这种情况下,就是空字符串——Python的EOF)。 - the paul
多有趣啊!我在EC2上使用裸的ubuntu-precise-12.04-amd64-server-20120616镜像运行了200次,但无法在Linux 3.2上重现。EIO只应该用于硬件或意外的文件系统错误。 - the paul
奇怪。 "Linux ubuntu 3.2.0-26-generic #41-Ubuntu SMP Thu Jun 14 17:49:24 UTC 2012 x86_64 x86_64 x86_64 GNU/Linux" 在第5次运行时失败,"Linux 3.1.10-grbfs-custom #2 SMP Sun Jan 22 18:37:08 EET 2012 x86_64 GNU/Linux" 在第一次运行时就失败了。你确定没有意外运行output.py吗(我刚才也发生了这种情况:)。然而,当运行父进程时,出现OSError:[Errno 5]输入/输出错误,输出= os.read(f,1000)。 - Antti Haapala -- Слава Україні
同时也是第一次尝试在64位EC2自定义精确镜像上进行。 - Antti Haapala -- Слава Україні
非常确定-每次运行都以"**ALL COMPLETED**"消息结束。这很有趣-我希望你不介意我们尝试确定区分因素是什么。您是否在同一台物理机器上尝试了这两个内核?您对源代码的唯一更改是添加了os.close(slave)s/pty\.openpty/os.pipe/吗? - the paul

0
据我所知,您不需要使用ptyrunner.py可以进行修改。
import subprocess
import sys

def main():
        process = subprocess.Popen(['python', 'outputter.py'],
                        stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE, stderr=subprocess.PIPE)

        while process.poll() is None:
                output = process.stdout.readline()
                sys.stdout.write(output)
                sys.stdout.flush()
        print "**ALL COMPLETED**"

if __name__ == '__main__':
        main()

process.stdout.read(1)可以用来替代process.stdout.readline()从子进程实时地按字符输出。

注意:如果您不需要子进程的实时输出,请使用Popen.communicate来避免轮询循环。


1
panickal:谢谢您的回复,但我实际上想确保任何输出都不会被缓冲,因此需要使用pty。我将编辑问题以明确这是一个要求。 - ravenac95
如果runner.py程序正在与Python程序交互,您可以在Popen命令中添加python -u以启用无缓冲输出。我已经使用outputter.py进行了测试,它可以正常工作。 - panickal
1
不幸的是,它们并不总是Python应用程序 :-/ - ravenac95

0

当您的子进程退出时,父进程会收到SIGCHLD信号。默认情况下,此信号被忽略,但您可以拦截它:

import sys
import signal

def handler(signum, frame):
    print 'Child has exited!'
    sys.exit(0)

signal.signal(signal.SIGCHLD, handler)

信号还应该中断阻塞的系统调用,如“select”或“read”(或者你正在进行的任何操作),并让你在处理函数中执行必要的操作(清理、退出等)。


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