协程的异常处理2:协程的取消

本文深入探讨了Kotlin协程中的取消机制,包括调用`cancel`方法、协程的取消状态检查、`yield()`的使用、`join()`与`await()`的取消行为以及处理取消的副作用。通过例子展示了如何在协程中优雅地处理取消操作,确保资源的有效管理和避免不必要的计算。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

我将翻译三篇介绍协程的 取消异常处理 相关的文章,三篇文章是层层递进的关系。翻译过程中我将尽量忠实于原文。当然,由于水平有限,不能保证完全的翻译正确。如果您发现翻译有错误之处,欢迎在评论里指出。我也将贴出每篇翻译的原文。

这是第二篇。




在开发中,就如在生活中一样,我们都知道避免做多余的事情很重要,因为它会浪费 memoryenergy。这一原则也适用于协程。你需要确保自己能控制协程的生命周期,并在不再需要的时候取消它——这就是结构化并发所代表的。请继续阅读,探寻协程取消的来龙去脉。



调用 cancel 方法

当启动多个协程时,追踪或者一个个地取消它们是一件很痛苦的事情。但是,我们可以取消启动协程的整个 scope,这样我们就能取消在它里面启动的所有子协程。

//假设我们已经定义了一个 scope
val job1 = scope.launch { ... }
val job2 = scope.launch { ... }

scope.cancel()

取消 scope会取消它的子协程

有时候,你可能只需要取消一个协程,比如说响应用户的输入。调用 job1.cancel确保只有特定的协程被取消,其它所有的同级别协程不会受影响:

val job1 = scope.launch { ... }
val job2 = scope.launch { ... }
//协程job1会被取消,而其它的不会受影响
job1.cancel()

取消一个子协程不会影响同级别的其它协程

协程通过抛一个特殊的异常(CancellationException)来处理取消。如果你想为取消的原因提供更多细节,你可以在调用 cancel方法的时候提供一个 CancellationException实例,以下是 cancel方法的完整签名:

fun cancel(cause: CancellationException? = null)

如果你不提供自己的 CancellationException实例,一个默认的CancellationException实例将被创建,如下:

public override fun cancel(cause: CancellationException?) {
    cancelInternal(cause ?: defaultCancellationException())
}

因为CancellationException被抛出,你能通过该机制来处理协程的取消。更多关于如何取消的内容请阅读后面的章节 处理取消的副作用

在协程底层,子 job通过异常来通知父节点它的取消。父节点使用取消的 cause来判断是否处理这个异常。如果子任务是因为 CancellationException被取消,那么父节点就不必做额外的动作。

一旦你取消一个 scope,你将无法在被取消的 scope里发起新的协程

如果你正在使用 android KTX库,在大多数情况下你无需创建自己的 scope,因此也不必负责取消它们。如果你想在 ViewModelscope中工作,可以使用 viewModelScope。或者,如果你想启动一个与生命周期的 scope绑定的协程,则可以使用 lifecycleScopeviewModelScopelifecycleScope都是会在适当的时候被取消的 CoroutineScope对象。例如,当 ViewModel被清理的时候(其 clear()方法将被调用),它会取消在它的 scope中启动的协程。



为什么我的协程没有停止?

如果我们只是调用 cancel方法,并不表示协程的工作会立即停止。如果你正在执行一些相对重型的计算工作,比如读取多个文件,那么没有什么能自动停止正在执行的代码。

让我们用一个更简单的例子来看看会发生什么。比如我们需要在协程里每秒打印两次 "Hello",我们让这个协程运行一秒钟,然后取消掉它。看下面的代码:

fun main(args: Array<String>) = runBlocking<Unit> {
    val startTime = System.currentTimeMillis()
    val job = launch (Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            // 每秒打印两次消息
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("Hello ${i++}")
                nextPrintTime += 500L
            }
        }
    }
    delay(1000L)
    println("Cancel!")
    job.cancel()
    println("Done!")
}

让我们一步一步来看看会发生什么。当调用 launch的时候,我们创建了一个状态为 Activie的新协程。我们让该协程运行1000毫秒,因此我们会看到如下的打印输出:

Hello 0 
Hello 1 
Hello 2

一旦 job.cancel()被调用,我们的协程就会进入 Cancelling状态。但随后,我们会看到 Hello 3Hello 4也被打印到终端。只有当任务完成之后,协程才会变为 Cancelled状态。

上面代码的完整打印如下:

Hello 0 
Hello 1 
Hello 2 
Cancel! 
Done! 
Hello 3 
Hello 4

协程的工作并不会在调用 cancel方法的时候立即停止。相反,我们需要修改自己的代码,并且周期性地检查协程是否处于 Active状态。

协程取消的代码需要是可协作的!



使你的协程工作可以取消

你需要确保自己实现的所有协程工作都可以协作取消,因此,你需要周期性地、或者在开始长时间运行任务前进行检查。例如,如果你需要从磁盘中读取多份文件,在读取每份文件之前,应该检查协程是否已被取消。这样你就能避免执行多余的CPU密集型工作。

val job = launch {
    for(file in files) {
        //读文件之前,检查协程是否处于活跃状态
        if(!isActive){
            break
        }
        readFile(file)
    }
}

来自 kotlinx.coroutines的所有挂起方法都是可取消的:比如 withContextdelay等。因此,当你使用它们中的任意一个方法时,你不必进行取消检查并停止执行,或者抛出 CancellationException。但是,如果你不使用它们,自己写协程代码的时候我们有两个选择:

  • 调用 job.isActive() 或者 ensureActive() 进行检查
  • 调用 yield()方法,让其它工作进行


检查Job的活跃状态

在前面的 while(i < 5)中,可以添加对协程状态的检查:

while (i < 5 && isActive)

这意味着我们的工作只在协程处于 Active状态时才执行。这也意味着一旦出了 while循环,如果我们想做其它操作,比如打印 job是否被取消,我们可以添加一个检查 !isActive并执行我们的工作。

协程库提供了另一个有用的方法 ensureActivie()。它的实现如下:

fun Job.ensureActive(): Unit {
    if (!isActive) {
        throw getCancellationException()
    }
}

因为这个方法会在 job处于非 Active状态时立即抛异常,我们可以在 while循环中首先调用它:

while( i < 5) {
    ensureActive()
    ...
}

使用 ensureActive()方法,你可以避免自己实现 isActive所需要的 if 语句,减少你需要写的模板代码量,但是,也会丢失执行其它操作的灵活性,比如记录日志。



使用yield()让其它工作进行

如果你正在执行的工作:1) CPU占用高,2) 也许会耗尽线程池,3) 你想让线程做其它工作,而不必向线程池添加更多线程,那么就使用 yield()yield()做的第一项操作是进行完成检查,如果 job已经执行完成,那么就会通过抛 CancellationException退出该协程。和前面的 ensureActive()一样,yield()可以作为周期性检查的第一个方法。



Job.join vs Deferred.await 取消

有两种方式等待协程的结果:通过 launch返回的 job可以调用 join,通过 async返回的 Deferred(另一种形式的 Job)可以调用 await

Job.join会挂起当前协程直到它的工作执行完成。和 job.cancel搭配使用,它会有如下表现:

  • 如果你先调用 job.cancel,然后调用 job.join,当前协程会挂起直到 job完成。
  • job.join之后调用 job.cancel没有效果,因为 job已经完成。

当你对协程的结果感兴趣时,就可以使用 Deferred。当协程执行完成,它的结果会通过 Deferred.await返回。Deferred也是一种 Job,它也能被取消。

对一个已经被取消的 Deferred调用 await会导致抛 JobCancellationException

val deferred = async { ... }
deferred.cancel()
val result = deferred.await() //抛 JobCancellationException

这是我们为什么会得到异常的原因:await的作用是挂起当前协程直到得到计算结果,由于协程已经被取消,结果无法被计算。因此,在取消之后调用 await将导致 JobCancellationException: Job was cancelled

相反,如果你在 deferred.await之后调用 deferred.cancel,什么事都不会发生,因为协程已经完成了。



处理取消的副作用

假设你想在协程取消的时候执行一些特殊的操作:关闭你可能正在使用的任何资源,记录取消信息,或者你想执行其它的清理代码。我们有几种方法可以做到这一点:

检查 !isActive

如果你在使用 isActive做周期性检查,那么一旦跳出 while循环,你就可以清理资源。我们前面的代码可以更新如下:

while(i < 5 && isActive){
     // 每秒打印两次消息
    if (...) {
        println("Hello ${i++}")
        nextPrintTime += 500L
    }
}

//协程的工作完成了,可以开始清理工作
println("Clean up!")

现在,当协程不再 Active时候,while循环就会结束,然后我们可以做清理工作。

Try catch finally

由于协程被取消的时候会抛出 CancellationException,我们可以把挂起任务用 try/catch包裹起来,然后在 finally里面实现我们的清理工作。

val job = launch {   
    try {     
        work()   
    } catch (e: CancellationException) {      
        println(“Work cancelled!)    
    } finally {      
        println(“Clean up!)            
    }}
    
    delay(1000L)
    println(“Cancel!)
    job.cancel()
    println(“Done!)

但是,如果我们执行的清理工作也是可挂起的,上面的代码就不会生效,因为协程一旦处于 Cancelling状态,他就无法再挂起。

一个处于 Cancelling状态的协程不能挂起!

为了能在协程被取消的时候调用挂起方法,我们需要把清理工作切换到不可取消的 CoroutineContext中执行。这将允许代码挂起,并使协程保持为 Cancelling状态,直到工作完成。

val job = launch {   
    try {     
        work()   
    } catch (e: CancellationException) {      
        println(“Work cancelled!)    
    } finally {
        withContext(NonCancellable) {
            delay(1000L) //
            println(“Clean up!)  
        }          
    }}
    
    delay(1000L)
    println(“Cancel!)
    job.cancel()
    println(“Done!)


suspendCancellableCoroutine 和 invokeOnCancellation

如果你使用 suspendCoroutine方法将回调转换成协程,那么最好使用 suspendCancellableCoroutine方法替代它。取消时要做的工作可以用 continuation.invokeOnCancellation来实现:

suspend fun work() {
    return suspendCancellableCoroutine { continuation -> 
        continuation.invokeOnCancellation {
            // 清理工作
        }
    }
}


为了实现结构化并发的好处,确保我们没有做多余的工作,你需要确保自己的代码是可取消的。

使用在 JetPack里面定义好的 CoroutineScopeviewModelScope或者 lifecycleScope, 它们会在 scope完成的时候取消任务。如果你使用自己创建的 CoroutineScope,确保你把它与 job绑定并在需要的时候取消它。

协程取消的代码需要是可协作的,因此确保你的代码有取消检查,以避免做多余的工作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值