中EN
概念页

事件队列

Event Queue

更新于 2026-10-13 约 12 分钟设计模式事件队列消息异步环形缓冲GPP

事件队列 (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++ 里要保证消息活得够久

与其他模式的关系

代码草图(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 的上述讨论互相印证。

  1. Game Programming Patterns — event-queue
  2. Game Programming Patterns — event-queue(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) event-queue 章,gameprogrammingpatterns.com/event-queue.html
  4. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  5. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 15 章 15.5.6–15.5.8 节
  6. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.8.9–17.8.10 节

相关页面

游戏开发观察者模式Observer Pattern观察者模式 (Observer Pattern) 让一段代码宣布“发生了某件事”,而不必关心谁会收到通知:主体 (Subject) 维护一个观察者列表,在事件发生时逐个同步调用它们。它适合连接彼此几乎无关的子系统(例如物理与成就),但要注意同步阻塞、观察者的注销,以及运行时才能确定的通信关系难以推理。游戏开发命令模式Command Pattern命令模式 (Command Pattern) 把一次方法调用“物化”成对象,从而可以存储、传递、延迟执行、排队或撤销。游戏里最典型的三种用法是:可配置的按键映射、让玩家输入和 AI 共用同一套“驱动角色”的接口、以及撤销/重做。游戏开发状态模式与有限状态机State Pattern & Finite State Machines状态模式 (State Pattern) 把对象“依赖状态的行为”分散到各个状态类中,对象只持有指向当前状态的指针并把调用委托给它,状态切换就是换指针。它是实现有限状态机 (Finite State Machine, FSM) 的一种方式。FSM 能用很强的结构约束理清一团乱麻的标志位代码;状态组合爆炸、需要复用和需要“回到之前状态”时,分别用并发状态机、分层状态机和下推自动机来扩展。游戏开发单例与服务定位器Singleton & Service Locator单例 (Singleton) 同时承诺两件事:一个类只有一个实例,并且提供全局访问点。Nystrom 认为它在游戏里弊大于利,因为单例本质上就是全局变量。服务定位器 (Service Locator) 是更灵活的“近亲”:它同样给出全局访问点,但调用者只依赖抽象服务接口,具体实现由定位器在运行时或编译期决定,还可以退化为空服务。两者都应当慎用,首选仍是直接把对象传进去。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发音频引擎架构Audio Engine Architecture音频渲染软件和多数系统一样是分层的"栈":硬件 → 驱动 → 底层 API → 3D 音频引擎,功能集通常模仿录音棚的多通道调音台。每个 2D/3D 声音是一条语音 (voice),沿语音总线经过编解码、增益、预发送滤波、辅助发送分出干湿两路、混响、后发送滤波和声像,然后由主混音器合成多声道信号,再经主输出总线(预放、LFE 生成、EQ、压缩、主音量)送到扬声器。资产上分片段、音效提示 (cue)、声音库和流式声音;混音靠分组、闪避、总线预设与混音快照、实例限制和语音抢占(GEA4 卷 II 15.5)。游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。游戏开发 · UE5MassEntity 数据导向框架MassEntity FrameworkMassEntity 是 UE5 面向玩法的数据导向 (Data-Oriented) 计算框架,属于实体-组件-系统 (Entity Component System, ECS) 一类设计:实体 (Entity) 只是一个 ID 加上一组片段 (Fragment) 数据,相同组成的实体归入同一原型 (Archetype) 并按内存块 (Chunk) 连续存放,无状态的处理器 (Processor) 通过实体查询 (EntityQuery) 批量处理片段。它适合成千上万个同质对象(人群、交通),MassGameplay 插件在其上提供表现、生成、LOD、复制、StateTree 等功能。游戏开发 · UE5委托与 Tick 机制Delegates & TickUE 有两种驱动游戏逻辑的方式:事件驱动——委托 (Delegate) 在某事发生时回调绑定的函数;轮询驱动——Tick 每帧(或按最小间隔)调用 Actor/组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。官方建议能用定时器、委托就不用 Tick,必须 Tick 时用 Tick 组和 Tick 依赖保证顺序,避免“差一帧”问题。游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原