сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Асинхронность в Python

Event loop и корутины в asyncio

Цикл событий

Замерил на живом коде. Двадцать операций по 100 миллисекунд, выполненных подряд с ожиданием каждой, заняли 2007,7 миллисекунды. Те же двадцать, запущенные вместе, - 101,3 миллисекунды. В двадцать раз быстрее на одном-единственном потоке, без всяких процессов и параллельности.

Стержень: асинхронность не ускоряет вычисления, она перестаёт простаивать в ожидании; выигрыш равен доле времени, которое код проводит в ожидании.

// Формулировки: «как работает event loop?», «зачем async, если есть потоки?», «почему асинхронный код не стал быстрее?»

Что происходит на самом деле

Цикл событий - это очередь заданий и один поток, который их крутит. Пока задание считает, оно занимает поток целиком. Как только доходит до ожидания (сеть, диск, таймер), оно ГОВОРИТ об этом словом await и добровольно уступает поток другим, а цикл возвращается к нему, когда данные придут.

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

// И отсюда же граница применимости. Если код в основном СЧИТАЕТ, уступать нечего, и асинхронность не даст ничего - только добавит сложности. Если код в основном ЖДЁТ (а веб-сервис, ходящий в базу и в чужие сервисы, именно такой), выигрыш измеряется разами, как в моём замере. Проверяется это одним вопросом: какую долю времени запроса процессор реально занят?

цикл событий
очередь заданий и один поток, который их по очереди крутит
await
точка, где задание уступает поток другим, пока ждёт
корутина
функция, которая умеет останавливаться на await и продолжаться позже

Одна синхронная строка ложит всё

Это главная опасность асинхронного кода, и я её измерил. Десять быстрых задач по 50 миллисекунд заканчиваются на 50,5 миллисекунде - как и ожидалось. Добавляю к ним ОДНУ задачу, которая внутри вызывает обычный синхронный сон на полсекунды. Результат: быстрые задачи закончились на 500,4 миллисекунде. Они не делали ничего дольше, они просто ждали, пока освободится поток.

Та же самая работа, вынесенная в отдельный поток специальной обёрткой, ничего не ломает: быстрые задачи снова закончились на 50,8 миллисекунде, а долгая доехала своим чередом к 507.

// Отсюда правило, которое стоит держать в голове постоянно: внутри асинхронной функции не должно быть НИ ОДНОГО синхронного вызова, который занимает поток надолго. Синхронный запрос к базе, чтение большого файла, тяжёлый разбор, обычный сон - всё это надо либо заменять на асинхронные версии, либо уводить в отдельный поток. Ищется такое просто: специальный режим отладки цикла печатает предупреждение о заданиях, которые держали поток дольше заданного порога.

блокирующий вызов
синхронная операция, которая занимает поток и не даёт работать другим заданиям
вынос в поток
запуск синхронной операции в отдельном потоке, чтобы не держать цикл

Задачи и запуск

Вызов асинхронной функции сам по себе ничего не запускает - он создаёт корутину, объект-заготовку. Я проверил: если забыть слово await, в переменной окажется объект типа coroutine, а не результат. Работа начнётся только когда её отдадут циклу.

Способов отдать три. Дождаться прямо здесь через await - самый простой, но пока ждём, ничего другого не происходит. Создать задачу - тогда она начнёт крутиться в фоне, а мы пойдём дальше. Или запустить набор вместе и дождаться всех - это и дало мне двадцатикратное ускорение.

// ⚠С фоновыми задачами есть ловушка, которую я тоже проверил: задача, запущенная и брошенная без сохранения ссылки, может просто не успеть доработать - у меня она не доделала работу, потому что программа закончилась раньше. Поэтому ссылку на задачу сохраняют, а перед завершением дожидаются или явно отменяют.

задача
корутина, отданная циклу; крутится в фоне, пока её не дождутся
брошенная задача
запущена без сохранения ссылки; может не успеть доработать

Как отвечать: «Как работает цикл событий и когда асинхронность даёт выигрыш?»

Цикл событий - это очередь заданий и один поток, который их крутит. Пока задание считает, оно занимает поток целиком; когда доходит до ожидания сети или диска, оно словом await уступает поток другим, а цикл вернётся к нему, когда данные придут. Отсюда и вся выгода: она равна доле времени, которое код проводит в ожидании. Я мерил: двадцать операций по сто миллисекунд подряд занимают две секунды, а запущенные вместе - сто миллисекунд, потому что ждут одновременно. Обратная сторона того же устройства: одна синхронная операция внутри асинхронной функции держит поток и тормозит всех. В моём замере десять быстрых задач заканчивались на пятидесятой миллисекунде, а стоило добавить к ним один обычный синхронный сон на полсекунды - все они закончились только на пятисотой. Поэтому для счётной нагрузки асинхронность бесполезна, а любые синхронные вызовы внутри неё выносят в отдельный поток.

Ответ объясняет механику, показывает выигрыш и его границу числами и заканчивается главной практической опасностью. Замер про заблокированный цикл - то, что отличает опыт от теории.

На чём валятся

  • Ждут ускорения счётной работы от асинхронности. Она ускоряет только ожидание.
  • Оставляют синхронный запрос к базе внутри асинхронной функции и тормозят весь сервис.
  • Забывают await и получают объект корутины вместо результата.
  • Запускают фоновую задачу без сохранения ссылки, и она не успевает доработать.
  • Ждут каждую операцию по очереди там, где их можно запустить вместе.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 14, остальные разбираются в тренажёре.

  1. #async_event_loop1 / 5
    Что делает asyncio.run(main()) с циклом событий?
    A)Заводит по циклу на каждую задачу внутри
    B)Переиспользует глобальный цикл и не закрывает
    C)Держит один цикл на процесс до самого exit
    D)Создаёт новый цикл и по завершении закрывает его
    показать ответ и разбор
    +D)Создаёт новый цикл и по завершении закрывает его

    // разбор: asyncio.run создаёт новый event loop, исполняет корутину до конца и затем закрывает цикл (отменив недобитые задачи). Вызвать его дважды подряд — нормально, а вот из уже работающего цикла нельзя: «asyncio.run() cannot be called from a running event loop».

  2. #async_event_loop2 / 5
    Чем asyncio.create_task(coro()) отличается от await coro() по старту?
    A)create_task запускает в отдельном потоке
    B)Планирует корутину сразу, код идёт дальше
    C)Оба стартуют одинаково поздно — в точке await
    D)Разницы нет, это синонимы
    показать ответ и разбор
    +B)Планирует корутину сразу, код идёт дальше

    // разбор: create_task оборачивает корутину в Task и сразу отдаёт циклу — та начнёт исполняться при ближайшей передаче управления, а код продолжится дальше. await coro() запускает корутину и блокирует текущую до её конца. create_task — способ получить конкурентность: запустил несколько тасок, потом их await.

  3. #async_event_loop3 / 5
    Создали asyncio.create_task(bg()), ссылку не сохранили. Чем это грозит?
    A)Ничего: цикл держит сильную ссылку сам
    B)GC может собрать task до завершения
    C)Task исполнится дважды
    D)Немедленный RuntimeError
    показать ответ и разбор
    +B)GC может собрать task до завершения

    // разбор: Цикл держит на задачу лишь слабую ссылку, поэтому «висячий» task без внешней ссылки может быть уничтожен сборщиком мусора посреди работы — задача молча не доработает. Практика из документации asyncio: складывать task в множество (self._tasks) и снимать в callback add_done_callback.

  4. #async_event_loop4 / 5
    Функция должна грузить две страницы конкурентно, но идёт последовательно:
    async def load(u):
        return await fetch(u)
    
    async def main():
        a = await load(u1)
        b = await load(u2)
        return a, b
    Почему нет конкурентности?
    A)main саму следует обернуть в create_task заранее
    B)Нужен отдельный цикл на каждую load
    C)Каждый await load ждёт до конца перед следующим
    D)fetch не поддерживает await
    показать ответ и разбор
    +C)Каждый `await load` ждёт до конца перед следующим

    // разбор: Два последовательных await — это последовательность: main доходит до первого load, ждёт его целиком, только потом стартует второй. Конкурентность даёт одновременный запуск: a, b = await asyncio.gather(load(u1), load(u2)) либо create_task на обе до await. Сам по себе await ничего не распараллеливает.

  5. #async_event_loop5 / 5
    Почему asyncio.get_event_loop() в свежесозданном потоке без цикла ломается (deprecated → ошибка)?
    A)Для цикла в фоновом потоке нужны права суперпользователя
    B)Потоки в принципе не могут иметь свой цикл
    C)Вне главного потока цикл автоматически не создаётся
    D)Цикл в процессе может быть только один
    показать ответ и разбор
    +C)Вне главного потока цикл автоматически не создаётся

    // разбор: Цикл событий привязан к потоку. В не-главном потоке без явно установленного цикла get_event_loop не создаёт его автоматически (в 3.10+ deprecated, дальше — ошибка). Правильно: asyncio.run в этом потоке либо new_event_loop()+set_event_loop. Один процесс спокойно держит несколько циклов — по одному на поток.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.