かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

JavaScript async / await で並行処理

前回のあらすじ

直列処理

async / await 、非同期処理を順番に実行(直列処理)を簡単に書くことができます。

async function sleepSquareFunc(x) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve( x * x );
    }, 1000); 
  });
}

async function sumSerialFunc() {
  const a = await sleepSquareFunc(2);
  const b = await sleepSquareFunc(4);
  const c = await sleepSquareFunc(6);
  return (a + b + c) * 2;
}
const sum = serialFunc();
// => sum: 112

上記の例ではa, b, cを順番に求めますが、awaitは処理を止めて結果が返されるのを待つので最終的な結果を返すまでに3秒は待つ必要があります。
awaitさせる関数に関連性があって順番に処理を行う必要があるのであれば問題ないのですが、上記のような関連性が無い非同期処理なら逐一awaitで待たせて処理をする必然性がありません。

並行処理

Promise.allを使う
async functionはPromiseを返す関数なので、並行処理したいものはPromise.allで取得してしまえばOK。

async function sleepSquareFunc(x) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve( x * x );
    }, 1000); 
  });
}

async function sumParallelFunc() {
  const a = sleepSquareFunc(2);
  const b = sleepSquareFunc(4);
  const c = sleepSquareFunc(6);
  const [aa, bb, cc] = await Promise.all([a, b, c]);
  return (aa + bb + cc) * 2;
}
const sum = serialFunc();
// => sum: 112

非同期処理をループで扱う

ループの際にawaitが処理を止めることを理解しておかないと、意図せず上記の直列処理にしてしまいパフォーマンスの悪いコードになってしまう可能性があります。

ループ内でawaitを使用して直列処理になってしまうパターン

async function calculationByLoop(arg) {
  let sum = 0
  for(let v of arg) {
    let squareV = await sleepSquareFunc(v);
    // 直列処理する必要はないが 1000ms 待機してしまう
    console.log(squareV);
    sum += squareV * 2;
  }
  const endAt = performance.now();
  return sum;
}
const sum = calculationByLoop([2, 4, 6]);
// => sum: 112

Array.forEach は何も返さないのでawaitに使えない

Array.prototype.forEachundefinedを返すのでasync / awaitで使うことができない

async function calculationForEach(arg) {
  let sum = 0;
  const f = await arg.forEach( async (val) => {
    const squareV = await sleepSquareFunc(val);
    sum += squareV * 2;
  });
  console.log(f); // => undefined
  return sum;
}
const sum = calculationForEach([2, 4, 6]);
// => sum: 0

forEachのループが終わるまで待機されることがないので結果を待つ前にsumが返される。

Array.reduce はreturnする値のawaitの付け方次第で並列処理のように扱われるっぽい

配列の値に手を加えて合算するような場合であれば、Array.prototype.reduceは値を返すので、内部のコールバックにasync, awaitを使えば、次のループに引数としてPromiseなオブジェクトが渡り並行処理のように処理できるようです。(試してみるまでてっきり直列処理になるものだと思っていました。)

async function calculationByReduce(arg) {
  const sum = await arg.reduce( async (x, add) => {
    const a = await sleepSquareFunc(add);
    console.log('reduce', x, a);
    // return 値にawait を付けないと、待たずに次のループに入ってしまう
    return await x + a * 2;
  }, 0);
  return sum;
};
const sum = calculationByReduce([2, 4, 6]);
// reduce 0 4
// reduce Promise { 8 } 16
// reduce Promise { 40 } 36
// => sum: 112

reduceループに渡される引数がPromiseオブジェクトになり値が戻されるまでプレースホルダーのように計算結果を待ちつつも、ループ処理そのものはawaitせずに先に進むので結果的に並行処理のようになっているという印象です。
なので、reduce内でreturnする値にどうawaitをつけるかによっては直列処理のようになったり、値が返されるまで待たないものになったりする可能性もありそうです。

Array.mapで配列を展開してPromise.allに渡す

Promiseを返す処理にArray.prototype.mapで展開してしまい、Promise.allに渡してしまえば、それぞれの非同期処理を並行処理することができます。

async function calculationParallel(arg) {
  const all = await Promise.all( arg.map( async (val) => {
    const squareV = await sleepSquareFunc(val);
    return squareV * 2;
  } ) );
  const sum = all.reduce((x, y) => x + y);
  return sum;
};
const sum = calculationParallel([2, 4, 6]);
// => sum: 112

取得される値が配列になり、そこからループで処理などが必要になるので処理的にはreduceより多くなると思いますが、 個人的にPromise.allにしたほうが見通しは良いかなという印象です。

ただしPromise.allは渡された処理の1つでもrejectになると、その場でrejectとして返されてしまうので、並行処理して取得できた値だけでゴニョるような用途の場合は呼び出す関数がrejectを返さないようにする工夫が必要になりそうです。
reject含め全ての処理が終わるまで並行処理で待つ関数が欲しい。

おまけ

アロー関数でasync functionを書く時はこんな感じ

const functionName = async () => { ... }

async はあくまで function の前につけるキーワード。


[参考]