发生场景
当用 Kotlin 编写第一个协程时,可以使用 runBlocking 函数来完成,如下所示。
fun main() = runBlocking {
delay(50)
println("This is a coroutine!")
}
这是启动新协程的超级简单方法。从普通的旧函数调用像 delay 这样的挂起函数通常会给你一个红色编译错误。但是用 runBlocking
包裹它,突然间你不仅可以自由地调用挂起函数,还可以使用像 launch
和 async
一样使用协程.
像这样在主函数中使用runBlocking
是没有问题的。但是,您最后一次实际将代码添加到实际生产系统的主入口点是什么时候?使用协程时,更有可能深入了解应用程序的代码,这就是事情变得更加复杂的地方。
runBlocking
函数在等待协程完成时会阻塞当前线程。从错误的线程调用 runBlocking
,您最终可能会停止它应该等待的协程的进度。
重现问题不需要很多行代码,展示一个示例。这段代码应该只是打印“Hello!”十次然后退出。但如果你尝试运行它,它实际上会永远挂起,不会打印任何内容。
suspend fun main() = withContext(Dispatchers.Default) {
repeat(times = 10) {
launch { doSomething() }
}
}
fun doSomething() = runBlocking {
launch(Dispatchers.Default) {
println("Hello!")
}
}
默认调度程序使用共享线程池来执行协程。它建立在这样的假设之上:协程在等待外部事件时将挂起,并且仅在实际工作时才使用线程。分配给默认调度程序的线程数是根据 CPU 核心数自动确定的,以便有适当的线程数让一个协程始终主动使用每个核心。
尝试在示例代码中上下调整 times = 10 值以启动不同数量的协程。 两个协程似乎足以导致问.
当协程数量低于默认调度程序可用的线程数量时,程序正常完成。一旦启动的协程数量多于可用线程,程序就会永远挂起并且不产生任何输出。
为什么会发生这种情况?
这是因为在此示例中对 runBlocking 的调用会阻塞来自用于运行协程的同一调度程序的线程。主函数的 withContext 块和新协程的 launch 都指定 Dispatchers.Default 作为其上下文的一部分。
在协程内调用 runBlocking 意味着它会阻塞协程当前正在执行的任何线程,并且协程调度程序线程并不是设计来被阻塞的!现在,在所有协程完成之前, runBlocking 调用不会将线程释放回调度程序,但在默认调度程序有可用线程之前,协程无法运行。这听起来确实像是一个等待发生的僵局。
这似乎是一个很容易避免的错误。只是不要从默认调度程序进行阻塞调用!更好的是,遵循文档中的建议,根本不要从协程内部调用 runBlocking 。
问题是,一旦您在代码中调用了 runBlocking ,它们就很容易被遗忘。与挂起函数不同, runBlocking 不会通过图标来宣传其存在,也不会在错误的位置使用它时导致构建失败。在包含许多模块和许多贡献者的庞大代码库中,您可能不知道函数最终会调用 runBlocking ,也不知道它可能使用什么调度程序来启动其协程。
我第一次在生产代码中使用协程时,将它们集成到现有的服务器端应用程序中。最终目标是通过切换到非阻塞 HTTP 客户端来请求其他服务来减少内存使用。
将庞大的代码库一次性转换为协程将是一项艰巨且危险的任务。相反,我们想要一种增量方法,进行一系列小的更改,通过将工作代码发送给用户来不断检查我们的工作。
Kotlin 的内置 runBlocking 函数似乎是完成这项工作的完美工具。它的文档称,它的设计目的是“将常规阻塞代码桥接到以挂起风格编写的库(bridge regular blocking code to libraries that are written in suspending style.)”。我们能够立即向 HTTP 客户端引入协程(我们使用了 Ktor),然后使用 runBlocking 调用它,直到我们准备好逐步迁移调用代码以使用协程。
将其称为自下而上引入协程的方法,结果证明这是一个很大的错误。
这个计划很简单。假设函数 a 调用 b ,后者又调用 c 。我们希望在函数 c 中发出异步 HTTP 请求。
fun a() = b()
fun b() = c()
fun c() = runBlocking { /** coroutines! **/ }
首先,只需让 c 使用 runBlocking 来调用新的 HTTP 客户端。稍后将 c 转换为挂起函数,将 runBlocking 调用移至 b 调用 c 的位置。然后将 b 转换为挂起函数,将 runBlocking 调用一直移动到 a 。必须像这样慢慢地做,因为 c 实际上可能是一个共享模块,在多个不同的后端服务中有数十个调用者。
fun a() = runBlocking { b() }
suspend fun b() = c()
suspend fun c() { /** coroutines! */ }
该计划的一个问题是它在许多不同的地方引入了对 runBlocking 的许多调用。每次发出传出 HTTP 请求时,都会有另一个 runBlocking 实例。当将更多的调用代码转换为使用挂起函数时,我们能够启动额外的协程来加速或简化并行任务。
这是一颗定时炸弹。当我们迁移和重构代码时,其中一些对 runBlocking 的调用实际上最终是从其他协程内部调用的。这从来都不是我们故意或有意识地做的事情。但复杂的代码库有很多分支代码路径,有时整个代码部分会被提升并从系统的一个部分转移到另一部分。很容易对现有函数引入看似无辜的调用,而没有意识到它最终会调用 runBlocking 。
没过多久,代码更改的巧合就导致了我之前展示的示例中的确切情况 - 只是有更多级别的函数调用,使得问题无法检测到。
没有任何构建失败,也没有人在测试环境中发现任何问题。在流量较低的情况下,阻塞一些额外的线程没有问题。
但在生产中,没过多久就陷入了彻底的僵局。当流量增加并且服务器同时处理多个请求时就会发生这种情况。默认的协程调度程序只会停止,使服务器陷入困境,直到应用程序重新启动。新的协程根本不会运行,现有的协程将永远暂停,永远不会恢复执行。
如何解决
我们花了几个小时的时间来绞尽脑汁,经过大量的试验和错误才弄清楚到底发生了什么。一旦我们最终理解了这个问题,我们几乎完全禁止在系统中的任何地方使用 runBlocking 。
我从这次经历中学到了很多关于协程的知识——太多了,无法在这里分享。最重要的认识之一是协程超时无法解决此类问题。如果请求由于任何类型的协程死锁而挂起,则引入 withTimeout 块可能根本不会执行任何操作。这是因为超时通过触发协程提前恢复来完成其工作。没有可用线程的调度程序根本无法恢复协程,即使超时或取消也是如此。
suspend fun main() = withContext(Dispatchers.Default) {
withTimeout(100) {
repeat(times = 10) {
launch {
doSomething()
}
}
}
}
fun doSomething() = runBlocking {
launch(Dispatchers.Default) {
println("Hello!")
}
}
在例子中,整个程序仍然会因为时间太长而被杀死。但这会在协程应遵守的 100 毫秒超时之后很久发生,并且在真正的应用程序中对我们没有帮助。协程超时本身没有任何作用,这意味着我们早期尝试防御该问题的一些尝试完全无效。
因此,如果使用 runBlocking 不安全,那么您应该如何在主函数之外启动新的协程或向现有应用程序添加挂起函数调用?
自定义协程作用域是一种解决方案。为了避免资源泄漏和处理错误,必须等待协程完成。但这不必通过阻塞线程来完成。如果有一个具有生命周期的现有组件,可以将协程链接到该组件。 Android 中为生命周期感知组件提供的协程作用域就是一个很好的例子。
解决增量迁移的另一种方法是避免自下而上的方法,而采用自上而下的方法。不要先添加异步 HTTP 调用,然后更新调用代码,而是首先确定调用堆栈中可以调用 runBlocking 的最高可能位置。从这里开始,当您需要调用尚未迁移的阻塞代码时,请使用 IO 调度程序。这不是一个完美的解决方案,但比冒着僵局的风险更安全。
如果幸运的话,您可能可以选择完全避免这个问题。例如,Spring WebFlux 现在允许您将 REST 控制器方法直接编写为挂起函数,因此您可以从一开始就访问协程。
弄清楚如何启动协程或调用挂起函数并不总是那么容易, runBlocking 是一个诱人的捷径,但最终可能会适得其反。您在自己的应用有哪些解决方案或策略?