Backend · Python

asyncio и event loop на собеседовании

Что произойдёт, если внутри корутины вызвать блокирующий код: документация отвечает числом. Разбираем кооперативность цикла и цену одной ошибки.

Асинхронный код можно писать раньше, чем понимаешь его устройство, — до тех пор, пока не понадобится объяснить, что произойдёт, если внутри корутины вызвать обычный блокирующий код. Документация отвечает на это числом, а не словами «станет медленнее», и из её ответа выводится вся модель целиком. Ниже она и разобрана — от того, как цикл переключает задачи, до границы с обычными потоками.

Всё, что сказано о поведении, относится к Python 3.

Одна задача за раз §

Модель цикла событий описана в документации короче, чем её обычно пересказывают: цикл работает в потоке — как правило, главном — и выполняет все обратные вызовы и задачи в этом потоке. Пока задача выполняется, никакая другая задача в том же потоке выполняться не может.

Переключение происходит в одной-единственной точке: когда задача доходит до await, она приостанавливается, и цикл выполняет следующую. Документация называет это кооперативным планированием — цикл выполняет одну задачу за раз, и пока задача ожидает завершения другого объекта, цикл занимается остальными задачами, обратными вызовами или вводом-выводом.

Из этого следует различение, которое стоит проговорить вслух, потому что на нём построена вся тема: asyncio даёт конкурентность, а не параллелизм. Задачи чередуются в одном потоке, а не выполняются одновременно. Слово «кооперативное» здесь не украшение: управление не отбирают у задачи — она отдаёт его сама, и только в точке await.

Что будет при блокирующем вызове §

Это центральный пункт темы, и документация формулирует его настолько конкретно, что ответ стоит запомнить близко к тексту. Блокирующий код — документация уточняет: CPU-bound — вызывать напрямую нельзя. Причина сформулирована примером: если функция выполняет вычисление в течение одной секунды, все конкурентные задачи asyncio и все операции ввода-вывода задержатся на одну секунду.

Механизм понятен из предыдущей секции. Переключение возможно только на await; блокирующий вызов до await не доходит, значит, управление не возвращается циклу вовсе. Задержится не только соседняя задача — задержится весь цикл, включая обслуживание сети.

Средство названо там же: увести такую работу в исполнитель через loop.run_in_executor() — в другой поток, в другой интерпретатор или даже в другой процесс, чтобы не блокировать поток с циклом. Для функций, которые блокируются на вводе-выводе, есть более прямое средство — asyncio.to_thread: документация относит его именно к I/O-bound функциям, которые иначе заблокировали бы цикл.

Полезно назвать и способ обнаружения — он превращает теорию в проверяемое: в отладочном режиме asyncio логирует обратные вызовы, выполняющиеся дольше 100 миллисекунд, а порог настраивается через loop.slow_callback_duration. Ответ, доведённый до этого, показывает, что человек не только знает проблему, но и представляет, как её увидеть.

Корутина сама не запускается §

Различие между корутиной и задачей выглядит терминологическим ровно до момента, когда код молча ничего не делает. Документация формулирует это прямо: простой вызов корутинной функции не планирует её выполнение. Вызов возвращает объект корутины — и всё; если его никто не ожидает, корутина не выполнится вообще, а интерпретатор выдаст предупреждение о том, что её так и не дождались.

Задача — это обёртка, добавляющая недостающее: корутину оборачивают в задачу и планируют её выполнение. С этого момента она попадает в очередь цикла и выполнится сама, без явного ожидания в точке создания.

Отсюда выводится и назначение gather: он выполняет переданные объекты конкурентно и возвращает список результатов в том порядке, в котором были переданы аргументы, — а не в порядке завершения. Последнее уточнение стоит помнить: порядок результатов детерминирован, порядок выполнения — нет.

Практический вывод, который связывает секцию с предыдущей: последовательность из двух await — это не конкурентность, а два ожидания подряд. Конкурентность появляется тогда, когда обе операции запланированы до того, как начали ожидать результат первой.

asyncio и потоки §

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

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

Правило для этой границы названо одно: чтобы запланировать обратный вызов из другого потока операционной системы, используется loop.call_soon_threadsafe(). Это тот случай, когда знание конкретного имени метода уместно — оно и есть ответ.

Стоит держать в голове и обратное направление, разобранное выше: run_in_executor и asyncio.to_thread уводят синхронный код из цикла в поток, а call_soon_threadsafe возвращает управление в цикл из чужого потока. Два разных направления одной границы, и путать их — типичный источник ошибок.

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

Вопросы из разбора одним списком. Если на каждый есть ответ своими словами — тему можно считать закрытой.

  1. Как работает event loop?
  2. Что произойдёт, если внутри корутины вызвать блокирующий код?
  3. Чем корутина отличается от задачи?
  4. Можно ли обращаться к asyncio-объектам из другого потока?
По этой теме на платформе

Готовьтесь не вслепую

Возьмите маршрут по своей роли и отрабатывайте ответы на вопросы с AI-разбором.