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(①) |
---|---|
3.sync2 がCallStackにpushされ実行 & CallStackからpop(②) |
4.sync3 がCallStackにpushされ実行 & CallStackからpop(③) |
関数は初め、待機状態となっていて、関数が呼び出されると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され実行 |
---|---|
3.sync1 の実行中にsync2 がCallStackにpushされ実行 |
4.sync2 の実行中にsync3 がされCallStackにpushされ実行 & sync3 がCallStackからpop(①) |
5.sync2 がCallStackからpop(②) |
6.sync1 がCallStackからpop(③) |
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' --- ④
---------
*/
非同期関数と同期関数を交互に呼び出しています。この場合の内部処理は以下のようになっています。
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' --- ④
---------
*/
同様に非同期関数と同期関数を交互に呼び出していますが、出力結果が前回とは異なっています。この場合の内部処理は以下のようになっています。
なんとなく予想はつくかと思いますが、Promiseで解決された非同期処理はMicroTaskにpushされます。そして、MicroTaskに積まれた関数も同様に、CallStackに積まれている同期関数が全て実行し終えた後で、CallStackにpushされ実行されます。
また、MicroTaskとMacroTaskではMicroTaskに積まれた関数が優先してCallStackにpushされます。すなわち、MicroTaskの非同期関数が全て実行された後で、MacroTaskに積まれた非同期関数がCallStackにpushされ、最後に実行されるということになります。したがって、上で示した図のような挙動を示し、async1
とasync2
の実行順序が逆転するというわけです。
ちなみに、MicroTaskとMacroTaskは、キュー型のデータ構造で、先入れ先出し(FIFO:First In, First Out)方式で処理されます。
まとめ
ここまでの内容をまとめると以下のようになります。
- 同期関数 -> CallStackにpush -> ただちに実行
- 非同期関数(Promise系)-> MicroTaskにpush -> 1の後に実行
- 非同期関数(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'
---------
*/