Главная / Статьи / Объяснение конкурентности в Node.js: libuv, цикл событий и пул потоков

Объяснение конкурентности в Node.js: libuv, цикл событий и пул потоков

Узнайте, как Node.js использует примитивы операционной системы libuv и пул рабочих потоков для обработки асинхронных операций ввода-вывода, а также о распространённых проблемах пула потоков и советах по его настройке.

2284 слов

Почти каждый разработчик слышит фразу «Node.js работает в однопоточном режиме» уже в первую неделю изучения этой платформы. Однако на практике один процесс Node.js может одновременно читать сотни файлов, искать тысячи записей DNS и управлять десятками тысяч открытых соединений с базой данных, при этом продолжая выполнять код без пауз.

Если сам JavaScript работает только в одном потоке, как сервер Node.js может продолжать отвечать на запросы, пока загружает файл объемом в несколько гигабайт с вращающегося диска?

Механизмом этого является libuv — библиотека на языке C, специально созданная для Node.js и предназначенная для управления асинхронными, неблокирующими операциями ввода-вывода.

Полное понимание того, как libuv переносит нагрузку вне потока JavaScript, — это не просто теоретические знания. Именно оно объясняет, почему запрос к базе данных с точки зрения производительности ведет себя иначе, чем операция хэширования, почему изменение одной переменной окружения может существенно повлиять на скорость (или медлительность) отклика вашего API в производстве, а также как можно выявлять и избегать скрытых узких мест в ваших сервисах.

Что такое libuv?

Node.js — это не единый монолитный движок, а набор нескольких взаимодействующих слоев:

+-------------------------------------------------------------+
|                      Your Application                       |
+-------------------------------------------------------------+
|                    Node.js Core (JS / C++)                  |
+------------------------------+------------------------------+
|     V8 Engine (Google)       |            libuv             |
|   (Executes JavaScript)      |   (Event Loop & Async I/O)   |
+------------------------------+------------------------------+
|                Operating System Kernel                      |
+-------------------------------------------------------------+
  • V8 (Google): Этот движок компилирует и выполняет ваш JavaScript. У него есть ровно одна стек вызовов, и он выполняет код последовательно, только в одном потоке.
  • libuv: мультиплатформенная библиотека, написанная на C, которая отвечает за цикл событий, пул рабочих потоков, доступ к файловой системе, таймеры, запуск дочерних процессов и мониторинг сетевых сокетов.
  • Когда люди называют Node.js однопоточным, они на самом деле имеют в виду, что контекст выполнения JavaScript работает на одном основном потоке. Однако сама libuv написана на C и по своей природе является многопоточной. Она использует любые низкоуровневые возможности, предоставляемые операционной системой, для одновременного выполнения задач, при этом не блокируя выполнение JavaScript.

    Два способа обработки асинхронных задач libuv

    Часто считается, что libuv направляет каждую асинхронную операцию в фоновый поток. Это не совсем верно — на самом деле libuv распределяет работу между двумя разными стратегиями в зависимости от типа задачи:

    • Встроенные неблокирующие средства операционной системы (для ввода-вывода в сети)
    • Внутренний пул потоков libuv (для доступа к файловой системе, поиска в DNS и работы с криптографией)

    Понимание этого разделения, пожалуй, является самой полезной моделью для анализа производительности бэкенда Node.js.

    Incoming Async Task
            │
            ├── Is it Network I/O? (TCP/UDP, HTTP sockets)
            │     └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
            │          (Zero worker threads used)
            │
            └── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
                  └──> Handled by libuv Thread Pool (4 threads by default)
    

    1. Ввод-вывод в сети: примитивы операционной системы

    Современные операционные системы оснащены специализированными неблокирующими API для работы с сетевыми сокетами:

    • epoll в Linux
    • kqueue в macOS и семействе BSD
    • IOCP (порты завершения для ввода-вывода) в Windows

    Когда приложение Node.js запускает TCP-слушатель или отправляет внешний HTTPS-запрос, libuv не передаёт его на обработку рабочей нити. Вместо этого она регистрирует файловый дескриптор сокета непосредственно в ядре операционной системы, фактически запрашивая уведомление о поступлении данных на этот сокет или когда он готов принимать дополнительные данные.

    После этого libuv просто ждёт. Именно ядро операционной системы отслеживает сетевое оборудование.

    Как только пакеты действительно поступают на сетевой интерфейс, ядро генерирует событие. libuv захватывает его во время этапа опроса своего цикла событий, затем помещает соответствующий JavaScript-колбэк в очередь на выполнение. В конечном итоге V8 извлекает его из очереди и запускает на главной нити.

    Поскольку ни один рабочий поток не простаивает в ожидании поступления данных по сети, один процесс Node.js может легко обрабатывать десятки тысяч медленных или неактивных соединений, при этом потребляя совсем немного памяти.

    2. Ввод/вывод файлов и системные операции: пул рабочих потоков

    Учитывая, что сетевые сокеты могут обрабатываться без блокировки на уровне ядра, возникает вопрос: почему чтение и запись файлов не могут работать таким же образом?

    Причина в том, что большинство операционных систем не имеют настоящего неблокирующего API для доступа к файловой системе. В системах на основе POSIX, таких как Linux и macOS, обычные операции с файлами блокируют тот поток, который их запускает, до тех пор, пока устройство хранения фактически не вернет запрошенные данные.

    Если Node.js попытается выполнить чтение файла непосредственно на основном потоке JavaScript, весь срок выполнения программы будет приостановлен до тех пор, пока диск не начнет вращаться, не скачает соответствующие блоки данных и не вернет байты. В течение всего этого времени никакие другие входящие HTTP-запросы не смогут быть обработаны.

    Чтобы избежать этой проблемы, libuv поддерживает внутренний пул рабочих потоков.

    Вот что происходит, когда ваш код вызывает fs.readFile():

    • Запрос на выполнение JavaScript-кода передается через внутренние механизмы Node в libuv.
    • libuv упаковывает запрос на чтение файла в единицу работы и помещает его во внутренний очередь задач.
    • Один из фоновых потоков пула берет этот запрос из очереди.
    • Этот поток затем безопасно выполняет фактический блокирующий системный вызов — read() или write() — в отдельности от основного потока.
  • После завершения чтения рабочая нить сигнализирует циклу событий через механизм уведомления между нитями.
  • Цикл событий затем планирует выполнение соответствующего обратного вызова на JavaScript на главной нити, передавая полученный буфер.
  • Что на самом деле выполняется в пуле нитей?

    Четыре основные категории задач используют пул нитей libuv:

    • Запросы к файловой системе: все асинхронные методы из модуля fs, такие как fs.readFile, fs.stat и fs.writeFile.
    • Поиск в DNS: конкретно функция dns.lookup(), которая опирается на блокирующую функцию C getaddrinfo(). В отличие от нее, dns.resolve() полностью игнорирует пул нитей и взаимодействует с сетью напрямую с помощью неблокирующих запросов.
  • Дорогостоящие криптографические операции: функции вроде crypto.pbkdf2(), crypto.scrypt() и алгоритмы генерации ключей.
  • Рутины сжатия: асинхронные методы zlib, такие как zlib.gzip().
  • Наблюдение за работой пула потоков

    Вы можете подтвердить, что libuv использует фоновый пул потоков и узнать его стандартный размер с помощью простого эксперимента:

    // thread-pool-test.js
    const crypto = require('crypto');
    
    const start = Date.now();
    const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
      crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
        const elapsed = Date.now() - start;
        console.log(`Task ${i} completed in ${elapsed}ms`);
      });
    }
    

    crypto.pbkdf2() — хороший пример для тестирования, поскольку он выполняет интенсивные вычисления на процессоре для генерации хэша пароля, и эти вычисления передаются в пул потоков.

    Запустите скрипт из своей терминалы:

    node thread-pool-test.js
    

    Результат будет выглядеть примерно так:

    Task 2 completed in 218ms
    Task 1 completed in 220ms
    Task 4 completed in 224ms
    Task 3 completed in 226ms
    Task 5 completed in 435ms
    Task 6 completed in 437ms
    

    Почему последние два задания выполняются вдвое дольше?

    Внимательно посмотрите на временные показатели. Первые четыре задачи завершаются примерно в один и тот же момент — около 220 мс. Однако пятая и шестая задачи занимают почти 435 мс, что в два раза больше.

    Причина в том, что пул потоков libuv по умолчанию содержит 4 потока.

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

    Настройка размера пула с помощью UV_THREADPOOL_SIZE

    Вы можете изменить количество рабочих потоков, которые запускает libuv, установив переменную окружения UV_THREADPOOL_SIZE перед запуском процесса Node.js. Она принимает значения от 1 до 128.

    Попробуйте тот же скрипт снова, на этот раз запросив пул из 8 потоков:

    # On Linux / macOS:
    UV_THREADPOOL_SIZE=8 node thread-pool-test.js
    
    # On Windows (PowerShell):
    $env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
    

    Результаты теперь выглядят иначе:

    Task 1 completed in 240ms
    Task 3 completed in 242ms
    Task 2 completed in 245ms
    Task 6 completed in 249ms
    Task 4 completed in 250ms
    Task 5 completed in 252ms
    

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

    Важное ограничение: вы не можете изменить размер пула потоков прямо внутри своего скрипта, установив значение process.env.UV_THREADPOOL_SIZE = 8. Библиотека libuv считывает и фиксирует это значение ещё до запуска вашего JavaScript-кода, поэтому переменную окружения необходимо задать на уровне оболочки или через менеджер процессов, запускающий Node, а не изнутри самого приложения.

    Распространенная проблема в производстве: общий пул потоков для несвязанных задач

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

    Представьте следующую последовательность событий:

    • Волна входов запускает несколько одновременных вызовов функции crypto.pbkdf2 для проверки паролей.
    • Все четыре потока libuv теперь полностью заняты вычислением хешей паролей.
    • В тот же момент какая-то другая часть приложения вызывает функцию fs.readFile() для загрузки шаблона электронного письма или функцию dns.lookup() для разрешения имени хоста вашей базы данных.
    • Обе эти операции вынуждены ждать своей очереди в очереди.

    Чтение файла практически не использует процессорные ресурсы, но оно все равно задерживается, поскольку каждый рабочий поток занят вычислением хешей. Снаружи кажется, что доступ к файлам или подключение к базе данных замедлились, хотя на самом деле причина — конкуренция за потоки libuv.

    Как снизить конкуренцию в пуле потоков:

    • Увеличьте количество потоков в пуле: если ваш сервис выполняет много операций ввода-вывода с файлами или криптографических вычислений, увеличение значения UV_THREADPOOL_SIZE до 16 или 32 может снизить конкуренцию, при условии, что у основной машины достаточно процессорных ресурсов для этого.
    • Вынесите пользовательские задачи, зависящие от процессора, из пула: для задач, которыми вы управляете, таких как генерация отчетов или обработка изображений, не пытайтесь направлять их через libuv. Вместо этого используйте модуль worker_threads, который запускает отдельные экземпляры V8 на собственных потоках операционной системы.
    • Избегайте использования dns.lookup(), если это возможно: предпочитайте dns.resolve4() или настраивайте пулы соединений с использованием явных IP-адресов, чтобы разрешение сетевых имён не занимало ограниченное количество рабочих потоков libuv.

    Частые ошибки разработчиков

    Ошибка номер один: предположение, что async/await автоматически переносит работу на фон

    Добавление префикса async к функции не создает для неё фонового потока. async/await — это лишь более читаемая синтаксисная конструкция, построенная на основе Promises. Если в теле функции содержится синхронный цикл или ресурсоемкие вычисления, этот код всё равно выполняется непосредственно на основном JavaScript-потоке и будет блокировать сервер во время своей работы.

    Ошибка номер два: путаница между пулом потоков libuv и worker_threads

    • Пул потоков libuv управляется внутренне с помощью нативного кода на C. Он обрабатывает встроенные операции, такие как fs, crypto и zlib. У вас нет возможности добавлять собственные произвольные JavaScript-функции в этот пул.
  • Модуль worker_threads, доступный с Node.js 10.5, представляет собой API на уровне JavaScript. Он позволяет выполнять собственный код параллельно, причем каждый рабочий процесс имеет собственный независимый движок V8 и цикл событий.
  • Третья ошибка: слишком большой пул потоков

    Хочется просто установить UV_THREADPOOL_SIZE=128 везде и полагать, что чем больше, тем лучше. Но потоки сопряжены с реальными затратами. Каждому из них требуется память для собственной стек-структуры выполнения, и когда сотни потоков конкурируют за всего два или четыре ядра CPU, операционная система тратит значительное время на их переключение.

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

    Краткое резюме

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

    • Выполнение JavaScript остается однопоточным: логика приложения выполняется пошагово, что позволяет избежать ситуаций соревнования и необходимости в блокировках.
    • Сетевые операции осуществляются через механизмы операционной системы в libuv: сокеты управляются системами опроса ядра, такими как epoll, kqueue или IOCP, и совсем не потребляют рабочих потоков.
    • Доступ к файлам, поиск в DNS и криптографические операции выполняются с использованием пула потоков: четыре фоновых C-потока обрабатывают эти задерживающие вызовы, позволяя основному потоку продолжать принимать новые запросы.

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

    Связанные статьи

  • Node.js Streams Explained: Fixing Out-of-Memory File Crashes — Узнайте, почему загрузка целых файлов в память приводит к сбоям серверов Node.js, и как потоки чтения, записи, двунаправленного обмена данных и преобразования решают эту проблему с помощью механизма обратного давления.
  • Taming Node.js Concurrency: Avoiding API Meltdowns with p-map and Bottleneck — Узнайте, как сочетание инструментов p-map и Bottleneck в Node.js позволяет избегать ошибок ограничения скорости и перегрузки системы путем контроля конкурентности и времени обработки запросов.
  • Node.js 26: Обзор Temporal API, методов Map Upserts и улучшений Undici 8 — Рассматриваются основные изменения в Node.js 26, направленные на улучшение работы бэкенда: стабильная версия Temporal API, встроенные методы Map upsert, повышение производительности Undici 8, а также изменения, которые необходимо учесть перед обновлением.
  • Понимание замыканий в JavaScript и цикла событий — Объясняется, как замыкания сохраняют переменные внешнего области видимости, и как цикл событий организует выполнение стека вызовов, микозадач и макрозадач с примерами кода.