event loop 事件迴圈

Node.js event loop 事件迴圈

JS 是 單執行緒,所有同步性的工作,會一個個執行,但遇到非同步的操作就會先放到一個叫做 task queue 的地方,等到目前沒有其他工作,就會到 task queue 看看有沒有還沒執行的任務,再把它拿出來執行。

one thread == one call stack == one thing at a time

事件

event loop 教學

JavaScript 執行順序

Node.js 及 JavaScript 皆是按照以下優先權順序執行程式,越前面的優先權會優先執行

1. 宏任務(MacroTask)

  • script 全部代碼
  • I/O
  • UI Rendeting
  • MessageChannel
  • postMessage

2. 微任務(MicroTask)

  • Process.nextTick(Node.js)
  • Promise
  • Object.observe(廢棄)
  • MutationObserver

3. 任務佇列(task queues) 非同步事件

  • click
  • ajax
  • setTimeout、setInterval、setImmediate

4. 繪製畫面佇列(render queue) 繪製畫面事件

若是 瀏覽器 執行 JavaScript 的話會有這個 render queue

如果畫面是 60 fps(frame per second) 的話,大約是 16.67 ms 會執行一次畫面重繪

所以如果中間有程序被 1, 2, 3 繪製畫面的程序要執行就會超過 16.67 m 才會執行

所以中間如果有程式執行太慢的話,你會覺得畫面好像卡住沒辦法動

Node.js 沒有繪製畫面的狀況,但還是有 1, 2, 3 執行順序及優先權的狀況發生

// 執行順序:1
console.log('Script Start');

// 執行順序:4
setTimeout(() => {
    console.log('Set Timeout');
}, 0)

// 執行順序:3
Promise.resolve()
    .then(() => {
        console.log('Promise 1');
    })
    .then(() => {
        console.log('Promise 2');
    });

// 執行順序:2
console.log('Script End');

// Script Start
// Script End
// Promise 1
// Promise 2
// Set Timeout

圖解 event loop 流程

  1. Stack 沒工作,執行 task queue 工作,將工作加入到 Stack 中,最早加入的工作優先處理(Queue)
  2. task queue 沒工作時,sleep 直到工作出現,然後執行步驟 1

事件

前往event loop 圖解說明理解程式及事件處理流程

事件

Call Stack 情境說明

後進先出 Last in First out,LIFO

正常 Call Stack 流程

乘法程式

// 乘法
let multiply = (a, b) => {
    return a * b;
};
// 平方
let square = (n) => {
    return multiply(n, n);
}
// 列印平方
let printSquare = (n) => {
    let squared = square(n);
    console.log(squared);
}
// 執行列印平方
printSquare(3);

Stack 狀況

Step 1. 進入程式
順序 Stack
1 main()
Step 2. 執行 printSquare(3);

前面都是 function 定義,不會執行,直到遇到 printSquare(3); 執行程式,所以會把要執行的程式加入到 Stack 當中

順序 Stack
1 main()
2 printSquare(3);
Step 3. 執行 square(n);

進到 printSquare(3); 函式中,執行 square(n);,將 square(n); 加入到 Stack

順序 Stack
1 main()
2 printSquare(3);
3 square(n);
Step 4. 執行 multiply(n, n);

進到 square(n); 函式中,執行 multiply(n, n);,將 multiply(n, n); 加入到 Stack

順序 Stack
1 main()
2 printSquare(3);
3 square(n);
4 multiply(n, n);
Step 5. 取得 multiply(n, n); 回傳資料

multiply(n, n); 裡面會直接運算乘法,然後回傳資料,所以回傳後 multiply(n, n); 要執行的任務完成了

依照 Stack 後進先出 Last in First out,LIFO 的原則,會將 multiply(n, n); 移除 Stack

順序 Stack
1 main()
2 printSquare(3);
3 square(n);
Step 6. 取得 square(n); 回傳資料

square(n); 會取得 multiply(n, n); 的結果,然後直接回傳資料,所以回傳後 square(n); 要執行的任務完成了

依照 Stack 後進先出 Last in First out,LIFO 的原則,會將 square(n); 移除 Stack

順序 Stack
1 main()
2 printSquare(3);
Step 7. 列印資料 console.log(squared);

printSquare(3); 裡面有新的任務是要列印變數資料,所以會把 console.log(squared); 加入 Stack

順序 Stack
1 main()
2 printSquare(3);
3 console.log(squared);
Step 8. 列印資料完成 console.log(squared);

列印資料完成後,會將 console.log(squared); 移除 Stack

順序 Stack
1 main()
2 printSquare(3);
Step 9. 完成 printSquare(3);

完成 printSquare(3); 沒有更多的任務了,會將 printSquare(3); 移除 Stack,最後遇到 main() 結束整個程式

順序 Stack
1 main()

setTimeout Stack 狀況

console.log('Hi');

setTimeout(() => {
    console.log('setTimeout Hello');
}, 3000);

console.log('End Hi');

// Hi
// End Hi
// setTimeout Hello
Step 1. 進入程式
Stack 順序 Stack
1 main()
Step 2. 執行 console.log(‘Hi’);

遇到 console.log('Hi'); 執行程式,所以會把要執行的程式加入到 Stack 當中

Stack 順序 Stack
1 main()
2 console.log(‘Hi’);
Step 3. 執行完成 console.log(‘Hi’);

console.log('Hi'); 執行完成後,會將 console.log('Hi'); 移除 Stack

Stack 順序 Stack
1 main()
Step 4. 執行 setTimeout();

遇到 setTimeout(callback()); 執行程式,所以會把要執行的程式加入到 Stack 當中

Stack 順序 Stack
1 main()
2 setTimeout(callback());
Step 5. 執行完成 setTimeout();

setTimeout(callback()); 執行時,會將裡面的 callback() 放到管理 Timer 的 webapis

然後完成 setTimeout(callback());setTimeout(callback()); 移除 Stack

Stack 順序 Stack webapis 順序 webapis
1 main() 1 timer(callback())
Step 6. 執行 console.log(‘End Hi’);

遇到 console.log('End Hi'); 執行程式,所以會把要執行的程式加入到 Stack 當中

Stack 順序 Stack webapis 順序 webapis
1 main() 1 timer(callback())
2 console.log(‘End Hi’);
Step 7. 執行完成 console.log(‘End Hi’);

執行完成後,將 console.log('End Hi'); 移除 Stack

Stack 順序 Stack webapis 順序 webapis
1 main() 1 timer(callback())
Step 8. 執行完成所有程式

執行完成所有程式後,將 main(); 移除 Stack

Stack 順序 Stack webapis 順序 webapis
1 timer(callback())
Step 9. webapis 時間計時完成

webapis 計時完成後,會將 callback(); 放到 task queue 當中

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 callback();
Step 10. event loop 檢查 Stack 並移動 task queue

這時候 event loop 會去檢查 Stack 是否有其他任務,如果沒有其他任務,才會將 task queue 的任務以 先進先出(FIFO First In First Out) 的方式,將任務移動到 Stack 執行

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 callback();
Step 11. 執行 callback 中的 console.log(‘setTimeout Hello’);

遇到 console.log('setTimeout Hello') 執行程式,所以會把要執行的程式加入到 Stack 當中

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 callback();
2 console.log(‘setTimeout Hello’)
Step 11. 完成執行 callback 中的 console.log(‘setTimeout Hello’);

執行完成所有程式後,將 console.log('setTimeout Hello'); 移除 Stack

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 callback();
Step 12. 完成執行 callback

執行完成所有程式後,將 callback(); 移除 Stack

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue

多個 setTimeout Stack 狀況

setTimeout(() => {
    console.log('setTimeout Hello 1');
}, 0);

setTimeout(() => {
    console.log('setTimeout Hello 2');
}, 0);

setTimeout(() => {
    console.log('setTimeout Hello 3');
}, 0);

// setTimeout Hello 1
// setTimeout Hello 2
// setTimeout Hello 3
Step 1. 執行所有 setTimeout();

在連續設定 3 個 setTimeout 後,會直接將 3 個程序丟給 webapis 管理

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 timer(callback1())
2 timer(callback2())
3 timer(callback3())
Step 2. 執行完成 setTimeout();

webapis 計時完成後,會將 callback(); 放到 task queue 當中

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 callback1()
2 callback2()
3 callback3()
Step 3. 依序完成執行 callback 中的 console.log(‘setTimeout Hello’);

event loop 會檢查 Stack 是否有工作,沒有的話依序將 task queue 的工作放到 Stack 中去執行

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 callback() 1 callback2()
2 console.log(‘setTimeout Hello1’); 2 callback3()

即便所有的 setTimeout 設定的時間一樣,前面的 callback1() 如果執行時間太久,會影響後面的 callback2()callback3() 執行的時間

所以 setTimeout 只能保證程式執行的時間一定在指定的秒數之後,而不保證在指定的時間一定會執行,所以 setTimeout 設定 1000 ms,則程式會在 1000 ms 後執行,可能是 1000ms1300ms1700ms 或是 1000ms + X ms 秒後執行

無窮迴圈 Stack 狀況

無窮迴圈程式

let foo = () => {
    foo();
}
//  RangeError: Maximum call stack size exceeded
foo();

Stack 狀況

順序 Stack
1 main()
2 foo()
3 foo()
.. foo()
.. foo()
.. foo()
n foo()
over stack size foo()

執行特定事件太多次導致 task queue 阻塞

$.on('document', 'scroll', () => {
  console.log('scroll!');
});
Step 1. 綁定事件到 webapis 中

執行程式時,webapi 會監聽事件,直到事件發生後,將事件需要執行的 callback() 放到 task queue

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 $.($.on(‘document’, ‘scroll’, callback())
Step 2. 觸發很多 scroll 事件

當觸發很多事件後,task queue 會有很多此次 callback() 要處理的函式

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 $.($.on(‘document’, ‘scroll’, callback()) 1 callback()
2 callback()
3 callback()
4 callback()
5 callback()
callback()
n callback()
Step 3. 處理 callback()

然後 task queue 會持續不斷的檢查 Stack 是否有在執行程序,沒有的話再將 callback() 放到 Stack 中去執行,直到 依序task queue 工作消化完成

所以在這裡就會看到,整個 task queue 被太多 callback() 佔滿的狀況,若有其他較重要的 callback 要執行,也會被原先的程序卡住

Stack 順序 Stack webapis 順序 webapis task queue 順序 task queue
1 callback() 1 $.($.on(‘document’, ‘scroll’, callback()) 1 callback()
2 console.log(‘scroll!’); 2 callback()
3 callback()
4 callback()
5 callback()
callback()
n callback()

所以要盡量避免使用 scroll 事件或者任何會導致 task queue 阻塞的事件,除非必要,不然會導致整個工作執行緩慢

議題狀況

執行時間太長的事件函數

當遇到需要執行比較久的工作,可以使用 setTimeout 的方式,將秒數設定為 0,然後將工作依序拆分到各個小工作,這樣可以減少 Queue 被卡住的狀況

let i = 0;

let start = Date.now();

function count() {

    // do a heavy job
    for (let j = 0; j < 1e9; j++) {
        i++;
    }

    console.log("Done in " + (Date.now() - start) + 'ms');
}

count();

副作用

因為 setTimeout 的工作也是排入 Queue 去等待執行,所以若 Queue 塞滿工作時,setTimeout 設定的時間可能會有延遲的狀況發生

  • 若 stack 裡頭有其他任務正在進行,setTimeout 的時間可能不會被正確觸發。
  • 在 setTimeout 裡頭執行過長的任務也會導致 UI blocking。

主線任務執行時間過長

執行這一段程式碼,你會發現為什麼過了一秒,console.log 卻還沒出現

function fib(n) {
    if (n < 1) {
        return 1;
    }
    return fib(n - 1) + fib(n - 2);
}
setTimeout(() => console.log('hello'), 1000);
fib(40);

這是因為 fib(40) 造成的遞迴塞滿了整個 stack,直到執行序消化完了之後,才趕緊將 task queue 的 setTimeout 拿出來。

setTimeout 任務執行時間過長

setTimeout 會先把任務放入 task queue 當中,再回到 stack 執行,若 stack 有其他 task 也會阻塞任務執行。

因此,fib(40) 執行後因為讓 stack 持續有任務,導致 500ms 過後 console.log(‘hello’) 還沒出現。

function fib(n) {
    if (n < 1) {
        return 1;
    }
    return fib(n - 1) + fib(n - 2);
}
setTimeout(() => {
    console.log('fib(40)')
    fib(40);
}, 0);
setTimeout(() => {
    console.log('hello');
}, 500);

// fib(40)
// hello

Stack 與 Queue 比較

類型 說明
Stack 先進先出 (FIFO, First in first out)
Queue 後進先出 (LIFO, Last in First out)

事件 事件

參考資料