iT邦幫忙

2024 iThome 鐵人賽

DAY 15
0
JavaScript

Don't make JavaScript Just Surpise系列 第 15

事件循環(Event Loop)

  • 分享至 

  • xImage
  •  

運算子算是中間插入的章節介紹,雖然也很重要,但可能覺得有點突兀。
原本順序上講完函式,閉包,再來應該會接上同步/異步,總之現在把順序接回來,先來講事件循環,下一篇就會接上同步/異步的內容。

因為後面會大量講到同步異步兩個詞,我們先定義同步異步,但相關的特性解說留待相關段落。
同步(Sync)操作指的是定義的操作會一個接一個,依序地完成。每個操作都需等待前個操作完成才能接續執行。
非同步(Async)又稱異步,指的是和同步不一樣的行為,非同步操作無需等待前一個操作,可以略過目前操作,直到該被執行的時間點才執行。

那講同步異步前,為什麼要先講事件循環,事件循環的重要性是什麼?

首先, JS 是一個單執行緒(single-threaded)的語言,這意味著 JS 中永遠不會有兩個語句同時被執行。以印出 new Date() 這樣印出當下時間的函式呼叫來舉例,你永遠無法透過兩次獨立的 new Date() 得到兩個完全一樣的 timestamp,無論你透過如何的異步包裝。
就因為 JS 本質上是單執行序的語言,一個時間點,他就是只能處理一段程式碼,一個語句。

所以如果有一個語句需要長時間的執行,它就會讓後面語句被阻塞(Blocking)。

let startTime = new Date();
console.log(startTime);
for(let i = 0; i < 1000000000; i++){
}
console.log(`It takes time! ${new Date() - startTime} ms`);

依據你的電腦性能,上面一個非常大的迴圈會佔用一定的時間。
形成了阻塞,像是呼叫遠端 API,在等待遠端 API 執行完畢前,這也是一種阻塞。
阻塞發生時,同個網頁上的其他 JS 都無法執行,對使用者來說,就好像網頁卡住了。

事件循環就是用於管理事件執行順序的機制,同時事件循環機制會基於執行環境(Execution Context),比如執行於瀏覽器上,事件循環機制就會依照瀏覽器的規範執行。
實際上,在 ES 6 以前,ECMAScript 規範中完全沒有提到關於異步同步的相關語法,直到 ES 6 後才陸續有 Promise,ES 2017 的 asnyc/await 被引入。ES 6 以前,儘管有 callback,他更像是在被要求的時間點執行你定義的程式碼,用於預防阻塞 I/O 的情況發生。

那 JS 是怎麼決定順序的呢?我們就要來看呼叫堆疊。

呼叫堆疊(Call Stack)

這個我比較沒找到繁體中文的統一稱呼方法,可能大家平常都直接叫 Call Stack(我都這樣叫啦哈哈),執行堆疊,調用棧都是指它。

有讀過資料結構的人應該知道,堆疊是一種數據結構,特色是先進後出(FILO)。
我們來看一段程式碼。


function a(){
    console.log('here is start of a');//1
    b();
    console.log('here is a');//4
}
function b(){
    c();
    console.log('here is b');//3
}
function c(){console.log('here is c');}//2

語句的執行順序會是 1,2,3,4。
當程式遇到函式呼叫,則放入堆疊,如果函式中有其他函式,則持續放往堆疊內,而最後從堆疊頂端開始執行。

這邊我們以陣列表示堆疊,左方為先放入堆疊的內容:
上面的例子會是 a 放入堆疊 -> 堆疊中為 [a]
console.log() 放入堆疊 -> 堆疊中為 [a, console.log]
執行 console.log() -> 堆疊中為 [a]
b 放入堆疊 -> 堆疊中為 [a,b]
c 放入堆疊 -> 堆疊中為 [a,b,c]
執行 c -> 堆疊中為 [a,b]
執行 b -> 堆疊中為 [a]
執行 a -> 堆疊為空

這樣就能解釋上面的執行順序的 1,2,3,4。
這就是 Call Stack 的大致概念。

這邊借用 MDN 的圖幫助我們更全面的了解事件循環的其他要角

圖中可以看到 Stack 中是寫 Frame 而不是 function,是因為 Frame 實際上代表著函式相關的調用上下文(Context),像是傳入參數、傳入參數、變數、返回位址、this指向...等等,上面說明是用比較簡略的概念來說明。

所以除了 Stack 外,接下來還要介紹 HeapQueue

記憶體堆(Memory Heap)

在事件循環的上下文中,可以簡單的理解就是個分配的記憶體區域,用於儲存物件/函式的地方。

工作佇列(Task Queue)

通常也指 Marco QueueCallback Queue任務佇列
佇列與堆疊不同,是先進先出(FIFO)的資料結構。
事件循環基於執行環境,以瀏覽器來說,提供了一些 API(Web APIs),如 DOMAJAXTimeout 等等,當我們執行這些 API,就會執行非同步操作,即呼叫所謂的 callback function

依據 function 本身來源和等待機制的不同,在觸發條件達到前(以 setTimeout 為例,就是時間還沒到之前),他們會被各自機制管理,而一旦觸發條件達到了(以 setTimeout 為例,就是時間到了),則事件循環會把這些 callback function 放入工作佇列中,這也是為什麼會有人稱呼為 Callback Queue

那什麼時候工作佇列的函式會被執行?依據事件循環機制,直到目前 Call Stack 一度為空為止,才會開始處理工作佇列裡的函式。而因為佇列的資料結構特性,工作佇列中的任務處理會依先進佇列先處理的順序進行。

微工作佇列(Micro Queue)

自 ECMAScript 中的 Promise 語法被引入後,微工作佇列的規範更明確,因為 .then() 就是在微工作佇列中進行。
可以理解為某些細小的工作會被安排到這個佇列中,且這個佇列的執行優先序優於一般的工作佇列。
Marco Queue 的概念也是相對於這個佇列而來的。

所以工作佇列如果加進微工作佇列的概念,則實際執行工作佇列的函式的時間點則變成 1.Call Stack 為空,2. 執行微工作佇列直到微工作佇列為空,3. 執行工作佇列。

小結

探討 JS 非同步的時候有個常用的例子,我稍微改一下它:

console.log('1: Start of script'); // 進入 Call Stack,立即執行

setTimeout(() => {
  console.log('4: Macrotask (setTimeout)'); // 進入 Task queue,稍後執行
}, 0);

Promise.resolve().then(() => {
  console.log('3: Microtask (Promise.then)'); // 進入 Microtask queue,優先於 Macrotask
});

console.log('2: End of script'); // 進入 Call Stack,立即執行

上面的例子中有個 等待時間為 0 的 setTimeout 函式,如果邏輯上來說,執行語句應該上到下,且等待時間為 0 則應該立即執行,則我們預期印出順序應為1,4,3,2 的順序。
但經過事件循環,我們應該會知道,setTimeout 會導致 console.log('Callback') 進入工作佇列,而 .then() 實際上會進入微工作佇列,執行順序應為 Call Stack > Microtask Queue > Task Queue,所以印出來的順序會是 1,2,3,4

所以實際上的 setTimeout(...,0) 並不會真的即刻被執行,同理,透過這種方式的 callback,只能說近乎在該指定時間後被呼叫,可以保證只晚不早(因為早的話還沒打觸發條件,還沒放進工作佇列)。

如果看懂了這個例子的發生原因,可以說對事件循環和 JS 的同異步有了最基本的概念,足夠讓我們在下一篇進入更多的同異步探討了。


上一篇
運算子(Operator) 下篇(含JS 中的運算子優先級/序)
下一篇
異步(Async)中的Promise 物件
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言