IT技术博客大学习 共学习 共进步

ECS 中的消息发布订阅机制

云风的 BLOG 2020-02-01 15:20:17 浏览 2,401 次

   我们在实践 ECS 框架时发现,之所以 ECS 的概念诞生于游戏领域,是因为游戏程序往往都在周期性的处理一批对象,进行运算,根据上个周期的状态得到下个周期的状态。而传统人机交互的应用则是响应型的:即一个外部请求触发一系列的业务运作。

   如果你把游戏业务塞到响应型框架中,就会发现,不得不用时间去触发,业务响应的是 timer 。但这种情况下,timer 几乎没有携带任何状态,对单个 timer 的响应,是不可能做成无状态的:它本身就是整个游戏世界对上个状态的迭代。

   这种情况下,响应式框架就很低效。

   但是,如果框架完全做周期性自迭代,对外部输入事件的处理又远不如响应式框架灵活。

   如果只是简单的操作输入还好,比如手柄,我们可以每帧把手柄各个按键的状态置入世界,那么 System 在不断迭代时,直接把这些状态当作世界中某个单例的状态就好了。但更复杂的输入就没那么好做了。

   我们在一开始实践 ECS 框架时,很想保持整个模式的纯粹性,刻意回避了传统响应式框架里的东西,仅仅只添加了一个外部事件输入的消息队列。对于框架内部产生的事件,如组件的出生、销毁;内部对象的状态机状态切换引发的次生效应;物理系统抛出的事件,等等,都转换为状态的变化。由对应的 System 每帧去重复处理这些状态。

   最近我觉得,刻意的在 ECS 框架中去回避一个事件系统是不对的。游戏业务本身也是一个混合体:混合了周期性的世界状态迭代模式和响应式业务处理模式。

   所以我下决心给 ECS 框架增加一个完备的消息发布订阅模块。

   由于我们的框架是用 Lua 编写的,所以这样一个模块可以做的远比 C/C++ 的类似框架更灵活。我们的每条消息都是一个 key/value 元组,用 lua table 承载,例如:{ type = "new" , eid = 42 } 就是一条消息,表示一个 id 为 42 的 entity 创建出来了。而鼠标消息则可能是 { type = "mouse", action = "move", x = 100, y = 200 } 。

   所有的消息都通过 world:pub(message) 来发布出去。

   而任何 System 都可以通过 mailbox = world:sub(pattern) 来订阅满足一类 pattern 的消息。这个 pattern 也是一个 key/value 元组,比如 { type = "new" } 表示关注所有的 new 类型的消息;{ type = "mouse" } 可以跟住所有鼠标消息,{ type = "mouse", action = "move" } 则可以精确到鼠标的移动,{ eid = 42 } 可以关注到 42 号 entity 上发生的所有事情(通常用于 log)。

   任何 system 都可以通过 for msg in mailbox:each() do  来枚举信箱里所有的消息,并清空信箱。


   看看背后的实现:

   时间复杂度最高的部分在于 pub 消息的时候,目前的实现中,pub 的那一刻就已经把消息分发到了所有之前 sub 过的 mailbox 中了,通常会有 n 个 mailbox 关心这条消息。每个 mailbox 就是一个队列,所以迭代反而很简单。

   我们的消息匹配(message matching)很灵活,规则却很简单:pattern 中的每一个 key/value 就是一个过滤条件,只有消息满足所有的条件,才会被投递到对应的 mailbox 中。如果不加优化,pub 的时间复杂度是 O(n*m) 的,n 是消息的长度,m 是系统中的 mailbox 数量。

   但优化也比较容易,在 sub 时,建立索引 cache ,用空间换时间即可。

   例如:在一次 sub 的 pattern 中,出现了一条 type = "new" 的条件,那么就建立一个 type:new 的索引表,把所有满足这个条件的 mailbox 都放在一个集合(候选集)中;同时不满足这个条件的 mailbox 也放入一个集合(排除集)。

   其中,满足条件指明确写了 type = "new" 这个条件的 pattern 或是 pattern 中没有 type 这个 key 。不满足条件指,pattern 中有 type 这个 key 但 value 不是 "new" 。

   这样,pub 一条消息时,根据消息中的一项,就能 O(1) 快速判断,这个条件能排除哪些 mailbox ,以及哪些 mailbox 是潜在的候选者。遍历完消息后,依然留在候选者名单中的 mailbox 就是筛选出来的。因为每次处理一个条件,最终候选集都会减小。为了减少比较次数,还可以对消息中所有条件对应的候选集的大小排序,先过滤候选集较小的那个条件,

   这样,这个过程虽然还是 O(n * m) ,但 m 是最短候选集的长度而不是所有 mailbox 的个数,大多数条件对应的 m 为 0 (没有人关心)。

   如果实际使用的时候,筛选条件五花八门,可能导致 cache 索引集占内存过多,我认为可以用元表机制做一些合并,或是标注一些条件不生成 cache ,而是退化成遍历的方式检查。

   另外,对于常用的条件组合,还可以生成联合索引 cache 。当然,这些都是以后可以优化的地方。

建议继续学习

  1. 各消息队列软件产品大比拼 (阅读 6,081)
  2. 浅析手机消息推送设计 (阅读 4,301)
  3. Feed消息队列架构分析 (阅读 3,722)
  4. 2015年版阿里云ECS服务器使用总结(与aws比较) (阅读 3,340)
  5. storm入门教程 第四章 消息的可靠处理 (阅读 3,100)
  6. Android的Handler机制原理 (阅读 2,901)
  7. 浅谈《守望先锋》中的 ECS 构架 (阅读 2,582)
  8. Chaos网络库(三)- 主循环及异步消息的实现 (阅读 2,321)
  9. ECS 中的概念缺失 (阅读 2,041)
  10. 嵌入主线程消息循环的任务调度器 (阅读 2)