15. Event Queue

15. Event Queue:把消息发送时刻与处理时刻分离,并控制队列所有权和反馈环,通过可复位因果实验和反例证据验收。

15. Event Queue

学习目标

  • 能画出生产者-队列-消费者
  • 能解释反馈环与上限
  • 能选择队列内容与访问控制

为什么"15. Event Queue"从问题证据开始

游戏里很多系统在"同一个时刻"被触发:击杀产生音效、掉血产生粒子、升级弹出 UI。最直觉的做法是当场调用——playSound()、spawnParticle()、showUI() 写在击杀逻辑里。代码今天能跑,但两个问题浮现:一帧事件风暴(大量击杀同帧发生,所有系统当场处理,帧率崩掉)、处理时机不自由(音效必须立刻播,没法攒到合适时机)。

本页把"无模式基线"定义为"事件当场调用处理"的代码,然后引入事件队列作为候选机制,验证它是否真的让"产生事件与处理事件解耦"。通过条件是生产者瞬间完成入队、消费者按自己的节奏处理——而不是事件当场触发处理链。

🔮 猜一猜:一帧 50 个击杀事件,队列方案每帧处理几次?

来源、版本与独立重写边界

本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。

本章机制与术语

理解 Event Queue 需要四个核心概念:

  • :一条"某事发生了"的消息(音效请求、伤害通知),含类型与参数。
  • :产生事件的系统(碰撞、输入、AI)——只负责入队,不负责处理。
  • :缓存事件的缓冲(常为环形缓冲)——解耦"产生时刻"与"处理时刻"。
  • :按序取出并处理事件的系统(音频、UI)——按自己的节奏处理。

四个概念共同约束"把消息发送时刻与处理时刻分离,并控制队列所有权和反馈环":任何结论都必须回到"生产者是否非阻塞、消费者是否可控、反馈环是否受限"三个可观察量。

Decoupling Pattern · Event Queue

Event Queue — 事件先排队,别当场处理

▷ 可交互
"发生了"与"处理它"被一道队列隔开生产者瞬间入队不阻塞,消费者按自己的节奏处理生产者碰撞检测输入系统AI 决策post(event)⟶ enqueue ⟶环形缓冲音效:命中伤害:25动画:受击⟵ dequeue ⟵消费者音频系统每帧取出处理解耦:突发负载被队列吸收,不丢事件、不卡帧注意:中央队列是全局变量;反馈环(处理又产生事件)要设上限防风暴

第 1 / 4 步 · ① 生产者:产生事件(音效、伤害、动画触发)

事件先入队、后处理:生产者与消费者彻底解耦,突发负载被队列摊平。

💡 对着动画看:动画是流水线——左侧生产者(碰撞/输入/AI)post 事件,中间环形缓冲排队(音效:命中、伤害:25……),右侧消费者(音频系统)按序取出处理。注意生产者和消费者之间没有直接连线——只有队列这座桥。读"反馈环"一节时想想:消费者处理事件又产生新事件入队,会发生什么?

官方结构逐项深读

15. Event Queue

Event Queue 的主张:把"事件发生"与"事件处理"用队列隔开。生产者把事件写入队列就返回(不阻塞、不等待处理);消费者按自己的节奏从队列取出处理。这让"产生"与"处理"在时间上解耦——突发负载被队列吸收,处理时机由消费者决定。本质是生产-消费模式在游戏事件系统的应用。

Intent

意图:解耦事件的产生时刻与处理时刻。三个直接收益:生产者不阻塞(事件风暴不会让产生方卡住);消费者可控制处理节奏(音频系统攒一批再播,避免爆音);处理可聚合(多个同类事件合并处理,省开销)。代价是延迟(事件不是立刻处理)与全局状态(中央队列)。

Motivation

回到击杀场景:一次大规模战斗,一帧内几百次击杀。当场调用方案:每次击杀都触发音效系统、粒子系统、UI 系统立即处理——一帧内几百次系统调用,帧率崩盘。队列方案:击杀逻辑只往队列写"击杀事件",帧末由各系统集中处理——突发负载被摊平,每帧只处理一次队列。

GUI event loops

GUI 事件循环是事件队列的经典形态:操作系统把鼠标、键盘事件放进消息队列,应用程序循环取出分发。游戏可以借鉴:把"输入事件""UI 事件"用队列管理,但要注意——GUI 队列是阻塞取(没事件就等),游戏主循环是非阻塞取(没事件也继续更新世界)。两者形态相似,语义不同。

Central event bus

中央事件总线:所有系统共享一个事件队列,任意系统可发可收。好处是完全解耦(系统之间不认识,只认识事件类型)——新增系统只需订阅它关心的事件。坏处是全局状态(总线是全局变量:谁都能发,调试困难,隐式耦合)。作者警告:总线用起来爽,但要控制"谁在发什么"——否则事件流变成一团乱麻。

Say what?

作者对事件队列的直白警告:它解决"什么时候处理",不解决"谁该处理"——那是 Observer 的事。事件队列管"排队",观察者管"通知"。两者常被混淆:Observer 是"直接调用回调",Event Queue 是"先进队列再处理"。选哪个看需求:需要即时通知用 Observer,需要解耦时间用 Event Queue。

The Pattern

模式结构:Event 类型(类型 ID + 参数)+ 队列(固定大小数组 + 头尾指针,环形缓冲)+ 生产者(push:写入队尾)+ 消费者(pop:读出队头)。核心操作 O(1):入队出队都不遍历。生产者与消费者通过队列间接交互——互不引用、互不阻塞。

When to Use It

何时用事件队列:生产者与消费者频率不同(音频系统 30Hz 处理、击杀可能一帧几百次)、需要处理时机控制(攒批、延时、跨帧)、系统之间要彻底解耦(谁发的不重要,重要的是发什么)。何时不用:事件需要即时反馈(输入直接影响角色——队列延迟会感到"飘")、事件量极少(队列是浪费)。

Keep in Mind

三个注意点:中央队列是全局变量(隐式耦合、调试难——要控制可见性);处理期间世界状态会变(事件入队时世界是 A 状态,处理时已是 B 状态——事件要携带所需数据快照,别让处理方回查世界);反馈环(消费者处理事件又产生事件,队列可能无限增长——要设上限)。

A central event queue is a global variable

中央事件队列本质是全局变量:任何系统都能向它写事件。这带来全局变量的通病——隐式耦合(系统之间通过事件悄悄互动)、调试困难(事件流看不到源头)、测试不便(要清空/注入队列)。缓解:把队列封装成服务(事件总线对象),用显式接口管理;必要时按子系统拆分队列(音频队列、UI 队列各一个),缩小全局范围。

The state of the world can change under you

事件延迟处理的陷阱:生产者入队时世界处于状态 A,消费者处理时世界已是状态 B——如果事件只含"类型",处理方回查世界拿参数,可能拿到 B 状态的数据。解法:事件携带数据快照(击杀事件带上目标位置、伤害值等处理所需的一切),处理方不依赖处理时刻的世界状态。这是"事件自包含"原则。

You can get stuck in feedback loops

反馈环:消费者处理"击杀事件"→ 播放音效 → 音效系统又产生"音效播放"事件 → 入队……如果处理又触发新事件,队列可能无限增长,陷入风暴。解法:事件上限(队列满则丢弃或阻塞)、分层处理(同一批事件处理中产生的新事件进"下一批",避免同批级联)、去重聚合(同类事件合并)。反馈环是事件系统最常见的崩溃源。

Sample Code

示例代码:EventQueue 用环形缓冲实现——push(event) 写队尾、pop() 读队头、isEmpty() 判空。音频消费循环:每帧 while (!queue.isEmpty()) { process(queue.pop()); }——一帧内处理完当前队列,处理中产生的新事件留到下帧(天然防同批级联)。

A ring buffer

环形缓冲是事件队列的高效实现:固定大小数组 + head/tail 指针,入队写 tail、出队读 head,指针到末尾回绕。满则覆盖或拒绝(要明确策略:丢新事件保旧事件,还是丢旧保新?)。环形缓冲避免了动态分配(游戏里分配是禁忌),O(1) 操作,缓存友好。

Aggregating requests

事件聚合:消费者可以把一批同类事件合并处理——比如"音效:命中"事件一帧来 50 个,音频系统不播 50 次,而是合并成一次"音量放大 50 的音效"(或取最大音量那次)。聚合减少处理次数、避免音效叠加爆音。这要求事件携带可聚合的参数(次数、强度),是队列方案的额外收益。

Spanning threads

事件队列的跨线程价值:音频线程、网络线程与主线程通过队列通信——生产者线程写队列,消费者线程读队列,队列本身提供线程间缓冲(加锁或锁自由队列)。这比直接跨线程调用安全得多。注意:跨线程队列要处理并发(锁、原子指针),环形缓冲单生产者单消费者可无锁。

Design Decisions

四个关键设计决策:队列里放什么谁能读谁能写队列中对象的生命周期。前两者决定访问控制,后两者决定内存与所有权——下面四节展开。

What goes in the queue?

队列内容:事件(类型 + 数据快照——自包含,处理方不查世界)或命令(更具体的操作对象,如"播放这个音效"——比事件更直接,但耦合更紧)。作者建议:事件优先("发生了什么"让消费者自己决定怎么响应,比命令更解耦);命令留给"必须执行特定操作"的场景。

Who can read from the queue?

读权限:单读者(一个消费者独占队列——简单、无竞争、语义清晰:音频队列只有音频系统读)与多读者(多个系统读同一队列——灵活但要么竞争要么按类型分发)。作者建议:默认单读者——每个队列归属一个消费系统(音频队列、UI 队列分离),多读者场景让各系统订阅自己的队列。

Who can write to the queue?

写权限:任何人可写(中央总线——最大解耦,但隐式耦合与噪声)与受限写(只允许特定系统写——可控、可审计,但少灵活性)。作者建议:默认受限——只让真正产生该事件类型的系统写。控制写入口比控制读入口更重要(写的系统多,噪声来源多)。

What is the lifetime of the objects in the queue?

队列对象的生命周期:值语义(队列存事件的值拷贝——简单安全,但事件大时复制开销)与指针语义(存指针,事件对象在堆上——避免复制,但要管理谁释放:生产者 new、消费者 delete)。作者建议:小事件用值(类型 + 几个参数,几十字节,拷贝无妨),大事件用对象池 + 指针(避免高频分配)。

See Also

  • Observer:观察者解决"谁要知道",事件队列解决"什么时候处理"——事件系统常两者结合。
  • Command:命令对象 + 队列 = 命令队列(延迟执行的命令)——事件队列的变体。
  • Game Loop:事件处理通常挂在游戏循环里——每帧消费一批,与 Update 节奏对齐。

可迁移实现或计算骨架

initial state -> 生产者 push 事件 -> 环形缓冲排队 -> 消费者每帧 pop 处理 -> 处理中新事件留到下一批
fault injection -> 事件当场调用处理(无队列)
pass condition -> 生产者非阻塞、消费者按节奏、反馈环受上限约束
reset -> initial state

对应实现要点:环形缓冲固定数组实现 O(1) 入出队;满则按策略丢新/丢旧;事件携带数据快照自包含;消费者每帧清空当前批,新事件留下一批;跨线程用锁自由环形缓冲。该骨架只保存实验合同;真实项目还要固定队列容量、满策略、事件类型注册表与线程模型,并保留基线实现以便回退。

本章练习与节点验证矩阵

练习

问题 1:事件风暴对比。

操作:模拟一帧 50 个击杀事件:当场调用 vs 入队集中处理

问题 2:反馈环注入。

操作:让消费者处理事件时又产生同类事件,观察队列长度

问题 3:环形缓冲实现。

操作:实现固定数组环形缓冲,测试满时覆盖/拒绝两种策略

问题 4:跨线程实验。

操作:生产者线程与消费者线程各一个,用队列通信

术语复核

名词解释

本章出现的专业名词,用大白话再讲一遍。

事件
生产者
消费者

练习答案参考

练习判据即答案:每个练习的"验证判据"列给出了通过标准——先自己动手,再对照判据核验。

术语复核与本章回顾

掌握"15. Event Queue"意味着能从"事件当场调用处理让一帧风暴卡死帧率"出发,解释事件、生产者、队列与消费者四者的关系,再用"生产者是否非阻塞、消费者是否可控、反馈环是否受限"三个可观察量推翻或保留实现。若三个判据不能同时满足,本章仍未通过。

一句话回顾:事件先排队、后处理——生产者瞬间入队不阻塞,消费者按节奏集中处理,突发负载被环形缓冲摊平,反馈环被上限勒住。

阅读导航

← 上一页:14. Component · 下一页:16. Service Locator → ← 上一页:14. Component · 下一页:16. Service Locator →

资料与写作方式声明

本章以Robert Nystrom《Game Programming Patterns》(游戏编程模式)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…