破解 Kotlin 協(xié)程(4) - 異常處理篇

關鍵詞:Kotlin 協(xié)程 異常處理

異步代碼的異常處理通常都比較讓人頭疼,而協(xié)程則再一次展現了它的威力。

1. 引子

我們在前面一篇文章當中提到了這樣一個例子:

typealias Callback = (User) -> Unit

fun getUser(callback: Callback){
    ...
}

我們通常會定義這樣的回調接口來實現異步數據的請求,我們可以很方便的將它轉換成協(xié)程的接口:

suspend fun getUserCoroutine() = suspendCoroutine<User> {
    continuation ->
    getUser {
        continuation.resume(it)
    }
}

并最終交給按鈕點擊事件或者其他事件去觸發(fā)這個異步請求:

getUserBtn.setOnClickListener {
    GlobalScope.launch(Dispatchers.Main) {
        userNameView.text = getUserCoroutine().name
    }
}

那么問題來了,既然是請求,總會有失敗的情形,而我們這里并沒有對錯誤的處理,接下來我們就完善這個例子。

2. 添加異常處理邏輯

首先我們加上異?;卣{接口函數:

interface Callback<T> {
    fun onSuccess(value: T)

    fun onError(t: Throwable)
}

接下來我們在改造一下我們的 getUserCoroutine

suspend fun getUserCoroutine() = suspendCoroutine<User> { continuation ->
    getUser(object : Callback<User> {
        override fun onSuccess(value: User) {
            continuation.resume(value)
        }

        override fun onError(t: Throwable) {
            continuation.resumeWithException(t)
        }
    })
}

大家可以看到,我們似乎就是完全把 Callback 轉換成了一個 Continuation,在調用的時候我們只需要:

GlobalScope.launch(Dispatchers.Main) {
    try {
        userNameView.text = getUserCoroutine().name
    } catch (e: Exception) {
        userNameView.text = "Get User Error: $e"
    }
}

是的,你沒看錯,一個異步的請求異常,我們只需要在我們的代碼中捕獲就可以了,這樣做的好處就是,請求的全流程異常都可以在一個 try ... catch ... 當中捕獲,那么我們可以說真正做到了把異步代碼變成了同步的寫法。

如果你一直在用 RxJava 處理這樣的邏輯,那么你的請求接口可能是這樣的:

fun getUserObservable(): Single<User> {
    return Single.create<User> { emitter ->
        getUser(object : Callback<User> {
            override fun onSuccess(value: User) {
                emitter.onSuccess(value)
            }

            override fun onError(t: Throwable) {
                emitter.onError(t)
            }
        })
    }
}

調用時大概是這樣的:

getUserObservable()
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe ({ user ->
            userNameView.text = user.name
        }, {
            userNameView.text = "Get User Error: $it"
        })

其實你很容易就能發(fā)現在這里 RxJava 做的事兒跟協(xié)程的目的是一樣的,只不過協(xié)程用了一種更自然的方式。

也許你已經對 RxJava 很熟悉并且感到很自然,但相比之下,RxJava 的代碼比協(xié)程的復雜度更高,更讓人費解,這一點我們后面的文章中也會持續(xù)用例子來說明這一點。

3. 全局異常處理

線程也好、RxJava 也好,都有全局處理異常的方式,例如:

fun main() {
    Thread.setDefaultUncaughtExceptionHandler {t: Thread, e: Throwable ->
        //handle exception here
        println("Thread '${t.name}' throws an exception with message '${e.message}'")
    }

    throw ArithmeticException("Hey!")
}

我們可以為線程設置全局的異常捕獲,當然也可以為 RxJava 來設置全局異常捕獲:

RxJavaPlugins.setErrorHandler(e -> {
        //handle exception here
        println("Throws an exception with message '${e.message}'")
});

協(xié)程顯然也可以做到這一點。類似于通過 Thread.setUncaughtExceptionHandler 為線程設置一個異常捕獲器,我們也可以為每一個協(xié)程單獨設置 CoroutineExceptionHandler,這樣協(xié)程內部未捕獲的異常就可以通過它來捕獲:

private suspend fun main(){
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        log("Throws an exception with message: ${throwable.message}")
    }

    log(1)
    GlobalScope.launch(exceptionHandler) {
        throw ArithmeticException("Hey!")
    }.join()
    log(2)
}

運行結果:

19:06:35:087 [main] 1
19:06:35:208 [DefaultDispatcher-worker-1 @coroutine#1] Throws an exception with message: Hey!
19:06:35:211 [DefaultDispatcher-worker-1 @coroutine#1] 2

CoroutineExceptionHandler 竟然也是一個上下文,協(xié)程的這個上下文可真是靈魂一般的存在,這倒是一點兒也不讓人感到意外。

當然,這并不算是一個全局的異常捕獲,因為它只能捕獲對應協(xié)程內未捕獲的異常,如果你想做到真正的全局捕獲,在 Jvm 上我們可以自己定義一個捕獲類實現:

class GlobalCoroutineExceptionHandler: CoroutineExceptionHandler {
    override val key: CoroutineContext.Key<*> = CoroutineExceptionHandler

    override fun handleException(context: CoroutineContext, exception: Throwable) {
        println("Coroutine exception: $exception")
    }
}

然后在 classpath 中創(chuàng)建 META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler,文件名實際上就是 CoroutineExceptionHandler 的全類名,文件內容就寫我們的實現類的全類名:

com.bennyhuo.coroutines.sample2.exceptions.GlobalCoroutineExceptionHandler

這樣協(xié)程中沒有被捕獲的異常就會最終交給它處理。

Jvm 上全局 CoroutineExceptionHandler 的配置,本質上是對 ServiceLoader 的應用,之前我們在講 Dispatchers.Main 的時候提到過,Jvm 上它的實現也是通過 ServiceLoader 來加載的。

需要明確的一點是,通過 async 啟動的協(xié)程出現未捕獲的異常時會忽略 CoroutineExceptionHandler,這與 launch 的設計思路是不同的。

4. 異常傳播

異常傳播還涉及到協(xié)程作用域的概念,例如我們啟動協(xié)程的時候一直都是用的 GlobalScope,意味著這是一個獨立的頂級協(xié)程作用域,此外還有 coroutineScope { ... } 以及 supervisorScope { ... }。

  • 通過 GlobeScope 啟動的協(xié)程單獨啟動一個協(xié)程作用域,內部的子協(xié)程遵從默認的作用域規(guī)則。通過 GlobeScope 啟動的協(xié)程“自成一派”。
  • coroutineScope 是繼承外部 Job 的上下文創(chuàng)建作用域,在其內部的取消操作是雙向傳播的,子協(xié)程未捕獲的異常也會向上傳遞給父協(xié)程。它更適合一系列對等的協(xié)程并發(fā)的完成一項工作,任何一個子協(xié)程異常退出,那么整體都將退出,簡單來說就是”一損俱損“。這也是協(xié)程內部再啟動子協(xié)程的默認作用域。
  • supervisorScope 同樣繼承外部作用域的上下文,但其內部的取消操作是單向傳播的,父協(xié)程向子協(xié)程傳播,反過來則不然,這意味著子協(xié)程出了異常并不會影響父協(xié)程以及其他兄弟協(xié)程。它更適合一些獨立不相干的任務,任何一個任務出問題,并不會影響其他任務的工作,簡單來說就是”自作自受“,例如 UI,我點擊一個按鈕出了異常,其實并不會影響手機狀態(tài)欄的刷新。需要注意的是,supervisorScope 內部啟動的子協(xié)程內部再啟動子協(xié)程,如無明確指出,則遵守默認作用域規(guī)則,也即 supervisorScope 只作用域其直接子協(xié)程。

這么說還是比較抽象,因此我們拿一些例子來分析一下:

suspend fun main() {
    log(1)
    try {
        coroutineScope { //①
            log(2)
            launch { // ②
                log(3)
                launch { // ③ 
                    log(4)
                    delay(100)
                    throw ArithmeticException("Hey!!")
                }
                log(5)
            }
            log(6)
            val job = launch { // ④
                log(7)
                delay(1000)
            }
            try {
                log(8)
                 job.join()
                log("9")
            } catch (e: Exception) {
                log("10. $e")
            }
        }
        log(11)
    } catch (e: Exception) {
        log("12. $e")
    }
    log(13)
}

這例子稍微有點兒復雜,但也不難理解,我們在一個 coroutineScope 當中啟動了兩個協(xié)程 ②④,在 ② 當中啟動了一個子協(xié)程 ③,作用域直接創(chuàng)建的協(xié)程記為①。那么 ③ 當中拋異常會發(fā)生什么呢?我們先來看下輸出:

11:37:36:208 [main] 1
11:37:36:255 [main] 2
11:37:36:325 [DefaultDispatcher-worker-1] 3
11:37:36:325 [DefaultDispatcher-worker-1] 5
11:37:36:326 [DefaultDispatcher-worker-3] 4
11:37:36:331 [main] 6
11:37:36:336 [DefaultDispatcher-worker-1] 7
11:37:36:336 [main] 8
11:37:36:441 [DefaultDispatcher-worker-1] 10. kotlinx.coroutines.JobCancellationException: ScopeCoroutine is cancelling; job=ScopeCoroutine{Cancelling}@2bc92d2f
11:37:36:445 [DefaultDispatcher-worker-1] 12. java.lang.ArithmeticException: Hey!!
11:37:36:445 [DefaultDispatcher-worker-1] 13

注意兩個位置,一個是 10,我們調用 join,收到了一個取消異常,在協(xié)程當中支持取消的操作的suspend方法在取消時會拋出一個 CancellationException,這類似于線程中對 InterruptException 的響應,遇到這種情況表示 join 調用所在的協(xié)程已經被取消了,那么這個取消究竟是怎么回事呢?

原來協(xié)程 ③ 拋出了未捕獲的異常,進入了異常完成的狀態(tài),它與父協(xié)程 ② 之間遵循默認的作用域規(guī)則,因此 ③ 會通知它的父協(xié)程也就是 ② 取消,② 根據作用域規(guī)則通知父協(xié)程 ① 也就是整個作用域取消,這是一個自下而上的一次傳播,這樣身處 ① 當中的 job.join 調用就會拋異常,也就是 10 處的結果了。如果不是很理解這個操作,想一下我們說到的,coroutineScope 內部啟動的協(xié)程就是“一損俱損”。實際上由于父協(xié)程 ① 被取消,協(xié)程④ 也不能幸免,如果大家有興趣的話,也可以對 ④ 當中的 delay進行捕獲,一樣會收獲一枚取消異常。

還有一個位置就是 12,這個是我們對 coroutineScope 整體的一個捕獲,如果 coroutineScope 內部以為異常而結束,那么我們是可以對它直接 try ... catch ... 來捕獲這個異常的,這再一次表明協(xié)程把異步的異常處理到同步代碼邏輯當中。

那么如果我們把 coroutineScope 換成 supervisorScope,其他不變,運行結果會是怎樣呢?

11:52:48:632 [main] 1
11:52:48:694 [main] 2
11:52:48:875 [main] 6
11:52:48:892 [DefaultDispatcher-worker-1 @coroutine#1] 3
11:52:48:895 [DefaultDispatcher-worker-1 @coroutine#1] 5
11:52:48:900 [DefaultDispatcher-worker-3 @coroutine#3] 4
11:52:48:905 [DefaultDispatcher-worker-2 @coroutine#2] 7
11:52:48:907 [main] 8
Exception in thread "DefaultDispatcher-worker-3 @coroutine#3" java.lang.ArithmeticException: Hey!!
    at com.bennyhuo.coroutines.sample2.exceptions.ScopesKt$main$2$1$1.invokeSuspend(Scopes.kt:17)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:238)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:742)
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 9
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 11
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 13

我們可以看到,1-8 的輸出其實沒有本質區(qū)別,順序上的差異是線程調度的前后造成的,并不會影響協(xié)程的語義。差別主要在于 9 與 10、11與12的區(qū)別,如果把 scope 換成 supervisorScope,我們發(fā)現 ③ 的異常并沒有影響作用域以及作用域內的其他子協(xié)程的執(zhí)行,也就是我們所說的“自作自受”。

這個例子其實我們再稍做一些改動,為 ② 和 ③ 增加一個 CoroutineExceptionHandler,就可以證明我們前面提到的另外一個結論:

首先我們定義一個 CoroutineExceptionHandler,我們通過上下文獲取一下異常對應的協(xié)程的名字:

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    log("${coroutineContext[CoroutineName]} $throwable")
}

接著,基于前面的例子我們?yōu)?② 和 ③ 添加 CoroutineExceptionHandler 和名字:

...
supervisorScope { //①
    log(2)
    launch(exceptionHandler + CoroutineName("②")) { // ②
        log(3)
        launch(exceptionHandler + CoroutineName("③")) { // ③
            log(4)
...

再運行這段程序,結果就比較有意思了:

...
07:30:11:519 [DefaultDispatcher-worker-1] CoroutineName(②) java.lang.ArithmeticException: Hey!!
...

我們發(fā)現觸發(fā)的 CoroutineExceptionHandler 竟然是協(xié)程 ② 的,意外嗎?不意外,因為我們前面已經提到,對于 supervisorScope 的子協(xié)程 (例如 ②)的子協(xié)程(例如 ③),如果沒有明確指出,它是遵循默認的作用于規(guī)則的,也就是 coroutineScope 的規(guī)則了,出現未捕獲的異常會嘗試傳遞給父協(xié)程并嘗試取消父協(xié)程。

究竟使用什么 Scope,大家自己根據實際情況來確定,我給出一些建議:

  • 對于沒有協(xié)程作用域,但需要啟動協(xié)程的時候,適合用 GlobalScope
  • 對于已經有協(xié)程作用域的情況(例如通過 GlobalScope 啟動的協(xié)程體內),直接用協(xié)程啟動器啟動
  • 對于明確要求子協(xié)程之間相互獨立不干擾時,使用 supervisorScope
  • 對于通過標準庫 API 創(chuàng)建的協(xié)程,這樣的協(xié)程比較底層,沒有 Job、作用域等概念的支撐,例如我們前面提到過 suspend main 就是這種情況,對于這種情況優(yōu)先考慮通過 coroutineScope 創(chuàng)建作用域;更進一步,大家盡量不要直接使用標準庫 API,除非你對 Kotlin 的協(xié)程機制非常熟悉。

當然,對于可能出異常的情況,請大家盡量做好異常處理,不要將問題復雜化。

5. join 和 await

前面我們舉例子一直用的是 launch,啟動協(xié)程其實常用的還有 asyncactorproduce,其中 actorlaunch 的行為類似,在未捕獲的異常出現以后,會被當做為處理的異常拋出,就像前面的例子那樣。而 asyncproduce 則主要是用來輸出結果的,他們內部的異常只在外部消費他們的結果時拋出。這兩組協(xié)程的啟動器,你也可以認為分別是“消費者”和“生產者”,消費者異常立即拋出,生產者只有結果消費時拋出異常。

actorproduce 這兩個 API 目前處于比較微妙的境地,可能會被廢棄或者后續(xù)提供替代方案,不建議大家使用,我們在這里就不展開細講了。

那么消費結果指的是什么呢?對于 async 來講,就是 await,例如:

suspend fun main() {
    val deferred = GlobalScope.async<Int> { 
        throw ArithmeticException()
    }
    try {
        val value = deferred.await()
        log("1. $value")
    } catch (e: Exception) {
        log("2. $e")
    }
}

這個從邏輯上很好理解,我們調用 await 時,期望 deferred 能夠給我們提供一個合適的結果,但它因為出異常,沒有辦法做到這一點,因此只好給我們丟出一個異常了。

13:25:14:693 [main] 2. java.lang.ArithmeticException

我們自己實現的 getUserCoroutine 也屬于類似的情況,在獲取結果時,如果請求出了異常,我們就只能拿到一個異常,而不是正常的結果。相比之下,join 就有趣的多了,它只關注是否執(zhí)行完,至于是因為什么完成,它不關心,因此如果我們在這里替換成 join

suspend fun main() {
    val deferred = GlobalScope.async<Int> {
        throw ArithmeticException()
    }
    try {
        deferred.join()
        log(1)
    } catch (e: Exception) {
        log("2. $e")
    }
}

我們就會發(fā)現,異常被吞掉了!

13:26:15:034 [main] 1

如果例子當中我們用 launch 替換 async,join 處仍然不會有任何異常拋出,還是那句話,它只關心有沒有完成,至于怎么完成的它不關心。不同之處在于, launch 中未捕獲的異常與 async 的處理方式不同,launch 會直接拋出給父協(xié)程,如果沒有父協(xié)程(頂級作用域中)或者處于 supervisorScope 中父協(xié)程不響應,那么就交給上下文中指定的 CoroutineExceptionHandler處理,如果沒有指定,那傳給全局的 CoroutineExceptionHandler 等等,而 async 則要等 await 來消費。

不管是哪個啟動器,在應用了作用域之后,都會按照作用域的語義進行異常擴散,進而觸發(fā)相應的取消操作,對于 async 來說就算不調用 await 來獲取這個異常,它也會在 coroutineScope 當中觸發(fā)父協(xié)程的取消邏輯,這一點請大家注意。

6. 小結

這一篇我們講了協(xié)程的異常處理。這一塊兒稍微顯得有點兒復雜,但仔細理一下主要有三條線:

  1. 協(xié)程內部異常處理流程:launch 會在內部出現未捕獲的異常時嘗試觸發(fā)對父協(xié)程的取消,能否取消要看作用域的定義,如果取消成功,那么異常傳遞給父協(xié)程,否則傳遞給啟動時上下文中配置的 CoroutineExceptionHandler 中,如果沒有配置,會查找全局(JVM上)的 CoroutineExceptionHandler 進行處理,如果仍然沒有,那么就將異常交給當前線程的 UncaughtExceptionHandler 處理;而 async 則在未捕獲的異常出現時同樣會嘗試取消父協(xié)程,但不管是否能夠取消成功都不會后其他后續(xù)的異常處理,直到用戶主動調用 await 時將異常拋出。
  2. 異常在作用域內的傳播:當協(xié)程出現異常時,會根據當前作用域觸發(fā)異常傳遞,GlobalScope 會創(chuàng)建一個獨立的作用域,所謂“自成一派”,而 在 coroutineScope 當中協(xié)程異常會觸發(fā)父協(xié)程的取消,進而將整個協(xié)程作用域取消掉,如果對 coroutineScope 整體進行捕獲,也可以捕獲到該異常,所謂“一損俱損”;如果是 supervisorScope,那么子協(xié)程的異常不會向上傳遞,所謂“自作自受”。
  3. join 和 await 的不同:join 只關心協(xié)程是否執(zhí)行完,await 則關心運行的結果,因此 join 在協(xié)程出現異常時也不會拋出該異常,而 await 則會;考慮到作用域的問題,如果協(xié)程拋異常,可能會導致父協(xié)程的取消,因此調用 join 時盡管不會對協(xié)程本身的異常進行拋出,但如果 join 調用所在的協(xié)程被取消,那么它會拋出取消異常,這一點需要留意。

如果大家能把這三點理解清楚了,那么協(xié)程的異常處理可以說就非常清晰了。文中因為異常傳播的原因,我們提到了取消,但沒有展開詳細討論,后面我們將會專門針對取消輸出一篇文章,幫助大家加深理解。

附加說明

join 在父協(xié)程被取消時有一個 bug 會導致不拋出取消異常,我在準備本文時發(fā)現該問題,目前已經提交到官方并得到了修復,預計合入到 1.2.1 發(fā)版,大家有興趣可以查看這個 issue:No CancellationException thrown when join on a crashed Job。

當然,這個 bug 對于生成環(huán)境的影響很小,大家也不要擔心。


歡迎關注 Kotlin 中文社區(qū)!

中文官網:https://www.kotlincn.net/

中文官方博客:https://www.kotliner.cn/

公眾號:Kotlin

知乎專欄:Kotlin

CSDN:Kotlin中文社區(qū)

掘金:Kotlin中文社區(qū)

簡書:Kotlin中文社區(qū)

開發(fā)者頭條:Kotlin中文社區(qū)

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容