Kotlin中取消协程是怎么实现的?

1,006 阅读6分钟

大家好,

今天, 我们将深入探讨协程的一个重要主题: 协程的取消. 这一点至关重要, 因为在 Android 中, 每个协程都与视图或生命周期相关联, 当视图被销毁时, 协程也应结束. 同样, 当应用关闭时, 其他协程也需要结束.

这种取消行为非常重要, 因为许多其他库都需要复杂的机制来管理它. 然而, 在 Kotlin 协程中, 取消行为既简单又安全, 从而更容易确保正确的常规的协程清理.

让我们一起来探索这一关键特性, 看看协程是如何高效处理取消的.

这种机制也用于后端, 尤其是当我们处理长连接(如 WebSockets 或长轮询)时. 更重要的是, 取消机制经常在我们没有意识到的情况下发挥作用, 以释放资源并提高应用的效率.

所以,

取消是至关重要的, 许多类和库都使用挂起函数来支持取消. 这种关注是有道理的: 一个强大的取消机制是非常宝贵的. 简单地杀死线程是一种糟糕的解决方案, 因为这样做无法适当清理资源或关闭连接. 同样, 强迫开发人员反复手动检查某些状态也不理想. 对有效取消方法的需求由来已久, 但 Kotlin 协程提供的解决方案却出人意料地简单, 友好, 安全.

ViewModelScope 的取消:-

当你使用viewModelScopelifecycleScope时, 取消就变得简单明了 -- 你无需手动管理它. 当 ViewModel 被销毁时, viewModelScope 会自动取消, 同时取消的还有它所包含的所有协程.

内部是如何工作的? 让我们深入一些代码来了解其中的原理:

说明:-

  • viewModelScope 使用一个结构进行操作, 该结构涉及为 ViewModel 设置一个标记. 这是通过 setTagIfAbsent 来完成的, 它需要两个参数: 一个key和一个 CloseableCoroutineScope. CloseableCoroutineScope实现了一个Cancelable接口, 提供了一种取消协程的方法.
  • setTagIfAbsent 中, 有一个名为 mBagOfTags 的 HashMap. 该Map存储了各种标记和相应的作用域. 当要清除 ViewModel 时, 会调用 onClear 方法.
  • onClear 期间, 它会调用 closeWithRuntimeException, 它会检查作用域是否是 Closeable 的实例. 如果是, 它将关闭(或取消)该作用域, 从而有效地取消在该作用域中运行的所有程序. 这种行为确保了在 ViewModel 被销毁时, 所有相关的协程都会被自动取消.

这就是 viewModelScope 的内部机制, 它提供了一种在 ViewModel 不再使用时清理和取消协程的方法. 这种自动化意味着你不必担心手动处理取消协程 -- 它是生命周期的一部分.

如果我们手动创建的 CoroutineScope 没有绑定到自动取消的 viewModelScopelifecycleScope, 该怎么办?

在这种情况下, 你可以通过调用 job.cancel() 手动取消Job. 让我们看看如何取消Job的示例.

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(200)
            println("Printing $i")
        }
    }

    delay(1100)
    job.cancel()
    job.join()
    println("Cancelled successfully")
}
// (0.2 sec)
// Printing 0
// (0.2 sec)
// Printing 1
// (0.2 sec)
// Printing 2
// (0.2 sec)
// Printing 3
// (0.2 sec)
// Printing 4
// (0.1 sec)
// Cancelled successfully

说明:-

  • 取消一个协程时, 它的状态会变为cancelling.
  • 在上面的代码中, 你可以看到在 200 毫秒后, 它打印了 5 次, 表示执行了大约 1 秒钟. 但再过 100 毫秒后, 协程被取消, 并以 成功取消的状态停止.
  • 当取消发生时, 协程的状态会转换为 cancelling, 并抛出一个 CancellationException, 表示协程已被中断, 正在停止过程中.
  • job.join(): 这会挂起当前的协程, 直到指定的 job 完成. 这意味着它会等待其他程序完全停止并释放所有资源.

让我们来看看取消协程的顺序和工作方式:-

  • 该Job的所有子Job也会被取消.
  • 该Job不能作为任何新的子程序的父程序.
  • 在第一个挂起点, 会抛出一个 CancellationException 异常. 如果该协程当前处于挂起状态, 则会立即通过 CancellationException 恢复. CancellationException 会被协程构建器忽略, 因此没有必要捕获它, 但它用于尽快完成协程体.
  • 一旦协程主体完成, 并且它的所有子协程也完成, 它就会将状态更改为cancelled.

你可以使用 try-catch 块捕获 CancellationException. 但为什么要捕获它呢? 毕竟, 它不会对任何事情造成伤害, 不是吗?

有时, 你需要在取消一个协程后进行清理, 比如关闭资源或回滚修改. 在这种情况下, 捕获异常就是处理清理过程的方法.当协程被取消时, 这一切都是为了保持整洁.🔥🧹

让我们看看代码:-

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        try {
            repeat(1_000) { i ->
                delay(200)
                println("Printing $i")
            }
        } catch (e: CancellationException) {
            println("Cancelled with $e")
        } finally {
            println("Finally")
        }
    }
    delay(700)
    job.cancel()
    job.join()
    println("Cancelled successfully")
    delay(1000)
}
// (0.2 sec)
// Printing 0
// (0.2 sec)
// Printing 1
// (0.2 sec)
// Printing 2
// (0.1 sec)
// Cancelled with JobCancellationException...
// Finally
// Cancelled successfully

说明:-

上面的代码非常简单, 让我们继续.

如果在取消一项Job后, 你想确保它真的被取消了, 该怎么办? 不用担心 -- Kotlin 协程为你提供了以下三种检查状态:

  1. job.isActive
  2. job.isCompleted
  3. job.isCancelled

Job创建状态:

  • Job通常在active状态下创建, 即立即开始.
  • 有些协程创建器有一个可选的 start 参数.如果设置为[CoroutineStart.LAZY], 则会在 new 状态下创建协程, 并可通过调用 startjoin 激活.

active状态:

  • 当协程执行时, Job被视为active, 直到它完成, 或者如果它失败或被取消.
  • cancellingcancelled状态:
  • 如果activeJob出现异常失败, 则会转入cancelling状态.
  • 可随时使用 cancel 功能手动取消Job, 该功能也会将Job转入cancelling状态.
  • 当Job执行完毕, 其所有子Job也都完成时, Job就会变成cancelling状态.

completing和completed状态:

  • 当一个 active 协程的主体完成或 CompletableJob.complete 被调用时, Job会过渡到 completing 状态.
  • 在等待所有子Job完成的过程中, Job会一直处于completing状态.
  • 一旦所有子Job都已完成, Job就会过渡到completed状态.
  • 请注意, 对于外部观察者来说, 处于completing状态的Job看起来仍是active的, 但在内部, 它是在等待其子Job完成.

当你取消一个协程作用域时, 它就完成了 -- 你不能用它来启动新的协程. cancelled的作用域不能用作任何新的父进程. 如果你想继续, 就必须创建一个新的作用域.一个cancelledJob的作用域就像一张过期的车票: 它哪儿也去不了.

让我们看一些代码来了解一下:-

suspend fun main() {

    val scope = CoroutineScope(Dispatchers.IO)
    scope.launch {

        repeat(1_000) { i ->
            delay(200)
            println("Printing $i")
        }
    }
    delay(1100)
    scope.cancel()
    scope.launch {
        println("New Scope")
    }
    println("Cancelled successfully")
    delay(1000)
}

Output:-
Printing 0
Printing 1
Printing 2
Printing 3
Printing 4
Cancelled successfully

说明:-

  • 正如你在上面的代码输出中看到的, 我们取消了作用域并再次启动了它, 但并没有执行.

在内部取消一个协程作用域:-

  • 要了解为什么会发生这种情况, 让我们深入研究一下内部代码库.
  • 这里我们将看到内部发生了什么:

  • 让我们来看看这种机制如何影响子程序的取消:

Job的作用:-

  • Job代表了一个协程中的一个工作单元, 并充当同一范围内所有子协程的父程序. 它跟踪它们的状态(激活, 取消或完成). 如果 CoroutineContext 中不包含Job, 则会创建一个默认的Job, 以确保对协程管理的集中控制.

取消行为:-

  • 当你创建了一个包含一个 Job 的上下文的 CoroutineScope 时, 在此作用域中启动的任何子程序都会成为该 Job 的一部分. 这允许级联取消, 即取消父 Job 也会取消所有子程序.
  • 在有一个 Job 的作用域上调用 CoroutineScope.cancel(), 就会触发所有子程序的取消过程. 这将启动一个有序的关闭过程, 使相关程序完成其工作或在下一个挂起点(如delay)退出.

代码中的应用:

  • 在你的代码中, 你创建了一个 CoroutineScope 并在其中启动了多个协程.当你调用 scope.cancel() 时, 会触发父程序 Job 及其所有子程序的取消过程. 有序的取消过程有助于确保一致地停止作用域内的所有操作, 并适当地释放资源.

但如果我们的线程被阻塞, 而大型进程的协程内部正在进行 I/O 操作, 那么取消时会发生什么情况呢? 取消Job时会发生什么? 我们可以使用 Thread.sleep 来模拟进程, 但千万不要在实际项目中尝试这种情况.

让我们看看下面的场景 :-

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            Thread.sleep(200) // We might have some
            // complex operations or reading files here
            println("Printing $i")
        }
    }
    delay(1000)
    job.cancelAndJoin()
    println("Cancelled successfully")
    delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// ... (up to 1000)

说明:-

  • 下面的示例展示了这样一种情况: 由于协程内部没有挂起点(我们使用了 Thread.sleep 而不是 delay), 因此无法取消该协程. 尽管应该在 1 秒后取消, 但执行时间却超过了 3 分钟.
  • 你们可以反编译代码并理解.

解决方法:-

  • 要解决这个问题, 我们可以使用 yield().

  • 现在有人会问 yield() 是什么东西. 等等, 在深入探讨之前, 让我们先看看代码.

      suspend fun main(): Unit = coroutineScope {
          val job = Job()
          launch(job) {
              repeat(1_000) { i ->
                  Thread.sleep(200) // We might have some
                  yield()
                  // complex operations or reading files here
                  println("Printing $i")
              }
          }
          delay(1000)
          job.cancelAndJoin()
          println("Cancelled successfully")
          delay(1000)
      }
    
      Output:-
      Printing 0
      Printing 1
      Printing 2
      Printing 3
      Printing 4
      Cancelled successfully
    
  • 在挂起函数中, 在非挂起的 CPU 密集型或时间密集型操作块之间使用 yield 是一种很好的做法.

  • 让我们看看 yield() 是什么意思.

在通常情况下, yield的意思是对再也扛不住的人或事物让步.

用我们的话来说就是:-

  • 线程让步: 通过调用yield(), 当前的协程将控制权交还给调度程序, 允许其他协程运行 .这对共享同一调度程序的各协程之间的公平性尤其有用.
  • 可取消: 如果在调用 yield() 或等待调度时取消了与协程相关的 Job, 则协程会立即恢复并发出 CancellationException. 这提供了及时的取消保证, 即使 yield() 已准备好返回, 也能确保取消得到遵守.
  • 取消检查: 即使yield()没有挂起, 它仍会通过ensureActive()检查协程是否已被取消. 这增加了额外的安全级别, 以防止取消后不必要的协程继续.

ensureActive():-

它将使用isActive检查Job是否仍处于active状态.

今天的主题分享就到这里啦!

一家之言, 欢迎斧正!

Happy Coding! Stay GOLDEN!