Дочерний процесс завис: после communicate timeout нужен kill и повторный сбор

Приложение запускает утилиту через Popen и ждёт результат. Чтобы ограничить ожидание, автор добавляет communicate(timeout=...). По истечении времени возникает TimeoutExpired, обработчик возвращает ошибку, а дочерний процесс продолжает работать. Ограничили ожидание родителя, но не завершили принадлежащую ему работу.

У Popen.communicate тайм-аут сам по себе не убивает ребёнка. Документация предлагает после TimeoutExpired вызвать kill, затем снова communicate. Второй вызов дожидается завершения и дочитывает оставшиеся данные из потоков. Для subprocess.run похожая очистка выполняется самим run, поэтому важно не переносить ожидания от одного API на другое.

Завершение после TimeoutExpired

Сделаем ребёнка, который пишет строку с flush=True, а затем долго спит. Родитель ограничивает ожидание и явно завершает процесс. Проверяем, что начальный вывод сохранился и у процесса уже есть код завершения. Конкретное числовое значение кода после kill зависит от платформы, поэтому тест не привязывается к одному числу.

import subprocess
import sys

process = subprocess.Popen(
    [sys.executable, "-c", "import time; print('started', flush=True); time.sleep(30)"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
    encoding="utf-8",
)
try:
    stdout, stderr = process.communicate(timeout=0.5)
except subprocess.TimeoutExpired:
    process.kill()
    stdout, stderr = process.communicate()
assert "started" in stdout
assert process.poll() is not None
print(stdout.strip(), process.poll() is not None)

Потоки и сбор процесса

Запуск Python 3.12 печатает started и True. Повторный communicate получает весь накопленный вывод; не нужно вручную добавлять к нему кусок из первого TimeoutExpired, иначе можно продублировать данные. Если код использует bytes вместо text=True, обработка результата должна последовательно работать с байтами.

Для короткой команды предпочитайте run, когда его возможностей достаточно. Popen нужен для более управляемого взаимодействия, и вместе с ним родитель получает обязанности по завершению и сбору процесса. Вынесите этот протокол в одну функцию, чтобы разные места приложения не забывали вторую половину очистки.

communicate читает stdout и stderr совместно с ожиданием, что помогает избежать взаимной блокировки на заполненных pipe. Вариант wait с обоими потоками PIPE может зависнуть, если ребёнок ждёт освобождения буфера, а родитель ждёт его окончания. При этом communicate буферизует результат в памяти. Для неограниченного вывода нужен другой протокол чтения или направление в файлы.

Если ребёнок запускает собственные процессы, kill одного Popen не означает остановку всего дерева. Политику группы процессов определяют отдельно под нужную платформу. В этом примере есть один ребёнок, поэтому его завершение полностью проверяется poll и сбором stdout. В прикладной обёртке тестируйте и обычный успех: исправление пути тайм-аута не должно терять stderr или менять обработку ненулевого статуса.

Когда локальный пример работает, полезно разобрать соседние механизмы языка и их взаимодействие. В курсе «Python для профессионалов» есть практика по итераторам, генераторам, декораторам, asyncio, метапрограммированию и профилированию. Эти темы помогают понимать время жизни объектов, управление выполнением и причины поведения прикладной программы.

Посмотреть программу и практику курса.

Документация: официальный источник по теме.

Другие задачи по теме: Типы, тесты и поведение программы.

Продолжить на практике

  • Python для профессионалов ↗

    Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.