Планировщик задач в asyncio: как устроен event loop внутри
Почему asyncio.sleep не блокирует поток, откуда берётся очередь готовых колбэков и что делает event loop между итерациями — разбор внутренностей планировщика в CPython 3.12.

Вы пишете await asyncio.sleep(1) и корутина замирает на секунду, не занимая процессор. Тысяча таких корутин работает в одном потоке без единого блокирующего вызова. Магии тут нет: за этим стоит конкретный механизм — цикл событий с двумя очередями и системным вызовом опроса дескрипторов. Разберём, что именно происходит между строками вашего кода, на примере CPython 3.12.
Речь только про планировщик asyncio из стандартной библиотеки — про то, как event loop решает, какую корутину продолжить и когда. Диспетчеризация сетевых событий через транспорты и протоколы — соседняя тема, её тут не трогаем.
Что вообще планирует event loop
Корутина в Python — это не поток и не процесс. Это объект, который умеет приостанавливаться на await и отдавать управление наружу. Тот, кто получает управление обратно, и есть event loop. Его задача — держать список того, что нужно выполнить, и выполнять это по одному в единственном потоке.
Внутри цикла событий (класс BaseEventLoop в модуле asyncio.base_events) живут две структуры, ради которых всё и работает:
- _ready —
collections.dequeс колбэками, готовыми к немедленному выполнению. Каждый элемент — объектHandle, обёртка вокруг функции и её аргументов. - _scheduled — список объектов
TimerHandle, организованный как двоичная куча через модульheapq. Куча упорядочена по времени срабатывания, поэтому ближайшая по времени задача всегда лежит в корне.
Когда вы вызываете loop.call_soon(callback), объект Handle просто добавляется в конец _ready. Когда вызываете loop.call_later(delay, callback), создаётся TimerHandle с рассчитанным временем и кладётся в кучу _scheduled через heapq.heappush.
Причём тут sleep
А asyncio.sleep(delay) — это обёртка, которая под капотом планирует пробуждение через call_later и приостанавливает текущую задачу через Future. Корутина не крутит цикл ожидания и не занимает CPU: она просто снимается с исполнения, а её продолжение отложено в кучу таймеров до нужного момента.
Одна итерация цикла: что происходит по шагам
Сердце планировщика — метод _run_once. Каждый его вызов — это одна итерация цикла событий. Порядок действий фиксирован и объясняет почти всё поведение asyncio под нагрузкой:
- Считается таймаут для опроса. Если
_readyнепустая — таймаут ноль, опрашивать некогда, есть готовая работа. Если пустая — берётся ближайшийTimerHandleиз корня кучи и вычисляется, сколько до него ждать. Если и таймеров нет — таймаутNone, цикл готов спать бесконечно. - Опрос дескрипторов. Вызывается селектор —
self._selector.select(timeout). На Linux это обёртка надepoll, на macOS —kqueue, на Windows — по умолчанию другой механизм. Именно здесь поток реально засыпает, отдавая управление ядру, пока не наступит событие ввода-вывода или не выйдет таймаут. - События I/O превращаются в колбэки. Всё, что вернул селектор, кладётся в
_ready. - Просроченные таймеры переезжают в _ready. Цикл достаёт из кучи
_scheduledвсеTimerHandle, чьё время срабатывания уже наступило, и добавляет их колбэки в_ready. - Прогон готовых колбэков. Фиксируется текущая длина
_ready, и ровно столько элементов выполняется черезhandle._run(). Колбэки, добавленные во время этого прогона, попадут уже в следующую итерацию.
Ключевая деталь: длина _ready снимается один раз в начале прогона. Колбэк, который на лету добавляет в очередь ещё работу, не может заблокировать итерацию бесконечно — новые задачи ждут следующего круга. Это защита от голодания I/O.
Почему это однопоточно и чем платишь
Весь механизм крутится в одном потоке. Пока выполняется тело колбэка на шаге 5, никакой другой код в этом цикле не идёт. Отсюда главное правило asyncio: любой синхронный вызов, который блокирует поток дольше, чем на доли миллисекунды, замораживает весь цикл целиком.
Классические ловушки, которые проще всего поймать в проде:
- time.sleep(2) вместо await asyncio.sleep(2). Первый блокирует поток на две секунды — ни один таймер не сработает, ни одно сетевое событие не обработается.
- Тяжёлые вычисления в корутине. Разбор большого JSON, обход миллиона строк, хеширование — всё это держит цикл, пока не завершится. Вынос в
loop.run_in_executor()отдаёт работу в пул потоков и возвращает управление циклу. - Синхронный драйвер БД. Обычный
psycopg2в корутине блокирует так же, какtime.sleep. Нужен асинхронный драйвер.
Как это заметить в замерах
У event loop есть встроенный детектор долгих колбэков. Включите отладку и порог:
import asyncio
import logging
logging.basicConfig(level=logging.WARNING)
async def main():
loop = asyncio.get_running_loop()
# Логировать любой колбэк, выполнявшийся дольше 100 мс
loop.slow_callback_duration = 0.1
loop.set_debug(True)
# ... ваши задачи
asyncio.run(main())При включённом set_debug(True) и превышении slow_callback_duration в лог уйдёт предупреждение вида «Executing … took … seconds» с именем функции. Это первый инструмент, к которому стоит тянуться, когда latency скачет без видимой причины.
Где механизм ломается или ведёт себя неожиданно
Разберём граничные случаи, которые редко видны на маленьких нагрузках, но всплывают под трафиком.
Таймеры не точны, и это by design
Если вы заказали пробуждение через 100 мс, а какой-то колбэк на шаге 5 крутился 300 мс, ваш таймер сработает с опозданием — цикл физически не дошёл до кучи вовремя. asyncio не даёт гарантий реального времени. Для точных интервалов при высокой загрузке цикла это критично: считайте заявленное время нижней границей, а не точным значением.
Отменённые таймеры остаются в куче
Когда вы отменяете TimerHandle (через handle.cancel()), он не удаляется из _scheduled сразу — просто помечается как отменённый. Реальная чистка происходит, когда доля отменённых превышает внутренний порог. При тысячах постоянно создаваемых и отменяемых таймеров куча может распухнуть до момента чистки. Обычно это не проблема, но в паттернах вроде «таймаут на каждый запрос, который почти всегда снимается» держите это в голове.
Порядок call_soon сохраняется, порядок таймеров — нет
Колбэки из call_soon выполняются в порядке добавления — deque это гарантирует. А вот два таймера, назначенные на одно и то же время, выполнятся в порядке их TimerHandle: у объекта есть монотонный счётчик _when и порядковый номер, по которому куча их различает при равном времени. Полагаться на этот порядок в логике не стоит.
Разные реализации цикла
Стандартный BaseEventLoop — не единственный вариант. Под нагрузкой часто ставят uvloop, который заменяет реализацию на основе libuv:
| Свойство | Стандартный asyncio | uvloop |
|---|---|---|
| Основа | selectors + чистый Python | libuv (C) |
| Пропускная способность | базовая | заметно выше на сетевом I/O |
| API | стандартный | совместим, ставится через set_event_loop_policy |
| Платформы | Linux, macOS, Windows | Linux, macOS (Windows не поддерживается) |
Точный прирост зависит от нагрузки и версии — измеряйте на своём профиле трафика, а не берите цифры из чужих бенчмарков. Логика планирования (две очереди, _run_once) концептуально та же, отличается реализация опроса и работа с памятью.
Что мерить и куда смотреть дальше
Если вы отвечаете за сервис на asyncio, три показателя стоит держать под наблюдением:
- Задержка итерации цикла — время между соседними вызовами
_run_once. Растёт — значит, что-то блокирует поток. Ловится черезloop.set_debug(True)иslow_callback_duration. - Размер _ready на пике — косвенный признак того, что колбэки накапливаются быстрее, чем прогоняются.
- Число pending-задач через
asyncio.all_tasks()— утечка задач, которые никто не ждёт и не отменяет, копится молча.
Дальше стоит прочитать исходник asyncio/base_events.py — метод _run_once занимает меньше сотни строк и читается за один заход. После него станет понятно, почему любой совет по asyncio сводится к одному: не блокируй поток.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Python docs — asyncio Event Loop, CPython — asyncio/base_events.py на GitHub
Частые вопросы
Это верно для Python 3.11 или 3.13?
Почему мой await asyncio.sleep(0.1) срабатывает через 0.3 секунды?
Стоит ли ставить uvloop сразу?
Как безопасно запустить блокирующий код внутри корутины?
Что будет, если создать два event loop в одном потоке?
Почему задачи иногда не выполняются, хотя я их создал?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.