前言
對於Javascript異步,我是從其他面向對象編程語言的併發編程層層向下介紹的,在一些細節上並沒有多詳細說明。此次算是補充所缺,在選擇主題時,我茫然了好一陣,決定從微任務和宏任務開始入手,閱讀下文時,儘可能有些Promise的基礎。
開始吧。
事件循環
首先補充一下上次事件循環的更多細節:
- 事件循環開啓
- 將新消息序列設爲當前消息序列
- 從當前消息序列中取出任務
消息序列是先入先出結構,也就是說它是按照順序取出的。- 處理任務
自上而下運行JS代碼
如果發出異步請求,然後將消息保存到這個新消息序列(若無則新建)中
新消息序列的任務全部被阻塞,等待下次事件循環迭代處理。- 檢查當前消息序列是否爲空,是則繼續,否則轉至 (3)
- 是否觸發UI Rendering事件,是則立即進行視圖渲染。 否則繼續
- 是否新增消息序列,
如果是,開始下一輪事件循環,回到(2)
否則繼續- 確定再無事件,關閉事件循環。線程進入休眠;
直至有事件發生,新建消息序列並保存消息,轉至(1)。
從上面的過程中,可以得到下面的結論:
- 此次事件循環將消息序列進行了細分,即當前的和新增的,兩者並不同。
- 一次事件循環,處理一個消息序列,而不只是一個消息。
- 一個事件循環都只渲染一次。
- 此事件循環仍需改進
宏任務
我們常說的任務(task),都是宏任務(Macrotask),由宏任務組成的消息序列,稱作宏任務序列,即 Macrotasks(套娃嫌疑確定……),一般都是涉及到 IO操作(包括網絡請求、頁面渲染等)的任務,例如:
- scripts: 腳本代碼
- Mouse/Key Events : click、onload、input等。
- Timers: 定時器,例如
setTimeout
setInterval
等。 - 未完待續
注意:Timers
工作過程是這樣的:
- 調用
setTimeout
時,將消息(回調函數,即task)放到延遲消息隊列中 - 延遲消息隊列中的task到期後,放入新Macrotasks中。
- 在下次事件循環迭代中等候處理。
示例:
首先搞一個用於生成定時器的函數。
<div id="text"></div>
var text = document.querySelector('#text')
var genTimer = (string,delay=0,cb)=>{
return ()=>{
setTimeout(()=>{
text.innerHTML+=string+'<br/>'
!cb||cb();
},delay*1000)
}
}
現在請一直記住腳本代碼和Timer
是一個宏任務。
並且這個函數會一直用到結束爲止。
(……想必上面的代碼極易理解的吧……)
事件循環與渲染
準備兩個消息序列的任務,
算是模擬兩次事件循環的消息序列。
var macroTasks = []
macroTasks[0] = [genTimer('A1: Be honest rather clever.',1),
genTimer('A2: Being on sea, sail; being on land, settle.',1)]
// 兩個隊列的到期時間不一致!
macroTasks[1] = [genTimer('B1: Failure is the mother of success.',3),
genTimer('B2: The shortest answer is doing.', 3)]
/*
macroTask[0] 是 下次的消息序列的任務
macroTask[1] 是 下下次的消息序列的任務
*/
請注意, macroTasks[0]
中的task
都在1s
後到期。因此下輪事件循環中會處理這些tasks。同理,macroTasks[1]
中的tasks
都在3s
後到期,因此會在下下次事件循環中處理這些tasks。
爲什麼要注意這些區別呢?
因爲它們分兩次事件循環的處理的!
text.innerHTML +='start......<br>'
macroTasks[0].forEach(f=>f())
macroTasks[1].forEach(f=>f())
text.innerHTML +='end......<br>'
輸出如下:
答案很容易就被猜出來的。但是輸出結果並不重要,重要的是現象。
A1
和A2
以及B1
和B2
彷彿是分成了兩次渲染出來的! 這纔是關鍵。
說明如下:
- 一次事件循環,只會處理一個消息序列。由於
A1
和A2
的Timer
是同時到期的,因此會被劃分到同一個消息序列中,而一次事件循環只渲染一次,所以A1
和A2
同時被渲染。 - 同理,
B1
和B2
也是同樣的情形;但是要注意:B1
和B2
的到期時間與A1
和A2
的並不同,因此它們分爲兩次事件循環處理的。
基本過程如下:
注意:事實上這次也渲染了,不過這不是我要講的內容無關,因此略過。
處理完畢後,渲染一次。
最後一次事件循環, 渲染完畢後結束。
綜上所述可以知道:
同一個消息序列中的task會共享同一次事件循環,並且會等待所有task處理完成後纔會渲染
爲什麼我們要得到這個結論?
繼續吧。
阻塞問題
大多數情況下,我們是不會感知到阻塞的,這一方面是CPU計算能力強悍,另一方面也是JS引擎高性能的原因。
不過偶爾也會出現例外,事實上,我們所說的宏任務基本上都是工作量較大的任務,例如我們的JS代碼文件(少說也要有2000行代碼吧),如果處理不好,就很容易阻塞(即響應時間超長)。
現在模擬一個阻塞任務,例如:
var macroTasks =[]
// 在下次事件循環中,新增一個普通任務,
macroTasks.push(genTimer('01:Better to light one candle \
than to curse the darkness.',0))
// 新增一個超長事件的阻塞任務。
// 阻塞時長3s
macroTasks.push(genTimer('XXXXXblocking long time!',
0,
()=>{
console.log('i am running!');
let c = Date.now();
while((Date.now()-c)<=3000);
}))
text.innerHTML +='hello<br>'
macroTasks.forEach(f=>f())
text.innerHTML +='byebye!<br>'
注意:: macroTasks
中所有任務都是同時到期的,因此可知它們會被劃分到同一個事件循環中;
然後輸出如下(請耐心看下去):
在上面的示例中,儘管將 microtasks中的所有內容都分到了統一個時間循環中,但它們並沒有如我們所想的那般在 0s
後輸出。而是同時阻塞了3s
。 這是又爲何?
說明:
- 因爲
microtask
中的所有任務共享一次事件循環,並且只有事件循環的所有任務都處理完畢時纔會發生渲染事件。 - 所以可以知道,
microtask[0]
和microtask[1]
只有都被處理完成後才能夠渲染!但是由於microtask[1]
產生了阻塞,最終導致了卡頓。
所以可以得到下面的結論:
- 如果消息序列中有一個
task
陷入阻塞,那麼就會導致整個事件循環陷入阻塞,最終導致卡頓。
事實上,一旦事件循環陷入阻塞,也會影響到下次事件循環的運行。
接下來,當做我們不知道 macrotasks[1] 是阻塞任務。
上面的代碼總是Hello
之後就ByeBye!!
了,內容完全沒輸出,這是沒道理的。所以姑且爲了用戶體驗着想,代碼改成這樣:
var macroTasks =[]
macroTasks.push(genTimer('XXXXXblocking long time!',
0.5,
()=>{
console.log('i am running!');
let c = Date.now();
while((Date.now()-c)<=3000);
}))
macroTasks.push(genTimer('01:Be honest rather clever',1))
macroTasks.push(genTimer('ByeBye',1))
text.innerHTML +='hello<br>'
macroTasks.forEach(f=>f())
注意:上面的代碼中,macrotasks[0]
和macrotasks[1]
以及macrotasks[2]
的事件循環不同,它們已經被錯開了。但是仍然被硬生生卡到3s
後才輸出。原因很簡單,因爲當前事件循環仍在處理中,所以就推遲了進入下次事件循環的時間。
因此總結一條:永遠不要阻塞事件循環,它是所有異步模型的黃金鐵律。因爲它不僅導致嚴重的卡頓,而且極其影響用戶體驗,更重要的是:事件循環阻塞就意味着更大的性能開銷。
因此我們只能在阻塞任務之前處理所有任務,但通常情況下仍不可避免的受其影響,例如阻塞任務的延遲時間爲0s
時,那麼任何宏任務都會受阻塞影響, 惹怒用戶第一步,循環阻塞想嘔吐
微任務便是上面的一種解決方案(當然最直接的處理辦法就把阻塞任務給Pass掉,但是大多數情況下,這種任務偏偏就很重要。)
使用微任務
微任務Microtask
簡單來說是能夠快速完成的任務,並且它保證所有的tasks處理完成後(但仍然在UI Rendering前)進行處理完成。在ES8
規範中,微任務用 Job 表示,嘛,不過喜歡 microtask的人更多些,兩個術語表達的意思都是相同的。
最經常使用的微任務是Promise
。
例如下面代碼:
var macroTasks =[]
macroTasks.push(genTimer('XXXXXblocking long time!',
0 ,
()=>{
console.log('i am running!');
let c = Date.now();
while((Date.now()-c)<=3000);
}))
macroTasks.push(()=>{
return new Promise(res=>{
text.innerHTML+='lark in the clear air<br/>'
res('success!');
})
})
macroTasks.push(()=>{
return new Promise(res=>{
text.innerHTML+='ByeByeBye!<br/>'
})
})
text.innerHTML +='hello<br>'
macroTasks.forEach(f=>f())
輸出:
過程圖如下:
(注意: 上面的macroTasks
中混入了兩個微任務)
雖然現在仍然還是受阻塞影響,但是至少表面上沒什麼卡頓。當然這只是一種實驗;生產環境下無論如何也不要這樣做。自此不再贅述。
微任務在瀏覽器環境下有三個:
- queueMicrotask: 微任務回調函數。
- Promise: Promise,最常用的
- MutationObserver: DOM樹監聽
這裏面除了Promise
其他都不怎麼常用,有興趣的可以去了解一下。不過微任務給人的感覺,就像是一個可以追加到宏任務後面的同步代碼,微任務定義不重要,重要的是,微任務儘可能是體積較小的任務代碼,不要嘗試阻塞微任務,否則就失去了微任務的本來含義。
將上面的代碼再進一步改寫:
var text = document.querySelector('#text')
function genMicrotask(str){
return async ()=>{
text.innerHTML += str + '<br>'
return str ;
}
}
var microTasks = [genMicrotask('01: For man is man and master of his fate.'),
genMicrotask('ByeBye!!!')]
text.innerHTML='hello!!!<br>'
microTasks.forEach(f=>f())
輸出:
hello!!!
01: For man is man and master of his fate.
ByeBye!!!
很完美,至少比上次的看起來清爽了許多。
注意:async函數最終也返回一個settled的Promise。
好了,微任務和宏任務就先到這裏。
(? Promise放後面吧,相信看的人也不是零基礎,總知道用法吧……)
更新事件循環模型
根據上面所有內容,加入macroTasks
和microTasks
等元素,就是:
- 事件循環開啓
- 將新增Macrotasks設爲當前Macrotasks
- 從當前Macrotasks中按順序取出任務
- 處理任務
自上而下運行JS代碼
如果發出異步請求,然後將消息保存到新Macrotasks(若無則新建)中;
如果存在Microtasks,那麼:
- 從Microtasks中取出任務
- 運行代碼
- 如果發出異步請求,然後將消息保存到新Macrotasks(若無則新建)中
- 如果存在Microtask,仍然將其添加到當前macrotask的Microtasks。
- 檢查當前Macrotasks是否爲空,是則繼續,否則轉至 (3)
- 是否觸發UI Rendering事件,是則立即進行視圖渲染。 否則繼續
- 是否有新增MacroTasks,
如果是,開始下一輪事件循環, 回到(2);否則繼續。- 確定再無事件,關閉事件循環。線程進入休眠;
直至有事件發生,新建消息序列並保存消息,轉至(1)。
其實關於事件循環可以簡單記作爲:
- 任何事件循環都是以 Macrotasks --> MicroTasks --> UI Rendering 順序進行的。
- 新增的任何 Microtask 只會保存在 當前事件循環的 Microtasks中。
- 新增的任何 Macrotask 只會保存在 新增Macrotasks 中,它是下一輪事件循環的 Macrotasks。
最後
原來我心想能帶入 NodeJS 的東西, 但是未曾想 NodeJS的底層細節如此複雜,遠不是Javascript事件循環模型能概括得了的。對於不明白的原理,小生不敢自以爲是,因此只能稍作安排。libuv
好難啊。。。
限於篇幅,只能說這麼多……但是關於這部分內容涉及知識量極大,有謬誤之處,還請慷慨指正,不勝感激。
同步轉發:
https://juejin.im/post/5ecfa657f265da76ed484fd9