JavaScript-Sync-Async

JavaScript 中的同步與非同步 – Event Loop 原理

剛好最近公司讀書會報告到這個主題,就順手把 JavaScript 中的同步與非同步整理成文章了。

在說明同步與非同步的概念之前,先簡單介紹一下 JavaScript 這個程式語言。

1. JavaScript 的特性

1.1 需要 JavaScript 引擎才能執行

我們都知道 JavaScript 是一個程式語言,然而要執行這個程式語言需要有 JavaScript 引擎才能編譯並執行,最有名的 JavaScript 引擎就是 Google 所開發的 V8 引擎,被用在 Chrome 瀏覽器還有 Node.js 上。

1.2 單執行緒 (Single Thread)

上述所提到的 JavaScript 引擎,是使用單執行緒 (Single thread) 來執行 JavaScript 的,也就是說,每次執行 JavaScript 時只會執行一小段程式碼片段(最常見的單位為 function),講白話一點也就是:JavaScript 執行時,一次只能做一件事,所以在執行 JavaScript 的時候,是屬於同步執行的(一步一步執行,這步還沒執行完的話不會到下一步執行)。

2. 同步執行 JavaScript 的缺點

2.1 阻塞 (block) 後面程式碼的執行

因為 JavaScript 引擎是單執行緒,所以同步執行一個耗時的函數時就會阻塞 (block) 到後面程式的執行。

2.2 在瀏覽器同步執行時,會阻塞畫面的渲染(render)

當 JavaScript 在瀏覽器同步執行耗時的函數時,除了會阻塞 (block) 後面函數的執行外,還會阻塞整個網頁的畫面,讓你整個網頁的畫面動彈不得。為甚麼呢?我們先來看一下瀏覽器的架構:

瀏覽器是由許多不同的 process 所組成,不同的 process 負責不同的工作內容,舉例來說:

  • Browser process: 負責瀏覽器中的網址列、上一頁、下一頁按鈕、書籤、網路連線、檔案存取等等功能。
  • Plugin process: 負責瀏覽器中的第三方套件,像是 Adobe reader、flash 等等。
  • GPU process: 負責幫助渲染 (render) 網頁中的影片和圖片 。
  • Renderer process: 負責整個網頁的渲染 (render) ,執行 HTML、CSS 以及 JavaScript。

每個 process 各自負責不同的工作,每個 process 又至少有一個 thread (執行緒) 來負責執行相關工作。
其中 HTML、CSS 的解析以及 JavaScript 的執行是由 renderer process 中的 main thread 所負責的,所以當你的 JavaScript 執行一個很耗時的同步工作時,就會導致網頁沒辦法渲染 (render) 。

Renderer process
Browser Renderer Process
圖片來源:Inside look at modern web browser (part 3)
browser main thread
Browser Main Thread in the Renderer Process
圖片來源:JavaScript main thread. Dissected. 🔬

舉例來說:我寫了一個可以用網頁計算出費氏數列最後一個數字的程式,程式如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <style>
      .question {
        width: 100%;
        display: flex;
        justify-content: center;
        box-sizing: border-box;
      }
      .question:hover {
        background-color: antiquewhite;
      }
    </style>
  </head>
  <body>
    <h2 style="text-align: center;">Compute Fibonacci !</h2>
    <div
      style="
        margin-top: 15vh;
        display: flex;
        justify-content: center;
        flex-wrap: wrap;
      "
    >
      <div class="question">
        <label for=""
          >Please enter variable.
          <input type="text" id="variable" />
        </label>
        <button id="start">Start computing</button>
      </div>
      <div style="margin-top: 10px">
        <label for="">
          Answer:
          <input type="text" id="answer" />
        </label>
      </div>
    </div>
  </body>
  <script>
    const fib = (num) => {
      if (num < 2) return num;
      return fib(num - 1) + fib(num - 2);
    };
    const startComputing = () => {
      const variable = document.getElementById("variable").value;
      const answer = fib(variable)
      document.getElementById('answer').value = answer
      console.log("Answer: " + answer);
    };
    document.getElementById("start").addEventListener("click", startComputing);
  </script>
</html>

當還沒按下 Start computing 按鈕時,把滑鼠放到 Please enter variable 上,背景顏色會改變(簡單做了一個 hover 的效果),但當我輸入變數,並按下 start computing 後,會發現當瀏覽器在執行這個函數的時候,滑鼠就算移到 Please enter variable 上也不會有反應,整個網頁是不會動的!(輸入42 試看看)

2.3 解決瀏覽器中阻塞渲染的問題 – Web Worker

瀏覽器提供了一個 API ,讓我們可以解決同步執行時遇到耗時的函數導致畫面阻塞的問題 — Worker thread。

web worker
Web Worker
圖片來源:你不知道的 Web Workers (上)

原理就是,當你需要執行一個很耗時的函數時,可以用 new Worker 語法請瀏覽器為你開啟一個 worker thread ,用來執行你耗時的 JavaScript 函數,與此同時,你的 JavaScript 函數仍會繼續同步執行你所寫的程式,不讓該耗時程式阻塞你後面程式的執行與畫面的渲染。
以剛剛的例子用 Web worker 改寫,JavaScript 的部分就會變成如下:

const startComputing = () => {
  // 請瀏覽器開啟一個新的 worker thread
  const worker = new Worker("./fib.js");
  const variable = document.getElementById("variable").value;
  // 把使用者輸入的變數丟給 worker thread
  worker.postMessage(variable);
  // 用 worker.onmessage 來監聽 worker 是否運算完畢並將結果丟回來
  worker.onmessage = function(e) {
    document.getElementById("answer").value = e.data;
    // 運算完畢後把結果印出來
    console.log("Answer: " + e.data);
    // 將 worker thread 給關掉
    worker.terminate();
  };
  console.log('test');
};
document.getElementById("start").addEventListener("click", startComputing);
// fib.js
// worker thread 要執行的東西寫在一個新的 js 檔
const fib = (num) => {
  if (num < 2) return num;
  return fib(num - 1) + fib(num - 2);
};

// 執行完畢後丟回 main thread 繼續處理
onmessage = function(e) {
  postMessage(fib(e.data));
}
web worker
Web Worker 使用方法及原理
圖片來源:Browser Rendering Optimization – JavaScript

以上就是如何解決同步執行導致瀏覽器畫面阻塞的方法,Web worker 的詳細用法可以參考 使用Web Workers – Web APIs | MDN

3. JavaScript 同步執行原理 – Call Stack 的觀念

當 JavaScript 引擎在執行 JavaScript 時,會將目前執行到哪個部分記錄在一個叫做 Call Stack 的資料結構中, stack 資料結構的特性是後進先出 (Last in First out,LIFO),以下說明 JavaScript 執行時的原則:

  1. 當 JavaScript 開始執行某函數時,interpreter 會將該函數放進 call stack 當中
  2. 任何在該函數內執行的函數,會被加進call stack 的更上面
  3. 當call stack 中最上面的函數執行完畢 (函數執行到最後一行或是遇到 return) 後,會被 pop 掉,往下執行 call stack 中的函數

舉個簡單的例子就可以說明上面的原則了,執行以下 JavaScript :

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  bar()
  baz()
}

foo()

// foo
// bar
// baz

Call Stack 的順序如下:

JavaScript Call Stack
圖片來源:The Node.js Event Loop

以上就是 Call Stack 的觀念。

4. JavaScript 的非同步執行

首先先來推薦最淺顯易懂講解 JavaScript 非同步原理的影片:What the heck is the event loop anyway? | Philip Roberts | JSConf EU,非常推薦看完影片後再來繼續看文章,會有概念很多。

4.1 為甚麼需要非同步?

在執行 JavaScript 的過程中,有時候我們並不希望函數被「立刻」執行,而是遇到某些「事件」後才被執行,例如:點擊某個按鈕後才去執行某函數、經過幾秒後再執行某個函數、送出 http request 後,等接收到 response 再執行某函數等等。因此我們需要 JavaScript 進行非同步的執行,以下圖片簡單說明了同步與非同步的概念:

JavaScript Sync Async
JavaScript Sync and Async

4.2 為甚麼瀏覽器可以實現 JavaScript 的非同步執行?

前面有說到執行 JavaScript 的 JavaScript 引擎是單執行緒 (single thread),一次只能做一次事情,然而瀏覽器並不是,瀏覽器中有許多 processes,每個 process 又至少有一個執行緒 ( thread),因此可以在瀏覽器中實踐 JavaScript 的非同步執行。

4.3 JavaScript 的非同步執行原理

瀏覽器提供了許多非同步函數,當 JavaScript 引擎中的 Call Stack 發現執行到非同步函數時,會將該非同步函數的觸發事件交給瀏覽器處理(可能是倒數計時、監聽事件等等),當該非同步函數的執行條件被觸發時(使用者點擊 component 或是倒數時間到)時,再將非同步函數的 callback function 放到一個叫做 Callback Queue 的資料結構中。
瀏覽器中的 event loop 則會不斷監聽 Call Stack 中是否有還沒執行完的函數,一旦 Call Stack 中為空,event loop 就會將 Callback Queue 裡的第一個 callback function 放到 Call Stack 中執行。

以執行 setTimeout(() => console.log('Success'), 2000) 這個函數為例子:

  1. Call Stack 執行到 setTimout() 這個函數,發現他是瀏覽器提供的非同步函數,因此交給瀏覽器開始計時。
  2. 瀏覽器開始計時 2 秒鐘,當時間到的時候,將 setTimeout 的 Callback function (也就是 () => console.log('success))放到 Callback Queue 中,等待執行。
  3. Event Loop 不斷監聽 Call Stack 中是否有尚未執行完的函數,一旦發現是空的,就將 Callback Queue 中的第一個函數放到 Call Stack 中執行。

以執行 window.addEventListener('click', () => console.log('Click!'))為例:

  1. Call Stack 執行到 window.addEventListener() 這個函數,發現他是瀏覽器提供的非同步函數,因此交給瀏覽器開始監聽事件。
  2. 瀏覽器開始監聽事件,當使用者點擊視窗的時候,將 window.addEventListener的 Callback function (也就是 () => console.log('Click!'))放到 Callback Queue 中,等待執行。
  3. Event Loop 不斷監聽 Call Stack 中是否有尚未執行完的函數,一旦發現是空的,就將 Callback Queue 中的第一個函數放到 Call Stack 中執行。

以上就是瀏覽器如何實現 JavaScript 的原理,希望對你有幫助!

5. 參考資料

Inside look at modern web browser
進階 Javasctipt 概念 (1)
JavaScript main thread. Dissected. 🔬
你不知道的 Web Workers (上)
Browser Rendering Optimization – JavaScript
Web Workers | Summer。桑莫。夏天
The Node.js Event Loop
JavaScript 中的同步與非同步(上):先成為 callback 大師吧!
What the heck is the event loop anyway? | Philip Roberts | JSConf EU

如果覺得我的文章有幫助的話,歡迎幫我的粉專按讚哦~謝謝你!

Leave a Comment

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

Scroll to Top