Node.js Internals

Worker Threads: Параллелизм в Node.js

Node.js - однопоточный. Но что если задача требует 5 секунд вычислений? Event Loop замирает, сервер не отвечает. Worker Threads решают проблему: настоящий параллелизм, когда JavaScript-код выполняется на всех ядрах CPU одновременно.

  • **Хеширование паролей**: bcrypt.hash блокирует Event Loop на ~100ms. С Worker Pool 4 потока обрабатывают 40 регистраций в секунду вместо 10.
  • **Обработка изображений**: сервер генерирует превью для загруженных фото. Sharp (libvips) быстрый, но для 4K изображения требует ~200ms CPU. Worker Pool позволяет обрабатывать сотни фото параллельно.
  • **Real-time аналитика**: агрегация логов в реальном времени. Парсинг и group-by для миллиона записей занимает ~5 секунд. 8 воркеров делят данные на чанки - результат за ~700ms.

Параллелизм vs Конкурентность

Node.js построен на **однопоточной модели**: Event Loop обрабатывает задачи последовательно, переключаясь между ними (concurrency). Но CPU-intensive задачи блокируют поток - хеширование паролей, обработка изображений, компиляция.

**Worker Threads** вводят настоящий параллелизм: несколько потоков выполняют код одновременно на разных ядрах CPU. Это не `child_process` (тяжёлый процесс с отдельным V8) - это лёгкие потоки, разделяющие память.

**Ключевое отличие:** - **Concurrency (Event Loop)**: один повар жонглирует несколькими блюдами - **Parallelism (Worker Threads)**: несколько поваров готовят одновременно Event Loop идеален для I/O (сеть, диск), Worker Threads - для вычислений (криптография, обработка данных).

Приложение обрабатывает 1000 HTTP-запросов в секунду (чтение из базы). Нужны ли Worker Threads?

Создание Worker Thread

Worker Thread создаётся через класс `Worker` из модуля `worker_threads`. Воркер исполняет отдельный файл или inline-код в изолированном JavaScript-контексте с собственным Event Loop.

**Жизненный цикл Worker Thread:** 1. **Spawn**: создание потока ОС, инициализация V8 контекста 2. **Execute**: выполнение кода в worker.js 3. **Communicate**: обмен сообщениями через `postMessage` 4. **Terminate**: завершение через `worker.terminate()` или выход из скрипта Каждый воркер имеет ~2MB overhead (V8 контекст + Event Loop). Создание ~10-50ms.

Какой оверхед создания нового Worker Thread в Node.js?

MessageChannel и MessagePort

Воркеры общаются через **postMessage** - клонирование данных (structured clone). Для продвинутых сценариев используется **MessageChannel**: пара связанных портов для двусторонней коммуникации между потоками.

**Иерархия коммуникации:** - **parentPort.postMessage()**: воркер → родитель (встроенный канал) - **worker.postMessage()**: родитель → воркер (встроенный канал) - **MessageChannel**: создание кастомных каналов для воркер ↔ воркер - **transferList**: передача владения буфером (zero-copy) Structured clone поддерживает: примитивы, объекты, массивы, Date, RegExp, ArrayBuffer, но не функции и символы.

Передаёте 100MB ArrayBuffer воркеру через postMessage. Что происходит?

SharedArrayBuffer и Atomics

**SharedArrayBuffer** - общая память между потоками. Несколько воркеров читают и пишут в один буфер одновременно. Но без синхронизации - гонка данных (data race). **Atomics** решает проблему: атомарные операции и примитивы синхронизации.

**Atomics API:** - `Atomics.add/sub/and/or/xor`: атомарная арифметика - `Atomics.load/store`: атомарное чтение/запись - `Atomics.compareExchange`: CAS (Compare-And-Swap) - `Atomics.wait/notify`: futex-подобная синхронизация (блокировка потока) ⚠️ **Spectre/Meltdown**: SharedArrayBuffer был отключён в 2018, вернулся с Cross-Origin-Opener-Policy. В Node.js доступен без ограничений.

Два воркера инкрементируют sharedArray[0] 1000 раз. Без Atomics результат:

Worker Pool: Переиспользование потоков

Создавать воркер для каждой задачи - дорого (~10-50ms + 2MB). **Worker Pool** - фиксированный набор воркеров, которые переиспользуются для задач. Аналог thread pool в других языках.

**Стратегии пула:** - **Fixed size**: N воркеров (обычно = кол-во CPU ядер) - **Dynamic**: растёт при нагрузке, сжимается при простое - **Task queue**: задачи в очереди, воркеры берут следующую - **Round-robin**: задачи распределяются по кругу Библиотеки: **piscina** (рекомендуется), **workerpool**, **worker-threads-pool**.

Сервер имеет 4-ядерный CPU. Сколько воркеров создать в пуле для CPU-intensive задач?

Паттерны использования Worker Threads

Worker Threads решают конкретные проблемы: CPU-intensive задачи, параллельная обработка данных, изоляция ненадёжного кода. Рассмотрим паттерны применения и антипаттерны.

**Когда использовать Worker Threads:** ✅ **CPU-intensive**: криптография (bcrypt, scrypt), компрессия, парсинг больших файлов ✅ **Параллельные вычисления**: обработка изображений, ML inference, data processing ✅ **Изоляция**: выполнение пользовательского кода (sandbox) ❌ **Когда НЕ использовать:** - I/O операции (БД, API, файлы) → Event Loop эффективнее - Короткие задачи (<10ms) → overhead создания воркера - Частый обмен данными → serialization overhead

Worker Threads ускоряют любой асинхронный код (например, HTTP-запросы).

Worker Threads ускоряют только CPU-bound задачи. Для I/O (сеть, БД) они добавляют overhead.

Event Loop уже оптимизирован для I/O: он не блокируется на сетевых запросах или чтении файлов (libuv thread pool). Worker Threads нужны когда JavaScript-код сам занимает CPU: парсинг, хеширование, обработка данных. Иначе приходится платить за создание воркера (~10ms) без реального ускорения.

API endpoint загружает файл из S3, парсит JSON, сохраняет в БД. Нужен ли Worker Thread?

Ключевые идеи

  • **Параллелизм vs Конкурентность**: Event Loop переключается между задачами (concurrency), Worker Threads выполняют одновременно на разных ядрах (parallelism). Воркеры для CPU-bound, Event Loop для I/O.
  • **Коммуникация**: postMessage (clone), transferList (zero-copy), MessageChannel (worker ↔ worker), SharedArrayBuffer (общая память). Выбор зависит от размера данных и частоты обмена.
  • **SharedArrayBuffer + Atomics**: общая память без копирования, но требует синхронизации. Atomics.add для атомарности, Atomics.wait/notify для блокировок. Data race без Atomics → потери данных.
  • **Worker Pool**: переиспользование воркеров (создание дорогое: ~10ms + 2MB). Размер пула = CPU cores для CPU-bound задач. Piscina для production.
  • **Паттерны**: вынос CPU-intensive из API endpoints, параллельная обработка массивов (чанки), long-running workers с bidirectional communication, sandbox для пользовательского кода.

Связанные темы

Worker Threads встраиваются в архитектуру Node.js приложений:

  • Event Loop — Worker Threads дополняют Event Loop: I/O остаётся в главном потоке, CPU-intensive выносится в воркеры
  • Streams — Worker Threads обрабатывают чанки из streams параллельно (например, парсинг CSV по частям)
  • Cluster Module — Cluster для масштабирования I/O (несколько процессов слушают один порт), Worker Threads для CPU внутри процесса

Вопросы для размышления

  • Почему Worker Threads не заменяют Event Loop для I/O операций? Какой overhead они добавляют?
  • В каких случаях transferList лучше postMessage? Когда SharedArrayBuffer эффективнее обоих?
  • Как защититься от data race при работе с SharedArrayBuffer? Почему Atomics.add атомарен, а обычный increment - нет?

Связанные уроки

  • os-03-threads
Worker Threads: Параллелизм в Node.js

0

1

Войти