Erlang 是一种旨在让凡人编写、测试、部署和调试容错并发软件的语言。1 它于 20 世纪 80 年代后期在瑞典电信公司 Ericsson 开发,最初是用于开发管理电话交换机的软实时软件的平台。2 此后,它已开源并移植到多个常用平台,不仅在分布式互联网服务器应用程序中找到了自然的契合点,而且在图形用户界面和普通批处理应用程序中也找到了自然的契合点。
Erlang 的最少并发原语集及其丰富且常用的库,为任何尝试设计并发程序的人提供了指导。Erlang 为并发编程提供了有效的平台,原因如下:
本文介绍了 Erlang 语言,并展示了如何在实践中使用它来正确且快速地实现并发程序。
Erlang 构建于少量的顺序编程类型和概念,以及更少量的并发编程类型和概念之上。那些想要全面介绍的人可以在 Web 上找到几个优秀的教程3,但以下示例(函数式编程联盟法规要求)应该传达要点。
如图 1A 所示,每个 Erlang 代码文件都是一个模块。文件中的声明命名模块(必须与文件名匹配)并声明可以从其他模块调用的函数。注释从百分号 (%) 运行到行尾。
阶乘由两个函数实现。两者都名为 factorial,但它们的参数数量不同;因此,它们是不同的。factorial/2(双参数版本)的定义分为两个子句,用分号分隔。当调用 factorial/2 时,实际参数将依次针对每个子句头中的模式进行测试以找到第一个匹配项,然后评估主体(箭头之后)。主体中最后一个表达式的值是调用的返回值;不需要显式的 return 语句。Erlang 是动态类型的,因此调用 factorial(“pancake”) 将会编译,但在无法匹配任何子句时会引发运行时异常。尾调用已优化,因此此代码将在恒定空间中运行。
列表用方括号括起来(参见图 1B)。单根竖线将第一个元素与列表的其余部分分隔开。如果在子句头模式中使用列表,它将匹配列表值,将其分离成其组件。带有双竖线的列表是“列表推导式”,通过生成器和过滤器表达式构造列表。双加号 (++) 连接列表。
元组(向量)用花括号括起来(参见图 1C)。模式中的元组将从它们匹配的元组中提取组件。以大写字母开头的标识符是变量;以小写字母开头的标识符是原子(符号常量,例如枚举值,但无需定义数值表示形式)。布尔值简单地表示为原子 true 和 false。模式中的下划线 (_) 匹配任何值,并且不创建绑定。如果同一个新变量在模式中出现多次,则出现必须相等才能匹配。Erlang 中的变量是单次赋值的(一旦变量绑定到值,该值就永远不会更改)。
并非所有列表处理操作都可以用列表推导式表示。当我们确实需要直接编写列表处理代码时,常见的习惯用法是为一个子句处理空列表,另一个子句处理非空列表的第一个元素。图 1D 中显示的 foldl/3 函数是一个常见的实用程序,它将双参数函数链接到列表上,并由初始值作为种子。Erlang 允许在运行时定义匿名函数(“fun”或闭包),作为参数传递或从函数返回。
Erlang 具有看起来像赋值但具有不同语义的表达式。= 的右侧被评估,然后与左侧的模式匹配,就像在选择子句以匹配函数调用时一样。模式中的新变量将与右侧的相应值匹配。
让我们通过翻译一个来自 Java 的小例子来介绍并发 Erlang
Sequence.java
// A shared counter.
public class Sequence {
private int nextVal = 0;
// Retrieve counter and increment.
public synchronized int getNext() {
return nextVal++;
}
// Re-initialize counter to zero.
public synchronized void reset() {
nextVal = 0;
}
}
序列作为堆上的对象创建,可能可以被多个线程访问。synchronized 关键字意味着所有调用该方法的线程必须首先获取对象的锁。在锁的保护下,共享状态被读取和更新,返回预递增值。没有这种同步,两个线程可能会从 getNext() 获取相同的值,或者 reset() 的效果可能会被忽略。
让我们从 Erlang 的“原始”方法开始,直接使用并发原语。
sequence1.erl (raw implementation)
-module(sequence1).
-export([make_sequence/0, get_next/1, reset/1]).
% Create a new shared counter.
make_sequence() ->
spawn(fun() -> sequence_loop(0) end).
sequence_loop(N) ->
receive
{From, get_next} ->
From ! {self(), N},
sequence_loop(N + 1);
reset ->
sequence_loop(0)
end.
% Retrieve counter and increment.
get_next(Sequence) ->
Sequence ! {self(), get_next},
receive
{Sequence, N} -> N
end.
% Re-initialize counter to zero.
reset(Sequence) ->
Sequence ! reset.
spawn/1 原语创建一个新的进程,并将其进程标识符 (pid) 返回给调用者。Erlang 进程就像线程一样,是一个独立调度的顺序活动,具有自己的调用堆栈,但像操作系统进程一样,它不与其他进程共享数据——进程仅通过相互发送消息来交互。self/0 原语返回调用者的 pid。pid 用于向进程寻址消息。在这里,pid 也是数据抽象——序列只是服务器进程的 pid,该服务器进程理解我们特定于序列的消息传递协议。
新进程开始执行 spawn/1 中指定的函数,并在该函数返回时终止。因此,长寿命进程避免过早返回,通常通过执行循环函数。尾调用优化确保堆栈不会在诸如 sequence_loop/1 之类的函数中增长。序列进程的状态在对此永恒调用的参数中传递。
消息使用语法 pid ! message 发送。消息可以是任何 Erlang 值,并且它是原子且不可变地发送的。消息被放置在接收进程的邮箱中,并且发送者继续执行——它不等待接收进程检索消息。
进程使用 receive 表达式从其邮箱中提取消息。它指定一组模式和关联的处理程序代码,并扫描邮箱以查找与任何模式匹配的第一个消息,如果没有找到此类消息,则会阻塞。这是 Erlang 中唯一的阻塞原语。与函数子句中的模式一样,receive 选项中的模式匹配结构并绑定新变量。如果模式使用已绑定到值的变量,则匹配模式需要与该值匹配,如 get_next/1 中 receive 表达式中 Sequence 的值一样。
此处的代码实现了一个简单的客户端-服务器协议。在调用中,客户端进程向服务器进程发送请求消息并阻塞等待响应消息。在这里,get_next/1 调用请求消息是一个双元素元组:客户端自己的 pid,后跟原子 get_next。客户端发送自己的 pid 以让服务器知道将响应发送到哪里,并且 get_next 原子将让我们区分此协议操作与其他协议操作。服务器使用它自己的双元素元组进行响应:服务器 pid,后跟检索到的计数器值。包括服务器 pid 让客户端可以将此响应与可能位于其邮箱中的其他消息区分开来。
cast 是对不需要响应的服务器的请求,因此协议只是一个请求消息。reset/1 cast 的请求消息只是一个裸原子。
尽管 Erlang 序列实现很简单,但它比原始 Java 版本要长得多且不太清晰。然而,许多代码并非特定于序列,因此应该可以将所有客户端-服务器协议通用的消息传递机制提取到公共库中。
由于我们希望使协议独立于序列的细节,因此我们需要稍微更改一下它。首先,我们通过显式标记每种请求消息来区分客户端调用请求和 cast 请求。其次,我们通过使用每个调用的唯一值标记请求和响应来加强请求和响应的关联。有了这样一个唯一值,我们就可以使用它来代替服务器 pid 来区分回复。
如图 2 所示,服务器模块包含与 sequence1 模块相同的结构,但移除了特定于序列的部分。语法 Module:function 在运行时由原子指定的模块中调用函数。唯一标识符由 make_ref/0 原语生成。它返回一个新的引用,该引用是一个保证与程序中可能发生的所有其他值不同的值。
序列的服务器端现在简化为三个单行函数,如图 3 所示。此外,它们是纯粹的顺序、函数式和确定性的,没有消息传递。这使得编写、分析、测试和调试更加容易,因此投入了一些示例单元测试。
Erlang 对协议模式的抽象称为行为。(我们使用英联邦拼写,因为那是 Erlang 源代码注释中使用的拼写。)行为由实现通用通信模式的库以及回调函数的预期签名组成。行为的实例需要一些接口代码来包装对库的调用以及实现回调,所有这些代码都很大程度上避免了消息传递。
代码的这种隔离提高了稳健性。当回调函数避免消息传递原语时,它们变得确定性并且经常表现出简单的静态类型。相比之下,行为库代码是不确定的,并且对静态类型分析提出了挑战。然而,行为通常经过充分测试并且是标准库的一部分,从而使应用程序程序员更容易完成仅编码回调的任务。
回调具有纯函数式接口。有关任何触发消息和当前行为状态的信息作为参数给出,并且传出消息和新状态在返回值中给出。进程的“永恒循环函数”在库中实现。这允许对回调函数进行简单的单元测试。
大型 Erlang 应用程序大量使用行为——直接使用原始消息发送或接收表达式是不常见的。在 Ericsson AXD301 电信交换机(已知最大的 Erlang 项目,超过一百万行代码)中,几乎所有应用程序代码都使用标准行为,其中大部分是服务器行为。4
Erlang 的 OTP 标准库提供了三种主要行为
通用服务器 (gen_server)。 通用服务器是最常见的行为。它抽象了分布式计算中客户端-服务器或远程过程调用协议中使用的标准请求-响应消息模式。它提供了超出我们简单服务器模块的复杂功能
通用有限状态机 (gen_fsm)。许多并发算法都以有限状态机模型来指定。OTP 库为此模式提供了方便的行为。它遵循的消息协议允许客户端向状态机发出事件信号,并可能等待同步回复。特定于应用程序的回调处理这些事件,接收当前状态并将新状态作为返回值传递。
通用事件处理程序 (gen_event)。 事件管理器是一个进程,它接收事件作为传入消息,然后将这些事件分派给任意数量的事件处理程序,每个事件处理程序都有自己的回调函数模块和自己的私有状态。处理程序可以动态添加、更改和删除。事件处理程序运行事件的应用程序代码,经常选择一个子集来采取操作并忽略其余部分。此行为自然地模拟了日志记录、监视和“发布/订阅”系统。OTP 库为将事件假脱机到文件或远程进程或主机提供了现成的事件处理程序。
行为库为运行程序的动态调试提供了功能。可以请求它们显示当前行为状态、生成接收和发送消息的跟踪以及提供统计信息。所有应用程序自动提供此功能为 Erlang 程序员在交付生产质量的系统方面提供了深刻的优势。
Erlang 应用程序可以使用自然适合标准行为的长寿命进程来实现其大部分功能。然而,许多应用程序还需要动态创建并发活动,通常遵循更临时的协议,这些协议太不寻常或太微不足道,无法在标准库中捕获。
假设我们有一个客户端想要并行进行多个服务器调用。一种方法是直接发送服务器协议消息,如图 4A 所示。客户端向所有服务器发送格式良好的服务器调用消息,然后收集它们的回复。回复可能会以任何顺序到达收件箱,但 collect_replies/1 将按照原始列表的顺序收集它们。客户端可能会阻塞等待下一个回复,即使其他回复可能正在等待。然而,这不会减慢速度,因为整体操作的速度由最慢的调用决定。
为了重新实现协议,我们不得不打破服务器行为提供的抽象。虽然这对于我们的玩具示例来说很简单,但 Erlang 标准库中生产质量的通用服务器要复杂得多。用于监视服务器进程和超时管理的计算的设置将使此代码运行几页,并且如果在标准库中添加了新功能,则需要重写它。
相反,我们可以通过使用工作进程(不执行标准行为的短寿命、特殊用途进程)来完全重用现有的行为代码。使用工作进程,此代码变为图 4B 中所示的代码。
我们为每个调用生成一个新的工作进程。每个进程发出请求的调用,然后使用自己的 pid 作为标签回复父进程。然后,父进程依次接收每个回复,并将它们收集在一个列表中。服务器调用的客户端代码按原样完全重用。
通过使用工作进程,库可以自由地根据需要使用 receive 表达式,而无需担心阻塞其调用者。如果调用者不希望阻塞,则始终可以自由生成工作进程。
尽管 Erlang 消除了共享状态,但它并非免受竞争的影响。服务器行为允许其应用程序代码作为访问受保护数据的临界区执行,但始终可能错误地绘制此保护线。
例如,如果我们已经使用原始原语实现了序列来读取和写入计数器,那么我们将像忘记获取锁的共享状态实现一样容易受到竞争的影响
badsequence.erl % BAD - race-prone implementation - do not use - BAD -module(badsequence). -export([make_sequence/0, get_next/1, reset/1]). -export([init/0, handle_call/2, handle_cast/2]).
% API make_sequence() -> server:start(badsequence). get_next(Sequence) -> N = read(Sequence), write(Sequence, N + 1), % BAD: race! N. reset(Sequence) -> write(Sequence, 0). read(Sequence) -> server:call(Sequence, read). write(Sequence, N) -> server:cast(Sequence, {write, N}).
% Server callbacks
init() -> 0.
handle_call(read, N) -> {N, N}.
handle_cast({write, N}, _) -> N.
此代码是隐蔽的,因为它将通过简单的单元测试,并且可以在现场可靠地运行很长时间,然后才会静默地遇到错误。然而,客户端包装器和服务器端回调看起来与正确实现的回调完全不同。相比之下,不正确的共享状态程序看起来几乎与正确的程序相同。需要训练有素的眼睛来检查共享状态程序并注意到丢失的锁请求。
并发编程中的所有标准错误在 Erlang 中都有等价物:竞争、死锁、活锁、饥饿等等。即使在 Erlang 提供的帮助下,并发编程也远非易事,并且并发的不确定性意味着始终难以知道最后一个错误何时被消除。
测试有助于消除大多数严重错误——在测试用例模拟现场遇到的行为的程度上。注入定时抖动并允许长时间的老化时间将有助于覆盖率;并发系统中可能的事件排序的组合爆炸意味着无法针对所有可能的情况测试任何重要的应用程序。
当合理的测试工作达到尾声时,剩下的错误通常是海森堡错误5,它们以不确定的方式但很少发生。只有当执行中出现一些不寻常的定时模式时才能看到它们。它们是调试的祸根,因为它们难以重现,但这种诅咒也是一种伪装的祝福。如果海森堡错误难以重现,那么如果您重新运行计算,您可能看不到该错误。这表明并发程序中的缺陷虽然不可避免,但可以通过自动重试机制来减轻其影响——只要可以检测和约束初始错误事件的影响。
Erlang 是一种安全语言——所有运行时故障,例如除以零、超出范围的索引或向已终止的进程发送消息,都会导致明确定义的行为,通常是异常。应用程序代码可以安装异常处理程序以包含和从预期故障中恢复,但未捕获的异常意味着该进程无法继续运行。这样的进程被称为已失败。
有时进程可能会陷入无限循环而不是公开失败。我们可以使用内部看门狗进程来防止进程卡住。这些看门狗定期调用正在运行的应用程序的各个角落,理想情况下会引起一系列涵盖所有长寿命进程的事件,并且如果在慷慨但有限的超时时间内没有收到响应,则会失败。进程故障是 Erlang 中检测错误的统一方式。
Erlang 的错误处理理念源于以下观察:任何稳健的硬件集群都必须至少由两台机器组成,其中一台机器可以对另一台机器的故障做出反应并采取措施进行恢复。6 如果恢复机制在损坏的机器上,它也会损坏。恢复机制必须在故障范围之外。在 Erlang 中,进程不仅是并发的单位,也是故障的范围。由于进程不共享状态,因此进程中的致命错误会使其状态不可用,但不会破坏其他进程的状态。
Erlang 提供了两个原语,供一个进程注意另一个进程的故障。建立对另一个进程的监视会创建故障的单向通知,而链接两个进程会建立相互通知。监视用于临时关系,例如客户端-服务器调用,而相互链接用于更永久的关系。默认情况下,当故障通知传递到链接进程时,它也会导致接收者失败,但可以设置进程本地标志以将故障通知转换为可以由 receive 表达式处理的普通消息。
在通用应用程序编程中,稳健的服务器部署包括一个外部“保姆”,它将监视正在运行的操作系统进程,并在进程失败时重新启动它。重新启动的进程通过从磁盘读取其持久状态来重新初始化自身,然后恢复运行。任何挂起的操作和易失性状态都将丢失,但假设持久状态没有不可修复地损坏,则服务可以恢复。
Erlang 版本的保姆是supervisor行为。supervisor 进程生成一组子进程并将它们链接起来,以便在它们失败时通知它。supervisor 使用初始化回调来指定策略和子规范列表。子规范给出有关如何启动新子进程的说明。策略告诉 supervisor 如果其子进程之一死亡该怎么办:重新启动该子进程、重新启动所有子进程或其他几种可能性。如果子进程死于持久性状况而不是错误的命令或罕见的海森堡错误,那么重新启动的子进程将再次失败。为了避免永远循环,supervisor 的策略还给出了最大重启速率。如果重启次数超过此速率,则 supervisor 本身将失败。
子进程可以是运行正常行为的进程,也可以是 supervisor 本身,从而产生 supervisor 的树状结构。如果重启未能清除错误,则它将触发 supervisor 子树故障,从而导致更大范围的重启。在 supervisor 树的根部,应用程序可以选择总体策略,例如永远重试、退出或可能重新启动 Erlang 虚拟机。
由于链接是双向的,因此失败的服务器将通知或使它下面的子进程失败。临时工作进程通常生成并链接到其长寿命父进程。如果父进程失败,工作进程也会自动失败。此链接可防止未收集的工作进程在系统中累积。在正确编写的 Erlang 应用程序中,所有进程都链接到 supervisor 树中,以便顶层 supervisor 重启可以清理所有正在运行的进程。
通过这种方式,易受偶尔的死锁、饥饿或无限循环影响的并发 Erlang 应用程序仍然可以在现场无人值守的情况下稳健地工作。
Erlang 的并发性建立在进程生成和消息传递的简单原语之上,并且其编程风格建立在这些原语具有低开销的假设之上。进程的数量也必须可扩展——想象一下,如果系统中不能超过几百个对象,面向对象的编程会受到多大的限制。
为了使 Erlang 具有可移植性,它不能假设其主机操作系统具有快速的进程间通信和上下文切换,或者允许内核中真正可扩展数量的可调度活动。因此,Erlang 模拟器(虚拟机)负责用户级别的调度、内存管理和消息传递。
Erlang 实例是一个操作系统进程,其中运行着多个操作系统线程,这些线程可能跨多个处理器或内核进行调度。这些线程执行用户级调度程序以运行 Erlang 进程。调度的进程将运行直到它阻塞或直到其时间片用完。由于该进程正在运行 Erlang 代码,因此模拟器可以安排调度片在进程上下文最小时结束,从而最大限度地减少上下文切换时间。
每个进程都有一个小的专用内存区域用于其堆和栈。两代复制垃圾回收器回收存储空间,并且内存区域可能会随着时间的推移而增长。大小从小开始——几百个机器字——但可以增长到千兆字节。Erlang 进程堆栈与模拟器中的 C 运行时堆栈分开,并且没有最小大小或所需粒度。这使得进程轻量级。
默认情况下,Erlang 模拟器解释编译器生成的中间代码。许多重要的 Erlang 程序可以在不使用本机代码编译器的情况下足够快地运行。这是因为 Erlang 是一种高级语言,并且处理大型抽象对象。运行时,即使是解释器也将大部分时间花在用 C 编写的高度调整的运行时系统内执行。例如,在网络套接字之间复制批量数据时,解释的 Erlang 的性能与执行相同任务的自定义 C 程序相当。7
实现效率的重要测试是工作进程习惯用法的实用性,如前面所示的 multicall2 代码所示。生成工作进程似乎比直接发送消息效率低得多。父进程不仅必须生成和销毁进程,而且工作进程还需要额外的消息跳来返回结果。在大多数编程环境中,这些开销将是令人望而却步的,但在 Erlang 中,并发原语(包括进程生成)足够轻量级,以至于开销通常可以忽略不计。
工作进程不仅具有可忽略不计的开销,而且在许多情况下还提高了效率。当进程退出时,可以立即回收其所有内存。短寿命进程甚至可能不需要垃圾回收周期。每个进程的堆也消除了全局垃圾回收暂停,实现了软实时级别的延迟。因此,Erlang 程序员避免使用可重用的进程池,而是在需要时创建新进程并在之后丢弃它们。
由于 Erlang 中的值是不可变的,因此在发送消息时是复制消息还是按引用发送消息取决于实现。在所有情况下,复制似乎都是较慢的选择,但按引用发送消息需要进程之间垃圾回收的协调:共享堆空间或维护区域间链接。对于许多应用程序,复制的开销与短垃圾回收时间和从临时进程中快速回收空间的好处相比,是很小的。复制的低惩罚是由按复制发送中的一个重要例外驱动的:原始二进制数据始终按引用发送,这不会使垃圾回收复杂化,因为原始二进制数据不能包含指向其他结构的指针。
Erlang 模拟器可以在不到一微秒的时间内创建一个新的 Erlang 进程,并同时运行数百万个进程。每个进程占用不到一千字节的空间。消息传递和上下文切换需要数百纳秒。
由于其性能特点以及语言和库支持,Erlang 特别适合用于
那么,Erlang 在什么情况下不是合适的编程语言呢,无论是出于效率还是其他原因?Erlang 往往不适合用于
然而,Erlang 仍然可以与其他语言结合使用,成为更大解决方案的一部分。至少,Erlang 程序可以通过标准进程间通信机制以文本或二进制协议进行通信。此外,Erlang 提供了一个 C 库,其他应用程序可以链接该库,这将允许它们发送和接收 Erlang 消息并受到 Erlang 控制程序的监视,对它来说就像另一个(Erlang)进程一样。
随着并发编程的重要性日益提高,Erlang 受到的关注和采用也越来越多。实际上,Erlang 被标榜为“面向并发”的语言。标准 Erlang 发行版正在积极开发中。许多高质量的库和应用程序可免费用于
多家公司提供在 Erlang 中实现的电信、电子支付系统和社交网络聊天商业产品和服务。基于 Erlang 的 Web 服务器以其高性能和可扩展性而闻名。8
并发编程永远不会容易,但借助 Erlang,开发人员有机会使用一种从头开始为该任务构建的语言,并在语言、运行时系统和标准库中设计了令人难以置信的弹性。
标准 Erlang 实现及其文档已移植到 Unix 和 Microsoft Windows 平台,是开源的,可从 https://erlang.org.cn 免费下载。您可以在 http://trapexit.org 找到社区论坛,该论坛也镜像了多个邮件列表。
JIM LARSON 是 Google 的一名软件工程师。自 1999 年以来,他断断续续地从事 Erlang 商业产品的工作。他是 Amazon.com 的 Amazon SimpleDB Web 服务复制引擎的架构师。他之前曾在 Sendmail Inc. 和喷气推进实验室工作。他拥有圣奥拉夫学院的数学学士学位、克莱蒙特研究生大学的数学硕士学位以及俄勒冈大学的计算机科学硕士学位。
最初发表于 Queue vol. 6, no. 5—
在 数字图书馆 中评论本文
Adam Morrison - 多核程序中的同步扩展
为现代多核处理器设计软件提出了一个难题。传统的软件设计,其中线程操作共享数据,其可扩展性有限,因为对共享数据更新的同步会串行化线程并限制并行性。替代的分布式软件设计,其中线程不共享可变数据,消除了同步并提供了更好的可扩展性。但是,分布式设计使得实现共享数据结构自然提供的功能(例如动态负载平衡和强一致性保证)具有挑战性,并且并非适用于每个程序。然而,通常,共享可变数据结构的性能受到当今使用的同步方法的限制,无论是基于锁的还是无锁的。
Fabien Gaud, Baptiste Lepers, Justin Funston, Mohammad Dashti, Alexandra Fedorova, Vivien Quéma, Renaud Lachaize, Mark Roth - 现代 NUMA 系统上内存管理的挑战
现代服务器级系统通常由多个多核芯片组合在一个系统中构建。每个芯片都有一个本地 DRAM(动态随机存取存储器)模块;它们一起被称为一个节点。节点通过高速互连连接,并且系统是完全连贯的。这意味着,对于程序员来说是透明的,一个核心可以向其节点的本地内存以及其他节点的内存发出请求。关键的区别在于,远程请求将花费更长的时间,因为它们会受到更长的线路延迟的影响,并且可能必须在遍历互连时跳过多个跃点。
Spencer Rathbun - 使用 Promise 的并行处理
在当今世界,有很多理由编写并发软件。提高性能和增加吞吐量的愿望导致了许多不同的异步技术。然而,所涉及的技术通常很复杂,并且是许多细微错误的根源,特别是当它们需要共享可变状态时。如果不需要共享状态,那么这些问题可以通过称为 promise 的更好抽象来解决。这些 promise 允许程序员将异步函数调用连接在一起,等待每个调用返回成功或失败,然后再运行链中的下一个适当函数。
Davidlohr Bueso - 实用同步原语的可扩展性技术
在理想的世界中,应用程序有望在越来越大的系统上执行时自动扩展。然而,在实践中,不仅不会发生这种扩展,而且在更大的系统上看到性能实际下降是很常见的。