协程的异常处理与OKhttp、RxJava这些框架的处理方式都不太一样,因为异步代码的异常处理,往往是比较麻烦的,而到了同步化处理的协程框架下,异常就变得比较容易进行管理了。
要完全理解协程的异常,我们需要先理解协程的树形结构和结构化并发,在这基础上,就能很容易的理解协程是如果管理异常的了。
协程树与结构化并发
在协程作用域中,可以创建一个协程,同时,一个协程中还可以继续创建协程,所以这就形成了一个树形结构。借助这样的树形结构,协程可以很容易的控制结构化并发,父协程可以控制子协程的生命周期,而子协程可以从父协程继承协程上下文。
在代码中,可以通过coroutineScope {}来显示的创建一个协程作用域,它和测试时常用的runBlocking {}一样,都是协程的作用域构建器。
协程作用域的cancel
借助协程作用域的管理,我们可以轻松的控制该协程作用域下的所有协程,一旦取消一个协程作用域,那么这个协程作用域下的所有协程都将被取消。
val job1 = scope.launch {...}
val job2 = scope.launch {...}
scope.cancel()
如上所示,调用scope的cancel之后,job1和job2都将被取消。
而如果只想取消某个单独的协程,那么可以通过该协程的句柄Job对象来取消。
val job1 = scope.launch { … }
val job2 = scope.launch { … }
job1.cancel()
如上所示,这样就只取消了Job1的协程,而Job2不受影响。
这就是协程结构化并发的两个特点:
- 取消一个协程作用域,将取消该协程作用域下的所有子协程
- 被取消的子协程,不会影响其它同级的协程
在Android开发中,大部分场景下我们不需要考虑协程的cancel,借助ViewModelScope、LifecycleScope和MainScope这些场景的协程作用域,我们可以很方便的避免内存泄漏,在cancel时结束所有的子协程。
协程的cancel状态
协程的cancel与线程的cancel类似,协程一旦开始执行(代码占用CPU),只有执行完毕才会被cancel,当协程调用cancel,只是将协程的Job生命周期设置为了Canceling,直到协程执行完毕才会被置为Canceled。
如果一定要及时取消掉协程的执行,那么可以和线程做类似的操作,在协程代码内及时判断协程的状态来控制代码的执行。
所以,协程推荐开发者在使用协程时,以协作的方式来使用,即随时判断当前协程的生命周期,避免浪费计算资源。
协程提供了两种方式来进行协作式的cancel:
- Job.isActive或者ensureActive()
- yield
ensureActive()是Job.isActive的封装实现,借助这个方法,就是在协程内代码执行前,对当前协程的状态进行一次判断。
清理
通常, 当协程被取消时, 需要做一些清理工作, 此时, 可以把协程中运行的代码用try {} fininaly {}块包住, 这样当协程被取消时, 会执行fininaly块中的清理工作。但是fininaly块中不能直接调用挂起函数,否则会抛出CancellationException异常,因为它已经被取消了,而你又要在fininaly块中执行挂起函数把它挂起,显然与要求矛盾。然而,如果非要这么做,也不是不可以,当你需要挂起一个被取消的协程,你可以将相应的代码包装在withContext(NonCancellable) {}中,并使用withContext函数以及NonCancellable上下文,代码如下所示。
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
withContext(NonCancellable) { // 重点注意这里
println("job: I'm running finally")
delay(1000L) // 这里调用了挂起函数!
println("job: And I've just delayed for 1 sec because I'm non-cancellable")
}
}
}
delay(1300L) // 延迟一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消该作业并等待它结束
println("main: Now I can quit.")
}
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
job: And I've just delayed for 1 sec because I'm non-cancellable
main: Now I can quit.
协程的返回值
协程获取返回值有两种方式:
- launch返回的Job实例可以调用Join方法(Join函数会挂起协程直到协程执行完成)
- async返回的Deferred实例(Job 的子类)可以调用await方法
如果在调用Join后再调用cancel,那么协程将在执行完成后被Cancel,如果先cancel再调用Join,那么协程也将执行完成
协程异常的处理
当协程作用域中的一个协程发生异常时,此时的异常流程如下所示:
- 发生异常的协程被cancel
- 异常传递到它的父协程
- 父协程cancel(取消其所有子协程)
- 将异常在协程树上进一步向上传播
这种行为实际上是符合协程结构化并发的规则的,但是在实际使用中,这种结构化的异常处理,会让异常的处理有些暴力,大部分场景下,业务需求都是希望异常不影响正常的业务流程。
结构化并发的异常处理
所以,协程提出了SupervisorJob的新概念,它是Job的子类。
SupervisorJob的作用就是将协程中的异常「掐死」在协程内部,切断其向上传播的路径。使用SupervisorJob后,子协程的异常退出不会影响到其他子协程,同时SupervisorJob也不会传播异常而是让异常发生的协程自己处理。
SupervisorJob可以在创建CoroutineScope的时候作为参数传进来,也可以使用supervisorScope来创建一个自定义的协程作用域,所以SupervisorJob只有下面两种使用方式。
- supervisorScope{}
- CoroutineScope(SupervisorJob())
但是要注意的是,不论是SupervisorJob还是Job,如果协程内部发生异常,这个异常是肯定会被抛出的,只是是否会崩溃。
这里有个误区,那就是大家不要以为使用SupervisorJob之后,协程就不会崩溃,不管你用什么Job,该崩溃的还是要崩溃的,它们的差别在于是否会影响到别的协程,例如下面这个例子。
val coroutineScope = CoroutineScope(Job())
coroutineScope.launch {
throw Exception("test")
}
coroutineScope.launch {
Log.d("xys", "test")
}
使用Job的时候,第二个协程是无法执行的,但你改为SupervisorJob()之后,第二个协程就可以执行了,因为第一个协程的崩溃,并没有影响到第二个协程的执行。
所以说,SupervisorJob的目的是为了在结构化并发中找到一个特殊处理的方式,并没有将异常隐藏起来。
SupervisorJob最多的使用场景就是多协程的并发处理,让某个协程的异常不干扰其它正常的协程。而CoroutineScope也很有用,因为你可以在一个协程发生异常时,取消其关联的所有协程,做为统一的处理。
从异常流动方向上来看,coroutineScope是双向的,而supervisorScope则是单向的。
平时常见的MainScope,就是使用的SupervisorJob,所以MainScope中的子协程之间互相不会影响。
协程的异常处理
前面我们说了,协程中的异常是一定会抛出的,所以在一个协程内部,我们到底怎么处理异常呢?
launch:通过launch启动的异常可以通过try catch来进行异常捕获,或者使用协程封装的拓展函数runCatching来捕获,其内部也是使用的try catch。
async:async的异常处理比较麻烦,我们下面详细的说下。
首先,当async被用作构建根协程(由协程作用域直接管理的协程)时,异常不会主动抛出,而是在调用.await()时抛出。
来看下这个例子:
MainScope().launch {
supervisorScope {
val deferred = async {
throw Exception("test")
}
try {
deferred.await()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
执行这个例子后,异常将被捕获,从上面的代码可以看出,异常只会发生在执行await的时候,调用async是不会发生异常的,不过,细心的朋友可能发现了,这里使用的是supervisorScope,如果我们改成coroutineScope呢?
执行代码后我们会发现,异常并没有被捕获,这就是我们前面说到的SupervisorJob和Job的区别。
再看一个例子:
MainScope().launch {
try {
async {
throw Exception("test")
}
} catch (e: Exception) {
e.printStackTrace()
}
}
我们去掉了supervisorScope,所以async的父协程是Job,所以这个时候,即使是调用async,也会发生异常,同时也不会被捕获。
综上,async的异常,只能在supervisorScope中,使用try catch进行捕获。
CoroutineExceptionHandler
CoroutineExceptionHandler类似Android中的全局异常处理,当异常在协程树中传递时,如果没有设置CoroutineExceptionHandler,那么异常将被继续传递直到抛出,但如果设置了CoroutineExceptionHandler,那么则可以在这里处理未捕获的异常,CoroutineExceptionHandler的创建如下所示。
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("xys", "---${coroutineContext} ${throwable.printStackTrace()}")
}
我们来看下面的这个例子,在父协程中设置CoroutineExceptionHandler,当它的子协程发生异常时,即使不使用try catch,异常也会被捕获。
MainScope().launch(exceptionHandler) {
async {
throw Exception("test")
}
}
但是考虑下这样一个场景,让发生异常的协程使用CoroutineExceptionHandler,代码如下所示。
MainScope().launch {
async(exceptionHandler) {
throw Exception("test")
}
}
很遗憾,这样就不能捕获异常,因为CoroutineExceptionHandler属于异常抛出的协程,它本身无法处理。
所以,CoroutineExceptionHandler的使用也有这样的限制,即CoroutineExceptionHandler必须在发生异常的父协程中设置,其原因就是协程的结构化并发,异常会传递到父协程中进行处理,所以,这里必须是父协程中设置CoroutineExceptionHandler才能生效。
要注意的是,CoroutineExceptionHandler只是协程处理异常「最后的倔强」,此时协程已经完全Cancel,只是给你个通知,协程异常了,所以这里只能对异常做记录,无法再操作协程。
向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问