DEV Community

собачья будка
собачья будка

Posted on • Edited on

ускоренные async функции и промисы [перевод]

Вольный перевод статьи 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);
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Данный паттерн использует коллбэки глубокой вложенности, что обычно называют “callback hell” (ад коллбэков), поскольку такой метод усложняет поддержку и читабельность кода.

К счастью, промисы теперь являются частью спецификации JavaScript, и тот же код можно сделать более элегантным и поддерживаемым:

function handler() {
  return validateParams()
    .then(dbQuery)
    .then(serviceCall)
    .then(result => {
      console.log(result);
      return result;
    });
}
Enter fullscreen mode Exit fullscreen mode

Совсем недавно JavaScript получил поддержку асинхронных функций. И тот же самый асинхронный код теперь очень схож с синхронным:

async function handler() {
  await validateParams();
  const dbResults = await dbQuery();
  const results = await serviceCall(dbResults);
  console.log(results);
  return results;
}
Enter fullscreen mode Exit fullscreen mode

С асинхронными функциями код становится более коротким, и теперь гораздо легче следить и контролировать потоки данных, несмотря на то, что код выполняется асинхронно. (Заметьте, что выполнение 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);
Enter fullscreen mode Exit fullscreen mode

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

Упростить код поможет асинхронная итерация - новая крутая фича 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);
Enter fullscreen mode Exit fullscreen mode

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

https://v8.dev/_img/fast-async/doxbee-benchmark.svg

Диаграмма выше показывает замеры doxbee бенчмарка, который замеряет производительность нагруженного промисами кода. Обратите внимание, что график отображают время выполнения, то есть чем меньше, тем лучше.

Результат параллельного бенчмарка, который специально стрессует производительность [Promise.all()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all), еще более интересен:

https://v8.dev/_img/fast-async/parallel-benchmark.svg

Мы смогли улучшить производительность Promise.all в 8 раз.

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

https://v8.dev/_img/fast-async/http-benchmarks.svg

График выше отображает перформанс нескольких популярных 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'));
Enter fullscreen mode Exit fullscreen mode

Код выше создает зарезолвенный промис p, и ожидает его результата с помощью await, но также создает цепочку из двух обработчиков. И в каком же порядке нам стоит ожидать выполнения console.log?

Поскольку p уже выполнен, вы можете ожидать, что первым в консоли появится 'after:await', и только потом оба 'tick'. По факту, так себя и поведет Node.js 8:

https://v8.dev/_img/fast-async/await-bug-node-8.svg

Это и есть await баг Node.js 8

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

https://v8.dev/_img/fast-async/await-bug-node-10.svg

Node.js 10 избавился от await бага

Это “правильное поведение”  может вызвать споры или удивить JavaScript разработчиков, а потому заслуживает некоторого пояснения. Давайте начнем с основ, прежде чем погрузимся в волшебный мир промисов и асинхронных функций.

Таски против микротасок

На верхнем уровне JavaScript есть таски и микротаски. Первые обрабатывают события вроде I/O и таймеров, и выполняют их по одному в момент времени. Вторые внедряют отложенное выполнение async/await и промисов, и выполняют их в конце каждого таска. Очередь для микротасок всегда опустошается, прежде чем выполнение вернётся в цикл обработки событий.

https://v8.dev/_img/fast-async/microtasks-vs-tasks.svg

Разница между тасками и микротасками

Больше подробностей вы можете найти в статье Джейка Арчибальда "таски, микротаски, очереди и планировки в браузере". Модель задач в Node.js очень схожа.

Асинхронные функции

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

Простейшая асинхронная функция выглядит следующим образом:

async function computeAnswer() {
  return 42;
}
Enter fullscreen mode Exit fullscreen mode

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

const p = computeAnswer();
// → Промис

p.then(console.log);
// выведет 42 на следующем ходу
Enter fullscreen mode Exit fullscreen mode

Значение промиса p вы получите тогда, когда отработают микротаски. Другими словами, код выше семантически равен использованию Promise.resolve со значением:

function computeAnswer() {
  return Promise.resolve(42);
}
Enter fullscreen mode Exit fullscreen mode

Но настоящая мощь асинхронных функций появляется при использовании выражения await, которое тормозит выполнение функции, пока промис не будет зарезолвлен. Значением await будет выполненный промис. Вот пример для пояснения:

async function fetchStatus(url) {
  const response = await fetch(url);
  return response.status;
}
Enter fullscreen mode Exit fullscreen mode

Выполнение fetchStatus остановится на моменте await, и продолжится только после того, как fetch промис выполнится. Это в какой-то мере эквивалентно цепочке обработчиков промиса, который возвращает fetch.

function fetchStatus(url) {
  return fetch(url).then(response => response.status);
}
Enter fullscreen mode Exit fullscreen mode

Обработчик выполняет те же действия, что и await в асинхронной функции.

Обычно, вы прокидываете Promise в await, но await может ожидать и любого другого JavaScript значения, и, если это значение не является промисом, то будет в него преобразовано. Это означает, что вы можете ожидать даже число (await 42), если нужно:

async function foo() {
  const v = await 42;
  return v;
}

const p = foo();
// → Промис

p.then(console.log);
// в итоге выведет `42` 
Enter fullscreen mode Exit fullscreen mode

Более интересно то, что 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);
})();
Enter fullscreen mode Exit fullscreen mode

Давайте посмотрим, как V8 обрабатывает await под капотом, следуя спецификации. Возьмем простой пример асинхронной функции foo:

async function foo(v) {
  const w = await v;
  return w;
}
Enter fullscreen mode Exit fullscreen mode

После вызова функции, параметр v оборачивается в промис и выполнение функции останавливается, пока промис не будет зарезолвлен. Как только это произойдет, выполнение продолжится, и возвращаемой переменной w будет присвоено значение выполненного промиса.

await под капотом

В первую очередь, V8 помечает функцию, как возобновляемую. Значит, что она может быть приостановлена и возобновлена (в точках await). Затем он создает так называемый implicit_promise, который возвращается, когда вы вызываете асинхронную функцию, и которое в конечном итоге резолвлится до значения, созданного асинхронной функцией.

https://v8.dev/_img/fast-async/await-under-the-hood.svg

Сравнение между асинхронной функцией и тем, во что V8 ее превращает

Дальше самое интересное: сам await. В первую очередь, значение, переданное await, оборачивается в промис. Затем к этому промису добавляются обработчики, которые возобновят выполнение функции, как только промис будет выполнен, и выполнение асинхронной функции приостанавливается, возвращая implicit_promise в место вызова. Как только promise будет выполнен, выполнение асинхронной функции продолжится со значением w из promise, и implicit_promise будет зарезолвлен в w.

Короче говоря, основные шаги для await v следующие:

  1. Обернуть v — значение, переданное await — в промис.
  2. Добавить обработчики для последующего возобновления асинхронной функции.
  3. Приостановить выполнение асинхронной функции и вернуть implicit_promise в место вызова.

Давайте шаг за шагом пройдемся по каждой операции. Предположим, что ожидаемое значение уже является промисом, который был выполнен со значением 42. Тогда движок создает новый promise и зарезолвлит его с тем, что было передано в await. Этим самым цепочка промисов откладывается до следующего хода, что в спецификации зовется как [PromiseResolveThenableJob](https://tc39.es/ecma262/#sec-promiseresolvethenablejob).

https://v8.dev/_img/fast-async/await-step-1.svg

Затем движок создает так называемый throwaway промис. Называется он throwaway (одноразовый/на выброс),  поскольку ни к чему не привязывается и находится только внутри движка. Этот throwaway промис связывается с promise вместе с соответствующими обработчиками для возобновления выполнения асинхронной функции. Операция performPromiseThen, по сути, выражает то, что за кулисами делает метод [Promise.prototype.then()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). Наконец, выполнение асинхронной функции возобновляется и возвращает контроль в место ее вызова.

https://v8.dev/_img/fast-async/await-step-2.svg

Выполнение продолжается в месте вызова, и в итоге стэк вызовов очищается. Потом движок JavaScript запускает микротаски: ранее запланированную [PromiseResolveThenableJob](https://tc39.es/ecma262/#sec-promiseresolvethenablejob), которая говорит [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob) сцепить  promise со значением, переданным в await. Потом движок возвращает очередь микротасок на обработку, поскольку очередь микротасок должна быть очищена перед обработкой основного цикла событий.

https://v8.dev/_img/fast-async/await-step-3.svg

Далее [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob), который заполняет promise значением промиса, которого мы ожидаем — 42 — планирует реакцию на throwaway промис. Движок снова возвращает цикл микротасок, который уже содержит финальную микротаску для обработки.

https://v8.dev/_img/fast-async/await-step-4-final.svg

Теперь второй [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob) передает разрешение промису throwaway и возобновляет выполнение асинхронной функции, возвращая значение  42 из await.

https://v8.dev/_img/fast-async/await-overhead.svg

Суммируя то, что мы выучили, для каждого await движку приходится создавать два дополнительных промиса (даже если значение по правую сторону уже им является) и требует минимум три тика очереди микротасок. Кто знал, что одно  await выражение приведёт к такому количеству оверхеда?!

https://v8.dev/_img/fast-async/await-code-before.svg

Давайте посмотрим, откуда этот оверхед берется. Первая строка ответственна за создание обёртки промиса. Вторая строка немедленно резолвлит эту обёртку с ожидаемым значением v. Эти две строки ответственны за один дополнительный промис два из трех микротиков. Дороговато, если v уже является промисом (что является частым случаем, т.к. приложения обычно навешивают await на промисы). В редком случае, когда разработчику нужно ожидать await, например, значения 42, движку все равно придется обернуть его в промис.

Как оказалось, в спецификации уже есть операция [promiseResolve](https://tc39.es/ecma262/#sec-promise-resolve), которая будет выполнять обертку только когда нужно:

https://v8.dev/_img/fast-async/await-code-comparison.svg

Эта операция вернет промис неизменным и обернет только необходимые значения. Таким путем вы избавитесь от одного дополнительного промиса и двух тиков, поскольку чаще всего в await мы передаем промис. Это поведение доступно по умолчанию в V8, начиная с версии 7.2.

Вот как работает новый и улучшенный await, шаг за шагом:

https://v8.dev/_img/fast-async/await-new-step-1.svg

Давайте снова предположим, что ожидаем промис со значением 42. Благодаря магии [promiseResolve](https://tc39.es/ecma262/#sec-promise-resolve)promise теперь просто ссылается на такой же промис v, потому на этом шаге больше нет других действий. После того движок действует так же, как и раньше: создает промис throwaway, с помощью [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob) планирует возобновить асинхронную функцию на следующем тике в очереди, приостанавливает выполнение функции и возвращает в место вызова.

https://v8.dev/_img/fast-async/await-new-step-2.svg

В итоге все выполнение кода заканчивается и движок запускает микротаски, которые выполняют [PromiseReactionJob](https://tc39.es/ecma262/#sec-promisereactionjob), промису  throwaway передается разрешение, выполнение асинхронной функции возобновляется, и из await мы получаем 42.

https://v8.dev/_img/fast-async/await-overhead-removed.svg

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

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

https://v8.dev/_img/fast-async/await-optimized.svg

https://v8.dev/_img/fast-async/node-10-vs-node-12.svg

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

Сравнивая await в Node.js 10 с оптимизированным await версии Node.js 12, видим следующую разницу:

https://v8.dev/_img/fast-async/benchmark-optimization.svg

Заключение

Мы ускорили асинхронные функции благодаря двум значительным оптимизациям:

  • удалением двух дополнительных микротиков
  • удалением throwaway промиса.

А еще для JavaScript разработчиков у нас есть пара советов по оптимизации:

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

Top comments (0)