Поводом для данной статьи стало то, что даже сегодня я слышу от разработчиков, включая тех, кто позиционирует себя как senior, что современный JavaScript остаётся однопоточным. Более того, этот вопрос нередко задают на технических собеседованиях, вводя неуверенных кандидатов в заблуждение. Давайте разберёмся, как на самом деле устроена многопоточность в JavaScript.
Терминология
ECMAScript - это встраиваемый расширяемый не имеющий средств ввода‑вывода язык программирования общего назначения, используемый в качестве основы для построения скриптовых языков.
ECMAScript - это язык или спецификация?
Из спецификации: "стандарт ECMA определяет язык ECMAScript."
ECMA является стандартом, разрабатываемым ECMA International, который имеет право на стандартизацию синтаксиса и функциональности языка JavaScript. Термин ECMAScript был введен для стандартизации языка, и он является официальной торговой маркой, но не имеет права называться "JavaScript".
JavaScript является торговой маркой, принадлежащей компании Oracle (ранее принадлежала Sun Microsystems до приобретения в 2009 году). Компания Oracle управляет торговой маркой «JavaScript» и не позволяет использовать её в официальном названии стандарта ECMA даже не смотря на общественное влияние.
JavaScript - это JIT‑компилируемый и интерпретируемый скриптовый язык программирования, который используется для выполнения вычислений и управления вычислительными объектами среды выполнения, являющийся реализацией спецификации языка ECMAScript.
Среда выполнения (или host-среда) - это вычислительное окружение, необходимое для выполнения JavaScript программы.
Встраиваемый?
Исполнение JavaScript кода обеспечивается host‑средой.
В качестве host‑среды могут выступать:
Серверные платформы: Node.js (v8), Deno (v8), Bun.js (JavaScriptCore);
Экосистема для программирования микроконтроллеров: Espruino;
Веб-Браузеры: Google Chrome (v8), Mozilla Firefox (SpiderMonkey), Safari (JavaScriptCore);
Их множество, поэтому цель спецификации формализовать работу языка настолько, насколько это возможно, чтобы поведение JavaScript было унифицировано и эквивалентно поведению описанному в спецификации. Реализации стандарта могут быть разными, но они соответствуют спецификации.
Расширяемый?
Из спецификации: ‘Каждый веб-браузер и сервер, поддерживающий ECMAScript, предоставляет свою собственную среду выполнения, дополняя среду выполнения языка ECMAScript.’
JavaScript не должен быть самодостаточным в вычислительном отношении. Ожидается, что host‑среда будет предоставлять не только объекты и средства, описанные в спецификации, но также специфичные для среды объекты, описание и поведение которых выходят за рамки ECMAScript спецификации.
Очевидный, но наглядный пример: Chrome и Node.js работают на базе движка v8, но в Chrome отсутствуют такие модули как: fs, path, os, worker_threads.
Так же, как и в Node.js отсутствуют: XHR, Worker, navigator
и т. д.
console, setTimeout, setInterval
— это API также предоставляемые host‑средой. В браузерах их работу специфицирует HTML5.
Что интереснее, Event Loop за счет которого реализуется асинхронность также не является частью JavaScript и никоим образом не упоминается в ECMAScript спецификации. Это специфическое свойство host‑среды. В веб‑браузерах его поведение регламентирует HTML5 спецификация.
При вызове асинхронной операции происходит обращение либо к внутреннему API Chrome, либо к API Libuv (в случае Node.js), в зависимости от среды выполнения скрипта.
Важно отметить, что ECMAScript не обязывает JavaScript быть асинхронным или многопоточным, но включает необходимые для этого стандарты. Выполнение асинхронных операций, как и создание потоков это задачи host‑среды, в которой выполняется JavaScript.
Таким образом асинхронным или многопоточным JavaScript делает именно host‑среда.
Внутреннее устройство host-среды
Рассмотрим внутреннюю работу на примере наиболее популярных.
Chrome
До 2015 года браузеры работали в одном потоке и разделяли вычислительные ресурсы между всеми открытыми вкладками — single‑threaded execution. То есть один EventLoop мог выполнять задачи от разных агентов, которые занимались выполнением скриптов вкладок. Если на одной вкладке выполнялся ресурсоемкий алгоритм, это отражалось на безопасности и работе других вкладок. Об этом подробнее тут.
Современные браузеры улучшили эту ситуацию за счет многопоточности. Каждой вкладке или плагину в большинстве случаев, соответствует отдельный процесс, что также положительно сказывается на безопасности, за счет изоляции. Также используются отдельные потоки для выполнения парсинга, стилизации, компоновки и отрисовки элементов на странице.
Garbage Collection в v8 работает параллельно в несколько потоков.
Сетевые запросы во внутреннем API host-среды выполняются в отдельных потоках. Продемонстрировать это наглядно можно с помощью следующего кода:
const TODO_API = 'https://jsonplaceholder.typicode.com/todos/';
const fetchTodoItem = ({ async, id }) => {
const request = new XMLHttpRequest();
request.open('GET', TODO_API + id, async); // Третий аргумент делает запрос синхронным
request.onload = () => console.log(performance.now());
request.send(null);
};
fetchTodoItem({ async: false, id: 1 });
fetchTodoItem({ async: false, id: 2 });
Результат:

Соответственно, запросы выполнились последовательно.
Однако, при этом изучив исходники Chrome, вы узнаете, что даже выполнение синхронных запросов происходит в отдельных потоках.
Теперь выполним их в асинхронном режиме передав true
в метод .open()
третьим аргументом.

Результат показывает, что они выполнились параллельно.
Убедимся окончательно выполнив 4 запроса:

Они все также выполнились параллельно, в отдельных потоках.
Имплементация данной функциональности в Chrome:
Выделяется отдельный поток выполнения для запроса, по завершению которого будет вызван GarbageCollector для очистки выделенного сегмента памяти.


Материалы для изучения архитектуры Chrome:
Однако это еще не делает JavaScript многопоточным. Выделенные системные потоки в большинстве случаев улучшили производительность, до тех пор пока вы не запустите ресурсоемкий алгоритм в главном потоке. Возможности запускать скрипты в отдельных потоках не было, но в 2009 году HTML5 спецификацией было представлено средство призванное решить данную проблему, но об этом далее в соответствующем разделе.
Node.js
Про node.js однозначно можно сказать, что это многопоточное приложение.
По умолчанию Libuv использует 4 системных потока.
Продемонстрируем на примере криптографических алгоритмов:
const crypto = require('crypto');
crypto.pbkdf2('12345', '5', 100000, 64, 'sha512', () => {
console.log('1 - ', performance.now())
})
crypto.pbkdf2('12345', '5', 100000, 64, 'sha512', () => {
console.log('2 - ', performance.now())
})
crypto.pbkdf2('12345', '5', 100000, 64, 'sha512', () => {
console.log('3 - ', performance.now())
})
crypto.pbkdf2('12345', '5', 100000, 64, 'sha512', () => {
console.log('4 - ', performance.now())
})
crypto.pbkdf2('12345', '5', 100000, 64, 'sha512', () => {
console.log('5 - ', performance.now())
})
Результат выполнения демонстрирует, что первые четыре вызова алгоритма выполнялись параллельно, выполнение пятого произошло после первого освободившегося потока.

Также начиная с версии 10.5.0 в Node.js доступен модуль worker_threads
, который позволяет самостоятельно запускать рабочие потоки.
Event Loop в Node.js работает на основе паттерна Реактор и Демультиплексора, чтобы эффективно обрабатывать множество параллельных I/O операций.
Реактор является архитектурной моделью, в которой основной цикл событий слушает набор источников и вызывает соответствующие обработчики при наступлении событий.
Демультиплексор используется для прослушивания событий от нескольких источников (например: сокетов, файловых дескрипторов) и выбора доступного события для обработки. Это позволяет максимально эффективно использовать системные ресурсы.
Асинхронность
Чтобы избежать ознакомления веб-разработчиков со сложностями многопоточности, API HTML и DOM разработаны таким образом, что ни один скрипт никогда не сможет обнаружить одновременное выполнение других скриптов. Даже в случае с workers цель состоит в том, чтобы поведение реализаций можно было рассматривать как полную сериализацию выполнения всех скриптов во всех глобальных файлах.
Исключением из этого общего принципа проектирования является класс JavaScript SharedArrayBuffer. Используя объекты SharedArrayBuffer, фактически можно наблюдать, что скрипты выполняются одновременно.
Таким образом изоляция потоков и асинхронность нивелируют сложности многопотчного программирования, и что более важно, гарантирует безопасность от уязвимостей типа Spectre и Meltdown.
Асинхронное программирование подразумевает инициацию выполнения некоторой операции, об окончании которой главный поток выполнения узнает через некоторое время.
Многопоточное программирование подразумевает, что код выполняется в разных потоках. Например, есть главный поток, и несколько рабочих потоков, которые выполняют операции, результаты которых затем передаются куда необходимо.
Отличие между асинхронностью и многопоточностью заключается в том, каким образом задачи выполняются одновременно. В многопоточности, каждая задача выполняется в отдельном потоке, который работает параллельно с другими потоками. В то время как в асинхронности, задачи выполняются одновременно, но заниматься выполнением операции может как отдельный поток или процесс, так и устройство за пределами текущего вычислительного окружения, результаты операций будут обработаны главным потоком, когда они будут готовы.

Из чего следует, что асинхронность это не многопоточность, но многопоточность может быть способом организации асинхронности.
В современных host‑средах выполнение асинхронных операций в большинстве случаев выполняются параллельно в отдельных потоках (или процессах), кроме Mutation Observer, Promise.then|catch|finally, queueMicrotask
. Таким образом операции не блокируют выполнение последующих операций и могут быть запущены одновременно с другими, что позволяет увеличить производительность и продолжать реагировать на пользовательские действия, при этом более эффективно и безопасно задействовать вычислительные ресурсы устройства.
В истории JavaScript были различные механизмы и паттерны для работы с асинхронностью.
Callback
Использовались для обратного вызова функций после завершения асинхронной операции. Это простой и самый быстрый способ взаимодействовать с асинхронным API, однако у него возникли недостатки в виде:
Callback Hell ‑ ситуация, когда множество асинхронных операций вложены друг в друга и приводят к сложному для чтения и поддержки коду.
Zalgo ‑ ситуация, когда трудно определить как будет вызвана функция, синхронно или асинхронно.
Передача ответственности за выполнение функции обратного вызова ненадежному коду, который не гарантирует нам детерминированный порядок выполнения.
Promise
Появляется в ES6 стандарте, и используется для обработки асинхронных вычислений.
Спецификация Promise определяет строгий порядок операций, связанных с их разрешением. Порядок выполнения имеет важное значение для детерминированной и надежной обработки данных и управления потоком выполнения.
В соответствии с этим порядком, обработчики промисов всегда выполняются асинхронно после выполнения всех предыдущих синхронных задач в текущем потоке выполнения.
Async/await
Появляются в ES8 стандарте, и представляют собой лаконичный способ работы с асинхронным кодом поверх промисов, позволяя писать его в синхронном стиле. Это упрощает чтение и уменьшает количество кода.
Важно помнить о рациональном использовании последовательных вызовов асинхронных функций.
Инструменты организации многопоточности
Современная разработка не стоит на месте, и постоянно появляются новые инструменты и возможности, которые делают JavaScript еще более мощным. Однако, начиная с 2009 HTML5 спецификация вводит новые возможности в JavaScript для параллельного выполнения кода.
Dedicated Worker — это объект, создающий отдельный изолированный контекст выполнения, который работает параллельно с основным потоком. Он позволяет распределять нагрузку между ядрами процессора и эффективно обрабатывать вычислительно сложные задачи. При грамотном использовании Worker значительно повышает производительность приложения, разгружая основной поток.

Shared Worker ‑ объект, который создает общий контекст выполнения, доступный для нескольких окон, вкладок или фреймов. В основном используется для параллельного выполнения кода и обеспечивает общий доступ к состоянию между разными частями приложения.
Также ECMAScript спецификацией представлены следующие объекты:
SharedArrayBuffer позволяет разделять и обеспечивает возможность совместного доступа к памяти между различными потоками.
Atomics Object обеспечивающий синхронизацию доступа к разделяемому сегменту памяти, что позволяет избежать гонок за ресурсами и обеспечить корректное взаимодействие между потоками.
Web Lock API - это механизм запроса доступа к ресурсам и гарантия того, что только один поток в момент времени действительно имеет доступ. Блокировка ресурсов позволяет избежать состояния гонки.
Worker — это поток операционной системы, создаваемый в рендеринг-процессе вкладки. Важно понимать, что он не является отдельным процессом и не имеет доступа к DOM, LocalStorage, SessionStorage и данным других потоков. Это необходимо для безопасности: браузер должен предотвращать влияние одного скрипта на другие вкладки и минимизировать риски атак.
Для передачи данных в воркеры используется postMessage, но этот метод требует сериализации и десериализации (в Chrome применяется structured clone), что может замедлять работу при передаче объемных структур данных. Более быстрый способ — передача TypedArray через transferable, однако это работает по концепции передачи владения, что лишает основной поток доступа к данным. Самый эффективный метод — SharedArrayBuffer, но его использование в браузере ограничено требованиями безопасности (требуется заголовок cross-origin-isolated).
Если требуется передавать объекты между потоками без потерь производительности, удобное решение — библиотека objectbuffer. Она позволяет шарить объекты между воркерами, избегая накладных расходов сериализации и обеспечивая быстрый доступ к данным.
Подробнее про модель памяти
Заключение
JavaScript — это высокоуровневый встраиваемый язык программирования, существующий в рамках собственной терминологии, официально определяемой стандартом ECMA. Сам по себе JavaScript не выполняется в вакууме — его работа обеспечивается host-средой (например, браузером или Node.js). Именно поэтому язык не должен иметь встроенных API для работы с потоками или обработки событий — это задачи среды выполнения.
Уже более девяти лет спецификация JavaScript содержит стандарты, необходимые для параллельного выполнения кода. При этом многопоточность реализуется на уровне host-среды, предоставляя соответствующие API с определёнными ограничениями и особенностями.
Также рекомендую ознакомится: