Подписывайтесь на мой твиттер, там всегда что-нибудь интересное!

Всё, что нужно знать об async/await. Циклы, контроль потоков, ограничения

В этой статье вы узнаете как использовать async/await в JavaScript, применять в них циклы и контролировать поток.

Перевод статьи Async/Await Essentials for Production: Loops, Control Flows & Limits

С момента своего официального дебюта в ES8, async/await стал главным нововведением в плане будущего JavaScript. Каждый день новые NPM пакеты начинают поддерживать промисы, промисы которые всё ещё будучи новыми, приводят нас к относительно старому синтаксису ( С# помните?), в наши дни вы даже можете найти util.promisify в ядре Node.js, который позволяет упростить конверсию колбэков в промисы.

Советую предварительно прочитать статью — Полное понимание синхронного и асинхронного JavaScript с Async/Await

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

Основы

Перед тем как мы затронем темы параллельной работы и лимитирования в циклах, давайте вернёмся к основам. Всем известный паттерн async/await представляет собой простое ожидание промиса, получение значения и продолжение функции, давайте рассмотрим момент, когда нам надо получить данные пользователя и основываясь на объекте пользователя, получить конкретный новостной поток, это может быть легко реализовано с помощью добавления async нашей функции getUser и последующего использования await перед каждым запросом функции, возвращающей промис:

const getUser = async (query) => {
  const user = await Users.findOne(query);
  const feed = await Feeds.findOne({ user: user._id });
  
  return { user, feed };
};

// getUser will return a promise
getUser({ username: 'test' }).then(...);

Простой пример использования, который бы сработал на любой промисообразной функции или библиотеке, но что если мы хотим использовать старые добрые колбэки с промисами? Если вы уже применяли petkaantonov/bluebird, то вы возможно уже знакомы методом promisify, к большой радости, если вы используете Node 8.0 и выше, то вы можете сэкономить несколько минут своего времени и не устанавливать этот модуль, а вместо этого использовать схожий функционал, доступный уже в самом Node, а именно Util#promisify, который конвертирует error first колбэки (Это колбэки, которые передают ошибку и данные, первый аргумент сохраняется за объектом ошибки, то есть если произойдёт ошибка, то он вернётся с первым аргументом err. А за вторым аргументом колбэка сохраняются данные любой успешной операции, при этом err будет выставлен на null и все данные будут переданы во втором аргументе)

const fs = require('fs');
const { promisify } = require('util');

// Function#bind as needed
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);

const transform = () => /* ... */;

const transformFileAsync = async (source, dest) => {
  const data = await readFile(source);
  // Делаем некоторое изменение 
  const transformedData = tranform(data);
  
  await writeFile(dest, transformedData);
};

transformFileAsync('x.js', 'y.js').then(...).catch(...);

Обратите внимание, что с v9.4.0, возвращение нескольких аргументов с Util#promisify доступно только внутри Node и если вы находитесь вне среды Node.js, то вы можете применить Bluebird, а именно Promise#promisify, как и указывалось выше.

Обработка ошибок

Один факт, который вы должны помнить, невзирая на некоторые неверные представления, бушующие вокруг этой темы — async/await это не просто синтаксический сахар промисов, он ещё и несёт в себе свойства промисов, которые могут очень помочь при дебаггинге. Одним из огромных преимуществ в плане дебаггинга и возможностей обработки ошибок является всемогущий, но навряд ли когда-либо кем-то любимый try/catch блок. Отлов синхронных и асинхронных ошибок внутри одного блока кода ещё никогда не был так прост.

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

const PromiseThatThrows = (message) => new Promise((resolve, reject) => {
  // Появится после 100мс с новой ошибкой
  setTimeout(() => reject(new Error(message))), 100);
};

Мы можем ловить ошибки из этого промиса в async функции, используя обычный try/catch блок, хоть следующая строка выкинет синхронную ошибку, она никогда не будет сработана:

const throwsLater = async () => {
  await PromiseThatThrows('Some error');
};

async () => {
  try {
    // Обратите внимание, что без await
    // ошибка никогда не будет
    // отловлена
    await throwsLater;
  } catch (error) {
    console.log(error.message);
    // Выведет: Some error
  }
}

Как и с catch в промисе, вы можете отловить ошибку, происходящую в потоке:

const throwsLater = async () => {
  await PromiseThatThrows('Some error');
};

async () => {
  try {
    // Обратите внимание, что без await
    // ошибка никогда не будет
    // отловлена
    await throwsLater;
  } catch (error) {
    console.log(error.message);
    // Выведет: Some error
  }
}

Контроль потока

Запускаете ли вы таски параллельно, планируете цикл или создаёте каскадируемую структуру или пайплайн, async/await может упростить то, во что превращается ваш процесс, в коде с эффективным и читаемым контролем потока. Давайте пробежимся по популярным паттернам:

1. Параллельное выполнение

Нет конкретного синтаксиса для параллельного выполнения с помощью async/await, но мы можем применить Promise#all с массивом промисов, чтобы получить ожидаемые результаты:

const [user1, user2] = await Promise.all([db.get('user1'), db.get('user2')]);

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

2. Таймауты

Пожалуй самый малоизвестный герой саги об async/await это таймауты в промисах. Это эффективно и необходимо, особенно тогда, когда используется в контексте цикла (смотрите отложенный цикл ниже), мы можем оборачивать обычные таймеры (setTimeoutsetImmediate) в промисы, например как тут:

const immediatePromise = () => new Promise((resolve) => setImmediate(resolve));

const timeoutPromise = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout));

Мы можем пустить эти функции в дело, создав асинхронную паузу (без блокирования процесса), между двумя функциями, вне зависимости от того синхронные они или асинхронные:

const z = async () => {
  await x();
  await timeoutPromise(1000); // Wait 1 second
  
  //  Можете рекурсировать z, если надо
  return y();
}

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

Циклы

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

1. Последовательный цикл

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

async (items) => {
  for (let i = 0; i < items.length; i++) {
    const result = await db.get(items[i]);
    console.log(result);
  }
}

2. Отложенный цикл

Мы можем смело применить концепцию таймаутов в нашем цикле, для примера, если нам нужно создать метод, который бы добавлял произвольное число в массив каждую секунду на протяжении десяти секунд, мы бы могли использовать setTimeout и setImmediate с счетчиком или цикл ожидающий timeoutPromise, который вы увидите ниже:

const randForTen = async () => {
  let results = [];
  
  for (let i = 0; i < 10; i++) {
    await timeoutPromise(1000);
    results.push(Math.random());
  }
  
  return results;
}

И мы можем уйти ещё дальше, применив условные операторы с setInterval, на примере цикла while:

const runFor = async (time, func, interval) => {
  // Запуск интервала до истечения времени
  while (time > Date.now()) {
    await timeoutPromise(interval);
    //  Обратите внимание, что вам нужно учесть
    //  время выполнения функции func()
    //  если она асинхронная
    func();
  }
};

runFor(Date.now() + 2000, () => console.count('time'), 1000);
// Вывод:
// time: 1
// time: 2

3. Параллельный цикл

Если это работает параллельно, делайте это параллельно. Параллельные циклы могут быть созданы добавлением промиса в массив. Сам промис при этом обратится в значение, следовательно все промисы запустятся в одно и тоже время и каждый будет иметь своё время для завершения, конечные результаты будут автоматически сгруппированы Promise#all:

async (items) => {
  let promises = [];
  
  for (let i = 0; i < items.length; i++) {
    promises.push(db.get(items[i]));
  }
  
  const results = await Promise.all(promises);
  console.log(results);
}

Это можно сделать куда более элегантно с Array#map и это куда более функциональнее для применения в реальных кейсах, делая маппинг массива элементов в массив промисов и ожидая их значение.

async (items) => {
     // Помните, что асинхронные функции возвращают промисы
  const promises = items.map(async (item) => {
    const result = await db.get(item);
    // Вывод в консоль результатов по их завершению
    console.log(result);
    return result;
  });
  const results = await Promise.all(promises);
  console.log(results);
}

Ограничения промисов и race’ы

Ещё одной не очень изученной темой является выставление ограничений для контроля общего количества выполняемых задач параллельно с async/await. Если вы заядлый пользователь coalan/async, то скорее всего уже использовали Async#parallelLimit или Async#eachLimit, но не пугайтесь, установка ограничений возможна. Сейчас мы вернёмся к нашей магии промисов и начнём гонку (от promise.race())!

Promise#race возвратит промис, который решится в тот момент, когда первый элемент из заданного списка промисов будет полностью выполнен.

Подробно про промисы можно прочитатть в этой статье — Промисы для чайников

1.Простой race

Простой race можно создать, передав список промисов, который в нашем случае, вернёт асинхронную функцию, которая решится за произвольное количество времени:

const randAsyncTimer = (i) => {
  //  Таймаут в одну секунду
  const timeout = Math.floor(Math.random() * 1000);
  return new Promise((resolve) => setTimeout(() => resolve(i), timeout));
};

async () => {
  let calls = [randAsyncTimer(1), randAsyncTimer(2), randAsyncTimer(3)];
   // Начинаем гонку
  const result = await Promise.race(calls);
  console.log(result);
  //  Возможные выводы будут от 1 до 3
}

Первый решенный промис и победит в гонке, став результатом выведенным в консоль.

2. Выставляем ограничения

Мы можем использовать почти всё, что мы обсудили выше, для создания функции, которая выполняет другую асинхронную функцию параллельно с заданным лимитом. Реальным примером тут будет процесс создания списка скриншотов страниц, но только 5 за раз.

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

async (promises) => {
  let inFlight = new Set();
  
  return promises.map((promise) => {
    // Добавляет промис в inFlight Set
    inFlight.add(promise);
    // Удаляет промис из Set, когда тот решён
    promise.then(() => inFlight.delete(promise));
  });
}

Далее мы применим Set.size, чтобы проверить общее число запущенных промисов, это позволит нам определить сколько ещё итераций нашего цикла мы можем продолжить.

Далее, мы используем Promise#race как часть нашего арсенала по контролю потока. Что нам нужно, так это способ остановить итерирование цикла (в этом случае Array#map) до момента, пока следующий промис будет решён (мы будем использовать race для этого) и проверить то каково количество запущенных промисов и не превышает ли оно нужный нам лимит, если да, то мы применим ещё один race. Это довольно легко сделать при помощи следующего while цикла:

while(inFlight.size >= limit) {
  await Promise.race(inFlight);
}

Объединив оба, мы сможем закончить с parallelLimit:

const parallelLimit = async (funcList, limit = 10) => {
  let inFlight = new Set();
  
  return funcList.map(async (func, i) => {
    //  Придерживаем цикл, другим циклом
    //  пока следующий промис решается
    while(inFlight.size >= limit) {
      await Promise.race(inFlight);
    }
    
    console.log(`STARTING ROUND->${i} SIZE->${inFlight.size}`);
    
    const promise = func();
    // Add promise to inFlight Set
    inFlight.add(promise);
    //  Добавляем промис inFlight Set
    await promise;
    inFlight.delete(promise);
  });
};
(async () => {
  const timeoutPromise = (timeout) => {
    return new Promise((resolve) => setTimeout(resolve, timeout));
  };
  const waitTwoSeconds = async () => await timeoutPromise(2000);
  const promises = await parallelLimit([
    waitTwoSeconds,
    waitTwoSeconds,
    waitTwoSeconds,
    waitTwoSeconds,
    waitTwoSeconds
  ], 2);
  
  await Promise.all(promises);
  console.log("DONE");
})();