Kotlin 协程的取消,我觉得设计的不好

0 阅读8分钟

1.jpg

不久之前,我写过两篇文章,

核心是讲解的协程中取消,如果再配合上讲解异常的两篇文章,

我觉得在协程结束方面(无论是正常结束还是异常结束),在座的各位,应该已经成为了专家。

我同事也是这么想的,看完这些文章,他说:原来也没有这么难,现在的我,强的一逼!

我默不作声,抛出了另一个问题,问这位新晋的协程专家:


fun main() = runBlocking {

    val job1 = launch {
        delay(500)
        throw CancellationException("Cancel job1") // 这里抛出了异常
    }

    val job2 = launch {
        delay(1000L)
        println("job 2 done") // 这句话会执行吗?
    }

    println("All done")
}

例 1

job2 会被取消吗?

不妨再乱一点儿

如果你从结构化并发,异常提升的角度来看,job2 会被取消,因为 job1 中异常提升导致姐妹协程 job2 也被取消了。

OK,这个问题先想到这里,我们按下不表,来看第二个例子:

fun main() = runBlocking {

    val job1 = launch {
        delay(1000)
        println("job 1 done")
    }

    val job2 = launch {
        delay(1000L)
        println("job 2 done") // 这句话会执行吗?
    }

    delay(500)
    job1.cancel() // 这里取消了 job1
    println("All done")
}

例 2

我同事斩钉截铁地告诉我,job2 不会被取消!而 job1 会被取消。

没错,整个任务都会按照我们理解的那样合理地运行起来,因为我只取消了 job1

好的,再来一个:

// ...
val job1 = launch {
    runCatching { // 这里我把 delay 用 catch 包裹起来了
        delay(1000)
    }
    println("job 1 done")
}
// ...

例 3

我同事稍微有点犯难,你这有用吗?

嘿,我说,还真有用!

上面的代码输出是这样的:

All done
job 1 done
job 2 done

正式起航

开发就像生活一样,我们都清楚没必要做多余的工作 —— 这只会浪费内存和资源,这个原则同样适用于协程。

你需要确保自己能掌控协程的生命周期,在它不再被需要时及时取消 —— 这正是结构化并发的核心要义。

scope.cancel

我们给一个新的例子:

scope.launch { }
scope.launch { }
scope.cancel()

当启动多个协程时,如果你觉得逐一跟踪它们或单独取消每个协程会十分繁琐。

我们不妨直接取消承载这些协程的整个 scope,如此一来,该作用域下创建的所有子协程都会被一并取消。

但是这样做其实是有风险的,我们一般不推荐直接取消 scope,因为一旦 scope 取消之后,后续再次创建协程,直接就会被取消掉,被取消的 scope 不会再执行任何协程。

如果在开发中使用标准的协程相关库,在绝大多数场景下都无需自行创建协程作用域,自然也不用负责取消这些作用域。

如果你在 ViewModel 的作用域内开发,可直接使用 viewModelScope;而若想启动与生命周期作用域绑定的协程,则可以使用 lifecycleScope

viewModelScopelifecycleScope 均为 CoroutineScope 类型的对象,且会在恰当的时机自动取消。例如,当 ViewModel 被销毁时,其作用域内启动的所有协程都会被一并取消。

viewModelScope 我们在 这篇文章 中详细探讨过。

job.cancel

有时你可能只需取消某个特定的协程——比如响应用户输入时。调用 job.cancel() 方法可确保只有该指定协程被取消,其余同级协程均不受影响。

这也正是 例2 的做法。

这样做的好处就是 —— 被取消的子协程不会对其他同级协程造成影响。

取消是如何执行的

协程通过抛出一种特殊的异常 CancellationException 来处理取消操作。

若你想补充取消原因的更多细节,可在调用 .cancel() 方法时传入 CancellationException 的实例——该方法的完整签名如下:

fun cancel(cause: CancellationException? = null)

如果没有提供 CancellationException 实例,系统会自动创建一个默认的 CancellationException(完整代码如下):

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

正因为协程取消时会抛出 CancellationException,你才能借助这一机制来处理协程取消逻辑。

底层实现上,子协程会通过该异常通知其父协程自身已被取消。父协程会根据取消操作的原因判断是否需要处理该异常:若子协程因 CancellationException 被取消,则父协程无需执行任何额外操作。

恍然大悟

到这里,前面三个例子的答案已经出现了。

例 1 中的 throw CancellationException("Cancel job1") 这个写法,和 例 2 中的 job1.cancel() 别无二致。

例 3,因为我们使用了 catch,后续代码是会执行的。

这也是为什么我觉得 Kotlin 协程的取消设计的不好的原因,因为协程取消的判定太依赖 CancellationException——它只是万千异常中的一个特殊 case。

我打个比方,这种感觉就像你在学习加法运算的时候,老师告诉你在 +3 的时候需要特殊处理,+3 处理成减法。这种感觉实在是太奇怪了。

当然,如果你熟知协程取消的内部逻辑,那么这些抱怨也就不成问题了。

为什么有时候我的协程并没有取消

仅仅调用 cancel() 方法,并不意味着协程任务会立刻停止。如果你正在执行一些相对繁重的计算操作(比如从多个文件中读取数据),系统并不会自动终止这些代码的运行。

我们用一个更简单的例子来看看具体现象:假设需要通过协程实现每秒钟打印两次「Hello」,我们让协程运行 1 秒后再取消它:

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!")
}

这段代码输出如下:

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

我们一步步拆解实际发生的过程:

调用 launch() 方法时,会创建一个处于活跃状态的新协程。我们让这个协程运行 1000 毫秒,此时会看到控制台打印出:

Hello 0
Hello 1
Hello 2

调用 job.cancel() 后,我们的协程会进入取消中状态。但此时你会发现,终端仍会打印出后续日志,只有当任务完全执行完毕后,协程才会进入已取消状态。

Cancel!
Done!
Hello 3
Hello 4

调用 cancel() 方法并不会直接终止协程任务。相反,我们需要修改代码,定期检查协程是否仍处于活跃状态。

可取消的任务

你需要确保自己实现的所有协程任务都能配合取消操作,因此需定期检查取消状态,或在执行任何耗时任务前先做取消检查。

再次提醒:协程的取消是协作的。需要互相配合、开发者编写额外代码的。

例如,若你要从磁盘读取多个文件,在开始读取每个文件前,都应检查协程是否已被取消。这样做能避免在任务已无必要执行时,仍消耗大量 CPU 资源。

val job = launch {
    for(file in files) {
        // TODO 检查取消状态
        readFile(file)
    }
}

标准的协程库中所有挂起函数均支持取消:比如 withContextdelay 等。

因此只要使用了这些函数,你无需手动检查取消状态、终止执行或抛出 CancellationException

但如果未使用这些函数,要让协程代码支持取消,有两种方案可选:

  1. 检查 job.isActive 或调用 ensureActive()
  2. 使用 yield() 让出执行权,让其他任务得以执行

检查 Job 的活跃状态

一种方案是在 while(i < 5) 循环中,增加对协程状态的检查:

// 因为我们在 launch 代码块内,所以可以直接访问 job.isActive
while (i < 5 && isActive)

这句话的意思是,只有当协程处于活跃状态时,我们的任务才会执行。

同时,这也意味着当退出循环后,如果我们想执行一些额外操作(比如记录 Job 是否被取消),可以通过检查 !isActive 来触发相应的行为。

ensureActive

协程库还提供了另一个实用方法——ensureActive()。它的实现逻辑如下:

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

这个方法会在 Job 被取消时立即抛出异常,因此我们可以把它放在 while 循环的开头:

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

使用 ensureActive() 可以避免手动编写 isActive 所需的 if 判断语句,减少样板代码的编写量。但代价是,你会失去执行日志记录等额外操作的灵活性(因为没有检查是否取消,而是直接抛出异常函数结束了)。

yield

如果你正在执行的任务满足以下条件:

  1. 是 CPU 密集型操作;
  2. 可能耗尽线程池;
  3. 你希望在不增加线程池线程数量的情况下,让当前线程能处理其他任务。

那么就可以使用 yield()

yield() 执行的第一步是检查任务是否已完成,如果 Job 已经结束,它会通过抛出 CancellationException 来终止当前协程。

yield() 可以像前面提到的 ensureActive() 一样,作为周期性检查的第一个调用函数。

实际上,几乎任何一个良好编写 suspend 函数,都会在协程取消的时候抛出 CancellationException

Job.join 与 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 会返回这个结果。

DeferredJob 的一种类型,因此它也支持取消操作。

如果对一个已经被取消的 Deferred 调用 await,会抛出 JobCancellationException

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

之所以会抛出异常,是因为 await 的作用是挂起协程,直到结果计算完成;而如果协程已经被取消,结果就无法生成。

因此,在 cancel 之后调用 await 会触发 JobCancellationException,提示:Job was cancelled(任务已被取消)。

反过来,如果你在调用 deferred.await 之后再调用 deferred.cancel,则不会产生任何效果,因为此时协程已经执行完毕。

取消后的清理工作

2.jpg

假设你希望在协程被取消时执行一些特定操作:比如关闭正在使用的资源、记录取消事件,或者执行其他清理代码。我们有几种实现方式:

检查 !isActive 状态

如果你会周期性地检查 isActive 状态,那么当退出 while 循环后,就可以执行资源清理。我们可以把之前的代码更新为:

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

// 协程任务已完成,我们可以执行清理了
println("Clean up!")

现在,当协程取消时,while 循环就会退出,我们就可以执行清理操作了。

try-catch

我也称之为古法捕获——几乎在任何情况下,这个是最好用的

由于协程被取消时会抛出 CancellationException,我们可以将挂起任务包裹在 try/catch 代码块中,并在 finally 块里实现清理工作。

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

delay(1000L)
println("Cancel!")
job.cancel()
println("Done!")

但如果我们需要执行的清理工作本身是挂起操作,上面的代码就无法正常运行了。例如上面这个示例中,delay(1000L) 也会理解取消,不会打印 Clean up!,

一旦协程进入“取消中”状态,它就无法再挂起了。

如果希望在协程被取消后仍能调用挂起函数,我们需要将清理工作切换到 NonCancellable 协程上下文中执行。这样可以保证代码能够正常挂起,并让协程保持在取消状态,直到所有清理工作完成。

val job = launch {
    try {
        work()
    } catch (e: CancellationException) {
        println("Work cancelled!")
    } finally {
        withContext(NonCancellable) {
            delay(1000L) // 或其他挂起函数
            println("Cleanup done!")
        }
    }
}

delay(1000L)
println("Cancel!")
job.cancel()
println("Done!")

suspendCancellableCoroutine 与 invokeOnCancellation

如果你使用 suspendCoroutine 将使用回调的函数转换为协程中能使用的同步函数,建议改用 suspendCancellableCoroutine

取消时需要执行的清理工作,可以通过 continuation.invokeOnCancellation 来实现:

suspend fun work() {
    return suspendCancellableCoroutine { continuation ->
        continuation.invokeOnCancellation {
            // 执行清理操作
        }
        // 剩余实现逻辑
    }
}

为了充分利用结构化并发的优势,避免执行不必要的任务,你需要确保代码支持取消操作。

suspendCancellableCoroutine 是最常用的,也是最安全的,异步代码变成同步代码的方法。

建议

使用 Jetpack 中定义的 CoroutineScope,例如 viewModelScopelifecycleScope,它们会在作用域结束时自动取消内部的所有任务。

如果需要创建自定义 CoroutineScope,请确保将其与一个 Job 绑定,并在需要时主动调用 cancel

最后

我在 这篇文章 中提到过:针对 CancellationException 的正确做法应该是重新抛出,而不是吞掉。

为什么呢?

fun badCase() = runBlocking<Unit> {

    val job1 = launch {

        runCatching {
            dowork()
        }.onFailure {
            if (it is CancellationException) {
                println("Cancelled") // 用户无法感知取消操作,只知道任务完成了
            }
        }

        println("job 1 done")
    }

    delay(100L)
    job1.cancel()
}

suspend fun dowork() {

    try {
        delay(2000L)
        println("dowork")
    } catch (e: Exception) {
        // catch 而非重新抛出
    } finally {
        println("clean up")
    }
}

通过上面这个代码就能理解,如果用户需要知道取消操作(例如通知用户任务已经取消)但是你没有重新抛出这个异常的话,上层是无法知道这个任务是取消了还是正常完成了的。

希望这篇文章,对你有所帮助。