Kotlin 协程取消原理

284 阅读3分钟

在使用 Kotlin 协程处理重复任务(如轮询 API、定期更新数据或调度任务)时,清晰理解协程取消机制至关重要。若取消操作处理不当,可能引发隐性错误、无限循环或 “僵尸” 协程(即无法正常终止的协程 )。

协程取消的原理

Kotlin 协程的取消机制,本质是通过抛出 CancellationException 来 “通知” 协程终止。

正常流程是:

  1. 调用 scope.cancel() → 协程作用域内抛出 CancellationException
  2. 协程检测到这个异常 → 主动结束执行

关于 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),即使不为协程的异常取消,也是好的编程习惯。

参考: 【1】mp.weixin.qq.com/s/LzS8Kujov…