JS — однопоточный язык, но асинхронный код нередко описывают как «выполняющийся параллельно» — и это не совсем так. В статье разберёмся, что на самом деле происходит, когда вы вызываете fetch или setTimeout, и почему настоящая многопоточность в браузере — это отдельная история про Web Workers, а не про промисы. Читать далее
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, стека вызовов, очереди задач и очереди микрозадач. Я постараюсь сделать это кратко: существует большое количество статей, в которых намного лучше объяснены их суть и принцип работы.
Стек вызовов — это механизм отслеживания того, какая из функций выполняется сейчас и какая будет вызвана следующей. Когда выполнение функции завершено, она удаляется из стека вызовов, и наступает очередь следующей. Очередей ожидания на самом деле две, и попадает в них разное: в очередь задач (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 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 WorkersWorkers могут пригодиться в следующих кейсах:
Любые сложные и долгие вычисления (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 позволят вам строить масштабные и сложные, но при этом отзывчивые и понятные системы.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Один поток, а всё успевает: как работает цикл событий в JavaScript | 0 | 7.77 | 24-07-2026 |
| 2 | Широкий вход, узкий выход: Vue 3 внутри jQuery‑формы без бесконечных циклов | 0 | 7.31 | 28-07-2026 |
| 3 | Как я написал собственный Event Bus на JavaScript | 0 | 6.83 | 31-07-2026 |
| 4 | Модель исполнения JavaScript по спецификации ECMAScript: call stack, контексты, окружения и замыкания | 0 | 6.66 | 28-07-2026 |
| 5 | Объекты в JS не бесплатные | 0 | 8.74 | 05-08-2026 |
| 6 | Картинка грузится, а fetch к тому же хосту — нет: как CSP тихо сломала три интеграции | 0 | 7.2 | 04-08-2026 |
| 7 | Заменяем JavaScript с помощью HTML и CSS | 0 | 5 | 23-06-2026 |
| 8 | AAA Движок для GTA San Andreas в браузере и переписывание ThreeJS | 0 | 6.18 | 04-08-2026 |
| 9 | Обзор на Astro JS: опыт боевого проекта на 500+ тысяч страниц | 0 | 7.5 | 22-07-2026 |
| 10 | Сложные сайты на вайбкоде: четыре лендинга, два пути к «дорогим» эффектам и 3D-логотип из спрайтов | 0 | 9.77 | 05-08-2026 |