2017年8月24日

[筆記] 理解 JavaScript 中的事件循環、堆疊、佇列和併發模式(Learn event loop, stack, queue, and concurrency mode of JavaScript in depth)

本篇內容整理自 Philip Roberts 在 JS Conf 的演講影片 What the heck is the event loop anyway? 和 MDN Concurrency model and Event Loop
如果你對於 JavaScript 的執行環境還不夠清楚,建議可以參考以下文章:

單線程(single threaded)

首先,我們要知道 JavaScript 是單線程(single threaded runtime)的程式語言,所有的程式碼片段都會在堆疊中(stack)被執行,而且一次只會執行一個程式碼片段(one thing at a time)。

堆疊(stack)

在 JavaScript 中的執行堆疊(called stack)會記錄目前執行到程式的哪個部分,如果進入了某一個函式(step into),便把這個函式添加到堆疊(stack)當中的最上方;如果在函式中執行了 return ,則會將此函式從堆疊(stack)的最上方中抽離(pop off)。
以下面的程式碼為例:
function multiply(a, b) {
  return a * b
}

function square(n) {
  return multiply(n, n)
}

function printSquare(n) {
  let squared = square(n)
  console.log(squared)
}

printSquare(4)
當我們在執行 JavaScript 的函式時,首先進入 stack 的會是這個檔案中全域環境的程式(這裡稱作 main);接著 printSquare 會被呼叫(invoke)因此進入堆疊(stack)的最上方;在 printSquare 中會呼叫 square() 因此 square() 會進入堆疊(stack)的最上方;同樣的,square 中呼叫了 multiply(),因此 multiply 進入堆疊的最上方。
因此目前的執行的堆疊(call stack)會長的像這樣:
接著執行到每一個函式中的 return 或結尾時,這個函式便會跳離(pop off)堆疊。

無窮迴圈

如果他是一個無窮迴圈,例如:
function foo () {
  return foo()
}

foo()
那麼這個堆疊(Stack)將會不斷被疊加上去,直到瀏覽器出現錯誤:

阻塞(blocking)

當執行程式碼片段需要等待很長一段時間,或好像「卡住」的這種現象,被稱作 阻塞(blocking),假設請求資料的 AJAX Request 變成同步(Synchronous)處理的話,那麼每 request 一次,因為必需等這個函式執行完畢從堆疊(stack)中跳離開後才能往下繼續走,進而導致阻塞的情形產生,以下面的 pseudo code 為例:
// pseudo code
var foo = $.getSync('//foo.com')
var bar = $.getSync('//bar.com')
var qux = $.getSync('//qux.com')

console.log(foo)
console.log(bar)
console.log(qux)

阻塞的情形導致瀏覽器停滯

從上面的影片中可以看出當堆疊中有未處理完的函式導致阻塞產生時(以同步的方式模擬發出一個 request 但尚未回應前),我們沒辦法在瀏覽器執行其他任何動作,瀏覽器也無法重新渲染(click 的 button 一直處於被按壓的狀態);必須要等到 request 執行結束後瀏覽器才會繼續運作。堆疊被阻塞(stack blocked)的情況會導致我們的瀏覽器無法繼續渲染頁面,導致頁面「停滯」。

非同步處理與堆疊(Async Callback & Call Stack)

如果尚不清楚同步和非同步的差異,可參考 [筆記] 談談JavaScript中的asynchronous和event queue
為了要解決阻塞的問題,我們可以透過非同步(Asynchronous)的方式搭配 callback ,在這裡我們以 setTimeout 來模擬非同步請求的進行,以下面的程式碼為例:
console.log('hi')

setTimeout(function () {
  console.log('there')
}, 5000)

console.log('JSConfEU')
在執行這段程式的時候,執行堆疊(call stack)中會先執行 hi, 接著執行 setTimeout,但是在 setTimeout 中的這個回呼函式(callback function,簡稱 cb)並不會立即被執行(等等會說明它到哪去了),最後堆疊中(stack)會在執行 JSConfEU

Concurrency and Event Loop

為了要理解 JavaScript 之所以能夠透過非同步的方式(asynchronous)「看起來」一次處理很多事情,我們需要進一步瞭解 Event Loop。
我們之所以可以在瀏覽器中同時(concurrently)處理多個事情,是因為瀏覽器並非只是一個 JavaScript Runtime。
JavaScript 的執行時期(Runtime)一次只能做一件事,但瀏覽器提供了更多不同的 API 讓我們使用,進而讓我們可以透過 event loop 搭配非同步的方式同時處理多個事項。
在下面的影片中,我們可以看到當我們在堆疊中執行 setTimeout 這個 function 時,setTimeout 實際上是一個瀏覽器提供的 API ,而不是 JS 引擎本身的功能;於是瀏覽器提供一個計時器給我們使用, setTimeout 中的 callback function(簡稱 cb)會被放到 WebAPIs 中,這時候,setTimeout 這個 function 就已經執行結束,並從堆疊中脫離
當計時器的時間到時,會把要執行的 cb 放到一個叫做工作佇列(task queue)的地方。
這時候就輪到事件循環(event loop)的功能,它的作用很簡單—如果堆疊(stack)是空的,它便把佇列(queue)中的第一個項目放到堆疊當中;堆疊(stack)便會去執行這個項目。
event loop 的作用是去監控堆疊(call stack)和工作佇列(task queue),當堆疊當中沒有執行項目的時候,便把佇列中的內容拉到堆疊中去執行。

setTimeout 0 是什麼意思?

那麼如果我們使用 setTimeout 並且希望在 0 秒後馬上執行是什麼意思呢?
console.log('hi')

setTimeout(function () {
  console.log('there')
}, 0)

console.log('JSConfEU')
如同剛剛的邏輯,即使我們使用 0 秒,它一樣會先將 cb 放到 WebAPIs 的計時器中,當時間到時,把該 cb 放到工作佇列(task queue)內,「等到」所有堆疊的內容都被清空後才會「立即」執行這個 cb
也就是說會依序 console 出來的內容是 hi --> JSConfEU --> there , there 會是最後才輸入的。

把同樣的觀念套到 AJAX request

同樣的道理,我們把剛剛利用 setTimout 來模擬非同步處理的 code,改成發出 AJAX Request:
console.log('hi')

$.get('url', function cb(data) {
  console.log(data)
})

console.log('JSConfEU')
和 setTimeout 一樣,AJAX Request 的功能並不在 JavaScript 引擎中,而是屬於 Web APIs(XHR)。
當執行 AJAX 請求的時候,cb 的內容會被放在 Web APIs 中,因此原本的堆疊(stack)將可以繼續往下執行。
直到 AJAX Request 給予回應後(不論成功或失敗),便把 cb 放到工作佇列(queue)當中,當堆疊(stack)被清空的時候,event loop 便會把 cb 拉到堆疊中加以執行。

更多範例

講者 Philip Roberts 自己寫了一個方便視覺化瞭解 JavaScript Runtime, call stack, event loop, task queue 的工具 Loupe ,你可以把下面更多不同的例子,貼到他所提供的網站中執行:

Click Event

假設我們的程式碼長這樣,我們可以思考一下它會怎麼執行:
console.log('Started')

$.on('button', 'click', function onClick () {
  console.log('Clicked')
})

setTimeout(function onTimeout () {
  console.log('Timeout Finished')
}, 5000)

console.log('Done')
邏輯是一樣的。
首先 Started 會被放到堆疊中執行,再來 clicksetTimeout 這兩個都是屬於 WebAPIs,當他們被放到 WebAPIs 後,就會從堆疊中脫離,最後執行 Done
當 setTimeout 的 Timer 時間到時,或者是 click 事件被觸發時,WebAPIs 會將 cb 放到工作佇列中(task queue),當堆疊空掉的時候,event loop 就會把工作佇列中的內容搬到堆疊(stack)中加以執行。
因此,當我們點擊瀏覽器時,這個點擊事件的 cb 並不是立即被執行的,而是先被放到工作佇列(queue)中,直到堆疊(stack)空了之後,才會被執行:

Multiple setTimeout

一樣的邏輯,讓我們看下面這段程式碼,思考一下它會怎麼執行:
setTimeout(function timeout() {
console.log('hi')
}, 1000)

setTimeout(function timeout() {
console.log('hi')
}, 1000)

setTimeout(function timeout() {
console.log('hi')
}, 1000)

setTimeout(function timeout() {
console.log('hi')
}, 1000)
從運作的過程中我們便可以瞭解到,setTimeout 這個 timer 只能保證超過幾毫秒後 「即將會」 執行,但並不能保證在幾毫秒後會 「即刻」 執行。舉例來說,setTimeout(cb, 1000),表示在 1 秒後這個 function 「即將會」 被執行,但並不是說在 1 秒後這個 function 「立即會」 被執行。

寫一個非同步執行的 forEach callback

在這裡我們利用 setTimeout 的方式,可以寫一個非同步處理的 forEach。一樣的邏輯,讓我們看下面這段程式碼,思考一下它會怎麼執行:
// Synchronous
[1, 2, 3, 4].forEach(function (i) {
  console.log(i)
})

// Asynchronous
function asyncForEach(array, cb) {
  array.forEach(function () {
    setTimeout(cb, 0)
  })
}

asyncForEach([1, 2, 3, 4], function (i) {
  console.log(i)
})
一般來說 callback function(簡稱 cb)會有兩個情況:
  1. 當一個 function 執行完後要隨著執行的 function
  2. 當一個非同步事件觸發時要隨之執行的 function

模擬瀏覽器 render 的情況

在這個範例中,作者使用 delay() 這個內建的 function,目的是要模擬一個耗時的函式:
// Synchronous
[1, 2, 3, 4].forEach(function (i) {
  console.log(i)
  delay()
})

// Asynchronous
function asyncForEach(array, cb) {
  array.forEach(function () {
    setTimeout(cb, 0)
  })
}

asyncForEach([1, 2, 3, 4], function (i) {
  console.log(i)
  delay()
})
一般來說,瀏覽器會在每 16.6 毫秒(也就是每秒 60 個 frames)的時候重新渲染(render)畫面,而渲染的優先權高於回呼函數(callback function)。
從下面的影片中我們可以看出當我們在堆疊(stack)中執行一個耗時的函式時,畫面的渲染會被阻塞住,這時你並沒辦法選取瀏覽器上的文字、沒辦法點擊瀏覽器上的元件。
但是如果我們是透過非同步的方式執行這些函式的時候,在每一個 cb 從工作佇列(task queue)到堆疊(stack)的過程中,提供了瀏覽器重新渲染的機會。
而這也就是為什麼不要把耗時的程式碼放入堆疊(stack)當中,因為當堆疊在運作的時候,瀏覽器並沒有辦法重新渲染畫面。

滑動捲軸的情況

function animateSomething () {
  delay()
}

$.on('document', 'scroll', animateSomething)
從這個範例中我們可以看到雖然不會導致 stack 出現阻塞的情況,但是卻可能導致工作佇列(task queue)阻塞:

參考

0 意見:

張貼留言