Вольный перевод статьи Maya Lekova и Benedikt Meurer "Faster async functions and promises"
Ускоренные async функции и промисы
Традиционно, асинхронность в JavaScript имела репутацию не очень быстрой. Что еще хуже, лайв-отладка JavaScript приложений, в частности серверов Node.js, - непростая задача, особенно когда речь идет об асинхронном программировании. К счастью, времена меняются. В этой статье описано, как мы оптимизировали асинхронные функции и промисы в V8 (и в других JS движках в некоторой степени) и улучшали отладку асинхронного кода.
Примечание: если тексту вы предпочитаете презентацию, ниже приложено видео.
video https://www.youtube.com/watch?v=DFP5DKDQfOc
Новый подход к асинхронному программированию
От коллбэков до промисов и асинхронных функций
Перед тем, как промисы стали частью 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);
});
});
});
}
Данный паттерн использует коллбэки глубокой вложенности, что обычно называют “callback hell” (ад коллбэков), поскольку такой метод усложняет поддержку и читабельность кода.
К счастью, промисы теперь являются частью спецификации 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 кода все еще происходит в одном потоке, потому асинхронные функции сами по себе не создают физических потоков.)
От обработки коллбэков event listener до асинхронной итерации
Еще одна асинхронная парадигма, которая особенно часто используется в Node.js - [ReadableStream](https://nodejs.org/api/stream.html#stream_readable_streams)
. Вот пример:
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);
Все эти новые фичи вы уже можете использовать в продакшене! Асинхронные функции полностью поддерживаются в Node.js 8 (V8 v6.2 / Chrome 62), а асинхронные итераторы и генераторы в Node.js 10 (V8 v6.8 / Chrome 68)!
Улучшение перформанса асинхронности
Нам удалось существенно улучшить производительность асинхронного кода между V8 версии 5.5 (Chrome 55 & Node.js 7) и V8 версии 6.8 (Chrome 68 & Node.js 10). Мы достигли уровня производительности, когда разработчики могут без опаски и не беспокоясь о скорости пользоваться этими новыми парадигмами.
Диаграмма выше показывает замеры doxbee бенчмарка, который замеряет производительность нагруженного промисами кода. Обратите внимание, что график отображают время выполнения, то есть чем меньше, тем лучше.
Результат параллельного бенчмарка, который специально стрессует производительность [Promise.all()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all)
, еще более интересен:
Мы смогли улучшить производительность Promise.all
в 8 раз.
Всё же, оба микро-теста были синтетическими, а команда V8 больше заинтересована в том, как оптимизация повлияла на реальную производительность пользовательского кода.
График выше отображает перформанс нескольких популярных middleware HTTP фреймворков, которые плотно используют промисы и async
функции. Заметьте, что данный график, в отличие от прошлых, показывает количество запросов в секунду, потому чем больше, тем лучше. Производительность этих фреймворком была улучшена между Node.js 7 (V8 версии 5.5) и Node.js 10 (V8 версии 6.8).
Такие улучшения производительности появились благодаря трем ключевым достижениям:
- TurboFan, новый оптимизирующий компилятор 🎉
- Orinoco, новый сборщик мусора 🚛
- Исправлению бага Node.js 8, при котором
await
пропускал микротики 🐛
Мы запустили TurboFan в Node.js 8, что дало огромный прирост в производительности.
Также поработали над новым сборщиком мусора Orinoco, который исключает работу по сбору мусора из основного потока, что, в свою очередь, значительно улучшает обработку запросов.
И последним по списку, но не по важности, было исправление бага Node.js 8, который приводил к тому, что в некоторых случаях await
пропускал микротики. Баг считался непреднамеренным нарушением спецификации, но позже дал нам идею, как улучшить производительность. Давайте начнем с объяснения багованного поведения:
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 есть таски и микротаски. Первые обрабатывают события вроде I/O и таймеров, и выполняют их по одному в момент времени. Вторые внедряют отложенное выполнение async
/await
и промисов, и выполняют их в конце каждого таска. Очередь для микротасок всегда опустошается, прежде чем выполнение вернётся в цикл обработки событий.
Разница между тасками и микротасками
Больше подробностей вы можете найти в статье Джейка Арчибальда "таски, микротаски, очереди и планировки в браузере". Модель задач в Node.js очень схожа.
Асинхронные функции
Как описывает MDN, асинхронная функция - это функция, которая подразумевает асинхронное возвращение промиса в качестве результата. Они предназначены для того, чтобы асинхронный код выглядел как синхронный, тем самым скрывая некоторые сложности асинхронной разработки от разработчика.
Простейшая асинхронная функция выглядит следующим образом:
async function computeAnswer() {
return 42;
}
После вызова она возвращает промис, данные которого можно получить как при работе с любым другим промисом.
const p = computeAnswer();
// → Промис
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
в асинхронной функции.
Обычно, вы прокидываете Promise
в await
, но await
может ожидать и любого другого JavaScript значения, и, если это значение не является промисом, то будет в него преобразовано. Это означает, что вы можете ожидать даже число (await 42
), если нужно:
async function foo() {
const v = await 42;
return v;
}
const p = foo();
// → Промис
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
, который возвращается, когда вы вызываете асинхронную функцию, и которое в конечном итоге резолвлится до значения, созданного асинхронной функцией.
Сравнение между асинхронной функцией и тем, во что V8 ее превращает
Дальше самое интересное: сам await
. В первую очередь, значение, переданное await
, оборачивается в промис. Затем к этому промису добавляются обработчики, которые возобновят выполнение функции, как только промис будет выполнен, и выполнение асинхронной функции приостанавливается, возвращая implicit_promise
в место вызова. Как только promise
будет выполнен, выполнение асинхронной функции продолжится со значением w
из promise
, и implicit_promise
будет зарезолвлен в w
.
Короче говоря, основные шаги для await v
следующие:
- Обернуть
v
— значение, переданноеawait
— в промис. - Добавить обработчики для последующего возобновления асинхронной функции.
- Приостановить выполнение асинхронной функции и вернуть
implicit_promise
в место вызова.
Давайте шаг за шагом пройдемся по каждой операции. Предположим, что ожидаемое значение уже является промисом, который был выполнен со значением 42
. Тогда движок создает новый promise
и зарезолвлит его с тем, что было передано в await
. Этим самым цепочка промисов откладывается до следующего хода, что в спецификации зовется как [PromiseResolveThenableJob](https://tc39.es/ecma262/#sec-promiseresolvethenablejob)
.
Затем движок создает так называемый throwaway
промис. Называется он throwaway (одноразовый/на выброс), поскольку ни к чему не привязывается и находится только внутри движка. Этот throwaway
промис связывается с promise
вместе с соответствующими обработчиками для возобновления выполнения асинхронной функции. Операция performPromiseThen
, по сути, выражает то, что за кулисами делает метод [Promise.prototype.then()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then)
. Наконец, выполнение асинхронной функции возобновляется и возвращает контроль в место ее вызова.
Выполнение продолжается в месте вызова, и в итоге стэк вызовов очищается. Потом движок JavaScript запускает микротаски: ранее запланированную [PromiseResolveThenableJob](https://tc39.es/ecma262/#sec-promiseresolvethenablejob)
, которая говорит [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob)
сцепить promise
со значением, переданным в await
. Потом движок возвращает очередь микротасок на обработку, поскольку очередь микротасок должна быть очищена перед обработкой основного цикла событий.
Далее [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob)
, который заполняет promise
значением промиса, которого мы ожидаем — 42
— планирует реакцию на throwaway
промис. Движок снова возвращает цикл микротасок, который уже содержит финальную микротаску для обработки.
Теперь второй [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob)
передает разрешение промису throwaway
и возобновляет выполнение асинхронной функции, возвращая значение 42
из await
.
Суммируя то, что мы выучили, для каждого await
движку приходится создавать два дополнительных промиса (даже если значение по правую сторону уже им является) и требует минимум три тика очереди микротасок. Кто знал, что одно await
выражение приведёт к такому количеству оверхеда?!
Давайте посмотрим, откуда этот оверхед берется. Первая строка ответственна за создание обёртки промиса. Вторая строка немедленно резолвлит эту обёртку с ожидаемым значением v
. Эти две строки ответственны за один дополнительный промис два из трех микротиков. Дороговато, если v
уже является промисом (что является частым случаем, т.к. приложения обычно навешивают await
на промисы). В редком случае, когда разработчику нужно ожидать await
, например, значения 42
, движку все равно придется обернуть его в промис.
Как оказалось, в спецификации уже есть операция [promiseResolve](https://tc39.es/ecma262/#sec-promise-resolve)
, которая будет выполнять обертку только когда нужно:
Эта операция вернет промис неизменным и обернет только необходимые значения. Таким путем вы избавитесь от одного дополнительного промиса и двух тиков, поскольку чаще всего в await
мы передаем промис. Это поведение доступно по умолчанию в V8, начиная с версии 7.2.
Вот как работает новый и улучшенный await
, шаг за шагом:
Давайте снова предположим, что ожидаем промис со значением 42
. Благодаря магии [promiseResolve](https://tc39.es/ecma262/#sec-promise-resolve)
, promise
теперь просто ссылается на такой же промис v
, потому на этом шаге больше нет других действий. После того движок действует так же, как и раньше: создает промис throwaway
, с помощью [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob)
планирует возобновить асинхронную функцию на следующем тике в очереди, приостанавливает выполнение функции и возвращает в место вызова.
В итоге все выполнение кода заканчивается и движок запускает микротаски, которые выполняют [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob)
, промису throwaway
передается разрешение, выполнение асинхронной функции возобновляется, и из await
мы получаем 42
.
Эта оптимизация позволяет избежать необходимости оборачивать промис, его значение, переданное в await
, уже им является, и вместо трех микротиков мы выполняем только один. Node.js 8 ведет себя также, только это больше не считается багом, а введенной в стандарт оптимизацией!
Но все равно кажется неправильным, что движку приходится создавать промис throwaway
, пускай он и является полностью внутренним. Как оказалось, throwaway
промис был нужен только для того, чтобы удовлетворить ограничениям внутренней операции performPromiseThen
API.
Сравнение await
кода до и после оптимизаций
Сравнивая await
в Node.js 10 с оптимизированным await
версии Node.js 12, видим следующую разницу:
Заключение
Мы ускорили асинхронные функции благодаря двум значительным оптимизациям:
- удалением двух дополнительных микротиков
- удалением
throwaway
промиса.
А еще для JavaScript разработчиков у нас есть пара советов по оптимизации:
- предпочитайте
async
/await
вручную написанному коду с промисами - придерживайтесь встроенной реализации промисов, предложенной движком, чтобы использовать преимущества шорткатов, т.е. избегайте двух микротиков для
await
.
Top comments (0)