Skip to content

取消机制与资源清理

源:Cancellation and timeouts

协程的取消是协作式(Cooperative)的。这意味着协程不会被强制杀死,而是通过定期检查取消信号并主动停止执行。理解这一特质对于编写不产生资源泄露且能及时响应系统指令的并发代码至关重要。

协作式取消的本质

一个协程只有在挂起点(如 delay, yield, await)或者显式检查时,才能被取消。如果一个协程正在执行密集的计算任务且没有检查取消状态,那么即使调用了 job.cancel(),它也会坚持运行到底。

取消失效陷阱

如果在协程内部使用了阻塞式线程 API(如 Thread.sleep()),该协程将完全无法响应取消信号,直到阻塞结束。

检查取消状态的三种标准方式

kotlin
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    // ⭐️ 核心:在每一轮循环开始前检查 isActive
    while (isActive) { 
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("正在打印 ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
kotlin
val job = launch(Dispatchers.Default) {
    for (i in 1..10000) {
        // ⭐️ 如果已取消,立即抛出 CancellationException
        ensureActive() 
        heavyComputation(i)
    }
}
kotlin
val job = launch(Dispatchers.Default) {
    for (i in 1..10000) {
        heavyComputation(i)
        // ⭐️ 既检查了取消,又防止了当前协程霸占 CPU
        yield() 
    }
}

资源清理与 finally 块

当协程被取消时,挂起函数会抛出 CancellationException。我们可以利用 try-finally 结构来确保资源的释放,就像处理普通异常一样。

kotlin
val job = launch {
    val file = openFile("data.txt")
    try {
        repeat(1000) { i ->
            file.write("Data $i")
            delay(500)
        }
    } finally {
        // ⭐️ 无论正常结束还是被取消,都会执行清理
        file.close()
        println("文件资源已安全关闭")
    }
}

在取消时执行挂起操作:NonCancellable

finally 块中,如果你需要调用另一个挂起函数(如关闭数据库连接、发送网络告警日志),普通的挂起调用会因为协程已经处于“取消中”状态而立即抛出异常并失败。

解决方案:使用 withContext(NonCancellable) 将清理逻辑包裹在不可取消的上下文中。

kotlin
finally {
    withContext(NonCancellable) {
        println("开始清理并同步状态至服务器...")
        delay(1000) // ⭐️ 这里的 delay 不会因为父级取消而失效
        println("清理完成")
    }
}

慎用 NonCancellable

NonCancellable 仅应被用于资源清理。绝对不要在其中执行长期的业务逻辑,否则会导致协程无法被彻底销毁,造成事实上的内存泄露。

超时机制:withTimeout

超时本质上是一种自动触发的取消。

  • withTimeout(ms):超时抛出 TimeoutCancellationException
  • withTimeoutOrNull(ms):超时不抛出异常,而是返回 null
kotlin
try {
    withTimeout(1300L) {
        repeat(1000) { i ->
            println("正在处理 $i ...")
            delay(500)
        }
    }
} catch (e: TimeoutCancellationException) {
    println("任务已超时并取消")
}
kotlin
val result = withTimeoutOrNull(1300L) {
    repeat(1000) { i ->
        delay(500)
    }
    "Success"
}

if (result == null) {
    println("请求超时,显示缓存数据")
}

取消时的 Job 状态机流转

理解 Job 的内部状态对于排查复杂的取消问题非常有帮助。

  1. Active:运行中。
  2. Cancelling:调用了 cancel(),此时 isActive 变为 false
  3. Cancelled:所有子协程和清理逻辑(finally)执行完毕后的终态。

连坐机制

默认情况下,如果一个子协程抛出非 CancellationException 异常,它会取消父协程,进而导致父协程取消所有其他子协程。而 CancellationException 被视为“良性”的,它只取消当前协程,不会影响父级和兄弟。

核心开发准则

  1. 所有的挂起函数都是可取消的:如果你的代码调用了 delayawaitcollect,你已经天然具备了响应取消的能力。
  2. 循环任务必须手动检查:在 whilefor 循环中,始终使用 isActiveensureActive()
  3. 不要吞掉 CancellationException:如果你在 try-catch 中捕获了 Exception 但没有将其重新抛出(rethrow),你会破坏结构化并发的取消逻辑。
  4. 优先使用 withTimeoutOrNull:相比于抛出异常,使用返回 null 的方式处理超时通常能让业务代码更加简洁且易于维护。