Многопоточность в JavaScript: event loop, Web Workers и worker_threads

Многопоточность в JavaScript: event loop, Web Workers и worker_threads Полезное

Многопоточность в JavaScript — это выполнение кода в нескольких потоках с помощью воркеров: Web Workers в браузере и модуля worker_threads в Node.js. Сам язык в пределах одной среды выполнения (главный поток страницы или процесса Node.js) исполняет ваш код в одном потоке, а async/await и промисы дают конкурентность, но не параллельность.

Ниже — как работает event loop, почему тяжелый расчет «замораживает» программу, как вынести его в воркер и как безопасно делить память через SharedArrayBuffer и Atomics. Примеры для Node.js проверены на Node.js 24.21 (LTS-линия 24) 24.09.2026.

Четыре термина, которые путают

Термин Что это Пример в JS
Поток (thread) Независимая последовательность выполнения со своим стеком главный поток, поток воркера
Конкурентность Задачи чередуются, пока одна ждет (сеть, диск, таймер) await fetch(...), промисы
Параллельность Задачи идут одновременно на разных ядрах процессора несколько Worker
Event loop Цикл, который берет следующую задачу из очереди, когда стек пуст setTimeout, .then

Главное различие: асинхронность — это не многопоточность. Промис не создает поток, он лишь откладывает продолжение кода до момента, когда результат готов.

Как event loop выполняет код в одном потоке

Упрощенная модель: движок выполняет синхронный код до конца, затем опустошает очередь микрозадач (колбэки промисов, queueMicrotask), затем берет одну макрозадачу (таймер, событие, ввод-вывод) и повторяет. Точный порядок фаз в Node.js и браузере различается, но правило «микрозадачи раньше следующей макрозадачи» общее.

console.log('1: синхронный код');

setTimeout(() => console.log('4: таймер (макрозадача)'), 0);

Promise.resolve().then(() => console.log('3: промис (микрозадача)'));

console.log('2: синхронный код, конец');

Запуск node order.mjs печатает строки строго по номерам:

1: синхронный код
2: синхронный код, конец
3: промис (микрозадача)
4: таймер (макрозадача)

Таймер с задержкой 0 выполнился последним: он ждет, пока освободится стек и отработают все микрозадачи.

Почему тяжелый расчет блокирует все

Пока главный поток занят вычислением, event loop не может взять следующую задачу. В браузере это зависший интерфейс, в Node.js — сервер, который не отвечает никому.

const start = Date.now();

setTimeout(() => {
  console.log(`таймер на 0 мс сработал через ${Date.now() - start} мс`);
}, 0);

// 2 секунды занятости главного потока
while (Date.now() - start < 2000) {}
console.log('цикл закончился');
цикл закончился
таймер на 0 мс сработал через 2002 мс

Если заменить цикл на async-функцию, ничего не изменится: вычисление внутри async все равно идет в том же потоке. Для CPU-нагрузки нужен отдельный поток.

Важная граница: ввод-вывод в Node.js главный поток не блокирует. Сетевые операции идут через механизмы ОС, а часть работы (файловая система, dns.lookup, crypto.pbkdf2, zlib) libuv выполняет в своем пуле потоков, по умолчанию из 4 потоков (UV_THREADPOOL_SIZE). Поэтому Node.js хорошо держит тысячи соединений без отдельного потока на каждое.

worker_threads: параллельный расчет в Node.js

Модуль node:worker_threads запускает JS-код в отдельном потоке со своим изолятом V8 (отдельная куча и глобальный объект) и своим event loop в том же процессе. Минимальный полный пример в одном файле fib-worker.mjs: главный поток каждые 500 мс печатает, что он свободен, а воркер считает число Фибоначчи наивной рекурсией.

import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

if (isMainThread) {
  const start = Date.now();
  const timer = setInterval(() => {
    console.log(`главный поток свободен: ${Date.now() - start} мс`);
  }, 500);

  const worker = new Worker(new URL(import.meta.url), { workerData: 42 });

  worker.on('message', (result) => {
    console.log(`fib(42) = ${result}, заняло ${Date.now() - start} мс`);
    clearInterval(timer);
  });
  worker.on('error', (err) => {
    console.error('ошибка в воркере:', err);
    clearInterval(timer);
  });
} else {
  parentPort.postMessage(fib(workerData));
}
главный поток свободен: 503 мс
главный поток свободен: 1004 мс
главный поток свободен: 1505 мс
fib(42) = 267914296, заняло 1568 мс

Время зависит от процессора, но главное видно: пока воркер считает, таймер главного потока срабатывает вовремя. Разбор:

  • isMainThread разделяет роли: тот же файл работает и как главный поток, и как воркер.
  • workerData — входные данные, копируются в воркер при старте.
  • parentPort.postMessage отправляет результат, главный поток получает его в событии message.
  • Обработчик error обязателен: исключение внутри воркера иначе уронит процесс.

Запуск воркера стоит несколько миллисекунд и памяти под отдельный изолят V8. Для потока мелких задач создают пул воркеров заранее и раздают им задания, а не заводят новый воркер на каждый запрос.

Web Workers в браузере

В браузере тот же принцип, но API другой: new Worker(url), сообщения через postMessage и onmessage. У воркера нет доступа к DOM, поэтому он считает, а интерфейс обновляет главный поток.

// main.js - подключается на странице как <script type="module" src="main.js">
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });

worker.onmessage = (event) => {
  document.querySelector('#result').textContent = `Сумма: ${event.data}`;
};
worker.onerror = (event) => console.error('Ошибка воркера:', event.message);

worker.postMessage(50_000_000);

// worker.js
self.onmessage = (event) => {
  let sum = 0;
  for (let i = 0; i < event.data; i++) sum += i;
  self.postMessage(sum);
};

Страница открывается через локальный веб-сервер: с file:// браузеры обычно не дают создать воркер. Кроме обычных (dedicated) воркеров есть SharedWorker — один на несколько вкладок одного источника — и Service Worker, который перехватывает сетевые запросы и для вычислений не предназначен.

Что можно передать в воркер

Сообщения копируются алгоритмом structured clone: объекты, массивы, Map, Date, типизированные массивы — можно; функции — нет (ошибка), а экземпляры классов приходят обычными объектами (Object.prototype) без методов своего класса.

Неверно — передать колбэк внутри сообщения:

import { Worker } from 'node:worker_threads';

const worker = new Worker('setTimeout(() => {}, 100)', { eval: true });
try {
  worker.postMessage({ onDone: () => console.log('готово') });
} catch (err) {
  console.log(`${err.name}: ${err.message}`);
} finally {
  await worker.terminate();
}

Результат в Node.js 24:

DataCloneError: () => console.log('готово') could not be cloned.

Исправление: передавать только данные, а реакцию на результат держать в главном потоке, в обработчике message. Большие буферы выгоднее не копировать, а передавать владение: postMessage(buf, [buf]) — после этого исходный ArrayBuffer в отправителе становится пустым.

SharedArrayBuffer и Atomics: общая память

SharedArrayBuffer — единственный способ дать нескольким потокам одну область памяти без копирования. Но с общей памятью появляются гонки. Пример: четыре воркера по миллиону раз увеличивают общий счетчик.

import { Worker, isMainThread, workerData } from 'node:worker_threads';

const WORKERS = 4;
const STEPS = 1_000_000;

if (isMainThread) {
  const mode = process.argv[2] ?? 'plain';
  const shared = new SharedArrayBuffer(4); // 4 байта = один Int32
  const counter = new Int32Array(shared);

  const jobs = [];
  for (let i = 0; i < WORKERS; i++) {
    const w = new Worker(new URL(import.meta.url), { workerData: { shared, mode } });
    jobs.push(new Promise((resolve, reject) => {
      w.on('exit', resolve);
      w.on('error', reject);
    }));
  }
  await Promise.all(jobs);
  console.log(`${mode}: ожидали ${WORKERS * STEPS}, получили ${counter[0]}`);
} else {
  const counter = new Int32Array(workerData.shared);
  for (let i = 0; i < STEPS; i++) {
    if (workerData.mode === 'atomics') {
      Atomics.add(counter, 0, 1);
    } else {
      counter[0]++; // чтение + сложение + запись: не атомарно
    }
  }
}

Три запуска node race.mjs plain и два node race.mjs atomics на машине с 2 ядрами:

plain: ожидали 4000000, получили 3602900
plain: ожидали 4000000, получили 3203922
plain: ожидали 4000000, получили 3489655
atomics: ожидали 4000000, получили 4000000
atomics: ожидали 4000000, получили 4000000

counter[0]++ — это три шага: прочитать, прибавить, записать. Два потока читают одно и то же значение, и одно из увеличений теряется. Число потерь каждый раз разное, и отдельный запуск может случайно дать точные 4000000: в повторных прогонах на тех же 2 ядрах так вышло в 4 запусках из 8. Поэтому один зеленый прогон ничего не доказывает, и такие ошибки трудно поймать тестом. Atomics.add выполняет операцию целиком, и результат точный. Для ожидания между потоками есть Atomics.wait и Atomics.notify; в главном потоке браузера Atomics.wait запрещен.

В браузере SharedArrayBuffer доступен только на странице в режиме cross-origin isolation: сервер отдает заголовки Cross-Origin-Opener-Policy: same-origin и Cross-Origin-Embedder-Policy: require-corp (современные браузеры принимают и credentialless), а проверить режим можно по self.crossOriginIsolated. Ограничение введено после уязвимостей класса Spectre.

Что выбрать под задачу

Задача Что взять Когда иначе
Много сетевых запросов, чтение файлов async/await, промисы потоки не нужны, ожидание не грузит CPU
Тяжелый расчет в браузере (парсинг, обработка изображений) Web Worker если нужен DOM — дробить работу на части в главном потоке
CPU-расчет на сервере Node.js worker_threads + пул нужна изоляция памяти и падений — child_process
Много ядер под HTTP-сервер cluster или несколько процессов в контейнерной среде часто масштабируют репликами
Частый обмен большими данными между потоками SharedArrayBuffer + Atomics обычно проще передать буфер через transfer

Правило выбора: если программа ждет — хватит асинхронности; если программа считает — нужен отдельный поток или процесс.

Выводы

  • Код JavaScript в одной среде выполнения идет в одном потоке, а event loop чередует задачи: сначала синхронный код, затем микрозадачи, затем следующая макрозадача.
  • async/await и промисы дают конкурентность для ожидания ввода-вывода, но не ускоряют вычисления.
  • Для параллельных вычислений служат Web Workers в браузере и worker_threads в Node.js; данные между потоками копируются structured clone, функции передать нельзя.
  • Общая память через SharedArrayBuffer требует Atomics, иначе обновления теряются; в браузере она работает только при cross-origin isolation.

Где применяется / связь с практикой

Освойте тему на практике

Понимание event loop и воркеров нужно, когда интерфейс начинает подтормаживать на больших данных, а сервер на Node.js — отвечать с задержкой под нагрузкой. Асинхронность, модули, работу с Node.js и производительность фронтенда системно разбирают на курсе «JavaScript-разработчик. Продвинутый уровень». Попробовать формат и задать вопросы преподавателям можно на открытых уроках Otus.

FAQ

JavaScript и Java — это одно и то же?
Нет, это разные языки с разными моделями потоков. В Java потоки создаются в самом языке (Thread, виртуальные потоки), в JavaScript параллельность дают только воркеры среды выполнения.

Сколько воркеров запускать?
Для CPU-задач ориентир — число доступных ядер: os.availableParallelism() в Node.js или navigator.hardwareConcurrency в браузере. Больше воркеров, чем ядер, расчет обычно не ускоряет.

Можно ли остановить зависший воркер?
Да: worker.terminate() в браузере и в Node.js прерывает поток снаружи, даже если он застрял в цикле. Незавершенная работа при этом теряется.

OTUS Журнал