Kotlin协程出来的时间也很久了,在Kotlin里总是用着RxJava,好像也有点别扭。
官方也是推荐将Kotlin协程作为Kotlin的异步编程方案。
之前对Kotlin协程一直只停留在初步了解的程度。想用到实际项目中也不知如何下手,这次就抽空下决心系统的整理学习一遍kotlin协程。
于是便有了这Kotlin协程系列的文章,算是学习记录,如有疏漏错误,欢迎各位大佬指正。
本篇主要介绍kotlin协程的基础使用,以及粗略挖掘一下其背后的运作机理。
Kotlin版本 : 1.5.31
coroutine 版本 : 1.5.2
Kotlin协程系列相关文章导航
Kotlin Flow上手指南(三)SharedFlow与StateFlow
以下正文。
概念
-
协程
- 挂起恢复
- 程序自己处理挂起恢复
- 程序自己处理挂起恢复来实现协程的协作运行
核心就是一段程序能够被挂起,并在稍后再在挂起的位置恢复。
Kotlin协程是依赖于线程池API的,在一个线程可以创建多个协程,并且协程运行时并不会阻塞当前线程。
其最大的特点就是能够以阻塞(同步)方式写出非阻塞(异步)的代码。
详细可参见:
-
挂起恢复
表示从当前运行线程中暂时离开,等待任务完成。 当任务执行完成后,再从当前运行线程继续执行后续任务。
suspend挂起函数
使用suspend关键字修饰的函数被称为挂起函数。
suspend关键字的作用就只是告诉编译器,这是一个挂起函数,仅作一个标记,提醒会挂起执行。
- 挂起函数被限制在只能在协程作用域
CoroutineScope、协程、另一个suspend挂起函数内被调用。- 挂起并不等于切线程,同一个线程内依然可以挂起。
比如官方API中的delay函数就是个顶层的suspend函数,不会阻塞线程,将协程延迟指定时间,然后恢复执行后续任务。
挂起函数在挂起时,不会阻塞协程所运行的线程
在编译器内,suspend修饰的函数,以及调用suspend函数的地方都还会在左侧有个挂起点的标识
但suspend修饰的函数不一定会有挂起操作,协程的挂起是由内部框架执行的,suspend关键字本身只是个提示作用,并限制挂起函数调用位置。
如果
suspend函数内部没有挂起逻辑,编译器也会提示redundant suspend modifier警告,表示这个suspend关键字是多余的。
创建协程
协程创建的方式有很多种,首先来看看在kotlin协程标准库中,是如何创建协程的。
val coroutine = suspend {
//模拟异步任务
...
println("create coroutine")
"result"
}.createCoroutine(object : Continuation<String>{
override val context: CoroutineContext = Job()
override fun resumeWith(result: Result<String>) {
println("Coroutine Result $result")
}
})
//执行协程
coroutine.resumeWith(Result.success(Unit))
suspend修饰函数作为接收者的拓展函数createCoroutine,创建协程,调用resultWith启动协程。
而通常我们创建协程都是要让协程直接启动的,所有官方还提供了另一种拓展函数startCoroutine。
是不是感觉很不对劲?这也太麻烦了。
不慌,其实官方也不推荐直接使用这种方式创建协程,这就如同直接new Thread()把线程跑完再回调回来一样,中间几乎是个黑盒无法干预。
所以官方要求使用CoroutineScope管理内部协程的生命周期。
最常用的方法还是使用CoroutineScope的拓展函数launch与async来创建、启动协程。
launch
launch函数会创建新协程,立即执行,不会阻塞当前线程
通常是用于启动不需要返回值的新协程任务。
该函数会返回一个Job对象是协程的句柄,可用于管理协程的开启与关闭。
fun testCoroutineLaunch() {
val scope = CoroutineScope(Job())
val job = scope.launch{
println("running launch")
...
}
//控制新创建协程任务的关闭
job.cancel()
}
参数block就是以CoroutineScope为接收者的函数类型。
async
而async函数同样会创建新协程并立即执行,不会阻塞当前线程。
与launch的唯一区别是,该函数会返回一个Deferred对象(Job子类),可以通过调用await函数挂起协程,直到协程执行完成,返回执行结果。(不阻塞当前线程)
async方法构建的协程,支持并发async协程不调用await函数也会开始执行协程
fun testAsync() = runBlocking {
val startTime = System.currentTimeMillis()
val task1 = async {
delay(100)
println("task1 running print")
1
}
val task2 = async {
delay(150)
println("task2 running print")
2
}
task1.await()
task2.await()
val endTime = System.currentTimeMillis()
println("async task over ${endTime - startTime}")
}
task1 running print
task2 running print
async task over 164 //总计时间表明两个协程为并发关系
此外官方还提供了awaitAll函数,可以将相同类型返回值的协程同步挂起并返回List
val taskList = listOf(task1,task2)
taskList.awaitAll()
awaitAll(task1,task2)
launch和async仅能够在CouroutineScope中使用,所以任何创建的协程都会被该CoroutineScope追踪。Kotlin禁止创建不能够被追踪的协程,从而避免协程泄漏。
runBlocking
还有一种仅在单元测试时使用的协程创建方式runBlocking。
runBlock以阻塞当前线程的方式创建一个新协程作用域,直到协程体执行完成。
回调转协程
回想一下在Android日常开发中是如何进行异步任务?
val handler = Handler()
getTestData(object : CallBack{
override fun onSuccess(result : String){
...
handler.post(...)
}
})
在线程池开启一个线程,然后在异步任务结束后,要有个恢复的动作,将线程切换回UI主线程更新UI。而这种切换线程恢复的动作,在以前都会演变成回调的方式。
如果此时需要顺序触发下一个异步任务,叠加一层回调,依次类推,也就演变成了俗称的"回调地狱"。
那么协程要如何解决回调地狱呢?
官方提供了suspendCoroutine和suspendCancellableCoroutine来将回调API转化为suspend的函数。
-
suspendCoroutine
-
suspendCancellableCoroutine
其中suspendCancellableCoroutine是最为常用的,也是官方推荐使用转换回调API的方式。
相比suspendCoroutine新增了可取消的能力,能调用cancel方法取消函数挂起。
内部调用的
suspendCoroutineUninterceptedOrReturn函数是编译器内部字节码的函数,没有源码,从注释上来看,作用就是拿到Continuation实例
这两个函数的参数block都是以Continuation(CancellableContinuation是其子类)作为参数的高阶函数。
在函数体内调用resumeWith函数,接收Result类型参数,即恢复挂起,返回函数处理结果。
此外,官方还提供了拓展函数resume和resumeWithException来更方便的恢复协程。
Retrofit自2.6.0版本后就默认支持协程,他们又是如何做到的呢?
其实同样也是利用suspendCancellableCoroutine,调用resume方法进行网络请求回调。
如此,利用suspendCancellableCoroutine,我们便能优雅的写出串行的异步(回调)逻辑
//模拟的回调API封装
fun queryData1() = suspendCancellableCoroutine<String>{continuation->
getTestData1(object : CallBack){
override fun onSuccess(result : String){
continuation.resume("result")
}
override fun onFail(throwable : Throwable){
continuation.resumeWithException(throwable)
}
}
}
//模拟的回调API封装
fun queryData2(value : String)
= suspendCancellableCoroutine<String>{continuation->
getTestData2(object : CallBack){
override fun onSuccess(result : String){
continuation.resume("$value new query")
}
override fun onFail(throwable : Throwable){
continuation.resumeWithException(throwable)
}
}
}
fun test() = runBlocking{
val result = queryData1()
val result2 = queryData2(result)
}
注意,这两种方式内部并不是处于协程作用域,不能调用挂起函数。
仅用于将回调API转化为挂起函数。
小结
观察launch、async、suspendCoroutine、suspendCancellableCoroutine函数,其实都可以算作是对标准协程构建的封装,最终会调用Continuation的resumeWith方法来恢复协程。
在kotlin协程中,有很多操作都围绕
Continuation展开的,而这怎么看都像是个回调嘛
kotlin协程的挂起后恢复本质上还是回调,只是把这部分代码隐藏在内部,实现了一个有限状态机,让外部的异步代码看起来像是同步一样。
- 如果一个协程内调用的挂起函数(如
delay、yeild等),如果没有主动调用resumeWith进行回调值,就会返回COROUTINE_SUSPENDED表示进入挂起状态,而不会执行协程内部的后续内容,相当于当前这个函数已经执行完毕,直到这个挂起函数调用resumeWith才会继续执行一次。 - 协程的挂起恢复逻辑都集中在原始协程体的包装类
ContinuationImpl内部,该包装类是在创建协程过程中通过内部调用的createCoroutineUnintercepted方法对原始协程体包装创建的。 - 协程在返回
COROUTINE_SUSPENDED挂起时相当于已经执行完包装类的resumeWith函数的逻辑,当前线程可以继续执行其他之后的逻辑,所以并不会阻塞线程
更多关于suspend函数挂起原理的详细解析,网上有很多很好的文章,可以参见
另外bennyhuo老师在B站的视频能更好的帮助理解协程挂起的过程
CoroutineContext
在Kotlin协程中,CorroutineContext是相当重要的组成部分。
launch、async、runBlocking等函数都接收CorroutineContext类型的参数。
CorroutineContext其实是一个包含了用户定义的一些各种不同元素的Element对象集合
内部实现为单链表,每一种
Element都有一个唯一key。
作为协程的持久上下文, 其允许定义协程的行为:
Job:控制协程的生命周期。CoroutineDispatcher:将工作分派到适当的线程。CoroutineName:协程的名称,可用于调试。CoroutineExceptionHandler:处理未捕获的异常。
Job
Job 作为协程的句柄,能够管理协程的生命周期
对于每一个您所创建的协程 (通过
launch或者async),它会返回一个 Job 实例
生命周期
每个Job任务都包含一系列生命周期状态机制
- New :新创建,未开始执行
- Active :调用start开启协程,使协程处于活跃状态
- Completing :当前协程已完成,等待子协程的执行完成
- Completed : 协程已完成已结束
- Cancelling : 处于取消中状态,出现异常或者调用
cancel时,等待子协程结束 - Canceled:已取消
这些生命周期状态并不能直接访问,但Job提供了间接访问属性
//处于活跃状态(Active)
public val isActive: Boolean
//是否已完成
public val isCompleted: Boolean
//是否已取消
public val isCancelled: Boolean
Job的生命周期状态流转图(来自官网)
Deferred
async方法创建协程返回的Deferre继承自Job。拥有相同的状态机制。除此之外,Deferre能够调用await函数,等待协程执行完后获取返回值。
CoroutineName
CoroutineName是用户用来指定的协程名称的,方便调试和定位问题
调度器
协程调度器顾名思义就是调度协程在哪个线程上执行,在JVM底层就是基于线程池API,而Android主线程就是基于主线程Handler。
在启动协程时,如果没有指定
CoroutineContext的调度器,且没有拦截器时,会默认就会添加一个Dispatchers.default调度器,也即是协程作用域的默认调度器。public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext { val combined = coroutineContext + context //继承自当前作用域的上下文 val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null) debug + Dispatchers.Default else debug //默认添加default作为拦截器 }
官方API默认提供了四种调度器
- Dispatchers
- Default :默认的后台计算线程池
- IO : 适合 IO 密集型的任务线程池,比如:读写文件,操作数据库以及网络请求
- Main : UI主线程
- Unconfined :当前默认的协程中运行。(但是在遇到第一个挂起点之后,恢复的线程是不确定的。所以对于 Unconfined 其实是无法保证全都在当前线程中调用的。)
其中Default和IO在内部会优化共享同一个线程池,避免频繁切换线程造成的资源损耗。
同时还提供了个顶层suspend函数withContext用于切换协程内部运行的协程上下文(包括调度器)。
可以只在外部调用
withContext只切换一次协程上下文,这样可以在多次调用的情况下,以尽可能避免了线程切换所带来的性能损失。
-
自定义调度器
官方提供了
ExecutorService的拓展函数,可以很方便的将线程池转化为Dispatcher。
拦截器
CoroutineDispatcher是ContinuationInterceptor的实现类
前面通过launch或async创建协程时,内部会调用intercepted方法,从而都是经过拦截器拦截包装后的Coroutine对象。
这就类似于okHttp中的拦截器的作用。所不同的是,拦截器在CoroutineContext中只能存在一个。
- 所有拦截器实现类,如果将
Element的Key自定义,内部依旧会将自定义的Key重新替换成ContinuationInterceptor。- 拦截器在
CoroutineContext中永远处于队列末尾,CoroutineContext取值时是从最后入队的开始取的,永远只会取到最先添加的拦截器。
调度器原理
引用官方的一张图,清晰表述了协程的线程调度流程
前面提到,由协程调度器CoroutineDispatcher实现的interceptContinuation函数,在通过intercept进行拦截时,将上一层的ContinuationImpl对象,再次包装一层,创建DispatchedContinuation。
当通过launch或async创建协程时,最后会调用这个包装DispatchedContinuation的resumeCancellableWith方法,进而实现线程调度功能
DispatchedContinuation类继承自DispatchedTask,而其父类SchedulerTask是Task类的类型别名,Task又实际是实现了Runnable接口。所以实际在调度器内部执行的任务就是对
DispatchedContinuation的拆包装过程,操作内部的上一层Continuation包装类ContinuationImpl的resumeWith方法。//SchedulerTask.kt internal actual typealias SchedulerTask = Task //实现了Runnable接口 //DispatchedTask.kt internal abstract class DispatchedTask<in T>( @JvmField public var resumeMode: Int ) : SchedulerTask() { public final override fun run() { ... val delegate = delegate as DispatchedContinuation<T> val continuation = delegate.continuation //上一层的协程体 ... continuation.resume(getSuccessfulResult(state)) //执行恢复上一层协程体 ... } }
以DEFAULT默认调度器来说,最终会创建ExperimentalCoroutineDispatcher,其内部维持着一个CoroutineScheduler,由Kotlin协程自己实现Executor接口的线程池。
而ExperimentalCoroutineDispatcher内部调用dispatch也是分发到这个线程池内部进行执行调度。
IO调度器创建的LimitingDispatcher内部也还是会调用ExperimentalCoroutineDispatcher的disptach方法。只是在LimitingDispatcher中对线程池调度的并发数量进行了限制,默认被限制在最大并发数64个的程度上。实际进行调度的功能还是利用其父类
ExperimentalCoroutineDispatcher中创建的线程池来实现。所以
Default与IO调度器实际上是公用同一个线程池。
至于前面提到的外部线程池转为协程调度器的拓展函数asCoroutineDispatcher方法,创建的ExecutorCoroutineDispatcherImpl类则是继承自其父类的ExecutorCoroutineDispatcher。
将协程任务转交给外部线程池进行调度。
internal class ExecutorCoroutineDispatcherImpl(override val executor: Executor) : ExecutorCoroutineDispatcher(), Delay {
override fun dispatch(context: CoroutineContext, block: Runnable) {
try {
executor.execute(wrapTask(block)) //交由外部线程池调度
} catch (e: RejectedExecutionException) {
unTrackTask()
cancelJobOnRejection(context, e)
Dispatchers.IO.dispatch(context, block) //出现线程异常则交由默认IO线程池重新调度
}
}
}
在Android平台上自然要使用主线程的Handler才能在主线程运行,那么Dispatchers.Main又是如何的进行调度的呢?
//Dispatchers.kt
@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
按注释上的说明 :
在其他平台上,如 JS 和 Native 上,其相当于
Default调度器。在JVM平台上,也有
JavaFx、Swing EDT等调度器需要添加对应依赖才存在实现
在MainCoroutineDispatcher内部由ServiceLoader来寻找MainDispatcherFactory的实现类中关于createDispatcher的实现。
而Android平台上的MainDispatcherFactory实现类就是AndroidDispatcherFactory。
内部创建HandlerContext的协程调度器,利用主线程的Handler.post方法将协程任务添加到消息队列进行执行。
组合上下文元素
如果需要为协程定义多个元素,则可以使用+运算符进行合并。比如同时设置Job、调度器、协程名称
val context = Job() + Dispatchers.Main + CoroutineName("name")
内部重写了
plus操作符如果有相同类型(Key相同),则会替代旧元素
CoroutineScope
kotlin协程的另一个重要组件就是CoroutineScope。
前面也提到过,CoroutineScope定义了协程所运行在的作用域,所有协程都必须在作用域内启动。
而可管理作用域内运行的任务,则通过调用 scope.cancel()来取消正在进行的任务
官方提供了 KTX 库在一些类的生命周期里提供了CoroutineScope,比如 在ViewModel内的viewModelScope 和 LifecycleOwner的lifecycleScope,都会在各自生命周期结束时取消作用域内的协程。
此外还有个全局协程作用域GlobalScope,但不推荐使用,不会继承外部作用域(即永远是顶级协程作用域)且无法自定义CoroutineContext
如果需要全局的驻留任务,最好还是在
Application内构建自定义的CoroutineScope。
作用域层级
在CoroutineScope中可以创建协程,而在协程体内也隐含了当前协程所处的CorroutinScope。
val scope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// 新的协程会将 CoroutineScope 作为父级
...
val result = launch {
// launch创建的新协程会将当前协程作为父级
...
}
}
以最开始的CoroutineScope为根节点层级,在哪个CoroutineScope中创建新协程,就是新的协程的父级。(图片来自官方)
但使用最根节点CoroutinesScop的创建的新协程的CoroutineContext实际是
新的 CoroutineContext = 父级 CoroutineContext + 新context (默认只创建Job)
父级
CoroutineContext里的 Job 是 scope 对象的 Job (红色) ,而新的 Job 实例 (绿色) 会赋值给新的协程的
CoroutineContext。在新协程的范围内,会覆盖父级CoroutineContext的Job对象。
父协程只能在全部子协程执行完成后才会进入完成状态,即使父协程本身的任务已经执行完成。
作用域分类
-
顶级作用域 :
没有父协程的协程所在的作用域称之为顶级作用域。
-
协同作用域 :
在协程中启动一个协程,新协程为所在协程的子协程。子协程所在的作用域默认为协同作用域。此时子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。
-
主从作用域 :
与协同作用域在协程的父子关系上一致,区别在于,处于该作用域下的协程出现未捕获的异常时,不会将异常向上传递给父协程。
官方还提供了两种在协程内部创建作用域的API:
-
coroutineScope
coroutineScope是顶层suspend函数,创建一个新的协程作用域,并调用指定的协程代码块,等待内部协程结束后再结束作用域,属于协同作用域。 -
supervisorScope
supervisorScope是顶层suspend函数,与coroutineScope的区别就是协程作用域在取消\异常不会自动传递到父协程层级,属于主从作用域。
协程启动模式
在launch和async函数的start参数中,允许接收枚举类CoroutineStart。
-
DEFAULT
创建协程后 立即调度执行,调度前如果被取消,直接进入取消响应的状态,有可能在执行前被取消。
-
ATOMIC
创建协程后,立即调度执行,协程执行到第一个挂起点之前,不响应取消,协程一定会被执行(执行途中可能会被取消) 。
-
LAZY
如果调度前被取消了,直接进入异常结束状态,且不调用start、await等方法是不会执行的。
-
UNDISPATCHED
协程在这种模式下会直接开始在当前线程下执行,直到运行到第一个挂起点。这听起来有点像
ATOMIC,不同之处在于UNDISPATCHED是不经过任何调度器就开始执行的。当然遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。
还记得launch和async内部调用的Coroutine.start方法吗?
其内部最终会调用到CoroutineStart的invoke方法
以默认的DEFAULT模式为例,调用startCoroutineCancellable方法来启动协程
是不是很眼熟?其实就是对于标准协程的拓展封装,其内部依然是围绕Continuation来启动协程。
更多关于启动模式,参见破解 Kotlin协程(2) - 协程启动篇
协程取消
前面提到CoroutineScope能统一管理作用域内的协程,最终是协程上下文的job对象,调用cancel方法来取消协程。
//Job.kt
public fun cancel(cause: CancellationException? = null)
该方法会使协程抛出一个特殊的CancellationException异常来结束协程操作。
同时作为参数
cause可以传递指定结束原因,默认为null,使用默认的异常defaultCancellationException//JobSupport.kt internal inline fun defaultCancellationException( message: String? = null, cause: Throwable? = null ) = JobCancellationException( message ?: cancellationExceptionMessage(), cause, this ) protected open fun cancellationExceptionMessage(): String = "Job was cancelled"
协程在取消时会轮询,所有子协程都将被取消
而被取消的协程作用域是不能再创建新协程的
被取消的协程并不会影响到相同层级的其他协程
val scopeJob = CoroutineScope().launch{
val job1 = launch{
...
}
val job2 = launch{
...
}
//只会取消job1协程
job1.cancel()
}
...
//会取消作用域内的所有协程
scopeJob.cancel()
协程不一定取消
虽说调用cancel方法会使协程进入取消流程。
但就与线程中执行Runnable类似,在协程开始运行后,取消协程并不意味着协程执行的任务也会随之停止。
fun test() = runBlocking{
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i <= 5){
if (System.currentTimeMillis() >= nextPrintTime){
println("job running print ${i++}")
nextPrintTime += 500
}
}
}
//程序执行1s
delay(1000)
println("coroutine scope delay done,wait cancel job")
job.cancel()
println("job canceled")
}
job running print 0
job running print 1
job running print 2
coroutine scope delay done,wait cancel job
job canceled
job running print 3
job running print 4
job running print 5
就像上面的代码,cancel执行完成后,协程依然在运行。
如果需要让上面的代码按预期执行,则需要在协程体中,定时检查协程是否已被取消。
-
isActive
还记得
Job提供的isActive属性吗?可以通过该属性检查协程是否处于活跃状态。同时官方还提供了
CoroutineScope的拓展函数可以检查协程活跃状态另外
Job还提供了更方便的拓展方法。 -
yield
这个函数会挂起当前协程,让出线程资源去执行其他协程任务。
其内部首先会调用
ensureActive方法检查协程的活跃状态 -
join
Jop.join方法会挂起当前协程,等待协程执行完成,才执行后续任务- 如果在调用
cancel方法后再调用join方法,协程会处于挂起直到协程执行完成 - 如果先调用
join再调用cancel,则不会产生影响,因为join执行后,协程就已经结束了。
- 如果在调用
-
await
Deferred.await方法挂起当前协程,等待协程执行完,并返回协程结果内容。- 如果在调用
cancel方法后,再调用await方法,会抛出CancellationException异常(表示正常结束),结束协程。 - 如果先调用
await再调用cancel,则不会产生影响,因为await执行后,协程就已经结束了。
- 如果在调用
协程的cancel能否成功,仅仅取决于是否在协程体中加入了检查点,比如 isActive 、yield、delay等, 如果协程没有加入检查点,那么cancel 一定是无效的。
释放资源
在协程被结束后,可以在finally代码块中,执行清理资源操作
fun test() = runBlocking{
val job = launch{
try{
//执行任务
doSomeWork()
}finally{
//在协程结束后,执行的操作
doCleanWork()
}
}
println("协程已结束")
}
但由于finally代码块是在协程结束后才会执行的,此时不能继续挂起协程。
此时如果还需要挂起,则可以指定context 为NonCancellable。
finally{
withContext(NonCancellable){
println("job NonCancellable suspend finally")
doCleanWork()
}
}
注意不能滥用
NonCancellable,这会导致协程无法被取消,造成内存泄漏。最好只用于处理一些资源回收操作
拓展
还记得在前面提到转换回调API的suspendCancellableCoroutine函数吗?该函数的block参数类型是(CancellableContinuation<T>) -> Unit。
作为Continuation子类的CancellableContinuation,其内部提供了invokeOnCancellation函数,可以在协程被取消(或出现异常) 时,执行一些资源回收操作。
比如在Retrofit中,就是在invokeOnCancellation中,协程结束时尝试结束Call。
协程异常传递
默认情况下,调用launch、或者async方法,会默认使用Job来创建协程。
而在协程内抛出异常结束时,且协程内部未捕获,会抛出异常给父协程,同时父协程内的所有子协程都将被取消,并且进一步向上一层级传递,并最终将异常传递给顶层的作用域
官方的示例图很形象的展示了这一过程
- 取消它自己的子级;
- 取消它自己;
- 将异常传递给它的父级。
CoroutineScope().launch{
...
lauch(CoroutineName("coroutine_1")){
//协程1出现异常
throw Exception("...")
}
launch(CoroutineName("coroutine_2")){
//协程2在异常出现后,会被取消
...
}
}
而传递给顶层作用域CoroutineScope,将会把作用域内所有协程全部取消。
协程作用域被取消后,无法继续开启新协程
SupervisorJob
如果我们不想因为一个任务的失败而影响其他任务,子协程运行失败不影响其他子协程和父协程,在协程创建时可以使用SupervisorJob
作为Job的子类,其重写了chileCanceled方法为false,表示不会传递异常到父类协程,只会在协程内部处理(结束当前协程),并结束内部子协程。
lifecycleScope.launch(SupervisorJob()){
...
}
当子协程任务出错或失败时,
SupervisorJob只会取消它和它自己的子级,也不会传播异常给它的父级,它会让子协程自己处理异常
而使用挂起函数supervisorScope创建的协程作用域,同样有SupervisorJob的作用。
异常捕获处理
但不论是Job还是SupervisorJob,只要内部没有捕获的异常都会被抛出,最终导致程序崩溃。
唯有协程取消时抛出的CancellationException异常是会被忽略的,这对于协程而言就意味着协程是正常结束或者退出。
-
launch
对于
launch创建的协程,异常会在第一时间被抛出,可以直接使用try catch来捕获。fun test() = runBlocking{ launch(CoroutineName("coroutine_1")){ try{ throw NullPointException("...") }catch(e : Exception){ //直接捕获异常 } } }但对于父协程内部开启的子协程,在父协程如果使用默认
Job创建的子协程,会在子协程抛出异常后,直接将异常抛出到最顶层作用域,内部无法拦截。fun test() = runBlocking{ launch(CoroutineName("coroutine_1")){ try{ launch(CoroutineName("inner_coroutine_1")){ throw NullPointerException("...") } }catch(e : Exception){ //无法捕获内部协程异常,会直接越过当前协程范围传播到父级作用域 } } }如果coroutine_1使用
SupervisorJob或者supervisorScope,则异常会被拦截在coroutine_1范围内,不会延伸传播到runBlocking,只会在协程内部处理。 -
async
对于
async创建的协程,若协程内没有捕获异常如果没有调用
await,外部try-catch是无法捕获到异常。fun test() = runBlocking{ try{ val task = async{ throw NullPointerException("...") } }catch(e : Exception){ //无法捕获到异常 } }而在调用了
await后,async代码块本身是不会抛出异常,await本身会抛出异常。fun test() = runBlocking{ val task = async{ throw NullPointerException("...") } try{ task.await() }catch(e : Exception){ //能够捕获到异常 } }
CoroutineExceptionHandler
在线程中有Thread.setDefaultUncaughtExceptionHandler 方法来处理未知异常。Kotlin协程中同样有相同的机制。
CoroutineExceptionHandler作为CoroutineContext中的一种,可以添加到CoroutineContext中,处理协程中未捕获的异常。
- 仅针对
launch创建协程自动抛出的异常生效。 - 对于
async创建协程是不会有任何效果,无法捕获。
CoroutineExceptionHandler是兜底机制,无法从异常中恢复协程!通常用作记录异常、资源回收等操作。
对于默认的Job而言,在子协程内设置CoroutineExceptionHandler是无意义的,异常会被向上传递到父协程,由设置的CoroutineExceptionHandler处理,如果没有设置,则继续向上传递,根作用域。
fun test() = runBlocking {
val scope = CoroutineScope(Job())
val handler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("handler cast $throwable")
}
scope.launch(handler){ //只有作用域的根协程,能拦截处理异常
launch(handler){ //子协程无法拦截
throw NullPointerException("...")
}
async(handler){ //async抛出的异常无法被handler捕获
throw NullPointerException("...")
}
}
}
如果内部子协程使用SupervisorJob,则异常就不会向上传递,直接在内部协程的CoroutineExceptionHandler捕获处理
scope.launch(handler){
launch(SupervisorJob()+handler){ //拦截在子协程内部的handler
throw NullPointerException("...")
}
}
而使用supervisorScope创建的作用域,也有相同的作用。作用域根协程不会将异常向上传递,而是在协程内部的CoroutineExceptionHandler捕获处理
supervisorScope{
launch(handler){ //在协程内部handler捕获
throw NullPointerException("...")
}
}
总结
Kotlin协程并没有什么神奇的“魔法”。
在基于JVM的Android平台上,本质上还是通过协程调度器把协程任务挂起,将阻塞转移到另一个线程,然后在协程任务执行完成后恢复,回调到原来的线程。
只是Kotlin协程将实现细节隐藏在框架内部与编译器字节码中,把它”拍平“了,这才显得能直接把异步任务写成同步的方式。
使用CoroutineScope的launch和async来启动协程,便于在CoroutineScope统一管理内部协程。
launch适合启动外部不需要返回值的协程async适合启动外部需要返回值的协程
在CoroutineContext中设置Dispatcher来让协程运行在指定协程调度器(线程)。
可通过withContext来切换协程内代码块所运行的协程调度器(线程)。
在Android中可以使用拓展库lifecycleScope和viewModelScope作用域管理协程。
- 作用域(父级协程)取消(异常)时,会取消所有子协程
- 作用域取消后无法创建新协程
- 父级协程需等待所有子协程执行完才能完成
- 默认情况下,子协程未捕获的异常会传递到父协程。
对于异步任务,内部可以使用isActive、yield检查协程状态,使其能够被取消。
对于回调API转化的协程,最好使用suspendCancellableCoroutine来创建可取消的协程(内部会检查协程状态)。
而当我们不希望协程出现异常时,自动传递到父级协程(无法拦截),造成同层级的其他协程被取消,可以在CoroutineContext中设置SupervisorJob,或者使用supervisorScope创建子协程作用域,将异常拦截在协程体内部。
最后,可以给CoroutineContext设置CoroutineExceptionHandler来作为最后的异常拦截器,处理一些出现异常后的资源回收操作。
在理解了Kotlin协程的基础后,下一篇将会尝试从RxJava使用者的角度,揭开Kotlin Flow的神秘面纱。