扒一扒Kotlin协程

3,925 阅读13分钟

Kotlin协程出来的时间也很久了,在Kotlin里总是用着RxJava,好像也有点别扭。

官方也是推荐将Kotlin协程作为Kotlin的异步编程方案。

之前对Kotlin协程一直只停留在初步了解的程度。想用到实际项目中也不知如何下手,这次就抽空下决心系统的整理学习一遍kotlin协程。

于是便有了这Kotlin协程系列的文章,算是学习记录,如有疏漏错误,欢迎各位大佬指正。

本篇主要介绍kotlin协程的基础使用,以及粗略挖掘一下其背后的运作机理。

Kotlin版本 : 1.5.31

coroutine 版本 : 1.5.2

Kotlin协程系列相关文章导航

扒一扒Kotlin协程

Kotlin Flow上手指南(一)基础应用

Kotlin Flow上手指南(二)ChannelFlow

Kotlin Flow上手指南(三)SharedFlow与StateFlow

以下正文。


概念

  • 协程

    • 挂起恢复
    • 程序自己处理挂起恢复
    • 程序自己处理挂起恢复来实现协程的协作运行

    核心就是一段程序能够被挂起,并在稍后再在挂起的位置恢复

    Kotlin协程是依赖于线程池API的,在一个线程可以创建多个协程,并且协程运行时并不会阻塞当前线程

    其最大的特点就是能够以阻塞(同步)方式写出非阻塞(异步)的代码

    详细可参见:

    破解 Kotlin协程 番外篇(1) - 协程为什么被称为『轻量级线程』?

  • 挂起恢复

    表示从当前运行线程中暂时离开,等待任务完成。 当任务执行完成后,再从当前运行线程继续执行后续任务。

suspend挂起函数

使用suspend关键字修饰的函数被称为挂起函数

suspend关键字的作用就只是告诉编译器,这是一个挂起函数,仅作一个标记,提醒会挂起执行。

  • 挂起函数被限制在只能在协程作用域CoroutineScope、协程、另一个suspend挂起函数内被调用。
  • 挂起并不等于切线程,同一个线程内依然可以挂起

比如官方API中的delay函数就是个顶层的suspend函数,不会阻塞线程,将协程延迟指定时间,然后恢复执行后续任务。

delay源码解析.png

挂起函数在挂起时,不会阻塞协程所运行的线程

在编译器内,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启动协程。

createCoroutine源码解析.png

而通常我们创建协程都是要让协程直接启动的,所有官方还提供了另一种拓展函数startCoroutine

startCoroutine源码.png

是不是感觉很不对劲?这也太麻烦了。

不慌,其实官方也不推荐直接使用这种方式创建协程,这就如同直接new Thread()把线程跑完再回调回来一样,中间几乎是个黑盒无法干预。

所以官方要求使用CoroutineScope管理内部协程的生命周期。

最常用的方法还是使用CoroutineScope的拓展函数launchasync来创建、启动协程。

launch

launch函数会创建新协程,立即执行不会阻塞当前线程

通常是用于启动不需要返回值的新协程任务。

Kotlin协程的launch源码解析.png

该函数会返回一个Job对象是协程的句柄,可用于管理协程的开启与关闭。

 fun testCoroutineLaunch() {
     val scope = CoroutineScope(Job())
     val job = scope.launch{
         println("running launch")
         ...
     }
     //控制新创建协程任务的关闭
     job.cancel()
 }

参数block就是以CoroutineScope为接收者的函数类型。

async

async函数同样会创建新协程并立即执行不会阻塞当前线程

Kotlin协程async源码解析.png

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)

launchasync 仅能够在 CouroutineScope 中使用,所以任何创建的协程都会被该CoroutineScope追踪。

Kotlin禁止创建不能够被追踪的协程,从而避免协程泄漏。

runBlocking

还有一种仅在单元测试时使用的协程创建方式runBlocking

runBlock阻塞当前线程的方式创建一个新协程作用域,直到协程体执行完成。

runBlocking源码解析.png

回调转协程

回想一下在Android日常开发中是如何进行异步任务?

 val handler = Handler()
 ​
 getTestData(object : CallBack{
      override fun onSuccess(result : String){
          ...
          handler.post(...)
      }
 })

在线程池开启一个线程,然后在异步任务结束后,要有个恢复的动作,将线程切换回UI主线程更新UI。而这种切换线程恢复的动作,在以前都会演变成回调的方式。

如果此时需要顺序触发下一个异步任务,叠加一层回调,依次类推,也就演变成了俗称的"回调地狱"。

那么协程要如何解决回调地狱呢?

官方提供了suspendCoroutinesuspendCancellableCoroutine来将回调API转化为suspend的函数。

  • suspendCoroutine

    suspendCoroutine源码解析.png

  • suspendCancellableCoroutine

    suspendCancellableCoroutine源码解析.png

其中suspendCancellableCoroutine是最为常用的,也是官方推荐使用转换回调API的方式。

相比suspendCoroutine新增了可取消的能力,能调用cancel方法取消函数挂起。

内部调用的suspendCoroutineUninterceptedOrReturn函数是编译器内部字节码的函数,没有源码,从注释上来看,作用就是拿到Continuation实例

这两个函数的参数block都是以ContinuationCancellableContinuation是其子类)作为参数的高阶函数。

Continuation源码.png

在函数体内调用resumeWith函数,接收Result类型参数,即恢复挂起,返回函数处理结果。

此外,官方还提供了拓展函数resumeresumeWithException来更方便的恢复协程。

resume与resumeWithException源码.png

Retrofit自2.6.0版本后就默认支持协程,他们又是如何做到的呢?

其实同样也是利用suspendCancellableCoroutine,调用resume方法进行网络请求回调。

Retrofit对协程的支持源码.png

如此,利用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转化为挂起函数。

小结

观察launchasyncsuspendCoroutinesuspendCancellableCoroutine函数,其实都可以算作是对标准协程构建的封装,最终会调用ContinuationresumeWith方法来恢复协程。

在kotlin协程中,有很多操作都围绕Continuation展开的,而这怎么看都像是个回调嘛

kotlin协程的挂起后恢复本质上还是回调,只是把这部分代码隐藏在内部,实现了一个有限状态机,让外部的异步代码看起来像是同步一样。

  • 如果一个协程内调用的挂起函数(如delayyeild等),如果没有主动调用resumeWith进行回调值,就会返回COROUTINE_SUSPENDED表示进入挂起状态,而不会执行协程内部的后续内容,相当于当前这个函数已经执行完毕,直到这个挂起函数调用resumeWith才会继续执行一次。
  • 协程的挂起恢复逻辑都集中在原始协程体的包装类ContinuationImpl内部,该包装类是在创建协程过程中通过内部调用的createCoroutineUnintercepted方法对原始协程体包装创建的。
  • 协程在返回COROUTINE_SUSPENDED挂起时相当于已经执行完包装类的resumeWith函数的逻辑,当前线程可以继续执行其他之后的逻辑,所以并不会阻塞线程

更多关于suspend函数挂起原理的详细解析,网上有很多很好的文章,可以参见

图解协程原理

揭秘协程中的 suspend 修饰符

破解 Kotlin协程(6) - 协程挂起篇

深入理解协程的挂起、恢复与调度

另外bennyhuo老师在B站的视频能更好的帮助理解协程挂起的过程

挂起函数咋挂起?不如自挂东南枝...

CoroutineContext

在Kotlin协程中,CorroutineContext是相当重要的组成部分。

launchasyncrunBlocking等函数都接收CorroutineContext类型的参数。

CoroutineContext源码解析.png

CorroutineContext其实是一个包含了用户定义的一些各种不同元素的Element对象集合

内部实现为单链表,每一种Element都有一个唯一key。

作为协程的持久上下文, 其允许定义协程的行为:

Job

Job 作为协程的句柄,能够管理协程的生命周期

对于每一个您所创建的协程 (通过launch或者 async),它会返回一个 Job 实例

Job源码解析.png

生命周期

每个Job任务都包含一系列生命周期状态机制

  • New :新创建,未开始执行
  • Active :调用start开启协程,使协程处于活跃状态
  • Completing :当前协程已完成,等待子协程的执行完成
  • Completed : 协程已完成已结束
  • Cancelling : 处于取消中状态,出现异常或者调用cancel时,等待子协程结束
  • Canceled:已取消

这些生命周期状态并不能直接访问,但Job提供了间接访问属性

 //处于活跃状态(Active)
 public val isActive: Boolean
 //是否已完成
 public val isCompleted: Boolean
 //是否已取消
 public val isCancelled: Boolean

Job的生命周期状态流转图(来自官网)

job生命周期.webp

Deferred

async方法创建协程返回的Deferre继承自Job。拥有相同的状态机制。除此之外,Deferre能够调用await函数,等待协程执行完后获取返回值。

Deferred await源码解析.png

CoroutineName

CoroutineName源码解析.png

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作为拦截器
 }

CoroutineDispatcher源码解析.png

官方API默认提供了四种调度器

  • Dispatchers
  • Default :默认的后台计算线程池
  • IO : 适合 IO 密集型的任务线程池,比如:读写文件,操作数据库以及网络请求
  • Main : UI主线程
  • Unconfined :当前默认的协程中运行。(但是在遇到第一个挂起点之后,恢复的线程是不确定的。所以对于 Unconfined 其实是无法保证全都在当前线程中调用的。)

其中Default和IO在内部会优化共享同一个线程池,避免频繁切换线程造成的资源损耗。

同时还提供了个顶层suspend函数withContext用于切换协程内部运行的协程上下文(包括调度器)。

withContext源码解析.png

可以只在外部调用 withContext 只切换一次协程上下文,这样可以在多次调用的情况下,以尽可能避免了线程切换所带来的性能损失。

  • 自定义调度器

    官方提供了ExecutorService的拓展函数,可以很方便的将线程池转化为DispatcherasCoroutineDispatcher源码.png

拦截器

CoroutineDispatcherContinuationInterceptor的实现类

ContinuationInterceptor源码解析.png

前面通过launchasync创建协程时,内部会调用intercepted方法,从而都是经过拦截器拦截包装后的Coroutine对象。

这就类似于okHttp中的拦截器的作用。所不同的是,拦截器在CoroutineContext中只能存在一个

  • 所有拦截器实现类,如果将Element的Key自定义,内部依旧会将自定义的Key重新替换成ContinuationInterceptor
  • 拦截器在CoroutineContext永远处于队列末尾CoroutineContext取值时是从最后入队的开始取的,永远只会取到最先添加的拦截器

调度器原理

引用官方的一张图,清晰表述了协程的线程调度流程

协程调度器流程.webp

前面提到,由协程调度器CoroutineDispatcher实现的interceptContinuation函数,在通过intercept进行拦截时,将上一层的ContinuationImpl对象,再次包装一层,创建DispatchedContinuation

当通过launchasync创建协程时,最后会调用这个包装DispatchedContinuationresumeCancellableWith方法,进而实现线程调度功能

协程调度器的调度入口.png

DispatchedContinuation类继承自DispatchedTask,而其父类SchedulerTaskTask类的类型别名,Task又实际是实现了Runnable接口。

所以实际在调度器内部执行的任务就是对DispatchedContinuation拆包装过程,操作内部的上一层Continuation包装类ContinuationImplresumeWith方法。

 //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内部也还是会调用ExperimentalCoroutineDispatcherdisptach方法。只是在LimitingDispatcher中对线程池调度的并发数量进行了限制,默认被限制在最大并发数64个的程度上。

  • 实际进行调度的功能还是利用其父类ExperimentalCoroutineDispatcher中创建的线程池来实现。

  • 所以DefaultIO调度器实际上是公用同一个线程池。

协程默认调度器原理.png

至于前面提到的外部线程池转为协程调度器的拓展函数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平台上,也有JavaFxSwing EDT 等调度器需要添加对应依赖才存在实现

MainCoroutineDispatcher内部由ServiceLoader来寻找MainDispatcherFactory的实现类中关于createDispatcher的实现。

而Android平台上的MainDispatcherFactory实现类就是AndroidDispatcherFactory

内部创建HandlerContext的协程调度器,利用主线程的Handler.post方法将协程任务添加到消息队列进行执行。

协程的主线程调度器.png

组合上下文元素

如果需要为协程定义多个元素,则可以使用+运算符进行合并。比如同时设置Job、调度器、协程名称

 val context = Job() + Dispatchers.Main + CoroutineName("name")

内部重写了plus操作符

如果有相同类型(Key相同),则会替代旧元素

CoroutineScope

kotlin协程的另一个重要组件就是CoroutineScope

前面也提到过,CoroutineScope定义了协程所运行在的作用域,所有协程都必须在作用域内启动

而可管理作用域内运行的任务,则通过调用 scope.cancel()来取消正在进行的任务

CoroutineScope源码.png

官方提供了 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中创建新协程,就是新的协程的父级。(图片来自官方) CoroutineContext图解.webp

但使用最根节点CoroutinesScop的创建的新协程的CoroutineContext实际是

新的 CoroutineContext = 父级 CoroutineContext + 新context (默认只创建Job)

父级 CoroutineContext 里的 Job 是 scope 对象的 Job (红色)

而新的 Job 实例 (绿色) 会赋值给新的协程的 CoroutineContext

在新协程的范围内,会覆盖父级CoroutineContext的Job对象。

父协程只能在全部子协程执行完成后才会进入完成状态,即使父协程本身的任务已经执行完成。

作用域分类

  • 顶级作用域

    没有父协程的协程所在的作用域称之为顶级作用域。

  • 协同作用域

    在协程中启动一个协程,新协程为所在协程的子协程。子协程所在的作用域默认为协同作用域。此时子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。

  • 主从作用域

    与协同作用域在协程的父子关系上一致,区别在于,处于该作用域下的协程出现未捕获的异常时,不会将异常向上传递给父协程。

官方还提供了两种在协程内部创建作用域的API:

  • coroutineScope

    coroutineScope是顶层suspend函数,创建一个新的协程作用域,并调用指定的协程代码块,等待内部协程结束后再结束作用域,属于协同作用域

    顶层函数coroutineScope源码.png

  • supervisorScope

    supervisorScope是顶层suspend函数,与coroutineScope的区别就是协程作用域在取消\异常不会自动传递到父协程层级,属于主从作用域

    顶层函数supervisorScope源码.png

协程启动模式

launchasync函数的start参数中,允许接收枚举类CoroutineStart

  • DEFAULT

    创建协程后 立即调度执行,调度前如果被取消,直接进入取消响应的状态,有可能在执行前被取消

  • ATOMIC

    创建协程后,立即调度执行,协程执行到第一个挂起点之前,不响应取消,协程一定会被执行(执行途中可能会被取消)

  • LAZY

    如果调度前被取消了,直接进入异常结束状态,且不调用start、await等方法是不会执行的

  • UNDISPATCHED

    协程在这种模式下会直接开始在当前线程下执行,直到运行到第一个挂起点。这听起来有点像 ATOMIC,不同之处在于UNDISPATCHED是不经过任何调度器就开始执行的。当然遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。

还记得launchasync内部调用的Coroutine.start方法吗?

其内部最终会调用到CoroutineStartinvoke方法

Coroutine的start源码解析.png

以默认的DEFAULT模式为例,调用startCoroutineCancellable方法来启动协程

startCoroutineCancellable源码解析.png

是不是很眼熟?其实就是对于标准协程的拓展封装,其内部依然是围绕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的拓展函数可以检查协程活跃状态

    isActive源码.png

    另外Job还提供了更方便的拓展方法。

    ensureActive源码.png

  • yield

    这个函数会挂起当前协程,让出线程资源去执行其他协程任务。

    其内部首先会调用ensureActive方法检查协程的活跃状态

    yield源码解析.png

  • join

    Jop.join方法会挂起当前协程,等待协程执行完成,才执行后续任务

    • 如果在调用cancel方法后再调用join方法,协程会处于挂起直到协程执行完成
    • 如果先调用join再调用cancel,则不会产生影响,因为join执行后,协程就已经结束了。
  • await

    Deferred.await方法挂起当前协程,等待协程执行完,并返回协程结果内容。

    • 如果在调用cancel方法后,再调用await方法,会抛出CancellationException异常(表示正常结束),结束协程。
    • 如果先调用await再调用cancel,则不会产生影响,因为await执行后,协程就已经结束了。

协程的cancel能否成功,仅仅取决于是否在协程体中加入了检查点,比如 isActiveyielddelay等, 如果协程没有加入检查点,那么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函数,可以在协程被取消(或出现异常) 时,执行一些资源回收操作。

CancellableContinuation源码解析.png

比如在Retrofit中,就是在invokeOnCancellation中,协程结束时尝试结束Call

Call拓展函数await源码.png

协程异常传递

默认情况下,调用launch、或者async方法,会默认使用Job来创建协程。

而在协程内抛出异常结束时,且协程内部未捕获,会抛出异常给父协程,同时父协程内的所有子协程都将被取消,并且进一步向上一层级传递,并最终将异常传递给顶层的作用域

官方的示例图很形象的展示了这一过程

协程异常传递

  1. 取消它自己的子级;
  2. 取消它自己;
  3. 将异常传递给它的父级。
 CoroutineScope().launch{
     ...
     lauch(CoroutineName("coroutine_1")){
         //协程1出现异常
         throw Exception("...")
     }
     
     launch(CoroutineName("coroutine_2")){
         //协程2在异常出现后,会被取消
         ...
     }  
 }

而传递给顶层作用域CoroutineScope,将会把作用域内所有协程全部取消。

JobSupport源码解析.png

协程作用域被取消后,无法继续开启新协程

SupervisorJob

如果我们不想因为一个任务的失败而影响其他任务,子协程运行失败不影响其他子协程和父协程,在协程创建时可以使用SupervisorJob

SupervisorJob源码解析.png

作为Job的子类,其重写了chileCanceled方法为false,表示不会传递异常到父类协程,只会在协程内部处理(结束当前协程),并结束内部子协程。

 lifecycleScope.launch(SupervisorJob()){
     ...
 }

当子协程任务出错或失败时,SupervisorJob 只会取消它和它自己的子级,也不会传播异常给它的父级,它会让子协程自己处理异常

而使用挂起函数supervisorScope创建的协程作用域,同样有SupervisorJob的作用。

supervisorScope源码解析.png

异常捕获处理

但不论是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协程将实现细节隐藏在框架内部与编译器字节码中,把它”拍平“了,这才显得能直接把异步任务写成同步的方式。

使用CoroutineScopelaunchasync来启动协程,便于在CoroutineScope统一管理内部协程。

  • launch 适合启动外部不需要返回值的协程
  • async 适合启动外部需要返回值的协程

CoroutineContext中设置Dispatcher来让协程运行在指定协程调度器(线程)。

可通过withContext来切换协程内代码块所运行的协程调度器(线程)。

在Android中可以使用拓展库lifecycleScopeviewModelScope作用域管理协程。

  • 作用域(父级协程)取消(异常)时,会取消所有子协程
  • 作用域取消后无法创建新协程
  • 父级协程需等待所有子协程执行完才能完成
  • 默认情况下,子协程未捕获的异常会传递到父协程。

对于异步任务,内部可以使用isActiveyield检查协程状态,使其能够被取消。

对于回调API转化的协程,最好使用suspendCancellableCoroutine来创建可取消的协程(内部会检查协程状态)。

而当我们不希望协程出现异常时,自动传递到父级协程(无法拦截),造成同层级的其他协程被取消,可以在CoroutineContext中设置SupervisorJob,或者使用supervisorScope创建子协程作用域,将异常拦截在协程体内部。

最后,可以给CoroutineContext设置CoroutineExceptionHandler来作为最后的异常拦截器,处理一些出现异常后的资源回收操作。

在理解了Kotlin协程的基础后,下一篇将会尝试从RxJava使用者的角度,揭开Kotlin Flow的神秘面纱。

参考资料

【码上开学】Kotlin协程的挂起好神奇好难懂?今天我把它的皮给扒了

一文看透 Kotlin协程本质

协程中的取消和异常 | 取消操作详解

协程中的取消和异常 | 异常处理详解

抽丝剥茧Kotlin- 协程

破解 Kotlin协程(6) - 协程挂起篇