«Перевод» Более быстрые асинхронные функции и обещания

Node.js JavaScript Promise V8
«Перевод» Более быстрые асинхронные функции и обещания

Переведено с:Faster async functions and promises

Асинхронный процесс JavaScript всегда считался недостаточно быстрым, и, что еще хуже, отладка в требовательных сценариях в реальном времени, таких как NodeJS, превращается в кошмар. Однако это меняется, и в этом посте подробно объясняется, как мы оптимизировали асинхронные функции и промисы в движке V8 (а также в некоторых других движках), а также о сопутствующей оптимизации процесса разработки.

Советы:вотвидео, вы можете прочитать его вместе со статьей.

Новый подход к асинхронному программированию

От обратных вызовов к обещаниям и асинхронным функциям

До того, как промисы официально стали частью стандарта JavaScript, обратные вызовы широко использовались в асинхронном программировании.Вот пример:

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 по-прежнему является однопоточным, а асинхронные функции не открывают новые потоки.)

Обратный вызов от прослушивателя событий к асинхронному итератору

В NodeJSReadableStreamsАсинхронный как другая форма также особенно распространен, вот пример:

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

Вы можете поместить всю логику обработки данных в асинхронную функцию.for await…ofперебирать куски, а не по отдельности'data'а также'end'Он обрабатывается в обратном вызове, и мы также добавилиtry-catchблокировать, чтобы избежатьunhandledRejectionвопрос.

Вы можете использовать эти функции в своей среде сборки уже сегодня! асинхронная функцияПолностью поддерживается, начиная с Node.js 8 (V8 v6.2/Chrome 62)., асинхронный итераторПоддерживается с Node.js 10 (V8 v6.8/Chrome 68).

асинхронная оптимизация производительности

От V8 v5.5 (Chrome 55 и Node.js 7) до V8 v6.8 (Chrome 68 и Node.js 10) мы работали над оптимизацией производительности для асинхронного кода, пока результаты довольно хорошие, вы можете использовать их с уверенностью Новые функции.

выше этотест доксби, используемый для отображения производительности при интенсивном использовании промисов Вертикальная ось на рисунке представляет время выполнения, поэтому чем меньше, тем лучше.

с другой стороны,параллельный тестРеагирует на интенсивное использованиеPromise.all()производительность, результаты следующие:

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

Тогда приведенный выше тест - это всего лишь небольшой тест уровня DEMO, команда V8 больше беспокоится оЭффект оптимизации фактического пользовательского кода.

Вышеприведенные тесты основаны на популярных на рынке HTTP-фреймворках, в которых активно используются промисы иasyncФункция, эта таблица показывает количество запросов в секунду, поэтому он отличается от предыдущей таблицы, тем больше значение. Из таблицы можно увидеть, что есть много производительности от Node.js 7 (v8 v5.5) до node.js 10 (v8 v6.8).

Прирост производительности зависит от трех факторов:

  • TurboFan, новый оптимизирующий компилятор 🎉
  • Orinoco, новый сборщик мусора 🚛
  • Ошибка Node.js 8 заставляла await пропускать некоторые микротики 🐛

когда мы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В результате две цепи были скованы одновременно.then, финалconsole.logЧто получится в результате печати?

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

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

Это «правильное поведение» не кажется нормальным и даже удивляет многих разработчиков JavaScript, и его стоит подробно объяснить. Прежде чем объяснять, давайте начнем с некоторых основ.

Задачи против микрозадач

На одном уровне в JavaScript есть задачи и микрозадачи. Задачи обрабатывают события, такие как ввод-вывод и таймеры, по одному. Микрозадачи предназначены дляasync/awaitОн разработан с отложенным выполнением промисов, и каждая задача выполняется последней. Очередь микрозадач очищается перед возвратом в цикл обработки событий.

Доступно через Джейка Арчибальдаtasks, microtasks, queues, and schedules in the browserпонять больше. Модель задач в Node.js очень похожа.

асинхронная функция

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

Простейшая асинхронная функция:

async function computeAnswer() {
  return 42;
}

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

const p = computeAnswer();
// → Promise

p.then(console.log);
// prints 42 on the next turn

Вы можете получить промис только после выполнения следующей микрозадачи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поставить один позадиPromise, но на самом деле за ним может следовать любое значение JavaScript.Если за ним не следует промис, он будет преобразован в промис, поэтомуawait 42Эффект следующий:

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

const p = foo();
// → Promise

p.then(console.log);
// prints `42` eventually

Более интересно,awaitможет сопровождаться любым"тогда", например, любой, содержащий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Назначено выполненному обещанию, и, наконец, async вернул это значение.

таинственныйawait

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

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

просто скажи,await vЭтапы инициализации состоят из следующего:

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

Давайте рассмотрим это шаг за шагом, предполагая, чтоawaitза которым следует обещание, а значение конечного завершенного состояния равно42. Затем движок создает новыйpromiseи положиawaitЗначение после него используется как значение разрешения. с помощью стандартаPromiseResolveThenableJobЭти обещания будут выполнены в следующем цикле.

Затем движок создает другой, называемыйthrowawayобещать. Он назван так, потому что больше ничего с ним не связывает, он предназначен только для внутреннего использования в движке.throwawayОбещание будет привязано к обещанию с обработчиком восстановления.promiseначальство. здесьperformPromiseThenОперация на самом деле внутреннеPromise.prototype.then(). В конце концов, асинхронная функция приостанавливается и передает управление вызывающей стороне.

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

затем еще одинPromiseReactionJob, ждем мыawait(имеется в виду здесь42)этоpromiseсделано, затем запланируйте это действие наthrowawayв обещании. Механизм продолжает возвращаться к очереди микрозадач, потому что осталась последняя микрозадача.

Теперь эта секундаPromiseReactionJobсообщить о решенииthrowawayобещание и возобновить выполнение асинхронной функции, наконец, вернувшись изawaitпринадлежит42.

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

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

Итак, здесь вы можете использоватьpromiseResolveОперации обрабатываются, а промисы инкапсулируются только при необходимости:

Если входной параметр является промисом, он будет возвращен без изменений, и будут инкапсулированы только необходимые промисы. Эта операция сохраняет дополнительное обещание и две микрозадачи, когда значение уже является обещанием. Эта функция может быть--harmony-await-optimizationПараметр включен в V8 (начиная с V7.1), и мыЯ сделал предложение Ecmascript, который, как ожидается, скоро будет объединен.

Ниже приведен упрощенныйawaitПроцесс реализации:

спасибо удивительноpromiseResolve, теперь нам просто нужно пройтиvПросто не важно, что это такое. Затем, как и раньше, движок создаетthrowawayобещай и ставьPromiseReactionJobЧтобы возобновить асинхронную функцию на следующем тике, она приостанавливает функцию и возвращается вызывающей стороне.

Когда все выполнения будут завершены в конце, движок запустит очередь микрозадач и выполнитPromiseReactionJob. Эта задача пройдетpromiseрезультат даетthrowawayи восстановить асинхронную функцию изawaitполучать42.

Несмотря на внутреннее использование, двигатель создаетthrowawayОбещания могут все еще казаться неправильными. Оказывается, чтоthrowawayобещания только для удовлетворения спецификацииperformPromiseThenпотребности.

Недавно это было предложено ECMAScript.изменять, двигатель больше не должен создавать большую часть времениthrowaway.

В сравненииawaitПроизводительность на Node.js 10 и оптимизированная (следует поставить на Node.js 12):

async/awaitПревосходит написанный от руки код обещания. Суть в том, что благодаря этому мы уменьшили некоторые ненужные накладные расходы в асинхронной функции не только в движке V8, но и в других движках JavaScript.пластырьОптимизировано.

Оптимизация опыта разработки

Помимо производительности, разработчики JavaScript также очень озабочены поиском и устранением проблем, что никогда не было легко в асинхронном коде.Chrome DevToolsТеперь поддерживаются асинхронные трассировки стека:

Это полезная функция при локальной разработке, но она бесполезна после развертывания приложения. При отладке видно только в лог-файлеError#stackинформации, они не содержат никакой асинхронной информации.

Что мы делали недавноАсинхронная трассировка стека с нулевой стоимостьюсделатьError#stackСодержит информацию о вызовах для асинхронных функций. «Нулевая стоимость» звучит захватывающе, не так ли? Как функция Chrome DevTools может обеспечить нулевую стоимость, если она сопряжена со значительными накладными расходами? Например,fooвызыватьbar,barВыбросить исключение после ожидания обещания:

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Его нет в самой трассировке стека. Если приложение развернуто в облачном контейнере, разработчикам может быть сложно найти проблему.

Интересно, что двигатель знает об этом.barЧто должно продолжать выполняться после окончания: т.е.fooв функцииawaitназад. Вот именно, вотfooМесто для паузы. Механизм может использовать эту информацию для восстановления асинхронных трассировок стека. С приведенными выше оптимизациями вывод будет выглядеть следующим образом:

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

В трассировке стека сначала появляется функция верхнего уровня, за которой следуют несколько стеков асинхронных вызовов, а затемfooвbarИнформация о стеке контекста. Эта функция может быть включена через V8.--async-stack-tracesпараметр включен.

Однако, если вы сравните информацию о стеке в Chrome DevTools выше, вы увидите, что асинхронная часть трассировки стека отсутствует.fooинформация о точке вызова. используется здесьawaitПозиция возобновления и паузы — это одна и та же функция, ноPromise#then()илиPromise#catch()Не так. См. статью Матиаса Байненса.await beats Promise#then()понять больше.

В заключение

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

  • Убраны две лишние микрозадачи
  • удаленныйthrowaway promise

Кроме того, мы проходимАсинхронная трассировка стека с нулевой стоимостьюпродвигаетсяawaitа такжеPromise.all()Опыт разработки и отладки.

У нас также есть несколько советов по повышению производительности, удобных для разработчиков JavaScript:

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

Статья может быть воспроизведена по желанию, но просьба сохранитьОригинальная ссылка. Добро пожаловать!ES2049 Studio, отправьте свое резюме на caijun.hcj(at)alibaba-inc.com.