1. 程式人生 > >破陣九解:Node和瀏覽器之事件迴圈/任務佇列/非同步順序/資料結構

破陣九解:Node和瀏覽器之事件迴圈/任務佇列/非同步順序/資料結構

前言

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

>> 目錄

  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的執行順序問題 這個問題比較複雜,可參考下面這篇文章
  • 《Node.js官方文件:事件迴圈,定時器和 process.nextTick》
 

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

瀏覽器中,涉及的非同步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瀏覽器中的測試結果為  

>> 參考資料

  • 《MDN: window.setImmediate》

二.兩種環境下的巨集任務和微任務陣營(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 是一個面向科學計算的語言)
  • libuv官方文件:http://libuv.org/
  • libuv簡單介紹:https://www.oschina.net/p/libuv
 

四.Node和瀏覽器的事件迴圈的任務佇列

>> 參考資料

  • 《Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2》

>> 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和瀏覽器相一致了。  

>> 參考資料

  • 《New Changes to the Timers and Microtasks in Node v11.0.0 ( and above)》
 

六.理清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四個主佇列的對應關係

 

>> 參考資料

  • 《Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 3》

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

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

“HTML5 規範規定最小延遲時間不能小於 4ms,即 x 如果小於 4,會被當做 4 來處理。 不過不同瀏覽器的實現不一樣,比如,Chrome 可以設定 1ms,IE11/Edge 是 4ms。”
  • 《JavaScript定時器和執行機制解析》— — 騰訊Alloyteam前端團隊

>> Node端的最小延遲時間

一句話足以:Node端沒有最小延遲時間
  • 《Does Node.js enforce a minimum delay for setTimeout? - Stack Overflow》

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

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的佈道者