异常传播如何在 CoroutineScope.async 上起作用?

How does exception propagation works on CoroutineScope.async?

我看到多个消息来源声称 async{} 块内发生的异常不会传递到任何地方,并且仅存储在 Deferred 实例中。声称异常仍然是“隐藏的”,并且只会在调用 await() 的那一刻影响外部的事物。这通常被描述为 launch{}async{} 之间的主要区别之一。 .

An uncaught exception inside the async code is stored inside the resulting Deferred and is not delivered anywhere else, it will get silently dropped unless processed

根据这个说法,至少按照我的理解,下面的代码应该不会抛出,因为没有人在调用 await:

// throws
runBlocking {
  async { throw Exception("Oops") }
}

然而,异常被抛出。这也被讨论here,但我无法真正理解为什么阅读这个。

所以在我看来,当异步抛出时,“取消信号”会在父作用域上传播,即使 await() 没有被调用。正如上面引述的那样,也就是异常并没有真正保持隐藏,也没有悄无声息地丢弃。我的假设正确吗?

现在,如果我们传递 SupervisorJob(),代码 不会 抛出:

// does not throw
runBlocking {
  async(SupervisorJob()) { throw Exception("Oops") }
}

这似乎是合理的,因为主管的工作是为了吞下失败。

现在是我完全不明白的部分。如果我们传递 Job(),即使 Job() 应该将失败传播到其父范围,代码仍会运行而不会抛出:

// does not throw. Why?
runBlocking {
  async(Job()) { throw Exception("Oops") }
}

所以我的问题是,为什么传递 Job 没有抛出,但传递 Job 或 SupervisorJob 都没有抛出?

从某种意义上说,您遇到的混乱是 Kotlin 协程在变得稳定之前取得早期成功的结果。在他们的实验日,他们缺乏的一件事是结构化并发,并且在该状态下有大量网络 material 写了关于他们的文章(例如你的 从 2017 年开始)。一些 then-valid 成见甚至在人们成熟之后仍然存在,并在最近的帖子中得到延续。

实际情况很清楚——你只需要了解协程层次结构,它是通过 Job 对象进行调解的。它是 launch 还是 async 或任何其他协程构建器并不重要 — 它们的行为都是一致的。

考虑到这一点,让我们看一下您的示例:

runBlocking {
  async { throw Exception("Oops") }
}

通过只写 async,您隐含地使用了 this.async,其中 thisrunBlocking 建立的 CoroutineScope。它包含与 runBlocking 协程关联的 Job 实例。由于这个原因,async协程成为runBlocking的子协程,所以后者在async协程失败时抛出异常。

runBlocking {
  async(SupervisorJob()) { throw Exception("Oops") }
}

此处您提供了一个没有父项的独立作业实例。这打破了协程层次结构并且 runBlocking 不会失败。事实上,runBlocking 甚至不会等待协程完成——添加一个 delay(1000) 来验证这一点。

runBlocking {
  async(Job()) { throw Exception("Oops") }
}

这里没有新的推理 — JobSupervisorJob,没关系。您破坏了协程层次结构并且失败不会传播。

现在让我们探索更多变体:

runBlocking {
    async(Job(coroutineContext[Job])) {
        delay(1000)
        throw Exception("Oops")
    }
}

现在我们创建了一个新的 Job 实例,但我们将其设为 runBlocking 的子实例。这会引发异常。

runBlocking {
    async(Job(coroutineContext[Job])) {
        delay(1000)
        println("Coroutine done")
    }
}

同上,但现在我们不抛出异常,async协程正常完成。它打印 Coroutine done,但随后发生了意想不到的事情:runBlocking 完成,程序永远挂起。为什么?

这可能是此机制中最棘手的部分,但一旦您仔细考虑,它仍然非常有意义。当您创建协程时,它会在内部创建自己的 Job 实例——这种情况总是会发生,无论您是否明确提供作业作为 async 的参数。如果您提供明确的工作,它将成为该 internally-created 工作的 parent

现在,在第一种情况下,您没有提供明确的工作,父工作是由 runBlocking 在内部创建的。它会在 runBlocking 协程完成时自动完成。但是完成不会像取消那样传播到父级 — 你不希望一切都因为一个子协程正常完成而停止。

因此,当您创建自己的 Job 实例并将其作为 async 协程的父级提供时,您的工作将无法完成。如果协同程序失败,失败会传播到您的作业,但如果它正常完成,您的作业将永远保持“进行中”的原始状态。

最后,让我们再次引入 SupervisorJob

runBlocking {
    async(SupervisorJob(coroutineContext[Job])) {
        delay(1000)
        throw Exception("Oops")
    }
}

这将永远运行而没有任何输出,因为 SupervisorJob 吞下了异常。