js的事件循環(Event Loop)


(本文從掘金小冊整理)

首先介紹一下幾個概念

進程與線程

相信大家經常會聽到 JS 是單線程執行的,但是你是否疑惑過什么是線程?

講到線程,那么肯定也得說一下進程。本質上來說,兩個名詞都是 CPU 工作時間片的一個描述。

進程描述了 CPU 在運行指令及加載和保存上下文所需的時間,放在應用上來說就代表了一個程序。線程是進程中的更小單位,描述了執行一段指令所需的時間。

把這些概念拿到瀏覽器中來說,當你打開一個 Tab 頁時,其實就是創建了一個進程,一個進程中可以有多個線程,比如渲染線程、JS 引擎線程、HTTP 請求線程等等。當你發起一個請求時,其實就是創建了一個線程,當請求結束后,該線程可能就會被銷毀。

上文說到了 JS 引擎線程和渲染線程,大家應該都知道,在 JS 運行的時候可能會阻止 UI 渲染,這說明了兩個線程是互斥的。這其中的原因是因為 JS 可以修改 DOM,如果在 JS 執行的時候 UI 線程還在工作,就可能導致不能安全的渲染 UI。這其實也是一個單線程的好處,得益於 JS 是單線程運行的,可以達到節省內存,節約上下文切換時間,沒有鎖的問題的好處。當然前面兩點在服務端中更容易體現,對於鎖的問題,形象的來說就是當我讀取一個數字 15 的時候,同時有兩個操作對數字進行了加減,這時候結果就出現了錯誤。解決這個問題也不難,只需要在讀取的時候加鎖,直到讀取完畢之前都不能進行寫入操作。

執行棧

以把執行棧認為是一個存儲函數調用的棧結構,遵循先進后出的原則。

 

 

當開始執行 JS 代碼時,首先會執行一個 main 函數,然后執行我們的代碼。根據先進后出的原則,后執行的函數會先彈出棧,在圖中我們也可以發現,foo 函數后執行,當執行完畢后就從棧中彈出了。

平時在開發中,大家也可以在報錯中找到執行棧的痕跡

 

function foo() {
  throw new Error('error')
}
function bar() {
  foo()
}
bar()

 

 

 

瀏覽器中的 Event Loop

上面我們講到了什么是執行棧,大家也知道了當我們執行 JS 代碼的時候其實就是往執行棧中放入函數,那么遇到異步代碼的時候該怎么辦?其實當遇到異步的代碼時,會被掛起並在需要執行的時候加入到 Task(有多種 Task) 隊列中。一旦執行棧為空,Event Loop 就會從 Task 隊列中拿出需要執行的代碼並放入執行棧中執行,所以本質上來說 JS 中的異步還是同步行為。

 

 

 

不同的任務源會被分配到不同的 Task 隊列中,任務源可以分為 微任務(microtask) 和 宏任務(macrotask)。在 ES6 規范中,microtask 稱為 jobs,macrotask 稱為 task。下面來看以下代碼的執行順序:

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')
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout

注意:新的瀏覽器中不是如上打印的,因為 await 變快了,具體內容可以往下看

首先先來解釋下上述代碼的 async 和 await 的執行順序。當我們調用 async1 函數時,會馬上輸出 async2 end,並且函數返回一個 Promise,接下來在遇到 await的時候會就讓出線程開始執行 async1 外的代碼,所以我們完全可以把 await 看成是讓出線程的標志。

然后當同步代碼全部執行完畢以后,就會去執行所有的異步代碼,那么又會回到 await 的位置執行返回的 Promise 的 resolve 函數,這又會把 resolve 丟到微任務隊列中,接下來去執行 then 中的回調,當兩個 then 中的回調全部執行完畢以后,又會回到 await 的位置處理返回值,這時候你可以看成是 Promise.resolve(返回值).then(),然后 await 后的代碼全部被包裹進了 then 的回調中,所以 console.log('async1 end') 會優先執行於 setTimeout

如果你覺得上面這段解釋還是有點繞,那么我把 async 的這兩個函數改造成你一定能理解的代碼

new Promise((resolve, reject) => {
  console.log('async2 end')
  // Promise.resolve() 將代碼插入微任務隊列尾部
  // resolve 再次插入微任務隊列尾部
  resolve(Promise.resolve())
}).then(() => {
  console.log('async1 end')
})

 

也就是說,如果 await 后面跟着 Promise 的話,async1 end 需要等待三個 tick 才能執行到。那么其實這個性能相對來說還是略慢的,所以 V8 團隊借鑒了 Node 8 中的一個 Bug,在引擎底層將三次 tick 減少到了二次 tick。但是這種做法其實是違法了規范的,當然規范也是可以更改的,這是 V8 團隊的一個 PR,目前已被同意這種做法。

所以 Event Loop 執行順序如下所示:

  • 首先執行同步代碼,這屬於宏任務
  • 當執行完所有同步代碼后,執行棧為空,查詢是否有異步代碼需要執行
  • 執行所有微任務
  • 當執行完所有微任務后,如有必要會渲染頁面
  • 然后開始下一輪 Event Loop,執行宏任務中的異步代碼,也就是 setTimeout 中的回調函數

所以以上代碼雖然 setTimeout 寫在 Promise 之前,但是因為 Promise 屬於微任務而 setTimeout 屬於宏任務,所以會有以上的打印。

微任務包括 process.nextTick ,promise ,MutationObserver

宏任務包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering

這里很多人會有個誤區,認為微任務快於宏任務,其實是錯誤的。因為宏任務中包括了 script ,瀏覽器會先執行一個宏任務,接下來有異步代碼的話才會先執行微任務。

Node 中的 Event Loop

涉及面試題:Node 中的 Event Loop 和瀏覽器中的有什么區別?process.nexttick 執行順序?

Node 中的 Event Loop 和瀏覽器中的是完全不相同的東西。

Node 的 Event Loop 分為 6 個階段,它們會按照順序反復運行。每當進入某一個階段的時候,都會從對應的回調隊列中取出函數去執行。當隊列為空或者執行的回調函數數量到達系統設定的閾值,就會進入下一階段。

timer

timers 階段會執行 setTimeout 和 setInterval 回調,並且是由 poll 階段控制的。

同樣,在 Node 中定時器指定的時間也不是准確時間,只能是盡快執行。

I/O

I/O 階段會處理一些上一輪循環中的少數未執行的 I/O 回調

idle, prepare

idle, prepare 階段內部實現,這里就忽略不講了。

poll

poll 是一個至關重要的階段,這一階段中,系統會做兩件事情

  1. 回到 timer 階段執行回調
  2. 執行 I/O 回調

並且在進入該階段時如果沒有設定了 timer 的話,會發生以下兩件事情

  • 如果 poll 隊列不為空,會遍歷回調隊列並同步執行,直到隊列為空或者達到系統限制
  • 如果 poll 隊列為空時,會有兩件事發生
    • 如果有 setImmediate 回調需要執行,poll 階段會停止並且進入到 check 階段執行回調
    • 如果沒有 setImmediate 回調需要執行,會等待回調被加入到隊列中並立即執行回調,這里同樣會有個超時時間設置防止一直等待下去

當然設定了 timer 的話且 poll 隊列為空,則會判斷是否有 timer 超時,如果有的話會回到 timer 階段執行回調。

check

check 階段執行 setImmediate

close callbacks

close callbacks 階段執行 close 事件

在以上的內容中,我們了解了 Node 中的 Event Loop 的執行順序,接下來我們將會通過代碼的方式來深入理解這塊內容。

首先在有些情況下,定時器的執行順序其實是隨機的

setTimeout(() => { console.log('setTimeout') }, 0) setImmediate(() => { console.log('setImmediate') }) 

對於以上代碼來說,setTimeout 可能執行在前,也可能執行在后

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的
  • 進入事件循環也是需要成本的,如果在准備時候花費了大於 1ms 的時間,那么在 timer 階段就會直接執行 setTimeout 回調
  • 那么如果准備時間花費小於 1ms,那么就是 setImmediate 回調先執行了

當然在某些情況下,他們的執行順序一定是固定的,比如以下代碼:

const fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0) setImmediate(() => { console.log('immediate') }) }) 

在上述代碼中,setImmediate 永遠先執行。因為兩個代碼寫在 IO 回調中,IO 回調是在 poll 階段執行,當回調執行完畢后隊列為空,發現存在 setImmediate 回調,所以就直接跳轉到 check 階段去執行回調了。

上面介紹的都是 macrotask 的執行情況,對於 microtask 來說,它會在以上每個階段完成前清空 microtask 隊列,下圖中的 Tick 就代表了 microtask

 

 

setTimeout(() => {
  console.log('timer21')
}, 0)

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

對於以上代碼來說,其實和瀏覽器中的輸出是一樣的,microtask 永遠執行在 macrotask 前面。

最后我們來講講 Node 中的 process.nextTick,這個函數其實是獨立於 Event Loop 之外的,它有一個自己的隊列,當每個階段完成后,如果存在 nextTick 隊列,就會清空隊列中的所有回調函數,並且優先於其他 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 全部打印出來。

小結

這一章節我們學習了 JS 實現異步的原理,並且了解了在瀏覽器和 Node 中 Event Loop 其實是不相同的。Event Loop 這個知識點對於我們理解 JS 是如何執行的至關重要,同時也是常考題。如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM