前言
先提出一個問題JavaScript 既然是單執行緒,那為什麼瀏覽器或 Node.js 可以執行非同步操作呢?
下面簡短解釋一下:
1、JavaScript 是單執行緒的,只有一個主執行緒;
2、函式內的程式碼是從上到下依次執行,遇到被呼叫的函式先進入被呼叫的函式執行,待完成後繼續執行;(這個機制主要是通過函式呼叫棧實現的)
3、遇到非同步事件,JavaScript 的宿主環境會另開一個執行緒,主執行緒繼續執行,待結果返回後,執行回撥函式。
上述的宿主環境,則是指瀏覽器或 Node.js 環境,在瀏覽器中一般會提供額外的執行緒,而在 Node.js 中,則是藉助 libuv 來實現不同作業系統上的多執行緒。並且在宿主環境中,這個非同步執行緒又分為 微任務 和 巨集任務 。
以上內容不明白沒關係,接著往下看。
JavaScript 單執行緒歷史
我們知道,JavaScript 剛出來的時候是作為瀏覽器內的一種指令碼語法,負責操作 DOM,與使用者進行互動等,如果是多執行緒的話,執行順序無法預知,而且操作以哪個執行緒為準也是個難題。所以為了避免這種局面,JavaScript 便採用單執行緒設計,這已經成了這門語言的核心特徵,將來也不會改變。
在 HTML5 時代,瀏覽器為了充分發揮 CPU 效能優勢,允許 JavaScript 建立多個執行緒,但是即使能額外建立執行緒,這些子執行緒仍然是受到主執行緒控制,而且不得操作 DOM,類似於開闢一個執行緒來運算複雜性任務,運算好了通知主執行緒運算完畢,結果給你,這類似非同步的處理方式,但並沒有改變 JavaScript 單執行緒的本質。
函式呼叫棧
JavaScript只有一個主執行緒,所以也只有一個函式呼叫棧,學過資料結構的同學應該都知道,棧是一種後進先出(LIFO)的資料結構。
在JavaScript中,每當開始執行一個函式時,就會建立一個函式的執行上下文,我們可以籠統的將JavaScript中的執行上下文分為全域性上下文和函式執行上下文。可以通過例子理解,如下程式碼:
function a(){
var hello = "Hello";
var world = "world";
function b(){
console.log(hello);
}
function c(){
console.log(world);
}
b();
c();
}
a();
複製程式碼
函式的出入棧順序如下圖:
如果對函式呼叫棧還不是很瞭解,請參考我的另外一篇文章:讀《JavaScript核心技術解密》筆記
從函式呼叫棧的執行特點中可以知道,棧內後一個函式必須在前一個函式執行完成之後才可以開始執行,如果某一個函式任務需要很長時間才能完成的話,例如網路請求,I/O操作等,後面的函式任務就會一直在等待,那麼整個系統的效率就會特別低。於是大家意識到,這些耗時久的任務完全可以先掛起,等主執行緒上的其他任務執行完之後,再回頭將這些掛起的任務繼續執行,所以有了任務佇列的概念。
任務佇列
我們可以簡單的理解為一個函式就是一個任務,基本上可以將任務分為同步任務和非同步任務。
同步任務就是指在主執行緒上排隊執行的任務,只有當前一個任務完成之後後一個才會執行;非同步任務則是不進入主執行緒,而是進入任務對列的任務,只有佇列任務通知了主執行緒說某個非同步任務可以執行了,該任務才會進入主執行緒執行。
所以,我們思考得知,當執行過程碰到setTimeout
等非同步操作時,會將其交給瀏覽器或 Node.js 的其他執行緒進行處理,當達到setTimeout
指定延遲執行的時間後,才會將回撥函式放入任務佇列中。
我們可以看一個例子:
function fun() {
function callback() {
console.log('執行回撥');
}
setTimeout(callback, 2000);
console.log('準備');
}
fun();
複製程式碼
在呼叫棧-非同步模組-任務佇列模型中,上述程式碼的執行過程如下:
第一步,fun()
函式入棧(我們省略了該程式碼全域性執行上下文入棧步驟)
第二步,因為fun()
函式內執行了setTimeout()
,所以setTimeout()
入棧,如圖:
第三步,由於setTimeout()
是非同步操作,不屬於JavaScript主執行緒模組內容,所以setTimeout()
進入非同步執行模組執行計時,如圖
第四步,fun()
函式內的console.log('準備')
函式進入函式呼叫棧並執行,所以控制檯輸出準備
第五步,由於fun()
函式內部沒有其他需要繼續執行的函式,所以fun()
出棧,隨後全域性上下文也沒有需要執行的程式碼,所以全域性上下文也出棧,如圖:
第六步,假如此時剛好setTimeout()
的兩秒計時結束,那麼非同步模組就會將setTimeout()
的回撥函式放到任務佇列裡面,因為此時函式呼叫棧已經空閒,所以任務佇列依次將任務函式入棧,如圖:
第七步,進入callback()
回撥中,將console.log('執行回撥')
入棧執行,所以在控制檯輸出執行回撥
,如圖:
第八步,callback()
再出棧,整段程式碼執行結束。
上面所說的步數並不是說一定是有8步,目的是讓大家有個順序瞭解接下來每一步會進行什麼內容,理解JavaScript的函式呼叫執行,非同步模組和任務佇列之間的關係是最重要的。
那麼,這段程式碼整體的過程就是如圖所示,通過這種建立底層模型的方式可以加深大家的理解。趁熱打鐵,請閱讀如下程式碼,想一想在“呼叫棧-非同步模組-任務佇列”模型中,是怎麼樣的一個流程:
setTimeout(() => {
console.log('1');
}, 32);
setTimeout(() => {
console.log('2');
}, 30);
function fun() {
console.log('3');
}
for (var i = 0; i < 100000000; i++) {
i === 99999999 && console.log('4');
}
fun();
複製程式碼
程式碼最終輸出的內容順序是4 3 2 1
,請思考執行過程。
注意一點,就是兩個
setTimeout()
都會進入非同步模組,這裡主要進入了非同步模組,這兩個函式其實是同時執行的,延遲30ms的先完成,先進入佇列(先進先出),延遲32ms的後完成後進入佇列,所以最後的順序是... 2 1
,即2在1前面。
上述講到非同步模組,在瀏覽器中,例如 Chrome 瀏覽器,由 webcore 模組擔任開啟其他執行緒角色,其提供了DOM Binding
、network
、timer
子模組,這些都可以理解為非同步模組,分別對應DOM處理、Ajax、時間處理函式等API。
而在Node.js中,前言裡也說到了,是通過libuv來實現在不同作業系統上統一的執行緒排程API。
巨集任務與微任務
前言裡說到任務由巨集任務和微任務構成,也被稱為task
和job
,我們看一張網上的事件迴圈圖:
其中,Task Queue
是指巨集任務,Microtask Queue
則是微任務。
巨集任務大概包括主執行緒程式碼
、setTimeout
、setInterval
、setImmediate(僅Node.js)
、requestAnimationFrame(僅瀏覽器)
、I/O
、UI Rendering
;
微任務大概包括Promise.then/catch/finally
、process.nextTick(僅Node.js)
、MutationObserver(僅瀏覽器)
、Object.observe(已廢棄)
。
事件迴圈中,當主執行緒的所有任務(函式)執行結束之後,然後順序執行微任務佇列中的所有微任務,當所有的微任務執行完成後,再執行巨集任務佇列中的下一個巨集任務,當這個巨集任務執行完畢,再看微任務佇列是否存在微任務,如果存在,則順序執行所有微任務,一直迴圈直至所有的任務執行完畢。
注意,瀏覽器在每一次巨集任務結束的時候都會進行一次渲染
任務佇列的事件迴圈可以用下圖表示:
分析一段程式碼:
<script>
setTimeout(() => {
console.log(4)
}, 0);
new Promise((resolve) => {
console.log(1);
for (var i = 0; i < 10000000; i++) {
i === 9999999 && resolve();
}
console.log(2);
}).then(() => {
console.log(5);
});
console.log(3);
</script>
<script>
console.log(6)
new Promise((resolve) => {
resolve()
}).then(() => {
console.log(7);
});
</script>
複製程式碼
程式碼中輸出順序為:1 2 3 5 6 7 4
;簡單分析下:
開始,程式往下走,遇到setTimeout,是非同步任務,放到非同步模組執行,執行結束的回撥進入巨集任務佇列先暫存著,如圖:
繼續往下走,碰到Promise物件。由於是new操作,其建構函式是一個匿名函式,所以會立即執行Promise建構函式的實參函式任務,所以console.log(1)
被執行,控制檯輸出1,接著進入迴圈,直到執行resolve()
,執行完該函式之後,會附帶呼叫then
方法,因為then
屬於非同步方法,所以then
內部的回撥console.log(5)
被送入微任務佇列,接著執行console.log(2)
,控制檯輸出2,此時狀態如圖:
程式往下走,緊接著執行console.log(3)
,所以控制檯輸出3。到現在控制檯輸出順序為1 2 3。
到這裡,第一段指令碼里已經結束了,所以此時在這段<script>
指令碼中函式呼叫棧已空,按照之前的事件迴圈邏輯,微任務佇列裡的任務會依次放到函式呼叫棧裡面執行,所以接下來控制檯就輸出5,如圖:
當微任務佇列中的所有任務執行完畢(這裡只有一個微任務),函式呼叫棧為空會先看程式是否可以繼續,由於下一個<script>
指令碼存在,所以事件迴圈被打斷,繼續下一個指令碼內容,所以先執行console.log(6)
,控制檯輸出6,此時已輸出順序為1 2 3 5 6
,如圖:
接下來,又將碰到一個Promise,Promise內建構函式的回撥引數函式會立即執行,內部執行到resolve()
則會呼叫其then()
,由於then()
是非同步方法,所以進入非同步執行模組執行之後將console.log(7)
放入微任務佇列,如圖:
由於在這個<script>
指令碼里沒有其餘程式碼,所以接下來執行所有的微任務,則繼續執行console.log(7)
,隨後根據事件迴圈原理執行下一個巨集任務console.log(4)
,到此所有的程式碼執行完畢,所以最終的順序是1 2 3 5 6 7 4
。
可以嘗試分析下下面這個題:
setImmediate(() => {
console.log(1);
},0);
setTimeout(() => {
console.log(2);
},0);
new Promise((resolve) => {
console.log(3);
resolve();
console.log(4);
}).then(() => {
console.log(5);
});
console.log(6);
process.nextTick(()=> {
console.log(7);
});
console.log(8);
複製程式碼
剩下的疑問
1、非同步執行模組內究竟是怎麼執行的呢? 筆者個人覺得裡面的執行是每一個非同步函式都分配一個執行緒去執行,可以說是將非同步函式跟主執行緒併發執行的,當非同步函式執行結束之後,再將非同步裡面的回撥任務根據巨集任務與微任務的劃分劃入不同的任務佇列,等待事件迴圈。
2、如果整體script屬於巨集任務,那麼主執行緒的函式呼叫棧算不算入巨集任務裡面?如果算入,那如下程式碼是否順序應該是1 2 3 5 6 7 8 4
?結果肯定不是,正確順序是1 2 3 5 6 8 7 4
;所以筆者覺得在微任務console.log(5)執行結束,即第一次微任務佇列被清空,函式呼叫棧會先判斷程式是否還有script程式碼可以載入,若可以則截斷本次事件迴圈,再次進入順序執行狀態,這樣似乎說的通一些。
<script>
setTimeout(() => {
console.log(4)
}, 0);
new Promise((resolve) => {
console.log(1);
for (var i = 0; i < 10000000; i++) {
i === 9999999 && resolve();
}
console.log(2);
}).then(() => {
console.log(5);
});
console.log(3);
</script>
<script>
console.log(6)
new Promise((resolve) => {
resolve()
}).then(() => {
console.log(7);
});
console.log(8);
</script>
複製程式碼
參考文章:
樑音.JavaScript 事件迴圈及非同步原理(完全指北); 程式碼題目取自該文章,其文章後面最後還有一個進階題,有興趣夥伴可以研究下。