Асинхронность в Python
Теория: Блокирующие операции и адаптеры
Асинхронный код в Python эффективно использует время ожидания ввода-вывода, но не решает всех проблем производительности. В реальных приложениях часто встречается код, который нельзя переписать под async/await: старые библиотеки, блокирующие функции или задачи с серьёзными вычислениями. Для интеграции таких функций в асинхронные программы используются пулы потоков (ThreadPoolExecutor) и процессов (ProcessPoolExecutor). Они позволяют запускать синхронный код параллельно, не блокируя главный цикл событий.
Использование ThreadPoolExecutor и ProcessPoolExecutor
ThreadPoolExecutor для I/O-операций
Когда мы вызываем asyncio.to_thread(func, *args), Python по умолчанию использует глобальный ThreadPoolExecutor. Однако, если приложение требует контроля над количеством потоков или поведения пула, удобнее создать свой экземпляр. Например, для задач чтения с диска или запросов к внешним API можно создать пул из 5 потоков и использовать его во всех корутинах:
В этом примере чтение десяти файлов выполняется параллельно, хотя каждая функция read_file блокирующая. Без пула потоков программа выполнялась бы последовательно и занимала бы около 10 секунд, а с пулом из 5 потоков все задачи завершаются примерно за 2 секунды. Это не «настоящая» асинхронность, но с точки зрения event loop — результат тот же: он не простаивает во время ожидания.
Пул можно создать заранее и переиспользовать:
ProcessPoolExecutor для CPU-операций
ProcessPoolExecutor используется аналогично, но каждая задача выполняется в отдельном процессе, а не в потоке. Это важно, потому что процессы не делят память и не блокируются глобальной блокировкой интерпретатора (GIL). Однако обмен данными между процессами требует сериализации через pickle, поэтому передача больших структур данных замедляет работу. Тем не менее, для независимых и изолированных задач ProcessPoolExecutor остаётся эффективным:
Внутренне оба пула управляются очередями заданий. Когда мы вызываем run_in_executor, задача помещается в очередь пула, а один из рабочих потоков или процессов её извлекает и выполняет. После завершения результат возвращается через будущее (Future), которое корутина ожидает с помощью await. Цикл событий остаётся свободным, пока процессы выполняют свои функции, и управление возвращается, когда они завершаются. При этом программа не блокируется и может обслуживать другие корутины.
С появлением Python 3.14 и особенно 3.15 многопоточность стала эффективнее благодаря переработке GIL. Ранее глобальная блокировка интерпретатора позволяла работать только одному потоку Python одновременно, что лишало ThreadPoolExecutor смысла для вычислений. Теперь, в режиме «free-threading», потоки действительно могут выполняться параллельно на разных ядрах процессора. Улучшена синхронизация между потоками, снижены накладные расходы при переключении контекста, что делает ThreadPoolExecutor быстрее и предсказуемее. Асинхронные программы, использующие пулы потоков, теперь выигрывают не только при работе с вводом-выводом, но и при умеренной вычислительной нагрузке.
Можно совмещать оба типа пулов — сетевые запросы в ThreadPoolExecutor, обработку данных в ProcessPoolExecutor:
При вызове cancel() корутина отменится, но задача в потоке/процессе продолжит выполнение. Исключения из пула передаются в корутину:
CPU-bound задачи в процессах
Для CPU-интенсивных операций ProcessPoolExecutor обеспечивает истинный параллелизм. Оптимальный размер пула обычно равен числу ядер процессора (os.cpu_count()):
Каждый процесс имеет собственный интерпретатор Python и не делит память с другими. Данные передаются через сериализацию (pickle), поэтому избегайте передачи тяжёлых структур. Для больших массивов чисел используйте общую память через multiprocessing.Array или библиотеки вроде numpy.
Интеграция sync API в async-программы
Если синхронная функция не поддерживает asyncio, её можно изолировать в пуле потоков или процессов.
Простейший способ — использовать asyncio.to_thread() (доступен с Python 3.9):
Для частого использования синхронных API создайте свой ThreadPoolExecutor:
Для CPU-интенсивных синхронных функций используйте ProcessPoolExecutor:
Функция calc_pi() загружает процессор на максимум, но цикл событий не блокируется и может обслуживать другие корутины.
Рекомендуемые программы
Завершено
0 / 10

