ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

游戏引擎基础架构实战:内存管理、数据结构与系统通信

游戏引擎基础架构实战:内存管理、数据结构与系统通信 1. 引擎基础架构到底在解决什么问题很多人第一次接触游戏引擎架构脑子里冒出来的第一个念头往往是“这不就是个渲染器加个物理库吗”。我刚开始做引擎相关工作时也这么想直到自己动手把一个能跑的小引擎拆了又装、装了又拆才意识到基础架构真正要解决的根本不是“怎么把画面画出来”而是“怎么让几十上百个系统在同一个进程里互不打架地活着”。引擎基础架构的核心命题说白了就三件事内存怎么管、数据怎么组织、系统之间怎么通信。这三件事听起来平平无奇但它们决定了引擎能不能撑住一个真实项目的体量。你去看任何一个成熟的商业引擎渲染模块可能换过三代物理库可能从这家换到那家但底层的内存分配策略和对象生命周期管理往往十年都不怎么动因为动一次就是伤筋动骨。为什么基础架构这么难改因为它处在整个引擎的最底层上面所有模块都直接或间接依赖它。渲染器每帧要分配临时的顶点缓冲物理系统要维护碰撞体的持久化数据脚本层要创建和销毁游戏对象音频系统要加载和解码资源——这些操作全部要经过内存管理层。如果基础架构设计得不好上层模块就会被迫写大量绕路的代码最后整个引擎变成一锅粥。这篇文章适合谁看如果你已经写过一些游戏逻辑但对引擎底层怎么运转只有模糊概念那这篇内容能帮你把“黑盒”拆开看清楚。如果你正在自己写一个小引擎或者在做引擎相关的技术选型这里面的取舍逻辑和踩坑经验应该能帮你少走弯路。我不会只讲概念每个设计决策我都会说清楚“为什么这么选”以及“不这么选会怎样”。关键词里提到的内存管理、数据结构、分布式架构这些词在引擎语境下都有非常具体的含义。内存管理不是简单调个malloc和free而是要考虑帧内分配、帧间持久化、多线程安全、内存对齐、缓存友好性等一堆问题。数据结构也不是背几个链表和哈希表就完事而是要根据访问模式选择最合适的容器甚至自己手写针对特定场景优化的结构。至于分布式架构在游戏引擎里更多体现在多线程任务调度和资源流水线的设计上和互联网后端那套微服务架构有本质区别。2. 内存管理引擎架构里最容易翻车的地方2.1 为什么不能直接用系统默认的malloc和new刚入行的开发者最容易犯的错误就是在引擎代码里到处写new和delete。我早期做过一个项目渲染线程每帧要创建几千个临时对象来表示绘制命令用的就是标准库的new。在开发机上跑得好好的一到真机上帧率直接掉到个位数。用性能分析工具一查发现大量时间花在了内存分配器内部的锁竞争和碎片整理上。系统默认的内存分配器是为通用场景设计的它要兼顾各种大小的分配请求还要处理多线程安全所以内部结构相当复杂。每次调用malloc分配器都要在空闲链表里找一块足够大的内存可能要加锁可能要分割内存块可能要触发系统调用向操作系统要更多内存。对于游戏引擎这种每帧要分配成千上万次小对象的场景这套机制的开销完全不可接受。引擎需要的是针对特定分配模式优化的内存管理器。游戏引擎的内存分配有几个非常明显的特征分配大小往往集中在几个固定尺寸附近比如顶点数据可能是32字节或64字节矩阵是64字节变换信息是48字节分配和释放有明显的帧周期性很多临时数据在这一帧开始分配、这一帧结束就释放分配频率极高每秒可能几十万次。针对这些特征引擎通常会实现一个分层的内存分配系统。最底层是页分配器直接向操作系统申请大块内存比如一次申请几MB甚至几十MB然后自己管理这些页。中间层是各种尺寸的池分配器把页切分成固定大小的块比如32字节池、64字节池、128字节池。最上层是给游戏逻辑用的通用分配接口根据请求大小路由到对应的池。2.2 帧内分配器与帧间持久化的分离引擎内存管理里有一个非常关键的设计决策把帧内临时数据和帧间持久数据彻底分开。这个决策的影响面极广几乎所有上层模块的设计都要考虑这个分离。帧内临时数据的特点是生命周期极短通常只在一个帧内有效。比如渲染器收集的绘制命令、物理系统计算的碰撞对、动画系统采样的骨骼矩阵这些数据在这一帧被创建和使用下一帧开始前就完全没用了。对于这类数据最合适的分配策略是线性分配器或者叫栈分配器。线性分配器的原理极其简单维护一大块连续内存和一个偏移指针每次分配就是把指针往前推释放就是把指针回退。没有空闲链表没有锁没有碎片整理分配和释放都是O(1)操作而且对CPU缓存极其友好因为分配出来的内存是连续的。我实测过在同样的分配频率下线性分配器比系统malloc快两个数量级。但线性分配器有个硬性限制释放顺序必须和分配顺序相反也就是后进先出。这正好符合帧内数据的生命周期特征——这一帧分配的所有临时数据在帧结束时统一释放。引擎通常会在每帧开始时重置线性分配器的指针相当于一次性释放了上一帧的所有临时数据。帧间持久数据就完全是另一回事了。游戏对象、资源句柄、组件数据这些需要跨帧存活生命周期不确定可能几秒也可能几小时。对于这类数据通常用池分配器或者通用堆分配器来管理。池分配器把内存切成固定大小的块用空闲链表串联分配和释放都是O(1)而且不会有外部碎片。缺点是只能分配固定大小的对象但引擎里大部分持久对象的大小是可以预先确定的。这里有一个很容易踩的坑不要在线性分配器里分配需要跨帧存活的数据。我见过一个项目开发者图省事把网络同步的状态数据也放在帧内分配器里结果下一帧指针一重置数据全没了而且这种bug往往在特定时序下才出现排查起来极其痛苦。判断标准很简单如果这个数据在下一帧开始时还需要访问就绝对不能放在帧内分配器里。2.3 内存对齐与缓存友好性内存对齐这件事很多从应用层转过来的开发者会忽略但在引擎里它是必须处理的基础问题。CPU访问内存时如果数据地址没有按照特定边界对齐可能会触发多次内存访问甚至在某些架构上直接崩溃。比如一个64位的浮点数如果地址不是8字节对齐的CPU可能要读两次才能拼出完整的数据。引擎里几乎所有数据结构都要考虑对齐。顶点数据通常要求16字节对齐因为SIMD指令一次处理16字节。矩阵数据要求16字节或32字节对齐取决于是否使用AVX指令集。分配器在返回内存地址时必须保证地址满足请求的对齐要求。比对齐更影响性能的是缓存友好性。现代CPU的运算速度远远超过内存访问速度一次缓存未命中的代价可能是几百个时钟周期。引擎里最影响缓存性能的是数据的访问模式。如果数据结构是“数组的数组”遍历时就会频繁跳转内存缓存命中率极低。如果改成“数组的结构体”把相关数据连续存放缓存命中率会大幅提升。我做过一个实测同样的粒子系统用面向对象的方式把每个粒子的位置、速度、颜色封装成对象遍历更新时帧率是45帧。改成结构体数组位置放一起、速度放一起、颜色放一起帧率直接上到120帧。代码逻辑几乎没变只是数据布局变了性能差了两倍多。这就是缓存友好性的威力。引擎里常用的缓存优化手段包括把频繁一起访问的数据放在同一个缓存行内避免伪共享用SoA替代AoS对热点数据做预取控制数据结构的大小使其能放进L1或L2缓存。这些优化在应用层可能收益不大但在引擎这种每帧要处理海量数据的场景下累积效果非常可观。3. 数据结构选型不是背八股而是看访问模式3.1 游戏对象管理从场景树到扁平数组游戏引擎里最核心的数据结构就是场景图或者叫对象层级结构。早期引擎普遍用树形结构每个节点保存父节点和子节点指针变换矩阵通过父子关系级联计算。这种结构直观符合人类对场景的理解但性能问题很明显遍历树要频繁跳转内存缓存命中率低每个节点单独分配内存碎片严重多线程遍历时锁的粒度很难控制。现代引擎越来越多地采用扁平化数组加索引的方案。所有游戏对象存在一个连续的数组里每个对象保存父对象的索引而不是指针。变换计算时先按层级排序然后从根节点开始顺序遍历父节点的世界矩阵算好后直接传给子节点。这样遍历是顺序内存访问缓存友好而且可以很容易地并行化——按层级分批处理同一层内的对象可以并行计算。索引代替指针还有一个额外好处对象可以安全地移动和删除。用指针的话删除一个对象要通知所有持有它指针的地方否则就是悬空指针。用索引的话删除只是把对应槽位标记为空索引本身仍然有效访问时检查一下有效性就行。引擎里通常会用世代索引来解决索引复用的问题每个槽位有一个世代号索引里包含槽位号和世代号删除时世代号加一这样旧索引自动失效。3.2 组件存储稀疏集与原型系统的取舍实体组件系统ECS架构现在很火但很多人只知其然不知其所以然。ECS的核心问题是组件的存储方式如何影响遍历性能。最直观的方案是每个实体持有一个组件指针数组遍历时先访问实体再访问组件。这种方案的问题是内存访问跳跃太大缓存命中率低。改进方案是稀疏集每种组件类型维护一个密集数组存放组件数据再用一个稀疏数组把实体ID映射到密集数组的索引。遍历某种组件时直接顺序遍历密集数组缓存友好。缺点是删除组件时要做交换删除会打乱顺序。更激进的方案是原型系统把拥有相同组件组合的实体放在一起每种组合对应一个原型原型内组件数据按列存储。遍历时直接按原型遍历组件数据完全连续缓存命中率最高。缺点是实体增删组件时要迁移原型开销较大。这三种方案没有绝对优劣要看具体场景。如果组件种类多但每种组件数量少稀疏集更合适。如果组件组合模式比较固定原型系统性能最好。如果只是简单项目指针数组也够用。我在实际项目里见过用稀疏集做核心组件、用指针数组做低频组件的混合方案效果也不错。3.3 空间划分结构四叉树、八叉树与BVH的适用边界引擎里做可见性剔除和碰撞检测都需要空间划分结构。常见的有四叉树、八叉树、BVH、网格等。选哪种结构取决于场景的特征。四叉树适合二维场景或者高度变化不大的三维场景实现简单查询效率不错。八叉树适合三维空间均匀分布的场景但实现比四叉树复杂而且如果场景物体集中在某些区域八叉树的效率会下降。BVH层次包围盒适合物体分布不均匀的场景它根据物体的实际分布自适应划分查询效率稳定但构建开销比四叉树和八叉树大。网格结构适合物体大小均匀、分布密集的场景查询是O(1)的但内存开销大而且物体大小差异大时效率会下降。实际引擎里经常是多种结构混用大物体用BVH小物体用网格静态物体用八叉树动态物体用BVH。这里有一个经验性的判断标准如果场景中物体的大小差异超过一个数量级优先考虑BVH。因为四叉树和八叉树的划分粒度是固定的大物体会跨越很多节点小物体又会让树变得很深。BVH根据物体包围盒自适应划分能更好地处理大小混合的场景。4. 系统通信从直接调用到消息驱动4.1 为什么引擎需要解耦系统间的通信引擎里有很多子系统渲染、物理、动画、音频、脚本、网络、输入。这些系统之间需要通信比如物理系统算出了碰撞结果要通知脚本系统触发回调输入系统收到了按键要通知角色控制器。最直接的做法是系统A直接调用系统B的接口但这样会导致系统之间强耦合。强耦合的问题在项目规模变大后会集中爆发。渲染系统直接依赖物理系统的数据结构物理系统改一个字段渲染系统就要跟着改。脚本系统直接调用音频系统的接口音频系统换实现脚本系统全部要重写。更麻烦的是多线程如果系统A在渲染线程系统B在物理线程直接调用就要加锁锁的粒度很难控制容易死锁或者性能下降。引擎架构里解决这个问题的核心思路是消息驱动或者叫事件驱动。系统之间不直接调用而是通过消息队列通信。发送方把消息放进队列就返回接收方在合适的时机从队列取消息处理。这样发送方和接收方在时间和空间上都解耦了可以运行在不同线程可以有不同的生命周期。4.2 消息队列的实现细节与性能考量消息队列听起来简单但引擎里实现一个高性能的消息队列要考虑很多细节。首先是消息的内存管理消息数据不能放在发送方的栈上因为发送方可能已经返回了。通常的做法是在堆上分配消息或者用环形缓冲区预分配固定大小的消息槽。环形缓冲区是引擎里最常用的消息队列实现。它是一块固定大小的连续内存读写指针循环移动。写入时把消息数据拷贝到写指针位置读取时从读指针位置拷贝出来。优点是分配和释放都是O(1)没有内存碎片缓存友好。缺点是队列满了要处理溢出通常的做法是丢弃最旧的消息或者阻塞发送方。多线程场景下消息队列需要保证线程安全。最简单的做法是加锁但锁的开销在高频消息场景下不可忽略。更高效的做法是无锁队列用原子操作管理读写指针。无锁队列的实现要小心处理内存序和ABA问题但一旦实现正确性能比加锁队列高很多。消息的投递时机也很关键。如果发送方每发一条消息就立即通知接收方接收方可能频繁被唤醒上下文切换开销大。通常的做法是批量投递发送方把消息放进队列接收方在每帧的固定时间点统一处理。这样接收方的处理是批量的缓存友好而且可以控制处理时机避免在关键路径上被打断。4.3 任务调度把系统通信和并行执行结合起来现代引擎的通信架构往往和任务调度系统结合在一起。每个系统注册成一组任务任务之间有依赖关系调度器根据依赖关系决定执行顺序和并行度。比如物理任务依赖输入任务渲染任务依赖物理任务和动画任务音频任务独立可以并行。任务调度器的核心是依赖图和工作窃取。依赖图记录任务之间的先后关系调度器拓扑排序后把就绪的任务放进队列。工作窃取是每个线程有自己的任务队列空闲线程从其他线程的队列尾部偷任务执行。这样负载均衡好而且减少了锁竞争。任务调度和消息队列结合的方式通常是每个任务在执行前先处理自己的消息队列执行完把产生的消息投递给下游任务。这样消息的传递就自然融入了任务依赖图不需要额外的同步机制。我在实际项目里用这套架构做过一个多线程引擎物理、动画、渲染三个系统并行执行帧率比单线程版本提升了2.5倍而且代码逻辑比加锁版本清晰得多。5. 从零搭建基础架构的实操路径5.1 第一阶段先把内存分配器跑通如果你要自己动手实现一个引擎基础架构我的建议是从内存分配器开始而不是从渲染器开始。原因很简单内存分配器是其他所有模块的基础先把它做好后面写其他模块时就不用考虑内存问题直接调分配接口就行。如果先写渲染器再补内存管理就要回头改大量代码成本高得多。第一阶段的实现目标很明确一个页分配器加几个固定尺寸的池分配器再加一个帧内线性分配器。页分配器负责向操作系统申请大块内存池分配器把页切成固定大小的块线性分配器从页里划一块出来做帧内分配。接口设计上提供alloc(size, alignment)和free(ptr)两个基本函数内部根据size路由到不同的池。这个阶段最容易踩的坑是对齐处理。池分配器返回的地址必须满足请求的对齐要求但池的块大小是固定的如果请求的对齐要求大于块大小就要特殊处理。我的做法是在池分配器里为每个块预留额外的对齐空间分配时把地址向上对齐释放时再还原。这样会浪费一些内存但实现简单性能也好。另一个坑是线程安全。如果引擎是多线程的分配器必须线程安全。最简单的做法是每个线程一个分配器实例线程内分配不需要加锁跨线程释放通过消息队列传回原线程。这样既保证了线程安全又避免了锁竞争。我在项目里用这个方案分配器的性能开销几乎可以忽略不计。5.2 第二阶段对象管理和组件存储内存分配器跑通后下一步是游戏对象管理。我的建议是用扁平数组加世代索引的方案不要一上来就搞复杂的场景图。扁平数组实现简单性能好而且后面要改成层级结构也容易。具体做法是维护一个对象数组每个对象有一个世代号。创建对象时找一个空闲槽位世代号加一返回槽位号和世代号的组合作为句柄。删除对象时把槽位标记为空世代号加一。访问对象时先检查句柄的世代号是否匹配不匹配就是无效句柄。这套机制能有效防止悬空引用而且对象在内存里是连续存放的遍历性能好。组件存储我建议先用稀疏集因为实现简单性能也不错。每种组件类型维护一个密集数组和一个稀疏映射。添加组件时把数据追加到密集数组末尾在稀疏映射里记录实体到密集数组索引的对应关系。删除组件时把密集数组末尾的元素移到被删除的位置更新稀疏映射。遍历组件时直接顺序遍历密集数组缓存友好。这个阶段要注意的是组件数据的对齐和大小。如果组件里包含SIMD类型的数据要保证组件数组的对齐满足要求。如果组件大小差异很大可以考虑把大组件单独存储避免小数组被大组件撑大导致缓存浪费。5.3 第三阶段消息系统和任务调度前两个阶段完成后引擎已经能管理对象和组件了但系统之间还是直接调用。第三阶段就是引入消息队列和任务调度把系统解耦。消息队列我建议先用简单的环形缓冲区加锁实现跑通逻辑后再考虑无锁优化。环形缓冲区的大小要预先确定太小会频繁溢出太大浪费内存。我的经验值是每帧消息数量的两到三倍比如每帧大概产生1000条消息缓冲区就设3000个槽位。任务调度器可以先实现一个简单的依赖图加线程池。每个任务声明它依赖哪些任务调度器拓扑排序后把就绪任务放进队列线程池里的线程从队列取任务执行。工作窃取可以后面再加先跑通基本逻辑。这个阶段的关键是任务粒度的划分任务太细调度开销大任务太粗并行度不够。我的经验是每个任务执行时间在0.1到1毫秒之间比较合适。消息和任务的结合方式是每个任务执行前先处理自己的消息队列执行完把产生的消息投递给下游任务。这样消息传递就自然融入了任务依赖图。要注意的是消息的投递顺序如果任务A和任务B并行执行它们都向任务C投递消息消息到达C的顺序是不确定的。如果业务逻辑对顺序敏感就要在消息里加序号接收方按序号排序后再处理。6. 那些文档里不会写的踩坑经验6.1 内存分配器的调试与验证内存分配器写完后一定要做压力测试和边界测试。我见过太多分配器在正常使用时没问题一遇到极端情况就崩溃。压力测试的方法是随机生成大量分配和释放请求分配大小覆盖所有池的尺寸范围释放顺序随机打乱跑几个小时看有没有崩溃或者内存泄漏。边界测试要覆盖这些情况分配大小为0、分配大小刚好等于池的块大小、分配大小刚好超过池的块大小、对齐要求大于块大小、连续分配直到池耗尽、释放空指针、重复释放同一个指针。这些边界情况在实际运行中可能很少遇到但一旦遇到就是崩溃级别的bug。还有一个容易被忽略的问题是内存对齐的验证。分配器返回的地址必须满足请求的对齐要求这个要在调试版本里加断言检查。我遇到过一个bug池分配器在某个特定尺寸下返回的地址没有正确对齐导致SIMD指令读取时崩溃。这种bug在release版本里可能表现为数据错误而不是崩溃更难排查。6.2 多线程下的伪共享问题多线程引擎里有一个非常隐蔽的性能杀手伪共享。两个线程分别访问不同的变量但这两个变量恰好在同一个缓存行里一个线程修改变量会导致另一个线程的缓存行失效强制从内存重新加载。这种情况下两个线程并没有真正共享数据但性能却像在共享一样差。伪共享在引擎里很常见因为引擎的数据结构往往很紧凑。比如任务调度器的就绪队列每个线程一个队列队列的读写指针如果放在同一个缓存行里就会产生伪共享。解决办法是缓存行填充在每个线程的队列前后加填充字节使它们落在不同的缓存行里。缓存行大小通常是64字节填充到64字节的倍数就行。检测伪共享可以用性能分析工具看缓存未命中的次数。如果两个线程各自访问自己的数据但缓存未命中率很高大概率就是伪共享。我在一个项目里遇到过任务调度器性能上不去的问题用工具一查发现是伪共享加了填充后性能提升了40%。6.3 消息队列的溢出处理策略消息队列溢出是实际运行中一定会遇到的问题关键是溢出时怎么办。常见的策略有三种丢弃最旧的消息、丢弃最新的消息、阻塞发送方直到队列有空位。丢弃最旧的消息适合那些“最新状态最重要”的场景比如输入事件、状态同步。丢弃最新的消息适合那些“先到先处理”的场景比如日志、统计。阻塞发送方适合那些“消息不能丢”的场景比如资源加载完成通知、网络包处理。我的经验是根据消息类型选择策略而不是全局用一种策略。引擎里可以给每种消息类型配置溢出策略发送时根据消息类型走不同的队列。这样既保证了关键消息不丢又避免了非关键消息阻塞发送方。实现上可以给每个消息类型一个独立的环形缓冲区缓冲区大小根据消息频率调整。还有一个细节是溢出时的统计和告警。队列溢出说明缓冲区大小设置不合理或者消息产生速度超出预期应该在调试版本里记录溢出次数和溢出时的队列状态方便定位问题。我在项目里加了一个简单的统计每帧输出各队列的峰值使用率根据这个数据调整缓冲区大小效果很好。6.4 任务依赖图的死锁预防任务调度器最怕的是依赖图成环也就是任务A依赖任务B任务B又依赖任务A两个任务都等对方完成永远无法执行。这种问题在代码规模变大后很容易出现因为依赖关系可能跨模块开发者很难在写代码时就看到全局的依赖图。预防死锁的方法有几个。第一个是在构建依赖图时检测环用拓扑排序如果排序失败说明有环直接报错。这个检测要在引擎初始化时做不要等到运行时才发现。第二个是限制依赖的方向比如规定只能从高层系统依赖低层系统不能反向依赖。第三个是用超时机制任务等待依赖超过一定时间就报错避免无限等待。我在项目里用的是第一种加第二种的组合初始化时做拓扑排序检测环同时用代码规范限制依赖方向。这样大部分死锁在开发阶段就能发现不会带到运行时。还有一个小技巧是给每个任务加一个调试名称依赖图出错时能直接看到是哪些任务成环排查效率高很多。7. 架构演进从单机到分布式的边界7.1 什么情况下引擎需要分布式架构大部分游戏引擎是单机架构所有系统跑在同一个进程里。但有些场景下需要分布式架构比如大规模多人在线游戏服务器端要处理大量玩家的状态同步比如云游戏渲染在服务器端完成客户端只负责显示和输入比如分布式构建系统把资源编译任务分发到多台机器。分布式架构引入的复杂度是数量级的提升。网络通信有延迟和丢包节点可能随时宕机数据一致性难以保证。所以不要为了分布式而分布式先确认单机架构真的撑不住再考虑。我见过一些项目明明单机就能跑非要搞分布式结果大部分时间花在处理网络问题上核心功能反而没做好。判断标准很简单如果单机架构的性能瓶颈在CPU或GPU计算上分布式能帮上忙如果瓶颈在内存带宽或磁盘IO上分布式帮助有限如果瓶颈在单线程性能上分布式基本没用。引擎里大部分性能问题其实是单线程性能问题优化单线程代码比搞分布式有效得多。7.2 分布式架构在引擎里的实际应用形态引擎里的分布式架构主要有三种形态。第一种是渲染农场把离线渲染任务分发到多台机器每台机器渲染一部分帧或一部分区域。这种架构的任务之间独立性高通信量小实现相对简单。第二种是服务器集群把游戏逻辑分散到多台服务器每台服务器负责一部分区域或一部分玩家。这种架构需要处理跨服务器的状态同步和玩家迁移复杂度高。第三种是分布式构建把资源编译、代码编译任务分发到多台机器加速构建过程。这三种形态的共同点是任务可以并行且任务之间通信量可控。如果任务之间需要频繁通信分布式的收益就会被网络开销抵消。引擎里适合分布式的任务通常是计算密集且数据独立的比如光照烘焙、物理模拟、动画压缩。不适合分布式的任务是那些需要频繁访问共享状态的比如游戏逻辑更新、渲染命令提交。7.3 分布式带来的新问题与应对思路分布式架构引入的新问题里最麻烦的是一致性和容错。多个节点同时修改同一份数据怎么保证最终一致某个节点宕机了它负责的任务怎么办这些问题在单机架构里不存在但在分布式架构里是必须解决的。一致性方面引擎里常用的方案是状态同步加冲突解决。每个节点维护自己的状态副本修改时先改本地再同步到其他节点冲突时用时间戳或版本号决定谁优先。这个方案实现简单但一致性是最终一致中间状态可能不一致。对于游戏逻辑来说最终一致通常够用因为玩家感知不到毫秒级的不一致。容错方面常用的方案是任务重新调度。每个任务有多个副本某个节点宕机后调度器把它的任务重新分配给其他节点。这个方案要求任务是幂等的重复执行不会产生副作用。引擎里大部分计算任务天然幂等比如渲染一帧、计算一次物理重复执行结果一样。但涉及状态修改的任务就要小心比如资源加载、玩家数据保存重复执行可能出问题。我在实际项目里做分布式渲染时用的是任务重新调度加结果校验的方案。每个渲染任务完成后把结果哈希值上报调度器发现同一个任务有不同哈希值就重新执行确保结果正确。这个方案增加了少量开销但容错能力大幅提升节点宕机时整个渲染任务不会失败只是慢一点。8. 一些个人体会做引擎架构这些年最大的感受是基础架构的价值在于它让上层开发变得简单。一个好的内存分配器让上层开发者不用关心内存泄漏和碎片一个好的对象管理系统让上层开发者不用关心生命周期和悬空引用一个好的消息系统让上层开发者不用关心线程安全和耦合。这些“不用关心”累积起来就是开发效率的巨大提升。另一个体会是不要过度设计。我早期做引擎时总想一步到位把所有可能用到的功能都加上结果代码复杂度爆炸调试困难性能反而不好。后来学乖了先做最简单的版本跑通核心流程遇到问题再针对性优化。大部分优化其实用不上真正需要的优化往往在写代码之前根本想不到。还有一个很实用的经验给每个系统写一个独立的测试用例。内存分配器有分配释放的测试对象管理有创建删除的测试消息队列有收发消息的测试。这些测试在重构时特别有用改完代码跑一遍测试就知道有没有破坏原有功能。引擎代码的耦合度高没有测试的话重构就是走钢丝。最后说一个关于学习的建议。引擎架构的知识很难从书本上完全学会因为很多设计决策的权衡只有在实际项目中才能体会到。我的建议是自己动手写一个小引擎不用功能很全能把内存管理、对象管理、消息系统跑通就行。写的过程中会遇到各种问题解决这些问题的经验比看十本书都有用。写完之后再去看商业引擎的源码会有豁然开朗的感觉很多之前不理解的设计决策突然就明白了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进