JavaScript — async/await 的 race condition

LiRen Lin
10 min readJun 5, 2021

(6/6 晚間追記)
關於 race condition 一詞的使用,我參考的 race condition 定義是來自 wiki 的「the condition of an electronics, software, or other system where the system’s substantive behavior is dependent on the sequence or timing of other uncontrollable events」,且我搜尋了「JS race condition」有看到一些國外文章討論類似的情況,因此我認為 race condition 用在以下文章討論到的情況,應該是可以的。
當然就細節而言,就是「單一執行緒中的事件執行順序」上的差異,只是 async 的執行順序是不太可控制的,要看 promise 是甚麼時間被完成,且 promise 不完全是先建立先完成。

TL;DR,在 async / await 後的程式段落,要特別注意拿到的結果與現在的 data state 是否還有相關,以及後續取用資料時,該資料是否已經不如預期。

希望先備知識:Event loopasync/await (promise)
部分程式會用 React 的情境舉例。
若有先備知識不清楚的觀眾,可以參考相關文章,以及其他文章的分享。

雖然 JS 是單一執行緒的程式語言,但在 promise 的相關非同步實作上,是有機會碰到 race condition 的情況。

先來個簡單的例子,了解一下 async / await 的執行順序:

這段程式碼宣告了兩個 function 並馬上呼叫,預期最後輸出的結果會是甚麼呢?
答案是:

如果你原先預期,第二次輸出會顯示成 "after, { value:2}" 的話,那就是執行順序搞錯了。
這段程式的執行順序大致上會長這樣:

1. 宣告 value =1
2. 宣告 waitFunction 與 setFunction
3. 執行 waitFunction
4. 第一次 console.log
5. value = 2
6. 宣告 promise,註冊 setTimeout
7. 等待 promise 完成,此時把這個事件放到 event queue 裡面
8. 執行 setFunction,value = 3
9. 因為過了一秒了,所以 setTimeout 觸發,resolve promise
10. 執行第二次 console.log,此時 value 為 3

哎,可是 async / await 不是會等待嗎? 怎麼沒有等待?
實際上有等待,只是後續 event loop 挑了其他事件來做,沒有把整個執行過程停住,並不是完全等待 waitFunction 做完才執行 setFunction。

那我們直接呼叫成 await waitFunction 就好了吧?但是 await 要在 async function 才能使用,所以總會有一個進入點是用 sync 的方式呼叫 function 的。

這個案例比較容易推敲出執行順序,所以還算能預見執行結果為何。
後面來點無法預期執行順序的狀況。

情境: Fetch Data

接下來,來一個常見的情境: Fetch Data。
快速的討論一下這個 sample code:

這段 sample code 是 React hooks 的寫法,我省略了很多內容。
大致上的流程是,如果 page 被改變了,就會觸發一次 useEffect 的 function 執行,也就是會執行 fetchData。
用來模擬如果要跟 backend 拉取某個表格的資料,而且有做分頁處理。

這段 code 會有甚麼問題呢?

我們沒有辦法預期,先發送的 API 請求,會先收到對應的回應。

這是甚麼意思?

假設使用者點擊了第一頁,發送了 page 1 的請求給 backend。
然後回應還沒有回來,使用者就點了第二頁,發送了 page 2 的請求給 backend。

這時,我們有兩個 API request 正在等候回應。而因為種種因素(譬如網路因素),導致 page 2 的回應先回來了,我們就會先把 page 2 的回應寫回 state (setResponse),預期我們畫面上會顯示 page 2 的內容。

這樣好像很合理耶,使用者在第二頁,顯示的內容也正確啊?

然後 page 1 的回應可能現在才回來了,於是乎,把回應寫回了 state (setResponse),畫面上的資料就變成了第一頁的資料,明明使用者在第二頁。

但我們也沒辦法中斷已經發出去的 API 請求,這種情境要怎麼處理?
以下舉例幾個方案做為參考,但請注意,這並非代表只有這幾種做法:

方案1: 搭配 useEffect 的特性,用區域變數卡位 (閂鎖 latch)

利用 useEffect 的特性,在每次要執行新的 side effect 前,把 ignore 的值設成 true,並且在 setResponse 前,檢查 ignore 的值,若 ignore = true 則不做 setResponse,就可以迴避把資料寫回 state 的問題。

方案2: 先行比對 state

如果是 React class component,沒有 useEffect hooks 怎麼辦,更甚是非 React 的狀況呢?
那我們可以在 setState 前,先比對跟現在的 state 是否相同,相同再寫回 state 裡面。

這個用法是依賴於 this.state 會是唯一參考且 class 內都能存取得到,在準備寫入 state 的當下,先看 this.state.page 是不是我們發出請求的 page,如果是才寫回。這個做法雖然也有機會碰上 setState 是非即時性的,可能前面已經有新的 page setState 且還沒生效,但已經可以迴避掉大部分的狀況了。

另外這個做法比較不能使用在 React hooks 裡面,因為 page 的變化不會在這個 cycle 生效,此時拿到的 page 依舊是該次 render 時的狀態。

方案3: Redux with middleware

通常使用 React 的時候,遇到這類有非同步且 call API 的操作,滿多實作會採用 Redux 以及相關的 middleware 做處理,並把 API 結果放置在 reducer,在這邊提供幾個作法:

  • Redux-toolkit: 搭配 thunk 並包裝了不少功能,其中一個功能是執行 async action 後會拿到一個有額外包裝過的 promise。在 promise 還沒完成前,可以直接執行 promise.abort() 來取消這個 action,使結果不會進 reducer。
  • Redux-obersable: Redux + RxJS 的 middleware,利用 RxJS 的 takeUntil 操作,使流程中斷,一樣不會讓結果進 reducer。或是簡單一點,用 switchMap,直接關注最後一次動作的結果。
  • 沒有 middleware: 由於 Redux 有保證送進 Reducer 的時候都是 sync 執行,因此可以回歸上述所提的方案2,先行比對 Redux state 再做修改。

情境: MediaDevices.getUserMedia()

接下來要講的情境,稍微跟 React 脫離一下,來講 Web API 好了,getUserMedia()

這段 sample code 是在模擬一個情境:使用者可以選擇聲音輸入的裝置,並且拿到對應的 steam 來做使用。譬如在做 WebRTC 即時通訊的時候,用來切換輸入裝置與 steam。

使用者操作 UI 的時候,可能會使用 Select & Option 來作為選擇操作,此時 UI 上的 onChange 都會用 sync 的方式呼叫,並不會用 await 來呼叫。

discord 所提供的切換輸入裝置 UI

若使用者多次選擇裝置,我們會因為 getUserMedia 的非同步因素,以及 selectedAudioDeviceId 值的頻繁更換,而產生 stream 與目前選擇的 device 可能不相同。

因此,在 getUserMedia 的操作真的完成後,我們要再把 selectedAudioDeviceId 與 getUserMedia 時所帶入的 deviceId 做比對,若相同才把 stream 做設定,不相同則把 stream 的 track 全部停掉,讓 stream 停下等著讓 GC 回收。讓 stream 停下是很重要的事情,如果是做 video stream (存取視訊鏡頭) 會更明顯,沒有讓 stream 停下,可能就會看到鏡頭持續亮燈(代表使用中)。

情境: 帶到錯誤的值

這個情境就跟「寫入時」比較沒有關聯了,這次是存取到可能不正確的資料

這段 sample code 是模擬一個情境,假設我們每 3 秒會做一個 report,並且把 report 與現在的一些 state 狀態一起送往 backend,這段 code 會發生甚麼事情呢?

通常這種情況,我們應該預期 report 跟被觸發動作時的 data 搭配送給 backend,也就是說在不改變 data.value 的情況下,我們應該是送出 value = 1 與 report 一起給 backend。

對,在不改變 data.value 的情況下。

如果說生成 report 還沒完成的時候,因為其他因素而改變 data 的話呢?
那在這個 sample code 的實作中,就會帶到改變後的 data,可能導致 report 失真 (如果也需要 data value 一起判讀的話)。

時間軸參考

這種狀況,就要在 setInterval 觸發的時候,在執行到 await 的片段前,先把 data value 的值 copy 出來(snapshot),最後寫進送往 backend 的 payload 裡,而不是等到要送往 backend 的時候才對 data 做存取。

時間軸參考

就算要送的 value 是一個 object 的形式也可以這樣處理,只要變更 data.value 的時候,是做一個新的 object 就好,如果是直接對原有的 object 做更改的話,那一開始在 copy value 的時候就要寫得特別詳細。

情境: forEach

其實不只 forEach,map 這類的 array method 實際上在呼叫的時候,都是用 sync 的方式在處理,也就是不會管 callback 是不是 async function。

因此也要注意可能有執行順序不如預期的狀況,如果真的要「保證線性執行」,要改採用 for 的語法等等方案來做相關處理。

相關方案的傳送門可參考這裡。google 「forEach async 」也能找到相關討論。

總結一下,因為 Event loop 的關係,在執行到 await 的程式片段後,要特別注意後續的相關操作有沒有跟 state 是否相符,以及 state 的 variable scope 到哪裡,不然可能發生不如預期的結果。

以上,感謝各位收看!

--

--