协程取消与超时:取消协程、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. 知识体系图
下面这张图把协程取消与超时的核心逻辑串起来了,你看一眼就能明白它们之间的关系:
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