javascript

Реальная и мнимая многопоточность в JS

  • пятница, 31 июля 2026 г. в 00:00:05
https://habr.com/ru/articles/1064906/

JS — однопоточный язык, но асинхронный код нередко описывают как «выполняющийся параллельно» — и это не совсем так. В статье разберёмся, что на самом деле происходит, когда вы вызываете fetch или setTimeout, и почему настоящая многопоточность в браузере — это отдельная история про Web Workers, а не про промисы.

Однопоточность

Очень скоро после первого знакомства с JS вы сталкиваетесь с концепцией однопоточности. Легко догадаться из самого слова, что оно означает: код выполняется в одном потоке.

Для людей, которые начинают изучать этот язык, это отличное подспорье, т.к. нет никакой сложной логики разделения задач по процессам и потокам: весь ваш код выполняется строго по порядку, метод за методом, строчка за строчкой, а следующая задача начнет выполняться, только когда закончит предыдущая, — как выполнение инструкции по шагам.

И вроде нет проблем писать код так, чтобы от него не требовалось параллельных действий, но тут сразу стоит сделать отступление: ведь в этом одном потоке выполняется не только код, написанный вами, но и многие другие процессы, такие как парсинг документа, отрисовка компонентов, выполнение анимаций и многое другое. А очень скоро после начала погружения в язык мы еще сталкиваемся с запросами по сети.

И тут может возникнуть вопрос: если вообще все выполняется в одном потоке, то и запросы по сети выполняются там же? Ну раз язык однопоточный, то запросы тоже должны быть в нем. Звучит логично, но такой подход был бы совершенно неудобен, потому что время запросов и обмена данными с сервером зависит от множества параметров, и ответ не всегда приходит мгновенно. Почему же тогда множественные запросы на страницах не блокируют анимации, и мы все еще можем как-то взаимодействовать с сайтом, вызывая обработчики разных пользовательских событий?

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

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

var request = new XMLHttpRequest();
request.open("GET", "/bar/foo.txt", false); // `false` makes the request synchronous
request.send(null);

if (request.status === 200) {
  console.log(request.responseText);
}

Асинхронность — мнимая параллельность

Итак, вернемся к асинхронности. Асинхронность разработчику предоставляет Web API. К Web API относятся: fetch (и другие запросы по сети), setTimeout, setInterval и многие другие. Приведенные Web API работают по такой логике: мы передаем в них функции (колбэки) с синхронным кодом, который должен быть выполнен в момент наступления некоторого события. В JS есть еще промисы — объекты-обёртки для асинхронного кода, которые работают по схожему принципу: в момент, который считается успешным завершением, они выполняют синхронный код и передают результат дальше по цепочке.

Приведенные API являются асинхронными, поэтому они немного ломают привычное поведение однопоточности, т.к. задачи, описанные внутри колбэков этих API, пропускаются и выполняются в какой-то момент позже. И такой принцип работы создает иллюзию того, что раз это не блокирует интерфейс и не останавливает выполнение кода вашей программы, то это выполняется где-то параллельно. Мне кажется, для простоты объяснений я и сам не раз использовал именно слово «параллельно», но вот в этом «параллельно» на самом деле и есть основная иллюзия.

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

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

function heavyTask(name, ms) {
  console.log(`${name} started`);

  const start = Date.now();

  while (Date.now() - start < ms) {
    // тяжёлая синхронная работа
  }

  console.log(`${name} finished`);
}

async function run() {
  console.log('run start');

  Promise.resolve().then(() => {
    heavyTask('Task A', 3000);
  });

  Promise.resolve().then(() => {
    heavyTask('Task B', 3000);
  });

  console.log('run end');
}

run();

Результатом работы такого кода будет вот такой вывод в консоль:

run start
run end
Task A started
Task A finished
Task B started
Task B finished

Как видно из сообщений в консоли, Task B не начинает работу, пока Task A не закончит свои действия. Никаких параллельных вычислений. Все так, будто нет никаких Promise, а есть просто вызовы функций (за одним маленьким исключением в виде места вывода в консоль run end).

Event loop

Чтобы объяснить причину такого поведения, нужно немного коснуться темы event loop, стека вызовов, очереди задач и очереди микрозадач. Я постараюсь сделать это кратко: существует большое количество статей, в которых намного лучше объяснены их суть и принцип работы.

Стек вызовов — это механизм отслеживания того, какая из функций выполняется сейчас и какая будет вызвана следующей. Когда выполнение функции завершено, она удаляется из стека вызовов, и наступает очередь следующей. Очередей ожидания на самом деле две, и попадает в них разное: в очередь задач (macrotask queue) — колбэки Web API: setTimeout, setInterval, обработчики событий, а в очередь микрозадач (microtask queue) — колбэки промисов из then/catch/finally и код после await.

Цикл событий (event loop) следит за стеком вызовов: как только стек пустеет, он сначала выполняет все накопившиеся микрозадачи до полного опустошения их очереди и только потом берет одну задачу из очереди задач. После нее — снова все накопившиеся микрозадачи, и так по кругу.

Получается, у нас есть Web API — то, что выполняется где-то за пределами нашего движка JS, на стороне браузера. В момент завершения работы одного из элементов Web API колбэк попадает в соответствующую очередь и ждет, пока event loop передаст его на выполнение. Именно поэтому в примере выше задача A и задача B выполняются последовательно, а не параллельно.

Вот пошаговый разбор: сначала в стек вызовов попадает вывод в консоль run start. Дальше интерпретатор встречает Promise.resolve() — он ничего не «выполняет», а просто создаёт уже зарезолвленный промис, поэтому колбэк из then сразу отправляется в очередь микрозадач. То же самое происходит со вторым промисом — теперь в очереди микрозадач две функции, а стек вызовов занят выполнением функции run. Дальше в стек попадает вывод в консоль run end, функция run завершается, и стек освобождается. Теперь в стек попадает функция из первого промиса, и она начинает выполняться; функция из второго промиса стоит первой в очереди микрозадач, но event loop оставляет ее в ожидании, т.к. стек занят. И только в момент окончания работы первой функции в стек попадает вторая. Как видно, никакой параллельности.

Web Workers — реальная параллельность

Перед описанием работы кода я употребил фразу, что Web API выполняет код за пределами движка JS. Получается, если бы была возможность как-то сказать браузеру, что нужно выполнить какой-то код параллельно, то он бы смог это сделать. И с 2009–2010 годов такая возможность есть — Web Workers. Они отвечают именно за параллельность: весь JS-код воркера выполняется в отдельном потоке, где-то на фоне. Основной поток не блокируется, и можно взаимодействовать с сайтом, а браузер в этот момент будет выполнять сложные вычисления отдельно.

Из-за того, что код воркера выполняется в отдельном потоке, на него накладываются определенные ограничения: на то, что этот код может делать, к чему у него есть доступ и как с ним можно взаимодействовать. К примеру, из кода, который является частью воркера, нет доступа к DOM, т.е. не получится напрямую манипулировать DOM, покрасить кнопочку или навесить обработчик на блок; у воркеров нет доступа к alert и confirm, но зато есть доступ к таким Web API, как сетевые запросы, setTimeout и setInterval. Из-за того, что Web Worker находится в отдельном потоке, у него нет прямого доступа к переменным основного кода, поэтому вся передача данных происходит через postMessage.

// main.js — основной поток:

// Создаём воркер — браузер загрузит и запустит этот файл в отдельном потоке
const worker = new Worker('worker.js');

document.querySelector('#calc-btn').addEventListener('click', () => {
  // Передаём данные воркеру. Объект будет склонирован
  // (structured clone), а не передан по ссылке
  worker.postMessage({ limit: 50000000 });
  console.log('Задача отправлена, интерфейс не заблокирован');
});

// Слушаем ответ от воркера
worker.addEventListener('message', (event) => {
  // А вот тут мы уже в основном потоке — DOM доступен
  document.querySelector('#result').textContent =
    `Найдено простых чисел: ${event.data.count}`;
});
// worker.js — выполняется в отдельном потоке:

// Здесь нет ни document, ни window, ни alert.
// Глобальный объект — self (WorkerGlobalScope)

self.addEventListener('message', (event) => {
  const { limit } = event.data;

  // Тяжёлое синхронное вычисление. В основном потоке оно
  // повесило бы страницу на несколько секунд
  let count = 0;
  for (let n = 2; n < limit; n++) {
    if (isPrime(n)) count++;
  }

  // Единственный способ вернуть результат — postMessage
  self.postMessage({ count });
});

function isPrime(n) {
  for (let i = 2; i * i <= n; i++) {
    if (n % i === 0) return false;
  }
  return true;
}

Стоит уточнить, что Web Workers бывают трех типов, и всё, что описано выше, — это dedicated worker: он принадлежит одной вкладке и умирает вместе с ней. Кроме него, существуют Shared Worker и Service Worker. Shared Worker — один экземпляр, к которому могут подключаться сразу несколько вкладок и iframe одного origin. Service Worker — прокси между приложением и сетью, который живет независимо от вкладок и используется для офлайн-режима и кэширования запросов.

Когда использовать Web Workers

Workers могут пригодиться в следующих кейсах:

  • Любые сложные и долгие вычисления (dedicated worker)

  • Предварительная загрузка и обработка больших объемов данных (dedicated worker)

  • Подсчёт или мониторинг событий (dedicated worker)

  • Синхронизация состояния между вкладками (Shared Worker)

  • Общий кэш или данные между вкладками (Shared Worker)

  • Централизованная подписка на сервер — например, одно WebSocket-соединение на все вкладки (Shared Worker)

  • Поддержание работы приложения при проблемах с сетью (Service Worker)

И еще во множестве других. Но из-за модели взаимодействия через сообщения Workers не очень подойдут для мелких и простых задач: это будет переусложнение, которое не даст видимого эффекта и прироста в производительности; передача больших объемов данных через postMessage может быть медленной из-за клонирования, а дебаг ошибок в многопоточном приложении усложняется.

Проблема медленной передачи, правда, частично решаема: ArrayBuffer и ряд других объектов можно не клонировать, а передать вторым аргументом postMessage как transferable — тогда владение памятью перейдет в Worker без копирования, но в исходном потоке объект станет недоступен.

А для случаев, когда потокам действительно нужна общая память, существует SharedArrayBuffer — буфер, который виден одновременно из основного потока и из воркера без всякого копирования, и Atomics — набор операций для безопасной работы с ним. Это уже настоящая многопоточность с её типичными проблемами вроде гонок данных, поэтому она заслуживает отдельной статьи.

Заключение

Web Worker не является серебряной пулей для любого кейса: чаще всего его использование может быть неоправданным из-за переусложнения, а где-то проще перенести логику на бэкенд. Попытка распараллелить то, что хорошо работает в одном потоке, только усложнит поддержку вашего кода, но в некоторых кейсах Workers могут помочь сделать вашу систему отзывчивее и приятнее в использовании. Поэтому понимание разницы в понятиях асинхронности и многопоточности, а также знание механизмов Event Loop и Web Workers позволят вам строить масштабные и сложные, но при этом отзывчивые и понятные системы.