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
的神秘面纱。