Многопоточность в JavaScript — это выполнение кода в нескольких потоках с помощью воркеров: Web Workers в браузере и модуля worker_threads в Node.js. Сам язык в пределах одной среды выполнения (главный поток страницы или процесса Node.js) исполняет ваш код в одном потоке, а async/await и промисы дают конкурентность, но не параллельность.
Содержание
- Четыре термина, которые путают
- Как event loop выполняет код в одном потоке
- Почему тяжелый расчет блокирует все
- worker_threads: параллельный расчет в Node.js
- Web Workers в браузере
- Что можно передать в воркер
- SharedArrayBuffer и Atomics: общая память
- Что выбрать под задачу
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — как работает 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 прерывает поток снаружи, даже если он застрял в цикле. Незавершенная работа при этом теряется.



