Перейти к основному содержимому

Быстрее асинхронные функции и промисы

· 17 мин. чтения
Майя Армянова ([@Zmayski](https://twitter.com/Zmayski)), всегда-ожидающий предвосхититель, и Бенедикт Мойрер ([@bmeurer](https://twitter.com/bmeurer)), профессиональный гарантирующий производительность

Асинхронная обработка в 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, чтобы избежать проблемы unhandledRejection1.

Вы уже можете использовать эти новые возможности сегодня в продакшене! Асинхронные функции полностью поддерживаются начиная с 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:

Ошибка await в Node.js 8

Хотя это поведение кажется интуитивным, оно не соответствует спецификации. Node.js 10 реализует правильное поведение: сначала выполняются связанные обработчики, а только потом продолжается выполнение асинхронной функции.

Node.js 10 больше не имеет ошибки await

Это «правильное поведение» не сразу очевидно и было на самом деле неожиданным для разработчиков 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 таковы:

  1. Обернуть v — значение, переданное в await — в обещание.
  2. Прикрепить обработчики для возобновления асинхронной функции позже.
  3. Приостановить выполнение асинхронной функции и вернуть 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 приводит к столь большим накладным расходам?!

Давайте рассмотрим, откуда возникают эти накладные расходы. Первая строка отвечает за создание обещания-обертки. Вторая строка немедленно разрешает это обещание-обертку со значением 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

Эта оптимизация исключает необходимость создания обещания-обертки, если значение, переданное в await, уже является обещанием, и в таком случае мы переходим от минимума трех микротиков к всего одному микротику. Такое поведение похоже на то, что делает Node.js 8, за исключением того, что теперь это больше не ошибка — это теперь оптимизация, которая стандартизируется!

Все еще кажется неправильным, что движок должен создавать это throwaway обещание, несмотря на то, что оно полностью внутреннее для движка. Как оказалось, throwaway обещание было нужно только для удовлетворения API ограничений внутренней операции performPromiseThen в спецификации.

Это недавно было рассмотрено в редакционных изменениях спецификации ECMAScript. Движкам больше не нужно создавать throwaway promise для await — в большинстве случаев2.

Сравнение кода await до и после оптимизаций

Сравнение 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

  1. Спасибо Matteo Collina за указание на эту проблему.

  2. V8 всё ещё необходимо создавать throwaway promise, если в Node.js используются async_hooks, так как хуки before и after выполняются в контексте throwaway promise.