Javascriptで同時に非同期処理を行う

Promise.allを使用して複数の非同期処理をうまく扱う方法について整理しました。 こうすることで多数のHTTPリクエストなどを同時に送り、処理することが可能になります。
2021.12.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

JSでは非同期処理を使用することでHTTPリクエストを送った際にレスポンスが帰ってくるまでの間に別のリクエストを送ることができます。 なのでタイトルには同時と書いてありますが、厳密には同時ではありません。

ただ、こうすることで複数のリクエストをすばやく処理することが可能です。 しかし、サーバーに負荷をかけたくないなどの理由から同時にリクエストを送る数を制限する必要があると思います。 今回はそんな場合にリクエストをバッチ化して処理する方法についても整理してみます。

準備

今回はwait関数を作成し、一定時間待機させるような処理を利用して、HTTPリクエストなどの代わりとします。 今回は2秒間処理をブロックするような関数を作成しました。

const sleep = (time) => new Promise(resolve => setTimeout(resolve, time))

const wait = async (i) => {
    console.log(`Start wait: ${i}`)
    await sleep(2000)
    console.log(`End wait: ${i}`)
    return i
}

同期的な処理

まずはじめに同期的に処理する場合を考えます。 全部で6回分処理を行います。

const lo = require('lodash')

const main = async () => {
    const idx = lo.range(0, 6)

    const start = new Date()
    const values = []
    for (const i of idx) {
        const ret = await wait(i)
        values.push(ret)
    }
    const end = new Date()
    const elapsed = (end - start) / 1000
    console.log(`${values}, elapsed: ${elapsed} sec`)
}

main()

実行結果は以下のようになります。 一つ一つの処理の完了を待ってから次の処理が行われるので処理数x2秒くらいの時間がかかります。 実際結果を見ると、12秒ほどの時間がかかっています。

Start wait: 0
End wait: 0
Start wait: 1
End wait: 1
Start wait: 2
End wait: 2
Start wait: 3
End wait: 3
Start wait: 4
End wait: 4
Start wait: 5
End wait: 5
0,1,2,3,4,5, elapsed: 12.02 sec

非同期的な処理

次は非同期的に複数の処理を行い実行時間を削減してみます。

const main = async () => {
    const idx = lo.range(0, 6)

    const start = new Date()
    const values = await Promise.all(idx.map(wait))
    const end = new Date()
    const elapsed = (end - start) / 1000
    console.log(`${values}, dudation: ${elapsed} sec`)
}

main()

sleepの待機中に別の処理が動いていることが確認できます。 一度にすべてスタートしてから、その後逐次処理が完了しています。 ほぼ同時に処理するため処理時間は全体で2秒程度になります。

Start wait: 0
Start wait: 1
Start wait: 2
Start wait: 3
Start wait: 4
Start wait: 5
End wait: 0
End wait: 1
End wait: 2
End wait: 3
End wait: 4
End wait: 5
0,1,2,3,4,5, dudation: 2.01 sec

ここでのポイントは以下の部分です。

const values = await Promise.all(idx.map(wait))

2つのパートに分けて解説します。

1つ目は以下の部分です。

idx.map(wait)

async関数の返り値はPromiseになります。 idxは0〜5が含まれているArrayなので、これをmapで変換するとPromiseのリストになります。

2つ目は以下の部分です。

Promise.all([Promise...])

Promise.allArrayなどのiterableなPromise引数としてとり、単一のPromiseとして処理します。 つまりはここではArrayに含まれるすべてのPromiseの処理が終わった場合に、これ以降の処理がすすみます。 なので、すべての処理が終わるまでここで待機することになります。

同時に処理する数を制御する

さて、ここまでで同期的に逐一処理する方法と、同時に処理する方法を述べてきました。 同時に処理する数を制御にはこの2つを組み合わせれば可能です。

今回は2個ずつ同時に処理し、それを3回繰り返します。

const main = async () => {
    const idx = lo.range(0, 6)

    const totalStart = new Date()

    for (const chunked of lo.chunk(idx, 2)) {
        const start = new Date()
        const values = await Promise.all(chunked.map(wait))
        const end = new Date()
        const elapsed = (end - start) / 1000
        console.log(`${values}, elapsed: ${elapsed} sec`)
    }

    const totalEnd = new Date()
    const totalElapsed = (totalEnd - totalStart) / 1000
    console.log(`Total elapsed: ${totalElapsed} sec`)
}

main()

実行結果を確認すると2個ずつ処理が進んでいることが確認できます。 全体の処理時間は各バッチで2秒、それが3回なので計 2x3 = 6秒かかります。

Start wait: 0
Start wait: 1
End wait: 0
End wait: 1
0,1, elapsed: 2.008 sec
Start wait: 2
Start wait: 3
End wait: 2
End wait: 3
2,3, elapsed: 2.004 sec
Start wait: 4
Start wait: 5
End wait: 4
End wait: 5
4,5, elapsed: 2.003 sec
Total elapsed: 6.016 sec

順に解説していきます。

最初は以下の部分です。

    for (const chunked of lo.chunk(idx, 2)) {
        ...
    }

ここはlodashの関数を利用してArrayを2個ずつに区切っています。 つまりlo.chunk(idx, 2)==[[0, 1], [2, 3], [4, 5]]です。

つぎは以下の部分です。

const values = await Promise.all(chunked.map(wait))

ここでは2個区切りになったArrayを非同期的に処理しています。 つまりは2個分の処理が解決されるまでここで待機することになります。 解決されると次の2個が処理されます。

おわりに

非同期的に処理を行うことで全体の処理時間を短縮することができました。 また、同時実行数を制御することでHTTPリクエストなどを使用する際にサーバーに過度な負荷をかけることを防ぐこともできました。