Promise 同時実行制御のための async-pool

Published: 2023/9/30


JavaScript において Promise を生成するということは、基本的には何かしらの I/O コールを伴う処理を発火し、その完了を待たずに別の処理をしておく、ことを意味する。 並列処理できるものを並列に処理しないのは、 event loop 駆動で実行される想定の JavaScript としてはあまり意図しないことなので、普段ロジックを書くときには、並列に実行可能な Promise たちは、基本的には、並列に実行しておいて、その完了を例えば Promise.all などで待つことで、効率的でスピーディーなロジックを記述できる。

ここで、そのように I/O 処理を並列化して記述できるような場合で、例えば 100 や 1000 のオーダーで I/O を処理していかなければならない場合、それを一気に Promise として発火してしまって問題ないなのだっけ? という疑問が発生する。

これが例えばブラウザ上の JavaScript であれば、大体の I/O はつまるところ fetch によるネットワーク通信であり、これはブラウザ側が勝手に同時実行制限をかけるため、ひとまずはネットワーク通信が必要だと分かったタイミングで fetch を呼び出してしまってもそこまで問題がない。

Node.js の場合、 Node.js は汎用プログラム言語(としての JavaScript の実行機)の側面があるため、そういった制限などは、少なくとも Node.js 側においてはかけられていない。 I/O の処理を受け付ける OS も、今度は逆にアプリケーション側が要求するなら、と、指示された I/O 処理は片っ端から実行しようとする。 そのため、そのハードウェアが本来持っているスループット限界までは Promise 発火を並列化することで、処理の効率性は高まっていくが、それを越えると、逆に処理の渋滞(congestion)のようなことが発生し、プログラムの性能は低下していく。

そのような状況下における Promise 発火を制御する方法としては、例えば async-pool がある。 引数なしで呼び出すと Promise を返すような関数の iterable を引数に取り、 concurrency を指定することで、その concurrency 以内で Promise を順次発火してくれる。 さらに良いのは、その asyncPool 関数自体は Async Generator として実装されていて、 for await of 構文により、 pool として実行された関数の中から、完了したものから順に await で結果を取得していくことができる。

// 上記 Github README からの example 実装の抜粋

const timeout = ms => new Promise(resolve => setTimeout(() => resolve(ms), ms));

for await (const ms of asyncPool(2, [1000, 5000, 3000, 2000], timeout)) {
  console.log(ms);
}

処理としてハードウェアのリソースを競合して利用しそうな処理たちを並列実行する際には、この async-pool などで throttling を行えるようにしておくと、環境に応じて並列数を調整して、最適なパフォーマンスを出していけるようになり、良さげに感じられる。


Tags: javascriptnode.js

関連記事