Как совмещать потоки и асинхронность в Python?

«Как совмещать потоки и асинхронность в Python?» — вопрос из категории Асинхронность, который задают на 10% собеседований Python Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Да, совмещать потоки (threading) и асинхронность (asyncio) в Python возможно, но требует понимания их различий и осторожности. Они решают разные задачи конкурентности:

  • asyncio: Однопоточная конкурентность, идеальна для I/O-bound задач (сетевые запросы, чтение/запись файлов), где ожидание внешних операций не блокирует выполнение других задач.
  • threading: Многопоточная конкурентность, полезна для CPU-bound задач (интенсивные вычисления), хотя в CPython GIL (Global Interpreter Lock) ограничивает истинный параллелизм для таких задач, позволяя выполнять только один поток Python-кода одновременно.

Основные сценарии совмещения:

  1. Запуск блокирующего кода в асинхронном приложении: Если в asyncio приложении необходимо выполнить блокирующую (синхронную) операцию, которая может "заморозить" весь цикл событий, её следует вынести в отдельный поток. Для этого используется loop.run_in_executor() с ThreadPoolExecutor.

    Пример:

    import asyncio
    import time
    from concurrent.futures import ThreadPoolExecutor
    
    def blocking_io_task(duration):
        """Имитация блокирующей I/O операции."""
        print(f"[Thread {time.time():.2f}] Начинаю блокирующую задачу на {duration} сек...")
        time.sleep(duration) # Блокирующая операция
        print(f"[Thread {time.time():.2f}] Блокирующая задача завершена.")
        return f"Результат блокирующей задачи ({duration}s)"
    
    async def non_blocking_async_task(name):
        """Неблокирующая асинхронная задача."""
        for i in range(3):
            print(f"[Async {time.time():.2f}] {name} работает... ({i+1}/3)")
            await asyncio.sleep(0.5)
        print(f"[Async {time.time():.2f}] {name} завершена.")
    
    async def main():
        loop = asyncio.get_running_loop()
        executor = ThreadPoolExecutor(max_workers=2)
    
        # Запускаем неблокирующие асинхронные задачи
        task1 = asyncio.create_task(non_blocking_async_task("Task A"))
        task2 = asyncio.create_task(non_blocking_async_task("Task B"))
    
        # Запускаем блокирующие задачи в отдельных потоках через ThreadPoolExecutor
        # Это предотвращает блокировку основного цикла событий asyncio
        blocking_result1 = await loop.run_in_executor(executor, blocking_io_task, 2)
        blocking_result2 = await loop.run_in_executor(executor, blocking_io_task, 1)
    
        print(f"[Main {time.time():.2f}] Получен результат блокирующей задачи 1: {blocking_result1}")
        print(f"[Main {time.time():.2f}] Получен результат блокирующей задачи 2: {blocking_result2}")
    
        await asyncio.gather(task1, task2) # Ожидаем завершения асинхронных задач
    
    if __name__ == "__main__":
        asyncio.run(main())
  2. Запуск цикла событий asyncio в отдельном потоке: Менее распространенный сценарий, когда необходимо запустить asyncio цикл в фоновом потоке и взаимодействовать с ним из основного потока. Используются функции типа asyncio.run_coroutine_threadsafe() или loop.call_soon_threadsafe() для безопасной отправки корутин или колбэков в цикл событий из другого потока.

Важные соображения:

  • GIL (Global Interpreter Lock): В CPython GIL не позволяет нескольким потокам выполнять Python-байткод одновременно. Это означает, что threading не дает истинного параллелизма для CPU-bound задач. Для таких задач лучше использовать multiprocessing.
  • Синхронизация: При совмещении потоков и асинхронности необходимо тщательно управлять общими ресурсами и использовать соответствующие механизмы синхронизации (мьютексы, очереди, семафоры) для предотвращения состояний гонки и ошибок.
  • Выбор инструмента: Всегда старайтесь использовать наиболее подходящий инструмент для конкретной задачи. Для I/O-bound задач предпочтительнее чистый asyncio. Для CPU-bound задач — multiprocessing.