你應該瞭解一下的 事件循環/event loop 詳解

相關係列: 從零開始的前端築基之旅(面試必備,持續更新~)javascript

javascript是一門單線程語言,在最新的HTML5中提出了Web-Worker,但javascript是單線程這一核心仍未改變,無論誰寫的代碼,都得一句一句的來執行。html

當咱們打開網站時,網頁的渲染過程包括了一大堆任務,好比頁面元素的渲染。script腳本的執行,經過網絡請求加載圖片音樂之類。若是一個一個的順序執行,趕上任務耗時過長,就會發生卡頓現象。因而,事件循環(Event Loop)應運而生。前端

什麼是 Event Loop?

事件循環,能夠理解爲實現異步的一種方式。event loopHTML Standard中的定義:java

爲了協調事件,用戶交互,腳本,渲染,網絡等,用戶代理必須使用本節所述的event loopnode

JavaScript 有一個主線程 main thread,和調用棧 call-stack 也稱之爲執行棧。全部的任務都會放到調用棧中等待主線程來執行。待執行的任務就是流水線上的原料,只有前一個加工完,後一個才能進行。event loops就是把原料放上流水線的工人,協調用戶交互,腳本,渲染,網絡這些不一樣的任務。git

將待執行任務分爲兩類:github

  • 同步任務
  • 異步任務

主線程自上而下執行全部代碼web

  • 同步任務直接進入到主線程被執行,而異步任務則進入到 Event Table 並註冊相對應的回調函數
  • 知足指定條件(異步任務完成)後,Event Table 會將這個函數移入 Event Queue
  • 主線程任務執行完了之後,會從Event Queue中讀取任務,進入到主線程去執行。
  • 不斷重複的上述過程就是所謂的Event Loop(事件循環)。

任務隊列(task queue)

一個event loop有一個或者多個task隊列。當用戶代理安排一個任務,必須將該任務增長到相應的event loop的一個tsak隊列中。面試

task也被稱爲macrotask(宏任務),是一個先進先出的隊列,由指定的任務源去提供任務。編程

task任務源很是寬泛,總結來講task任務源包括:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering
  • 總體代碼script

因此 Task Queue 就是承載任務的隊列。而 JavaScriptEvent Loop 就是會不斷地過來找這個 queue,問有沒有 task 能夠運行運行。

微任務(microtask)

每個event loop都有一個microtask隊列,一個microtask會被排進microtask隊列而不是task隊列。

microtask 隊列和task 隊列有些類似,都是先進先出的隊列,由指定的任務源去提供任務,不一樣的是一個 event loop裏只有一個microtask 隊列。

一般認爲是microtask任務源有:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

在Promises/A+規範的Notes 3.1中說起了promise的then方法能夠採用「宏任務(macro-task)」機制或者「微任務(micro-task)」機制來實現。因此不一樣瀏覽器對promise的實現可能存在差別。

瀏覽器環境下的 Event Loop

事件循環的順序,決定js代碼的執行順序。進入總體代碼(宏任務)後,開始第一次循環。接着執行全部的微任務。而後再次從宏任務開始,找到其中一個任務隊列執行完畢,再執行全部的微任務。

執行完microtask隊列裏的任務,有可能會渲染更新。(瀏覽器很聰明,在一幀之內的屢次dom變更瀏覽器不會當即響應,而是會積攢變更以最高60HZ的頻率更新視圖)

setTimeout

以下面代碼,setTimeout 就是一個異步任務,

console.log('start')
setTimeout(()=>{
  console.log('setTimeout')
});
console.log('end');
複製代碼
  • 主線程執行同步任務:console.log('start');
  • 遇到 setTimeout 發現是一個異步任務,就先註冊了一個異步的回調
  • 執行語句console.log('end')
  • 主線程任務執行完畢,看Event Queue是否有待執行的 task,只要主線程的task queue沒有任務執行了,主線程就一直在這等着
  • 等異步任務等待的時間到了之後,在執行console.log('setTimeout')

js引擎存在monitoring process進程,會持續不斷的檢查主線程執行棧是否爲空,一旦爲空,就會去Event Queue那裏檢查是否有等待被調用的函數。

注意,只有等主線程執行完畢,纔會檢查Event Queue是否有待執行的 task,所以可能會出現另外一種狀況。

console.log('start')

setTimeout(()=>{
  console.log('setTimeout')
}, 3000);

todo(); // 假定這裏是一個耗時10秒的操做
複製代碼

正常狀況下,控制檯輸出應該是這樣的

start
// 等待3秒
setTimeout
複製代碼

而實際上,輸出大概是這樣的:

start
// 等待10秒
setTimeout
複製代碼

從新分析一下執行流程:

  • 主線程執行同步任務:console.log('start');
  • 遇到 setTimeout 發現是一個異步任務,就先註冊了一個異步的回調
  • 執行語句 todo()
  • 3秒到了,計時事件timeout完成,打印任務進入Event Queue
  • 主線程任務執行完畢,看Event Queue是否有待執行的 task,
  • 執行console.log('setTimeout')

setTimeout這個函數,是通過指定時間後,把要執行的任務加入到Event Queue中,與上一個栗子不一樣,當計時事件完成後,主線程任務並無執行完畢。只有等主線程執行完本輪代碼後,纔會查詢Event Queue。因此,等待大約10秒後控制檯纔有第二次輸出。

setTimeout(fn,0)

setTimeout(fn,0)的含義是,指定某個任務在主線程最先可得的空閒時間執行,意思就是隻要主線程執行棧內的同步任務所有執行完成,棧爲空就立刻執行。

即使主線程爲空,0毫秒實際上也是達不到的。根據HTML的標準,最低是4毫秒。

setInterval

setInterval會每隔指定的時間將註冊的函數置入Event Queue,若是前面的任務耗時過久,那麼一樣須要等待。

setTimeout類似,對於setInterval(fn,ms)來講,不是每過ms秒會執行一次fn,而是每過ms秒,會有fn進入Event Queue。一旦**setInterval的回調函數fn執行時間因爲主線程繁忙超過了延遲時間ms,那麼就徹底看不出來有時間間隔,而是會連續執行。**

Promise與process.nextTick(callback)

process.nextTick(callback)相似node.js版的"setTimeout",在事件循環的下一次循環中調用 callback 回調函數。

以一段代碼爲例:

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

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

console.log('console');
複製代碼

主線程自上而下執行全部代碼

  • 先遇到setTimeout,那麼將其回調函數註冊後分發到宏任務Event Queue。
  • 接下來遇到了Promisenew Promise當即執行,then函數分發到微任務Event Queue。
  • 遇到console.log(),當即執行。
  • 總體代碼script做爲第一個宏任務執行結束,看看有哪些微任務?咱們發現了then在微任務Event Queue裏面,執行。
  • ok,第一輪事件循環結束了,咱們開始第二輪循環,固然要從宏任務Event Queue開始。咱們發現了宏任務Event Queue中setTimeout對應的回調函數,當即執行。
  • 結束。

宏任務和微任務嵌套

執行完一個宏任務後,會執行全部的微任務,而後再執行一個宏任務

console.log('start');
Promise.resolve()
  .then(function promise1() {  // then1
    console.log('promise1');
  })
  .then(function () {          // then2
    console.log('promise2')
  })
  
setTimeout(function setTimeout1() {  // setTimeout1
  console.log('setTimeout1')
  Promise.resolve().then(function promise2() {  // then3
    console.log('promise3');
  })
}, 0)

setTimeout(function setTimeout2() {  // setTimeout1
  console.log('setTimeout2')
}, 0)
console.log('end')
複製代碼

分析下執行流程:

  • 遇到console.log(),當即執行, 輸出 start。
  • 遇到Promisethen被分發到微任務Event Queue中。咱們記爲then1
  • 遇到setTimeout,其回調函數被分發到宏任務Event Queue中。咱們暫且記爲setTimeout1
  • 又遇到了setTimeout,其回調函數被分發到宏任務Event Queue中,咱們記爲setTimeout2
  • 遇到console.log(),當即執行, 輸出 end。

第一輪執行結束,控制檯輸出 stert,end,此時,任務隊列以下:

宏任務Event Queue 微任務Event Queue
setTimeout1 then1
setTimeout2

執行微任務:

  • 執行then1,輸出 promise1,then被分發到微任務,記爲 then2
  • 此時爲微任務循環, 執行 hten2, 輸出 promise2

微任務執行完畢,第二輪循環開始,轉入宏任務 setTimeout1:

  • 遇到console.log(),當即執行, 輸出 setTimeout1。
  • 遇到Promisethen被分發到微任務Event Queue中。咱們記爲then3

執行微任務 then3:

  • 遇到console.log(),當即執行, 輸出 promise3。

第三輪循環開始,執行宏任務setTimeout2

  • 遇到console.log(),當即執行, 輸出 setTimeout2

最後,控制檯輸出結果爲

stert
end
promise1
promise2
setTimeout1
promise3
setTimeout2
複製代碼

Node 環境下的 Event Loop

Node中的Event Loop是基於libuv實現的,而libuv是 Node 的新跨平臺抽象層,libuv使用異步,事件驅動的編程方式,核心是提供i/o的事件循環和異步回調。libuvAPI包含有時間,非阻塞的網絡,異步文件操做,子進程等等。

Node 的 Event Loop 分爲 6 個階段:

  • timers:執行setTimeout()setInterval()中到期的callback。
  • pending callback: 上一輪循環中有少數的I/O callback會被延遲到這一輪的這一階段執行
  • idle, prepare:僅內部使用
  • poll: 最爲重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
  • check: 執行setImmediate的callback
  • close callbacks: 執行close事件的callback,例如socket.on('close'[,fn])http.server.on('close, fn)

上面六個階段都不包括 process.nextTick()

timers 階段

timers 階段會執行 setTimeoutsetInterval 回調,而且是由 poll 階段控制的。

在 timers 階段其實使用一個最小堆而不是隊列來保存全部的元素,由於timeout的callback是按照超時時間的順序來調用的,並非先進先出的隊列邏輯)。而爲何 timer 階段在第一個執行階梯上其實也不難理解。在 Node 中定時器指定的時間也是不許確的,而這樣,就能儘量的準確了,讓其回調函數儘快執行。

pending callbacks 階段

pending callbacks 階段實際上是 I/O 的 callbacks 階段。好比一些 TCP 的 error 回調等。

poll 階段

poll 階段主要有兩個功能:

  • 執行 I/O 回調
  • 處理 poll 隊列(poll queue)中的事件

當時Event Loop 進入到 poll 階段而且 timers 階段沒有任何可執行的 task 的時候(也就是沒有定時器回調),將會有如下兩種狀況

  • 若是 poll queue 非空,則 Event Loop就會執行他們,直到爲空或者達到system-dependent(系統相關限制)
  • 若是 poll queue 爲空,則會發生如下一種狀況
    • 若是setImmediate()有回調須要執行,則會當即進入到 check 階段
    • 檢查timer 階段的任務。若是有的話,則會回到 timer 階段執行回調。
    • 若是沒有setImmediate()須要執行,則 poll 階段將等待 callback 被添加到隊列中再當即執行,這也是爲何咱們說 poll 階段可能會阻塞的緣由。

check 階段

check 階段在 poll 階段以後,setImmediate()的回調會被加入check隊列中,他是一個使用libuv API 的特殊的計數器。

一般在代碼執行的時候,Event Loop 最終會到達 poll 階段,而後等待傳入的連接或者請求等,可是若是已經指定了setImmediate()而且這時候 poll 階段已經空閒的時候,則 poll 階段將會被停止而後開始 check 階段的執行。

close callbacks 階段

若是一個 socket 或者事件處理函數忽然關閉/中斷(好比:socket.destroy()),則這個階段就會發生 close 的回調執行。

setImmediate() vs setTimeout()

  • setImmediate在 poll 階段後執行,即check 階段
  • setTimeout 在 poll 空閒時且設定時間到達的時候執行,在 timer 階段

計時器的執行順序將根據調用它們的上下文而有所不一樣。 若是二者都是從主模塊中調用的,則時序將受到進程性能的限制。

若是不在I / O週期(即主模塊)內,則兩個計時器的執行順序是不肯定的,由於它受進程性能的約束:

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

setImmediate(() => {
  console.log('immediate');
});
複製代碼
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
複製代碼

若是在一個I/O 週期內移動這兩個調用,則始終首先執行當即回調:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
複製代碼
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout
複製代碼

setTimeout()相比,使用setImmediate()的主要優勢是,若是在I / O週期內安排了任何計時器,則setImmediate()將始終在任何計時器以前執行,而與存在多少計時器無關。

nextTick queue

process.nextTick()從技術上講不是Event Loop的一部分。 相反,不管當前事件循環的當前階段如何,若是存在 nextTickQueue,都將在當前操做完成以後處理nextTickQueue,優先於其餘 microtask

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
複製代碼

Node與瀏覽器的 Event Loop 差別

瀏覽器環境下,microtask的任務隊列是每一個macrotask執行完以後執行。而在Node.js中,microtask會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask隊列的任務。

若是你收穫了新知識,請給做者點個贊吧,左側邊欄第一個按鈕,用力的點一下~

參考文章:

  1. 這一次,完全弄懂 JavaScript 執行機制
  2. 從event loop規範探究javaScript異步及瀏覽器更新渲染時機
  3. 完全吃透 JavaScript 執行機制
相關文章
相關標籤/搜索