把耗時工作丟到別的執行緒(Thread)並用回呼(Callback)回傳結果,是非同步寫法的入門款,但在實務上很快會撞牆。這一節從一個常見情境出發,整理「執行緒 + 回呼」的兩大痛點:回呼地獄(Callback Hell)與控制權轉移(Inversion of Control)。
直覺的同步寫法#
先看一個三步驟的同步流程:
fun showContents() {
val token = login(userName, password)
val contents = fetchLatestContents(token)
showContents(contents)
}如果直接在主執行緒上跑,遇到 fetchLatestContents() 這種需要連線到伺服器的耗時任務,畫面就會卡住。要躲開這個問題,最直覺的想法是把工作塞進新的執行緒。
為什麼不能在執行緒裡直接 return#
Kotlin 提供了 thread {} 建立新的執行緒,但它無法把計算結果以一般 return 的方式傳回呼叫端:
fun login(userName: String, password: String): Token {
thread {
// ...
return@thread token // 編譯失敗:型別不符
}
// 這裡也沒辦法回傳 Token
}thread {} 接受的 lambda 是 () -> Unit,沒有回傳值的概念。執行緒一啟動,原本的呼叫端就已經繼續往下走,無法直接「等」結果回來再回傳。
用回呼回傳結果#
既然不能直接 return,常見的解法是把後續要做的事包成 lambda 傳進去,讓非同步任務完成後再呼叫它:
fun loginAsync(userName: String, password: String, callback: (Token) -> Unit) {
thread {
// ...
callback(token)
}
}
fun fetchLatestContentAsync(token: Token, callback: (List<Contents>) -> Unit) {
thread {
val contents = service.fetchContent(token)
callback(contents)
}
}把這幾個函式串起來,就會變成階梯狀的程式碼:
fun showContents() {
loginAsync(userName, password) { token ->
fetchLatestContentAsync(token) { contents ->
showContents(contents)
}
}
}寫到三層還勉強可讀,但實務上常會更深,這就是回呼地獄(Callback Hell)。
巢狀越深,錯誤處理就越難集中。想像在每一層都加上 try-catch、加上失敗時的 callback、加上取消邏輯,程式碼很快會變得難以維護。
控制權轉移(Inversion of Control)#
回呼還有第二個問題:呼叫者把後續流程的控制權交給了被呼叫者。看下面的小範例:
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)
}
}正常情況會印出 1。但是如果 doA 不小心呼叫了兩次 callback:
fun doA(value: Int, callback: (Int) -> Unit) {
callback(value)
callback(value) // 多了一次
}doB 與後續的 println 都會被執行兩次,輸出變成兩個 1。呼叫端把「後續流程要被執行幾次」這件事完全交給了 doA,一旦上游函式行為不正確,下游邏輯就跟著出錯。這就是控制權轉移(Inversion of Control)。
兩個痛點的本質#
回呼地獄與控制權轉移其實是同一件事的兩個面向:
- 寫法層面:流程被拆進巢狀 lambda,順序、錯誤處理、取消、逾時都不容易維持一致。
- 語意層面:原本「先做 A、再做 B」的線性語意,被改寫成「告訴 A 做完之後叫 B」,呼叫者對流程的掌握度下降。
業界其他解法#
在 Coroutine 之前,業界已經出現幾種針對這兩個痛點的解法:
Future/CompletableFuture:可組合的非同步結果,但語法仍偏笨重。Promise:類似 Future,常見於 JavaScript 生態。- 反應式串流(例如 RxJava):用串流運算子描述非同步流程,能力強但學習曲線陡。
這些方案都在嘗試把「非同步程式寫得像同步」這件事做好。Coroutine 走的路線是「在語言層級加上 suspend / resume」,讓你直接以同步的語法描述非同步流程,下一節就會看到怎麼做。
小結#
- 純執行緒(Thread)+ 回呼(Callback)寫法會遇到回呼地獄與控制權轉移兩個結構性問題。
- 回呼地獄讓程式碼難讀難維護,控制權轉移則讓上游函式的錯誤直接影響下游語意。
- Future、Promise、RxJava 都嘗試解決這些問題,而 Coroutine 透過語言層級的 suspend / resume 提出了更直覺的選擇。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10261164