官網:node官網-事件循環
瀏覽器中的事件循環是由HTML規范來定義,之后由各瀏覽器廠商實現的,而node中的事件循環的定義與實現均由libuv引擎完成。
node使用chrome v8引擎作為js解釋器,v8引擎分析代碼后,主線程立即執行同步任務,而異步任務則由libuv引擎驅動執行,而且不同異步任務的回調事件會放在不同的隊列中等待主線程執行,不再是簡單的宏任務隊列和微任務隊列。因此在nodeJS中,雖然程序運行表現出的整體狀態與瀏覽器中傳統的js大致相同,先同步后異步,但是對于異步的部分,node則依靠libuv引擎來進行更復雜的管理。
宏任務隊列和微任務隊列
六個基本階段(六個宏任務隊列)
- timers:計時器階段,處理setTimeout()和setInterval()定時器的回調函數
- pending callbacks :待定回調階段,用于處理系統級別的錯誤信息,例如 TCP 錯誤或者 DNS 解析異常
- idle,prepare:僅在內部使用,可以忽略不計
- poll:輪詢階段,等待I/O事件(如網絡請求或者文件I/O等)的發生,然后執行對應的回調函數,并且會處理定時器相關的回調函數。如果沒有任何I/O事件發生,此階段可能會使事件循環阻塞
- check:檢查階段,處理 setImmediate() 的回調函數。check 的回調優先級比 setTimeout 高,比微任務要低
- close callbacks:關閉回調階段,處理一些關閉的回調函數,比如 socket.on(‘close’)
nextTick隊列(微任務隊列)
該事件隊列獨立于6個階段的事件隊列之外,用于存儲 process.nextTick() 的回調函數。
microTask隊列(微任務隊列)
該事件隊列也獨立于6個階段的事件隊列之外,用于存儲 Promise(Promise.then()、Promise.catch()、Promise.finally())的回調函數。
NodeJS事件循環流程
以上六個基本階段和兩個獨立的事件隊列構成了node事件循環的核心部分,在一次循環迭代的流程中,需要注意:
- nextTick隊列、microTask隊列中的任務穿插于6個階段之間進行,每個階段進行前會先執行并清空nextTick隊列、microTask隊列中的回調任務(可以理解為一次循環迭代至少處理6次nextTick隊列和microTask隊列中的任務)
- nextTick隊列、microTask隊列執行的次數在Node v11.x版本前后有一些差異,(上文中的至少很有深意),具體如下:
a. Node版本小于11時,nextTick隊列、microTask隊列中的任務只會在6個階段之間進行,因此一次循環迭代最多處理6次這兩個隊列
b. Node版本大于11時,任何一個階段的事件隊列中任務之間都會處理一次這兩個隊列,因此一次循環迭代至少處理6次這兩個隊列,上限則受各個階段總任務數影響而不固定
c. 上述2個版本之間的區別,被認為是一個應該要修復的bug,因此在v11.x之后,node修改了nextTick隊列、microTask隊列的處理時機。從宏、微任務的角度看,修復后的流程和傳統js的事件循環保持了一致 - nextTick隊列中任務的優先級高于microTask隊列
setTimeout() 與 setlmmediate() 的特殊情況
我們知道 setTimeout()的回調是在 timers階段執行,setImmediate()的回調是在 check階段執行,并且事件循環是從 timers階段開始的,那么 setTimeout()的回調一定會先于 setImmediate()的回調執行嗎?答案是不一定。在只有這兩個函數且近乎同時觸發的情況下,它們回調的執行順序不是固定的(受調用時機、計算機性能影響)。下面是一個例子:
// 示例1(node v12.16.3)
setTimeout(() => {console.log("setTimeout");
});setImmediate(() => {console.log("setImmediate");
});// 結果:
// setTimeout -> setImmediate
// 或
// setImmediate -> setTimeout
上面示例1中的這段代碼輸出結果就是不固定的,這是因為這種情況下回調不一定完全準備好了。因為主線程沒有同步代碼需要執行,程序一開始就進入了事件循環。這時setTimeout()的回調并不是一定完全準備好了,因此就可能會在第一次循環迭代的check階段中執行setImmediate()的回調,再到第二次循環迭代的timers階段執行setTimeout()的回調;同時也有可能setTimeout()的回調一開始就準備好了,這樣就會按照先setTimeout()再setImmediate()的順序執行回調。由此就造成了輸出結果不固定的現象。
有以下兩種方法可以使輸出順序固定:
① 人為添加同步代碼的延時器,保證回調都準備好了(延時器的時長設定可能會受機器運行程序時的性能影響,因此該方法嚴格意義上并不能100%固定順序)。
② 將這兩個方法放入pending callbacks、idle,prepare、poll階段中任意一個階段即可,因為這些階段執行完后是一定會先到check再到下一個迭代的timers。由于pending callbacks、idle,prepare階段都偏向于系統內部,因此一般可以放入poll階段中使用。
如下示例2,我們人為加上一個2000ms的延時器,輸出的結果就固定了,如下所示:
//示例2(node v12.16.3)
setTimeout(() => {console.log("setTimeout");
});setImmediate(() => {console.log("setImmediate2");
});const sleep = (delay) => {const startTime = +new Date();while (+new Date() - startTime < delay) {continue;}
};
sleep(2000);// 結果:setTimeout -> setImmediate
如下示例3,我們將函數放入文件I/O的回調中,輸出的結果也就固定了,如下所示:
//示例3(node v12.16.3)
const fs = require("fs");fs.readFile("./fstest.js", "utf8", (err, data) => {setTimeout(() => {console.log("setTimeout");});setImmediate(() => {console.log("setImmediate");});
});// 結果:setImmediate -> setTimeout
NodeJS事件循環示例
console.log('1'); //1層同步//1層timers,setTimeout1
setTimeout(function() {console.log('2'); //2層同步process.nextTick(function() {console.log('3'); //2層nextTick隊列})new Promise(function(resolve) {console.log('4'); //2層同步resolve();}).then(function() {console.log('5'); //2層microTask隊列})
})process.nextTick(function() {console.log('6'); //1層nextTick隊列
})new Promise(function(resolve) {console.log('7'); //1層同步resolve();
}).then(function() {console.log('8'); //1層microTask隊列
})//1層timers,setTimeout2
setTimeout(function() {console.log('9'); //2層同步process.nextTick(function() {console.log('10'); //2層nextTick隊列})new Promise(function(resolve) {console.log('11'); //2層同步resolve();}).then(function() {console.log('12'); //2層microTask隊列})
})console.log('13'); //1層同步//(node v12.16.3)結果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 3 -> 5 -> 9 -> 11 -> 10 -> 12
//(node v8.16.0)結果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 9 -> 11 -> 3 -> 10 -> 5 -> 12
圖解:node12+版本下的執行順序
- 首先是1層的同步任務直接執行:1、7、13
- 進入事件循環
- 執行1層的nextTick隊列:6
- 執行1層的microTask隊列:8
- 進入timer階段,由于setTimeout1的回調任務先進入隊列,因此先執行setTimeout1的2層同步任務:2、4
- 執行setTimeout1的2層nextTick隊列:3
- 執行setTimeout1的2層microTask隊列:5
- setTimeout1的2層代碼均執行完畢,再執行setTimeout2的2層同步代碼:9、11
- 執行setTimeout2的2層nextTick隊列:10
- 執行setTimeout2的2層microTask隊列:12
和瀏覽器中事件循環的區別
瀏覽器事件循環在每次宏任務執行后,瀏覽器有機會進行UI渲染,但實際渲染取決于是否觸發了重排或重繪。
● 執行環境:瀏覽器的事件循環主要運行在JavaScript引擎和渲染引擎之間,而Node.js的事件循環是運行在單獨的線程中。這意味著在瀏覽器中,事件循環可能與渲染進程共享同一個線程,可能會出現線程阻塞的情況。而在Node.js中,事件循環運行在單獨的線程中,不會導致瀏覽器那樣的渲染阻塞
● 宏任務和微任務的實現方式:在瀏覽器中,宏任務和微任務是通過HTML5規范中定義的消息隊列來實現的。所有異步任務都被分為宏任務和微任務兩種類型,并依次加入到對應的隊列中。當當前的宏任務執行完畢后,會立即執行所有的微任務,然后再選擇下一個宏任務執行。常見的宏任務包括setTimeout、setInterval、DOM事件等,常見的微任務包括Promise.then、MutationObserver等
● 微任務隊列的執行時機:在瀏覽器事件循環中,每執行完一個宏任務后,便要檢查執行微任務隊列。而在Node事件循環中,微任務是在兩個階段之間執行的,即在"上一階段"執行完,"下一階段"開始前執行微任務隊列中的任務。這意味著Node中的微任務是在兩個階段之間執行的,而瀏覽器中的微任務是在每個宏任務執行完后執行的
● 事件循環的執行機制:瀏覽器的事件循環是在HTML5中定義的規范,而Node的事件循環則是由libuv庫實現。這兩個環境的事件循環執行機制不相同,不可以混為一談
● process.nextTick()的優先級:在Node.js中,process.nextTick()的優先級要高于其他微任務,也就是說,在兩個階段之間執行微任務時,若存在process.nextTick(),則先執行它,然后再執行其他微任務
● 事件循環的執行順序:瀏覽器的事件循環機制包括同步代碼的執行、宏任務隊列的執行、微任務隊列的執行以及瀏覽器UI線程的渲染工作。如果有Web Worker任務,也會被執行。而在Node.js中,事件循環的執行順序包括腳本作為宏任務的執行、微任務的執行以及可能的Web Worker任務的執行
應用場景影響
● Node.js 更強調后端服務的高效I/O處理和高并發能力,因此其事件循環機制側重于快速響應I/O事件和維持穩定的事件處理流。
● 瀏覽器 則側重于UI渲染和用戶交互的實時響應,故其事件循環設計確保了UI的流暢更新和事件的及時處理。