共计 3411 个字符,预计需要花费 9 分钟才能阅读完成。
写在开始
一般来说有两种策略用来在并发线程中进行通信:共享数据和消息传递。使用共享数据方式的并发编程面临的最大的一个问题就是数据条件竞争。处理各种锁的问题是让人十分头痛的一件事。
传统多数流行的语言并发是基于多线程之间的共享内存,使用同步方法防止写争夺,Actors 使用消息模型,每个 Actor 在同一时间处理最多一个消息,可以发送消息给其他 Actor,保证了单独写原则。从而巧妙避免了多线程写争夺。和共享数据方式相比,消息传递机制最大的优点就是不会产生数据竞争状态。实现消息传递有两种常见的类型:基于 channel(golang 为典型代表)的消息传递和基于 Actor(erlang 为代表)的消息传递。
Actor 简介
Actor 模型 (Actor model) 首先是由 Carl Hewitt 在 1973 定义,由 Erlang OTP 推广,其 消息传递更加符合面向对象的原始意图。Actor 属于并发组件模型,通过组件方式定义并发编程范式的高级阶段,避免使用者直接接触多线程并发或线程池等基础概念。
Actor 模型 = 数据 + 行为 + 消息。
Actor 模型是一个通用的并发编程模型,而非某个语言或框架所有,几乎可以用在任何一门编程语言中,其中最典型的是 erlang,在语言层面就提供了 Actor 模型的支持,杀手锏应用 RabbitMQ 就是基于 erlang 开发的。
更加面向对象
Actor 类似面向对象编程(OO)中的对象,每个 Actor 实例封装了自己相关的状态,并且和其他 Actor 处于物理隔离状态。举个游戏玩家的例子,每个玩家在 Actor 系统中是 Player 这个 Actor 的一个实例,每个 player 都有自己的属性,比如 Id,昵称,攻击力等,体现到代码级别其实和我们 OO 的代码并无多大区别,在系统内存级别也是出现了多个 OO 的实例
class PlayerActor
{public int Id { get; set;}
public string Name {get; set;}
}
无锁
在使用 Java/C# 等语言进行并发编程时需要特别的关注锁和内存原子性等一系列线程问题,而 Actor 模型内部的状态由它自己维护即它内部数据只能由它自己修改(通过消息传递来进行状态修改),所以使用 Actors 模型进行并发编程可以很好地避免这些问题。Actor 内部是以单线程的模式来执行的,类似于 redis,所以 Actor 完全可以实现分布式锁类似的应用。
异步
每个 Actor 都有一个专用的 MailBox 来接收消息,这也是 Actor 实现异步的基础。当一个 Actor 实例向另外一个 Actor 发消息的时候,并非直接调用 Actor 的方法,而是把消息传递到对应的 MailBox 里,就好像邮递员,并不是把邮件直接送到收信人手里,而是放进每家的邮箱,这样邮递员就可以快速的进行下一项工作。所以在 Actor 系统里,Actor 发送一条消息是非常快的。
[外链图片转存失败, 源站可能有防盗链机制, 建议将图片保存下来直接上传(img-vWwNZ5t0-1570964270753)(https://timgsa.baidu.com/timg…]
这样的设计主要优势就是解耦了 Actor,数万个 Actor 并发的运行,每个 actor 都以自己的步调运行,且发送消息,接收消息都不会被阻塞。
隔离
每个 Actor 的实例都维护这自己的状态,与其他 Actor 实例处于物理隔离状态,并非像 多线程 + 锁 模式那样基于共享数据。Actor 通过消息的模式与其他 Actor 进行通信,与 OO 式的消息传递方式不同,Actor 之间消息的传递是真正物理上的消息传递。
天生分布式
每个 Actor 实例的位置透明,无论 Actor 地址是在本地还是在远程机器上对于代码来说都是一样的。每个 Actor 的实例非常小,最多几百字节,所以单机几十万的 Actor 的实例很轻松。如果你写过 golang 代码,就会发现其实 Actor 在重量级上很像 Goroutine。由于位置透明性,所以 Actor 系统可以随意的横向扩展来应对并发,对于调用者来说,调用的 Actor 的位置就在本地,当然这也得益于 Actor 系统强大的路由系统。
生命周期
每个 Actor 实例都有自己的生命周期,就像 C# java 中的 GC 机制一样,对于需要淘汰的 Actor,系统会销毁然后释放内存等资源来保证系统的持续性。其实在 Actor 系统中,Actor 的销毁完全可以手动干预,或者做到系统自动化销毁。
容错
说到 Actor 的容错,不得不说还是挺令人意外的。传统的编程方式都是在将来可能出现异常的地方去捕获异常来保证系统的稳定性,这就是所谓的防御式编程。但是防御式编程也有自己的缺点,类似于现实,防御的一方永远不能 100% 的防御住所有将来可能出现代码缺陷的地方。比如在 java 代码中很多地方充斥着判断变量是否为 nil,这些就属于防御式编码最典型的案例。但是 Actor 模型的程序并不进行防御式编程,而是遵循“任其崩溃”的哲学,让 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 可以共享一个线程池,这就保证了在使用最少线程资源的情况下,最大量化业务代码。
搜索公众号:架构师修行之路,领取福利,获取更多精彩内容