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

關鍵詞:Kotlin 協程 異常處理java

異步代碼的異常處理一般都比較讓人頭疼,而協程則再一次展示了它的威力。git

1. 引子

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

typealias Callback = (User) -> Unit

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

複製代碼

咱們一般會定義這樣的回調接口來實現異步數據的請求,咱們能夠很方便的將它轉換成協程的接口:bash

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

複製代碼

並最終交給按鈕點擊事件或者其餘事件去觸發這個異步請求:併發

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

複製代碼

那麼問題來了,既然是請求,總會有失敗的情形,而咱們這裏並無對錯誤的處理,接下來咱們就完善這個例子。異步

2. 添加異常處理邏輯

首先咱們加上異常回調接口函數:jvm

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

    fun onError(t: Throwable)
}

複製代碼

接下來咱們在改造一下咱們的 getUserCoroutineasync

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,在調用的時候咱們只須要:ide

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"
        })
        
複製代碼

其實你很容易就能發如今這裏 RxJava 作的事兒跟協程的目的是同樣的,只不過協程用了一種更天然的方式。

也許你已經對 RxJava 很熟悉而且感到很天然,但相比之下,RxJava 的代碼比協程的複雜度更高,更讓人費解,這一點咱們後面的文章中也會持續用例子來講明這一點。

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}'")
});

複製代碼

協程顯然也能夠作到這一點。相似於經過 Thread.setUncaughtExceptionHandler 爲線程設置一個異常捕獲器,咱們也能夠爲每個協程單獨設置 CoroutineExceptionHandler,這樣協程內部未捕獲的異常就能夠經過它來捕獲:

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 居然也是一個上下文,協程的這個上下文可真是靈魂通常的存在,這卻是一點兒也不讓人感到意外。

固然,這並不算是一個全局的異常捕獲,由於它只能捕獲對應協程內未捕獲的異常,若是你想作到真正的全局捕獲,在 Jvm 上咱們能夠本身定義一個捕獲類實現:

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

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

複製代碼

而後在 classpath 中建立 META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler,文件名實際上就是 CoroutineExceptionHandler 的全類名,文件內容就寫咱們的實現類的全類名:

com.bennyhuo.coroutines.sample2.exceptions.GlobalCoroutineExceptionHandler
複製代碼

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

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

須要明確的一點是,經過 async 啓動的協程出現未捕獲的異常時會忽略 CoroutineExceptionHandler,這與 launch 的設計思路是不一樣的。

4. 異常傳播

異常傳播還涉及到協程做用域的概念,例如咱們啓動協程的時候一直都是用的 GlobalScope,意味着這是一個獨立的頂級協程做用域,此外還有 coroutineScope { ... } 以及 supervisorScope { ... }

  • 經過 GlobeScope 啓動的協程單獨啓動一個協程做用域,內部的子協程聽從默認的做用域規則。經過 GlobeScope 啓動的協程「自成一派」。
  • coroutineScope 是繼承外部 Job 的上下文建立做用域,在其內部的取消操做是雙向傳播的,子協程未捕獲的異常也會向上傳遞給父協程。它更適合一系列對等的協程併發的完成一項工做,任何一個子協程異常退出,那麼總體都將退出,簡單來講就是」一損俱損「。這也是協程內部再啓動子協程的默認做用域。
  • supervisorScope 一樣繼承外部做用域的上下文,但其內部的取消操做是單向傳播的,父協程向子協程傳播,反過來則否則,這意味着子協程出了異常並不會影響父協程以及其餘兄弟協程。它更適合一些獨立不相干的任務,任何一個任務出問題,並不會影響其餘任務的工做,簡單來講就是」自食其果「,例如 UI,我點擊一個按鈕出了異常,其實並不會影響手機狀態欄的刷新。須要注意的是,supervisorScope 內部啓動的子協程內部再啓動子協程,如無明確指出,則遵照默認做用域規則,也即 supervisorScope 只做用域其直接子協程。

這麼說仍是比較抽象,所以咱們拿一些例子來分析一下:

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 當中啓動了兩個協程 ②④,在 ② 當中啓動了一個子協程 ③,做用域直接建立的協程記爲①。那麼 ③ 當中拋異常會發生什麼呢?咱們先來看下輸出:

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,收到了一個取消異常,在協程當中支持取消的操做的suspend方法在取消時會拋出一個 CancellationException,這相似於線程中對 InterruptException 的響應,遇到這種狀況表示 join 調用所在的協程已經被取消了,那麼這個取消到底是怎麼回事呢?

原來協程 ③ 拋出了未捕獲的異常,進入了異常完成的狀態,它與父協程 ② 之間遵循默認的做用域規則,所以 ③ 會通知它的父協程也就是 ② 取消,② 根據做用域規則通知父協程 ① 也就是整個做用域取消,這是一個自下而上的一次傳播,這樣身處 ① 當中的 job.join 調用就會拋異常,也就是 10 處的結果了。若是不是很理解這個操做,想一下咱們說到的,coroutineScope 內部啓動的協程就是「一損俱損」。實際上因爲父協程 ① 被取消,協程④ 也不能倖免,若是你們有興趣的話,也能夠對 ④ 當中的 delay進行捕獲,同樣會收穫一枚取消異常。

還有一個位置就是 12,這個是咱們對 coroutineScope 總體的一個捕獲,若是 coroutineScope 內部覺得異常而結束,那麼咱們是能夠對它直接 try ... catch ... 來捕獲這個異常的,這再一次代表協程把異步的異常處理到同步代碼邏輯當中。

那麼若是咱們把 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 的輸出其實沒有本質區別,順序上的差別是線程調度的先後形成的,並不會影響協程的語義。差異主要在於 9 與 十、11與12的區別,若是把 scope 換成 supervisorScope,咱們發現 ③ 的異常並無影響做用域以及做用域內的其餘子協程的執行,也就是咱們所說的「自食其果」。

這個例子其實咱們再稍作一些改動,爲 ② 和 ③ 增長一個 CoroutineExceptionHandler,就能夠證實咱們前面提到的另一個結論:

首先咱們定義一個 CoroutineExceptionHandler,咱們經過上下文獲取一下異常對應的協程的名字:

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

複製代碼

接着,基於前面的例子咱們爲 ② 和 ③ 添加 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!!
...
複製代碼

咱們發現觸發的 CoroutineExceptionHandler 居然是協程 ② 的,意外嗎?不意外,由於咱們前面已經提到,對於 supervisorScope 的子協程 (例如 ②)的子協程(例如 ③),若是沒有明確指出,它是遵循默認的做用於規則的,也就是 coroutineScope 的規則了,出現未捕獲的異常會嘗試傳遞給父協程並嘗試取消父協程。

究竟使用什麼 Scope,你們本身根據實際狀況來肯定,我給出一些建議:

  • 對於沒有協程做用域,但須要啓動協程的時候,適合用 GlobalScope
  • 對於已經有協程做用域的狀況(例如經過 GlobalScope 啓動的協程體內),直接用協程啓動器啓動
  • 對於明確要求子協程之間相互獨立不干擾時,使用 supervisorScope
  • 對於經過標準庫 API 建立的協程,這樣的協程比較底層,沒有 Job、做用域等概念的支撐,例如咱們前面提到過 suspend main 就是這種狀況,對於這種狀況優先考慮經過 coroutineScope 建立做用域;更進一步,你們儘可能不要直接使用標準庫 API,除非你對 Kotlin 的協程機制很是熟悉。

固然,對於可能出異常的狀況,請你們儘可能作好異常處理,不要將問題複雜化。

5. join 和 await

前面咱們舉例子一直用的是 launch,啓動協程其實經常使用的還有 asyncactorproduce,其中 actorlaunch 的行爲相似,在未捕獲的異常出現之後,會被當作爲處理的異常拋出,就像前面的例子那樣。而 asyncproduce 則主要是用來輸出結果的,他們內部的異常只在外部消費他們的結果時拋出。這兩組協程的啓動器,你也能夠認爲分別是「消費者」和「生產者」,消費者異常當即拋出,生產者只有結果消費時拋出異常。

actorproduce 這兩個 API 目前處於比較微妙的境地,可能會被廢棄或者後續提供替代方案,不建議你們使用,咱們在這裏就不展開細講了。

那麼消費結果指的是什麼呢?對於 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 就有趣的多了,它只關注是否執行完,至因而由於什麼完成,它不關心,所以若是咱們在這裏替換成 join

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

複製代碼

咱們就會發現,異常被吞掉了!

13:26:15:034 [main] 1
複製代碼

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

無論是哪一個啓動器,在應用了做用域以後,都會按照做用域的語義進行異常擴散,進而觸發相應的取消操做,對於 async 來講就算不調用 await 來獲取這個異常,它也會在 coroutineScope 當中觸發父協程的取消邏輯,這一點請你們注意。

6. 小結

這一篇咱們講了協程的異常處理。這一起稍微顯得有點兒複雜,但仔細理一下主要有三條線:

  1. 協程內部異常處理流程:launch 會在內部出現未捕獲的異常時嘗試觸發對父協程的取消,可否取消要看做用域的定義,若是取消成功,那麼異常傳遞給父協程,不然傳遞給啓動時上下文中配置的 CoroutineExceptionHandler 中,若是沒有配置,會查找全局(JVM上)的 CoroutineExceptionHandler 進行處理,若是仍然沒有,那麼就將異常交給當前線程的 UncaughtExceptionHandler 處理;而 async 則在未捕獲的異常出現時一樣會嘗試取消父協程,但無論是否可以取消成功都不會後其餘後續的異常處理,直到用戶主動調用 await 時將異常拋出。
  2. 異常在做用域內的傳播:當協程出現異常時,會根據當前做用域觸發異常傳遞,GlobalScope 會建立一個獨立的做用域,所謂「自成一派」,而 在 coroutineScope 當中協程異常會觸發父協程的取消,進而將整個協程做用域取消掉,若是對 coroutineScope 總體進行捕獲,也能夠捕獲到該異常,所謂「一損俱損」;若是是 supervisorScope,那麼子協程的異常不會向上傳遞,所謂「自食其果」。
  3. join 和 await 的不一樣:join 只關心協程是否執行完,await 則關心運行的結果,所以 join 在協程出現異常時也不會拋出該異常,而 await 則會;考慮到做用域的問題,若是協程拋異常,可能會致使父協程的取消,所以調用 join 時儘管不會對協程自己的異常進行拋出,但若是 join 調用所在的協程被取消,那麼它會拋出取消異常,這一點須要留意。

若是你們能把這三點理解清楚了,那麼協程的異常處理能夠說就很是清晰了。文中由於異常傳播的緣由,咱們提到了取消,但沒有展開詳細討論,後面咱們將會專門針對取消輸出一篇文章,幫助你們加深理解。

附加說明

join 在父協程被取消時有一個 bug 會致使不拋出取消異常,我在準備本文時發現該問題,目前已經提交到官方並獲得了修復,預計合入到 1.2.1 發版,你們有興趣能夠查看這個 issue:No CancellationException thrown when join on a crashed Job

固然,這個 bug 對於生成環境的影響很小,你們也不要擔憂。


歡迎關注 Kotlin 中文社區!

中文官網:www.kotlincn.net/

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

公衆號:Kotlin

知乎專欄:Kotlin

CSDN:Kotlin中文社區

掘金:Kotlin中文社區

簡書:Kotlin中文社區

相關文章
相關標籤/搜索