在前一篇文章中,我們知道依據程式的執行順序分成兩種執行方式,一種是同步(Synchronous) 、另一種則是非同步(Asynchronous)。
同步的執行方式會依照程式碼的順序,依序執行。如下:
fun sayHello(){
println("Hello")
println("Nice to meet you!")
}
執行 sayHello()
我們就可以得到如我們程式碼順序的結果。
Hello
Nice to Meet you!
好的,同步程式的執行方式是如此直覺:按照順序。
假如,有一個方法 showContents()
為我們登入 FB 之後,系統會根據登入的使用者,把最近的內容列出來。
以這個例子來說,我們會有三個步驟,
fun showContents() {
val token = login(userName, password)
val contents = fetchLatestContents(token)
showContents(contents)
}
login()
會回傳 Token
, fetchLatestContents()
會使用 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
,也就是沒有回傳值。
重新檢視上述的情境,一個函式的結果會當作下一個函式的輸入傳遞進去,我們要怎麼解決這個問題呢?
在 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 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 讀書會
有興趣的讀者歡迎參考:https://coroutine.kotlin.tips/
天瓏書局
這裡有個小建議 ~ 下面這個地方要說明清楚點會比較好 ~ 不然會讓初學者誤會。
解決方案
這個情況是不是跟我們昨天所提到的情況很類似呢?有一個耗時的任務在主執行緒上執行,那麼其他在主執行緒上的任務將無法執行,因為一次只能執行一項任務。還記得我們可以怎麼處理嗎?
這裡需要說明清楚『 耗時的任務 』是『 I/O 任務 』才行,如果是耗時的 cpu 運算任務,基本上只有另開個 process or thread 才能處理。
一般 coroutine 本身的確是可以透過調度器,來分配給有閒暇 cpu 的 thread 來處理,但假設你有限定只能開 4 個 thead,而這 4 個 thread 都在運行 cpu 運算的 coroutine 任務,那你在開第 5 個 coroutine 來處理 cpu 運算,它也只能等待。
謝謝你的建議,我改一下文章內容