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

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

Полное понимание синхронного и асинхронного JavaScript с Async/Await

Как избежать async/await ада

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

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

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 это таймауты в промисах. Это эффективно и необходимо, особенно тогда, когда используется в контексте цикла (смотрите отложенный цикл ниже), мы можем оборачивать обычные таймеры (setTimeout, setImmediate) в промисы, например как тут:

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");
})();