這篇文章帶給大家的內容是關於瀏覽器與Node的事件循環(Event Loop)之間的區別總結,有一定的參考價值,有需要的朋友可以參考一下,希望對你有所幫助。
本文我們將會介紹 JS 實作非同步的原理,並且了解了在瀏覽器和 Node 中 Event Loop 其實是不相同的。
一、執行緒與進程
1.概念
我們常說JS 是單執行緒執行的,指的是一個行程裡只有一個主線程,那到底什麼是線程?什麼是進程?
官方的說法是:進程是 CPU 資源分配的最小單位;執行緒是 CPU 調度的最小單位。這兩句話都不好理解,我們先來看張圖:
#進程好比圖中的工廠,有單獨的專屬自己的工廠資源。
線程好比圖中的工人,多個工人在一個工廠中協作工作,工廠與工人是 1:n 的關係。也就是說一個行程由一個或多個執行緒組成,執行緒是一個行程中程式碼的不同執行路線;
工廠的空間是工人們共享的,這象徵一個行程的記憶體空間是共享的,每個執行緒都可用這些共享記憶體。
多個工廠之間獨立存在。
多進程:在同一個時間裡,同一個電腦系統中如果允許兩個或兩個以上的進程處於運作狀態。多進程帶來的好處是明顯的,例如你可以聽歌的同時,打開編輯器敲程式碼,編輯器和聽歌軟體的進程之間絲毫不會互相干擾。
多執行緒:程式中包含多個執行流,即在一個程式中可以同時執行多個不同的執行緒來執行不同的任務,也就是說允許單一程式建立多個並行執行的執行緒來完成各自的任務。
以Chrome 瀏覽器中為例,當你開啟一個Tab 頁時,其實就是建立了一個行程,一個行程中可以有多個執行緒(下文會詳細介紹),例如渲染線程、JS 引擎線程、HTTP 請求線程等等。當你發起一個請求時,其實就是創建了一個線程,當請求結束後,該線程可能就會被銷毀。
簡單來說瀏覽器核心是透過取得頁面內容、整理資訊(應用程式CSS)、計算和組合最終輸出可視化的圖像結果,通常也被稱為渲染引擎。
瀏覽器核心是多線程,在核心控制下各線程相互配合以保持同步,一個瀏覽器通常由以下常駐線程組成:
GUI 渲染執行緒
JavaScript 引擎執行緒
#定時觸發器執行緒
事件觸發執行緒
非同步http 請求執行緒
主要負責頁面的渲染,解析HTML 、CSS,建構DOM 樹,佈局和繪製等。
當介面需要重繪或因為某種操作引發回流時,就會執行該執行緒。
該執行緒與 JS 引擎執行緒互斥,當執行 JS 引擎執行緒時,GUI 渲染會被掛起,當任務佇列空閒時,JS 引擎才會去執行 GUI 渲染。
該執行緒當然是主要負責處理 JavaScript 腳本,執行程式碼。
也是主要負責執行準備好待執行的事件,即定時器計數結束,或非同步請求成功並正確返回時,將依序進入任務佇列,等待JS 引擎執行緒的執行。
當然,該執行緒與 GUI 渲染執行緒互斥,當 JS 引擎執行緒執行 JavaScript 腳本時間過長,將導致頁面渲染的阻塞。
#負責執行非同步定時器一類的函數的線程,如: setTimeout,setInterval。
主執行緒依序執行程式碼時,遇到計時器,會將計時器交給該執行緒處理,當計數完畢後,事件觸發執行緒會將計數完畢後的事件加入到任務佇列的尾部,等待JS 引擎執行緒執行。
主要負責將準備好的事件交給 JS 引擎執行緒執行。
例如setTimeout 計時器計數結束, ajax 等非同步請求成功並觸發回呼函數,或使用者觸發點擊事件時,該執行緒會將整裝待發的事件依序加入任務佇列的隊伍尾,等待JS 引擎執行緒的執行。
負責執行非同步請求一類的函數的線程,如: Promise,axios,ajax 等。
主執行緒依序執行程式碼時,遇到非同步請求,會將函數交給該執行緒處理,當監聽到狀態碼變更,如果有回呼函數,事件觸發執行緒會將回呼函數加入到任務佇列的尾部,等待JS 引擎執行緒執行。
事件循環中的非同步佇列有兩種:macro(巨集任務)佇列和micro(微任務)佇列。 巨集任務佇列可以有多個,微任務佇列只有一個。
常見的 macro-task 例如:setTimeout、setInterval、 setImmediate、script(整體程式碼)、 I/O 操作、UI 渲染等。
常見的 micro-task: process.nextTick、new Promise().then(回呼)、MutationObserver(html5 新特性) 等。
一個完整的Event Loop 過程,可以概括為以下階段:
一開始執行棧空,我們可以把執行堆疊認為是儲存函數呼叫的堆疊結構,遵循先進後出的原則。 micro 佇列空,macro 佇列裡有且只有一個 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 和瀏覽器中的是完全不相同的東西。 Node.js 採用V8 作為js 的解析引擎,而I/O 處理方面則使用了自己設計的libuv,libuv 是基於事件驅動的跨平台抽象層,封裝了不同作業系統一些底層特性,對外提供統一的API ,事件循環機制也是它裡面的實作(下文會詳細介紹)。
Node.js 的執行機制如下:
V8 引擎再将结果返回给用户。
其中 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 是一个至关重要的阶段,这一阶段中,系统会做两件事情
回到 timer 阶段执行回调
执行 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 差异,下文还会详细介绍)。
(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
浏览器环境下,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=>timer2=>promise1=>promise2
全局脚本(main())执行,将 2 个 timer 依次放入 timer 队列,main()执行完毕,调用栈空闲,任务队列开始执行;
首先进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise1.then 回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;
至此,timer 阶段执行结束,event loop 进入下一个阶段之前,执行 microtask 队列的所有任务,依次打印 promise1、promise2
Node 端的处理过程如下:
浏览器和 Node 环境下,microtask 任务队列的执行时机不同
Node 端,microtask 在事件循环的各个阶段之间执行
浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
以上是瀏覽器與Node的事件循環(Event Loop)之間的差異總結的詳細內容。更多資訊請關注PHP中文網其他相關文章!