JavaScript однопоточный или многопоточный? Ставим точку

Поводом для ревизии данного вопроса стало то, что я по сей день слышу от специалистов (в том числе позиционирующих себя как senior), что современный JavaScript является однопоточным. При этом они охотно задают этот вопрос на техническом интервью, вводя неуверенных кандидатов в заблуждение.

Терминология

ECMAScript — это встраиваемый расширяемый не имеющий средств ввода‑вывода язык программирования общего назначения, используемый в качестве основы для построения скриптовых языков.

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/Fetch, Worker, navigator и т. д.

console, setTimeout, setInterval — это API также предоставляемые host‑средой, и не имеющие отношения к ECMAScript. Их работу специфицирует 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 работает параллельно в несколько потоков.

c3dcf3de2bc6d50ad2da276e8aeb9b7b.png

Сетевые запросы во внутреннем API среды выполняются в отдельных потоках. Продемонстрировать это можно с помощью следующего кода:

const TODO_API = 'https://jsonplaceholder.typicode.com/todos/';
    const startTime = new Date().getTime()
    const xhr1 = new XMLHttpRequest();

    xhr1.open("GET", TODO_API + 1, false); // Третий аргумент делает запрос синхронным
    xhr1.onload = () => {
        if (xhr1.readyState !== 4) {
            return;
        }
        if (xhr1.status === 200) {
            console.log(xhr1.responseText);
        } else {
            console.error(xhr1.statusText);
        }
        console.log(new Date().getTime() - startTime) // измеряем в миллисекундах
    }
    xhr1.onerror = () => console.error(xhr1.statusText);
    xhr1.send(null);

    const xhr2 = new XMLHttpRequest();
    xhr2.open("GET", TODO_API + 2, false);
    xhr2.onload = () => {
        if (xhr2.readyState !== 4) {
            return;
        }
        if (xhr2.status === 200) {
            console.log(xhr2.responseText);
        } else {
            console.error(xhr2.statusText);
        }
        console.log(new Date().getTime() - startTime)
    }
    xhr2.onerror = () => console.error(xhr2.statusText);
    xhr2.send(null);

Результат:

70459aa73f7cef3deb3283f904579061.png

Соотвественно запросы выполнились последовательно в рамках одного потока.

Теперь выполним их в асинхронном режиме передав true в метод .open() третьим аргументом.

Результат показывает, что они выполнились параллельно:

выполнение двух асинхронных запросов

выполнение двух асинхронных запросов

Теперь выполним 4 запроса:

выполнение четырех асинхронных запросов

выполнение четырех асинхронных запросов

Они все также выполнились параллельно.

Для более глубокого изучения многопроцессной архитектуры Chrome:

Threading and tasks

Multi-process architecture

Выделенные системные потоки в большинстве случаев улучшили производительность, до тех пор пока вы не запустите ресурсоемкий алгоритм в главном потоке. Мы не могли запускать скрипты в отдельных потоках, но в 2009 году HTML5 спецификацией было представлено средство призванное решить данную проблему, но об этом далее в соответствующем разделе.

Node.js

Про node.js однозначно можно сказать, что это многопоточное приложение.

Обзор архитектуры Libuv

По умолчанию Libuv использует 4 системных потока.

Продемонстрируем на примере криптографических алгоритмов:

const crypto = require('crypto');
const startTime = new Date().getTime();

crypto.pbkdf2('memento_mori', '5', 100000, 64, 'sha512', () => {
    console.log('1 - ', startTime - new Date().getTime())
})
crypto.pbkdf2('memento_mori', '5', 100000, 64, 'sha512', () => {
    console.log('2 - ', startTime - new Date().getTime())
})
crypto.pbkdf2('memento_mori', '5', 100000, 64, 'sha512', () => {
    console.log('3 - ', startTime - new Date().getTime())
})
crypto.pbkdf2('memento_mori', '5', 100000, 64, 'sha512', () => {
    console.log('4 - ', startTime - new Date().getTime())
})
crypto.pbkdf2('memento_mori', '5', 100000, 64, 'sha512', () => {
    console.log('5 - ', startTime - new Date().getTime())
})

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

результат выполнения потоков

результат выполнения потоков

Так начиная с версии 10.5.0 в Node.js доступен модуль worker_threads для организации многопоточности.

Event Loop в Node.js работает на основе паттерна Реактор и Демультиплексора, чтобы эффективно обрабатывать множество параллельных операций I/O.

Реактор является архитектурной моделью, в которой основной цикл событий слушает набор источников и вызывает соответствующие обработчики при наступлении событий.

Демультиплексор используется для прослушивания событий от нескольких источников (например: сокетов, файловых дескрипторов) и выбора доступного события для обработки. Это позволяет максимально эффективно использовать системные ресурсы.

Асинхронность

Асинхронное программирование подразумевает инициацию выполнения некоторой операции, об окончании которой главный поток выполнения узнает через некоторое время.

Многопоточное программирование подразумевает, что код выполняется в разных потоках. Например, есть главный поток, и несколько рабочих потоков, которые выполняют операции, результаты которых затем передаются куда необходимо.

Отличие между асинхронностью и многопоточностью заключается в том, каким образом задачи выполняются одновременно. В многопоточности, каждая задача выполняется в отдельном потоке, который работает параллельно с другими потоками. В то время как в асинхронности, задачи выполняются одновременно, но по сути нам неважно кто и как будет заниматься выполнением операции. Это может быть как отдельный поток или процесс, так и устройство за пределами текущего вычислительного устройства. Из чего следует, что многопоточность — это один из способов организации асинхронности.

В современных host‑средах выполнение асинхронных операций в большинстве случаев выполняются параллельно в отдельных потоках (или процессах), кроме Mutation Observer, Promise .then/catch/finally, queueMicrotask(()=>{}). Таким образом операции не блокируют выполнение последующих операций и могут быть запущены одновременно с другими, что позволяет увеличить производительность и продолжать реагировать на пользовательские действия, при этом более эффективно и безопасно задействовать вычислительные ресурсы устройства.

В истории JavaScript были различные механизмы и паттерны для работы с асинхронностью.

Callback

Использовались для обратного вызова функций после завершения асинхронной операции. Это простой и самый быстрый способ взаимодействовать с асинхронным API, однако у него возникли недостатки в виде:

  • Callback Hell ‑ ситуация, когда множество асинхронных операций вложены друг в друга и приводят к сложному для чтения и поддержки коду.

  • Zalgo ‑ ситуация, когда трудно определить как будет вызвана функция, синхронно или асинхронно.

  • Передача ответственности за выполнение функции обратного вызова ненадежному коду, который не гарантирует нам правильный порядок выполнения.

Лично я так и не понял первые две проблемы. В случае с Callback Hell вместо того, чтобы инициализировать анонимные функции в месте их использования, можно было вынести их инициализацию выше и воспользоваться композицией, при этом задав функциям понятный идентификатор. Касательно Zalgo вполне достаточно отделять системный код от прикладного.

Promises

Появляются в ES6 стандарте. Используется для обработки асинхронных вычислений.

Спецификация Promise определяет строгий порядок операций, связанных с их разрешением. Порядок выполнения имеет важное значение для детерминированной и надежной обработки данных и управления потоком выполнения.

HostEnqueuePromiseJob

В соответствии с этим порядком, обработчики промисов всегда выполняются асинхронно после выполнения всех предыдущих синхронных задач в текущем потоке выполнения.

Async/await

Появляются в ES8 стандарте. Представляют собой лаконичный способ работы с асинхронным кодом, позволяя писать его в синхронном стиле. Это упрощает чтение и уменьшает количество кода.

Важно помнить о рациональном использовании последовательных вызовов асинхронных функций.

Инструменты организации многопоточности

Современная разработка не стоит на месте, и постоянно появляются новые инструменты и возможности, которые делают JavaScript еще более мощным. Однако начиная с 2009 HTML5 спецификация вводит новые возможности в JavaScript для многопоточного выполнения кода.

  • Dedicated Worker объект, который создает отдельный и изолированный контекст выполнения, работающий параллельно с основным потоком, позволяющий распределять нагрузку на несколько ядер процессора и выполнять параллельные вычисления. Это мощный инструмент в основном для выполнения вычислительно интенсивных задач, который, при его рациональном использовании может существенно повысить производительность приложения.

  • Shared Worker объект, который создает общий контекст выполнения, доступный для нескольких окон, вкладок или фреймов. В основном используется для параллельного выполнения кода и обеспечивает общий доступ к состоянию между разными частями приложения.

Также ECMAScript спецификацией представлены следующие объекты:

  • SharedArrayBuffer позволяет разделять и обеспечивает возможность совместного доступа к памяти между различными потоками.

  • Atomics Object обеспечивающий синхронизацию доступа к разделяемому сегменту памяти, что позволяет избежать гонок за ресурсами и обеспечить корректное взаимодействие между потоками

Тут подробнее о модели памяти

Заключение

Язык JavaScript не может и не должен иметь своего API для обслуживания событий или создания потоков, поскольку это задача host‑среды. Спецификация языка JavaScript уже достаточно долгое время содержит в себе необходимые стандарты для параллельного выполнения кода. Если руководствоваться той логикой на основании которой сделан вывод, что JavaScript асинхронный, то ровно таким же образом мы должны признать, что он и многопоточный, так как существует все необходимое, чтобы через свое API host‑среда предоставила такую возможность.

© Habrahabr.ru