2025, Oct 02 15:17
Управление задачами и подпроцессами ThreadPoolExecutor сигналами UNIX
Практическое решение для Python: управление ThreadPoolExecutor и подпроцессами через SIGUSR1 и SIGTERM. Остановка планирования и корректное завершение задач.
Управление пулом долго работающих заданий-подпроцессов уже после запуска — распространенная задача: приостановить планирование, дождаться завершения текущей работы и затем возобновить выполнение или корректно выйти. Когда в Python для отправки вызовов подпроцессов используется ThreadPoolExecutor, первое желание — привязать горячую клавишу. Однако в терминалах WSL библиотеки-хуки вроде pynput или keyboard могут не получать нажатия как ожидается, а блокирующий ввод неприемлем. Подход ниже заменяет хоткеи сигналами UNIX — это надежный и удобный для автоматизации способ управления во время выполнения.
Минимальный пример, показывающий проблему управления
Схема проста: пул отправляет задачи в подпроцессы, но как только запуск начался, нет надежного способа ненаблокированно приостановить планирование новых задач.
import concurrent.futures
import subprocess
import sys
import time
def do_work(arg):
# Имитируем медленный вызов подпроцесса
return subprocess.run([
sys.executable,
"-c",
f"import time; time.sleep({arg})"
])
def main():
durations = [5, 4, 6, 3, 5, 4]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
future_list = [pool.submit(do_work, d) for d in durations]
for fut in concurrent.futures.as_completed(future_list):
_ = fut.result()
if __name__ == "__main__":
main()
После того как пул начинает отправлять задачи, в терминале WSL нельзя неблокирующим образом приостановить или остановить планирование следующей работы простым нажатием клавиши. Библиотеки, слушающие глобальные хоткеи, могут не видеть нажатия в этой среде, а использование input блокирует основной поток. Сигнализация через файлы здесь тоже нежелательна.
Что на самом деле происходит
Суть проблемы — не в самом executor, а в канале управления. Хуки горячих клавиш могут не доставлять события в процесс Python, запущенный в терминале WSL, и не остается надежного способа менять состояние без блокирующего чтения или опроса файлов. Как только задания отправлены, выполнение идет по принципу «запустил и забыл».
Решение: сигналы UNIX, имя процесса и явные обработчики
Практичный путь — управлять процессом Python снаружи с помощью сигналов UNIX. Сначала задайте процессу отличимое имя через setproctitle, чтобы можно было целиться по имени. Затем добавьте обработчики для SIGTERM и SIGUSR1. Сигналом SIGUSR1 можно приказать планировщику прекратить постановку новых задач, позволив текущим подпроцессам завершиться. Сигналом SIGTERM — выйти корректно. Поскольку у процесса есть узнаваемое имя, достаточно pkill -USR1 -f [exe_name], чтобы послать сигнал без ручного поиска PID.
Полная реализация
Код ниже задает имя процесса, реагирует на SIGUSR1 остановкой дальнейшего планирования и на SIGTERM — корректным завершением. Запущенные подпроцессы дожидаются окончания. Логика соответствует описанному подходу и делает управление явным и наблюдаемым.
import concurrent.futures
import signal
import subprocess
import sys
import threading
import time
from setproctitle import setproctitle
# Глобальные флаги управления, которые меняются обработчиками сигналов
halt_queue = threading.Event()
terminate_run = threading.Event()
def on_usr1(sig, frame):
# Остановить постановку новых задач; текущие — дать завершиться
halt_queue.set()
def on_term(sig, frame):
# Запросить корректную остановку: прекратить добавление задач и перейти к завершению
halt_queue.set()
terminate_run.set()
def worker_job(delay_s):
# Имитируем медленный вызов подпроцесса, не прерывая его при управляющих событиях
return subprocess.run([
sys.executable,
"-c",
f"import time; time.sleep({delay_s})"
])
def orchestrate():
# Присваиваем процессу узнаваемое имя, чтобы нацеливаться через pkill -f
setproctitle("batch-runner-example")
# Подключаем обработчики сигналов
signal.signal(signal.SIGUSR1, on_usr1)
signal.signal(signal.SIGTERM, on_term)
tasks = [5, 4, 6, 3, 5, 4, 7, 2]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as exe:
submitted = []
for item in tasks:
if halt_queue.is_set():
break
fut = exe.submit(worker_job, item)
submitted.append(fut)
for fut in concurrent.futures.as_completed(submitted):
_ = fut.result()
# Необязательная логика после завершения всех отправленных задач
if terminate_run.is_set():
# Корректное завершение уже выполнено
pass
if __name__ == "__main__":
orchestrate()
С этим подходом управление выполняется «снаружи внутрь» и легко автоматизируется. Один раз задайте имя процесса и отправляйте сигналы по имени — без блокировки stdin и без надежды на слушатели клавиш в терминале.
Как отправлять сигналы по имени
Сигналы можно отправлять, не разыскивая PID. Используйте имя процесса, заданное в скрипте. SIGUSR1 останавливает постановку новых задач, позволяя текущим подпроцессам завершиться. SIGTERM по умолчанию обеспечивает корректную остановку тем же механизмом.
# Приостановить постановку новых задач (текущие — дать завершиться)
pkill -USR1 -f batch-runner-example
# Запросить корректное завершение (сигнал по умолчанию — SIGTERM)
pkill -f batch-runner-example
При желании можно найти процесс через ps и отправить сигнал напрямую с помощью kill.
В Linux можно использовать ps, чтобы найти process_id, а затем kill process_id для отправки сигнала (по умолчанию SIGTERM). Приложение должно иметь обработчик сигналов, чтобы корректно отреагировать.
Как правило, сигналы, отправленные процессу Python, обрабатываются через обработчик сигналов (модуль стандартной библиотеки signal).
В качестве альтернативы самому параллельному исполнению некоторые практики рекомендуют GNU Parallel для запуска скриптов в несколько потоков. Подход выше делает акцент на управлении со стороны Python через сигналы.
Почему это важно
Сигналы Unix дают предсказуемый и малозатратный контроль там, где хуки клавиатуры ненадежны или нежелательны. Вы избегаете Ctrl+C, который немедленно убил бы подпроцессы, и обходите блокирующий ввод или опрос файлов. В итоге нагрузку можно приостанавливать извне и безопасно «сливать» без радикальных изменений модели исполнения.
Итоги
Если нужно приостанавливать планирование или корректно завершать работу в ThreadPoolExecutor, запускающем подпроцессы, отдавайте предпочтение сигналам, а не слушателям хоткеев в терминалах WSL/Linux. Задайте отдельное имя процесса с помощью setproctitle, добавьте обработчики SIGUSR1 и SIGTERM и контролируйте отправку задач через общий флаг. Управляйте выполнением командой pkill -USR1 -f [exe_name], чтобы прекратить постановку новых задач, и используйте стандартный SIGTERM для упорядоченного выхода. Так плоскость управления останется простой, сценаризуемой и устойчивой для длинных пакетов задач.