總結:JavaScript非同步、事件迴圈與訊息佇列、微任務與巨集任務

ZavierTang發表於2018-11-09

前言

原文

Philip Roberts 在演講 great talk at JSConf on the event loop 中說:要是用一句話來形容 JavaScript,我可能會這樣:

“JavaScript 是單執行緒、非同步、非阻塞、解釋型指令碼語言。”

  • 單執行緒 ?
  • 非同步 ? ?
  • 非阻塞 ? ? ?

然後,這又牽扯到了事件迴圈、訊息佇列,還有微任務、巨集任務這些。

作為一個初學者,對這些瞭解甚少。

這幾天翻閱了不少資料,似乎瞭解到了一二,是時候總結一下了,它們困擾了我好一段時間,就像學高數那會兒自己去理解一個概念一樣。

單執行緒與多執行緒

單執行緒語言:JavaScript 的設計就是為了處理瀏覽器網頁的互動(DOM操作的處理、UI動畫等),決定了它是一門單執行緒語言。

如果有多個執行緒,它們同時在操作 DOM,那網頁將會一團糟。

JavaScript 是單執行緒的,那麼處理任務是一件接著一件處理,從上往下順序執行:

console.log('script start')
console.log('do something...')
console.log('script end')

// script start
// do something...
// script end
複製程式碼

上面的程式碼會依次列印: "script start" >> "do something..." >> "script end"

那如果一個任務的處理耗時(或者是等待)很久的話,如:網路請求、定時器、等待滑鼠點選等,後面的任務也就會被阻塞,也就是說會阻塞所有的使用者互動(按鈕、滾動條等),會帶來極不友好的體驗。

但是:

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引擎執行緒會在適當的時候去訊息佇列取出訊息並執行。

JS引擎執行緒什麼時候去處理呢?訊息佇列又是什麼?

這裡,JavaScript 通過 事件迴圈 event loop 的機制來解決這個問題。

這個放在後面再討論吧!

同步與非同步

上面說到了非同步,JavaScript 中有同步程式碼與非同步程式碼。

下面便是同步:

console.log('hello 0')

console.log('hello 1')

console.log('hello 2')

// hello 0
// hello 1
// hello 2
複製程式碼

它們會依次執行,執行完了後便會返回結果(列印結果)。

setTimeout(() => {
  console.log('hello 0')
}, 1000)

console.log('hello 1')

// hello 1
// hello 0
複製程式碼

上面的 setTimeout 函式便不會立刻返回結果,而是發起了一個非同步,setTimeout 便是非同步的發起函式或者是註冊函式,() => {...} 便是非同步的回撥函式。

這裡,JS引擎執行緒只會關心非同步的發起函式是誰、回撥函式是什麼?並將非同步交給 webapi 去處理,然後繼續執行其他任務。

非同步一般是以下:

  • 網路請求
  • 計時器
  • DOM時間監聽
  • ...

事件迴圈與訊息佇列

回到事件迴圈 event loop

其實 事件迴圈 機制和 訊息佇列 的維護是由事件觸發執行緒控制的。

事件觸發執行緒 同樣是瀏覽器渲染引擎提供的,它會維護一個 訊息佇列

JS引擎執行緒遇到非同步(DOM事件監聽、網路請求、setTimeout計時器等...),會交給相應的執行緒單獨去維護非同步任務,等待某個時機(計時器結束、網路請求成功、使用者點選DOM),然後由 事件觸發執行緒 將非同步對應的 回撥函式 加入到訊息佇列中,訊息佇列中的回撥函式等待被執行。

同時,JS引擎執行緒會維護一個 執行棧,同步程式碼會依次加入執行棧然後執行,結束會退出執行棧。

Stack&Queue

如果執行棧裡的任務執行完成,即執行棧為空的時候(即JS引擎執行緒空閒),事件觸發執行緒才會從訊息佇列取出一個任務(即非同步的回撥函式)放入執行棧中執行。

訊息佇列是類似佇列的資料結構,遵循先入先出(FIFO)的規則。

執行完了後,執行棧再次為空,事件觸發執行緒會重複上一步操作,再取出一個訊息佇列中的任務,這種機制就被稱為事件迴圈(event loop)機制。

還是上面的程式碼:

console.log('script start')

setTimeout(() => {
  console.log('timer over')
}, 1000)

// 點選頁面
console.log('click page')

console.log('script end')

// script start
// click page
// script end
// timer over
複製程式碼

執行過程:

  1. 主程式碼塊(script)依次加入執行棧,依次執行,主程式碼塊為:

    • console.log('script start')
    • setTimeout()
    • console.log('click page')
    • console.log('script end')
  2. console.log() 為同步程式碼,JS引擎執行緒處理,列印 "script start",出棧;

  3. 遇到非同步函式 setTimeout,交給定時器觸發執行緒(非同步觸發函式為:setTimeout,回撥函式為:() => { ... }),JS引擎執行緒繼續,出棧;

  4. console.log() 為同步程式碼,JS引擎執行緒處理,列印 "click page",出棧;

  5. console.log() 為同步程式碼,JS引擎執行緒處理,列印 "script end",出棧;

  6. 執行棧為空,也就是JS引擎執行緒空閒,這時從訊息佇列中取出(如果有的話)一條任務(callback)加入執行棧,並執行;

  7. 重複第6步。

  8. (此步的位置不確定)某個時刻(1000ms後),定時器觸發執行緒通知事件觸發執行緒,事件觸發執行緒將回撥函式 () => { ... } 加入訊息佇列隊尾,等待JS引擎執行緒執行。

可以看出,setTimeout非同步函式對應的回撥函式( () => {} )會在執行棧為空,主程式碼塊執行完了後才會執行。

零延時:

console.log('script start')

setTimeout(() => {
  console.log('timer 1 over')
}, 1000)

setTimeout(() => {
  console.log('timer 2 over')
}, 0)

console.log('script end')

// script start
// script end
// timer 2 over
// timer 1 over
複製程式碼

這裡會先列印 "timer 2 over",然後列印 "timer 1 over",儘管 timer 1 先被定時器觸發執行緒處理,但是 timer 2 的callback會先加入訊息佇列。

上面,timer 2 的延時為 0ms,HTML5標準規定 setTimeout 第二個引數不得小於4(不同瀏覽器最小值會不一樣),不足會自動增加,所以 "timer 2 over" 還是會在 "script end" 之後。

就算延時為 0ms,只是 timer 2 的回撥函式會立即加入訊息佇列而已,回撥的執行還是得等執行棧為空(JS引擎執行緒空閒)時執行。

其實 setTimeout 的第二個引數並不能代表回撥執行的準確的延時事件,它只能表示回撥執行的最小延時時間,因為回撥函式進入訊息佇列後需要等待執行棧中的同步任務執行完成,執行棧為空時才會被執行。

巨集任務與微任務

以上機制在ES5的情況下夠用了,但是ES6會有一些問題。

Promise同樣是用來處理非同步的:

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')

// script start
// script end
// promise1
// promise2
// timer over
複製程式碼

WTF?? "promise 1" "promise 2" 在 "timer over" 之前列印了?

這裡有一個新概念:macrotask(巨集任務) 和 microtask(微任務)。

所有任務分為 macrotaskmicrotask:

  • macrotask:主程式碼塊、setTimeout、setInterval等(可以看到,事件佇列中的每一個事件都是一個 macrotask,現在稱之為巨集任務佇列)

  • microtask:Promise、process.nextTick等

JS引擎執行緒首先執行主程式碼塊。

每次執行棧執行的程式碼就是一個巨集任務,包括任務佇列(巨集任務佇列)中的,因為執行棧中的巨集任務執行完會去取任務佇列(巨集任務佇列)中的任務加入執行棧中,即同樣是事件迴圈的機制。

在執行巨集任務時遇到Promise等,會建立微任務(.then()裡面的回撥),並加入到微任務佇列隊尾。

microtask必然是在某個巨集任務執行的時候建立的,而在下一個巨集任務開始之前,瀏覽器會對頁面重新渲染(task >> 渲染 >> 下一個task(從任務佇列中取一個))。同時,在上一個巨集任務執行完成後,渲染頁面之前,會執行當前微任務佇列中的所有微任務。

也就是說,在某一個macrotask執行完後,在重新渲染與開始下一個巨集任務之前,就會將在它執行期間產生的所有microtask都執行完畢(在渲染前)。

這樣就可以解釋 "promise 1" "promise 2" 在 "timer over" 之前列印了。"promise 1" "promise 2" 做為微任務加入到微任務佇列中,而 "timer over" 做為巨集任務加入到巨集任務佇列中,它們同時在等待被執行,但是微任務佇列中的所有微任務都會在開始下一個巨集任務之前都被執行完。

在node環境下,process.nextTick的優先順序高於Promise,也就是說:在巨集任務結束後會先執行微任務佇列中的nextTickQueue,然後才會執行微任務中的Promise。

執行機制:

  1. 執行一個巨集任務(棧中沒有就從事件佇列中獲取)

  2. 執行過程中如果遇到微任務,就將它新增到微任務的任務佇列中

  3. 巨集任務執行完畢後,立即執行當前微任務佇列中的所有微任務(依次執行)

  4. 當前巨集任務執行完畢,開始檢查渲染,然後GUI執行緒接管渲染

  5. 渲染完畢後,JS引擎執行緒繼續,開始下一個巨集任務(從巨集任務佇列中獲取)

總結

  • JavaScript 是單執行緒語言,決定於它的設計最初是用來處理瀏覽器網頁的互動。瀏覽器負責解釋和執行 JavaScript 的執行緒只有一個(所有說是單執行緒),即JS引擎執行緒,但是瀏覽器同樣提供其他執行緒,如:事件觸發執行緒、定時器觸發執行緒等。

  • 非同步一般是指:

    • 網路請求
    • 計時器
    • DOM事件監聽
  • 事件迴圈機制:

    • JS引擎執行緒會維護一個執行棧,同步程式碼會依次加入到執行棧中依次執行並出棧。
    • JS引擎執行緒遇到非同步函式,會將非同步函式交給相應的Webapi,而繼續執行後面的任務。
    • Webapi會在條件滿足的時候,將非同步對應的回撥加入到訊息佇列中,等待執行。
    • 執行棧為空時,JS引擎執行緒會去取訊息佇列中的回撥函式(如果有的話),並加入到執行棧中執行。
    • 完成後出棧,執行棧再次為空,重複上面的操作,這就是事件迴圈(event loop)機制。

原文連結

參考:

相關文章