破陣九解:Node和瀏覽器之事件循環/任務隊列/非同步順序/數據結構

  • 2019 年 10 月 21 日
  • 筆記

前言

本文內容比較長,請見諒。如有評議,還請評論區指點,謝謝大家!

>> 目錄

  1. 開門見山:Node和瀏覽器的非同步執行順序問題
  2. 兩種環境下的宏任務和微任務(macrotask && microtask)
  3. Node和瀏覽器的事件循環模型在實現層面的區別
  4. Node和瀏覽器的事件循環的任務隊列(task queue)
  5. Node和瀏覽器的事件循環模型在表現層面的差異
  6. 理清libuv的“7隊列”和Node“6隊列”的關係
  7. Node和瀏覽器環境下setTimeout的最小延遲時間
  8. setTimeout和setImmediate的執行順序詳解
  9. Node相關組成結構中涉及的數據結構

一.開門見山:Node和瀏覽器的非同步執行順序問題

>> Node端的非同步執行順序

Node端的非同步執行順序如下

同步程式碼 > process.nextTick > Promise.then中的函數 > setTimeOut(0) 或 setImmediate

  • 「備註1」 Promise中的函數,無論是resolve前的還是後的,都屬於“同步程式碼”的範圍,並不是“非同步程式碼”
  • 「備註2」 setTimeOut(0) 或 setImmediate的執行順序取決於具體情況,並沒有確定的先後區分

>> Node端非同步邏輯順序實驗論證

setTimeout (function () {    console.log ('setTimeout');  }, 0);  setImmediate (function () {    console.log ('setImmediate');  });  new Promise (function (resolve, reject) {    resolve ();  }).then (function () {    console.log ('promise.then');  });  process.nextTick (function () {    console.log ('next nick');  });  console.log ('同步程式碼');

輸出

 
備註1: Promise接收的函數的同步問題(實驗論證)
console.log ('我是同步程式碼');  new Promise (function (resolve, reject) {    console.log ('resolve前');    resolve ();    console.log ('resolve後');  }).then (function () {});  console.log ('我是同步程式碼');

 

 
備註2: setTimeOut(0) 或 setImmediate的執行順序問題

這個問題比較複雜,可參考下面這篇文章

 

>> 瀏覽器的非同步執行順序問題

瀏覽器中,涉及的非同步API有:Promise, setTomeOut,setImmediate

(其中setImmediate可以忽略不計,因為它只在egde和IE11才支援,沒錯,Chrome和火狐都是不支援的,所以當然也不建議使用)

執行順序
Promise.then中的函數 > setTimeOut(0) 或 setImmediate

以下程式碼
setTimeout (function () {    console.log ('setTimeout');  }, 0);  setImmediate (function () {    console.log ('setImmediate');  });  new Promise (function (resolve, reject) {    resolve ();  }).then (function () {    console.log ('promise');  });

 
在edge瀏覽器中的測試結果為

 

>> 參考資料

二.兩種環境下的宏任務和微任務陣營(macrotask && microtask)

我們上面講述了不同的程式,它們的非同步執行順序的區別,其中我們發現,有的非同步API執行快,而有的非同步API執行慢,實際上,它們作為非同步任務,被分成了宏任務和微任務兩大陣營,同時整體表現出微任務執行快於宏任務的現象

在宏任務和微任務方面,Node和瀏覽器也是差異很大的,這是因為它們的底層實現不一樣。具體原理會在下面講解,下面先概述下兩種環境下的task的差別

>> 瀏覽器端的宏任務和微任務

下面簡單介紹下宏任務和微任務的陣營

  • 宏任務(macrotasks):setTimeout, setInterval, I/O,setImmediate(如果存在),requestAnimationFrame(存在爭議)
  • 微任務 (microtasks) : process.nextTick, Promises,MutationObserver

 

>> 備註解釋

  • 備註1:MutationObserver是HTML5新增的用來檢測DOM變化的,參考資料

  • 備註2: 部分資料認為,requestAnimationFrame也屬於宏任務,理由是:requestAnimationFrame在MDN的定義為,下次頁面重繪前所執行的操作,而重繪也是作為宏任務的一個步驟來存在的,且該步驟晚於微任務的執行,參考資料

>> Node端的宏任務和微任務

(⚠️該概念定義可能存在爭議,部分資料對Node中也做了宏任務和微任務的劃分,而部分資料則只提出了微任務的概念,而沒有涉及宏任務,本文遵從前者)

  • 微任務:process.nextTick,promise.then
  • 宏任務:setTimeout, setInterval,setImmediate
當然了,直接說宏任務的執行比微任務的解釋也許太粗糙了,沒辦法解釋很多具體的問題,比如:具體不同的宏任務之間的順序問題,所以,要做進一步的判斷,我們就要理解JS事件循環中的執行階段,和隊列相關的知識
 

三.Node和瀏覽器的事件循環模型在實現層面的區別

瀏覽器的事件循環是在 HTML5 中定義的規範,而 Node 中則是由 libuv 庫實現,這是它們在實現上的根本差別。也就是說,很多時候,他們的行為看起來很像,但event loop的內在實現卻存在差別。

>> 瀏覽器的event loop

我們看下規範的定義,以下援引自HTML5規範草案

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop.
“為了協調事件,用戶交互,腳本,渲染,網路等,用戶代理(瀏覽器)必須使用本節中描述的事件循環。每個代理都有一個關聯的事件循環。”

也就是說,瀏覽器根據這個草案的規定,實現了事件循環,目的是用來協調瀏覽器的事件,交互和渲染的。
 

>> Node的event loop

Node的事件循環基於libuv實現,libuv是Node.js的底層依賴,一個跨平台的非同步IO庫。分別通過windows平台下的IOCP和Unix 環境下的 libev實現跨平台的兼容。

實際上,雖然libuv作為Node的底層模組,一開始是為了Node而設計的,但是它被抽象了出來,並且不僅僅為Node服務,也服務於其他語言,例如,它也支援了julia等語言的實現(Julia 是一個面向科學計算的語言)

 

四.Node和瀏覽器的事件循環的任務隊列

>> 參考資料

>> Node的任務隊列

Node的任務隊列總共6個:包括4個主隊列(main queue)和兩個中間隊列(intermediate queue)

  • 四個主隊列由libuv提供
  • 兩個中間隊列由Node.js實現
(⚠️上面這個論斷我是根據相關資料推斷的,如有不當請指正)

>> 6個隊列具體內容

  • 主隊列(main queue):包括計時器隊列,IO事件隊列,即時隊列,關閉事件處理程式隊列
  • 中間隊列(intermediate queue):包括(1)Next Ticks隊列和(2)其他微任務隊列
(此概念 由Deepal Jayasekara,一位德國Node開發者提出,即上面文章的作者)

>> 四個主隊列

Q1.計時器隊列 (timer queue)

在計數器隊列中,Node會在這裡保存setTimeOut和setInterval添加的處理程式,所以處理到這個隊列的時候,Node會在一堆計時器中檢查有沒有過期的計時器,如果過期了,就調用其這個計時器的回調函數。如果有多個計時器到期(設置了相同的到期時間),那麼會根據設置的先後,按照順序去執行它們。

從這裡也可以看出,為什麼我們總會強調setTimeOut和setInterval的時間誤差。這是因為只有在該循環流程中,檢查到“過期”了,才會對計時器進行處理

 
Q2.IO事件隊列(IO events queue)

IO一般指的是和CPU以外的外部設備通訊的工作,例如文件操作和TCP/UDP網路操作等。

Node依賴於底層模組libuv提供的非同步IO的功能。在IO事件隊列中,Node將處理所有待處理的I/O操作

 
Q3.即時隊列 (immediate queue)

處理這個隊列的時候,setImmediate設置的函數回調,會被依次調用

 
Q4.關閉事件處理程式(close handlers queue)

當處理到這個隊列的時候,Node將會處理所有I / O事件處理程式

 

>> 兩個中間隊列

Q5.next ticks隊列

保存process.nextTick調用形成的任務
 

Q6.其他微任務隊列

保存Promise形成的任務

 

>> 主隊列和中間隊列的關係

在一輪循環中,4個主隊列,每處理完一個主隊列,接著就要把兩個中間隊列處理一次, 我的理解是:一趟循環走下來, 4個主隊列都各自被處理了一次,而2個中間隊列則是被處理了4次。
 

圖示如下

 
這個圖可能說的不是很清楚,所以我整理了一下,如下所示:

(備註⚠️:此圖只適用於Node11.0.0版本以前的情況! 對於Node11以後的隊列執行流程,請參考下面一節)

 

>> 瀏覽器的任務隊列

瀏覽器中只分兩種隊列:

  • 宏任務隊列(macro task)
  • 微任務隊列。(micro task)
他們的處理順序是

  1. 每次從宏任務隊列中取一個宏任務執行, 完成後, 把微任務隊列中的所有微任務,一次性處理完
  2. 不斷重複上述過程
如下圖所示

 

五.Node和瀏覽器的事件循環模型在表現層面的差異

Node和瀏覽器的區別情況是:

  • 在Node11.0.0以前的版本,Node和瀏覽器的非同步流程存在一些細節上的差異,
  • 但在Node11.0.0以後,這一差異被抹去了,因為Node主動修改了實現以和瀏覽器保持一致
吐槽:聽話的Node.js
修改前後區別在於

  • 在瀏覽器和Node11以後,每執行完一個timer類回調,例如setTimeout,setImmediate 之後,都會把微任務給執行掉(promise等)。
  • 原來Node10和以前: 當一個任務隊列(例如timer queue)裡面的回調都批量執行完了,才去執行微任務
我們可以看出,微任務的執行變得更迅速了,不再是跟在任務隊列處理完後處理,而是在單個timer類回調(setTimeout,setImmediate)處理完後,也會被處理了。

 
讓我們分析下面這段程式碼

setTimeout (function () {    console.log ('timeout1:宏任務');    new Promise (function (resolve, reject) {      resolve ();    }).then (() => {      console.log ('promise:微任務');    });  });  setTimeout (function () {    console.log ('timeout2:宏任務');  });


對這段程式碼

  • 如果是11以後的Node和瀏覽器:執行完第一個setTimeout後,接下來輪到Promise這類微任務執行了,所以接下來應該是輸出「promise:微任務」
  • 如果是version11以前的Node,則執行完第一個setTimeout後,因為timer隊列沒處理完,所以接下來執行的是第二個setTimeout,輸出的是「timeout2:宏任務」
運行結果

瀏覽器

Node10.16.3(nvm切換node版本)

Node11.0.0(nvm切換node版本)

我們不難發現其中差別,Node10.16.3的表現是和瀏覽器不一樣的,而到了Node11,則Node和瀏覽器相一致了。

 

>> 參考資料

 

六.理清libuv的“七隊列”和Node“四個主隊列”的關係

(⚠️下面的是個人理解,如有您有更合理的觀點,請在評論區給出,謝謝)

好吧,其實上面的內容已經有點複雜了! 可是這個時候,又有個神奇的概念過來插一腳 它就是,Node官方文檔裡面提出的“七隊列”
下面介紹一下這位小夥伴

>> 我們首先要明白的是三點

  1. 這裡的七隊列是libuv內部的概念
  2. 之前介紹的”Node六隊列”和”四個主隊列”是Node內部,但在libuv外部的實現和概念
  3. 這兩者之間存在對應關係,雖然不是一一對應(下面會細講對應關係)

>> libuv七隊列圖解

 

>> 七隊列的具體作用

  • timers:執行滿足條件的 setTimeout 、setInterval 回調;

  • pending callbacks: 檢索新的 I/O 事件;執行與 I/O 相關的回調(幾乎所有情況下,除了關閉的回調函數,它們由計時器和 setImmediate() 排定的之外),其餘情況 node 將在此處阻塞。

  • idle:僅僅供給Node系統內部使用
  • prepare:僅僅供給Node系統內部使用
  • poll:檢索新的 I/O 事件;執行與 I/O 相關的回調(幾乎所有情況下,除了關閉的回調函數,它們由計時器和 setImmediate() 排定的之外),其餘情況 node 將在此處阻塞。
  • check:執行 setImmediate 的回調;
  • close callbacks:關閉所有的 closing handles ,一些 onclose 事件;

>> libuv七隊列和Node四個主隊列的對應關係

 

>> 參考資料

七.Node和瀏覽器環境下setTimeout的最小延遲時間

>> 瀏覽器端的最小延遲時間

“HTML5 規範規定最小延遲時間不能小於 4ms,即 x 如果小於 4,會被當做 4 來處理。 不過不同瀏覽器的實現不一樣,比如,Chrome 可以設置 1ms,IE11/Edge 是 4ms。”

>> Node端的最小延遲時間

一句話足以:Node端沒有最小延遲時間

>> 我覺得裡面有一句話說的特別好

Node沒有最小延遲,這實際上是瀏覽器和節點之間的兼容性問題。計時器(setTimeout和setImmediate)在JavaScript中是完全未指定的(這是DOM規範,在Node中沒有用,何況瀏覽器也沒有遵循),而node實現它們的原因僅僅是因為它們在JavaScript的歷史上非常地基礎

It doesn’t have a minimum delay and this is actually a compatibility issue between browsers and node. Timers are completely unspecified in JavaScript (it’s a DOM specification which has no use in Node and isn’t even followed by browsers anyway) and node implements them simply due to how fundamental they’ve been in JavaScript’s history
 

八.setTimeout(0 delay)和setImmediate的執行順序詳解

這個問題其實比較複雜,不能一概而論。

>> 總結來說

  • 在主執行緒中直接調用setTimeOut(0,function) 和setImmediate不能確定其執行的先後順序
  • 但是如果在同一個IO循環中,例如在一個非同步回調中調用這兩個方法,setImmediate會首先被調用

>> 具體解釋

第一.在主執行緒中運行以下腳本,我們不能確定timeout和immediate輸出的先後順序,結果受到進程性能的影響 (例子源於Node官方文檔,鏈接在下面給出)

// timeout_vs_immediate.js  setTimeout(() => {    console.log('timeout');  }, 0);    setImmediate(() => {    console.log('immediate');  });

結果

輸出結果無法確定

 
第二.如果在一個IO循環中運行setTimeOut(0,function) 和setImmediate,那麼setImmediate 總是被優先調用

// timeout_vs_immediate.js  const fs = require('fs');    fs.readFile(__filename, () => {    setTimeout(() => {      console.log('timeout');    }, 0);    setImmediate(() => {      console.log('immediate');    });  });

 
輸出結果

immediate timeout

 

九.Node相關組成結構中涉及的數據結構

>> 介紹

  • setTimeout與setInterval: 調用這兩個函數創建的定時器會被插入到定時器觀察者內部的一個紅黑樹中,每次tick執行時候都會從紅黑樹中迭代取出定時器對象。

  • process.nextTick: 將回調函數放入到隊列中,在下一輪Tick時取出執行,可以達到setTimeout(fn,0)的效果,由於不需要動用紅黑樹,效率更高時間複雜度為O(1)。相比較之下。(紅黑樹時間複雜度O(lg(n)) )

  • setImmediate:的回調函數保存在鏈表中,每次Tick只執行鏈表中的一個回調函數。

>> 本節參考資料

  • 《深入淺出Node.js》作者:朴靈,阿里巴巴數據平台資深開發者,被尊為Node.js的佈道者