解耦模式--事件隊列

理論要點

  • 什麼是事件隊列模式:對消息或事件的發送與處理進行時間上的解耦。通俗地講就是在隊列中按先入先出的順序存儲一系列通知或請求。 發送通知時,將請求放入隊列並返回。 處理請求的系統以後稍晚從隊列中獲取請求並處理。 編程

  • 要點
    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循環。簡單修改下就能使之線程安全。

嗯,關於事件隊列就先介紹到這裏了~

相關文章
相關標籤/搜索