В сервисе две корутины запускаются через 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())
Задачи не выполнятся параллельно: вызов 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.
Чтобы не блокировать цикл событий, блокирующую операцию можно вынести в отдельный поток:
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 и не позволило большому числу запросов бесконтрольно занять все рабочие потоки.
Вопрос: Достаточно ли объявить функцию async def, чтобы она перестала блокировать event loop?
Ответ: Нет. async def лишь создаёт корутину и задаёт синтаксис асинхронного выполнения. Если внутри находятся синхронные блокирующие вызовы без передачи управления, они выполняются в потоке event loop и блокируют его так же, как обычный синхронный код.
Вопрос: Почему добавление await перед обычной блокирующей функцией не решает проблему?
Ответ: Обычная функция возвращает результат сразу, поэтому передать её непосредственно в await нельзя. Если сначала вызвать её, например await blocking_work(), функция успеет заблокировать поток до того, как await получит значение. Нужно передать вызов в механизм, который действительно выполняет его вне event loop, например asyncio.to_thread.
Вопрос: Когда перенос блокирующей функции в поток всё равно может не дать ожидаемого результата?
Ответ: Это возможно при CPU-bound вычислениях на чистом Python: потоки конкурируют за GIL, поэтому процессорная работа не становится полноценной параллельной в рамках одного процесса. Кроме того, чрезмерное число потоков создаёт конкуренцию за CPU и ресурсы. Для тяжёлых вычислений обычно рассматривают пул процессов или специализированную библиотеку, освобождающую GIL.