node.js的異步I/O、事件驅動、單線程

nodejs的特點總共有以下幾點

  1. 異步I/O(非阻塞I/O)
  2. 事件驅動
  3. 單線程
  4. 擅長I/O密集型,不擅長CPU密集型
  5. 高併發

下面是一道很經典的面試題,描述了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等事件句柄)。
          • 如果有,繼續下一輪循環。
          • 如果沒有,結束事件循環,退出程序。

: 在主線程執行完和事件循環總共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.logpromise的new方法在初始化主線程中執行,他們倆個的輸出時間按照先上後下的順序輸出,他們兩個執行完後會立即執行主線程的process.nextTick,然後執行promise.then方法,然後是進入事件隊列中執行setTimeoutsetImmediate。因爲setTimeout的
'最少經過n毫秒後執行的腳本'特性,導致無法確定setTimeoutsetImmediate的執行先後順序,但如果是在回調函數中,則必然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的主線程的性能和安全性。

參考

  1. Node.js 事件循環機制
  2. nodejs筆記之:事件驅動,線程池,非阻塞,異常處理等
  3. 官網文檔
  4. Node.js 事件循環,定時器和 process.nextTick()
  5. nodejs 事件循環
  6. 不要阻塞你的事件循環(或是工作線程池

題外話

事實上,對於nodejs的相關理解更多的收穫在於這裏,nodejs官網指南的中文文檔,以前有點粗心了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章