> Как обойти блокировку при вызове синхронной функции в асинхронном коде в Python? (Python)

Уровень: senior · Роль: backend · Язык: Python · Категория: Технические вопросы

Компании: HeadHunter

Стек: Python

> Пример ответа

Короткий ответ

Синхронную блокирующую функцию нельзя "обойти" в рамках одного потока - event loop застрянет. Правильный подход: вынести вызов в отдельный поток через asyncio.to_thread() (Python 3.9+) или loop.run_in_executor(). Это позволяет event loop продолжать обработку других корутин, пока блокирующая функция выполняется в фоновом потоке. Для CPU-bound задач используйте ProcessPoolExecutor, чтобы обойти GIL.

Подробное объяснение

Асинхронный код в Python работает на одном потоке с cooperative multitasking: корутина должна сама отдать управление через await. Синхронная функция, например requests.get() или time.sleep(), блокирует поток целиком - event loop не может переключиться на другие задачи, пока функция не вернёт результат.

Основные варианты решения:

  1. asyncio.to_thread() - высокоуровневая обёртка над run_in_executor с дефолтным ThreadPoolExecutor. Подходит для I/O-bound задач: сетевые запросы, чтение файлов, работа с БД через синхронные драйверы.

  2. loop.run_in_executor() - даёт больше контроля: можно указать свой executor (Thread или Process), управлять пулом потоков, переиспользовать executor между вызовами.

  3. asyncio.create_task() + loop.run_in_executor() - если нужно запустить несколько блокирующих вызовов параллельно, оберните каждый в задачу.

  4. Для CPU-bound - только ProcessPoolExecutor, так как потоки в Python не дают параллелизма из-за GIL. Но помните: передача данных между процессами требует сериализации (pickle), что добавляет overhead.

  5. Альтернатива - заменить синхронную библиотеку на асинхронную: httpx.AsyncClient вместо requests, aiosqlite вместо sqlite3. Это лучший вариант, если библиотека имеет асинхронный API.

Важный нюанс: to_thread не отменяет выполнение функции при отмене задачи - поток продолжит работу в фоне. Для отмены нужен свой механизм (например, concurrent.futures.Future.cancel() не работает для уже запущенных задач).

На практике

  • Для I/O-bound: await asyncio.to_thread(sync_func, arg1, arg2) - просто и читаемо.
  • Для нескольких вызовов: создайте список задач через asyncio.gather с to_thread внутри.
  • Для CPU-bound: loop.run_in_executor(ProcessPoolExecutor(), cpu_func, data).
  • Если синхронная функция принимает много аргументов или возвращает сложный объект - используйте functools.partial или лямбду.
  • Не вызывайте to_thread для быстрых операций (например, len() или арифметика) - overhead на создание потока больше, чем выигрыш.
  • Для долгих блокирующих операций (минуты) рассмотрите отдельный процесс-воркер с очередью, а не поток - иначе рискуете исчерпать пул потоков.

Пример кода

PYTHON
import asyncio
import time
import requests
from concurrent.futures import ProcessPoolExecutor
def blocking_io(url: str) -> str:
# Блокирующий сетевой запрос
response = requests.get(url, timeout=5)
return response.text[:100]
def cpu_bound(n: int) -> int:
# CPU-bound задача
return sum(i * i for i in range(n))
async def main():
# Вариант 1: to_thread для I/O-bound
result = await asyncio.to_thread(blocking_io, "https://example.com")
print(result)
# Вариант 2: несколько блокирующих вызовов параллельно
urls = ["https://example.com", "https://httpbin.org/get"]
tasks = [asyncio.to_thread(blocking_io, url) for url in urls]
results = await asyncio.gather(*tasks)
print(results)
# Вариант 3: ProcessPoolExecutor для CPU-bound
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as executor:
result_cpu = await loop.run_in_executor(executor, cpu_bound, 10_000_000)
print(result_cpu)
asyncio.run(main())

Как отвечать на собеседовании

Начните с сути: синхронная функция блокирует event loop, поэтому её нужно вынести из основного потока. Упомяните asyncio.to_thread как основной инструмент для I/O-bound, затем run_in_executor для более тонкого контроля. Обязательно разграничьте I/O-bound и CPU-bound - это показывает понимание GIL. Приведите пример с gather для параллельных вызовов. Если спросят про отмену - честно скажите, что to_thread не поддерживает отмену, и предложите альтернативы. Хорошо добавить, что в идеале стоит использовать асинхронные библиотеки вместо обёрток.

Что проверяет интервьюер

  • Понимание модели выполнения asyncio: cooperative multitasking, один поток, event loop.
  • Знание ограничений: GIL, невозможность отмены потоков.
  • Умение выбирать инструмент под задачу: поток для I/O, процесс для CPU.
  • Практический опыт: знание to_thread, run_in_executor, gather.
  • Способность объяснить trade-off: overhead на создание потока, сериализация для процессов.

Типичные ошибки

  • Утверждение, что asyncio.to_thread "делает функцию асинхронной" - нет, она остаётся синхронной, просто выполняется в другом потоке.
  • Использование ThreadPoolExecutor для CPU-bound задач - из-за GIL параллелизма не будет, только переключение контекста.
  • Забывают, что to_thread не отменяется при отмене корутины - поток продолжит работу.
  • Вызов to_thread для тривиальных операций - создание потока дороже, чем сама операция.
  • Путаница между asyncio.to_thread и asyncio.create_task - первое для синхронных функций, второе для корутин.
  • Не учитывают, что ProcessPoolExecutor требует picklable аргументов и возвращаемых значений - лямбды и замыкания не работают.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью