Как запустить асинхронные циклы по порядку или параллельно в JavaScript?


Перед тем, как делать асинхронную магию, я хочу напомнить как выглядят классические синхронные циклы.


Синхронные циклы


Очень давно я писал циклы таким способом (возможно вы тоже):


for (var i=0; i < array.length; i++) {
  var item = array[i];
  // делаем что-нибудь с item
}

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


array.forEach((item) => {
  // делаем что-нибудь с item
});

Язык JavaScript развивается очень быстро. Появляются новые фичи и синтаксис. Одна из моих любимых улучшений это async/await.


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


Асинхронные циклы


Как использовать await в теле цикла? Давайте просто попробуем написать асинхронную функцию и ожидать задачу обработки каждого элемента:


async function processArray(array) {
  array.forEach(item => {
    // тут мы определили синхронную анонимную функцию
    // НО ЭТО КОД ВЫДАСТ ОШИБКУ!
    await func(item);
  })
}

Этот код выдаст ошибку. Почему? Потому что мы не можем использовать await внутри синхронной функции. Как вы можете видеть processArray — это асинхронная функция. Но анонимная функция, которую мы используем для forEach, является синхронной.


Что можно с этим сделать?


1. Не дожидаться результата выполнения


Мы можем определить анонимную функцию как асинхронную:


async function processArray(array) {
  array.forEach(async (item) => {
    await func(item);
  })
  console.log('Done!');
}

Но forEach не будет дожидаться выполнения завершения задачи. forEach — синхронная операция. Она просто запустит задачи и пойдет дальше. Проверим на простом тесте:


function delay() {
  return new Promise(resolve => setTimeout(resolve, 300));
}

async function delayedLog(item) {
  // мы можем использовать await для Promise
  // который возвращается из delay
  await delay();
  console.log(item);
}
async function processArray(array) {
  array.forEach(async (item) => {
    await delayedLog(item);
  })
  console.log('Done!');
}

processArray([1, 2, 3]);

В консоли мы увидим:


Done!
1
2
3

В некоторых ситуация это может быть нормальным результатом. Но всё же в большинстве вариантов это не подходящая логика.


2. Обработка цикла последовательно


Чтобы дождаться результата выполнения тела цикла нам нужно вернуться к старому доброму циклу "for". Но в этот раз мы будем использовать его новую версию с конструкцией for..of (Спасибо Iteration Protocol):


async function processArray(array) {
  for (const item of array) {
    await delayedLog(item);
  }
  console.log('Done!');
}

Это даст нам ожидаемый результат:


1
2
3
Done!

Каждый элемент массива будет обработан последовательно. Но мы можем запустить цикл параллельно!


3. Обработка цикла параллельно


Нужно слегка изменить код, чтобы запустить операции параллельно:


async function processArray(array) {
  // делаем "map" массива в промисы
  const promises = array.map(delayedLog);
  // ждем когда всё промисы будут выполнены
  await Promise.all(promises);
  console.log('Done!');
}

Этот код может запустить несколько delayLog задач параллельно. Но будьте аккуратны с большими массивами. Слишком много задач может быть слишком тяжело для CPU и памяти.


Так же, пожалуйста, не путайте "параллельные задачи" из примера с реальной параллельностью и потоками. Этот код не гарантирует параллельного исполнения. Всё завесит от тела цикла (в примере это delayedLog). Запросы сети, webworkers и некоторые другие задачи могуть быть выполнены параллельно.

Комментарии (18)


  1. epishman
    04.01.2019 20:25
    +1

    +, хотя, конечно, параллельный запуск с негарантированным порядком исполнения (не уверен) сложно назвать циклом :)


  1. zag2art
    04.01.2019 20:51

    ну а результаты Promise.all где?


    1. lavrton Автор
      04.01.2019 20:54

      В данном примере результов нет, так как delayedLog просто выводил в консоль. Но если нужны то:


      const results = await Promise.all(array.map(delayedLog););


      1. Enverest
        04.01.2019 00:22

        Как получить список ошибок из отклонённых промисов?


        1. Kolonist
          04.01.2019 13:00
          +1

          После первого же отклоненного промиса, выполнение приостанавливается. Ошибку вы сможете перехватить через try catch.


          1. Keyten
            04.01.2019 15:35

            А как быть, если хочется, чтобы все промисы выполнились даже если один реджектнулся?

            Я для этого писал функцию promiseResolveAll, но может быть, есть более хороший способ.

            async function promiseResolveAll(promises){
                if(promises.length === 0){
                    return [];
                }
                return new Promise(resolve => {
                    var results = [];
                    var processed = 0;
                    function onPromiseAnswer(type, i){
                        return value => {
                            results[i] = type ? {ok: true, value: value} : {ok: false, error: value};
                            if(++processed === promises.length){
                                resolve(results);
                            }
                        };
                    }
            
                    promises.forEach((promise, i) => {
                        promise.then(onPromiseAnswer(true, i)).catch(onPromiseAnswer(false, i));
                    });
                });
            }


            1. ilyapirogov
              04.01.2019 17:11
              +2

              Я бы ваш алгоритм как-то так реализовал бы:

              const promises = [
                Promise.resolve(1),
                Promise.reject('something wrong'),
                Promise.resolve(3),
              ];
              
              function fail(error) { 
                return {ok: false, error};
              };
              function success(value) { 
                return {ok: true, value};
              };
              
              const results = await Promise.all(
                promises.map(p => p.then(success).catch(fail))
              );
              // [{ok: true, value: 1}, {ok: false, error: "something wrong"}, {ok: true, value: 3}];
              


              1. mayorovp
                05.01.2019 13:16

                Только всё-таки p => p.then(success, fail). Это и короче, и семантичнее (мы перехватываем не общую ошибку цепочки p.then(success), а ошибку которая возникает в p).


            1. Serator
              04.01.2019 18:07

              Есть предложение для будущей реализации: github.com/tc39/proposal-promise-allSettled.


          1. mayorovp
            05.01.2019 13:13

            Каким это таким образом выполнение приостанавливается, когда все промисы — независимые?


            1. Kolonist
              05.01.2019 14:04

              Согласен, ввожу в заблуждение.

              Само по себе выполнение каждого из промисов продолжается, завершается лишь исполнение Promise.all() при первом же режекте.

              Примерно проследить за поведением Promise.all() можно на этом примере:

              'use strict';
              
              const sleep = (time, v, err = false) => new Promise((resolve, reject) => {
                  setTimeout(() => {
                      console.log(`start   ${v}, time ${time}`);
                      if (err) {
                          console.log(`reject  ${v}, time ${time}`);
                          return void reject(new Error(`REJECTED: ${v}`));
                      }
              
                      console.log(`resolve ${v}, time ${time}`);
                      resolve(`RESOLVED ${v}`);
                  }, time * 1000);
              });
              
              
              const promises = [
                  sleep(2, '1'),
                  sleep(1, '2'),
                  sleep(2, '3'),
                  sleep(4, '4', true),
                  sleep(3, '5'),
                  sleep(2, '6'),
                  sleep(1, '7', true),
                  sleep(5, '8'),
              ];
              
              
              console.log('Begin');
              Promise.all(promises)
                  .then(values => {
                      console.log(JSON.stringify(values, null, 4));
                  })
                  .catch(err => {
                      console.log(err);
                  })
                  .then(() => {
                      console.log('* Promise.all finished *');
                  });
              console.log('End');
              


              Результат исполнения будет примерно таким:
              % node test.js
              Begin
              End
              start   2, time 1
              resolve 2, time 1
              start   7, time 1
              reject  7, time 1
              Error: REJECTED: 7
              * Promise.all finished *
              start   1, time 2
              resolve 1, time 2
              start   3, time 2
              resolve 3, time 2
              start   6, time 2
              resolve 6, time 2
              start   5, time 3
              resolve 5, time 3
              start   4, time 4
              reject  4, time 4
              start   8, time 5
              resolve 8, time 5
              


        1. kahi4
          04.01.2019 13:23

          const results = await Promise.all(
              array
                .map(delayedLog)
                .map(promise => promise.catch(err => new SynteticError(e))));
          
          const withError = result.filter(e => e instanceof SynteticError);

          Некрасиво, да. try… catch тоже перестанет работать, потому что все ошибки будут обработаны. Но можно сделать обертку вроде promise.Any, который будет кидать массив exception.


  1. serf
    04.01.2019 21:45
    +1

    await array.reduce((accumulator, item) => accumulator.then(item)), Promise.resolve());
    


    1. mayorovp
      03.01.2019 23:03

      Вот не надо писать такого в туториалах, кто-то же может и запомнить!

      Трюк с reduce имел смысл только до появления await.


  1. Taraflex
    04.01.2019 03:12

    github.com/sindresorhus/p-filter
    github.com/sindresorhus/p-map
    И другие от этого автора полезны тем, что позволяют указать concurrency — число одновременно обрабатываемых элементов массива.


  1. fukkit
    04.01.2019 17:48
    +2

    Не понятно, в чем проблема использовать классический for, который, так случилось, с await'ами заходит лучше всего.

    for (var i=0; i < array.length; i++) {
        await func(array[i])
    }


    Не модно? Смузями заплюют? Замените var на let — пусть подавятся.


    1. vpotseluyko
      04.01.2019 18:52

      Абсолютно согласен. Если хочется быть модным-функциональным можно последовательно в редьюсе обрабатывать промисы


    1. bill876
      04.01.2019 20:09
      +2

      Это то же самое, что пример под номером 2 в статье, только с дополнительной переменной. Если вам нужна последовательная обработка каждого элемента массива, то используйте