詳解JavaScript運行機制(Event Loop)
原創(chuàng)【51CTO.com原創(chuàng)稿件】
前言
在瀏覽器中,每個渲染進(jìn)程都有一個主線程,主線程非常繁忙,既要處理DOM,又要計算樣式,還要處理布局,同時還需要處理JavaScript任務(wù)以及各種輸入事件。此時我們就需要一個系統(tǒng)來統(tǒng)籌調(diào)度這么多不同類型的任務(wù)在主線程中有條不紊地執(zhí)行,而這個統(tǒng)籌調(diào)度系統(tǒng)就是本文要介紹的事件循環(huán)系統(tǒng)(Event Loop)。
讀完本文,希望你能明白:
-
進(jìn)程與線程的區(qū)別
-
最新的Chrome瀏覽器包括哪些進(jìn)程?
-
瀏覽器與Node的事件循環(huán)(Event Loop)有何區(qū)別?
一、進(jìn)程與線程
1.概念
我們經(jīng)常說JavaScript是單線程執(zhí)行的,那到底什么是線程?什么是進(jìn)程?
一個進(jìn)程就是一個程序的運行實例。詳細(xì)解釋就是,啟動一個程序的時候,操作系統(tǒng)會為該程序創(chuàng)建一塊內(nèi)存,用來存放代碼、運行中的數(shù)據(jù)和一個執(zhí)行任務(wù)的主線程,我們把這樣的一個運行環(huán)境叫進(jìn)程。
而線程是操作系統(tǒng)能夠進(jìn)行運算調(diào)度的最小單位。線程是不能單獨存在的,它是由進(jìn)程來啟動和管理的,在進(jìn)程中使用多線程并行處理能提升運算效率。
我們通過以下這張圖來加深對兩者的理解:
-
進(jìn)程好比圖中的工廠,有單獨的專屬自己的工廠資源。當(dāng)一個進(jìn)程關(guān)閉之后,操作系統(tǒng)會回收進(jìn)程所占用的內(nèi)存。
-
線程好比圖中的工人,多個工人在一個工廠中協(xié)作工作,工廠與工人是 1:n的關(guān)系。這意味著一個進(jìn)程由一個或多個線程組成,進(jìn)程中的任意一線程執(zhí)行出錯,都會導(dǎo)致整個進(jìn)程的崩潰。
-
工廠的空間是工人們共享的,這意味著一個進(jìn)程的內(nèi)存空間是共享的,每個線程都可用這些共享內(nèi)存。
-
多個工廠之間獨立存在。這意味著進(jìn)程之間的內(nèi)容相互隔離。
2.多進(jìn)程與多線程
-
多進(jìn)程:在同一個時間里,同一個計算機系統(tǒng)中允許兩個或兩個以上的進(jìn)程處于運行狀態(tài)。
以最新的 Chrome 瀏覽器為例,我打開掘金編輯文章頁面時,出現(xiàn)以下五個進(jìn)程:1個網(wǎng)絡(luò)進(jìn)程、1個瀏覽器進(jìn)程、1個GPU進(jìn)程以及1個渲染進(jìn)程,共4個;如果打開的頁面有運行插件的話,還需要再加上1個插件進(jìn)程(下圖有番茄鬧鐘插件)。
-
多線程:程序中包含多個執(zhí)行流,即在一個程序中可以同時運行多個不同的線程來執(zhí)行不同的任務(wù),也就是說允許單個程序創(chuàng)建多個并行執(zhí)行的線程來完成各自的任務(wù)。
二、最新的 Chrome 進(jìn)程架構(gòu)
最新的Chrome瀏覽器包括:1個瀏覽器(Browser)主進(jìn)程、1個GPU進(jìn)程、1個網(wǎng)絡(luò)(NetWork)進(jìn)程、多個渲染進(jìn)程和多個插件進(jìn)程。
接下來我們介紹下這些進(jìn)程的功能:
-
瀏覽器進(jìn)程。主要負(fù)責(zé)界面顯示、用戶交互、子進(jìn)程管理,同時提供存儲等功能。
-
渲染進(jìn)程。核心任務(wù)是將HTML、CSS 和JavaScript轉(zhuǎn)換為用戶可以與之交互的網(wǎng)頁,排版引擎Blink和JavaScript引擎V8都是運行在該進(jìn)程中,默認(rèn)情況下,Chrome 會為每個Tab標(biāo)簽創(chuàng)建一個渲染進(jìn)程。出于安全考慮,渲染進(jìn)程都是運行在沙箱模式下。渲染進(jìn)程中主要包含以下線程:主線程(Main thread)、工作線程(Worker thread)、 排版線程 (Compositor thread)和光柵線程(Raster thread)。
-
GPU進(jìn)程。其實,Chrome剛開始發(fā)布的時候是沒有GPU進(jìn)程的。而GPU的使用初衷是為了實現(xiàn)3D CSS的效果,只是隨后網(wǎng)頁、Chrome 的UI界面都選擇采用GPU來繪制,這使得GPU成為瀏覽器普遍的需求。最后,Chrome在其多進(jìn)程架構(gòu)上也引入了GPU進(jìn)程。
-
網(wǎng)絡(luò)進(jìn)程。主要負(fù)責(zé)頁面的網(wǎng)絡(luò)資源加載,之前是作為一個模塊運行在瀏覽器進(jìn)程里面的,直至最近才獨立出來,成為一個單獨的進(jìn)程。
-
插件進(jìn)程。主要是負(fù)責(zé)插件的運行,因插件易崩潰,所以需要通過插件進(jìn)程來隔離,以保證插件進(jìn)程崩潰不會對瀏覽器和頁面造成影響。
頁面中的大部分任務(wù)都是在渲染進(jìn)程的主線程上執(zhí)行,這些任務(wù)包括了:
-
渲染事件(如解析 DOM、計算布局、繪制);
-
用戶交互事件(如鼠標(biāo)點擊、滾動頁面、放大縮小等);
-
JavaScript腳本執(zhí)行事件;
-
網(wǎng)絡(luò)請求完成、文件讀寫完成事件。
那么,如何協(xié)調(diào)這些任務(wù)有條不紊地在主線程上執(zhí)行呢? 這就需要事件循環(huán)系統(tǒng)(Event Loop)
三、瀏覽器中的 Event Loop
1.什么是Event Loop
通過使用消息隊列,我們實現(xiàn)了線程之間的消息通信。在Chrome中,跨進(jìn)程之間的任務(wù)也是頻繁發(fā)生的,那么如何處理其他進(jìn)程發(fā)送過來的任務(wù)?可以參考下圖(來源極客時間):
消息隊列是一種數(shù)據(jù)結(jié)構(gòu),可以存放要執(zhí)行的任務(wù)。它符合隊列“先進(jìn)先出”的特點,也就是說要添加任務(wù)的話,添加到隊列的尾部;要取出任務(wù)的話,從隊列頭部去取。
從圖中可以看出,渲染進(jìn)程專門有一個IO線程用來接收其他進(jìn)程傳進(jìn)來的消息,接收到消息之后,會將這些消息組裝成任務(wù)發(fā)送給渲染主線程。主線程從"消息隊列"中讀取事件,這個過程是循環(huán)不斷的,所以整個的這種運行機制又稱為Event Loop(事件循環(huán))。
2.同步任務(wù)和異步任務(wù)
-
同步任務(wù)即可以立即執(zhí)行的任務(wù),例如聲明一個變量或者執(zhí)行一次加法操作等。同步任務(wù)屬于宏任務(wù)。
-
異步任務(wù)是不會立即執(zhí)行的事件任務(wù)。異步任務(wù)包括宏任務(wù)和微任務(wù)。
瀏覽器端常見的宏任務(wù)包括:setTimeout、setInterval、script(整體代碼)、 I/O 操作、UI 渲染等;
瀏覽器端常見的微任務(wù)包括:new Promise().then(回調(diào))、MutationObserver(html5新特性) 等。
3.Event Loop 過程解析
一個完整瀏覽器端的 Event Loop 過程,可以概括為以下階段:
-
一開始執(zhí)行???我們可以把執(zhí)行棧認(rèn)為是一個存儲函數(shù)調(diào)用的棧結(jié)構(gòu),遵循先進(jìn)后出的原則。微任務(wù)隊列空,宏任務(wù)隊列里有且只有一個 script 腳本(整體代碼)。
-
全局上下文(script 標(biāo)簽)被推入執(zhí)行棧,同步代碼執(zhí)行。在執(zhí)行的過程中,會先判斷是同步任務(wù)還是異步任務(wù),也會產(chǎn)生新的 macro-task 與 micro-task,它們會分別被推入各自的任務(wù)隊列里。同步代碼執(zhí)行完了,script 腳本會被移出 macro 隊列,這個過程本質(zhì)上是隊列的 macro-task 的執(zhí)行和出隊的過程。
-
上一步我們出隊的是一個 macro-task,這一步我們處理的是 micro-task。但需要注意的是:當(dāng) macro-task 出隊時,任務(wù)是一個一個執(zhí)行的;而 micro-task 出隊時,任務(wù)是一隊一隊執(zhí)行的。因此,我們處理 micro 隊列這一步,會逐個執(zhí)行隊列中的任務(wù)并把它出隊,直到隊列被清空。宏任務(wù)隊列可以有多個,微任務(wù)隊列只有一個。
-
執(zhí)行渲染操作,更新界面
-
檢查是否存在 Web worker 任務(wù),如果有,則對其進(jìn)行處理
-
上述過程循環(huán)往復(fù),直到兩個隊列都清空
我們總結(jié)一下,每一次循環(huán)都是一個這樣的過程:
當(dāng)某個宏任務(wù)執(zhí)行完后,會查看是否有微任務(wù)隊列。如果有,先執(zhí)行微任務(wù)隊列中的所有任務(wù),如果沒有,會讀取宏任務(wù)隊列中排在最前的任務(wù),執(zhí)行宏任務(wù)的過程中,遇到微任務(wù),依次加入微任務(wù)隊列。??蘸螅俅巫x取微任務(wù)隊列里的任務(wù),依次類推。
接下來我們看道例子來介紹上面流程:
- Promise.resolve().then(()=>{
- console.log('Promise1')
- setTimeout(()=>{
- console.log('setTimeout2')
- },0)
- })
- setTimeout(()=>{
- console.log('setTimeout1')
- Promise.resolve().then(()=>{
- console.log('Promise2')
- })
- },0)
最后輸出結(jié)果是Promise1,setTimeout1,Promise2,setTimeout2
-
一開始執(zhí)行棧的同步任務(wù)(這屬于宏任務(wù))執(zhí)行完畢,會去查看是否有微任務(wù)隊列,上題中存在(有且只有一個),然后執(zhí)行微任務(wù)隊列中的所有任務(wù)輸出Promise1,同時會生成一個宏任務(wù) setTimeout2
-
然后去查看宏任務(wù)隊列,宏任務(wù) setTimeout1 在 setTimeout2 之前,先執(zhí)行宏任務(wù) setTimeout1,輸出 setTimeout1
-
在執(zhí)行宏任務(wù)setTimeout1時會生成微任務(wù)Promise2 ,放入微任務(wù)隊列中,接著先去清空微任務(wù)隊列中的所有任務(wù),輸出 Promise2
-
清空完微任務(wù)隊列中的所有任務(wù)后,就又會去宏任務(wù)隊列取一個,這回執(zhí)行的是 setTimeout2
四、Node 中的 Event Loop
1.Node簡介
Node 環(huán)境下的 Event Loop 與瀏覽器環(huán)境下的 Event Loop并不相同。Node.js 采用 V8 作為js的解析引擎,而I/O處理方面使用了自己設(shè)計的libuv,libuv是一個基于事件驅(qū)動的跨平臺抽象層,封裝了不同操作系統(tǒng)一些底層特性,對外提供統(tǒng)一的API,事件循環(huán)機制也是它里面的實現(xiàn)(下文會詳細(xì)介紹)。注:本文中所介紹Node 環(huán)境中的 Event Loop,是基于node10及其之前版本。
Node.js的運行機制如下:
-
V8引擎解析JavaScript腳本。
-
解析后的代碼,調(diào)用Node API。
-
libuv庫負(fù)責(zé)Node API的執(zhí)行。它將不同的任務(wù)分配給不同的線程,形成一個Event Loop(事件循環(huán)),以異步的方式將任務(wù)的執(zhí)行結(jié)果返回給V8引擎。
-
V8引擎再將結(jié)果返回給用戶。
2.六個階段
其中l(wèi)ibuv引擎中的事件循環(huán)分為 6 個階段,它們會按照順序反復(fù)運行。每當(dāng)進(jìn)入某一個階段的時候,都會從對應(yīng)的回調(diào)隊列中取出函數(shù)去執(zhí)行。當(dāng)隊列為空或者執(zhí)行的回調(diào)函數(shù)數(shù)量到達(dá)系統(tǒng)設(shè)定的閾值,就會進(jìn)入下一階段。
從上圖中,大致看出node中的事件循環(huán)的順序:
外部輸入數(shù)據(jù)-->輪詢階段(poll)-->檢查階段(check)-->關(guān)閉事件回調(diào)階段(close callback)-->定時器檢測階段(timer)-->I/O事件回調(diào)階段(I/O callbacks)-->閑置階段(idle, prepare)-->輪詢階段(按照該順序反復(fù)運行)...
-
timers 階段:這個階段執(zhí)行timer(setTimeout、setInterval)的回調(diào)
-
I/O callbacks 階段:處理一些上一輪循環(huán)中的少數(shù)未執(zhí)行的 I/O 回調(diào)
-
idle, prepare 階段:僅node內(nèi)部使用
-
poll 階段:獲取新的I/O事件, 適當(dāng)?shù)臈l件下node將阻塞在這里
-
check 階段:執(zhí)行 setImmediate() 的回調(diào)
-
close callbacks 階段:執(zhí)行 socket 的 close 事件回調(diào)
注意:上面六個階段都不包括 process.nextTick()(下文會介紹)
接下去我們詳細(xì)介紹timers
、poll
、check
這3個階段,因為日常開發(fā)中的絕大部分異步任務(wù)都是在這3個階段處理的。
(1) timer
timers 階段會執(zhí)行 setTimeout 和 setInterval 回調(diào),并且是由 poll 階段控制的。 同樣,在 Node 中定時器指定的時間也不是準(zhǔn)確時間,只能是盡快執(zhí)行。
(2) poll
poll 是一個至關(guān)重要的階段,這一階段中,系統(tǒng)會做兩件事情:
1.回到 timer 階段執(zhí)行回調(diào)
2.執(zhí)行 I/O 回調(diào)
并且在進(jìn)入該階段時如果沒有設(shè)定了 timer 的話,會發(fā)生以下兩件事情:
-
如果 poll 隊列不為空,會遍歷回調(diào)隊列并同步執(zhí)行,直到隊列為空或者達(dá)到系統(tǒng)限制
-
如果 poll 隊列為空時,會有兩件事發(fā)生
-
如果有 setImmediate 回調(diào)需要執(zhí)行,poll 階段會停止并且進(jìn)入到 check 階段執(zhí)行回調(diào)
-
如果沒有 setImmediate 回調(diào)需要執(zhí)行,會等待回調(diào)被加入到隊列中并立即執(zhí)行回調(diào),這里同樣會有個超時時間設(shè)置防止一直等待下去
-
當(dāng)然設(shè)定了 timer 的話且 poll 隊列為空,則會判斷是否有 timer 超時,如果有的話會回到 timer 階段執(zhí)行回調(diào)。
(3) check階段
setImmediate()的回調(diào)會被加入check隊列中,從event loop的階段圖可以知道,check階段的執(zhí)行順序在poll階段之后。 我們先來看個例子:
- console.log('start')
- setTimeout(() => {
- console.log('timer1')
- Promise.resolve().then(function() {
- console.log('promise1')
- })
- }, 0)
- setTimeout(() => {
- console.log('timer2')
- Promise.resolve().then(function() {
- console.log('promise2')
- })
- }, 0)
- Promise.resolve().then(function() {
- console.log('promise3')
- })
- console.log('end')
- //start=>end=>promise3=>timer1=>timer2=>promise1=>promise2
-
一開始執(zhí)行棧的同步任務(wù)(這屬于宏任務(wù))執(zhí)行完畢后(依次打印出start end,并將2個timer依次放入timer隊列),會先去執(zhí)行微任務(wù)(這點跟瀏覽器端的一樣),所以打印出promise3
-
然后進(jìn)入timers階段,執(zhí)行timer1的回調(diào)函數(shù),打印timer1,并將promise.then回調(diào)放入microtask隊列,同樣的步驟執(zhí)行timer2,打印timer2;這點跟瀏覽器端相差比較大,timers階段有幾個setTimeout/setInterval都會依次執(zhí)行,并不像瀏覽器端,每執(zhí)行一個宏任務(wù)后就去執(zhí)行一個微任務(wù)(關(guān)于Node與瀏覽器的 Event Loop 差異,下文還會詳細(xì)介紹)。
3.Micro-Task 與 Macro-Task
Node端事件循環(huán)中的異步隊列也是分為macro(宏任務(wù))隊列和 micro(微任務(wù))隊列。
-
Node端常見的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整體代碼)、 I/O 操作等。
-
Node端常見的 micro-task 比如: process.nextTick、new Promise().then(回調(diào))等。
4.注意點
(1) setTimeout 和 setImmediate
二者非常相似,區(qū)別主要在于調(diào)用時機不同。
-
setImmediate 設(shè)計在poll階段完成時執(zhí)行,即check階段;
-
setTimeout 設(shè)計在poll階段為空閑時,且設(shè)定時間到達(dá)后執(zhí)行,但它在timer階段執(zhí)行
- setTimeout(function timeout () {
- console.log('timeout');
- },0);
- setImmediate(function immediate () {
- console.log('immediate');
- });
-
對于以上代碼來說,setTimeout 可能執(zhí)行在前,也可能執(zhí)行在后。
-
首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的 進(jìn)入事件循環(huán)也是需要成本的,如果在準(zhǔn)備時候花費了大于 1ms 的時間,那么在 timer 階段就會直接執(zhí)行 setTimeout 回調(diào)
-
如果準(zhǔn)備時間花費小于 1ms,那么就是 setImmediate 回調(diào)先執(zhí)行了
但當(dāng)二者在異步i/o callback內(nèi)部調(diào)用時,總是先執(zhí)行setImmediate,再執(zhí)行setTimeout
- const fs = require('fs')
- fs.readFile(__filename, () => {
- setTimeout(() => {
- console.log('timeout');
- }, 0)
- setImmediate(() => {
- console.log('immediate')
- })
- })
- // immediate
- // timeout
在上述代碼中,setImmediate 永遠(yuǎn)先執(zhí)行。因為兩個代碼寫在 IO 回調(diào)中,IO 回調(diào)是在 poll 階段執(zhí)行,當(dāng)回調(diào)執(zhí)行完畢后隊列為空,發(fā)現(xiàn)存在 setImmediate 回調(diào),所以就直接跳轉(zhuǎn)到 check 階段去執(zhí)行回調(diào)了。
(2) process.nextTick
這個函數(shù)其實是獨立于 Event Loop 之外的,它有一個自己的隊列,當(dāng)每個階段完成后,如果存在 nextTick 隊列,就會清空隊列中的所有回調(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')
- })
- })
- })
- })
- // nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
五、Node與瀏覽器的 Event Loop 差異
瀏覽器環(huán)境下,microtask的任務(wù)隊列是每個macrotask執(zhí)行完之后執(zhí)行。而在Node.js中,microtask會在事件循環(huán)的各個階段之間執(zhí)行,也就是一個階段執(zhí)行完畢,就會去執(zhí)行microtask隊列的任務(wù)。
接下我們通過一個例子來說明兩者區(qū)別:
- setTimeout(()=>{
- console.log('timer1')
- Promise.resolve().then(function() {
- console.log('promise1')
- })
- }, 0)
- setTimeout(()=>{
- console.log('timer2')
- Promise.resolve().then(function() {
- console.log('promise2')
- })
- }, 0)
瀏覽器端運行結(jié)果:timer1=>promise1=>timer2=>promise2
瀏覽器端的處理過程如下:
Node端運行結(jié)果:
要看第一個定時器執(zhí)行完,第二個定時器是否在完成隊列中。
-
如果是第二個定時器還未在完成隊列中,最后的結(jié)果為
timer1=>promise1=>timer2=>promise2
-
如果是第二個定時器已經(jīng)在完成隊列中,則最后的結(jié)果為
timer1=>timer2=>promise1=>promise2
(下文過程解釋基于這種情況下)
1.全局腳本(main())執(zhí)行,將2個timer依次放入timer隊列,main()執(zhí)行完畢,調(diào)用??臻e,任務(wù)隊列開始執(zhí)行;
2.首先進(jìn)入timers階段,執(zhí)行timer1的回調(diào)函數(shù),打印timer1,并將promise1.then回調(diào)放入microtask隊列,同樣的步驟執(zhí)行timer2,打印timer2;
3.至此,timer階段執(zhí)行結(jié)束,event loop進(jìn)入下一個階段之前,執(zhí)行microtask隊列的所有任務(wù),依次打印promise1、promise2
Node端的處理過程如下:
六、總結(jié)
瀏覽器和Node 環(huán)境下Event Loop有所區(qū)別,主要體現(xiàn)在微任務(wù)隊列的執(zhí)行時機不同
-
Node端,microtask 在事件循環(huán)的各個階段之間執(zhí)行
-
瀏覽器端,microtask 在事件循環(huán)的 macrotask 執(zhí)行完之后執(zhí)行
參考文章與資料
作者介紹
浪里行舟:碩士研究生,專注于前端。個人公眾號:「前端工匠」,致力于打造適合初中級工程師能夠快速吸收的一系列優(yōu)質(zhì)文章!
【51CTO原創(chuàng)稿件,合作站點轉(zhuǎn)載請注明原文作者和出處為51CTO.com】