解耦模式--事件队列

理论要点

  • 什么是事件队列模式:对消息或事件的发送与处理进行时间上的解耦。通俗地讲就是在队列中按先入先出的顺序存储一系列通知或请求。 发送通知时,将请求放入队列并返回。 处理请求的系统以后稍晚从队列中获取请求并处理。 编程

  • 要点
    1,事件队列其实能够看作观察者模式的异步实现。
    2,事件队列很复杂,会对游戏架构引发普遍影响。中心事件队列是一个全局变量。这个模式的一般方法是一个大的交换站,游戏中的每一个部分都能将消息送过这里。
    3,事件队列是基础架构中很强大的存在,但有些时候强大并不表明好。事件队列模式将状态包裹在协议中,可是它仍是全局的,仍然存在全局变量引起的一系列危险。数组

  • 使用场合
    1,若是你只是想解耦接收者和发送者,像观察者模式和命令模式均可以用较小的复杂度来进行处理。在须要解耦某些实时的内容时才建议使用事件队列。
    2,不妨用推和拉来的情形来考虑。有一块代码A须要另外一块代码B去作些事情。对A天然的处理方式是将请求推给B。同时,对B天然的处理方式是在B方便时将请求拉入。当一端有推模型另外一端有拉模型时,你就须要在它们间放一个缓冲的区域。 这就是队列比简单的解耦模式多出来的那一部分。队列给了代码对拉取的控制权——接收者能够延迟处理,合并或者忽视请求。发送者能作的就是向队列发送请求而后就完事了,并不能决定何时发送的请求会受处处理。
    3,当发送者须要一些回复反馈时,队列模式就不是一个好的选择。缓存

代码分析

1,若是你作过任何用户界面编程,你就应该很熟悉事件队列。 每当用户与你的程序交互,点击按钮,拉出菜单,或者按个键…操做系统就会生成一个事件。 它会将这个对象扔给你的应用程序,你的工做就是获取它而后将其与有趣的行为相挂钩。
底层代码大致相似这样:安全

while (running)
{
  Event event = getNextEvent();
  // 处理事件……
}

这个getNextEvent就循环从某个地方读取事件,而用户的输入事件则会写入这个地方。这个地方就是咱们的中转站缓存区,通常是队列。
这里写图片描述架构

2,事件队列其实能够看作观察者模式的异步实现。既然是要体现异步实现,咱们仍是换个情形。
想一想咱们真实的游戏都是声情并茂,人类是视觉动物,听觉强烈影响到情感系统和空间感受。 正确模拟的回声可让漆黑的屏幕感受上是巨大的洞穴,而适时的小提琴慢板可让心弦拉响一样的旋律。
为了得到优秀的音效表现,咱们从最简单的解决方法开始,看看结果如何。 添加一个“声音引擎”,其中有使用标识符和音量就能够播放音乐的API:异步

class Audio
{
public:
  static void playSound(SoundId id, int volume);
};

简单模拟实现下:spa

void Audio::playSound(SoundId id, int volume)
{
  ResourceId resource = loadSound(id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, volume);
}

好,如今咱们播放声音的API接口写好了,假设咱们在选择菜单时播放一点小音效:操作系统

class Menu {
public:
  void onSelect(int index)
  {
    Audio::playSound(SOUND_BLOOP, VOL_MAX);
    // 其余代码……
  }
};

这样当咱们点击按钮时就会播放对应音效。代码算是写完了,如今咱们来看看这段代码都有哪些坑。
首先,playSound是个单线程运行,阻塞式接口,播放音效须要本地访问文件操做,这是耗时的,若是游戏中充斥着这些,那么咱们的游戏就会像幻灯片同样一卡一卡的了。
还有,玩家杀怪,他在同一帧打到两个敌人。 这让游戏同时要播放两遍哀嚎。 若是你了解一些音频的知识,那么就知道要把两个不一样的声音混合在一块儿,就要加和它们的波形。 当这两个是同一波形时,它与一个声音播放两倍响是同样的。那会很刺耳。
在Boss战中有个相关的问题,当有一堆小怪跑动制造伤害时。 硬件只能同时播放必定数量的音频。当数量超过限度时,声音就被忽视或者切断了。
为了处理这些问题,咱们须要得到音频调用的整个集合,用来整合和排序。 不幸的是,音频API独立处理每个playSound()调用。 看起来这些请求是从针眼穿过同样,一次只能有一个。线程

说了这么一堆问题,那么怎么解决呢?
1,首先是阻塞问题,咱们要让playSound()快速返回,那么具体的读取本地音效文件的操做明显就不能这里边操做了。咱们这里的策略是想办法把音效请求和具体播放音效分开解耦。
咱们首先用一个小结构体来储存发送请求的细节:code

struct PlayMessage
{
    SoundId id;
    int volume;
};

而后就是请求事件的储存,咱们使用最简单的经典数组:

class Audio
{
public:
  static void init()
  {
    numPending_ = 0;
  }

  // 其余代码……
private:
  static const int MAX_PENDING = 16;

  static PlayMessage pending_[MAX_PENDING];
  static int numPending_;
};

好,如今咱们要播放一个音效就只是发送一个消息而已了,几乎是快速返回:

void Audio::playSound(SoundId id, int volume)
{
  assert(numPending_ < MAX_PENDING);

  pending_[numPending_].id = id;
  pending_[numPending_].volume = volume;
  numPending_++;
}

上面就是咱们分开的发送音效请求的部分,那么具体的播放声音咱们就能够抽离出来,放在另外一个接口update中,甚至单独由另外一个线程去执行。

class Audio
{
public:
  static void update()
  {
    for (int i = 0; i < numPending_; i++)
    {
      ResourceId resource = loadSound(pending_[i].id);
      int channel = findOpenChannel();
      if (channel == -1) return;
      startSound(resource, channel, pending_[i].volume);
    }

    numPending_ = 0;
  }

  // 其余代码……
};

目前,咱们已经实现了声音请求与播放的解耦,可是还有一个问题,咱们的中间桥梁缓冲区用的是简单数组,若是是用在异步操做中,这个就无法工做了。这时咱们须要一个真实的队列来作缓冲,实现能从头部移除元素,向尾部添加元素。

2,如今咱们就来实现一个真实的队列,有不少种方式能实现队列,但我最喜欢的是环状缓存。 它保留了数组的全部优势,同时能让咱们不断从队列的前方移除事物而不须要将全部剩下的部分都移一次。
这个环状缓存队列有两个标记,一个是头部,存储最先发出的请求。另外一个是尾部,它是数组中下个写入请求的地方。移除事物头部移动,添加事物尾部移动,当到数组最大时折回到头部,头部与尾部的距离就是要处理的事件个数,相等时则表示没有事物处理。
首先,咱们显式定义这两个标记在类中的意义:

class Audio
{
public:
  static void init()
  {
    head_ = 0;
    tail_ = 0;
  }

  // 方法……
private:
  static int head_;
  static int tail_;

  // 数组……
};

而后,咱们先修改playSound()接口:

void Audio::playSound(SoundId id, int volume)
{
  //保证队列不会溢出
  assert((tail_ + 1) % MAX_PENDING != head_);

  // 添加到列表的尾部
  pending_[tail_].id = id;
  pending_[tail_].volume = volume;
  tail_ = (tail_ + 1) % MAX_PENDING;
}

再来看看update()怎么改写:

void Audio::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;
}

这样就好——没有动态分配,没有数据拷贝,缓存友好的简单数组实现的队列完成了。

3,如今有队列了,咱们能够转向其余问题了。 首先来解决多重请求播放同一音频,最终致使音量过大的问题。 因为咱们知道哪些请求在等待处理,须要作的全部事就是将请求和早先等待处理的请求合并:

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(volume, pending_[i].volume);

      // 无需入队
      return;
    }
  }

  // 以前的代码……
}

4,最终,最险恶的问题。 使用同步的音频API,调用playSound()的线程就是处理请求的线程。 这一般不是咱们想要的。
在今日的多核硬件上,你须要不止一个线程来最大程度使用芯片。 有无数的编程范式在线程间分散代码,可是最通用的策略是将每一个独立的领域分散到一个线程——音频,渲染,AI等等。
其实如今咱们要分离线程已经很方便了,由于咱们已经把请求音频的代码与播放音频的代码解耦。有队列在二者间处理它们。从高层看来,咱们只需保证队列不是同时被修改的。 因为playSound()只作了一点点事情——基本上就是声明字段。——不会阻塞线程太长时间。 在update()中,咱们加点等待条件变量之类的东西,直到有请求须要处理时才会消耗CPU循环。简单修改下就能使之线程安全。

嗯,关于事件队列就先介绍到这里了~

相关文章
相关标签/搜索