中EN
概念页

游戏对象并发更新

Concurrent Game Object Updates

游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。

并发的引擎子系统

渲染、动画、音频、物理这些对性能最关键的部分最能受益于并行处理;无论对象模型单线程还是多核更新,都要能与多线程的底层系统交互(卷 II 17.7.1)。

  • 有通用作业系统(卷 I 8.6.4,见 作业系统)时,每个子系统每帧可以作为一个作业启动;更好的是每个子系统启动多个作业,例如动画系统为每个需要动画混合的对象启动一个作业,之后计算世界矩阵和蒙皮矩阵时再用分散/汇集分给可用的核。
  • 子系统接口必须线程安全,外部客户之间、客户与子系统内部都不能发生数据竞争,通常要给所有外部调用加锁。
  • 作业系统用用户级线程(协程或纤程)时要用自旋锁;子系统由 OS 线程更新时互斥锁也可行(见 线程同步原语、进程、线程与纤程)。
  • 如果确定某子系统只在游戏循环的某个阶段运行,可以用**"无需加锁"断言**代替真正加锁(卷 I 4.9.7.5)。
  • 也可以用无锁数据结构实现关键共享数据,但很难实现,有些数据结构至今没有已知的无锁实现,所以最好只用于性能要求最严苛的子系统(见 无锁编程与内存序)。

异步程序设计

与并发子系统交互时应避免阻塞函数(在调用线程上下文中直接完成工作,阻塞该线程或作业直到完成);尽量调用非阻塞函数,把请求交给另一个线程、核或处理器执行并立即返回,调用方继续做无关工作(比如更新其他对象),同一帧稍后或下一帧再取结果(卷 II 17.7.2)。以判断玩家与敌人之间是否有视线的射线检测为例:

// 同步:立即得到结果
RayCastResult r = castRay(playerPos, enemyPos);
if (r.hitSomething() && isEnemy(r.getHitObject())) { /* 玩家看到了敌人 */ }

// 异步:同一帧内发起、稍后等待
RayCastResult r;
requestRayCast(playerPos, enemyPos, &r);
// ……做其他无关工作……
waitForRayCastResults(&r);   // 已完成则立即返回,否则主线程空等
if (r.hitSomething() && isEnemy(r.getHitObject())) { /* ... */ }

// 跨帧:取上一帧的结果,再为下一帧发起新请求
RayCastResult r; bool rayJobPending = false;
SomeGameObject::Update() {
    if (rayJobPending) {
        waitForRayCastResults(&r);
        if (r.hitSomething() && isEnemy(r.getHitObject())) { /* ... */ }
    }
    rayJobPending = true;
    requestRayCast(playerPos, enemyPos, &r);
}

何时发起、何时取结果(卷 II 17.7.2.1):

  • 最早能多早发起? 越早发起,需要时越可能已完成,主线程越不会空等。对每个请求找出帧内已有足够信息的最早时刻,在那里发起。
  • 最晚能多晚用结果? 也许可以把操作的后半段挪到更新循环后面;也许能容忍一帧延迟,用上一帧结果更新本帧状态(AI 等每几秒才更新的子系统能容忍更长延迟)。稍加思考、重构代码、再缓存些中间数据,很多使用结果的代码都能推迟到帧的后面。

作业依赖与并行度

游戏循环的每次迭代可能有成百上千个并发作业,作业间的依赖会降低核的利用率:B 以 A 产出的数据为输入,就必须等 A 完成(卷 II 17.7.3)。

  • 并行度 (degree of parallelism, DOP),也叫并发度 (DOC):任一时刻理论上最多能并行运行的作业数。画出依赖图(作业为节点,父子关系为依赖),GEA 说树的叶子数就是这组作业的 DOP(图 17.17 给出 DOP 为 4、1、6 的三棵树)。
  • DOP 恰好等于核数时吞吐量最大;大于核数时有些作业要串行,吞吐量下降但没有核闲着;小于核数时有些核无事可做。
  • 同步点 (sync point):作业被迫等待依赖的作业完成时就产生同步点,每个同步点都可能浪费 CPU(图 17.18:D 依赖 C,F 依赖 A、B、D、E)。
  • 对策:减少依赖以提高 DOP;在空闲期找其他无关工作;推迟同步点:D 依赖 A、B、C,若紧接着 C 调度 D,核 2 会空等 B;把 D 推迟到 A、B、C 都完成很久之后再运行,核 2 就能先跑别的作业,D 也不必等待(图 17.19)。Mike Acton 在演讲 "Diving Down the Concurrency Rabbit Hole" 中说:"优化并发设计的秘诀是延迟。"

注:GEA 用"依赖树的叶子数"定义 DOP,对树形依赖成立;但它紧接着给出的图 17.18 里 F 同时依赖 A、B、D、E,已经不是树而是一般的有向无环图,此时不能直接数叶子,需要看图中能同时运行的最大作业集合。在偏序/DAG 理论里,能同时运行的最大作业集合是"最大反链",其大小称为 DAG 的宽度(Dilworth 定理,见 Wikipedia "Dilworth's theorem");对树而言最大反链恰好就是全部叶子,所以 GEA 的说法是这一一般定义在树上的特例。阅读时注意适用范围。

并行化对象模型本身

对象模型出了名地难以并行:对象之间高度相互依赖,又依赖众多子系统;它们在更新中多次通信、互查状态,通信模式难以预测,并且对玩家输入和世界中发生的事件高度敏感(卷 II 17.7.4)。但并非不可能:Naughty Dog 在为《最后生还者 重制版》把引擎从 PS3 移植到 PS4 时实现了并发对象模型。GEA 强调这些只是一种方案,其他引擎可能不同。

对象更新即作业(卷 II 17.7.4.1):把所有对象分成 N 个桶,使桶 B 中的对象只依赖桶 0 到 B − 1 中的对象(分桶思想见 游戏对象的实时更新),然后把每个桶内的对象更新作为作业启动,由作业系统调度到各个核上(并与其他正在运行的作业穿插),这大致就是 Naughty Dog 引擎的做法:

void UpdateBucket(int iBucket) {
    job::Declaration aDecl[kMaxObjectsPerBucket];
    const int count = GetNumObjectsInBucket(iBucket);
    for (int jObject = 0; jObject < count; ++jObject) {
        job::Declaration& decl = aDecl[jObject];
        decl.m_pEntryPoint = UpdateGameObjectJob;
        decl.m_param = reinterpret_cast<uintptr_t>(GetGameObjectInBucket(iBucket, jObject));
    }
    job::KickJobsAndWait(aDecl, count);
}

原书问题:已核对排版页卷 II p.610,原书这段代码的 for 循环体确实没有花括号(只靠缩进表示循环体)。按 C++ 语法,for 的循环体只是紧随其后的一条语句(cppreference "for loop"),所以只有 job::Declaration& decl = aDecl[jObject]; 在循环里,后面两行引用的 decl 已出了作用域,照抄无法编译。上面的代码按作者意图补上了花括号。

另一种做法是显式声明依赖(如 A 依赖 B、C、D),形成有向图:先为没有依赖的对象启动更新作业,每个作业完成后走到依赖它的对象,等它所依赖的对象全部完成再启动,直到整张图更新完。若图中有环,相关对象不能简单按依赖顺序更新:要么改变对象交互方式消除环、使图成为有向无环图 (DAG),要么把这些循环依赖的"团块"隔离出来,在单个核上串行更新。

异步对象更新(卷 II 17.7.4.2):对象自身并发更新时,异步请求照样适用。若作业系统基于用户级线程(协程或纤程),阻塞调用也重新可行:协程能让出并在之后从原处继续;Naughty Dog 的纤程作业不是协程,但同样能在执行中途"睡眠"、之后被"唤醒"继续。

SomeGameObject::Update() {
    RayCastResult r = castRayAndWait(playerPos, enemyPos);  // zzz……结果就绪时醒来
    if (r.hitSomething() && isEnemy(r.getHitObject())) { /* ... */ }
}

这看起来几乎和前面"不要这样做"的同步写法一样,但作业实际被劈成射线调用前后两段(图 17.20)。作业系统的 WaitForCounter()、KickJobsAndWait() 正是靠这种机制让作业睡眠、在计数器归零前让其他作业运行(见 作业系统 的作业计数器一节)。

原书问题:原文写作 "WaitForCounter() KickJobsAndWait()",两个函数名之间漏了 "and"(已核对排版页卷 II p.612)。另外本节开头(p.611)说"我们在 17.7.1 节说过对象更新通常是异步的",但异步请求的讨论实际在 17.7.2 节。

对象更新中的加锁

分桶保证了正确的全局更新顺序(例如火车车厢先于坐在上面的对象更新),也有助于对象间状态查询:桶 B 中的对象可以安全查询桶 B − Δ 和 B + Δ(Δ > 0)中的对象,因为它们不会与桶 B 并发更新,跨桶访问不需要任何锁。但仍有一帧问题:第 N 帧查询 B − Δ 看到的是第 N 帧状态,查询 B + Δ 看到的是第 N − 1 帧状态(卷 II 17.7.4.3)。

同一桶内的对象互相交互或查询时又会有竞争条件:

  • 单一全局锁:每个对象获取锁、更新、释放,能保证没有数据竞争,但会把桶内所有对象的更新串行化,即使两个对象毫无交互,"并发"更新实际退化为单线程。
  • 解引用句柄时才加锁:Naughty Dog 早期尝试过。他们的引擎用句柄而不是裸指针引用对象,以支持内存碎片整理(见 游戏对象引用与查询),要拿到裸指针必须先解引用句柄,这正是检测"一个对象打算与另一个交互"的时机。只在交互可能发生时加锁恢复了一定的并发度,但这套锁系统复杂、难用,桶更新期间 CPU 核的利用率仍然不高。

对象快照

分析真实引擎中的对象依赖后发现:对象间的绝大多数交互是状态查询,A 只需要 B、C 的状态的只读副本,不需要与这些(可能正在并发更新的)对象本身交互。于是让每个对象提供一份相关状态的快照 (snapshot),任何对象都可以无锁、无数据竞争地查询。快照其实就是状态缓存的一个实例。Naughty Dog 在《最后生还者 重制版》《神秘海域 4:盗贼末路》《神秘海域:失落的遗产》《最后生还者 第二部》中使用了这种方法(卷 II 17.7.4.4)。

Naughty Dog 引擎中的流程:每个桶更新开始时,让每个对象更新快照;这些更新从不查询其他对象,所以能无锁并发运行。全部完成后,再启动并发作业更新对象的内部状态。桶 B 中的对象查询其他对象时:

  1. 查询桶 B − Δ 中的对象:可以直接查对象,也可以查快照;
  2. 查询桶 B 中的对象:查快照;
  3. 查询桶 B + Δ 中的对象:同样可以查对象本身或快照。
sequenceDiagram
  participant S as 桶 B 开始
  participant SN as 快照更新作业(并发、不互查)
  participant U as 对象更新作业(并发)
  S->>SN: 每个对象更新自己的快照
  SN-->>S: 全部完成
  S->>U: 启动对象内部状态更新
  U->>U: 读同桶对象只读快照;跨桶可直接读对象
  U-->>S: 桶 B 完成(同步点),进入桶 B+1

上图按时间顺序表示一个桶内两轮并发作业之间的关系。

处理对象间的修改

快照只解决读取,不解决同桶内一个对象修改另一个对象状态时的数据竞争。Naughty Dog 结合使用以下经验规则和技术(卷 II 17.7.4.5):

  • 尽可能减少对象间修改;
  • 跨桶的对象间修改是安全的;
  • 桶内的对象间修改必须小心处理:要么加锁;要么把修改请求放进请求队列而不是立即应用,队列本身由锁保护,请求在桶更新完成后再处理(延迟处理的思路见 事件队列);
  • 在下一个桶中生成一个专门同步这些修改操作的作业:推迟到后面的桶,就能确定相关对象已完成更新、不会与这项工作并发。Naughty Dog 用它处理玩家与 NPC 之间同步的近战动作。

不足与改进方向

GEA 坦言这套系统远非完美(卷 II 17.7.4.6):

  • 每次桶切换都给游戏循环引入一个同步点,部分核可能闲着等桶内所有对象完成;
  • 快照只处理只读查询,对象间修改仍需加锁;快照本身更新也可能很贵,一个容易实施的优化是只为需要的对象生成快照。

GEA 认为找到并发更新对象的最佳方法要靠实验。

与 UE5 的对照

  • UE 的 Tick 依赖只给真正相关的对象设先后关系,同一 Tick 组内其他对象仍可并行更新,官方明确说这比把一批对象挪到更晚的组更高效,见 委托与 Tick 机制;这与 GEA "显式声明依赖、减少同步点"的方向一致(对比归纳)。
  • UE 的通用任务并行见 任务系统;MassEntity 数据导向框架 用无状态处理器批量处理片段,从数据布局上回避了"对象互相查询"的问题,这是另一条路线(对比归纳)。
  1. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.7 节

相关页面

游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发线程同步原语Thread Synchronization Primitives并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。游戏开发无锁编程与内存序Lock-Free Programming & Memory Ordering「无锁 (lock-free)」的真正含义不是不用互斥锁,而是从不让线程阻塞(作者说叫 block-free 更贴切)。数据竞争有三个来源:关键操作被打断、编译器与 CPU 的指令重排、缓存一致性协议优化造成的内存序问题。对应的工具是原子指令(TAS、XCHG、CAS、LL/SC)和内存屏障/栅栏(acquire、release、full fence),C++11 用 std::atomic 与 std::memoryorder 把它们封装成可移植接口。在此基础上可以写出自旋锁、可重入锁、读写锁,以及基于「本地准备 + 一次原子提交」事务的无锁数据结构。游戏开发基于锁的并发问题Problems with Lock-Based Concurrency即使所有共享数据操作都用锁保护好了,基于锁的并发仍有四类问题:死锁 (Deadlock)、活锁 (Livelock)、饥饿 (Starvation)、优先级反转 (Priority Inversion)。哲学家就餐问题集中体现了它们。GEA 由此总结出经验法则:全局排序、事务式算法、尽量减少争用;并反对把所有接口都做成「100% 线程安全」,主张正视并发、少用锁。游戏开发并发与并行基础Concurrency & Parallelism FundamentalsGEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。游戏开发 · UE5任务系统Tasks SystemUE::Tasks 是 UE5 的任务级并行库,是对 TaskGraph 的改进,与 TaskGraph 共用同一套调度器和工作线程。它支持启动任务、等待或取结果、声明前置依赖、嵌套任务、管道 (Pipe) 和任务事件。官方建议用依赖关系把工作连成图,而不是阻塞等待;5.5 起用「超额订阅」取代了容易死锁的忙等待。游戏开发 · UE5委托与 Tick 机制Delegates & TickUE 有两种驱动游戏逻辑的方式:事件驱动——委托 (Delegate) 在某事发生时回调绑定的函数;轮询驱动——Tick 每帧(或按最小间隔)调用 Actor/组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。官方建议能用定时器、委托就不用 Tick,必须 Tick 时用 Tick 组和 Tick 依赖保证顺序,避免“差一帧”问题。游戏开发游戏对象引用与查询Game Object References & Queries游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。游戏开发事件队列Event Queue事件队列 (Event Queue) 用一个先进先出队列,把“发送消息或事件”和“处理它”在时间上解耦:发送方入队后立即返回,接收方在自己方便的时候再取出处理。它比观察者和命令模式多做的一件事是时间上的解耦,适合“推”与“拉”两端节奏不同的场合(例如音频请求),代价是全局可见、世界状态可能已经改变,以及可能出现隐蔽的反馈回环。游戏开发 · UE5MassEntity 数据导向框架MassEntity FrameworkMassEntity 是 UE5 面向玩法的数据导向 (Data-Oriented) 计算框架,属于实体-组件-系统 (Entity Component System, ECS) 一类设计:实体 (Entity) 只是一个 ID 加上一组片段 (Fragment) 数据,相同组成的实体归入同一原型 (Archetype) 并按内存块 (Chunk) 连续存放,无状态的处理器 (Processor) 通过实体查询 (EntityQuery) 批量处理片段。它适合成千上万个同质对象(人群、交通),MassGameplay 插件在其上提供表现、生成、LOD、复制、StateTree 等功能。游戏开发游戏世界与游戏对象Game World & Game Objects游戏由玩法 (gameplay) 而不是技术定义;游戏机制 (game mechanics) 是支配游戏中各实体交互的规则,以及目标、成败标准、玩家能力、非玩家实体和整体流程(GEA 引用的论文称实现玩法的软件系统为游戏的 G 因子)。大多数游戏世界由静态元素和动态元素组成,划分为若干世界块,并由高层游戏流程组织玩家目标;动态元素通常实现为游戏对象 (GO),引擎提供的建模设施称为游戏对象模型,且分为工具侧和运行时两套(GEA4 卷 II 16、16.1–16.2)。游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。

输入关键词开始搜索

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