«Однопоточный ли JavaScript?» — один из любимых вопросов на собеседовании. Звучит просто, но почти все отвечают наполовину: говорят либо «да», либо «нет», а правильный ответ — «и да, и нет, смотря про что речь».

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

Представь кафе с одним кассиром. Кассир один, и двух человек сразу он обслужить не может — только по очереди. Но за его спиной работают бариста, повар, курьеры, и работают они параллельно. Кассир лишь принимает у них готовое и отдаёт клиентам. JavaScript — это кассир. Давай разберём по слоям, чтобы на собеседовании ответить полно. Если язык в целом ещё осваиваете — начните с обзорной статьи про JavaScript.

Что значит «один поток»: стек вызовов

Поток (по-английски thread) — это одна линия выполнения, где команды идут строго по очереди. У JavaScript такая линия одна.

Очередью команд управляет стек вызовов (call stack). Стек — это стопка тарелок: вызвали функцию — положили тарелку сверху, функция закончилась — убрали верхнюю. Пока верхняя функция не отработает, до тех, что под ней, очередь не дойдёт.

function multiply(a, b) {
  return a * b;
}
function square(n) {
  return multiply(n, n);
}
function printSquare(n) {
  console.log(square(n));
}

printSquare(5); // 25

Что происходит по шагам: на стек кладётся printSquare, она зовёт square — та ложится сверху, square зовёт multiply — и она сверху. multiply вернула 25 и убралась со стека, за ней square, потом printSquare. Стек снова пуст. Всё это — в одном потоке, никакой параллельности.

Отсюда главное следствие: пока на стеке крутится одна тяжёлая операция, всё остальное стоит. Браузер в это время не может ни перерисовать страницу, ни отреагировать на клик — поток занят.

button.addEventListener("click", () => {
  const stopAt = Date.now() + 4000;
  while (Date.now() < stopAt) {
    // 4 секунды просто держим поток занятым
  }
  console.log("готово");
});

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

Почему один поток — это не значит «тормоза»

Возникает вопрос: если поток один, почему сайты не зависают на каждом запросе к серверу? Ведь ответ может идти секунду или больше. Ответ простой: долгие дела JavaScript не делает сам — он отдаёт их браузеру.

setTimeout, fetch, обработчики кликов, работа с файлами — это, строго говоря, не JavaScript. Это функции самого браузера (их называют Web API). А браузер — большая программа из многих потоков. Он берёт у тебя задачу («подожди три секунды» или «сходи на сервер»), выполняет её на своём потоке, а твой единственный поток в это время свободен.

Когда дело готово, браузер не врывается в твой код посреди выполнения. Он кладёт твою функцию-колбэк в очередь (queue) и терпеливо ждёт, пока поток освободится.

А следит за этим событийный цикл (event loop). Это простой сторож с одной привычкой: он постоянно проверяет, пуст ли стек вызовов. Как только весь обычный код отработал и стек опустел — event loop берёт первую функцию из очереди и кладёт её на стек. Выполнилась — берёт следующую. И так по кругу, бесконечно.

Вернёмся к кафе: кассир обслуживает всех в зале по очереди. Когда зал опустел, он смотрит на полку с готовыми заказами с кухни (это очередь) и берёт следующий. Event loop — это его привычка постоянно поглядывать на полку.

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

console.log("3");

// в консоли: 1, 3, 2

Обрати внимание: задержка у setTimeout — ноль миллисекунд, а 2 всё равно печатается последней. Почему? Колбэк из setTimeout не выполняется сразу — он отправляется в очередь и ждёт, пока весь обычный код (1 и 3) не закончится и стек не опустеет. Ноль здесь значит не «немедленно», а «как только освободишься».

Схема событийного цикла JavaScript: стек вызовов, Web API, очереди задач и event loop

Самый частый источник таких отложенных задач — запросы к серверу. Про то, как их отправлять, есть отдельная статья.

Микрозадачи против макрозадач

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

  • Макрозадачи — это колбэки от setTimeout, setInterval и от событий. Обычная очередь, «не срочно».
  • Микрозадачи — это то, что приходит от промисов (.then, await), а также queueMicrotask. Очередь «срочных дел».

Правило простое: как только стек опустел, event loop сначала разгребает все микрозадачи до последней, и только потом берёт одну макрозадачу. Микрозадачи всегда лезут вперёд.

console.log("старт");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("promise");
});

console.log("конец");

// порядок: старт, конец, promise, setTimeout

Разберём по полочкам. Сначала выполняется обычный код — печатаются старт и конец. Колбэк setTimeout ушёл в очередь макрозадач, колбэк .then — в очередь микрозадач. Стек опустел — event loop хватает сначала все микрозадачи (печатается promise), и только потом макрозадачу (setTimeout).

Практический вывод: await и .then реагируют «почти мгновенно», а setTimeout с нулём — заметно позже. Это не магия и не случайность, а просто порядок двух очередей.

Настоящая многопоточность: Web Workers

Соберём, что есть на этот момент: один поток выполнения, а долгие дела за него делает браузер. Но это всё ещё один поток для твоего кода. А что, если расчёт по-настоящему тяжёлый — обработать большую картинку или просуммировать массив на миллион элементов? Тут браузер не поможет: это чистый JavaScript, он займёт единственный поток и заморозит страницу, как в первом примере.

Решение — Web Worker. Это отдельный файл с кодом, который браузер запускает в настоящем втором потоке. Он считает параллельно, а основной поток (а вместе с ним и весь интерфейс) остаётся живым и отзывчивым.

// main.js — основной поток
const worker = new Worker("worker.js");

worker.postMessage(1000000);          // отдали воркеру задачу
worker.onmessage = (e) => {
  console.log("результат:", e.data);  // получили ответ
};
// worker.js — отдельный поток
onmessage = (e) => {
  let sum = 0;
  for (let i = 0; i < e.data; i++) {
    sum += i;
  }
  postMessage(sum);                   // отправили ответ назад
};

У воркера есть два важных ограничения, и оба — не случайность, а защита:

  • Воркер не видит страницу. У него нет доступа к document, к кнопкам, к DOM. Он умеет только считать и пересылать данные. Общение с основным потоком идёт через postMessage — как обмен записками.
  • Память не общая. Воркер и основной поток не делят переменные: данные при пересылке копируются. Это сделано нарочно — так два потока не смогут случайно испортить одни и те же данные одновременно (классическая беда многопоточного кода).

Из второго правила есть исключение — SharedArrayBuffer. Это особый объект, который позволяет двум потокам читать и писать в один и тот же участок памяти. Штука мощная, но опасная (нужно вручную следить, чтобы потоки не наступали друг другу на ноги), и просто так она не включается: после уязвимости Spectre браузеры закрыли общую память по умолчанию, и теперь сайт должен отдавать специальные заголовки безопасности (их называют COOP и COEP), иначе SharedArrayBuffer будет недоступен.

Поддержка браузерами
chrome
Chrome
68
firefox
Firefox
79
edge
Edge
79
safari
Safari
15.2
opera
Opera
64

Так однопоточный или нет? Что отвечать на собеседовании

Полный ответ помещается в пару фраз: «Сам движок JavaScript выполняет код в один поток — у него один стек вызовов. Но среда вокруг (браузер или Node.js) многопоточная: долгие операции она делает на своих потоках, а событийный цикл аккуратно возвращает результаты в наш единственный поток. А если нужна настоящая параллельность в самом JavaScript — её дают Web Workers».

И сразу несколько частых заблуждений, чтобы их не повторять:

  • «setTimeout запускает код параллельно» — нет. Он лишь откладывает колбэк в очередь. Выполнится тот всё в том же одном потоке, когда поток освободится.
  • «async/await делает код многопоточным» — нет. await просто ставит остаток функции в очередь микрозадач. Поток по-прежнему один.
  • «Промисы выполняются в фоне» — сам промис ничего не исполняет в отдельном потоке. В фоне (на потоке браузера) работает то, что внутри — например, сетевой запрос. А .then — это просто колбэк в очереди микрозадач.

Кстати, в Node.js всё устроено так же: один основной поток, набор служебных потоков под капотом для файлов и сети, и отдельный модуль для своих потоков, если они понадобятся. Браузер и сервер тут думают одинаково.

Итог

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

Но один поток — это не приговор скорости. Долгие дела (таймеры, сеть, события) JavaScript отдаёт браузеру, а событийный цикл возвращает результаты обратно через очередь. Причём микрозадачи от промисов всегда идут вперёд макрозадач от setTimeout. А когда нужно реально считать параллельно — на помощь приходят Web Workers со своим отдельным потоком.

Так что правильный ответ на собеседовании — не сухое «да» и не «нет», а «движок один поток, среда многопоточная, а параллельность включается воркерами».