游戏编程模式-事件队列
“对消息或事件的发送与受理进行事件上的解耦。”
动机
如果你曾从事过用户界面编程,那肯定对“事件”不陌生了。每当你在界面中点击一个按钮或下拉菜单,系统都会生成一个事件,系统会把这个事件抛给你的应用程序,你的任务就是获取到这些事件并将其与你自定义的行为关联起来。那么为了获取到这些事件,你的代码通常都会由个事件循环。就像这样:
while(running) { Event e = pollEvent(); //handle event }
可以看到,这段代码会不断的获取事件,然后处理。但如果期间再事件发生,应该如何处理了?这里必须得有个位置安置这些输入,避免漏掉,而这个所谓的“安置位置”正是一个队列。
中心事件总线
多数游戏的事件驱动机制并非如此,但是对于一个游戏而言维护它自身的事件队列作为其神经系统的主干是很常见的。你会经常听到“中心式”、“全局的”、“主要的”类似这样的描述。它被用于那些希望保持模块间低耦合的游戏,起到游戏内部高级通信模块的作用。比如你的游戏有一个新手教程,该新手教程会在完成指定的游戏事件后弹出帮助框。简单的做法就是在代码中判断事件是否发生,如果发生则执行处理代码。但随着事件的增多,代码需要判断的情况也越来越多,代码也变得越来越复杂。这个时候你可能会想到用一个中心事件队列来取代它。游戏的任何一个系统都可以向这个队列发送事件,同时也可以从队列中“收取”事件。新手引导模块向事件队列注册自身,并向其声明接收“敌人死亡”事件。借此,敌人死亡的消息可以在战斗系统和新手模块不进行直接交互的情况下在两者间传递。
可能的问题
中心事件队列虽然解决了一些问题,但也还有一些问题存在。我们来看一个例子,比如我们在游戏中播放音效:
class Audio { public: static void playSound(SoundId id,int volume); }; void Audio:playSound(SoundId id,int volume) { ReourceId resource = loadSound(id); int channel = findOpenChannel(); if(channel == -1) return; startSound(resource,channel,volume); }
在UI代码中我们这样调用。
class Menu { public: void onSelect(int index) { Audio::playSound(SOUND_BLOOP,VOL_MAX); //other stuff.... } };
我们可能需要面对的问题有:
- 在音效引擎完全处理完播放请求前,API的调用会一直阻塞着调用者
因为playSound是”同步“执行的,它只有在音效被完全播放出来后才会返回至调用者的代码;除此外,我们还会面临着另一个问题,比如我们的英雄角色击中两个以上的怪物,那么就会播放多次怪物的哀嚎声,如果你了解一些音效的知识,你就会知道多个声音混合在一起会叠加它们的声波。也就是说,当声波相同时,声音听起来和第一个声音一样,但音量会大两倍,这会让声音听起来很刺耳,而且一旦场景中的声音更多的时候,因为硬件的处理能力有限,超出阈值的声音就会被忽略或中断。为了处理这些问题,我们需要对所有的音效进行汇总和区分。但从上述代码可以看出,我们的处理每次只处理一个音效,无法满足这个需求。
- 不能批量处理处理请求
代码库中,有很多不同的子系统都会调用”playSound“,而我们的游戏通常运行在现代多核硬件上面。为了充分利用多核,它们通常都分配在不同的线程中。由于我们的API是同步的,它会在调用者的线程中执行,所以当不同的系统调用playSound时,我们就遇到了线程同步调用API的情况。
- 请求在错误的线程被处理
这些问题的共同点就是声音引擎调用”playSound“函数的意思就是”放下所有的事情,马上播放音乐“。”马上处理“就是问题所在。其它游戏系统在它们合适的时候调用”play Sound“,而声音引擎此时却未必能处理这一需求,为修复这个问题,我们将对请求的接收和手里进行解耦。
事件队列模式
事件队列是一个按照先进先出顺序存储一系列通知或请求的队列。发出通知时系统会将请求置入队列并随即返回,请求处理器随后从事件队列中取出事件并进行处理。请求可由处理器直接处理或转交给对其感兴趣的模块。这一模式的发送者与受理者进行了解耦,使消息的处理变得动态且非实时。
使用情境
如果你只是向对一条消息的发送者和接收者进行解耦,那么诸如观察者模式和命令模式都能以更低的复杂度满足你。需要在某个问题上对时间进行解耦时,一个队列往往足矣。
按照推送和拉去的方式思考:代码A希望另一个代码块B做一些事情。A发起这一请求最自然的方式就是把它推送给B。同时,B在其循环中适时拉取该请求并进行处理也是非常自然的。当你具备推送端和拉取端之后,在两者之间需要一个缓冲。这正是缓冲队列比简单的解耦模式多出来的优势。
队列提供给拉取请求的代码块一些控制权:接收者可以延迟处理,聚合请求或者完全废弃它们。但这是通过”剥夺“发送者对队列的控制来实现的。所有发送端能做的就是往队列中投递消息。这使得队列在发送端需要实时反馈时显得很不适用。
注意事项
不像其它更简单的模式,事件队列会更复杂一些并对游戏框架产生广泛而深远的影响。这意味着你在决定如何适用、是否适用本模式时须三思。
- 中心事件队列是个全局变量
该模式的一种普遍用法被称为”中央枢纽站“,游戏中所有模块的消息都可以通过它来传递。它是强大的基础设施,但强大并不意味着好用。关于”全局变量是糟糕的“这一点我们之前在单例模式中也讲过,全局变量的使用意味着游戏的任何部分都可以访问它,这会在不知不觉中让这些部分产生相互依赖,虽然本模式将这些状态封装成一种不错的小协议,但仍然具备全局变量所包含的危险性。
- 游戏世界的状态任你掌控
使用事件队列,你需要非常谨慎。因为投递和接收事件的解耦,一个事件发送到事件队列中大多时候都不会马上得到处理,也就是说处理事件的时候不能想当然的认为当前的世界状态和事件发生时的世界状态时一致的,比如一个实体收到攻击,可能会引起附近的同伴过来反击,但如果这个事件是后来被处理,而附近的同伴那时移动到其它位置,那么共同反击这个行为就不会发生。所以这意味着队列的事件视图要比同步系统的事件具有更重量级的结构,这个结构用来记录事件发生时的细节,以便后面处理时使用。而同步系统只需要知道事件发生了,然后检查环境即可了解这些细节。
- 你会在反馈中绕圈子
任何一个事件或消息系统都得留意循环。
1.A发送一个事件;
2.B接收事件并处理,然后发送另一个事件;
3.恰好,这个事件是A关心的,A接收后反馈,同时发送事件……
4.回到2.
当你的信息系统是同步的时候,你会很快的发现死循环——它们会导致栈溢出然后游戏崩溃。对于队列来说,异步的放开栈处理会导致这些伪事件在系统中徘徊,但游戏会保持运行。这个很难发现,一个规避的办法就是避免在事件处理代码中再发送事件。还有就是再系统中实现一个小的调试日志也是个不错的主意。
实例代码
之前的代码已经具备一定的功能,但它们不完美,现在让我们使用一些手段来完善它们。
首先,针对playSond是同步调用的问题,我们选择让它推迟执行,从而快速返回。这个时候我们需要把这个播放请求先存起来,这里我们使用最简单的数组。
struct PlayMessage { SoundId id; int volume; }; class Audio { public: static void init(){numPending_=0;} void playSound(SoundId id, int volume) { assert(numPending_<MAX_PENDING); pending_[numPending_].id = id; pending_[numPending_].volume=volume; numPending_++; } static void update() { for(int i=0;i<numPending_;++i) { ResourceId resource = loadSound(pending_[i].id); int channel = findOpenChannel(); if(channel == -1) continue; startSound(resource,channel,pending_[i].volume); } numPending_ = 0; } private: static const int MAX_PENDING=16; static PlayMessage pending_[MAX_PENDING]; static int numPending_; };
在这里我们把事件处理代码移到了update中,接下来我们只需要在合适的时候调用它即可。比如在游戏主循环中调用或在一个专用的声音线程中调用。这样可以很好的运行,但上述代码是假定我们对每个音效的处理都能够在一次“update”中完成。如果你做一些,例如在声音资源加载后异步处理其它请求的事情,上面的代码就不凑效了。为了保证“update()”一次只处理一个请求,它必须能够在保证保留队列中其它请求而把将要处理的请求单独拉出缓冲区。换句话说,我们需要一个真正的队列。
环状缓冲区
有很多的方法可以实现队列。一种值得推荐的方法是环状缓冲区。它保有数组的优点,同时允许我们从队列的前端持续的移除元素。
class Audio { public: static void init(){head_=0;tail_=0;} void playSound(SoundId id, int volume) { assert((tail_+1) % MAX_PENDING_ != head_); pending_[tail_].id = id; pending_[tail_].volume=volume; tail_ = (tail_+1) % MAX_PENDING; } void update() { if(head_ == tail_) return; ResourceId resource = loadSound(pending_[head_].id); int channel = findOpenChannel(); if(channel == -1) return; startSound(resource,channel,pending_[head_].volume); head_ = (head_+1) % MAX_PENDING; } private: static const int MAX_PENDING=16; PlayMessage pending_[MAX_PENDING]; int head_; int tail_; };
环状缓冲区的实现非常简单,就是使用两个指针,一个指向对头,一个指向队尾,一开始两个指针重合表示为空,然后队尾在添加请求时向前移动,队头则在处理请求后向前移动,但它们到达数组的尾部时,则绕回头部。当然,我们添加请求时,首先要查看当前队列是否已满,已满则丢弃事件。
汇总请求
现在我们已经有一个队列了,可以处理声音叠加过大的问题。处理方法就是汇总并合并请求。
void Audio::playSound(SoundId id,int volume) { for(int i=head_;i!=tail_;i=(i+1) % MAX_PENDING) { if(pending_[i].id == id) { pending_[i].volume=max(pending_[i].volume,volume); return; } } //previous code... }
这里我们简单的判断请求中是否有相同的音效,如果有,则合并它们,声音则其最大的为准。这里需要考察的一点就是合并的时候需要遍历整个队列,如果队列较大,这个会比较耗时,这个时候可以把合并代码移动到“update”中去处理。
除此之外,还有一个重要事情,我们可汇总的“同步发生”的请求数量之和队列一般大小。如果我们更快的处理请求,队列保持的很小,那么可批量处理请求的机会就小。同样,如果处理请求滞后,队列被填满,那么我们发生崩溃的机率就更大。
这种模式将请求方与请求被处理的时间进行隔离,但是当你把整个队列作为一个动态的数据结构去操作时,提出请求和处理请求之间的之后会显著的影响系统表现。所以,确定这么做之前你已经准备好了。
跨越线程
最后的问题就是线程同步的问题。在现今多核硬件的时代,这是必须解决的一个问题。而因为现在我们已经使用队列把请求代码和处理请求的代码解耦了,所以我们只需要做到队列不被同时修改即可。最简单的做法就是给添加请求的时候给队列的时候加锁,而在处理请求的时候等待一个条件变量,避免没有请求或事件的时候cpu空转。这部分可参照线程池实现方案:https://blog.csdn.net/caoshangpa/article/details/80374651
设计决策
入队的是什么
迄今为止,“事件”和“消息”总是被混着使用,因为这无伤大雅,无论你往队列里塞什么,它都具备相同的解耦和聚合能力,但二者仍然有一些概念上的不同。
- 如果队列中是事件
-
一个“事件”或“通知”描述的是已经发生的事情。所以,如果你将它入队,
- 你可能会允许多个监听器。因为队列中的事情已经发生,发送者不关心谁会接到它。从这个角度看,这个事件已经过去并且被忘记;
- 可访问队列的域往往更广。事件队列经常用于给任何和所有感兴趣的部分广播事件。为了允许感兴趣的部分有更大的灵活性,这些队列往往有更多的全局可见性。
-
- 如果队列中是消息
- 一个“消息”或“请求”描述的是一种“我们期望”发生在“将来”的行为。类似于“播放音乐”。你可以认为这是一个异步API服务。这样,
- 你更可能只有单一的监听器。比如示例中的播放音乐的请求,我们并不希望游戏的其它部分来偷窃队列中的消息。
谁能从队列中读取
在播放音乐的示例中,只有“Audio”类能读取队列。这是一中单播的方式,还有广播和工作队列方式,它们有多个读取者。
- 单播队列
当一个队列是一个类的API本身的一部分时,单播是最合适的。比如在上述示例中,调用者只能调用“playSound”函数。单播队列的特点:
- 队列称为读取者的实现细节。所有的发送者知道的只是它发送了一个消息;
- 队列被更多的封装。所有其它条件都相同的情况下,更多的封装通常是更好的;
- 你不必担心多个监听器竞争的情况。在多个监听器下,你不得不决定它们是否都获取队列的每一项(广播)还是队列中的每一项都分配给一个监听器(更像一个工作队列);
- 广播队列
这是大多数“事件”系统所作的事情。当一个事件进来时,它有多个监听器。这样,
- 事件可以被删除。在大多数广播系统中,如果某一时刻事件没有监听器,则事件会被废弃;
- 可能需要过滤事件。广播队列通常是在系统内大范围可见的,而且你会有大量的监听器。大量的监听器乘以大量的事件,于是你要调用大量的事件句柄。为了缩减规模,大部分的广播系统会为让监听器过滤它们收到的事件集合。比如只接受UI事件。
- 工作队列
类似于广播队列,此时你也有多个监听器。不同的是队列中每一项只会被分配给一个监听器。这是一种对于并发线程支持友好的系统中的常见工作分配模式。这个方式让你必须做好规划。因为一个项目之投递给一个监听器,队列逻辑需要找到最好的选择。这可能是一个简单循环或随机选择,亦或者是一个更复杂的优先级系统。
谁可以写入队列
写入队列的可能模式有:一对一、一对多、多对多。
-
一个写入者
-
- 你隐式的知道事件的来源。因为只有一个对象往队列中添加事件,所以可以安全的假定事件来自于该发送者;
- 通常允许多个读取者。你可以创造一个一对一的接收者的队列。但是,这样不太像通信系统,而更像一个普通的队列数据结构。
-
多个写入者
这个是我们音频引擎例子的工作原理。游戏的任何模块都可以向队列中添加事件。就先“全局”或“中央”事件总线。对于它
-
- 你必须小心反馈循环。因为任何东西都可以进入队列,所以很可能会触发反馈循环;
- 你可能会想要一些发送方在事件本身的引用。当监听器得到一个事件时,它可能想要知道是谁发送的。这个时候就需要把发送方的引用打包到事件对象中。
队列中对象的声明周期管理
- 转移所有权
这是抖动管理内存时的额一个中传统方法。当一个消息入队时,发送者不再拥有它。当消息处理时,接收者取得所有权并负责释放它。
- 共享所有权
- 队列拥有它
另一个观点是消息总是存在于队列中。不用自己释放消息,发送者会先从队列中请求要给消息,而队列返回已经存在消息的引用,发送者填充它。就像一个对象池。