一,關於線程
javascript從誕生之日起就是一門單線程的非阻塞的腳本語言。這是由其最初的用途來決定的:與瀏覽器交互。
單線程意味着,javascript代碼在執行的任何時候,都只有一個主線程來處理所有的任務。
而非阻塞則是當代碼需要進行一項異步任務(無法立刻返回結果,需要花一定時間才能返回的任務,如I/O事件)的時候,主線程會掛起(pending)這個任務,然后在異步任務返回結果的時候再根據一定規則去執行相應的回調。
JavaScript 的單線程,與它的用途有關。作為瀏覽器腳本語言,JavaScript 的主要用途是與用戶互動,以及操作 DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定 JavaScript 同時有兩個線程,一個線程在某個 DOM 節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?
所以,為了避免復雜性,從一誕生,JavaScript 就是單線程,這已經成了這門語言的核心特征,將來也不會改變。
為了利用多核 CPU 的計算能力,HTML5 提出 Web Worker 標准。允許 JavaScript 腳本創建多個線程,但是子線程完全受主線程控制,且不得操作 DOM。所以,這個新標准並沒有改變 JavaScript 單線程的本質。
二,瀏覽器環境下的事件循環機制
1,任務隊列
js引擎遇到一個異步事件后並不會一直等待其返回結果,而是會將這個事件掛起,繼續執行執行棧中的其他任務。當一個異步事件返回結果后,js會將這個事件加入與當前執行棧不同的另一個隊列,我們稱之為任務隊列。被放入任務隊列不會立刻執行其回調,而是等待當前執行棧中的所有任務都執行完畢, 主線程處於閑置狀態時,主線程會去查找事件隊列是否有任務。如果有,那么主線程會從中取出排在第一位的事件,並把這個事件對應的回調放入執行棧中,然后執行其中的同步代碼...,如此反復,這樣就形成了一個無限的循環。這就是這個過程被稱為“事件循環(Event Loop)”的原因。
任務隊列本質:
- 所有同步任務都在主線程上執行,形成一個**執行棧**(execution context stack)。
- 主線程之外,還存在一個”**任務隊列**”(task queue)。只要異步任務有了運行結果,就在”任務隊列”之中放置一個事件。
- 一旦”執行棧”中的所有同步任務執行完畢,系統就會讀取”任務隊列”,看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
- 主線程不斷重復上面的第三步。
下圖中的stack表示我們所說的執行棧,web apis則是代表一些異步事件,而callback queue即任務隊列
2,宏任務與微任務
以上的事件循環過程是一個宏觀的表述,實際上因為異步任務之間並不相同,因此他們的執行優先級也有區別。不同的異步任務被分為兩類:微任務(micro task)和宏任務(macro task)。
## 宏任務
(macro)task(又稱之為宏任務),可以理解是每次執行棧執行的代碼就是一個宏任務(包括每次從事件隊列中獲取一個事件回調並放到執行棧中執行)。
瀏覽器為了能夠使得 JS 內部(macro)task 與 DOM 任務能夠有序的執行,**會在一個(macro)task 執行結束后,在下一個(macro)task 執行開始前,對頁面進行重新渲染**,流程如下:
(macro)task->渲染->(macro)task->...
(macro)task 主要包含:
script(整體代碼)
setTimeout
setInterval
I/O
UI 交互事件
postMessage
MessageChannel
setImmediate(Node.js 環境)
## 微任務
microtask(又稱為微任務),**可以理解是在當前 task 執行結束后立即執行的任務**。也就是說,在當前 task 任務后,下一個 task 之前,在渲染之前。
所以它的響應速度相比 setTimeout(setTimeout 是 task)會更快,因為無需等渲染。也就是說,在某一個 macrotask 執行完后,就會將在它執行期間產生的所有 microtask 都執行完畢(在渲染前)。
microtask 主要包含:
Promise.then
MutaionObserver
process.nextTick(Node.js 環境)
## 運行機制
在事件循環中,每進行一次循環操作稱為 tick,每一次 tick 的任務是比較復雜的,但關鍵步驟如下:
- 執行一個宏任務(棧中沒有就從事件隊列中獲取)
- 執行過程中如果遇到微任務,就將它添加到微任務的任務隊列中
- 宏任務執行完畢后,立即執行當前微任務隊列中的所有微任務(依次執行)
- 當前宏任務執行完畢,開始檢查渲染,然后 GUI 線程接管渲染
- 渲染完畢后,JS 線程繼續接管,開始下一個宏任務(從事件隊列中獲取)
## await 做了什么
從字面意思上看 await 就是等待,await 等待的是一個表達式,這個表達式的返回值可以是一個 promise 對象也可以是其他值。
很多人以為 await 會一直等待之后的表達式執行完之后才會繼續執行后面的代碼,實際上 await 是一個讓出線程的標志。await 后面的表達式會先執行一遍,將 await 后面的代碼加入到 microtask 中,然后就會跳出整個 async 函數來執行后面的代碼。
由於因為 async await 本身就是 promise+generator 的語法糖。所以 await 后面的代碼是 microtask。
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
等價於
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(() ={
console.log('async1 end');
})
}
三,node環境下的事件循環機制
在node中,事件循環表現出的狀態與瀏覽器中大致相同。不同的是node中有一套自己的模型。node中事件循環的實現是依靠的libuv引擎。我們知道node選擇chrome v8引擎作為js解釋器,v8引擎將js代碼分析后去調用對應的node api,而這些api最后則由libuv引擎驅動,執行對應的任務,並把不同的事件放在不同的隊列中等待主線程執行。 因此實際上node中的事件循環存在於libuv引擎中。
下面是一個libuv引擎中的事件循環的模型:
從上面這個模型中,我們可以大致分析出node中的事件循環的順序:
外部輸入數據-->輪詢階段(poll)-->檢查階段(check)-->關閉事件回調階段(close callback)-->定時器檢測階段(timer)-->I/O事件回調階段(I/O callbacks)-->閑置階段(idle, prepare)-->輪詢階段...
以上各階段的名稱是根據我個人理解的翻譯,為了避免錯誤和歧義,下面解釋的時候會用英文來表示這些階段。
這些階段大致的功能如下:
- timers: 這個階段執行定時器隊列中的回調如
setTimeout()
和setInterval()
。 - I/O callbacks: 這個階段執行幾乎所有的回調。但是不包括close事件,定時器和
setImmediate()
的回調。 - idle, prepare: 這個階段僅在內部使用,可以不必理會。
- poll: 等待新的I/O事件,node在一些特殊情況下會阻塞在這里。
- check:
setImmediate()
的回調會在這個階段執行。 - close callbacks: 例如
socket.on('close', ...)
這種close事件的回調。
下面我們來按照代碼第一次進入libuv引擎后的順序來詳細解說這些階段:
poll階段
當個v8引擎將js代碼解析后傳入libuv引擎后,循環首先進入poll階段。poll階段的執行邏輯如下: 先查看poll queue中是否有事件,有任務就按先進先出的順序依次執行回調。 當queue為空時,會檢查是否有setImmediate()的callback,如果有就進入check階段執行這些callback。但同時也會檢查是否有到期的timer,如果有,就把這些到期的timer的callback按照調用順序放到timer queue中,之后循環會進入timer階段執行queue中的 callback。 這兩者的順序是不固定的,收到代碼運行的環境的影響。如果兩者的queue都是空的,那么loop會在poll階段停留,直到有一個i/o事件返回,循環會進入i/o callback階段並立即執行這個事件的callback。
值得注意的是,poll階段在執行poll queue中的回調時實際上不會無限的執行下去。有兩種情況poll階段會終止執行poll queue中的下一個回調:1.所有回調執行完畢。2.執行數超過了node的限制。
check階段
check階段專門用來執行setImmediate()
方法的回調,當poll階段進入空閑狀態,並且setImmediate queue中有callback時,事件循環進入這個階段。
close階段
當一個socket連接或者一個handle被突然關閉時(例如調用了socket.destroy()
方法),close事件會被發送到這個階段執行回調。否則事件會用process.nextTick()
方法發送出去。
timer階段
這個階段以先進先出的方式執行所有到期的timer加入timer隊列里的callback,一個timer callback指得是一個通過setTimeout或者setInterval函數設置的回調函數。
I/O callback階段
如上文所言,這個階段主要執行大部分I/O事件的回調,包括一些為操作系統執行的回調。例如一個TCP連接生錯誤時,系統需要執行回調來獲得這個錯誤的報告。
四,setTimeOut、setImmediate、process.nextTick()的比較
## setTimeout()
將事件插入到了事件隊列,必須等到當前代碼(執行棧)執行完,主線程才會去執行它指定的回調函數。
當主線程時間執行過長,無法保證回調會在事件指定的時間執行。
瀏覽器端每次 setTimeout 會有 4ms 的延遲,當連續執行多個 setTimeout,有可能會阻塞進程,造成性能問題。
## setImmediate()
事件插入到事件隊列尾部,主線程和事件隊列的函數執行完成之后立即執行。和 setTimeout(fn,0)的效果差不多。
服務端 node 提供的方法。瀏覽器端最新的 api 也有類似實現:window.setImmediate,但支持的瀏覽器很少。
## process.nextTick()
插入到事件隊列尾部,但在下次事件隊列之前會執行。也就是說,它指定的任務總是發生在所有異步任務之前,當前主線程的末尾。
大致流程:當前”執行棧”的尾部–>下一次 Event Loop(主線程讀取”任務隊列”)之前–>觸發 process 指定的回調函數。
服務器端 node 提供的辦法。用此方法可以用於處於異步延遲的問題。
可以理解為:此次不行,預約下次優先執行。
五,常見考題
考題一:
//請寫出輸出內容 async function async1() { console.log("async1 start"); await async2(); console.log("async1 end"); } async function async2() { console.log("async2"); } console.log("script start"); // 1 setTimeout(function () { console.log("setTimeout"); }, 0); async1(); new Promise(function (resolve) { console.log("promise1"); resolve(); }).then(function () { console.log("promise2"); }); console.log("script end");
答案:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
考題二:將 async2 中的函數也變成了 Promise 函數
async function async1() { console.log("async1 start"); await async2(); console.log("async1 end"); } async function async2() { //async2做出如下更改: new Promise(function (resolve) { console.log("promise1"); resolve(); }).then(function () { console.log("promise2"); }); } console.log("script start"); setTimeout(function () { console.log("setTimeout"); }, 0); async1(); new Promise(function (resolve) { console.log("promise3"); resolve(); }).then(function () { console.log("promise4"); }); console.log("script end");
答案:
script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout
考題三:將 async1 中 await 后面的代碼和 async2 的代碼都改為異步的
async function async1() { console.log("async1 start"); await async2(); //更改如下: setTimeout(function () { console.log("setTimeout1"); }, 0); } async function async2() { //更改如下: setTimeout(function () { console.log("setTimeout2"); }, 0); } console.log("script start"); setTimeout(function () { console.log("setTimeout3"); }, 0); async1(); new Promise(function (resolve) { console.log("promise1"); resolve(); }).then(function () { console.log("promise2"); }); console.log("script end");
答案:
script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1
考題四:
async function a1 () { console.log('a1 start') await a2() console.log('a1 end') } async function a2 () { console.log('a2') } console.log('script start') setTimeout(() =>{ console.log('setTimeout') }, 0) Promise.resolve().then(() =>{ console.log('promise1') }) a1() let promise2 = new Promise((resolve) =>{ resolve('promise2.then') console.log('promise2') }) promise2.then((res) =>{ console.log(res) Promise.resolve().then(() =>{ console.log('promise3') }) }) console.log('script end')
答案:
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
以上題目,你都做對了嗎,只要理解了事件循環,宏任務和微任務,這些題目萬變不離其宗~