Создание рекурсивной схемы обещаний в соображениях javascript - памяти

В этом ответе цепочка обещаний построена рекурсивно.

Упрощенный, мы имеем:

function foo() {
    function doo() {
        // always return a promise
        if (/* more to do */) {
            return doSomethingAsync().then(doo);
        } else {
            return Promise.resolve();
        }
    }
    return doo(); // returns a promise
}

Предположительно это приведет к возникновению стека вызовов и цепочки обещаний - то есть "глубокой" и "широкой".

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

  • Это так?
  • Кто-нибудь рассматривал проблемы памяти при построении цепи таким образом?
  • Будет ли потребление памяти различаться между обеими листами?

Ответ 1

стек вызовов и цепочку обещаний - то есть "глубокий" и "широкий".

Собственно, нет. Здесь нет никакой обездоленной цепочки, поскольку мы знаем ее от doSomeThingAsynchronous.then(doSomethingAsynchronous).then(doSomethingAsynchronous).… (что может сделать Promise.each или Promise.reduce для последовательного выполнения обработчиков, если оно было написано таким образом).

То, с чем мы здесь сталкиваемся, это цепочка решений 1 - что происходит в конце, когда встречается базовый случай рекурсии, что-то вроде Promise.resolve(Promise.resolve(Promise.resolve(…))). Это только "глубокий", а не "широкий", если вы хотите это назвать.

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

На самом деле это не шип. Вы постепенно, со временем, создаете большую часть promises, которые разрешаются с самой внутренней, все представляют один и тот же результат. Когда в конце вашей задачи условие выполняется, и внутреннее обещание разрешено с фактическим значением, все эти promises должны быть разрешены с одинаковым значением. Это приведет к стоимости O(n) для перехода по цепочке разрешений (если реализовать наивно, это может быть даже рекурсивно и вызвать переполнение стека). После этого все promises, кроме самого внешнего, могут стать собранными мусором.

Напротив, цепочка обещаний, созданная чем-то вроде

[…].reduce(function(prev, val) {
    // successive execution of fn for all vals in array
    return prev.then(() => fn(val));
}, Promise.resolve())

будет показывать всплеск, выделяя объекты n обеими одновременно, а затем медленно разрешать их один за другим, мусор собирает предыдущие, пока не будет только обещанное обещание конца.

memory
  ^     resolve      promise "then"    (tail)
  |      chain          chain         recursion
  |        /|           |\
  |       / |           | \
  |      /  |           |  \
  |  ___/   |___     ___|   \___     ___________
  |
  +----------------------------------------------> time

Это так?

Не обязательно. Как сказано выше, все promises в этом объеме в конечном итоге разрешены с тем же значением 2 поэтому нам нужно только сохранить самое внешнее и самое внутреннее обещание за один раз. Все промежуточные promises могут собираться как можно скорее, и мы хотим запустить эту рекурсию в постоянном пространстве и времени.

Фактически, эта рекурсивная конструкция абсолютно необходима для агастровых циклов с динамическим условием (без фиксированного количества шагов), вы не можете этого избежать. В Haskell, где это все время используется для монады IO, оптимизация для него реализуется только из-за этого случая. Он очень похож на рекурсия хвостового вызова, которая обычно устраняется компиляторами.

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

Да. Это было обсужденное в promises/aplus, но, тем не менее, не было результата.

Многие библиотеки обещаний поддерживают итерационные помощники, чтобы избежать цепочек обещаний then, таких как методы Bluebird each и map.

В моей собственной библиотеке обещаний 3,4 реализована цепочка решений без ввода служебных данных памяти или времени выполнения. Когда одно обещание принимает другое (даже если оно еще не принято), они становятся неразличимыми, а промежуточные promises больше не ссылаются нигде.

Будет ли потребление памяти различаться между обеими libs?

Да. Хотя этот случай можно оптимизировать, это редко бывает. В частности, спецификация ES6 требует promises для проверки значения при каждом вызове resolve, поэтому свертывание цепочки невозможно. promises в цепочке может даже быть разрешен с разными значениями (путем создания примерного объекта, который злоупотребляет геттерами, а не в реальной жизни). Проблема была поднята на esdiscuss, но остается нерешенной.

Итак, если вы используете протечки реализации, но нуждаетесь в асинхронной рекурсии, то лучше переключиться на обратные вызовы и использовать отложенных антипаттерн распространять сокровенную результат обещает единый результат.

[1]: нет официальной терминологии
[2]: ну, они разрешаются друг с другом. Но мы хотим разрешить их с одинаковой ценностью, мы ожидаем, что [3]: недокументированная детская площадка, проходит aplus. Прочтите код на свой страх и риск: https://github.com/bergus/F-Promise
[4]: также реализован для Creed в этот запрос на перенос

Ответ 2

Отказ от ответственности: преждевременная оптимизация плоха, реальный способ узнать о различиях в производительности - это сравнить ваш код, и вам не стоит беспокоиться об этом (мне нужно было только один раз и я 'w использовано promises для не менее 100 проектов).

Это так?

Да, promises должен "запомнить" то, за чем они следуют, если вы сделаете это для 10000 promises, у вас будет целая многообещающая цепочка в 10000, если вы 't тогда вы не будете (например, с рекурсией) - это верно для любого управления потоком очередей.

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

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

Конечно, это большая проблема и прецедент для использования чего-то типа Promise.each в таких библиотеках, как bluebird over then.

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

Будет ли потребление памяти различаться между обеими libs?

Да, сильно.. Например, bluebird 3.0 не будет выделять дополнительную очередь, если обнаруживает операцию обещания, уже асинхронную (например, если она начинается с Promise.delay) и будет просто выполнять вещи синхронно (потому что асинхронные гарантии уже сохранены).

Это означает, что то, что я утверждал в своем ответе на первый вопрос, не всегда верно (но верно в обычном случае использования):) Native promises никогда не сможет этого сделать, если не будет предоставлена ​​внутренняя поддержка.

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

Ответ 3

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

const powerp = (base, exp) => exp === 0 
 ? Promise.resolve(1)
 : new Promise(res => setTimeout(res, 0, exp)).then(
   exp => power(base, exp - 1).then(x => x * base)
 );

powerp(2, 8); // Promise {...[[PromiseValue]]: 256}

С помощью некоторых шагов замещения рекурсивная часть может быть заменена. Обратите внимание, что это выражение можно оценить в вашем браузере:

// apply powerp with 2 and 8 and substitute the recursive case:

8 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 8)).then(
  res => 7 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 7)).then(
    res => 6 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 6)).then(
      res => 5 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 5)).then(
        res => 4 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 4)).then(
          res => 3 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 3)).then(
            res => 2 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 2)).then(
              res => 1 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 1)).then(
                res => Promise.resolve(1)
              ).then(x => x * 2)
            ).then(x => x * 2)
          ).then(x => x * 2)
        ).then(x => x * 2)
      ).then(x => x * 2)
    ).then(x => x * 2)
  ).then(x => x * 2)
).then(x => x * 2); // Promise {...[[PromiseValue]]: 256}

Интерпретация:

  • С new Promise(res => setTimeout(res, 0, 8)) исполнитель вызывается немедленно и выполняет неблокирующее вычисление (подмечено с помощью setTimeout). Затем возвращается неустановленная Promise. Это эквивалентно doSomethingAsync() примера OP.
  • Обратный вызов разрешения связан с этим Promise через .then(.... Примечание. Тело этого обратного вызова было заменено телом powerp.
  • Точка 2) повторяется, и вложенная структура обработчика then создается до тех пор, пока не будет достигнут базовый случай рекурсии. Базовый регистр возвращает Promise с 1.
  • Вложенная структура обработчика then "разматывается", вызвав соответствующий обратный вызов соответственно.

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

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

Ответ 4

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

function foo() {
    function doo() {
        // always return a promise
        if (/* more to do */) {
            return doSomethingAsync().then(function(){
                        throw "next";
                    }).catch(function(err) {
                        if (err == "next") doo();
                    })
        } else {
            return Promise.resolve();
        }
    }
    return doo(); // returns a promise
}

Ответ 5

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

Чтобы проиллюстрировать это, я буду использовать небольшую библиотеку обещаний, называемую Sequence, которую я написал. Он полагается на рекурсию для обеспечения последовательного выполнения для цепочки функций:

var funcA = function() { 
    setTimeout(function() {console.log("funcA")}, 2000);
};
var funcB = function() { 
    setTimeout(function() {console.log("funcB")}, 1000);
};
sequence().chain(funcA).chain(funcB).execute();

Последовательность отлично работает для сетей малого и среднего размера в диапазоне 0-500 функций. Однако около 600 цепей Последовательность начинает деградировать и часто генерирует ошибки.

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

Это, конечно, не означает, что рекурсия promises плохая. Нам просто нужно использовать их с учетом их ограничений. Кроме того, вам редко приходится связывать много вызовов ( >= 500) с помощью promises. Обычно я использую их для асинхронных конфигураций, которые используют сильно ajax. Но даже если в самых сложных случаях я не видел ситуации с более чем 15 цепями.

На боковой ноте...

Эти статистические данные были получены из тестов, выполненных с другой из моих библиотек - provisnr - который фиксирует достигнутое количество вызовов функций в пределах заданный интервал времени.