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, остальные разбираются в тренажёре.
- Что делает
asyncio.run(main())с циклом событий?A)Заводит по циклу на каждую задачу внутриB)Переиспользует глобальный цикл и не закрываетC)Держит один цикл на процесс до самого exitD)Создаёт новый цикл и по завершении закрывает егопоказать ответ и разбор
+D)Создаёт новый цикл и по завершении закрывает его// разбор: asyncio.run создаёт новый event loop, исполняет корутину до конца и затем закрывает цикл (отменив недобитые задачи). Вызвать его дважды подряд — нормально, а вот из уже работающего цикла нельзя: «asyncio.run() cannot be called from a running event loop».
- Чем
asyncio.create_task(coro())отличается отawait coro()по старту?A)create_task запускает в отдельном потокеB)Планирует корутину сразу, код идёт дальшеC)Оба стартуют одинаково поздно — в точке awaitD)Разницы нет, это синонимыпоказать ответ и разбор
+B)Планирует корутину сразу, код идёт дальше// разбор: create_task оборачивает корутину в Task и сразу отдаёт циклу — та начнёт исполняться при ближайшей передаче управления, а код продолжится дальше.
await coro()запускает корутину и блокирует текущую до её конца. create_task — способ получить конкурентность: запустил несколько тасок, потом их await. - Создали
asyncio.create_task(bg()), ссылку не сохранили. Чем это грозит?A)Ничего: цикл держит сильную ссылку самB)GC может собрать task до завершенияC)Task исполнится дваждыD)Немедленный RuntimeErrorпоказать ответ и разбор
+B)GC может собрать task до завершения// разбор: Цикл держит на задачу лишь слабую ссылку, поэтому «висячий» task без внешней ссылки может быть уничтожен сборщиком мусора посреди работы — задача молча не доработает. Практика из документации asyncio: складывать task в множество (self._tasks) и снимать в callback add_done_callback.
- Функция должна грузить две страницы конкурентно, но идёт последовательно:
Почему нет конкурентности?async def load(u): return await fetch(u) async def main(): a = await load(u1) b = await load(u2) return a, bA)mainсаму следует обернуть в create_task заранееB)Нужен отдельный цикл на каждуюloadC)Каждыйawait loadждёт до конца перед следующимD)fetchне поддерживает awaitпоказать ответ и разбор
+C)Каждый `await load` ждёт до конца перед следующим// разбор: Два последовательных await — это последовательность: main доходит до первого load, ждёт его целиком, только потом стартует второй. Конкурентность даёт одновременный запуск:
a, b = await asyncio.gather(load(u1), load(u2))либо create_task на обе до await. Сам по себе await ничего не распараллеливает. - Почему
asyncio.get_event_loop()в свежесозданном потоке без цикла ломается (deprecated → ошибка)?A)Для цикла в фоновом потоке нужны права суперпользователяB)Потоки в принципе не могут иметь свой циклC)Вне главного потока цикл автоматически не создаётсяD)Цикл в процессе может быть только одинпоказать ответ и разбор
+C)Вне главного потока цикл автоматически не создаётся// разбор: Цикл событий привязан к потоку. В не-главном потоке без явно установленного цикла get_event_loop не создаёт его автоматически (в 3.10+ deprecated, дальше — ошибка). Правильно: asyncio.run в этом потоке либо new_event_loop()+set_event_loop. Один процесс спокойно держит несколько циклов — по одному на поток.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.