Погружение в libuv. Часть 3. Опять Event Loop.
Anastasia KotovaПредыдущие части
Погружение в libuv. Часть 1. Зачем он нужен?
Погружение в libuv. Часть 2. Неблокирующий ввод-вывод.
Мы уже говорили про Event Loop в Node.js в отдельном цикле статей. Там мы рассмотрели, какие фазы цикла существуют. В этой статье мы сосредоточимся на реализации Event Loop в библиотеке libuv.
Но прежде чем переходить к циклу, нужно познакомиться с базовыми понятиями, которые нам пригодятся.
Ключевые понятия в libuv
Если рассматривать только взаимодействие с Event Loop внутри libuv, то разработчик работает с ограниченным набором сущностей. Вот ключевые из них.
1. Цикл (uv_loop_t)
Это базовый объект, управляющий всеми событиями. Его можно получить как синглтон:
uv_loop_t *loop = uv_default_loop();
Или инициализировать вручную:
uv_loop_t custom_loop; uv_loop_init(&custom_loop); uv_loop_close(&custom_loop); // Очистка
Цикл запускается с помощью uv_run, а при необходимости может быть остановлен:
uv_run(loop, UV_RUN_DEFAULT); uv_stop(loop);
У функции uv_run есть три режима:
UV_RUN_DEFAULT— цикл работает, пока есть активные handles.UV_RUN_ONCE— цикл обрабатывает одно событие и выходит.UV_RUN_NOWAIT— цикл проверяет наличие событий, не блокируя поток.
2. Обработчики (Handles)
Handles — это долгоживущие структуры, которые представляют объекты, генерирующие события. Их нужно привязать к циклу:
uv_timer_t timer; uv_timer_init(loop, &timer);
А затем активировать:
uv_timer_start(&timer, callback, timeout, repeat);
Закрытие handle производится асинхронно:
uv_close((uv_handle_t*)&timer, callback);
Примеры других обработчиков: uv_idle_t, uv_prepare_t, uv_check_t — они участвуют в разных фазах Event Loop.
3. Колбэки (Callbacks)
Колбэки — это функции, которые libuv вызывает при наступлении событий. Они аналогичны тем, что мы используем в JavaScript.
void timer_cb(uv_timer_t* handle) { ... }
uv_timer_start(&timer, timer_cb, timeout, repeat);
void close_cb(uv_handle_t* handle) { ... }
uv_close((uv_handle_t*)&timer, close_cb);
Колбэки выполняются в контексте Event Loop, поэтому любые блокирующие операции внутри них приведут к задержкам во всей программе.
Кратко о фазах в Node.js
В Event Loop в Node.js, в отличие от браузера, существует несколько фаз, которые выполняются в определённой последовательности:
- Timers — выполняются колбэки
setTimeout()иsetInterval(). - Pending Callbacks — обрабатываются отложенные колбэки системных операций.
- Idle, Prepare — внутренние фазы, используемые самим движком.
- Poll — основная фаза ввода-вывода, где обрабатываются I/O-операции.
- Check — выполняются колбэки
setImmediate(). - Close Callbacks — обрабатываются колбэки закрытия ресурсов.
Эта последовательность задаётся не самим Node.js, а библиотекой libuv.
Как фазы Event Loop реализованы в libuv
Всё начинается с вызова uv_run(loop, mode). Пока в цикле остаются активные задачи, он продолжает выполняться. Внутри uv_run фазы обрабатываются в строго определённом порядке:
uv__update_time(loop);
uv__run_timers(loop);
while (loop_is_alive(loop)) {
uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
uv__io_poll(loop, timeout);
uv__run_check(loop);
uv__run_closing_handles(loop);
uv__update_time(loop);
uv__run_timers(loop);
}
Каждая из этих фаз напрямую связана с тем, что мы видим в Event Loop Node.js.
Фаза Timers реализуется через uv__run_timers(). libuv отслеживает таймеры с помощью приоритетной очереди, в которую задачи попадают с учётом времени запуска. Этот механизм используется, когда в Node.js вызывается setTimeout или setInterval.
Фаза Pending Callbacks, представленная в libuv как uv__run_pending(), отвечает за отложенные колбэки, которые были зарегистрированы системными вызовами или внутренними механизмами — например, при завершении асинхронных TCP-операций.
Фазы Idle и Prepare реализованы соответственно в uv__run_idle() и uv__run_prepare(). Это внутренние хуки, доступные разработчикам, которые используют libuv напрямую, но в Node.js они используются в основном для координации внутренних процессов. Например, Prepare позволяет выполнять код непосредственно перед тем, как Event Loop перейдёт к ожиданию ввода-вывода.
Фаза Poll — это сердце неблокирующего ввода-вывода. В libuv она реализована через uv__io_poll(). Именно здесь цикл блокируется до тех пор, пока не появятся события на файловых дескрипторах. В зависимости от операционной системы libuv использует под капотом epoll (на Linux), kqueue (на macOS), или IOCP (на Windows). Здесь обрабатываются события сокетов, файлов, pipes — всё, что может инициировать I/O.
После Poll следует Check, реализованная как uv__run_check(). В Node.js она используется для выполнения колбэков setImmediate(). Эта фаза наступает после I/O, но до следующего Timers, и часто применяется для «отложенного выполнения» задач, которые не должны мешать текущему I/O.
Наконец, Close Callbacks — это uv__run_closing_handles(). В эту фазу попадают колбэки, связанные с закрытием ресурсов. Если мы закрыли сокет и подписались на 'close', то наш колбэк будет вызван именно здесь.
Ожидание в Poll-фазе
Одна из важнейших задач Event Loop — грамотно управлять временем ожидания между итерациями. Если «уснуть» слишком надолго, можно пропустить срабатывание таймера. Если проверять события слишком часто, процесс будет зря расходовать CPU. Поэтому libuv динамически рассчитывает, можно ли блокироваться в фазе Poll, и если да — то на сколько.
Перед каждой итерацией цикла libuv анализирует состояние системы и принимает решение, стоит ли вообще ждать события или лучше сразу перейти к следующей фазе. Для этого он проверяет ряд условий:
- Цикл не должен быть явно остановлен.
- Должны существовать активные обработчики или асинхронные запросы — то, чего мы ждём в Poll-фазе.
- Не должно быть отложенных колбэков (pending callbacks), ожидающих немедленного выполнения.
- Не должно быть активных idle-обработчиков — они требуют исполнения каждый тик и не позволяют засыпать.
- Не должно быть завершающихся дочерних процессов, за которыми нужно следить.
- Не должно быть ресурсов, которым требуется завершение (closing handles).
Если все эти условия выполнены, libuv рассчитывает время до ближайшего таймера и использует его как максимальную длительность блокировки в фазе Poll. В противном случае цикл не блокируется вовсе: он тут же переходит к следующей итерации, чтобы как можно быстрее обработать срочные задачи.
Например, если мы написали TCP-сервер без таймеров, цикл может ждать входящее соединение бесконечно долго, не нагружая CPU. Но как только появляется новая задача, libuv немедленно пробуждается и продолжает работу.
Этот механизм позволяет эффективно балансировать между производительностью, энергопотреблением и своевременным выполнением таймеров.
Как использовать libuv
Чтобы написать программу на C с использованием libuv, достаточно следовать базовой последовательности:
- Инициализировать цикл (
uv_loop_initилиuv_default_loop). - Создать нужные обработчики (
uv_timer_t,uv_tcp_t, и т.д.). - Назначить колбэки.
- Запустить цикл с помощью
uv_run. - Очистить ресурсы (
uv_loop_close).
Всё это очень похоже на модель работы Node.js, но требует явной инициализации.
Пример
Вот простой пример: таймер, срабатывающий через секунду.
#include <stdio.h>
#include <stdlib.h>
#include <uv.h>
// Колбэк для таймера
void timer_cb(uv_timer_t* handle) {
printf("Таймер сработал!\\n");
// После срабатывания останавливаем Event Loop
uv_stop(handle->loop);
}
int main() {
uv_loop_t* loop = uv_default_loop(); // Получаем Event Loop по умолчанию
uv_timer_t timer_req; // Создаём структуру для таймера
uv_timer_init(loop, &timer_req); // Инициализируем таймер
// Запускаем таймер: через 1000 мс (1 секунда), повторов нет
uv_timer_start(&timer_req, timer_cb, 1000, 0);
printf("Запускаем Event Loop\\n");
uv_run(loop, UV_RUN_DEFAULT); // Запускаем Event Loop
printf("Event Loop завершён\\n");
uv_loop_close(loop); // Чистим ресурсы
return 0;
}
Под капотом в этом простом примере работают все те же фазы, что и в полноценной Node.js-программе. Только теперь они контролируются напрямую — через libuv.
Важные нюансы
Стоит помнить несколько ключевых особенностей libuv:
- Порядок фаз жёстко задан — управляет ими
uv_run, а не сам разработчик. uv_close()лишь планирует закрытие handle — реальное завершение произойдёт позже, в колбэке.- Один цикл (
uv_loop_t) — один поток. Для многопоточности используются либо отдельные циклы, либо специальные объекты, такие какuv_async_t, позволяющие пробуждать цикл из других потоков.