Почему JavaScript однопоточный?

mr. Cooper 1 час назад Веб-разработка
Почему JavaScript однопоточный?

JavaScript часто описывают одной фразой: «это однопоточный язык». После неё обычно появляется знакомая схема - один call stack, event loop и очередь задач. Но остаётся странный вопрос: если у компьютера давно несколько ядер, зачем современному языку вообще сохранять модель, в которой JavaScript-код выполняется последовательно?

Тем более что JavaScript давно умеет работать с Promise, async/await, Web Workers и worker_threads. Значит, дело не в том, что язык технически не способен использовать несколько потоков.

Есть простой способ проверить, что именно подразумевается под этой фразой. Запустить на странице тяжёлый цикл.

//js

let result = 0;

for (let i = 0; i < 10_000_000_000; i++) {
    result += i;
}

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

Процессор при этом не обязательно занят целиком. У компьютера могут быть свободные ядра. Браузер тоже не останавливает всю свою работу. Занят конкретный поток, на котором в этот момент исполняется JavaScript.

Вот это и есть та часть фразы «JavaScript однопоточный», которая действительно имеет практический смысл.

У обычного JavaScript-кода внутри одного агента нет второго исполнителя, который в это же время возьмёт соседнюю инструкцию и начнёт выполнять её параллельно. Текущая работа должна закончиться.

Для переменной вроде этой:

//js

let balance = 100;
balance -= 30;

ситуация поэтому достаточно предсказуема. Пока выполняется этот код, другой JavaScript-поток не меняет balance.

В многопоточной программе пришлось бы учитывать конкурирующий доступ к общей памяти. Один поток прочитал значение, другой успел его изменить, первый записал свой результат - классическая гонка. Отсюда блокировки, атомарные операции и прочая инфраструктура, которой в обычной последовательной логике JavaScript просто не требуется.

Но за это удобство приходится платить.

Если вычисление долгое, его нельзя протолкнуть между другими задачами. Даже если эти задачи совершенно независимы.

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

Сетевой запрос показывает разницу лучше всего.

//js

const response = await fetch('/api/users');
const users = await response.json();

Пока сервер отвечает, JavaScript не должен непрерывно выполнять цикл ожидания. Сетевая операция находится за пределами непосредственного исполнения этого кода. Когда результат готов, выполнение продолжится.

Слово await здесь часто создаёт ложное впечатление. Кажется, что функция «ушла» в отдельный поток, а потом вернулась.

Она никуда в таком смысле не ушла.

await позволяет остановить продолжение конкретной функции до получения результата и вернуться к нему позже. Сам JavaScript от этого многопоточным не становится.

Поэтому вот такой код остаётся вычислительно тяжёлым:

//js

async function calculate() {
    for (let i = 0; i < 10_000_000_000; i++) {
        // вычисление
    }
}

У функции появился async, но свободное ядро процессора от этого не получило новую задачу. Когда цикл начнёт выполняться, он займёт тот же поток.

С Promise история такая же. Promise описывает будущий результат. Он не является контейнером для отдельного потока исполнения.

А event loop нужен затем, чтобы всё это хозяйство вообще могло работать без постоянного ожидания.

//js

console.log(1);

setTimeout(() => console.log(2), 0);

Promise.resolve().then(() => console.log(3));

console.log(4);

В консоли получится:

//text

1
4
3
2

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

При этом event loop не может освободить поток от вычислений, которые уже начались. Бесконечный цикл остаётся бесконечным циклом. Очередь задач может сколько угодно ждать рядом - до неё просто не дойдёт выполнение.

С ожиданием сети всё иначе, потому что большую часть времени JavaScript там вообще нечего вычислять.

Это различие становится особенно заметным в приложениях, где есть тяжёлые операции. Например, обработка большого изображения. Разбор огромного массива. Сложное математическое вычисление. Кодирование данных.

Для такой работы в браузере существуют Web Workers.

//js

const worker = new Worker('worker.js');

worker.postMessage(data);

worker.onmessage = event => {
    console.log(event.data);
};

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

Причём разработчик не получает свободный доступ к переменным другого потока. Основной способ общения - передача сообщений. DOM также не становится общей переменной, которую можно одновременно менять из нескольких мест.

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

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

Но это скорее исключение, чем обычный путь веб-разработки.

В Node.js всё выглядит знакомо. Сетевой сервер может держать множество соединений, пока запросы ждут базу данных или сеть. Но если обработчик сам несколько секунд занят вычислением, асинхронность здесь не поможет:

//js

app.get('/report', (req, res) => {
    const result = veryHeavyCalculation();

    res.json(result);
});

Пока выполняется veryHeavyCalculation(), JavaScript занят именно этой работой.

Для таких случаев в Node.js существуют worker_threads:

//js

const { Worker } = require('node:worker_threads');

const worker = new Worker('./worker.js');

Задача может быть вынесена в отдельный поток, а основной продолжит заниматься остальной работой.

На этом месте возникает вопрос интереснее самого определения однопоточности: зачем вообще сохранять такую модель, если параллельное выполнение всё равно доступно?

Ответ связан с тем, как JavaScript взаимодействует с состоянием программы.

Представьте обычный обработчик страницы. Он получил событие, изменил несколько объектов, обновил интерфейс и завершился. Следующий обработчик получает уже новое состояние. Между этими действиями нет второго JavaScript-исполнителя, который мог бы одновременно изменить тот же объект.

Для интерфейсов это довольно важное свойство.

Если разрешить произвольным потокам одновременно менять одно состояние, многие совершенно обычные операции пришлось бы защищать от конкурирующего доступа. Веб-разработчик довольно быстро столкнулся бы с теми же проблемами, которые давно знакомы системному программированию: блокировки, гонки, порядок записи, взаимное ожидание.

Вместо этого JavaScript оставляет параллельность за отдельными механизмами.

И здесь становится понятна нынешняя архитектура языка.

async/await нужен там, где программа ждёт результат. Event loop организует готовую работу. Worker позволяет вынести вычисления. worker_threads дают похожий инструмент в Node.js.

Это разные уровни одной системы, хотя в разговорах о «многопоточном JavaScript» их часто складывают в одну кучу.

Поэтому браузер может использовать десятки потоков, Node.js - создавать дополнительные Worker, а JavaScript-код страницы при этом всё равно оставаться последовательным в рамках своего агента.

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

Причём оно никуда не исчезло вместе с многоядерными процессорами.

Когда задача связана с ожиданием, JavaScript может уступить управление и вернуться к ней позже. Когда задача требует процессора, её можно вынести в отдельный Worker. А если этого не сделать, длинный цикл по-прежнему способен остановить нормальную работу текущего потока.

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

Именно поэтому одновременно верны две вещи: JavaScript-код внутри одного агента выполняется последовательно, а современная среда исполнения JavaScript вполне может быть многопоточной.

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи