> Как обойти блокировку при вызове синхронной функции в асинхронном коде в 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 не может переключиться на другие задачи, пока функция не вернёт результат.
Основные варианты решения:
-
asyncio.to_thread()- высокоуровневая обёртка надrun_in_executorс дефолтнымThreadPoolExecutor. Подходит для I/O-bound задач: сетевые запросы, чтение файлов, работа с БД через синхронные драйверы. -
loop.run_in_executor()- даёт больше контроля: можно указать свой executor (Thread или Process), управлять пулом потоков, переиспользовать executor между вызовами. -
asyncio.create_task()+loop.run_in_executor()- если нужно запустить несколько блокирующих вызовов параллельно, оберните каждый в задачу. -
Для CPU-bound - только
ProcessPoolExecutor, так как потоки в Python не дают параллелизма из-за GIL. Но помните: передача данных между процессами требует сериализации (pickle), что добавляет overhead. -
Альтернатива - заменить синхронную библиотеку на асинхронную:
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 на создание потока больше, чем выигрыш. - Для долгих блокирующих операций (минуты) рассмотрите отдельный процесс-воркер с очередью, а не поток - иначе рискуете исчерпать пул потоков.
Пример кода
PYTHONimport asyncioimport timeimport requestsfrom concurrent.futures import ProcessPoolExecutordef 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-boundresult = 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-boundloop = 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 аргументов и возвращаемых значений - лямбды и замыкания не работают.
> Похожие задачи по Python
Что такое дедлоки?
Что является верхнеуровневым и низкоуровневым компонентом в системах на SOLID?
Что происходит при вызове синхронной функции из асинхронной ручки в FastAPI?
Какова иерархия процессов, потоков и asyncio в Python?
> Похожие задачи по backend
Что такое дедлоки?
Что является верхнеуровневым и низкоуровневым компонентом в системах на SOLID?
Что происходит при вызове синхронной функции из асинхронной ручки в FastAPI?
Какова иерархия процессов, потоков и asyncio в Python?
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью