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