Использование подпроцесса с select и pty зависает при захвате вывода

Я пытаюсь написать программу python, способную взаимодействовать с другими программами. Это означает отправку stdin и получение данных stdout. Я не могу использовать pexpect (хотя он определенно вдохновил часть дизайна). Процесс, который я сейчас использую, заключается в следующем:

  • Прикрепите pty к подпроцессу stdout
  • Петля, пока подпроцесс не завершится, проверив subprocess.poll
    • Когда есть данные, доступные в stdout, немедленно записывайте эти данные в текущий stdout.
  • Готово!

Я прототипировал какой-то код (ниже), который работает, но, похоже, имеет один изъян, который подтачивает меня. После завершения дочернего процесса родительский процесс зависает, если я не укажу таймаут при использовании select.select. Я бы предпочел не устанавливать тайм-аут. Это просто немного грязно. Тем не менее, все другие способы, с помощью которых я пытался обойти эту проблему, похоже, не работают. Кажется, что Pexpect обходит его, используя os.execv и pty.fork вместо subprocess.Popen и pty.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 не буферизовался.

Ответы

Ответ 1

Прежде всего, os.read блокирует, вопреки тому, что вы заявляете. Однако после select он не блокируется. Кроме того, os.read в закрытом дескрипторе файла всегда возвращает пустую строку, которую вы можете проверить.

Однако реальная проблема заключается в том, что дескриптор ведущего устройства никогда не закрывается, поэтому окончательный select является тем, который будет блокироваться. В условиях редкой расы дочерний процесс выходил между select и process.poll(), и ваша программа отлично прекращается. Большую часть времени, однако, блокирует выбор навсегда.

Если вы установите обработчик сигнала, как было предложено izhak, все ад разрывается; всякий раз, когда дочерний процесс завершается, выполняется обработчик сигнала. После запуска обработчика сигнала исходный системный вызов в этом потоке не может быть продолжен, так что вызов syscall возвращает ненулевое значение errno, что часто приводит к тому, что какое-то случайное исключение выбрасывается в python. Теперь, если в другом месте вашей программы вы используете какую-либо библиотеку с любыми системными вызовами блокировки, которые не знают, как обрабатывать такие исключения, у вас большие проблемы (любой os.read, например, где-либо теперь можно исключить исключение даже после успешной select).

Взвешивание с случайными исключениями, отброшенными в любом месте, против опроса немного, я не думаю, что тайм-аут на select не звучит так плохо. В любом случае, ваш процесс все равно будет единственным (медленным) процессом опроса в системе.

Ответ 2

Есть несколько вещей, которые вы можете изменить, чтобы сделать свой код правильным. Самое простое, что я могу придумать, это просто закрыть копию родительского процесса ведомого fd после форсирования, так что, когда ребенок выйдет и закроет свой собственный ведомый fd, родительский select.select() отметит мастер как доступный для чтения, и последующий os.read() даст пустой результат, и ваша программа завершится. (Мастер pty не будет считать конец подчиненного устройства закрытым до тех пор, пока обе копии ведомого fd не будут закрыты.)

Итак, только одна строка:

os.close(slave)

.., помещенный сразу после вызова subprocess.Popen, должен исправить вашу проблему.

Однако есть, возможно, лучшие ответы, в зависимости от ваших требований. Как заметил кто-то другой, вам не нужно pty, чтобы избежать буферизации. Вы можете использовать голый os.pipe() вместо pty.openpty() (и обрабатывать возвращаемое значение точно так же). Голая OS-трубка никогда не будет буферизироваться; если дочерний процесс не буферизует свой вывод, тогда ваши вызовы select() и os.read() также не будут видеть буферизацию. Тем не менее вам понадобится строка os.close(slave).

Но возможно, что вам нужно pty по разным причинам. Если некоторые из ваших дочерних программ ожидают, что они будут выполняться в интерактивном режиме большую часть времени, тогда они могут проверять, является ли их stdin pty и ведет себя по-разному в зависимости от ответа (это делает множество общих утилит). Если вы действительно хотите, чтобы ребенок подумал, что у него есть выделенный для него терминал, то модуль pty - это путь. В зависимости от того, как вы будете запускать runner.py, вам может потребоваться переключиться с subprocess на pty.fork(), чтобы у ребенка был установлен его идентификатор сеанса, а pty предварительно открыт (или посмотреть источник для pty.py чтобы увидеть, что он делает, и дублировать соответствующие части вашего объекта подпроцесса preexec_fn, вызываемого).

Ответ 3

Из того, что я понимаю, вам не нужно использовать pty. runner.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, чтобы избежать цикла опроса.

Ответ 4

Когда ваш дочерний процесс завершается - ваш родительский процесс получает сигнал SIGCHLD. По умолчанию этот сигнал игнорируется, но вы можете его перехватить:

import sys
import signal

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

signal.signal(signal.SIGCHLD, handler)

Сигнал также должен отключить системный столбец блокировки, чтобы "выбрать" или "прочитать" (или что бы вы ни находились), и позволить вам делать все, что вам нужно (очистка, выход и т.д.) в функции обработчика.