Поводом для ревизии данного вопроса стало то, что я по сей день слышу от специалистов (в том числе позиционирующих себя как 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 в host‑средах было унифицировано и эквивалентно поведению описанному в спецификации. Реализация стандарта может быть разной, но они соответствуют спецификации.
Расширяемый?
Из спецификации: ‘Каждый веб-браузер и сервер, поддерживающий 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 за счет которого реализуется асинхронность тоже не является частью движка и никоим образом не упоминается в ECMAScript спецификации. Это специфическое свойство host‑среды. В веб‑браузерах его поведение регламентирует HTML5 спецификация.
При вызове асинхронной операции происходит обращение к API Libuv, либо к внутреннему API Chrome, в зависимости от того, в какой среде выполняется скрипт.
Важно отметить, что 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 ‑ объект, который создает отдельный и изолированный контекст выполнения, работающий параллельно с основным потоком, позволяющий распределять нагрузку на несколько ядер процессора и выполнять параллельные вычисления. Это мощный инструмент в основном для выполнения вычислительно интенсивных задач, который, при его рациональном использовании может существенно повысить производительность приложения.
Запуск воркера в отдельном потоке: Реализация в Chrome.
Shared Worker ‑ объект, который создает общий контекст выполнения, доступный для нескольких окон, вкладок или фреймов. В основном используется для параллельного выполнения кода и обеспечивает общий доступ к состоянию между разными частями приложения.
Также ECMAScript спецификацией представлены следующие объекты:
SharedArrayBuffer позволяет разделять и обеспечивает возможность совместного доступа к памяти между различными потоками.
Atomics Object обеспечивающий синхронизацию доступа к разделяемому сегменту памяти, что позволяет избежать гонок за ресурсами и обеспечить корректное взаимодействие между потоками.
Web Lock API - это механизм запроса доступа к ресурсам и гарантия того, что только один поток в момент времени действительно имеет доступ. Блокировка ресурсов позволяет избежать состояния гонки.
Worker - это поток на уровне операционной системы (не процесс), который создается в rendering-процессе вкладки, однако он не может работать с DOM и не имеет доступа к структурам памяти других потоков из-за уязвимостей упомянутых выше (Spectre, Meltdown). Браузер должен гарантировать что зловредный код с одной страницы не окажет негативное влияние на другие. Поместить сайт в песочницу сложно и довольно ресурсоемко. Учитывая еще существование iframe и браузерных расширений. Поэтому иногда rendering процесс может переиспользоваться для нескольких вкладок одного и того же сайта. Изоляция воркеров делает использование безопасным.
Если нужно передать данные в Worker, можно использовать механизм postMessage
. При передачи данных между потоками их нужно сериализовать и десерилизовать. В Chrome это делается при помощи structured clone, что не очень быстро, поэтому следует подумать об архитектуре. Также можно передать данные определенных типов через опцию transferable
, что гораздо быстрее, но тогда данные исчезнут из основного потока. Самый эффективный способ передачи данных между потоками это использование общей памяти SharedArrayBuffer . Однако, вы не можете его использовать без заголовка cross-origin-isolated
. Для гарантирования безопасности также требуется изоляция между источниками.
Подробнее про модель памяти
Заключение
JavaScript - это высокоуровневый встраиваемый язык общего назначения, существующий в рамках собственной терминологии. Дефиницией терминов занимается единственный официальный источник в лице ECMA. Выполнение JavaScript обеспечивается исключительно host‑средой (типа node.js или браузера), тем самым он не может, и не должен иметь своего API для обслуживания событий или создания потоков, поскольку эти низкоуровневые задачи решает host‑среда. Спецификация языка уже более 9 лет содержит в себе необходимые стандарты для параллельного выполнения кода. Host‑среда предоставила через свое API возможность организации многопоточности с некоторыми особенностями.
Также рекомендую к просмотру: