[译]BEAM vs JVM

作者是 Francesco Cesarini & Gabor Olah


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

单独使用这些组件中的任何一个,您将获得亚军。但是,将这三个因素放在一起,您将获得可伸缩,灵活的软实时系统的无可争议的赢家。引用Joe Armstrong的话:“您可以复制Erlang库,但是如果它不能在BEAM上运行,则无法模拟语义”。这是由罗伯特·维尔丁(Robert Virding)的《第一条编程规则》强制执行的,该条规定:“任何另一种足够复杂的并发程序都使用另一种语言,包含非正式的,临时指定的,bug缠身的缓慢的Erlang一半实现。”

在本文中,我们想探索BEAM VM内部。我们将在适用的情况下将它们与JVM进行比较和对比,强调您为什么要注意它们并加以注意。长期以来,此组件一直被视为黑匣子,并且在不了解原因或含义的情况下被视为理所当然。现在该改变这种情况了!

BEAM的亮点

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

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

JVM的亮点

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

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

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

并发与并行

如果部分代码在多个内核,处理器或计算机上同时运行,则我们谈论并行代码执行,而并发编程是指独立处理到达系统的事件。可以在单线程硬件上模拟并发执行,而并行执行则不能。尽管这种区别似乎很古怪,但这种差异导致需要解决的问题非常不同。想想很多厨师在做一盘Carbonara意大利面。在并行方法中,将任务分配给可用厨师的数量,并且只要完成这些厨师完成其特定任务的速度,就可以完成单个部分。在一个并发的世界中,每位厨师将获得一部分,每位厨师将完成所有任务。您将并行性用于速度,并发性用于规模。

并行执行试图将问题的最佳分解解决为彼此独立的部分。将水煮沸,煮意大利面,混合鸡蛋,炸瓜里阿塞火腿,将佩克立诺奶酪磨碎1。共享数据(或在我们的示例中为餐盘)由锁,互斥锁和各种其他技术处理,以确保正确性。另一种看待这种情况的方式是存在数据(或成分),并且我们希望利用尽可能多的并行CPU资源来尽快完成工作。

另一方面,并​​发编程处理许多事件,这些事件在不同的时间到达系统,并尝试在合理的时间内处理所有事件。在多核或分布式体系结构上,某些执行是并行运行的,但这不是必需的。另一种看待它的方法是,同一位厨师按照始终相同的顺序算法,将水煮沸,煮意大利面,混合鸡蛋等。跨过程(或做饭)的变化是要处理的数据(或成分),这些数据(或成分)存在于多个实例中。

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

BEAM和并发

BEAM提供轻量级流程为正在运行的代码提供上下文。这些进程也称为actor,不共享内存,而是通过消息传递进行通信,将数据从一个进程复制到另一个进程。消息传递是虚拟机通过各个进程拥有的邮箱实现的功能。消息传递是一种非阻塞操作,这意味着将消息发送到另一个进程几乎是即时的,并且不会阻塞发送者的执行。发送的消息采用不可变数据的形式,从发送过程的堆栈复制到接收者的邮箱。无需在进程之间使用锁和互斥锁即可实现此目的,而在多个进程并行将消息发送到同一收件人的情况下,只需对邮箱进行锁定即可。

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

进程不共享内存,因此您可以复制数据以恢复弹性并分发数据以实现规模扩展。这意味着在两个不同的机器上具有相同进程的两个实例,彼此之间共享状态更新。如果一台计算机发生故障,则另一台计算机具有数据副本,并且可以继续处理该请求,从而使系统具有容错能力。如果两台计算机都可运行,则两个进程都可以处理请求,从而为您提供可伸缩性。BEAM为所有这些无缝集成提供了高度优化的原语,而OTP(“标准库”)则提供了更高级别的结构以简化程序员的生活。

Akka在复制更高级别的结构方面做得很好,但是由于缺少JVM提供的原语而在一定程度上受到了限制,从而使其可以高度优化并发性。尽管JVM的原语支持更广泛的用例,但由于它们没有用于通信的内置原语且通常基于共享内存模型,因此它们使对分布式系统的编程变得更加困难。例如,您在分布式系统中的何处放置共享内存?以及访问它的成本是多少?

调度器

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

默认情况下,调度程序为每个内核启动一个OS线程,并优化它们之间的工作负载。每个过程都包含要执行的代码和随时间变化的状态。调度程序会选择运行队列中准备运行的第一个进程,并为其赋予定量的reductions(译者注:2000个),其中每次reduction都大致等同于一条指令。一旦进程用尽了reductions,会被I/O阻塞,等待消息或代码完成,调度程序就会在运行队列中选择下一个进程并将其分派(此句翻译有问题:原文是Once the process has either run out of reductions, is blocked by I/O, is waiting for a message or completes executing its code, the scheduler picks the next process in the run queue and dispatches it. 如果您有更好的翻译请不吝指教)。这种调度技术称为抢先式。

我们多次提到Akka框架,因为它的最大缺点是需要在调度处添加注解,因为调度不是在JVM级别进行的。通过解除程序员的控制,可以保留和保证软实时属性,从而减低了导致进程饿死的风险。

进程围绕着调度程序线程,并最大程度地利用CPU。有许多方法可以调整调度程序,但是它很少见,仅在边缘和边界情况下才需要,因为默认选项涵盖了大多数使用模式。

关于调度程序,经常出现一个敏感的话题:如何处理本机实现的函数(NIF)。NIF是用C编写的代码片段,被编译为库并在与BEAM相同的内存空间中运行以提高速度。NIF的问题在于它们不是抢占式的,并且会影响调度程序。在最新的BEAM版本中,添加了一项新功能,即脏调度程序,以更好地控制NIF。肮脏的调度程序是在不同线程中运行的单独的调度程序,以最大程度地减少NIF对系统造成的中断。脏这个词是指这些调度程序运行的代码的性质。

垃圾收集器

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

BEAM的另一个功能是,仅在需要时才在每个进程的基础上运行垃圾回收,而不会影响在运行队列中等待的其他进程。结果,Erlang中的垃圾收集不会“stop-the-world”。它可以防止处理延迟高峰,因为VM不会从整体上停止——仅特定进程停止,并且绝不会同时停止所有进程。实际上,这只是流程的一部分,并且被视为另一种reduction。垃圾收集器收集过程将过程暂停很短的时间间隔,通常是微秒。代价是,将有许多小的爆发(bursts),仅在进程需要更多内存时才触发。单个进程通常不会分配大量内存,并且通常是短暂的,通过在终止时立即释放所有分配的内存,进一步降低了影响。JVM的一个功能是具备切换垃圾收集器的能力,因此,通过使用商用GC,还可以在JVM中实现不间断的GC。

Lukas Larsson在一篇出色的博客文章中讨论了垃圾收集器的功能。有许多复杂的细节,但已对其进行了优化以有效地处理不可变数据,并为每个进程在堆栈和堆之间分配了数据。最好的方法是在短暂的过程中完成大部分工作。

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

如果确实需要,可以通过调整分配器策略来减轻第一个影响。如果您可以看到不同类型的内存使用情况,则第二个易于监视和计划。(WombatOAM就是这样一种提供开箱即用的系统指标的监视工具。)

热代码加载

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

对于电信基础架构无需停机代码升级而言,这是一项至关重要的功能,被裁减的硬件可用于处理高峰。如今,在容器化时代,其他技术也可以实现产线的更新。从未使用过它的人会认为它是不重要的功能,但是在开发工作流程中仍然有用。开发人员可以通过替换部分代码来加快迭代速度,而不必重新启动系统来对其进行测试。即使该应用程序并非设计为可在生产环境中进行升级,也可以减少重新编译和重新部署所需的时间。

何时不使用BEAM

正确的工具非常重要。您需要一个速度极快的系统,但不关心并发性吗?并行处理一些事件,并且必须快速处理它们?是否需要计算图形,人工智能或分析数据?沿C ++,Python或Java路线走。电信基础设施不需要快速运行,因此速度从来都不是优先事项。在动态类型的辅助下,它必须在运行时进行所有类型检查,这意味着编译器时间优化并不那么简单。因此,数字运算最好留给JVM,Go或其他编译成本地语言的语言使用。毫不奇怪,在JVM上运行的Erlang版本Erjang上的浮点运算比BEAM快5000%。但是我们看到BEAM大放异彩的地方是利用它的并发来安排数字运算,将分析外包给C,Julia,Python或Rust。您可以在BEAM外部做map,而在BEAM内部做reduce(译者注:map-reduce思想)。

口头禅总是很快。人类感知刺激(事件)并在大脑中进行处理需要几百毫秒,这意味着对于许多应用而言,微秒或纳秒的响应时间并不是必需的。您也不会将BEAM用于微控制器,这太浪费资源了。但是对于具有更多处理能力的嵌入式系统(多核已成为常态),您需要并发性,而BEAM令人眼前一亮。上世纪90年代,我们实现了电话交换机,以处理运行在具有16MB内存的嵌入式板上的成千上万的用户。RaspberryPi都有多少内存了?还有,硬实时系统,您可能不希望BEAM管理您的安全气囊控制系统。你需要硬保证 仅仅是硬实时操作系统,没有垃圾收集或异常的语言。在诸如GRiSP之类的裸机上运行的Erlang VM的实现将为您提供类似的保证。

结论

使用正确的工具完成工作。如果您正在编写一个软实时系统,而该系统必须能够立即扩展并且永远不会失败,并且无需重新发明轮子就可以做到,那么BEAM是您正在寻找的经过验证的技术。对于许多人来说,它就像一个黑匣子。不知道它的工作原理类似于驾驶法拉利,无法获得最佳性能或无法理解奇怪声音来自马达的哪个部分。这就是为什么您应该更多地了解BEAM,了解其内部结构并准备对其进行微调和修复的原因。对于在实际任务中使用Erlang和Elixir的人(译者注:in anger 的意思是to do or use something in a real situation),我们开设了一天的讲师指导课程,该课程通俗易懂并解释您所看到的很多内容,同时为您准备大规模处理大规模并发做好准备。在这里了解更多。我们也推荐Erik Stenman撰写的关于BEAM的书和Dmytro Lytovchenko的文章集——BEAM Wisdoms