2019-4-01更新:采納JSC引擎的術(shù)語,我們把宿主(瀏覽器、Node環(huán)境)發(fā)起的任務(wù)稱為宏任務(wù)(如SetTimeout),把JavaScript引擎發(fā)起的任務(wù)稱為微觀任務(wù)(如Promise)。
我們?cè)诮佑|到JavaScript語言的時(shí)候就經(jīng)常聽到別人介紹JavaScript 是單線程、異步、非阻塞、解釋型腳本語言。
究竟應(yīng)該如何理解這句話呢?
確切的說,對(duì)于開發(fā)者的開發(fā)過程來說,js確實(shí)只有一個(gè)線程(由JS引擎維護(hù)),這個(gè)線程用來負(fù)責(zé)解釋和執(zhí)行JavaScript代碼,我們可以稱其為主線程。例如在控制臺(tái)輸入如下代碼:
console.log("a");
console.log("b");
console.log("c");
//依次輸出a,b,c
可以看出,這段代碼在主線程上是按照順序執(zhí)行的。但是我們平時(shí)的任務(wù)處理可能并不會(huì)直接獲取到結(jié)果,這種情況下如果仍然使用同步方法,例如發(fā)起一個(gè)ajax請(qǐng)求,大概500ms后受到響應(yīng),在這個(gè)過程中,后面的任務(wù)就會(huì)被阻塞,瀏覽器頁面就會(huì)阻塞所有用戶交互,呈“卡死”狀態(tài)。
console.log("a");
$.ajax({
url:"xxx",
async:false, //同步請(qǐng)求ajax
success:function(){
console.log("b");
}
})
console.log("c");
這種同步的方式對(duì)于用戶操作非常不友好,所以大部分耗時(shí)的任務(wù)在JS中都會(huì)通過異步的方式實(shí)現(xiàn)。雖然js引擎只維護(hù)一個(gè)主線程用來解釋執(zhí)行JS代碼,但實(shí)際上瀏覽器環(huán)境中還存在其他的線程,例如處理AJAX,DOM,定時(shí)器等,我們可以稱他們?yōu)楣ぷ骶€程。同時(shí)瀏覽器中還維護(hù)了一個(gè)消息隊(duì)列,主線程會(huì)將執(zhí)行過程中遇到的異步請(qǐng)求發(fā)送給這個(gè)消息隊(duì)列,等到主線程空閑時(shí)再來執(zhí)行消息隊(duì)列中的任務(wù)。
同步任務(wù)的缺點(diǎn)是阻塞,異步任務(wù)的缺點(diǎn)是會(huì)使代碼執(zhí)行順序難以判斷。
兩者比較一下我們還是更傾向于后者。
到目前為止,我們已經(jīng)涉及到了幾個(gè)名詞,主線程,js引擎,事件循環(huán),消息隊(duì)列等。接下來會(huì)對(duì)這些名詞一一進(jìn)行解釋。
js引擎
我們所熟悉的引擎是chrome瀏覽器中和node.js中使用的V8引擎。它的大致組成如圖:
這個(gè)引擎主要由兩個(gè)部分組成,內(nèi)存堆和調(diào)用棧。(只負(fù)責(zé)取消息,不負(fù)責(zé)生產(chǎn)消息)
內(nèi)存堆:進(jìn)行內(nèi)存分配。如變量賦值。
調(diào)用棧:這是代碼在棧幀中執(zhí)行的地方。調(diào)用棧中順序執(zhí)行主線程的代碼,當(dāng)調(diào)用棧中為空時(shí),js引擎會(huì)去消息隊(duì)列取消息。取到后就執(zhí)行。JavaScript是單線程的編程語言,意味著它有一個(gè)單一的調(diào)用棧。因此它只能在同一時(shí)間做一件事情。調(diào)用棧是一種數(shù)據(jù)結(jié)構(gòu),它基本上記錄了我們?cè)诔绦蛑械氖裁次恢谩H绻覀儾饺胍粋€(gè)函數(shù)中,我們會(huì)把這些數(shù)據(jù)放在堆棧的頂部。如果我們從一個(gè)函數(shù)中返回,這些數(shù)據(jù)將會(huì)從棧頂彈出。這就是堆棧的用途。調(diào)用棧中的每個(gè)條目叫做棧幀。當(dāng)我們?cè)赾hrome調(diào)試窗口中看到拋出的錯(cuò)誤時(shí),就能夠看到大致的調(diào)用順序。
js運(yùn)行時(shí)
我們經(jīng)常使用的一些API并不是js引擎中提供的,例如setTimeout。它們其實(shí)是在瀏覽器中提供的,也就是運(yùn)行時(shí)提供的,因此,實(shí)際上除了JavaScript引擎以外,還有其他的組件。其中有個(gè)組件就是由瀏覽器提供的,叫Web APIs,像DOM,AJAX,setTimeout等等。
然后還有就是非常受歡迎的事件循環(huán)和回調(diào)隊(duì)列。
運(yùn)行時(shí)負(fù)責(zé)給引擎線程發(fā)送消息,只負(fù)責(zé)生產(chǎn)消息,不負(fù)責(zé)取消息。
消息隊(duì)列和事件循環(huán)
主線程在執(zhí)行過程中遇到了異步任務(wù),就發(fā)起函數(shù)或者稱為注冊(cè)函數(shù),通過event loop線程通知相應(yīng)的工作線程(如ajax,dom,setTimout等),同時(shí)主線程繼續(xù)向后執(zhí)行,不會(huì)等待。等到工作線程完成了任務(wù),eventloop線程會(huì)將消息添加到消息隊(duì)列中,如果此時(shí)主線程上調(diào)用棧為空就執(zhí)行消息隊(duì)列中排在最前面的消息,依次執(zhí)行。
新的消息進(jìn)入隊(duì)列的時(shí)候,會(huì)自動(dòng)排在隊(duì)列的尾端。
單線程意味著js任務(wù)需要排隊(duì),如果前一個(gè)任務(wù)出現(xiàn)大量的耗時(shí)操作,后面的任務(wù)得不到執(zhí)行,任務(wù)的積累會(huì)導(dǎo)致頁面的“假死”。這也是js編程一直在強(qiáng)調(diào)需要回避的“坑”。
主線程會(huì)循環(huán)上述步驟,事件循環(huán)就是主線程重復(fù)從消息隊(duì)列中取消息、執(zhí)行的過程。
需要注意的是 GUI渲染線程與JS引擎是互斥的,當(dāng)JS引擎執(zhí)行時(shí)GUI線程會(huì)被掛起,GUI更新會(huì)被保存在一個(gè)隊(duì)列中等到JS引擎空閑時(shí)立即被執(zhí)行。因此頁面渲染都是在js引擎主線程調(diào)用棧為空時(shí)進(jìn)行的。
其實(shí) 事件循環(huán) 機(jī)制和 消息隊(duì)列 的維護(hù)是由事件觸發(fā)線程控制的。
事件觸發(fā)線程 同樣是瀏覽器渲染引擎提供的,它會(huì)維護(hù)一個(gè) 消息隊(duì)列。
JS引擎線程遇到異步(DOM事件監(jiān)聽、網(wǎng)絡(luò)請(qǐng)求、setTimeout計(jì)時(shí)器等...),會(huì)交給相應(yīng)的線程單獨(dú)去維護(hù)異步任務(wù),等待某個(gè)時(shí)機(jī)(計(jì)時(shí)器結(jié)束、網(wǎng)絡(luò)請(qǐng)求成功、用戶點(diǎn)擊DOM),然后由 事件觸發(fā)線程 將異步對(duì)應(yīng)的 回調(diào)函數(shù) 加入到消息隊(duì)列中,消息隊(duì)列中的回調(diào)函數(shù)等待被執(zhí)行。
同時(shí),JS引擎線程會(huì)維護(hù)一個(gè) 執(zhí)行棧,同步代碼會(huì)依次加入執(zhí)行棧然后執(zhí)行,結(jié)束會(huì)退出執(zhí)行棧。
如果執(zhí)行棧里的任務(wù)執(zhí)行完成,即執(zhí)行棧為空的時(shí)候(即JS引擎線程空閑),事件觸發(fā)線程才會(huì)從消息隊(duì)列取出一個(gè)任務(wù)(即異步的回調(diào)函數(shù))放入執(zhí)行棧中執(zhí)行。
執(zhí)行順序
了解了事件循環(huán)和消息隊(duì)列之后,接下來就是弄清楚當(dāng)同步任務(wù)和異步任務(wù)都存在時(shí),代碼執(zhí)行的順序究竟是怎么樣的。
舉個(gè)例子:
console.log("a");
setTimeout(function(){
console.log("b")},1000
);
console.log("c");
相信所有人都知道執(zhí)行順序是 a, c , b。
如果變化一下:
console.log("a");
setTimeout(function(){
console.log("b")},0
);
console.log("c");
相信通過上面的內(nèi)容,大部分人也都知道執(zhí)行順序還是a,c,b。setTimeout在主線程執(zhí)行時(shí)被添加到了消息隊(duì)列中,等待主線程調(diào)用棧為空時(shí),再?gòu)南㈥?duì)列中取出執(zhí)行。因此setTimeout中的延時(shí)時(shí)間并非確切的執(zhí)行時(shí)間,實(shí)際上應(yīng)該理解為添加到消息隊(duì)列中的延遲時(shí)間。以上述代碼為例,如果console.log("c")處是一個(gè)計(jì)算量很大的任務(wù),或者消息隊(duì)列中已經(jīng)存在了若干個(gè)等待處理的消息。setTimeout都將延遲都將大于設(shè)置的延遲時(shí)間。
ES6 Promise
以上的內(nèi)容在ES6之前就基本cover了執(zhí)行順序的問題,但是在ES6引入了promise后,產(chǎn)生了一個(gè)新的名詞”微任務(wù)(microtask)“。微任務(wù)的執(zhí)行順序與之前我們所說的任務(wù)(我們可以稱之為”宏任務(wù)“)是不同的。
console.log('script start')
setTimeout(function() {
console.log('timer over')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})
console.log('script end')
輸出的結(jié)果是:
script start
script end
promise1
promise2
timer over
你答對(duì)了嗎?
我猜這里讓你困惑的一定是為什么promise1和promise2在timer over之前輸出了。下面我們來解釋一下微任務(wù)這個(gè)概念。
- 一個(gè)線程中,事件循環(huán)是唯一的,但是任務(wù)隊(duì)列可以擁有多個(gè)。
- 任務(wù)隊(duì)列又分為macro-task(宏任務(wù))與micro-task(微任務(wù)),在最新標(biāo)準(zhǔn)中,它們被分別稱為task與jobs。
- macro-task大概包括:script(整體代碼), setTimeout, setInterval, setImmediate, I/O, UI rendering。
- micro-task大概包括: process.nextTick, Promise, Object.observe(已廢棄), MutationObserver(H5新特性)
- setTimeout/Promise等我們稱之為任務(wù)源。而進(jìn)入任務(wù)隊(duì)列的是他們指定的具體執(zhí)行任務(wù)。
- 來自不同任務(wù)源的任務(wù)會(huì)進(jìn)入到不同的任務(wù)隊(duì)列。其中setTimeout與setInterval是同源的。
- 事件循環(huán)的順序,決定了JavaScript代碼的執(zhí)行順序。它從script(整體代碼)開始第一次循環(huán)。之后全局上下文進(jìn)入函數(shù)調(diào)用棧。直到調(diào)用棧清空(只剩全局),然后執(zhí)行所有的micro-task。當(dāng)所有可執(zhí)行的micro-task執(zhí)行完畢之后。循環(huán)再次從macro-task開始,找到其中一個(gè)任務(wù)隊(duì)列執(zhí)行完畢,然后再執(zhí)行所有的micro-task,這樣一直循環(huán)下去。
- 其中每一個(gè)任務(wù)的執(zhí)行,無論是macro-task還是micro-task,都是借助函數(shù)調(diào)用棧來完成。
舉個(gè)例子:
setTimeout(function() {
console.log('timeout1');
})
new Promise(function(resolve) {
console.log('promise1');
for(var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log('promise2');
}).then(function() {
console.log('then1');
})
console.log('global1');
執(zhí)行結(jié)果為:
promise1
promise2
global1
then1
timeout1
分析一下代碼,首先程序開始執(zhí)行,遇到setTimeout時(shí)將它添加到消息隊(duì)列,等待后續(xù)處理,遇到Promise時(shí)會(huì)創(chuàng)建微任務(wù)(.then()里面的回調(diào)),注意此時(shí)new promise構(gòu)造函數(shù)中的代碼還是同步執(zhí)行的,只有.then中的回調(diào)會(huì)被添加到微任務(wù)隊(duì)列。因此會(huì)連續(xù)輸出promise1和promise2。繼續(xù)執(zhí)行到console.log('global1')輸出global1,到此調(diào)用棧中已經(jīng)為空。此時(shí)微任務(wù)隊(duì)列里有一個(gè)任務(wù).then,宏任務(wù)隊(duì)列里也有一個(gè)任務(wù)setTimout。
microtask必然是在某個(gè)宏任務(wù)執(zhí)行的時(shí)候創(chuàng)建的,而在下一個(gè)宏任務(wù)開始之前,瀏覽器會(huì)對(duì)頁面重新渲染(task >> 渲染 >> 下一個(gè)task(從任務(wù)隊(duì)列中取一個(gè)))。同時(shí),在上一個(gè)宏任務(wù)執(zhí)行完成后,渲染頁面之前,會(huì)執(zhí)行當(dāng)前微任務(wù)隊(duì)列中的所有微任務(wù)。也就是說,在某一個(gè)宏任務(wù)執(zhí)行完后,在重新渲染與開始下一個(gè)宏任務(wù)之前,就會(huì)將在它執(zhí)行期間產(chǎn)生的所有微任務(wù)都執(zhí)行完畢(在渲染前)。因此會(huì)執(zhí)行.then輸出then1,然后進(jìn)行下一輪事件循環(huán),取出任務(wù)隊(duì)列中的setTimeout輸出timeout1。
總結(jié)一下執(zhí)行機(jī)制:
執(zhí)行一個(gè)宏任務(wù)(棧中沒有就從事件隊(duì)列中獲取)
執(zhí)行過程中如果遇到微任務(wù),就將它添加到微任務(wù)的任務(wù)隊(duì)列中
宏任務(wù)執(zhí)行完畢后,立即執(zhí)行當(dāng)前微任務(wù)隊(duì)列中的所有微任務(wù)(依次執(zhí)行)
當(dāng)前宏任務(wù)執(zhí)行完畢,開始檢查渲染,然后GUI線程接管渲染
渲染完畢后,JS引擎線程繼續(xù),開始下一個(gè)宏任務(wù)(從宏任務(wù)隊(duì)列中獲取)
參考文章:
https://www.cnblogs.com/jymz/p/7900439.html
https://juejin.im/post/5a6547d0f265da3e283a1df7#heading-6
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/