【问题标题】:Explain reason for deadlock in kotlin coroutines解释 kotlin 协程死锁的原因
【发布时间】:2021-10-03 03:23:53
【问题描述】:

在尝试使用 kotlin 协程时,我遇到了一个我没想到会发生死锁的情况。

我将代码简化为以下显示问题的最小代码示例:

@Test
    fun deadlockTest() {
        runBlocking {
            val job = launch {
                runBlocking {
                    println("await cancellation")
                    awaitCancellation()
                }
            }
            println("launched job")
            delay(100)
            println("waited a bit")
            job.cancelAndJoin()
            println("canceled and joined")
        }
        assertTrue(true)
    }

结果是

launched job
await cancellation
waited a bit

它永远不会超出job.cancelAndJoin,好像有一些死锁一样。

如果我将代码稍微更改为以下内容:

@Test
    fun fixedDeadlockTest() {
        runBlocking {
            val job = launch {
                withContext(Dispatchers.Default) { // <-- this is the only difference
                    println("awaiting cancellation")
                    awaitCancellation()
                }
            }
            println("launched job")
            delay(100)
            println("waited a bit")
            job.cancelAndJoin()
            println("canceled and joined")
        }
        assertTrue(true)
    }

一切正常,所有行都被打印出来,测试完成。

问题是:为什么这段代码会导致死锁,将一个 runBlocking 放在另一个 runBlocking 的启动中是不是一种不好的做法? (即在您的代码中永远不要使用 runBlocking,直到您从非协程范围内实际启动协程?)

我使用了以下版本:

  • kotlin 多平台 1.4.32
  • kotlinx-coroutines-core 版本 1.5.0-native-mt

【问题讨论】:

    标签: kotlin kotlin-coroutines kotlin-multiplatform


    【解决方案1】:

    协程使用所谓的结构化并发来支持取消、异常处理等。它们被结构化为作业树,因此通常当您创建新的协程时,它们会成为当前协程的子级。父母和孩子之间有特定的责任,例如取消父级会取消其所有子级。

    但是,有一些方法可以启动未附加到当前协程的协程。发生这种情况,例如如果您提供特定的CoroutineScope 或者如果您使用runBlocking()。请注意,与launch()async() 不同,runBlocking() 不需要从协程运行。它的设计主要是为了桥接非协程和协程代码,因此它从“根”开始创建协程——它们与其他协程分离。

    由于上述原因,您的示例中的cancelAndJoin() 取消了在launch() 内部运行的协程,但它不会取消在runBlocking() 内部运行的协程。 “awaitCancellation”协程与“launch”协程分离,因此它忽略了它的取消。

    以下是我的原始答案。我对这个僵局的主要原因是错误的,但我所说的仍然大部分是正确的,它补充了上面的答案,所以我保持原样。我错的原因是我忘记了runBlocking()在内部使用了一个线程局部变量来存储它的事件循环。这意味着在另一个runBlocking() 中运行的runBlocking() 实际上共享相同的事件循环/调度程序,因此通过在awaitCancellation() 暂停它可以从delay() 恢复。不过,我认为不鼓励这样做。

    原答案:

    发生死锁是因为外部runBlocking() 启动了一个单线程协程调度程序以在其中启动协程,而内部runBlocking() 阻塞了这个唯一的线程。

    你是对的,runBlocking() 主要用于桥接非协程和协程代码。没有硬性规定禁止在协程内部使用runBlocking(),但通常我们应该避免在协程内部阻塞,而是应该暂停。 runBlocking() 阻止,因此不鼓励这样做,可能会导致上述后果。

    【讨论】:

    • 我在这里仍然有点挣扎,如果内部runBlocking() 阻塞线程,为什么我仍然能够打印"waited a bit" 文本,但取消该作业会导致死锁?当我尝试打印文本"waited a bit" 时线程没有被阻塞
    • 哦,好点。我必须承认我对这些结果感到很困惑。查看我的更新答案:-)
    • 感谢您的额外澄清,它确实有很大帮助,足以接受答案。还有一个最后的问题。如果内部runBlocking创建自己的协程上下文,与外部runBlocking上下文完全分开(它只共享它的事件循环/调度程序/线程),而我只cancelAndJoin()启动创建的子协程,有什么理由不能够完全执行这个cancelAndJoin()
    • 即使在极少数情况下runBlocking() 并没有真正完全阻塞线程,它仍然会阻塞调用它的人的执行。内部 runBlocking() 内部的 Corotuine 没有完成,所以 runBlocking() 本身没有完成。 “启动”协程永远在runBlocking() 等待。您可以通过将launch() 的主体放入try...finally 并在finally 中添加一些日志来确认这一点 - 您将看到launch() 中的协程永远不会超过runBlocking() 点。
    • 因为协程取消是合作的,它们不会被强制执行,有时你取消了一些东西,但它仍然没有完成。似乎runBlocking() 不合作,它不关心调用协程被取消的事实(这是有道理的,因为再次 - 它不打算从协程中使用)。我知道这一切听起来都过于复杂,但我认为这确实是在协程内部使用runBlocking() 的结果。这可能会导致一些非常奇怪的行为。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-27
    • 1970-01-01
    相关资源
    最近更新 更多