Сопоставьте планирование корутин asyncio и потоков: кто определяет момент передачи управления?

Сопоставьте планирование корутин asyncio и потоков: кто определяет момент передачи управления?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

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

Исторический контекст

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

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

Постановка проблемы

Предположим, event loop обслуживает множество сетевых запросов. Если одна корутина выполняет длинный синхронный участок без await, остальные задачи не получают управления, даже если их сетевые операции уже завершились.

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

Подробное решение

В asyncio задача исполняется в потоке event loop. Event loop запускает корутину до тех пор, пока она не завершится или не передаст управление ожиданием. Если ожидаемый объект ещё не готов, loop сохраняет состояние задачи и выбирает другую готовую задачу.

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

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

Минимальное сравнение выглядит так:

import asyncio import time async def cooperative(): for _ in range(3): await asyncio.sleep(0) print("получила управление") async def blocking(): time.sleep(1) print("заблокировала loop")

cooperative явно отдаёт управление event loop, а blocking удерживает поток event loop на время time.sleep. В реальном приложении блокирующую операцию выносят в поток или процесс либо заменяют на асинхронный API.

Главный компромисс такой: asyncio обеспечивает дешёвое и предсказуемое переключение, но требует кооперативного поведения всех задач. Потоки позволяют использовать синхронный код и допускают вытеснение, но требуют защиты общего состояния и обычно имеют большую стоимость при масштабировании большого числа задач.

Ситуация из практики

В HTTP-сервисе одна корутина обрабатывает запрос и запускает CPU-затратный разбор большого документа. Пока разбор выполняется без точек приостановки, задержка возрастает у всех запросов, обслуживаемых тем же event loop.

Рассматривались три варианта. Добавление искусственных await между небольшими фрагментами работы сохраняет один event loop, но требует разбить алгоритм и не помогает библиотеке, которая сама выполняет длинный синхронный вызов. Вынос в поток проще для блокирующего или преимущественно ожидающего кода, но для чистых Python-вычислений ограничивается влиянием GIL. Вынос в процесс лучше изолирует CPU-вычисление, однако требует сериализации аргументов и результата и имеет большие накладные расходы.

Для большого документа выбрали отдельный процесс, а сетевое ожидание оставили в asyncio. Это сохранило отзывчивость event loop и позволило выполнять вычисление параллельно на другом ядре, а стоимость сериализации оказалась приемлемой по сравнению с длительностью обработки.

Что кандидаты часто упускают

  1. Всегда ли await немедленно передаёт управление другой задаче?

Нет. Если ожидаемый объект уже завершён, корутина может продолжить выполнение без фактической приостановки. Поэтому наличие await в исходном тексте само по себе не гарантирует справедливого чередования задач.

  1. Может ли корутина быть вытеснена event loop посреди обычного синхронного участка?

Нет, event loop не прерывает корутину произвольно посреди её синхронного кода. Пока корутина не завершится или не приостановится на реально ожидающей операции, управление остаётся у неё. Именно поэтому короткие атомарные участки без await обычно не требуют потоковой блокировки для защиты от другой корутины, хотя общий дизайн всё равно должен учитывать явные точки приостановки.

  1. Делает ли передача CPU-затратной функции в поток asyncio-параллельной на нескольких ядрах?

Не обязательно. Для чистого Python-кода в стандартной реализации CPython потоки ограничены GIL, поэтому они обычно не дают настоящего параллельного выполнения такого вычисления на нескольких ядрах. Поток может быть полезен, чтобы убрать блокировку event loop, но для ускорения CPU-bound вычисления чаще выбирают процессы или код библиотек, который освобождает GIL.