Программирование PythonКонкурентность и asyncioPython-разработчик серверных приложений

В сервисе две корутины запускаются через asyncio.gather, но внутри каждой вызывается обычная блокирующая фу...

В сервисе две корутины запускаются через asyncio.gather, но внутри каждой вызывается обычная блокирующая функция. Какой эффект это окажет на выполнение задач?

import asyncio
import time

def blocking_work():
    time.sleep(2)

async def worker(name):
    print(f"{name}: start")
    blocking_work()
    print(f"{name}: done")

async def main():
    await asyncio.gather(worker("A"), worker("B"))

asyncio.run(main())
Проходите собеседования с ИИ помощником Hintsage

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

Задачи не выполнятся параллельно: вызов blocking_work() заблокирует поток, в котором работает event loop. Пока выполняется time.sleep(2), цикл событий не может переключиться на вторую корутину или обслужить другие задачи.

Обе корутины завершатся примерно за 4 секунды, а не за 2. asyncio.gather планирует корутины конкурентно, но не превращает синхронный блокирующий код в асинхронный.

Исторический контекст

Asyncio предназначен для эффективного обслуживания большого числа задач, ожидающих сетевые операции, таймеры или другие поддерживаемые события. Один поток с event loop может переключаться между корутинами, пока текущая задача добровольно передаёт управление через await.

Такой подход уменьшает стоимость создания большого числа потоков и делает переключение задач предсказуемым. Однако он предполагает, что выполняемый код быстро возвращает управление циклу событий; обычные синхронные вызовы этого не гарантируют.

Постановка проблемы

Вызов time.sleep() блокирует текущий поток на две секунды. Поскольку event loop обычно выполняется в этом же потоке, он не может запустить или продолжить другие корутины до окончания блокирующего вызова.

Такая ошибка особенно опасна в сервере: один запрос с блокирующей операцией может задержать обработку всех остальных запросов, таймеров и сетевых событий, обслуживаемых тем же event loop. При большом числе таких вызовов растут задержки и снижается пропускная способность.

Подробное решение

asyncio.gather создаёт конкурентное выполнение корутин, но переключение происходит только в точках, где корутина приостанавливается, обычно на await асинхронной операции. В приведённом коде до blocking_work() корутина не отдаёт управление, а внутри функции вообще нет механизма, понятного event loop.

Чтобы не блокировать цикл событий, блокирующую операцию можно вынести в отдельный поток:

import asyncio import time def blocking_work(): time.sleep(2) async def worker(name): print(f"{name}: start") await asyncio.to_thread(blocking_work) print(f"{name}: done") async def main(): await asyncio.gather(worker("A"), worker("B")) asyncio.run(main())

asyncio.to_thread запускает синхронную функцию в пуле потоков и возвращает awaitable. Пока поток выполняет функцию, event loop может обслуживать другие задачи. Для блокирующего ввода-вывода это обычно подходящий компромисс.

Потоки не делают CPU-bound код эффективным в CPython автоматически: из-за GIL чистый Python-код, интенсивно использующий процессор, обычно не получает параллельного выполнения в нескольких потоках. Для такой нагрузки чаще применяют процессы, учитывая стоимость сериализации данных и межпроцессного обмена.

Нельзя бездумно выносить в поток любую функцию: нужно учитывать потокобезопасность используемых объектов, ограниченный размер пула и отмену задачи. Отмена ожидающего to_thread не обязательно немедленно остановит уже выполняющуюся синхронную функцию.

Ситуация из практики

В HTTP-сервисе обработчик был объявлен как async def, но внутри использовал синхронный клиент внешнего API. При медленном внешнем сервисе задержка одного запроса распространялась на все запросы, попавшие в тот же event loop.

Рассматривались три варианта. Полная замена клиента на асинхронный уменьшала блокировки и накладные расходы, но требовала переписать интеграцию. Запуск вызова через asyncio.to_thread позволял быстро сохранить существующий код, но создавал нагрузку на пул потоков. Вынос в отдельный процесс был избыточен для сетевого ожидания и добавлял стоимость межпроцессного взаимодействия.

Выбрали асинхронный клиент для нового кода, а для редких legacy-вызовов — asyncio.to_thread с ограничением параллелизма. Это сохранило отзывчивость event loop и не позволило большому числу запросов бесконтрольно занять все рабочие потоки.

Что кандидаты часто упускают

  1. Вопрос: Достаточно ли объявить функцию async def, чтобы она перестала блокировать event loop?

    Ответ: Нет. async def лишь создаёт корутину и задаёт синтаксис асинхронного выполнения. Если внутри находятся синхронные блокирующие вызовы без передачи управления, они выполняются в потоке event loop и блокируют его так же, как обычный синхронный код.

  2. Вопрос: Почему добавление await перед обычной блокирующей функцией не решает проблему?

    Ответ: Обычная функция возвращает результат сразу, поэтому передать её непосредственно в await нельзя. Если сначала вызвать её, например await blocking_work(), функция успеет заблокировать поток до того, как await получит значение. Нужно передать вызов в механизм, который действительно выполняет его вне event loop, например asyncio.to_thread.

  3. Вопрос: Когда перенос блокирующей функции в поток всё равно может не дать ожидаемого результата?

    Ответ: Это возможно при CPU-bound вычислениях на чистом Python: потоки конкурируют за GIL, поэтому процессорная работа не становится полноценной параллельной в рамках одного процесса. Кроме того, чрезмерное число потоков создаёт конкуренцию за CPU и ресурсы. Для тяжёлых вычислений обычно рассматривают пул процессов или специализированную библиотеку, освобождающую GIL.