はじめに
Promise.all()で5件のAPIリクエストを一気に投げたら、サーバーに同時アクセスが集中して弾かれた——そんな経験はないでしょうか。並列処理は書けても、「同時に何件まで動かすか」を自分で調整する手段は、標準のPromise APIには用意されていません。
Asyncは、この「同時実行数のコントロール」を含めて、非同期処理のパターンを一通り関数として提供してくれるユーティリティライブラリです。直列実行、並列実行、リトライ、依存関係のあるタスク管理まで、車輪の再発明をせずに済みます。
とはいえ、読むより触った方が早いと思います。実際にブラウザ上でqueue()の同時実行数を変えながら動かせるサンプルを用意したので、先に挙動を見たい方はこちらからどうぞ。
Asyncとは
Asyncは、Node.jsとブラウザの両方で動作する、非同期JavaScriptのための高階関数集です。作者はCaolan McMahonで、2010年ごろから公開されている息の長いライブラリです。
GitHubでは28,000以上のスターを獲得しており、2,378件のフォーク、270人を超えるコントリビューターが開発に参加しています。最終コミットは前日と、10年以上経った今も現役でメンテナンスされています。async/awaitが標準化された今でも、queueやretryのような「標準にはない制御パターン」を求めて採用され続けているのが特徴です。
主な特徴
- 同時実行数を制御できる -
queueやmapLimitなど、並列度を数値で指定できる関数が揃っている - コールバックとPromiseの両対応 - 最終引数のコールバックを省略すると、そのままPromiseを返す
- 豊富な制御フロー関数 -
waterfall(順送り)、series(直列)、parallel(並列)、auto(依存関係解決)など約70種類 - ブラウザ・Node.js両対応 - ESM/CJS双方のビルドを提供しており、そのままフロントエンドでも使える
インストール
# npm
npm install async
# yarn
yarn add async
# pnpm
pnpm add async
CDN経由で読み込む場合は次のようにします。
<script src="https://cdn.jsdelivr.net/npm/async@3.2.6/dist/async.min.js"></script>
Asyncのサンプルを動かす
ここでは、Asyncのqueue()を使ったタスクキューのサンプルを用意しました。「タスクを追加」ボタンを押すたびにタスクが積まれ、queueに設定した同時実行数(初期値2)を超える分は、実行中のタスクが終わるまで待機します。同時実行数の入力欄を1に変えてから追加すると、タスクが1件ずつ順番にしか動かなくなる様子が確認できます。
queue()の第2引数が同時実行数の上限です。3件目以降は、実行中の枠が空くまでキューで待たされます。
import { queue } from 'async'
// 第2引数の2が同時実行数。3件目以降は枠が空くまで待機する
const q = queue((task, callback) => {
console.log('開始', task.id)
setTimeout(() => callback(), 800)
}, 2)
q.push({ id: 1 })
q.push({ id: 2 })
q.push({ id: 3 })
q.drain = () => console.log('全タスク完了')
実際に動かせるものが下です。値を書き換えるとその場で挙動が変わります。
同時実行数を5まで上げてから連続で追加すると、ほぼ同時に「開始」が並ぶはずです。逆に1に下げると、前のタスクの「完了」を待ってから次の「開始」が出ます。queueが返すconcurrencyプロパティは実行中でも書き換えられるため、負荷に応じて動的に調整するといった使い方もできます。
基本的な使い方
Asyncの基本形として、waterfallを紹介します。waterfallは前のタスクの結果を次のタスクへ順番に渡していく関数で、「ユーザーIDを取得 → そのIDで注文を取得 → 件数を集計する」のような、前の結果に依存する処理を素直に書けます。
import { waterfall } from 'async'
waterfall([
(callback) => callback(null, 42), // ユーザーIDを取得
(userId, callback) => callback(null, userId, ['注文A', '注文B']), // 注文を取得
(userId, orders, callback) => callback(null, orders.length),
], (err, result) => {
console.log(`合計${result}件`) // 前のステップの結果が次に渡っていく
})
「waterfallを実行」ボタンを押すと、3つのステップが順番に実行され、各ステップの結果が次のステップの引数として渡っていく様子がログに表示されます。
各ステップのコールバックに渡した第2引数以降が、そのまま次のステップの引数になっているのがポイントです。ステップを追加したいときは、配列に関数を1つ足すだけで済みます。
実践的なユースケース
直列処理と並列処理を使い分ける
複数の非同期タスクをまとめて実行するとき、「順番に1つずつ」実行したいのか「同時に」実行したいのかで、seriesとparallelを使い分けます。どちらもタスクの配列と完了時コールバックという同じインターフェースなので、切り替えが簡単です。
import { series, parallel } from 'async'
// series: 前のタスクが終わるまで次へ進まない(3件で約1500ms)
series([taskA, taskB, taskC], (err, results) => { /* ... */ })
// parallel: 全タスクを同時に開始する(最も遅いタスクの時間で完了、約500ms)
parallel([taskA, taskB, taskC], (err, results) => { /* ... */ })
「series()で実行」と「parallel()で実行」の2つのボタンで、同じ3つのタスク(それぞれ500msかかる)を実行し、所要時間の違いを比較できます。
seriesは約1500ms、parallelは約500msで完了するはずです。タスクのmsを変えて試すと、seriesは合計時間、parallelは最大値で完了時間が決まることがより分かりやすくなります。
mapLimitで同時実行数を制御する
配列の全要素をmapのように処理しつつ、同時実行数だけは制限したい場面があります。APIのレート制限に引っかからないようにしたいときなどです。mapLimitは、そのための関数です。
import { mapLimit } from 'async'
// 同時実行数を2に制限しながら、5件をまとめて処理する
mapLimit([1, 2, 3, 4, 5], 2, (item, callback) => {
fetchItem(item).then((result) => callback(null, result))
}, (err, results) => {
console.log(results) // 各処理結果が入力順に並んだ配列になる
})
同時実行数の入力欄を書き換えてから「5件をmapLimitで処理」を押すと、各アイテムの処理開始タイミング(経過ミリ秒)がログに表示され、同時実行数によって全体の完了時間がどう変わるかを確認できます。
同時実行数を1にすると各アイテムの「処理開始」が600msずつずれて表示され、5にするとほぼ同時に全件の処理が始まります。mapLimitの第2引数を変えるだけで、この並列度を自在にコントロールできるわけです。
retryで失敗時に自動再試行する
不安定な外部APIやネットワーク処理では、失敗したときに一定回数だけ自動で再試行したいことがよくあります。retryは、試行回数と間隔を指定してこれを実現します。
import { retry } from 'async'
// 最大5回、300ms間隔で再試行する
retry({ times: 5, interval: 300 }, (callback) => {
unstableApiCall((err, result) => callback(err, result))
}, (err, result) => {
if (err) return console.error('最終的に失敗:', err)
console.log('成功:', result)
})
「成功するまでの失敗回数」を指定してから「retryで実行」を押すと、retryが指定した回数だけ失敗を返す関数を何度呼び出すかがログに表示されます。5より大きい値を入れると、times: 5の上限に達して最終的に失敗する様子も確認できます。
失敗回数を0にすると1回目で即成功、7のようにtimesを超える値にすると5回試したうえで失敗が確定します。intervalを長くすれば、再試行の間隔を広げてサーバー負荷を抑えることもできます。
まとめ
Asyncは、async/awaitが当たり前になった今でも、標準のPromise APIだけではカバーしきれない「同時実行数の制御」「依存関係のあるタスク管理」「自動リトライ」といったパターンを、関数1つで解決してくれるライブラリです。
今回紹介したqueue、waterfall、series/parallel、mapLimit、retryはほんの一部で、他にもauto(依存関係解決)やcargo(バッチ処理)など約70種類の関数が用意されています。まずは自分のコードで「同時に何件まで走らせたいか」を意識してみると、Asyncを使うべき場面が見えてくるはずです。