Погружение в 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
Причина такого разделения в том, что операции из первой группы потенциально могут блокироваться на длительное время (например, при чтении с медленного диска), тогда как операции из второй группы обычно выполняются быстро.
Также стоит помнить несколько нюансов:
- Thread pool ограничен по размеру (по умолчанию — 4 потока).
- Если все потоки заняты, новые операции встают в очередь и ждут.
- После завершения работы с файлом обязательно вызывайте
uv_fs_close, чтобы освободить дескриптор.
Несвоевременное закрытие файлов может привести к следующим проблемам:
- превышению лимита открытых дескрипторов на процесс;
- утечкам памяти;
- невозможности удалить файл (например, на Windows).
Заключение
Мы рассмотрели основные методы, реализованные в libuv, однако их гораздо больше, чем было представлено здесь.
Я верю, что полученные знания об устройстве libuv помогут вам ещё лучше понимать особенности Node.js — как они помогли и мне.
Подписывайтесь на мой телеграм-канал — там ещё много чего интересного)