Быстрее асинхронные функции и промисы
Асинхронная обработка в JavaScript традиционно имела репутацию недостаточно быстрой. К тому же отладка живых JavaScript приложений — особенно серверов Node.js — является непростой задачей, особенно в случае асинхронного программирования. К счастью, времена меняются. В этой статье рассматриваются оптимизации асинхронных функций и промисов в V8 (и в некоторой степени в других движках JavaScript), а также описывается, как улучшился опыт отладки асинхронного кода.
Примечание: Если вы предпочитаете смотреть презентацию вместо чтения статей, наслаждайтесь видео ниже! Если нет, пропустите видео и продолжайте чтение.
Новый подход к асинхронному программированию
От обратных вызовов к промисам и асинхронным функциям
До того как промисы стали частью языка JavaScript, для асинхронного кода обычно использовались API на основе обратных вызовов, особенно в Node.js. Вот пример:
function handler(done) {
validateParams((error) => {
if (error) return done(error);
dbQuery((error, dbResults) => {
if (error) return done(error);
serviceCall(dbResults, (error, serviceResults) => {
console.log(result);
done(error, serviceResults);
});
});
});
}
Этот способ использования глубоко вложенных обратных вызовов часто называют «адом обратных вызовов», поскольку он делает код менее читаемым и сложным для поддержки.
К счастью, теперь, когда промисы стали частью языка JavaScript, тот же код можно писать более элегантным и удобным для поддержки образом:
function handler() {
return validateParams()
.then(dbQuery)
.then(serviceCall)
.then(result => {
console.log(result);
return result;
});
}
Еще более недавно JavaScript получил поддержку асинхронных функций. Теперь вышеупомянутый асинхронный код можно писать так, чтобы он выглядел очень схоже с синхронным кодом:
async function handler() {
await validateParams();
const dbResults = await dbQuery();
const results = await serviceCall(dbResults);
console.log(results);
return results;
}
С помощью асинхронных функций код становится более лаконичным, а управление и поток данных намного легче отслеживать, несмотря на то, что выполнение все еще асинхронное. (Обратите внимание, что выполнение JavaScript все еще происходит в одном потоке, то есть асинхронные функции сами не создают физические потоки.)
От обратных вызовов слушателей событий к асинхронной итерации
Еще одна асинхронная парадигма, которая особенно распространена в Node.js, — это использование ReadableStream
. Вот пример:
const http = require('http');
http.createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
res.write(body);
res.end();
});
}).listen(1337);
Этот код может быть немного сложным для восприятия: входящие данные обрабатываются по фрагментам, которые доступны только внутри обратных вызовов, а сигнализация конца потока также происходит внутри обратного вызова. Это делает простой введение ошибок, если не учитывать, что функция завершается немедленно, а фактическая обработка должна происходить в обратных вызовах.
К счастью, крутая новая функция ES2018 под названием асинхронные итерации может упростить этот код:
const http = require('http');
http.createServer(async (req, res) => {
try {
let body = '';
req.setEncoding('utf8');
for await (const chunk of req) {
body += chunk;
}
res.write(body);
res.end();
} catch {
res.statusCode = 500;
res.end();
}
}).listen(1337);
Вместо того чтобы вставлять логику обработки запроса в два разных обратных вызова — 'data'
и 'end'
— мы теперь можем поместить все в одну асинхронную функцию и использовать новый цикл for await…of
для асинхронного перебора фрагментов. Мы также добавили блок try-catch
, чтобы избежать проблемы unhandledRejection
1.
Вы уже можете использовать эти новые возможности сегодня в продакшене! Асинхронные функции полностью поддерживаются начиная с Node.js 8 (V8 v6.2 / Chrome 62), а итераторы и генераторы async полностью поддерживаются начиная с Node.js 10 (V8 v6.8 / Chrome 68)!
Улучшение производительности асинхронного кода
Мы существенно улучшили производительность асинхронного кода между V8 v5.5 (Chrome 55 & Node.js 7) и V8 v6.8 (Chrome 68 & Node.js 10). Мы достигли уровня производительности, который позволяет разработчикам безопасно использовать эти новые парадигмы программирования без необходимости беспокоиться о скорости.
На приведённой выше диаграмме показан тест производительности doxbee, который измеряет производительность кода, интенсивно использующего обещания. Обратите внимание, что на диаграммах отображено время выполнения, а значит, чем меньше, тем лучше.
Результаты параллельного теста, который специально оценивает производительность Promise.all()
, ещё более впечатляющие:
Мы смогли улучшить производительность Promise.all
в 8 раз.
Однако приведённые выше тесты являются синтетическими микро-бенчмарками. Команда V8 больше заинтересована в том, как наши оптимизации влияют на реальную производительность пользовательского кода.
На приведённой выше диаграмме показана производительность некоторых популярных HTTP-фреймворков промежуточного слоя, которые активно используют обещания и функции async
. Обратите внимание, что на этой диаграмме показано количество запросов/секунду, так что, в отличие от предыдущих диаграмм, чем больше, тем лучше. Производительность этих фреймворков значительно улучшилась между Node.js 7 (V8 v5.5) и Node.js 10 (V8 v6.8).
Эти улучшения производительности являются результатом трёх ключевых достижений:
- TurboFan, новый оптимизирующий компилятор 🎉
- Orinoco, новый сборщик мусора 🚛
- ошибка в Node.js 8, которая приводила к пропуску
await
микротиков 🐛
Когда мы запустили TurboFan в Node.js 8, это дало огромный прирост производительности.
Мы также работали над новым сборщиком мусора под названием Orinoco, который переносит работу по сбору мусора с основной нити выполнения, тем самым значительно улучшая обработку запросов.
И последнее, но не менее важное: в Node.js 8 была полезная ошибка, из-за которой await
пропускал микротики в некоторых случаях, что приводило к улучшению производительности. Эта ошибка началась как непреднамеренное нарушение спецификации, но позже дала нам идею для оптимизации. Начнем с объяснения ошибочного поведения:
Примечание: Следующее поведение было правильным согласно спецификации JavaScript на момент написания. С тех пор наше предложение спецификации было принято, и следующее "ошибочное" поведение теперь является правильным.
const p = Promise.resolve();
(async () => {
await p; console.log('after:await');
})();
p.then(() => console.log('tick:a'))
.then(() => console.log('tick:b'));
Программа выше создает выполненное обещание p
и await
его результат, но также связывает с ним два обработчика. В каком порядке вы ожидаете выполнение вызовов console.log
?
Поскольку p
выполнено, вы можете ожидать, что сначала будет напечатано 'after:await'
, а затем 'tick'
. На самом деле, такое поведение вы получите в Node.js 8:
Хотя это поведение кажется интуитивным, оно не соответствует спецификации. Node.js 10 реализует правильное поведение: сначала выполняются связанные обработчики, а только потом продолжается выполнение асинхронной функции.
Это «правильное поведение» не сразу очевидно и было на самом деле неожиданным для разработчиков JavaScript, поэтому оно заслуживает объяснения. Прежде чем погрузиться в магический мир обещаний и асинхронных функций, начнем с некоторых основ.
Задачи против микрозадач
На высоком уровне в JavaScript существуют задачи и микрозадачи. Задачи обрабатывают такие события, как ввод-вывод и таймеры, и выполняются по одной за раз. Микрозадачи реализуют отложенное выполнение для async
/await
и обещаний и выполняются в конце каждой задачи. Очередь микрозадач всегда очищается перед возвращением к циклу событий.
Для получения дополнительных сведений ознакомьтесь с объяснением Джейка Арчибальда о задачах, микрозадачах, очередях и расписании в браузере. Модель задач в Node.js очень похожа.
Асинхронные функции
Согласно MDN, асинхронная функция — это функция, которая работает асинхронно, используя внутреннее обещание для возврата своего результата. Асинхронные функции предназначены для того, чтобы асинхронный код выглядел как синхронный, скрывая часть сложности асинхронной обработки от разработчика.
Самая простая асинхронная функция выглядит так:
async function computeAnswer() {
return 42;
}
При вызове она возвращает обещание, и вы можете получить его значение так же, как и с любым другим обещанием.
const p = computeAnswer();
// → Promise
p.then(console.log);
// выводит 42 на следующем цикле
Вы получаете значение этого обещания p
только при следующем выполнении микрозадач. Иными словами, приведённая выше программа семантически эквивалентна вызову Promise.resolve
с этим значением:
function computeAnswer() {
return Promise.resolve(42);
}
Истинная мощь асинхронных функций раскрывается через выражения await
, которые приостанавливают выполнение функции до тех пор, пока обещание не будет выполнено, и возобновляют его после выполнения. Значение await
является значением выполненного обещания. Вот пример того, что это значит:
async function fetchStatus(url) {
const response = await fetch(url);
return response.status;
}
Выполнение fetchStatus
приостанавливается на await
и возобновляется позже, когда обещание fetch
выполняется. Это в общем эквивалентно цепочке обработчика, прикреплённого к обещанию, возвращённому из fetch
.
function fetchStatus(url) {
return fetch(url).then(response => response.status);
}
Этот обработчик содержит код, следующий за await
в асинхронной функции.
Обычно await
используется с обещаниями, но вы также можете ожидать произвольных значений JavaScript. Если значение выражения, следующего за await
, не является обещанием, оно преобразуется в обещание. Это означает, что вы можете использовать await 42
, если захотите:
async function foo() {
const v = await 42;
return v;
}
const p = foo();
// → Promise
p.then(console.log);
// в итоге выводит `42`
Более интересно то, что await
работает с любыми «thenable», то есть любыми объектами с методом then
, даже если это не настоящее обещание. Поэтому можно реализовать забавные вещи, например асинхронную задержку, которая измеряет фактическое время ожидания:
class Sleep {
constructor(timeout) {
this.timeout = timeout;
}
then(resolve, reject) {
const startTime = Date.now();
setTimeout(() => resolve(Date.now() - startTime),
this.timeout);
}
}
(async () => {
const actualTime = await new Sleep(1000);
console.log(actualTime);
})();
Рассмотрим, что делает V8 для await
под капотом, согласно спецификации. Вот простая асинхронная функция foo
:
async function foo(v) {
const w = await v;
return w;
}
При вызове она оборачивает параметр v
в обещание и приостанавливает выполнение асинхронной функции до тех пор, пока это обещание не будет выполнено. После этого выполнение функции возобновляется, и w
получает значение выполненного обещания. Это значение затем возвращается из асинхронной функции.
await
под капотом
Прежде всего, V8 отмечает эту функцию как возможную для возобновления, что означает, что выполнение может быть приостановлено и впоследствии возобновлено (в точках await
). Затем создается так называемое implicit_promise
— это обещание, которое возвращается при вызове асинхронной функции и которое в конечном итоге разрешается в значение, созданное этой функцией.
Затем наступает интересный момент: непосредственно await
. Сначала значение, переданное в await
, оборачивается в обещание. Затем к этому обещанию присоединяются обработчики, чтобы возобновить выполнение функции после его выполнения, и выполнение асинхронной функции приостанавливается, возвратив implicit_promise
вызвавшей стороне. После выполнения promise
выполнение функции возобновляется с значением w
из promise
, и implicit_promise
разрешается с w
.
В двух словах, начальные шаги для await v
таковы:
- Обернуть
v
— значение, переданное вawait
— в обещание. - Прикрепить обработчики для возобновления асинхронной функции позже.
- Приостановить выполнение асинхронной функции и вернуть
implicit_promise
вызывающему.
Рассмотрим индивидуальные операции шаг за шагом. Предположим, что объект, на который используется await
, уже является обещанием, которое выполнено со значением 42
. Затем движок создаёт новое promise
и разрешает его с любым значением, на которое применяется await
. Это приводит к отложенной цепочке этих обещаний в следующем цикле, выраженной с помощью того, что спецификация называет PromiseResolveThenableJob
.
Затем движок создает еще одно так называемое throwaway
обещание. Его называют throwaway, потому что к нему ничего не цепляется — оно полностью внутреннее для движка. Затем это throwaway
обещание добавляется к promise
с подходящими обработчиками для возобновления выполнения асинхронной функции. Эта операция performPromiseThen
фактически то, что делает Promise.prototype.then()
за кулисами. Наконец, выполнение асинхронной функции приостанавливается, а управление возвращается вызывающей функции.
Выполнение продолжается в вызывающей функции, и, в конце концов, стек вызовов становится пустым. Затем движок JavaScript начинает выполнять микрозадачи: он выполняет ранее запланированную PromiseResolveThenableJob
, которая планирует новую PromiseReactionJob
для добавления promise
к значению, переданному в await
. Затем движок возвращается к обработке очереди микрозадач, так как очередь микрозадач должна быть очищена перед продолжением основного цикла событий.
Следующей задачей является PromiseReactionJob
, которая выполняет promise
со значением из обещания, которое мы ожидаем — в данном случае 42
— и добавляет реакцию к throwaway
обещанию. Затем движок снова возвращается к циклу микрозадач, который содержит последнюю микрозадачу для обработки.
Теперь это второе PromiseReactionJob
распространяет разрешение к throwaway
обещанию и возобновляет приостановленное выполнение асинхронной функции, возвращая значение 42
из await
.
Резюмируя то, что мы узнали, при каждом await
движок должен создавать два дополнительных обещания (даже если правая часть уже является обещанием), и требуется по крайней мере три такта очереди микрозадач. Кто бы мог подумать, что одно выражение await
приводит к столь большим накладным расходам?!
Давайте рассмотрим, откуда возникают эти накладные расходы. Первая строка отвечает за создание обещания-обертки. Вторая строка немедленно разрешает это обещание-обертку со значением await
, равным v
. Эти две строки отвечают за одно дополнительное обещание плюс два из трех микротиков. Это довольно дорого, если v
уже является обещанием (что является обычным случаем, поскольку приложения обычно ожидают обещания). В маловероятном случае, если разработчик ожидает, например, значение 42
, движок все равно должен обернуть его в обещание.
Как оказалось, в спецификации уже существует операция promiseResolve
, которая выполняет обертку только тогда, когда это необходимо:
Эта операция возвращает обещания без изменений и оборачивает другие значения в обещания только при необходимости. Таким образом, вы экономите одно из дополнительных обещаний плюс два такта очереди микрозадач в случае, если переданное значение уже является обещанием. Это новое поведение уже включено по умолчанию в V8 v7.2. Для V8 v7.1 новое поведение можно включить с помощью флага --harmony-await-optimization
. Мы также предложили это изменение для спецификации ECMAScript.
Вот как новый и улучшенный await
работает за кулисами, шаг за шагом:
Предположим снова, что мы используем await
для обещания, выполненного со значением 42
. Благодаря магии promiseResolve
promise
теперь просто ссылается на то же самое обещание v
, так что на этом шаге ничего делать не нужно. Затем движок продолжает, как и раньше, создавая throwaway
обещание, запланировав PromiseReactionJob
для возобновления выполнения асинхронной функции в следующем такте очереди микрозадач, приостанавливая выполнение функции и возвращая управление вызывающей функции.
Затем, когда выполнение JavaScript заканчивается, движок начинает выполнять микрозадачи, и, таким образом, выполняет PromiseReactionJob
. Эта задача распространяет разрешение promise
на throwaway
и возобновляет выполнение асинхронной функции, возвращая значение 42
из await
.
Эта оптимизация исключает необходимость создания обещания-обертки, если значение, переданное в await
, уже является обещанием, и в таком случае мы переходим от минимума трех микротиков к всего одному микротику. Такое поведение похоже на то, что делает Node.js 8, за исключением того, что теперь это больше не ошибка — это теперь оптимизация, которая стандартизируется!
Все еще кажется неправильным, что движок должен создавать это throwaway
обещание, несмотря на то, что оно полностью внутреннее для движка. Как оказалось, throwaway
обещание было нужно только для удовлетворения API ограничений внутренней операции performPromiseThen
в спецификации.
Это недавно было рассмотрено в редакционных изменениях спецификации ECMAScript. Движкам больше не нужно создавать throwaway
promise для await
— в большинстве случаев2.
Сравнение await
в Node.js 10 и оптимизированного await
, который, вероятно, будет в Node.js 12, показывает влияние этого изменения на производительность:
async
/await
теперь превосходит код с ручным использованием promise. Главное здесь заключается в том, что мы значительно снизили затраты на выполнение асинхронных функций — не только в V8, но и во всех JavaScript-движках, внедрив изменения в спецификацию.
Обновление: Начиная с V8 v7.2 и Chrome 72, --harmony-await-optimization
включен по умолчанию. Патч в спецификацию ECMAScript был принят.
Улучшенный опыт разработчика
Помимо производительности, разработчики JavaScript также заботятся о возможности диагностировать и исправлять проблемы, что не всегда легко при работе с асинхронным кодом. Chrome DevTools поддерживает асинхронные трассировки стека, т.е. трассировки стека, которые включают не только текущую синхронную часть, но и асинхронную:
Это невероятно полезная функция во время локальной разработки. Однако этот подход действительно не помогает после развертывания приложения. При отладке постфактум вы увидите только свойство Error#stack
в файлах журналов, и это не дает информации об асинхронных частях.
Мы недавно работали над асинхронными трассировками стека без затрат, которые обогащают свойство Error#stack
вызовами асинхронных функций. “Без затрат” звучит захватывающе, не так ли? Как это может быть без затрат, если функция Chrome DevTools сопровождается значительными накладными расходами? Рассмотрим этот пример, где foo
вызывает bar
асинхронно, а bar
выбрасывает исключение после выполнения await
на promise:
async function foo() {
await bar();
return 42;
}
async function bar() {
await Promise.resolve();
throw new Error('BEEP BEEP');
}
foo().catch(error => console.log(error.stack));
При запуске этого кода в Node.js 8 или Node.js 10 результат будет следующим:
$ node index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
Обратите внимание, что хотя вызов foo()
вызывает ошибку, foo
вообще не включен в трассировку стека. Это усложняет отладку для JavaScript-разработчиков, независимо от того, развернут ваш код в веб-приложении или внутри какого-либо облачного контейнера.
Интересная часть здесь заключается в том, что движок знает, где он должен продолжить, когда bar
завершится: прямо после await
в функции foo
. Совпадение или нет, это также то место, где функция foo
была приостановлена. Движок может использовать эту информацию для реконструкции частей асинхронной трассировки стека, а именно мест await
. С этим изменением результат становится:
$ node --async-stack-traces index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
at async foo (index.js:2:3)
В трассировке стека сначала отображается верхняя функция, за которой следует остальная синхронная трассировка стека, за которой следует асинхронный вызов bar
в функции foo
. Это изменение реализовано в V8 за новым флагом --async-stack-traces
. Обновление: Начиная с V8 v7.3, --async-stack-traces
включен по умолчанию.
Тем не менее, если вы сравните это с трассировкой асинхронного стека в Chrome DevTools выше, вы заметите, что фактическое место вызова foo
отсутствует в асинхронной части трассировки стека. Как упоминалось ранее, этот подход использует тот факт, что для await
точки возобновления и приостановки совпадают — но для обычных вызовов Promise#then()
или Promise#catch()
это не так. Для получения дополнительной информации см. объяснение Маттиаса Биненса о почему await
лучше, чем Promise#then()
.
Заключение
Мы сделали асинхронные функции быстрее благодаря двум значительным оптимизациям:
- удаление двух лишних микротиков, и
- удаление promise
throwaway
.
Кроме того, мы улучшили работу разработчиков с помощью асинхронных трассировок стека без затрат, которые работают с await
в асинхронных функциях и Promise.all()
.
Также у нас есть несколько полезных советов по производительности для разработчиков JavaScript:
- предпочтение отдавайте
async
функциям иawait
вместо написанного вручную кода с promise, и - используйте собственную реализацию promise, предлагаемую движком JavaScript, чтобы воспользоваться оптимизациями, то есть избежать двух микротиков для
await
.
Footnotes
-
Спасибо Matteo Collina за указание на эту проблему. ↩
-
V8 всё ещё необходимо создавать
throwaway
promise, если в Node.js используютсяasync_hooks
, так как хукиbefore
иafter
выполняются в контекстеthrowaway
promise. ↩