關鍵詞:Kotlin 協程 異常處理java
異步代碼的異常處理一般都比較讓人頭疼,而協程則再一次展示了它的威力。git
咱們在前面一篇文章當中提到了這樣一個例子: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
}
}
複製代碼
那麼問題來了,既然是請求,總會有失敗的情形,而咱們這裏並無對錯誤的處理,接下來咱們就完善這個例子。異步
首先咱們加上異常回調接口函數:jvm
interface Callback<T> {
fun onSuccess(value: T)
fun onError(t: Throwable)
}
複製代碼
接下來咱們在改造一下咱們的 getUserCoroutine
:async
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 的代碼比協程的複雜度更高,更讓人費解,這一點咱們後面的文章中也會持續用例子來講明這一點。
線程也好、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
的設計思路是不一樣的。
異常傳播還涉及到協程做用域的概念,例如咱們啓動協程的時候一直都是用的 GlobalScope
,意味着這是一個獨立的頂級協程做用域,此外還有 coroutineScope { ... }
以及 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,你們本身根據實際狀況來肯定,我給出一些建議:
固然,對於可能出異常的狀況,請你們儘可能作好異常處理,不要將問題複雜化。
前面咱們舉例子一直用的是 launch
,啓動協程其實經常使用的還有 async
、actor
和 produce
,其中 actor
和 launch
的行爲相似,在未捕獲的異常出現之後,會被當作爲處理的異常拋出,就像前面的例子那樣。而 async
和 produce
則主要是用來輸出結果的,他們內部的異常只在外部消費他們的結果時拋出。這兩組協程的啓動器,你也能夠認爲分別是「消費者」和「生產者」,消費者異常當即拋出,生產者只有結果消費時拋出異常。
actor
和produce
這兩個 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
替換 async
,join
處仍然不會有任何異常拋出,仍是那句話,它只關心有沒有完成,至於怎麼完成的它不關心。不一樣之處在於, launch
中未捕獲的異常與 async
的處理方式不一樣,launch
會直接拋出給父協程,若是沒有父協程(頂級做用域中)或者處於 supervisorScope
中父協程不響應,那麼就交給上下文中指定的 CoroutineExceptionHandler
處理,若是沒有指定,那傳給全局的 CoroutineExceptionHandler
等等,而 async
則要等 await
來消費。
無論是哪一個啓動器,在應用了做用域以後,都會按照做用域的語義進行異常擴散,進而觸發相應的取消操做,對於
async
來講就算不調用await
來獲取這個異常,它也會在coroutineScope
當中觸發父協程的取消邏輯,這一點請你們注意。
這一篇咱們講了協程的異常處理。這一起稍微顯得有點兒複雜,但仔細理一下主要有三條線:
若是你們能把這三點理解清楚了,那麼協程的異常處理能夠說就很是清晰了。文中由於異常傳播的緣由,咱們提到了取消,但沒有展開詳細討論,後面咱們將會專門針對取消輸出一篇文章,幫助你們加深理解。
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中文社區