ускоренные async функции и промисы [перевод]
Published: 2020-04-29 19:29:11

Вольный перевод статьи Maya Lekova и Benedikt Meurer "[Faster async functions and promises](https://v8.dev/blog/fast-async)"

# Ускоренные async функции и промисы

Традиционно, асинхронность в JavaScript имела репутацию не очень быстрой. Что еще хуже, лайв-отладка JavaScript приложений, в частности серверов Node.js, - непростая задача, *особенно* когда речь идет об асинхронном программировании. К счастью, времена меняются. В этой статье описано, как мы оптимизировали асинхронные функции и промисы в V8 (и в других JS движках в некоторой степени) и улучшали отладку асинхронного кода.

**Примечание:**  если тексту вы предпочитаете презентацию, ниже приложено видео.

video [https://www.youtube.com/watch?v=DFP5DKDQfOc](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 получил поддержку [асинхронных функций](https://developers.google.com/web/fundamentals/primers/async-functions). И тот же самый асинхронный код теперь очень схож с синхронным:

    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);

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

Упростить код поможет [асинхронная итерация](https://2ality.com/2016/10/asynchronous-iteration.html) - новая крутая фича 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). Мы достигли уровня производительности, когда разработчики могут без опаски и не беспокоясь о скорости пользоваться этими новыми парадигмами.

![https://v8.dev/_img/fast-async/doxbee-benchmark.svg](https://v8.dev/_img/fast-async/doxbee-benchmark.svg)

Диаграмма выше показывает замеры [doxbee бенчмарка](https://github.com/v8/promise-performance-tests/blob/master/lib/doxbee-async.js), который замеряет производительность нагруженного промисами кода. Обратите внимание, что график отображают время выполнения, то есть чем меньше, тем лучше.

Результат [параллельного бенчмарка](https://github.com/v8/promise-performance-tests/blob/master/lib/parallel-async.js),  который специально стрессует производительность `[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](https://v8.dev/_img/fast-async/parallel-benchmark.svg)

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

Всё же, оба микро-теста были синтетическими, а команда V8 больше заинтересована в том, как оптимизация повлияла на [реальную производительность пользовательского кода](https://v8.dev/blog/real-world-performance).

![https://v8.dev/_img/fast-async/http-benchmarks.svg](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](https://v8.dev/docs/turbofan), новый оптимизирующий компилятор 🎉
- [Orinoco](https://v8.dev/blog/orinoco), новый сборщик мусора 🚛
- Исправлению бага Node.js 8, при котором `await` пропускал микротики 🐛

Мы [запустили TurboFan](https://v8.dev/blog/launching-ignition-and-turbofan) в [Node.js 8](https://medium.com/the-node-js-collection/node-js-8-3-0-is-now-available-shipping-with-the-ignition-turbofan-execution-pipeline-aa5875ad3367), что дало огромный прирост в производительности.

Также поработали над новым сборщиком мусора 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:

![https://v8.dev/_img/fast-async/await-bug-node-8.svg](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](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](https://v8.dev/_img/fast-async/microtasks-vs-tasks.svg)

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

Больше подробностей вы можете найти в статье Джейка Арчибальда "[таски, микротаски, очереди и планировки в браузере](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/)". Модель задач в 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”](https://promisesaplus.com/) значениями, т.е. с любым объектом с методом `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` под капотом, следуя [спецификации](https://tc39.es/ecma262/#await). Возьмем простой пример асинхронной функции `foo`:

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

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

### `await` под капотом

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

![https://v8.dev/_img/fast-async/await-under-the-hood.svg](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](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](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](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](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](https://v8.dev/_img/fast-async/await-overhead.svg)

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

![https://v8.dev/_img/fast-async/await-code-before.svg](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](https://v8.dev/_img/fast-async/await-code-comparison.svg)

Эта операция вернет промис неизменным и обернет только необходимые значения. Таким путем вы избавитесь от одного дополнительного промиса и двух тиков, поскольку чаще всего в `await` мы передаем промис. Это поведение [доступно по умолчанию в V8, начиная с версии 7.2](https://v8.dev/blog/v8-release-72#async%2Fawait).

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

![https://v8.dev/_img/fast-async/await-new-step-1.svg](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](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](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/await-optimized.svg)

![https://v8.dev/_img/fast-async/node-10-vs-node-12.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](https://v8.dev/_img/fast-async/benchmark-optimization.svg)

## Заключение

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

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

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

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

---