EventLoop其實如此簡單

夢想攻城獅發表於2018-09-11

瀏覽器的EventLoop

瀏覽器機制:

瀏覽器的主要元件

瀏覽器的主要元件包括:

  1. 使用者介面 - 包括位址列、前進/後退按鈕、書籤選單等。除了瀏覽器主視窗顯示的你請求的頁面外,其他顯示的各個部分都屬於使用者界。
  2. 瀏覽器引擎 - 在使用者介面和渲染引擎之間傳送指令。
  3. 渲染引擎 - 負責顯示請求的內容。如果請求的內容是 HTML,它就負責解析 HTML 和 CSS 內容,並將解析後的內容顯示在螢幕上。
  4. 網路 - 用於網路呼叫,比如 HTTP 請求。其介面與平臺無關,併為所有平臺提供底層實現。
  5. 使用者介面後端 - 用於繪製基本的視窗小部件,比如組合框和視窗。其公開了與平臺無關的通用介面,而在底層使用作業系統的使用者介面方法。
  6. JavaScript 直譯器。用於解析和執行 JavaScript 程式碼,比如chrome的javascript直譯器是V8。
  7. 資料儲存。這是持久層。瀏覽器需要在硬碟上儲存各種資料,例如Cookie。新的HTML規範(HTML5)定義了“網路資料庫”,這是一個完整(但是輕便)的瀏覽器內資料庫。

瀏覽器渲染流程:

瀏覽器渲染流程

  1. render:渲染引擎解析HTML文件,並將文件中的標籤轉化為dom節點樹,即”內容樹”。同時,它也會解析外部CSS檔案以及syle標籤中的樣式資料。這些樣式資訊連同HTML中的”可見內容”一道,被用於構建另一棵樹——”渲染樹(Render樹)”。渲染樹由一些帶有視覺屬性(如顏色、大小等)的矩形組成,這些矩形將按照正確的順序顯示在頻幕上。
  2. layout:渲染樹構建完畢之後,將會進入”佈局”處理階段,即為每一個節點分配一個螢幕座標。
  3. painting:即遍歷render樹,並使用UI後端層繪製每個節點。

瀏覽器的單執行緒和任務佇列:

瀏覽器EventLoop原理圖

  1. 瀏覽器是單執行緒,Js的主要用途是與使用者互動以及操作DOM。這決定了它只能是單執行緒,否則會帶來很複雜的同步問題。比如,假定Js同時有兩個執行緒,一個執行緒在某個DOM節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器不知道以哪個執行緒為準,會產生混亂。
  2. 瀏覽器是單執行緒,並表示只有一個執行緒而是隻擁有一個主執行緒js解析和ui渲染,其他非同步任務有其單獨的執行緒,例如:DOM事件、ajax呼叫、setTimeout
  3. dom事件、ajax呼叫、定時器等非同步任務會開單獨的執行緒,它們會往非同步佇列中存放回撥函式,不阻塞主執行緒的執行
  4. 主執行緒執行完成之後會從非同步佇列中取出回撥函式執行
  5. 非同步佇列中存在巨集任務佇列(task)和微任務(microtask)佇列
  6. 巨集任務:script(內嵌和外鏈)、setImmediate、MessageChannel、setTimeout,微任務:Promise.then、MutationObserver

瀏覽器EventLoop過程:

巨集任務處理:

  1. 選擇當前要執行的任務佇列task,選擇一個最先進入任務佇列的任務,如果沒有任務可以選擇,則會跳轉至microtask的執行步驟。 將事件迴圈的當前執行任務設定為已選擇的任務。
  2. 執行巨集任務。
  3. 將事件迴圈的當前執行任務設定為null。
  4. 將執行完的任務從任務佇列task中移除。 microtasks步驟:進入microtask檢查點(performing a microtask checkpoint )。
  5. 更新介面渲染。
  6. 返回第一步。

微任務處理(microtask的執行步驟):

  1. 設定進入microtask檢查點的標誌為true。
  2. 當事件迴圈的微任務佇列不為空時:選擇一個最先進入microtask佇列的microtask;設定事件迴圈的當前執行任務為已選擇的microtask
  3. 執行microtask;設定事件迴圈的當前執行任務為null;將執行結束的microtask從microtask佇列中移除。
  4. 對於相應事件迴圈的每個環境設定物件(environment settings object),通知它們哪些promise為rejected。
  5. 清理indexedDB的事務。
  6. 設定進入microtask檢查點的標誌為false。

上面的過程可以總結為:

  1. 檢視task中是否存在任務,如果存在則執行巨集任務,執行完畢將其從任務佇列task中刪除
  2. 如果task中不存在任務,檢視microtask是否存在任務,存在執行微任務,執行完畢將其從microtask;否則執行下一輪迴圈重新檢視task

程式碼例項分析:

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');

//script start
//script end
//promise1
//promise2
//setTimeout
複製程式碼
  1. 開始時,task中只有script,則script中所有函式放入stack中按順序執行執行。
  2. 執行到setTimeout,script執行完後會將回撥函式放入task佇列中,將在下一個事件迴圈中執行。
  3. 執行到Promise,Promise屬於microtask,所以會將第一個.then()放入microtask佇列。
  4. 當script程式碼執行完畢後,此時task為空。開始檢查microtask佇列,執行.then()的回撥函式輸出'promise1',由於.then()返回的依然是promise,所以第二個.then()會放入microtask佇列繼續執行,輸出'promise2'。
  5. microtask佇列為空了,進入下一個事件迴圈,檢查task佇列發現了setTimeout的回撥函式,立即執行回撥函式輸出'setTimeout',非同步程式碼執行完畢。

node的EventLoop

node中的處理流程:

node流程

  1. v8引擎從上到下解析node主程式
  2. 當呼叫fs,buffer等nodeAPI時會呼叫底層的libuv函式庫,利用多執行緒+事件池實現同步非阻塞先將回撥放在非同步佇列EventQueue中
  3. 當呼叫底層的libuv庫的方法成功後會找到EventQueue中相應的回撥函式執行,並將結果返回。

node中的EventLoop和瀏覽器中的EventLoop存在一些差別,node是通過多執行緒來實現的,可以同時處理多個任務。當其中一個任務完成時,相應的callback被插入到輪詢佇列中,最終被執行。

node中的任務佇列:

  1. timers:執行setTimeout()和setInterval安排的回撥
  2. I/O callbacks: 執行幾乎所有異常的close回撥,由timer和setImmediate執行的回撥。
  3. idle,prepare: 只用於內部
  4. poll : 獲取新的I/O事件,node在該階段會適當的阻塞
  5. check : setImmediate的回撥被呼叫
  6. close callbacks: e.g socket.on(‘close’,…);

node中EventLoop流程:

瀏覽器渲染流程

  1. timers,定時器階段: 執行定時任務(setTimeOut(), setInterval())
  2. poll 輪詢階段:
    • 處理到期的定時器任務,然後(因為最開始階段佇列為空,一旦佇列為空,就會檢查是否有到期的定時器任務)
    • 處理佇列任務,直到佇列空,或達到上限
    • 如果佇列為空:如果setImmediate,終止輪詢階段,進入檢查階段執行。如果沒setImmediate,檢視有沒有定時器任務到期,有的話就到timers階段,執行回撥函式.
  3. check 檢查階段:輪詢階段空閒,且有setImmediate的時候,進入檢查階段

上述的五個階段都是按照先進先出的規則執行回撥函式。按順序執行每個階段的回撥函式佇列,直至佇列為空或是該階段執行的回撥函式達到該階段所允許一次執行回撥函式的最大限制後,才會將操作權移交給下一階段,否則的話不會進入下一個階段。

區分setImmediate()與setTimeout()

從上面的poll和check階段的邏輯,我們可以看出setImmediate和setTimeout、setInterval都是在poll階段執行完當前的I/O佇列中相應的回撥函式後觸發的。但是這兩個函式卻是由不同的路徑觸發的:

  1. setImmediate函式,是在當前的pollqueue對列執行後為空或是執行的數目達到上限後,eventloop直接調入check階段執行setImmediate函式。
  2. setTimeout、setInterval則是在當前的pollqueue對列執行後為空或是執行的數目達到上限後,eventloop去timers檢查是否存在已經到期的定時器,如果存在直接執行相應的回撥函式。
  3. 程式中既有setTimeout和setImmediate時,在非I/O迴圈(主模組)中,順序不固定;在I/O迴圈中setImmdiate回撥總是先執行
//在非I/O迴圈(主模組)中,順序不固定
setTimeout(function timeout() {
  console.log('timeout');
}, 0);

setImmediate(function immediate() {
  console.log('immediate');
});
複製程式碼
// 在I/O迴圈中setImmdiate回撥總是先執行
const fs = require('fs');

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

區分process.nextTick()與setImmediate()

  1. process.nextTick() 函式是不管當前正在eventloop的哪個階段,在當前階段執行完畢後,跳入下個階段前的瞬間執行;setImmediate() 函式是在poll階段後進去check階段事執行
  2. process.nextTick() 函式的應用
//允許執行緒在進入event loop下一個階段前做一些關於處理異常、清理一些無用或無關的資源。
function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(callback,new TypeError('argument should be string'));
}
複製程式碼
//在進入下個event loop階段前,並且回撥函式還沒有釋放回撥許可權時執行一些相關操作。
//在MyEmitter建構函式例項化前註冊“event”事件,這樣就可以保證例項化後的函式可以監聽“event”事件。
const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);

  // use nextTick to emit the event once a handler is assigned
  process.nextTick(function() {
    this.emit('event');
  }.bind(this));
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
  console.log('an event occurred!');
});
複製程式碼

結語:

以上就是關於EventLoop的介紹,如果有錯誤歡迎指正,本文參考:

  1. 什麼是瀏覽器事件迴圈(EventLoop)
  2. 不要混淆nodejs和瀏覽器中的event loop
  3. 快速掌握Nodejs系列之—Events模組
  4. 深入理解nodejs Event loop
  5. Nodejs 解讀event loop的事件處理機制

相關文章