Всё, что нужно знать об async/await. Циклы, контроль потоков, ограничения
В этой статье вы узнаете как использовать async/await в JavaScript, применять в них циклы и контролировать поток.
С момента своего официального дебюта в 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
это таймауты в промисах. Это эффективно и необходимо, особенно тогда, когда используется в контексте цикла (смотрите отложенный цикл ниже), мы можем оборачивать обычные таймеры (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");
})();