Soft2Soft Dev Практическая база знаний
Python

Почему Python subprocess зависает при чтении stdout и как это исправить

36 просмотров
subprocess deadlock stdout

Если subprocess зависает при чтении stdout, проверьте три типовые причины: дочерний процесс заполнил stderr и заблокировался на записи; родитель ждёт завершения строки через readline(), но дочерний процесс не отправляет \n или не сбрасывает собственный буфер; либо родитель вызвал wait(), не опустошая созданные через PIPE каналы. Для конечного результата обычно используйте subprocess.run() или Popen.communicate(). Для потокового вывода обслуживайте все активные каналы конкурентно.

Применимые версии и ограничения

Основные примеры рассчитаны на Python 3.7 и новее: в этой версии у subprocess.run() появился аргумент capture_output, а text стал поддерживаемым псевдонимом universal_newlines. В Python 3.6 можно использовать явные stdout=subprocess.PIPE, stderr=subprocess.PIPE и universal_newlines=True.

import subprocess

result = subprocess.run(
    ["some-command"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    universal_newlines=True,
    check=False,
)

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

Почему возникает deadlock при stdout=PIPE и stderr=PIPE

При stdout=subprocess.PIPE и stderr=subprocess.PIPE операционная система создаёт каналы ограниченной ёмкости. Дочерний процесс записывает данные в каналы, а родитель должен их читать. Если один канал заполнится, очередная запись дочернего процесса может заблокироваться до освобождения места.

Опасный шаблон выглядит так:

import subprocess

proc = subprocess.Popen(
    ["some-command"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
)

stdout = proc.stdout.read()
stderr = proc.stderr.read()
proc.wait()

Пока родитель читает stdout до EOF, дочерняя программа может интенсивно писать в stderr. После заполнения stderr она остановится на записи и не сможет завершиться. Следовательно, она не закроет stdout, а родитель продолжит ждать EOF в stdout.

Если одновременно используются stdout=PIPE и stderr=PIPE, не читайте эти каналы последовательно до EOF и не вызывайте wait(), оставляя их непрочитанными. Для накопления конечного результата используйте communicate() или run(); для потокового журнала обслуживайте оба канала одновременно.

Для конечного вывода используйте subprocess.run()

Если данные нужны только после завершения команды, проще не управлять Popen вручную:

import subprocess

result = subprocess.run(
    ["some-command", "--option"],
    capture_output=True,
    text=True,
    check=False,
)

print("return code:", result.returncode)
print("stdout:", result.stdout)
print("stderr:", result.stderr)

capture_output=True настраивает захват стандартного вывода и стандартного потока ошибок. run() дожидается завершения команды и возвращает накопленные данные в объекте CompletedProcess.

Такой подход подходит, если вывод конечен и его допустимо хранить в памяти. Для очень большого или бесконечного потока полное накопление данных не подходит: вывод следует обрабатывать постепенно.

При работе с Popen используйте communicate()

Если нужен объект Popen, но обрабатывать строки во время выполнения не требуется, используйте communicate():

import subprocess

proc = subprocess.Popen(
    ["some-command"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
)

stdout, stderr = proc.communicate()

print("return code:", proc.returncode)
print("stdout:", stdout)
print("stderr:", stderr)

Официальная документация отдельно предупреждает: Popen.wait() может привести к взаимной блокировке при использовании stdout=PIPE или stderr=PIPE, если дочерний процесс записывает достаточно данных для заполнения канала. В этом случае документация рекомендует использовать Popen.communicate().

Не используйте следующий порядок при потенциально активном PIPE:

# Опасно, если дочерний процесс может заполнить PIPE
proc.wait()
stdout = proc.stdout.read()

Если процесс уже остановился на записи в заполненный канал, он не сможет завершиться, поэтому wait() также не вернёт управление.

Если разделять stderr и stdout не нужно, объедините их

Когда достаточно одного журнала, можно перенаправить stderr в stdout:

import subprocess

proc = subprocess.Popen(
    ["some-command"],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
)

output, _ = proc.communicate()
print(output)

В результате родителю требуется обслуживать только один канал. Ограничение очевидно: после объединения нельзя определить, был ли конкретный фрагмент первоначально отправлен в stdout или stderr.

readline() может ждать перевод строки

Остановка на следующей операции не обязательно является deadlock:

line = proc.stdout.readline()

readline() читает строку. Если дочерний процесс отправил часть данных без символа перевода строки и продолжает работать, вызов может ждать продолжения строки или EOF.

Например:

import time

print("starting...", end="", flush=True)
time.sleep(60)

Здесь данные действительно сбрасываются в стандартный вывод благодаря flush=True, но символа \n нет. Поэтому родитель, использующий построчное readline(), не должен считать появление отдельных байтов гарантией получения законченной строки.

Если протокол должен быть построчным, дочерняя программа должна формировать завершённые строки:

import sys

sys.stdout.write("starting...\n")
sys.stdout.flush()

Буферизация дочернего Python-процесса и параметр -u

Даже если дочерний код сформировал данные, они могут некоторое время оставаться в его собственных буферах. Если вы контролируете Python-программу, используйте явный flush=True, вызов flush() или подходящий режим запуска.

Интерпретатор Python поддерживает параметр -u, который переводит стандартные потоки stdout и stderr в небуферизованный режим на соответствующем уровне, описанном в документации командной строки Python:

import subprocess
import sys

proc = subprocess.Popen(
    [sys.executable, "-u", "worker.py"],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
)

Для программ на других языках правила зависят от их среды выполнения и реализации вывода. Настройки Popen не являются универсальным способом заставить произвольный дочерний процесс выполнять flush() после каждой записи.

Не путайте bufsize с буферизацией дочерней программы

Аргумент bufsize в Popen передаётся при создании файловых объектов каналов на стороне родительского Python-процесса. Он не отключает автоматически внутреннюю буферизацию запускаемой программы.

proc = subprocess.Popen(
    ["some-command"],
    stdout=subprocess.PIPE,
    text=True,
    bufsize=1,
)

Такая настройка сама по себе не гарантирует, что дочерняя программа начнёт немедленно отправлять данные. Если сообщения появляются только перед её завершением, проверяйте механизм буферизации именно в дочернем процессе.

Для потокового журнала читайте stdout и stderr конкурентно

Если строки необходимо получать во время выполнения и при этом важно различать два потока, оба канала должны регулярно опустошаться. Переносимый вариант — отдельный поток чтения для каждого канала:

import subprocess
import threading


def consume(name, stream):
    try:
        for line in stream:
            print(f"{name}: {line}", end="")
    finally:
        stream.close()


proc = subprocess.Popen(
    ["some-command"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
)

stdout_thread = threading.Thread(
    target=consume,
    args=("stdout", proc.stdout),
)
stderr_thread = threading.Thread(
    target=consume,
    args=("stderr", proc.stderr),
)

stdout_thread.start()
stderr_thread.start()

returncode = proc.wait()

stdout_thread.join()
stderr_thread.join()

print("return code:", returncode)

Пока дочерний процесс работает, один поток читает stdout, а второй — stderr, поэтому один канал не остаётся полностью необслуживаемым во время чтения другого.

Такой пример рассчитан на текстовый построчный протокол. Для бинарных данных или сообщений, не разделённых символом \n, способ чтения следует выбирать под фактический формат.

Два потока также не обеспечивают точный общий порядок событий между stdout и stderr: планировщик может передать управление читающим потокам родителя в ином порядке. Если важен единый наблюдаемый поток сообщений, проще использовать stderr=subprocess.STDOUT, понимая, что источник каждой строки после этого теряется.

Если захват не требуется, не создавайте PIPE

Когда задача состоит только в запуске программы с выводом в тот же терминал, PIPE не нужен:

import subprocess

result = subprocess.run(
    ["some-command"],
    check=False,
)

print("return code:", result.returncode)

Без явного перенаправления дочерний процесс наследует соответствующие стандартные потоки родителя. Это также полезная диагностическая проверка: если команда без PIPE завершается, а после добавления ручного чтения останавливается, изучайте схему чтения каналов и буферизацию.

Добавьте контролируемый тайм-аут

Тайм-аут не устраняет ошибочную работу с каналами, но позволяет ограничить время выполнения внешней команды.

import subprocess

try:
    result = subprocess.run(
        ["some-command"],
        capture_output=True,
        text=True,
        timeout=30,
        check=False,
    )
except subprocess.TimeoutExpired:
    print("process timeout")

При использовании Popen.communicate() после TimeoutExpired процесс можно явно завершить в соответствии с политикой приложения, а затем повторно вызвать communicate(), чтобы дождаться его окончания и дочитать каналы:

import subprocess

proc = subprocess.Popen(
    ["some-command"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
)

try:
    stdout, stderr = proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
    proc.kill()
    stdout, stderr = proc.communicate()

print("return code:", proc.returncode)

Воспроизводимый тест: заполнение stderr

Для проверки deadlock не нужна сторонняя команда. Создайте файл child_stderr.py, который много раз пишет в stderr, а затем выводит маркер в stdout:

import sys

chunk = "x" * 4096

for _ in range(10000):
    sys.stderr.write(chunk)

sys.stderr.flush()
print("finished")

Размер системного канала зависит от платформы, поэтому тест не должен опираться на конкретную ёмкость. Здесь намеренно создаётся объём вывода, значительно превышающий обычный небольшой поток диагностических сообщений.

Не используйте для такого дочернего процесса последовательное чтение stdout, а затем stderr. Безопасная проверка через communicate():

import subprocess
import sys

proc = subprocess.Popen(
    [sys.executable, "child_stderr.py"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
)

stdout, stderr = proc.communicate(timeout=30)

print("return code:", proc.returncode)
print("stdout:", stdout.strip())
print("stderr chars:", len(stderr))

Критерий проверки здесь не конкретное число символов, а успешное завершение процесса и наличие строки finished без зависания при заполнении второго канала.

Воспроизводимый тест: данные без перевода строки

Чтобы отдельно проверить поведение readline(), создайте child_no_newline.py:

import sys
import time

sys.stdout.write("partial")
sys.stdout.flush()

time.sleep(5)

sys.stdout.write("\n")
sys.stdout.flush()

Родительский код:

import subprocess
import sys
import time

proc = subprocess.Popen(
    [sys.executable, "child_no_newline.py"],
    stdout=subprocess.PIPE,
    text=True,
)

started = time.monotonic()
line = proc.stdout.readline()
elapsed = time.monotonic() - started

print("received:", repr(line))
print("seconds:", round(elapsed, 1))

proc.wait()

Дочерний процесс немедленно сбрасывает текст partial, но перевод строки отправляет только после паузы. Поэтому readline() возвращает законченную строку лишь после появления \n. Этот тест помогает отличить ожидание границы строки от взаимной блокировки из-за заполненного stderr.

Как определить конкретную причину

  1. Проверьте параметры Popen. Если одновременно указаны stdout=PIPE и stderr=PIPE, найдите код, обслуживающий оба канала.
  2. Если wait() вызывается до чтения каналов, замените схему на communicate() либо конкурентное чтение.
  3. Если сначала выполняется stdout.read() до EOF, а затем stderr.read(), исключите последовательное чтение двух активно заполняющихся каналов.
  4. Если выполнение остановилось на readline(), проверьте наличие \n и факт сброса данных дочерней программой.
  5. Если сообщения появляются только при завершении дочерней программы, проверьте её собственную буферизацию.
  6. Если разделение потоков не требуется, рассмотрите stderr=STDOUT.
  7. Если захват вывода не требуется вообще, не создавайте PIPE.

Итоговый чек-лист

  • Родитель не вызывает wait(), оставляя активно заполняемые PIPE непрочитанными.
  • stdout и stderr не читаются последовательно до EOF, если оба потока могут активно использоваться.
  • Для конечного результата применяется run() или communicate().
  • Для потоковой обработки все активные каналы обслуживаются конкурентно.
  • Код с двумя читающими потоками не предполагает сохранения точного общего порядка stdout и stderr.
  • При использовании readline() дочерний протокол действительно формирует строки с переводом строки.
  • bufsize родительского Popen не используется как замена управлению буферизацией дочерней программы.
  • Для потенциально долгих внешних команд предусмотрены подходящий тайм-аут и политика завершения процесса.
  • Исправление отдельно проверено тестом интенсивной записи в stderr и тестом вывода без немедленного \n.

Источники