事件迴圈詳解

大芒果哇發表於2020-10-11

引言

javascript 是一門單執行緒的語言,在同一個時間只能做完成一件任務,如果有多個任務,就必須排隊,前面一個任務完成,再去執行後面的任務。作為瀏覽器端的指令碼語言,javascript 的主要功能是用來和使用者互動以及操作 dom。假設 javascript 不是單執行緒語言,在一個執行緒裡我們給某個 dom 節點增加內容的時候,另一個執行緒同時正在刪除這個 dom 節點的內容,則會造成混亂。

由於 js 單執行緒的設計,假設 js 程式的執行都是同步。如果執行一些耗時較長的程式,例如 ajax 請求,在請求開始至請求響應的這段時間內,當前的工作執行緒一直是空閒狀態, ajax 請求後面的 js 程式碼只能等待請求結束後執行,因此會導致 js 阻塞的問題。

javascript 單執行緒指的是瀏覽器中負責解釋和執行 javascript 程式碼的只有一個執行緒,即為 js 引擎執行緒,但是瀏覽器的渲染程式是提供多個執行緒的,如下:

  1. js 引擎執行緒
  2. 事件觸發執行緒
  3. 定時器觸發執行緒
  4. 非同步 http 請求執行緒
  5. GUI 渲染執行緒

一、非同步 & 同步

為解決上述類似上述 js 阻塞的問題,js 引入了同步和非同步的概念。

1、什麼是同步?

“同步”就是後一個任務等待前一個任務結束後再去執行。

2、什麼是非同步?

“非同步”與同步不同,每一個非同步任務都有一個或多個回撥函式。webapi 會在其相應的時機裡將回撥函式新增進入訊息佇列中,不直接執行,然後再去執行後面的任務。直至當前同步任務執行完畢後,再把訊息佇列中的訊息新增進入執行棧進行執行。

非同步任務在瀏覽器中一般是以下:

  1. 網路請求
  2. 計時器
  3. DOM 監聽事件
  4. ...

二、什麼是執行棧(stack)、堆(heap)、事件佇列(task queue)?

1、執行棧

“棧”是一種資料結構,是一種線性表。特點為 LIFO,即先進後出 (last in, first out)。

利用陣列的 push 和 shift 可以實現壓棧和出棧的操作。

stack

在程式碼執行的過程中,函式的呼叫會形成一個由若干幀組成的棧。

function foo(b) {
  let a = 10;
  return a + b + 11;
}

function bar(x) {
  let y = 3;
  return foo(x * y);
}

console.log(bar(7))

上面程式碼最終會在控制檯列印42,下面梳理一下它的執行順序。

  1. console.log 函式作為第一幀壓入棧中。
  2. 呼叫 bar,第二幀被壓入棧中。幀中包含著 bar 的變數物件。
  3. bar 呼叫 foo,foo 做一位第三幀被壓入棧中,幀中包含著 foo 的變數物件。
  4. foo 執行完畢然後返回。被彈出棧。
  5. bar 執行完畢然後返回,被彈出棧。
  6. log 函式接收到 bar 的返回值。執行完畢後,出棧。此時棧已空。

2、堆

物件被分配在堆中,堆是一個用來表示一大塊(通常是非結構化的)記憶體區域的計算機術語。

堆和棧的區別

首先,stack 是有結構的,每個區塊按照一定次序存放,可以明確知道每個區塊的大小;heap 是沒有結構的,資料可以任意存放。因此,
stack 的定址速度要快於 heap。

其次,每個執行緒分配一個 stack,每個程式分配一個 heap,也就是說,stack 是執行緒獨佔的,heap 是執行緒共用的。

此外,stack 建立的時候,大小是確定的,資料從超過這個大小,就發生 stack overflow 錯誤,而 heap 的大小是不確定的,
需要的話可以不斷增加。


public void Method1()
{
    int i=4;

    int y=2;

    class1 cls1 = new class1();
}

上面程式碼這三個變數和一個物件例項在記憶體中的存放方式如下。

從上圖可以看到,i、y和cls1都存放在stack,因為它們佔用記憶體空間都是確定的,而且本身也屬於區域性變數。但是,cls1指向的物件例項存放在heap,因為它的大小不確定。作為一條規則可以記住,所有的物件都存放在heap。

接下來的問題是,當Method1方法執行結束,會發生什麼事?

回答是整個stack被清空,i、y和cls1這三個變數消失,因為它們是區域性變數,區塊一旦執行結束,就沒必要再存在了。而heap之中的那個物件例項繼續存在,直到系統的垃圾清理機制(garbage collector)將這塊記憶體回收。因此,一般來說,記憶體洩漏都發生在heap,即某些記憶體空間不再被使用了,卻因為種種原因,沒有被系統回收。

3、事件佇列和事件迴圈

佇列是一種資料結構,也是一種特殊的線性表。特點為 FIFO,即先進先出(first in, first out)

利用陣列的 push 和 pop 可實現入隊和出隊的操作。

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

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

js 引擎遇到上文所列的非同步任務後,會交個相應的執行緒去維護非同步任務,等待某個時機,然後由事件觸發執行緒將非同步任務對應的回撥函式加入到事件佇列中,事件佇列中的函式等待被執行。

js 引擎在執行過程中,遇到同步任務,會將任務直接壓入執行棧中執行,當執行棧為空(即 js 引擎執行緒空閒),事件觸發執行緒會從事件佇列中取出一個任務(即非同步任務的回撥函式)放入執行在棧中執行。

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

為了更好地理解Event Loop,請看下圖(轉引自Philip Roberts的演講《Help, I'm stuck in an event-loop》)。

例子程式碼:

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

模擬 js 引擎對其執行過程:

第一輪事件迴圈:

  1. console.log 為同步任務,入棧,列印“script start”。出棧。
  2. setTimeout 為非同步任務,入棧,交給定時器觸發執行緒處理(在1秒後加入將回撥加入事件佇列)。出棧。
  3. setTimeout 為非同步任務,入棧,交給定時器觸發執行緒處理(在4ms之內將回撥加入事件佇列)。出棧。
  4. console.log 為同步任務,入棧,列印"script end"。出棧。

此時,執行棧為空,js 引擎執行緒空閒。便從事件佇列中讀取任務,此時佇列如下:

第二輪事件迴圈

  1. js 引擎執行緒從事件對列中讀取 cb2 加入執行棧並執行,列印”time 2 over“。出棧。

第三輪事件迴圈

  1. js 引擎從事件佇列中讀取 cb1 加入執行棧中並執行,列印”time 1 over“ 。出棧。

注意點:

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

就算延時為0ms,只是 time 2 的回撥函式會立即加入事件佇列而已,回撥的執行還是得等到執行棧為空時執行。

四、巨集任務 & 微任務

在 ES6 新增 Promise 處理非同步後,js 執行引擎的處理過程又發生了新的變化。

看程式碼:

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

這裡又新增了兩個新的概念,macrotask (巨集任務)和 microtask(微任務)。

所有的任務都劃分到巨集任務和微任務下:

  • macrotask: script 主程式碼塊、setTimeout、setInterval、requestAnimationFrame、node 中的setimmediate 等。
  • microtask: Promise.then catch finally、MutationObserver、node 中的process.nextTick 等。

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

執行棧每次執行的程式碼就是一個巨集任務,包括任務佇列(巨集任務佇列)中的。執行棧中的任務執行完畢後,js 引擎會從巨集任務佇列中去新增任務到執行棧中,即同樣是事件迴圈的機制。

當在執行巨集任務遇到微任務 Promise.then 時,會建立一個微任務,並加入到微任務佇列中的隊尾。

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

所以上述程式碼的執行過程就可以解釋了。

js 引擎執行 promise.then 時,promise1、promise2 被認為是兩個微任務按照程式碼的先後順序被加入到微任務佇列中,script end執行後,棧空。

此時當前巨集任務(script 主程式碼塊)執行完畢,並不從當前巨集任務佇列中讀取任務。而是立馬清空當前巨集任務所產生的微任務佇列。將兩個微任務依次放入執行棧中執行。執行完畢,列印 promise1、promise2。棧空。此時,第一輪事件迴圈結束。

緊接著,再去讀取巨集任務佇列中的任務,time over 被列印。棧空。

因此,巨集任務和微任務的執行機制如下:

  1. 執行一個巨集任務(棧中沒有就從巨集任務佇列中獲取)
  2. 執行過程中遇到微任務,就將它新增到微任務的任務佇列中
  3. 巨集任務執行完畢,立即執行當前微任務佇列中的所有微任務(依次執行)
  4. 當前所有微任務執行完畢後,開始檢查渲染,GUI 執行緒接管渲染
  5. 渲染完畢後,JS 引擎繼續開始下一個巨集任務,從巨集任務佇列中獲取

async & await

因為,async 和 await 本質上還是基於 Promise 的封裝,而 Promise 是屬於微任務的一種。所以使用 await 關鍵字與 Promise.then 效果類似:

setTimeout(_ => console.log(4))

async function main() {
  console.log(1)
  await Promise.resolve()
  console.log(3)
}

main()

console.log(2)
// 1
// 2
// 3
// 4

async 函式在 await 之前的程式碼都是同步執行的,可以理解為 await 之前的程式碼都屬於 new Promise 時傳入的程式碼,await 之後的所有程式碼都是 Promise.then 中的回撥,即在微任務佇列中。

五、總結

  1. js 單執行緒實際上時解釋執行 js 程式碼的只有一個執行緒,但是瀏覽器的渲染是多執行緒的。
  2. 非同步和同步的概念與區別,非同步任務有哪些。
  3. 棧、堆、佇列的特點和使用場景。
  4. 事件佇列以及事件迴圈機制。
  5. es6 下,巨集任務與微任務的執行過程。

參考:

相關文章