async и await в Python: что реально происходит под капотом
Корутина — не поток и не магия. Разбираем, во что превращается async def, как event loop дёргает ваши функции через send() и почему один блокирующий вызов вешает весь сервис.

Сервис на FastAPI обрабатывал 200 одновременных запросов и вдруг начал отвечать за 8 секунд вместо 40 миллисекунд. CPU — 3%. В коде нашёлся один requests.get() внутри async def. Технически всё «работало»: код был помечен как асинхронный, тесты проходили. Но один синхронный вызов заблокировал единственный поток, в котором крутится event loop, и все остальные корутины встали в очередь. Чтобы понимать, почему так происходит, нужно перестать думать об async как о «сделать быстро» и посмотреть, во что реально превращается корутина.
Разбор для Python 3.11+ (поведение генераторов и корутин стабильно с 3.5, но детали трейсбеков и производительности event loop менялись). Здесь только механизм: как async def становится объектом, кто и как его исполняет, где именно теряется управление. Пул потоков и asyncio.to_thread — упоминаю, но отдельно не разворачиваю.
Корутина — это объект, а не выполнение
Первое, что ломает интуицию: вызов асинхронной функции ничего не выполняет.
import asyncio
async def fetch(name):
print(f"старт {name}")
await asyncio.sleep(1)
print(f"конец {name}")
return name
# Это НЕ запускает fetch. Создаётся объект корутины.
coro = fetch("a")
print(type(coro)) # <class 'coroutine'>
print(coro) # <coroutine object fetch at 0x...>
Строка print(f"старт a") не выполнилась. fetch("a") вернул объект корутины — приостановленное вычисление, которое кто-то должен «прокрутить». Если забыть про await или про передачу в event loop, Python предупредит: RuntimeWarning: coroutine 'fetch' was never awaited.
Что общего у корутины и генератора
Корутина построена на том же механизме, что и генератор: у объекта есть метод send(), который двигает выполнение до следующей точки приостановки, и он может бросить StopIteration с результатом в момент завершения. Убедиться можно вручную, без event loop:
async def add(x, y):
return x + y
coro = add(2, 3)
try:
# send(None) запускает корутину. Так как внутри нет await,
# она сразу завершается и кладёт результат в StopIteration.
coro.send(None)
except StopIteration as exc:
print(exc.value) # 5
Именно это event loop и делает под капотом: вызывает coro.send(None), ловит момент, когда корутина отдаёт управление, и возвращается к ней позже.
Что делает await
await something означает: «прокрути something и, если оно приостановилось, приостанови и меня тоже, вернув управление тому, кто меня запустил». Управление уходит вверх по цепочке — до event loop.
Ключевой момент: await asyncio.sleep(1) не блокирует поток. Он говорит event loop «разбуди меня через секунду» и отдаёт управление. За эту секунду loop запускает другие готовые корутины. А вот time.sleep(1) внутри async def честно замораживает поток целиком — event loop в это время не может сделать ничего, включая обслуживание сотен других соединений.
Асинхронность в Python — это кооперативная многозадачность. Никто не отбирает управление у корутины принудительно. Она отдаёт его сама, на каждомawait. Если корутина не доходит доawait— например, крутит тяжёлый цикл или делает синхронный сетевой вызов, — она держит event loop мёртвой хваткой.
await работает не с чем угодно
Справа от await должен стоять awaitable: другая корутина, Task, Future или объект с методом __await__. Обычная функция, которая возвращает список или число, awaitable не является — получите TypeError: object int can't be used in 'await' expression.
Event loop: кто на самом деле исполняет корутины
Event loop — это цикл, который в одном потоке держит очередь готовых к выполнению задач и по одной прокручивает их до ближайшего await. Упрощённо порядок такой:
- Взять из очереди готовую задачу.
- Вызвать её
send()— корутина выполняется доawait, который приостанавливает её (например, ожидание сокета или таймера). - Зарегистрировать, чего корутина ждёт: сработавший таймер, готовность файлового дескриптора на чтение или запись.
- Перейти к следующей готовой задаче. Когда очередь пуста — заснуть в системном вызове опроса дескрипторов (в Linux это epoll) до ближайшего события.
- Событие пришло — вернуть соответствующую корутину в очередь готовых и повторить.
Отсюда главный вывод: параллелизма по CPU здесь нет. Один поток, одна корутина в каждый момент. Выигрыш появляется только там, где корутины большую часть времени ждут — сеть, диск, ответ базы. На чистых вычислениях asyncio не ускорит ничего, а усложнит код.
Разница между корутиной и Task
Когда вы пишете await coro, корутина выполняется в текущем «стеке» и loop не начинает её параллельно с текущей. Чтобы отдать корутину в очередь loop как независимую единицу, её оборачивают в задачу:
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay)
return f"{name} готов"
async def main():
# Обе задачи запускаются "одновременно": пока одна спит,
# loop крутит другую. Общее время — около 2 секунд, не 3.
task_a = asyncio.create_task(fetch("a", 2))
task_b = asyncio.create_task(fetch("b", 1))
results = await asyncio.gather(task_a, task_b)
print(results)
asyncio.run(main())
asyncio.run() создаёт event loop, прокручивает main() до завершения и закрывает loop. create_task() кладёт корутину в очередь готовых немедленно, не дожидаясь await.
Где это ломается на практике
Механизм объясняет самые частые провалы. Все они — про поток, который один.
| Что делаете | Что происходит | Как чинить |
|---|---|---|
requests.get(), psycopg2, синхронный клиент | Блокирует поток целиком, весь loop встаёт | Асинхронный клиент (httpx, asyncpg) или asyncio.to_thread |
time.sleep(5) | Замораживает loop на 5 секунд | await asyncio.sleep(5) |
| Тяжёлый CPU-цикл в корутине | Управление не отдаётся, соседние задачи ждут | Вынести в процесс (ProcessPoolExecutor) |
Забыли await перед корутиной | Корутина не выполняется, RuntimeWarning | Добавить await или create_task |
| Создали task и не сохранили ссылку | Сборщик мусора может убить задачу до завершения | Держать ссылку на объект Task |
Проверка блокировки за 10 секунд
Если сервис на asyncio тормозит при почти нулевом CPU — почти наверняка где-то синхронный вызов. Начиная с Python 3.12 можно включить встроенный детектор медленных колбэков: передайте debug=True в asyncio.run(). Loop будет логировать колбэки, которые выполнялись дольше порога (по умолчанию около 100 мс). Точное имя параметра порога и его значение сверяйте в документации модуля asyncio вашей версии.
import asyncio
async def main():
# ... ваш код
pass
# debug=True включит предупреждения о долгих колбэках
# и о корутинах, которые никто не дождался
asyncio.run(main(), debug=True)
Как смотреть на это в проде
Три вещи, которые стоит держать под контролем, когда сервис на asyncio уже работает:
- Задержка планирования loop. Измеряйте, за сколько loop успевает прокрутить пустую задачу. Если растёт — значит, какая-то корутина держит управление слишком долго. Простейший замер: планируете колбэк на «сейчас» через
loop.call_soonи смотрите фактическую задержку до его вызова. - Синхронные вызовы в коде. Ревьюйте каждый вызов внутри
async def: есть ли у используемой библиотеки асинхронный клиент. ORM, HTTP-клиенты, драйверы БД — самые частые нарушители. - Незавершённые задачи при остановке. При graceful shutdown отменяйте оставшиеся задачи и дожидайтесь их, иначе рискуете потерять данные в середине операции.
Дальше стоит прочитать исходный PEP про корутины и синтаксис async/await, а затем — раздел документации asyncio про разработку и отладку. Если под asyncio у вас упирается именно CPU, а не сеть — это сигнал, что инструмент выбран не тот: смотрите в сторону процессов, а не корутин.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: asyncio — Asynchronous I/O (Python docs), PEP 492 — Coroutines with async and await syntax
Частые вопросы
async ускоряет вычисления?
Почему один requests.get вешает весь сервис?
У меня Python 3.9, разница есть?
Что будет, если забыть await перед корутиной?
Чем create_task отличается от прямого await?
Почему моя задача пропала, хотя я её создал?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.