代码改变世界

适合C# Actor的消息执行方式(4):阶段性总结

2009-07-20 09:19 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

这个系列原本打算写3篇,也就是说在上一篇文章中已经把老赵认为较合适的方法展现出来了,但事实上这个系列的计划已经扩展为8篇了——没错,翻了一倍不止,而最终是几篇现在我也无法断言。其实这也是我写不了书的原因之一。虽说唯一不变的就是变化,但是我变的太离谱了。不断写,不断出现新想法,不断变化。作为这个系列的第4篇,我们现在来对之前3篇文章进行一番阶段性总结。

阶段性总结本不在计划之内,不过似乎Actor模型这方面内容还不太受人关注,因此有的朋友也误解这系列文章想要解决的问题是什么。除了这方面的解释之外,我还会对之前提出的几种做法进行综合的对比,可以进一步了解整个演变过程的思路,为接下去的改变做铺垫——因为下次改变就涉及到多个方向,每个方向都是在一定程度上真正可用的方式。

我们究竟是要解决什么问题

Actor模型的本质已经被强调了无数遍:万物皆Actor。Actor之间只有发送消息这一种通信方式,例如,无论是管理员让工作者干活,还是工作者把成果交还给管理员,它们之间也要通过发送消息的方式来传递信息。这么做看似不如直接方法调用来的直接,但是由于大量的消息可以同时执行。同样,消息让Actor之间解耦,消息发出之后执行成功还是失败,需要耗费多少时间,只要没有消息传递回来,这一切都和发送方无关。Actor模型的消息传递形式简化了并行程序的开发,使开发人员无需在共享内存(确切地说,其实是共享“写”)环境中与“锁”、“互斥体”等常用基础元素打交道。不过,使用Actor模型编写应用程序,需要开发人员使用一种与以往不同的设计思路,这样的思路说难倒不难,说简单也不简单。等我们有了成熟、稳固的Actor模型之后(例如高效的调度,合适的容错机制,老赵正在为此努力),再回头来探究这种特殊的架构方式。

由于Actor执行的唯一“事件”便是接受到了一个消息,而一个Actor很可能会做多件事情,因此我们一定需要一种机制,可以把消息“分派”到不同的“逻辑段”中去,并为不同的逻辑指定各自所需要的参数。例如,Person是一个Actor类型,它有三种任务,不同的任务会带有不同参数:

  1. 聊天(Chat):指定另一个Person对象(聊天的另一方),以及一个Topic对象(聊天的话题)。
  2. 吃饭(Eat):指定一个Restaurant对象(餐馆)。
  3. 干活(Work):指定一个Person对象(工作完成后的汇报人),以及一个Job对象(任务)。

当Person对象获得一条消息时,它需要将其识别为聊天、吃饭或干活中的一种,再从中获取到这个行动所需要的数据。如果用一幅示意图来表示,它可能是这样的:

message dispatch.png

如何在C#中把一条消息转化为一段逻辑的执行,并且尽可能确保一些优势(如易于编写,静态检查,代码提示,重构,单元测试……),这便是这系列文章唯一的目的。正如文章的标题,我们关注的是“消息执行方式”,而不是:

  • “消息传递”与“共享内存”两种并行方式的比较
  • 讲述Actor模型的应用程序设计方式。
  • 提出消息传递时的解耦方式。
  • ……

文章使用Actor模型作为示例,是因为我编写的ActorLite组件易于说明问题,并且是典型的“消息传递”场景。事实上,文章所表达的内容,适合任何基于消息传递的C#场景,例如内存中的消息队列、生产者/消费者模式、消息总线……它并没有限制Actor模型这一种架构方式。

Erlang的模式匹配

首先,我们观察了Erlang中的模式匹配。在Erlang中,一个消息往往为一个元组,而一个Actor便会根据这个消息的模式,或者用更通俗的方式来讲,“结构”,来选择处理消息的逻辑分支。例如对于上面举出的例子,它的模式匹配代码便可能是:

receive
    {chat, Person, Topic} ->
        ... % “聊天”逻辑
    {eat, Restaurant} ->
        ... % “吃饭”逻辑
    {work, Person, Job} ->
        ... % “干活”逻辑
end

小写字母开头的标识符为“原子”,可以认为是一个常量,用于标识这个消息用来“干什么”。大写开头的为“绑定”,可以认为是一个变量(虽然不可变),用于标识这个消息“所使用的数据”。如果使用示意图来表示这个消息执行方式,则类似于:

erlang pattern matching

如果收到的消息是{eat, {mcdonalds, 2}},则会执行“吃饭”逻辑,而执行时Restaurant的值将自动绑定为元组{mcdonalds, 2},而不需要任何转化或赋值操作。Erlang便是这样将一个消息转化为一段逻辑执行的。

C#的Tag Message

一般来说,Erlang的消息是一个元组,而元组的第一个元素为原子,用来标识“做什么”。这个原子被称为是这个消息tag,这种用法被叫做Tag Message,它是“Erlang编程规范”中的推荐用法。在C#中,我们当然也可以这么做

class Person : Actor<Message>
{
    protected override void Receive(Message message)
    {
        if (message.Tag == "Chat")
        {
            Person another = (Person)message.Arguments[0];
            Topic topic = (Topic)message.Arguments[1];
            // ...
        }
        else if (message.Tag == "Eat")
        {
            Restaurant restaurant = (Restaurant)message.Arguments[0];
            // ...
        }
        else if (message.Tag == "Work")
        {
            Person reportTo = (Person)message.Arguments[0];
            Job job = (Job)message.Arguments[1];
            // ...
        }
    }
}

图示如下:

message dispatch.png

这个方式和Erlang可谓如出一辙,但是由于缺少了Erlang的模式匹配和自动绑定,于是C#代码需要大量的if…else判断,以及繁琐而危险的转型操作。此外,和Erlang中动态类型的缺点完全相同,无论是消息的发送还是接受完全不是静态类型的,因此无论是静态检查,编辑还是重构都比较困难。试想,如果一个公用的服务所接受的消息结构改变了,那么所有用到它的地方都必须修改正确——如果缺少静态检查,错误都只能在运行时才能发现。Erlang有着强大的动态升级能力,尚可接受不断地在线更新。而在.NET平台中,如果使用这种Tag Message的方式,待到运行时发现错误,要修改起来就比较麻烦了。

强类型消息

为了避免繁琐的转型,为了获得类型安全的各种优势,我们也可以选择为每种不同的消息创建独立的类型。不过由于一个Actor往往会应对各种消息,因此在.NET环境中,往往我们需要把消息类型定义为object。如果使用ActorLite来演示的话,代码可能是这样的:

class Person : Actor<object>
{
    protected override void Receive(object message)
    {
        if (message is ChatMessage)
        {
            ChatMessage chatMsg = (ChatMessage)message;
            Person another = chatMsg.Another;
            Topic topic = chatMsg.Topic;
            // ...
        }
        else if (message is EatMessage)
        {
            EatMessage eatMsg = (EatMessage)message;
            Restaurant restaurant = eatMsg.Restaurant;
            // ...
        }
        else if (message is WorkMessage)
        {
            WorkMessage workMsg = (WorkMessage)message;
            Person reportTo = workMsg.ReportTo;
            Job job = workMsg.Job;
            // ...
        }
    }
}

图示如下:

string typing message

使用if…else来进行逻辑分支判断还是必要的,不过我们这里使用了静态类型代替了Magic String(当然在使用Tag Message时也可以使用常量)的判断,同时危险而麻烦的类型转换操作也减少的。与Tag Message相比,这种做法获得了一定的类型安全优势,可以得到编译器的静态检查,做起重构来也有了依据。不过他也有比较明显的缺陷,那就是需要构建大量的消息类型。要知道消息类型的数量很可能是Actor类型数量的几倍,每种消息类型还包含着多个属性,构造函数接受参数,然后在构造函数里设置属性……这种做法对复杂性的提升还是较为可观的,有时候会感觉还不如使用简单的Tag Message。

接口、协议及消息

消息,其实是两个Actor之间定下的协议,一个Actor可以实现多种协议。这样的对应关系使人联想到.NET中的接口。因此我们可以使Actor实现某个接口,一条消息其实就是使用“委托”来告诉Actor该做什么事情。一个“委托”对象也可以自然地携带执行时所用的数据。这似乎满足我们的要求。使用这种方式来实现的消息执行大概是这样的

interface IPersonMessageHandler
{
    void Chat(Person another, Topic topic);
    void Eat(Restaurant restaurant);
    void Work(Person reportTo, Job job);
}

class Person : Actor<Action<IPersonMessageHandler>>, IPersonMessageHandler
{
    protected override void Receive(Action<IPersonMessageHandler> message)
    {
        message(this);
    }

    #region IPersonMessageHandler Members

    void IPersonMessageHandler.Chat(Person another, Topic topic) { ... }

    void IPersonMessageHandler.Eat(Restaurant restaurant) { ... }

    void IPersonMessageHandler.Work(Person reportTo, Job job) { ... }

    #endregion
}

图示如下:

interface contract

使用这种方式似乎带来的许多好处,例如我们使用接口这个非常轻量级的特性实现了消息,无须编写额外的代码将消息转化为逻辑。此外,接口是强类型的,适合编译期检查,易于重构和单元测试,还为我们带来“消息组”这样一种简单的消息管理方式——似乎就是我们理想的消息执行方式啊。是啊,这是很美好的消息执行方式,但是……为什么说它“中看不中用”?

这里带来的最大问题在于耦合地过于强烈。例如Chat消息的第一个参数是Person,表示聊天的对象。但是很可能在同一个系统中,可以聊天的对象不一定仅限于是人(Person),还可能是一个机器人(Robot);同理Work的汇报者也可能是一个记录系统。事实上,我们可能只要求Chat的目标是一个可以处理IChater消息的Actor即可,这意味着Chat方法的第一个参数的类型需要是Actor<Action<IChater>>。但是,这个Actor还需要接受其他类型的消息,如IWorkReportHandler,这又意味着它的类型需要是Actor<Action<IWorkResultHandler>>。一个Actor又如何成为两种类型?

在实际运用中,这点无法回避,因此我们必须得变。

使用“消息总线”来解耦?

风云兄提出,既然问题的关键在于强耦合,为什么不使用消息总线来解耦呢?其实这问题很容易回答。

首先,“强耦合”并不是我们想要解决的问题。我们想要解决的是“消息执行”(见文章标题)而不是“消息传递”,“强耦合”只是我们得到满意的解决方案之前所遇到的困难而已。“消息总线”是消息传递时,系统(大粒度)组件之间的解耦方式。而现在我们要解开的,是Actor这种小粒度对象之间消息传递造成的耦合。在.NET消息传递过程中,消息是一个对象,一般在框架中使用TMessage表示。例如风云兄给出的代码,TMessage即为字符串。我们的目标是如何从一个TMessage类型的对象(如字符串)分配至不同的逻辑片断——还要携带参数过去。风云兄的例子回避了这一点。

事实上,正如文章一开始所说的那样,我们文章得出的解决方案并不仅限于Actor模型,它适合各种消息传递场景——这些场景里自然包括“消息总线”的使用。关于文章的目的,“亚历山大同志”同学称之为“要在C#里面实现优雅的模式匹配的问题”。从一定角度上来说,老赵认为这个描述非常妥当,因为Erlang中模式匹配的目的便是消息执行。

其实这可能也是基于Actor模型的程序架构方式还不为人熟悉的缘故。其实“消息总线”和“Actor模型”间的关系……不大,其相似性大概也只有“消息传递”这个特性而已。但是,在Actor模型中,消息是Actor对象间通信的唯一方式。Actor模型在使用时,内存中可能产生成千上万个Actor对象,它们互相发送消息产生交互。同时,Actor可以随时被创建出来,用完后又可以随时丢弃,因此Actor之间的通信无法“预先指定”。而“消息总线”需要在运行之前进行“注册”,然后它可以控制一条消息的Subject(即目标)。如果使用消息总线来实现Actor模型的话,则必须在Actor对象创建出来以后“注册”到消息总线,然后在Actor销毁之后“解开”。这又要求消息总线拥有高效的“注册”和“解开”操作,还必须完全是线程安全的。

正是这个原因,Actor模型在使用时一般都是得到对方的引用并“直接”发送。而且,会把自己作为消息的一部分传递过去,这是为了让对方可以“直接”回复,这带来了程序设计过程中相当的灵活性。当然,这条消息可能会暂时放在Actor的队列中,等Actor空闲时再执行。这又是Actor模型的又一个特性:对于单个Actor来说,消息的执行完全是线程安全的。这大大简化了并行程序设计的难度,也是它与“共享内存”这种并行程序设计方式的区别。如果使用消息总线来实现Actor模型,它可以保证向一个Subject快速发送两条消息后,它们被依次执行吗?

因此,如乒乓测试这种简单的消息传递示例,可以使用消息总线来实现,而复杂的场景就不合适了。在下一篇文章中,老赵会使用Actor模型来实现一个相对复杂的示例:网页小爬虫。在添加功能的过程中,您一定可以更好的了解Actor模型的使用方式,以及它在并行程序设计时的优势。

相关文章