Kotlin协程(Kotlin Coroutines) VS Java虚拟线程(Java Virtual Threads)本质区别
在这篇文章中,我们将试错看看两种不同的协程实现,也称为 JVM 中的 Continuations。这些是 Java 虚拟线程,它们是 Project Loom 和 Kotlin Coroutines 的一部分,以 DSLs 的形式在JVM。由于本文的性质,它会受到频繁的评论。支持代码位于 GitHub。
近年来,如果您围绕 JVM 工作,您一定注意到 JVM 世界中出现了一位新玩家。输入 Kotlin。长话短说,Kotlin 始于 JetBrains 研发部门,以附近的 Kotlin 岛屿 命名圣。圣彼得堡。
到目前为止,这是一个简短的 Kotlin 故事。但为了了解今年(2022 年)我们在哪里以及我们已经走了多远,我们需要深入记忆,了解 Java 是如何发展的,以及其他语言是何时以及如何从 Java 蓬勃发展。通过这种方式,我们可以获得更好的画面并得出更明智的结论。
在我们继续之前,我们必须向那些负责的人表示敬意,根据扩展文档,他们开始了所有这场 JVM 革命。 James Gosling 被许多人认为是 Java 和 JVM 的发明者。没有他,后来在 JVM 之上发明的一切都将是不可能的。同样,Martin Odersky 几乎是 Scala 的发明者。最后,对于 Kotlin,我们只能说 JetBrains 团队负责进一步开发的团队负责人是 Dmitry Jemerov。
上表是一个简短的草图,整合了三种语言历史上的主要亮点,它们共享一个共同的生态系统,即 Java 虚拟机。 Java 存在于 1995 之前,Scala 存在于 2001 年, 和 Kotlin 存在于 2010年。 Java 是最古老的 JVM 语言,最新的是 Kotlin。 Java 至少比 Kotlin 早 15 年开始,而 Scala 比 Kotlin 早 9 年。
我找不到确切的位置,但检查项目 Loom 的提交我可以看到第一次提交发生在 2007。这告诉我们,Loom 的想法很可能是在今年左右开始的。 Loom 是一个类似于 Kotlin 中的 coroutines 的项目,专注于最大限度地利用 系统线程将它们分成独立的独立进程。 Loom 将这些进程称为虚拟线程。 Kotlin 于 2018 发布了一个实验版本,支持使用 coroutines 实现相同的想法。 Java 中的 Project Loom 计划于 2022 发布。
关于 Kotlin,很难确定创建新语言的确切动机是什么。我能找到的最好的是“需要添加新功能”。在本文中,我想与您分享我对 Kotlin 协程和 Java 虚拟线程的发现,然后揭示我得出的一个很好的结论。
我本人从未加入过 Java Loom 团队,也没有加入过 Kotlin Coroutines 团队。我是根据源码资料、国际会议视频、论文做的这篇文章。
但是在我们继续之前,协程是很久以前发明的,但是,如果你不知道,这里有一个很大的启示。它们确实很老,而且实际上比1958还要老。这只是 Donald Knuth 和Melvin Conway。在这里,人们已经创建了自己的实现,例如 这个 codecop。
多年来,软件工程发生了变化,毫无疑问,每个人都在努力让一切变得更好。我们希望更容易创建软件并使我们的代码工作。为此,我们创建了语法和语义,使我们能够以越来越简单的方式进行开发。当 Kotlin 出现时,我几乎立刻就被它在许多层面上优于 Java 的想法吓倒了。这就是 Kotlin 社区主要提倡的内容。几个月后,我意识到一些事情让我对它感到兴奋。随着时间的推移,我越来越意识到 Kotlin 只是另一种语言,而真正让人兴奋的是它不同。 新事物打破常规,为创造力腾出空间。有一件事我没有改变主意,那就是 Kotlin,如果使用正确的方式,可以生成比 Java 更漂亮的代码。但美是我不想在这篇文章中讨论的东西。本文真正讨论的是性能。我们不会单独讨论 Kotlin 和 Java。我们将讨论利用系统线程和一个非常古老的概念称为协程的两个实现。在 Java 中, 这在 project Loom 中被称为 虚拟线程,而在 Kotlin 中则被称为……嗯…… 协程。当我们在两边的代码中进行时,我们将进行进站并相互比较代码并查看差异。但首先,让我们进入一些理论来准确理解我们在说什么,讨论为什么这不是一场革命,以及为什么花了这么多时间让语言开发接口和语义以能够使用系统线程更有效。
如果我们采用 coroutines 的字面意思,纯粹在语义层面上,我们会得到 Co 和 Routines。因此,例程只是运行的一些指令。 协程是运行的东西。运行在这种情况下,字面意思是暂停原来的例程,并允许一个完全不同的例程开始然后恢复原来的程序。
为了说明这一点,我回到了 1985 年,在互联网的帮助下,我用 C++ 创建了一个小程序,其中显示了一些关于使用 Epoxy 创建表的说明(如果您想制作真正的环氧树脂桌,请不要遵循这些说明,用环氧树脂制作桌需要安全装备和保护,因此请先了解情况)。为什么选择 C++?那么,为什么不呢?此外,我认为从中立点开始非常重要。如果我们掌握了这些基本知识,那么我们就成功了!所以这是主程序:
所以,我们有一堆案例(准确地说是 10 个),目前,这段代码似乎没有显示太多。目前我们确实有一些东西应该引起您的注意,那就是 pthread_self()。另一件事是processes(1,11),包含在for-loop的验证检查中.让我们深入研究一下这个方法:
所以,在这里,我们有一个奇怪的 switch-case
。在 0 的情况下,我们将 1 分配给状态。 这会导致主线程在返回之前分裂。从技术上讲,它并没有分成 2 个,但它确实在运行时挂起,以允许另一个启动。这意味着当例程命中 return i
时,它将暂停自身和 线程
将首先运行主 for-loop 中的任何内容,然后才会完成运行 case 中的内容1。在 C++ 中观察,可能看起来非常违反直觉,但是如果我们运行代码,我们会看到这种现象正在发生,我们也可以看到,虽然主线程已经暂停和恢复了不同的例程,它们都挂在同一线程上:
所以这本质上就是协程。在这个 C++ 示例中,一切都是异步运行的。还有很多方法可以实现协程。 Loom 和 Kotlin Coroutines项目在2000年代后半段被视为金矿,是探索这一点并实施 协程以一种异步方式。两种语言都在发展,并且都在各自的实现上运行着实验性的特性。然而,Java 仍处于EAB(Early Access Build)阶段,尽管它在下半年开始更早开发十年。
为了讨论 Java 虚拟线程,我们必须熟悉一些基本概念:Fibers、Continuations,当然还有虚拟线程。
- Fibers:很清楚,fiber 只是指虚拟线程的另一种方式。它没有什么神奇之处
- 虚拟线程:以这种方式命名它们是为了更好地参考它们的实际行为。对于开发人员来说,线程(平台或系统线程)和虚拟线程(由独立执行的载体线程运行的东西,允许更多进程跑)
- Carrier Thread:这个术语最初似乎被流行使用并且正在发生,看起来只是指代平台线程或系统的另一种方式线。 然而,它确实有比这更重要的作用。 载体线程是一个虚拟线程执行的地方。当我们查看代码时,这变得更加明显,我们将在下面进一步介绍。
- Continuation:Fibers 和 Virtual Threads 是
continuations
。延续只是允许我们在产生结果后继续的东西。这是所有虚拟线程的最低级别以及它们的工作方式。我们之前已经看到协程是如何工作的。这正是延续的工作原理。事实上,协程只是延续的别称。在本文开头的示例代码中,会有两个虚拟线程。一个在执行开始时,另一个在我们以文本开始时:“Ending step”。
在这一点上,从上面的内容来看,我认为您已经非常清楚地了解了整个 continuation 和 coroutines 是关于什么的。同样的事情对吗?理论似乎相同,但实现方式不同。在这个阶段,让我们看一下虚拟线程实现的一些亮点(至少在我看来):
在这一点上,什么都没有发生。我们收到一个普通的可运行文件,然后进入该方法。我们已经在 JDK19
内部执行了,这个代码只是 JDK19
代码。在那里,Loom 会创建一个 VirtualThread,并将我们的任务作为参数并启动它。当我们以这种方式启动虚拟线程时,我们将前两个参数设为 null,第三个为 0,第四个是我们的任务。让我们先深入了解 VirtualThread,看看我们是否看到任何与我们所见并了解的关于延续是什么的迹象非常相似的迹象:
在这种情况下,这意味着我们创建了一个没有调度程序、没有名称和 0 特征的虚拟线程。当然,这一切意味着什么?也许这里我们可以跳过几个步骤,但是线程初始化会给它分配一个id,并且没有特征 .由于我们没有给它命名,我们的 线程 将无法通过名称来识别。至少默认情况下没有。在启动我们的线程之前,我们会获得一个调度程序。在这一部分中,我们遇到了一些代码,这些代码确保我们从系统线程获得适当的调度程序或从虚拟线程。我们似乎有两种调度器。一个用于虚拟线程,另一个用于系统线程。这些看起来实际上可以重复使用。新的 scheduler 仅在构造函数中没有给出 scheduler 的情况下才被分配,并且它是在基础上分配的父线程,也就是当前线程。一旦我们有了调度器,我们最终可以使用当前的 VirtualThread 创建一个延续 (VThreadContinuation),然后我们通过我们给出的可运行任务。最后,我们为 runContinuation
属性分配 runContinuation
lambda 以便以后能够执行它。
所以现在我们用Platform Thread的scheduler创建了一个Virtual Thread,没有名字,只有一个id,和 0 个特征,我们为其分配了一个 continuation,并为 runContinuation
属性分配了 runContinuation
拉姆达。我们刚刚创建的调度器是一个ForkJoinPool
,默认情况下创建一个parallelisation
级别相当于机器提供的CPU的
个数和一个 最多 256 个工作人员池
。
从这里开始,描述发生的事情变得相当复杂,因为这涉及到相当多的本机代码调用,我不太了解,与本文无关。不过,与本文相关的是虚拟线程在其生命周期 中所经历的状态。虚拟线程可能会经历以下状态(它们都是 int 值):
- New 0:线程开始时的状态。
- Started 1:虚拟线程已经启动。
- Runnable 2:线程unmounted,该状态可以分配给状态Yielding的线程。此时线程未运行。
- Running 3:线程正在running并且它是mounted
- Parking 4:开始禁用线程以进行调度,除非线程具有permit。
- Parked 5:线程在Parking 状态和yielding之后Parked。 Parked 意味着,换句话说,等待被安排。
- Pinned 6:线程被同步进程延迟或执行一些虚拟线程不支持时被pinned 操作,就像一些 IO 操作一样。其他IO操作以非阻塞方式执行。更准确地说,固定是一种不允许虚拟线程在等待尚不可用的对象时卸载的方法。
- Yielding 7:线程unmounted以yielding它对处理器的控制,然后它得到mounted 再次允许时再次安装。换句话说,它只是返回 carrier Thread。 这也是上下文切换的一种形式。使用 (0) 睡眠将立即触发此状态。
- Terminated 99:虚拟线程的Final状态。它不会被再次使用。
- Suspended 256:虚拟线程在卸载后可以suspended。
- Runnable Suspended:线程可以是runnable和suspended。
- Parked Suspended:线程可以parked和suspended。
当一个虚拟线程需要休眠时,它会执行一个delay
操作。这需要一种叫做Yielding的东西。通过Yielding,我们将当前虚拟线程从其当前系统线程中卸载,并将其控制权交给另一个虚拟线程。
如果我们正在执行阻塞操作并且线程正在固定,则一个系统线程将被阻塞,但其他系统线程不会。这意味着,例如。如果你有 12 个核心,11 个将用于管理虚拟线程,但只有 1 个会被阻塞等待。使用本机代码中的某些阻塞操作时会发生阻塞操作,例如使用 synchronized
和 Object.wait()
导致线程被固定
休眠是虚拟线程暂停执行的一种方式。它与在 同步
代码上运行的另一个虚拟线程具有不同的行为。对于这种组合,我们需要另一个概念,称为 VirtualThread.java
中的停车:
停放发生在我们使用某种预定进程时,例如队列或某些 IO 操作。如果它们无法运行并且必须像提到的 synchronised
测试用例那样阻塞本机进程,它们会将状态从 PARKING 更改为 PINNED :
我提供了一个带有测试用例的示例 saveWordsParking:
Parked,然而,这是一个很奇怪的状态,我无法重现它。这与这个变量 notifyJvmtiEvents 有关,它显然对 mounting 和 有作用使用本机方法卸载。根据文献,Parked 是一种状态,用于标识调度程序中没有做任何事情并等待轮到它Unparked 并被Scheduler占用的线程。这应该是 JVM 可以管理的解锁操作的情况,即本机独立。
正如我们之前所见,协程与虚拟线程非常相似。从理论上讲,两者实际上并没有太大的区别。但是,它们的实现确实不同。但在像之前使用虚拟线程那样深入研究它们之前,让我们先熟悉一下 Kotlin 世界的一些术语:
- Suspend:指创建协程的行为。称为挂起的方法仅在协程上下文中运行。该上下文可能在执行期间切换到另一个上下文。
- delay:延迟,有点像睡眠,但只要我们告诉它,它就会暂停或暂停正在运行的协程
- coroutine:就像虚拟线程一样,协程在平台线程上运行。它还可以自动切换内容。
Kotlin,您现在可能已经知道,它仍然只是一个简单的 DSL,它启用了一些 新语法,其目标是使程序员更容易构建他们的应用程序。当第一次解释 code 和 bytecode 时,这会带来一些混乱。因此,在 Kotlin 的情况下,不像在 Java 的情况下使用我们最喜欢的 IDE 那样单击 startVirtualThread 之类的东西,我们需要想办法输入suspend代码。我们首先看一个这样的例子:
根据您的 IDE,您会发现执行以下操作的不同方法。在 Intellij 中,幸运的是,有一个工具可以让我们查看编译后的 字节码:
在这里,我们可以点击按钮反编译:
我们终于得到了这样的代码:
很乱吧?嗯,这就是我们目前在 2022 年将 Kotlin 代码反编译为 Java 代码的方式。这实际上不是 Java 代码本身,但它为我们提供了一个窗口,让我们了解事物是如何真正转换到 JVM 中的。如果我们想跳过这些步骤并准确查看代码是如何编译的,那么您可能需要转到命令行。只是出于好奇,如果您确实转到命令行并列出 target 目录中的文件,您会看到比编译后的 Java 中通常看到的文件多得多的文件强>类:
请注意,我们有 相当 一个 少数 类和一些具有实际 方法 名称的类。不是很好看,但 Kotlin 这样做是因为 Kotlin 是 Java 之上的一个层。换句话说,它是一种DSL(Domain Service Language)。这意味着我们不会像从 Java 代码中那样获得 bytecode 类。最后,您不需要 Java 代码,因为 字节码 是在编译时在后台生成的。另一个奇怪的事实是,当您通过 default 使用 Intellij 时,您并没有真正看到所有这些文件。你看到的唯一的东西是他们以解释方式的 Kotlin 对应物。
无论如何,让我们回到反编译的代码。您是否注意到我们正在使用Continuation?我们之前在 Java 中看到过正确吗?让我们像在 Java 中一样深入研究它:
我们看到 Continuation
是一个接口,它有一个 CoroutineContext
和一个 resumeWith
函数。
这实际上是我们在评估协程方面似乎能够做到的,因为整个库都是用 Kotlin 源代码开发的,这使得很难看到如何将其转换为 Java。我想我想说的是,目前看来 Kotlin 协程与 Java 虚拟线程并没有太大的不同。但是,另一方面,仅仅因为源代码是用 Kotlin 编写的,并不意味着我们无法阅读它。所以让我们试试吧。
SafeContinuation 是 Continuation 的实现。 expect 是一个关键字,在 Kotlin 中的使用方式与 native 相同。换句话说,在 Kotlin 中,这只是意味着实现是平台相关的,当然,访问它也不容易。在coroutines 代码的后面,很难理解任何东西。而在 Java 中,我可以通过整个 JDK 进行调试,而在 Kotlin 中,这变得相当困难,我假设这与suspend 被解释为 Intellij 中的关键字,而不是普通代码。因此,我们并不能真正轻松地调试 Continuation 之类的东西。 但坚持住! 当然可以!。使用 Kotlin,就像使用 Java 一样,我们有时需要猜测代码将落入何处。因此,我们通过在 DispatchedTask.kt 中打开 run 方法来进行疯狂猜测:
如果您运行我的 Kotlin 示例,您会看到代码位于 `here.这个分派的任务是允许我们的协程运行的。
在 Kotlin 中,我们可以通过多种方式启动协程。我们可以在函数中使用 suspend 并获取一些东西来调用它,我们可以使用 withContext
启动一个协程上下文, 我们可以使用 runBlocking
以及许多其他方式来实现它们。在我们的测试示例中,我们使用的是这样的:
Intellij 可以帮助您找出协程从哪里开始。在这个例子中,我们实际上创建了 3 个协程:
suspend
使用调用者的上下文创建协程GlobalScope.launch
,将在全局上下文中启动协程(强烈建议不要这样做)。始终建议改用 coroutineScope。withContext(IO)
将在 IO 上下文中创建协程。
关键字 suspend
, 创建一个协程。我们在示例中看不到它。它与父函数相关联:suspend fun generalTest()
。为此,请在代码中查找此示例。然后我们开始一个新的GlobalScope
。 GlobalScope
将启动一个具有全局上下文的协程。当然,在它下面,我们可以用 withContext(IO)
启动另一个协程。
深入了解 Tasks.kt
中的协程实现,向我们展示了协程具有 mode 和状态。
协程可以有这些模式:
- TASK_NON_BLOCKIN 0: 任务受 CPU 限制,不会阻塞。
- TASK_PROBABLY_BLOCKING: 1:任务可能会阻塞。这就像一个提示,就像我们在虚拟线程中看到的那样,这将使调度程序知道可能需要一个系统线程。
CoroutineScheduler.kt 中的 Kotlin 协程工作者 可用的状态是:
- CPU_ACQUIRED:它获取一个 CPU 令牌并尝试以非阻塞方式执行任务。
- BLOCKING:任务处于阻塞状态,唯一允许这样做的模式是TASK_PROBABLY_BLOCKING。
- PARKING:它会暂停一个线程,就像我们之前看到的一样,当线程无法临时执行时会发生停车。
- 休眠:它一直处于休眠状态,直到它可以执行另一个任务。这与 PARKING 不同,因为 PARKING 意味着工作人员已经负责一项任务。
- TERMINATED:这是工人的最后状态
最后,协程在DispatchedCoroutine.kt中有这些状态:
- RESUMED 2:只有在协程仍然 UNDECIDED 时才可以设置。协程正在执行
- SUSPENDED 1:只有在协程仍然 UNDECIDED 时才可以设置。协程被挂起。
- UNDECIDED 0:协程的初始状态(在源代码中也描述为_decision)
这些是我们启动协程时熟悉的状态。在设计期间,我们并不真正关心 Worker 是如何工作的,我们也绝对不关心 modes。但是,了解这些关于协程的基本 概念或至少知道它们的存在会非常有帮助。
回顾一下,协程可以从 suspend 函数开始,withContext
,或启动。 withContext
和 launch
做不能在协程上下文之外工作。如果您需要创建这样的上下文,那么您需要使用类似 runBlocking
或 suspend< /em>
函数。
现在我们已经检查了代码,让我们通过深入研究理论来尝试更理解它。关于 协程 和 java 虚拟线程的理论几乎可以在 Internet 上的任何地方找到,我执行测试的 repo 包含许多指向有关它的信息的链接。也许我们现在需要了解的关于这两种实现的基础知识是:
- 两者都基于 1958 发明的原始协程原理。这确实不是什么新概念
- 两者都基于您可以暂停一个函数运行时以让位于另一个函数 运行时。
- 两者都实现了
suspend
和waiting
的思想使用固定、休眠、和停车等概念的主线程。 - 两者都由 JVM 而非 系统 管理
- 两者都避免创建一个全新的 platform 线程并利用已经运行的线程。它们已在线程池中启动。 用于 Java 虚拟线程的 ForkJoinPool 和 CoroutineScheduler 用于 Kotlin 协程。
- 虽然我们的平台 线程只能与我们的CPU核心一样多,但我们可以启动不同的进程,并具有一定程度的并行化< /strong> 达到我们拥有的核心数,并同时启动我们想要的尽可能多的进程,直到我们的机器可以处理的限制。我们并行执行更多操作的错觉是由于不允许系统线程在可能的情况下阻塞而造成的。
- 从技术上讲,两者都不睡觉。至少他们不会在
blocking
状态下睡觉。在 Java 中,这是通过Thread.sleep
无缝完成的,它通过给线程一个 PARKING 状态并授予它许可。 Parking 是指睡觉,unparking 是指醒来。在 Kotlin 中,延迟确保当前执行安排稍后执行。但是深入研究让我们看到 Parking 和 Unparking 也是实现的一部分。 - 两者都有不同的PINNING方法。在 Java 中,Pinning 是为了将线程紧紧地固定在它的载体线程上。这发生在同步方法中。在 Kotlin 协程中,执行PINNED到单个 CPU 线程。挂起和恢复操作将确保协程将在同一线程上运行直到结束。同样,Kotlin 也有同步方法,当然,它们也使用 PINNING
- 在这两种情况下,线程都是原生线程的薄包装。
为了执行这些测试集,我创建了一个框架,该框架允许我测量具有不同时间和空间复杂性的不同方法的运行时间。这个想法是为不同类型的进程提供足够的变化,并看看在同时部署多个虚拟线程时所有这些都如何发挥作用。对于这些测试,我对测量一个特定虚拟线程执行所需的单独时间不感兴趣。相反,我想衡量整体,看看它是如何发挥作用的。性能测量代码还包含报告代码、文件管理代码和 CSV 文件生成算法,以帮助确定允许在一个时间点部署多少 Java 虚拟线程。让我们看一下接收 lambda 作为参数的方法,包括其他参数,以便执行、执行和测量每个单独测试的持续时间:
所以我在这里创建的只是一种方法,灵感来自我从 Kotlin 学到的一些东西。让我们分别看看它们
testName
只是方法的名称methodName
是一个参数,它让我们知道我们正在测试什么方法。在 Kotlin 中,我们稍后会看到,我们可以轻松地通过反射获取方法名称,而无需太多麻烦。但是,在 Java 中,我仍然必须对方法名称进行硬编码,并将其用作输入参数,作为一种快速双赢的解决方案。timeComplexity
实际上是一个字符串,您可以在其中放置您想要的任何东西用于表示正在测试的方法的 大 O 表示法。这对于查看方法复杂性是否会在性能中发挥任何作用很重要spaceComplexity
在字面上也是一个 String ,但在这种情况下,用于 Space复杂sampleTest
只是一个供应商,因此我们可以在日志中看到单个测试的输出片段toTest
是要运行的实际测试repeats
是它会运行多少次
为清楚起见,timeComplexity
和 spaceComplexity
应该以渐进的方式进行测试,从小输入到缓慢增加的输入。将来会在我的网站 http://joaofilipesabinoesperancinha.nl 上提供进度在将来。由于个人计算机的限制,进度测试有点难以运行,因此这两个因素在本文的结果中没有发挥重要作用。每个方法的单独实现应该在我创建的 项目中易于阅读对于这篇文章。
startProcessAsync 是调用 startVirtualThread 方法的地方:
协程的范式比 Java 虚拟线程 稍微复杂一些。这是因为它为您提供了关于如何启动它们的不同选项。 Java 虚拟线程 也有这一点,但 Kotlin 更进一步,通过更改其自己的语法来适应这些变化。然而,它的复杂性使其相当复杂。对我来说,这让它变得非常有趣,但对于普通开发人员来说,这可能有点过头了。简而言之,Kotlin coroutines 允许你异步启动一个执行并等待返回对象,同样的事情和不是等待返回对象,暂停当前协程并执行另一个,而不是在不同或相同上下文上,它有 4 种不同的抽象 用于运行 context,它允许您以 delay 的名称“sleep”,即最后调度睡眠动作,它允许您创建具有启用协程功能的特殊IO特定上下文。这些是我们将在本节中看到的基础知识。现在,让我们看看以下内容:
您会在许多教程中发现,人们使用 类似线程的曲线 来表示协程的工作方式。我以前曾经这样做过,但在我自己看来,这可能有点误导。或者您可以争辩说这只是对初学者的介绍性表示。然而,协程的工作方式与 Threads 不同,尽管您可能在代码中的某些位置有这种印象。至此,如果您阅读了以上所有内容,您可能已经明白我为什么要这样说。如果您运行上述位于类 CoroutinesShortExplained.kt 中的代码,您会看到大部分代码都在线程 上运行主要。所以你可能会问自己,为什么在一个线程中我们可以等待 2 秒,然后 2 秒,,然后整个过程恰好需要 2秒执行?那是因为与 Thread.sleep
(用于协程)不同, delay
操作安排当前协程稍后执行并停放它。这将释放主线程以继续执行。当 2 秒过去后,协程unparked 并重新开始。使用 async,我们与 launch 执行相同的操作,但在这种情况下,我们返回接收者返回的任何内容。在这种情况下,它只是一个 Unit,因为它什么也不返回。最后,我们遇到 withContext
会有增加 500 ms 的效果这个函数的整个等待时间。原因是 withContext
执行上下文切换。它暂停调用协程并运行它的执行,在它结束时返回给调用者。无论系统线程运行它,都会发生这种情况。这就是为什么当我们运行整个代码时,我们在运行时得到大约 3500 ms :
所以这些是基础知识,但了解不同上下文的作用也很重要:
- IO:此上下文在阻塞操作期间管理协程,其方式与 Java 虚拟线程在 PINNING 期间的管理方式相同。 您可以在执行结果编号中看到这一点2. 特意在IO操作期间使用,以便在可能的情况下允许IO操作以非阻塞方式执行。
- 默认:它至少使用 2 个内核才能工作,并且默认使用包含与可用内核一样多的线程的线程池。您可以在执行结果编号 7 中看到这一点。如果可能,它将使用与可用 JVM 线程池不同的线程。否则,它将使用第一个。
- Unconfined:这意味着调度程序不一定会继续在同一个线程上执行。您可以在执行结果编号 6 中看到这一点。它的标准是使用第一个可用线程,使其非常快。这个和
Default
, 之间的细微差别是Default
尽可能选择第一个不同的线程,而Unconfined
允许调度程序选择任何第一个可用的线程其中之一。 - Main:这个是平台相关的,不一定要存在。它有时被称为 Android 特定上下文,但实际上,它只是指实现任何平台,您运行它的地方将其定义为。
在 Loom 项目中,Thread.sleep
不再一定被视为阻塞操作。至少不严格。但是,在运行 Kotlin Coroutines 时,正在执行的线程不被认为是虚拟线程。它是 Worker 由 Kotlin coroutines 核心库提供的。 Worker
是 Thread
接口,所以 Worker
是一个协程,也是一个 线程< /em>
, 但是因为它不是 VirtualThread
的类型,它不会被调度休眠,而是仍然阻塞整个执行:
协程测试函数的实现与其对应的 java 方法非常相似,但重要的是我们快速浏览一下:
虽然这一点看起来是一样的,但还是有一点点不同。由于我们想将数据保存到文件中并且我们希望所有这些都是非阻塞的,因此我们在 IO 上下文下使用协程启动整个过程。一旦我们实现了这一点,我们就可以在异步上下文中启动要测试的方法:
使本文难以编写的一件事是在这里清楚地解释目标。我是否试图衡量虚拟线程相对于 协程 的执行情况,反之亦然?绝对地! 虚拟线程和协程是否可以解决性能问题?简短的回答是一个巨大的否! 长答案是复杂的。 Continuations 正在解决的问题是我们拥有的资源短缺。通过让JVM处理并发,我们现在可以以结构化并发方式编写代码,我们可以触发多个进程同时,我们可以封装它们。
解释为什么 Java Virtual Threads 和 Kotlin Coroutines 都允许我们以结构化并发的方式进行编程,这本身就是一篇完全离题的全新文章,但是我认为,如果我们只是在简短的定义中使用我们的常识,我们可以立即明白为什么会这样:
我们触发它们,但不一定开始运行它们。 平台线程是非常昂贵的进程,占用空间、和启动时间,并且受限于内核数量< /strong> 你的机器。这在实践中以及作为任何 Continuations 实现的结果意味着什么,突然间我们拥有如此多的资源,以至于已经讨论了并发和异步编程现在是否值得付出努力。我的测试正在做的是让我耗尽资源,直到讨论双方的实施都受到挑战。这就是 性能 测试的用武之地。在资源耗尽时管理延续需要以一种智能的方式完成,这就是我强调这两种实现的原因。我会发现 协程 比 虚拟线程 好得多,或者我会发现 虚拟线程 更好。或者,也许我会发现完全没有区别,实际上可能是这种情况,因为我们已经看到这两种实现之间似乎没有任何重大区别。
当然,为了生成这样的测试,我们构建了很多代码。如果您在应用程序的根目录上运行 make clean build-run
,您会看到 dump
会生成目录。在里面你会发现两个目录 java
和 kotlin
。这是我们的测试结果将进入的地方。每种文件都会生成两种类型的文件。有一个可读的 mardown
文件和几个完全不可读的 csv
文件。这些 csv
文件是成对创建的。一个文件包含方法名称,另一个文件包含方法名称,但以 -ms 结尾。 第一个文件的前两列,包含每个 virtual-thread
start 和 end 时间戳>/协程
。第三列包含承载该进程的运行线程的名称。
最后,在 root 上,生成另一个 markdown
文件,其中包含尽可能以相同方式实现的不同方法的简短比较报告在 Java
和 Kotlin
中。此文件名为 Log.md.
但我们仍然需要看看这两种技术理论背后的另一种可视化。这个想法是您可以在暂停之前的执行时执行其他操作。虚拟线程的工作方式有点像这样,这只是一个过于简单的表示:
协程在实践中给出了相同的结构,又是另一个过于简化的例子:
不管它们是如何在低级别实现的,在这两种情况下唯一发生的事情就是在可用线程之间进行切换。在仅使用 platform Threads 的并发环境中,没有上下文切换,因此进行阻塞调用总是意味着等待阻塞调用完成之前被允许继续。 Coroutines 或 Continuations 最大限度地探索线程,确保我们尽可能避免任何阻塞。如果我们正在等待一个阻塞调用,那么当我们完成后我们会回到那个协程,但同时我们只是让另一个协程在另一个线程中移动,甚至在同一个线程中移动。这就是我们现在能够以结构化并发方式实现的原因,如果我们愿意,我们仍然需要在代码中显式地执行此操作。
它们可能在低层次上有所不同,但我看到的是在高层次上,Kotlin 协程和 Java 虚拟线程(在过去也称为fibers)是完全一样的。
为了使这篇文章更有趣,我将所有这些算法都将运行的数据源作为一个小型开发小说。时间越长,两种不同的实现就越难工作。这部小小说讲述了一个名叫 Lucy 的女人,她努力恢复积极的生活,面对生活对她来说太艰难时留下的挑战。所有这些都可以在 项目存储库。
这个故事的灵感来源于我自己的个人生活。故事围绕着露西展开,她是一个在生活中寻找意义的年轻女子,她的肩上仍然担负着世界的重担,但仍然充满活力的心跳,提醒她她还没有完成。生活仍然有很多东西可以提供露西。故事以虚构的神灵和人物的隐喻方式讲述。它包括感受的具体化以及它们如何表现出来。
正如我之前提到的,运行这些测试的最佳方式是通过命令行,但您也可以通过 IntelliJ 运行它们。
如果您通过 Intellij 运行它们,则至少需要运行两个主要类。一个用于 Java,另一个用于 Kotlin。它们分别是 GoodStoryJava.java 和 GoodStoryKotlin.kt。 我们需要使用以下参数运行它们:
-f docs/good.story/GoodStory.md -lf Log.md -dump dump
特别是对于 Java,我们必须启用 JDK19 功能:
--enable-preview
如果您有 VisualVM,请同时运行它。我能够在 VisualVM 崩溃之前抓取这些快照:
我能够以同样的方式为 Kotlin 协程项目捕捉到这一点:
有一些区别,但这只是名称上的区别。在两次捕获之间,我们得到了用于 Java 虚拟线程的 ForkJoinPool-1-worker-N 和用于 Kotlin 协程的 DefaultDispatcher-worker-N。这些工作人员负责协调协程、协程上下文、上下文切换以及将协程分配给系统线程。 Java ForkJoinPool 开始设置最多 256 个工人。 CoroutineScheduler 以 2097150 个工人的最大设置开始。
我创建了一些 CSV 文件来了解在任何给定时间有多少虚拟线程或协程正在执行。这些都是不准确的,原因是因为他们假设这两种进程连续运行,并且在这些运行中从不切换上下文,每次继续。然而,我们现在知道这并不一定总是正确的。无论如何,研究它们是值得的。如果我们看看我们在这两个项目中运行的最繁重的流程之一。例如,让我们检查一下方法/函数发生了什么:repetitionCount
。此方法检查有多少单词重复多次。这意味着如果我们找到两个单词“dog”,那么就是 1 次重复。对于每发现一个“狗”,我们就会在该计数上增加一个。如果我们查看 Java 的计数生成,我们会发现在任何给定时间活动虚拟线程的数量为 12:
对于 Kotlin,我们发现了一些东西。我们看到,在任何给定时间,活跃协程的数量都上升到 63:
这是怎么发生的?好吧,对于 Java 虚拟线程,在给定时间只有 12 个处于活动状态 是完全合理的。对于 Kotlin 协程,这很奇怪。在这种情况下,我不太清楚发生了什么,但我猜测这个 63 数字 只是一个误导性的结果,因为协程应该在运行过程中改变上下文,或者如果无论出于什么原因暂停,那么当然,开始和结束 时间戳 将包含比平时更长的 delta 并且结果不会是适用于我们已经启动的异步进程已经连续运行在启动后没有暂停的初始假设。我们应该知道得到了 12 或更少,因为这就是我的机器有多少个内核。不是63!我只能希望在这一点上。
最后,让我们看一下一般结果,我们可以比较每种实现算法的 10000 次重复运行:
查看表格我们可以看到,在几乎所有情况下,方法/函数中投入一万个虚拟线程或协程的持续时间大约为相同的复杂性并没有那么不同。事实上,更近地放大几乎让我们觉得 project Loom 在性能方面似乎更好。无论如何,仅仅得出结论是不够的。在这一点上,我已经用尽了本地机器的限制,并且在这些测试中它已经足够工作了。在我的测试中,有迹象表明 Project Loom 的虚拟线程 似乎确实比 协程 表现得更好,但是,正如我之前提到的,这并不是一个明确的结论。这只是一个相关性,一个想法,如果你愿意的话。我仍然无法肯定地证明一个比另一个更好。我能够证明的是,在我当前的本地环境中,没有任何东西,绝对没有任何东西让我怀疑这些解决同样问题的方法中的任何一种。它们似乎在同一水平上都不错,而 Java Virtual Threads 做得更好的轻微迹象仍然只是一个迹象。这只是一个指示的另一个原因是我能够在其他情况下运行这些相同的测试,所有 协程实现 都比 Java 虚拟线程做得更好强>。这只是似乎最有利于 Java 虚拟线程 的频率,但这并不是得出任何结论的材料。也许,无法得出任何结论本身就是一个结论,但我让你决定。
当我比较 Continuations 的相同想法的两种实现时,我在实践中并没有真正看到任何重大差异。我发现 Kotlin 协程 和 Java 虚拟线程 都是很棒的技术。当用 协程 耗尽系统并强制各种算法采取行动以 优化 时,我没有看到性能上有任何重大差异。
事情就是这样。 Kotlin 将继续存在,Java 也将继续存在。我对这篇文章的观点是引导双方进行讨论,以充分了解这两种语言的特点。 Kotlin 是 2010 的发明,Java 自 1995 以来就已存在。同样,创建 Scala ,创建 Kotlin 也是为了“提供以前没有的功能”。好吧,这对我来说是一颗难以下咽的药丸。你知道为什么吗?因为 Kotlin 中可用的一切以及我们所说的 Kotlin “需要”我总是发现它在 Java 中可用也是!只是风格不同而已。这范围从我们现在所说的惯用的 Kotlin 到我们现在所说的 惯用的 Java。
自从 Java 8 我们有了 lambda,实际上是 Java 早在 2014 就开始担心缺乏更好的解决方案。 Lambda 与 for, while 和 {} while, 以同样的方式,receivers 做同样的事情在 Kotlin 中做。 它们让一切都慢得要命!只有在为高可用性应用程序实施算法或在黑客网站上进行有关黑客网站的练习时,您才能意识到这一点。 strong>big O 符号。 这可能有点夸张,但是,嘿,我也喜欢两者带来的优雅,所以我也大量使用它们,老实说,但我的观点是它们不是一切。当我们投资于序列、 lambda、接收器和 map-reduce 操作时,我们在某种程度上是在惩罚性能。有关系吗? 只有在重要的时候才重要,所以我最好的建议就是成为他们的专家。我们都非常喜欢 Lambdas 和 Receivers,但不要让它们成为您日常编码人员生活中的愤怒点,因为有时,旧的 for 可以产生真正的影响。
例如,如果我们谈论 扩展函数 比 Java 中的 静态方法 更好,那也不是一个好的观点。当我看到这些讨论或当我被拖入其中时,我通常观察到的一方对其选择的语言非常热情,但在我看来,真正发生的只是人捍卫他们的个人喜好。我,我更喜欢客观,我无法客观地看到与这些语言中的任何一种有关的任何事情。他们只是不同。太好了!
Java 在很多方面都是 Scala 和 Kotlin 的父级。我认为想要或希望 Kotlin 接管 Java 有点愚蠢。我个人认为所有语言都应该存在,我们应该向所有语言学习,因为它们不同但最终做相同的原则正是相同的原则,它让我们保持活跃并让我们理解不同的观点代码。我不希望 Java、Kotlin、 或 Scala 消失。我希望所有这些和其他语言也能发展。我想向他们所有人学习。嘿,还记得我已经开始在带有橡胶键的 ZX-Spectrum 48K 机器上使用磁带进行编程吗?那是在 80 年代对我而言结束的时候。这可能与今天的世界无关,但有这个参考确实让我更好地了解我们在哪里,我们过去面临哪些问题,现在面临哪些问题,以及我们将来可能会发现哪些问题。 更多语言为世界带来的丰富经常被忽视。
我可以永远继续下去,但我真正想用这篇文章说的很简单。 Kotlin 是一个新玩家,协程实现也是如此。 我们都爱他们。但无论如何,我看不到这些技术相对于 Java 虚拟线程 的工程附加值。我认为 Kotlin 只是与众不同,它为 JVM 增添了新的风味。然而,我发现的每一个关于 Kotlin 的批评者,我都可以看到 Java。同样,对于 Kotlin 的每一个赞美,我都可以在 Java 中找到完全相同的内容。 它似乎有不同的风格。当然,很多东西并没有集成到 Java SDK 中,但是 Kotlin 仍然只是 JVM 之上的 DSL 。 这意味着如果我在 Java 中使用 Lombok 之类的东西,我可能也有同样的权利?它只是另一个 DSL,就像 Kotlin。好吧,你们中的许多人读到这篇文章时会站起来说 Lombok 是“一个糟糕的主意”, 然后我会说“但是我们有 record's
; 现在使用 Java !”然后你会说“是的,但是数据类一起完成了所有这些,你可以让所有东西不可变看起来好多了!”。这一切都太棒了,我同意最后的说法。 Kotlin 确实看起来更好。或者是吗?也许我更喜欢使用注释,也许我更喜欢使用 @Builder
而不是 data class
,也许我想在单个 data 关键字后面被提醒我得到一个哈希实现、一个equals、getters和setters,如果我使用val
在我的所有属性上,然后我得到一个 不可变 对象!这就是我认为 Kotlin 是一种天才语言的地方。它仍然让我不清楚它为代码增加了哪些工程优势,但是,通过我们的直觉和当前趋势,它找到了一个黄金机会来填补许多开发人员和工程师面临的差距。天。 样板、重码、难码、工程成本等。此外,在确保结构化并发方面,它提供了一种令人惊叹的编程风格。当然,我们渴望做一些刺激的和新的事情。新语法和新语义创造了一个全新的游乐场,这只是一件积极的事情。
在我看来,在严格的工程意义上,Kotlin 和 Java 都不比彼此更好。你当然可以不同意。而且我认为,如果您来自 Android 背景,那么您在这里可以说的比我说的要多。我非常清楚 Kotlin 已被 Android 开发人员广泛接受。听起来不错。我的观点(或缺乏观点)来自仅服务实现的观点。 Android 确实有很多内容,所以我不得不放弃对此发表评论。目前,就是这样。
如果你必须选择一项新技术,我的建议是,选择你最喜欢的。我严重怀疑你会从语言本身中发现任何性能优势。与您的团队保持一致。如果他们对 Kotlin 充满热情,那就去吧。如果他们对 Java 充满热情,那就去吧。在激情中,您会发现最高的生产力。如果你想追求高效的东西,并且这是你唯一关心的事情,那么,对此有非常广泛的共识,你可能想留下首先远离任何与JVM相关的东西。在 JVM 中启动和运行可能很困难,这就是为什么许多人转而使用 Native 解决方案的原因。我还想指出的是,协程有时会在多线程和提供更多线程的上下文中讨论。只是不是这样。 协程的范式本质上与反应式编程的关系比其他任何东西都多。我之所以这么说是因为协程更有效地利用了系统/平台线程。然而,这听起来可能与 多线程 有关,但事实并非如此。这只是一种避免线程无故暂停的方法,如果你愿意的话。无论您是决定使用 Kotlin 协程,还是使用 Project Loom 下即将推出的 JDK19 中的虚拟线程,这完全取决于您。
Java 必须保护自己免受 Kotlin 或 Kotlin 可能对 Java 构成威胁的想法是我最初的想法写这篇文章的动机,这是因为,就像 Lucy 的故事有一天会展示一样,有时我们只是互相讲述非常好的故事,但它们最终毫无意义。当我醒来时,我个人会继续用我喜欢的任何语言进行编程。 在工作中,我坚持计划。 在我的业余时间,我只是选择任何我当时喜欢的,包括Java、Kotlin、Scala、Go、Rust , Python, Ruby, PHP, Javascript 等
我已将此应用程序的所有源代码放在 Github