JavaScript 中的非同步:Event Loop 及其他

天方夜發表於2016-09-30

非同步

簡單地說,JavaScript 是單執行緒執行的語言,但在使用中有很多非同步執行的情況。非同步的本質是用其他方式(相對同步)控制程式的執行順序,這與其他語言中的多執行緒模型不同,所以常常有人對非順序 JavaScript 程式碼的執行結果感到困惑不解。

一段簡單的小程式

任何使用過 JavaScript 的程式設計師都能說出下面這段程式碼的輸出:

console.log("A");

setTimeout(() => {
  console.log("B");
}, 100);

console.log("C");

先後順序是 A、C、B,因為第二個引數的作用是指定延遲的毫秒數,這段程式碼只有一個 setTimeout,所以不會讓人迷惑。

對類似程式的解釋通常是由 setTimeout 設定一個定時器,在指定毫秒數後呼叫回撥函式。然而,它的執行機制並不是這麼簡單。實際上,setTimeout 的作用是在指定的毫秒數之後,在得到機會時,將 callback 放入 Event Loop Queue

Event Loop

首先要丟擲一些概念,通常所說的 JavaScript Engine 是指負責執行一個一個 chunk 的程式,它依賴宿主環境的排程,也需要通過宿主環境與作業系統產生關聯並得到支援。JavaScript Engine 是 JavaScript Runtime(Hosting Environment) 的一部分。

每個 chunk 通常是以 function 為單位,一個 chunk 執行完成後,才會執行下一個 chunk。下一個 chunk 是什麼呢?取決於當前 Event Loop Queue 中的隊首。Event Loop Queue 中存放的都是訊息,每個訊息關聯著一個函式,JavaScript Engine 就按照佇列中的訊息順序執行它們,也就是執行 chunk。

所以上面的 setTimeout 實際執行起來更接近這樣:

  • chunk1執行:由 setTimeout 啟動定時器(100毫秒)

  • chunk2執行:得到機會,將 callback 放入 Event Loop Queue

  • chunk3執行:此 callback 執行

不難發現,得到機會很重要!這也就可以解釋用 setTimeout 延遲 1000 不一定是準確的,而是會至少延遲一秒。因為如果還有其他的任務在前面,它要等待那些任務對應的訊息都出隊,也就是程式都執行完成,它才能將 callback 放入佇列。也就是實際延遲會大於或等於一秒。

通常所說的觸發了一個事件,就是指這個 event listener 得到了執行。與 setTimeout 這個例子中的概念一樣,這也是一次 chunk 的執行。像這樣一個一個執行 chunk 的過程就叫 Event Loop

還有一個經常提到的概念叫「無阻塞」,JavaScript 中的無阻塞就是指這種 Event Loop 模型。除去 alert 或同步 Ajax 請求等歷史原因造成的問題,程式總是不會出現阻塞;也就是說 JavaScript Engine 總是可以處理下一個任務,如處理使用者對瀏覽器的操作。

一些簡單的小例子

將 setTimeout 加入 try 語句之中,結果會如何?

try {
  setTimeout(() => {
    throw new Error("Error - from try statement");
  }, 0);
} catch (e) {
  console.error(e);
}

try catch 與 setTimeout 不在同一個 chunk,所以……你懂的。

再看下一個。

下面的堆疊資訊會輸出 C - B - A 嗎?

setTimeout(function A() {
  setTimeout(function B() {
    setTimeout(function C() {
      throw new Error("Error - from function C");
    }, 0);
  }, 0);
}, 0);

它們並不對應同一條 Event Loop Queue 中的訊息,分別有各自的呼叫棧,所以錯誤棧裡面只有 C。

Job Queue

Job 是 ES6 中新增的概念,它與 Promise 的執行有關,可以理解為等待執行的任務;Job Queue 就是這種型別的任務的佇列。JavaScript Runtime 對於 Job Queue 與 Event Loop Queue 的處理有所不同。

相同點:

  • 都用作先進先出佇列

相異點:

  • 每個 JavaScript Runtime 可以有多個 Job Queue,但只有一個 Event Loop Queue

  • 當 JavaScript Engine 處理完當前 chunk 後,優先執行所有的 Job Queue,然後再處理 Event Loop Queue

ES6 中,一個 Promise 就是一個 PromiseJob,一種 Job。

再來觀察一段小程式:

console.log("A");

setTimeout(() => {
  console.log("A - setTimeout");
}, 0);

new Promise((resolve) => {
  resolve();
})
.then(() => {
  return console.log("A - Promise 1");
})
.then(() => {
  return console.log("B - Promise 1");
});

new Promise((resolve) => {
  resolve();
})
.then(() => {
  return console.log("A - Promise 2");
})
.then(() => {
  return console.log("B - Promise 2");
})
.then(() => {
  return console.log("C - Promise 2");
});

console.log("AA");

在原生支援 Promise 的環境,輸出是這樣:

A
AA
A - Promise 1
A - Promise 2
B - Promise 1
B - Promise 2
C - Promise 2
A - setTimeout

理解這個輸出:

  • A 與 AA 最先輸出,因為它們不是非同步任務,屬於第一個 chunk。

  • Promise 1 與 Promise 2 先於 setTimeout 執行,因為 Job Queue 的執行優先於 Event Loop Queue。

  • Promise 1 與 Promise 2 各自的輸出都是順序的,因為 Job Queue 是先進先出佇列,同一 Job Queue 中的任務順序執行。

  • Promise 1 與 Promise 2 的後續任務是交錯的,因為 Promise 1 與 Promise 2 都是獨立的 PromiseJob(job 的其中一種),屬於不同的 Job Queue,它們之間的順序規範中沒有規定。

併發

文章開頭,我說「簡單地說,JavaScript 是單執行緒執行的語言」,現在可以說得稍微複雜一點了:JavaScript Engine 對 JavaScript 程式的執行是單執行緒的,但是 JavaScript Runtime(整個宿主環境)並不是單執行緒的;而且,幾乎所有的非同步任務都是併發的,例如多個 Job Queue、Ajax、Timer、I/O(Node)等等。

上面說的是 JavaScript Runtime 層面,從開發者的 JavaScript 程式碼執行層面來說,也有一些特殊情況,例如:一個 Web Worker,是一個獨立的執行緒,有自己的記憶體空間(棧、堆)以及 Event Loop Queue。要與這樣的不同的執行緒通訊,只能通過 postMessage。一次 postMessage 就是在另一個執行緒的 Event Loop Queue 中加入一條訊息。

而一個跨域的 iframe 中,JavaScript 也有單獨的記憶體空間(棧、堆)以及 Event Loop Queue,也只能通過 postMessage 與它通訊。至於它與主頁面是否執行在同一執行緒內,取決於瀏覽器的實現(目前是在同一執行緒內)。

參考資料

  1. Concurrency model and Event Loop
  2. ECMAScript® 2015 Language Specification
  3. You Don't Know JS: Async & Performance
  4. JavaScript非同步程式設計:設計快速響應的網路應用

相關文章