演员模型:为什么Erlang/OTP很特别?你可以使用其他编程语言吗?

79

我一直在研究学习Erlang/OTP,因此已经开始阅读(好吧,浏览)有关Actor模型的内容。

据我所知,Actor模型只是一组函数(在Erlang/OTP中在名为“进程”的轻量级线程中运行),它们通过消息传递彼此通信。

这似乎很容易在C++或任何其他语言中实现:

class BaseActor {
    std::queue<BaseMessage*> messages;
    CriticalSection messagecs;
    BaseMessage* Pop();
public:
    void Push(BaseMessage* message)
    {
        auto scopedlock = messagecs.AquireScopedLock();
        messagecs.push(message);
    }
    virtual void ActorFn() = 0;
    virtual ~BaseActor() {} = 0;
}

每个进程都是派生自BaseActor的实例。Actor之间只通过消息传递进行通信(即推送)。Actor在初始化时向中央地图注册,允许其他Actor找到它们,并允许中央函数运行它们。

现在,我明白我忽略或者说掩盖了一个重要问题,那就是: 缺乏yielding意味着单个Actor可能会不公平地消耗过多的时间。但在C++中,跨平台协程是使这种情况变得困难的主要因素吗?(例如,Windows有纤程。)

除此之外还有什么我忽略的吗?或者说这个模型真的如此明显吗?


5
编程语言的目的是帮助表达想法或规范。在 Erlang 中,演员模型是隐含的,因此您可以在任一语言中表达您的想法,但在 Erlang 中会更好,因为模板代码已为您完成。 - GManNickG
3
一旦模板完成(我认为这将是一次性的),有什么好处? - Seth Carnegie
4
确实,这就是我的问题要点。 - Jonathan Winks
15
Erlang进程可以驻留在同一台机器上,也可以驻留在不同的物理机器上(编写实际代码几乎相同),因此您的示例似乎是一种过度简化。另外,对于代码的热交换,C++是否也可以轻松实现?您的C++ actor是否具有内存沙箱保护? - Kevin
20
如果你能够像使用Erlang语言那样轻松地编写出安全、可靠、并发和易于维护的C++代码,那就去尝试吧。但这段话中还有很多问题。 Erlang语言的核心是可靠性。如果进程无法完成其任务,它会失败并将该失败消息传播到整个系统,从而允许依赖关系图在各种类型的故障(或错误)上重新组织自己。你确实可以这样做,但你应该问为什么没有人这样做。这就是新语言产生的原因。 - Dustin
显示剩余10条评论
6个回答

88
C++代码没有处理公平性、隔离性、故障检测或分布式,这些都是Erlang作为其Actor模型的一部分带来的东西。
  • 不允许任何一个Actor挨饿(公平性)
  • 如果一个Actor崩溃,它只应影响该Actor(隔离性)
  • 如果一个Actor崩溃,其他Actor应能够检测并对该崩溃做出反应(故障检测)
  • Actors应能够像在同一台机器上一样在网络上通信(分布式)

此外,beam SMP仿真器带来了Actor的JIT调度,将它们移动到目前利用率最低的核心,并在某些核心上休眠线程,如果不再需要它们。

此外,所有用Erlang编写的库和工具都可以假定这是世界运行的方式,并相应地进行设计。

这些事情在C++中并非不可能实现,但如果考虑到Erlang可以在几乎所有主要的硬件和操作系统配置上运行,那么它们会变得越来越困难。

编辑:刚刚发现Ulf Wiger关于他如何看待Erlang风格并发的描述。


1
我肯定会在Erlang并发模型中包含进程隔离错误处理,否则Ulf所写的内容非常好。 - rvirding
5
你列出的所有属性都由操作系统提供给进程。C++程序和其他任何程序都可以轻松利用它们。我认为Erlang的关键在于,其actor比操作系统进程更便宜,以提供这些属性。因此,可以更自由地使用actors。 - Karmastan
3
@Karmastan 是的,Erlang 进程非常便宜,因为并发是构建应用程序的基本抽象。我们更喜欢称它们为进程而不是演员,因为在设计 Erlang 时我们还没听说过演员这个概念。 :-) - rvirding

33

我不喜欢引用自己,但来自Virding的编程第一原则:

在其他语言中,任何足够复杂的并发程序都包含一个特定而非正式指定的错误且缓慢的Erlang实现。

和Greenspun有关。Joe(Armstrong)有类似的规则。

问题不是如何实现actor,这并不是那么困难。问题在于让所有东西协同工作:进程、通信、垃圾回收、语言基元、错误处理等等……例如,使用操作系统线程扩展性很差,因此需要自己动手实现。这就像试图“销售”一种只能拥有1k个对象且创建和使用都很费劲的面向对象语言一样。从我们的观点来看,并发是构建应用程序的基本抽象。

有点冲动,所以到此为止吧。


所以我会在这里停下来:更多的内容可能会很有趣。 - serv-inc

23

这实际上是一个非常好的问题,已经得到了优秀的回答,但或许还不足以令人信服。

为了强调和补充其他出色的回答,考虑一下 Erlang 相对于传统通用语言(如 C/C++)为了实现容错性和可用性所“削减”的东西。

首先,它削减了锁。Joe Armstrong 的书中提出了这样一个想法实验:假设您的进程获取了一个锁,然后立即崩溃(内存故障导致该进程崩溃,或是系统的某个部分停电)。下一次有进程等待那个相同的锁时,整个系统就会死锁。这可能是一个明显的锁,例如示例代码中的 AquireScopedLock() 调用;或者可能是由内存管理器代表您获取的隐式锁,例如在调用 malloc() 或 free() 时。

无论如何,您的进程崩溃现在已经阻止了整个系统的进展。完了。故事结束了。您的系统已经挂了。除非您可以保证您在 C/C++ 中使用的每个库都从不调用 malloc 并且从不获取锁,否则您的系统就不能容忍故障。Erlang 系统可以并且确实会在负载过重时随意地杀死进程,以便继续前进,所以在规模方面,您的 Erlang 进程必须可被杀死(在任何单个执行点)以保持吞吐量。

有一个部分解决方案:到处都使用租约而不是锁,但您无法保证您使用的所有库也这样做。正确性的逻辑和推理也很快就变得非常复杂。此外,租约恢复速度较慢(在超时到期后),因此面对故障时,整个系统变得非常缓慢。

第二,Erlang取消了静态类型,从而实现了热代码替换并同时运行两个版本的相同代码。这意味着您可以在运行时升级代码而无需停止系统。这就是系统能够保持九个9或每年32毫秒的停机时间的原因。它们只是被就地升级。如果要升级C++函数,则必须手动重新链接,无法同时运行两个版本。代码升级需要系统停机时间,如果您有一个无法同时运行多个代码版本的大型集群,您将需要立即关闭整个集群。哎呀,在电信业中,这是不可容忍的。

此外,Erlang还取消了共享内存和共享垃圾收集;每个轻量级进程都将独立进行垃圾收集。这是第一点的简单扩展,但强调了要想实现真正的容错性,需要没有依赖关系的进程。这意味着与Java相比,您的GC暂停是可以容忍的(小而非要暂停半个小时以完成8GB GC),适用于大型系统。


1
首先,您可以使用lock_guard,在程序崩溃的情况下释放锁定。其次,您可以在C++中实现热插拔系统,但这很麻烦。并发的问题在于同步原语,即使是原子操作,也会引入内存屏障和障碍,并减慢速度。您拥有的线程越多,速度就会越慢。像Clojure或Haskell一样,Erlang不使用互斥体或原子操作,这迫使开发人员以不同的方式解决问题。这是解决并发问题的一种非常有效的方法。 - Asier Gutierrez
听起来是有道理的,但这只是针对C++的比较,而C++总是一个容易被责备的目标。例如,在Java(或Clojure)中是否可能实现这个功能呢?Java中的锁是安全的,并且有方法在运行时编译/加载代码(在Clojure中也非常容易)。 - Display Name

14

1
libcppa最近更名为C++ Actor Framework (CAF)。新的URL是:https://github.com/actor-framework/actor-framework - mavam

3
这与 actor 模型关系不大,更关键的是在 C++ 中编写类似 OTP 的程序非常困难。此外,不同的操作系统提供截然不同的调试和系统工具,而 Erlang 的虚拟机和多种语言构造支持一种统一的方法来确定所有这些进程正在做什么,这在多个平台上以统一的方式(或者根本不可能)实现将会非常困难(需要记住 Erlang/OTP 先于当前"actor 模型"术语的热潮,因此在某些情况下,这些讨论比较的是苹果和翼龙。优秀的想法容易独立创新)。
所有这些意味着,虽然你当然可以用另一种语言编写“actor 模型”程序集(我知道,在遇到 Erlang 之前,我已经在 Python、C 和 Guile 中长期这样做了很长时间,包括形式化的监视器和链接,而在此之前我从未听说过“actor 模型”一词),但理解您的代码实际生成的进程及其之间发生的事情非常困难。Erlang 强制执行一些规则,而 OS 简单无法完成重大内核改进 - 这些内核改进可能总体上不会有益。这些规则表现为程序员的一般限制(如果你确实需要,总是可以避开这些限制)和系统为程序员提供的基本承诺(如果您确实需要,也可以故意违反这些承诺)。
例如,它强制执行两个进程不能共享状态来保护您免受副作用的影响。这并不意味着每个函数都必须是“纯”的,即所有内容都是引用透明的(显然不是,尽管使尽量多的程序引用透明是大多数 Erlang 项目的明确设计目标),而是两个进程不会不断创建与共享状态或竞争相关的竞争条件。(顺便说一句,在 Erlang 的上下文中,“副作用”更多的是指这个。了解这一点可能有助于你解密一些关于 Erlang 是否与 Haskell 或玩具“纯”语言相比“真正的功能性”的讨论。)
另一方面,Erlang 运行时保证消息传递。在必须纯粹通过未管理的端口、管道、共享内存和仅由 OS 内核管理的公共文件进行通信的环境中,这是一种非常缺失的东西(操作系统内核对这些资源的管理与 Erlang 运行时提供的极其有限相比,必然极其有限)。这并不意味着 Erlang 保证 RPC(无论如何,消息传递不是 RPC,也不是方法调用!),它不保证您的消息被正确寻址,并且也不保证您尝试发送消息的进程存在或处于活动状态。如果你发送的对象此时有效,它只能保证送达。
建立在这个承诺之上的是监控和链接准确无误的承诺。基于此,Erlang运行时使整个“网络集群”概念在你掌握了系统的运作方式(以及如何使用erl_connect...)后似乎消失了。这使得您可以跳过一组棘手的并发情况,大大地帮助您成功编写代码,而不是陷入为裸的并发编程所需的防御性技术的泥潭中。
因此,问题并不在于需要 Erlang 这种语言,而是在于运行时和 OTP 已经存在,并以相当干净的方式表达出来,而在另一种语言中实现接近它的任何东西都非常困难。OTP 只是一个难以超越的优秀例子。同样地,我们也不真正需要 C++,我们可以坚持使用原始二进制输入、Brainfuck,并将汇编视为高级语言。我们也不需要火车或轮船,因为我们都知道如何步行和游泳。
所有这些都说过了,该虚拟机的字节码已经有了很好的文档,并且出现了许多将其编译或与 Erlang 运行时一起使用的替代语言。如果我们将问题分为语言/语法部分(“我是否必须了解月文才能进行并发处理?”)和平台部分(“OTP 是处理并发的最成熟方法,而且它会引导我避免并发、分布式环境中最棘手、最常见的陷阱吗?”),那么答案是(“不需要”,“是的”)。

2

Casablanca是另一个新的Actor模型库。一个典型的异步接收看起来像这样:

PID replyTo;
NameQuery request;
accept_request().then([=](std::tuple<NameQuery,PID> request)
{
   if (std::get<0>(request) == FirstName)
       std::get<1>(request).send("Niklas");
   else
       std::get<1>(request).send("Gustafsson");
}

个人而言,我认为CAF在隐藏模式匹配方面做得更好,提供了一个良好的接口。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接