EventLoop那些事兒

Event Loop

在實(shí)踐的過(guò)程中,你是否遇到過(guò)以下場(chǎng)景,為什么 setTimeout 會(huì)比 Promise 后執(zhí)行,明明代碼寫(xiě)在 Promise 之前。這其實(shí)涉及到了 Event Loop 相關(guān)的知識(shí),我們來(lái)詳細(xì)地了解 Event Loop 相關(guān)知識(shí),知道 JS 異步運(yùn)行代碼的原理。

進(jìn)程與線程

涉及面試題:進(jìn)程與線程區(qū)別?JS 單線程帶來(lái)的好處?

相信大家經(jīng)常會(huì)聽(tīng)到 JS 是單線程執(zhí)行的,但是你是否疑惑過(guò)什么是線程?

講到線程,那么肯定也得說(shuō)一下進(jìn)程。本質(zhì)上來(lái)說(shuō),兩個(gè)名詞都是 CPU 工作時(shí)間片的一個(gè)描述。

進(jìn)程描述了 CPU 在運(yùn)行指令及加載和保存上下文所需的時(shí)間,放在應(yīng)用上來(lái)說(shuō)就代表了一個(gè)程序。線程是進(jìn)程中的更小單位,描述了執(zhí)行一段指令所需的時(shí)間。

把這些概念拿到瀏覽器中來(lái)說(shuō),當(dāng)你打開(kāi)一個(gè) Tab 頁(yè)時(shí),其實(shí)就是創(chuàng)建了一個(gè)進(jìn)程,一個(gè)進(jìn)程中可以有多個(gè)線程,比如渲染線程、JS 引擎線程、HTTP 請(qǐng)求線程等等。當(dāng)你發(fā)起一個(gè)請(qǐng)求時(shí),其實(shí)就是創(chuàng)建了一個(gè)線程,當(dāng)請(qǐng)求結(jié)束后,該線程可能就會(huì)被銷毀。

上文說(shuō)到了 JS 引擎線程和渲染線程,大家應(yīng)該都知道,在 JS 運(yùn)行的時(shí)候可能會(huì)阻止 UI 渲染,這說(shuō)明了兩個(gè)線程是互斥的。這其中的原因是因?yàn)?JS 可以修改 DOM,如果在 JS 執(zhí)行的時(shí)候 UI 線程還在工作,就可能導(dǎo)致不能安全的渲染 UI。這其實(shí)也是一個(gè)單線程的好處,得益于 JS 是單線程運(yùn)行的,可以達(dá)到節(jié)省內(nèi)存,節(jié)約上下文切換時(shí)間,沒(méi)有鎖的問(wèn)題的好處。當(dāng)然前面兩點(diǎn)在服務(wù)端中更容易體現(xiàn),對(duì)于鎖的問(wèn)題,形象的來(lái)說(shuō)就是當(dāng)我讀取一個(gè)數(shù)字 15 的時(shí)候,同時(shí)有兩個(gè)操作對(duì)數(shù)字進(jìn)行了加減,這時(shí)候結(jié)果就出現(xiàn)了錯(cuò)誤。解決這個(gè)問(wèn)題也不難,只需要在讀取的時(shí)候加鎖,直到讀取完畢之前都不能進(jìn)行寫(xiě)入操作。

執(zhí)行棧

涉及面試題:什么是執(zhí)行棧?

可以把執(zhí)行棧認(rèn)為是一個(gè)存儲(chǔ)函數(shù)調(diào)用的棧結(jié)構(gòu),遵循先進(jìn)后出的原則。

aHR0cHM6Ly91c2VyLWdvbGQtY2RuLnhpdHUuaW8vMjAxOC8xMS8xMy8xNjcwZDJkMjBlYWQzMmVj.gif

執(zhí)行??梢暬?/p>

當(dāng)開(kāi)始執(zhí)行 JS 代碼時(shí),首先會(huì)執(zhí)行一個(gè) main 函數(shù),然后執(zhí)行我們的代碼。根據(jù)先進(jìn)后出的原則,后執(zhí)行的函數(shù)會(huì)先彈出棧,在圖中我們也可以發(fā)現(xiàn),foo 函數(shù)后執(zhí)行,當(dāng)執(zhí)行完畢后就從棧中彈出了。

平時(shí)在開(kāi)發(fā)中,大家也可以在報(bào)錯(cuò)中找到執(zhí)行棧的痕跡

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

aHR0cHM6Ly91c2VyLWdvbGQtY2RuLnhpdHUuaW8vMjAxOC8xMS8xMy8xNjcwYzBlMjE1NDAwOTBj.png

函數(shù)執(zhí)行順序

大家可以在上圖清晰的看到報(bào)錯(cuò)在 foo 函數(shù),foo 函數(shù)又是在 bar 函數(shù)中調(diào)用的。

當(dāng)我們使用遞歸的時(shí)候,因?yàn)闂?纱娣诺暮瘮?shù)是有限制的,一旦存放了過(guò)多的函數(shù)且沒(méi)有得到釋放的話,就會(huì)出現(xiàn)爆棧的問(wèn)題

function bar() {
  bar()
}
bar()

aHR0cHM6Ly91c2VyLWdvbGQtY2RuLnhpdHUuaW8vMjAxOC8xMS8xMy8xNjcwYzEyOGFjY2U5NzVm.png

爆棧

瀏覽器中的 Event Loop

涉及面試題:異步代碼執(zhí)行順序?解釋一下什么是 Event Loop ?

上一小節(jié)我們講到了什么是執(zhí)行棧,大家也知道了當(dāng)我們執(zhí)行 JS 代碼的時(shí)候其實(shí)就是往執(zhí)行棧中放入函數(shù),那么遇到異步代碼的時(shí)候該怎么辦?其實(shí)當(dāng)遇到異步的代碼時(shí),會(huì)被掛起并在需要執(zhí)行的時(shí)候加入到 Task(有多種 Task) 隊(duì)列中。一旦執(zhí)行棧為空,Event Loop 就會(huì)從 Task 隊(duì)列中拿出需要執(zhí)行的代碼并放入執(zhí)行棧中執(zhí)行,所以本質(zhì)上來(lái)說(shuō) JS 中的異步還是同步行為。

aHR0cHM6Ly91c2VyLWdvbGQtY2RuLnhpdHUuaW8vMjAxOC8xMS8yMy8xNjc0MGZhNGNkOWM2OTM3.png

事件循環(huán)

不同的任務(wù)源會(huì)被分配到不同的 Task 隊(duì)列中,任務(wù)源可以分為 微任務(wù)(microtask) 和 宏任務(wù)(macrotask)。在 ES6 規(guī)范中,microtask 稱為 jobs,macrotask 稱為 task。下面來(lái)看以下代碼的執(zhí)行順序:

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

注意:新的瀏覽器中不是如上打印的,因?yàn)?await 變快了,具體內(nèi)容可以往下看

首先先來(lái)解釋下上述代碼的 asyncawait 的執(zhí)行順序。當(dāng)我們調(diào)用 async1 函數(shù)時(shí),會(huì)馬上輸出 async2 end,并且函數(shù)返回一個(gè) Promise,接下來(lái)在遇到 await的時(shí)候會(huì)就讓出線程開(kāi)始執(zhí)行 async1 外的代碼,所以我們完全可以把 await 看成是讓出線程的標(biāo)志。

然后當(dāng)同步代碼全部執(zhí)行完畢以后,就會(huì)去執(zhí)行所有的異步代碼,那么又會(huì)回到 await 的位置執(zhí)行返回的 Promiseresolve 函數(shù),這又會(huì)把 resolve 丟到微任務(wù)隊(duì)列中,接下來(lái)去執(zhí)行 then 中的回調(diào),當(dāng)兩個(gè) then 中的回調(diào)全部執(zhí)行完畢以后,又會(huì)回到 await 的位置處理返回值,這時(shí)候你可以看成是 Promise.resolve(返回值).then(),然后 await 后的代碼全部被包裹進(jìn)了 then 的回調(diào)中,所以 console.log('async1 end') 會(huì)優(yōu)先執(zhí)行于 setTimeout。

如果你覺(jué)得上面這段解釋還是有點(diǎn)繞,那么我把 async 的這兩個(gè)函數(shù)改造成你一定能理解的代碼

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

也就是說(shuō),如果 await 后面跟著 Promise 的話,async1 end 需要等待三個(gè) tick 才能執(zhí)行到。那么其實(shí)這個(gè)性能相對(duì)來(lái)說(shuō)還是略慢的,所以 V8 團(tuán)隊(duì)借鑒了 Node 8 中的一個(gè) Bug,在引擎底層將三次 tick 減少到了二次 tick。但是這種做法其實(shí)是違法了規(guī)范的,當(dāng)然規(guī)范也是可以更改的,這是 V8 團(tuán)隊(duì)的一個(gè) PR,目前已被同意這種做法。

所以 Event Loop 執(zhí)行順序如下所示:

  • 首先執(zhí)行同步代碼,這屬于宏任務(wù)
  • 當(dāng)執(zhí)行完所有同步代碼后,執(zhí)行棧為空,查詢是否有異步代碼需要執(zhí)行
  • 執(zhí)行所有微任務(wù)
  • 當(dāng)執(zhí)行完所有微任務(wù)后,如有必要會(huì)渲染頁(yè)面
  • 然后開(kāi)始下一輪 Event Loop,執(zhí)行宏任務(wù)中的異步代碼,也就是 setTimeout 中的回調(diào)函數(shù)

所以以上代碼雖然 setTimeout 寫(xiě)在 Promise 之前,但是因?yàn)?Promise 屬于微任務(wù)而 setTimeout 屬于宏任務(wù),所以會(huì)有以上的打印。

微任務(wù)包括 process.nextTick ,promise ,MutationObserver,其中 process.nextTick 為 Node 獨(dú)有。

宏任務(wù)包括 script , setTimeout ,setIntervalsetImmediate ,I/OUI rendering。

這里很多人會(huì)有個(gè)誤區(qū),認(rèn)為微任務(wù)快于宏任務(wù),其實(shí)是錯(cuò)誤的。因?yàn)楹耆蝿?wù)中包括了 script ,瀏覽器會(huì)先執(zhí)行一個(gè)宏任務(wù),接下來(lái)有異步代碼的話才會(huì)先執(zhí)行微任務(wù)。

Node 中的 Event Loop

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

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

Node 的 Event Loop 分為 6 個(gè)階段,它們會(huì)按照順序反復(fù)運(yùn)行。每當(dāng)進(jìn)入某一個(gè)階段的時(shí)候,都會(huì)從對(duì)應(yīng)的回調(diào)隊(duì)列中取出函數(shù)去執(zhí)行。當(dāng)隊(duì)列為空或者執(zhí)行的回調(diào)函數(shù)數(shù)量到達(dá)系統(tǒng)設(shè)定的閾值,就會(huì)進(jìn)入下一階段。

aHR0cHM6Ly91c2VyLWdvbGQtY2RuLnhpdHUuaW8vMjAxOC8xMS8xMy8xNjcwYzNmZTNmOWE1ZTJi.png

timer

timers 階段會(huì)執(zhí)行 setTimeoutsetInterval 回調(diào),并且是由 poll 階段控制的。

同樣,在 Node 中定時(shí)器指定的時(shí)間也不是準(zhǔn)確時(shí)間,只能是盡快執(zhí)行。

I/O

I/O 階段會(huì)處理一些上一輪循環(huán)中的少數(shù)未執(zhí)行的 I/O 回調(diào)

idle, prepare

idle, prepare 階段內(nèi)部實(shí)現(xiàn),這里就忽略不講了。

poll

poll 是一個(gè)至關(guān)重要的階段,這一階段中,系統(tǒng)會(huì)做兩件事情

  1. 回到 timer 階段執(zhí)行回調(diào)
  2. 執(zhí)行 I/O 回調(diào)

并且在進(jìn)入該階段時(shí)如果沒(méi)有設(shè)定了 timer 的話,會(huì)發(fā)生以下兩件事情

  • 如果 poll 隊(duì)列不為空,會(huì)遍歷回調(diào)隊(duì)列并同步執(zhí)行,直到隊(duì)列為空或者達(dá)到系統(tǒng)限制
  • 如果 poll 隊(duì)列為空時(shí),會(huì)有兩件事發(fā)生
    • 如果有 setImmediate 回調(diào)需要執(zhí)行,poll 階段會(huì)停止并且進(jìn)入到 check 階段執(zhí)行回調(diào)
    • 如果沒(méi)有 setImmediate 回調(diào)需要執(zhí)行,會(huì)等待回調(diào)被加入到隊(duì)列中并立即執(zhí)行回調(diào),這里同樣會(huì)有個(gè)超時(shí)時(shí)間設(shè)置防止一直等待下去

當(dāng)然設(shè)定了 timer 的話且 poll 隊(duì)列為空,則會(huì)判斷是否有 timer 超時(shí),如果有的話會(huì)回到 timer 階段執(zhí)行回調(diào)。

check

check 階段執(zhí)行 setImmediate

close callbacks

close callbacks 階段執(zhí)行 close 事件

在以上的內(nèi)容中,我們了解了 Node 中的 Event Loop 的執(zhí)行順序,接下來(lái)我們將會(huì)通過(guò)代碼的方式來(lái)深入理解這塊內(nèi)容。

首先在有些情況下,定時(shí)器的執(zhí)行順序其實(shí)是隨機(jī)

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

對(duì)于以上代碼來(lái)說(shuō),setTimeout 可能執(zhí)行在前,也可能執(zhí)行在后

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的
  • 進(jìn)入事件循環(huán)也是需要成本的,如果在準(zhǔn)備時(shí)候花費(fèi)了大于 1ms 的時(shí)間,那么在 timer 階段就會(huì)直接執(zhí)行 setTimeout 回調(diào)
  • 那么如果準(zhǔn)備時(shí)間花費(fèi)小于 1ms,那么就是 setImmediate 回調(diào)先執(zhí)行了

當(dāng)然在某些情況下,他們的執(zhí)行順序一定是固定的,比如以下代碼:

const fs = require('fs')

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

在上述代碼中,setImmediate 永遠(yuǎn)先執(zhí)行。因?yàn)閮蓚€(gè)代碼寫(xiě)在 IO 回調(diào)中,IO 回調(diào)是在 poll 階段執(zhí)行,當(dāng)回調(diào)執(zhí)行完畢后隊(duì)列為空,發(fā)現(xiàn)存在 setImmediate 回調(diào),所以就直接跳轉(zhuǎn)到 check 階段去執(zhí)行回調(diào)了。

上面介紹的都是 macrotask 的執(zhí)行情況,對(duì)于 microtask 來(lái)說(shuō),它會(huì)在以上每個(gè)階段完成前清空 microtask 隊(duì)列,下圖中的 Tick 就代表了 microtask

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

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

對(duì)于以上代碼來(lái)說(shuō),其實(shí)和瀏覽器中的輸出是一樣的,microtask 永遠(yuǎn)執(zhí)行在 macrotask 前面。

最后我們來(lái)講講 Node 中的 process.nextTick,這個(gè)函數(shù)其實(shí)是獨(dú)立于 Event Loop 之外的,它有一個(gè)自己的隊(duì)列,當(dāng)每個(gè)階段完成后,如果存在 nextTick 隊(duì)列,就會(huì)清空隊(duì)列中的所有回調(diào)函數(shù),并且優(yōu)先于其他 microtask 執(zhí)行。

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

對(duì)于以上代碼,大家可以發(fā)現(xiàn)無(wú)論如何,永遠(yuǎn)都是先把 nextTick 全部打印出來(lái)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容