我們在接觸到JavaScript語言的時候就經常聽到別人介紹JavaScript 是單線程、異步、非阻塞、解釋型腳本語言。
確切的說,對於開發者的開發過程來說,js確實只有一個線程(由JS引擎維護),這個線程用來負責解釋和執行JavaScript代碼,我們可以稱其為主線程。
代碼在主線程上是按照從上到下順序執行的。但是我們平時的任務處理可能並不會直接獲取到結果,這種情況下如果仍然使用同步方法,例如發起一個ajax請求,大概500ms后受到響應,在這個過程中,后面的任務就會被阻塞,瀏覽器頁面就會阻塞所有用戶交互,呈“卡死”狀態。這種同步的方式對於用戶操作非常不友好,所以大部分耗時的任務在JS中都會通過異步的方式實現。
雖然js引擎只維護一個主線程用來解釋執行JS代碼,但實際上瀏覽器環境中還存在其他的線程,例如處理AJAX,DOM,定時器等,我們可以稱他們為工作線程。同時瀏覽器中還維護了一個消息隊列,主線程會將執行過程中遇到的異步請求發送給這個消息隊列,等到主線程空閑時再來執行消息隊列中的任務。
同步任務的缺點是阻塞,異步任務的缺點是會使代碼執行順序難以判斷。兩者比較一下我們還是更傾向於后者。到目前為止,我們已經涉及到了幾個名詞,單線程、多線程、主線程,js引擎,事件循環,消息隊列等。
1、單線程與多線程
單線程語言:JavaScript 的設計就是為了處理瀏覽器網頁的交互(DOM操作的處理、UI動畫等),決定了它是一門單線程語言。如果有多個線程,它們同時在操作 DOM,那網頁將會一團糟。
console.log('script start') console.log('do something...') setTimeout(() => { console.log('timer over') }, 1000) // 點擊頁面 console.log('click page') console.log('script end') // script start // do something... // click page // script end // timer over
timer over
在 script end
后再打印,也就是說計時器並沒有阻塞后面的代碼。那,發生了什么?其實,JavaScript 單線程指的是瀏覽器中負責解釋和執行 JavaScript 代碼的只有一個線程,即為 JS引擎線程,但是瀏覽器的渲染進程是提供多個線程的,如下:
- JS引擎線程
- 事件觸發線程
- 定時觸發器線程
- 異步http請求線程
- GUI渲染線程
當遇到計時器、DOM事件監聽或者是網絡請求的任務時,JS引擎會將它們直接交給 webapi,也就是瀏覽器提供的相應線程(如定時器線程為setTimeout計時、異步http請求線程處理網絡請求)去處理,而JS引擎線程繼續后面的其他任務,這樣便實現了 異步非阻塞。定時器觸發線程也只是為 setTimeout(..., 1000) 定時而已,時間一到,還會把它對應的回調函數(callback)交給 消息隊列 去維護,JS引擎線程會在適當的時候去消息隊列取出消息並執行。這里,JavaScript 通過 事件循環 event loop 的機制
來解決這個問題
我們所熟悉的引擎是chrome瀏覽器中和node.js中使用的V8引擎。
這個引擎主要由兩個部分組成,Memory Heap 和 Call Stack,即內存堆和調用棧。(只負責取消息,不負責生產消息)
內存堆:進行內存分配。如變量賦值。
調用棧:這是代碼在棧幀中執行的地方。調用棧中順序執行主線程的代碼,當調用棧中為空時,js引擎會去消息隊列取消息,取到后就執行。
JavaScript是單線程的編程語言,意味着它有一個單一的調用棧。因此它只能在同一時間做一件事情。調用棧是一種數據結構,它基本上記錄了我們在程序中的什么位置。如果我們步入一個函數中,我們會把這些數據放在堆棧的頂部。如果我們從一個函數中返回,這些數據將會從棧頂彈出。先進后出,這就是堆棧的用途。調用棧中的每個條目叫做棧幀。當我們在chrome調試窗口中看到拋出的錯誤時,就能夠看到大致的調用順序。

我們經常使用的一些API,並不是js引擎中提供的,例如setTimeout。它們其實是在瀏覽器中提供的,也就是運行時提供的,因此,實際上除了JavaScript引擎以外,還有其他的組件。其中有個組件就是由瀏覽器提供的,叫Web APIs,像DOM,AJAX,setTimeout等等。
然后還有就是非常受歡迎的事件循環和回調隊列,運行時負責給引擎線程發送消息,只負責生產消息,不負責取消息。
主線程在執行過程中遇到了異步任務,就發起函數或者稱為注冊函數,通過event loop線程通知相應的工作線程(如ajax,dom,setTimout等),同時主線程繼續向后執行,不會等待。等到工作線程完成了任務,eventloop線程會將消息添加到消息隊列中,如果此時主線程上調用棧為空就執行消息隊列中排在最前面的消息,依次執行。新的消息進入隊列的時候,會自動排在隊列的尾端。
單線程意味着js任務需要排隊,如果前一個任務出現大量的耗時操作,后面的任務得不到執行,任務的積累會導致頁面的“假死”。這也是js編程一直在強調需要回避的“坑”。
主線程會循環上述步驟,事件循環就是主線程重復從消息隊列中取消息、執行的過程。
需要注意的是 GUI渲染線程與JS引擎是互斥的,當JS引擎執行時GUI線程會被掛起,GUI更新會被保存在一個隊列中等到JS引擎空閑時立即被執行。
因此頁面渲染都是在js引擎主線程調用棧為空時進行的。
其實 事件循環 機制和 消息隊列 的維護是由事件觸發線程控制的。事件觸發線程 同樣是瀏覽器渲染引擎提供的,它會維護一個 消息隊列。
JS引擎線程遇到異步(DOM事件監聽、網絡請求、setTimeout計時器等...),會交給相應的線程單獨去維護異步任務,等待某個時機(計時器結束、網絡請求成功、用戶點擊DOM),然后由 事件觸發線程 將異步對應的 回調函數 加入到消息隊列中,消息隊列中的回調函數等待被執行。
同時,JS引擎線程會維護一個 執行棧,同步代碼會依次加入執行棧然后執行,結束會退出執行棧。如果執行棧里的任務執行完成,即執行棧為空的時候(即JS引擎線程空閑),事件觸發線程才會從消息隊列取出一個任務(即異步的回調函數)放入執行棧中執行。
5、執行順序
了解了事件循環和消息隊列之后,接下來就是弄清楚當同步任務和異步任務都存在時,代碼執行的順序究竟是怎么樣的。
console.log("a"); setTimeout(function(){ console.log("b")},0 ); console.log("c");
大部分人都知道執行順序是a,c,b。setTimeout在主線程執行時被添加到了消息隊列中,等待主線程調用棧為空時,再從消息隊列中取出執行。因此setTimeout中的延時時間並非確切的執行時間,實際上應該理解為添加到消息隊列中的延遲時間。以上述代碼為例,如果console.log("c")處是一個計算量很大的任務,或者消息隊列中已經存在了若干個等待處理的消息。setTimeout都將延遲都將大於設置的延遲時間。
以上的內容在ES6之前就基本覆蓋了執行順序的問題,但是在ES6引入了promise后,產生了一個新的名詞”微任務(microtask)“。微任務的執行順序與之前我們所說的任務(我們可以稱之為”宏任務“)是不同的。
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')
- 一個線程中,事件循環是唯一的,但是任務隊列可以擁有多個。
- 任務隊列又分為macro-task(宏任務)與micro-task(微任務),在最新標准中,它們被分別稱為task與jobs。
- macro-task大概包括:script(整體代碼), setTimeout, setInterval, setImmediate, I/O, UI rendering。
- micro-task大概包括: process.nextTick, Promise, Object.observe(已廢棄), MutationObserver(H5新特性)
- setTimeout/Promise等我們稱之為任務源。而進入任務隊列的是他們指定的具體執行任務。
- 來自不同任務源的任務會進入到不同的任務隊列。其中setTimeout與setInterval是同源的。
- 事件循環的順序,決定了JavaScript代碼的執行順序。它從script(整體代碼)開始第一次循環。之后全局上下文進入函數調用棧。直到調用棧清空(只剩全局),然后執行所有的micro-task。當所有可執行的micro-task執行完畢之后。循環再次從macro-task開始,找到其中一個任務隊列執行完畢,然后再執行所有的micro-task,這樣一直循環下去。
- 其中每一個任務的執行,無論是macro-task還是micro-task,都是借助函數調用棧來完成。
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');
執行結果為:promise1 - promise2 - global1 - then1 - timeout1,分析一下代碼,首先程序開始執行,遇到setTimeout時將它添加到消息隊列,等待后續處理,遇到Promise時會創建微任務(.then()里面的回調),注意此時new promise構造函數中的代碼還是同步執行的,只有.then中的回調會被添加到微任務隊列。因此會連續輸出promise1和promise2。繼續執行到console.log('global1')輸出global1,到此調用棧中已經為空。
此時微任務隊列里有一個任務.then,宏任務隊列里也有一個任務setTimout。
總結一下執行機制:
執行一個宏任務(棧中沒有就從事件隊列中獲取)
執行過程中如果遇到微任務,就將它添加到微任務的任務隊列中
宏任務執行完畢后,立即執行當前微任務隊列中的所有微任務(依次執行)
當前宏任務執行完畢,開始檢查渲染,然后GUI線程接管渲染
渲染完畢后,JS引擎線程繼續,開始下一個宏任務(從宏任務隊列中獲取)