协程取消与超时:取消协程、isActive检查、withTimeout、非取消的挂起函数

协程的取消和超时,这俩概念在 Kotlin 协程里属于「保命技能」。我刚开始用协程那会儿,总觉得协程启动了就让它跑呗,干嘛要取消?后来线上出了个事故——一个协程里做了个死循环查询,用户切页面了它还在后台疯狂请求,直接把服务器打挂了。嗯,从那以后我再也不敢忽视协程取消了。

说白了,协程不是线程,你不能像 stop() 线程那样暴力干掉它。协程的取消是协作式的,你得跟它商量着来。今天咱们就把这块掰开揉碎讲清楚。

1. 协程取消的基本原理

协程取消的核心就一句话:协程代码必须配合检查取消状态。你调用 cancel() 只是给协程发了个「你可以停了」的信号,但协程如果不理你,它照样跑。

关键点:协程取消不是强制的,是协作式的。协程需要定期检查自己是否被取消了。

来看个最简单的例子:

val job = scope.launch {
    repeat(100) { i ->
        delay(50)  // delay 是可取消的挂起函数
        println("正在处理第 $i 个任务")
    }
}

// 过一会儿取消
delay(200)
job.cancel()
job.join()  // 等待协程真正结束

这里 delay() 是个可取消的挂起函数,它内部会检查协程状态。一旦发现被取消,就会抛出 CancellationException。所以协程能优雅地停下来。

我的习惯:每次调用 cancel() 之后,我都会紧跟一个 join()。这样能确保协程确实结束了,再往下走。不然你 cancel 完立刻做清理工作,协程可能还在跑。

2. isActive 检查——手动配合取消

但问题来了:如果你的协程里没有调用 delay()、withContext() 这类可取消的挂起函数,而是纯计算任务呢?比如一个巨大的循环:

scope.launch {
    var i = 0
    while (i < 1_000_000) {
        // 做一些计算...
        i++
    }
    println("终于算完了")
}

你 cancel 它试试?协程根本不理你,它会老老实实把一百万次循环跑完。为什么?因为它从来没检查过「我是不是该停了」。

解决办法就是手动检查 isActive:

scope.launch {
    var i = 0
    while (isActive && i < 1_000_000) {
        // 做一些计算...
        i++
    }
    println("协程结束了,i = $i")
}

isActive 是 CoroutineScope 的一个扩展属性,它返回当前协程是否还处于活跃状态。一旦你调用了 cancel(),isActive 就变成 false,循环自然就停了。

我曾经踩过的坑:在自定义的挂起函数里忘了检查 isActive。写了个下载文件的协程,用户取消后,文件还在后台继续下载。后来我养成了习惯——只要协程里有循环,第一件事就是加 isActive 检查。

3. withTimeout——给协程上个闹钟

有时候协程跑得太久了,你不想无限等下去。这时候 withTimeout 就派上用场了。它就像给协程设了个闹钟,时间一到就响铃(抛异常)。

scope.launch {
    try {
        withTimeout(1000) {  // 最多等 1 秒
            repeat(100) { i ->
                delay(100)
                println("处理中... $i")
            }
        }
    } catch (e: TimeoutCancellationException) {
        println("超时了!协程被取消")
    }
}

withTimeout 内部其实也是调用了 cancel(),只不过它帮你自动管理了时间。超时后抛出的 TimeoutCancellationException 是 CancellationException 的子类,所以它本质上也是一种取消。

注意:withTimeout 会抛出异常,如果你不想用 try-catch,可以用 withTimeoutOrNull。超时了返回 null,否则返回结果。

val result = withTimeoutOrNull(1000) {
    // 一些可能超时的操作
    "完成"
}
println(result ?: "超时了,返回 null")

我个人更偏爱 withTimeoutOrNull,少写一个 try-catch,代码清爽很多。你想想看,超时本身就是一种「异常情况」,用 null 来表示也挺合理。

4. 非取消的挂起函数——那些「不听话」的函数

这里有个容易翻车的地方:不是所有挂起函数都支持取消。比如 kotlinx.coroutines 里的 delay()、yield() 是可取消的,但有些第三方库提供的挂起函数,或者你自己写的挂起函数,可能压根不检查取消状态。

举个例子:

// 一个「不听话」的挂起函数
suspend fun slowOperation(): String {
    var result = ""
    for (i in 1..100) {
        // 模拟耗时操作,但不检查取消
        Thread.sleep(10)  // 注意:这不是挂起函数,是阻塞的!
        result += i
    }
    return result
}

scope.launch {
    val result = slowOperation()  // 这个协程取消不了
    println(result)
}

Thread.sleep() 是阻塞的,它不会响应协程的取消。你 cancel 了,它照样睡完。正确的做法是用 delay() 替代,或者手动检查 isActive。

我的建议:写自定义挂起函数时,一定要在关键节点调用 yield() 或检查 isActive。yield() 是个好习惯,它会让出执行权,同时检查取消状态。说白了就是「我干一会儿活,歇口气,顺便看看老板有没有叫我停」。

5. 知识体系图

下面这张图把协程取消与超时的核心逻辑串起来了,你看一眼就能明白它们之间的关系:

协程取消与超时核心逻辑 协程启动 协程是否包含可取消的挂起函数? 自动响应取消 (delay, withContext 等) 需要手动检查 (isActive / yield) 取消方式 job.cancel() 手动取消 withTimeout 超时自动取消 withTimeoutOrNull 超时返回 null

6. 实战中的避坑指南

讲几个我实际项目中遇到的坑,你遇到了能少走弯路:

场景 问题 解决方案
协程里用 Thread.sleep() 无法响应取消,协程变成「僵尸」 换成 delay(),或者用 withContext(Dispatchers.IO) 包裹
循环里没检查 isActive cancel 无效,CPU 继续空转 while (isActive) 或定期调用 yield()
withTimeout 超时后资源未释放 文件句柄、网络连接泄漏 用 try-finally 或 use 块确保资源释放
自定义挂起函数不检查取消 协程取消后函数还在执行 在挂起函数里加 ensureActive() 检查

我曾经翻过的一个大车:用 withTimeout 包裹了一个数据库查询,超时后以为查询停了,结果数据库连接池里的连接还在跑。后来排查发现,那个数据库驱动用的是阻塞 IO,根本不响应协程取消。最后我不得不把超时逻辑改成了「超时后标记任务为失败,但让底层查询自己跑完」。所以记住:withTimeout 只能取消协程,不能取消底层阻塞操作

7. 总结一下

协程取消这块,说白了就三件事要记住:

  • 协作式取消:协程不是线程,不能暴力停止。你得在代码里主动配合。
  • isActive 检查:循环和耗时计算里一定要加,不然 cancel 就是个摆设。
  • withTimeout 是利器:给协程设个时间上限,超时自动取消。用 withTimeoutOrNull 更省心。

嗯,这些知识点看着不多,但每个都是实战里血泪换来的。你写协程代码的时候,多想想「如果用户突然取消操作,我的协程能优雅停下来吗?」——想明白了,你的协程代码就稳了。


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