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

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

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

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

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

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

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

配合的方式有两种:

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

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

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

协程的取消

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

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

delay(2500) 
job.cancel()

运行结果:

less 复制代码
Coroutine is running...
Coroutine is running...
Coroutine is running...

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

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

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

(为什么不直接 return?因为 return 只会取消当前协程的代码块,但协程是结构化的,这样会导致其子协程无法被取消。)

kotlin 复制代码
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() 函数,这也让我们省去了不必要的手动检查。

清理工作

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

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

job.cancel()

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

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

job.cancel()

结构化取消

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

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

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()

上述代码的运行结果是:

less 复制代码
子协程 1 开始
父协程结束
子协程 2 开始

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

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

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

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

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

kotlin 复制代码
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}")
}

运行结果:

less 复制代码
子协程开始执行
子协程执行中... 
子协程结束执行
父协程耗时:1.502403800s

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

不配合取消的 NonCancellable

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

kotlin 复制代码
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()

运行结果:

less 复制代码
Child job started
Child job ended

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

这是怎么做到的呢?

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

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

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

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

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

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

    kotlin 复制代码
    launch(NonCancellable) {
        globalLog(...)
    }
相关推荐
leon_zeng03 小时前
更改 Android 应用 ID (ApplicationId) 后遭遇记
android·发布
2501_916007475 小时前
iOS 混淆工具链实战,多工具组合完成 IPA 混淆与加固(iOS混淆|IPA加固|无源码混淆|App 防反编译)
android·ios·小程序·https·uni-app·iphone·webview
Jeled6 小时前
Retrofit 与 OkHttp 全面解析与实战使用(含封装示例)
android·okhttp·android studio·retrofit
ii_best9 小时前
IOS/ 安卓开发工具按键精灵Sys.GetAppList 函数使用指南:轻松获取设备已安装 APP 列表
android·开发语言·ios·编辑器
2501_915909069 小时前
iOS 26 文件管理实战,多工具组合下的 App 数据访问与系统日志调试方案
android·ios·小程序·https·uni-app·iphone·webview
limingade10 小时前
手机转SIP-手机做中继网关-落地线路对接软交换呼叫中心
android·智能手机·手机转sip·手机做sip中继网关·sip中继
RainbowC010 小时前
GapBuffer高效标记管理算法
android·算法
程序员码歌11 小时前
豆包Seedream4.0深度体验:p图美化与文生图创作
android·前端·后端
、花无将12 小时前
PHP:下载、安装、配置,与apache搭建
android·php·apache