こんにちはNaotoです。
今回は、個人開発をする中で、JavaScriptの非同期処理についてちゃんと理解しきれていない部分があると感じたので、同期処理と非同期処理の違いや、コールバック関数、Promise、async、awaitなどについて、調べてまとめてみました。
同期処理とは?
同期処理とは、コードをメインスレッドで順番に処理し、1つの処理が終わるまで次の処理を行わない処理方法です。
console.log("task1");
//時間のかかる処理
for (let i = 0; i < 10000000000; i++) {}
console.log("task2");
上記は同期処理の例です。
console.log(”task1”)の実行後、時間のかかる処理(For文の処理)の完了を待ってから、console.log(”task2”)が実行されるため、”task1”が出力されてから、”task2”が出力されるまでに待ち時間が発生してしまいます。
同期処理を日常生活で例えると・・・
風呂→洗濯→料理
の場合、洗濯が回し終わるまで、何もできず座って待機している感じです。
とても非効率です。
非同期処理とは?
非同期処理とは、時間のかかる処理をメインスレッドとは別の、”Web API”という環境で行う処理方法です。同期処理(メインスレッド)の裏側で実行しといてくれているイメージです。
console.log("task1");
setTimeout(() => {
console.log("非同期処理");
}, 1000);
console.log("task2");
上記は、非同期処理として処理されるsetTimeout()を使った例です。
コンソールには以下の順番で出力されます。
>task1
>task2
>非同期処理
setTimeout()をWeb API(裏側)で実行している間にメインスレッドでconsole.log(”task2”)が実行されるため、console.log("非同期処理")の完了を待たなくて良いのです。
非同期処理を日常生活で例えると・・・
風呂→洗濯→料理
の場合、洗濯を回している間に料理をする感じです。
とても効率的です。
上記setTimeout()は非同期処理の一例です。
他にも例えば、
”クライアント側からAPIを叩いてデータを取得する”などの処理は、非同期(メインスレッドとは別のWeb API)で行わないと、APIサーバとの通信が終わるまでメインスレッドが占有されてしまい、ユーザーによるブラウザ操作や、レンダリング等ができなくなり、ブラウザが固まってしまいます。
上記のような理由から、非同期処理を行うことはとても重要です。
非同期処理の実行順
では非同期処理が複数ある場合は、どのような順序で実行されるのでしょうか。
以下のコードの出力結果を予想してみてください。
//非同期処理A
setTimeout(() => {
console.log("task1");
}, 1200);
//非同期処理B
setTimeout(() => {
console.log("task2");
}, 1100);
//非同期処理C
setTimeout(() => {
console.log("task3");
}, 1000);
正解は、以下のようになります。
>task3
>task2
>task1
非同期処理A, B, Cのタイマー処理が並列でWeb API上で行われ、それぞれのタイマーが満了する順に、コールバック関数内の処理が行われます。
※厳密に言うと、非同期処理(ここでいうsetTimeout)により生成された処理(ここでいうコールバック関数)は、タスクキューというキューに追加されたのち、コールスタックに積まれ、順番にメインスレッドで実行されます。ここら辺の説明は割愛します。
つまり、タイマーの時間が最も短い、1000ms の 非同期処理Cのコールバック関数 が最初に実行され、次に 1100ms の 非同期処理B、最後に 1200ms の 非同期処理Aという順にコールバック関数 が実行されます。
では、 非同期処理A → B → C の順で実行させたい場合、どうすれば良いでしょうか。
非同期処理の実行順を制御する方法は主に3つあります。
- コールバック関数(一番めんどくさい)
- Promisによるメソッドチェーン(ちょっと便利)
- async, awaitの使用(かなり便利)
上記3つとも、やっていることは一緒です。
1つずつ説明します。
非同期処理の実行順の制御
コールバック関数
1つ目はコールバック関数を使用する方法です。
//非同期処理A
setTimeout(() => {
console.log("task1");
//非同期処理B
setTimeout(() => {
console.log("task2");
//非同期処理C
setTimeout(() => {
console.log("task3");
}, 1000);
}, 1100);
}, 1200);
上記のようにコールバック関数を使用することで、
非同期処理A → B → C
の順で実行されるようになります。
ただこれめちゃくちゃ読みづらいですよね。
上記は、3つの非同期処理だけなのでまだ良いですが、8つにしたらこんな感じになってしまいます。
setTimeout(() => {
console.log("task1");
setTimeout(() => {
console.log("task2");
setTimeout(() => {
console.log("task3");
setTimeout(() => {
console.log("task4");
setTimeout(() => {
console.log("task5");
setTimeout(() => {
console.log("task6");
setTimeout(() => {
console.log("task7");
setTimeout(() => {
console.log("task8");
}, 1800);
}, 1700);
}, 1600);
}, 1500);
}, 1400);
}, 1300);
}, 1200);
}, 1100);
これが、波動拳 a.k.a コールバック地獄です。
Promiseによるメソッドチェーン
上記コールバック関数による読みづらさを解決したのが、Promiseによるメソッドチェーンです。
まずはPromiseについて以下雛形を用いて説明します。コード内のコメントをお読みください。
// Promiseは非同期処理を行うためのオブジェクトになります。
// resolveとreject(命名は自由)という2つの引数を持つ関数(コールバック関数)をコンストラクタに渡しインスタンス化します。
// そして、コールバック関数内で非同期処理を行い、処理に成功した場合はresolve()を、失敗した場合はreject()を呼び出します。
const myPromise = new Promise((resolve, reject) => {
// 非同期処理をここに記述
let success = true; // 成功か失敗かをシミュレート
if (success) {
resolve("処理が成功しました!"); // 成功時にresolveを呼び出す
} else {
reject("処理が失敗しました..."); // 失敗時にrejectを呼び出す
}
});
// 上記で生成したPromisnのインスタンス(myPromise)を使って非同期処理を実行します。
// Promiseは、then(), catch(), finally()というメソッドを持っており、
// resolve()が実行されたときは、then()を、reject()が実行されたときはcatch()を、最終的には必ずfinally()を実行するようになっています。
myPromise
.then((message) => {
console.log(message); // 成功時の処理
})
.catch((error) => {
console.error(error); // 失敗時の処理
})
.finally(() => {
console.log("bye"); // 最後に必ず実行される。
});
上記の場合、success === trueとなり、resolve()が呼び出されるため、then()が実行され、成功メッセージを出力し、処理を終了します。
上記の場合、1回の非同期処理のみとなりますが、then()のコールバック関数内で、再度myPromiseを実行し、Promiseをreturnすることで、以下のように非同期処理を複数つなげて実行することもできます。
これがメソッドチェーンです。
myPromise
.then((message) => {
console.log(message); // 成功時の処理
return myPromise; // 再度myPromiseを実行し、Promiseをreturnする。成功した場合、下のthenに続く。
})
.then((message) => {
console.log(message); // 成功時の処理(上のthen()の中で、Promiseがreturnされていないと、このthen()は動かない。
.catch((error) => {
console.error(error); // 失敗時の処理
})
.finally(() => {
console.log("bye"); // 最後に必ず実行される。
});
Promiseの基本について説明したところで本題に戻りますが、
上記メソッドチェーンを使用することで、
非同期処理A → B → C
の順で実行できるようになります。
以下がコード例です。
(ここでは非同期処理の失敗を想定していないため、rejectは省略しています。)
//非同期処理A
const task1 = new Promise((resolve) => {
setTimeout(() => {
console.log("task1");
resolve();
}, 1200);
});
//非同期処理B
const task2 = new Promise((resolve) => {
setTimeout(() => {
console.log("task2");
resolve();
}, 1100);
});
//非同期処理C
const task3 = new Promise((resolve) => {
setTimeout(() => {
console.log("task3");
resolve();
}, 1000);
});
//非同期処理A
task1
.then(() => {
return task2; // 非同期処理Aが成功したら非同期処理B
})
.then(() => {
return task3; // 非同期処理Bが成功したら非同期処理C
});
出力結果は以下のようになります。
>task1
>task2
>task3
以上が、Promiseによるメソッドチェーンを用いた、非同期処理の実行順の制御方法になります。
async, awaitの使用
Promiseによるメソッドチェーンを使うことで、コールバック関数の使用に比べれば、かなりわかりやすく非同期処理の実行順を制御できるようになったものの、then()をチェーンさせる感じや、then()内で、Promiseを返す必要があるなど、まだ少し書きずらい、という感じはあると思います。
そこで登場したのが、async, awaitになります。
async、awaitを使えば、メソッドチェーンなどを使わなくても、同期処理と同じような書き方で非同期処理を実行することができます。
基本ルールは以下です。
- 非同期処理を呼ぶときは必ずawaitをつける。
- awaitはasyncをつけた関数内で必ず使う。(トップレベルでは使えない。)
//非同期処理A
const task1 = new Promise((resolve) => {
setTimeout(() => {
console.log("task1");
resolve();
}, 1200);
});
//非同期処理B
const task2 = new Promise((resolve) => {
setTimeout(() => {
console.log("task2");
resolve();
}, 1100);
});
//非同期処理C
const task3 = new Promise((resolve) => {
setTimeout(() => {
console.log("task3");
resolve();
}, 1000);
});
// async/await で書き換え
async function runTasks() {
await task1; // 非同期処理A
await task2; // 非同期処理B
await task3; // 非同期処理C
}
runTasks();
このように、async、awaitを使うことで、記述が簡単になったかと思います。
もちろん非同期処理なので、非同期処理Aが完了しない限り、非同期処理Bは実行されません。
まとめ
上記を簡単にまとめるとこんな感じです。
- 同期処理は処理が1つずつ順に実行され、時間のかかる処理がある場合、待機時間が発生する。
- 非同期処理は同期処理の裏側(Web API)で実行され、メインスレッドはその間に同期処理を進めることができる。
- コールバック関数、Promise、async/awaitを使って非同期処理の順序を制御できるが、async/awaitが最もシンプルでわかりやすい。
普段何気なくasync、awaitと簡単に書いていたのですが、その背景や深い意味まで理解することができたかと思います。
今回はコールスタックやタスクキュー(macro tasks, micro tasks)、Web API、イベントループなどを用いた、JavaScriptのの実行の流れみたいなところまでは、まとめきれなかったので、今後時間があったら追記していく予定です。
最後までお読みいただきありがとうございました。