Actor Model
Actors模型(Actor model)首先是由Carl Hewitt在1973定义, 由Erlang OTP (Open Telecom Platform) 推广,其消息传递更加符合面向对象的原始意图。 Actors属于并发组件模型 ,通过组件方式定义并发编程范式的高级阶段,避免使用者直接接触多线程并发或线程池等基础概念。
在 Actor 模型中,一切都是 actor,actor 通过消息传递的方式与外界通信。消息传递是异步的。每个actor都有一个邮箱(Mailbox),该邮箱接收并缓存其他actor发过来的消息,actor一次只能同步处理一个消息,处理消息过程中,除了可以接收消息,不能做任何其他操作。
使用 Actor 模型需要遵循以下几个基本原则:
- 所有计算都是在 actor 内执行的
- actor 之间只能通过消息进行通信交流
- 为了响应消息,actor 可以进行如下操作
- 更改状态或行为
- 发消息给其他 actor
- 创建有限数量的子 actor
特性解读
所有计算都在一个 actor 中进行
actor 是最基本的计算单元,在使用 Actor 模型构建系统时,一切都是actor。无论是计算斐波那契序列还是维护系统中用户的状态,都可以在一个或多个 actor 中进行。
Actor 模型中的 actor 不仅有状态,还有行为,和OOP有点像。不同的是OOP将对象(类实例)作为基本的计算单元。
Actor 模型中另一个对高并发应用有帮助的是隔离actor状态的思想。actor 的状态永远不会直接暴露在外面,也无法直接被其他 actor 查看或修改,除非通过消息机制间接地进行。这种机制同样适用于 actor 的行为。actor内部的方法同样也不会直接暴露给其他 actor 。事实上,在 actor 内部,状态和行为可以被视为相同的因素。
actor 之间只能通过消息进行通信
在 Actor 模型中,所有的通信都是基于消息机制进行的,这也是 actor 之间进行通信的方法。
每个 actor 在创建的时候都会获得一个地址,该地址是与该 actor 通信的入口。不能通过这个地址直接访问该 actor,但是可以通过这个地址发消息给它。
发送给 actor 的消息是不可变的数据。这些消息会被发送到目标 actor 提供的地址并被存在邮箱(Mailbox)中。Actor 模型提供了最多投递一次的消息机制,这意味着有可能发生投递失败的情况,如果想要保证每次都投递成功,需要使用其他工具来辅助。
akka 实现了最少投递一次的机制,更进一步提供了更加强大的顺序保证机制,可以确保正在通信的 actor 之间的消息都是按照顺序被送达的。也就是说,一个actor 发给另一个 actor多个消息的时候,可以确保消息被送达的顺序和发送顺序是一致的。
消息被投递到邮箱(Mailbox)之后,actor 可以接受并处理这些消息,但是每次只能处理一条消息。所以actor 可以很自由的修改自己的内部状态,而不用担心是否会有其他线程也在操作该状态。
actor 可以创建子 actor
在 actor 模型中,一切都是 actor ,而且 actor 之间只能通过消息机制进行通信,但是 actor 还需要知道其他 actor 的存在。
当 actor 接收到消息之后,可以进行的操作之一是创建有限数量的子 actor。之后父节点就会知道它的所有子节点的存在,并可以访问子节点的地址。
除了通过创建子节点来获取其他 actor 的方法,一个 actor 还可以把地址信息通过消息机制发送给其他 actor。这样父节点便可以把他知道的所有actor 的地址信息通知给子 actor。
actor 的这种层级结构意味着,除根节点外的所有节点都将拥有一个父节点,同时任何节点都可以拥有一个或多个子节点。这样从根节点开始遍历整棵树的actor集合被称为一个 actor 系统。
actor系统中的每个 actor 总是可以通过其他地址被唯一标识。地址命名不需要遵循任何特定的规范,只要地址唯一即可。 akka 使用层级结构的模式来命名,就像目录结构一样。或者可以使用随机生成的唯一键。
优势
更加面向对象
Actor类似面向对象编程(OOP)中的对象,每个Actor实例封装了自己相关的状态,并且和其他Actor处于物理隔离状态。
举个游戏玩家的例子,每个玩家在Actor系统中是Player 这个Actor的一个实例,每个player都有自己的属性,比如Id,昵称,攻击力等,体现到代码级别其实和我们OO的代码并无多大区别,在系统内存级别也是出现了多个OO的实例
1 | class PlayerActor { |
无锁
Actor 模型内部的状态由它自己维护,即它内部数据只能由它自己修改(通过消息传递来进行状态修改),所以使用Actors模型进行并发编程可以很好地避免这些问题。
异步
每个Actor都有一个专用的MailBox来接收消息,这也是Actor实现异步的基础。当一个Actor实例向另外一个Actor发消息的时候,并非直接调用Actor的方法,而是把消息传递到对应的MailBox里,就好像邮递员,并不是把邮件直接送到收信人手里,而是放进每家的邮箱,这样邮递员就可以快速的进行下一项工作。所以在Actor系统里,Actor发送一条消息是非常快的。
这样的设计主要优势就是解耦了Actor,数万个Actor并发的运行,每个actor都以自己的步调运行,且发送消息,接收消息都不会被阻塞。
隔离
每个Actor的实例都维护着自己的状态,与其他Actor实例处于物理隔离状态,并非像 多线程+锁
模式那样基于共享数据。
天生分布式
每个Actor实例的位置透明,无论Actor地址是在本地还是在远程机器上对于代码来说都是一样的。每个Actor的实例非常小,最多几百字节,所以单机几十万的Actor的实例很轻松。由于位置透明性,所以Actor系统可以随意的横向扩展来应对并发,对于调用者来说,调用的Actor的位置就在本地,当然这也得益于Actor系统强大的路由系统。
容错
传统的编程方式都是在将来可能出现异常的地方去捕获异常来保证系统的稳定性,这就是所谓的防御式编程。但是防御式编程也有自己的缺点,类似于现实,防御的一方永远不能100%的防御住所有将来可能出现代码缺陷的地方。比如在java代码中很多地方充斥着判断变量是否为nil,这些就属于防御式编码最典型的案例。
但是Actor模型的程序并不进行防御式编程,而是遵循“任其崩溃”的哲学,让Actor的管理者们来处理这些崩溃问题。比如一个Actor崩溃之后,管理者可以选择创建新的实例或者记录日志。每个Actor的崩溃或者异常信息都可以反馈到管理者那里,这就保证了Actor系统在管理每个Actor实例的灵活性。
劣势
由于同一类型的Actor对象是分散在多个宿主之中,所以取多个Actor的集合是个软肋。比如在电商系统中,商品作为一类Actor,查询一个商品的列表在多数情况下经过以下过程:首先根据查询条件筛选出一系列商品id,根据商品id分别取商品Actor列表(很可能会产生一个商品搜索的服务,无论是用es或者其他搜索引擎)。如果量非常大的话,有产生网络风暴的危险(虽然几率非常小)。在实时性要求不是太高的情况下,其实也可以独立出来商品Actor的列表,利用MQ接收商品信息修改的信号来处理数据一致性的问题。
在很多情况下基于Actor模型的分布式系统,缓存很有可能是进程内缓存,也就是说每个Actor其实都在进程内保存了自己的状态信息,业内通常把这种服务成为有状态服务。但是每个Actor又有自己的生命周期,会产生问题吗?还是拿商品作为例子, 如果环境是非Actor并发模型,商品的缓存可以利用LRU策略来淘汰非活跃的商品缓存,来保证内存不会使用过量,如果是基于Actor模型的进程内缓存呢,每个actor其实就是缓存本身,就不那么容易利用LRU策略来保证内存使用量了,因为Actor的活跃状态对于你来说是未知的。
分布式事物问题,其实这是所有分布式模型都面临的问题,非由于Actor而存在。还是以商品Actor为例,添加一个商品的时候,商品Actor和统计商品的Actor(很多情况下确实被设计为两类Actor服务)需要保证事物的完整性,数据的一致性。在很多的情况下可以牺牲实时一致性用最终一致性来保证。
每个Actor的mailBox有可能会出现堆积或者满的情况,当这种情况发生,新消息的处理方式是被抛弃还是等待呢,所以当设计一个Actor系统的时候mailBox的设计需要注意。
升华一下
- 通过以上介绍,既然Actor对于位置是透明的,任何Actor对于其他Actor就好像在本地一样。基于这个特性我们可以做很多事情了,以前传统的分布式系统,A服务器如果想和B服务器通信,要么RPC的调用(http调用不太常用),要么通过MQ系统。但是在Actor系统中,服务器之间的通信都变的很优雅了,虽然本质上也属于RPC调用,但是对于编码者来说就好像在调用本地函数一样。其实现在比较时兴的是Streaming方式。
- 由于Actor系统的执行模型是单线程,并且异步,所以凡是有资源竞争的类似功能都非常适合Actor模型,比如秒杀活动。
- 基于以上的介绍,Actor模型在设计层面天生就支持了负载均衡,而且对于水平扩容支持的非常好。当然Actor的分布式系统也是需要服务注册中心的。
- 虽然Actor是单线程执行模型,并不意味着每个Actor都需要占用一个线程,其实Actor上执行的任务就像Golang的goroutine一样,完全可以是一个轻量级的东西,而且一个宿主上所有的Actor可以共享一个线程池,这就保证了在使用最少线程资源的情况下,最大量化业务代码。