阅读 1723

【带着问题学】协程异常到底是怎么传播的?

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

之前对协程异常机制做过一个使用上的介绍,介绍了协程的异常机制与优雅封装,感兴趣同学可以了解下:协程异常机制与优雅封装 | 技术点评

通过上文,我们可以了解到以下前置知识:
1.协程通过异常处理器来捕获异常
2.协程取消时会抛出CancellationException,需要特别对待
3.协程中存在不同的作用域,不同作用域的异常传播机制不同

看到上面这些前置知识,本文主要内容也就呼之欲出了
1.协程异常处理器是如何生效的?
2.协程取消时的异常如何处理?
3.不同的作用域的异常传播机制不同是怎样实现的?
4.协程异常传播流程图总结

1. 前置知识

本文主要内容是对kotlin协程异常传播的原理介绍,下面先介绍一下前置知识

1.1 异常处理器

kotlin异常处理器即CoroutineExceptionHandler,CoroutineExceptionHandler也继承于CoroutineContext
在创建协程时添加到上下文中,在发生异常时通过key从上下文中获得
我们一般这样使用:

    val handler = CoroutineExceptionHandler { coroutineContext, throwable ->
        println("error")
    }

    viewModelScope.launch(handler) {
    	//...
    }
复制代码

1.2 协程作用域

协程作用域主要用于明确协程之间的父子关系,以及对于取消或者异常处理等方面的传播行为,主要分为以下三类:

  • 顶级作用域:没有父协程的协程所在的作用域为顶级作用域
  • 协同作用域:协程中启动新的协程,新协程为所在协程的子协程,这种情况下子协程所在的作用域默认为协同作用域。此时子协程抛出的未捕获异常都将传递给父协程处理,父协程同时也会被取消。
  • 主从作用域:与协同作用域在协程的父子关系上一致,区别在于处于该作用域下的协程出现未捕获的异常时不会将异常向上传递给父协程

1.3 异常传播机制

协程最创新的功能之一就是结构化并发。为了使结构化并发的所有功能成为可能,CoroutineScopeJob对象以及CoroutinesChild-CoroutinesJob对象形成了父子关系的层次结构。
未捕获的异常会优先向上传播,直到没有父协程才自行处理。这种异常传播会导致父Job的失败,进而导致其子级所有Job的取消。


如上所示,子协程的异常传播到协程(1)的Job,然后传播到topLevelScope(2)的Job

但是如果我们中间使用了supervisorScope,它将截断异常向上传播

2. 异常处理器是如何生效的?

我们一般使用CoroutineExceptionHandler来处理异常,简单示例如下

    fun testExceptionHandler() {
        val handler = CoroutineExceptionHandler { coroutineContext, throwable ->
            print("error")
        }
        viewModelScope.launch(handler) {
            print("1")
            throw NullPointerException()
            print("2")
        }
    }
复制代码

那么问题来了,异常我们通常是用try,catch捕获的,是怎么转换成这个的呢?
这个问题其实挺简单的,我们打个断点看下调用栈:

上面其实很直观了,这就是协程内异常传递的调用栈 ,这里面主要有两个重点

  1. BaseContinuationImplresumeWith方法
  2. StandaloneCoroutinehandleJobException方法

2.1 BaseContinuationImpl介绍

我们之前在介绍协程到底是什么的时候介绍过,我们的协程体实质上是ContinuationImpl的子类,会周期性的回调BaseContinuationImplinvokeSuspend方法
如果这方面有不了解的同学,可以再回顾下:协程字节码反编译

我们来看下BaseContinuationImplresumeWith方法

    public final override fun resumeWith(result: Result<Any?>) {
        //...
        val outcome: Result<Any?> =
            try {
                val outcome = invokeSuspend(param)
                if (outcome === COROUTINE_SUSPENDED) return
                Result.success(outcome)
            } catch (exception: Throwable) {
                Result.failure(exception)
            }
            //...
        completion.resumeWith(outcome)
        return
        
    }
复制代码

这里的代码也比较简单

  1. 我们的协程体实现执行在invokeSuspend方法中
  2. 当协程体抛出异常时,自然就被catch住了,并被包装成Result.failure
  3. 异常结果通过completion.resumeWith继续向上抛出

2.2 StandaloneCoroutine介绍

从刚开始的调用栈可以看出,协常会被传递到StandaloneCoroutine中,这个StandaloneCoroutine又是哪里来的呢?
其实是在我们启动协程时传入的completion,也是协程体执行完成后回调的completion.resumeWith(outcome)中的completion,启动代码如下所示:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}
复制代码

当我们启动协程时,如果没有指定启动模式,默认传入的就是StandaloneCoroutine
关于协程的启动,之前分析过,如果有想了解的同学可参考:launch方法解析

下面我们看下handleJobException方法

private open class StandaloneCoroutine(
    parentContext: CoroutineContext,
    active: Boolean
) : AbstractCoroutine<Unit>(parentContext, active) {
    override fun handleJobException(exception: Throwable): Boolean {
        handleCoroutineException(context, exception)
        return true
    }
}

public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
    try {
    	//如果设置了异常处理器,则从context从取出来,并处理异常
        context[CoroutineExceptionHandler]?.let {
            it.handleException(context, exception)
            return
        }
    } catch (t: Throwable) {
        handleCoroutineExceptionImpl(context, handlerException(exception, t))
        return
    }
    // 如果没有设置CoroutineExceptionHandler,则传递给全局的异常处理器
    handleCoroutineExceptionImpl(context, exception)
}
复制代码

从上可以直观地看出:

  1. handleJobException只是单纯的调用了handleCoroutineException方法
  2. handleCoroutineException方法首先会尝试从context中取出CoroutineExceptionHandler
  3. 如果我们没有设置CoroutineExceptionHandler,则会传递给全局的异常处理器,最终使用线程的uncaughtExceptionHandler兜底

3. 协程取消时的异常如何处理?

我们已经知道被取消的协程会在挂起点抛出CancellationException, 并且它会被协程的机制所忽略
那么问题来了,CancellationException为什么会被忽略呢?

还是要回到代码上来,继续看上面的调用栈,在调用handleJobException前,会调用JobSupportfinalizeFinishingState方法

private fun finalizeFinishingState(state: Finishing, proposedUpdate: Any?): Any? {
	//...
	if (finalException != null) {
            val handled = cancelParent(finalException) || handleJobException(finalException)
            if (handled) (finalState as CompletedExceptionally).makeHandled()
        }
	//...
}

private fun cancelParent(cause: Throwable): Boolean {
    //...
    //CancellationException被认为正常的
    val isCancellation = cause is CancellationException
    val parent = parentHandle
    // 如果没有parent协程,则忽略CancellationException,但仍然传播其他异常
    if (parent === null || parent === NonDisposableHandle) {
        return isCancellation
    }

    // 通知parent协程去检查子协程取消状态
    return parent.childCancelled(cause) || isCancellation
}
复制代码

从上可以看出:

  1. 在调用handleJobExcepion前,会先调用cancelParent
  2. cancelParent中如果发现是CancellationException,则会直接返回true,所以handleJobExcepion也就不会执行了,异常也就不会向上传播了

4. supervisorScope异常传播

上文我们提到,supervisorScope会截断异常向上传播,是怎么做到的呢?我们一起看下代码

public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = SupervisorCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

private class SupervisorCoroutine<in T>(
    context: CoroutineContext,
    uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {
    override fun childCancelled(cause: Throwable): Boolean = false
}
复制代码

从上可以看出:

  1. 使用supervisorScope作用域,启动时传入的是SupervisorCoroutine
  2. SupervisorCoroutine重写了childCancelled方法,返回false表示不处理子协程的异常,因此异常就此截断了

5. coroutineScope异常传播

我们上文说到coroutineScope遇到未捕获异常会优先向上传播,如果没有父协程才会自行处理,我们可以看一个例子

fun main() {
    val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
        println("Handle $exception in CoroutineExceptionHandler")
    }
    val topLevelScope = CoroutineScope(Job())
    topLevelScope.launch {
        launch(coroutineExceptionHandler) {
            throw RuntimeException("RuntimeException in nested coroutine")
        }
    }
    Thread.sleep(100)
}
// 输出
// Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: RuntimeException in nested coroutine
复制代码

可以发现程序还是crash了
为什么不生效?
这是因为给子协程设置CoroutineExceptionHandler是没有效果的,我们必须给顶级协程设置,或者初始化Scope时设置才有效

这个原理也很简单,我们再回顾下代码

private fun finalizeFinishingState(state: Finishing, proposedUpdate: Any?): Any? {
	//...
	if (finalException != null) {
            val handled = cancelParent(finalException) || handleJobException(finalException)
            if (handled) (finalState as CompletedExceptionally).makeHandled()
        }
	//...
}
复制代码

使用CoroutineScope时,只要parent协程不为空,则cancelParent一直会返回true,后面的handleJobException不会执行
因此给子协程设置CoroutineExceptionHandler是没有效果的,为了使其起作用,必须将其设置在CoroutineScope或顶级协程中。

总结

本文主要分析了kotlin协程的异常传播机制,主要分为以下几步

  1. 协程体内抛出异常
  2. 判断是否是CancellationException,如果是则不做处理
  3. 判断父协程是否为空或为supervisorScope,如果是则调用handleJobException,处理异常
  4. 如果不是则将异常传递给父协程,然后父协程再进行一遍上面的流程

以上步骤总结为流程图如下所示:

参考资料

协程异常处理
破解 Kotlin 协程(4) - 异常处理篇
协程异常机制与优雅封装 | 技术点评

文章分类
Android
文章标签