1 介绍
1.1 什么是actor
对于刚接触actor的我,第一感觉就像redis一样,每个actor就是一个redis 实例,都有自己消息队列,actor相互通信通过将消息发给对方,消息发送进对方的消息队列,等待对方线程处理。来看看我们之前做项目的痛点。
游戏服务器通常分为多个服,每个服上有多个玩家。假设玩家与玩家数据交互操作,这时怎么避免数据竞争?两种解决方案:
1、将数据写入redis
首先redis效率高,也是单线程模型,不存在数据竞争,但是也有它的不足之处,操作redis 会受网络影响,即使再快也避免不了网络io 的开销。go redis sdk在序列化响应回来的消息时,会序列化成字符串,大量的操作会造成gc 的压力。
2、加锁
这个就是很常用的做法,避免数据竞争,但是玩家其实大部分数据都是自身有的,属于玩家自己,如果每个玩家操作别人数据都加锁(例如扣血),在大型游戏如果王者荣耀这种实时性高的游戏,性能估计会很差。但是在面对mongo 事务写冲突时,加互斥锁估计就无法解决了。这时候只能将粒度调大,调大在事务级别,这样性能就更差了。一旦业务处理耗时太长,所有相关玩家都将会感觉到卡顿操作。
有没有更好的解决方案?有,当然那就是actor?
Actor模型是1973年提出的一个分布式并发编程模式,在Erlang语言中得到广泛支持和应用。
在Actor模型中,Actor
参与者是一个并发原语,简单来说,一个参与者就是一个工人,与进程或线程一样能够工作或处理任务。
来看看来自于维基百科的解释:
From Wikipedia:
The actor model in computer science is a mathematical model of concurrent computation that treats "actors" as the universal primitives of concurrent computation: in response to a message that it receives, an actor can make local decisions, create more actors, send more messages, and determine how to respond to the next message received.
The Actor model adopts the philosophy that everything is an actor. This is similar to the everything is an object philosophy used by some object-oriented programming languages.
“ 计算机科学中的actor模型是一个并发计算的数学模型,它将actors视为并发计算的通用原语:actor可以做出本地决策,来作为其接收到的消息的响应,创建更多actors,发送更多消息,并确定如何响应接收到的下一条消息。 Actor模型采用的哲学是一切都是Actor。这与一些面向对象编程语言应用的“任何事物都是一个对象”的哲学类似。”
在Scala 和erlang 语言有actor 类似的设计理念。在计算机科学领域,Actor是一个并行计算的数学模型,最初是为了由大量独立的微处理器组成的高并行计算机所开发的。
Actor模型的理念非常简单:万物皆Actor
在Actor模型中主角是actor,类似一种worker。Actor彼此之间直接发送消息,不需要经过什么中介,消息是异步发送和处理的。在Actor模型中一切都是Actor,所有逻辑或模块都可以看成是Actor,通过不同Actor之间的消息传递实现模块之间的通信和交互。
actors 直接是隔离的,并不会共享内存,所以只能通过发邮件去和对方打交道。
Erlang引入了”随它崩溃“的哲学理念,这部分关键代码被监控着,监控者supervisor
唯一的职责是知道代码崩溃后干什么,让这种理念成为可能的正是Actor模型。
在Erlang中,每段代码都运行在进程中,进程是Erlang中对Actor的称呼,意味着它的状态不会影响其他进程。系统中会有一个supervisor
,实际上它只是另一个进程。被监控的进程挂掉了,supervisor
会被通知并对此进行处理,因此也就能创建一个具有自愈功能的系统。如果一个Actor到达异常状态并且崩溃,无论如何,supervisor
都可以做出反应并尝试把它变成一致状态,最常见的方式就是根据初始状态重启Actor。
简单来说,Actor通过消息传递的方式与外界通信,而且消息传递是异步的。每个Actor都有一个邮箱,邮箱接收并缓存其他Actor发过来的消息,通过邮箱队列mail queue
来处理消息。Actor一次只能同步处理一个消息,处理消息过程中,除了可以接收消息外不能做任何其他操作。
如上图所示,多个actor通过发信息进行交流,actor与actor之间可以交流,但是不会产生数据竞争。
1.2 actor 组成
1.2.1状态(state)
指actor本身的属性信息,state只能被actor自己操作,不能被其他actor共享和操作,有效的避免加锁和数据竞争
1.2.2行为(behavior)
指actor处理逻辑,如果通过行为来操作自身state
1.2.1Mailbox邮箱
指actor存储消息的fifo队列,actor与actor发送消息,消息只能发送到邮箱,等待拥有邮箱的actor 去处理,这个过程是异步的。简单来说,有时间才处理,等我把前面任务先完成。
Actor模型遵循下面的规则:
- 所有的Actor状态是本地的,外部是无法访问的。
- Actor必须通过消息传递进行通信
- 一个Actor可以响应消息、退出新Actor、改变内部状态、将消息发送到一个或多个Actor。
- Actor可能会堵塞自己但Actor不应该堵塞自己运行的线程
来看看actor如何通信
- actors 都有自己的邮箱地址,这个地址无论是本地还是跨服,是唯一的,actor 之间只能通过message 进行交流,message 会发到接收actor 的mailbox,等待接收actor处理。如图actorA 向actorB 发送消息。
- 多个actor向同一actor发送消息,按照时间顺序投递进对方MailBox,图中actorA 和actorc 分别向actorB 发送消息,按照时间顺序进入actorB 的邮箱。
- actorB ,actorC,actorD 分别在各自的线程运行,在go 中就是goroutine,每个actor 各自处理各自邮箱中的任务,互不干扰,且开发者写业务不需要关心任何锁的问题,大大减少了心智负担和维护成本。
- actor 能够根据到来的消息通过行为修改自己的状态,可以发送信息给其他actor,可以创建新的actor或者新的子actor。
1.3 actor 和csp 区别
goroutine 和actor对比:
- csp 和actor 最大的不同是actor需要知道接收方的地址,需要知道将消息传递给谁,而channel不关心接收方和发送方是谁,只关心传递介质,相当于CSP把发送方和接收方给解耦了。
- csp 是同步虽然buffer 支持有限量的异步,而且它关心接收者是否处理信息,没有处理就阻塞
- actor 是异步的,只关心发给谁,不关心消息是否处理和传递通道,发送者不能假定发送的消息一定被收到和处理。Actor模型必须支持强大的模式匹配机制,因为无论什么类型的消息都会通过同一个通道发送过来,需要通过模式匹配机制做分发
- 还有一个极好的特性,可以轻松地允许actor在其网络上的不同计算机上存储,实现分布式。消息分组和路由是自动处理的。
actor模式缺点:
- 有些情况下需要流量控制。虽然这些可以在Akka中使用握手对话来实现,但代码会变得混乱,不再有效。
- actor没有拒绝交流的能力。当消息到达时,必须对其进行处理,或者将其放入缓冲区,以便稍后处理。goroutine可以选择处于忽略其通道子集的状态。goroutine可以选择监听全部或部分channel,具体取决于其状态。一个示例可能是一个具有一个输入和一个输出channel的简单固定大小队列goroutine的实现。最初它是空的,因此忽略其输出channel。最终,当它忽略输入channel时,它可能是满的。否则,它将在两个通道之间进行选择,以决定要做什么。因为goroutine可以忽略任何一个通道,所以它的逻辑表达得非常简洁。
- Akka完全依赖无限的输入缓冲区,永远不会耗尽内存,但这并不能保证。
CSP方式的网络并发行为有一个重要的缺点:
- goroutine相互依赖会造成死锁,开发人员必须有意识的避免有死锁代码出现,这需要识别失败依赖关系并且修改。有比较出名的技术,那就是客户端/服务器模式
- 假设将goroutine转换成actor,非阻塞意味死锁不会发生,但是我们怎么保证队列不会溢出,因为mailbox 是无限的,容量受计算机内存限制。死锁和无限缓存内存耗尽关系是等价的
- goroutines只能一台计算机中工作(尽管有任意数量的内核),不能在网络通信。这与actor不同;网络层实现channel,它还不如其他基于CSP的通信,例如JCSP for Java,它包括通过网络连接透明操作的通道,保留正常的同步语义。
- 关于goroutine的最后一点是它们的终止是任意的。与Occam(另一种CSP语言)相比,Occam中的进程由其父进程“拥有”,父进程等待其所有子进程终止。通过显式添加同步代码,可以使Go以更严格的CSP方式运行(如Occam)。
Roland Kuhn Xitrum Scala web framework创始人提的建议:
我没有使用Go,因此我对该部分的知识有限。(他很谦虚)
- Go channel密切模拟了通信顺序过程的语义(Hoare,1978),而Akka actor实现了actor模型(Hewitt,1973)。
- 两者都描述了通过消息传递进行通信的独立进程。主要区别在于,消息交换在CSP中是同步的(即,两个进程在其中传递消息的执行的“接触点”),而在actor模型中是完全解耦的,消息传递未经发送方确认,并且可以在任意时间。因此,actor之间享有更大的自由和独立,因为他们可以根据自己的状态选择何时处理传入的消息。程序员必须预见检查不同传入消息的正确顺序,以避免阻止程序进行。好处是channel不需要缓冲消息,而actor需要理论上无限大小的邮箱。
- 将消息的发送与其在接收方的处理分离的好处是,在没有用户显示声明下,在不同的网络节点上传递消息变得很轻松。它还允许接收者不可用(例如,由于软件或硬件故障),而不会影响发送者,除了无法获得回复。
总之,channle对于在严格控制的环境中协调并发执行非常有用,而actor为松散耦合的分布式组件提供了抽象。
2proto-actor使用
官方文档:https://proto.actor/docs/
github: https://github.com/asynkron/protoactor-go
2.1介绍
Akka项目创始人曾说:
作为http://Akka.NET项目的创建者,我在参与该项目的过程中得出了一些不同的结论。在http://Akka.NET中,我们创建了我们自己的线程池、我们自己的网络层、我们自己的序列化支持、我们自己的配置支持等等。这一切都很有趣,也很有挑战性,但现在我坚定地认为这是错误的做法。
如果可能的话,软件应该是组合的,而不是构建的,只添加代码将现有的部分粘合在一起。这将产生更好的上市时间,并允许我们专注于解决手头的实际问题,在本例中是并发和分布式编程。
Proto Actor基于现有技术,Protobuf用于序列化,gRPC流用于网络传输。这确保了跨平台兼容性、网络协议版本容限和经实践验证的稳定性。
另一个非常重要的因素是业务敏捷性和退出策略。通过跨平台,您的组织不再绑定到特定的平台,如果您要从.NET迁移到Go,这可以在允许基于参与者的服务在平台之间通信的同时完成。
Proto Actor提供可伸缩的实时事务处理
Proto.Actor是一个统一的运行时和编程模型,具有下面特点:
- 放大(并发,指单台计算机多核扩展)
- 扩展(远程,指多台服务器扩展)
- 容错性
只有一件事需要学习和管理,具有高度的内聚性和连贯的语义。
Actor是一个非常可伸缩的软件;不仅在性能方面,而且在应用程序的大小方面,它都是有用的。Proto.Actor的核心Proto.Actor-Actor非常小,可以很容易地将其放入现有项目中,在该项目中,您只需要关注异步性和无锁并发,而无需担心其他问题。
您可以选择只在应用程序中包含所需的Proto.Actor部分,也可以将整个包与持久性扩展和集群一起使用。
Proto.Actor被许多行业的大型组织采用:
- 投资和商业银行业务
- 零售
- 社会化媒体
- 模拟
- 博彩
- 汽车和交通系统
- 保健
- 资料分析
还有更多。任何需要高吞吐量和低延迟的系统都适合使用Proto.Actor。
actor让您可以管理服务故障(管理器)、负载管理(退避策略、超时和处理隔离),以及水平和垂直可伸缩性(添加更多内核和/或添加更多计算机)。
所有这些都是ApacheV2许可的开放源码项目。
2.2设计准则
- 最低限度的API:该API应该小且易于使用。避免使用EnterpriseyJVM类容器和配置。
- 在现有技术的基础上发展:站在巨人的肩膀上,而不是造轮子,例如网络和集群相关技术,有用于网络传输的gRPC流,用于集群的Consul。
- 传递数据:对象序列化是一个明确的问题,不要试图隐藏它。。
- 快速:不要牺牲性能去造一起神奇的API
超高速远程处理,Proto Actor目前仅使用两个Actor就可以在节点之间每秒传递超过200万条消息,同时仍然保持消息顺序!这是Scala-Akka新的基于超高级UDP的动脉传输的六倍,比http://Akka.NET快30倍。
2.3actors介绍
actor 是State, Behavior, a Mailbox, Children and a Supervisor Strategy 的容器. 所有这些都被封装成了 (PID
).
2.3.1PID - Process ID
如下所述,为了从actor模型中获益,actor对象需要从外部屏蔽。因此,可以使用PID向外部表示actor,PID是可以自由且不受限制地传递的对象。这种将内部对象外部对象拆分的方法可以使所有所需操作透明:在不需要更新其他位置的引用的情况下重新启动actor,将实际actor对象放置在远程主机上,向完全不同的应用程序中的actor发送消息。但最重要的一点是,除非参与者自己不明智地发布这些信息,否则不可能观察参与者的内部并从外部掌握其状态。
2.3.2State
参与者对象通常包含一些变量,这些变量反映actor可能处于的状态。这可以是一个显式状态机(例如,使用FSM模块),也可以是一个计数器、一组侦听器、挂起的请求等。这些数据使参与者变得有价值,并且必须防止其他参与者损坏这些数据。好消息是Proto.Actor在概念上都有自己的轻量级线程,它与系统的其余部分完全隔离。这意味着不必使用锁来同步访问,您只需编写actor代码,而无需担心并发性。
幕后Proto.Actor将在一组真正的线程上运行一组Actor,其中通常有多个Actor共享一个线程,一个Actor的后续调用可能最终在不同的线程上处理。Actor确保此实现细节不会影响处理Actor状态的单线程性。
因为内部状态对参与者的操作至关重要,所以状态不一致是致命的。因此,当actor失败并由其主管重新启动时,将从头开始创建状态,就像第一次创建actor时一样。这是为了实现系统的自愈能力。
或者,可以通过持久化接收到的消息并在重新启动后重播这些消息,将参与者的状态自动恢复到重新启动前的状态(请参阅持久化)。
2.3.3Behavior
每次处理消息时,它都会与参与者的当前行为相匹配。行为是指定义在该时间点对消息作出反应时要采取的行动的功能,例如,如果客户端得到授权,则转发请求,否则拒绝请求。此行为可能会随着时间的推移而改变,例如,因为不同的客户端会随着时间的推移获得授权,或者因为actor可能会进入“停止服务”模式,然后返回。这些更改是通过在从行为逻辑读取的状态变量中对它们进行编码来实现的,或者函数本身可能在运行时被调出,请参见become和unbecome操作。但是,在构造actor对象期间定义的初始行为是特殊的,因为重新启动actor会将其行为重置为该初始行为。
2.3.4Mailbox
actor的目的是处理消息,这些消息是从其他actor(或actor系统外部)发送给actor的。连接发送者和接收者的部分是actor的邮箱:每个actor只有一个邮箱,所有发送者都将他们的消息排队。排队是按照发送操作的时间顺序进行的,这意味着,由于在线程中分布actor的明显随机性,从不同actor发送的消息在运行时可能没有定义的顺序。另一方面,从同一actor向同一目标发送多条消息将以相同的顺序排队。
有不同的邮箱实现可供选择,默认为FIFO:actor处理的消息顺序与它们排队的顺序相匹配。这通常是一个很好的默认值,但应用程序可能需要将某些消息优先于其他消息。在这种情况下,优先级邮箱将不总是在末尾排队,而是在消息优先级指定的位置排队,甚至可能在前面。当使用这样的队列时,处理的消息顺序自然由队列的算法定义,通常不是FIFO。
Proto.Actor与其他一些Actor模型实现不同的一个重要特性是,当前行为必须始终处理下一个出列消息,没有扫描邮箱以查找下一个匹配消息。除非覆盖此行为,否则处理消息失败通常会被视为失败。
2.3.5Children
每个actor都可能是一个监督者:如果它为委派子任务创建子任务,它将自动监督它们。子项列表在actor的上下文中维护,actor可以访问它。对列表的修改是通过创建Context.Spawn(…)或停止child.Stop()子项来完成的,这些操作会立即反映出来。实际的创建和终止操作以异步方式发生在幕后,因此它们不会“阻止”其监管。
2.3.6Supervisor Strategy
actor的最后一个部分是处理孩子错误的策略。然后,Proto.Actor对每个传入故障应用“监视和监视”中描述的策略之一,透明地完成故障处理。由于此策略对于actor系统的构造方式至关重要,因此一旦创建了actor,就无法对其进行更改。
考虑到每个actor只有一个这样的策略,这意味着如果不同的策略适用于一个actor的不同孩子,那么孩子们应该分组在具有匹配策略的中级主管之下,根据将任务分为子任务的方式,再次偏好参与者系统的结构。
总体架构浏览
2.4 Messages介绍
actor 基础概念之一是事件驱动,参考 “message-driven systems,” as defined by the Reactive Manifesto:
消息是发送到特定目的地的数据项。事件是组件在达到给定状态时发出的信号。在消息驱动的系统中,可寻址的接收者等待消息的到达并对其作出反应,否则处于休眠状态。
在Proto.Actor里面,消息传递是和其他actor 交流的重要方式
消息被定义成类似这样的结构体:
type MyMessage struct {
Name string
}
Actor允许您自动将这些消息传递给任何其他actor,无论是在应用程序本地进程中运行的actor还是在不同机器上运行的远程actor。Proto.Actor可以自动序列化您的邮件并将其路由到指定的收件人
actor 根据不可变消息内容去并发安全的改变它自身的状态
2.4.1消息是不可变的
不可变消息本质上是线程安全的。没有线程可以修改不可变消息的内容,因此接收原始消息的第二个线程不必担心前一个线程以不可预知的方式改变状态。
因此,在Proto.Actor中,所有消息都应该是不可变的,因此是线程安全的。这就是为什么我们可以有数千个Proto.Actor在没有同步机制的情况下并发处理消息的原因之一,因为不可变消息解决了这一问题。
2.4.2消息是异步的
在OOP编程(面向对象)中,对象通过函数调用相互通信。面向过程编程也是如此。类A调用类B上的函数,并等待该函数返回,然后类A才能继续其它工作。
在Proto.Actor和Actor模型中,Actor通过发送消息相互通信。
那么这个想法有什么不同之处呢?
首先,消息传递是异步的,发送消息的actor可以在接收actor处理发送者消息的同时继续做其他工作。因此,实际上,一个actor与任何其他actor的每一次交互在默认情况下都是异步的。
这是一个很大的变化,由于所有“函数调用”都已被消息(即对象的不同实例)所取代,actor可以存储其函数调用的历史记录,甚至可以将某些函数调用的处理推迟到以后!
想象一下,在Microsoft Word中使用actor构建类似“撤消”按钮的东西是多么容易——默认情况下,您会收到一条消息,表示有人对文档所做的每一次更改。要撤消其中一个更改,您只需将消息从UndoActor的消息库中弹出,并将该更改推回给另一个管理Word文档当前状态的actor即可。在实践中,这是一个非常强大的概念。
2.5 一些专业术语和概念
在本章中,我们试图建立一个通用术语,为Proto.Actor所针对的并发分布式系统的通信奠定坚实的基础。请注意,对于这些术语中的许多术语,没有统一的定义。我们只是试图给出将在Proto.Actor文档范围内使用的工作定义。
2.5.1Concurrency vs. Parallelism
并发和并行是相关的概念,但有一些小的区别。并发意味着两个或多个任务正在进行,即使它们可能不会同时执行。例如,这可以通过时间切片来实现,其中任务的部分按顺序执行,并与其他任务的部分混合。另一方面,当执行可以真正同时进行时,并行性就会出现。
Concurrency(并发)
Parallelism(并行)
2.5.2Asynchronous vs. Synchronous
如果调用方在方法返回值或引发异常之前无法做下面的事,则认为方法调用是同步的。另一方面,异步调用允许调用方在有限的步骤后继续,并且可以通过一些额外的机制(可能是已注册的回调、未来或消息)来通知方法的完成。
同步API可以使用阻塞来实现同步,但这不是必需的。CPU密集型任务可能会产生与阻塞类似的行为。一般来说,最好使用异步API,因为它们保证系统能够进行。actor本质上是异步的:actor可以在消息发送后继续进行,而无需等待实际的交付发生。
2.5.3Non-blocking vs. Blocking
如果一个线程的延迟导致其他线程一直延迟,将会出现讨论阻塞。一个很好的例子是一个资源,它可以通过互斥被一个线程独占使用。如果某个线程无限期地使用资源(例如意外地运行无限循环),则等待该资源的其他线程将无法继续。相反,非阻塞意味着没有线程能够一直地延迟其他线程。
非阻塞操作优先于阻塞操作,因为当系统包含阻塞操作时,系统的整体进度并不能得到很好的保证。
2.5.4Deadlock vs. Starvation vs. Live-lock
当几个actor等待对方达到某个特定状态以便能够继续进行时,就会出现死锁。如果没有其他actor达到某个特定状态(“第22条军规”问题),所有受影响的子系统都会停止。死锁与阻塞密切相关,因为actor线程必须能够无限期地延迟其他线程的进程。
在死锁的情况下,没有actor能够取得进展,相反,当有actor能够取得进展,但可能有一个或多个actor不能取得进展时,饥饿就会发生。典型的场景是一个简单的调度算法,它总是选择高优先级的任务而不是低优先级的任务。如果传入的高优先级任务的数量一直足够高,那么低优先级任务将永远无法完成。
Livelock类似于死锁,因为没有任何actor取得进展。但不同之处在于,actor不是被冻结在等待他人进步的状态中,而是不断地改变自己的状态。两个actor有两个相同资源可用的示例场景。他们每个人都试图获得资源,但他们也会检查对方是否也需要资源。如果资源是由其他actor请求的,他们会尝试获取资源的其他实例。在不幸的情况下,两个actor可能会在两种资源之间“反弹”,从未获得它,但总是屈服于另一种资源。
2.5.5Race Condition
当一组事件的顺序的假设可能被外部的非确定性效应所违反时,我们称之为竞争条件。当多个线程共享可变状态时,通常会出现争用情况,并且一个线程在该状态上的操作可能会交错,从而导致意外行为。虽然这是一种常见情况,但共享状态并不一定要有竞争条件。一个示例可以是客户端向服务器发送无序数据包(例如UDP数据报)P1、P2。由于包可能经由不同的网络路由传送,因此服务器可能首先接收P2,然后接收P1。如果消息不包含有关其发送顺序的信息,则服务器无法确定它们是以不同的顺序发送的。根据数据包的含义,这可能导致竞争条件。
Proto.Actor提供的关于给定一对Actor之间发送的消息的唯一保证是它们的顺序始终保持不变。请参阅消息传递可靠性
2.5.6Non-blocking Guarantees (Progress Conditions)
如前几节所述,阻塞是不可取的,原因有几个,包括死锁的危险和系统中吞吐量的降低。在以下章节中,我们将讨论不同强度的各种非阻塞特性。
2.5.7Wait-freedom
如果每个调用都保证在有限的步骤内完成,则方法是无等待的。如果一个方法是有界无等待的,那么操作步骤有一个有限的上界。
根据这个定义,无等待方法从不阻塞,因此死锁不会发生。此外,由于每个actor都可以在有限的步骤后(当调用完成时)继续进行,因此无等待方法不会出现饥饿
2.5.8Lock-freedom
在无锁调用的情况下,某些方法通常在有限的步骤内完成。此定义意味着无锁调用不可能出现死锁。另一方面,保证某些调用在有限的步骤中完成并不足以保证所有调用最终都完成。换句话说,锁的自由不足以保证没有饥饿。
2.5.9Obstruction-freedom
障碍自由度是这里讨论的最弱的非阻塞保证。如果某个方法在某个时间点之后独立执行(其他线程不执行任何步骤,例如挂起),则该方法称为无障碍方法,它将在有限的步骤数内完成。所有无锁对象都是无障碍的,但通常情况并非如此。
乐观并发控制(OCC)方法通常是无障碍的。OCC方法是,修改的时候比较原来的值是否和现在的查询到的值一样,如果一样则修改原来的值为新值,不一样则放弃操作。相当于下面这个函数做的事情
atomic.CompareAndSwapInt32()
2.6 消息传递可靠性
Proto.Actor帮助您构建可靠的应用程序,这些应用程序在一台机器中使用多个处理器核(“单台机器纵向扩展”)或分布在计算机网络中(“横向扩展”)。这项工作的关键抽象是代码单元actor之间的所有交互都是通过消息传递实现的,这就是为什么actor之间消息传递的精确语义应该有自己的内容。
为了给下面的讨论提供一些上下文,考虑跨越多个网络主机的应用程序。无论是向本地应用程序上的actor发送还是向远程actor发送,通信的基本机制都是相同的,但是,在传递延迟(可能还取决于网络链路的带宽和消息大小)和可靠性方面会有明显的差异。在远程消息发送的情况下,显然需要更多的步骤,这意味着更多的步骤可能出错。另一个方面是,本地发送将只传递对同一应用程序内消息的引用,对发送的底层对象没有任何限制,而远程传输将限制消息大小。
写自己的actor,使每一次互动都可能是遥远的,这是一个安全、悲观的赌注。这意味着只依赖于那些始终有保证且下文将详细讨论的属性。当然,这在actor的实现中有一些开销。如果您愿意牺牲完全的位置透明性,例如在一组密切合作的参与者的情况下,您可以将他们始终放在同一个本地应用程序上,并享受更严格的消息传递保证。下文将进一步讨论这种权衡的细节。
常规规则
- 至多一次消息分发
- 在一次发送和接收中,消息是有序的
第一条规则通常也存在于其他actor实现中,而第二条规则特定于Proto.actor。
2.6.1最多一次投递意味着什么?
在描述交付时,通常有下面三种类型:
1、at-most-once 交付意味着对于传递给该机制的每条消息,该消息只能被传递零次或一次,更准确的说,这意味着信息可能会丢失。
2、 at-least-once 交付意味着,对于每个传递的消息,可能会进行多次尝试来传递它,从而至少有一次成功,同样,更准确的说法是,这意味着消息可能会被复制,但不会丢失。
3、exactly-once 交付意味着,对于每个交给该机制的消息,仅向收件人进行一次传递;消息既不能丢失,也不能复制。
第一种是最便宜的、性能最高的、实现开销最少的,因为它可以一种“一劳永逸”的方式完成,而无需在发送端或传输机制中保持状态。第二种方法需要重试以抵消传输丢失,这意味着在发送端保持状态,在接收端具有确认机制。第三种是最昂贵的,因此性能最差,因为除了第二种,它还要求状态保持在接收端,以便过滤掉重复交付。
2.6.2消息传送为什么没有保证?
问题的核心在于,该保证的内容是什么?
- 消息在网络上传递
- 消息被其他主机收到
- 消息被投递到目标actor 的邮箱
- 消息开始被目标actor 处理
- 消息被目标actor 成功处理
其中每一个都有不同的挑战和成本,很明显,在某些情况下,任何消息传递库都无法遵守;例如,想想可配置的邮箱类型,以及一个有边界的邮箱如何与第三点交互,甚至想想决定第五点的“成功”部分意味着什么。
同样道理,没有人需要可靠的消息传递。发送方知道交流是否成功的唯一有意义的方法是接收业务级别的确认消息,这不是Proto.Actor自己可以弥补的(我们既没有编写“照我说的做”框架,也不希望我们这样做)。
Proto.Actor支持分布式计算,并通过消息传递明确了通信的易出错性,因此它不试图撒谎和模拟泄漏的抽象。这是一个在Erlang中使用非常成功的模型,需要用户围绕它设计应用程序。您可以在Erlang文档中阅读更多关于这种方法的信息,Proto.Actor紧随其后。
我们通过只提供消息的基本保证,需要更高成本实现的可靠性我们不会保证;始终可以在基本可靠性的基础上增加更高的可靠性,但不可能为了获得更高的性能而主动取消可靠性。
2.6.3消息的顺序
更具体的规则是,对于给定的一对actor,从第一个actor发送到第二个actor的消息不会无序接收。如下所示:
Actor A1
发送消息 M1
, M2
, M3
to A2
Actor A3
发送消息 M4
, M5
, M6
to A2
- 如果
M1
发送了,那么它一定在M2
andM3
之前 - 如果
M2
发送了,那么它一定在M3
之前 - 如果
M4
发送了,那么它一定在M5
andM6
之前 - 如果
M5
发送了,那么它一定在M6
之前 - Actor
A2
可能看见A1和A3消息交错达到 - 既然发送消息没有保证, 那么任何消息在达到A2 之前都可能丢失。
请注意上面规则不是通用的
Actor A
发送消息 M1
给 actor C
Actor A
同时发送消息 M2
给 actor B
Actor B
转发收到的消息 M2
给actor C
Actor C
可能收到 M1
and M2
任何顺序
因果传递顺序意味着,在参与者C处,在收到M1消息之前从未收到M2(尽管其中任何一个可能丢失)。当A、B和C在不同的网络主机上时,由于不同的消息传递延迟,可能会违反此顺序。
actor的创建可以视为父级向子级发送消息,意思与上面相同,此时向actor发送的消息可能与创建消息发送重排。这意味着消息可能来的太早,因为actor还没创建(如果actor start 消息晚来与用户消息,可能发送错误,因为start 消息时初始化actor)。消息来的太早的例子就是创建一个远程的actorR1,将其引用发给另一个远程actorR2,并让R2 向R1 发送消息。定义好的消息排序就是父级创建子actor 并立即发送消息。
2.6.4失败的交流
需要注意的是,上面讨论的顺序保证仅适用于actor之间的用户消息。actor的子级的故障通过特殊的系统消息进行通信,这些消息的顺序与普通用户消息不同。尤其注意:
子 actor C
发送消息 M
给他的父级P
,子 actor失败消息是F
父 actor P
可能收到消息顺序为 M
, F
或者 F
, M
。
造成这种原因是他们内部系统有自己的邮箱。系统消息或者用户消息入队的顺序是不能被保证的。
2.6.5本地应用程序交流
不建议依赖本节中更高的可靠性,因为它会将您的应用程序绑定到仅本地的部署:为了适合在计算机集群上运行,应用程序可能必须进行不同的设计(而不仅仅是采用某些参与者本地的一些消息交换模式)。我们的信条是“一次设计,按您的意愿部署”,要实现这一点,您只需遵循一般规则即可。
Proto.Actor测试套件依赖于不丢失本地上下文中的消息(对于非错误条件测试也适用于远程生成),这意味着我们确实尽了最大努力来保持测试的稳定性。但是,本地发送操作可能会失败,原因与CLR上的正常方法调用相同:
StackOverflowException
OutOfMemoryException
- other :
SystemException
本地消息发送失败通常是下面两种方式:
- 邮箱不接收这条消息
- 接收actor处理消息失败或者已经停止了
虽然第一个问题显然是配置问题,但第二个问题值得思考:如果在处理过程中出现异常,则消息的发送者不会得到反馈,而是通知监管者。对于外部观察者而言,这通常与丢失的信息无法区分。
本地消息发送的顺序
假设采用严格的FIFO邮箱,则在某些条件下消除了对消息排序保证的不可传递性的上述警告。正如您将注意到的,这些都是相当微妙的,而且未来的性能优化甚至可能会使整个段落失效。计数器指示的可能非详尽列表如下:
- 在收到来自顶级参与者的第一个回复之前,有一个锁保护内部临时队列,该锁是不公平的;这意味着,在参与者构造期间到达的来自不同发送者的排队请求(象征性地说,更多涉及细节)可能会根据低级线程调度进行重新排序。由于CLR上不存在完全公平锁,因此这是不可调整的。
- 在构建路由器的过程中使用了相同的机制,更准确地说是路由ActorRef,因此对于部署了路由器的参与者来说,存在相同的问题。
- 如上所述,问题发生在排队过程中涉及锁的任何位置,这也可能适用于自定义邮箱。
这个列表是经过仔细研究过的的,但其他有问题的场景可能没有经过我们的分析。
本地顺序和网络顺序有什么关系
如前一段所述,在某些条件下,本地消息发送遵循传递因果顺序。如果远程消息传输也尊重此顺序,则这将转化为跨一个网络链路的传递因果顺序,即如果正好涉及两个网络主机。涉及多个链路,例如上述三个不同节点上的三个参与者,则无法保证。
当前远程传输不支持这一点(这也是由锁的非FIFO唤醒顺序造成的,这次是串行连接建立)。
作为对未来的推测,通过完全基于actor重新实现远程传输层,可能支持这种顺序保证;同时,考虑提供其他低级别的传输协议,如UDP或SCTP,通过删除此保证,可以实现更高的吞吐量或更低的延迟,这意味着在不同的实现之间进行选择将允许顺序保证而不是性能保证。
2.6.6高水平抽象
Proto.Actor基于ProtoActor核心中的一个小而一致的工具集,它还提供了强大的、更高级别的抽象。
如上所述,要实现可靠交付要求的必须有ACK–重试协议。以最简单的形式,这需要
- 识别消息以将消息与确认关联的一种方法
- 重试机制,如果消息未及时确认,将重新发送消息
- 接收者过滤和检查重复消息
Proto.Actor持久化模块的至少一次传递支持带有业务级确认的ACK-RETRY协议。可以通过跟踪通过至少一次传递发送的邮件的标识符来检测重复。实现第三部分的另一种方法是使消息处理在业务逻辑级别上是幂等的。
事件源(和分片)是使大型网站扩展到数十亿用户的原因,其思想非常简单:当一个组件(think actor)处理一个命令时,它将生成一个表示命令效果的事件列表。除了应用于组件的状态之外,还存储这些事件。这个方案的好处在于,事件只会被附加到存储中,不会发生任何变化;这使得能够完美地复制和扩展此事件流的使用者(即,其他组件可以使用事件流作为在不同大陆上复制组件状态或对更改作出反应的手段)。如果组件的状态由于机器故障或从缓存中推出而丢失,则可以通过重放事件流(通常使用快照来加速过程)轻松地重建组件:ref:Proto.Actor持久性支持事件源。
2.7地址透明性
Proto.Actor中的所有内容都设计为在分布式环境中工作:Actor的所有交互都使用纯消息传递,并且所有消息都是异步的。这项工作是为了确保在单个参与者系统或数百台机器的集群中运行时,所有功能都是平等可用的。实现这一点的关键是通过优化从远程到本地,而不是通过泛化从本地到远程。
在Proto.Actor中,PID可以被认为类似于电话号码。如果你对某人有PID,你可以与他们沟通。不管他们是近是远。
使用 PID 和本地actor交流
使用 PID和远程的 actor通信
透明度被破坏的方式
正如与你旁边的人有不同意见,而不是在另一个国家和别人交谈时,可能会有不良的接收和沟通延迟,在构建分布式系统时,需要考虑一些方面。
Proto.Actor的适用性不一定适用于使用它的应用程序,因为为分布式执行而设计会对可能的情况造成一些限制。最明显的一点是,通过网络发送的所有消息都必须是可序列化的。在Proto.Actor中,远程消息传递的默认值是Protobuf。在.NET平台上,我们内置了对生成Protobuf到C#开箱即用的支持。好的做法是以这种方式定义大多数系统消息,以避免以后从不可序列化消息切换到可序列化消息时出现问题。
另一个结果是,一切都需要知道所有交互都是完全异步的,这在计算机网络中可能意味着消息可能需要几分钟才能到达收件人(取决于配置)。这也意味着消息丢失的概率远高于一个CLR内的概率,在CLR内,消息丢失的概率接近于零。
2.7.1点对点通信和客户端/服务器通信对比
Proto.Remote远程是一种以对等方式连接actor系统的通信模块,它是原型系统的基础。远程处理的设计由两个(相关)设计决策驱动:
相关系统之间的通信是对称的:如果系统A可以连接到系统B,那么系统B也必须能够独立连接到系统a。就连接模式而言,通信系统的作用是对称的:没有只接受连接的系统,也没有只发起连接的系统。
Client和Server大家都很熟悉了,client 是经常做请求连接的一方,在 proto.Actor 设计中,两端是一样的,既可以发起连接也可以被连接。
2.7.2Marking Points for Scaling Out with Routers
除了能够在集群的不同节点上运行actor系统的不同部分外,还可以通过乘以支持并行化的actor子树(例如,考虑并行处理不同查询的搜索引擎)来扩展到更多的核心。然后可以以不同的方式将克隆路由到服务器,例如循环路由。要做到这一点,唯一需要做的事情是,开发人员需要使用新的路由器声明一个特定的actor,然后取而代之的是,将创建一个路由器actor,它将生成所需类型的可配置数量的子级,并以配置的方式路由到它们。在路由器中阅读更多关于此的信息。
引用
2.8Supervision
2.8.1监管意味着什么?
监督描述了actor之间的依赖关系:监管者将任务委托给下级,因此必须对他们的失败做出响应。当下级检测到故障(即引发异常)时,它将挂起自身及其所有下级,并向其监管者发送消息,表示故障。根据待监管者的性质和故障的性质,监管人可选择以下四个选项:
- Resume 下级将保持其原来内部状态
- Restart 下级将清空其原来内部状态
- Stop 下级永远的停止
- Escalate 将失败传递给层次结构中的下一个父级,从而导致自身失败
始终将actor视为监督层级的一部分很重要,这解释了第四种选择的存在(因为监管者也从属于另一个监管者),并对前三种选择产生了影响:恢复actor会恢复其所有下级,重新启动actor需要重新启动其所有下属(详情请参见下文),类似地终止一个actor也将终止其所有下级。应该注意的是,actor重新启动事件的默认行为是在重新启动之前终止其所有子级,但此钩子可以被重写;递归重新启动适用于执行此钩子后剩下的所有子级。
每个监管者都配置了转换所有可能故障原因(即异常)的功能上述四种选择中的一种;值得注意的是,该功能不会将失败actor的身份作为输入。很容易想出一些结构示例,这些示例可能不够灵活,例如希望将不同的策略应用于不同的下级。在这一点上,理解监管者结构是递归处理错误。如果您试图在一个级别上做太多的工作,则很难进行推理,因此在这种情况下,推荐的方法是添加一个级别的监督。
Proto.Actor实现了一种称为“家长监督”的特定形式。actor只能由其他actor创建,其中顶级actor由库提供,每个创建的actor由其父级监控。此限制使actor监控层次结构的形成隐式化,并鼓励合理的设计决策。应注意的是,这也保证了actor不会从外部连接到监管者,这可能会让他们措手不及。此外,这为actor应用程序的(子树)生成了一个自然而干净的关闭过程。
注意:
与监控相关的父子通信通过特殊的系统消息进行,这些消息具有自己的邮箱与用户消息分开。这意味着监管相关事件不是决定性的,相对于普通消息排序。一般来说,用户不能影响正常消息和消息的顺序失败通知。有关详细信息和示例,请参阅讨论:消息排序部分。
2.8.2顶级监管者
Proto.Actor中的监控器是实现监控器接口的任何类型。这意味actor和非actor都可以成为监督者。
在Proto.Actor的顶部是N个非Actor的主管。
2.8.3重启意味着什么
当actor处理正常消息失败时,我们要阻止,通常失败由下面三个类型引起:
- 接收到的特定消息的系统错误
- 当处理消息时一些依赖的资源缺失
- 混乱的内部状态
除非故障是明确可识别的,否则无法排除第三个原因,从而得出需要清除内部状态的结论。如果监管者决定其他子级或其自身不受损坏的影响,例如,由于有意识地应用了错误内核模式,则最好重新启动子级。这是通过创建底层Actor类的新实例并在子类的PID中用新实例替换失败的实例来实现的;这样做的能力是将actor封装在特殊引用中的原因之一。然后,新的actor继续处理其邮箱信息,这意味着在actor之外看不到重新启动,但值得注意的是,发生故障的消息没有重新处理。
重启期间事件的详细顺序如下所示:
- 挂起actor,在这之间将不能处理普通消息直到恢复过来
- 调用实例的
PreRestart
钩子(默认发送终止请求给所有的孩子,同时调用 postStop) - 等待所有请求终结的孩子在
PreRestart
之间(使用context.Stop()
真正的停止; 像所有children actor 终结一样, 上一次终结child 的提示 将会影响下一步的处理 - 通过原来actor的创建函数创建一个新的actor。
- 新实例调用
PostRestart
,默认已经调用了PreStart
。 - 发送重启消息给所有在步骤三被杀死的孩子,重启孩子将会递归的遵循同一个 步骤2 的process 。
- 恢复actor。
2.8.4生命周期监控意味这什么
与上面描述的父级和子级之间的特殊关系不同,每个actor可以监视任何其他actor。由于actor完全活着从创建中出现,并且在受影响的监管者之外看不到重新启动,因此可用于监控的唯一状态更改是从活着到死亡的过渡。因此,监控用于将一个actor与另一个actor联系起来,以便它可以对另一个actor的终止做出反应,而不是对失败做出反应的监督。
生命周期监视使用监视actor接收的终止消息来实现。要开始侦听终止的消息,请调用Context.Watch(targetPID)。要停止侦听,请调用Context.Unwatch(targetPID)。一个重要属性是,无论监视请求和目标终止的发生顺序如何,消息都将被传递,即,即使在注册时目标已经死亡,您仍然会收到消息。
如果监管者无法简单地重新启动其子级,并且必须终止子级,则监视尤其有用,例如在参与者初始化过程中出现错误时。在这种情况下,它应该监视这些子级并重新创建它们,或者安排自己稍后重试。
另一个常见的用例是,参与者需要在缺少外部资源的情况下失败,外部资源也可能是其自己的子资源之一。如果第三方通过context.Stop(pid)方法或发送context.Poison(pid)或其.NET异步对等方终止子级,则监管者可能会受到影响。
2.8.5一对一和多对一的区别
Proto.Actor提供了两类监督策略:一种是ForOneStrategy,另一种是AllForOneStrategy。两者都配置了从异常类型到监督指令的映射(见上文),并限制了在终止子项之前允许其失败的频率。它们之间的区别在于前者仅将获得的指令应用于失败的子级,而后者也将其应用于所有兄弟姐妹。通常,您应该使用OneForOneStrategy,如果没有明确指定,这也是默认值。
简单的来说,就是子级失败,就专门针对这个子级采取策略
AllForOne策略适用于child actor 之间存在紧密依赖关系的情况,即一个child的失败会影响其他child的功能,他们之间存在着不可分割的联系。由于重新启动不会清除邮箱,因此通常最好在出现故障时终止子项,并从监督者显式地重新创建它们(通过观察子项的生命周期);否则,您必须确保任何actor都可以接收在重新启动之前排队但之后处理的消息。
简单的来说,就是子级失败,就针对这个子级相关的actor采取相同的策略,即时他们此时并未失败
未完待续
下篇预告:proto-actor 源码解析
参考:
发布于 10-31