2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Javascript 同期処理と非同期処理の内部処理について

Posted at

Javascriptの同期処理と非同期処理が内部でどのように処理されているかについて、深掘りした内容をまとめてみました。
なお、同期処理と非同期処理に関する基本的な知識は前提としています。

同期処理について

まずは、同期処理について2つのケースを考えてみます。

Case 1

function() sync1() {
    console.log('sync1');
}

function() sync2() {
    console.log('sync2');
}

function() sync3() {
    console.log('sync3');
}

sync1();
sync2();
sync3();

/* 
-- 出力 --
'sync1' --- ①
'sync2' --- ②
'sync3' --- ③
---------
*/

上記は、単に同期関数を連続して3つ呼び出しただけの、最もシンプルなケースです。
この場合の内部処理は以下のようになっています。

1.初期状態 2.sync1がCallStackにpushされ実行 & CallStackからpop(①)
image.png image.png
3.sync2がCallStackにpushされ実行 & CallStackからpop(②) 4.sync3がCallStackにpushされ実行 & CallStackからpop(③)
image.png image.png

関数は初め、待機状態となっていて、関数が呼び出されるとCallStackと呼ばれる場所に移動(push)され、実行されます。また、実行し終わった関数はCallStackから取り除かれます(pop)。
JavaScriptではCallStackで関数が実行される、ということを覚えていてください。
また、非同期関数の場合は、これとは異なる挙動を示しますが、それは後ほど説明します。

CallStack = 関数の実行される場所

Case 2

function sync1() {
    sync2();
    console.log('sync1');
}

function sync2() {
    sync3();
    console.log('sync2');
}

function sync3() {
    console.log('sync3');
}

sync1();

/* 
-- 出力 --
'sync3' --- ①
'sync2' --- ②
'sync1' --- ③
---------
*/

今度は、sync1の中でsync2を呼び出し、さらにその中でsync3を呼び出しています。この場合の内部処理は以下のようになっています。

1.初期状態 2.sync1がCallStackにpushされ実行
image.png image.png
3.sync1の実行中にsync2がCallStackにpushされ実行 4.sync2の実行中にsync3がされCallStackにpushされ実行 & sync3がCallStackからpop(①)
image.png image.png
5.sync2がCallStackからpop(②) 6.sync1がCallStackからpop(③)
image.png image.png

CallStackは名前からも推測できる通り、スタック型のデータ構造をしており、後入れ先出し(LIFO:Last In, First Out)方式で処理されます。すなわち、CallStackに最後にpushされたsync3から処理されていくというわけです。つまり、関数のネストが深ければ深いほど、CallStackに溜まっていくことになります。
ここまでの2つのCaseで、ある程度同期処理の流れが掴めたかと思います。

非同期処理について

次に、非同期関数も追加して考えてみます。ここでも、2つのケースを用意しました。

Case 1

function async1() {
    setTimeout(() => {
        console.log('async1');
    }, 0)
}

function sync1() {
    console.log('sync1');
}

function async2() {
    setTimeout(() => {
        console.log('async2');
    }, 0)
}

function sync2() {
    console.log('sync2');
}

async1();
sync1();
async2();
sync2();

/* 
-- 出力 --
'sync1' --- ①
'sync2' --- ②
'async1' --- ③
'async2' --- ④
---------
*/

非同期関数と同期関数を交互に呼び出しています。この場合の内部処理は以下のようになっています。

1.初期状態 2.async1がMacroTaskにpush
image.png image.png
3.sync1がCallStackにpushされ実行 & sync1がCallStackからpop(①) 4.async2がMacroTaskにpush
image.png image.png
5.sync2がCallStackにpushされ実行 & sync2がCallStackからpop(②) 6.async1がCallStackにpushされ実行 & async1がCallStackからpop(③)
image.png image.png
7.async2がCallStackにpushされ実行 & async2がCallStackからpop(④)
image.png

setTimeoutでラップされた関数は遅延の時間に関係なく、非同期処理として扱われます。非同期処理には2種類存在し、MacroTaskにpushされる非同期処理と、MicroTaskにpushされる非同期処理です(MicroTaskにpushされる非同期処理についてはCase 2で説明します)。
setTimeoutはMacroTaskにpushされる非同期関数なため、async1, async2はいきなりCallStackにpushされるのではなく、まずMacroTaskにpushされます。MacroTaskに積まれた非同期関数は、CallStackに積まれている同期関数が全て実行し終えた後で、CallStackにpushされ実行されます。したがって、上で示した図のような挙動を示し、同期関数が全て実行された後で、非同期関数が実行されるというわけです。

Case 2

function async1() {
    setTimeout(() => {
        console.log('async1');
    }, 0)
}

function sync1() {
    console.log('sync1');
}

async function async2() {
    Promise.resolve().then(() => {
        console.log('async2');
    });
}

function sync2() {
    console.log('sync2');
}

async1();
sync1();
async2();
sync2();

/* 
-- 出力 --
'sync1' --- ①
'sync2' --- ②
'async2' --- ③
'async1' --- ④
---------
*/

同様に非同期関数と同期関数を交互に呼び出していますが、出力結果が前回とは異なっています。この場合の内部処理は以下のようになっています。

1.初期状態 2.async1がMacroTaskにpush
image.png image.png
3.sync1がCallStackにpushされ実行 & sync1がCallStackからpop(①) 4.async2がMicroTaskにpush
image.png image.png
5.sync2がCallStackにpushされ実行 & sync2がCallStackからpop(②) 6.async2がCallStackにpushされ実行 & async2がCallStackからpop(③)
image.png image.png
7.async1がCallStackにpushされ実行 & async1がCallStackからpop(④)
image.png

なんとなく予想はつくかと思いますが、Promiseで解決された非同期処理はMicroTaskにpushされます。そして、MicroTaskに積まれた関数も同様に、CallStackに積まれている同期関数が全て実行し終えた後で、CallStackにpushされ実行されます。
また、MicroTaskとMacroTaskではMicroTaskに積まれた関数が優先してCallStackにpushされます。すなわち、MicroTaskの非同期関数が全て実行された後で、MacroTaskに積まれた非同期関数がCallStackにpushされ、最後に実行されるということになります。したがって、上で示した図のような挙動を示し、async1async2の実行順序が逆転するというわけです。
ちなみに、MicroTaskとMacroTaskは、キュー型のデータ構造で、先入れ先出し(FIFO:First In, First Out)方式で処理されます。

まとめ

ここまでの内容をまとめると以下のようになります。

  1. 同期関数 -> CallStackにpush -> ただちに実行
  2. 非同期関数(Promise系)-> MicroTaskにpush -> 1の後に実行
  3. 非同期関数(setTimeout系)-> MacroTaskにpush -> 2の後に実行

また、MacroTask, MicroTaskに追加される非同期関数には他にも以下のものがあるようです。

MacroTask: setInterval(), setImmediate(), I/O操作(ファイルの読み書き)
MicroTask: process.nextTick(), MutationObserver

複雑な例

最後に、今までよりも複雑なケースを考えてより理解を深めてみたいと思います。以下の場合について、どのような順序でログが出力されるか考えてみてください。

function async1() {
    console.log('async1 start');
    setTimeout(() => {
        console.log('setTimeout 1');
        Promise.resolve().then(() => {
            console.log('setTimeout 1 microtask');
        });
    }, 0);

    Promise.resolve().then(() => {
        console.log('async1 microtask');
    });

    console.log('async1 end');
}

async1();

答えは、以下のようになります。

/*
-- 出力 --
'async1 start'
'async1 end'
'async1 microtask'
'setTimeout 1'
'setTimeout 1 microtask'
---------
*/
2
3
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?