【JS】 非同期処理を学ぶ(Promise async / awaitと制御予測の基本)

【JS】 非同期処理を学ぶ(Promise async / awaitと制御予測の基本)

現在、JSの非同期処理についてについて学んでおり一旦の頭の整理を兼ねて記事にしたいと思います。

非同期処理と同期処理

同期処理は、コードを上から順次処理(実行)されていきます。

対して、

非同期処理とは、ある処理が終了するのを待たずに、別の処理を実行することです。

そして、JavaScriptは基本的にシングルスレッドの実装しかできないため、2つ以上の処理を並行して実行することはできません。
※ServiceWorkerというものを利用することで、バックグラウンド実行は可能になるそうですが、今回は割愛します。

同期処理のイメージ

同期処理ではこのように順次処理を実行することになります。

この場合、
処理1 -> 処理2 -> 処理3 という実行順序になるため
処理2が実行されるまで処理3は実行されません。
仮に、処理2がDBから値を取得する等、通信状況によって取得まで時間がかかったり値が返ってくる保証がない処理だった場合、DBから値を取得し終わるまで、待ち時間が発生してしまいます。

非同期処理のイメージ

非同期処理を実装すると、その処理はメインスレッドから一時的に切り離されて次の処理に譲るイメージになります。

処理2を非同期処理として実装した場合、メインスレッドから一時的に切り離し処理3の実行を待ってから再びメインスレッドに戻り実行されることになります。

ですので、この場合は
処理1 -> 処理3 -> 処理2 という実行順序になります

このようにシングルスレッドでは、何らかの非同期処理がきた場合は、その処理の完了を待たずに、次のタスクを実行する必要があるため、JavaScriptで非同期処理は「処理の完了を待たない」という性質を持っています。

非同期処理の実装方法

現在、主に使われている非同期処理の実装方法として

  • ES2015で追加されたPromise
  • ES2017で追加されたasync / await

があります。
今回は、この2つを利用した非同期処理の実装について自身の頭の整理も兼ねて書いていきたいと思います。

Promiseの基本理解

Promiseは、非同期処理を行うためのもので、
「非同期処理の結果を表現するビルトインオブジェクト」とも呼ばれています。

Promise の状態

まずPromise は以下の3つの状態のいずれかに定義されます。

  • 待機 (pending): 実行中の状態(成功も失敗もしていない)を意味する
  • 履行 (fulfilled): 処理の成功(正常終了)を意味する
  • 拒否 (rejected): 処理の失敗(異常終了)を意味する

以下Promiseの状態についてJavaScript Primerから引用

Promiseインスタンスの状態は作成時にPendingとなり、一度でもFulfilledまたはRejectedへ変化すると、それ以降状態は変化しなくなります。 そのため、FulfilledまたはRejectedの状態であることをSettled(不変)と呼びます。

jsPrimer

基本構文

Promiseインスタンスを生成する際は引数にコールバック関数としてresolverejectを設定します。
Promiseインスタンス内は基本的には同期処理で実行され、then() catch() finally()の内部は非同期処理として実行されます。

  • resolve()の実行で非同期関数が正常終了したことを知らせ、thenメソッド内部が実行される。
  • reject()実行で、非同期関数が異常終了したことを知らせ、catchメソッド内部が実行される。
  • thenまたはcatchメソッドが実行されたら共通の終了処理としてfinallyメソッドが実行されます。
new Promise((resolve, reject) => {
  // 同期処理
 // resolve() or reject()
})
  .then(() => {
    // 非同期処理 (resolveの実行を待つ)
  })
  .catch(() => {
    // 非同期処理 (rejectの実行を待つ)
  })
  .finally(() => {
    // 非同期処理 (then, またはcatchを待つ)
  });

resolve

resolveが実行されるとthenメソッド内部が実行され、
thenメソッド内部のコールバック関数には、resolve実行時の実引数が渡されます。
そして、thenメソッド実行後はcatchメソッドを飛ばしてfinallyが実行されます。

const promise = new Promise((resolve, reject) => {
  resolve('thenメソッド内の処理');
})
  promise
  .then(data => console.log(data))// => thenメソッド内の処理
  .catch(data => console.log(data))
  .finally(() => console.log('終了処理'))// => 終了処理

console.log(promise);

出力結果

このようにPromiseの状態もfulfilled(正常終了)と出力されます。

reject

rejectが実行されるとthenメソッドは実行されず、catchメソッド内部が実行され、
catchメソッド内部のコールバック関数には、reject実行時の実引数が渡されます。
そして、最後にfinallyメソッドが実行されます。

const promise = new Promise((resolve, reject) => {
  reject('catchメソッド内の処理');
})
  promise
  .then(data => console.log(data))
  .catch(data => console.log(data))// => catchメソッド内の処理
  .finally(() => console.log('終了処理'))// => 終了処理

console.log(promise);

出力結果

rejectの実行でcatchメソッドが呼ばれた場合Promiseの状態はrejected(異常終了)と出力されます。

実際に同期処理と非同期処理を走らせてみる

以下の状態でコンソールの出力順序を比べてみます。

  • グローバルコンテキスト内
  • Promiseインスタンス内
  • thenメソッド内
console.log('global1');

new Promise((resolve, reject) => {
  console.log('promise');
  resolve('then');
})
  .then(data => console.log(data))

console.log('global2');

出力結果

global1
promise
global2
then

このようにグローバルコンテキスト内とPromiseインスタンス内ではconsole.logが上から順番に出力され、thenメソッド内のconsole.logは一番最後に出力されました。

このことから、
グローバルコンテキスト内とPromiseインスタンス内部は同期処理が行われ、
thenメソッド内部の処理は非同期処理であることがわかります。

new Promiseの糖衣構文

Promise.resolve

Promise.resolveメソッドは、new演算子を使用せずにPromiseとresolveをメソッドチェーンでつなぐことでFulfilledの状態となったPromiseインスタンスを生成することができます。

// const promise = new Promise(resolve => {
//   resolve();
// });

// 上記処理の糖衣構文(シンタックスシュガー)
const promise = Promise.resolve();

resolveの実引数はthenメソッド内のコールバック関数の引数に渡ります。

const promise = Promise.resolve(1);
promise.then(val => console.log(val));// => 1

Promise.reject

Promise.rejectメソッドは、new演算子を使用せずにPromiseとrejectをメソッドチェーンでつなぐことでRejectedの状態となったPromiseインスタンスを生成することができます。

// const promise = new Promise((resolve, reject) => {
//  reject();
// });

// 上記処理の糖衣構文(シンタックスシュガー)
const promise = Promise.reject();

rejectの実引数はcatchメソッド内のコールバック関数の引数に渡ります。

const promise = Promise.reject(1);
promise.catch(val => console.log(val));// => 1

Promiseチェーン

Promiseのチェーンとは、Promiseを使って、非同期処理を順次実行することです。Promiseでチェーンをつなげるには、thenメソッドのコールバック関数に戻り値としてPromiseのインスタンスを渡す必要があります。

const sleep = (val) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      val++
      console.log(`${val}秒経ちました。`);
      resolve(val);
    }, 1000);
  })
}

sleep(0)
  .then(val => sleep(val))
  .then(val => sleep(val))
  .then(val => sleep(val))
  .then(val => sleep(val));

もしくはこのようにつなげることもできます。

sleep(0).then(sleep).then(sleep).then(sleep).then(sleep);

出力結果

Promise内のresolveメソッドが実行されるまで、then()の中身は実行されないという特徴を利用して、PromiseのコールバックにsetTimeout(非同期関数)を定義することで1秒経つ毎に順次thenを実行することができます。

注意点として、thenメソッド内のコールバック関数の戻り値にPromiseインスタンスを返さない場合、後続の処理が待たずに実行されてしまうのでチェーンが切れてしまうので必ずPromiseインスタンスを返します。

もう一つ、例としてダミーのユーザーリソースをPromiseチェーンを使って順番に配列に格納し、結果としてconsoleに出力してみます。

console.log('同期処理1');

const usersFetch = (path) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (path.startsWith("/users")) {
        resolve({ body: `Response body of ${path}` });
      } else {
        reject(new Error("ユーザーデータが見つかりません"));
      }
    }, 1000);
  });
}

//resultにデータを格納する
const results = [];

// users Taroを取得する
usersFetch("/users/Taro")
  .then(response => {
    // users Taroをresultの末尾に追加
    results.push(response.body);
    // users Bobを取得する
    return usersFetch("/users/Bob");
  })
  .then(response => {
    // users Bobをresultの末尾に追加
    results.push(response.body);
  })
  .finally(() => {
    // 結果を出力
    console.log(results);
  });

  console.log('同期処理2');

出力結果

finallyメソッドよりも後のグローバルコンテキストに出力したconsole.logが先に出力されていることからユーザーリソースの出力が非同期であり、チェーンも切れていないことがわかります。

Promiseの並列処理

Promiseには以下のような並列で処理するための静的メソッドが用意されています。

  • Promise.all
  • Promise.race
  • Promise.allSettled

Promise.all

Promise.allは配列(反復可能オブジェクト)でPromiseのインスタンスを受け取り、
配列に格納したPromiseインスタンスが全てFulfilledした後にthenメソッドが呼ばれます。thenメソッドのコールバック関数には結果をまとめた配列が渡されます。

const sleep = (val) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(val);
      resolve(val);
    }, val * 500);
  })
}

const promise = Promise.all([
  sleep(1),
  sleep(2),
  sleep(3)
])
  
promise
  .then(data => {
    console.log(data);
    console.log(`${data}の処理の後にthenが呼ばれました`)
  })

出力結果

逆に、渡したPromiseがひとつでもRejectedとなった場合は、その時点でcatchに処理が移行します。

const sleep = (val) => {
  return new Promise((resolve,reject) => {
    setTimeout(() => {
      console.log(val);
      reject(val);
    }, val * 500);
  })
}

const promise = Promise.all([
  sleep(1),
  sleep(2),
  sleep(3)
])
  
promise
  .then(data => {
    console.log(data);
  })
  .catch(data => {
    console.error(`${data}の処理でエラーになりました`)
  });

出力結果

Promise.race

Promise.raceでは、配列で渡したPromiseのどれか一つがFulfilledまたはRejectedになった時点でthen、またはcatchに処理を移行します。

const sleep = (val) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(val);
      resolve(val);
    }, val * 500);
  })
}

const promise = Promise.race([
  sleep(1),
  sleep(2),
  sleep(3)
])

promise
  .then(data => {
    console.log(`${data}の処理の後にthenが呼ばれました`)
  })

実行結果

このように1でrejectが呼ばれた次の処理でthenメソッドに処理が移行していることがわかります。

Promise.allSettled

Promise.allSettledは、配列に格納したPromiseインスタンスが全てFulfilledになったらthenメソッドに処理を移行します。ここはPromise.allと同じです。

そして、Paomise.allとの違いはPromiseインスタンスのいずれかがrejectの場合でもPromiseインスタンスが全てFulfilledした後にthenメソッドが呼ばれることです。

const sleep = (val) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(val);
      reject(val);
    }, val * 500);
  })
}

const promise = Promise.allSettled([
  sleep(1),
  sleep(2),
  sleep(3)
])

promise
  .then(data => {
    console.log(data);
  })
  .catch(data => {
    console.error(data)
  });

実行結果

このように、rejectを返したとしても、全ての処理が完了した後に配列でrejectedとなったオブジェクトが出力されます。

制御予測の基本を学ぶ

このセクションでは、僕自身が非同期処理を学んでいく中でどのような順序で処理が実行されていくのか知るために調べた内容を一旦の頭の整理としてまとめます。

Event Loop(イベントループ)の基本理解

イベントループやコールスタック、非同期処理の基本的な流れは以下の文献が参考になりました。

【JS】ガチで学びたい人のためのJavaScriptメカニズム

JavaScript イベントループの仕組みをGIFアニメで分かりやすく解説 | コリス

 JavaScript Visualized: Event Loop

 Philip Roberts 氏の講演動画「イベントループとは一体何ですか? | Philip Roberts | JSConf EU」

用語の整理

  • Event Loop(イベントループ)
    • コールスタックとタスクキューの状態を常にを監視している
  • Web API(setTimeout, fetch など)
    • 非同期に実行される非同期API
  • Heap(ヒープ)
    • 動的に確保と解放を繰り返せるメモリ領域で、オブジェクトはヒープに割り当てられる
  • Call Stack(コールスタック)
    • 変数や関数などは呼び出されるとコールスタックに追加される
    • 後入れ先出し、LIFO(Last In First Out)の仕組み
    • メインスレッドで実行される
  • Macrotask(マクロタスク、タスクキュー)
    • 実行待ちの非同期処理の待ち行列。(非同期処理の実行順序を管理している)
    • 先入れ先出し、FIFO(First In, First Out)の仕組み
    • メインスレッドからは切り離されている
    • 例: setTimeout, setInterval, requestAnimationFrameなど
  • Microtask(マイクロタスク、ジョブキュー)
    • タスクキューとは別で存在する非同期処理の待ち行列
    • メインスレッドからは切り離されている
    • 例: Promise, queueMicrotask, MutationObserverなど

Macrotask(マクロタスク)とMicrotask(マイクロタスク)の違い

MacrotaskとMicrotaskの違いには主に以下が挙げられます。

  • マイクロタスクはマクロタスクより先に処理される
  • マイクロタスク => イベントループが回ってきたら格納されている全てのタスクをコールスタックに返す
  • マクロタスク => イベントループが回ってきたら格納されているタスクを一つずつコールスタックに返す

これらの違いを実際にコードを書いて確認していきます。

マイクロタスクはマクロタスクより先に処理される

以下の状態でconsoleに出力して出力順を見ていきたいと思います。

  • setTimeoutのコールバック関数内(非同期処理) => コールスタックからイベントループによりマクロタスクキューに格納される
  • thenメソッドのコールバック関数内(非同期処理) => コールスタックからイベントループによりマイクロタスクキューに格納される
  • Promiseインスタンス内(同期処理) => コールスタックに追加されメインスレッドでそのまま実行される
  • グローバルコンテキスト => コールスタックに追加されメインスレッドでそのまま実行される
setTimeout(() => {
  console.log('[4]setTimeout'); 
});

new Promise(resolve => {
  console.log('[1]promise');
  resolve();
})
  .then(() => console.log('[3]then'));

console.log('[2]global');

出力結果

[1]promise
[2]global
[3]then
[4]setTimeout

出力結果を見てみると、まず'[1]promise’出力され、次に'[2]global’が呼ばれました。

そして、上に書いた'[4]setTimeout’よりも先に'[3]then’が出力されています

このことからマイクロタスクはマクロタスクより先に処理されるということがわかりました。

マイクロタスクの実行中にマクロタスクを追加してみる

次に、マイクロタスクキューに格納される非同期処理はにイベントループが回ってきたら格納されている全てのタスクをコールスタックに返すのかを見るために、Promiseチェーンの途中にsetTimeoutを呼び出してみます。

Promise.resolve()
  .then(() => {
    console.log('[1]then');
    setTimeout(() => {
      console.log('[4]setTimeout');
    });
  })
  .then(() => {
    console.log('[2]then');
  })
  .then(() => {
    console.log('[3]then');
  })

出力結果

[1]then
[2]then
[3]then
[4]setTimeout

Promiseチェーンの実行中にsetTimeout追加すると、thenの処理が全て完了してからsetTimeoutが呼ばれました。

このことから、マイクロタスクキューに格納される非同期処理はにイベントループが回ってきたら格納されている全てのタスクをコールスタックに返すことがわかりました。

以上のことをまとめると、コールスタックが空になると、次にマイクロタスクにある処理が実行され、それが全てなくなるとマクロタスクにある処理が実行されるということになります。

async / await

async / awaitはPromiseをさらに直感的に書けるようにしたものです。

つまりPromiseの糖衣構文となります。

以下がそれぞれの基本的な特徴としてあげられます。

  • asyncはPromiseオブジェクトを返す
  • awaitは右辺でPromiseインスタンスを受け取る

async function

先頭にasyncをつけた関数は非同期関数になり、Promiseを返却します
Promiseを返すので、thenメソッドやcatchメソッドでつなぐことが可能です。

そして、async functionの特徴として以下が挙げられます。

  • 値をreturnした場合、その返り値を持つFulfilledなPromiseインスタンスを返す
  • Promiseインスタンスをreturnした場合、その返り値のPromiseインスタンスをそのまま返す
  • 例外をthrowした場合は、そのエラーを持つRejectedなPromiseを返す
// 値を返す
async function resolveFn() {
  return '正常終了';
}
resolveFn()
  .then(val => {
    console.log(val); // => 正常終了 
  });

// Promiseインスタンスを返す
async function rejectFn() {
  return Promise.reject(new Error('異常終了'));
}
rejectFn()
  .catch(error => {
    console.log(error.message); // => 異常終了
  });

// 例外をthrowする
async function exceptionFn() {
  throw new Error('例外が発生しました');
}
exceptionFn()
  .catch(error => {
    console.log(error.message); // => 例外が発生しました
  });

await

awaitは右辺にPromiseインスタンスを受け取り、Promiseの結果(resolveもしくはreject)が返されるまで待機する演算子です。
注意点として、awaitasync functionの中でしか使えない、という制約があります。

awaitとPromiseを比較をしてみます。

// await
async function asyncFn() {
  const val = await Promise.resolve('正常終了');
  console.log(val); // => 正常終了
}
asyncFn(); 

// promise
function promiseFn() {
  return Promise.resolve('正常終了').then(val => {
    console.log(val); // => 正常終了
  });
}
promiseFn(); 

awaitを使用することでコールバック関数を使わずに実装できていることがわかります。

以上のことをまとめると、
コールスタックが空になると、次にマイクロタスクにある処理が実行され、それが全てなくなるとマクロタスクにある処理が実行されることになります。

マイクロタスクのキューが全てなくなって、マクロタスクにある処理が実行された最中に、マイクロタスクからキューが追加されたら、また、マイクロタスクにあるキューがなくならない限り、マクロタスクにある処理は実行されません。

Promiseチェーンをasync / awaitで書き換える

Promiseのthenチェーン

const sleep = (val) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      val++
      console.log(`${val}秒経ちました。`);
      resolve(val);
    }, 1000);
  })
}

sleep(0)
  .then(val => sleep(val))
  .then(val => sleep(val))
  .then(val => sleep(val))
  .then(val => sleep(val));

async / awaitで書き換える

const sleep = (val) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      val++
      console.log(`${val}秒経ちました。`);
      resolve(val);
    }, 1000);
  })
}

async function init() {
  let val = await sleep(0)
  val = await sleep(val);
  val = await sleep(val);
  val = await sleep(val);
  val = await sleep(val);
  return val;
}

init()
  .then((val) => console.log(`${val}秒経ってthenが呼ばれました。`))

Promise内のresolveが呼ばれたタイミングでawaitに返り値(resolveの引数)が渡ってきます。

また、async Functionであるinit()はPromiseを返すことが担保られているので、後述して、thenメソッドを繋げることができます。

出力結果

例外を投げた場合

function sleep(val) {
  return new Promise((resolve) => {
    setTimeout(() => {
      val++
      console.log(`${val}秒経ちました。`);
      resolve(val);
    }, 1000);
  })
}

async function init() {
  let val = await sleep(0)
  val = await sleep(val);
  val = await sleep(val);
  throw new Error(val);
  // この行以下は実行されません
  val = await sleep(val);
  val = await sleep(val);
}

init()
  .then((val) => console.log(`${val}秒経ってthenが呼ばれました。`))
  .catch((val) => console.log(`${val}秒経って例外が発生しました`))

出力結果

async Functionで throwが呼ばれた場合、init()のcatchに処理が移っていることがわかります。

逐次処理の書き換え

Promiseチェーンでダミーのユーザーリソースを出力

const usersFetch = (path) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (path.startsWith("/users")) {
        resolve({ body: `Response body of ${path}` });
      } else {
        reject(new Error("ユーザーデータが見つかりません"));
      }
    }, 1000);
  });
}

const results = [];

usersFetch("/users/Taro")
  .then(response => {
    results.push(response.body);
    return usersFetch("/users/Bob");
  })
  .then(response => {
    results.push(response.body);
  })
  .finally(() => {
    console.log(results);// => ['Response body of /users/Taro', 'Response body of /users/Bob']

async / awaitで書き換え

const usersFetch = (path) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (path.startsWith("/users")) {
        resolve({ body: `Response body of ${path}` });
      } else {
        reject(new Error("ユーザーデータが見つかりません"));
      }
    }, 1000);
  });
}

async function asyncUsers() {
  const results = [];
  const responseTaro = await usersFetch("/users/Taro");
  results.push(responseTaro.body);
  const responseBob = await usersFetch("/users/Bob");
  results.push(responseBob.body);
  return results;
}
asyncUsers()
  .then((results) => {
    console.log(results); // => ['Response body of /users/Taro', 'Response body of /users/Bob']
  });

このようにawaitを使った場合は取得と配列へのpushを順番に行ってもネストが深くなることはないので、thenチェーンによるコールバック関数よりも直感的に非同期処理を順番に処理していくことができます。

さいごに

JavaScript の非同期処理を学習してみて、基礎的な部分ですでに頭がパンクしそうですが
ユーザー体験に直結するようなところで非常に興味深いテーマでもありますのでこれからも積極的にキャッチアップしていきたいと思います。

参考文献