Хабрахабр

[Перевод] JavaScript-движки: как они работают? От стека вызовов до промисов — (почти) всё, что вам нужно знать

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

Вы увидите несколько разделов, и один из самых интересных называется Call Stack (в Firefox вы увидите Call Stack, когда поставите брейкпоинт в коде): Начнём наше путешествие в язык с экскурсии в удивительный мир JavaScript-движков.
Откройте консоль в Chrome и перейдите на вкладку Sources.

Похоже, тут много чего происходит, даже ради исполнения пары строк кода. Что такое Call Stack? Существует большой компонент, который компилирует и интерпретирует наш JavaScript-код — это JavaScript-движок. На самом деле JavaScript не поставляется в коробке с каждым браузером. Самыми популярными являются V8, он используется в Google Chrome и Node.js, SpiderMonkey в Firefox, JavaScriptCore в Safari/WebKit.

Однако основную работу по исполнению кода делают для нас лишь несколько компонентов движков: Call Stack (стек вызовов), Global Memory (глобальная память) и Execution Context (контекст исполнения). Сегодня JavaScript-движки представляют собой прекрасные образцы программной инженерии, и будет практически невозможно рассказать обо всех аспектах. Готовы с ними познакомиться?

Содержание:

  1. JavaScript-движки и глобальная память
  2. JavaScript-движки: как они работают? Глобальный контекст исполнения и стек вызовов
  3. JavaScript является однопоточным, и другие забавные истории
  4. Асинхронный JavaScript, очередь обратных вызовов и цикл событий
  5. Callback hell и промисы ES6
  6. Создание и работа с JavaScript-промисами
  7. Обработка ошибок в ES6-промисах
  8. Комбинаторы ES6-промисов: Promise.all, Promise.allSettled, Promise.any и другие
  9. ES6-промисы и очередь микрозадач
  10. JavaScript-движки: как они работают? Асинхронная эволюция: от промисов до async/await
  11. JavaScript-движки: как они работают? Итоги

1. JavaScript-движки и глобальная память

Я говорил, что JavaScript является одновременно компилируемым и интерпретируемым языком. Хотите верьте, хотите нет, но на самом деле JavaScript-движки компилируют ваш код за микросекунды до его исполнения.

Это волшебство называется JIT (Just in time compilation). Волшебство какое-то, да? Но пока что мы пропустим теорию и сосредоточимся на фазе исполнения, которая не менее интересна. Она сама по себе является большой темой для обсуждения, даже книги будет мало, чтобы описать работу JIT.

Для начала посмотрите на этот код:

var num = 2; function pow(num) { return num * num;
}

Допустим, я спрошу вас, как этот код обрабатывается в браузере? Что вы ответите? Вы можете сказать: «браузер читает код» или «браузер исполняет код». В реальности всё не так просто. Во-первых, код считывает не браузер, а движок. JavaScript-движок считывает код, и как только он определяет первую строку, то кладёт пару ссылок в глобальную память.

И когда он прочитает приведённый выше код, то в глобальной памяти появятся два биндинга: Глобальная память (которую также называют кучей (heap)) — это область, в которой JavaScript-движок хранит переменные и объявления функций.

В таких средах есть много заранее определённых функций и переменных, которые называют глобальными. Даже если в примере содержится лишь переменная и функция, представьте, что ваш JavaScript-код исполняется в более крупной среде: в браузере или в Node.js. Поэтому глобальная память будет содержать гораздо больше данных, чем просто num и pow, имейте в виду.

Давайте теперь попробуем исполнить нашу функцию: В данный момент ничего не исполняется.

var num = 2; function pow(num) { return num * num;
} pow(num);

Что произойдёт? А произойдёт кое-что интересное. При вызове функции JavaScript-движок выделит два раздела:

  • Глобальный контекст исполнения (Global Execution Context)
  • Стек вызовов (Call Stack)

Что они собой представляют?

2. JavaScript-движки: как они работают? Глобальный контекст исполнения и стек вызовов

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

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

Именно так устроены JavaScript-функции. Это стековая структура данных: элементы могут добавляться в неё сверху, но они не могут исключаться из структуры, пока над ними есть другие элементы. Обратите на это внимание, поскольку эта концепция помогает понять утверждение «JavaScript является однопоточным». При исполнении они не могут покинуть стек вызовов, если в нём присутствует другая функция.

При вызове функции движок отправляет её в стек вызовов: Но вернёмся к нашему примеру.

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

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

Как мило! Представьте глобальный контекст исполнения в виде моря, в котором глобальные JavaScript-функции плавают, словно рыбы. Что, если наша функция имеет вложенные переменные, или внутренние функции? Но это лишь половина всей истории.

Даже в простом случае, как показано ниже, JavaScript-движок создаёт локальный контекст исполнения:

var num = 2; function pow(num) { var fixed = 89; return num * num;
} pow(num);

Обратите внимание, что я добавил в функцию pow переменную fixed. В этом случае локальный контекст исполнения будет содержать раздел для fixed. Я не очень хорошо рисую маленькие прямоугольники внутри других маленьких маленьких прямоугольников, так что используйте своё воображение.

Представьте также, как для каждой вложенной функции внутри вложенной функции движок создаёт другие локальные контексты исполнения. Рядом с pow появится локальный контекст исполнения, внутри зелёного раздела-прямоугольника, расположенного внутри глобального контекста исполнения. Как матрёшка! Все эти разделы-прямоугольники появляются очень быстро!

Что это означает? Давайте теперь вернёмся к истории с однопоточностью.

3. JavaScript является однопоточным, и другие забавные истории

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

К примеру, сложение двух чисел является синхронным и вычисляется за микросекунды. Это не проблема, если мы работаем с синхронным кодом. А что насчёт сетевых вызовов и других взаимодействий с внешним миром?

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

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

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

Теперь у вас есть общее представление о механике синхронности, лежащей в основе всех JavaScript-движков. В следующей главе мы поговорим о том, как в JavaScript работает асинхронный код и почему он работает именно так.

4. Асинхронный JavaScript, очередь обратных вызовов и цикл событий

Благодаря глобальной памяти, контексту исполнения и стеку вызовов синхронный JavaScript-код исполняется в наших браузерах. Но мы кое о чём забыли. Что происходит, если нужно исполнить какую-нибудь асинхронную функцию?

Вызов REST API или таймера — асинхронны, потому что на их выполнение могут уйти секунды. Под асинхронной функцией я подразумеваю каждое взаимодействие с внешним миром, для завершения которого может потребоваться какое-то время. Не забывайте, стек вызовов может исполнять одновременно только одну функцию, и даже одна блокирующая функция может буквально остановить браузер. Благодаря имеющимся в движке элементам мы можем обрабатывать такие функции без блокирования стека вызовов и браузера. К счастью, JavaScript-движки «умны», и с небольшой помощью браузера могут такие вещи отсортировывать.

Возьмём такой таймер: Когда мы исполняем асинхронную функцию, браузер берёт её и выполняет для нас.

setTimeout(callback, 10000); function callback(){ console.log('hello timer!');
}

Уверен, что хоть вы и видели setTimeout уже сотни раз, однако можете не знать, что эта функция не встроена в JavaScript. Вот так, когда JavaScript появился, в нём не было функции setTimeout. По сути, она является частью так называемых браузерных API, коллекции удобных инструментов, которые нам предоставляет браузер. Чудесно! Но что это означает на практике? Поскольку setTimeout относится к браузерным API, эта функция исполняется самим браузером (на мгновение она появляется в стеке вызовов, но сразу оттуда удаляется).

В данный момент в JavaScript-движке появилось ещё два раздела-прямоугольника. Через 10 секунд браузер берёт callback-функцию, которую мы ему передали, и кладёт её в очередь обратных вызовов. Посмотрите на этот код:

var num = 2; function pow(num) { return num * num;
} pow(num); setTimeout(callback, 10000); function callback(){ console.log('hello timer!');
}

Теперь наша схема выглядит так:

Через 10 секунд таймер запускается и callback-функция готова к исполнению. setTimeout исполняется внутри контекста браузера. Это структура данных в виде очереди, и, как свидетельствует её название, представляет собой упорядоченную очередь из функций. Но для начала она должна пройти через очередь обратных вызовов.

Но кто отправляет функции дальше? Каждая асинхронная функция должна пройти через очередь обратных вызовов, прежде чем попасть в стек вызовов. Это делает компонент под названием цикл событий.

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

Так выглядит общая схема обработки асинхронного и синхронного кода JavaScript-движком: После этого функция считается исполненной.

После завершения исполнения pow() стек вызовов освобождается и цикл событий отправляет в него callback(). Допустим, callback() готова к исполнению. Хотя я немного всё упростил, если вы поняли приведённую выше схему, то можете понять и весь JavaScript. И всё!

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

Это одно из лучших объяснений цикла событий. И если интересно, можете посмотреть любопытное видео «What the heck is the event loop anyway» Филипа Робертса.

В следующих главах мы рассмотрим ES6-промисы. Но мы ещё не закончили с темой асинхронного JavaScript.

5. Callback hell и ES6-промисы

Callback-функции используются в JavaScript везде, и в синхронном, и в асинхронном коде. Рассмотрим этот метод:

function mapper(element){ return element * 2;
} [1, 2, 3, 4, 5].map(mapper);

mapper — это callback-функция, которая передаётся внутри map. Приведённый код является синхронным. А теперь рассмотрим этот интервал:

function runMeEvery(){ console.log('Ran!');
} setInterval(runMeEvery, 5000);

Этот код асинхронный, поскольку внутри setInterval мы передаём обратный вызов runMeEvery. Обратные вызовы применяются по всему JavaScript, так что у нас годами существует проблема, получившая название «callback hell» — «ад обратных вызовов».

Термин Callback hell в JavaScript применяют к «стилю» программирования, при котором callback’и вкладывают в другие callback’и, которые вложены в другие callback’и… Из-за асинхронной природы JavaScript-программисты уже давно попадают в эту ловушку.

Возможно, потому что я ценю читабельный код и всегда стараюсь придерживаться его принципов. Если честно, я никогда не создавал большие пирамиды callback’ов. Если вы попали в callback hell, это говорит о том, что ваша функция делает слишком много.

А мы поговорим о ES6-промисах. Я не будут подробно говорить о callback hell, если вам интересно, то сходите на сайт callbackhell.com, там эта проблема подробно исследована и предложены разные решения. Но что такое «промисы»? Это аддон к JavaScript, призванное решить проблему ада обратных вызовов.

Промис может завершиться успешно, или на жаргоне программистов промис будет «разрешён» (resolved, исполнен). Промис в JavaScript — это представление будущего события. Также у промисов есть состояние по умолчанию: каждый новый промис начинается в состоянии «ожидания решения» (pending). Но если промис завершается с ошибкой, то мы говорим, что он в состоянии «отклонён» (rejected). Да. Можно ли создать собственный промис? Об этом мы поговорим в следующей главе.

6. Создание и работа с JavaScript-промисами

Для создания нового промиса нужно вызвать конструктор, передав в него callback-функцию. Она может принимать только два параметра: resolve и reject. Давайте создадим новый промис, который будет разрешён через 5 секунд (можете протестировать примеры в браузерной консоли):

const myPromise = new Promise(function(resolve), 5000)
});

Как видите, resolve — это функция, которую мы вызываем, чтобы промис успешно завершился. А reject создаст отклонённый промис:

const myPromise = new Promise(function(resolve, reject){ setTimeout(function(){ reject() }, 5000)
});

Обратите внимание, что вы можете игнорировать reject, потому что это второй параметр. Но если вы намерены воспользоваться reject, то не сможете проигнорировать resolve. То есть следующий код не будет работать и закончится разрешённым промисом:

// Can't omit resolve ! const myPromise = new Promise(function(reject){ setTimeout(function(){ reject() }, 5000)
});

Сейчас промисы не выглядят такими полезными, верно? Эти примеры ничего не выводят для пользователя. Давайте кое-что добавим. И разрешённые, от отклонённые промисы могут возвращать данные. Например:

const myPromise = new Promise(function(resolve) { resolve([{ name: "Chris" }]);
});

Но мы всё ещё ничего не видим. Для извлечения данных из промиса вам нужно связать промис с методом then. Он берёт callback (какая ирония!), который получает актуальные данные:

const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]);
}); myPromise.then(function(data) { console.log(data);
});

Как JavaScript-разработчик и потребитель чужого кода вы по большей части взаимодействуете с внешними промисами. Создатели библиотек чаще всего обёртывают legacy-код в конструктор промисов, таким образом:

const shinyNewUtil = new Promise(function(resolve, reject) { // do stuff and resolve // or reject
});

И при необходимости мы также можем создать и разрешить промис, вызвав Promise.resolve():

Promise.resolve({ msg: 'Resolve!'})
.then(msg => console.log(msg));

Итак, напомню: промисы в JavaScript — это закладка на событие, которое произойдёт в будущем. Событие начинается в состоянии «ожидание решения», и может быть успешным (разрешённым, исполненным) или неуспешным (отклонённым). Промис может возвращать данные, которые можно извлечь, прикрепив к промису then. В следующей главе мы обсудим, как работать с ошибками, приходящими из промисов.

7. Обработка ошибок в ES6-промисах

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

function makeAnError() { throw Error("Sorry mate!");
} try { makeAnError();
} catch (error) { console.log("Catching the error! " + error);
}

Результатом будет:

Catching the error! Error: Sorry mate!

Как и ожидалась, ошибка попала в блок catch. Теперь попробуем асинхронную функцию:

function makeAnError() { throw Error("Sorry mate!");
} try { setTimeout(makeAnError, 5000);
} catch (error) { console.log("Catching the error! " + error);
}

Этот код является асинхронным из-за setTimeout. Что будет, если мы его исполним?

throw Error("Sorry mate!"); ^ Error: Sorry mate! at Timeout.makeAnError [as _onTimeout] (/home/valentino/Code/piccolo-javascript/async.js:2:9)

Теперь результат другой. Ошибка не была поймана блоком catch, а свободно поднялась выше по стеку. Причина в том, что try/catch работает только с синхронным кодом. Если хотите узнать больше, то эта проблема подробно рассмотрена здесь.

В прошлой главе я говорил, что вызов reject приводит к отклонению промиса: К счастью, с промисами мы можем обрабатывать асинхронные ошибки, словно они синхронные.

const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!');
});

В этом случае мы можем обрабатывать ошибки с помощью обработчика catch, дёргая (опять) обратный вызов:

const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!');
}); myPromise.catch(err => console.log(err));

Кроме того, чтобы для создания и отклонения промиса в нужном месте можно вызывать Promise.reject():

Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err));

Напомню: обработчик then исполняется, когда промис выполнен, а обработчик catch выполняется для отклонённых промисов. Но это ещё не конец истории. Ниже мы увидим, как async/await замечательно работают с try/catch.

8. Комбинаторы ES6-промисов: Promise.all, Promise.allSettled, Promise.any и другие

Промисы не предназначены для работы по одиночке. Promise API предлагает ряд методов для комбинирования промисов. Один из самых полезных — Promise.all, он берёт массив из промисов и возвращает один промис. Только проблема в том, что Promise.all отклоняется, если отклонены все промисы в массиве.

Promise.race разрешает или отклоняет, как только один из промисов в массиве получает соответствующий статус.

Promise.any пока на ранней стадии предложенной функциональности, на момент написания статьи не поддерживается. В более свежих версиях V8 также будут внедрены два новых комбинатора: Promise.allSettled и Promise.any. Отличие от Promise.race в том, что Promise.any не отклоняется, даже если отклонён один из промисов. Однако, в теории, он сможет сигнализировать, был ли исполнен какой-либо промис.

Он тоже берёт массив промисов, но не «коротит», если один из промисов отклоняется. Promise.allSettled ещё интереснее. Его можно считать противоположностью Promise.all. Он полезен, когда нужно проверить, все ли промисы в массиве перешли в какую-то стадию, вне зависимости от наличия отклонённых промисов.

9. ES6-промисы и очередь микрозадач

Если помните из предыдущей главы, каждая асинхронная callback-функция в JavaScript оказывается в очереди обратных вызовов, прежде чем попадает в стек вызовов. Но у callback-функций, переданных в промис, иная судьба: они обрабатываются очередью микрозадач (Microtask Queue), а не очередью задач.

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

Подробнее эта механика описана Джейком Арчибальдом в Tasks, microtasks, queues and schedules, замечательное чтиво.

10. JavaScript-движки: как они работают? Асинхронная эволюция: от промисов до async/await

JavaScript быстро развивается и мы каждый год получаем постоянные улучшения. Промисы выглядели как финал, но с ECMAScript 2017 (ES8) появился новый синтаксис: async/await.

async/await никак не меняет JavaScript (не забывайте, язык должен быть обратно совместим со старыми браузерами и не должен ломать существующий код). async/await — всего лишь стилистическое улучшение, которое мы называем синтаксическим сахаром. Рассмотрим пример. Это лишь новый способ написания асинхронного кода на основе премисов. Выше мы уже сохранили проми с соответствующим then:

const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]);
}); myPromise.then((data) => console.log(data))

Теперь с помощью async/await мы можем обработать асинхронный код так, чтобы для читающего наш листинг код выглядел синхронным. Вместо применения then мы можем обернуть промис в функцию, помеченную как async, и затем будем ожидать (await) результат:

const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]);
}); async function getData() { const data = await myPromise; console.log(data);
} getData();

Выглядит здраво, верно? Забавно, что async-функция всегда возвращает промис, и никто не может ей в этом помешать:

async function getData() { const data = await myPromise; return data;
} getData().then(data => console.log(data));

А что насчёт ошибок? Одно из преимуществ async/await в том, что эта конструкция может позволить нам воспользоваться try/catch. Почитайте введение в обработку ошибок в async-функциях и их тестирование.

Давайте снова взглянем на промис, в котором мы обрабатываем ошибки с помощью обработчика catch:

const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!');
}); myPromise.catch(err => console.log(err));

С асинхронными функциями мы можем отрефакторить вот так:

async function getData() { try { const data = await myPromise; console.log(data); // or return the data with return data } catch (error) { console.log(error); }
} getData();

Однако ещё не все перешли на этот стиль. try/catch может усложнить ваш код. При этом нужно учитывать ещё кое-что. Посмотрите, как в этом коде возникает ошибка внутри блока try:

async function getData() { try { if (true) { throw Error("Catch me if you can"); } } catch (err) { console.log(err.message); }
} getData() .then(() => console.log("I will run no matter what!")) .catch(() => console.log("Catching err"));

Что насчёт двух строк, которые выводятся в консоли? Не забывайте, что try/catch — синхронная конструкция, а наша асинхронная функция генерирует промис. Они идут по двум разным путям, словно поезда. Но они никогда не встретятся! Поэтому ошибка, которую подняла throw, никогда не активирует обработчик catch в getData(). Исполнение этого кода приведёт к тому, что сначала появится надпись «Catch me if you can», а за ней «I will run no matter what!».

Решить это можно, скажем, возвращая Promise.reject() из функции: В реальном мире нам не нужно, чтобы throw запускал обработчик then.

async function getData() { try { if (true) { return Promise.reject("Catch me if you can"); } } catch (err) { console.log(err.message); }
}
Now the error will be handled as expected:
getData() .then(() => console.log("I will NOT run no matter what!")) .catch(() => console.log("Catching err")); "Catching err" // output

Помимо этого async/await выглядит лучшим способом структурирования асинхронного кода в JavaScript. Мы лучше управляем обработкой ошибок и код выглядит чище.

Обсудите это с командой. В любом случае, я не рекомендую рефакторить весь ваш JS-код под async/await. Но если вы работаете самостоятельно, то выбор между чистыми промисами и async/await — лишь дело вкуса.

11. JavaScript-движки: как они работают? Итоги

JavaScript — это скриптовый язык для веба, он сначала компилируется, а затем интерпретируется движком. Самые популярные JS-движки: V8, применяется в Google Chrome и Node.js; SpiderMonkey, разработан для Firefox; JavaScriptCore, используется в Safari.

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

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

Промис — это асинхронный объект, используемый для представления успешности или неуспешности любой асинхронной операции. Для упрощения работы асинхронного кода в ECMAScript 2015 были внедрены промисы. В 2017-м появились async/await: стилистическое улучшение для промисов, позволяющее писать асинхронный код, как если бы он был синхронным. Но улучшения на этом не прекратились.

Теги
Показать больше

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

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть