«Однопоточный ли 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) не закончится и стек не опустеет. Ноль здесь значит не «немедленно», а «как только освободишься».

Самый частый источник таких отложенных задач — запросы к серверу. Про то, как их отправлять, есть отдельная статья.
Микрозадачи против макрозадач
Тут есть тонкость, которую любят спрашивать дальше: очередей на самом деле две, и одна из них главнее.
- Макрозадачи — это колбэки от 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 будет недоступен.
Так однопоточный или нет? Что отвечать на собеседовании
Полный ответ помещается в пару фраз: «Сам движок JavaScript выполняет код в один поток — у него один стек вызовов. Но среда вокруг (браузер или Node.js) многопоточная: долгие операции она делает на своих потоках, а событийный цикл аккуратно возвращает результаты в наш единственный поток. А если нужна настоящая параллельность в самом JavaScript — её дают Web Workers».
И сразу несколько частых заблуждений, чтобы их не повторять:
- «setTimeout запускает код параллельно» — нет. Он лишь откладывает колбэк в очередь. Выполнится тот всё в том же одном потоке, когда поток освободится.
- «async/await делает код многопоточным» — нет. await просто ставит остаток функции в очередь микрозадач. Поток по-прежнему один.
- «Промисы выполняются в фоне» — сам промис ничего не исполняет в отдельном потоке. В фоне (на потоке браузера) работает то, что внутри — например, сетевой запрос. А .then — это просто колбэк в очереди микрозадач.
Кстати, в Node.js всё устроено так же: один основной поток, набор служебных потоков под капотом для файлов и сети, и отдельный модуль для своих потоков, если они понадобятся. Браузер и сервер тут думают одинаково.
Итог
Код на JavaScript выполняется в один поток — по очереди, через стек вызовов. Поэтому тяжёлая синхронная операция замораживает страницу: поток занят, и больше ничего не происходит.
Но один поток — это не приговор скорости. Долгие дела (таймеры, сеть, события) JavaScript отдаёт браузеру, а событийный цикл возвращает результаты обратно через очередь. Причём микрозадачи от промисов всегда идут вперёд макрозадач от setTimeout. А когда нужно реально считать параллельно — на помощь приходят Web Workers со своим отдельным потоком.
Так что правильный ответ на собеседовании — не сухое «да» и не «нет», а «движок один поток, среда многопоточная, а параллельность включается воркерами».
Комментарии (0)