🗂

【初心者】setTimeoutから非同期通信を考えてみる

2022/09/24に公開

はじめに

jsを触り始めた頃から、setTimeoutにはお世話になってきました。
当時はsetTimeout=「処理の実行タイミングを指定秒数遅らせる」という理解でいたため、初心者の私でもイメージしやすかったのです。
一方で、なぜか希望のタイミングで実行されなかったり、callback地獄ならぬsetTimeout地獄を作ったりと苦戦した思い出も多い関数です。

しかし最近Promiseajaxの非同期処理(通信)に触れる中で、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では実行前の非同期処理を管理する機能としてタスクキューを持っています。
さらに今回のトピックには、コールスタックとイベントループという機能も登場します。
それぞれの役割は以下です。

  • タスクキュー:実行前の非同期処理が追加されていく場所
  • コールスタック:呼び出された関数の位置を追跡する機能(呼び出された実行対象の関数が追加されていく)
  • イベントループ:コールスタックが空の場合、タスクキュー内のタスクをコールスタックへ取り出す

このタスクキュー達が同期/非同期処理でどう動くのかを見ていきます。

同期処理の場合

先に同期処理の場合の動きを見ていきます。
前述の処理を少し変更して、task2task3の中で呼び出されるようにしました。

const task1 = () => console.log('1番目');
const task2 = () => console.log('2番目');
const task3 = () => {
    task2();
    console.log('3番目');
}

task1();
task3();

結果

1番目
2番目
3番目

コールスタック側の処理の流れ

  1. task1();に来た時点で、task1関数をコールスタックにプッシュ(追加)。

  2. task1の処理(console.log('1番目');)が実行され、task1はコールスタックからポップ(削除)される。ここまではごくシンプルな動きです。

  3. 次にtask3();に来た時点で、task3関数をコールスタックにプッシュ。

  4. task3内の処理を進め、task2();に来た時点で、task2関数をコールスタックにプッシュ。

  5. task2の処理(console.log('2番目');)が実行され、task2はコールスタックからポップされる。

  6. task3の残りの処理(console.log('3番目');)が実行され、task3はコールスタックからポップされる。

task1は呼出時にそのままコールスタックに追加&実行後にポップされます。
その後コールスタックにプッシュされたtask3は関数内で別の関数であるtask2を呼び出しています。この場合は、task3自体は先にプッシュされますが、task2呼出時点で同じくtask2がコールスタックに追加&先に実行、その後task3の残りの処理が改めて実行されてからポップされます。
今回の処理では非同期処理(setTimeout)を使用していないので、タスクキューは登場しません。

ちなみに、task3より後から来たtask2が先にポップされているような動きを、先入後出し方式/LIFO(Last In First Out) と言います。

非同期処理の場合

今度はtask2setTimeoutを設定しました。これにより、処理の中にタスクキューが登場します。

const task1 = () => console.log('1番目');
const task2 = () => setTimeout(() => {console.log('2番目'), 1000});
const task3 = () => {
    task2();
    console.log('3番目');
}

task1();
task3();

結果

1番目
3番目
2番目

コールスタック・タスクキュー側の処理の流れ

  1. task1();に来た時点で、task1関数をコールスタックにプッシュ&実行後にポップ。

  2. 次にtask3();に来た時点で、task3関数をコールスタックにプッシュ。ここまでは先ほどの流れと同じです。

  3. task3内の処理を進め、task2();に来た時点で、setTimeoutによりtask2一秒後にタスクキューへ追加される。

  4. task3の残りの処理が実行され、task3はコールスタックからポップされる。

  5. task3ポップ後、コールスタックが空になったことがイベントループでタスクキューに知らされ、タスクキュー内のtask2がコールスタックへ移される

6.task2が実行&ポップされる。

非同期に処理される内容は一度タスクキューへ移されます。その間に同期処理は直接コールスタックにポップされ順番に処理されていきます。
そしてコールスタック内の処理が完了すると、タスクキュー内の処理が実行されていきます。

コールスタック内が空にならないと、タスクキュー内の処理は実行されません。
setTimeoutの引数が0の場合も、即座にタスクキューに移されるだけで、処理自体はコールスタック内が空になった後に実行されます。そのため先ほどの例でも、setTimeoutのconsoleが最後に表示されたのでした。
またコールスタックが空になるまで時間がかかれば、その分タスクキューへ処理が移るのも遅くなるため、指定した秒数以上経ってからsetTimeout内の処理が行われることもあります。

まとめ

1.setTimeoutは「タスクキューへの登録を指定秒数遅らせる」関数
2.本来jsは1度に1つの処理しかできない
3.コールスタックが空になると、イベントループによりタスクキュー内の処理がコールスタックへ移動し実行される

現在はsetTimeoutを使う機会はあまり多く無いかもしれませんが、setTimeoutを切り口にタスクキューなどに関する理解を初心者なりにまとめてみました。
タスクキューをもっと深掘りすると、マクロ/マイクロタスクの話になったり、callback関数やPromiseなどもっと広がっていくので、また別の機会で自分用に記事にしようと思っています。

参考

Discussion