代码改变世界

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

2009-05-16 17:52 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

上一篇文章中,我们实现了一个简单的Actor模型。如果要构建一个Actor,便只是简单地继承Actor<T>类型并实现其Receive方法即可。在上次文章的末尾,我们使用C#演示了该Actor模型的使用。不过现在我们将尝试一下F#。

C#使用Actor模型的缺陷

在Erlang中,每个消息都使用模式匹配来限制其“结构”或“格式”,以此表达不同含义。C#类型系统的抽象能力远胜于Erlang,但是Erlang的“动态性”使得开发人员可以在程序中随意发送和接收任何类型,这种“自由”为Erlang带来了灵活。我们的Actor模型中,每个Actor对象都需要一种特定的消息格式,而这种消息格式承担了“表现Actor所有职责”的重任,但是一个Actor的职责是可能由任何数据组合而成。例如一段最简单的“聊天”程序,其Actor表示了一个“人”,用Erlang实现可能就会这么写:

loop() ->
    receive
        % 系统要求发起聊天,于是向对方打招呼
        {start, Person} ->
            Person ! {self(), {greeting, "你好")},
            loop();

        % 有人前来发起聊天,于是向对方说了点什么
        {Person, {greeting, Message}} ->
            Person ! {self(), {say, "..."}},
            loop();

        % 有人前来说话,于是拜拜
        {Person, {say, Message}} ->
            Person ! {self(), {bye, "..."}},
            loop();

        ...
    end.

不同的元组(tuple)配合不同的原子(atom)便表示了一条消息的“含义”,但是使用C#您又该怎样来表现这些“命令”呢?您可能会使用:

  1. 使用object[]作为消息类型,并检查其元素。
  2. 使用object作为消息类型,并判断消息的具体类型。
  3. 使用枚举或字符串代表“命令”,配合一个参数集合。

第1种做法十分麻烦;第2种则需要“先定义,后使用”也颇为不易;而第3种做法,平心而论,如果有一个“分发类库”的支持就会比较理想——可能比这篇文章中的F#还要理想。老赵正在努力实现这一功能,因为C#的这个特性会影响到.NET平台下所有Actor模型(如第一篇文章中所提到的CCR或Retlang)的使用。

而目前,我们先来看看F#是否可以略为缓解一下这方面的问题。

在F#中使用Actor模型

Erlang没有严谨的类型系统,其“消息类型”是完全动态的,因此非常灵活。那么F#又有什么“法宝”可以解决C#中所遇到的尴尬呢?在现在这个问题上,F#有三个领先于C#的关键:

  • 灵活的类型系统
  • 强大的模式匹配
  • 自由的语法

虽然F#也是强类型的编译型语言(这点和C#一致),但是F#的类型系统较C#灵活许多,例如在“聊天”这个示例中,我们就可以编写如下类型作为“消息”类型:

type Message = string
type ChatMsg = 
    | Start of Person
    | Greeting of Person * Message
    | Say of Person * Message
    | Bye of Person * Message

在这个定义中用到了F#类型系统中的三个特点:

  • 类型别名:即type Message = string。为一个已有的类型定义一个别名,可以得到更好的语义。与C#使用using定义别名不同的是,F#中的别名可以定义为全局性的,而不仅仅是“源代码”级别的别名。
  • Discriminated Unions:即type ChatMsg = …。Discriminated Unions可以为一个类型指定多个discriminator,每个discriminator由一个名称,以及另一种具体类型来表示。不同的discriminator的具体类型可以不同。
  • 元组(Tuple):即Person * Message。在F#中可以通过把现有类型按顺序进行任意组合来得到新的类型,这种类型便被称为“元组”。

在Actor模型中,我们便组合了F#的三个特别特性,定义了消息的具体类型。而在使用时,我们便可以使用“模式匹配”对不同的“消息”——其实是CharMsg的不同discriminator进行不同地处理。于是具体的Actor类型Person,便可以使用如下定义:

and Person(name: string) = 
    inherit ChatMsg Actor()
    
    let GetRandom = 
        let r = new Random(DateTime.Now.Millisecond)
        fun() -> r.NextDouble()

    member self.Name = name
    
    override self.Receive(message) =
        match (message) with

Person类的构造函数接受一个name作为参数,并将其放置到Name属性中。我们同时定义了GetRandom函数,它会在内部构造一个System.Random对象,并每次返回NextDouble方法的值(请注意,无论调用多少次GetRandom方法,永远使用了同一个Random对象,因为他是在定义GetRandom方法时创建的)。而在override的Receive方法中,我们使用“模式匹配”对message对象进行处理:

        // 系统要求发起聊天
        | Start(p) -> 
            Console.WriteLine("系统让{0}向{1}打招呼", self.Name, p.Name)
            Greeting(self, "Hi, 有空不?") |> p.Post

请注意上述最后一行,原本我们使用p.Post(…)的调用方式,现在使用了“|>”符号代替。在F#中,x |> f便代表了f(x),它的本意是可以把f(g(h(x)))这样冗余的调用方式转变为清晰的“消息发送”形式:x |> h |> g |> f。而“消息发送”也恰好是我们所需要的“感觉”。因此,我们在接下来的代码中也使用这样的方式:

        // 打招呼
        | Greeting(p, msg) ->
            Console.WriteLine("{0}向{1}打招呼:{2}", p.Name, self.Name, msg)
            if (GetRandom() < 0.8) then
                Say(self, "好,聊聊。") |> p.Post
            else
                Bye(self, "没空,bye!") |> p.Post
        // 进行聊天
        | Say(p, msg) ->
            Console.WriteLine("{0}向{1}说道:{2}", p.Name, self.Name, msg)
            if (GetRandom() < 0.8) then
                Say(self, "继续聊。") |> p.Post
            else
                Bye(self, "聊不动了,bye!") |> p.Post
        // 结束
        | Bye(p, msg) ->
            Console.WriteLine("{0}向{1}再见:{2}", p.Name, self.Name, msg)

至此,Person类型定义完毕。我们构造三个Person对象,让它们随意聊天:

let startChat() =
    let p1 = new Person("Tom")
    let p2 = new Person("Jerry")
    let p3 = new Person("老赵")
    Start(p2) |> p1.Post
    Start(p3) |> p2.Post

startChat()

结果如下(内容会根据随机结果不同而有所改变):

系统让Tom向Jerry打招呼
系统让Jerry向老赵打招呼
Jerry向老赵打招呼:Hi, 有空不?
Tom向Jerry打招呼:Hi, 有空不?
Jerry向Tom说道:好,聊聊。
老赵向Jerry说道:好,聊聊。
Jerry向老赵说道:继续聊。
Tom向Jerry说道:继续聊。
Jerry向Tom说道:继续聊。
老赵向Jerry说道:继续聊。
Jerry向老赵说道:继续聊。
Tom向Jerry再见:聊不动了,bye!
老赵向Jerry说道:继续聊。
Jerry向老赵再见:聊不动了,bye!

使用Actor模型抓取网络数据

我们再来看一个略为“现实”一点的例子,需要多个Actor进行配合。首先,我们定义一个“抓取”数据用的Actor,它的唯一作用便是接受一个消息,并将抓取结果传回:

type Crawler() =
    inherit ((obj Actor) * string) Actor()

    override self.Receive(message) =
        let (monitor, url) = message
        let content = (new WebClient()).DownloadString(url)
        (url, content) |> monitor.Post

再使用“单件”方式直接定义一个monitor对象:

let monitor =
    { new obj Actor() with
        override self.Receive(message) =
            match message with
            // crawling
            | :? string as url -> (self, url) |> (new Crawler()).Post

            // get crawled result
            | :? (string * string) as p ->
                let (url, content) = p
                Console.WriteLine("{0} => {1}", url, content.Length)

            // unrecognized message
            | _ -> failwith "Unrecognized message" }

每次收到“抓取”消息时,monitor都会创建一个Crawler对象,并把url发送给它,并等待回复消息。而在使用时,只要把对象一个一个“发送”给monitor便可:

let urls = [
    "http://www.live.com";
    "http://www.baidu.com";
    "http://www.google.com";
    "http://www.cnblogs.com";
    "http://www.microsoft.com"]

List.iter monitor.Post urls

运行结果如下:

http://www.live.com => 18035
http://www.google.com => 6942
http://www.cnblogs.com => 62688
http://www.microsoft.com => 1020
http://www.baidu.com => 3402

性能分析

最后,我们再对这个Actor模型的性能作一点简单的分析。

如果从“锁”的角度来说,这个Actor模型唯一的锁是在消息队列的访问上,这基本上就是唯一的瓶颈。如果把它替换为lock-free的队列,那么整个Actor模型就是完全的lock-free实现,其“调度”性能可谓良好。

不过,从另一个角度来说,这个Actor模型的调度非常频繁,每次只执行一个消息。试想,如果执行一个消息只需要50毫秒,而进行一次调度就需要100毫秒,那么这个性能的瓶颈还是落在“调度”上。因此,如果我们需要进一步提高Actor模型的性能,则需要从Dispatcher.Execute方法上做文章,例如把每次执行一个消息修改为每次执行n个消息,或超过一个时间的阈值再进行下一次调度。减少调度,也是提高Actor模型性能的关键之一。

此外,如果觉得.NET自带的线程池性能不高,或者说会受到程序其他部分的影响,那么也可以使用独立的线程池进行替换。

自然,任何性能优化都不能只凭感觉下手,一切都要用数据说话,因此在优化时一定要先建立合适的Profile机制,保证每一步优化都是有效的。

 

源代码及示例下载:http://code.msdn.microsoft.com/ActorLite