发布于 2026-01-06 7 阅读
0

Elixir 和 Beam:并发的真正运作方式

Elixir 和 Beam:并发的真正运作方式

本文最初发表于 Flatiron Labs 博客。点击此处查看 Flatiron School 技术团队的更多精彩内容。

我不理解并发性。

我之前用的是 Ruby 和 JS,所以接触 Elixir 时,我明白并发是我想要的语言特性。然而,即使用 Elixir 编程几年之后,我仍然不太理解它的并发机制。我听说过 BEAM,也用过 OTP,但要我详细解释它们,恐怕也只能给出个大概的解释。

许多“转行”程序员——我们这些没有计算机科学学位就走上编程之路的人——都是通过实践学习的。我们亲手构建各种东西。因此,我们有时会想当然地认为某些东西的工作原理是理所当然的,并且仅仅因为它们能运行就感到满足。再加上问“愚蠢”的问题会让人感到尴尬甚至难为情,最终就形成了像我这样的人——我编写过能在BEAM上运行的代码,但却说不出BEAM是什么。

过去几周,我对 Elixir 的基础知识越来越感兴趣。Erlang 的创始人 Joe Armstrong 的去世促使我深入了解他的工作,而 Bruce Tate 和 James Edward Gray 的著作《使用 OTP 设计 Elixir 系统》的测试版则让我开始思考 OTP 的真正含义。

如果你想停止吹嘘 Elixir 的并发性,并开始了解它是如何实现并发的,请继续阅读。

什么是梁?

BEAM(Bogdan Erlang 抽象机)是 Erlang 虚拟机。在本文中,我们将交替使用 BEAM 和 Erlang 虚拟机这两个术语。

BEAM 将 Erlang 和 Elixir 代码编译成字节码(您可能在已编译的 Elixir 应用程序中注意到的 .beam 文件)并执行它。

BEAM 和 OTP

要讨论 BEAM,我们需要先讨论 OTP。

OTP是一套Erlang库,包括Erlang运行时系统、若干主要用Erlang编写的即用型组件,以及一套Erlang程序设计原则

事实上,为了在您的机器上安装和使用 Erlang,您需要安装并构建 Erlang/OTP 发行版

OTP 提供代表常见实践(如进程监督、消息传递、生成任务等)的标准实现的模块和行为。

例如,OTP框架对GenServer行为的定义是对某些类型进程的常见用法/交互的抽象。因此,我们所有Erlang和Elixir程序员无需为每个新应用程序都重新编写服务器并重复造轮子,而是可以通过使用GenServer模块和行为来订阅一个共享的、成熟的、经过实战检验的服务器接口。

OTP 也包含了 Erlang 运行时系统 (ERTS)。这意味着 OTP 框架包含了 BEAM。因此,当我们谈到 BEAM 时,我们指的是内置于 OTP 发行版中的虚拟机。

光束和并行处理

作为 Erlang 处理系统 (ERTS) 的一部分,BEAM 负责调度 Erlang 进程。并发的奥秘就在这里展现。BEAM
为每个操作系统核心使用一个线程,并在每个线程上运行一个调度器。每个调度器从其自身的运行队列中拉取要执行的进程。BEAM 还负责将 Erlang 进程填充到这些运行队列中。为了理解这有多么神奇,我们需要了解 Erlang 与并发的关系。
并发与 Erlang 的诞生

Erlang编程语言是由Joe Armstrong等人于1986年在爱立信计算机科学实验室开发的。爱立信当时正在建设和维护拥有数十万用户的大型电话交换机。这些系统有一个重要的要求:它们绝不能发生故障,必须具备完全的容错能力。Joe在深入开发大规模容错系统的过程中发现,并发性是这些系统的一个显著特征。

最初,我对并发本身并不感兴趣,我更感兴趣的是如何构建容错系统。这些系统的一个特点是能够同时处理数十万个电话呼叫

认识到这一特点后,他开始构思“面向并发编程”——一种模拟我们周围世界的应用程序建模方式。

世界是平行的。如果我们想编写像现实世界中其他对象那样运行的程序,那么这些程序就必须具有并发结构……人作为独立的实体,通过发送消息进行交流。Erlang 进程就是这样工作的……Erlang 程序由许多相互交流的小进程组成——就像人一样

基于这种模型,Erlang 被开发为具有容错性和并发性。但对于一种语言来说,“并发”究竟意味着什么?

多核处理和光束技术的历史

在英特尔于 2007 年发布酷睿 2 四核处理器之前,计算机芯片都只有一个核心。核心是 CPU 的一部分,它接收指令并根据这些指令执行操作。
在单核时代,计算机能够并发执行多个进程,但不能并行执行。等等!“并发”和“并行”难道不是一回事吗?不!

假设我们正在做一件我们讨厌的事情,比如洗两大堆衣服。我们把深色衣服和浅色衣服分开,然后去自助洗衣店。洗衣服真是件烦人的事,而且特别费时间,所以我们想尽量缩短在洗衣店的时间。我们没有选择分别洗两堆衣服(先放深色衣服,加洗衣液,投币,按下“开始”按钮,等程序结束,把衣服放进烘干机,然后再重复洗浅色衣服的步骤),而是决定同时洗两堆衣服。

我们把深色衣物放进一台洗衣机,浅色衣物放进另一台。我们先往一台洗衣机里加洗衣液,然后再往另一台里加;我们先往一台洗衣机里投币,然后再往另一台里投币;我们先在一台洗衣机上按下“启动”键,然后再在另一台洗衣机上按下“启动”键。这代表了一组并发过程——深色衣物和浅色衣物同时处于相同的状态。但这却是因为我们快速地在两个过程之间来回切换(先对深色衣物执行一个步骤,然后再对浅色衣物执行该步骤)造成的。

在这个单核并发的环境中,Erlang 虚拟机在唯一的可用核心上运行一个线程和一个调度器。调度器从唯一的运行队列中取出 Erlang 进程,并为每个进程分配一个“时间片”。如果某个进程超过了其“时间片”,Erlang 虚拟机就会暂停该进程,将其放回队列,然后处理队列中的下一个进程。这样,Erlang 虚拟机就能通过在进程间快速切换来“并发”执行 Erlang 进程,就像我们快速切换深色衣物和浅色衣物,以便大致同时洗涤它们一样。

随着多核处理器的出现,BEAM得以在Erlang中利用对称多处理器(SMP)技术。Erlang虚拟机中SMP的第一个版本于2006年随Erlang OTP R11B发布。该版本的Erlang虚拟机可以在同一个线程上运行1到1024个调度器,每个调度器共享同一个运行队列,并在同一个核心上运行。

随着 2007 年 OTP R12B 的发布,运行在多核处理器上的 Erlang 虚拟机能够为每个核心分配一个线程来运行调度器。这使得 Erlang 进程可以并行运行,各个核心上的调度器从共享的运行队列中抽取进程来执行。因此,Erlang 原有的并发进程执行能力使其能够立即适应在多核计算机上运行并行进程。

再回到我们之前的洗衣比喻——想象一下,我们来了一位乐于助人的朋友,他/她特别喜欢洗衣服(谢天谢地,不过,他/她是谁呢?)。他/她来帮我们洗深色衣服,我们则洗浅色衣服。现在,我们可以同时把两套衣服分别放进两台洗衣机里。我们实现了并行处理

我们可以通过在我们的机器上启动 iex shell 来观察这种功能的实际应用:

$ iex
Erlang/OTP 21 [erts-10.0.4] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.7.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>

提示的这一部分[smp:4:4]表明我的机器有四个可用核心,每个核心上都有一个调度器,共四个。

在 Erlang 虚拟机上实现的第二代 SMP 存在一个局限性,即跨核心运行的并行调度器仍然共享同一个运行队列。为了避免数据污染,必须对队列中的数据进行锁定。

这可能会导致处理瓶颈——一旦某个进程运行缓慢或出现锁冲突,所有等待访问运行队列的调度程序都会被延迟。例如,如果你和你那位酷爱洗衣的朋友共用一瓶洗衣液,其中一人伸手去拿洗衣液就会阻止另一人继续进行洗衣流程的下一步。

这个问题已在 OTP R13B 版本中得到解决,该版本摒弃了公共运行队列,为每个线程、每个核心都实现了专用的运行队列。如今,Erlang 虚拟机在每个核心上运行一个线程。每个线程都运行着自己的调度器,并从其专属的运行队列中获取任务。数据不会在队列之间共享,因此无需管理锁定。每个人都有自己专属的待洗衣物和洗涤剂!

BEAM 如何管理并行进程?

BEAM 为每个核心创建一个线程,为每个线程创建一个调度器,并为每个调度器创建一个运行队列。它还负责向所有运行队列中填充进程,供调度器并行执行。这项工作由 BEAM 的负载均衡器管理。负载均衡器实现了迁移逻辑,用于在不同核心上的运行队列之间分配进程。该逻辑有助于负载均衡器将作业从过载的队列中转移出来(“任务窃取”),并将它们分配给空闲或负载较低的队列(“任务迁移”)。负载均衡器的目标是保持所有调度器上可运行进程的最大数量相等。

结论

深入研究 Elixir 和 Erlang 并发的“原理”确实让我对一直在使用的工具有了更深的理解和欣赏。希望对你也有同样的效果!如需进一步阅读,请查看以下资源。

资源

文章来源:https://dev.to/sophiedebenedetto/elixir-and-the-beam-how-concurrency-really-works-354n