在使用 Kotlin 协程处理重复任务(如轮询 API、定期更新数据或调度任务)时,清晰理解协程取消机制至关重要。若取消操作处理不当,可能引发隐性错误、无限循环或 “僵尸” 协程(即无法正常终止的协程 )。
协程取消的原理
Kotlin 协程的取消机制,本质是通过抛出 CancellationException 来 “通知” 协程终止。
正常流程是:
- 调用
scope.cancel()→ 协程作用域内抛出CancellationException - 协程检测到这个异常 → 主动结束执行
关于 CancellationException 有几个关键要点需要理解:
- 产生来源:仅由协程机制生成,并非典型的应用逻辑产生。若协程因
CancellationException结束,不会被视为错误,而是当作协程正常终止。 - 抛出时机:协程作用域取消后,若在已取消的作用域内调用任何内置挂起函数(如 delay、withContext、await等 ),会抛出
CancellationException。 - 传递性:与常规异常不同,
CancellationException不会在协程作业层级中向上传播。它会安静、干净地结束,不会触发父协程告警,也不会导致应用崩溃。
需要特别注意的是:为了让 CancellationException 正常终止协程,我们不能中途捕获这个异常,必须让它正常传达到栈顶。若反复捕获并忽略这个异常,可能产生 “僵尸” 协程 —— 无休止运行却不做有效工作,还可能占用锁或资源。
不好的实践
以下是存在问题的协程循环,它会无意间捕获 CancellationException,无法通知协程正常结束:
launch {
while (true) {
try {
doWork()
delay(1000) // delay 有可能抛出 CancellationException
} catch (e: Exception) { // 捕获了所有异常,导致无法正常取消
logError(e)
}
}
}
上面代码有两个严重问题:
while (true)是无条件死循环,协程即使被 “通知取消”(抛出CancellationException),也不会主动停止循环。delay(1000)被取消时,会抛出CancellationException,但你的代码用catch (e: Exception)把它捕获并吃掉了,吞掉了取消信号
这段代码会导致 死循环继续跑,变成僵尸协程(占着资源不干活,也不退出)
修复方案
核心思路是让协程能检测到取消信号,别吞掉 CancellationException
方案1:用 while (isActive) 检测取消
launch {
while (isActive) { // 取消时 isActive 变为 false,循环结束
try {
doWork()
delay(1000)
} catch (e: Exception) {
// 只处理业务异常,主动抛出取消异常
if (e is CancellationException) {
throw e // 重新抛出,让协程正常取消
}
logError(e)
}
}
}
while (isActive) 可以在协程取消时循环会终止。
- coroutineContext.ensureActive():显式检查协程是否仍活跃,明确区分真正的取消操作与其他异常。
方案2:coroutineContext.ensureActive()
直接用 coroutineContext.ensureActive() 显式检查取消,相对方案一更简洁。
launch {
while (isActive) {
try {
doWork()
delay(1000)
} catch (e: Exception) {
coroutineContext.ensureActive()
logError(e) // 只处理业务异常
}
}
}
coroutineContext.ensureActive() 相当于执行下面这句
if (!isActive) {
throw CancellationException()
}
方案3:delay 移出 try-catch
launch {
while (isActive) {
try {
doWork()
} catch (e: Exception) {
logError(e) // 只处理业务异常
}
// delay 放外面,取消时直接抛 CancellationException
delay(1000)
}
}
delay 等能抛出取消异常的函数不要放在 try-catch 内部
这个反面示例也提醒我们,捕获异常时不要使用 Exception 或 Throwable 这类宽泛类型,建议改为捕获明确的异常类型,可以维持清晰的错误处理逻辑。
try {
parseData()
} catch (e: ParseException) {
handleParseExceptionsExplicitly(e)
}
最佳实践:双重检查模式
launch {
while (isActive) {
try {
doWork()
} catch (e: Throwable) {
coroutineContext.ensureActive() // 如果有取消异常则会被向上抛出
handleError(e) // 其他错误会在这里处理
}
delay(1000)
}
}
-
使用
isActive清晰表达协程取消时停止执行的意图。 -
将挂起操作(delay )置于 try-catch 块外,可保障取消异常干净传播。
-
基于
coroutineContext.ensureActive()实现双重检查模式:确保真正的协程取消和常规异常互不干扰。 -
显式捕获特定异常:除非必要避免使用宽泛的catch (e: Exception),即使不为协程的异常取消,也是好的编程习惯。