解耦模式--事件队列

理论要点

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

  • 要点
    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);
};

简单模拟实现下:

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()快速返回,那么具体的读取本地音效文件的操作明显就不能这里边操作了。我们这里的策略是想办法把音效请求和具体播放音效分开解耦。
我们首先用一个小结构体来储存发送请求的细节:

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循环。简单修改下就能使之线程安全。

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

猜你喜欢

转载自blog.csdn.net/u010223072/article/details/70238369