async/awaitで配列要素の非同期処理を書く

二年ぶりぐらいにNodeを書いているのだが、寝ている間にコールバック地獄はasync/awaitの導入によって終了していたらしい。ほほーう。

で、配列要素の非同期処理である。コールバック時代はnpmのasyncモジュールを使うのが一般的だったが、async/await時代の今はどうするのか?

逐次処理

普通にループすればいいだけ。ま、そのための構文だからな。async/await

function someAsyncTask(i) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(i ** 2);
    }, i * 100);
  });
}

const arr = [1, 2, 3, 4];

(async () => {
  console.time('series');

  const results = [];
  for (const i of arr) {
    const r = await someAsyncTask(i);
    results.push(r);
  }
  console.log(results);
  console.timeEnd('series');
})();
node series.js
[ 1, 4, 9, 16 ]
series: 1016.411ms

並列処理

配列をmapでpromiseに変換し、Promise.allに渡す。

(async () => {
  console.time('parallel');

  const promises = arr.map((e) => someAsyncTask(e));
  const results = await Promise.all(promises);

  console.log(results);
  console.timeEnd('parallel');
})();
$ node paralle.js
[ 1, 4, 9, 16 ]
parallel: 410.257ms

並列数を指定して逐次処理

ここからが本題。

例えば100万行のjson linesを処理してDBに入れるという処理をnodeで書くことを考える。1行処理するのに50msかかったとすると、逐次処理では14時間もかかってしまう。並列処理では100万のinsertを無制限に投げ続けたらDB側の処理が追いつかずエラーになってしまう。

こういう時は並列数を指定して逐次実行したい。

方法1

逐次処理と並列処理を組み合わせ。sliceで並列数だけ要素を取り出してpromiseの配列にしてPromise.allに渡す。これをループする

function someAsyncTask(i) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(i ** 2);
    }, i * 100);
  });
}

const arr = [1, 4, 2, 1, 2, 10, 5, 1, 2, 2];
const concurrency = 3;

(async () => {
  console.time('throttle');

  const results = []
  for (let i = 0; i < arr.length; i += concurrency) {
    const p = arr.slice(i, i + concurrency).map((e) => someAsyncTask(e));
    const r = await Promise.all(p);
    results.push(r);
  }

  console.log(results);
  console.timeEnd('throttle');
})();
$ node throttle1.js
[ [ 1, 16, 4 ], [ 1, 4, 100 ], [ 25, 1, 4 ], [ 4 ] ]
throttle: 2121.252ms

しかしこれはあまり出来が良くない。数字の合計は30で3並列で処理するのだから1000msぐらいで終わって欲しいのだが、2000ms以上かかっている。Promise.all では時間がかかる処理が1つあると、他の処理がその終了を待つため効率が下がってしまう。

方法2

というわけでよくあるワーカー的な処理に改良。複数のワーカーを作って終わったやつから次のデータを処理させる。

function someAsyncTask(i) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(i ** 2);
    }, i * 100);
  });
}

const arr = [1, 4, 2, 1, 2, 10, 5, 1, 2, 2];
const concurrency = 3;

(async () => {
  console.time('throttle');

  let counter = 0;
  const workers = Array(concurrency).fill().map(async () => {
    const results = [];
    while(counter < arr.length) {
      const e = arr[counter];
      counter += 1;
      const r = await someAsyncTask(e);
      results.push(r);
    }
    return results;
  });
  const results = await Promise.all(workers);

  console.log(results);
  console.timeEnd('throttle');
})();
$ node throttle2.js
[ [ 1, 1, 100 ], [ 16, 25 ], [ 4, 4, 1, 4, 4 ] ]
throttle: 1216.769ms

Node.jsはシングルスレッドなのでワーカーが同一のカウンタ変数や配列を参照するような雑なコードでも正しく動く。ミューテックスセマフォなどで排他制御したり、ロックフリーなキュー実装を使ったりする必要もない。Nodeはこういう時に楽でいい。

エラーハンドリングなど何もしてないので、実際に使うならば同じような処理をするnpmライブラリを探すといいと思います。多分あるでしょ、知らんけど。

scriptのasync属性とdefer属性の挙動

MDNによるとscriptのdefer属性は

この論理属性は、スクリプトを文書の解析完了後かつ DOMContentLoaded が発生する前に実行することをブラウザーに示します。

と書かれているが、文書の解析完了と言う言葉が具体的にどういう意味を持つのかわからない。非deferなスクリプトでDOM操作を行う場合、操作したい要素より後に読み込むか、DOMContentLoadedイベントハンドラで行うのが作法となっている。deferの文書の解析完了とはDOMに自由にアクセスして良いという意味と同義なのか、ちょっと確証がない。

この手の情報をネットでググってもお互いにコピペしてんじゃないかと思える判で押したような記事ばかり、まるで役に立たないので、自分で実際の挙動を調べた。結論はDOM操作してOKである。

default async defer
後続処理のブロック する しない しない
実行タイミング 要素が現れた時点で 不定 文書の解析完了後からDOMContentLoaded発火の間
同一属性スクリプトの実行順 HTMLに現れた順番で 不定 HTMLに現れた順番で
DOM要素の操作可否 自分より前の要素またはDOMContentLoadedで 自分より前の要素 全てOK
DOMContentLoadedイベント ハンドリング可能 ハンドリングできない事がある ハンドリング可能

deferについてMDNにはこうも書かれている

defer 属性の付いたスクリプトは、スクリプトが読み込まれて評価が完了するまで、 DOMContentLoaded イベントの発生が抑制されます。

これは、ブラウザ上でドキュメント表示は完了しているのに、JS実行が終わっておらずWebアプリケーションとして動作しないケースがあり得るという事。まぁ、deferのスクリプトのダウンロードと実行だけが異常に遅いというケースは、スクリプトが無限ループしてしまったなどの極端な場合を除けば考えにくいので気にする必要はないだろう。が、頭の隅にでもいれておいて損はないと思う。

async属性付きのスクリプトは実行タイミングが不定のため、他のスクリプトと依存関係を持つコードが書けない。1つで完結したスクリプト、基本的には外部スクリプトの読み込み用途がメインになるだろう

<div id="ここにwidgetが表示"></div>
<script async src="http://example.com/external/widget.js"></script>

このようなケースでスクリプトを同期実行すると、せっかく構築が進んだDOMとCSSOMの再構築が必要になりファーストビュー表示が遅延するので、asyncをつけるのが有効らしい。