1. 引言
仅知道协程中可以用CoroutineExceptionHandler来捕获区里异常避免闪退,是远远不够的,因为协程中的异常传递与处理部分,与协程结构化并发部分息息相关,一不小心,非常容易踩到坑上!
2. 异常干扰其它协程
在协程作用域的介绍使用当中,创建协程作用域的方式是:
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
为什么这个代码要显示构造SupervisorJob()
?这是个好问题,与本文内容息息相关。
因为显式构造这个的功能,只有在多协程并产生协程结构化的时候,才会表现出来。
先再多创建一个协程作用域,方便与前面的作用域对象作对比:
private val jobScope = CoroutineScope(Job() + Dispatchers.Main)
然后,接下来,为方面对比实际效果,封装下面的函数:
private fun launchTenSecondsCoroutine(scope: CoroutineScope, extraMsg: String) {
scope.launch(Dispatchers.IO) {
val targetMilli = System.currentTimeMillis() + TEN_SECONDS
while (true) {
ensureActive()
if (System.currentTimeMillis() > targetMilli) {
break
}
myLog("launchLoopingCoroutine $extraMsg")
Thread.sleep(ONE_SECOND)
}
}
}
很简单,就是用函数参数传递进来的协程作用域启动一个IO协程,这个协程会在10秒中之内不断循环,同时每次循环开启前提供一个协程取消协作点,使得协程可以被取消。
再封装一个启动协程一个协程并在执行的5秒后会抛出异常的函数:
private fun launchCoroutineThrowException(scope: CoroutineScope, extraMsg: String) {
scope.launch(exceptionHandler + Dispatchers.IO) {
Thread.sleep(FIVE_SECONDS)
throw IllegalStateException("$extraMsg Throw exception!")
}
}
这里启动协程前传入了协程异常处理者对象:
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
myLog("CoroutineExceptionHandler:$throwable")
}
捕获到异常时仅打印log信息。
现在,到了真正的实践代码环节,通过前面jobScope
对象来启动3个协程,即两个仅执行10秒的协程和一个执行5秒后会抛出异常且带有协程异常处理者的协程:
private fun launchWithJobClicked() {
myLog("launchWithJobClicked")
launchTenSecondsCoroutine(jobScope, "launchWithJobClicked A")
launchTenSecondsCoroutine(jobScope, "launchWithJobClicked B")
launchCoroutineThrowException(jobScope, "launchWithJobClicked")
}
小小卖个关子,这里不妨先想一下:应用会因为异常的抛出而闪退吗?如果不会,那么协程A和协程B,实际的执行时间大概有多长?
最终的结果会是:
应用不会因为协程的抛出而闪退,在5秒时有一个异常触发CoroutineExceptionHandler的处理,但协程A和协程B,实际的执行时间只有5秒左右(根据log的输出情况判断),永远达不到10秒。
是不是很诡异?明明协程A跟B的执行逻辑,如果协程没有被取消的话,明明会执行10秒的!为什么这里的log在5秒后就不再输出了呢?
等等,注意前面这句话,“如果协程没有被取消的话”,从最终的执行结果反过来想,会不会是因为协程被取消了呢?但是这时候又没有点击取消按钮啊,为什么协程被取消了呢?
这便是一个协程异常处理者的一个大坑,当一个协程中遇到的异常用CoroutineExceptionHandler处理以后,默认情况下,当前协程会将遇到的异常继续向父协程中传递并取消父协程,而父协程的取消必然会取消其所有子协程。
回到实践代码上,由于launchCoroutineThrowException
中启动的协程和协程A/B三者之间会为兄弟协程,三者有个共同的父Job,即为jobScope
对象在构造时所创建Job对象。
当launchCoroutineThrowException
的协程中抛出了异常且被CoroutineExceptionHandler处理后,会进一步地取消父Job,而父Job的取消,从而使得其另外的两个子协程A和B被取消。
所以,这便解析了,为什么协程A和协程B在执行过程中只执行5秒左右。
这里还需要注意,在launchWithJobClicked
在页面第一次被调用的时候,启动的三个协程均得到执行,只不过是5秒后会结束,而在这个函数第二次及以后执行中,所启动的协程函数体中的内容将不再获得执行,不妨再想想,为什么?此非本文重点,但这个对协程结构化并发的理解其实也很重要。
3. 异常不干扰其它协程
因为某一个协程出现异常导致其父协程以及其他的兄弟协程的取消,这种场景肯定是有的,比如同时发出一系列请求,有一个请求出现异常而失败时将导致最终请求结果为失败,这时候出现异常后及时取消其他协程,是合理的。
但是,如果希望协程间互相独立呢?即某一个协程因异常而结束,并不希望其影响其他协程,因为同一个协程作用域启动的协程,可能是互相影响结果,也可能是互相独立互不影响的部分。
这时候,便是SupervisorJob()
发挥作用的时候了。
前面启动的协程是通过jobScope:
private val jobScope = CoroutineScope(Job() + Dispatchers.Main)
现在将换用scope对象:
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
启动协程的内容和第2节中相同,只不过传入的协程作用域对象从jobScope
换成了scope
:
private fun launchWithSupervisorJobClicked() {
launchTenSecondsCoroutine(scope, "launchWithSupervisorJobClicked A")
launchTenSecondsCoroutine(scope, "launchWithSupervisorJobClicked B")
launchCoroutineThrowException(scope, "launchWithSupervisorJobClicked")
}
这时候,log的输出结果,是5秒时抛出一个异常被CoroutineExceptionHandler处理,而A/B两个协程的log输出在10秒内始终输出,而且多次执行launchWithSupervisorJobClicked
,里面启动的协程函数体部分始终会获得执行。
这里便是SupervisorJob()
和Job()
两种写法导致的差异了,在某个协程抛出异常并被CoroutineExceptionHandler处理时,当前协程会不会取消其父协程。
嗯?前面好像说这个协程也会取消其兄弟协程?为什么这里不写后半句了?首先,再回第2节看看表述,并没有协程抛出异常后会取消兄弟协程一说,在第2节中的情况,兄弟协程的取消不是因为当前协程遇到异常,而是因为父协程的取消必然会取消子协程,这部分是结构化并发方面对于协程取消的设计,当前协程不会取消父协程了,所以兄弟协程自然就不会被取消。。
而SupervisorJob()
和Job()
的区别,仅仅只是,产生的Job对象会不会因子协程的异常而被取消。
4. 创建协程作用域的考虑
通过第2节和第3节的对比,可以看出,同一个协程作用域所启动的协程之间是否会因异常而互相干扰,关键是创建协程作用域之时所创建的Job元素对象是哪一种。
如果希望其中一个协程产生异常而自动取消其他协程并后续不再能启动协程,那么应该用Job()
;如果不希望某个协程产生异常时会影响到其他协程并后续可以继续启动协程,那么应该用SupervisorJob()
。
在Android平台页面的设计上,更多情况下应该是SupervisorJob()
,同时Android中提供的lifeCycleScope
和viewModelScope
两种实现中所用的均是SupervisorJob()
。
这里必要再提及一句,当用CoroutineScope(context: CoroutineContext)
这种方式创建协程作用域时,如果不显式提供Job元素的对象,那么最终协程作用域中的Job元素对象类型将会是Job()
。结合前面第二节的实践代码结果,请谨慎在创建协程作用域时不提供Job元素的方式,比如谨慎使用CoroutineScope(Dispatchers.Main)
这种写法创建作用域对象,除非第2节的是所实践结果是业务需要的。
5. 结构化并发中关于异常处理
在初识结构化一文当中,在“进阶版等待多协程完成”一节中,介绍的挂起函数是supervisorScope
,注意看这个函数的命名,是否与前面介绍的两种Job之间的差异单词,是否很类似?是的,并不是不知道有coroutineScope
这个,也知道不少技术文章中均有使用这个coroutineScope
这个函数作为结构化并发介绍的,前文中是故意地使用supervisorScope
,避免后续触及使用到CoroutineExceptionHandler
时踩到隐藏的坑。
其实,在初始结构化一文中的实践代码,用到的supervisorScope
的地方,换用coroutineScope
也会是同样的结果,而这两者之间的区别,只有在启动的协程中使用CoroutineExceptionHandler
且遇上异常抛出时才会体现出来。
先说明一下,coroutineScope
也是个挂起函数,而前面创建协程作用域用的方法CoroutineScope(CoroutineContext)
是个普通顶层函数(并不是构造函数),注意首字母的大小写以及后面的参数区别以及是否挂起函数的区别。
说得有点多了,还是上代码吧。
private fun suspendWithJobClicked() {
myLog("suspendWithJobClicked")
scope.launch(exceptionHandler) {
myLog("suspendWithJobClicked parent coroutine")
val ret = coroutineScope {
launchTenSecondsCoroutine(this, "suspendWithJobClicked A")
launchTenSecondsCoroutine(this, "suspendWithJobClicked B")
launchCoroutineThrowException(this, "suspendWithJobClicked")
"coroutineScope final line"
}
myLog("suspendWithJobClicked parent coroutine final line: $ret")
}
}
这里用挂起函数coroutineScope
挂起当前协程,并且用产生的子协程作用域启动两个执行10秒的协程和一个5秒后抛出异常的协程,并且将在coroutineScope
的lambda表达式最后一个表达式中返回字符串"coroutineScope final line",在coroutineScope
后输出log,log中带有coroutineScope
的返回值。
问:这里的执行结果能打印出来"suspendWithJobClicked parent coroutine final line: xxx"这一行log吗?如果能,log输出的时间点是在协程启动后的不久后,还是5秒或10秒左右还是其他情况?最终的这一行log的拼接后的完整字符串内容会是什么?
答:这里的执行结果打印不出来"suspendWithJobClicked parent coroutine final line: xxx"一行log。
结合第2第3节的内容,这里结果不对啊,这里外边启动协程时用的scope创建是用的已经是SupervisorJob()
了,为什么当中启动的三个协程还会被提前取消?
注意啊,这里的启动协程的封装函数中传入的this对象,是coroutineScope
中创建的子协程作用域而不是scope
对象本身,所以思考的应该是coroutineScope
中创建的协程作用域是Job
类型还是SupervisorJob
类型,很遗憾,从结果上看,是Job
类型(源码上也是)。
所以,这时候启动的3个协程的中结果,等同于第2节,即一个协程抛出异常,最终会导致其父协程以及兄弟协程的取消,从而使得最后一行log输出的代码不被执行。
那如果希望3个协程的执行互不干扰,想要第3节中类似的执行结果,该如何?很简单,换用supervisorScope
即可:
private fun suspendWithSupervisorJobClicked() {
myLog("suspendWithSupervisorJobClicked")
scope.launch {
myLog("suspendWithSupervisorJobClicked parent coroutine")
val ret = supervisorScope {
launchTenSecondsCoroutine(this, "suspendWithSupervisorJobClicked A")
launchTenSecondsCoroutine(this, "suspendWithSupervisorJobClicked B")
launchCoroutineThrowException(this, "suspendWithSupervisorJobClicked")
"supervisorScope final line"
}
myLog("suspendWithSupervisorJobClicked parent coroutine final line: $ret")
}
}
问:这里的执行结果能打印出来"suspendWithSupervisorJobClickedparent coroutine final line: xxx"这一行log吗?如果能,log输出的时间点是在协程启动后的不久后,还是5秒或10秒左右还是其他情况?最终的这一行log的拼接后的完整字符串内容会是什么?
答:能打印出来这行log,输出的时间点在10秒后,最后一行log的完整字符串会是
"suspendWithSupervisorJobClickedparent coroutine final line: supervisorScope final line"。
这里,不妨简单对比下coroutineScope
和supervisorScope
两者,因为这两者才是真正的同一层面的可对比函数:
- 如果启动的子协程中均没有抛出异常,两者在功能上没有区别;
- 如果启动的子协程中任意一个某一时刻抛出了异常且用CoroutineExceptionHandler进行处理,那么前者的其他子协程会再异常抛出后被取消,后者的其他子协程不受影响;
附:目前见不少地方会用coroutineScope
和CoroutineScope(context: CoroutineContext)
这两者作比较,事实上这两者除了函数名起得特别像以后,函数的使用范围、设计功能都是截然不同的,并没有什么可比较性。
6. 样例工程代码
代码样例Demo,见Github:https://github.com/TeaCChen/CoroutineStudy
本文示例代码,如觉奇怪或啰嗦,其实为SupervisorActivity.kt
中的代码摘取主要部分说明,在demo代码当中,为提升细节内容,有更加多的封装和输出内容。
本文的页面截图示例如下:
7. 补充说明
对于协程的异常处理,实际代码开发当中是要结合协程作用域、并发处理、协程取消协程等等内容来进一步按照需求来进行设计,协程当中各种概念都不是孤岛,往往讲解一部分内容的时候会设计到另一部分的补充设计,学习或使用协程的过程中请尽量保持好奇和探索。
基础篇的内容到此为止,如果能看到这里,对于系列内容中,协程启动、协程中切换线程、协程的取消、协程作用域、挂起函数、结构化并发、协程异常等内容有了个大概的实践或认识,对于launch
、withContext
、Dispatchers.IO
、Dispatchers.Main
、ensureActive
、CoroutineScope
、join
、async
、await
、supervisorScope
、CoroutineExceptionHandler
等关键内容有个大概的认识。
基础篇整体内容,服务于对协程拿起就用的实用主义,同时将协程的各项设计(协程作用域、挂起函数、取消、异常、结构化并发)从实践代码中逐步带出,为的是对协程使用有个较为系统的认识。
在协程的使用上,自然可以有“一看就会”这种更加通俗易懂的介绍内容,而且也不用分九个部分来逐一实践,但是个人认为,在协程的使用上,欲速则不达。这一系列内容,个人相信是一学就会的,对于里面各种讲解的各种情况,也给出了相应的项目版完整代码,有实践、有对比也有讲解,剩下的,就是思考了。
我思故我在。
慢者,为快。
一学就会的协程使用——基础篇
一学就会的协程使用——基础篇(一)协程启动
一学就会的协程使用——基础篇(二)线程切换
一学就会的协程使用——基础篇(三)初遇协程取消
一学就会的协程使用——基础篇(四)协程作用域
一学就会的协程使用——基础篇(五)再遇协程取消
一学就会的协程使用——基础篇(六)初识挂起
一学就会的协程使用——基础篇(七)初识结构化
一学就会的协程使用——基础篇(八)初识协程异常
一学就会的协程使用——基础篇(九)异常与supervisor(本文)