再谈协程之异常到底怎么办

855 阅读4分钟

协程的异常处理与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,只是给你个通知,协程异常了,所以这里只能对异常做记录,无法再操作协程。

向大家推荐下我的网站 xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问