并发编程的搭车客指南

在 21 世纪初,时尚开端的未知后水中,存在着人类知识的一个小小子集。

在这个人类知识子集中,有一个非常不起眼的小学科,其冯·诺依曼血统的架构是如此原始,以至于人们仍然认为逆波兰表示法计算器是一个非常棒的想法。

这个学科——或者更确切地说曾经——有一个问题,那就是:大多数学习它的人在尝试编写并行软件时,大部分时间都很不开心。许多解决方案被提出来解决这个问题,但大多数解决方案主要关注处理称为锁和互斥量等的小逻辑片段,这很奇怪,因为总体而言,并非小逻辑片段需要并行。

因此,问题依然存在;许多人很刻薄,而且大多数人很痛苦,即使是那些使用逆波兰表示法计算器的人。

许多人越来越认为,他们试图将并行性添加到他们的编程语言中是一个错误,并且任何程序都不应该离开其初始线程。

注意:模仿《银河系漫游指南》很有趣。如果您还没有读过,请阅读这本书。它很好!

不要惊慌

嗨。今天(或者无论您在阅读本文的哪一天,甚至明天),我要告诉您有关并发 Erlang 的知识。您可能已经阅读过或处理过并发。 一个在电脑前的胖子 您也可能对多核编程的出现感到好奇。无论如何,您很可能因为近来关于并发性的讨论而阅读本书。

不过需要提醒您;本章主要为理论性内容。如果您头痛、不喜欢编程语言历史或只是想编程,您最好跳过到本章结尾,或跳过下一章(其中展示了更多实用知识)。

我已经在本书的介绍中解释过,Erlang 的并发性是基于消息传递和 Actor 模型,以人们仅通过信件进行通信为例。我稍后将更详细地解释它,但首先,我认为定义并发性并行性之间的差异非常重要。

在许多地方,这两个词都指的是同一个概念。在 Erlang 的上下文中,它们通常被用作两种不同的概念。对于许多 Erlang 用户来说,并发性指的是同时运行多个 Actor 的概念,但并非一定同时运行。并行性指的是 Actor 同时运行。我想说,在计算机科学的各个领域,似乎还没有对这些定义达成一致,但我会在本文中以这种方式使用它们。如果其他来源或人员使用相同的术语来表示不同的含义,请不要感到意外。

也就是说,Erlang 从一开始就具有并发性,即使在 80 年代,所有工作都在单核处理器上完成。每个 Erlang 进程都会有自己的时间片运行,就像桌面应用程序在多核系统出现之前所做的那样。

当时仍然可以实现并行性;您只需让第二台计算机运行代码并与第一台计算机通信即可。即使这样,在这种设置下,也只有两个 Actor 可以并行运行。如今,多核系统允许在单台计算机上实现并行性(一些工业芯片拥有数十个内核),而 Erlang 充分利用了这种可能性。

不要喝太多酷乐时
区分并发和并行非常重要,因为许多程序员认为 Erlang 比实际早几年就做好了多核计算机的准备。Erlang 直到 2000 年代中期才真正适应对称多处理,并且直到 2009 年该语言的 R13B 版本发布才在很大程度上完善了实现。在此之前,SMP通常必须禁用以避免性能损失。为了在没有 SMP 的多核计算机上实现并行性,您将启动 VM 的多个实例。

有趣的是,由于 Erlang 并发性完全是关于隔离进程的,因此在语言级别上没有进行任何概念性更改来将真正的并行性引入该语言。所有更改都在 VM 中透明地完成,远离程序员的视线。

并发性概念

Joe Armstrong, as in 'Erlang - The Movie

过去,Erlang 作为一种语言的开发速度非常快,并经常从在 Erlang 本身中使用 Erlang 开发电话交换机的工程师那里获得反馈。这些互动证明了基于进程的并发性和异步消息传递是模拟他们面临的问题的好方法。此外,电话世界在 Erlang 出现之前已经开始朝着并发性发展。这是从 PLEX(早些时候在爱立信创建的一种语言)和 AXE(用 PLEX 开发的一种交换机)继承而来的。Erlang 遵循这种趋势,并试图改进现有的工具。

在被认为足够好之前,Erlang 需要满足一些要求。主要要求是能够扩展并支持跨多个交换机的数千用户,然后实现高可靠性——达到永不停止代码的程度。

可扩展性

我将首先关注扩展。一些属性被认为是实现可扩展性的必要条件。因为用户将被表示为仅在某些事件(例如:接听电话、挂断电话等)发生时才做出反应的进程,所以理想的系统将支持进程执行少量计算,并在事件到来时非常快速地切换它们。为了提高效率,进程启动速度非常快、销毁速度非常快以及能够非常快地切换它们是有意义的。轻量级是实现此目标的必要条件。它也是必要的,因为您不想使用诸如进程池(在您之间分配工作的固定数量的进程)之类的东西。相反,设计可以根据需要使用任意数量的进程的程序会容易得多。

可扩展性的另一个重要方面是能够绕过硬件的限制。有两种方法可以做到这一点:改进硬件或添加更多硬件。第一个选项在一定程度上是有用的,之后它会变得非常昂贵(例如:购买超级计算机)。第二个选项通常更便宜,并且要求您添加更多计算机来完成工作。这就是分布式作为语言的一部分可能很有用的地方。

无论如何,回到小型进程,由于电话应用程序需要高度可靠性,因此决定,最简洁的方法是禁止进程共享内存。共享内存可能会在某些崩溃后将事物置于不一致状态(尤其是在跨不同节点共享的数据上),并且会带来一些复杂性。相反,进程应该通过发送消息进行通信,其中所有数据都将被复制。这可能会降低速度,但更安全。

容错性

这将我们引向了 Erlang 的第二种类型要求:可靠性。Erlang 的第一批编写者始终牢记,故障很常见。您可以尝试尽一切努力防止出现错误,但大多数情况下,一些错误仍然会发生。如果偶然没有出现错误,没有任何东西可以阻止硬件始终出现故障。因此,想法是找到处理错误和问题的好方法,而不是试图完全阻止它们。

事实证明,采用多个进程和消息传递的设计方法是一个好主意,因为错误处理可以相对轻松地移植到它上面。以轻量级进程(用于快速重启和关闭)为例。一些研究证明,大型软件系统停机的主要来源是间歇性或瞬态错误 (来源)。然后,有一个原则指出,破坏数据的错误应该尽快导致系统出现故障的部分死亡,以避免将错误和错误数据传播到系统的其余部分。另一个概念是,系统有许多不同的终止方式,其中两种是干净关闭和崩溃(以意外错误终止)。

这里最糟糕的情况显然是崩溃。一个安全的解决方案是确保所有崩溃都与干净关闭相同:这可以通过共享无和单一赋值(隔离进程的内存)等做法来完成,避免 (锁可能在崩溃期间未被解锁,阻止其他进程访问数据或将数据置于不一致状态)以及其他我不会过多介绍的内容,但都是 Erlang 设计的一部分。因此,您在 Erlang 中的理想解决方案是尽快杀死进程,以避免数据损坏和瞬态错误。轻量级进程是其中的关键因素。其他错误处理机制也是语言的一部分,允许进程监控其他进程(在错误和进程章节中进行了描述),以便了解进程何时死亡以及决定如何处理它。

假设快速重启进程足以处理崩溃,您遇到的下一个问题是硬件故障。当有人踢了运行它的计算机时,您如何确保程序继续运行? 由仙人掌和激光保护的服务器(HAL) 虽然包含激光探测和战略性放置的仙人掌的防御机制可以工作一段时间,但这不会永远持续下去。提示很简单,让您的程序在多台计算机上同时运行,这对于扩展来说也是必要的。这是独立进程在消息传递之外没有通信通道的另一个优势。无论它们是本地还是在不同的计算机上,它们的工作方式都相同,使程序员几乎可以透明地通过分布实现容错性。

分布式对进程如何相互通信有直接影响。分布式最大的障碍之一是您不能假设当您进行函数调用时某个节点(远程计算机)在那里,它在整个调用传输过程中仍然存在,或者它甚至会正确执行该调用。有人绊倒电缆或拔掉机器会让您的应用程序挂起。或者它可能会使应用程序崩溃。谁知道呢?

事实证明,选择异步消息传递也是一个不错的设计选择。在进程与异步消息模型下,消息从一个进程发送到另一个进程,并存储在接收进程的邮箱中,直到它们被取出读取。需要注意的是,消息的发送甚至不会检查接收进程是否存在,因为这样做没有意义。正如上一段所述,无法知道进程在消息发送和接收之间的时间段内是否会崩溃。即使消息被接收,也无法确定它是否会被处理,或者接收进程是否会在处理之前死亡。异步消息允许安全远程函数调用,因为没有关于将会发生什么的假设;程序员是唯一知道的人。如果需要确认消息是否已送达,则需要发送第二条消息作为对原始进程的回复。这条消息将具有相同的安全语义,任何基于此原则构建的程序或库也是如此。

实现

好的,因此决定采用轻量级进程和异步消息传递的方法来实现 Erlang。如何实现呢?首先,不能信任操作系统来处理进程。操作系统有许多不同的方法来处理进程,它们的性能差异很大。大多数,如果不是全部的话,对于标准 Erlang 应用程序来说都太慢或太重了。通过在虚拟机中实现,Erlang 开发者可以控制优化和可靠性。如今,Erlang 的进程大约占用 300 字节的内存,并且可以在几微秒内创建——这是当今主流操作系统无法做到的。

Erlang's run queues across cores

为了处理程序可能创建的所有这些潜在进程,虚拟机为每个核心启动一个充当调度器的线程。每个调度器都拥有一个运行队列,也就是一个包含 Erlang 进程的列表,调度器将在这些进程上分配一小段时间。当某个调度器的运行队列中有太多任务时,一些任务会被迁移到其他调度器。这意味着每个 Erlang 虚拟机负责进行所有负载均衡,程序员无需为此操心。还有一些其他优化措施,比如限制在超载进程上发送消息的速率,以便调节和分配负载。

所有这些难题都被隐藏在后台,由虚拟机自动管理。这就是 Erlang 轻松实现并行的原因。并行意味着,如果添加第二个核心,程序速度应该快两倍,如果有 4 个核心,速度应该快四倍,等等,对吧?这取决于具体情况。这种现象被称为线性扩展,指的是速度增益与核心或处理器数量之间的关系(见下图)。现实生活中,没有免费的午餐(嗯,葬礼上会有,但总得有人付费,在某个地方)。

与线性扩展并不完全相同

难以实现线性扩展并非语言本身的问题,而是要解决的问题的性质。扩展性非常好的问题通常被称为令人尴尬的并行问题。如果你在互联网上搜索令人尴尬的并行问题,你可能会找到一些例子,比如光线追踪(一种创建 3D 图像的方法)、密码学中的暴力破解搜索、天气预报等等。

因此,人们经常在 IRC 频道、论坛或邮件列表中提出问题,询问 Erlang 是否可以用来解决这类问题,或者是否可以用来在GPU上编程。答案几乎总是“不”。原因很简单:所有这些问题通常都与包含大量数据处理的数值算法有关。Erlang 在这方面并不擅长。

Erlang 的令人尴尬的并行问题存在于更高层级。通常,它们与聊天服务器、电话交换机、Web 服务器、消息队列、网络爬虫或任何其他可以将完成的工作表示为独立逻辑实体(演员,有人吗?)的概念有关。这类问题可以通过接近线性扩展的方式有效解决。

许多问题永远不会展现出这种扩展属性。事实上,只需要一个集中的操作序列就会导致所有扩展性的丧失。**你的并行程序运行速度取决于其最慢的顺序部分**。当你到购物中心购物时,就可以观察到这种现象。数百人可以同时购物,很少互相干扰。然后,一旦到了付款时间,只要收银员的数量少于准备离开的顾客数量,就会排起长队。

可以一直添加收银员,直到每个顾客都有一个收银员,但这时就需要为每个顾客设置一个门,因为他们不能同时进入或离开购物中心。

换句话说,即使顾客可以同时挑选他们想要的商品,并且无论他们是单独购物还是在商店里购物时有上千人,他们购物所需的时间基本一致,但他们仍然需要等待付款。因此,他们的购物体验永远不会比他们在队列中等待付款的时间短。

这个原理的泛化被称为阿姆达尔定律。它表明,当你在系统中添加并行性时,可以期望系统速度有多快,以及按什么比例加快。

Graphic showing a program's speedup relative to how much of it is parallel on many cores

根据阿姆达尔定律,50% 并行的代码速度永远不会超过之前的两倍,而 95% 并行的代码理论上可以预期,如果你添加足够的处理器,速度可以快约 20 倍。有趣的是,从这张图中可以看出,消除程序中最后几个顺序部分,与在从一开始就不是非常并行的程序中消除同样多的顺序代码相比,可以实现相对巨大的理论速度提升。

不要喝太多酷乐时
并行性并非解决所有问题的答案。在某些情况下,并行甚至会降低应用程序的速度。当程序是 100% 顺序执行的,但仍然使用多个进程时,就会发生这种情况。

最好的例子之一是环形基准测试。环形基准测试是一种测试,其中成千上万个进程会以循环的方式将数据依次传递给下一个进程。如果你想,可以把它想象成一场传声游戏。在这个基准测试中,一次只有一个进程执行有用的操作,但 Erlang 虚拟机仍然会花费时间将负载分配到各个核心,并让每个进程获得其应得的运行时间。

这与许多常见的硬件优化背道而驰,并导致虚拟机花费时间做无用功。这往往导致纯顺序应用程序在多核上的运行速度比单核慢得多。在这种情况下,禁用对称多处理 ($ erl -smp disable) 可能是一个好主意。

好了,谢谢大家,再见!

当然,本章如果没展示 Erlang 中并发所需的三个基本原语,就不会完整:生成新进程、发送消息和接收消息。在实践中,还需要更多机制才能构建真正可靠的应用程序,但现在这些就足够了。

我已经绕过了这个问题很多,还没有解释进程究竟是什么。事实上,它只是一个函数。就这样。它运行一个函数,一旦完成,它就会消失。从技术上讲,进程还具有一些隐藏状态(例如用于消息的邮箱),但现在函数就足够了。

为了启动新进程,Erlang 提供了函数 spawn/1,该函数接受一个函数并运行它

1> F = fun() -> 2 + 2 end.
#Fun<erl_eval.20.67289768>
2> spawn(F).
<0.44.0>

spawn/1 的结果 (<0.44.0>) 被称为进程标识符,社区中通常简称为PIDPidpid。进程标识符是一个任意值,表示虚拟机生命周期中存在的(或可能曾经存在过的)任何进程。它用作与进程通信的地址。

你会注意到,我们无法看到函数 F 的结果。我们只得到了它的 pid。这是因为进程不返回任何东西。

那么,我们如何看到 F 的结果呢?嗯,有两种方法。最简单的方法是只输出我们得到的值

3> spawn(fun() -> io:format("~p~n",[2 + 2]) end).
4
<0.46.0>

这对于实际程序来说不实用,但对于查看 Erlang 如何调度进程很有用。幸运的是,使用 io:format/2 足够让我们进行实验。我们将快速启动 10 个进程,并在函数 timer:sleep/1 的帮助下,暂停每个进程一段时间。该函数接受一个整数 N,并在恢复代码之前等待 N 毫秒。延迟后,进程中存在的值会被输出。

4> G = fun(X) -> timer:sleep(10), io:format("~p~n", [X]) end.
#Fun<erl_eval.6.13229925>
5> [spawn(fun() -> G(X) end) || X <- lists:seq(1,10)].
[<0.273.0>,<0.274.0>,<0.275.0>,<0.276.0>,<0.277.0>,
 <0.278.0>,<0.279.0>,<0.280.0>,<0.281.0>,<0.282.0>]
2   
1   
4   
3   
5   
8   
7   
6   
10  
9   

顺序没有意义。欢迎来到并行世界。由于进程同时运行,因此事件的顺序不再有保障。这是因为 Erlang 虚拟机使用许多技巧来决定何时运行一个进程或另一个进程,从而确保每个进程都能获得足够的运行时间。许多 Erlang 服务都是作为进程实现的,包括你正在使用的 shell。你的进程必须与系统本身所需的进程保持平衡,这可能是造成奇怪顺序的原因。

注意:无论是否启用对称多处理,结果都类似。为了证明这一点,你可以通过使用 $ erl -smp disable 启动 Erlang 虚拟机来测试它。

要查看你的 Erlang 虚拟机最初是在启用还是禁用 SMP 支持的情况下运行的,请启动一个没有选项的新虚拟机,并查看输出的第一行。如果你能看到文本 [smp:2:2] [rq:2],这意味着你正在启用 SMP 支持的情况下运行,并且你在两个核心上运行着两个运行队列 (rq,或调度器)。如果你只看到 [rq:1],这意味着你正在禁用 SMP 支持的情况下运行。

如果你想知道,[smp:2:2] 表示有两个核心可用,并且有两个调度器。 [rq:2] 表示有两个运行队列处于活动状态。在早期版本的 Erlang 中,你可以拥有多个调度器,但只有一个共享运行队列。从 R13B 开始,默认情况下每个调度器都拥有一个运行队列;这可以实现更好的并行性。

为了证明 shell 本身是作为普通进程实现的,我将使用 BIF self/0,它返回当前进程的 pid

6> self().
<0.41.0>
7> exit(self()).
** exception exit: <0.41.0>
8> self().
<0.285.0>

并且 pid 会改变,因为进程已经被重新启动。这背后的机制细节将在后面讲解。现在,还有更多基本内容需要介绍。现在最重要的是弄清楚如何发送消息,因为没有人愿意一直被困在输出进程结果值的循环中,然后手动将这些值输入到其他进程中(至少我知道我不愿意)。

实现消息传递所需的下一个原语是运算符 !,也称为感叹号。它在左侧接受一个 pid,在右侧接受任何 Erlang 项。然后,该项会被发送到由 pid 表示的进程,该进程可以访问该项

9> self() ! hello.
hello

消息已放入进程的邮箱,但尚未读取。此处显示的第二个hello是发送操作的返回值。这意味着可以通过以下方式将同一消息发送到多个进程:

10> self() ! self() ! double.
double

这等效于self() ! (self() ! double)。需要注意的是,进程的邮箱会按接收顺序保存消息。每次读取消息时,都会将其从邮箱中取出。同样,这与引言中关于人们写信的示例有点类似。

Message passing explained as a drawing, again

要查看当前邮箱的内容,可以在 shell 中使用flush()命令

11> flush().
Shell got hello
Shell got double
Shell got double
ok

此函数只是一个快捷方式,用于输出接收到的消息。这意味着我们仍然无法将进程的结果绑定到变量,但至少我们知道如何将它从一个进程发送到另一个进程,以及如何检查它是否已被接收。

发送没有人会阅读的消息就像写情绪诗一样;没什么用。这就是为什么我们需要receive语句。与其在 shell 中玩太久,不如写一个关于海豚的简短程序来学习它

-module(dolphins).
-compile(export_all).

dolphin1() ->
    receive
        do_a_flip ->
            io:format("How about no?~n");
        fish ->
            io:format("So long and thanks for all the fish!~n");
        _ ->
            io:format("Heh, we're smarter than you humans.~n")
    end.

如您所见,receive在语法上类似于case ... of。实际上,这些模式的工作方式完全相同,只是它们绑定来自消息的变量,而不是caseof之间的表达式。接收还可以有守卫

receive
    Pattern1 when Guard1 -> Expr1;
    Pattern2 when Guard2 -> Expr2;
    Pattern3 -> Expr3
end

现在可以编译上面的模块,运行它,并开始与海豚进行通信

11> c(dolphins).
{ok,dolphins}
12> Dolphin = spawn(dolphins, dolphin1, []).
<0.40.0>
13> Dolphin ! "oh, hello dolphin!".
Heh, we're smarter than you humans.
"oh, hello dolphin!"
14> Dolphin ! fish.                
fish
15> 

这里介绍了一种使用spawn/3生成的新方法。spawn/3不是接受单个函数,而是接受模块、函数及其参数作为自己的参数。函数运行后,会发生以下事件

  1. 函数遇到receive语句。鉴于进程的邮箱为空,我们的海豚会等待,直到它收到消息;
  2. 收到消息"oh, hello dolphin!"。该函数尝试与do_a_flip进行模式匹配。这失败了,因此尝试fish模式,同样失败。最后,消息满足了catch-all子句(_)并匹配。
  3. 进程输出消息"Heh, we're smarter than you humans."

然后需要注意的是,如果我们发送的第一条消息成功了,第二条消息却没有任何反应,这与进程<0.40.0>有关。这是因为一旦我们的函数输出"Heh, we're smarter than you humans.",它就会终止,进程也会随之终止。我们需要重新启动海豚

8> f(Dolphin).    
ok
9> Dolphin = spawn(dolphins, dolphin1, []).
<0.53.0>
10> Dolphin ! fish.
So long and thanks for all the fish!
fish

这一次,鱼消息起作用了。能从海豚那里收到回复,而不是必须使用io:format/2,这不是很方便吗?当然方便(我为什么要问?)。我在本章前面提到过,知道进程是否接收到消息的唯一方法是发送回复。我们的海豚进程需要知道要回复谁。这就像邮政服务一样。如果我们希望有人知道我们的信件,我们需要添加我们的地址。在 Erlang 中,这是通过将进程的 pid 打包到元组中来实现的。最终结果是一条消息,看起来有点像{Pid, Message}。让我们创建一个新的海豚函数,它将接受这样的消息

dolphin2() ->
    receive
        {From, do_a_flip} ->
            From ! "How about no?";
        {From, fish} ->
            From ! "So long and thanks for all the fish!";
        _ ->
            io:format("Heh, we're smarter than you humans.~n")
    end.

如您所见,我们不再接受do_a_flipfish作为消息,而是需要一个变量From。进程标识符将放在那里。

11> c(dolphins).
{ok,dolphins}
12> Dolphin2 = spawn(dolphins, dolphin2, []).
<0.65.0>
13> Dolphin2 ! {self(), do_a_flip}.          
{<0.32.0>,do_a_flip}
14> flush().
Shell got "How about no?"
ok

看起来效果很好。我们可以收到对我们发送的消息的回复(我们需要在每条消息中添加一个地址),但我们仍然需要为每次调用启动一个新进程。递归是解决此问题的办法。我们只需要让函数自己调用,这样它就永远不会结束,并且始终会期望收到更多消息。以下是一个dolphin3/0函数,它将此付诸实践

dolphin3() ->
    receive
        {From, do_a_flip} ->
            From ! "How about no?",
            dolphin3();
        {From, fish} ->
            From ! "So long and thanks for all the fish!";
        _ ->
            io:format("Heh, we're smarter than you humans.~n"),
            dolphin3()
    end.

这里,catch-all子句和do_a_flip子句都借助dolphin3/0进行循环。请注意,该函数不会导致栈溢出,因为它尾递归。只要只发送这些消息,海豚进程就会无限循环。但是,如果我们发送fish消息,进程就会停止

15> Dolphin3 = spawn(dolphins, dolphin3, []).
<0.75.0>
16> Dolphin3 ! Dolphin3 ! {self(), do_a_flip}.
{<0.32.0>,do_a_flip}
17> flush().
Shell got "How about no?"
Shell got "How about no?"
ok
18> Dolphin3 ! {self(), unknown_message}.     
Heh, we're smarter than you humans.
{<0.32.0>,unknown_message}
19> Dolphin3 ! Dolphin3 ! {self(), fish}.
{<0.32.0>,fish}
20> flush().
Shell got "So long and thanks for all the fish!"
ok

这应该是关于dolphins.erl的所有内容。如您所见,它确实符合我们预期的行为,即每条消息回复一次,之后继续运行,除了fish调用。海豚受不了我们疯狂的人类行为,永远离开了我们。

A man asking a dolphin to do a flip. The dolphin (dressed like the fonz) replies 'how about no?'

就是这样。这是所有 Erlang 并发性的核心。我们已经看到了进程和基本的消息传递。为了创建真正有用和可靠的程序,还需要了解更多概念。我们将在下一章中看到其中的一些概念,并在后续的章节中看到更多概念。