Node.js理論實踐之《異步非阻塞IO與事件循環》

Node.js是一個構建在Chrome瀏覽器V8引擎上的JavaScript運行環境, 使用單線程事件驅動非阻塞I/O的方式實現了高併發請求,libuv爲其提供了異步編程的能力。javascript

架構組成

nodejs

從這張圖上咱們能夠看出,Node.js底層框架由Node.js標準庫Node bindings底層庫三個部分組成。java

Node.js標準庫

這一層是由Javascript編寫的,也就是咱們使用過程當中直接能調用的API,在源碼中的lib目錄下能夠看到,諸如http、fs、events等經常使用核心模塊node

Node bindings

這一層能夠理解爲是javascript與C/C++庫之間創建鏈接的, 經過這個橋,底層實現的C/C++庫暴露給javascript環境,同時把js傳入V8, 解析後交給libuv發起非阻塞I/O, 並等待事件循環調度;linux

底層庫

這一層主要有如下四塊:編程

  • V8: Google推出的Javascript虛擬機,爲Javascript提供了在非瀏覽器端運行的環境;
  • libuv:爲Node.js提供了跨平臺,線程池,事件池,異步I/O 等能力,是Nodejs之因此高效的主要緣由;
  • C-ares:提供了異步處理DNS相關的能力;
  • http_parser、OpenSSL、zlib等:提供包括http解析、SSL、數據壓縮等能力;

順帶看一下libuv的架構圖,可見Nodejs的網絡I/O文件I/ODNS操做、還有一些用戶代碼都是在libuv工做的。promise

WechatIMG2

單線程

咱們知道任務調度通常有兩種方案: 一是單線程串行執行,執行順序與編碼順序一致,最大的問題是沒法充分利用多核CPU,當並行極大的時候,單核CPU理論上計算能力是100%; 另外一種就是多線程並行處理,優勢是能夠有效利用多核CPU,缺點是建立與切換線程開銷大,還涉及到鎖、狀態同步等問題, CPU常常會等待I/O結束,CPU的性能就白白消耗。瀏覽器

一般爲客戶端鏈接建立一個線程須要消耗2M內存,因此理論上一臺8G的服務器,在Java應用中最多支持的併發數是4000。而Node.js只使用一個線程,當有客戶端鏈接請求時,觸發內部事件,經過非阻塞I/O,事件驅動機制,讓其看起來是並行的。 理論上一臺8G內存的服務器,能夠同時容納3到4萬用戶的鏈接。bash

Node.js採用單線程方案,免去鎖、狀態同步等繁雜問題,又能提升CPU利用率。Node.js高效的除了由於其單線程外,還必須配合下面要說的非阻塞I/O。服務器

非阻塞I/O

概念

首先要清楚,對於一個網絡IO,會涉及到兩個系統對象:網絡

  1. 調用這個IO的進程或線程
  2. 系統內核(kernel)

而當一個讀操做發生時,它會經歷兩個階段:

  1. 等待數據準備好
  2. 將數據從內核中拷貝到用戶進程中 記住這兩點很重要,由於IO模型的區別就是在兩個階段上各有不一樣的狀況。

接下來理清這幾個概念:

  • 阻塞I/O: 在發起I/O操做以後會一直阻塞着進程,不執行其餘操做,直到獲得響應或者超時爲止;
  • 非阻塞I/O:發起I/O操做不等獲得響應或者超時就當即返回,讓進程繼續執行其餘操做,可是要經過輪詢方式不斷地去check數據是否已準備好
  • 多路複用I/O:又分爲select、pool、epool。最大優勢就是單個進程就能夠同時處理多個網絡鏈接的IO。 基本原理就是select/poll這個function會不斷的輪詢所負責的全部socket,當某個socket有數據到達了,就通知用戶進程。 而epool經過callback回調通知機制.減小內存開銷,不因併發量大而下降效率,linux下最高效率的I/O事件機制。
  • 同步I/O:發起I/O操做以後會阻塞進程直到獲得響應或者超時。前三者阻塞I/O,非阻塞I/O,多路複用I/O都屬於同步I/O。 注意非阻塞I/O在數據從內核拷貝到用戶進程時,進程仍然是阻塞的,因此仍是屬於同步I/O。
  • 異步I/O:直接返回繼續執行下一條語句,當I/O操做完成或數據返回時,以事件的形式通知執行IO操做的進程。

總結

阻塞I/O和非阻塞I/O區別在於:在I/O操做的完成或數據的返回前是等待仍是返回(能夠理解成一直等仍是分時間段等) 同步I/O和異步I/O區別在於 :在I/O操做的完成或數據的返回前會不會將進程阻塞(或者說是主動查詢仍是被動等待通知)

設計理念

因爲Node.js中採用了非阻塞型I/O機制,所以在執行了讀數據的代碼以後,將當即轉而執行其後面的代碼,把讀數據返回結果的處理代碼放在回調函數中,從而提升了程序的執行效率。 當某個I/O執行完畢時,將以事件的形式通知執行I/O操做的線程,線程執行這個事件的回調函數。

事件循環

event-loop

基本流程

  1. 每一個Node.js進程只有一個主線程在執行程序代碼,造成一個執行棧(execution context stack);
  2. 主線程以外,還維護一個事件隊列(Event queue),當用戶的網絡請求或者其它的異步操做到來時,會先進入到事件隊列中排隊,並不會當即執行它,代碼也不會被阻塞,繼續往下走,直到主線程代碼執行完畢;
  3. 主線程代碼執行完畢完成後,而後經過事件循環機制(Event Loop),檢查隊列中是否有要處理的事件,從隊頭取出第一個事件,從線程池分配一個線程來處理這個事件,而後是第二個,第三個,直到隊列中全部事件都執行完了。 當有事件執行完畢後,會通知主線程,主線程執行回調,並將線程歸還給線程池。這個過程就叫事件循環(Event Loop);
  4. 不斷重複上面的第三步;

6個階段

event-loop2

注意:

  • 每一個框都被稱爲事件循環的一個流程階段.
  • 每一個階段都有一個FIFO(先進先出)執行回調函數的隊列,一般當事件循環進入到給定階段會執行特定於該階段的全部操做,而後執行該階段隊列的回調事件直到隊列耗盡或者超過最大執行限度爲止,而後事件循環就會走向下一階段;
  • poll階段中新的處理事件有可能會加入到內核的隊列,即處理輪詢事件時候又加入新的輪詢事件,所以長時間運行回調事件會讓poll階段運行時間超過定時器的閾值;
  • 當全部階段被順序執行一次後,稱 Event loop 完成了一個tick

階段概述:

  1. timers(定時器)階段:執行setTimeoutsetInterval調度的回調。
  2. pending callbacks(等待回調)階段: 用於執行前一輪事件循環中被延遲到這一輪的I/O回調函數
  3. idle,prepare(閒置,準備)階段: 只能內部使用。
  4. poll(輪詢)階段:最重要的階段,執行I/O事件回調,在適當的條件下 node 會阻塞在這個階段。
  5. check(檢查)階段:執行 setImmediate 的回調。
  6. close callbacks(關閉回調)階段:執行close事件的回調, 如套接字(socket)或句柄(handle)忽然關閉;

event-loop3

事件循環的 PendingIdle/PrepareClose 階段塗成灰色,由於這些是 Node 在內部使用的階段。

Node.js開發者編寫的代碼僅以微任務形式在主線計時器(Timers) 階段、輪詢(Poll) 階段和 查詢(Check) 階段中運行。

  • 爲了儘量快的處理異步 I/O 事件,那麼事件循環 tick 總有一種維持 poll 狀態的傾向
  • 當前 poll 階段應該維持(阻塞)多長時間是由 後續 tick 各個階段是否存在不爲空的回調函數隊列最近的計時器時間節點 決定。 若全部隊列爲空且不存在任何計時器,那麼事件循環將 無限制地維持在 poll 階段;以實現一旦存在 I/O 回調函數加入到 poll 隊列中便可當即獲得執行;
  • check 階段的回調函數隊列中全部的回調函數都是來自 poll 階段的 setImmediate 函數

poll 階段主要有兩個功能

  1. 當 timers 的定時器到期後,執行定時器(setTimeout 和 setInterval)的 callback
  2. 執行 poll 隊列裏面的 I/O callback
  • 若是 Event Loop 進入了 poll 階段,且代碼未設定 timer,可能發生如下狀況:
    1. 若是 poll queue 不爲空,Event Loop 將同步的執行 queue 裏的 callback,直至 queue 爲空,或者執行的 callback 到達系統上限。
    2. 若是 poll queue 爲空,可能發生如下狀況:
      • 若是代碼使用 setImmediate() 設定了 callback,Event Loop 將結束 poll 階段進入 check 階段,並執行 check 階段的 queue。
      • 若是代碼沒有使用 setImmediate(),Event Loop 將阻塞在該階段等待 callbacks 加入 poll queue,若是有 callback 進來則當即執行。 一旦 poll queue 爲空,Event Loop 將檢查 timers,若是有 timer 的時間到期,Event Loop 將回到 timers 階段,而後執行 timer queue。

process.nextTick

process.nextTick() 不在 Event Loop 的任何階段執行,而是在各個階段切換的中間執行,即從一個階段切換到下個階段前執行。

這裏還須要提一下 macrotask 和 microtask 的概念,macrotask(宏任務)指 Event Loop 每一個階段執行的任務,microtask(微任務)指每一個階段之間執行的任務。

即上述 6 個階段都屬於 macrotask,process.nextTick() 屬於 microtask

process.nextTick() 的實現和 v8 的 microtask 並沒有關係,是 Node.js 層面的東西,應該說 process.nextTick() 的行爲接近爲 microtask。 Promise.then 也屬於 microtask 的一種。

能夠經過遞歸 process.nextTick()調用來「餓死」您的 I/O,阻止事件循環到達 輪詢 階段。

promise.then 回調像微處理同樣執行,就像 process.nextTick 同樣。 雖然,若是二者都在同一個微任務隊列中,則將首先執行 process.nextTick 的回調。 優先級 process.nextTick > promise.then = queueMicrotask

案例分析

案例一

咱們來看這一段代碼在不一樣的環境下執行的結果:

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
複製代碼

首先在瀏覽器環境下,其輸出結果爲:

timer1
promise1
timer2
promise2
複製代碼

藉助於前面關於瀏覽器中Javascript事件循環的知識,不能理解。

而後咱們將其在 Node.js v11.0.0 如下版本執行,獲得結果:

timer1;
timer2;
promise1;
promise2;
複製代碼

但若是是在 Node.js v11.0.0 以上(包括)版本中執行中,將獲得結果:

timer1;
promise1;
timer2;
promise2;
複製代碼

緣由是 node v11 如下只有所有執行了 timers 階段隊列的所有任務才執行微任務隊列,而瀏覽器只要執行了一個宏任務就會執行微任務隊列。 node v11 在 timer 階段的 setTimeout,setInterval 和在 check 階段的 immediate 都在 node v11 裏面都修改成一旦執行一個階段裏的一個任務就馬上執行微任務隊列。 也是爲了和瀏覽器保持一致。

案例二

  • setImmediate 設計爲在當前輪詢 poll 階段完成後執行腳本
  • setTimeout 計劃在以毫秒爲單位的最小閾值過去以後運行腳本

不在 I/O 回調(即主模塊)內的腳本,則兩個計時器的執行順序是不肯定的,由於它受機器性能的約束,好比:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
複製代碼

輸出順序是不肯定的。

咱們知道 setTimeout 的回調函數在 timer 階段執行,setImmediate 的回調函數在 check 階段執行,Event loop 的開始會先檢查 timer 階段,可是在開始以前到 timer 階段會消耗必定時間,因此就會出現兩種狀況:

  • timer 前的準備時間超過 1ms,知足 loop->time >= 1,則執行 timer 階段(setTimeout)的回調函數。
  • timer 前的準備時間小於 1ms,則先執行 check 階段(setImmediate)的回調函數,下一次 Event loop 執行 timer 階段(setTimeout)的回調函數。

而若是這兩個調用在一個I/O回調中,那麼immediate老是先執行。

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
複製代碼

分析以下:

  1. fs.readFile 的回調函數執行完後;
  2. 註冊 setTimeout 的回調函數到 timer 階段;
  3. 註冊 setImmediate 的回調函數到 check 階段;
  4. Event loop 從 pool 階段出來繼續往下一個階段執行,剛好是 check 階段,因此 setImmediate 的回調函數先執行。 本次 Event loop 結束後,進入下一次 Event loop,執行 setTimeout 的回調函數;

案例三

setInterval(() => {
  console.log('setInterval')
}, 100)

process.nextTick(function tick () {
  process.nextTick(tick)
})
複製代碼

運行結果:setInterval 永遠不會打印出來。

process.nextTick 會無限循環,將 Event loop 阻塞在 microtask 階段,致使 Event loop 上其餘 macrotask 階段的回調函數沒有機會執行。 解決方法一般是用 setImmediate 替代 process.nextTick,以下:

setInterval(() => {
  console.log('setInterval')
}, 100)

setImmediate(function immediate () {
  setImmediate(immediate)
})
複製代碼

運行結果:每 100ms 打印一次 setInterval。

process.nextTick 內執行 process.nextTick 仍然將 tick 函數註冊到當前 microtask 的尾部,因此致使 microtask 永遠執行不完; setImmediate 內執行 setImmediate 會將 immediate 函數註冊到下一次 Event loop 的 check 階段,而不是當前正在執行的 check 階段,因此給了 Event loop 上其餘 macrotask 執行的機會。

案例四

setImmediate(() => {
  console.log('setImmediate1')
  setImmediate(() => {
    console.log('setImmediate2')
  })
  process.nextTick(() => {
    console.log('nextTick')
  })
})

setImmediate(() => {
  console.log('setImmediate3')
})
複製代碼

運行結果在 node v11如下是:

setImmediate1
setImmediate3
nextTick
setImmediate2
複製代碼

在node v11以上是:

setImmediate1
nextTick
setImmediate3
setImmediate2
複製代碼

緣由同案例一

案例五

setImmediate(() => {
  console.log(1)
  setTimeout(() => {
    console.log(2)
  }, 100)
  setImmediate(() => {
    console.log(3)
  })
  process.nextTick(() => {
    console.log(4)
  })
})
process.nextTick(() => {
  console.log(5)
  setTimeout(() => {
    console.log(6)
  }, 100)
  setImmediate(() => {
    console.log(7)
  })
  process.nextTick(() => {
    console.log(8)
  })
})
console.log(9)
複製代碼

運行結果在 node v11如下是:

9
5
8
1
7
4
3
6
2
複製代碼

在 node v11以上是:

9
5
8
1
4
7
3
6
2
複製代碼

緣由請自行分析。

相關文章
相關標籤/搜索