Цикл событий asyncio изнутри: селекторы, футуры и задачи
Как asyncio выполняет тысячи корутин на одном потоке: что делает event loop за один оборот, при чём тут epoll и почему await не блокирует остальные задачи.

Один поток, один процесс, а обслуживаются тысячи одновременных соединений. Секрет не в магии, а в связке из трёх сущностей: цикла событий, который крутит оборот за оборотом; селектора, который спрашивает у ядра «на каком сокете уже есть данные»; и футур с задачами, которые хранят состояние приостановленной корутины. Разберём, что происходит за один оборот asyncio в CPython 3.12+ и почему await на сетевом вызове отдаёт управление, а time.sleep() — нет.
Что делает цикл за один оборот
Event loop — это не бесконечный while True, который жрёт процессор. Один оборот (в CPython это метод _run_once внутри BaseEventLoop) состоит из нескольких понятных этапов, и между ними цикл засыпает в системном вызове.
- Посчитать таймаут: если есть готовые к запуску колбэки — таймаут ноль, если есть отложенные таймеры — время до ближайшего, иначе — бесконечность.
- Вызвать
selector.select(timeout). Здесь поток блокируется наepoll_wait(Linux),kqueue(macOS) или аналоге. Пока нет событий и не истёк таймер — процессор не тратится. - По готовым файловым дескрипторам поставить в очередь их колбэки.
- Перенести из очереди таймеров те, чьё время наступило.
- Выполнить все колбэки из очереди
_ready— по одному, синхронно, до конца или до следующегоawait.
Ключевое: колбэки в пункте 5 выполняются последовательно и не вытесняются. Если один колбэк крутит тяжёлый цикл на CPU или зовёт блокирующий requests.get(), все остальные задачи ждут. Отсюда главное правило asyncio: не блокировать поток.
Цикл событий не делает несколько дел одновременно. Он делает одно дело очень быстро и переключается ровно в те моменты, когда текущее дело ждёт ввода-вывода.
Селектор: как один поток ждёт много сокетов
Модуль selectors из стандартной библиотеки — это обёртка над механизмами мультиплексирования ядра. На старте asyncio сам выбирает лучший доступный: EpollSelector на Linux, KqueueSelector на BSD/macOS, SelectSelector как запасной вариант.
Разница принципиальная. select() на каждый вызов передаёт ядру весь список дескрипторов и получает обратно полный список — это O(n) по числу соединений. epoll регистрирует дескрипторы один раз и возвращает только сработавшие — близко к O(1) относительно числа готовых событий. На сотне соединений разница незаметна, на десятках тысяч select начинает съедать заметную долю оборота.
Проверить, какой селектор используется у вас:
# Python 3.12, Linux
import asyncio
import selectors
async def main():
loop = asyncio.get_running_loop()
# У SelectorEventLoop есть приватное поле _selector
selector = loop._selector
print(type(selector).__name__)
asyncio.run(main())
# На Linux напечатает: EpollSelector
Поле _selector приватное — опираться на него в продакшене не стоит, но для диагностики один раз посмотреть допустимо. На Windows по умолчанию с 3.8 используется ProactorEventLoop, который построен на IOCP и вообще не использует selectors-модуль в том же виде.
Футура: место, где живёт результат
asyncio.Future — объект-обещание. У него есть состояние (ожидает, завершён, отменён), поле результата или исключения и список колбэков, которые надо вызвать, когда результат появится. Сама по себе футура ничего не выполняет — её кто-то извне переводит в завершённое состояние через set_result() или set_exception().
Когда корутина делает await some_future, происходит вот что: корутина через генераторный протокол «отдаёт» эту футуру наверх, до самого цикла событий. Цикл видит незавершённую футуру и вешает на неё колбэк «когда завершишься — продолжи эту корутину». После этого управление возвращается в цикл, и он занимается другими задачами. Как только футура получает результат, её колбэк ставит продолжение корутины в очередь _ready.
Зачем вообще нужна футура, если есть корутина
Корутина описывает последовательность шагов. Футура — единица связи между кодом, который ждёт результат, и кодом, который его произведёт, возможно в другом колбэке. Транспорт сокета получил данные — он зовёт set_result на футуре, которую ждёт ваш await reader.read(). Без футуры корутине некуда было бы «подписаться» на будущее событие.
Задача: корутина, которую двигает цикл
asyncio.Task — подкласс Future. Разница в том, что задача сама себя продвигает. Когда вы пишете asyncio.create_task(coro()), создаётся Task, который на ближайшем обороте начнёт исполнять корутину: вызовет coro.send(None), получит либо новую футуру для ожидания, либо StopIteration с финальным результатом.
Механика одного шага задачи (метод __step внутри):
- вызвать
send()илиthrow()на обёрнутой корутине; - если корутина вернула футуру — подписать себя на её завершение колбэком
__wakeupи уступить управление; - если корутина завершилась (
StopIteration) — записать результат в саму задачу как в футуру; - если вылетело исключение — сохранить его в задаче.
Отсюда важное следствие про необработанные исключения. Если задача упала с ошибкой, а вы её результат нигде не ждёте через await, исключение «проглатывается» до момента сборки мусора, когда asyncio напечатает Task exception was never retrieved. Поэтому созданную create_task задачу нужно либо где-то дождаться, либо навесить на неё колбэк обработки ошибок.
Три сущности рядом
| Сущность | Кто продвигает | Хранит результат | Типичное использование |
|---|---|---|---|
| Coroutine | тот, кто зовёт send/await | нет, только логика | async def, тело работы |
| Future | внешний код через set_result | да | мост между колбэком и await |
| Task | сам себя, через цикл | да (как Future) | фоновое выполнение корутины |
Почему time.sleep блокирует, а asyncio.sleep — нет
Это самый частый источник недоумения. asyncio.sleep(1) внутри регистрирует таймер в цикле и делает await на футуре, которая завершится через секунду. На этот момент управление возвращается циклу, и он весь этот секундный интервал обслуживает другие задачи. А time.sleep(1) — обычный блокирующий вызов ОС: поток останавливается целиком, цикл не проворачивается, ни один колбэк не выполняется.
Тот же эффект даёт любой синхронный ввод-вывод: обращение к базе через синхронный драйвер, requests, чтение большого файла обычным open().read(). Всё это надо либо заменять на async-аналоги, либо уносить в пул потоков через asyncio.to_thread() (доступно с Python 3.9).
# Python 3.12
import asyncio
import time
def blocking_work(seconds: int) -> str:
# Синхронная функция, которую нельзя звать напрямую в корутине
time.sleep(seconds)
return f"готово за {seconds}с"
async def main():
# to_thread уносит блокирующий вызов в отдельный поток,
# цикл при этом продолжает крутиться
result = await asyncio.to_thread(blocking_work, 2)
print(result)
asyncio.run(main())
Как это увидеть в работе
У цикла есть встроенный режим отладки, который засекает колбэки, выполнявшиеся дольше порога, и ругается на них. Это прямой способ найти незаметную блокировку.
# Python 3.12
import asyncio
async def main():
loop = asyncio.get_running_loop()
# Порог в секундах: колбэк дольше него попадёт в предупреждение
loop.slow_callback_duration = 0.1
await work()
# debug=True включает проверки: медленные колбэки, забытые корутины
asyncio.run(main(), debug=True)
В режиме debug asyncio также предупредит, если корутину создали, но не дождались, и если вызов, ожидающий результата, выполняется не в том потоке.
Где механизм ломается
- CPU-bound внутри корутины. Тяжёлый цикл или парсинг большого JSON не отдаёт управление между итерациями. Один такой участок останавливает весь сервис. Решение —
run_in_executorс процессным пулом или вынос в отдельный сервис. - Блокирующая библиотека, притворяющаяся async. Не всякая обёртка с
async defдействительно неблокирующая внутри. Проверяйте, что драйвер БД реально асинхронный (asyncpg, а не psycopg2 в обёртке). - Голодание задач при плотном I/O. Если колбэки постоянно готовы, таймеры с нулевым таймаутом могут отодвигаться. Планировщик честный в порядке FIFO, но справедливость по времени он не гарантирует.
- Смешение циклов. Футура, созданная в одном цикле событий, не сработает в другом. Частая ошибка при запуске asyncio из разных потоков.
Что смотреть дальше
Если управляете нагруженным сервисом — начните с измерения времени оборота цикла и доли времени в select против времени в колбэках. Включите debug=True на стейджинге и вычистите все slow callback-предупреждения. Проверьте каждый внешний вызов на предмет скрытой блокировки — это чаще всего источник просадок latency под нагрузкой. Внутренняя реализация _run_once открыта в исходниках CPython (Lib/asyncio/base_events.py) — читать её полезнее любого пересказа. Отдельная тема — политики циклов и uvloop как замена стандартного цикла на C-реализацию; здесь мы её не разворачивали.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: asyncio — Asynchronous I/O, официальная документация Python, selectors — High-level I/O multiplexing, документация Python
Частые вопросы
У меня Python 3.8, приватное поле _selector есть?
Почему asyncio.to_thread не ускоряет CPU-задачу?
Что будет, если я не дождусь задачу через await?
На Windows тоже используется epoll?
Не сработало: get_running_loop падает с RuntimeError
Разница в подводных камнях между версиями Python есть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.