曖昧な理解ではなく、確実な理解を目指す立場から、まずコルーチンとイベントループを簡単に説明してから、Promiseとasync/awaitを解説します。
コルーチンと yield
:フィボナッチ数列のジェネレーター
コルーチン(coroutine)とyield
を理解するには、ジェネレーター関数を用いると効果的です。ここでは、フィボナッチ数列を生成する例を通じて説明します。
フィボナッチ数列のジェネレーター
フィボナッチ数列は、次の数が前の二つの数の和である数列です。以下に、フィボナッチ数列を生成するジェネレーター関数を示します。
function* fibonacci() {
let [a, b] = [0, 1];
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
このジェネレーター関数fibonacci
を使用する呼び出し元のコード例は以下の通りです:
const gen = fibonacci();
for (let i = 0; i < 10; i++) {
console.log(gen.next().value);
}
この例では、gen
というジェネレーターオブジェクトを作成し、next()
メソッドを呼び出すことで次のフィボナッチ数を取得しています。yield
文によってジェネレーターの実行が一時停止し、次にnext()
が呼ばれるまで状態(ローカル変数a, bに束縛された値と、関数が停止した場所)を保持します。
このように、状態を保ったまま、関数の処理を中断する処理がプログラミング言語の機能として実装される場合があります。
特に、このような処理の中断機能をうまく使うと、シングルスレッドでもIO待ち中などに他の処理を実行するといったことができるようになります。このようや考え方をマイクロスレッド、ファイバーなどと呼ぶこともあります。
JavaScriptのイベントループと実行キュー
JavaScriptの非同期処理は、イベントループ(event loop)と実行キュー(execution queue)によって管理されます。イベントループは、コールスタックと実行キューを監視し、非同期イベントを処理します。
イベントループとユーザークリック
次に、具体的な例を通じて説明します。以下のコードは、長時間の処理とユーザークリックイベントを含んでいます:
console.log("Start");
setTimeout(() => {
console.log("Timeout callback");
}, 2000);
for (let i = 0; i < 1e9; i++) {
// 長時間の処理
}
document.addEventListener('click', () => {
console.log("User clicked");
});
console.log("End");
このコードの実行順序は以下のようになります:
- "Start"が出力される。
- タイマーがセットされるが、
setTimeout
のコールバックは2秒後に実行される。 - 長時間の処理が実行される。この間、クリックイベントやsetTimeoutの処理は実行されない。
- もしユーザーがクリックしても、長時間の処理が終わるまでは"User clicked"は出力されない。
- "End"が出力される。
- 長時間の処理が終わると、実行キューに保留されていたクリックイベントのコールバックが実行される。
- タイマーの2秒が経過していれば、タイマーのコールバックが実行される。
JavaScriptは(WebWorkerを除いて)シングルスレッドモデルであるため、同時に複数の処理が行われることはなく、ユーザーイベントやタイマー処理は以上のような方法で処理されます。
Promiseの動作
Promiseのコンストラクタとresolve
/reject
JavaScriptのPromise
は、非同期処理を扱うためのオブジェクトです。以下のコードは、Promiseの基本的な使い方を示しています:
const promise = new Promise((resolve, reject) => {
// 非同期処理
setTimeout(() => {
resolve('Success!');
}, 1000);
});
resolve
とreject
は、Promiseの状態を成功(fulfilled)または失敗(rejected)に変更するために使われます。resolve
とreject
という変数は、新しく作られるPromiseインスタンスへの参照を持つ、特別な組み込み関数であるResolveもしくはRejectオブジェクトに束縛されています。
then
による更新とコールバックの登録
then
メソッドは、Promiseが解決(または拒否)され状態が更新された後自動で実行されるべき処理を登録するために使われます。
promise.then((result) => {
console.log(result); // 'Success!'
});
この例では、Promiseが成功したときに実行されるコールバックが登録されます。Promiseが解決されたとき、実行キューにこのコールバックが追加されます。
resolve
/reject
の検知と実行キュー
resolve
またはreject
は、特別な組み込み関数として、内部的にタグがつけられているので、これらが呼び出されるとJavaScriptの実行エンジンはそれを検知します。そして、対応するPromiseオブジェクトの参照をたどり、該当するコールバックを実行キューに追加します。例えば、以下のように:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Done!');
}, 2000);
});
promise.then((result) => {
console.log(result);
});
この例では、2秒後にresolve
が呼ばれ、then
に登録されたコールバックが実行キューに追加され、次にイベントループが回り、キューが消化されるときに実行されます。
async/awaitの解説
同期処理の例①
まず、readが同期処理である場合の例を示します:
function process() {
const data = read();
console.log(data);
}
process();
コールバック引数にまとめる例②
①と同様の同期処理を、人工的にコールバックにした例を示します:
function immediate(value) {
return {then: (callback)=>
callback(value)};
}
function process() {
immediate(read()).then(data => console.log(data));
}
process();
この例では、readしたあとの処理がコールバック関数にまとめられていますが、同期的に呼び出され、まったく①と同じ動作になります。
awaitを用いる非同期処理の例③
async
/await
を用いた非同期処理の例は以下の通りです:
async function process() {
const data = await read_async();
console.log(data);
}
process();
awaitの内部動作:例④
例③の内部では、次のようにPromiseを用いて処理がコールバックにまとめられています:
function process() {
read_async().then((data) => {
console.log(data);
});
}
process();
②の場合、thenを呼び出すとcallback関数がその場で呼び出されるため、記述の上では read の前と後の処理は別々に書かれていますが、実行の上では即時に順番に実行されます。
一方で、④の場合はPromiseであるので、callback関数を登録しただけでJavaScript実行キューの処理を終えます。その後、読み取り処理が完了すると、read_asyncが返したPromiseに関連付けられたresolveが実行され、callbackを実行する処理がキューに追加されます。従って別々のタイミングで実行されます。
④のように、実行のタイミングが異なる処理を書くと記述が煩雑になるため、②の処理を①のように書けるのと同じように、④を③のようにかけるようにしたのがasync/await構文です。
このような構文の変換は難しいように思われるかもしれませんが、CPS(継続渡しスタイル)変換といい、コンパイラーやインタプリターが機械的に変換可能です。
Comments
Let's comment your feelings that are more than good