在优化并发时比较BEAM Erlang VM和JVM的博客

2020-05-14 01:31:17

Erlang生态系统中任何编程语言的成功都可以分为三个紧密耦合的组件。它们是:1)Erlang编程语言的语义,在此基础上实现其他语言;2)用于构建可伸缩和弹性并发系统的OTP库和中间件;3)与语言语义和OTP紧密耦合的BEAM虚拟机。

把这些组件中的任何一个单独拿出来,你就会获得亚军。但是,将这三者放在一起,您就会在可伸缩、弹性的软实时系统方面获得无可争议的赢家。引用Joe Armstrong的话说,“您可以复制Erlang库,但如果它不在BEAM上运行,您就无法模仿其语义”。这由Robert Virding的编程第一规则强制执行,该规则规定“任何用另一种语言编写的足够复杂的并发程序都包含半个Erlang的特别的、非正式指定的、充满错误的缓慢实现。”

在这篇文章中,我们想要探索BEAM VM的内部结构。我们将在适用的情况下将它们与JVM进行比较,强调您应该关注它们的原因。太长时间以来,该组件一直被视为一个黑匣子,理所当然,没有理解其原因或含义。是时候改变这一点了!

Erlang和BEAM VM被发明为解决特定问题的合适工具。它们由爱立信开发,以帮助实现同时处理移动和固定网络的电信基础设施。该基础设施本质上是高度并发和可伸缩的。它必须显示软实时属性,并且可能永远不会失败。我们不希望在祖母挂断的情况下我们手机上的Hangout通话,或者我们对堡垒之夜的在线游戏体验受到系统升级、高用户负载或软件、硬件和网络中断的影响。BEAM VM通过提供在可预测的并发编程模型之上工作的微调功能进行了优化,以解决其中的许多挑战。

它的秘诀是不共享内存的轻量级进程,由调度器管理,调度器可以跨多个内核管理数百万个进程。它使用在每个进程基础上运行的垃圾收集器,高度优化以减少对其他进程的任何影响。因此,垃圾收集器不会影响系统的整体软实时属性。BEAM也是唯一大规模使用的具有内置分发模型的广泛使用的VM,该模型允许程序透明地在多台机器上运行。

Java虚拟机(JVM)是由Sun Microsystem开发的,目的是为在任何地方运行的“一次编写”代码提供一个平台。他们创建了一种类似于C++的面向对象语言,但是内存安全,因为它的运行时错误检测检查数组边界和指针取消引用。JVM生态系统在互联网时代变得非常流行,使其成为企业服务器应用程序的事实标准。广泛的适用性得益于可满足多种用例的虚拟机,以及一组令人印象深刻的面向企业开发的库。

JVM的设计考虑到了效率。它的大部分概念都是流行操作系统中特性的抽象,比如映射到操作系统线程的线程模型。JVM是高度可定制的,包括垃圾收集器(GC)和类加载器。一些最先进的GC实现提供了高度可调的特性,以迎合基于共享内存的编程模型。JVM允许您在程序运行时更改代码。而且,JIT编译器允许将字节码编译成本机代码,目的是加速应用程序的某些部分。

Java世界中的并发性主要关注在并行线程中运行应用程序,以确保它们是快速的。使用并发原语进行编程是一项困难的任务,因为它的共享内存模型带来了挑战。为了克服这些困难,有人尝试简化和统一并发编程模型,最成功的尝试是Akka框架。

我们谈到如果部分代码同时在多个内核、处理器或计算机上运行,则称为并行代码执行,而并发编程指的是独立处理到达系统的事件。并发执行可以在单线程硬件上模拟,而并行执行不能。尽管这种区别可能看起来很迂腐,但这种区别导致了一些非常不同的问题需要解决。想想很多厨师做了一盘卡波纳拉意大利面。在并行方法中,任务被分配到可用的厨师数量上,单个部分的完成速度与这些厨师完成其特定任务的速度一样快。在一个并发世界中,您将为每个厨师分得一部分,在那里每个厨师都完成所有的任务。您使用并行度来表示速度,使用并发性来表示规模。

并行执行试图解决问题的最优分解,将问题分解成相互独立的部分。烧开水,得到意大利面,搅拌鸡蛋,炸墨西哥火腿,磨碎佩科里诺奶酪1。共享的数据(或者在我们的例子中,是服务的菜肴)通过锁、互斥和各种其他技术来处理,以确保正确性。看待这一点的另一种方式是,数据(或成分)是存在的,并且我们希望利用尽可能多的并行CPU资源来尽可能快地完成工作。

另一方面,并发编程处理在不同时间到达系统的许多事件,并试图在合理的时间内处理所有事件。在多核或分布式体系结构上,有些执行是并行运行的,但这不是必需的。另一种看待它的方式是,同一个厨师煮水,吃意大利面,搅拌鸡蛋等等,遵循一个总是相同的顺序算法。流程(或厨师)之间的变化是要处理的数据(或配料),这些数据(或配料)存在于多个实例中。

JVM是为并行而构建的,而BEAM是为并发而构建的。这是两个本质上不同的问题,需要不同的解决方案。

BEAM提供了轻量级的进程来为运行的代码提供上下文。这些进程(也称为参与者)不共享内存,而是通过消息传递进行通信,将数据从一个进程复制到另一个进程。消息传递是虚拟机通过各个进程拥有的邮箱实现的功能。消息传递是非阻塞操作,这意味着将消息发送到另一个进程几乎是瞬间的,并且发送者的执行不会被阻止。发送的消息以不变数据的形式,从发送进程的堆栈复制到接收进程的邮箱。这是在进程之间不需要锁定和互斥的情况下实现的,仅在多个进程并行向同一收件人发送消息的情况下锁定邮箱。

不可变的数据和消息传递使程序员能够编写彼此独立工作的进程,并将重点放在功能上,而不是对内存的低级管理和任务的调度。事实证明,这个简单的设计不仅适用于单个线程,而且还适用于运行在同一VM中的本地计算机上的多个线程,并且使用内置的分发版本,跨具有VM和机器群集的网络运行。如果消息在进程之间是不可变的,则可以将它们发送到另一个线程(或机器)而不加锁,在分布式多核体系结构上几乎是线性扩展的。本地VM上的进程寻址方式与VM群集中的相同,消息发送工作透明,而与接收进程的位置无关。

进程不共享内存,因此您可以复制数据以获得恢复能力,并按规模分发数据。这意味着在两台不同的机器上拥有同一进程的两个实例,彼此共享状态更新。如果一台机器出现故障,另一台机器将拥有数据副本,并可以继续处理请求,从而使系统具有容错能力。如果两台机器都正常运行,则两个进程都可以处理请求,从而为您提供可伸缩性。BEAM为所有这些无缝工作提供了高度优化的原语,而OTP(“标准库”)提供了更高级别的构造,使程序员的生活变得轻松。

Akka在复制更高级别的构造方面做得很好,但是由于缺少JVM提供的原语,这在一定程度上受到了限制,这使得它可以针对并发进行高度优化。虽然JVM的原语支持更广泛的用例,但它们使分布式系统编程变得更加困难,因为它们没有用于通信的内置原语,并且通常基于共享内存模型。例如,在分布式系统中,您将共享内存放在哪里?使用它的费用是多少?

我们提到过,BEAM最强大的功能之一是能够将程序分解成小的、轻量级的过程。管理这些进程是调度器的任务。与JVM不同的是,JVM将其线程映射到OS线程并让操作系统对其进行调度,而BEAM带有自己的调度器。

默认情况下,调度程序为每个内核启动一个操作系统线程,并优化它们之间的工作负载。每个进程都由要执行的代码和随时间变化的状态组成。调度程序挑选运行队列中准备运行的第一个进程,给它一定的减量来执行,其中每个减量大致相当于一个命令。一旦进程耗尽减量、被I/O阻塞、正在等待消息或完成执行其代码,调度程序就会选择运行队列中的下一个进程并将其分派。这种调度技术称为抢占式。

我们多次提到Akka框架,因为它最大的缺点是需要用调度点来注释代码,因为调度不是在JVM级别进行的。通过从程序员手中移除控制,软实时属性被保留和保证,因为它们没有意外导致进程饥饿的风险。

这些进程可以分布在可用的调度程序线程中,并最大限度地提高CPU利用率。有很多方法可以调整调度器,但很少有,仅在边缘和边缘情况下才需要,因为默认选项覆盖了大多数使用模式。

有一个关于调度器的敏感话题经常出现:如何处理本地实现的功能(NIF)。NIF是用C语言编写的代码片段,作为库编译,并在与光束相同的内存空间中运行以提高速度。NIF的问题在于它们不是先发制人的,可能会影响调度程序。在最近的BEAM版本中,添加了一个新功能,脏调度器,以便更好地控制NIF。脏调度器是单独的调度器,它们在不同的线程中运行,以最大限度地减少NIF可能在系统中造成的中断。“脏”一词指的是由这些调度程序运行的代码的性质。

当今的现代高级编程语言大多使用垃圾收集器进行内存管理。BEAM语言也不例外。当您想要编写高级并发代码时,信任虚拟机来处理资源和管理内存非常方便,因为它简化了任务。多亏了基于不可变状态的内存模型,垃圾收集器的底层实现相当简单和高效。数据是复制的,而不是变异的,并且进程不共享内存的事实消除了任何进程的相互依赖性,因此不需要管理它们。

BEAM的另一个特性是,仅在需要时才按进程运行垃圾收集,而不会影响在运行队列中等待的其他进程。因此,Erlang中的垃圾收集不会“阻止世界”。它可以防止处理延迟峰值,因为VM永远不会作为一个整体停止-只有特定的进程才会停止,而且永远不会同时停止所有进程。在实践中,它只是流程所做工作的一部分,并被视为另一种减少。垃圾收集器收集进程会在非常短的时间间隔(通常是微秒)内挂起该进程。因此,将会有许多小突发,只有在进程需要更多内存时才会触发。单个进程通常不会分配大量内存,而且通常是短暂的,通过在终止时立即释放其分配的所有内存,进一步降低了影响。JVM的一个特性是能够交换垃圾收集器,因此通过使用商业GC,还可以在JVM中实现不间断GC。

Lukas Larsson在一篇优秀的博客文章中讨论了垃圾收集器的特性。有许多复杂的细节,但它经过优化,可以高效地处理不可变的数据,在每个进程的堆栈和堆之间划分数据。最好的方法是在短期流程中完成大部分工作。

在这个主题上经常出现的一个问题是,光束使用了多少内存。在幕后,VM分配大量内存,并使用自定义分配器有效地存储数据,最大限度地减少系统调用的开销。这有两个明显的影响:1)在不需要空间后,已用内存逐渐减少;2)重新分配大量数据可能意味着将当前工作内存增加一倍。

如果确实需要,可以通过调整分配器策略来缓解第一个影响。如果您了解不同类型的内存使用情况,则第二个选项很容易监视和计划。(提供开箱即用的系统指标的一种这样的监控工具是WOMBATOAM)。

热代码加载可能是BEAM被引用最多的独特功能。热代码加载意味着可以通过更改系统中的可运行代码来更新应用程序逻辑,同时保留内部进程状态。这是通过替换加载的BEAM文件并指示VM替换运行进程中的代码引用来实现的。

这是电信基础设施无需停机代码升级的关键功能,在电信基础设施中,冗余硬件用于处理峰值。如今,在集装箱化时代,其他技术也被用于生产更新。那些从未使用过它的人认为它是一个不太重要的功能,但它在开发工作流中的用处仍然很小。开发人员可以通过替换部分代码来加快迭代速度,而无需重新启动系统进行测试。即使应用程序设计为不能在生产中升级,这也可以减少重新编译和重新部署所需的时间。

这在很大程度上是关于适合这项工作的工具。您需要一个速度极快的系统,但是不关心并发性吗?并行处理几个事件,并且必须快速处理它们?需要处理图形、人工智能或分析方面的数字吗?沿着C++、Python或Java路线走下去。电信基础设施不需要在浮动车上快速操作,因此速度从来都不是优先事项。借助动态类型(必须在运行时执行所有类型检查),编译器时间优化就不那么简单了。因此,数字处理最好留给JVM、GO或其他编译为本机的语言。在JVM上运行的Erlang版本Erjang上的浮点操作比BEAM快5000%也就不足为奇了。但我们看到的亮点是使用其并发性来协调数字处理,将分析外包给C、Julia、Python或Rust。您可以在梁的外部绘制贴图,并在梁内进行减少。

口头禅总是够快的。人类只需要几百毫秒就能感知到刺激(事件)并在大脑中进行处理,这意味着微秒或纳秒的响应时间对于许多应用程序来说并不是必需的。你也不会把光束用在微控制器上,它太耗费资源了。但是对于处理能力稍高一些的嵌入式系统来说,多核正在成为标准,您需要并发性,并且光束大放异彩。早在90年代,我们就在实施电话交换机,处理在16MB内存的嵌入式主板上运行的数以万计的用户。现在RaspberryPI有多少内存?最后是硬实时系统。您可能不希望光束管理您的安全气囊控制系统。您需要硬保证,只需要一个硬实时操作系统和一种没有垃圾收集或异常的语言。在裸机(如GRiSP)上运行的Erlang VM的实现将为您提供类似的保证。

使用合适的工具来完成这项工作。如果您正在编写一个软实时系统,该系统必须开箱即用且永不失败,而且没有重新发明轮子的麻烦,那么BEAM就是您正在寻找的久经考验的技术。对许多人来说,它就像一个黑匣子。不知道它是如何工作的,就好比开着一辆法拉利,不能达到最佳性能,或者不知道那个奇怪的声音是从马达的哪个部分发出的。这就是为什么你应该学习更多关于梁的知识,了解它的内部结构,并准备好对其进行微调和修复。对于那些愤怒地使用Erlang和Elixir的人,我们推出了一个为期一天的讲师指导课程,它将揭开您所看到的许多神秘面纱,并解释您所看到的许多内容,同时为您处理大规模并发做好准备。该课程可通过我们的新讲师Lead远程培训获得,请单击此处了解更多信息。我们还推荐埃里克·斯坦曼(Erik Stenman)的BEAM书和德米特罗·利托夫琴科(Dmytro Lytovchenko)的文章集《BEAM WISDOMS》。

1.其中一位作者还喜欢一道看起来类似的菜肴,不叫科纳拉拉,而是用奶油做的。