web-dev-qa-db-ja.com

JavascriptでPromiseチェーンを再帰的に構築する-メモリの考慮事項

この答え では、Promiseチェーンが再帰的に構築されます。

少し簡略化して、次のようにします。

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
}

おそらく、これはコールスタックプロミスチェーン(つまり、「ディープ」と「ワイド」)を生じさせるでしょう。

再帰を実行するか、Promiseチェーンを単独で構築するよりも大きなメモリスパイクが予想されます。

  • そうですか?
  • 誰かがこのようにチェーンを構築することのメモリの問題を考えましたか?
  • Promiseライブラリ間でメモリ消費は異なりますか?
53
Roamer-1888

コールスタックとプロミスチェーン-つまり「ディープ」と「ワイド」。

実は違う。 doSomeThingAsynchronous.then(doSomethingAsynchronous).then(doSomethingAsynchronous).…(_Promise.each_または_Promise.reduce_からわかるように、ここにプロミスチェーンはありません。

ここで直面しているのは、resolve chain1 -再帰の基本ケースが満たされたときに、最終的に何が起こるかは、Promise.resolve(Promise.resolve(Promise.resolve(…)))のようなものです。あなたがそれをそれと呼びたいなら、これは「深い」だけで、「広い」ではありません。

再帰を実行したり、Promiseチェーンを単独で構築したりするよりも大きなメモリスパイクが予想されます。

実際にはスパイクではありません。時間をかけてゆっくりと、最も内側の約束で解決される約束の大部分を構築し、すべてが同じ結果を表すようにします。タスクの最後に条件が満たされ、最も内側の約束が実際の値で解決される場合、これらの約束はすべて同じ値で解決される必要があります。その結果、解決チェーンを上るのにO(n)コストがかかります(単純に実装された場合、これは再帰的に行われ、スタックオーバーフローを引き起こす可能性があります)。その後、最も外側のプロミスを除くすべてのプロミスがガベージコレクションになります。

対照的に、次のようなものによって構築されたプロミスチェーン

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

n promiseオブジェクトを同時に割り当て、スパイクを表示してから、1つずつゆっくりと解決し、解決されたend promiseのみが有効になるまで前のオブジェクトをガベージコレクションします。

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

そうですか?

必ずしも。上記で述べたように、そのバルク内のすべての約束は最終的に同じ値で解決されます2、したがって、必要なのは、最も外側の約束と最も内側の約束を一度に格納することです。すべての中間プロミスはできるだけ早くガベージコレクションされる可能性があるため、この再帰を一定の空間と時間で実行する必要があります。

実際、この再帰的な構成は、 非同期ループ に動的条件(固定数のステップなし)で完全に必要です。実際に回避することはできません。 Haskellでは、これがIOモナドで常に使用されますが、この場合のために最適化が実装されています。 tail call recursion と非常によく似ていますが、これはコンパイラによって定期的に削除されます。

誰かがこのようにチェーンを構築することのメモリの問題を考えましたか?

はい。これは promises/aplusで説明 でしたが、結果はまだありませんでした。

多くのpromiseライブラリは、Bluebirdのthenおよびeachメソッドのように、promise mapチェーンのスパイクを回避するために反復ヘルパーをサポートします。

私自身の約束ライブラリ3,4 メモリやランタイムのオーバーヘッドを発生させることなく、チェーンを解決する機能を備えています。あるプロミスが別のプロミスを採用すると(まだ保留中であっても)区別がつかなくなり、中間プロミスはどこからも参照されなくなります。

Promiseライブラリ間でメモリ消費は異なりますか?

はい。このケースは最適化できますが、めったにありません。特に、ES6仕様では、Promisesがresolve呼び出しごとに値を検査する必要があるため、チェーンを折りたたむことはできません。チェーン内のプロミスは、異なる値で解決されることさえあります(実際の生活ではなく、ゲッターを悪用するサンプルオブジェクトを構築することによって)。問題 esdiscussで発生しました ですが、未解決のままです。

したがって、リークしている実装を使用しているが、非同期再帰が必要な場合は、コールバックに切り替えて deferred antipattern を使用し、最も内側のプロミスの結果を単一の結果プロミスに伝播する方が良いでしょう。

[1]:公式用語なし
[2]:まあ、それらはお互いに解決されます。しかし、我々は同じ値でそれらを解決したいwant、我々はexpect
[3]:文書化されていない遊び場、プラスを渡します。あなた自身の危険でコードを読んでください: https://github.com/bergus/F-Promise
[4]: このプルリクエスト のクリードにも実装されています

44
Bergi

免責事項:早すぎる最適化は悪いです。パフォーマンスの違いを見つける実際の方法はコードをベンチマークするで、これについて心配する必要はありません(一度だけ、少なくとも100のプロジェクトで約束を使用しました)。

そうですか?

はい、約束は彼らがフォローしているものを「覚えておく」必要があります。10000の約束に対してこれを行うと、10000の長い約束の連鎖ができます。 (たとえば、再帰を使用)-これは、キューイングフロー制御に当てはまります。

10000個の余分なもの(操作)を追跡する必要がある場合、そのためのメモリを保持する必要があり、時間がかかります。その数が100万である場合、実行できない可能性があります。これはライブラリによって異なります。

誰かがこのようにチェーンを構築することのメモリの問題を考えましたか?

もちろん、これは大きな問題であり、Promise.eachthenableチェーン上のbluebirdのようなライブラリ内。

私は個人的にコード内で、VM一度だけすべてのファイルを横断するクイックアプリのこのスタイルを避けるために持っていました-しかし、ほとんどの場合、それは問題ではありません。

Promiseライブラリ間でメモリ消費は異なりますか?

はい、大きくたとえば、bluebird 3.0は、promise操作が既に非同期であると検出した場合(たとえば、Promise.delayで開始した場合)、余分なキューを割り当てません。非同期の保証は既に保持されています)。

これは、最初の質問の答えで主張したことが必ずしも真実ではないことを意味します(ただし、通常のユースケースでは真実です)。内部サポートが提供されない限り、ネイティブプロミスはこれを行うことができません。

繰り返しますが、promiseライブラリは互いに桁違いに異なるため、驚くことではありません。

15

私はちょうど問題を解決するのに役立つかもしれないハックを出しました:最後の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
dotslashlu

素晴らしい既存の答えを補完するために、このような非同期再帰の結果である式を説明したいと思います。簡単にするために、特定の底と指数のべき乗を計算する単純な関数を使用します。再帰的なケースと基本的なケースは、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}
_

解釈:

  1. new Promise(res => setTimeout(res, 0, 8))を使用すると、エグゼキュータはすぐに呼び出され、非ブロック計算(setTimeoutで模倣)を実行します。その後、未処理のPromiseが返されます。これは、OPの例のdoSomethingAsync()と同等です。
  2. 解決コールバックは、_.then(..._を介してこのPromiseに関連付けられます。注:このコールバックの本文は、powerpの本文に置き換えられました。
  3. ポイント2)が繰り返され、ネストされたthenハンドラー構造が、再帰の基本ケースに達するまで構築されます。基本ケースは、_1_で解決されたPromiseを返します。
  4. ネストされたthenハンドラー構造は、関連するコールバックをそれに応じて呼び出すことにより「巻き戻され」ます。

生成された構造がネストされ、連鎖されないのはなぜですか? thenハンドラー内の再帰的なケースにより、ベースケースに達するまで値を返すことができないためです。

これはスタックなしでどのように機能しますか?関連するコールバックは「チェーン」を形成し、メインイベントループの連続するマイクロタスクを橋渡しします。

1
user6445533

このpromiseパターンは、再帰チェーンを生成します。そのため、resolve()はそれぞれ、メモリを使用して(独自のデータを使用して)新しいスタックフレームを作成します。これは、このpromiseパターンを使用する多数の連鎖関数がスタックオーバーフローエラーを引き起こす可能性があることを意味します。

これを説明するために、小さな 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チェーンでSequenceが劣化を開始し、スタックオーバーフローエラーを生成することがよくあります。

一番下の行は:currently、再帰ベースのpromiseライブラリは小規模/中規模の関数チェーンにより適していますが、reduceベースのpromiseの実装はより大きなチェーンを含むすべてのケース。

もちろん、これは再帰ベースの約束が悪いことを意味するものではありません。制限を念頭に置いて使用するだけです。また、promiseを介してその数の呼び出し(> = 500)をチェーンする必要があることはまれです。私は通常、Ajaxを多用する非同期構成にそれらを使用していることに気付きます。しかし、最も複雑なケースであっても、チェーンが15を超える状況は見ていません。

サイドノートで...

これらの統計は、別のライブラリ( provisnr -)で実行されたテストから取得され、指定された時間内に達成された関数呼び出しの数をキャプチャします。

0
neatsu