瀏覽器/nodeJS 中的事件環工作原理

citystrawman發表於2018-03-25

眾所周知,JS的最大特點之一便是單執行緒.這意味著JS中如果從上到下執行命令,如果前面的命令花時間太長,則會出現"假死"狀態,影響使用者體驗. 因此在瀏覽器/nodeJS中,通過webAPI等方式, 將這些長時間的js命令通過非同步"分流"到其他的執行緒(JS本身是單執行緒,但是瀏覽器和nodeJS是多執行緒), 等這些命令執行完成後通過回撥函式"返回"JS中. 而這一套機制的實現 就是事件環(eventloop). 下面我們就來仔細研究一下它的工作原理.

瀏覽器中的事件環工作原理

首先 用一張圖來展示瀏覽器中的事件環:

Alt text
瀏覽器中的事件環

從這張圖中我們可以看到其中有巨集任務(MacroTask)和微任務(MicroTask)之分,我們來說下這個巨集任務與微任務。

巨集任務包括:

  • setTimeout
  • setInterval 微任務包括:
  • Promise
  • MutaionObserver
  • Object.observe(已廢棄:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe)

在單次的迭代中,event loop首先檢查Macrotask佇列,如果有一個Macrotask等待執行,那麼執行該任務。當該任務執行完畢後(或者Macrotask佇列為空),event loop繼續執行Microtask佇列。(V8 中 Microtask 預設是自動執行的)。

講了這麼多理論, 先來一點程式碼看看:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
複製程式碼

這段程式碼的順序如何呢? 將程式碼放入chrome執行, 我們可以得到順序如下:

Alt text
注意 該圖是chrome的結果 不同瀏覽器可能呈現不同結果

那麼我們來分析一下為什麼是按照這個順序出現的

我們先看一看wiki中對巨集任務和微任務的定義:

"Tasks(巨集任務) are scheduled so the browser can get from its internals into JavaScript/DOM land and ensures these actions happen sequentially. Between tasks, the browser may render updates. Getting from a mouse click to an event callback requires scheduling a task, as does parsing HTML, and in the above example, setTimeout.

setTimeout waits for a given delay then schedules a new task for its callback. This is why setTimeout is logged after script end, as logging script end is part of the first task, and setTimeout is logged in a separate task. Right, we're almost through this, but I need you to stay strong for this next bit…

Microtasks(微任務) are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task. The microtask queue is processed after callbacks as long as no other JavaScript is mid-execution, and at the end of each task. Any additional microtasks queued during microtasks are added to the end of the queue and also processed. Microtasks include mutation observer callbacks, and as in the above example, promise callbacks.

Once a promise settles, or if it has already settled, it queues a microtask for its reactionary callbacks. This ensures promise callbacks are async even if the promise has already settled. So calling .then(yey, nay) against a settled promise immediately queues a microtask. This is why promise1 and promise2 are logged after script end, as the currently running script must finish before microtasks are handled. promise1 and promise2 are logged before setTimeout, as microtasks always happen before the next task."

根據以上的描述, 我們一步一步的分析之前的程式碼:

瀏覽器/nodeJS 中的事件環工作原理
step1

程式執行到第一行 直接輸出 script start

瀏覽器/nodeJS 中的事件環工作原理
step2

接下來 setTimeout進入巨集任務列表中 如下圖所示:

瀏覽器/nodeJS 中的事件環工作原理
step3

接下來 Promise進入微任務列表中 如下圖所示:

瀏覽器/nodeJS 中的事件環工作原理
step4

然後程式直行至最後一行,輸出script End:

瀏覽器/nodeJS 中的事件環工作原理
step5

然後 先執行微任務中的命令:

瀏覽器/nodeJS 中的事件環工作原理
step6

then中的部分是直接執行 因此console中顯示promise1

瀏覽器/nodeJS 中的事件環工作原理
step7

由於promise的回撥函式中返回'undefined'於是將下一個promise 進入到微任務中.

瀏覽器/nodeJS 中的事件環工作原理
step8

下圖中的 promise then 和promise callback對應的都是第二個then的. 而promise2也在console中顯示.

瀏覽器/nodeJS 中的事件環工作原理
step9

最終結果如step10所示:

瀏覽器/nodeJS 中的事件環工作原理
step10

看完了這一題 是不是覺得事件環也沒有想象中那麼難呢? 那麼在這裡大家可以再看看下一題作為思考題. 限於文章篇幅所限,僅提供正確答案供大家參考 ^_^

首先, 我們來一個html頁面:

<div class="outer">
  <div class="inner"></div>
</div>
複製程式碼

得到如下的一個大方塊套小方塊的html頁面:

瀏覽器/nodeJS 中的事件環工作原理

如果該html頁面的JS如下所示,那麼我點選內部的小方塊,會得到怎樣的結果呢?

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

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

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
複製程式碼

(使用chrome)正確答案:

click

promise

mutate

click

promise

mutate

timeout

timeout

node中的事件環工作原理

事件驅動

Node採用事件驅動的執行方式。在事件驅動的模型當中,每一個IO工作被新增到事件佇列中,執行緒迴圈地處理佇列上的工作任務,當執行過程中遇到來堵塞(讀取檔案、查詢資料庫)時,執行緒不會停下來等待結果,而是留下一個處理結果的回撥函式,轉而繼續執行佇列中的下一個任務。這個傳遞到佇列中的回撥函式在堵塞任務執行結束後才被執行緒呼叫。

瀏覽器/nodeJS 中的事件環工作原理

Node Async IO 這一套實現開始於Node開始啟動的程式,在這個程式中Node會建立一個迴圈,每次迴圈執行就是一個Tick週期,每個Tick週期中會從事件佇列檢視是否有事件需要處理,如果有就取出事件並執行相關的回撥函式。事件佇列事件全部執行完畢,node應用就會終止。Node對於堵塞IO的處理在幕後使用執行緒池來確保工作的執行。Node從池中取得一個執行緒來執行復雜任務,而不佔用主迴圈執行緒。這樣就防止堵塞IO佔用空閒資源。當堵塞任務執行完畢通過新增到事件佇列中的回撥函式來處理接下來的工作。

瀏覽器/nodeJS 中的事件環工作原理

當然這麼華麗的執行機制就能解決前面說的兩個弊端。node基於事件的工作排程能很自然地將主要的排程工作限制到了一個執行緒,應用能很高效地處理多工。程式每一時刻也只需管理一個工作中的任務。當必須處理堵塞IO時,通過將這個部分的IO控制權交給池中的執行緒,能最小地影響到應用處理事件,快速地反應web請求。 當然對機器方便的事情對於寫程式碼的人來說就需要更小心地劃分業務邏輯,我們需要將工作劃分為合理大小的任務來適配事件模型這一套機制。

事件佇列排程

Node可以通過傳遞迴調函式將任務新增到事件佇列中,這種非同步的排程可以通過5種方式來實現這個目標:非同步堵塞IO庫(db處理、fs處理),Node內建的事件和事件監聽器(http、server的一些預定義事件),開發者自定義的事件和監聽器、定時器以及Node全域性物件process的.nextTick()API。

非同步堵塞IO庫

其IO庫提供的API有Node自帶的Module(比如fs)和資料庫驅動API,比如mongoose的.save(doc, callback)就是將繁重的資料庫Insert操作以及回撥函式交給子執行緒來操作,主執行緒只負責任務的排程。當MongoDB返回給Node操作結果後,回撥函式才開始執行。

Dtree.create(frontData, function (err, dtree) {
      if (err) {
            console.log('Error: createDTree: DB failed to create due to ', err);
            res.send({'success': false, 'err': err});
      } else {
            console.log('Info: createDTree: DB created successfully dtree = ', dtree);
            res.send({'success': true, 'created_id': dtree._id.toHexString()});
      }
});
複製程式碼

比如這段處理Dtree儲存的回撥函式只有當事件佇列中的接收到來自堵塞IO處理執行緒的執行完畢才會被執行。

Node內建的事件和事件監聽器

Node原生的模組都預定義來一些事件,比如NET模組的一套服務狀態事件。當Net中的Socket檢測到close就會呼叫放置在事件迴圈中的回撥函式,下例中就是將sockets陣列中刪除相應的socket連線物件。

socket.on('close', function(){
  console.log('connection closed');
  var index = sockets.indexOf(socket);
  //伺服器端斷開相應連線
  sockets.splice(index, 1);
});
複製程式碼

開發者自定義的事件

Node自身和很多模組都支援開發者自定義事件和處理持戟處理函式,當然既然是自定義,那麼觸發事件也是顯性地需要開發者。在Socket.io程式設計中就有很好的例子,開發者可以自定義訊息事件來處理端對端的互動。

//socket監聽自定義的事件訊息
socket.on('chatMessage', function(message){
  message.type = 'message';
  message.created = Date.now();
  message.username = socket.request.user.username;
  console.log(message);
  //同時也可以像對方發出事件訊息
  io.emit('chatMessage', message);
});
複製程式碼

計時器(Timers)

Node使用前端一致的Timeout和Interval計時器,他們的區別在Timeout是延時執行,Interval是間隔一段事件執行。值得注意的是這組函式其實不屬於JS語言標準,他們只是擴充套件。在瀏覽器中,他們屬於BOM,即它的確切定義為:window.setTimeout和window.setInterval;與window.alert, window.open等函式處於同一層次。Node把這組函式放置於全域性範圍中。

除了這兩個函式,Node還新增Immediate計時器,setImmediate()函式是沒有事件引數的,在事件佇列中的當前任務執行結束後執行,並且優先順序比Timeout、Interbal高。

計時器的問題在於它在事件迴圈中並非精確的執行回撥函式。《深入淺出Node.js》舉了一個例子:當通過setTimeout()設定一個任務在10毫秒後執行,但是如果在9毫秒後,有一個任務佔用了5毫秒的CPU,再次燉老定時器執行時,事件就已經過期了。

Node全域性物件process的.nextTick()API

這個延時執行函式函式是在新增任務到佇列的開頭,下一次Tick週期開始時就執行,也就是在其他任務前排程。

nextTick的優先順序是高於immediate的。並且每輪迴圈,nextTick中的回撥函式全部都會執行完,而Immediate只會執行一個回撥函式。這裡有得說明每個Tick過程中,判斷事件迴圈中是否有事件要處理的觀察者。在Node的底層libuv,事件迴圈是一個典型的生產者/消費者模型。非同步IO、網路請求是事件的生產者,回撥函式是事件的消費者,而觀察者則是在中間將傳遞過來的事件暫存起來。回撥函式的idle觀察者在每輪事件迴圈開始被檢查,而check觀察者後於idle觀察者檢查,兩者之間被檢查的就是IO操作的觀察者。

事件驅動與高效能伺服器

前面大致介紹了Node的事件驅動模型,事件驅動的實質就是主迴圈執行緒+事件觸發的方式來執行程式。Node的非同步IO成功地使得IO操作與CPU操作分離成為一套高效能平臺,既可以像Nginx一樣構建伺服器平臺,也可以處理具體的業務。雖然Node沒有Nginx在Web伺服器方面那麼專業,但不錯的效能和更多的使用場景使得在實際開發中能夠達到優異的效能。這一切也都歸功與非同步IO實現的核心——事件迴圈。在實際的專案中,我們可以結合不同工具的優點達到應用的最優效能。

相關文章