JavaScript

async/await、promise・・これが最後の「JavaScriptの非同期処理完全に理解した」

最近フロントエンド界隈の技術の変化が活発です。
React、Angular、Vue.jsなどモダンなフロントエンド開発を始めた人も多くいると思います。
JavaScript開発でおそらく多くの人が最初につまづくであろう処理が非同期処理です。

私も最初はこの非同期処理にかなり戸惑いました。
そして今まで何度も「JavaScriptの非同期処理完全に理解した」と思っては、「あれ、この場合ってどうなるんだっけ?」
「結局わかってないじゃん・・・」の繰り返しでしたので、非同期処理についてきちんと整理しました。

そもそもJavaScriptはどのように動いているのか?

JavaScriptはシングルスレッドで動く言語です。
シングルスレッドで動くということは、同時に2つ以上のことはできません。

JavaScriptでは処理したい関数をキューに貯めて、一つずつシングルスレッドで処理します。
例えば、関数Aを実行して、関数Bを実行して、関数Cを実行して、という命令を受けた場合、
キューに以下のように処理がたまり、1つずつ順番に処理されます。

処理待ちキュー
関数A
関数B
関数C
-

ただこの時1つ注意が必要なのが、JavaScript以外の処理を実行する時、その処理の完了を待たずに、次の処理が実行されます。
代表的な例がHTTPリクエストです。

非同期処理で最初に多くの人が陥るのが、例えば関数AでHTTPリクエストを投げてユーザー情報を取得して、関数Bで取得したユーザー情報を使いたい時に、関数Aの非同期が完了する前に、関数Bが実行されてしまい、ユーザー情報が使えないというケースです。

では、それを回避するために、JavaScriptではこの非同期処理をどのようにして同期的に扱うのか?
その歴史を以下で見ていきます。

非同期処理との戦いの歴史

非同期処理を同期的に扱うため、どのような方法を取られてきたのか、JavaScriptからHTTPリクエスト処理を実行する例を参考に説明します。

1.非同期処理を普通に呼び出した場合

まずは非同期処理を含む関数を普通に呼び出した場合どうなるか、を見てみます。

非同期処理を普通に呼び出した場合
const processA = function() {
  $.ajax({
    url: 'https://qiita.com/api/v2/items?page=1&per_page=100',
    type: 'GET',
    success: function () {
      console.log("processA")
    }
  })
}
const processB = function() {
  $.ajax({
    url: 'https://qiita.com/api/v2/items?page=2&per_page=20',
    type: 'GET',
    success: function () {
      console.log("processB")
    }
  })
}

const processC = function() {
  console.log("processC")
}

processA()
processB()
processC()

この処理の実行結果は、

processC
processB
processA

となります。
非同期で実行されるので、関数Aが終わる前に関数Bが動き、関数Bが終わる前に関数Cが動きます。
HTTPリクエストの戻り時間が関数Aが長くかかりますので、
関数C→関数B→関数Aの順番で処理が終わっています。

2.コールバック

JavaScriptでは関数をオブジェクトとして扱えます。
変数に代入したり、他の関数に引数として渡すことができます。

関数Aが終わった後、関数Bを実行したい場合、関数Bを関数Aの引数として渡して、関数Aの完了時に実行することが可能です。

コールバックを使った非同期処理
const processA = function(afterProcess1, afterProcess2) {
  $.ajax({
    url: 'https://qiita.com/api/v2/items?page=1&per_page=100',
    type: 'GET',
    success: function () {
      console.log("processA")
      afterProcess1(afterProcess2)
    }
  })
}

const processB = function(afterProcess) {
  $.ajax({
    url: 'https://qiita.com/api/v2/items?page=2&per_page=20',
    type: 'GET',
    success: function () {
      console.log("processB")
      afterProcess()
    }
  })
}

const processC = function() {
  console.log("processC")
}

processA(processB, processC)

この処理の実行結果は、

processA
processB
processC

となります。

順番に実行することはできました。
しかし、どの順番でどの関数が呼ばれているのかがわかりづらく、非常に可読性が低いコードになります。

3.Promiseを使った非同期処理

そこで登場したのがPromiseです。
Promiseは非同期処理の状態を持つオブジェクトです。
以下のいずれかの状態を持っています。

状態 概要
pending  非同期処理が未完了
fulfilled 非同期処理が完了した
rejected 非同期処理が失敗した

呼び出し元はPromiseの状態が変わったのをみて、次の処理を実行します。
そしてこれらの非同期処理をPromiseチェーンと呼ばれる一連の処理の中で順番に実行します。

Promiseを使った非同期処理
const processA =function() {
  return new Promise(function(resolve, reject) {
    $.ajax({
      url: 'https://qiita.com/api/v2/items?page=1&per_page=100',
      type: 'GET',
      success: function () {
        console.log("processA")
        resolve()
      }
    })
  })
}

const processB = function() {
  return new Promise(function(resolve, reject) {
    $.ajax({
      url: 'https://qiita.com/api/v2/items?page=2&per_page=20',
      type: 'GET',
      success: function () {
        console.log("processB")
        resolve()
      }
    })
  })
}

const processC = function() {
  return new Promise(function(resolve, reject) {
    console.log("processC")
    resolve()
  })
}

processA()
  .then(processB)
  .then(processC)

この処理の実行結果は、

processA
processB
processC

となります。
上記の例は処理が成功しているケースしかありませんが、処理が失敗した場合、Promiseはrejectを返します。
その場合、呼び出し元では、.catch()で処理を拾ってあげる必要があります。

Promiseチェーンが短い場合は良いですが、長くなるとこれでもやはり少し可読性は落ちます。

4.async/awaitを使った非同期処理

Ecma2017の新仕様として登場したのがasync/awaitです。読み方は「エーシンク/アウェイト」が正しいようです。
.then()による数珠繋ぎの処理をさらにわかりやすくしたものですが、基本はPromiseと同じです。
async/awaitもPromiseを返します。

async/awaitを使った非同期処理
const processA = async function() {
  await $.ajax({
    url: 'https://qiita.com/api/v2/items?page=1&per_page=100',
    type: 'GET',
    success: function () {
      console.log("processA")
    }
  })
}

const processB = async function() {
  await $.ajax({
    url: 'https://qiita.com/api/v2/items?page=2&per_page=10',
    type: 'GET',
    success: function () {
      console.log("processB")
    }
  })
}

const processC = async function() {
  console.log("processC")
}

const processAll = async function() {
  await processA()
  await processB()
  await processC()
}

processAll()

ずいぶんコードが見やすくなりました!

asyncを関数につけると、その関数はreturnでPromiseのresolve()を返し、throwでPromiseのreject()を返すようになります。

上記例では明示的にreturnを書いていませんが、JavaScriptではreturnを書かなかった関数ではundefinedがreturnされますので、その時点でPromiseのresolve()が返ります。

asyncをつけずに、「3.Promiseを使った非同期処理」と同様にreturn new PromiseでPromiseを返しても問題ありません。

awaitをつけた関数は、Promiseが返ってくるまで処理を待ちます。
awaitが無ければ、処理を待たないので、Promiseが返ってくるのを待たずに、次の処理が実行されます。
awaitはasyncをつけた関数内でしか実行できません。

※ここまでもっとも身近でわかりやすい非同期処理の例として\$.ajaxを使ったHTTPリクエストで非同期処理の説明をしてきましたが、実は\$.ajaxはPromiseを返すので、\$.ajaxの結果をreturnしてあげれば、同期的に処理可能です。

Promise.all

上記説明は複数の非同期処理を1つずつ同期して実行しましたが、例えば関数Bは関数Aの完了を待つ必要はなく、関数Aと関数Bが終わったら関数Cを実行したいというケースでは、Promise.all()を利用します。

Promise.allを使った非同期処理
const processA = async function() {
  await $.ajax({
    url: 'https://qiita.com/api/v2/items?page=1&per_page=100',
    type: 'GET',
    success: function () {
      console.log("processA")
    }
  })
}

const processB = async function() {
  await $.ajax({
    url: 'https://qiita.com/api/v2/items?page=2&per_page=10',
    type: 'GET',
    success: function () {
      console.log("processB")
    }
  })
}

const processC = async function() {
  console.log("processC")
}

const processAll = async function() {
  await Promise.all([processA(), processB()])
  await processC()
}

processAll()

この処理の実行結果は、

processB
processA
processC

となります。
processBはprocessAの完了を待っていませんが、processCはprocessA、processBの完了を待っています。

forEachでasync/await

これで完全にasync/await理解したと思っていたところ・・・forEachの繰り返し処理内で非同期のHTTPリクエスト処理を同期的に扱おうとしてうまくいかないことが判明しました。。
ソースはその時のもので、Vue.jsのstore内でaxiosによるHTTPリクエスト処理を書いたものです。

  async updatePost({ commit }, posts) {
    await posts.forEach(async (post, i) => {
      let url = 'http://localhost:4000/posts/' + post.id
      await axios.put(url, {
        post: {
          title: post.title,
          body: post.body
        }
      })
    })
  }

なんとなくこれでいけそうな気がしたのですがHTTPリクエストが終わる前に次の処理が走ってしまいました。
調べたところ、ちょうどこのような記事が。

forEachのコールバック関数は、処理の完了を待たないようです。

そこで、やり方としては、以下のやり方があるようです。

1.for-ofのループでasync/await

  async updatePost({ commit }, posts) {
    for (let post of posts) {
      let url = 'http://localhost:4000/posts/' + post.id
      await axios.put(url, {
        post: {
          title: post.title,
          body: post.body
        }
      })
    }
  }

for-ofを使えば、処理の完了を1つずつ待って順番に処理することができます。
for文の繰り返し処理を1つずつ同期して待つ必要はないという場合は、次の「2.PromiseAllを利用する」で処理できます。

2.PromiseAllを利用する

  async updatePost({ commit }, posts) {
    await Promise.all(
      posts.map(async post => {
        let url = 'http://localhost:4000/posts/' + post.id
        await axios.put(url, {
          post: {
            title: post.title,
            body: post.body
          }
        })
      })
    )
  }

配列のデータをmapして全体をPromise.allに渡すことで、配列の中の全ての処理が終わるまで処理を待つことができます。