はじめに
今回はフロントエンド側のJavaScript(ブラウザ環境)の非同期処理の解説になります。
引用された個所は 独習JavaScript 新版 の内容を使わせていただきました。
スレッド
非同期処理の用語について説明する前にまずは『スレッド』という単語について説明します。
『スレッド』とは、プログラムの開始から終了までの処理の一連の流れのことです。
ブラウザ上でJavaScriptのコードが実行されるスレッドは『メインスレッド』と呼ばれています。メインスレッドはあくまでも『シングルスレッド』のため、並列して処理が実行されることはできません。
JavaScriptはシングルスレッドのため、もしAPIにデータを取得しに行くなど、サーバーとの通信が発生する処理を行う場合、通信の待ち時間なども含めてスレッドが占有されてしまい、他の処理が行えなくなります。そのような問題を解消するため、サーバーとの通信が発生する処理は非同期処理を利用します。
※余談ですが、JavaScriptとよく間違えられるJavaは1つの処理を複数のスレッドにわけて実行することが可能です(マルチスレッドと呼ばれるもの)。マルチスレッドは並列処理とも言われます。
同期処理と非同期処理の違い
次に同期処理と非同期処理についての違いを明確にしたいと思います。
同期処理とは
1つのスレッドで、前の処理の完了を待ってから次の処理を実行すること
非同期処理とは
メインスレッドから一時的に切り離された処理のこと。
通常JavaScriptは、ブラウザに備わっているWebAPIの非同期関数(setTimeoutやsetIntervalなど)、Promise(後述)を使えば非同期処理となります。
具体例として、非同期関数(setTimeout)を使用したコードが以下になります。
function taskA() {
console.log("タスクAを実行 at " + Date.now());
}
function taskB() {
console.log("タスクBを実行 at " + Date.now());
}
function taskAsync() {
console.log("非同期のタスクを実行 at " + Date.now());
}
taskA();
setTimeout(() => {
taskAsync();
}, 1000);
taskB();
/* 処理の流れ taskA()-> taskB()->taskAsync()
1. taskA関数が実行される。
2. setTimeout関数そのものは実行されるが、taskAsync関数そのものは予約待ち状態(メインスレッドから切り離されている)。
3. taskB関数が実行される。
4. 1秒後に予約されていたtaskAsync関数が実行される。
*/
メインスレッドで実行される処理の順番としてはtaskA()-> taskB()->taskAsync()となります。
つまり、taskA関数とtaskB関数は同期処理、taskAsync関数はメインスレッドから処理が切り離され、非同期処理として処理が行われていることになります。
非同期処理は『一時的にメインスレッドから処理が切り離され、後で処理が行われるということ』になりますが、非同期処理がブラウザ上でどのように管理されているのかについては、『イベントループ』と呼ばれるものを理解する必要があります。
イベントループ
以下、イベントループと関連する用語の意味について記述します。
イベントループとは
『イベントループ』とはブラウザ上で非同期処理の管理、実行を行う仕組みのことです。
『タスクキュー(後述)』に格納されたタスクを順番に実行します。
定期的に『コールスタック(後述)』を監視し、コールスタックが空のときにタスクキューから一番古いタスクを取り出して実行します。
タスクキューとは
実行待ちのタスク(非同期で実行が予約されている関数)が格納されているキューのこと。キューとはデータの出し入れをリスト形式で管理するデータ構造です。キューからデータを取り出す際は古いものから順番に取り出します。この仕組みはFIFO(First In First Out)と呼ばれています。
コールスタックとは
実行コンテキスト(後述)が積み重なってできたものをコールスタックと呼びます。コードが実行されるときには必ず実行コンテキストが生成されるため、コールスタックには必ずコンテキストが積まれている状態となります。
実行コンテキスト
コードが実行される際にJavaScriptエンジンによって準備されるコードの実行環境のこと。実行コンテキストには、グローバルコンテキスト、関数コンテキストなどの種類があります。
イベントループに関連する用語を踏まえた上で、上記図のイベントループの挙動が以下になります。
1. コールスタック(図の表記だとstack)に実行コンテキスト(グローバルコンテキスト、関数コンテキスト)が積まれていく。
2. WebAPIの1つであるUIイベント(図だとonClickイベント)が呼び出されて、タスクキュー(図の表記だとcallback queue)に格納される。
3. コールスタックに積まれた実行コンテキストが順番に処理されていく。
4. コールスタックに積まれた実行コンテキストが全て消滅して空の状態になったら、イベントループがそれを検知して、キューに格納されたonClick関数を取得し、実行する。
5. 関数を実行したら関数コンテキストが生成され、コールスタックに積まれて処理される。
6. 上記のコールスタックに積まれた関数コンテキストが消滅したら、その後タスクキューに格納された残りのonClickイベントがイベントループによって取り出され、同様の処理が行われる。
以上がイベントループによる非同期処理の仕組みになります。
実は、JavaScriptにおける非同期処理と言うのはES6よりも前(2015年以前)から使えたのですが、コールバック地獄(コールバック関数のネストが深くなる)というコードの可読性が悪いという問題が顕在化していました。
次に、そのコールバック地獄について解説します。
コールバック地獄
コールバック地獄について、setTimeout関数を用いて説明します。
setTimeout(function () {
console.log('1秒経過しました。');
setTimeout(function () {
console.log('2秒経過しました。');
setTimeout(function () {
console.log('3秒経過しました。');
// 更にネストを続けることも可能です
setTimeout(function () {
console.log('4秒経過しました。');
}, 1000);
}, 1000);
}, 1000);
}, 1000);
上記のコードは、setTimeout をネストして使用しています。各setTimeoutのコールバック関数内で、指定した時間の経過後にログが表示されます。
このコードの問題は、コールバック関数がネストされるたびにインデントが深くなり、コードの可読性が低下することです。また、エラーハンドリングや複雑な制御フローの追加も困難になります。
このような問題を解決するために、後で解説する『Promise』や『async/await』を使用することで、より直感的で読みやすい非同期処理のコードを実現できるようになりました。
Promise
『Promise』は、非同期処理を扱うためのオブジェクト。Promiseを使うことで非同期処理のネストが深くなることを下げられるため、コードの可読性が向上します。
Promiseについては 【ES6】 JavaScript初心者でもわかるPromise講座 の記事がわかりやすいと思いましたので参考までに。
上記の『コールバック地獄』で書いたコードを、Promiseを使って書き換えたのが以下になります。
function delay(ms) {
return new Promise(function(resolve) {
setTimeout(resolve, ms);
});
}
delay(1000)
.then(function() {
console.log('1秒経過しました。');
return delay(1000);
})
.then(function() {
console.log('2秒経過しました。');
return delay(1000);
})
.then(function() {
console.log('3秒経過しました。');
return delay(1000);
})
.then(function() {
console.log('4秒経過しました。');
});
上記のコードでは、delay関数を定義し、指定された時間の経過後(上記のコードだと1000ms後)にPromiseを解決するようにしています。次に、delay関数を使用して非同期処理を逐次的に実行するためにthenメソッドを使い、Promiseチェーンを作成します。
delay(1000) の呼び出しにより、最初の1秒の待機が行われます。その後、.then メソッドを使用して待機後の処理を登録します。各 .then メソッドでは、指定された時間の経過後にログを表示し、次のdelay関数の呼び出しを返します。これにより、次の待機が始まります。
このようにPromiseを使用することでコールバック関数を使った非同期処理よりも、直感的かつシンプルに表現することができます。各待機の間には .then メソッドを使用してコードを記述することで、コールバック地獄のようなネストを避けることができます。
次に、Promiseを使ったコードを更にわかりやすく記述できる『async/await』構文がありますので解説します。
asyncとawait
まず最初にasyncとawaitの用語について説明します。
asyncとは
関数を定義する際、関数の先頭に付けることによって、非同期関数(asyncFunction)という特殊な関数を定義できる。また、async関数がreturnで返す値は暗黙的にPromiseでラップされた値になる。
awaitとは
awaitをPromiseインスタンスの前に記述することで、Promiseのステータスがsettled(fullfilledまたはrejected)になるまで、後続のコードの実行を待機する。なお、awaitは、非同期関数内(async function)でしか使用できない。
Promiseの解説で使用したコードをasync/await構文で書き直したものが以下になります。
function delay(ms) {
return new Promise(function(resolve) {
setTimeout(resolve, ms);
});
}
async function execute() {
await delay(1000);
console.log('1秒経過しました。');
await delay(1000);
console.log('2秒経過しました。');
await delay(1000);
console.log('3秒経過しました。');
await delay(1000);
console.log('4秒経過しました。');
}
execute();
上記のコードでは、非同期関数executeを定義し、awaitを使用して非同期処理の完了を待機します。awaitを使用することで、非同期処理の結果が『fullfilled』または『rejected』になるまで後続の処理が実行されません。
await delay(1000);のように非同期処理の呼び出しの前にawaitを置くことで、指定された時間の経過を待機します。その後、console.logを使用して経過した時間を表示します。
awaitを使用することで、コードが同期処理のように書けるので読みやすくなり、コールバック地獄やPromiseチェーンを避けることができます。
また、awaitで受けたPromise内でrejectが実行された場合には例外を発生させます。そのため、Promise内のcatchメソッドで行っていた失敗時の処理は、try~catch構文で処理できるようになり、例外処理も同期処理のように書けるようになるというメリットがあります。
最後に
ここまで記事を読んでいただきありがとうございました。
この記事を見ていただいた方が少しでも非同期処理について理解が深まれば嬉しいです。
記事の中に間違いなどありましたらご指摘いただけると幸いです。