JavaScript 同步、非同步、Event loop

閱讀時間約 9 分鐘

同步與非同步

我覺得光是「同步」這個詞就容易造成誤解。一般我們看到「同步」,腦中會聯想到好幾個人同時做某件事情對吧?但在 JavaScript 的情境下,同步指的是一次只做一件事情,一個口令一個動作,上一動還沒結束,不可能開始下一個動作,沒錯,就像當兵。

非同步則是相反的概念,指的是一次能做好幾件事情,手上正在處理的事情沒還完成嗎?沒關係,這邊有份報告比較急,今天下班前交給我。像極了廣大上班族。


JavaScript 是單執行緒 (Single Thread) 的語言

由於 JavaScript 是單執行緒 (Single Thread) 的程式語言,所以一次只能做一件事情。JavaScript 引擎執行程式碼的順序由上至下,所以中間如果有某個函式跑超久,那整個 script 就會阻塞。

在這層意義上,JavaScript 引擎是同步的,一次只能做一件事情


Call stack:函式與它們的英靈殿

一次只做一件事情,聽起來樸實無華又簡潔有力,那麼其背後又是如何運作的呢?這就要提起 call stack 了。前面說過,JavaScript 同時只能處理一段程式碼,而 call stack 便是 JavaScript 追蹤程式碼跑到哪裡的機制。

call stack 採取的是 LIFO (last-in-first-out) 原則,也就是後進先出,所以當 JavaScript 要執行一個函式,會先把函式丟到 call stack 最頂端,執行完畢之後,再離開 call stack

直接用程式碼和圖解看會更清楚:

function add(a, b) {
return a + b;
}

function average(a, b) {
return add(a, b) / 2;
}

let x = average(10, 20);
  1. 建立全域執行環境 (global execution context),這邊以 main() 來代稱。
  2. JavaScript 執行 average(10, 20) 的呼叫,並建立 average() 執行環境,然後把 average() 放到堆疊頂部。因為 average() 在堆疊頂部,所以 JS 開始執行它。
  3. average() 呼叫了 add()。這時候 JS 建立了 add() 的執行環境,然後把 add() 放到堆疊頂部。
  4. JS 執行完 add(),回傳值,把 add() 從堆疊頂部拿下來。
  5. JS 執行完 average(),回傳值,把 average() 從堆疊頂部拿下來。
  6. call stack 淨空,script 停止運作。


截至目前的小重點:

  • JavaScript 是單執行緒的語言,只有一個 call stack一次只能做一件事情
  • 函式進入和離開 call stack 的順序是後進先出的。


同步造成的問題

JavaScript 一次只能做一件事情了,這樣有什麼問題嗎?既然有那麼多人在討論,當然就是有囉。前面提過 JavaScript 是一行一行程式碼由上往下執行的,假設中間有某個函式花很多時間,那整個網頁就和當掉沒兩樣,點滑鼠、敲鍵盤都不會有回應,因為上一個動作還沒完成。

我們用程式碼模擬向遠端伺服器呼叫 API 的狀況,如果採取同步:

function task(message) {
// 模擬遠端伺服器很爛,所以資料回傳曠日廢時
let n = 10000000000;
while (n > 0){
n--;
}
console.log(message);
}

console.log('開始作業......');
task('資料回傳了');
console.log('作業完成');

//​開始作業......
//資料回傳了
//作業完成


因為是同步的程式碼,所以結果是從上到下的順序沒錯,但中間因為遠端伺服器不夠力,所以耽擱了好幾秒鐘的時間。

同步容易造成程式碼阻塞 (blocking) 的問題!

Callback 函式

為了避免上述的阻塞問題,最常見的方法是調用 callback 函式,其中最經典的又屬 setTimeout()setTimeout() 會等待你所指定的時間,等時間一到,再把 callback 函式丟回到 JS 的 call stack ,有點中途離場,再排隊加入的概念。

console.log('開始作業......');

setTimeout(() => {
console.log('資料回傳囉');
}, 1000);

console.log('作業完成');

//​開始作業......
//作業完成
//資料回傳囉


在這段時間內,JavaScript 似乎做了這些事情:

  1. 列印出開始作業......
  2. 等待 setTimeout() 指定的 1 秒鐘 (1,000 毫秒)
  3. 還在一秒鐘的等待時間,就直接列印出作業完成
  4. 一秒鐘到了,印出資料回傳囉


等等,可是我們剛剛一直說 JavaScript 一次只能做一件事情,所以當它處在在一秒鐘等待時間,應該無法列印出作業完成不是嗎?


JavaScript 執行環境

JavaScript 是個程式語言,而就像演員需要舞台展現魅力,程式語言也需要執行環境才能一展拳腳。由於 JavaScript 起初是為了寫網頁程式才誕生的,因此瀏覽器便成了 JavaScript 最常見的執行環境。

當我們呼叫 setTimeout()、發送 AJAX requet、或是按下按鈕等等,瀏覽器都能以協作者的身分 (提供 WebAPI),同時地 (concurrently) 幫我們完成工作,達到非同步的效果。

再次強調,這邊的非同步,指的是同時間處理很多件事情

所以其實不是 JavaScript 一邊數碼表、一邊執行後面的程式碼,而是我們將數碼表這件事情,外包給瀏覽器處理,這樣 JavaScript 就能繼續執行程式碼,不造成阻塞。JavaScript 始終一次只能做一件事情。


Event loop:實現非同步的關鍵要角

把工作外包出去很好,但這些工作中終究還是要回歸到 call stack 裡面,讓 JavaScript 來執行 setTimeout() 的 callback 函式。那我們應該在什麼樣的時間點把 callback 函式調回去呢?這就要來看看 event loop 機制了。

raw-image
Event loop 就像一台永不停歇的監控器,時時關注 callback queue 以及 callback stack 的狀況。若看到 stack 淨空了,就會把 queue 的函式丟回到 stack。

這邊請特別留意一下,callback queue 採取的是先進先出 (FIFO) 原則,和 callback stack 的後進先出不同。




我們用以下程式碼,一步一步拆解來看:

console.log('開始作業......');

setTimeout(() => {
task​('資料回傳囉');
}, 1000);

console.log('作業完成');


在這個範例中,當我們呼叫 setTimeout(),JavaScript 引擎把它放到 call stack 頂部,於此同時,瀏覽器 Web API 按下碼表,開始計時一秒鐘。

raw-image


一秒鐘過後,JavaScript 將 task() 放到 callback queue 裡面排隊,等待 Event Loop 將它調回 call stack 去。但現在還不是時候,因為 call stack 裡面尚有一個 console.log() 還沒執行完畢。

raw-image


Event Loop 辛勤地監控,確認 call stack 清空之後,回頭看看 callback queue 有誰在排隊......嗯,只有 task() 你一人啊,好吧好吧,那你趕快回去 call stack 吧。

raw-image


SetTimout 0 秒

上面的例子有一個隱藏的重點,那就是 setTimeout() 一秒過後不保證馬上執行,因為 Event Loop 還要確認 call stack 是否清空,以及 callback queue 前面是否還有人排隊,才能把 setTimout() 的 callback 函式調回去。

有了這樣的認知後,我們就不會被這個案例給迷惑了:

console.log('頭香');

setTimeout(() => {
console.log('給我馬上執行!');
}, 0);

console.log('掰掰');

//頭香
//​掰掰
//​給我馬上執行!


雖然 setTimeout() 的時間條件設定為 0 秒,但由於 Event Loop 盡忠職守,還是會先等到 console.log("掰掰") 從 call stack 離開後,再把 callback 函式 (給我馬上執行) 調回去 call stack 執行。



參考資料



16會員
34內容數
Bonjour à tous,我本身是法文系畢業,這邊會刊登純文組學習網頁開發的筆記。如果能鼓勵更多文組夥伴一起學習,那就太開心了~
留言0
查看全部
發表第一個留言支持創作者!