nodejs的特點總共有以下幾點
- 異步I/O(非阻塞I/O)
- 事件驅動
- 單線程
- 擅長I/O密集型,不擅長CPU密集型
- 高並發
下面是一道很經典的面試題,描述了node的整體運行機制,相信很多人都碰到了。這道題背后的原理就是nodejs代碼執行順序
setTimeout(function() {
console.log('4');
},0)
setImmediate(function() {
console.log('5');
})
let s = new Promise(function(resolve, reject) {
console.log('2');
resolve(true)
console.log('7')
})
s.then(function() {
console.log('3');
})
process.nextTick(function() {
console.log('6')
})
console.log('1');
// 我電腦的輸出結果是 2、7、1、6、3、4、5
1. nodejs代碼執行順序(事件循環機制)
nodejs的運行機制: nodejs主線程主要起一個任務調度的作用。nodejs用一個主線程處理所有的請求, 將I/O操作交由底下的線程池處理;在所有主線程任務執行完成后,主線程處理事件隊列。 所以在同步初始化代碼執行完成后,nodejs會基於事件隊列不停的做事件循環。事實上,nodejs運行環境 = 主線程(單線程,包括事件隊列) + 線程池(工作線程池,執行其他工作-多線程)
- node 的初始化
- 初始化 node 環境。
- 執行輸入代碼。
- 執行 process.nextTick 回調。
- 執行 microtasks。(Promise.then)
- 進入事件循環
- 進入 timers 階段 (定時器階段:本階段執行已經安排的 setTimeout() 和 setInterval() 的回調函數。)
- 檢查 timer 隊列是否有到期的 timer 回調,如果有,將到期的 timer 回調按照 timerId 升序執行。
- 檢查是否有 process.nextTick 任務,如果有,全部執行。
- 檢查是否有microtask,如果有,全部執行。
- 退出該階段。
- 進入pending IO callbacks階段。(對某些系統操作(如 TCP 錯誤類型)執行回調)
- 檢查是否有 pending 的 I/O 回調。如果有,執行回調。如果沒有,退出該階段。
- 檢查是否有 process.nextTick 任務,如果有,全部執行。
- 檢查是否有microtask,如果有,全部執行。
- 退出該階段。
- 進入 idle,prepare 階段:
- 僅系統內部使用。
- 進入 poll 階段(檢索新的 I/O 事件;執行與 I/O 相關的回調,除了定時器和關閉的回調函數,其余都在這里)
- 首先檢查是否存在尚未完成的回調,如果存在,那么分兩種情況。
- 第一種情況:
- 如果有可用回調(可用回調包含到期的定時器還有一些IO事件等),執行所有可用回調。
- 檢查是否有 process.nextTick 回調,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出該階段。
- 第二種情況:
- 如果沒有可用回調,執行下一步;
- 檢查是否有 immediate 回調,如果有,退出 poll 階段。如果沒有,阻塞在此階段,等待新的事件通知。
- 如果不存在尚未完成的回調,退出poll階段。
- 第一種情況:
- 首先檢查是否存在尚未完成的回調,如果存在,那么分兩種情況。
- 進入 check 階段。(setImmediate() 回調函數在這里執行)
- 如果有immediate回調,則執行所有immediate回調。
- 檢查是否有 process.nextTick 回調,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出 check 階段
- 進入 closing 階段。(檢測關閉的回調函數,例如 xx.on('close'))
- 如果有immediate回調,則執行所有immediate回調。
- 檢查是否有 process.nextTick 回調,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出 closing 階段
- 檢查是否有活躍的 handles(定時器、IO等事件句柄)。
- 如果有,繼續下一輪循環。
- 如果沒有,結束事件循環,退出程序。
- 檢查是否有活躍的 handles(定時器、IO等事件句柄)。
- 進入 timers 階段 (定時器階段:本階段執行已經安排的 setTimeout() 和 setInterval() 的回調函數。)
注: 在主線程執行完和事件循環總共7個階段,每一個階段執行完都會調用一遍process.nextTick
回調,一遍microtaks
(promise);
2. setImmediate和process.nextTick和setTimeout
- setImmediate(): 事件循環poll階段執行完后執行setImmediate;
- process.nextTick():主線程和事件循環每一階段完成后都會調用;
- setTimeout(): 最少經過n毫秒后執行的腳本,受到前一次事件循環時間影響,實際執行時間為>=n毫秒
- ** setTimeout和setImmediate執行順序問題**
- 如果運行的是不屬於 I/O 周期(即主模塊)的以下腳本,則執行兩個計時器的順序是非確定性的,因為它受進程性能的約束;
- 如果你把這兩個函數放入一個 I/O 循環內調用,setImmediate 總是被優先調用;I/O場景推薦使用setsetImmediate,因為setsetImmediate始終而且是立即執行
3. 對上題的理解
主線程中,console.log
和promise
的new方法在初始化主線程中執行,他們倆個的輸出時間按照先上后下的順序輸出,他們兩個執行完后會立即執行主線程的process.nextTick
,然后執行promise.then
方法,然后是進入事件隊列中執行setTimeout
和setImmediate
。因為setTimeout的
'最少經過n毫秒后執行的腳本'特性,導致無法確定setTimeout
和setImmediate
的執行先后順序,但如果是在回調函數中,則必然setImmediate
先執行,因為事件循環的階段中,setImmediate緊挨着回調函數之后執行,而setTimeout則在下次事件循環中執行。
4. 單線程和多線程
- 多線程: 服務器為每個客戶端請求分配一個線程,使用同步 I/O,系統通過線程切換來彌補同步 I/O 調用的時間開銷。比如 Apache 就是這種策略,由於 I/O 一般都是耗時操作,因此這種策略很難實現高性能,但非常簡單,可以實現復雜的交互邏輯。
- 單線程: 而事實上,大多數網站的服務器端都不會做太多的計算,它們接收到請求以后,把請求交給其它服務來處理(比如讀取數據庫),然后等着結果返回,最后再把結果發給客戶端。因此,Node.js 針對這一事實采用了單線程模型來處理,它不會為每個接入請求分配一個線程,而是用一個主線程處理所有的請求,然后對 I/O 操作進行異步處理,避開了創建、銷毀線程以及在線程間切換所需的開銷和復雜性。
5. 異步I/O
-
IO操作: IO操作就是以流的形式,進行的操作,比如網絡請求,文件讀取寫入。IO操作也就是input和output的操作。
-
阻塞IO: 在調用阻塞O時,應用程序需要等待IO完成才能返回結果。 阻塞IO的特點:調用之后一定要等到系統內核層面完成所有操作之后,調用才結束。 阻塞O造成CUP等待IO,浪費等待時間,CPU的處理能力不能得到充分利用。
-
非阻塞IO: 為了提高性能,內核提供了非阻塞IO,非阻塞IO跟阻塞IO的差別是調用之后會立即返回。阻塞IO完成整個獲取數據的過程,而非阻塞IO則不帶數據直接返回,要獲取數據,還要通過描述符再次讀取。非阻塞IO返回之前,node主線程可以用來處理其他事物,此時性能提升非常明顯。
-
為什么node擅長I/O密集型,不擅長CPU密集型:因為node的I/O處理中主線程只負責轉發,實際操作在其他線程及線程隊列里完成,所以性能相對較高; 而CPU密集則要求node的主線程處理,這時候其余請求只能等待
-
我的理解: node的異步I/O分為兩個階段,第一個階段是主線程調用線程池里的工作線程執行異步操作,主線程取回對應的描述符,存儲下來,工作線程執行相關操作取回數據后存儲下來,這一部分在主線程接收到請求后立即完成;第二個階段在事件隊列里完成,根據描述符去工作線程里去獲取數據,以提升性能.
6. 高並發
以下是對nodejs高並發的理解,nodejs的高並發體現在處理I/O的性能上,而不是CPU密集上,摘錄自官網文檔
讓我們思考這樣一種情況:每個對 Web 服務器的請求需要 50 毫秒完成,而那 50 毫秒中的 45 毫秒是可以異步執行的數據庫 I/O。選擇 非阻塞 異步操作可以釋放每個請求的 45 毫秒來處理其它請求。僅僅是選擇使用 非阻塞 方法而不是 阻塞 方法,就是容量上的重大區別。
7. 總結
Node 有兩種類型的線程:一個事件循環線程和 k 個工作線程。 事件循環負責 JavaScript 回調和非阻塞 I/O,工作線程執行與 C++ 代碼對應的、完成異步請求的任務,包括阻塞 I/O 和 CPU 密集型工作。 這兩種類型的線程一次都只能處理一個活動。 如果任意一個回調或任務需要很長時間,則運行它的線程將被 阻塞。 如果你的應用程序發起阻塞的回調或任務,在好的情況下這可能只會導致吞吐量下降(客戶端/秒),而在最壞情況下可能會導致完全拒絕服務。要編寫高吞吐量、防 DoS 攻擊的 web 服務,您必須確保不管在良性或惡意輸入的情況下,您的事件循環線程和您的工作線程都不會阻塞。
通常意義上,I/O密集型活動,如網絡I/O、文件I/O,DNS操作等通常建議放在對外提供網絡服務的端口所在的服務內,剩下的諸如大內容的crypto,zlib,fs同步操作、子進程,JSON處理、計算等盡量另起node服務或者其他語言服務去進行,因為這些操作會影響到node的主線程的性能和安全性。
參考
- Node.js 事件循環機制
- nodejs筆記之:事件驅動,線程池,非阻塞,異常處理等
- 官網文檔
- Node.js 事件循環,定時器和 process.nextTick()
- nodejs 事件循環
- 不要阻塞你的事件循環(或是工作線程池
題外話
事實上,對於nodejs的相關理解更多的收獲在於這里,nodejs官網指南的中文文檔,以前有點粗心了