はじめに
「ITインフラの仕組み」を読んでいく中で、同期・非同期についてのテーマがあり、改めて非同期処理について調べていたところ、色々な気づきがあったので今回記事にまとめてみました!
非同期処理とは?から、実行される際のキューとタスクの関係についても動画でまとめているので、よかったらみてください!
非同期処理とは
ある処理が終了するのを待たずに、別の処理を実行することを非同期処理といいます。非同期処理を日常生活でのやり取りに置き換えるとこんな感じです。
家族の例
- お母さん→息子に対して、「洗濯物干しといて」と指示
- 指示してる間に、お母さんは洗い物をやってしまう
- 洗い物が終わったタイミングで、息子の作業が終了。「洗濯物干し終わったよ〜」と言われる
もし、これを同期処理に置き換えると…
- お母さん→息子に対して、「洗濯物干しといて」と指示
- 指示してる間、お母さん待機
- 息子の作業が終了。「洗濯物干し終わったよ〜」と言われる
同期処理だと、待機時間が発生するわけです。
とても非効率ですね。
これと同じような事がWeb上で起きた場合、一度リクエストを送ると毎回毎回レスポンスが返ってくるのを待つことになるわけです。例えば、GoogleMapなどで、非同期通信が使われない場合、画面上を少し操作する度に読み込みされて白い画面になると考えるととても不便である事がわかると思います。
JSランタイム
非同期処理が何であるかは、なんとなく理解いただけたと思います。ここからはコードを見ながら、実際にどんな実行順序で、どのタイミングでキューやスタックに積まれていくのかを見ていきます。
JSランタイムには、キューとスタック、イベントループなどがあり(他にもヒープ・WebAPIなどがある)これらの実行順序を5つのコードをもとに検証します。
今回使用したツールは、JS Visualizer 9000というものになります。気になる方は、自分の手元でも試してみてください!
まずは、こちらのコードをご覧ください。
console.log(1);
setTimeout(() => console.log(2), 5000);
console.log(3);
setTimeout関数(TimerAPI)を使用して、「5秒後に2を表示する」という処理になっているので、「5秒後に2を表示する」を前提に考えると、出力結果としてはこんな感じでしょうか。
1
3
2
では、処理順序を詳しくみていきたいと思います。
処理の流れとしては以下になります。
1. “1”が表示される
2. setTimeout関数の引数である無名関数が、Task Queueに積まれる
3. “3”が表示される
4. イベントループにより、Task Queueにあった処理がCall Stackに移動
5. Call Stack内の処理が実行される→取り除かれる
このような処理の流れとなっています。
つまり、「5秒後に2を表示する」という解釈では不十分で、「指定された時間後にCall Stackに処理が積まれる」というのが正確な理解になります。
※WebAPI上では、setTimeoutが5秒後実行するための待機をしています。今回の処理は、その引数である無名関数が、Task Queueに積まれています。
次にこちらのコードをご覧ください。
function greeting() {
sayHi();
}
function sayHi() {
return "Hi!";
}
greeting();
処理順序はどうなるでしょう。
処理の流れとしては以下になります。
1. 8行目のgreetingから、呼び先の関数greetingが呼ばれ、Call Stackに積まれる
2. SayHi関数の処理が、Call Stackに積まれる
3. 後から入ってきたSayHiが先に実行される
4. 次にgreetingが実行される
スタックはLIFO(Last In, First Out)であるため、積み重なった上の部分から実行されます。
先ほどの処理は、setTimeout関数が使用されていたため、キューに積まれましたが、今回は同期処理のみのため、待ち時間は発生せずCall Stackに積まれる形となりました。
次にこちらのコードをご覧ください。
const hoge = () => {
return "hoge"
}
const fuga = () => {
piyo()
}
const piyo = () => {
return "piyo"
}
setTimeout(hoge, 3000)
fuga()
処理順序はどうなるでしょう。
処理の流れとしては以下となります。
1. setTimeout関数の引数であるhogeが、Tack Queueに積まれる
2. fugaが呼び出され、宣言されている無名関数がCall Stackに積まれる
3. piyoが呼び出され、宣言されている無名関数がCall Stackに積まれる
4. piyoのreturnが実行される
5. fugaが実行される
6. イベントループが再レンダリングをし、Tack Queueに積まれている処理をCall Stackに移動
7. hogeで宣言されている無名関数が実行される
待機待ちとなっているhogeが、fugaの後に積まれると思いきや、fugaとpiyoの処理が終わった後に、スタックが空になったことをイベントループが確認してから、スタックに積まれています。
つまり、同期処理を先に終えてから非同期処理が実行されているわけです。
一番初めの処理の考え方からいくと、「3秒後に、Call Stackに処理が積まれる」というわけです。
次にこちらのコードをご覧ください。
function a() {
return b();
}
function b() {
return c();
}
function c() {
return "ok";
}
function output(fn) {
console.log(fn);
}
output(a());
処理順序はどうなるでしょう。
処理の流れとしては以下となります。
1. 17行目のoutput関数の引数であるa()が呼び出され、Call Stackに積まれる
2. 次にbがCall Stackに積まれる
3. 次にcがCall Stackに積まれる
4. cのreturnが実行される
5. bのreturnが実行される
6. aのreturnが実行される
7. Call Stackが空になった状態で、13行目のoutput関数がCall Stackに積まれる※この時、outputは(”ok”)を返り値として保持してる
8. 引数であるfn=”ok”をconsole.logの引数に渡し、処理が実行される
僕は、この処理を見た時に、13行目のoutput関数が呼ばれてから17行目→1行目の流れになると思っていました。
しかし、実際には13行目の関数が先に評価されます。JavaScriptでは、関数を評価するとき、関数内の全ての関数呼び出しを評価するため、aを呼び出してその結果をoutputに渡すという処理が先に評価されるようです。
確かに、13行目が先にスタックに積まれて実行されてもまだ値も渡されていないので、先に値を持っている関数を実行すると考えれば、自然かもしれません。
最後にこちらのコードをご覧ください。
function executeA(callback){
console.log(2);
callback();
}
function executeB(callback){
console.log(3);
callback();
}
const say5 = function() {
executeB(say4);
console.log(5);
}
const say4 = function(){
console.log(4);
}
console.log(1);
executeA(say5);
console.log(6);
処理の流れとしては以下となります。
1. “1”が表示される
2. executeAが、Call Stackに積まれる
3. “2”が表示される
4. 3行目のcallback()により、say5で宣言されている無名関数がCall Stackに積まれる
5. executeBが、Call Stackに積まれる
6. “3”が表示される
7. 8行目のcallback()により、say4で宣言されている無名関数がCall Stackに積まれる
8. “4”が表示される
9. say4で実行された無名関数が、Call Stackから取り除かれる
10. executeBが、Call Stackから取り除かれる
11. “5”が表示される
12. say5で実行された無名関数が、Call Stackから取り除かれる
13. executeAが、Call Stackから取り除かれる
14. “6”が表示される
JavaScriptでは、関数が呼び出されると、その関数内のコードが順番に実行されます。内部で別の関数が呼び出された場合、その関数も同様に順番に評価されるため、関数の中に別の関数がある場合、内側の関数から順に外側の関数まで評価される形で進みます。また、スタックの動作として最後に呼び出された関数が最初にスタックから取り除かれることを考えると(LIFO)、executeAやBが引数で呼び出していた関数の実行が終了した時点で、同様にスタックから取り除かれるのも納得です。
JavaScriptインタプリターの順次実行がよくわかる一例でした。
参考