资讯详情

游戏引擎基础系统解析:内存管理设计与生命周期实践

📅 2026/10/13 22:27:02 | 华诺云谱 👁 阅读
游戏引擎基础系统解析:内存管理设计与生命周期实践
很多第一次接触游戏引擎的朋友容易把“引擎”这两个字想得特别玄乎总觉得里面有什么高深莫测的算法和架构。实际上你真正去拆一个引擎剥开外面那层渲染、物理、动画的光鲜外衣最底下就是一堆基础系统和内存管理代码。我做了这么多年引擎相关的工作越来越确认一件事一个引擎稳不稳、性能好不好七成看基础系统怎么设计而内存管理又是里面的大头。这篇是“游戏引擎原理与实践”系列的第 04 篇咱们就把引擎的基础系统剥开重点聊聊内存管理这套支撑一切的底层设施适合刚准备写引擎的开发者也适合在游戏客户端里做性能优化的朋友参考。1. 引擎基础系统到底包含哪些东西1.1 从启动流程认识模块划分不知道你有没有想过一个问题一个游戏引擎从我们双击启动图标到看到第一个画面中间到底发生了什么这个问题我没少拿来问身边的新人回答往往五花八门但只要顺着启动流程捋一遍引擎的基础系统长什么样就很清楚了。启动的顺序本质上就是依赖的倒序。你想内存管理是不是所有东西的前提没有内存分配器连日志字符串都拼不出来。所以引擎第一个要拉起的就是平台抽象层和核心内存系统。平台层负责跟操作系统打交道比如创建窗口、初始化图形 API 上下文而内存系统这时就要准备就绪给后续所有模块提供分配能力。再往后才是文件系统、配置系统、资源加载器、日志系统。等这些基础件都到位了引擎才敢去加载关卡配置、加载网格和贴图最后启动逻辑系统和渲染系统把第一帧画面送出来。我记得早期写引擎时偷懒把内存系统的初始化放在了文件系统之后结果日志系统启动时为了输出一行带时间戳的字符串内部临时分配内存崩了。那一次我算是彻底记住了模块启动顺序就是依赖关系的一张直观图你把这张图画清楚引擎的模块划分也就明了了。1.2 基础系统的各自职责与协作方式引擎的基础系统不是一个单一体它们各有各的任务但彼此之间又有明确的上下级关系。我习惯把它们分成三拨来看第一拨是“干活前必须有的”。这类系统包括内存分配器、平台抽象、日志断言、文件系统。它们的特点是你写任何业务逻辑之前都离不开它们。比如日志系统看着不起眼但它决定了你在引擎里能不能高效排查问题。我在自己项目里会把日志和断言放在很靠前的位置宁可别的模块晚一点启动也要保证出问题时有地方可以看到。第二拨是“供应物资的”。主要就是资源加载和配置系统。资源系统负责把硬盘上的模型、贴图、音频、配置文件读进来变成内存里的对象它直接决定游戏加载速度和后续的资源生命周期管理。配置系统则负责把命令行参数、项目设置、脚本配置统一整理成引擎可以查询的数据结构。第三拨是“在上面跑起来的”。包括场景管理、逻辑更新、渲染提交、音频播放等业务性更强的系统。它们不直接面对操作系统也不直接管理内存池而是通过上一层系统提供的服务来工作。这三拨系统之间的关系我常常打一个比方内存系统像是整栋办公楼的中央水电肯定得最先通资源和配置系统像是给各楼层搬运桌椅和设备按需分发而场景、渲染、逻辑这些业务系统就是在配套设施齐全的办公室里跑业务的员工。只有水电先通了、设备到位了员工才能干活。2. 内存管理为什么是引擎里的基础设施2.1 通用分配在引擎场景下的三个落点有些朋友可能会问我直接用 malloc/free 不就行了为什么引擎还要自己折腾一套内存管理这个问题的答案要落到游戏引擎的内存分配场景上。普通应用软件比如一个文本编辑器你输入一段文字分配一个缓冲区年龄长、访问频率低、数量也不大。但对游戏引擎来说内存分配的场景完全是另一个画风。场景一高频小对象分配。一帧渲染里动辄产生几千上万个临时对象骨骼矩阵、粒子数据、渲染命令、UI 布局节点……每个对象可能就几十上百字节。这种高频小分配如果全部走 malloc性能会非常难看因为通用的堆分配器要为兼容各种大小付出额外的维护代价。场景二实时性要求极高。游戏一帧只有 16 毫秒左右按 60 FPS 算渲染管线和逻辑更新都在抢这个时间。你完全不能接受一个内存分配操作偶尔卡顿一下哪怕是比较慢的分配器也都可能带来掉帧而这种不稳定的“毛刺”在调试时极难复现。场景三生命周期高度对齐帧边界。绝大多数临时对象生命周期只有一帧下一帧开始时这些数据都不需要了。通用 malloc 并不知道你的对象应该什么时候死它只会盲目地把已经释放的块挂在空闲链表里导致后续分配越来越慢。2.2 碎片、对齐与性能抖动具体实测光说概念大家可能没有体感我拿一组自己实测过的数据来说明。有一段时间我在优化一个模拟项目的加载流程里面频繁创建和销毁大量的小数据块大小集中在 32 到 128 字节之间。最开始直接用系统默认分配器跑了几万次操作后我看了下堆内存状态空闲块被切得七零八落总共空闲空间有 300 多 MB但最大的连续空闲块只有不到 20 MB。这就是碎片问题你明明有足够的空间却找不到一段连续的大块来用。后来又测过分配耗时。同样是分配 64 字节的内存第一次分配很快但连续多次分配、释放交叉进行之后某些分配操作耗时变成了之前的几十倍。原因很简单分配器在空闲链表里找合适的块时可能遍历了大量碎片化的节点。这种“平时很快偶尔抽风”的性能抖动对一个实时渲染的引擎来说完全不能接受。还有一个高频踩坑点就是内存对齐。现代 CPU 的 SIMD 指令、GPU 的顶点数据、引擎里的 SIMD 数学库都要求数据按 16 字节或 32 字节对齐。系统提供的分配器在很多平台上只保证最大对齐数但这是最通用的策略放进引擎的专用分配场景里就不够看了。2.3 引擎内存管理要盯住的设计目标基于上面的问题我总结引擎内存管理至少要同时满足这几条缺一条后面都会出大麻烦对齐可控。分配器必须能接受对齐参数对特殊数据按 16 或 32 字节对齐否则做数学库和渲染接口时会频繁踩到硬对齐错误。这一点在设计接口时就要定好不要等出了莫名其妙的问题再补。性能确定。分配复杂度最好是 O(1) 的查表和指针修改而不是在空闲链表上做线性搜索。哪怕最坏情况也要可控不能出现性能毛刺。生命周期可追踪。引擎要能在运行期告诉你某个对象是谁分配的、还活没活着、是否被正确释放。这对排查泄漏和越界来说是最宝贵的诊断信息。缓存友好。数据布局要考虑访问模式。一个系统内部的数据内存尽量连续、密集减少 CPU 缓存行换入换出的次数。这一点体现得最明显的就是后面会聊到的池分配器。3. 常用内存分配器设计与选型3.1 线性分配器最快但有代价先聊我最喜欢给新手介绍的分配器——线性分配器。它还有一个名字叫 bump allocator形象说就是一管的牙膏你只管从前往后挤挤到哪算哪从来不回头。它维护一大块预分配的内存再加一个偏移量。分配就是“看一眼剩余空间够不够够就把当前位置返回然后把偏移量往后推”。释放基本不做事。整块内存用一个 Reset 操作就可以把偏移量归零相当于这管牙膏又满血复活了。class LinearAllocator { public: explicit LinearAllocator(size_t size) { memory_ malloc(size); capacity_ size; offset_ 0; } ~LinearAllocator() { free(memory_); } void* Allocate(size_t size, size_t alignment 16) { uintptr_t base reinterpret_castuintptr_t(memory_) offset_; uintptr_t aligned (base alignment - 1) ~(alignment - 1); size_t padding aligned - base; if (offset_ size padding capacity_) { return nullptr; // 空间不足 } offset_ size padding; return reinterpret_castvoid*(aligned); } void Reset() { offset_ 0; } private: void* memory_ nullptr; size_t capacity_ 0; size_t offset_ 0; };这个分配器有两个铁定的前提一是你要预先准备一大块内存二是你不在中途单独释放某个对象。它最典型的应用就是“帧内临时内存”——每帧开始时 Reset 一下然后这一帧里所有临时数据结构随便分配帧结束统一重置。我当年第一次在渲染命令分配上用上它之后相关的分配耗时直接降到了几乎为零。代价也明摆着如果引擎里有些对象生命期很长、又分散在帧的不同模块里线性分配器就完全不适用。你没法释放其中某一个对象只能等整体 Reset长生命对象会把那一大块内存一直占着。3.2 栈式分配器按顺序回退栈式分配器是线性分配器在“可控回退”方向上多走了一步。它在底层还是连续内存加偏移量但你可以在任意点打一个标记之后从标记点开始分配所有对象都是后进先出地释放释放就是把偏移量回到标记点。class StackAllocator { public: explicit StackAllocator(size_t size) { memory_ malloc(size); capacity_ size; offset_ 0; } ~StackAllocator() { free(memory_); } void* Allocate(size_t size, size_t alignment 16) { uintptr_t base reinterpret_castuintptr_t(memory_) offset_; uintptr_t aligned (base alignment - 1) ~(alignment - 1); size_t padding aligned - base; size_t newOffset offset_ padding size; if (newOffset capacity_) { return nullptr; } offset_ newOffset; return reinterpret_castvoid*(aligned); } uintptr_t Mark() const { return offset_; } void Release(uintptr_t mark) { offset_ mark; } private: void* memory_ nullptr; size_t capacity_ 0; size_t offset_ 0; };这个特性在某些场景特别好用。比如渲染引擎在遍历场景树时要临时构建一条可见集列表遍历完这一层之后这些临时数据就不再需要了又比如你处理嵌套的地形节点裁剪时进入子树前 Mark 一下退出子树时 Release 回去内存的占用曲线会非常平滑不会因为递归深度不同而出现分配总量忽高忽低。不过它比线性分配器更挑使用模式你只能在边界明确、对象创建和销毁严格嵌套的地方用。要是中间有跨多层级的对象存活你用起来就会很别扭很容易直接把别人的内存给回退掉。3.3 池分配器同类对象的复读机说完线性这一类“整体分配整体释放”的分配器再看看另一种极端池分配器。它专门处理同一大小、大量重复创建销毁的对象比如引擎里的粒子、碰撞代理、UI 节点以及各种“飞一会儿就消失”的临时实体。池分配器的核心思路非常朴素预先按固定大小切好一块连续内存切成一个个槽位然后用一个空闲链表把这些槽位串起来。分配就是摘一个槽出来释放就是把这个槽放回链表。class PoolAllocator { public: PoolAllocator(size_t elementSize, size_t count) { memory_ malloc(elementSize * count); blockSize_ elementSize; capacity_ count; freeList_ new size_t[count]; for (size_t i 0; i count; i) { freeList_[i] i; } freeCount_ count; } ~PoolAllocator() { free(memory_); delete[] freeList_; } void* Allocate() { if (freeCount_ 0) return nullptr; size_t index freeList_[--freeCount_]; return static_castchar*(memory_) index * blockSize_; } void Free(void* ptr) { if (!ptr) return; size_t index (static_castchar*(ptr) - static_castchar*(memory_)) / blockSize_; freeList_[freeCount_] index; } private: void* memory_ nullptr; size_t blockSize_ 0; size_t capacity_ 0; size_t freeCount_ 0; size_t* freeList_ nullptr; };这里有个很关键的细节值得说明一下上面的精简实现里我用了额外的freeList_数组来做空闲索引这样写方便理解。但在实际高性能实现里池分配器通常会直接把下一个空闲槽的索引“编码”在槽位本身的内存里。也就是说空闲链表本身不占额外空间每个空闲槽存着下一个空闲槽的下标。这样做内存省一点缓存命中率也好一些代价是代码要小心处理内存区的别名问题。我建议新手先用带freeList_的版本逻辑完整跑通后再考虑空间优化。池分配器的最大优势是快和稳。快是因为操作就是数组下标级的复杂度稳是因为它不会产生碎片所有对象大小一致也没有浪费的零碎空隙。3.4 大小分级池把碎片摊平现实世界里的对象并不会厚道地都按同一大小创建。这时候就轮到大小分级池登场了它本质上是多个池分配器的合体把分配请求按大小对齐到某几个固定档位比如 16、32、64、128、256 字节每档用一个池来管理。这套思路在不少通用分配器的实现里也用过。分级的好处是不同大小的对象归属不同池彼此之间不会跨越边界把一大块连续内存切成小碎片。即使某个池内部会有一些碎片它的范围也被限制在池子内部了。我做引擎内存系统时给基础容器、字符串、命令缓冲这些对象用的就是分级池。字符串往往是释放最频繁的给字符串单独开一档 128 字节池之后我那个工具项目的帧分配抖动肉眼可见地减少了。不过分级池也要避免两个毛病一是档位太多反而浪费每档预分配的内存越多长期空置率越难看二是如果请求的大小跨度太大比如一个几千字节的大块也走 512 字节档反而容易把一个池的空间榨干。所以我会在前面加一层判断超过某个阈值的分配直接转发给系统分配器或者后面要说的专用大块分配器不要让大对象和小对象挤在一起。3.5 组合设计与典型内存层级你去看商用引擎的内存系统设计很少只单独用某一种分配器。它们基本都是分层的底层是系统分配的“大仓库”中间层是按用途分门别类的分配器最上层才面向各个业务模块。我常用的一套组合是这样的最底层是从操作系统一次性申请来的几块大内存比如 64 MB 的核心区、256 MB 的资源区、128 MB 的渲染区按用途切分原始来源。这个分区的意义在于不同区域的生命周期完全不同比如资源区随关卡切换而整体重置渲染区随帧同步清空物理区跟随物理世界步进更新。分区之后彼此不会干扰。中间层是在每个大区之上搭建具体分配器渲染区用线性分配器承接每帧的渲染命令资源区用引用计数配合大小分级池管理资源对象场景里的动态组件则用专门的池分配器管理。最上层是暴露给业务模块的统一接口比如gMemoryAllocator这样的单例入口或者按标签申请的接口它内部再根据请求大小和标签路由到不同的池。这样做的好处一句话就能概括让生命周期相似、大小相似的数据待在一起让内存的访问局部性最优让碎片问题在源头就被控制住。4. 生命周期管理与 Tick 循环的配合4.1 启动与关闭顺序谁先死谁后死分配器再多如果生命周期管理一团乱跑起来照样崩给你看。我在实际项目中生命周期问题九成出现在启动和关闭的顺序上。启动顺序相对好记内存系统最先平台层紧随文件与日志准备好资源管理器就位然后逻辑与渲染系统陆续启动。关引擎呢顺序就得反过来了先停掉逻辑与渲染再卸载资源写日志可以坚持到最后一刻再停最后才关闭内存系统。为什么顺序这么重要我讲一个踩过坑的例子。我早期在某个模拟项目里关闭引擎时渲染线程还在等一个后台加载线程返回结果但加载线程的线程池已经先被销毁了结果两个线程一个等锁一个等内存直接卡死在关闭阶段。后来我把关闭顺序改成先通知加载线程停止新任务等它把当前任务排空再关线程池渲染线程确认提交完最后一帧后再退出最后才关资源系统和内存池。整个流程一下就顺畅了。4.2 帧内临时内存的回收一帧的循环是引擎最重要的节拍。典型的主循环长这样接收输入更新逻辑剔除可见对象构建渲染命令提交渲染交换缓冲区然后进入下一帧。在这个循环里每一帧都有大量临时数据产生输入事件的列表、剔除结果集、渲染命令缓冲区、UI 布局项这些没有一帧需要跨帧存活。所以引擎会在每帧开始时调用FrameBegin这样一个入口把帧内使用的线性分配器 Reset每帧结束时调用FrameEnd把这块区域再清一次。就算某个模块不小心漏了释放帧级分配器也会在下一帧开始时自动兜底。这带来一个结果引擎里太容易默认为“凡是帧里分配的东西帧末都会自动消失”。如果你确实这样设计那使用临时分配器的代码就不要试图把指针传出帧外。我见过很多神秘 bug 的本质就是某个渲染资源指针在帧结束后还保存在逻辑侧下一帧被别人复用画面上出现“灵异数据”。4.3 悬空引用与句柄方案继续说那个问题对象销毁了但别的地方还握着它的指针。这是生命周期管理里最恶心的一个问题没有之一。原生 C 指针不知道对象死没死你拿去用的时候就是在访问一块可能已重新分配的内存表现就是随机崩溃、数据错乱查起来极其费劲。一个很实用的解决办法是改指针为句柄。简单说句柄就是一个带代次的索引。对象表里放了两个数组一是对象本身的数据二是它当前的代次编号也叫 generation。引用方持有的句柄包含“索引 代次”。访问时先到代次表里对比不一致说明对象已经销毁或重生直接判定无效。class HandleTable { public: uint32_t Create() { uint32_t slot AllocateFreeSlot(); generations_[slot]; return (slot 8) | generations_[slot]; } void Destroy(uint32_t handle) { uint32_t slot handle 8; uint32_t gen handle 0xFF; if (generations_[slot] ! gen) return; // 代次不匹配已经失效 generations_[slot]; FreeSlot(slot); } void* Resolve(uint32_t handle) { uint32_t slot handle 8; if (generations_[slot] ! (handle 0xFF)) return nullptr; return data_[slot]; } };用这个方案悬空引用就从“随机崩溃”变成了“可以判断的失败”resolve 返回空指针你在调用的地方做个空判断就能安全跳过。当然句柄方案的代价是访问多一层间接跳转性能比裸指针略有下降。但在绝大多数不是每帧几百万次密集访问的场景里这个代价完全可以接受。我最喜欢配合句柄用的一招是“按帧号验证”在池对象里加一个写入帧号调试模式下访问时检查这个对象是否在本帧被修改过。这样一些“数据没更新”的隐性 bug在开发期就能直接被断言揪出来。5. 内存调试与常见问题排查实录5.1 调试手段与检查工具内存管理写完了并不意味着万事大吉分配器本身也会有 bug。我怎么快速抓到内存问题靠的是内置的调试钩子。第一招是填充魔数。分配时把内存填成 0xCD释放时填成 0xDD。如果哪天看到一个异常变量的值全是 0xCDCDCDCD那就说明它在用未初始化的内存看到 0xDDDDDDDD说明它在用已经释放的内存。这一招看着土但真的救了我无数次。第二招是边界守护。在池的每个槽位前后写一段固定的 guard pattern释放时检查 pattern 有没有被改写。如果某个对象越界写大概率会写到相邻槽的前后 guard 区第一时间就能发现是哪个分配器管的哪块内存被越界了。第三招是分配统计。每个分配器都维护当前存活对象数、累计分配字节数、峰值分配量。当游戏跑一段时间后如果分配量只增不减就要怀疑是不是泄漏了。我给每个池的统计都加上“按调用点分类”的能力这样不只是知道“粒子池泄漏了”还能知道是哪一行代码分配的粒子没释放。5.2 实战案例粒子系统的越界写说一个我印象很深的排查案例。某次测试运行模拟项目画面随机出现花屏偶尔直接崩溃而且崩溃的堆栈每次都不一样。这种症状基本就可以锁定内存越界但没法直接从堆栈定位。我的排查流程是先把池分配器的 guard pattern 打开再设置越界即停的断点。跑了几分钟后断点真的停在了一个粒子系统的更新函数里。那里有一段代码用数组缓存最近几帧的粒子位置但数组大小在初始化时算错了少乘了一个粒子数。当粒子数量一多写入就超出了数组边界把后一个对象的成员数据给踩了。修完之后我看这次的教训其实不只是“下标算错了”更深一层是这个数组根本没有用引擎内存系统来管理而是用了裸的 C 数组。如果当初按照引擎规则走池分配器guard pattern 会在越界的瞬间就报警而不是等画面上出现奇怪的渲染结果才让人开始排查。5.3 实战案例UI 文本持续泄漏另一个案例是 UI 文本的内存一直增长。表现是打开某个界面后内存每秒涨几十 KB关掉界面也不再降。我打开分配统计功能把按调用点分类的分配表导出来发现一个字符串相关函数每帧都被调用但对应的池 free 计数几乎没有增加。顺着调用链查原来是一段文本缓冲只在发生文字变化时才释放旧数据而字体渲染模块又把这段缓冲缓存了一份UI 系统更新时会覆盖前者的内容但没通知渲染侧释放旧缓存。这个问题暴露了一个设计缺口跨模块的对象所有权没有约定清楚。到底是 UI 管还是渲染管代码里没说清。后来我在所有跨模块传递的内存对象上强制只允许传递只读引用确需传递所有权的必须通过资源句柄系统。从那以后这类泄漏基本绝迹了。5.4 避坑清单整理了这些年遇到的高频坑直接给出一份可抄的清单不要跨模块 delete 别的模块分配的内存。即使你知道它是堆上分配的也要通过接口交还给原模块处理约定好所有权边界。分配器如果不支持随机释放就坚决别在中间释放。我在线性区吃过亏之后强制自己在新代码里先用注释写清分配器的生命周期模型再动手写分配代码。多线程环境下用单一分配器一定要注意加锁。更好的做法是给渲染线程、加载线程、逻辑线程各配一套独立分配区从源头切断竞争。对齐问题不要靠运气。所有分配器接口都要带对齐参数在池分配器里用static_assert保证槽位大小是对齐值的整数倍一劳永逸。大对象和小对象一定要分开管理。否则大对象一释放小对象碎片就填进去了下次大对象分配又要找半天。任何内存系统改动先跑一轮带调试分配器的冒烟测试再合代码。调试分配器的慢是值得的它能在正式性能测试前把底层可靠性问题先暴露干净。我个人在实际操作中最深的一个体会是内存管理真的不难写难的是建起一套“出事能立刻查查就能定位”的机制。线性分配器、池分配器这些方案代码量都不大很快就能写出来但你要是没有带魔数、带统计、带调用点记录的那套调试辅助后续排查问题会消耗几十倍于写分配器的时间。还有一个很少人提到的细节我也想补充一下分配器的命名和分层一定要清晰。比如AllocFrame、AllocResident、AllocTemp每个分配接口对应的生命周期从命名上就能读出来。这样写久了团队里任何一个人看到调用点都能快速判断这个内存该由谁释放、何时释放比写一堆注释管用得多。基础系统的设计往往就藏在这种“一眼能看懂”的约定里。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑