4、协程的取消与超时:取消协程的机制、isActive与ensureActive、withTimeout与withTimeoutOrNull、不可取消的协程

协程的取消与超时,是日常开发中绕不开的话题。说实话,我刚接触协程那会儿,觉得取消不就是调个 cancel() 嘛,有什么好讲的?直到我在一个图片加载库中踩了坑——协程取消了,但子任务还在跑,内存泄漏了。嗯,从那以后,我才认真研究起取消的底层机制。

这一节,我们就来彻底搞懂协程取消的来龙去脉。我会结合自己的实战经验,把那些容易踩的坑一个一个指出来。

取消协程的机制:协作式取消

首先,你得明白一个核心概念:协程的取消是协作式的。什么意思?说白了,就是协程不会因为你调了 cancel() 就立刻停下来。它需要协程内部的代码主动配合,检查一下「我是不是被取消了?」然后自己决定要不要停止。

为什么会这样?因为协程本质上是一个轻量级线程,它运行在你的代码块里。如果代码块里是一个死循环,或者一个阻塞操作,外部是无法强行中断它的。你想想看,如果强行中断,资源没释放、状态不一致,那不就乱套了吗?

来看一个最简单的例子:

val job = scope.launch {
    repeat(1000) { i ->
        println("正在执行第 $i 次")
        delay(500) // delay 是一个可取消的挂起函数
    }
}

delay(2000)
job.cancel() // 取消协程
job.join()   // 等待协程结束
println("协程已取消")

这里 delay() 是一个可取消的挂起函数。当协程被取消时,delay() 会抛出一个 CancellationException,协程就优雅地退出了。但如果你把 delay() 换成 Thread.sleep(),那取消就失效了——因为 Thread.sleep() 不配合协程的取消机制。

⚠️ 避坑指南
我曾经在一个网络请求库中,用 Thread.sleep() 模拟延迟,结果协程取消后,线程还在睡。最后导致协程泄漏,内存暴涨。记住:在协程中,永远不要用线程阻塞操作。用 delay(),用 withContext(Dispatchers.IO),就是别用 Thread.sleep()

isActive 与 ensureActive:手动检查取消状态

如果你的协程里没有调用任何可取消的挂起函数(比如一个纯计算任务),那协程取消就形同虚设。这时候,你需要手动检查取消状态。

Kotlin 提供了两个工具:isActiveensureActive

  • isActive:一个布尔属性,返回当前协程是否处于活跃状态。你可以用它来在循环中做条件判断。
  • ensureActive:一个函数,如果协程已被取消,它会立即抛出 CancellationException

我个人习惯在耗时循环中,每隔几次迭代就检查一下 isActive。这样既保证了响应性,又不会因为频繁检查而影响性能。

scope.launch {
    for (i in 0 until 10_000) {
        // 每 100 次检查一次取消状态
        if (i % 100 == 0) {
            ensureActive() // 如果已取消,这里会抛异常
        }
        // 模拟耗时计算
        computeHeavyTask(i)
    }
}

你也可以用 isActive 做更精细的控制:

scope.launch {
    while (isActive) {
        // 只有协程活跃时才执行
        doSomeWork()
    }
}
💡 我的经验
在复杂的协程中,我倾向于用 ensureActive() 而不是 isActive。因为 ensureActive() 会直接抛出异常,让协程立即退出,而 isActive 还需要你自己写 breakreturn。少写一行代码,少一个 bug 的可能。

withTimeout 与 withTimeoutOrNull:给协程加个闹钟

超时,说白了就是「如果这个任务在规定时间内没完成,就别等了」。这在网络请求、数据库查询等场景中非常常见。

Kotlin 提供了两个函数:

  • withTimeout:超时后抛出 TimeoutCancellationException(它是 CancellationException 的子类)。
  • withTimeoutOrNull:超时后返回 null,而不是抛异常。

我个人更推荐 withTimeoutOrNull,因为它更安全。你想想看,抛异常意味着你要写 try-catch,而返回 null 只需要一个空检查。代码更简洁,心智负担更小。

// 使用 withTimeout,需要捕获异常
try {
    withTimeout(1000) {
        fetchDataFromNetwork()
    }
} catch (e: TimeoutCancellationException) {
    println("请求超时了")
}

// 使用 withTimeoutOrNull,优雅处理
val result = withTimeoutOrNull(1000) {
    fetchDataFromNetwork()
}
if (result == null) {
    println("请求超时了,返回 null")
}

这里有个细节要注意:withTimeout 抛出的异常是 CancellationException 的子类。而 CancellationException 在协程中有特殊地位——它通常不会被父协程捕获,而是直接向上传播。所以,如果你在 try-catch 里捕获了 Exception,可能抓不到它。

⚠️ 避坑指南
我曾经在项目中用 withTimeout 包裹了一个数据库查询,然后在外层写了 try-catch(Exception)。结果超时后,异常没被捕获,直接导致协程崩溃。后来才发现,TimeoutCancellationException 继承自 CancellationException,而 CancellationException 不是 Exception 的子类。记住:捕获超时异常,要用 catch (e: TimeoutCancellationException),或者直接用 withTimeoutOrNull

不可取消的协程:withContext(NonCancellable)

有些场景下,你希望某段代码「打死也不能被取消」。比如:你正在写入缓存、关闭文件、释放资源。如果协程被取消了,这些操作没执行完,数据就损坏了。

这时候,可以用 withContext(NonCancellable) 来包裹这段代码。它会创建一个新的协程上下文,这个上下文忽略取消信号。

scope.launch {
    try {
        // 可取消的操作
        fetchData()
    } catch (e: CancellationException) {
        // 协程被取消了,但我们需要确保资源释放
        withContext(NonCancellable) {
            // 这段代码不可取消
            releaseResources()
            clearCache()
            log("资源已释放")
        }
        // 重新抛出取消异常,让协程真正结束
        throw e
    }
}

注意,NonCancellable 不是让你滥用它的。它应该只用在「必须执行完」的临界区。如果你把整个协程都包上 NonCancellable,那取消机制就形同虚设了。

💡 我的经验
我一般在 finally 块中使用 NonCancellable。因为 finally 块本身不会因为协程取消而跳过,但如果 finally 里调用了可取消的挂起函数(比如 delay),那它还是会被取消。用 NonCancellable 包裹,就能保证这些清理操作「一条道走到黑」。

知识体系总览

下面这张图,帮你把这一节的核心逻辑串起来:

协程取消与超时知识体系 协程取消机制 协作式取消:需要代码主动配合 检查方式:isActive(手动检查) vs ensureActive(自动抛异常) 超时:withTimeout(抛异常) vs withTimeoutOrNull(返回null) 不可取消:withContext(NonCancellable)

总结一下

这一节的内容,说白了就四件事:

  1. 取消是协作式的——别指望调了 cancel() 就万事大吉,代码里要主动检查。
  2. isActiveensureActive 手动检查——纯计算任务里,这是你的救命稻草。
  3. 超时用 withTimeoutOrNull——少写 try-catch,代码更清爽。
  4. 不可取消用 NonCancellable——只用在资源释放等关键路径上,别滥用。

嗯,这些机制看起来简单,但用错了地方,后果很严重。我见过太多因为取消没处理好导致的内存泄漏、ANR 甚至数据损坏。希望你能把这些原则刻在脑子里,写协程代码时,多问自己一句:「如果用户突然退出,我的协程能优雅地停下来吗?」

📌 核心要点
- 协程取消是协作式的,不是强制的
- 在循环或耗时操作中,务必检查 isActive 或调用 ensureActive
- 优先使用 withTimeoutOrNull 而非 withTimeout
- NonCancellable 只用于不可中断的临界区

公众号:蓝海资料掘金营,微信deep3321