Kotlin 协程基础二 —— 结构化并发(一)

Kotlin 协程基础系列:

Kotlin 协程基础一 —— 总体知识概述

Kotlin 协程基础二 —— 结构化并发(一)

Kotlin 协程基础三 —— 结构化并发(二)

Kotlin 协程基础四 —— CoroutineScope 与 CoroutineContext

Kotlin 协程基础五 —— Channel

Kotlin 协程基础六 —— Flow

Kotlin 协程基础七 —— Flow 操作符(一)

Kotlin 协程基础八 —— Flow 操作符(二)

Kotlin 协程基础九 —— SharedFlow 与 StateFlow

Kotlin 协程基础十 —— 协作、互斥锁与共享变量

结构化并发实际上就是父子协程关系的管理,管理父子协程之间生命周期的关联,包括正常的生命周期,以及取消和异常这些特殊情况下的生命周期。接下来我们会用两篇文章的篇幅来介绍结构化并发,第一篇介绍协程的取消,第二篇介绍协程的异常管理。

本篇我们会先用代码将协程具象化,然后明确父子协程的关系是如何形成的,最后以线程结束的方式作为引导,对比引出协程的取消。

1、“一个协程”到底指什么

为了讲结构化并发,需要先讲父子协程;为了讲父子协程,就需要先弄清楚“一个协程”到底指什么。

先与线程做个类比,来看看线程是如何定义的。

使用线程时,通常会认为 Thread 对象就是线程,除了 Thread 这个单词本身就是线程的意思之外,更本质的原因是,通过 Thread 对象,可以实现对线程这个抽象概念的管理。比如控制线程的运行流程(start()、interrupt()、join() 等),获取线程的状态(isAlive()、isDaemon() 等等)。

那协程是否有一个对应的对象,就像 Thread 之于线程那样呢?严格来讲,并没有。Thread 类提供了所有对线程的操作,但是协程没有一个一模一样的类。因为协程做了细致的职责划分,使得没有一个类能囊括所有职责。

通常我们认为,Job 与 CoroutineScope 都可以视为一个协程,但是它们的职责不同:

  • Job 用于控制协程的执行流程,它不仅提供了控制协程运行流程的函数(start()、cancel()、join() 等),还提供了协程的状态查询(isActive()、isCancelled()、isCompleted()),还能获取父子协程关系(通过 parent 与 children 属性),因此将 launch() 启动协程返回的 Job 对象视为一个协程是合理的(async 返回的 Deferred 同理)
  • CoroutineScope 是一个顶级的协程管理器,它可以启动一个全新的协程(Job.start() 只有在以 LAZY 模式创建的协程才需要使用),通过它还可以获取到包括 ContinuationInterceptor 和 Job 在内的,协程的所有功能和属性,它类似于线程的 Thread,因此也可以将 CoroutineScope 视为一个协程

同时,由于 CoroutineScope 担任的是每个协程中大总管的角色,而 Job 仅负责流程部分相关的职责,二者具有从属关系:

fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(Dispatchers.IO)
    var innerJob: Job? = null
    var innerScope: CoroutineScope? = null
    val outerJob = scope.launch(Dispatchers.Default) { // this:CoroutineScope
        innerJob = coroutineContext[Job]
        innerScope = this
    }
    println("outerJob: $outerJob")
    println("innerJob: $innerJob")
    println("outerJob === innerJob: ${outerJob === innerJob}")
    println("innerScope === outerJob: ${innerScope === outerJob}")
}

运行结果:

outerJob: StandaloneCoroutine{Completed}@6979e8cb
innerJob: StandaloneCoroutine{Completed}@6979e8cb
outerJob === innerJob: true
innerScope === outerJob: true

结果表明,launch() 返回的 outerJob 就是被启动的协程的 CoroutineScope.CoroutineContext.Job,并且这个 Job 与被启动协程内的 CoroutineScope 是同一个对象。

之所以是同一个对象(AbstractCoroutine)还做责任拆分,目的是为了让 API 更加精准,或者说为了避免 API 污染,不让没用的 API 出现在不该出现的地方(返回值是 Job 说明该位置只需要进行流程控制)。

launch() 与 async() 闭包的大括号也可以视为一个协程,只不过从技术角度讲没什么价值,只是在思考和探讨流程时非常便捷。

2、父子协程以及协程间的并行和等待

结构化并发,其实就是父子协程的生命周期的各种关联。本节两个大问题:

  • 父子关系是如何确立的
  • 协程结束时,对父子协程会发生怎样的自动化影响

2.1 父子协程关系的确定

最直接的父子协程关系,就是在一个协程内部启动另一个协程:

fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    var innerJob: Job? = null
    val job = scope.launch { // this:CoroutineScope
        innerJob = launch {
            // 这里要做延时,因为协程的父子关系会随着协程运行完毕而解绑,
            // 倘若不进行延时,在打印 log 时,由于协程已经运行完,那么
            // 二者就不再是父子协程,就会输出 false
            delay(500)
        }
    }

    val children = job.children
    println("children count: ${children.count()}") // 1
    println("innerJob === children.first(): ${innerJob === children.first()}") // true
    println("innerJob?.parent === job: ${innerJob?.parent === job}") // true
    
    job.join()
}

这样 innerJob 就是 job 的子协程,本质上是将 job.children 设置为 innerJob,将 innerJob.parent 设置为 job。

父子协程是如何拿到对方的 Job 实现上述的关系挂接的呢?步骤如下:

  • 首先,innerJob 是在父协程 job 的 launch() 内启动的,因此父协程能拿到子协程 Job 对象 —— innerJob
  • 其次,innerJob 在通过 launch() 启动子协程时,本质上是 this.launch {…},this 是父协程 { } 内的 CoroutineScope,该 CoroutineScope 内包含该协程的 Job 对象 —— job。因此,子协程可以通过 this 拿到父协程的 Job 对象赋值给子协程的 parent 属性

下面将启动子协程的代码进行修改:

fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    var innerJob: Job? = null
    val job = scope.launch {
        // 使用 scope 启动子协程
        innerJob = scope.launch {
            delay(500)
        }
    }
}

使用 scope 代替隐式的 this 启动子协程,再进行关系验证,发现此时 innerJob 不再是 job 的子协程了。因为通过 scope 启动协程,拿到的就不再是通过 this 的 CoroutineScope 内含有的 Job —— job 对象,而是 scope 内含有的 Job 对象了,也就是说,此时 innerJob 的父协程就是 scope 内含有的 Job。此时 job 与 innerJob 都由 scope 启动,是兄弟协程而不再是父子协程了。

或许你会有疑问,scope 就是一个 CoroutineScope,它没有启动协程,哪里来的 Job 呢?看 CoroutineScope 的构造函数:

@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job()) // + 表示合并

如果参数传入的 CoroutineContext 没有 Job,会自动创建 Job 对象。所以,即便没启动协程,CoroutineScope 内也是含有 Job 对象的。

综上,我们固有观念中认为的,以代码结构的嵌套认定协程的父子关系是错误的,应该通过启动协程的 CoroutineScope 认定协程的父子关系。

原本判断时,通过代码结构认定父子协程能够成立的原因是,内部协程通过隐式 this 启动刚好使用了外部协程的 CoroutineScope:

fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    var innerJob: Job? = null
    val job = scope.launch {
        innerJob = /*this.*/launch {
            delay(500)
        }
    }
}

2.2 结构化结束

明确父子协程关系的意义在于结构化并发(包括协程的结束、取消、异常管理),结构化结束是指父协程会等待所有子协程都运行完毕后再结束自己,哪怕父协程自身的代码先运行完,也要等待所有子协程都结束后再结束自己。但从运行角度来看,所有协程之间都是并行关系,包括兄弟、父子协程,以及没有任何关系的协程。

通过一个例子来了解结构化结束:

fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    // 父子协程是并行运行的,但是因为子协程等待 100,父协程需要等待
    // 子协程运行完毕后再结束自己,所以在 job.join() 的位置,runBlocking
    // 协程会等待 job 大概 100ms 的时间
    val job = scope.launch {
        launch {
            delay(100)
        }
    }

    val startTime = System.currentTimeMillis()
    // 这里必须 join 一下,因为 runBlocking 开启的协程不是 job 的父协程
    // (job 的父协程是 scope 内包含的 Job),它不会等待 job 运行完再结束
    job.join()
    val duration = System.currentTimeMillis() - startTime
    println("Duration: $duration") // 输出在 100ms 左右
}

结构化结束的意义就在于这种表现,比如在做初始化工作时,一些后续工作需要等待初始化完成才能进行,此时可以:

fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    // 初始化放在协程中进行,这样应用可以正常启动而不必被初始化流程卡着
    val initJob = scope.launch {
        // 初始化 1
        launch {  }
        // 初始化 2
        launch {  }
        // 其他初始化...
    }
    
    // 某些工作依赖初始化流程,因此在执行工作之前,先 join 一下等待初始化工作完成,
    // 后续工作才能安全执行
    scope.launch { 
        initJob.join()
        // 后续工作...
    }
}

3、线程结束

协程的取消与线程结束的方式很像,因此我们先从比较熟悉的线程结束方式开始。

结束线程有两种方式,协作式(也称交互式)结束与强行结束:

  • 强行结束:指调用 Thread 的 stop(),该方法会直接暴力地结束线程。这样会导致不可预期的结果,因为不管线程运行到哪里都直接暴力停止线程,给程序带来极大的不稳定性,因此被废弃了
  • 协作式结束:指在线程外部调用 Thread 的 interrupt(),该方法会将 Thread 的中断状态标记 isInterrupted 置为 true。然后在线程内部通过检查该标记决定如何结束线程。这种需要线程内外配合的结束方式被称为协作式结束

3.1 协作式结束

协作式结束的示例代码:

fun main() = runBlocking {
    val thread = object : Thread() {
        override fun run() {
            var count = 0
            while (true) {
                if (isInterrupted) {
                    // 清理工作...做完后 return 结束线程
                    return
                }
                // 耗时任务...
                count++
                if (count % 100_000_000 == 0) {
                    println(count)
                }
                if (count % 1_000_000_000 == 0) {
                    break
                }
            }
            println("Thread: I'm done!")
        }
    }.apply { start() }
    Thread.sleep(500)
    thread.interrupt()
}

需要注意的是,在检测到 isInterrupted 为 true,结束线程之前,需要做清理/收尾工作。比如线程原本是执行为图片添加滤镜工作的,中断时图片滤镜尚未添加完毕,就需要回退到原始图片状态。如果不进行收尾工作,那么 interrupt() 就与 stop() 就没有多大区别了。

检查中断标记还可以使用 interrupted(),与 isInterrupted 标记不同的是,该方法会在调用时将 isInterrupted 置为 false。

3.2 结束会抛出的异常

如果在执行具有等待功能的方法,比如在 Thread.sleep() 的执行过程中调用了 interrupt(),sleep() 会直接抛出 InterruptedException:

fun main() = runBlocking {
    val thread = object : Thread() {
        override fun run() {
            println("Thread: I'm running!")
            sleep(200)
            println("Thread: I'm done!")
        }
    }.apply { start() }
    thread.interrupt()
}
Thread: I'm running!
Exception in thread "Thread-0" java.lang.InterruptedException: sleep interrupted
	at java.base/java.lang.Thread.sleep(Native Method)
	at com.kotlin.coroutine._2_structured_concurrency._4_thread_interruptKt$main$1$thread$1.run(4_thread_interrupt.kt:9)

追溯抛出上述异常的原因,需要看 sleep() 的源码:

	public static void sleep(long millis, int nanos) throws InterruptedException {
        ...
        // The JLS 3rd edition, section 17.9 says: "...sleep for zero
        // time...need not have observable effects."
        if (millis == 0 && nanos == 0) {
            // ...but we still have to handle being interrupted.
            if (Thread.interrupted()) {
              throw new InterruptedException();
            }
            return;
        }

        ...
    }

sleep() 内检测到中断标记为 true 就会抛 InterruptedException,这实际上是一种被动接受中断的处理方式。因为外部已经调用 interrupt() 要结束线程了,那么你也没必要再继续执行 sleep() 的等待过程了,通过抛出异常的方式立即结束线程,可以在 catch 中进行线程结束时的收尾工作。这也是 Java 中强制要求对 Thread.sleep() 添加 try-catch 的原因:

fun main() = runBlocking {
    val thread = object : Thread() {
        override fun run() {
            println("Thread: I'm running!")
            try {
                sleep(200)
            } catch (e: InterruptedException) {
                // 由于 sleep() 内通过 interrupted() 检查标记位,此时 isInterrupted 被重置为 false
                println("isInterrupted: $isInterrupted")
                // 清理收尾工作,比如恢复对象状态、回收资源、关闭 IO 流、关闭数据库和网络连接等
                println("Clearing...")
                return
            }
            println("Thread: I'm done!")
        }
    }.apply { start() }
    thread.interrupt()
}

运行结果:

Thread: I'm running!
isInterrupted: false
Clearing...

与 Thread.sleep() 类似的,所有涉及等待操作的方法,都会抛出 InterruptedException,比如 Object.wait()、Thread.join()、CountDownLatch.await(),目的也都是方便我们可以及时的以交互式的形式结束线程。

4、协程的取消

4.1 协作式取消

协程取消与线程的结束类似,都是交互式的,通过协程的 isActive 标记来判断(类似于线程的 isInterrupted):

fun main() = runBlocking<Unit> {
    val job = launch(Dispatchers.Default) {
        var count = 0
        while (true) {
            if (!isActive) {
                // 清理工作...做完后 return 结束线程
                return@launch
            }
            // 耗时任务...
            count++
            if (count % 100_000_000 == 0) {
                println(count)
            }
            if (count % 1_000_000_000 == 0) {
                break
            }
        }
    }
    delay(1000)
    job.cancel()
}

有多种方式可以获取到 isActive:

  1. 通过 CoroutineScope 的扩展属性:

    @Suppress("EXTENSION_SHADOWED_BY_MEMBER")
    public val CoroutineScope.isActive: Boolean
        get() = coroutineContext[Job]?.isActive ?: true
    
  2. 通过 CoroutineContext 的扩展属性:

    public val CoroutineContext.isActive: Boolean
        get() = get(Job)?.isActive ?: true
    
  3. 通过 CoroutineContext 先获取 Job 对象,再访问 Job 的 isActive。获取 Job 有两种方式:

    • 通过 coroutineContext[Job] 获取 Job?:

      /**
      * CoroutineContext 通过运算符重载实现 get(),便可使用 coroutineContext[xxx]
      * 的形式获取 Key 的值。而 Job 以及其他 CoroutineContext.Element 的实现类,如
      * ContinuationInterceptor、CoroutineName 等都用 Key 作为伴生对象的类型,因此
      * 就可以通过 coroutineContext[ContinuationInterceptor] 的形式获取对应的值,只
      * 不过需要注意的是,由于 get() 返回的类型为 E?,因此它拿到的都是可空类型
      */
      public interface CoroutineContext {
          /**
           * Returns the element with the given [key] from this context or `null`.
           */
          public operator fun <E : Element> get(key: Key<E>): E?
      }
      
      public interface Job : CoroutineContext.Element {
          /**
           * Key for [Job] instance in the coroutine context.
           */
          public companion object Key : CoroutineContext.Key<Job>
      }
      
    • 通过 CoroutineContext 的扩展属性 job 获取 Job:

      public val CoroutineContext.job: Job get() = get(Job) ?: error("Current context doesn't contain Job in it: $this")
      

此外,与结束线程不同的点是,结束协程不用 return,而是抛出 CancellationException,协程会接住这个异常并把自己取消:

fun main() = runBlocking<Unit> {
    val job = launch(Dispatchers.Default) {
        var count = 0
        while (true) {
            if (!coroutineContext.isActive) {
                // 抛出 CancellationException 结束协程
                throw CancellationException()
            }
            count++
            if (count % 100_000_000 == 0) {
                println(count)
            }
            if (count % 1_000_000_000 == 0) {
                break
            }
        }
    }
    delay(2000)
    job.cancel()
}

虽然从上述例子的运行结果来看,结束协程使用 return 或抛出 CancellationException 都可以,但是唯一正确的做法只有抛出 CancellationException。因为 return 只是结束当前协程的代码块,但是要结构化取消协程(结束相关的子协程),只能通过抛 CancellationException 实现。

假如在结束协程时,不需要做清理工作,可以使用官方提供的 ensureActive() 便捷方法:

fun main() = runBlocking<Unit> {
    val job = launch(Dispatchers.Default) {
        var count = 0
        while (true) {
            // 一共三种写法
//            coroutineContext.ensureActive()
//            coroutineContext.job.ensureActive()
            ensureActive()
            /*if (!coroutineContext.isActive) {
                // 抛出 CancellationException 结束协程
                throw CancellationException()
            }*/
            count++
            if (count % 100_000_000 == 0) {
                println(count)
            }
            if (count % 1_000_000_000 == 0) {
                break
            }
        }
    }
    delay(2000)
    job.cancel()
}

三种写法最终调用的都是 Job 的扩展函数:

public fun Job.ensureActive(): Unit {
    if (!isActive) throw getCancellationException()
}

4.2 delay 不用进行协作

下面再看一段代码:

fun main() = runBlocking<Unit> {
    // 由于 runBlocking 开启的是主线程的协程,为了不影响主线程的运行,所以启动
    // 子协程时手动调整到 Default 上,但不论调与不调,对 Demo 的运行结果无影响,
    // 只是提醒一下正确的启动方式,不要过多占用主线程
    val job = launch(Dispatchers.Default) {
        var count = 0
        while (true) {
            println("count: ${count++}")
            delay(500)
        }
    }
    delay(3000)
    job.cancel()
}

这样输出几个数字后协程就会被取消:

count: 0
count: 1
count: 2
count: 3
count: 4
count: 5

虽然可以取消,但是似乎协程并没有像线程那样体现出“协作”取消的样子,调用一个 cancel() 就直接取消掉了,是因为不用协作式也可以取消协程吗?

其实不是的,本质上是因为协程内部的 delay(500) 与 Thread.sleep() 类似,在遇到取消的情况时,会抛出 CancellationException 异常结束协程。

因此你可以看到,协程取消与线程停止的方式是类似的,都是两种情况:

  1. 正常的协作式取消,在协程内部检查 isActive 标记位,如为 false 需要做收尾工作并抛出 CancellationException 结束协程(线程是检查 isInterrupted)
  2. 如果在执行挂起函数时,如协程的 delay() 时(注意是除了 suspendCoroutine 之外所有的挂起函数都是以这种方式),协程已经被取消,那么会通过抛出 CancellationException 的方式结束协程(线程是 sleep()、join() 等,抛出 InterruptedException)

第 2 点需要注意,在处理线程时,通常是通过 try-catch 捕获 InterruptedException 时,在 catch 内做收尾工作并通过 return 结束线程。但是在协程中,不要照搬通过 try-catch 捕获 CancellationException,因为这样会导致协程无法结构化取消:

fun main() = runBlocking<Unit> {
    val job = launch(Dispatchers.Default) {
        var count = 0
        while (true) {
            println("count: ${count++}")
            try {
                delay(500)
            } catch (e: CancellationException) {
                println("Cancelled!")
            }
        }
    }
    job.cancel()
}

虽然能打印 catch 中的 log,但是协程并未真的结束:

...
count: 580069
Cancelled!
count: 580070
Cancelled!
count: 580071
Cancelled!
count: 580072
Cancelled!
count: 580073
...

所以在协程里,如果是除了 CancellationException 以外的异常,可以通过 try-catch 处理。如果是 CancellationException,在 catch 时务必要在收尾清理工作之后再将其抛出:

fun main() = runBlocking<Unit> {
    val job = launch(Dispatchers.Default) {
        var count = 0
        while (true) {
            println("count: ${count++}")
            try {
                delay(500)
            } catch (e: CancellationException) {
                // 收尾清理工作...
                println("Cancelled!")
                // 为了保证协程能正常取消,最后需要再将 CancellationException
                // 抛出。虽然看着有点奇怪,但这就是正常的,甚至常用的套路
                throw e
            }
        }
    }
    job.cancel()
}

还有另一种常用方式就是不管是否是正常的结束(即不用 catch 判断),统统用 finally 处理:

fun main() = runBlocking<Unit> {
    val job = launch(Dispatchers.Default) {
        var count = 0
        while (true) {
            println("count: ${count++}")
            try {
                delay(500)
            } finally {
                // 清理收尾工作...
                println("Clearing...")
            }
        }
    }
    job.cancel()
}

协程取消抛出异常这种方式的作用范围相比于线程要更广。线程是只对 Thread.sleep()、Thread.join()、Object.wait() 这种等待式的方法抛 InterruptedException,而协程是对几乎所有挂起函数都抛 CancellationException,除了不支持协作式取消的 suspendCoroutine()。如果在调用 cancel() 取消协程时,正在运行 suspendCoroutine(),这个函数是没有反应的,不会取消协程。

4.3 结构化取消

协程的取消是结构化的,父协程的取消会带着所有子协程一起取消。

fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val parentJob = scope.launch {
        val childJob = launch {
            println("Child job started")
            delay(3000)
            println("Child job finished")
        }
    }
    delay(1000)
    // 取消父协程,会导致其子协程 childJob 也被取消
    parentJob.cancel()
    delay(5000)
}

子协程因为被取消,只输出了第一句 log:

Child job started

Process finished with exit code 0

以上是最基本的结构化取消示例,现在有个问题,子协程能拒绝父协程的取消吗?

回顾取消的本质,取消协程时,调用 cancel() 会将当前协程内的 isActive 变为 false,然后在执行到对 isActive 的检查点时(在运行到对 isActive 的检查点之前,协程内的代码还是正常运行的),如果检测到 isActive 为 false 了就会抛出 CancellationException 结束当前协程。

那么在父协程取消时,会触发所有子协程的 cancel(),大致的流程如下:

  • 外部对父协程调用 cancel() 进行取消,此时父协程将自己的 isActive 设置为 false,并且对所有子协程也调用 cancel() 让它们的 isActive 也变为 false
  • 父协程与所有子协程在运行到 isActive 的代码检查点后,抛出 CancellationException。这个抛异常的过程,各个协程都是自己抛自己的,互相不影响

需要注意,每个协程抛异常的时间点是不确定的,因为每个协程运行的情况不同,虽然大家的 isActive 都被设置为 false 了,但是如果没运行到 isActive 的检查点之前是不会抛异常的。比如说,当前协程如果在执行 delay() 这种会检查 isActive 的函数,那可能该协程马上就抛异常;但如果协程在运行一般的业务代码,那么它就会运行一段时间,直到遇到 isActive 检查点再抛异常;甚至,协程后续没有检查点,那么它就会一直运行完,不抛异常。因此父协程早于或晚于子协程抛异常都是正常的。

现在再回到前面的问题,子协程可以拒绝父协程的取消要求吗?理论上可以,但是没有实际意义。协程取消时会做的三件事:

  1. 调用 cancel()
  2. cancel() 内把 isActive 置为 false
  3. 协程内设置 isActive 检查点进行协作式取消

前两点子协程是无法控制的,因为在父协程的 cancel() 中执行了,就剩下最后一点可以做文章。可以通过 catch 捕获 CancellationException 又不将其抛出,以这种不配合协作的方式拒绝父协程的结构化取消:

fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val parentJob = scope.launch {
        val childJob = launch {
            println("Child job started")
            try {
                delay(3000)
            } catch (e: CancellationException) {
                // 这里不抛 CancellationException 即可,但是后续的 delay 也得这么干
            }
            println("Child job finished")
        }
    }
    delay(1000)
    // 取消父协程,会导致其子协程 childJob 也被取消
    parentJob.cancel()
    delay(5000)
}

极为不推荐这种行为。因为强行不让子协程取消,不仅打乱了协程正常的执行流程,还会拖着父协程也无法结束(父协程会等待所有子协程完成才结束)。

4.4 不配合取消 NonCancellable

直接看下面的代码:

fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val parentJob = scope.launch {
        val childJob1 = launch {
            println("Child job1 started")
            delay(3000)
            println("Child job1 finished")
        }

        val childJob2 = launch(NonCancellable) {
            println("Child job2 started")
            delay(3000)
            println("Child job2 finished")
        }

        val childJob3 = launch(Job()) {
            println("Child job3 started")
            delay(3000)
            println("Child job3 finished")
        }
    }
    delay(1500)
    parentJob.cancel()
    delay(6000)
}

直接取消父协程,三个子协程的取消情况如何呢?输出结果:

Child job1 started
Child job2 started
Child job3 started
Child job3 finished
Child job2 finished

Process finished with exit code 0

只有 childJob1 被取消了,其余两个子协程没有因为父协程的取消而被取消掉。原因都是阻断了父子协程关系,解释如下:

  • childJob3 没被取消是因为使用 launch() 启动协程时,在参数中指定了 Job,那么 childJob3 的父协程就是这个 Job 而不是 parentJob。所以 parentJob.cancel() 不会取消掉不是它子协程的 childJob3
  • childJob2 没被取消的原因几乎一样,NonCancellable 是一个 Job 单例,致使 childJob2 也不是 parentJob 的子协程

NonCancellable 就是专门用于阻断父子协程的取消链条的,不能像普通 Job 那样使用。像 parent 和 children 属性都被赋值为空,也就是说 NonCancellable 不能作为内部协程(上例中的 childJob2)的父协程,连用于取消的 cancel() 都被废弃了:

// NonCancellable 是一个单例 Job
public object NonCancellable : AbstractCoroutineContextElement(Job), Job {
    @Deprecated(level = DeprecationLevel.WARNING, message = message)
    override val parent: Job?
        get() = null
    
    @Deprecated(level = DeprecationLevel.HIDDEN, message = "Since 1.2.0, binary compatibility with versions <= 1.1.x")
    override fun cancel(cause: Throwable?): Boolean = false // neve
    
    ...
}

NonCancellable 就是用于开启一些不希望被取消的任务的。什么样的任务不希望被取消?一般有三类:

  1. 收尾工作:协程被调用 cancel() 之后,真正退出协程之前需要做的清理、收尾工作。由于收尾工作中可能会调用挂起函数(比如 Jetpack 的 Room 在进行相关数据库操作时用的是挂起函数的方式),而在协程已经要取消,isActive 为 false 的情况下,运行挂起函数会抛出 CancellationException 导致收尾工作被中断。为了让调用了挂起函数的收尾工作免于这种中断,需要使用 NonCancellable:

    fun main() = runBlocking<Unit> {
        launch {
            if (!isActive) {
            // 结束协程前的收尾工作不希望被打断,因此通常会使用 withContext(NonCancellable) 包起来
            withContext(NonCancellable) {
                // 收尾工作……用 delay 表示收尾工作中可能会调用到的挂起函数。比如这里如果使用 Jetpack 
                // 的 Room 进行数据库操作。收尾时需要向数据库写入标记,这个写入操作就是一个挂起函数
                delay(1000)
            }
        }
    }
    
  2. 如果取消将会很难收尾的业务代码。既然取消不好收尾,那干脆就不要取消了

  3. 与协程的流程无关的操作,比如日志工作。日志与协程执行的业务代码无关,那么也就不需要随着协程的取消而取消。只不过这种情况下通常是用 launch(NonCancellable) 与协程并行,而不是 withContext(NonCancellable) 与协程串行挡着协程

注意,官方给 NonCancellable 添加的注释上说,NonCancellable 就是搭配 withContext() 使用的,它不是给 launch、async 或其他协程构建器设计的。但是在一些需要并行的环境下使用 launch(NonCancellable) 也没太大问题,比如上面第 3 点提到的日志打印。

此外还需注意,只有收尾工作包含挂起函数时才需要在收尾工作的外面加上 withContext(NonCancellable) 防止收尾工作被打断。

解释一下第 2 点。比如我们要做写入文件的操作,我们希望写入文件的结果是要么全写完,要么全没写,而不是写了一半的时候所在的协程被取消。因为写了一半被取消,就需要在收尾工作将写入的数据清理掉,恢复到写文件之前的状态,这是一件很麻烦的事情。

此时你可以将写文件的挂起函数写成这样:

suspend fun writeInfo() = withContext(Dispatchers.IO) { 
    // write to file
}

由于 writeInfo() 被 withContext() 包着且内部没有挂起函数,因此当调用 writeInfo() 的协程要被取消时,要么就是写文件过程还没开始,要么就是文件已经写完(因为 write to file 这个过程中没有挂起函数,也没有对 isActive 的检查,因此一旦开始执行,它就会执行完写入过程)。

但假如 writeInfo() 内部多加一个逻辑,比如要在写一段文件之后,从数据库中读取一段数据,将数据整合后继续写入文件中:

suspend fun writeInfo() = withContext(Dispatchers.IO) { 
    // 1.write to file
    // 2.read from database(Room suspend function)
    // 3.continue to write to file
} 

如果在做第 1 步时,协程被取消了,那么运行到第 2 步的挂起函数时,就会抛异常使得文件只写了一部分。

为了避免这种情况,有两种解决方案。一是将挂起函数用 try-catch 包起来,在 catch 中做收尾工作,把第 1 步写的内容撤销掉:

suspend fun writeInfo() = withContext(Dispatchers.IO) { 
    // 1.write to file
    try {
        // 2.read from database(Room suspend function)
    } catch (e: CancellationException) {
        // rollback step1
        throw e
    }
    // 3.continue to write to file
} 

但是撤销已经写入文件的内容这个操作不太好做,因此有第二种方案,给 withContext() 加上 NonCancellable 不支持取消:

suspend fun writeInfo() = withContext(Dispatchers.IO + NonCancellable) { 
    // 1.write to file
    // 2.read from database(Room suspend function)
    // 3.continue to write to file
} 
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值