Event Queue・Message Bus・Publish/Subscribe(Observerとの比較)¶
一言で言うと¶
「起きたことを他へ伝える」仕組みの一族です。違いはいつ届くかと誰を知っているかの2軸で整理できます。
| 方式 | いつ届くか | 発行者と購読者の関係 |
|---|---|---|
| Observer | 即時(同期呼び出し) | 購読者が発行者オブジェクトを知って登録する |
| Event Queue | 後で(キューに溜めて別のタイミングで処理) | キューを挟んで時間的に分離 |
| Publish/Subscribe | 実装による(即時も遅延もある) | チャネル(トピック/型)経由。互いに完全匿名 |
| Message Bus | 実装による | Pub/Subの実装形態。全域に1本(または数本)のバス |
解決したい問題¶
Observerは発行者と購読者を疎結合にしますが、2つの結合が残ります。
- 時間的結合: 通知は同期呼び出し。発行の瞬間に全購読者の処理が走る(重い購読者がフレームを食う/通知の連鎖が絡まる)
- 参照結合: 購読者は発行者オブジェクトへの参照が必要(
enemy.OnDied += ...— どの敵? いつ手に入れる?)
Event Queue: 時間的結合を切る¶
// C++20 — イベントを溜めて、フレームの決まった場所でまとめて処理
#include <queue>
#include <variant>
struct EnemyDied { int enemyId; int score; };
struct ItemPicked { int itemId; };
using Event = std::variant<EnemyDied, ItemPicked>;
class EventQueue {
public:
void Push(Event e) { queue_.push(std::move(e)); } // 発行は積むだけ(軽い)
template <class Handler>
void DrainAll(Handler&& handle) { // フレーム内の決まった場所で処理
while (!queue_.empty()) {
handle(queue_.front());
queue_.pop();
}
}
private:
std::queue<Event> queue_;
};
検証済みサンプル: samples/event_queue.cpp
Observer と Event Queue の比較(重要)¶
| Observer(即時) | Event Queue(遅延) | |
|---|---|---|
| 処理タイミング | 発行の瞬間(コールスタック上) | 自分の決めた場所(例: Update後) |
| 通知の連鎖 | 深く絡まりうる(A→B→C→A?) | 次フレームに平坦化される |
| 発行側の負荷 | 購読者全員分の処理を背負う | Pushのみ(一定) |
| 即時性 | その場で反映 | 1フレーム遅れうる(UIなら大抵無害、当たり判定なら致命的) |
| 集約・間引き | 不可 | 可能(同種イベントをまとめる: 同フレームの被弾SE 10件→1件) |
| デバッグ | スタックトレースで発行元が分かる | キュー経由で発行元が消える(イベントに発行元情報を積む必要) |
| 状態の鮮度 | 発行時の状態がそのまま見える | 処理時には状態が変わっているかも(イベントに必要な値を全部積むのが鉄則) |
サウンドリクエストが古典的な適用例です(Game Programming Patterns): 再生要求をキューに溜め、オーディオスレッド/フレーム末尾で重複除去してから再生。
Publish/Subscribe: 参照結合を切る¶
// C++20 — 型をチャネルとするイベントバス(即時配送版)
#include <functional>
#include <typeindex>
#include <unordered_map>
#include <vector>
class EventBus {
public:
template <class E>
void Subscribe(std::function<void(const E&)> handler) {
handlers_[typeid(E)].push_back([h = std::move(handler)](const void* e) {
h(*static_cast<const E*>(e));
});
}
template <class E>
void Publish(const E& e) {
auto it = handlers_.find(typeid(E));
if (it == handlers_.end()) return;
for (auto& h : it->second) h(&e);
}
private:
std::unordered_map<std::type_index, std::vector<std::function<void(const void*)>>> handlers_;
};
// 発行者: bus.Publish(EnemyDied{id, 100}); — 誰が聞いているか知らない
// 購読者: bus.Subscribe<EnemyDied>([](auto& e){ ... }); — 誰が発行するか知らない
検証済みサンプル: samples/event_bus.cpp
Observer と Pub/Sub の比較¶
| Observer | Pub/Sub(バス経由) | |
|---|---|---|
| 購読先 | 特定のオブジェクト(このenemyの死) | イベント型/トピック(すべてのEnemyDied) |
| 発行者への参照 | 必要 | 不要(バスだけ知る) |
| 粒度 | 個体単位で細かい | 型単位で粗い(個体filtering は購読側で) |
| 追跡性 | 参照を辿れる | 最悪(発行と購読が全域に分散し、「なぜ起きた」が追えない) |
| 向く用途 | 局所の連携(UI部品→画面) | システム間の横断通知(実績、アナリティクス、クエスト) |
Message Bus¶
Pub/Subをプロジェクト全域の1本のバスとして運用する形。強力ですが、乱用すると「すべてがバス経由で、制御フローが誰にも分からないプロジェクト」になります。運用ルール(バスに流してよいのは「起きた事実」だけ/コマンドは流さない/ドメインごとにバスを分ける)とセットで導入するべきものです。
Unity/C# との対応¶
- Observer = C#
event/UnityEvent - Event Queue = 自作が普通(UnityにはSendMessageがあるが低速で非推奨)。入力の
InputSystemはイベントキュー内蔵 - Pub/Sub = MessagePipe等のライブラリ、ScriptableObjectイベントチャネル(アセットをバスにする定石)
- UniRx/R3も購読の一形態(→ Reactive)
Unreal Engine との対応¶
- Observer = Delegate / Multicast Delegate(→ 第10部)
- Pub/Sub = Gameplay Message Subsystem(UE5、チャネル文字列ベース)
- 入力・アニメ通知(AnimNotify)はイベントキュー的に配送される
使う場面 / 使わない場面¶
- Observer: 参照が自然に手に入る局所の連携。即時性が必要な場合
- Event Queue: 発行タイミングと処理タイミングを分離したい(サウンド、スポーン予約、フレーム跨ぎ)。同種イベントの集約・間引きをしたい
- Pub/Sub: 発行者と購読者が互いを知り得ない遠いシステム間(ゲームプレイ→実績/アナリティクス)
- 使わない場面: 順序・因果が重要な処理(ダメージ→死亡→ドロップの厳密なパイプライン)をイベントに分解しない。1フレームの遅延が許されない判定系。呼び出し1本で済む隣接クラス間
よくある誤解¶
- 「イベント駆動にすれば疎結合で良い設計」— 結合は消えず見えなくなるだけ。見える結合(直接呼び出し)の方が保守しやすい場面は多い
- 「Pub/SubとObserverは同じもの」— 参照結合の有無という決定的な違いがある(上の比較表)
関連項目¶
理解度チェック¶
- Event QueueがObserverから切り離す「結合」は何ですか。Pub/Subは?
- 「イベントに必要な値を全部積む」べき理由は?(遅延配送との関係で)
- バスに「コマンド(〜せよ)」を流すべきでないとされる理由を考えてください。
演習¶
samples/event_queue.cpp を拡張し、同一フレームに積まれた PlaySeRequest の重複(同じSE ID)を1件にまとめる処理を追加してください。「集約は即時配送(Observer)では原理的にできない」ことを確認するのが狙いです。
前: Component と ECS | カテゴリ目次 | 次: Service Locator と DI