關鍵詞: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é)程其實常用的還有 async、actor 和 produce,其中 actor 和 launch 的行為類似,在未捕獲的異常出現以后,會被當做為處理的異常拋出,就像前面的例子那樣。而 async 和 produce 則主要是用來輸出結果的,他們內部的異常只在外部消費他們的結果時拋出。這兩組協(xié)程的啟動器,你也可以認為分別是“消費者”和“生產者”,消費者異常立即拋出,生產者只有結果消費時拋出異常。
actor和produce這兩個 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é)程的異常處理。這一塊兒稍微顯得有點兒復雜,但仔細理一下主要有三條線:
- 協(xié)程內部異常處理流程:launch 會在內部出現未捕獲的異常時嘗試觸發(fā)對父協(xié)程的取消,能否取消要看作用域的定義,如果取消成功,那么異常傳遞給父協(xié)程,否則傳遞給啟動時上下文中配置的 CoroutineExceptionHandler 中,如果沒有配置,會查找全局(JVM上)的 CoroutineExceptionHandler 進行處理,如果仍然沒有,那么就將異常交給當前線程的 UncaughtExceptionHandler 處理;而 async 則在未捕獲的異常出現時同樣會嘗試取消父協(xié)程,但不管是否能夠取消成功都不會后其他后續(xù)的異常處理,直到用戶主動調用 await 時將異常拋出。
- 異常在作用域內的傳播:當協(xié)程出現異常時,會根據當前作用域觸發(fā)異常傳遞,GlobalScope 會創(chuàng)建一個獨立的作用域,所謂“自成一派”,而 在 coroutineScope 當中協(xié)程異常會觸發(fā)父協(xié)程的取消,進而將整個協(xié)程作用域取消掉,如果對 coroutineScope 整體進行捕獲,也可以捕獲到該異常,所謂“一損俱損”;如果是 supervisorScope,那么子協(xié)程的異常不會向上傳遞,所謂“自作自受”。
- 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ū)
開發(fā)者頭條:Kotlin中文社區(qū)