一、前言
大家都知道JavaScript是單執行緒的,單執行緒就意味著同一時間只能做一件事,那麼有同學會問,為什麼JavaScript的作者不把它設計成多執行緒的呢,那樣效能不是更好。為了回答這個問題,我們得從JavaScript的用途上來解釋了,由於JavaScript是一門指令碼語言,被用於與使用者進行互動和操作DOM有關,如果是多執行緒的話, 會出現很多複雜的同步問題,讓JavaScript的操作變得難以控制。假如現在有一個執行緒A在dom上新增一個節點a,另一個執行緒又在dom上刪除了節點a,那麼我們該以哪個執行緒為標準呢。所以,對於JavaScript單執行緒這一特點,未來也不會改變。對於一些JavaScript開發者來說,JavaScript的執行機制一直困擾著一些同學,比如非同步請求的執行問題,為什麼js程式碼會造成頁面渲染的阻塞,作用域中的變數提升等等到底做了什麼,看完下面的文章你應該會對這些問題有清楚的瞭解。
二、程式與執行緒
我們經常說,JavaScript是單執行緒的,那到底什麼是執行緒呢。官方的說法是,程式是CPU資源分配的最小單位,而執行緒是CPU排程的最小單位。大家看到這句話可能有些懵。那以瀏覽器為例,當我們在瀏覽器中開啟一個新的標籤頁Tab的時候,CPU會為瀏覽器分配一個新的程式,去渲染我們的網頁,而渲染網頁的工作是通過這個程式中的多個執行緒來配合完成的,包括瀏覽器的渲染執行緒、JS引擎執行緒、http非同步請求執行緒等等。所以,一個程式由多個執行緒組成,每個執行緒是程式的不同執行路線。而程式與程式之間是相對獨立的,如:在瀏覽器開啟兩個標籤頁Tab,就是兩個程式,這兩個標籤頁的執行是互不影響的。
三、瀏覽器核心
說到瀏覽器核心,就不得不提到五大主流瀏覽器IE(IE核心),Chrome瀏覽器(以前是Webkit核心,現在是Blink核心),Safari(Webkit核心),Firefox(Gecko核心),Opera(最開始是Pestro核心,然後是Webkit核心,最後是Blink核心),也正是因為不同瀏覽器的核心不同,導致有些相同html元素在不同瀏覽器上的表現不同,這主要是由於瀏覽器核心中的GUI渲染執行緒不同所導致。
瀏覽器核心是多執行緒的,在核心的控制下,多個執行緒相互配合以保持同步,一個瀏覽器核心通常由以下幾個執行緒組成:
1、GUI渲染執行緒
2、JS引擎執行緒
3、定時器觸發執行緒
4、事件觸發執行緒
5、非同步HTTP請求執行緒
1、GUI渲染執行緒
- 該執行緒主要負責解析HTML,CSS,構建DOM樹,佈局和繪製等
- 當頁面需要重繪或者引起迴流時,將會執行該執行緒
- 注意,該執行緒是與JS引擎執行緒互斥的,當執行JS引擎執行緒時,GUI渲染執行緒將會被掛起(凍結),等到任務佇列為空的時候,主執行緒才會去執行GUI渲染執行緒
2、JS引擎執行緒
- 主要負責處理JavaScript指令碼,執行程式碼
- 也負責執行準備好執行的事件,如定時器計時結束或非同步請求成功並正確返回時,將依次進入任務佇列,等待JS引擎執行緒的執行
- 當然,該執行緒是與GUI渲染引擎執行緒互斥的,當JS引擎執行JavaScript程式碼時間過長時會造成頁面的阻塞,也就是為什麼我們要把script標籤在body的最後面引入
3、定時器觸發執行緒
- 負責執行定時器一類函式的程式,如settimeout、setInterval
- 當主執行緒依次執行程式碼時,遇到計時器,會將計時器交給該執行緒處理,當計時完畢之後,定時器觸發執行緒會將計時完畢後的事件加入到事件佇列的尾部,等待JS引擎執行緒的執行
4、事件觸發執行緒
- 主要負責將準備好執行的事件交給JS引擎執行緒執行,如計時器計時完畢後的事件,AJAX請求成功返回並觸發的回撥函式和使用者觸發點選事件時,事件觸發執行緒會將回撥函式加入到任務佇列的尾部,等待JS引擎執行緒的執行
5、非同步HTTP請求執行緒
- 負責執行非同步請求一類的函式,如ajax,fetch,axios等
- 當主執行緒依次執行程式碼時,遇到非同步請求,會將函式交給改執行緒處理,當監聽狀態碼變更時,如果有回撥函式,會將回撥函式加入到任務佇列的尾部,等待JS引擎執行緒的執行
四、任務佇列
單執行緒就意味著,所有任務的執行都需要排隊,前一個任務結束,後一個任務才能執行,如果一個任務耗時很長,後一個任務不得不一直等待著。JavaScript的作者意識到這個問題,將所有任務劃分為兩種,一種是同步任務,一種是非同步任務。同步任務是指在主執行緒上排隊執行的任務,前一個任務結束,後一個任務才能執行。非同步任務是指不進入主執行緒執行,而進入“任務佇列”的任務,只有當“任務佇列”通知主執行緒可以執行了,該任務才會進入主執行緒。非同步任務分為兩種,巨集任務和微任務(後面會重點介紹)。接下來通過兩個例子來說明同步任務和非同步任務的主要區別:
console.log('a')
while (true) {
console.log('這裡是while')
}
console.log('b')複製程式碼
最後列印的結果是a,因為上述程式碼均屬於同步任務,由上到下依次執行,當主執行緒執行完console.log('a')之後,開始執行while迴圈出現死迴圈,無限執行console.log('這裡是while'),導致記憶體溢位,導致while迴圈後面的任務就無法執行了。
console.log('a')
settimeout(function () {
console.log('settimeout1')
},0)
while (true) {
console.log('這裡是while')
} 複製程式碼
最後的列印結果還是a,因為這段程式碼中同時存在同步任務和非同步任務,非同步任務要等到主執行緒上所有的同步任務執行完成之後才能執行。上述程式碼中的console.log('a')和while迴圈均屬於同步任務,而settimeout屬於非同步任務(在後面的事件迴圈中會介紹哪些事件屬於非同步任務),所以當執行完console.log('a')之後,主執行緒將執行while迴圈,無限執行console.log('這裡是while'),最後導致記憶體溢位,無法執行下面的程式碼了
五、事件迴圈(Event Loop)
下圖為一個完整的事件迴圈的過程:
事件迴圈的執行機制:
- 一開始執行棧空,我們可以把執行棧認為是一個儲存函式呼叫的棧結構,遵循先進後出的原則。微任務佇列空,巨集任務佇列裡有且只有一個script指令碼(整體程式碼)。
-
全域性上下文(script 標籤)被推入執行棧,同步程式碼執行。在執行的過程中,會判斷是同步任務還是非同步任務,通過對一些介面的呼叫,可以產生新的巨集任務與微任務,它們會分別被推入各自的任務佇列裡。同步程式碼執行完了,全域性script指令碼會被移出巨集佇列,這個過程本質上是佇列的巨集任務的執行和出隊的過程。
-
上一步我們出隊的是一個巨集任務,這一步我們處理的是微任務。但需要注意的是:當巨集任務出隊時,任務是一個一個執行的;而微任務出隊時,任務是一隊一隊執行的。因此,我們處理微任務佇列這一步,會逐個執行佇列中的任務並把它出隊,直到佇列被清空。
主執行緒從“任務佇列”讀取事件這個過程,是迴圈不斷的,所以整個這種執行機制就叫做Event Loop(事件迴圈)。每當主執行緒為空時,就會去讀取“事件佇列”,這就是JavaScript的執行機制
六、巨集任務(Macrotask)和微任務(Microtask)
我們在上面提到,非同步任務分為巨集任務和微任務:
- 巨集任務包括:全域性script任務、setTimeout、setInterval、setImmediate、I/O操作、UI rendering
- 微任務包括:new Promise.then()、MutationObserver(HTML5新特性)等
當主執行緒上的所有同步任務執行完之後,是先執行巨集任務還是先執行微任務呢?
- 由於程式碼入口都是全域性任務script,而全域性任務script屬於巨集任務,所以當棧為空或者同步程式碼執行完之後,會先執行微任務佇列裡的任務
- 當微任務佇列裡的所有任務都執行完成之後,主執行緒會讀取巨集任務最前面的任務
- 執行巨集任務的過程中,遇到微任務,依次加入微任務佇列
- 當前主執行緒上的呼叫棧為空時,再次讀取微任務佇列的任務,以此類推
以下通過一個例子來理解非同步任務的執行機制:
Promise.resolve().then(() => {
console.log('Promse1')
setTimeout(function () {
console.log('setTimeout1')
}, 0)
})
setTimeout(function () {
console.log('setTimeout2')
Promise.resolve().then(() => {
console.log('Promise2')
})
}, 0)複製程式碼
最終列印結果依次為Promise1、setTimeout2、Promise2、setTimeout1
- 一開始執行棧所有的同步任務執行完成,主執行緒會去讀取微任務佇列(此時微任務佇列有且只有一個微任務),執行微任務中的任務列印出Promise1,同時也會生成一個巨集任務setTimeout1
- 當執行棧為空時,主執行緒又會去讀取巨集任務佇列最前面的任務。此時,巨集任務佇列依次排列著[setTimeou2, setTimeout1],所以setTimeout2執行列印setTimeout2,同時生成一個微任務Promise2加入微任務佇列
- 當主執行緒執行完巨集任務setTimeout2之後,呼叫棧為空,去讀取微任務佇列,此時,微任務佇列只有一個微任務Promise2,執行微任務中的任務列印Promise2
- 當主執行緒執行完微任務Promise2之後,呼叫棧為空,去讀取巨集任務佇列,此時,巨集任務佇列就只剩下setTimeout1了,執行setTimeout1列印setTimeout1。