事件队列
Event Queue
事件队列 (Event Queue) 用一个先进先出队列,把“发送消息或事件”和“处理它”在时间上解耦:发送方入队后立即返回,接收方在自己方便的时候再取出处理。它比观察者和命令模式多做的一件事是时间上的解耦,适合“推”与“拉”两端节奏不同的场合(例如音频请求),代价是全局可见、世界状态可能已经改变,以及可能出现隐蔽的反馈回环。
意图
把消息或事件的发送时间与处理时间解耦。(Game Programming Patterns — event-queue)
机制
队列按先进先出顺序存放一系列通知或请求。发送通知时入队后立即返回,请求处理者稍后从队列中处理条目。请求可以直接处理,也可以路由给感兴趣的各方。这样发送方与接收方在代码上(静态)和时间上都解耦了。(Game Programming Patterns — event-queue)
书中先回顾了两种常见形态:GUI 的事件循环(操作系统把输入放进队列,应用在自己想要时用 getNextEvent() 拉取,而不像中断那样被强行打断);以及游戏里的中央事件总线(例如战斗代码发出“敌人死亡”,教程系统订阅它来弹出提示,两者互不知晓,类似 AI 领域的黑板系统)。作者不太喜欢庞大的全局系统,所以主例改用一个类内部的队列。(Game Programming Patterns — event-queue)
主例是音频系统。最初的 playSound() 是同步的,暴露出三个问题:调用方被阻塞,直到音频引擎处理完(加载声音时菜单会卡几帧);请求无法汇总处理,同一帧两次播放同一声音会响一倍,声音数超过硬件上限会被丢弃或截断;请求在错误的线程上处理,各系统从各自线程并发调用一个毫无同步的 API,而本该负责音频的线程闲着。共同的根源是“立即执行”,所以要把接收请求与处理请求分开。(Game Programming Patterns — event-queue)
改造后,playSound() 不再立即播放,而是把请求写进一个环形缓冲 (ring buffer),即固定数组加 head_/tail_ 两个下标,入队和出队都只是移动下标,不分配内存;音频系统在自己的 update() 里取出请求再播放,还可以合并重复的请求。几个细节(Game Programming Patterns — event-queue):
- 存储同质元素的最佳选择几乎总是普通数组:无动态分配、无额外簿记、内存连续且缓存友好。尾下标指向最后一个元素之后,所以
head_ == tail_即为空;入队时断言(tail_ + 1) % MAX_PENDING != head_防止尾追上头而覆盖。嫌容量固定,可以满了就翻倍扩容,入队仍是摊还常数时间。 - 合并在入队时做最简单,也不浪费槽位,但调用方要遍历整个队列(可改用以
SoundId为键的哈希表,或改在update()里合并)。注意能合并的“同时”窗口只有队列那么大:处理越快、队列越短,能合并的越少;所以请求与处理之间的延迟会可见地影响行为。 - 跨线程:请求与播放已解耦、有队列做中转、队列被封装,剩下的只是让
playSound()和update()线程安全。playSound()工作量很小,可以加锁;update()可以等待条件变量,没有请求时不空耗 CPU。
下图表示环形缓冲中的生产与消费:多个发送方往尾部写,音频系统在自己的更新里从头部取。
flowchart LR A["玩法代码"] -->|"playSound():写入 tail_ 后返回"| Q["环形缓冲:固定数组 + head_ / tail_"] B["UI 代码"] -->|"入队"| Q Q -->|"Audio::update():从 head_ 取出"| P["音频引擎实际播放"]
发送方调用的开销只是写一个数组元素;什么时候真正播放,完全由右边的消费者决定。
何时使用
如果只想解耦“谁接收消息”,观察者模式 或 命令模式 就够了,而且更简单;只有想在时间上解耦时才需要队列。Nystrom 用“推”与“拉”来解释:A 想让 B 干活,A 自然的做法是推送请求;B 自然的做法是在自己运行周期里方便的时候去拉取。一端是推、一端是拉,中间就需要缓冲。队列把控制权交给拉取方(它可以延迟、合并、丢弃请求),同时也夺走了发送方的控制权:发送方只能把请求丢进去然后指望它被处理,所以需要回应的场合不适合用队列。(Game Programming Patterns — event-queue)
取舍与坑(Game Programming Patterns — event-queue)
- 中央事件队列就是全局变量:“所有消息都从这里走”的中央枢纽很强大,但强大不等于好;它只是用一层协议包装了全局状态。
- 世界状态可能已经变了:“实体死亡”事件过了几帧才被处理,那个实体可能已经被释放,周围的敌人也走开了。因此排队的事件要在发送时就捕获必要的细节,往往比同步事件携带更多数据。
- 反馈回环:A 发事件,B 收到后又发事件,A 收到后再发……同步系统里这会很快栈溢出崩溃;有了队列,栈被“展开”了,游戏可能继续运行,而事件在队列里来回空转。常见规则是:不要在处理事件的代码里发送事件。
设计决策(Game Programming Patterns — event-queue)
| 决策 | 选项 | 要点 |
|---|---|---|
| 队列里放什么 | 事件(已经发生的事,如“怪物死了”) | 通常允许多个监听者,作用域偏广,类似异步的观察者 |
| 消息/请求(希望将来发生的事,如“播放声音”) | 更可能只有一个监听者,相当于服务的异步 API | |
| 谁能读 | 单播队列 | 队列是读者的实现细节,封装好,没有监听者之间的竞争 |
| 广播队列 | 没有监听者时事件会被丢弃;往往需要过滤 | |
| 工作队列 | 每项只交给一个监听者,需要调度(常用于线程池) | |
| 谁能写 | 一个写者 | 隐式知道来源,类似同步观察者 |
| 多个写者 | 更要小心回环;事件里通常要带上发送者引用 | |
| 队列中对象的生命周期 | 转移所有权 / 共享所有权 / 队列拥有(发送方向队列要一个“新”消息再填写) | C/C++ 里要保证消息活得够久 |
与其他模式的关系
- 它在很多方面是 观察者模式 的异步版本。(Game Programming Patterns — event-queue)
- 状态模式与有限状态机 需要一串输入;希望异步响应输入时就把输入排队。许多状态机互发消息、各自带一个待处理消息队列(邮箱),就是 Actor 计算模型。(Game Programming Patterns — event-queue)
- 入队消息时如果不关心由谁处理、只关心会被正确处理,就有点像 单例与服务定位器 中的服务定位器。(Game Programming Patterns — event-queue)
- 别名:“消息队列 (message queue)”和“发布/订阅 (publish/subscribe)”通常指应用之间的大型分布式系统,而本模式在应用之内;Go 语言的 channel 本质上就是事件/消息队列。(Game Programming Patterns — event-queue)
代码草图(C++)
按书中示例整理(环形缓冲 + 入队时合并;线程同步书中只描述未给代码):
struct PlayMessage {
SoundId id;
int volume;
};
class Audio {
public:
static void init() { head_ = 0; tail_ = 0; }
static void playSound(SoundId id, int volume);
static void update();
private:
static const int MAX_PENDING = 16;
static PlayMessage pending_[MAX_PENDING];
static int head_;
static int tail_;
};
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; // 无需入队
}
}
assert((tail_ + 1) % MAX_PENDING != head_); // 防止尾追上头
pending_[tail_].id = id; // 加到队尾
pending_[tail_].volume = volume;
tail_ = (tail_ + 1) % MAX_PENDING;
}
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;
}
UE 中的对应
本知识库的 UE 页面里没有一个通用的“游戏事件队列”系统可以直接对应,这里只列出机制相近的两处:
- Mass 的命令缓冲 → MassEntity 数据导向框架:对实体增删片段会改变其原型,因此要通过命令缓冲延后执行,其他子系统也可以经命令缓冲异步调用 MassEntity Manager。这是“把请求与执行在时间上分开”的思想用于结构变更,而不是事件广播。
- 对比:委托是同步的观察者 → 委托与 Tick 机制:多播委托的
Broadcast()会立即调用所有绑定,属于 观察者模式 而不是事件队列。需要“稍后处理”时,要自己引入队列,或者用该页提到的定时器把工作推迟。
GEA 的视角:音频引擎内部的缓冲与声音句柄(卷 II 15.5.6–15.5.7)
GPP 用音频请求说明事件队列;GEA 从音频引擎内部给出了同一结构的另外几处用法:
- 数字总线用环形缓冲连接组件:编解码器写、DAC 读,各持写头和读头、到末尾回绕、互不越过,从而让不同步运行的组件安全交接数据(卷 II 15.5.6.2)。这与本页
head_/tail_环形缓冲相同,区别是 GEA 这里传的是 PCM 样本流而不是播放请求。 - 缓冲大小就是延迟:缓冲太小,CPU 要更频繁地"服务"音频,否则下溢断音;太大则延迟变高(卷 II 15.5.6.3)。事件队列的容量选择同样是"吸收突发"与"延迟"的取舍(这是对比归纳)。
- 播放 API 返回句柄而不是指针:GEA 建议声音句柄实现为全局句柄表的索引,声音自然结束后句柄自动失效,其他线程持有的句柄也会看到它失效(卷 II 15.5.7.2)。本页"请求入队后发送方立即返回"的设计如果还需要事后控制这个声音(调音量、停止),就需要这样的句柄。
- 请求合并之外的限制:GPP 示例合并同一帧对同一声音的重复请求;GEA 的实例限制和语音抢占是在更上一层按组、按优先级、按距离控制同时发声数(卷 II 15.5.8.4)。
详见 音频引擎架构。
GEA 的视角:游戏对象层面的事件排队(卷 II 17.8.9–17.8.10)
GEA 第 17 章从引擎实现角度讨论了排队与否,可与本页对照,完整内容见 事件与消息传递:
- 好处:控制事件在游戏循环中的处理时机;向未来投递事件(按投递时间升序排序,每帧只看队首,直到遇到未来的事件为止;闹钟和"每两秒闪一次灯"这样的周期任务都可以这样实现);同一投递时间的事件用优先级消除歧义,GEA 建议优先级级数尽量接近能消除所有实际歧义的最小值。
- 代价:实现更复杂;入队前必须深拷贝事件及其参数负载,因而需要动态分配(池分配器或小内存分配器,Naughty Dog 用可重定位内存块);调用栈看不出事件来自哪里,调试更难;还要在循环中多处分发事件以免一帧延迟(例如先更新动画时钟、再分发 EndOfAnimation 事件、最后开始动画混合)。
- 立即发送的问题:调用栈可能极深,每个处理器都必须可重入。
- 并发中的请求队列:Naughty Dog 处理同一桶内对象间修改时,把修改请求放进由锁保护的请求队列,在桶更新完成后再处理,见 游戏对象并发更新。这是本模式"在时间上解耦发送与处理"的又一用法。
本页"取舍与坑"中关于反馈循环和"发送时的世界状态与处理时不同"的提醒,与 GEA 的上述讨论互相印证。
出处
- Game Programming Patterns — event-queue
- Game Programming Patterns — event-queue(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) event-queue 章,gameprogrammingpatterns.com/event-queue.html
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 15 章 15.5.6–15.5.8 节
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.8.9–17.8.10 节