前端面試查漏補缺--(十五) Event Loop

shotCat發表於2019-02-24

前言

本系列最開始是為了自己面試準備的.後來發現整理越來越多,差不多有十二萬字元,最後決定還是分享出來給大家.

為了分享整理出來,花費了自己大量的時間,起碼是隻自己用的三倍時間.如果喜歡的話,歡迎收藏,關注我!謝謝!

文章連結

合集篇:

前端面試查漏補缺--Index篇(12萬字元合集) 包含目前已寫好的系列其他十幾篇文章.後續新增值文章不會再在每篇新增連結,強烈建議議點贊,關注合集篇!!!!,謝謝!~

後續更新計劃

後續還會繼續新增設計模式,前端工程化,專案流程,部署,閉環,vue常考知識點 等內容.如果覺得內容不錯的話歡迎收藏,關注我!謝謝!

求一份內推

目前本人也在準備跳槽,希望各位大佬和HR小姐姐可以內推一份靠譜的武漢 前端崗位!郵箱:bupabuku@foxmail.com.謝謝啦!~

Event loop的初步理解

相信大家如果對Event loop有一定了解的話,大概都會知道它的大概步驟是:

  • 1,Javascript的事件分為同步任務和非同步任務.
  • 2,遇到同步任務就放在執行棧中執行.
  • 3,遇到非同步任務就放到任務佇列之中,等到執行棧執行完畢之後再去執行任務佇列之中的事件.

上面的步驟說得沒錯,很對,但是太淺了.現在的面試應該不會這麼簡單,起碼得加上巨集任務,微任務.再整上幾個Promise,async await,讓你判斷.否則都不好意思叫面試題!

為了避免被面試官吊起來打的情況,我們現在來詳細地理一理Event Loop.

Event loop 相關概念

前端面試查漏補缺--(十五) Event Loop

JS呼叫棧

Javascript 有一個 主執行緒(main thread)和 呼叫棧(call-stack),所有的程式碼都要通過函式,放到呼叫棧(也被稱為執行棧)中的任務等待主執行緒執行。

JS呼叫棧採用的是後進先出的規則,當函式執行的時候,會被新增到棧的頂部,當執行棧執行完成後,就會從棧頂移出,直到棧內被清空。

WebAPIs

MDN的解釋: Web 提供了各種各樣的 API 來完成各種的任務。這些 API 可以用 JavaScript 來訪問,令你可以做很多事兒,小到對任意 window 或者 element做小幅調整,大到使用諸如 WebGL 和 Web Audio 的 API 來生成複雜的圖形和音效。

Web API 介面參考

總結: 就是瀏覽器提供一些介面,讓JavaScript可以呼叫,這樣就可以把任務甩給瀏覽器了,這樣就可以實現非同步了!

任務佇列(Task Queue)

"任務佇列"是一個先進先出的資料結構,排在前面的事件,優先被主執行緒讀取。主執行緒的讀取過程基本上是自動的,只要執行棧一清空,"任務佇列"上第一位的事件就自動進入主執行緒。但是,如果存在"定時器",主執行緒首先要檢查一下執行時間,某些事件只有到了規定的時間,才能返回主執行緒。

同步任務和非同步任務

Javascript單執行緒任務被分為同步任務和非同步任務.

  • 同步任務會在呼叫棧 中按照順序等待主執行緒依次執行.
  • 非同步任務會甩給在WebAPIs處理,處理完後有了結果後,將註冊的回撥函式放入任務佇列中等待主執行緒空閒的時候(呼叫棧被清空),被讀取到棧內等待主執行緒的執行。

巨集任務(MacroTask)和 微任務(MicroTask)

JavaScript中,任務被分為兩種,一種巨集任務(MacroTask)也叫Task,一種叫微任務(MicroTask)。

巨集任務(MacroTask)

  • script(整體程式碼)setTimeoutsetIntervalsetImmediate(瀏覽器暫時不支援,只有IE10支援,具體可見MDN)、I/OUI Rendering

微任務(MicroTask)

  • Process.nextTick(Node獨有)PromiseObject.observe(廢棄)MutationObserver(具體使用方式檢視這裡

Event loop 執行過程

首先巨集觀上是按照這樣的順序執行.也就是前面在"Event loop的初步理解"裡講到的過程

前端面試查漏補缺--(十五) Event Loop

注意:

  • 只要主執行緒空了,就會去讀取"任務佇列",這就是JavaScript的執行機制。這個過程會不斷重複。
  • 在上圖的Event Table裡存放著巨集任務與微任務,所以在它裡面 還發生了一些更細緻的事情.

前面介紹巨集任務的時候,提過script也屬於其中.那麼一段程式碼塊就是一個巨集任務。故所有一般執行程式碼塊的時候,先執行的是巨集任務script,也就是程式執行進入主執行緒了,主執行緒再會根據不同的程式碼再分微任務和巨集任務等待主執行緒執行完成後,不停地迴圈執行。

主執行緒(巨集任務) => 微任務 => 巨集任務 => 主執行緒

前端面試查漏補缺--(十五) Event Loop

事件迴圈的順序是從script開始第一次迴圈,隨後全域性上下文進入函式呼叫棧,碰到macro-task就將其交給處理它的模組處理完之後將回撥函式放進macro-task的佇列之中,碰到micro-task也是將其回撥函式放進micro-task的佇列之中。直到函式呼叫棧清空只剩全域性執行上下文,然後開始執行所有的micro-task。當所有可執行的micro-task執行完畢之後。 接著瀏覽器會執行下必要的渲染 UI,然後迴圈再次執行macro-task中的一個任務佇列,執行完之後再執行所有的micro-task,就這樣一直迴圈。

注意: 通過上述的 Event loop 順序可知,如果巨集任務中的非同步程式碼有大量的計算並且需要操作 DOM 的話,為了更快的 介面響應,我們可以把操作 DOM 放入微任務中。

例子分析

console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end') 
}
async1()

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

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })

console.log('script end')

複製程式碼

這裡需要先理解async/await

async/await 在底層轉換成了 promisethen 回撥函式。 也就是說,這是 promise 的語法糖。

每次我們使用 await, 直譯器都建立一個 promise 物件,然後把剩下的 async 函式中的操作放到 then 回撥函式中。

async/await 的實現,離不開 Promise。從字面意思來理解,async 是“非同步”的簡寫,而 awaitasync wait 的簡寫可以認為是等待非同步方法執行完成。

關於73以下版本和73版本的區別

  • 在73版本以下,先執行promise1promise2,再執行async1
  • 在73版本,先執行async1再執行promise1promise2

主要原因是因為在谷歌(金絲雀)73版本中更改了規範,如下圖所示:

前端面試查漏補缺--(十五) Event Loop

  • 區別在於RESOLVE(thenable)和之間的區別Promise.resolve(thenable)

在73以下版本中

  • 首先,傳遞給 await 的值被包裹在一個 Promise 中。然後,處理程式附加到這個包裝的 Promise,以便在 Promise 變為 fulfilled 後恢復該函式,並且暫停執行非同步函式,一旦 promise 變為 fulfilled,恢復非同步函式的執行。
  • 每個 await 引擎必須建立兩個額外的 Promise(即使右側已經是一個 Promise)並且它需要至少三個 microtask 佇列 tickstick為系統的相對時間單位,也被稱為系統的時基,來源於定時器的週期性中斷(輸出脈衝),一次中斷表示一個tick,也被稱做一個“時鐘滴答”、時標。)。

引用賀老師知乎上的一個例子

async function f() {
  await p
  console.log('ok')
}
複製程式碼

簡化理解為:


function f() {
  return RESOLVE(p).then(() => {
    console.log('ok')
  })
}

複製程式碼
  • 如果 RESOLVE(p) 對於 ppromise 直接返回 p 的話,那麼 pthen 方法就會被馬上呼叫,其回撥就立即進入 job 佇列。
  • 而如果 RESOLVE(p) 嚴格按照標準,應該是產生一個新的 promise,儘管該 promise確定會 resolvep,但這個過程本身是非同步的,也就是現在進入 job 佇列的是新 promiseresolve過程,所以該 promisethen 不會被立即呼叫,而要等到當前 job 佇列執行到前述 resolve 過程才會被呼叫,然後其回撥(也就是繼續 await 之後的語句)才加入 job 佇列,所以時序上就晚了。

谷歌(金絲雀)73版本中

  • 使用對PromiseResolve的呼叫來更改await的語義,以減少在公共awaitPromise情況下的轉換次數。
  • 如果傳遞給 await 的值已經是一個 Promise,那麼這種優化避免了再次建立 Promise 包裝器,在這種情況下,我們從最少三個 microtick 到只有一個 microtick

詳細過程:

73以下版本

  • 首先,列印script start,呼叫async1()時,返回一個Promise,所以列印出來async2 end
  • 每個 await,會新產生一個promise,但這個過程本身是非同步的,所以該await後面不會立即呼叫。
  • 繼續執行同步程式碼,列印Promisescript end,將then函式放入微任務佇列中等待執行。
  • 同步執行完成之後,檢查微任務佇列是否為null,然後按照先入先出規則,依次執行。
  • 然後先執行列印promise1,此時then的回撥函式返回undefinde,此時又有then的鏈式呼叫,又放入微任務佇列中,再次列印promise2
  • 再回到await的位置執行返回的 Promiseresolve 函式,這又會把 resolve 丟到微任務佇列中,列印async1 end
  • 微任務佇列為空時,執行巨集任務,列印setTimeout

谷歌(金絲雀73版本)

  • 如果傳遞給 await 的值已經是一個 Promise,那麼這種優化避免了再次建立 Promise 包裝器,在這種情況下,我們從最少三個 microtick 到只有一個 microtick
  • 引擎不再需要為 await 創造 throwaway Promise - 在絕大部分時間。
  • 現在 promise 指向了同一個 Promise,所以這個步驟什麼也不需要做。然後引擎繼續像以前一樣,建立 throwaway Promise,安排 PromiseReactionJobmicrotask 佇列的下一個 tick 上恢復非同步函式,暫停執行該函式,然後返回給呼叫者。

具體詳情檢視(這裡)。

Node.js的Event Loop

前端面試查漏補缺--(十五) Event Loop

Node.js的執行機制如下。

(1)V8引擎解析JavaScript指令碼。

(2)解析後的程式碼,呼叫Node API。

(3)libuv庫負責Node API的執行。它將不同的任務分配給不同的執行緒,形成一個Event Loop(事件迴圈),以非同步的方式將任務的執行結果返回給V8引擎。

(4)V8引擎再將結果返回給使用者。

Node 的 Event loop 分為 6 個階段,它們會按照順序反覆執行

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製程式碼

NodeEvent loop一共分為6個階段,每個細節具體如下:

  • timers: 執行setTimeoutsetInterval中到期的callback
  • pending callback: 上一輪迴圈中少數的callback會放在這一階段執行。
  • idle, prepare: 僅在內部使用。
  • poll: 最重要的階段,執行pending callback,在適當的情況下回阻塞在這個階段。
  • check: 執行setImmediate(setImmediate()是將事件插入到事件佇列尾部,主執行緒和事件佇列的函式執行完成之後立即執行setImmediate指定的回撥函式)的callback
  • close callbacks: 執行close事件的callback,例如socket.on('close'[,fn])或者http.server.on('close, fn)

關於Node.js的Event Loop更詳細的過程可以參考這篇文章

補充: 執行緒和程式

在概念上

程式是應用程式的執行例項,每一個程式都是由私有的虛擬地址空間、程式碼、資料和其它系統資源所組成;程式在執行過程中能夠申請建立和使用系統資源(如獨立的記憶體區域等),這些資源也會隨著程式的終止而被銷燬。

而執行緒則是程式內的一個獨立執行單元,在不同的執行緒之間是可以共享程式資源的,所以在多執行緒的情況下,需要特別注意對臨界資源的訪問控制。在系統建立程式之後就開始啟動執行程式的主執行緒,而程式的生命週期和這個主執行緒的生命週期一致,主執行緒的退出也就意味著程式的終止和銷燬。主執行緒是由系統程式所建立的,同時使用者也可以自主建立其它執行緒,這一系列的執行緒都會併發地執行於同一個程式中。

比喻理解

一個程式好比是一個工廠,每個工廠有它的獨立資源(類比到計算機上就是系統分配的一塊獨立記憶體),而且每個工廠之間是相互獨立、無法進行通訊。

每個工廠都有若干個工人(一個工人即是一個執行緒,一個程式由一個或多個執行緒組成),多個工人可以協作完成任務(即多個執行緒在程式中協作完成任務),當然每個工人可以共享此工廠的空間和資源(即同一程式下的各個執行緒之間共享程式的記憶體空間(包括程式碼段、資料集、堆等))。

到此你應該能初步理解了程式和執行緒之間的關係,這將有助於我們理解瀏覽器為什麼是多程式的,而JavaScript是單執行緒。

瀏覽器是多程式的(一個視窗就是一個程式),每個程式包含多個執行緒.但JavaScript是單執行緒的.一個主執行緒(一個stack),多個子執行緒.

為什麼JavaScript是單執行緒?

假定JavaScript同時有兩個執行緒,一個執行緒在某個DOM節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器應該以哪個執行緒為準? 所以,為了避免複雜性,從一誕生,JavaScript就是單執行緒,這已經成了這門語言的核心特徵,將來也不會改變。

瀏覽器中其他的執行緒:

js既然是單執行緒,那麼肯定是排隊執行程式碼,那麼怎麼去排這個隊,就是Event Loop。雖然JS是單執行緒,但瀏覽器不是單執行緒。瀏覽器中分為以下幾個執行緒:

  • js執行緒
  • UI執行緒
  • 事件執行緒(onclick,onchange,...)
  • 定時器執行緒(setTimeout, setInterval)
  • 非同步http執行緒(ajax)

其中JS執行緒和UI執行緒相互互斥,也就是說,當UI執行緒在渲染的時候,JS執行緒會掛起,等待UI執行緒完成,再執行JS執行緒

為了利用多核CPU的計算能力,HTML5提出Web Worker標準,允許JavaScript指令碼建立多個執行緒,但是子執行緒完全受主執行緒控制,且不得操作DOM。所以,這個新標準並沒有改變JavaScript單執行緒的本質。

感謝及參考

相關文章