Jeff的隨手筆記

學習當一個前端工程師

0%

『新手日記』Day-26 同步非同步

『新手日記』Day-26 同步非同步

https://miro.medium.com/max/1400/1*crk2WP5jfhZmh1JJQEA2Ow.jpeg

居然是因為生病中斷連載,難過!!!

最早在AC有一堂課是要我們學習分析什麼是 event loop。 在那邊第一次對同步跟非同步有了一絲的印象,那時候的文章:

https://www.notion.so/168538cd537243a18f89ba1dcca8b16d#49f4a62c06c7401084b31fb0f07df83e

之後陸陸續續在課堂或文章內容裡都有提到這個概念,但好像真的沒有認真的統整過一次,剛好在最新的課程裡講了Callback、Promises、Async/Await,又加上我第一次沒有聽懂決定再看一次,順便統整一次內容。

前言

我們都知道『JavaScript 是一種單線程(single threaded runtime)的程式語言』,因為作為瀏覽器的腳本(程式語言),他負責處理頁面的互動以及Dom的操作,讓你可以在網頁中實現出複雜的功能。但JavaScript 只是程式語言,因此我們需要一個『執行環境(runtime)』所提供的東西,例如說 setTiemout、document 等等,而這個 runtime可以是瀏覽器或是我們最近在學的Node.js。

blocking(阻塞)與non-blocking(非阻塞)

1
2
3
4
5
6
7
8
9
10
let queue = []
for (let i = 0; i < 9999; i++) {
queue.push({ key: i })
}
setTimeout(() => {
console.log('hello world')
}, 0)
queue.forEach((item) => {
console.log(item)
})

我們用這段程式碼來解釋什麼是 blocking,大家可以順便猜一下會consloe.log出來什麼。

雖然setTimeout 的時間間距設定為 0,乍看之下是「馬上執行」的意思。但因為setTimeout 是屬於非同步事件,因此還是會在其他原始碼運行完以後才執行。

執行流程如下圖:

https://miro.medium.com/max/1400/1*sqbXQ54bn1nJdR2Kn2BhhQ.png

在執行queue.forEach 由於資料過於龐大程式就會停在queue.forEach,要等執行完畢以後,才會去執行setTimeout。換句話說,queue.forEach「阻擋」了後續指令的執行,這時候我們就說這是一個 blocking(阻塞),因為程式的執行會一直 block 在這裡,直到執行完畢值為止。

如果後續指令跟現在在執行的程式有關那就只好認了,但如果現在執行的事情跟後續執行的事情一點關係都沒時,那把時間都浪費在這真的會奢侈。要解決這個狀況那我們就會運用到 non-blocking(非阻塞)。

用一個生活化的方式來解釋:去百貨公司美食街點餐,點完以後店家會給我一個呼叫器,等到餐點準備好的時候,呼叫器就會響,我就可以去店家領取餐點,而不用在原地傻傻地等。所以要解決這個問題我們就必須拿到那個『呼叫器』,而在 JavaScript 裡面,function 就很適合當作呼叫器!意思就是「當這個程序執行完畢時,請來執行這個 function,並且把結果傳進來」,而這個 function 又被稱作 callback function(回呼函式)

這樣我們就可以“看似”一次執行多個程式碼了,但請記住他只是“看似”而已,JavaScript 只是擅長用非同步的方式在不同事情間「切換」,它的本質仍然是一次執行一件事 (single thread)。

synchronous/asynchronous

了解了什麼是blocking(阻塞)與non-blocking(非阻塞)後,其實你已經懂了synchronous(同步) 與 asynchronous(非同步),為什麼這麼說呢?因為在Node.js 的官方文件是這麼說的:

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

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

但我相信還是會有很多人跟我一樣,同步不就是同時進行嗎?為什麼我卻只能一次執行一件事。

這就要提到中文翻譯的問題了,但我英文很差這邊就用另一個方式跟大家解釋:

synchronous(同步)

Synchronous:餐廳老闆自己從帶位、點餐、煮菜等,一條龍完成所有作業,在完成一組客人後,才能接待下一位客人。

https://miro.medium.com/max/1400/1*-6eUIGF8QthDjcEBQuWxVQ.png

在JavaScript大部分功能都是同步的,而同步的定義是指:程式碼的執行順序,依照由上而下執行,最後才輸出。

優點當然是很好閱讀,但缺點就是效率變很差

asynchronous(非同步)

Asynchronous:餐廳老闆請了不同的人負責各自專門的事情,服務生負責點餐、廚師負責煮飯。在固定的時間內,可以藉由大家的合作,處理多組客人。

https://miro.medium.com/max/1400/1*1UFWMlbdZqNzmZXqSi8mjw.png

在這案例我們可以得知:

  • 非同步機制能加速餐廳運作,更高效率的處理事情!
  • 但非同步的狀況下,不同客人在不同階段需要的服務是不同的,因此員工間可能會產生「等待」的過程。

我們可以總結:所謂的「非同步處理」,可以說就是釐清各式各樣的「等待」流程,並且在因「等待」而複雜化的程式流程中,確保程式能正確運作。。

非同步處理的演進

https://miro.medium.com/max/1400/1*2ZVcVNQNDxHw6RcaM20aMA.png

接下來我會用同一個案例來練習同步處理:

虛擬碼

實際開發過程裡,收到專案規格後不會馬上開始撰寫程式碼,而是會用虛擬碼(pseudocode) 來釐清程式的邏輯。

https://miro.medium.com/max/1394/1*xZPXXJdhY1Zc6vEPNGzAPg.png

專案目的:你需要為 2 位重要的客人安排一趟台北美食探索之旅,現在你手邊精選出 6 家優質餐廳,需要安排他們各自嘗試其中的 3 間餐廳,2 位客人分配到的餐廳沒有重覆。

先簡單拆解程式必要做的事情:

設定資料並連線資料庫:

  • 從資料庫取得資料
  • 將資料放進資料庫裡

推薦餐廳的邏輯:

  • 先建立使用者資料、找到使用者與餐廳的關聯,並生成結果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 資料描述
users = [ user1, user2 ]
restaurants = [ rest1, rest2, rest3, rest4, rest5, rest6 ]
// 資料庫連線設定
連接資料庫('資料庫名稱')
等待資料庫連接成功(100)// Step 1.建立使用者資料
function 對每一個user進行處理 (user) {
創建使用者資料(user)
等待創建使用者資料完成(100) // Step 2.建立使用者與餐廳關聯
function 對每個user與restaurant進行處理 (rest){
對每個user建立相對應餐廳資料(rest, user)
等待創建餐廳資料完成(100)
}(rest of restaurants)
} (user of users)// Step 3. 程式終止
等待所有使用者的餐廳資料創建都完成(100)
  • 由於資料庫對這支 JavaScript 程式來說,是一個外部的 server,我們不確定是否資料庫會連接成功,因此必須等待資料庫回傳「連接成功」的確認信號後,才能繼續往下執行程式。
  • 若忽略了這個「等待」就匆匆執行「塞資料」的程序的話,就會出現資料消失等不能理解的狀況。

callback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
db.once('open', () => {
console.log("openData connected")
for (const [user_index, user] of users.entries()) {
// 創建使用者資料(user): model.create
UserModel.create(user, (err, user) => {
console.log("user created")
const userRestaurant = []
restaurants.forEach((restaurants, rest_index) => {
if (rest_index >= 3 * user_index && rest_index < 3 * (user_index + 1)) {
restaurants.userId = user._id
userRestaurant.push(restaurants)
}
});
// 對每個user建立相對應餐廳資料
RestaurantModel.create(restaurants, (err, user) => {
// 等待所有使用者的餐廳資料創建完成
console.log('所有使用者與餐廳資料創建完成')
UserModel.find().count((err, count) => {
if (count >= users.length) {
print.exit()
}
})
})
})
}
})

for (const [user_index, user] of users.entries())

首先,使用 for拿出使用者的資料,並對每個使用者進行處理。因為要拿到資料庫裡的key/value pairs 我們使用entries 取出 user_index。

UserModel.create(user, (err, user) => {…}

接著,使用 mongoose 提供的 Model.create 建立使用者資料,這裡是一個典型的非同步操作:

  • 若操作成功,資料庫就會回傳一個成功創建的 user 物件,也就是我們設定給 UserModel.create 的第一個參數
  • 若成功取得 user 物件,就執行第二個參數中設定的 callback function,也就是 (err, user)=>{ }) 這段

restaurants.forEach((restaurants, rest_index) => {…}

用 forEach 取出 data 中 restaurants 資料(包含 restaurant, rest_index),下一步用條件式建立使用者與餐廳資料的關聯。

條件式建立後,每個 restaurant 會有一個 userId 的參數,與創建使用者時自動生成的 user._id 做 mapping,可以用 restaurant.userId = user._id 表示,建立使用者跟餐廳資料的關聯。

https://miro.medium.com/max/1400/1*XLnZDvUhrFsBAenzltm2aw.png

接下來想要用 RestaurantModel.create 將餐廳資料一次性的塞進資料庫裡,需要先建立 userRestaurant = [] ,並用 userRestaurant.push(restaurant) ,將匹配好的資料暫存到陣列中。

RestaurantModel.create(restaurants, (err, user) => {…}

程式終止的條件是:等待所有使用者的餐廳資料都建立完成。

在邏輯設計上,我們可以用 count 函式確認所有 users 與 restaurants 都成功創建完畢,來判定資料建立完成並結束執行程式。

此時可以用 node.js 提供的 process.exit 來終止程序。

Promises

Promise 是一個物件建構子 (constructor),使用時需要先從 Promise 物件產生物件實例 (instance),再使用繼承特性的 instance 去包裝程式碼的 callback 流程。

callback hell

https://www.notion.so/168538cd537243a18f89ba1dcca8b16d#2dd5a20abbf743cbb92bbed9f103796e

從 Callback 到 Promise

https://miro.medium.com/max/1400/1*t9vJZA-tDQ-tXrXF6pUh4A.png

從邏輯上來說,程式流程有 4 段:

  1. 先創建 User 資料,也就是 for loop + UserModel.create 的段落
  2. UserModel.create 執行成功後,略為整理資料,進行 User x Restaurant 的配對
  3. 再用 RestaurantModel.create 創建資料,
  4. 兩個資料都創建好以後,就印個 console.log 然後終止程式

在 callback 的版本中,流程 3 包含在 2 的 callback 裡,然後 4 又包在 3 的 callback 裡。

至於在 Promise 版本裡,可以運用 then 的架構,把四段流程刻意拉出來,創造一種由上而下的閱讀體驗。

async/await

使用邏輯很簡單,如下圖示意:

https://miro.medium.com/max/1400/0*ZBkFYwv8WSZvDmVE.png

原本當同步與非同步邏輯混搭時,我們會需要用一堆 then 來控制先後順序,而在 async/await 中,只要關鍵字放對了,就可以確保程式按照「視覺上由上而下的順序」來執行。例如上圖示意的 1~5。

使用時要把握三個原則:

1.要先確認有 Promise 物件實例,也就是已經定義好 resolve/reject,才能使用 async/await

2.在流程中正確設定關鍵字:

  • 把後續流程用一個 async function 包裝起來
  • 設定好 async function 之後,在要運用非同步處理的地方加上 await 關鍵字

3.注意 async/await 和 then 不可以混搭使用

流程架構

https://miro.medium.com/max/1400/0*ZP5aNyvd4YaOLES7.png

仍然會有雙層的結構,因此兩個層級各需要一個 async 關鍵字,而 await 後面接的都是 Promise 物件,也就是需要非同步處理的區塊。