解耦模式--事件佇列
理論要點
什麼是事件佇列模式:對訊息或事件的傳送與處理進行時間上的解耦。通俗地講就是在佇列中按先入先出的順序儲存一系列通知或請求。 傳送通知時,將請求放入佇列並返回。 處理請求的系統之後稍晚從佇列中獲取請求並處理。
要點
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迴圈。簡單修改下就能使之執行緒安全。
嗯,關於事件佇列就先介紹到這裡了~