Погружение в libuv. Часть 4. Другие функции.

Погружение в libuv. Часть 4. Другие функции.

Anastasia Kotova

Предыдущие части

Погружение в libuv. Часть 1. Зачем он нужен?

Погружение в libuv. Часть 2. Неблокирующий ввод-вывод.

Погружение в libuv. Часть 3. Опять Event Loop.


Помимо работы с Event Loop, которая, безусловно, является центральной частью libuv, библиотека предоставляет множество других полезных функций. О них мы и поговорим в этой статье, завершая цикл о libuv.

Thread pool

Во второй части мы уже говорили о том, что не все операции на уровне ОС могут быть асинхронными. Более того, некоторые из них поддерживают асинхронный режим только на одной из платформ, в то время как libuv стремится оставаться кроссплатформенной библиотекой. Кроме того, libuv берёт на себя выполнение вычислительно тяжёлых задач. Для всех таких случаев, а также для унификации подхода, используется thread pool.

Thread Pool (пул потоков) — это набор заранее созданных потоков, которые выполняют задачи по очереди.

Таким образом, можно сказать, что и сам Node.js, использующий libuv под капотом, не является полностью однопоточным.

Следующие типы операций выполняются в thead pool:

  • Файловая система: Все операции с файлами — чтение, запись, открытие, закрытие и другие (uv_fs_read, uv_fs_write, uv_fs_open, uv_fs_close и т. п.).
  • DNS-резолвинг: Функции типа uv_getaddrinfo() и uv_getnameinfo() также могут блокировать поток на неопределенное время, особенно при медленном DNS-сервере.

Также thread pool используется на уровне Node.js для:

  • Zlib операций: Сжатие и распаковка данных — это CPU-интенсивные операции, которые могут значительно замедлить event loop.
  • Криптографических операций: Например, crypto.pbkdf2, crypto.scrypt.

По умолчанию создаётся 4 потока, но это значение можно изменить через переменную окружения UV_THREADPOOL_SIZE (допустимые значения: от 1 до 1024). Потоки создаются не сразу, а по мере необходимости. Важно помнить, что все операции — будь то fs.readFile или DNS — используют общий пул, что может вызывать взаимное влияние при высоких нагрузках.

Как ставить задачи вручную

Libuv позволяет не только использовать пул потоков внутри своих API, но и запускать туда собственные задачи с помощью uv_queue_work:

int uv_queue_work(uv_loop_t* loop,
                  uv_work_t* req,
                  uv_work_cb work_cb,
                  uv_after_work_cb after_work_cb);
  • req — структура, через которую можно передать данные в колбэки.
  • work_cb — функция, выполняемая в одном из рабочих потоков. Здесь можно делать всё, что блокирует.
  • after_work_cb — вызывается в основном потоке после завершения work_cb, обычно используется для обработки результата и взаимодействия с Event Loop.

Важно: внутри work_cb нельзя вызывать большинство функций libuv — библиотека не является потокобезопасной. Все взаимодействия с Event Loop должны происходить строго из основного потока.

Пример работы с uv_queue_work:

#include <stdio.h>
#include <stdlib.h>
#include <uv.h>

// Структура для передачи данных между задачами
typedef struct {
    int input;
    int result;
} work_data_t;

// Функция, которая выполняется в потоке (долгая/блокирующая)
void heavy_task(uv_work_t* req) {
    work_data_t* data = (work_data_t*)req->data;
    printf("[Thread] Calculating factorial of %d...\\n", data->input);

    // Имитация долгого вычисления (например, факториал)
    data->result = 1;
    for (int i = 1; i <= data->input; i++) {
        data->result *= i;
        uv_sleep(100); // Искусственная задержка для наглядности
    }
}

// Функция, которая выполняется в основном потоке после heavy_task
void after_task(uv_work_t* req, int status) {
    work_data_t* data = (work_data_t*)req->data;
    printf("[Main Thread] Factorial of %d is %d\\n", data->input, data->result);

    // Освобождаем память
    free(req->data);
    free(req);
}

int main() {
    uv_loop_t* loop = uv_default_loop();

    // Создаем задачу
    uv_work_t* work_req = malloc(sizeof(uv_work_t));
    work_data_t* data = malloc(sizeof(work_data_t));
    data->input = 5; // Вычисляем 5!
    work_req->data = data;

    // Ставим задачу в очередь Thread Pool
    uv_queue_work(loop, work_req, heavy_task, after_task);

    // Главный поток продолжает работать
    printf("[Main Thread] Waiting for result...\\n");

    // Запускаем цикл событий
    uv_run(loop, UV_RUN_DEFAULT);

    return 0;
}

Дополнительное API для работы с потоками

Кроме uv_queue_work, libuv предоставляет низкоуровневый API для работы с потоками вручную:

  • uv_thread_create() — создает новый поток
  • uv_thread_self() — возвращает идентификатор текущего потока
  • uv_thread_equal() — сравнивает два потока
  • uv_mutex_*, uv_cond_*, uv_rwlock_* — дополнительные методы для синхронизации и доступа к общим данным

Node.js использует это API для реализации модуля worker_threads.

Работа со временем

Libuv предлагает два подхода к работе со временем: uv_timer_t и uv_hrtime.

Для планирования таймеров внутри Event Loop, а именно для работы setTimeout и setInterval в Node.js используется метод uv_timer_t. Пример работы с ним мы рассмотрели в третьей части. Такие таймеры не гарантируют точную задержку: они зависят от загрузки Event Loop, и если цикл перегружен, колбэк может выполниться с задержкой.

Для получения более точного времени libuv предоставляет uv_hrtime — функцию, возвращающую время в наносекундах. В Node.js это доступно через process.hrtime.bigint() и используется внутри performance.now().

Работа с DNS

Когда в Node.js происходит DNS-запрос, под капотом вызывается uv_getaddrinfo. Это асинхронная обёртка над системной функцией getaddrinfo(), которая по своей природе блокирующая. Чтобы не блокировать основной поток, libuv отправляет такую операцию в thread pool.

Использование thread pool для DNS-запросов обусловлено ограничениями операционных систем. Функция getaddrinfo() является синхронной, так как она может обращаться к различным источникам: локальному кэшу, файлу hosts, DNS-серверам и конфигурации. Это поведение предсказуемо и ожидаемо для системных администраторов, но требует блокирующих операций.

Node.js предлагает альтернативу — функции семейства dns.resolve*(), которые используют библиотеку c-ares. Она делает DNS-запросы напрямую, минуя системные настройки.

// Использует thread pool (медленнее)
dns.lookup('example.com', (err, address) => {
  console.log(address);
});

// Использует c-ares (быстрее)
dns.resolve4('example.com', (err, addresses) => {
  console.log(addresses);
});

Однако libuv не использует этот вариант в uv_getaddrinfo, потому что это нарушило бы совместимость с системными настройками, и библиотека остаётся на стороне предсказуемости и совместимости.

Работа с файловой системой

Когда мы вызываем fs.readFile(), fs.stat(), fs.writeFile() или любую другую функцию модуля fs в Node.js, под капотом происходит обращение к соответствующим uv_fs_* функциям внутри libuv.

И хоть мы и рассматривали выше, что работа с файлами происходит в thread pool, не все файловые операции выполняются одинаково. В libuv используется два подхода:

Thread Pool используется для:

  • Операций чтения/записи файлов (uv_fs_read, uv_fs_write)
  • Получения метаданных (uv_fs_stat, uv_fs_lstat, uv_fs_fstat)
  • Операций с директориями (uv_fs_readdir, uv_fs_mkdir)
  • Синхронизации (uv_fs_fsync, uv_fs_fdatasync)

Прямые системные вызовы используются для:

  • Открытия/закрытия файлов (uv_fs_open, uv_fs_close)
  • Простых операций вроде uv_fs_access

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

Также стоит помнить несколько нюансов:

  1. Thread pool ограничен по размеру (по умолчанию — 4 потока).
  2. Если все потоки заняты, новые операции встают в очередь и ждут.
  3. После завершения работы с файлом обязательно вызывайте uv_fs_close, чтобы освободить дескриптор.

Несвоевременное закрытие файлов может привести к следующим проблемам:

  • превышению лимита открытых дескрипторов на процесс;
  • утечкам памяти;
  • невозможности удалить файл (например, на Windows).

Заключение

Мы рассмотрели основные методы, реализованные в libuv, однако их гораздо больше, чем было представлено здесь.

Я верю, что полученные знания об устройстве libuv помогут вам ещё лучше понимать особенности Node.js — как они помогли и мне.

Подписывайтесь на мой телеграм-канал — там ещё много чего интересного)

Report Page