1. Внутреннее устройство asyncio и Event Loop
Внутреннее устройство asyncio и архитектура Event Loop
Библиотека asyncio реализует концепцию кооперативной многозадачности. В отличие от вытесняющей многозадачности, где операционная система сама решает, когда приостановить один поток и запустить другой, в кооперативной модели задачи добровольно отдают управление обратно планировщику. Этим планировщиком в Python выступает цикл событий (Event Loop).
Чтобы понять, как асинхронный код работает под капотом, необходимо отказаться от восприятия корутин как легковесных потоков. На уровне интерпретатора CPython асинхронность — это иллюзия одновременного выполнения, построенная на механизмах приостановки функций и делегирования ожидания ввода-вывода (I/O) операционной системе.
> Event loop (цикл событий) — это фундаментальная концепция в асинхронном программировании, которая позволяет системе обрабатывать большое количество задач без создания множества потоков. По сути, Event Loop - это реализация шаблона Reactor. > > Struchkov's Garden
Паттерн Reactor и системные вызовы
В основе Event Loop лежит архитектурный паттерн Reactor. Его главная задача — реагировать на события ввода-вывода и распределять их по зарегистрированным обработчикам. Когда вы вызываете сетевой запрос, Python не блокирует выполнение программы. Вместо этого он регистрирует файловый дескриптор (сокет) в операционной системе и просит уведомить его, когда появятся данные.
Для этого asyncio использует системные вызовы мультиплексирования ввода-вывода, которые зависят от платформы:
epoll* в Linux kqueue* в macOS и FreeBSD select или IOCP* в Windows
Когда цикл событий не имеет готовых к выполнению задач, он не крутится в холостую (busy wait), сжигая процессорное время. Он делает системный вызов, например epoll_wait, и засыпает. Операционная система будит процесс только тогда, когда хотя бы один из отслеживаемых сокетов готов к чтению или записи.
Рассмотрим разницу в потреблении ресурсов на конкретных числах. Стандартный поток операционной системы (OS Thread) в Linux резервирует под свой стек 8 мегабайт памяти. Если ваш сервер должен держать 10 000 одновременных WebSocket-соединений, использование потоков потребует около 80 гигабайт оперативной памяти только на стеки. В asyncio состояние корутины хранится в куче (heap) и занимает около 2-4 килобайт. Те же 10 000 соединений потребуют всего около 40 мегабайт памяти.
От генераторов к корутинам
Исторически асинхронность в Python выросла из генераторов. Ключевое слово yield позволяло функции приостановить свое выполнение, сохранить локальные переменные и вернуть значение вызывающей стороне. Позже появилось yield from, а затем синтаксический сахар async и await.
Под капотом корутина — это объект, который реализует метод send(). Когда Event Loop запускает корутину, он вызывает её метод send(None). Корутина выполняется до первого await, который в конечном итоге сводится к ожиданию объекта Future (результата, которого еще нет). Корутина возвращает управление циклу событий.
В этом примере asyncio.sleep(1) не блокирует поток. Он создает таймер внутри Event Loop и приостанавливает fetch_data. Цикл событий видит, что fetch_data ждет, и переключается на выполнение main.
| Характеристика | Потоки (Threading) | Асинхронность (Asyncio) | | :--- | :--- | :--- | | Управление переключением | Операционная система (вытесняющая) | Приложение (кооперативная) | | Память на единицу | ~8 МБ (стек ОС) | ~2-4 КБ (объект в куче) | | Состояния гонки (Data Races) | Высокий риск, нужны мьютексы | Низкий риск (выполнение в одном потоке) | | Блокирующие операции | Блокируют только один поток | Блокируют весь цикл событий |
Жизненный цикл итерации Event Loop
Чтобы глубоко понимать отладку и оптимизацию, нужно знать, из каких этапов состоит один "тик" (итерация) цикла событий. Внутри метода run_forever() происходит бесконечный цикл while True, который выполняет строгую последовательность действий:
asyncio.sleep).epoll_wait (или аналог) с таймаутом, равным времени до ближайшего таймера. Если таймеров нет, цикл засыпает до появления сетевой активности.epoll, в очередь ready добавляются соответствующие функции-обработчики.ready.ready и по очереди их выполняет (вызывает метод send() у корутин), пока очередь не опустеет.Важно понимать, что шаг 6 выполняется строго последовательно. Если одна из задач в очереди ready решит вычислить число Фибоначчи или сделает синхронный запрос через библиотеку requests, она заблокирует весь поток. Шаг 6 не завершится, цикл не пойдет на следующую итерацию, таймеры не сработают, а новые сетевые пакеты не будут обработаны.
Пределы производительности и закон Амдала
Асинхронность великолепно решает проблему простоя при ожидании сети или диска (I/O-bound задачи), но она не ускоряет вычисления (CPU-bound задачи). Оценивая потенциальный прирост производительности от внедрения asyncio, полезно опираться на закон Амдала.
Где — теоретическое ускорение системы, — доля программы, которую можно распараллелить (или выполнять асинхронно), а — количество параллельных исполнителей (в случае I/O — количество одновременных сетевых запросов).
Представим, что мы пишем парсер. Обработка HTML-кода (синхронная операция) занимает 10% времени, а ожидание ответа от сервера — 90%. Таким образом, . Если мы запустим 100 одновременных асинхронных запросов (), максимальное ускорение составит: раз. Даже при бесконечном количестве одновременных запросов общее ускорение никогда не превысит 10 раз, так как синхронная часть (10%) станет узким местом, блокирующим Event Loop.
Понимание того, как Event Loop взаимодействует с операционной системой через мультиплексоры и как корутины сохраняют свое состояние, является ключом к написанию отказоустойчивых высоконагруженных сервисов. В следующих материалах мы разберем, как именно объекты Future и Task связывают низкоуровневые коллбэки с высокоуровневым синтаксисом.