为什么我放弃使用 Kotlin 中的协程?

实不相瞒,我对 Kotlin 这门编程语言非常喜欢,尽管它有一些缺点和奇怪的设计选择。我曾经参与过一个使用 Kotlin、Kotlin 协程(coroutine, 下同)和基于协程的服务器框架 KTOR 的中型项目。这个技术组合提供了很多优点,但是我也发现,与常规的 Spring Boot 相比,它们很难使用。
声明:我无意抨击相关技术,我的目的仅是分享我的使用体验,并解释为什么我以后不再考虑使用。

调试

请看下面一段代码。

suspend fun retrieveData(): SomeData {

    val request = createRequest()

    val response = remoteCall(request)

    return postProcess(response)

}


private suspend fun remoteCall(request: Request): Response { // do suspending REST call }

假设我们要调试 retrieveData 函数,可以在第一行中放置一个断点。然后启动调试器(我使用的是 IntelliJ),它在断点处停止。现在我们执行一个 Step Over(跳过调用 createRequest),这也正常。但是如果再次 Step Over,程序就会直接运行,调用 remoteCall() 之后不会停止。
为什么会这样?JVM 调试器被绑定到一个 Thread 对象上。当然,这是一个非常合理的选择。然而,当引入协程之后,一个线程不再做一件事。仔细一看:remoteCall(request) 调用的是一个 suspend 函数,虽然我们在调用它的时候并没有在语法中看到它。那么会发生什么?我们执行调试器 “step over “,调试器运行 remoteCall 的代码并等待。
这就是难点所在:当前线程(我们的调试器被绑定到该线程)只是我们的coroutine 的执行者。当我们调用 suspend 函数时,会发生的情况是,在某个时刻,suspend 函数会 yield。这意味着另外一个 Thread 将继续执行我们的方法。我们有效地欺骗了调试器。
我发现的唯一的解决方法是在我想执行的行上放置一个断点,而不是使用Step Over。不用说,这是个大麻烦。而且很显然,这不仅仅是我一个人的问题。
此外,在一般的调试中,很难确定一个单一的 coroutine 当前在做什么,因为它在线程之间跳跃。当然,coroutine 是有名字的,你可以在日志中不仅打印线程,还可以打印 coroutine 的名字,但根据我的经验,调试基于 coroutine 的代码所需的心智负担,要比基于线程的代码高很多。

REST 调用中绑定 context 数据
在微服务上开发,一个常见的设计模式是,接收一个某种形式认证的 REST 调用,并将相同的认证传递给其他微服务的所有内部调用。在最简单的情况下,我们至少要保留调用者的用户名。
然而,如果这些对其他微服务的调用在我们调用栈中嵌套了 10 层深度怎么办?我们当然不希望在每个函数中都传递一个认证对象作为参数。我们需要某种形式的 “context”,这种 context 是隐性存在的。

在传统的基于线程的框架中,如 Spring,解决这个问题的方法是使用 ThreadLocal
对象。这使得我们可以将任何一种数据绑定到当前线程。只要一个线程对应一个 REST 调用(你应该始终以这个为目标),这正是我们需要的。这个模式的很好的例子是 Spring 的 SecurityContextHolder。
对于 coroutine,情况就不同了。一个 ThreadLocal 不再对应一个协程,因为你的工作负载会从一个线程跳到另一个线程;不再是一个线程在其整个生命周期内伴随一个请求。在 Kotlin coroutine 中,有 CoroutineContext。本质上,它不过是一个 HashMap,与 coroutine 一起携带(无论它运行在哪个线程上)。它有一个可怕的过度设计的 API,使用起来很麻烦,但这不是这里的主要问题。
真正的问题是,coroutine 不会自动继承上下文。
例如:

suspend fun sum(): Int {

    val jobs = mutableListOf<Deferred<Int>>()

    for(child in children){

        jobs += async {  // we lose our context here!

            child.evaluate() 

        }

    }

    return jobs.awaitAll().sum()

}

每当你调用一个 coroutine builder,如 async、runBlocking 或 launch,你将(默认情况下)失去你当前的 coroutine 上下文。你可以通过将上下文显式地传递到 builder 方法中来避免这种情况,但是上帝保佑你不要忘记这样做(编译器不会管这些!)。
一个子 coroutine 可以从一个空的上下文开始,如果有一个上下文元素的请求进来,但没有找到任何东西,可以向父 coroutine 上下文请求该元素。然而,在 Kotlin 中不会发生这种情况,开发人员需要手动完成,每一次都是如此。
如果你对这个问题的细节感兴趣,我建议你看看这篇博文。
Emulating request scoped objects with Kotlin Coroutines

synchronized 不再如你想的那样工作

在 Java 中处理锁或 synchronized
同步块时,我考虑的语义通常是 “当我在这个块中执行时,其他调用不能进入”。当然“其他调用”意味着存在某种身份,在这里就是线程,这应该在你的脑海中升起一个大红色的警告信号。
看看下面的例子。

val lock = ReentrantLock()


suspend fun doWithLock(){ lock.withLock { callSuspendingFunction() } }

这个调用很危险,即使 callSuspendingFunction() 没有做任何危险的操作,代码也不会像你想象的那样工作。

  • 进入同步锁
  • 调用 suspend 功能
  • 协程 yield,当前线程仍然持有锁。
  • 另一个线程继续我们的 coroutine
  • 还是同一个协程,但我们不再是锁的 owner 了!

潜在的冲突、死锁或其他不安全的情况数量惊人。你可能会说,我们只是需要设计我们的代码来处理这个问题。我同意,然而我们谈论的是 JVM。那里有一个庞大的 Java 库生态。而它们并没有做好处理这些情况的准备。
这里的结果是:当你开始使用 coroutine 的时候,你就放弃了使用很多 Java 库的可能性,因为它们目前只能工作在基于线程的环境。
单机吞吐量与水平扩展
对于服务器端来说,coroutine 的一大优势是,一个线程可以处理更多的请求;当一个请求等待数据库响应时,同一个线程可以愉快地服务另一个请求。特别是对于 I/O 密集型任务,这可以提高吞吐量。
然而,正如这篇博文所希望向您展示的那样,在许多层面上,使用 coroutine 都有一个非零成本的开销。
由此产生的问题是:这个收益是否值得这个成本?而在我看来,答案是否定的。在云和微服务环境中,有一些现成的扩展机制,无论是 Google AppEngine、AWS Beanstalk 还是某种形式的 Kubernetes。如果当前负载增加,这些技术将简单地按需生成你的微服务的新实例。因此,考虑到引入 coroutine 带来的额外成本,单一实例所能处理的吞吐量就不那么重要了。这就降低了我们使用 coroutine 所获得的价值。
Coroutine 有其存在的价值
话说回来,Coroutine 还是有其使用场景。当开发只有一个 UI 线程的客户端 UI 时,coroutine 可以帮助改善你的代码结构,同时符合 UI 框架的要求。听说这个在安卓系统上很好用。Coroutine 是一个有趣的主题,然而对于服务器端开发来说,我觉得协程还差点意思。JVM 开发团队目前正在开发 Fiber,本质上也是 coroutine,但他们的目标是与 JVM 基础库更好共存。这将是有趣的,看它将来如何发展,以及 Jetbrains 对 Kotlin coroutine 对此会有什么反应。在最好的情况下,Kotlin coroutine 将来只是简单映射到 Fiber 上,而调试器也能足够聪明来正确处理它们。

英文原文:

https://dev.to/martinhaeusler/why-i-stopped-using-coroutines-in-kotlin-kg0

参考阅读:

本文由高可用架构翻译,技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。


高可用架构


改变互联网的构建方式





长按二维码 关注「高可用架构」公众号