JavaScript為什么是單執行緒
JavaScript 最初被設計為瀏覽器腳本語言,主要用途包括對頁面的操作、與瀏覽器的互動、與用戶的互動、頁面邏輯處理等,如果將 JavaScript 設計為多執行緒,那當多個執行緒同時對同一個 DOM 節點進行操作時,執行緒間的同步問題會變得很復雜,
同步任務與異步任務
-
同步任務:在主執行緒上排隊執行的任務,前一個任務完整地執行完成后,后一個任務才會被執行,
-
異步任務:不會阻塞主執行緒,在其任務執行完成之后,會再根據一定的規則去執行相關的回呼,
同步任務與函式呼叫堆疊
在 JavaScript 中,同步任務基本上可以認為是執行 JavaScript 代碼,在上一講內容中,我們提到 JavaScript 在執行程序中每進入一個不同的運行環境時,都會創建一個相應的執行背景關系,那么,當我們執行一段 JavaScript 代碼時,通常會創建多個執行背景關系,
而 JavaScript 解釋器會以堆疊的方式管理這些執行背景關系、以及函式之間的呼叫關系,形成函式呼叫堆疊(call stack)(呼叫堆疊可理解為一個存盤函式呼叫的堆疊結構,遵循 FILO(先進后出)的原則),
我們來看一下 JavaScript 中代碼執行的程序:
-
首先進入全域環境,全域執行背景關系被創建并添加進堆疊中;
-
每呼叫一個函式,該函式執行背景關系會被添加進呼叫堆疊,并開始執行;
-
如果正在呼叫堆疊中執行的 A 函式還呼叫了 B 函式,那么 B 函式也將會被添加進呼叫堆疊;
-
一旦 B 函式被呼叫,便會立即執行;
-
當前函式執行完畢后,JavaScript 解釋器將其清出呼叫堆疊,繼續執行當前執行環境下的剩余的代碼,
由此可見,JavaScript 代碼執行程序中,函式呼叫堆疊堆疊底永遠是全域執行背景關系,堆疊頂永遠是當前執行背景關系,
在不考慮全域執行背景關系時,我們可以理解為剛開始的時候呼叫堆疊是空的,每當有函式被呼叫,相應的執行背景關系都會被添加到呼叫堆疊中,執行完函式中相關代碼后,該執行背景關系又會自動被呼叫堆疊移除,最后呼叫堆疊又回到了空的狀態(同樣不考慮全域執行背景關系),
呼叫堆疊示例
還是來個示例,捋清楚一下
let a = 10;
function test() {
console.log("你好");
};
test();
通過Chrome的除錯工具可以看到,代碼執行程序中產生了兩個執行背景關系,當前堆疊頂為test函式的執行背景關系,順便一說,這里的Scope中有三個:Local,Script,Global,Local代表函式的作用域,而Script則是let關鍵字產生的塊級作用域,Global毋庸置疑就是全域環境

我們也可以直接通過console.trace();API向控制臺輸出一個輸出一個堆疊跟蹤

但是堆疊的容量是有限制的,所以當我們沒有合理呼叫函式的時候,可能會導致爆堆疊例外,此時控制臺便會拋出錯誤:
遞回爆堆疊
function fn(n) {
if (n < 1) {
return
}
console.log(n);
fn(n - 1)
}
fn(100000)

這樣的一個函式呼叫堆疊結構,可以理解為 JavaScript 中同步任務的執行環境,同步任務也可以理解為 JavaScript 代碼片段的執行,
同步任務的執行會阻塞主執行緒,也就是說,一個函式執行的時候不會被搶占,只有在它執行完畢之后,才會去執行任何其他的代碼,這意味著如果我們一個任務執行的時間過長,瀏覽器就無法處理與用戶的互動,例如點擊或滾動,
因此,我們還需要用到異步任務,
異步任務與回呼佇列
異步任務
異步任務包括一些需要等待回應的任務,包括用戶互動、HTTP 請求、定時器等,
setTimeout(() => {
console.log("張三")
}, 0);
console.log("你好");

此處可以看出,同步任務總是優先于異步任務執行,即使定時器定時為0,但其實不是真正的0,
我們知道,I/O 型別的任務會有較長的等待時間,對于這類無法立刻得到結果的事件,可以使用異步任務的方式,這個程序中 JavaScript 執行緒就不用處于等待狀態,CPU 也可以處理其他任務,
異步任務需要提供回呼函式,當異步任務有了運行結果之后,該任務則會被添加到回呼佇列中,主執行緒在適當的時候會從回呼佇列中取出相應的回呼函式并執行,
回呼佇列
這里提到的回呼佇列又是什么呢?
實際上,JavaScript 在運行的時候,除了函式呼叫堆疊之外,還包含了一個待處理的回呼佇列,在回呼佇列中的都是已經有了運行結果的異步任務,每一個異步任務都會關聯著一個回呼函式,
回呼佇列則遵循 FIFO(先進先出)的原則,JavaScript 執行代碼程序中,會進行以下的處理:
-
運行時,會從最先進入佇列的任務開始,處理佇列中的任務;
-
被處理的任務會被移出佇列,該任務的運行結果會作為輸入引數,并呼叫與之關聯的函式,此時會產生一個函式呼叫堆疊;
-
函式會一直處理到呼叫堆疊再次為空,然后 Event Loop 將會處理佇列中的下一個任務,
這里我們提到了 Event Loop,它主要是用來管理單執行緒的 JavaScript 中同步任務和異步任務的執行問題,
示例
此處還是舉一個栗子吧
window.setTimeout除了回呼函式和延遲時間外,還有一個可選的引數,當定時器到期,會作為引數傳遞給回呼函式
setTimeout((name) => {
console.log(name)
console.trace();
}, 0, "張三");
console.log("你好");

可以看出,的的確確是將結果傳遞過去了,并且產生了相應的呼叫堆疊
單執行緒的 JavaScript 是如何管理任務的
事件回圈Event Loop
我們知道,單執行緒的設計會存在阻塞問題,為此 JavaScript 中任務被分為同步和異步任務,那么,同步任務和異步任務之間是按照什么順序來執行的呢?
JavaScript 有一個基于事件回圈的并發模型,稱為事件回圈(Event Loop),它的設計解決了同步任務和異步任務的管理問題,
根據 JavaScript 運行環境的不同,Event Loop 也會被分成瀏覽器的 Event Loop 和 Node.js 中的 Event Loop,
瀏覽器的 Event Loop
在瀏覽器里,每當一個被監聽的事件發生時,事件監聽器系結的相關任務就會被添加進回呼佇列,通過事件產生的任務是異步任務,常見的事件任務包括:
-
用戶互動事件產生的事件任務,比如輸入操作;
-
計時器產生的事件任務,比如
setTimeout; -
異步請求產生的事件任務,比如 HTTP 請求,
JavaScript 的運行程序,可以借用 Philip Roberts 演講《Help, I’m stuck in an event-loop》中經典的一張圖來描述:

如圖,主執行緒運行的時候,會產生堆(heap)和堆疊(stack),其中堆為記憶體、堆疊為函式呼叫堆疊,我們能看到,Event Loop 負責執行代碼、收集和處理事件以及執行佇列中的子任務,具體包括以下程序,
這個程序很重要,理解記憶
-
JavaScript 有一個主執行緒和呼叫堆疊,所有的任務最終都會被放到呼叫堆疊等待主執行緒執行,
-
同步任務會被放在呼叫堆疊中,按照順序等待主執行緒依次執行,
-
主執行緒之外存在一個回呼佇列,回呼佇列中的異步任務最侄訓在主執行緒中以呼叫堆疊的方式運行,
-
同步任務都在主執行緒上執行,堆疊中代碼在執行的時候會呼叫瀏覽器的 API,此時會產生一些異步任務,
-
異步任務會在有了結果(比如被監聽的事件發生時)后,將異步任務以及關聯的回呼函式放入回呼佇列中,
-
呼叫堆疊中任務執行完畢后,此時主執行緒處于空閑狀態,會從回呼佇列中獲取任務進行處理,
上述程序會不斷重復,這就是 JavaScript 的運行機制,稱為事件回圈機制(Event Loop),
Event Loop 的設計會帶來一些問題,比如setTimeout、setInterval的時間精確性,這兩個方法會設定一個計時器,當計時器計時完成,需要執行回呼函式,此時才把回呼函式放入回呼佇列中,
如果當回呼函式放入佇列時,假設佇列中還有大量的回呼函式在等待執行,此時就會造成任務執行時間不精確,
要優化這個問題,可以使用系統時鐘來補償計時器的不準確性,從而提升精確度,舉個例子,如果你的計時器會在回呼時觸發二次計時,可以在每次回呼任務結束的時候,根據最初的系統時間和該任務的執行時間進行差值比較,來修正后續的計時器時間,
Node.js 中的 Event Loop
除了瀏覽器,Node.js 中同樣存在 Event Loop,由于 JavaScript 是單執行緒的,Event Loop 的設計使 Node.js 可以通過將操作轉移到系統內核中,來執行非阻塞 I/O 操作,
Node.js 中的事件回圈執行程序為:
-
當 Node.js 啟動時將初始化事件回圈,處理提供的輸入腳本;
-
提供的輸入腳本可以進行異步 API 呼叫,然后開始處理事件回圈;
-
在事件回圈的每次運行之間,Node.js 會檢查它是否正在等待任何異步 I/O 或計時器,如果沒有,則將其干凈地關閉,
與瀏覽器不一樣,Node.js 中事件回圈分成不同的階段:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ |
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
由于事件回圈階段劃分不一致,Node.js 和瀏覽器在對宏任務和微任務的處理上也不一樣,
| Event Loop | 描述 |
|---|---|
| timers | 此階段由setTimeout()和安排的回呼setInterval()執行 |
| pending callbacks | 執行推遲到下一個回圈迭代的I/O回呼 |
| idle/prepare | 僅在Node.js內部使用 |
| poll | 檢索新的I/O事件,執行與I/O相關的回呼,節點將在此處阻塞 |
| check | setImmediate()在這里回呼 |
| close callbacks | 一些關倍訓呼,例如socket.on('close',...) |
宏任務和微任務
事件回圈中的異步回呼佇列有兩種:宏任務(MacroTask)和微任務(MicroTask)佇列,
什么是宏任務和微任務呢?
- 宏任務:包括 script 全部代碼、
setTimeout、setInterval、setImmediate(Node.js)、requestAnimationFrame(瀏覽器)、I/O 操作、UI 渲染(瀏覽器),這些代碼執行便是宏任務, - 微任務:包括
process.nextTick(Node.js)、Promise、MutationObserver,這些代碼執行便是微任務,
區分的目的
為什么要將異步任務分為宏任務和微任務呢?這是為了避免回呼佇列中等待執行的異步任務(宏任務)過多,導致某些異步任務(微任務)的等待時間過長,在每個宏任務執行完成之后,會先將微任務佇列中的任務執行完畢,再執行下一個宏任務,
因此,前面我們所說的回呼佇列可以理解為宏任務佇列,同時還有另外一個任務佇列為微任務佇列,
宏任務和微任務的執行程序
在瀏覽器的異步回呼佇列中,宏任務和微任務的執行程序如下:
- 0宏任務佇列一次只從佇列中取一個任務執行,執行完后就去執行微任務佇列中的任務,
- 微任務佇列中所有的任務都會被依次取出來執行,直到微任務佇列為空,
- 在執行完所有的微任務之后,執行下一個宏任務之前,瀏覽器會執行 UI 渲染操作、更新界面,
我們能看到,在瀏覽器中每個宏任務執行完成后,會執行微任務佇列中的任務,而在 Node.js 中,事件回圈分為 6 個階段,微任務會在事件回圈的各個階段之間執行,也就是說,每當一個階段執行完畢,就會去執行微任務佇列的任務,
宏任務和微任務的執行順序,常常會被用作面試題,比如下面這道考察Promise、setTimeout、async/await等 API 執行順序的題目:
console.log("script start");
setTimeout(() => {
console.log("setTimeout");
}, 1000);
Promise.resolve()
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
async function errorFunc() {
try {
await Promise.reject("error!!!");
} catch (e) {
console.log("error caught"); // 微1-3
}
console.log("errorFunc");
return Promise.resolve("errorFunc success");
}
errorFunc().then((res) => console.log("errorFunc then res"));
console.log("script end");
分析
首先執行同步代碼
console.log("script start");
setTimeout(() => {
// 8
console.log("setTimeout");
}, 1000);
console.log("script end");
但由于setTimeout是異步任務,所以不會等待它執行完成,
而是開啟一個瀏覽器的定時器執行緒,這個執行緒在渲染器行程中,指定一個回呼函式,到期后再講回呼函式放入異步回呼佇列
執行完以上代碼后,此時的微任務有 Promise.resolve(), errorFunc(),把它們加入異步回呼佇列
此時主執行緒是空閑的,就開始從異步回呼佇列開始提前任務,回呼佇列是先進先出,所以最先取出的是Promise.resolve(),將它調入呼叫堆疊,接著主執行緒就從呼叫堆疊中取出它運行,此時輸出promise1,此時.then()會產生一個微任務,將其加入到異步回呼佇列中
接下來從回呼佇列中調出errorFunc()
async function errorFunc() {
try {
await Promise.reject("error!!!");
} catch (e) {
console.log("error caught");
}
console.log("errorFunc");
return Promise.resolve("errorFunc success");
}
因為 await會阻塞異步操作, 所以這個 await后面的 Promise不會進入回呼佇列排隊, 而是等待完成,捕獲到錯誤,此時輸出error caught,接著是同步代碼,直接輸出errorFunc,然后回傳的promise物件也會被加入到異步回呼佇列中
接著從回呼佇列中取出第一次.then()產生的微任務,執行第二個.then(),輸出promise2
接著執行
errorFunc().then((res) => console.log("errorFunc then res"));
輸出errorFunc then res
最后setTimeout的等待時間到了,將其回呼函式加入回呼佇列,并執行,輸出setTimeout
注意:因為宏任務佇列一次取一個任務執行,執行完成后執行所有的微任務,當所有的微任務執行完成后在執行下一個宏任務,所以就算等待時間為0,也是在最后執行
所以最終結果為
"script start",
"script end",
"promise 1",
"error caught",
"errorFunc",
"promise 2",
"errorFunc then res",
"setTimeout"
補充
Promise.resolve()
.then(function () {
console.log("promise1");
})
以上代碼會回傳一個成功態的promise物件,也就是fulfilled狀態
小結
介紹了 JavaScript 的單執行緒設計,它的設計初衷是為了讓用戶獲得更好的互動體驗,同時,為了避免單執行緒的任務執行程序中發生阻塞,事件回圈(Event Loop)機制便出現了,
在瀏覽器和 Node.js 中,都存在單執行緒的 Event Loop 設計,它們之間的不一致主要表現為 Event Loop 階段劃分以及宏任務和微任務的處理,
或許你會感到疑惑,除了應對面試以外,掌握 JavaScript 的事件回圈、宏任務和微任務相關機制,對我們有什么用處呢?
要知道,瀏覽器中在執行 JavaScript 代碼的時候不會進行頁面渲染,如果一項任務花費的時間太長,瀏覽器將無法執行其他任務(例如處理用戶事件),因此,當存在大量復雜的計算、或導致了死回圈的編程錯誤時,甚至會使頁面終止,
我們可以更合理地利用這些機制來拆分任務,比如考慮將多次觸發的資料變更通過微任務收集起來,再一起進行 UI 的更新和渲染,便可以降低瀏覽器渲染的頻率,提升瀏覽器的性能,給到用戶更好的體驗,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/293373.html
標籤:其他
