优雅地处理协程:取消机制深度剖析

570 阅读7分钟

线程的强制取消和交互式取消

线程的取消详细可以看我的这篇博客:Java 线程通信基础:interrupt、wait 和 notifyAll 详解

Java 早期提供了一个 Thread.stop() 方法来停止线程。只要调用它,目标线程就会立即被无条件的终止,无论线程当前在执行什么任务。

这种野蛮的取消方式可能会导致文件损坏(线程正在写入文件),也可能会导致状态不一致(因为 stop() 会介入原子性操作)。

所以它早被废弃了,现在 Java 线程的正确取消方式是 Thread.interrupt()

interrupt() 的工作机制是:它不会强行终止线程,只是会通知线程取消,线程内部需要主动配合。

配合的方式有两种:

  • 主动检查:在循环或是耗时操作前,主动检查中断状态,如果为 true,就执行必要的清理工作后,主动退出。

  • 被动响应:许多阻塞方法,能够自动响应中断。当处于等待状态的线程被通知取消时,线程会被唤醒并抛出 InterruptedException

这样,线程被要求取消时,就能够进行收尾,从而保证程序状态的一致性。

协程的取消

在协程中,发送取消请求调用的方法是 Job.cancel(),和线程中的 Thread.interrupt() 是类似的。

val scope = CoroutineScope(Dispatchers.Default) 
val job = scope.launch {
    while (true) {
        println("Coroutine is running...")
        delay(1000)
    }
}

delay(2500) 
job.cancel()

运行结果:

Coroutine is running...
Coroutine is running...
Coroutine is running...

协程被取消了,但是你并没有在上述代码中看到交互式的影子,为什么呢?

我们先来说说协程的主动检查。在协程中,取消标志位是 isActive 属性(在 Job 类中),它表示协程是否活跃。如果标志位变为 false,应该手动抛出 CancellationException 异常来取消协程,而非直接 return

CancellationException 是协程取消机制的核心,它是一种特殊的异常。当我们抛出它时,它会被用于正常地取消协程,而不会导致程序崩溃。

val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch {
    // 可以通过CoroutineScope.isActive扩展属性来方便地检查
    while (isActive) { 
        println("Coroutine is computing...")
        Thread.sleep(500) // 模拟耗时且非挂起的计算
    }
}

delay(1300)
job.cancel()

也可以通过 CoroutineScope.ensureActive() 扩展函数来检查,它会在协程不活跃时,自动抛出 CancellationException

现在,我们来回答为什么最开始的代码中,协程被取消了。

关键在于协程的被动响应,几乎所有 kotlinx.coroutines 库中的挂起函数(如 delaywithContext)都是可取消的,它们在内部会自动响应取消请求(收到请求,会立即抛出 CancellationException 来中止执行)。

有个例外,底层的 suspendCoroutine 挂起函数并不能自动响应取消。如果要构建支持取消的自定义挂起逻辑,应该使用 suspendCancellableCoroutine

所以,协程被取消是因为使用了 delay() 函数,这也让我们省去了不必要的手动检查。

Job.cancel() 和抛出 CancellationException 的区别

这里有个点很关键,外部调用 Job.cancel() 和内部抛出 CancellationException 有什么区别吗?

外部调用 Job.cancel(),这是最常见的取消请求。这个调用会创建一个 CancellationException 作为 Job 的取消原因。同时,它会将 Job 的状态设为 Cancelling,导致 job.isActive 变为 false。这时,协程中正在执行的可取消挂起函数会监测到这个状态变化,并抛出先前存储的 CancellationException 来响应。

而内部抛出 CancellationException,这是协程的自我取消。当协程内部抛出这个异常时,协程框架会进行捕获,并将 Job 的状态设为 Cancelling,接着 isActive 也会变为 false

清理工作

由于协程的取消是通过异常完成的,所以要在清理工作完成后将异常再次抛出:

val job = scope.launch {
    try {
        println("Coroutine is running...")
        delay(1000)
    } catch (e: CancellationException) {
        println("Cleaning up...")
        // ...清理工作...
        throw e
    }
}

job.cancel()

如果清理工作无论如何都要执行(不管协程是正常结束还是被取消),最好使用 try-finally

val job = scope.launch {
    try {
        println("Coroutine is running...")
        delay(1000)
    } finally {
        // ...清理工作...
        println("Finally cleaning up!")
    }
}

job.cancel()

注意,finally 块中虽然一定会被执行,但此时的协程可能已处于取消状态(isActivefalse),如果在这调用了可取消的挂起函数,会再次抛出 CancellationException,导致后续代码无法执行。

这时,需要用到 NonCancellable 上下文,稍后将会提到它。

结构化取消

结构化取消(Structured Cancellation):当一个父协程被取消时,取消的请求也会传播给其所有子协程(父协程会自动调用每一个子 Jobcancel())。

结构化取消在我的这篇博客中有讲到:Kotlin 协程的灵魂:结构化并发详解

val scope = CoroutineScope(Dispatchers.Default)
val parentJob = scope.launch { // 父协程
    launch { // 子协程 1
        println("子协程 1 开始")
        delay(3000)
        println("子协程 1 结束")
    }
    launch { // 子协程 2
        println("子协程 2 开始")
        delay(3000)
        println("子协程 2 结束")
    }
    println("父协程结束")
}

delay(1500)
parentJob.cancel()

上述代码的运行结果是:

子协程 1 开始
父协程结束
子协程 2 开始

可以看到,在取消父协程后,两个子协程也一并被取消了。

现在,有个问题:子协程能够拒绝取消吗?

答案是:基本不能。因为子协程无法阻止自己的 isActive 状态变为 false,也无法阻止取消通知向下传递。

协程内部在抛出 CancellationException 异常时,也会将自己的 isActive 状态变为 false,并调用所有子协程的 Job.cancel(),这是用于协程主动自我取消的。

虽然可以通过捕获异常阻止 CancellationException 异常的向上传递,继续执行后续代码。不过,这么做是十分不推荐的,因为它破坏了结构化并发。

val scope = CoroutineScope(Dispatchers.Default)
val parentJob = scope.launch {
    launch { // 子协程
        println("子协程开始执行")
        try {
            delay(3000)
        } catch (_: CancellationException) {
            // 吞掉异常,不让它传播
        }

        println("子协程执行中...")
        Thread.sleep(1500)
        println("子协程结束执行")
    }
}

delay(100)
parentJob.cancel()
measureTime {
    parentJob.join()
}.also {
    println("父协程耗时:${it}")
}

运行结果:

子协程开始执行
子协程执行中... 
子协程结束执行
父协程耗时:1.502403800s

可以看到,这个子协程拖住了父协程,让父协程无法及时完成。

不配合取消的 NonCancellable

有时,我们需要某个协程不响应取消。为此,我们可以使用 NonCancellable

val scope = CoroutineScope(Dispatchers.Default)
val parentJob = scope.launch { // 父协程
    launch(context = NonCancellable) { // 子协程
        println("Child job started")
        delay(2000)
        println("Child job ended")
    }
}

delay(1000)
parentJob.cancel()

运行结果:

Child job started
Child job ended

可以看到,即使取消了父协程,子协程还是执行完了所有代码。

这是怎么做到的呢?

其实,launch(NonCancellable) 启动的协程与外部协程并不是父子关系,所以在取消外部的协程时,并不会将取消请求传递给它。NonCancellable 本身是一个特殊的 Job 对象,它的作用就是切断这种关联。

NonCancellable 主要用于不希望被取消的任务,比如:

  1. 清理工作:如果清理代码中包含挂起函数(如往数据库写入数据),它可能会因为协程已是取消状态而失败。所以,要将这类清理逻辑放在 withContext(NonCancellable) 代码块中。

    虽说 withContext 也是一个可取消的挂起函数,但 finally 块是特殊的清理阶段,协程框架为了清理逻辑能可靠地执行,在这允许 withContext 启动。

    try {
        // ...
    } finally {
        withContext(NonCancellable) {
            // 这个块内的挂起函数不会被取消
            logToDatabase("Operation cancelled.")
            delay(1000L)
        }
    }
    
  2. 执行原子性且很难回滚的操作:比如复杂文件的写入,一旦开始,中途停下并撤销的逻辑太复杂,不如直接让它不可取消。

    withContext(Dispatchers.IO + NonCancellable) {
        writeFileAtomically(...)
    }
    
  3. 与当前协程业务无关的任务:比如独立的日志上报,它的生命周期应该与当前业务协程解耦,不因随着当前协程的取消而中止。

    launch(NonCancellable) {
        globalLog(...)
    }