iT邦幫忙

2021 iThome 鐵人賽

DAY 2
0
Software Development

Coroutine 停看聽系列 第 2

Day2:非同步執行與 Callback 的問題

在前一篇文章中,我們知道依據程式的執行順序分成兩種執行方式,一種是同步(Synchronous) 、另一種則是非同步(Asynchronous)

同步

同步的執行方式會依照程式碼的順序,依序執行。如下:

fun sayHello(){
	println("Hello")
	println("Nice to meet you!")
}

執行 sayHello() 我們就可以得到如我們程式碼順序的結果。

Hello
Nice to Meet you!

執行順序圖

https://imgur.com/U1ZiJ98

好的,同步程式的執行方式是如此直覺:按照順序。


假如,有一個方法 showContents() 為我們登入 FB 之後,系統會根據登入的使用者,把最近的內容列出來。

以這個例子來說,我們會有三個步驟,

  1. 登入 FB,取得使用者資訊。
  2. 根據使用者資訊,來取得最近的內容
  3. 將最近的內容顯示在畫面上

Pseudo code 如下:

fun showContents() {
	val token = login(userName, password)
	val contents = fetchLatestContents(token)
	showContents(contents)
}

login() 會回傳 TokenfetchLatestContents() 會使用 login() 所回傳的 Token 來呼叫另一個 API 取得最新的內容,最後則是把 fetchLatestContentes() 回傳的結果傳給 showContents() ,讓它來顯示內容。

其中三個函式的定義如下:

fun login(userName: String, password: String): Token{...}
fun fetchLatestContent(token: Token): List<Contents> {...}
fun showContents(contents: List<Contents>){...}

這種寫法依然是我們所熟悉的同步式,但是可能會有一個問題,假如每一段函式時,都需要一段時間才能夠取得資料,當我們直接呼叫,也就是使用主執行緒 (Main Thread) 來執行時,那我們就會發現畫面被卡住。

例如 fetchLatestContent() 需要花費較長的時間,執行時間圖如下:

有一個任務執行很久

解決方案

這個情況是不是跟我們昨天所提到的情況很類似呢?有一個耗時的任務(I/O任務)在主執行緒上執行,那麼其他在主執行緒上的任務將無法執行,因為一次只能執行一項任務。還記得我們可以怎麼處理嗎?

.

.

.

.

沒錯,答案就是「非同步」,那麼實務上要怎麼實作非同步的程式呢?

建立新的執行緒

最直覺的想法是,由於在主執行緒上執行耗時任務會造成主執行緒卡住,所以建立新的執行緒來處理這些耗時的工作,避免佔用主執行緒。

fun thread01(){
	thread {
		// a work in new thread
	}
}

在 Kotlin 中,我們可以使用 thread{} 來新建一個執行緒,並且在這個括弧中 {} 執行我們所需要執行的任務。

如果我們嘗試將前面的 pseudo code 改成使用 thread{},會發生什麼事呢?

fun login(userName: String, password: String): Token{
    thread {
        // ...
        return@thread token // <- 沒有辦法在這邊直接回傳值
    }
}

我們會發現,在 return@thread token 這一行會出現, Type mismatch

原因在 thread{} 中,是不允許回傳值的,因為 thread{} 只接受 Unit,也就是沒有回傳值。

那,建立新的執行緒這招難道真的沒有辦法使用嗎?

/images/emoticon/emoticon06.gif

使用 Callback

重新檢視上述的情境,一個函式的結果會當作下一個函式的輸入傳遞進去,我們要怎麼解決這個問題呢?

在 Kotlin 中,我們可以將 lambda function 當作一個參數傳進函數中,所以解決這個問題的另一個思路是,將一個 lambda function 傳進去函式內,當執行完成之後,就呼叫 lambda function 通知下一個函式。

fun login(userName: String, password: String): Token{ ... }
fun fetchLatestContent(token: Token): List<Contents> { ... }
fun showContents(contents: List<Contents>){ ... }

改成

fun loginAsync(userName: String, password: String, callback: (Token) -> Unit) {
  thread {
      //....
      callback.invoke(token)
  }
}

fun fetchLatestContentAsync(token: Token, callback: (List<Contents>) -> Unit) {
    thread {
        val content = service.fetchContent(token)
        callback.invoke(contents)
    }
}

fun showContents(contents: List<Contents>){ ... }

如果 Lambda 函式是參數的最後一項,那麼我們可以將 Lambda 函式從括弧中搬出來,讓程式的結構更為清晰。

那麼我們就可以將原本的直敘式的寫法,改成 Callback 的寫法。如下

fun showContents(){
    loginAsync(userName, password){ token -> 
        fetchLatestContentAsync(token){ contents -> 
            showContents(contents)
        }	
    }
}

Callback 的問題

但是, 用 Callback 的寫法有兩個缺點,第一個是如果有多個函式都需要使用 Callback,那麼在觀看程式碼的時候,就會變得很不好看、不容易被維護。這就是 Callback hell (回呼地獄)。

請自行 Google Callback hell

使用 Callback 的另一個問題就是會發生控制權轉移 (Inversion of Control) 的情況,什麼是控制權轉移呢?看下面的範例:

假如有兩個函式,它們都各有兩個參數,第一個參數為輸入的值,另一個參數則為一個 Lambda 函式,作為 Callback 使用。由下方的程式碼可以得知,呼叫 doA 會將輸入的數值利用 callback 傳出去,同樣的,如果呼叫 doB 也會將輸入的數值利用 callback 傳遞出去。

fun doA(value: Int, callback: (Int) -> Unit) {
    callback(value)
}

fun doB(value: Int, callback: (String) -> Unit) {
    callback(value.toString())
}

假如我們將這兩個函式串在一起。

doA(1){ valueA -> 
	doB(valueA){ valueB ->
		println(valueB)
	}
}

當我們呼叫上面的函式時, doA 會在它裏面將輸入的值透過 Lambda 函式傳給 doB 。以上面的範例來說,我們最後就會列印出 1

如果 doA 的內部不小心呼叫兩次 callback

fun doA(value: Int, callback: (Int) -> Unit) {
    callback(value)
		callback(value)
}

那麼原本的結果就會出現連續兩個 1 。這明顯不是我們想要的。

doB 將自己輸入數值的控制權轉交給 doA 來呼叫,當 doA 錯誤呼叫時,後面的呼叫就有可能出錯。


那麼要怎麼避免 Callback hell、不在主執行緒上執行,又能讓程式碼是以非同步的方式執行呢?

能解決這個問題的方法有很多種: Futures , Promise, RxJava 以及本系列文章的主角 Coroutine

心智圖

非同步心智圖

特別感謝

Kotlin Taiwan User Group
Kotlin 讀書會


上一篇
Day 1:同步與非同步執行
下一篇
Day3:第一個 Coroutine 程式
系列文
Coroutine 停看聽30

1 則留言

0
文青的馬克
iT邦新手 2 級 ‧ 2021-09-11 23:18:28

這裡有個小建議 ~ 下面這個地方要說明清楚點會比較好 ~ 不然會讓初學者誤會。

解決方案

這個情況是不是跟我們昨天所提到的情況很類似呢?有一個耗時的任務在主執行緒上執行,那麼其他在主執行緒上的任務將無法執行,因為一次只能執行一項任務。還記得我們可以怎麼處理嗎?

這裡需要說明清楚『 耗時的任務 』是『 I/O 任務 』才行,如果是耗時的 cpu 運算任務,基本上只有另開個 process or thread 才能處理。

一般 coroutine 本身的確是可以透過調度器,來分配給有閒暇 cpu 的 thread 來處理,但假設你有限定只能開 4 個 thead,而這 4 個 thread 都在運行 cpu 運算的 coroutine 任務,那你在開第 5 個 coroutine 來處理 cpu 運算,它也只能等待。

謝謝你的建議,我改一下文章內容

我要留言

立即登入留言