Поводом для данной статьи послужило то, что я до сих пор слышу от разработчиков, в том числе позиционирующих себя как senior, что современный JavaScript является однопоточным. Более того, они нередко задают этот вопрос на технических интервью и вводят кандидатов в заблуждение. Разберемся, откуда возникает эта путаница и как на самом деле устроена многопоточность в JavaScript.
Терминология
ECMAScript — это встраиваемый, расширяемый язык программирования общего назначения, не имеющий собственных средств ввода-вывода. Он используется как основа для создания скриптовых языков.
Частый вопрос: ECMAScript - это язык или спецификация?
Из самой спецификации:
"стандарт ECMA определяет язык ECMAScript."
Иными словами, ECMAScript - это язык программирования, а спецификация - это документ ECMA-262, который описывает и стандартизирует этот язык.
На основе стандарта ECMA-262 реализуются конкретные языки, такие как:
JavaScript
JScript (Microsoft)
ActionScript (Adobe)
Почему ECMA использует нейтральное название "ECMAScript" вместо "JavaScript"?
Это обусловлено юридическими причинами: термин JavaScript является зарегистрированной торговой маркой компании Oracle, которая запрещает использования данного термина в стандарте, поэтому ECMA оперирует формально нейтральным обозначением языка.
Среда выполнения (host-среда) - это вычислительное окружение, необходимое для выполнения JavaScript программы.
JavaScript — скриптовый язык программирования, используемый для выполнения вычислений и взаимодействия с вычислительными объектами host-среды. Он представляет собой реализацию языка ECMAScript, и исполняется современными движками с применением интерпретации и JIT-компиляции.
Встраиваемый?
JavaScript не существует сам по себе и не выполняется в вакууме. Его выполнение всегда обеспечивается host-средой — вычислительным окружением, в рамках которого исполняется программа.
В качестве host-среды могут выступать:
Серверные платформы: Node.js (v8), Deno (v8), Bun.js (JavaScriptCore);
Экосистемы для программирования микроконтроллеров: Espruino;
Веб-Браузеры: Google Chrome (v8), Mozilla Firefox (SpiderMonkey), Safari (JavaScriptCore);
Экосистемы мобильной разработки: React Native (JavaScriptCore, Hermes);
Их множество, поэтому цель спецификации формализовать работу языка настолько, насколько это возможно, чтобы поведение 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, в случае Node.js к API Libuv — зависит от среды выполнения скрипта.
Важно отметить, что спецификация не обязывает JavaScript быть асинхронным или многопоточным, но включает необходимые для этого стандарты. Выполнение асинхронных операций, как и создание потоков это задачи host‑среды, в которой выполняется JavaScript.
Таким образом асинхронным или многопоточным JavaScript делает именно host‑среда.
Внутреннее устройство host-среды
Рассмотрим на примере наиболее популярных.
Chrome
До 2015 года браузеры работали в одном потоке и разделяли вычислительные ресурсы между всеми открытыми вкладками (single‑threaded execution). То есть один Event Loop мог выполнять задачи от разных агентов, которые занимались выполнением скриптов вкладок. Если на одной вкладке выполнялся ресурсоемкий алгоритм, это отражалось на работе и безопасности других вкладок. Об этом подробнее тут.
Современные браузеры улучшили эту ситуацию за счет многопоточности и многопроцессности. Каждой вкладке или плагину в большинстве случаев, соответствует отдельный процесс, что положительно сказывается на безопасности за счет изоляции. Также используются отдельные потоки для выполнения парсинга, стилизации, компоновки и отрисовки элементов на странице.
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, который позволяет самостоятельно запускать рабочие потоки.
Модель асинхронного исполнения и её отличие от многопоточности
HTML-спецификация прямо указывает, что API HTML и DOM спроектированы таким образом, чтобы разработчики не сталкивались напрямую со сложностями многопоточности. Исключением из этого принципа является SharedArrayBuffer. Используя разделяемую память, можно наблюдать реальное одновременное выполнение кода в разных потоках.
Изоляция потоков и асинхронная модель исполнения существенно снижают сложность многопоточного программирования и, что особенно важно, повышают уровень безопасности, в том числе снижая риски уязвимостей класса Spectre и Meltdown.
Асинхронное программирование предполагает инициацию выполнения некоторой операции, о завершении которой основной поток исполнения узнаёт по полученному результату.
Многопоточное программирование, в свою очередь, подразумевает выполнение кода в нескольких потоках. Как правило, существует главный поток и набор рабочих потоков, которые выполняют операции параллельно, а их результаты затем передаются в необходимые контексты.
Ключевое различие между асинхронностью и многопоточностью заключается в способе одновременного выполнения задач. В многопоточной модели каждая задача исполняется в отдельном потоке, работающем параллельно с другими потоками. В асинхронной модели задачи также могут выполнят��ся одновременно, однако сама операция может быть выполнена как в отдельном потоке или процессе, так и за пределами текущего вычислительного окружения. При этом обработка результатов всегда осуществляется основным потоком исполнения по мере их готовности.

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

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