JavaScript 中的同步與非同步 & event loop


Posted by ericcch24 on 2020-10-16

阻塞與非阻塞 blocking & non-blocking

  1. 阻塞(blocking)代表執行時程式會卡在那一行,直到有結果為止,例如說readFileSync,要等檔案讀取完畢才能執行下一行
  2. 非阻塞(non-blocking)代表執行時不會卡住,但執行結果不會放在回傳值,而是需要透過回呼函式(callback function)來接收結果

同步與非同步 synchronous & asynchronous

node.js 部分

Node.js 的官方文件說:

Blocking methods execute synchronously and non-blocking methods execute asynchronously.

阻塞的方法會同步地(synchronously)執行,而非阻塞的方法會非同步地(asynchronously)執行

簡單舉例:
在又要讀檔又要印出偶數的範例中,同步指的就是彼此互相協調互相等待,所以讀檔還沒完成的時候,是不能印偶數的,印出偶數一定要等到讀取檔案結束之後才能進行。

非同步就是說各做各的,你讀檔就讀你的,我繼續印我的偶數,大家腳步不一致沒關係,因為我們本來就不同步。

在討論到 JavaScript 的同步與非同步問題時,基本上你可以把非同步跟非阻塞劃上等號,同步與阻塞劃上等號。如果你今天執行一個同步的方法(例如說readFileSync),就一定會阻塞;如果執行一個非同步的方法(readFile),就一定不會阻塞。

重點:

  1. 同步(synchronous)代表執行時程式會卡在那一行,直到有結果為止,例如說readFileSync,要等檔案讀取完畢才能執行下一行
  2. 非同步(asynchronous)代表執行時不會卡住,但執行結果不會放在回傳值,而是需要透過回呼函式(callback function)來接收結果

瀏覽器上的同步與非同步

同步寫法:

const response = getAPIResponse()
console.log(response)

同步會發生什麼事?就會阻塞後面的執行,所以假設 API Server 主機規格很爛跑很慢需要等 10 秒,整個 JavaScript 引擎都必須等 10 秒,才能執行下一個指令。

如果把 JavaScript 的執行凍結在那邊 10 秒,就等於說讓執行 JavaScript 的執行緒(thread)凍結 10 秒。在瀏覽器裡面,負責執行 JavaScript 的叫做 main thread,負責處理跟畫面渲染相關的也是 main thread。換句話說,如果這個 thread 凍結 10 秒,就代表你怎麼點擊畫面都不會有反應,因為瀏覽器沒有資源去處理這些其他的事情。這時候整個畫面就會像當機一樣什麼事都不能做。

所以像是網路這麼耗時的操作,是不可能讓它同步執行的。既然要改成非同步,那依據之前學過的,就要改成用 callback function 來接收結果:

// 底下三個範例,都在做一模一樣的事情

// 範例一
// 最初學者友善的版本,額外宣告函式
function handleResponst() {
  console.log(response)
}
getAPIResponse(handleResponst)

// 範例二
// 比較常看到的匿名函式版本,功能跟上面完全一樣
getAPIResponse(function(err, response) {
  console.log(response)
})

// 範例三
// 利用 ES6 箭頭函式簡化過後的版本
getAPIResponse((err, response) => {
  console.log(response)
})

而實際用 Ajax 呼叫後端 API 的程式碼會長這樣子:

var request = new XMLHttpRequest();
request.open('GET', 'https://jsonplaceholder.typicode.com/users/1', true);

request.onload = function() {
  if (this.status >= 200 && this.status < 400) {
    console.log(this.response)
  }
};

request.send();

這邊的 callback function 就是 request.onload =後面的那個函式,這一行的意思就是說:「當 response 回來時,請執行這個函式」。


callback

callback function 的意思其實就是:「當某事發生的時候,請利用這個 function 通知我」

例如:

const btn = document.querySelector('.btn_alert')
btn.addEventListener('click', handleClick)

function handleClick() {
  alert('click!')
}

「當某事(有人點擊 .btn_alert 這個按鈕)發生時,請利用這個 function(handleClick)通知我」,handleClick就是 callback function。


初學者易混淆部分

// 錯誤範例
setTimeout(2000, tick())
function tick() {
  alert('時間到!')
}

// 上面的錯誤範例等同於
let fn = tick()
setTimeout(2000, fn)
function tick() {
  alert('時間到!')
}

上面的意思是tick是一個 function,tick()則是執行一個 function,並且把執行完的回傳結果當作 callback function。

由於 tick 執行後會回傳 undefined,所以 setTimeout 那行可以看成:setTimeout(2000, undefined),一點作用都沒有。

把 function 誤寫成 function call 以後,會產生的結果就是,畫面還是跳出「時間到!」三個字,可是兩秒還沒過完。因為這樣寫就等於是你先執行了 tick 這個 function。


Callback function 的參數

const btn = document.querySelector('.btn_alert')
btn.addEventListener('click', handleClick)

function handleClick() {
  alert('click!')
}

==一個叫做 event 的 object 會被傳進去,而這個 object 是在描述這個發生的事件。==

const btn = document.querySelector('.btn_alert')
btn.addEventListener('click', handleClick)

function handleClick(e) {
  console.log(e) 
  // 這時會印出一個有超多屬性的物件
  // 物件其實就是在描述剛剛的「點擊」
}

function 在傳送以及接收參數的時候,注重的只有「順序」,而不是文件上的名稱。文件上的名稱只是參考用的而已,並不代表你就一定要用那個名稱來接收。function 沒有那麼智慧,不會根據變數名稱來判斷是哪個參數。

所以== callback function 參數名稱想要怎麼取都可以==,handleClick(e)handleClick(evt)handleClick(event) 或是 handleClick(yoooooo)都可以,都可以拿到瀏覽器所傳的event這個物件,只是叫做不同名稱而已。


Callback 的 error first 慣例

流程 code 詳見參考資料

在同步的版本當中,我們會等待檔案讀取完畢才執行下一行,所以讀取檔案的時候出了什麼錯,就會把錯誤拋出來,我們就可以 try…catch 去處理。

但是在非同步的版本中,fs.readFile這個 function 只做了一件事,就是跟 Node.js 說:「去讀取檔案,讀取完之後呼叫 callback function」,做完這件事情之後就繼續執行下一行了。

所以讀取檔案那一頭發生了什麼事,我們是完全不知道的。

callback 會有兩個參數,第一個是 err,第二個是 data,這樣你就知道 err 是怎麼來的了。只要在讀檔的時候碰到任何錯誤,例如說檔案不存在、檔案超過記憶體大小或是檔案沒有權限開啟等等,都會透過這個 err 參數傳進來,這個錯誤你用 try…catch 是抓不到的。

所以,當我們非同步地執行某件事情的時候,有兩點我們一定會想知道:

  1. 有沒有發生錯誤,有的話錯誤是什麼
  2. 這件事情的回傳值

舉例來說,讀取檔案我們會想知道有沒有錯誤,也想知道檔案內容。或是操作資料庫,我們會想知道指令有沒有下錯,也想知道回傳的資料是什麼。

既然非同步一定會想知道這兩件事,那就代表至少會有兩個參數,一個是錯誤,另一個是回傳值。小標題所說的「error first」,就代表錯誤「依照慣例」通常會放在第一個參數,其他回傳值放第二個以及第二個之後。

先判斷有沒有錯誤再做其他事情:

const fs = require('fs')
fs.readFile('./README.md', (err, data) => {
  // 如果錯誤發生,處理錯誤然後返回,就不會繼續執行下去
  if (err) {
    console.log(err)
    return
  }

  console.log(data)
});

Q: 那為什麼 setTimeout 或是 event listener 這些東西都沒有 err 這個參數?」

那是因為這幾個東西的應用場合不太一樣。

setTimeout 的意思是:「過了 n 秒後,請呼叫這個 function」,而 event listener 的意思是:「當有人點擊按鈕,請呼叫這個 function」。

「過了 n 秒」以及「點擊按鈕」這兩件事情是不會發生錯誤的。

但像是 readFile 去讀取檔案,就有可能在讀取檔案時發生錯誤;而 XMLHttpRequest 則是有另外的 onerror 可以用來捕捉非同步所產生的錯誤。


小總結

  1. 瀏覽器裡執行 JavaScript 的 main thread 同時也負責畫面的 render,因此非同步顯得更加重要而且必須,否則等待的時候畫面會凍結
  2. callback function 的意思其實就是:「當某事發生的時候,請利用這個 function 通知我」
  3. fn 是一個 function,fn() 是執行 function
  4. callback function 的參數跟一般 function 一樣,是看「順序」而不是看名稱,沒有那麼智慧
  5. 依照慣例,通常 callback function 的第一個參數都是 err,用來告訴你有沒有發生錯誤(承第一點,你想取叫 e、error 或是 fxxkingError 都可以)
  6. 非同步還是有可能用 try catch 抓到錯誤,但那是代表你在「呼叫非同步函式」的時候就產生錯誤

Event loop

event loop 的作用:

不斷偵測 call stack 是否為空,如果是空的話就把 callback queue 裡面的東西丟到 call stack

以程式的角度去想,event loop 之所以叫做 loop,就是因為它可以表示成這樣:

while(true) {
  if (callStack.length === 0 && callbackQueue.length > 0) {
    // 拿出 callbackQueue 的第一個元素,並放到 callStack 去
    callStack.push(callbackQueue.dequeue())
  }
}

這邊只要掌握一個重點就好:「==非同步的 callback function 會先被放到 callback queue,並且等到 call stack 為空時候才被 event loop 丟進去 call stack==」

Event loop 就是那種只會出一張嘴不會做事的人,它不負責幫你執行 callback function,只會幫你把 function 丟到 call stack,真正在執行的還是 JavaScript 的 main thread。

  • 範例:

執行順序一樣是由上到下,只是在 setTimeout 那邊是先把這整段放到 call stack 裡面去執行,所以才會執行 setTimeout 這個 function。然後 setTimeout 會呼叫瀏覽器幫忙設定一個 0 ms 後到期的定時器,到期之後就會把第一個參數:() => {console.log('hello')} 整個 function 放進去 callback queue,
而 call stack 一樣會依序執行同步的指令,call stack 執行完沒東西後 event loop 才會把在 callback queue 排隊的 () => {console.log('hello')} 丟到 call stack 執行。

console.log(1)

setTimeout(() => {
  console.log(2)
}, 0)


console.log(3)

// 執行順序一樣是

// 1
// 3
// 2

錯誤範例

console.log(1)        // 放入 Call Stack 並直接執行,印出 1,執行完後移除
setTimeout(() => {    // setTimeout() 放到 Webapis 執行,直到倒數完畢,
  console.log(2)      // () => { console.log(2) } 被放到 Callback Queue 待命
}, 0)

錯誤的點在:「setTimeout() 放到 Webapis 執行」,web api 不是一個地方,是一個種類,setTimeout 是屬於 web api 的其中一個,但是不是 web api 跟非同步無關。

Web API 不是一個地方。這邊可以直接講瀏覽器就好,呼叫 setTimeout 之後叫瀏覽器設定一個計時器,0ms 之後會觸發,那這個計時器設定在哪邊?不重要,這是瀏覽器會去處理的事。

其他錯誤範例:
week16 自我檢討


還有一些同步與非同步小測驗,詳見參考資料

參考資料:
JavaScript 中的同步與非同步(上):先成為 callback 大師吧!
What the heck is the event loop anyway? | Philip Roberts | JSConf EU

tags: Week16

#week16







Related Posts

4. 安裝與使用第三方套件

4. 安裝與使用第三方套件

PM 工作流程解析與怎麼寫 PRD

PM 工作流程解析與怎麼寫 PRD

[Note] Vite 組態配置(持續更新)

[Note] Vite 組態配置(持續更新)


Comments