node.js的非同步I/O、事件驅動、單執行緒

六石發表於2019-07-05

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官網指南的中文文件,以前有點粗心了

相關文章