(本文從掘金小冊整理)
首先介紹一下幾個概念
進程與線程
相信大家經常會聽到 JS 是單線程執行的,但是你是否疑惑過什么是線程?
講到線程,那么肯定也得說一下進程。本質上來說,兩個名詞都是 CPU 工作時間片的一個描述。
進程描述了 CPU 在運行指令及加載和保存上下文所需的時間,放在應用上來說就代表了一個程序。線程是進程中的更小單位,描述了執行一段指令所需的時間。
把這些概念拿到瀏覽器中來說,當你打開一個 Tab 頁時,其實就是創建了一個進程,一個進程中可以有多個線程,比如渲染線程、JS 引擎線程、HTTP 請求線程等等。當你發起一個請求時,其實就是創建了一個線程,當請求結束后,該線程可能就會被銷毀。
上文說到了 JS 引擎線程和渲染線程,大家應該都知道,在 JS 運行的時候可能會阻止 UI 渲染,這說明了兩個線程是互斥的。這其中的原因是因為 JS 可以修改 DOM,如果在 JS 執行的時候 UI 線程還在工作,就可能導致不能安全的渲染 UI。這其實也是一個單線程的好處,得益於 JS 是單線程運行的,可以達到節省內存,節約上下文切換時間,沒有鎖的問題的好處。當然前面兩點在服務端中更容易體現,對於鎖的問題,形象的來說就是當我讀取一個數字 15 的時候,同時有兩個操作對數字進行了加減,這時候結果就出現了錯誤。解決這個問題也不難,只需要在讀取的時候加鎖,直到讀取完畢之前都不能進行寫入操作。
執行棧
以把執行棧認為是一個存儲函數調用的棧結構,遵循先進后出的原則。

當開始執行 JS 代碼時,首先會執行一個 main 函數,然后執行我們的代碼。根據先進后出的原則,后執行的函數會先彈出棧,在圖中我們也可以發現,foo 函數后執行,當執行完畢后就從棧中彈出了。
平時在開發中,大家也可以在報錯中找到執行棧的痕跡
function foo() { throw new Error('error') } function bar() { foo() } bar()

瀏覽器中的 Event Loop
上面我們講到了什么是執行棧,大家也知道了當我們執行 JS 代碼的時候其實就是往執行棧中放入函數,那么遇到異步代碼的時候該怎么辦?其實當遇到異步的代碼時,會被掛起並在需要執行的時候加入到 Task(有多種 Task) 隊列中。一旦執行棧為空,Event Loop 就會從 Task 隊列中拿出需要執行的代碼並放入執行棧中執行,所以本質上來說 JS 中的異步還是同步行為。

不同的任務源會被分配到不同的 Task 隊列中,任務源可以分為 微任務(microtask) 和 宏任務(macrotask)。在 ES6 規范中,microtask 稱為 jobs,macrotask 稱為 task。下面來看以下代碼的執行順序:
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
注意:新的瀏覽器中不是如上打印的,因為 await 變快了,具體內容可以往下看
首先先來解釋下上述代碼的 async 和 await 的執行順序。當我們調用 async1 函數時,會馬上輸出 async2 end,並且函數返回一個 Promise,接下來在遇到 await的時候會就讓出線程開始執行 async1 外的代碼,所以我們完全可以把 await 看成是讓出線程的標志。
然后當同步代碼全部執行完畢以后,就會去執行所有的異步代碼,那么又會回到 await 的位置執行返回的 Promise 的 resolve 函數,這又會把 resolve 丟到微任務隊列中,接下來去執行 then 中的回調,當兩個 then 中的回調全部執行完畢以后,又會回到 await 的位置處理返回值,這時候你可以看成是 Promise.resolve(返回值).then(),然后 await 后的代碼全部被包裹進了 then 的回調中,所以 console.log('async1 end') 會優先執行於 setTimeout。
如果你覺得上面這段解釋還是有點繞,那么我把 async 的這兩個函數改造成你一定能理解的代碼
new Promise((resolve, reject) => { console.log('async2 end') // Promise.resolve() 將代碼插入微任務隊列尾部 // resolve 再次插入微任務隊列尾部 resolve(Promise.resolve()) }).then(() => { console.log('async1 end') })
也就是說,如果 await 后面跟着 Promise 的話,async1 end 需要等待三個 tick 才能執行到。那么其實這個性能相對來說還是略慢的,所以 V8 團隊借鑒了 Node 8 中的一個 Bug,在引擎底層將三次 tick 減少到了二次 tick。但是這種做法其實是違法了規范的,當然規范也是可以更改的,這是 V8 團隊的一個 PR,目前已被同意這種做法。
所以 Event Loop 執行順序如下所示:
- 首先執行同步代碼,這屬於宏任務
- 當執行完所有同步代碼后,執行棧為空,查詢是否有異步代碼需要執行
- 執行所有微任務
- 當執行完所有微任務后,如有必要會渲染頁面
- 然后開始下一輪 Event Loop,執行宏任務中的異步代碼,也就是
setTimeout中的回調函數
所以以上代碼雖然 setTimeout 寫在 Promise 之前,但是因為 Promise 屬於微任務而 setTimeout 屬於宏任務,所以會有以上的打印。
微任務包括 process.nextTick ,promise ,MutationObserver。
宏任務包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。
這里很多人會有個誤區,認為微任務快於宏任務,其實是錯誤的。因為宏任務中包括了 script ,瀏覽器會先執行一個宏任務,接下來有異步代碼的話才會先執行微任務。
Node 中的 Event Loop
涉及面試題:Node 中的 Event Loop 和瀏覽器中的有什么區別?process.nexttick 執行順序?
Node 中的 Event Loop 和瀏覽器中的是完全不相同的東西。
Node 的 Event Loop 分為 6 個階段,它們會按照順序反復運行。每當進入某一個階段的時候,都會從對應的回調隊列中取出函數去執行。當隊列為空或者執行的回調函數數量到達系統設定的閾值,就會進入下一階段。
timer
timers 階段會執行 setTimeout 和 setInterval 回調,並且是由 poll 階段控制的。
同樣,在 Node 中定時器指定的時間也不是准確時間,只能是盡快執行。
I/O
I/O 階段會處理一些上一輪循環中的少數未執行的 I/O 回調
idle, prepare
idle, prepare 階段內部實現,這里就忽略不講了。
poll
poll 是一個至關重要的階段,這一階段中,系統會做兩件事情
- 回到 timer 階段執行回調
- 執行 I/O 回調
並且在進入該階段時如果沒有設定了 timer 的話,會發生以下兩件事情
- 如果 poll 隊列不為空,會遍歷回調隊列並同步執行,直到隊列為空或者達到系統限制
- 如果 poll 隊列為空時,會有兩件事發生
- 如果有
setImmediate回調需要執行,poll 階段會停止並且進入到 check 階段執行回調 - 如果沒有
setImmediate回調需要執行,會等待回調被加入到隊列中並立即執行回調,這里同樣會有個超時時間設置防止一直等待下去
- 如果有
當然設定了 timer 的話且 poll 隊列為空,則會判斷是否有 timer 超時,如果有的話會回到 timer 階段執行回調。
check
check 階段執行 setImmediate
close callbacks
close callbacks 階段執行 close 事件
在以上的內容中,我們了解了 Node 中的 Event Loop 的執行順序,接下來我們將會通過代碼的方式來深入理解這塊內容。
首先在有些情況下,定時器的執行順序其實是隨機的
setTimeout(() => { console.log('setTimeout') }, 0) setImmediate(() => { console.log('setImmediate') })
對於以上代碼來說,setTimeout 可能執行在前,也可能執行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的 - 進入事件循環也是需要成本的,如果在准備時候花費了大於 1ms 的時間,那么在 timer 階段就會直接執行
setTimeout回調 - 那么如果准備時間花費小於 1ms,那么就是
setImmediate回調先執行了
當然在某些情況下,他們的執行順序一定是固定的,比如以下代碼:
const fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0) setImmediate(() => { console.log('immediate') }) })
在上述代碼中,setImmediate 永遠先執行。因為兩個代碼寫在 IO 回調中,IO 回調是在 poll 階段執行,當回調執行完畢后隊列為空,發現存在 setImmediate 回調,所以就直接跳轉到 check 階段去執行回調了。
上面介紹的都是 macrotask 的執行情況,對於 microtask 來說,它會在以上每個階段完成前清空 microtask 隊列,下圖中的 Tick 就代表了 microtask

setTimeout(() => { console.log('timer21') }, 0) Promise.resolve().then(function() { console.log('promise1') })
對於以上代碼來說,其實和瀏覽器中的輸出是一樣的,microtask 永遠執行在 macrotask 前面。
最后我們來講講 Node 中的 process.nextTick,這個函數其實是獨立於 Event Loop 之外的,它有一個自己的隊列,當每個階段完成后,如果存在 nextTick 隊列,就會清空隊列中的所有回調函數,並且優先於其他 microtask 執行。
setTimeout(() => { console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) process.nextTick(() => { console.log('nextTick') process.nextTick(() => { console.log('nextTick') process.nextTick(() => { console.log('nextTick') process.nextTick(() => { console.log('nextTick') }) }) }) })
對於以上代碼,大家可以發現無論如何,永遠都是先把 nextTick 全部打印出來。
小結
這一章節我們學習了 JS 實現異步的原理,並且了解了在瀏覽器和 Node 中 Event Loop 其實是不相同的。Event Loop 這個知識點對於我們理解 JS 是如何執行的至關重要,同時也是常考題。如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。
