ちょっと踏み込んだ視点でJavaScriptの非同期処理をみてみる
シングルスレッドで実行されているJavaScriptが非同期処理をどのように実現しているのか、少し踏み込んで調べてみました。
正直このあたりを知らなくても開発に差し支えはないと思いますが、仕組みを理解していることで性能面を考慮した開発ができるようになったり、他言語との差別化にも繋がると思います。
JavaScript初・中級者の方にとって、ステップアップするきっかけになればと思います。
※下記の記事が非常に参考になったので、参考リンク等はここから引用させていただいてます。
もっと詳しく知りたい方はこちらからどうぞ。
一度でまとまり切らなかったため、②に続く予定です。
概要
- 調査に至った経緯
- JavaScriptが非同期処理をシングルスレッドで実現する仕組み
調査に至った経緯
調査に至った経緯としては、こちらの記事を見ていたことが始まりです。
記事の中ではJavaScriptがシングルスレッドで実行されているということをコードベースで説明されており、処理順序に関する問題も出題されているのですが、見事に間違えてしまいました。
console.log(`Start ${new Date()}`);
// 3秒後にCallbackと表示
setTimeout(() => {
console.log(`Callback ${new Date()}`);
}, 3000);
// 5秒間かかる処理を実行する
const now = new Date().getTime();
while (new Date().getTime() - now < 5000) {
// 適当な処理
}
console.log(`End ${new Date()}`);
どうして...非同期処理なのだからwhile処理の途中で合流するはずじゃ?
これはシングルスレッドであるために、このような処理順となっています。
では、上記の処理の後に5秒かかる処理を実行するとどうなるでしょうか。
const singleMain = (): void => {
console.log(`Start ${new Date()}`);
// 3秒後にCallbackと表示
setTimeout(() => {
console.log(`Callback ${new Date()}`);
}, 3000);
// 5秒間かかる処理を実行する
const now = new Date().getTime();
while (new Date().getTime() - now < 5000) {
// 適当な処理
}
console.log(`End ${new Date()}`);
// 5秒かかる非同期処理を追加
setTimeout(() => {
console.log(`5秒経過 ${new Date()}`);
}, 5000);
}
最初に設定した「3秒待ってから実行する」方の非同期処理が、最後に設定した非同期処理の前に実行されている。
これはスタックされている非同期処理がFIFOで処理されているということか?(あくまでも推測ですが)
ここで、最後のsetTimeout()を同期処理に置き換えます。
const singleMain = (): void => {
console.log(`Start ${new Date()}`);
// 3秒後にCallbackと表示
setTimeout(() => {
console.log(`Callback ${new Date()}`);
}, 3000);
// 5秒間かかる処理を実行する
const now = new Date().getTime();
while (new Date().getTime() - now < 5000) {
// 適当な処理
}
console.log(`End ${new Date()}`);
// 非同期処理を追加
const afterEnd = new Date().getTime();
while (new Date().getTime() - afterEnd < 5000) {
// 適当な処理
}
console.log(`All End ${new Date()}`);
}
やはり同期処理が一通り完了してから、非同期処理が呼ばれていることが分かります。
そこで、恐らく私の知識不足もあり以下の疑問が生まれます。
- シングルスレッドなのに、どうやって非同期処理を実現している?
- 非同期処理(
setTimeout()
等)が処理を待機している場合、後続処理がそれ以上時間がかかる場合もそうでない場合も、全て(上記だとローカルスコープ)の処理が完了してから非同期処理が実行されるの?- 上記が正しい場合、非同期処理と言いつつ結局のところはコールスタックの順番で処理されている?
- でも途中で合流する(ように見える)時もあるし、なにが違うんだ?
- このように動くと勘違いしていた可能性。。
- 複数の非同期処理はFIFOで実行されている?
このような経緯から、JavaScriptの非同期処理について理解するために調査を始めました。
JavaScriptが非同期処理をシングルスレッドで実現する仕組み
簡単に言うと下記の仕組みによって実現しています。
- 外部APIを使用
- ここで言う外部APIは「実行環境が提供するAPI」のこと(ブラウザやnode.js)
-
fetch()
やsetTimeout()
もこの一つ - ブラウザが提供するAPI
- 一般的にjsの機能だと思って使用しているものは、jsの言語規格(ECMAScript)+実行環境が提供するAPIとなる
- DOM APIもブラウザ(実行環境)が提供している
- 上記の外部APIを指揮する「イベントループ」が存在する
- イベントループは処理の順番を整理してくれている(後述)
- jsを実行し、画面の再描画を繰り返す
外部APIを使用
箇条書きでも説明してますが、jsの機能だけでは非同期処理を実現することはまず難しいため、実行環境が提供するAPI(外部API)を使用してます。
ブラウザが提供するAPIについてはこちらを参照。
普段なにげなく使用しているsetTimeout()もこのうちの一つになり、一般的にjsの機能だと思って使用しているものは、jsの言語規格(ECMAScript)+実行環境が提供するAPIということになります。
DOM APIもブラウザが提供するAPIです。
外部APIを取りまとめるイベントループ
イベントループは簡単に言うと「処理の順番を整理してくれる」ようなもので、jsを実行→画面の再描画を繰り返すような仕組みを実現しています。
下記の記事が非常にわかりやすいです。
ここでいう「scriptの実行でビジーである間」というのは、最初にお話しした「同期処理が実行されている間」と考えてもよさそう?(これについては②で解説予定)
で、その間に非同期処理(setTimeout()
等)の処理が来るとキューに入り、script(同期処理)の完了後にキューから処理が呼ばれるような形になっています。
また、再描画については処理が完了した後のみに実行されるみたいですね。
実際にイベントループの動きを確認できるサイト
これを見ると、
タスクを取得
↓
コールスタックに積んで消化
↓
レンダリング
↓
タスクを取得
↓
以下繰り返し
という流れが非常にわかりやすいです。
つまり、setTimeout()
やsetInterval()
は、「〇秒後にキューにタスクを追加する」ということで、厳密に〇秒後に実行されるわけではないということが分かります。
また、イベントループの細かな仕様はjsそのものではなくて実行環境ごとに定義されています。
抽象的ではありますが、ECMAScriptでもイベントループが定義されているみたいです。
今回はここまで!
最後に
はじめにも挙げましたが、こちらの記事の多くは下記を参考にさせていただいています。
個人的にわかりやすくまとめようかと思いましたが一度ではまとまり切らなかったので、今回は
- 外部APIを使用
- 上記の外部APIを指揮する「イベントループ」が存在する
ということが理解できたところで終わろうかと思います。
落ち着いたタイミングで
- イベントループの仕組み
- Microtaskについて
- 簡単に触れておくと、「タスクの合間にまとめて処理されるようなタスク。Promiseなど」
ここら辺を読み直した後、②として執筆予定です。
最後まで読んでいただきありがとうございました。
参考記事
- JavaScriptはシングルスレッドで実行される
- jsが非同期処理をシングルスレッドで実現する仕組み〜Web API、イベントループ、MicrotaskとしてのPromise〜
- イベントループ(event loop): microtask と macrotask
- JavaScriptはなぜシングルスレッドでも非同期処理ができるのか/Why Can JavaSctipt Invoke Asynchronous in Single Thread?
- JavaScriptがブラウザでどのように動くのか
- setTimeoutの挙動について
- [JavaScript] スレッドの仕組みから、非同期処理を説明してみる
- 非同期処理を理解するためのロードマップ
- JavaScript Visualizer
- How to work callstack