Skip to content

Instantly share code, notes, and snippets.

@stasyao
Last active February 12, 2026 17:12
Show Gist options
  • Select an option

  • Save stasyao/024284fa183473d6bc9d30a2ca3a8168 to your computer and use it in GitHub Desktop.

Select an option

Save stasyao/024284fa183473d6bc9d30a2ca3a8168 to your computer and use it in GitHub Desktop.
Событийный цикл в asyncio: как Python-код работает поверх механизмов Linux

Событийный цикл в asyncio: как Python-код работает поверх механизмов Linux

Статья для тех, кто, как и я, споткнулся об asyncio и так и не понял его до конца.

Введение

Моё знакомство с asyncio началось довольно типично и болезненно. На тот момент я уверенно владел базовым синтаксисом Python и решил разобраться с асинхронным кодом, просто открыв документацию верхнеуровневого API (да, я тогда очень буквально воспринял строку из документации о том, что "low-level APIs for library and framework developers" и расшифровал для себя как "даже не смотри в low-level API, это только для больших ребят"). По началу всё выглядело понятно: async, await, задачи, циклы событий. Но при попытке связать это в цельную картину (и начать работать с asyncio-кодом) быстро возникло ощущение, что я понимаю отдельные конструкции, но не понимаю, почему всё это работает так, как работает.

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

Поворотным моментом стало обзорное знакомство с системным программированием в Линуксе. Без углубления в ядро или написание драйверов, но с попыткой понять базовые вещи: что такое файловые дескрипторы, межпроцессное взаимодействие, как работает ожидание ввода-вывода, зачем нужен epoll, каким образом процесс "спит" и как его можно разбудить. Именно в этот момент разрозненные куски asyncio начали складываться в цельную модель. Стало ясно, что событийный цикл — не абстрактная Python-магия, а аккуратная надстройка над механизмами, которые давно и эффективно существуют в ядре Линукса. На эти "открытия" наложились и первые главы книги Мэтью Фаулера "Asyncio и конкурентное программирование на Python", где он упоминал epoll-механизм, как подкапотную основу asyncio.

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

Именно поэтому эта статья не про сахар с async и await, а про то, что скрывается за вершиной asyncio-айсберга. Без этого, имхо, события, циклы и задачи так и остаются словами из документации, а не частью единой, понятной картины.

Что такое событийный цикл в asyncio и почему его механику невозможно понять без понимания основ работы Linux

Событийный цикл в asyncio — это объект, который живёт в одном потоке и управляет выполнением асинхронного кода, опираясь не только на Python-логику, но и на механизмы операционной системы. Несмотря на то что внешне он выглядит как обычный бесконечный цикл, его ключевая роль состоит в координации работы Python-кода с возможностями ядра Линукса.

На каждой итерации событийный цикл передаёт управление ядру и блокируется в ожидании событий. Такими событиями могут быть готовность сокета к чтению или записи, истечение заданного времени ожидания или внутренний сигнал самого цикла — например, добавление задачи из другого потока, доставка сигнала ОС или запрос на остановку. Пока ни одно из этих событий не происходит, Python-код не выполняется, а поток фактически спит внутри ядра, не потребляя процессорного времени.

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

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

Вся эта модель напрямую опирается на низкоуровневые механизмы Линукса — такие как ожидание событий на файловых дескрипторах и управление временем ожидания в ядре. Без понимания того, как операционная система отслеживает готовность сокетов, пробуждает процессы и управляет ожиданием, событийный цикл asyncio неизбежно выглядит как пресловутая "магия". На самом деле это лишь аккуратная надстройка над инфраструктурой ядра, а не отдельный мир внутри Python.

Что такое файловый дескриптор в Linux

Файловый дескриптор (fd, файловый описатель) — это целое число, с помощью которого процесс обращается к какому-то открытому ресурсу ядра.

Когда программа открывает файл, сокет, канал, терминал, epoll-объект и т.п., ядро создаёт внутренний объект и возвращает процессу номер. Этот номер и есть файловый дескриптор. Дальше программа работает не с самим файлом, а с этим номером, передавая его в системные вызовы (read, write, close, epoll_wait и т.д.).

Для процесса файловый дескриптор — это просто "ручка" или "идентификатор", по которому ядро понимает, с каким именно ресурсом сейчас хотят что-то сделать.

Под файловым дескриптором понимается не только файл на диске. Это может быть:

  • стандартный ввод/вывод,
  • сетевой сокет,
  • канал (pipe),
  • терминал,
  • epoll-объект,
  • любой другой объект ввода-вывода ядра.

Что такое epoll-объект в Linux

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

С точки зрения пользовательского пространства epoll-объект выглядит как обычный файловый дескриптор, полученный в результате системного вызова epoll_create или epoll_create1. Этот дескриптор указывает не на файл на диске и не на сокет, а на внутреннюю структуру ядра, в которой хранится набор "наблюдаемых" файловых дескрипторов и список событий, которые по ним нужно отслеживать (готовность к чтению, записи, ошибки и т. д.).

После создания epoll-объекта процесс может регистрировать в нём другие файловые дескрипторы с помощью epoll_ctl. С этого момента ядро знает, что процесс заинтересован в событиях на этих дескрипторах, и берёт на себя задачу отслеживания их состояния. Сам процесс при этом не опрашивает каждый дескриптор по очереди, а один раз блокируется в вызове epoll_wait, передавая ядру epoll-дескриптор.

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

Таким образом, epoll-объект — это "точка взаимодействия" между процессом и ядром, через которую процесс говорит: "вот набор файлов, разбуди меня, когда с ними можно работать". Именно на этом механизме Линукса построены современные событийные циклы, включая asyncio.

Как работать с epoll-объектами из Python

Механизм epoll существует на уровне ядра и доступен процессам через системные вызовы: epoll_create, epoll_create1, epoll_ctl и epoll_wait. Эти вызовы относятся к системному программированию и изначально предназначены для использования из C-кода. Сам по себе epoll не имеет никакого отношения к Python — это инфраструктура ядра для ожидания событий на файловых дескрипторах.

Стандартная библиотека Python предоставляет обёртки над системными вызовами к ядру Линукса, позволяя работать с ними из Python-кода. Для epoll-механизма эти обертки сосредоточены в модуле select стандартной библиотеки.

Когда в Python вызывается select.epoll(), интерпретатор внутри себя вызывает соответствующий системный вызов ядра (epoll_create1). Ядро создаёт epoll-объект, выделяет для него внутреннюю структуру и возвращает процессу файловый дескриптор, указывающий на этот объект. Python получает этот дескриптор и оборачивает его в объект высокого уровня — экземпляр класса epoll. Этот Python-объект всего лишь хранит номер файлового дескриптора и знает, как вызывать через C-API нужные системные вызовы (epoll_ctl, epoll_wait, close).

С этого момента работа идёт так же, как и в системном программировании на C, только синтаксис другой. Python-код передаёт в методы объекта epoll числовые файловые дескрипторы других ресурсов — сокетов, пайпов, файлов — и тем самым регистрирует их в epoll-объекте ядра. Когда вызывается метод ожидания (epoll.poll()), Python снова просто прокидывает управление в ядро, блокируясь в epoll_wait до тех пор, пока ядро не сообщит о готовности одного или нескольких дескрипторов.

Таким образом, Python здесь выступает не как высокоуровневая асинхронная среда, а как язык системного программирования. Он даёт удобный интерфейс, управление временем жизни объектов и безопасность памяти, но фактически позволяет напрямую пользоваться механизмами ядра Linux. epoll-объект, с точки зрения ядра, ничем не отличается от созданного в C-программе; разница лишь в том, что его жизненным циклом управляет Python-объект-обёртка.

Модули select и selectors в стандартной библиотеке Python — в чем отличие

Модуль select — это низкоуровневый интерфейс к механизмам ожидания ввода-вывода, которые предоставляет операционная система. Он максимально близок к системным вызовам. В нём собраны прямые обёртки над select, poll, epoll, kqueue и другими примитивами, если они доступны на данной платформе. Используя select, программист сам выбирает конкретный механизм, сам работает с файловыми дескрипторами и сам отвечает за корректное управление ими. Этот модуль — про системное программирование и почти буквально отражает возможности ядра.

Модуль selectors находится уровнем выше. Он не добавляет новых возможностей и не заменяет select, а обобщает его. Его задача — скрыть платформенные различия и предоставить единый интерфейс для ожидания событий. Программисту не нужно знать, есть ли в системе epoll, kqueue или только select: он работает с объектом селектора, а модуль selectors сам выбирает наиболее эффективный механизм, доступный на текущей ОС. На Линуксе это почти всегда будет epoll.

Если select — это "прямой доступ к рычагам ядра", то selectors — это адаптер, который говорит: "скажи, что ты хочешь ждать, а я сам решу, как именно это сделать оптимальным способом". Внутри selectors всё равно используются те же самые системные вызовы, но ответственность за выбор механизма и унификацию интерфейса берёт на себя стандартная библиотека.

Именно поэтому в прикладном коде и в высокоуровневых библиотеках почти всегда используют selectors, а select остаётся инструментом для низкоуровневых задач, экспериментов и понимания того, как именно Python общается с ядром.

Как связаны объект событийного цикла в asyncio и epoll-объект ядра Linux

Событийный цикл в asyncio — это объект пользовательского уровня, который при инициализации создаёт Python-объект selector, а тот, в свою очередь, создаёт epoll-объект ядра.

Сам событийный цикл напрямую с epoll не работает: всё взаимодействие с механизмами ожидания ввода-вывода делегировано модулю selectors, который выбирает оптимальную реализацию для текущей платформы. На Линуксе этой реализацией является EpollSelector, внутри которого живёт файловый дескриптор epoll-объекта.

Таким образом, событийный цикл не является реализацией epoll, а выступает координатором: он создаёт и владеет селектором, а селектор инкапсулирует конкретный механизм ядра и обеспечивает связь между низкоуровневым I/O и высокоуровневой моделью выполнения asyncio.

Если после запуска любой Python-программы, в которой создан asyncio событийный цикл, посмотреть на список дескрипторов процесса, то мы увидим там кроме stdin/stdout/stderr (стандартные потоки ввода, вывода, ошибок) еще вот такую запись

fd=  3 -> anon_inode:[eventpoll]

Эта запись означает, что у процесса открыт файловый дескриптор, указывающий на epoll-объект ядра. Он не связан ни с файлом на диске, ни с сокетом, ни с каналом, поэтому в /proc/<pid>/fd он отображается как anon_inode. Это стандартный способ, которым ядро показывает объекты, существующие только в его памяти.

eventpoll — это внутреннее имя типа анонимного айнода (inode — запись, через которую ядро идентифицирует объект в файловой системе; "анонимный" означает, что за ним нет файла на диске), используемого именно для epoll. Когда Python через модуль select (опосредованно через selectors) вызывает epoll_create1, ядро создаёт epoll-объект и возвращает файловый дескриптор. Python сохраняет этот дескриптор внутри селектора, а значит — внутри событийного цикла. В результате у процесса появляется дополнительный файловый дескриптор, обычно с номером 3 или выше, в зависимости от того, какие дескрипторы уже были открыты.

Этот дескриптор — центральная точка взаимодействия между Python-процессом и ядром в части асинхронного ввода-вывода. Все сокеты регистрируются в этом epoll-объекте, а таймеры (о них расскажу позднее) управляются на уровне Python и определяют максимальное время ожидания, которое передается как аргумент timeout в системный вызов epoll_wait. Сам событийный цикл на каждой итерации блокируется, делая через свой селектор (обертку над epoll-объектом) системный вызов epoll_wait. Когда ядро сообщает о событиях, они возвращаются в Python-код уже в виде абстрактных уведомлений, с которыми работает событийный цикл.

Таким образом, появление anon_inode:[eventpoll] в списке дескрипторов — это прямое и ожидаемое следствие того, что событийный цикл создан и активен. Это материальное подтверждение того, что под высокоуровневой моделью asyncio действительно лежит низкоуровневый механизм ядра Линукс, подключённый к процессу через обычный файловый дескриптор.

Состояние каких объектов отслеживается через epoll-механизм

В epoll регистрируются файловые дескрипторы объектов, состояние которых может меняться во времени и о готовности которых ядро может уведомить процесс.

В первую очередь это:

  • сетевые сокеты (TCP, UDP, UNIX),
  • каналы и FIFO,
  • pty, терминалы,
  • некоторые специальные файлы ядра (eventfd, signalfd, timerfd),
  • другие "потоковые" или событийные объекты.

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

Регулярные файлы (файлы на диске) в epoll не поддерживаются, ядро отклонит попытку их зарегистрировать. Причина в том, что у них нет асинхронной природы. Чтение из файла не зависит от внешних событий вроде прихода сетевого пакета: данные либо уже в кеше, либо ядро блокирует поток на время чтения с диска. В любом случае здесь нет события, которое epoll мог бы отслеживать.

Сокеты — основной тип ресурсов, с которыми работает asyncio

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

Когда данные приходят по сети или когда буфер сокета освобождается и становится возможной запись, ядро фиксирует изменение состояния соответствующего файлового дескриптора и уведомляет об этом через epoll. Событийный цикл получает эти уведомления и на их основе решает, какие корутины можно продолжить. Именно поэтому асинхронные программы на asyncio хорошо масштабируются при большом количестве сетевых соединений: вместо активного опроса каждого сокета Python передаёт ядру задачу следить за ними и реагирует только на реальные события.

Таким образом, хотя epoll способен работать и с другими типами файловых дескрипторов, в практическом использовании asyncio он почти всегда обслуживает сетевые сокеты — как основной источник асинхронных событий ввода-вывода.

Модель взаимодействия событийного цикла с epoll-объектом ядра

Событийный цикл создаёт и удерживает селектор (высокоуровневую обертку над epoll ядра). Этот epoll-объект становится единой точкой, через которую цикл узнаёт о готовности ввода-вывода. Все сетевые сокеты, с которыми работает асинхронная программа, при создании или принятии соединения передаются событийным циклом в селектор и регистрируются в epoll с указанием интересующих событий — обычно готовности к чтению или записи.

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

Когда данные приходят по сети или сокет становится готов к записи, ядро фиксирует это и возвращает управление из epoll_wait, передавая список файловых дескрипторов, по которым есть события. Селектор (напомню, это объект-атрибут событийного цикла) возвращает события уже вместе с привязанными к ним колбэками — теми самыми обработчиками, которые были зарегистрированы при ожидании чтения или записи. Событийный цикл помещает их в очередь готовых к исполнению и выполняет связанный Python-код.

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

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

# Инициализация:
ep = epoll_create1() # попросили ядро создать для нашего процесса epoll-объект
fd_to_waiter = {} # пустой словарь для сопоставления: fd (файловый дескриптор) -> "кто ждёт" (функция-корутина)

# Когда корутина делает await чтения/записи:
#   зарегистрировать fd в epoll с нужной маской событий
#   запомнить, с какого места нужно продолжить исполнение связанной c fd функции, когда придёт событие

while not stopped:

    # 1) Ждём события от ядра (epoll_wait)
    events = epoll_wait(ep)   # вернёт список (fd, eventmask)

    # 2) Превращаем события в "готовую работу"
    ready = []
    for (fd, mask) in events:
        waiter = fd_to_waiter[fd]
        ready.append((waiter, mask))

    # 3) Выполняем готовую работу в Python
    for (waiter, mask) in ready:
        # снять или обновить интерес к fd (в зависимости от операции)
        # продолжить связанный Python-код (колбэк / корутину через Task)
        run_waiter(waiter, mask)

        # run_waiter исполняется до следующего await, где снова
        # регистрирует нужный fd в epoll и "отдаёт управление" обратно

Смысл такой: ядро через epoll говорит "вот дескриптор, который готов к чтению/записи", а цикл превращает это в продолжение конкретного ожидающего участка Python-кода.

Раскрываем "магию" asyncio — как же тысячи и десятки тысяч сетевых соединений быстро обрабатываются в одном потоке?

"Волшебство" asyncio при работе с тысячами одновременных соединений целиком опирается на свойства ядра Линукс и кооперативную модель выполнения в Python (кооперативную — значит задача сама решает, когда отдать управление; в противоположность вытесняющей, где переключение происходит принудительно, как в потоках ОС).

Во-первых, никакой параллельности на уровне исполнения Python-кода нет. В каждый момент времени выполняется ровно один участок кода в одном потоке. asyncio не пытается делать несколько вещей одновременно и не переключает задачи принудительно.

Во-вторых, ключевая идея состоит в том, что подавляющее большинство времени сетевые соединения ничего не делают. Они ждут прихода данных, освобождения буфера для записи или ответа удалённой стороны. Вместо того чтобы держать под каждое соединение отдельный поток и блокироваться на ожидании, программа передаёт ядру задачу следить за состоянием всех сокетов сразу (вот он, epoll-механизм!).

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

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

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

Именно поэтому модель хорошо масштабируется: стоимость ожидания почти нулевая, переключение между задачами происходит только в заранее определённых точках (await), а вся тяжёлая работа по отслеживанию состояния тысяч соединений выполняется внутри ядра, а не в Python-коде.

Ограничения asyncio

Описанная в предыдущем параграфе модель прекрасно работает ровно до тех пор, пока выполняемый Python-код остаётся коротким и неблокирующим. Вся описанная "красота" asyncio основана на том, что ожидание ввода-вывода вынесено за пределы программы и делегировано ядру через epoll. Именно ядро следит за тысячами сокетов и будит процесс только тогда, когда по ним действительно есть события.

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

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

Всё, что происходит между двумя await, выполняется как обычный синхронный Python-код в одном потоке, без какой-либо помощи со стороны epoll или событийного цикла.

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

Для таких случаев в asyncio предусмотрены инструменты выноса блокирующего кода за пределы событийного цикла. Функция asyncio.to_thread() позволяет выполнить блокирующий вызов в отдельном потоке, не останавливая цикл. Для интенсивных вычислительных задач можно использовать loop.run_in_executor() с ProcessPoolExecutor, чтобы вынести вычисления в отдельный процесс. В обоих случаях принцип тот же: событийный цикл остаётся свободным для обработки I/O-событий, а тяжёлая работа происходит в стороне. Результат возвращается в цикл, и корутина продолжается с того места, где остановилась.

Таймеры — второй кит событийного цикла asyncio

Второй ключевой элемент событийного цикла — таймеры. Именно они определяют, сколько времени событийный цикл имеет право спать в epoll_wait, если сетевых событий нет.

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

В asyncio все операции, связанные со временем (sleep, call_later, таймауты ожиданий, дедлайны отмен) сводятся к внутренней системе таймеров. Эти таймеры представляют собой отложенные колбэки с точным временем срабатывания. Событийный цикл хранит их в упорядоченной структуре данных и всегда знает, какой из таймеров должен сработать следующим.

Перед каждой итерацией ожидания событийный цикл сравнивает текущее время с временем ближайшего таймера. Если таймеров нет, он передает в epoll_wait бесконечный таймаут. Если таймер есть, цикл вычисляет разницу между текущим моментом и временем его срабатывания и использует эту разницу как timeout для epoll. Таким образом, epoll_wait либо вернётся раньше из-за сетевого события, либо гарантированно проснётся к моменту, когда нужно обработать таймер.

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

За счёт этого механизма таймеры и ввод-вывод органично объединяются в одном ожидании. Событийный цикл никогда не крутится впустую и не использует отдельные потоки для отсчёта времени. Он просто корректно подбирает значение timeout для epoll, используя таймеры как ориентир, и тем самым синхронизирует работу Python-кода с возможностями ядра Линукс.

asyncio.sleep — частный случай переключений между задачами с помощью таймеров

Когда корутина вызывает:

await asyncio.sleep(5)

никакого "сна" потока не происходит. Вместо этого происходит следующее.

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

Важно: в этот момент корутина полностью исчезает из исполнения. Она не занимает CPU, не проверяется в цикле и не "спит" сама по себе. Единственное, что о ней напоминает, — запись в очереди таймеров.

Дальше событийный цикл живёт своей обычной жизнью. Перед очередным блокирующим вызовом epoll_wait событийный цикл смотрит на ближайший таймер. Если этот sleep(5) — самый ранний таймер, цикл вычисляет timeout = 5 секунд и передаёт его в epoll_wait. Это означает: "разбуди меня либо до истечения этих 5 секунд, если хотя бы на одном из отслеживаемых сокетов произойдёт интересующее событие (разбуди "по I/O"), либо максимум через 5 секунд".

Если за это время приходят сетевые события, epoll_wait возвращается раньше, цикл их обрабатывает, а затем снова пересчитывает таймаут — уже с учётом оставшегося времени до пробуждения sleep. Если же никаких событий нет, epoll_wait просто возвращается по таймауту ровно через 5 секунд.

Когда таймаут истекает, событийный цикл видит, что время таймера наступило, извлекает его из очереди и возобновляет корутину, которая ждала asyncio.sleep. С этого момента выполнение продолжается сразу после await asyncio.sleep(5).

Таким образом, asyncio.sleep — это не блокировка и не задержка выполнения потока. Это всего лишь регистрация таймера и корректный подбор timeout для системного вызова epoll_wait. Вся "работа по ожиданию времени" снова делегирована ядру, а Python-код выполняется только в момент пробуждения.

Третий кит событийного цикла: self-pipe trick, или Как разбудить событийный цикл досрочно (или без таймера)

В параграфе о таймерах было важное замечание: "Если таймеров нет, событийный цикл передает в epoll_wait бесконечный таймаут". Что делать в этом случае? Как достучаться до событийного цикла? На этот случай под капотом событийного цикла предусмотрен так называемый self-pipe trick.

При создании событийного цикла за Python-процессом закрепляется не только epoll-объект ядра, но и пара специальных сокетов, которые не имеют отношения ни к сетевым соединениям, ни к пользовательскому вводу-выводу.

Если посмотреть на список файловых дескрипторов процесса, то кроме anon_inode:[eventpoll] можно увидеть, например:

fd=4 -> socket:[19527]
fd=5 -> socket:[19528]

Эти два сокета образуют внутреннюю связанную пару и используются исключительно самим событийным циклом. Они создаются через системный вызов socketpair и работают только внутри процесса. Данные, записанные в один сокет пары, немедленно становятся доступны для чтения из другого. Никакой сетевой стек, никакие удалённые узлы здесь не участвуют.

Назначение этой пары сокетов связано с фундаментальной проблемой событийного цикла. Пока цикл ждёт событий в epoll_wait, поток полностью блокирован в ядре. Если в этот момент из другого потока или из обработчика сигнала возникает необходимость срочно вмешаться в работу цикла — например, добавить задачу из потока-исполнителя, доставить SIGTERM или инициировать остановку, — одного epoll-механизма недостаточно. Нужно гарантированно и немедленно разбудить epoll_wait.

Именно для этого и применяется self-pipe trick. Один из этих сокетов регистрируется в epoll как источник событий чтения. Второй используется как "кнопка будильника". Когда требуется разбудить событийный цикл, asyncio записывает 1 байт во второй сокет. Ядро тут же считает первый сокет готовым к чтению, epoll_wait возвращается, и событийный цикл получает управление.

В Python-коде оба этих сокета доступны через объект событийного цикла и его атрибуты _ssock и _csock:

  • loop._ssock — self-pipe источник событий чтения
  • loop._csock — self-pipe источник событий записи

Таким образом, эти два сокета — служебный механизм пробуждения, встроенный в саму архитектуру цикла. Они не передают пользовательские данные и не обслуживают соединения, но без них событийный цикл не смог бы корректно реагировать на внутренние события, которые не связаны с сетевым вводом-выводом.

Без self-pipe механизм событийного цикла оказался бы "глухим" ко всем внутренним событиям в моменты, когда нет ни сетевой активности, ни таймеров.

Именно self-pipe обеспечивает возможность:

  • добавить задачу из другого потока
  • отменить корутину
  • корректно остановить цикл
  • доставить сигнал

даже когда epoll_wait вызван с бесконечным таймаутом.

Собираем всех китов вместе

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

  • epoll для ожидания I/O-событий (готовность сокетов к чтению/записи)
  • self-pipe для принудительного пробуждения
  • внутреннюю систему таймеров для расчёта максимального времени сна

Результаты работы всех трёх механизмов попадают в единую очередь готовых колбэков (атрибут _ready событийного цикла) — это "рабочий стол" цикла, из которого он последовательно исполняет накопившиеся действия. Если на этом столе уже есть работа, цикл не засыпает в epoll_wait вовсе — он вызывает его с нулевым таймаутом, чтобы только забрать возможные I/O-события, и сразу переходит к исполнению.

При запуске событийный цикл создаёт epoll-объект ядра и пару служебных сокетов для self-pipe. Один конец пары регистрируется в epoll как источник событий чтения: это "кнопка пробуждения", которая всегда может разбудить цикл, даже если он спит бесконечно. Далее цикл начинает повторять итерации.

На каждой итерации он сначала решает, сколько времени ему можно спать в ожидании событий. Если в очереди _ready уже есть работа, таймаут равен нулю — цикл только заберёт накопившиеся I/O-события и сразу перейдёт к исполнению. Если очередь пуста, цикл смотрит на ближайший таймер. Если таймеров нет, он выбирает бесконечный таймаут и готов проснуться только от событий ввода-вывода или от self-pipe. Если таймеры есть, он вычисляет, через сколько должен сработать ближайший, и использует эту величину как timeout для ожидания в epoll: так ядро разбудит поток либо раньше по I/O-событию, либо не позже нужного момента по истечении таймаута.

После этого цикл вызывает ожидание: он передаёт управление ядру через epoll_wait(epoll_fd, timeout), где epoll_fd - "адрес" (файловый дескриптор) epoll-объекта, который обслуживает наш процесс. Пока не произошло ни одного события и не истёк таймаут, Python-код не выполняется — поток спит внутри ядра.

Когда epoll_wait возвращается, цикл получает список событий: какие файловые дескрипторы (сокеты) стали готовы и по какому типу события. Дальше он обрабатывает этот список. Если среди событий есть self-pipe-сокет, цикл читает из него данные и тем самым «сбрасывает будильник»: это означает, что пробуждение было инициировано изнутри (например, добавили задачу из другого потока, отменили ожидание, попросили остановиться через SIGTERM и т.д.). Для остальных I/O-событий цикл помещает связанные с ними колбэки в очередь _ready.

Затем цикл смотрит на текущее время и проверяет таймеры. Все таймеры, чей срок уже наступил, тоже помещаются в очередь _ready.

После этого цикл последовательно исполняет всё, что накопилось в _ready: это могут быть обработчики I/O-событий, сработавшие таймеры и другие запланированные действия — всё в единой очереди, строго по порядку. 

Каждый обработчик выполняется до завершения или до ближайшего await, где корутина снова приостанавливается и отдаёт управление циклу.

Закончив исполнение, цикл возвращается в начало: снова вычисляет допустимый timeout, снова засыпает в epoll_wait, снова просыпается либо от I/O-события, либо от self-pipe, либо от истечения таймера.

Так три "кита" образуют единую конструкцию:

  • epoll делает ожидание масштабируемым
  • таймеры задают верхнюю границу таймаута в epoll_wait
  • self-pipe гарантирует мгновенное пробуждение по внутренним причинам даже при бесконечном таймауте

Псевдокод событийного цикла asyncio

# Структуры данных событийного цикла
fd_to_waiter = {} # fd -> ожидающая операция (корутина / колбэк)
timers = priority_queue() # таймеры, отсортированные по времени срабатывания
ready = queue() # очередь колбэков, готовых к немедленному выполнению
stopped = False

# ОСНОВНОЙ ЦИКЛ

while not stopped:

    # 1. Вычисляем timeout для epoll_wait

    if ready is not empty:
        # есть работа прямо сейчас — не спим в epoll,
        # только забираем накопившиеся I/O-события
        timeout = 0
    elif timers is not empty:
        # спим не дольше, чем до ближайшего таймера
        next_timer_time = timers.peek().when
        timeout = max(0, next_timer_time - now())
    else:
        # ни таймеров, ни готовой работы — можно спать бесконечно,
        # разбудит только I/O или self-pipe
        timeout = INFINITE

    # 2. Передаём управление ядру
    # (ожидаем I/O, self-pipe или истечение таймера)
    events = epoll_wait(epoll_fd, timeout)

    # 3. Обрабатываем epoll-события

    for (fd, eventmask) in events:

        if fd == self_pipe_read:
            # пробуждение по self-pipe
            # читаем данные, чтобы сбросить сигнал
            drain(self_pipe_read)
            continue

        # обычное I/O-событие — ставим колбэк в очередь ready
        waiter = fd_to_waiter[fd]
        update_epoll_registration(fd, waiter)
        ready.append(waiter)

    # 4. Обрабатываем сработавшие таймеры

    current_time = now()
    while timers is not empty and timers.peek().when <= current_time:
        timer = timers.pop()
        ready.append(timer.callback)

    # 5. Выполняем всю готовую работу

    while ready is not empty:
        callback = ready.pop()
        run(callback)
        # каждый callback выполняется последовательно,
        # до завершения или до следующего await,
        # где корутина снова зарегистрирует ожидание

Суть псевдокода:

  • epoll эффективно ждёт I/O-события на тысячах сокетов
  • таймеры ограничивают, как долго можно спать в epoll
  • self-pipe гарантирует немедленное пробуждение по внутренним причинам
  • ready-очередь — единая точка, из которой исполняются все колбэки; если в ней есть работа, цикл не блокируется в epoll

Заключение

Надеюсь, после этого разбора событийный цикл перестанет выглядеть как чёрный ящик (как долгое время выглядел для меня), а начнет восприниматься как действительно понятная конструкция.

Если будет желание покопаться в исходниках, то основная логика событийного цикла реализована на чистом Python в двух файлах модуля asyncio:

  • base_events.py —  базовый класс BaseEventLoop, содержащий ядро цикла: метод _run_once() с расчётом таймаута, обработкой таймеров и исполнением очереди _ready
  • selector_events.py — подкласс BaseSelectorEventLoop, реализующий взаимодействие с конкретным механизмом ввода-вывода: создание селектора и self-pipe (_make_self_pipe), преобразование I/O-событий в колбэки (_process_events) и пробуждение цикла из другого потока (_write_to_self)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment