Featured image of post 协程中子协程异常处理

协程中子协程异常处理

本文通过案例来熟悉子协程到父协程之间的异常是如何处理的。

案例一:子协程异常,父协程handler捕捉

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private fun demo1() {
    val handler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d(TAG, "onCreate: CoroutineExceptionHandler:${throwable.message}")
    }
    GlobalScope.launch(handler) {
        Log.d(TAG, "onCreate: parentJob start")
        withContext(Dispatchers.IO) {
            throw RuntimeException("runtime exception")
            delay(1000)
            Log.d(TAG, "onCreate: withContext end")
        }
        Log.d(TAG, "onCreate: parentJob end")
    }
}

先上日志:

1
2
com.example.coroutinescopedemo       D  onCreate: parentJob start
com.example.coroutinescopedemo       D  onCreate: CoroutineExceptionHandler:withContext runtime exception

结论:异常能被launch指定的handler所捕捉。
分析: launch启动的协程用到的coroutineScope是一个StandaloneCoroutine,withContext启动的协程对应的coroutineScope是一个DispatchedCoroutine(因为父context和子context的dispatcher不一样,所以创建的是DispatchedCoroutine)。 在withContext中发生异常的时候,首先会回调到DispatchedCoroutine的resumeWith,最终会走到finalizeFinishingState方法,该方法里面会判断是否存在异常,如果有异常会调用cancelParent方法:

finalizeFinishingState判断异常的逻辑.png

cancelParent处理结果.png

可以看到如果isScopedCoroutine为true的时候,cancelParent直接返回true,如果返回true,那么就不触发自己的上面的handleJobException,也就是把异常继续往上抛了。例子中也就是launch对应的StandaloneCoroutine。而在StandaloneCoroutine中不会去cancelParent,因为它的parent是null,所以会把异常交给了handleJobException了,所以上面的launch中传入的CoroutineExceptionHandler能捕获到该异常。

总结:如果子job中在处理异常的时候,cancelParent中如果isScopedCoroutine为true的时候,则不触发自己的handleJobException,也就是把异常交给了父job,如果父job不处理该异常,则会程序崩溃。

案例二:子协程异常,子协程try-catch捕捉

上面代码如果把withContext中的异常通过try-catch住,父job就收不到该异常了: 子协程try-catch住的例子.png 子协程try住后的日志.png 子协程把异常catch住后,父协程的handler捕捉不到异常,并且父协程的invokeOnCompletion收不到异常,父协程之后的代码也能正常执行。因为在子协程对应的SuspendLambda中的invokeSuspend方法给try-catch住,不会把异常往上抛。

案例三:子协程的handler无法捕捉

子协程给context传递coroutineExceptionHandler: 子协程中的context传递handler的例子.png 子协程传递handler的日志.png

子协程抛了异常,然后子协程也传了CoroutineExceptionHandler,但是子协程的CoroutineExceptionHandler不起作用,还是把异常传给了父协程。并且父协程的invokeOnCompletion收到了异常回调,而且发现父协程的invokeSuspend方法也没走完,所以onCreate: parentJob end没有输出。

分析:前面已经分析过withContext开启的协程对应的coroutineScope是一个DispatchedCoroutine重写了isScopedCoroutine=true,如果它为true,cancelParent方法则返回true,那么它自己的handleJobException就不会触发,所以就不会走到自己的CoroutineExceptionHandler回调了。

案例四:子协程抛CancellationException,父协程无法捕捉

当子协程抛的是CancellationException,父协程捕捉不到该异常: 子协程抛CancellationException异常的例子.png 子协程抛CancelationException的日志.png

此处handler没有捕捉到异常,并且程序也没崩溃,这是因为在子协程把异常回调给父协程后,父协程对应的scope,也就是StandaloneCoroutine在cancelParent中判断是CancelationException,它直接返回true,所以不会调用handleJobException方法,而向外抛异常的正是该方法。所以当子协程抛出CancellationException时候不会使父协程崩溃。

案例五:子协程抛异常,父协程分发到其它子协程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private fun demo2() {
    val handler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d(TAG, "handler:${throwable.message}")
    }
    val job = Job()
    val coroutineScope = CoroutineScope(job + handler)
    val job1 = coroutineScope.launch {
        delay(100)
        Log.d(TAG, "job1 end")
        throw RuntimeException("runtime exception")
    }
    job1.invokeOnCompletion {
        Log.d(TAG, "job1 invokeOnCompletion:${it?.message}")
    }
    val job2 = coroutineScope.launch {
        delay(200)
        Log.d(TAG, "job2 end")
    }
    job2.invokeOnCompletion {
        Log.d(TAG, "job2 invokeOnCompletion:${it?.message}")
    }
    val job3 = coroutineScope.launch {
        delay(300)
        Log.d(TAG, "job3 end")
    }
    job3.invokeOnCompletion {
        Log.d(TAG, "job3 invokeOnCompletion:${it?.javaClass?.simpleName}")
        Log.d(TAG, "job3 invokeOnCompletion:${it?.message}")
    }
}

日志如下:

1
2
3
4
5
6
com.example.coroutinescopedemo       D  job1 end
com.example.coroutinescopedemo       D  job2 invokeOnCompletion:Parent job is Cancelling
com.example.coroutinescopedemo       D  job3 invokeOnCompletion:JobCancellationException
com.example.coroutinescopedemo       D  job3 invokeOnCompletion:Parent job is Cancelling
com.example.coroutinescopedemo       D  handler:runtime exception
com.example.coroutinescopedemo       D  job1 invokeOnCompletion:runtime exception

数据结构:父协程的job启动了三个子协程,在job1中抛出异常,job2和job3收到了JobCancellationException。其中父job是一个JobImpl对象,在每个子协程启动过程中都会创建一个ChildHandleNode对象,其中job指向了父Job,也就是JobImpl,childJob指向了当前子job,也就是StandaloneCoroutine,最后在子Job中通过parentHandle指向了父job(通过ChildHandleNode的parent指向了父job),state指向了InvokeOnCompletion对象(是通过invokeOnCompletion添加的)。父job中通过state指向了一个NodeList,每一个next节点指向了三个子job(分别都是ChildHandleNode对象)。在三个子job中通过delay实现挂起,在delay的时候,会创建CancellableContinuationImpl,它是用来监听job取消的task,它会通过ChildContinuation进行持有,最后ChildContinuation添加到当前子job的state上,所以目前每一个子job的state上有两个对象,一个是InvokeOnCompletion对象,一个是ChildContinuation,组合成一个NodeList对象。

异常处理:当第一个job发生异常后,先将自己的state设置上Finishing状态,并且给Finish添加异常信息,接着会调用到job1的cancelParent逻辑,该方法中会调用到parentHandle的childCancelled逻辑,它是一个ChildHandleNode对象,在它的childCancelled方法中,会触发父job的childCancelled方法,最终会来到父job的cancelImpl方法。在cancelImpl中会触发cancelMakeCompleting->tryMakeCompleting->tryMakeCompletingSlowPath,在tryMakeCompletingSlowPath里面也会给父job设置上Finishing状态,同时会触发每一个ChildHandleNode对象的invoke,在invoke里面又会触发childJob的parentCancelled方法。此时又会来到每个子job的cancelImpl方法,在里面会触发makeCancelling方法。由于第一个job的state是Finishing状态,所以会给state添加一个JobCancellationException异常,但是不会覆盖原始异常。在job2和job3触发到cancelImpl方法时候,在makeCancelling里面,由于job2和job3的state是一个Incomplete对象,所以会创建JobCancellationException异常,接着调用了tryMakeCancelling方法,在该方法里面会给job2和job3的state设置上Finishing状态,并且会触发前面创建的ChildContinuation的invoke方法,在ChildContinuation的invoke中会触发CancellableContinuationImpl的parentCancelled方法,在里面最终会触发到SuspnedLambda的resumeWith,最后会触发job2和job3的resume方法,最终会把JobCancellationException异常分发到当前state的InvokeOnCompletion上。分发完子Job的cancelImpl后,继续回到父Job的tryMakeCompletingSlowPath流程中,给当前出现异常的job的state添加一个ChildCompletion对象并结束了tryMakeCompletingSlowPath流程。当job1的cancelParent逻辑都处理完后,也就是在finalizeFinishingState方法中。此时的cancelParent返回false,是因为调用父job的childCancelled时候handlesException变量为false,所以cancelParent方法返回false,那么此时会回调handleJobException方法,回调到CoroutineExceptionHandler,最后给state设置上CompletedExceptionally。接着会触发自己state的NodeList上的JobNode,一个是InvokeOnCompletion,一个是ChildCompletion,在ChildCompletion中会触发父job的continueCompleting方法,在该方法中会触发finalizeFinishingState逻辑,在该方法里面会将异常给到CoroutineExceptionHandler处理,但是此时的父job(JobImpl)没有重写handleJobException方法,所以不会分发到CoroutineExceptionHandler。最后将state设置为CompletedExceptionally,到此整个流程结束。
所以上面的日志先是job2和job3的InvokeOnCompletion收到JobCancellationException异常,接着是context中的CoroutineExceptionHandler收到原始异常,最后是job1的InvokeOnCompletion收到原始异常。

上面涉及到几个关键的类,把关键类的结构梳理下: alt text alt text

案例六:父Job使用SupervisorJob,异常不分发到其它子协程,异常协程自己处理异常

把案例五中的Job()换成SupervisorJob()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private fun demo3() {
    val handler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d(TAG, "handler:${throwable.message}")
    }
    val coroutineScope = CoroutineScope(SupervisorJob() + handler)
    val job1 = coroutineScope.launch {
        delay(100)
        Log.d(TAG, "job1 end")
        throw RuntimeException("runtime exception")
    }
    job1.invokeOnCompletion {
        Log.d(TAG, "job1 invokeOnCompletion:${it?.message}")
    }
    val job2 = coroutineScope.launch {
        delay(200)
        Log.d(TAG, "job2 end")
    }
    job2.invokeOnCompletion {
        Log.d(TAG, "job2 invokeOnCompletion:${it?.message}")
    }
    val job3 = coroutineScope.launch {
        delay(300)
        Log.d(TAG, "job3 end")
    }
    job3.invokeOnCompletion {
        Log.d(TAG, "job3 invokeOnCompletion:${it?.javaClass?.simpleName}")
        Log.d(TAG, "job3 invokeOnCompletion:${it?.message}")
    }
}

alt text 只是换了个job,job1还是一样收到了原始异常,job2和job3正常执行,并能执行完。按照上面分析,当job1发生异常的时候,会调用到cancelParent,它会分发到父job的childCancelled方法,而SupervisorJob重写了该方法直接返回false。所以异常不会分发到子协程中,当job1的cancelParent返回false的时候,会执行到handleJobException,而job1使用的context中的CoroutineExceptionHandler是使用的父job中指定的CoroutineExceptionHandler。因为context是plus叠加的方式。所以最后handler中收到了异常。

以下的案例来自于https://juejin.cn/post/7049537608262615070

案例七:try-catch在非异常协程位置,无法捕捉

案例七

结果:try-catch竟然捕获不住,程序直接抛异常了

如果想要调试的话,可以给协程拼接CoroutineName,这样在调试的时候知道job对应的context中CoroutineName。代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private fun demo7() {
    val scope = CoroutineScope(Dispatchers.Main + Job())
    scope.launch(CoroutineName("job1")) {
        Log.d(TAG, "scope1:$this")
        try {
            thirdApi(this)
        } catch (e: Exception) {
            Log.d(TAG, "demo7: e:${e.message}")
        }
    }
}
private fun thirdApi(coroutineScope: CoroutineScope) {
    coroutineScope.launch(CoroutineName("job2")) {
        Log.d(TAG, "thirdApi: start")
        delay(100)
        Log.d(TAG, "thirdApi: end")
        throw NullPointerException("null pointer exception")
    }
}

此处try-catch的位置不在子协程的SuspendInvoke位置,它是在主协程的launch位置,其实它是在invokeSuspend中调用内部launch的时候加了try-catch,对应的class代码如下:

alt text 从字节码来看,只是启动子协程的时候给try-catch。
第一阶段:job1给job2的state添加ChildCompletion监听
job1的SuspendLambda在正常执行invokeSuspend,执行到job1的时候,来到了tryMakeCompletingSlowPath方法,在该方法中会给job2的state添加ChildCompletion回调
第二阶段:job2触发job1的childCancelled
从原理上分析,当子job2发生异常后,在cancelParent中会调用job1的childCancelled方法,接着调用到cancelImpl,由于job1是一个StandaloneCoroutine,所以接着调用makeCancelling,此时job1的state是一个Finishing,isCancelling还是false。所以会把异常加入到state中。接着触发了notifyCancelling方法,在里面调用了notifyHandlers。
第三阶段:job1触发job2的parentCancelled
在里面调用到ChildHandleNode的invoke,继而触发了job2的parentCancelled方法,来到了job2的cancelImpl,由于job2也是一个StandaloneCoroutine。所以会触发job2的makeCancelling,此时job2中的state是一个Finishing,并且isCancelling为true,所以job2的makeCancelling结束。
第四阶段:job1触发父job的childCancelled
继续回到job1的notifyCancelling方法,接着调用了cancelParent方法,又来到了父job(JobImpl),也就是父job的cancelImpl,由于父job是一个jobImpl,所以会调用cancelMakeCompleting,一路来到了tryMakeCompletingSlowPath方法。
第五阶段:父job触发job1的parentCancelled
在该方法中接着触发了父job的ChildHandleNode的invoke,也就来到了job1的parentCancelled。又来到了job1的cancelImpl,此时job1的state是Finished,并且isCancelling = true,所以结束了makeCancelling。
第六阶段:父job继续执行tryMakeCompletingSlowPath
父job1触发完parentCancelled后,在tryMakeCompletingSlowPath里面通过自己的ChildHandleNode的childJob(也就是job1)的state添加一个ChildCompletion对象。
第七阶段:job2继续执行cancelParent之后的逻辑
job2在finalizeFinishingState方法中执行完cancelParent之后,会执行completeStateFinalization逻辑,在里面会回调到第一阶段添加的ChildCompletion逻辑中,在里面会调用job1的continueCompleting方法,在该逻辑中会调用cancelParent,而cancelParent会回调false,所以会触发handleJobException,而job1对应的是StandaloneCoroutine,它里面会拿context中的CoroutineExceptionHandler,如果没拿到直接抛异常。

案例八:异常job的父job处增加CoroutineExceptionHandler捕捉异常

alt text

结果:此处不会发生异常,异常被exceptionHandler捕捉。
分析:结合案例七的分析,在第七阶段,job1处理异常的时候,会交给handleJobException方法,并让CoroutineExceptionHandler捕捉异常,因此不会崩溃。

案例九:handler给到异常协程,handler捕获不到异常

alt text

分析:结合案例七的分析,最终是在job1中处理异常,而此处设置的handler是设置到job2处了,所以程序崩溃了。

案例十:增加coroutineScope,异常协程的父协程增加try-catch捕捉异常

案例十

结果:程序不会崩溃,异常被try catch捕获住,而不是被exceptionHandler捕获住。

前面分析过当子协程发生异常后,会把异常分发给到父job,在父job中需要等子job都处理完异常了,才会往下走,该处逻辑主要体现在子job中添加了一个ChildCompletion节点到state中,在invoke中会执行到parent.continueCompletion方法: alt text alt text 此处的parent实际是coroutineScope作用域的job,它是ScopeCoroutine,看下它的continueCompleting实现: alt text alt text 最终会把结果回调给到了传递进来的continuation,也就是最外层launch启动的时候的SuspendLambda,最终会调用它的invokeSuspend方法: alt text 所以最终被try-catch捕捉到异常。

案例十一:增加supervisorScope作为异常协程的作用域,异常能被异常协程的handler所捕获

案例十一

结果:程序被内层launch指定的exceptionHandler捕捉了 supervisorScope用到的job是SupervisorCoroutine,它重写了childCancel方法,并返回false,所以当子job发生异常的时候,不会抛给父job,并执行自己的handleJobException方法,所以被自己的handler捕获到。

案例十二

案例十二 集合案例七分析,在第七阶段,job1在处理异常时,会找到自己的handler,最终异常被外层的handler所捕获。

案例十三:增加supervisorScope作为异常协程的作用域,异常被内部的handler所捕获

案例十二

此时被内层launch的handler所捕捉,原因是supervisorScope启动的协程不会往上抛,交给了子协程自己处理。

总结

父子协程关系:父子协程之间通过job建立起关系,在子协程的job初始化的时候,会通过childHandleNode绑定到父子关系,其中子job指向childHandleNode的childJob,父job指向childHandleNode的job上,最后子job通过parentHandle持有该childHandleNode。父协程中如何包含了多个子协程,此时父job的state会是一个NodeList,也就是链表结构的对象,然后NodeList的next节点持有了第一个子协程的childHandleNode,第一个子协程的childHandleNode的next节点持有了第二个子协程的childHandleNode,依次类推。
异常处理:当某个子协程发生异常后,会将自己的state给置为Finishing状态,并且给Finishing状态添加异常信息,接着将异常给到父协程,如果此时父协程不处理异常,则交给自己的context中的CoroutineExceptionHandler处理,如果context中找不到CoroutineExceptionHandler,则将异常抛给系统,直到app崩溃。如果父协程处理异常,则会先取消掉自己下面的其他子协程的job,然后将异常继续抛给自己的父协程,依次这样去处理异常。

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy