1 JS執行機制詳解
1.1 單執行緒的JS
javascript是一門單執行緒語言,在最新的HTML5中提出了Web-Worker,但javascript是單執行緒這一核心仍未改變。所以一切javascript版的"多執行緒"都是用單執行緒模擬出來的,一切javascript多執行緒都是紙老虎!
1.2 Event Loop
既然js是單執行緒,後一個任務會等前一個任務執行完成後才會執行,如果前一個任務執行時間過長後面的任務一直得不到執行,就會引起阻塞。那麼問題來了,假如我們想瀏覽新聞,但是新聞包含的超清圖片載入很慢,難道我們的網頁要一直卡著直到圖片完全顯示出來?因此我們會將任務分為兩類:
- 同步任務
- 非同步任務
當我們開啟網站時,網頁的渲染過程就是一大堆同步任務,比如頁面骨架和頁面元素的渲染。而像載入圖片音樂之類佔用資源大耗時久的任務,就是非同步任務。具體邏輯見下面的導圖:
文字描述
- 同步和非同步任務分別進入不同的執行"場所",同步的進入主執行緒,非同步的進入Event Table並註冊函式。
- 當指定的事情完成時,Event Table會將這個函式移入Event Queue。
- 主執行緒內的任務執行完畢為空,會去Event Queue讀取對應的函式,進入主執行緒執行。
- 上述過程會不斷重複,也就是常說的Event Loop(事件迴圈)。準確的講,event loop是實現非同步的一種機制。
上圖中Event Queue 包括 macro task queue 和 micro task queue,下一小節我們會詳細解釋一下。 上程式碼我們體會一下這個流程:
console.log('1');
setTimeout(function () {
console.log('timeout');
});
console.log('2');
複製程式碼
上面的程式碼解釋
console.log('1');
和console.log('2');
是同步任務會放到主執行緒中,setTimeout
宣告的回撥函式會放到Event Table。主執行緒內的任務(console.log('1');console.log('2');
)執行完畢為空,會去Event Queue讀取console.log('timeout');
,進入主執行緒執行。所以執行的結果為1 2 timeout
。
1.3 Evnet Loop 中的macro task 和 micro task
1.3.1 定義
- macro-task(巨集任務):包括整體程式碼script,setTimeout,setInterval, setImmediate(node環境下)。
- micro-task(微任務):Promise,process.nextTick
下面兩張圖為Event Loop 和 macro-task 及 micro-task的關係
導圖解釋
- 不同型別的任務會進入對應的Event Queue,比如setTimeout和setInterval會進入macro task Queue, Promise 會進入 micro task Queue。
- 事件迴圈的順序,決定js程式碼的執行順序。
- 進入整體程式碼(巨集任務)後,開始第一次迴圈。接著執行所有的微任務。然後再次從巨集任務開始,找到其中一個任務佇列執行完畢,再執行所有的微任務。
1.3.2 e.g.
看到這麼多的定義和導圖,我們來段程式碼屢一下:
console.log('1');
setTimeout(function () {
console.log('2');
process.nextTick(function () {
console.log('3');
});
new Promise(function (resolve) {
console.log('4');
resolve();
}).then(function () {
console.log('5')
})
})
process.nextTick(function () {
console.log('6');
})
new Promise(function (resolve) {
console.log('7');
resolve();
}).then(function () {
console.log('8')
})
複製程式碼
- 整體script作為第一個巨集任務進入主執行緒,遇到console.log,輸出1。
- 遇到setTimeout,其回撥函式被分發到 macro task Queue中。
- 遇到process.nextTick(),其回撥函式被分發到micro task Queue中。我們記為process1。
- 遇到Promise,new Promise直接執行,輸出7。then被分發到micro task Queue中。我們記為then1。
macro task Queue | macro task Queue |
---|---|
setTimeout | process1 |
- | then1 |
- 我們發現了process1和then1兩個微任務。
- 執行process1,輸出6。
- 執行then1,輸出8。
- 好了,第一輪事件迴圈正式結束,這一輪的結果是輸出1,7,6,8。那麼第二輪時間迴圈從setTimeout巨集任務開始:
- 遇到console.log,輸出2。
- 遇到process.nextTick(),同樣將其分發到micro task Queue中,記為process2。new Promise立即執行輸出4,then也分發到macro task Queue中,記為then2。
macro task Queue | macro task Queue |
---|---|
- | process2 |
- | then2 |
- 我們發現了process2和then2兩個微任務。
- 執行process2,輸出3。
- 執行then2,輸出5。
- 好了,第一輪事件迴圈正式結束,這一輪的結果是輸出2,4,3,5。迴圈結束。最終的結果為
1 7 6 8 2 4 3 5
。
1.4 總結
- javascript是一門單執行緒語言
- 事件迴圈是js實現非同步的一種方法,也是js的執行機制。
2 Node中的Event Loop
2.1 node中Event Loop執行順序
2.1.1 node中Event Loop的執行順序的簡單介紹
下圖為node中Event Loop的執行順序的簡略圖
note
- timers: 執行被setTimeout() 和 setInterval()註冊的回撥函式.
- I/O callbacks: 執行除了 close事件的回撥、 被 timers和setImmediate()註冊的回撥.
- idle, prepare: node內部執行
- poll: 輪詢獲取新的 I/O 事件; node有可能會在這個地方阻塞.
- check: 在這裡呼叫setImmediate() 註冊的回撥.
- close: 執行close事件的回撥
2.1.2 詳解poll階段
1.poll階段的功能
- 執行剛剛過期的計時器的指令碼。
- 在輪詢佇列中處理事件。
2.poll階段的處理流程
下面我用if else的方式描述一下poll階段的處理邏輯,如下:
if ('事件迴圈進入到 poll 階段 ' && '沒有timers註冊的scripts') {
if ('poll 佇列 不為空') {
console.log('迴圈遍歷它的回撥佇列,以同步執行它們,直到佇列耗盡,或者達到系統依賴的最大值');
} else {
if ('存在setImmediate()註冊的scripts') {
console.log('結束poll phase 進入到check phase 執行這些註冊的scripts');
} else {
console.log('事件迴圈將等待被新增到佇列中的回撥,然後立即執行它們');
}
}
}
console.log('一旦輪詢佇列為空,事件迴圈將檢查有無到期的計時器。如果有一個或多個計時器準備就緒,事件迴圈將返回到計時器階段,以執行這些計時器的回撥。');
複製程式碼
3.比較setImmediate() 和 setTimeout()
setImmediate()
和 setTimeout()
很相似的,它們何時被呼叫,決定了它們的行為方式的不同。
setImmediate
用於在當前輪詢階段完成後執行指令碼setTimeout
用於把註冊的指令碼在最小閾值結束後執行。
它們執行的順序將根據呼叫它們的上下文而變化。如果兩個都是從主模組中呼叫,那麼它們將受到程式效能的約束(這可能會受到其他應用程式的影響)。
例如,如果我們執行的指令碼不是在I/O迴圈中(即主模組),那麼執行兩個定時器的順序是不確定的,因為它受過程效能的約束:
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// 列印結果的先後順序是不確定的,有時`timeout`在前,有時'immediate'在前
複製程式碼
但是,如果把這段程式碼放到I/O迴圈的回撥中,immediate
總是先被列印出來,如下:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// 在一個I/O週期內,在任何計時器的情況下,setImmediate的回撥,因為在一個I/O週期內,I/O callback 的下一個階段為setImmediate的回撥。
複製程式碼
2.2 node的Event Loop實現
如下圖:
說明
-
- Node的Event Loop分階段,階段有先後,依次是:
- expired timers and intervals,即到期的setTimeout/setInterval
- I/O events,包含檔案,網路等等
- immediates,通過setImmediate註冊的函式
- close handlers,close事件的回撥,比如TCP連線斷開
-
- 同步任務及每個階段之後都會清空microtask佇列
- 優先清空next tick queue,即通過process.nextTick註冊的函式
- 再清空other queue,常見的如Promise
-
- node會清空當前所處階段的佇列,即執行所有task
我們在回頭看一下,下面的程式碼:
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
複製程式碼
可以看出由於兩個setTimeout延時相同,被合併入了同一個expired timers queue,而一起執行了。所以,只要將第二個setTimeout的延時改成超過2ms(1ms無效,因為最小間隔為1s),就可以保證這兩個setTimeout不會同時過期,也能夠保證輸出結果的一致性。
我們在回頭看一下,上面提到的另外一段程式碼:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
複製程式碼
為何這樣的程式碼能保證setImmediate的回撥優先於setTimeout的回撥執行呢?因為當兩個回撥同時註冊成功後,當前node的Event Loop正處於I/O queue階段,而下一個階段是immediates queue,所以能夠保證即使setTimeout已經到期,也會在setImmediate的回撥之後執行。
3 補充
由於水平有限,理解的程度可能會有偏差,歡迎大家指正。