【初心者】setTimeoutから非同期通信を考えてみる
はじめに
jsを触り始めた頃から、setTimeout
にはお世話になってきました。
当時はsetTimeout
=「処理の実行タイミングを指定秒数遅らせる」という理解でいたため、初心者の私でもイメージしやすかったのです。
一方で、なぜか希望のタイミングで実行されなかったり、callback地獄ならぬsetTimeout地獄を作ったりと苦戦した思い出も多い関数です。
しかし最近Promise
やajax
の非同期処理(通信)に触れる中で、setTimeout
が当時の知識と実は違っていたことや、あの時のエラーの原因が分かってきました。
ここで一度、非同期処理の勉強も兼ねて自分のためにまとめておきます。
先に結論
setTimeout
=「処理の実行タイミングを指定秒数遅らせる」は間違い。
setTimeout
は「タスクキューへの登録を指定秒数遅らせる」関数です。
決して実行タイミングではないので、他処理の内容などによっては、指定秒数より遅れることもあります。
タスクキューって何? の前に、まずはsetTimeout
を理解するために、同期処理と非同期処理を見ていきます。
同期処理と非同期処理
同期処理について
前の処理が終わったら、次の処理が実行されます。
言い方を変えると、前の処理が実行されないと次の処理が実行されません。
同期処理の例
const task1 = () => console.log('1番目');
const task3 = () => console.log('3番目');
const task2 = () => console.log('2番目');
task1();
task2();
task3();
結果
1番目
2番目
3番目
上記の例はイメージしやすいかと思います。
関数の定義順に関わらず、処理の実行順でconsoleにテキストが表示されます。
シンプルですね。
一方の非同期処理とは
同期処理のような「実行順通り」の動き方をしません。
前の処理が終わる前に次の処理が実行されたりします。
非同期処理の例
const task1 = () => console.log('1番目');
const task3 = () => console.log('3番目');
const task2 = () => setTimeout(() => {console.log('2番目'), 1000});
task1();
task2();
task3();
結果
1番目
3番目
2番目
上記ではtask2
関数の中にsetTimeout
を追加しました。
すると実行順は1→2→3に関わらず、結果は1→3→2とconsoleに表示され、同期処理とは異なる順で処理が実行されているのが分かります。
「setTimeoutで実行が1000ミリ秒遅れるんだから当たり前じゃない?」と思った方。
私もずっと同じように考えていました。
ではこの場合はどうでしょう?
setTimeoutの第2引数を0にした時
const task1 = () => console.log('1番目');
const task3 = () => console.log('3番目');
const task2 = () => setTimeout(() => {console.log('2番目'), 0});
task1();
task2();
task3();
結果
1番目
3番目
2番目
結果は変わらず、1→3→2とconsoleに表示されます。
もしsetTimeout
が処理の実行タイミングのみに関わるなら、「0秒 = 遅れは無い」ので1→2→3でも良さそうですがなぜでしょうか。
ここで出てくるのがタスクキューです。
タスクキュー
jsでは実行前の非同期処理を管理する機能としてタスクキューを持っています。
さらに今回のトピックには、コールスタックとイベントループという機能も登場します。
それぞれの役割は以下です。
- タスクキュー:実行前の非同期処理が追加されていく場所
- コールスタック:呼び出された関数の位置を追跡する機能(呼び出された実行対象の関数が追加されていく)
- イベントループ:コールスタックが空の場合、タスクキュー内のタスクをコールスタックへ取り出す
このタスクキュー達が同期/非同期処理でどう動くのかを見ていきます。
同期処理の場合
先に同期処理の場合の動きを見ていきます。
前述の処理を少し変更して、task2
がtask3
の中で呼び出されるようにしました。
const task1 = () => console.log('1番目');
const task2 = () => console.log('2番目');
const task3 = () => {
task2();
console.log('3番目');
}
task1();
task3();
結果
1番目
2番目
3番目
コールスタック側の処理の流れ
-
task1();
に来た時点で、task1
関数をコールスタックにプッシュ(追加)。
-
task1
の処理(console.log('1番目');
)が実行され、task1
はコールスタックからポップ(削除)される。ここまではごくシンプルな動きです。
-
次に
task3();
に来た時点で、task3
関数をコールスタックにプッシュ。
-
task3
内の処理を進め、task2();
に来た時点で、task2
関数をコールスタックにプッシュ。
-
task2
の処理(console.log('2番目');
)が実行され、task2
はコールスタックからポップされる。
-
task3
の残りの処理(console.log('3番目');
)が実行され、task3
はコールスタックからポップされる。
task1
は呼出時にそのままコールスタックに追加&実行後にポップされます。
その後コールスタックにプッシュされたtask3
は関数内で別の関数であるtask2
を呼び出しています。この場合は、task3
自体は先にプッシュされますが、task2
呼出時点で同じくtask2
がコールスタックに追加&先に実行、その後task3
の残りの処理が改めて実行されてからポップされます。
今回の処理では非同期処理(setTimeout
)を使用していないので、タスクキューは登場しません。
ちなみに、task3
より後から来たtask2
が先にポップされているような動きを、先入後出し方式/LIFO(Last In First Out) と言います。
非同期処理の場合
今度はtask2
にsetTimeout
を設定しました。これにより、処理の中にタスクキューが登場します。
const task1 = () => console.log('1番目');
const task2 = () => setTimeout(() => {console.log('2番目'), 1000});
const task3 = () => {
task2();
console.log('3番目');
}
task1();
task3();
結果
1番目
3番目
2番目
コールスタック・タスクキュー側の処理の流れ
-
task1();
に来た時点で、task1
関数をコールスタックにプッシュ&実行後にポップ。 -
次に
task3();
に来た時点で、task3
関数をコールスタックにプッシュ。ここまでは先ほどの流れと同じです。 -
task3
内の処理を進め、task2();
に来た時点で、setTimeout
によりtask2
は一秒後にタスクキューへ追加される。
-
task3
の残りの処理が実行され、task3
はコールスタックからポップされる。
-
task3
ポップ後、コールスタックが空になったことがイベントループでタスクキューに知らされ、タスクキュー内のtask2
がコールスタックへ移される
6.task2
が実行&ポップされる。
非同期に処理される内容は一度タスクキューへ移されます。その間に同期処理は直接コールスタックにポップされ順番に処理されていきます。
そしてコールスタック内の処理が完了すると、タスクキュー内の処理が実行されていきます。
コールスタック内が空にならないと、タスクキュー内の処理は実行されません。
setTimeoutの引数が0の場合も、即座にタスクキューに移されるだけで、処理自体はコールスタック内が空になった後に実行されます。そのため先ほどの例でも、setTimeout
のconsoleが最後に表示されたのでした。
またコールスタックが空になるまで時間がかかれば、その分タスクキューへ処理が移るのも遅くなるため、指定した秒数以上経ってからsetTimeout
内の処理が行われることもあります。
まとめ
1.setTimeout
は「タスクキューへの登録を指定秒数遅らせる」関数
2.本来jsは1度に1つの処理しかできない
3.コールスタックが空になると、イベントループによりタスクキュー内の処理がコールスタックへ移動し実行される
現在はsetTimeout
を使う機会はあまり多く無いかもしれませんが、setTimeout
を切り口にタスクキューなどに関する理解を初心者なりにまとめてみました。
タスクキューをもっと深掘りすると、マクロ/マイクロタスクの話になったり、callback関数やPromise
などもっと広がっていくので、また別の機会で自分用に記事にしようと思っています。
Discussion