Actor 模型调研
Actor 模型的历史
Actor 模型最早由 Hewitt 于 1977 年提出,经过许多人的扩展,后来 Agha 总结定义了一个由少数表达能力强的原语组成的Actor模型。由于其提供的封装和消息传递等机制与面向对象方法非常相似,而且Actor模型又具有灵活的控制结构和强大的并发描述能力,因此成为并发面向对象语言的重要的基础模型之一。
Actor 模型提出之后,一系列基于 Actor 模型的并发语言开始发展,包括 ABCL,ACT++,ConcurrentSmalltalk。其中比较著名的两种编程语言是 Erlang 和 Scala。Erlang是一种通用的并行程序设计语言,它由乔·阿姆斯特朗(Joe Armstrong)在瑞典电信设备制造商爱立信所辖的计算机科学研究室开发,目的是创造一种可以应付大规模开发活动的程序设计语言和执行环境。Scala 是一种针对JVM将函数和面向对象技术组合在一起的编程语言。这两种语言的并发特性的实现都是基于 Actor 模型来实现的,而这两种语言
是目前比较受欢迎的并发编程语言,因此它们的发展对Actor的推广起了重要的作用。 到现在为止,Actor模型的使用无处不在,即使有些地方并没有明确说采用的 Actor 模型:
Actor 设计思想中最重要的一点就是:Actor模型希望能以一种更自然的方式模拟人类社会分工协作解决一类复杂系统问题的工作模式,因此在 Actor 的设计哲学中,认为万物都是Actor,这与面向对象编程的“一切皆是对象”类似。两者的根本区别在于面向对象编程通常是顺序执行的,而Actor模型是并发执行的。
Actor 模型通过消息来进行Actor之间的交互,发送消息是异步非阻塞的消息传递,也就说发送完消息并不需要等待响应;而面向对象对象之间是通过方法调用的,是同步阻塞的。
因为 Actor 之间只能发送消息,而不会共享任何数据或者内存空间,因此不存在锁的问题。
Actor 模型通过隔离控制和计算实体,实现封装和模块化;
我们可以这么认为:
Actor=数据+行为+消息 ,
即一个Actor实体是数据、行为和消息的合集,Actor 之间只能通过消息来传递信息。
Actor 模型机制介绍
下面简单介绍一些 Actor 的设计原理和机制。
Actor 基本元素
Actor 模型中的基本元素有:actor 实体,邮箱,信息,行为,状态。
一个actor实体是对客观世界功能实体的抽象和模拟,它是一个活动的对象,其职能是处理各类消息。比如,现实世界中的学生和老师都是一个 actor。
每个 actor 实体内部都有一个信箱,用于存放接受的消息,信箱有一个唯一的地址,且在 Actor的整个生命周期中地址是不会改变的,一般actor名字可以作为地址。就比如每个学生和老师都有一个实名信箱一样。
actor 之间只能通过互发消息来影响对方的行为,也就是邮件,存储在邮箱中的消息按先到先服务的顺序被响应。每个 actor 实体只能向认识的 actor 实体发消息,也就是发送消息必须知道对方的地址。学生通过发邮件向老师传递消息,老师可以回复学生的信息,也可以单独向学生发消息,当然前提是学生或者老师知道对方的邮箱地址。
actor 的行为说明 actor如何响应一个请求消息,一般行为描述的代码体包含一组可以响应的方法定义。
每个 actor 总是处在一个状态之下,每个状态会对应一组行为定义,行为导致状态变化,行为执行依靠线程。比如一个缓冲池的Actor,在状态为空的时候只有 put 方法,在状态为满的时候只有get方法,不空也不满的时候既可以执行 get 操作,也可以执行 put 操作。
Actor 消息处理
在处理1个消息时, 1个actor可以做3种操作: 生成新actor、发消息给认识的actor和指定一个替代行为,可以使用三个原语来表示。
new 原语用来生成新的actor,返回值所生成的actor的邮箱地址. 这使得actor可以在计算中按需要动态生成;
send原语用来进行消息传递,一个消息包含目的actor的邮箱地址、要调用的方法名和引用该方法所需参数;
become原语用来指明一个替代行为,替代行为是指actor 在处理完本消息后所处的行为,也就是actor用指出的替代行为来处理下一个消息,这样在become语句完成之后如果有下一条消息到达,可以继续执行,实现流水线并发。Become操作也可以改变actor所处的状态。
以上三个操作在一个信息的处理过程中是可以并发执行的,因为他们均为原语操作,不会被打断。
Actor 并发实现
Actor模型为并行而生,根据维基百科中的描述,它原本是为大量独立的微型处理器所构建的高性能网络而设计的模型。而目前,单台机器也有了多个独立的计算单元,这就是为什么在并行程序愈演愈烈的今天,Actor模型又重新回到了人们的视线之中了。Actor模型的执行方式有两个特点:
1) 每个Actor,单线程地依次执行发送给它的消息,在内部可以通过become原语来实现流水线并发;
2) 不同的Actor可以同时执行它们的消息;
如上图所示,我们学生可以单线程串行的处理自己的信息,但是由于多个学生和多个老师都可以并行的处理自己的消息,因此整个系统的并发效率是大大提高了。
我们知道,系统中执行任务的最小单元是线程,数量一定程度上是有限的,而过多的线程会占用大量资源,也无法带来最好的运行效率。但是Actor并不是线程实体,虽然Actor的底层实现还是线程,但是后面我们会谈到,由于线程的复用,因此Actor的并发效率是比较高,目前实现百万级别的Actor模型已经存在了。
Actor任务调度策略
Actor模型的任务调度一般有两个策略,一个是基于线程调度,一个是基于事件调度。
基于线程的调度为每个 actor 实体分配一个线程,在接受一个消息时,如果当前 actor的消息队列为空,则会阻塞当前线程直到接收到该消息为止。基于线程的调度实现起来较为简单,但是线程的开销是比较大的,线程数量是受到操作系统的限制,因此基于线程的调度会影响actor的并发性能;
基于事件的调度,在有消息到达,有任务需要执行时才会为 actor 的任务分配线程并执行。这样就可以使用少量的线程来执行许多个 actor 实体提交的任务,从而保证了运算资源的动态调度,也使系统有很好的伸缩性。
Scala语言就是基于事件的调度策略,所有actor共享一个固定大小的线程池,当一个Actor启动之后,系统会分配一个线程给它使用,如果当前邮箱中没有需要处理的消息,就会把actor设置为等待状态并且释放这个线程;如果当前消息队列里面有消息需要处理,则从线程池里选择一个新的线程来处理。处理完后会立即返回,或者是立即抛出异常,结束该线程的执行,这样该线程就可以被其它actor使用。这种通过线程复用的方式可以提高Actor模型的并发性能。
Actor实现例子
这是一个基于java 的Actor模型的例子,因为java有一个Actor模型的实现库。可以看到,当hello actor收到greet的消息之后,他会发送一个打印消息到系统内置的标准输出stdout actor,stdout actor收到消息后会把信息打印到屏幕上,同时hello actor会创建一个world类型的actor,名称为other,并向这个actor发送audience消息。Other actor收到audience消息后会调用audience方法,所以也会向stdout actor发送打印World的消息。
注意,由于消息是异步的,因此hello和world的打印顺序是不确定的。
Actor模型的应用
讲完了actor的机制,那么我们需要考虑为什么需要actor模型,actor模型适合在什么情况下用?我们可以看到,如果我们的编程模型变成了这种只能进行消息传递,没有共享变量,也没有方法调用,这完全不符合我们的编程习惯,因此在一开始肯定会束手无策。但是Actor模型的存在肯定有其存在的意义,而且Actor模型更符合自然社会的运作模式,所以我觉得基于Actor模型的编程语言是需要我们转变编程的思维习惯,改变长期以来对面向过程和面向对象的程序设计的固定思维模式,就像人们从面向过程到面向对象的思维模式的改变。经过一些了解,发现Actor的主要运用有两种场景。
两种编程模式
为了了解Actor的应用,在这里先讲一下两种编程模式:线程驱动和事件驱动。
基于线程的编程如下:1
2result = query('SELECT * FROM posts WHERE id = 1');
do_something_with(result);
线程驱动一般是顺序执行的,比如我们要访问数据库,需要先查询数据库,再拿查询的结果去做事情,如上所示;
基于事件的编程如下:1
2
3
4query_finished = function(result) {
do_something_with(result);
}
query('SELECT * FROM posts WHERE id = 1', query_finished);
而事件的驱动则改变了思维模式,发射事件后即忘记,做别的事情了,无需立即等待刚才发射的响应结果了。在基于线程编程中,我们为什么要等待,是因为希望基于响应结果来进行一些逻辑,这是线程驱动的思维,而事件驱动的思维就是将这些逻辑放到消费者那里去处理(事件的发射方为生产者,事件结果的使用方为消费者)。比如这个例子中,我们将基于数据库查询结果的响应操作作为事件响应,当数据库查询返回后会回调这个函数。
因此事件驱动是一种异步编程,需要完全改变思路,将“请求响应”的思路转变到“事件驱动”思路上,事件驱动编程分为事件的检测和响应两部分。
两种编程模式在并发时存在的问题
基于线程驱动的编程模式是同步共享机制,因此在多线程编程时,存在以下问题:
- 需要显式协调共享数据锁;
- 依赖锁导致死锁;
- 难以调试;
- 多线程在多核上的性能并不会比每个单核一个线程性能更好;
基于事件驱动的编程模式是异步非阻塞机制,天生具有并发性,但是仍然存在以下问题:
- 使得业务流程晦涩难懂;
- 需要转变软件编程思维;
而Actor模型的出现综合这两者的优点,Actor模型将事件统一为消息传递,通过隔离控制和计算实体来实现封装,大大降低了事件驱动编程的难度。
Actor模型应用-事件编程
基于事件编程存在以下特点:
1) 事件发生不可控,事件响应处理必须及时。
2) 事件处理不能采取传统同步模式,容易锁,堵塞,性能差,设计耦合,不能切分。
3) 事件处理必须采取异步,性能高,设计松耦合。
4) 事件处理最好并发,与事件触发机制一致。
而Actor模型的优点就在于:核心是异步消息机制;无共享状态,无锁,无堵塞;异步,高并发;因此Actor模型是事件驱动编程目前最合适模型。
Actor模型应用-保证数据一致性
Actor模型的另外一种使用场景就是保护数据的一致性。
线程1:
开始事务 –> 访问表A –> 访问表B –> 提交事务
线程2:
开始事务 –> 访问表B –> 访问表A –> 提交事务
比如说有两个线程,第一个线程开始事务之后先访问表 A 再访问表B,第二个线程先访问表B,再访问表A,这种情况是很容易出现死锁的。
这里线程是代表控制行为,表AB是代表数据,因此传统的模型中数据是被动的给行为,这样的话就很难保证数据的一致性,必须要给数据上锁。
而Actor模型如何避免锁呢?
Actor模型的一个关键思想就是:要让数据自己有行为能力保护实现自己的一致性。因此在Actor模型中,数据的一致性是靠行为职责来保证的,没有行为守护的数据是一盘散沙,这样的数据必须靠锁来维护一致性。如果数据自身要求有严格的一致性,也就是事务机制,数据就不能被动被加工,要让数据自己有行为能力保护实现自己的一致性,就像孩子小的时候可以任由爸妈怎么照顾关心都可以,但是如果孩子长大有自己的思想和要求,他就可能不喜欢被爸妈照顾,他要求自己通过行动实现自己的要求。只有我们改变思路,让数据自己有行为维护自己的一致性,才能真正安全实现真正的事务。
Actor模型在一个较高的层面上通过数据的行为实现了无锁的并发模型。
参考资料
- Gul Agha. Actors: A Model of Concurrent Computation in Distributed Systems. Doctoral Dissertation. MIT Press. 1986.
- Philipp Haller and Martin Odersky (January 2007). Actors that Unify Threads and Events. Technical report LAMP. 2007.
- 解道jdon
- Dennis Kafura, Manibrata Mukherji, Greg Lavender. Act++ 2.0 : A Class Library for Concurrent Programming in C++ Using Actors.
- AKKA NOTES
- 董哲,刘琳,田籁声. 基于ACTOR模型的并发面向对象语言AC++[J]. 软件学报,1997,03:38-44.
转载请标明文章出处。本文内容为实践后的原创整理,如果侵犯了您的版权,请联系我进行删除,邮件:yanhealth@163.com