把耗時工作丟到別的執行緒(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