代码改变世界

ActorLite:一个轻量级Actor模型实现(上)

2009-05-11 20:10  Jeffrey Zhao  阅读(...)  评论(... 编辑 收藏

Actor模型

Actor模型为并行而生,具Wikipedia中的描述,它原本是为大量独立的微型处理器所构建的高性能网络而设计的模型。而目前,单台机器也有了多个独立的计算单元,这就是为什么在并行程序愈演愈烈的今天,Actor模型又重新回到了人们的视线之中了。Actor模型的理念非常简单:天下万物皆为Actor,Actor之间通过发送消息进行通信。Actor模型的执行方式有两个特点:

  1. 每个Actor,单线程地依次执行发送给它的消息。
  2. 不同的Actor可以同时执行它们的消息。

对于第1点至今还有一些争论,例如Actor是否可以并行执行它的消息,Actor是否应该保证执行顺序与消息到达的一致(祥见Wikipedia的相关词条)。而第2点是毋庸置疑的,因此Actor模型天生就带有强大的并发特性。我们知道,系统中执行任务的最小单元是线程,数量一定程度上是有限的,而过多的线程会占用大量资源,也无法带来最好的运行效率,因此真正在同时运行的Actor就会少很多。不过,这并不影响我们从概念上去理解“同一时刻可能有成千上万个Actor正在运行”这个观点。在这里,“正在运行”的含义是“处于运行状态”。

Actor模型的使用无处不在,即使有些地方并没有明确说采用的Actor模型:

  • Google提出的Map/Reduce分布式运算平台
  • C#,Java等语言中的lock互斥实现
  • 传统Email信箱的实现
  • ……

Actor模型的现有实现

提到Actor模型的实现就不得不提Erlang。Erlang专以Actor模型为准则进行设计,它的每个Actor被称作是“进程(Process)”,而进程之间唯一的通信方式便是相互发送消息。一个进程要做的,其实只是以下三件事情:

  • 创建其他进程
  • 向其他进程发送消息
  • 接受并处理消息

例如《Programming Erlang》中的一段代码:

loop() ->
    receive
        {From, {store, Key, Value}} ->
            put(Key, {ok, Value}),
            From ! {kvs, true},
            loop();
        {From, {lookup, Key}} ->
            From ! {kvs, get(Key)},
            loop()
    end.

在Erlang中,大写开头的标识表示“变量(variable)”,而小写开头的标识表示“原子(atom)”,而大括号及其内部以逗号分割的数据结构,则被称作是“元组(tuple)”。以上代码的作用为一个简单的“名字服务(naming service)”,当接受到{From, {store, Key, Value}}的消息时,则表示从From这个进程发来一个store请求,要求把Value与Key进行映射。而接受到{From, {lookup, Key}}消息时,则表示从From这个进程发来一个请求,要求返回Key所对应的内容。服务本身,也是通过向消息来源进程(即From)发送消息来进行回复的。

从Erlang语言的设计并不复杂,其类型系统更加几乎可以用“简陋”来形容,这使得其抽象能力十分欠缺,唯一的复杂数据结构似乎只有“元组”一种而已——不过我们现在不谈其缺陷,谈其“优势”。Erlang语言设计的最大特点便是引入了“模式匹配(pattern matching)”,当且仅当受到的消息匹配了我们预设的结构(例如上面的{XXX, {store, YYY, ZZZ}}),则会进入相应的逻辑片断。其次便是其尾递归的特性,可见上面的代码中在loop方法的结尾再次调用了loop方法。

如果说Erlang语言专为Actor模型而设计,那么Scala语言(学Java的朋友们都去学Scala吧,那才是发展方向)中内置的Actor类库则是外部语言Actor模型实现的经典案例了:

class Pong extends Actor {
  def act() {
    var pongCount = 0
    while (true) {
      receive {
        case Ping =>
          if (pongCount % 1000 == 0)
            Console.println("Pong: ping " + pongCount)
          sender ! Pong
          pongCount = pongCount + 1
        case Stop =>
          Console.println("Pong: stop")
          exit()
      }
    }
  }
}

Pong类继承了Actor模型,并覆盖其act方法。由于没有Erlang的尾递归特性,Scala Actor使用一个while (true)进行不断的循环。获取到消息之后,将会使用case语句对消息进行判断,并执行相应逻辑。Scala的Actor类库充分利用了Scala的语法特性,让Actor模型好像是Scala内置功能一样,非常漂亮。

此外,其他较为著名的Actor模型实现还有Io LanguageJetlang、以及.NET平台下的MS CCRRetlang。后文中我们还会简单提到.NET下Actor Model实现,其他内容就需要感兴趣的朋友们自行挖掘了。

Actor模型中的任务调度

Actor模型的任务调度方式分为“基于线程(thread-based)的调度”以及“基于事件(event-based)的调度”两种。

基于线程的调度为每个Actor分配一个线程,在接受一个消息(如在Scala Actor中使用receive)时,如果当前Actor的“邮箱(mail box)”为空,则会阻塞当前线程直到获得消息为止。基于线程的调度实现起来较为简单,例如在.NET中可以通过Monitor.Wait/Pulse来轻松实现这样的生产/消费逻辑。不过基于线程的调度缺点也是非常明显的,由于线程数量受到操作系统的限制,把线程和Actor捆绑起来势必影响到系统中可以同时的Actor数量。而线程数量一多也会影响到系统资源占用以及调度,而在某些情况下大部分的Actor会处于空闲状态,而大量阻塞线程既是系统的负担,也是资源的浪费。因此基于线程的调度是一个拥有重大缺陷的实现,现有的Actor Model大都不会采取这种方式。

于是另一种Actor模型的任务调度方式便是基于事件的调度。“事件”在这里可以简单理解为“消息到达”事件,而此时才会为Actor的任务分配线程并执行。很容易理解,我们现在便可以使用少量的线程来执行大量Actor产生的任务,既保证了运算资源的充分占用,也不会让系统在同时进行的太多任务中“疲惫不堪”,这样系统便可以得到很好的伸缩性。在Scala Actor中也可以选择使用“react”而不是“recive”方法来使用基于事件的方式来执行任务。

现有的Actor Model一般都会使用基于事件的调度方式。不过某些实现,如MS CCR、Retlang、Jetlang等类库还需要客户指定资源分配方式,显式地指定Actor与资源池(即线程池)之间的对应关系。而如Erlang或Scala则隐藏了这方面的分配逻辑,由系统整体进行统一管理。前者与后者相比,由于进行了更多的人工干涉,其资源分配可以更加合理,执行效率也会更高——不过其缺点也很明显:会由此带来额外的复杂度。

我们即将实现的简单Actor Model类库,也将使用了基于事件的调度方式。同样为了简化资源分配的过程,我们将直接使用.NET自带的线程池来运行任务。