ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

游戏引擎基础系统与内存管理:从帧分配到对象池的实战指南

游戏引擎基础系统与内存管理:从帧分配到对象池的实战指南

“游戏引擎原理与实践”这个系列写到第04篇,我想把引擎里最不显眼、却最绕不开的两块单独拿出来聊:基础系统与内存管理。渲染、物理、寻路这些系统做起来很有成就感,但只要程序崩了、内存涨了、或者同一个问题只在特定设备和特定加载顺序下才出现,最后痛的往往都在底层这一层。这篇文章不绕概念,直接把引擎基础系统拆开,把基础系统和内存管理之间的关系讲清楚,然后给出我自己在实现里反复用到的分配器设计思路,以及排查内存问题的一整套方法。适合已经在写引擎、想系统理顺底层结构的人,也适合刚建完窗口主循环、想避免后期大量返工的人。

1. 引擎基础系统到底在管什么

1.1 基础系统不是“工具库”,而是引擎的操作系统

很多刚接触引擎的人会把基础系统理解成一堆工具函数:字符串拼接、数学库、文件读取、日志打印。这么理解不算错,但不准确。引擎里的基础系统更像是“引擎的操作系统”——它们负责跟真实操作系统打交道,负责提供时间、窗口、输入事件、主循环调度,还要负责所有上层模块都能安全地创建和销毁对象。如果没有这一层,渲染场景和物理世界再好,也只能趴在裸芯片上自生自灭。

你可以想象一下运行引擎时最普通的一帧:先是窗口系统把系统消息拿进来,紧接着输入模块把鼠标键盘状态采样到内部队列,然后是各类逻辑系统开始更新,更新完进入渲染阶段,渲染线程把命令推给GPU,最后在帧末提交画面。这个过程中,每个系统都需要知道“此时此刻距离上一帧过了多久”,资源管理器要回答“我该去哪块内存里加载网格”,日志系统要告诉你某个系统启动是否失败,而内存管理器要为每一笔临时请求提供空间。这些全部属于基础系统的职责范围,只不过它们平时被上层系统的光茫盖住了。

我见过不少独立开发的Demo,前三个月在铺功能,后三个月全在追奇怪的问题。比如某个平台下窗口一缩放就崩溃,比如切换场景时CPU突然飙高,比如物体偶尔变成一片马赛克。追到尽头,往往不是某个功能算法错误,而是基础系统没做好:时间戳不统一、输入事件没清干净、内存越界踩了别人的数据。基础系统真正的价值不是“实现什么特效”,而是让上层系统之间不互相践踏。它是在定义一套规则:谁能碰窗口,谁能改时间,一块内存从创建到归还由谁负责。

1.2 模块的边界比模块的功能更关键

拆解基础系统有一个很容易犯的误区:把功能差不多的东西都塞进同一个模块。举个最常见的例子,事件系统。窗口的尺寸变化是一个事件,键盘输入是一个事件,手柄插拔也是一个事件。很多初学者会写一个“EventManager”,把所有事件都往里丢,看着很集中。但用在项目里就会发现,逻辑层和渲染层都订阅同一个队列,事件类型一多,广播次数飙升,回调的执行顺序变得完全不可控,最后还得给每个事件加优先级和过滤条件,复杂度比一开始分开管理还要高。

更好的做法是把事件按生命周期和消费方式拆分。窗口和平台的系统消息走“平台事件通道”,它只负责把外部系统消息翻译成引擎能理解的统一格式,处理完之后立刻清空;输入采样则走“输入状态通道”,它更多是轮询式的,每一帧从设备读取一次状态,而不是依赖异步回调;至于游戏逻辑里的自定义事件,那是上层业务的事情,不应该放在基础系统里。你只需要确认一点:基础系统的事件通道,永远要比上层系统的事件通道短、准、快。

模块边界的关键,是定义清楚“谁可以依赖谁”。以窗口模块为例,它应该只依赖平台抽象层,不应该反过来依赖渲染系统;渲染系统可以依赖窗口系统的句柄来获取绘制表面,但不能在渲染线程里直接调用窗口过程函数。时间系统应尽量独立,不能让每个系统各查各的时钟。否则一旦某个系统改了时间缩放,其他模块就各跑各的,运动、动画、特效全部对不上。

模块之间的初始化顺序也非常重要。一个常见的实践是把核心基础系统做成一个显式的初始化列表:内存管理器最先启动,然后日志、平台层、时间系统、输入系统,最后才轮到资源、场景、渲染、物理这些业务系统。主循环每帧固定的更新序列也可以写成一张有序表。你可以把这张表打出来挂在墙上,遇到初始化崩溃时,看是第几步挂的,问题范围立刻缩小一半。

2. 内存管理:引擎里藏着“两层内存世界”

2.1 先从 malloc 为什么慢说起

正经讲引擎内存管理,必须从“为什么不用现成的动态分配函数”说起。很多人第一次听到这句话是反感的:标准库分配器成熟、方便、线程安全,凭什么不用?一个字:慢;两个字:失控。

系统分配器要在任意时刻、任意请求下,从任意大小的空闲块里找到足够大的内存给你。为了做到这件事,它内部要维护堆元数据、空闲块链表、线程安全锁,并且在某些情况下还要向操作系统申请新的内存页。这些都不是免费的。比如一次malloc调用可能要几十纳秒到几百纳秒,单独看无所谓。但在战斗场景里,一帧创建几百个临时对象,每帧都要分配又释放,哪怕每次只多花几百纳秒,也会吃掉好几毫秒的主线程时间。而你本来可以只用一次内存块顶分配,几纳秒就返回一个指针。

更麻烦的是,系统分配器对“什么时候释放”没有黏性。你以为你在构建一个资源,分配了一段内存,等资源销毁时释放,逻辑是对的。可系统分配器并不知道这块内存在未来会被谁访问、访问频率如何。它可能把若干个生命周期的对象散落在不同的堆页里,导致后续遍历时缓存频繁失效。简单说:系统分配器管的是“逻辑上的空闲”,而游戏引擎还要管物理上的“内存访问的模式”。

内存管理在游戏引擎里不是省内存的问题,而是控制生死、控制布局、控制成本的问题。自建分配器不是恋旧,也不是为了炫技,是因为通用分配器没法提供你想要的性能特征和诊断能力。它只能保证“每次请求都能返回一块合法的内存”,仅此而已。

2.2 内存碎片:看起来还有很多,却用不了

碎片是动态分配这个机制本身附带的伤害。只要对象的大小不同,创建、销毁的时间点又交错,空闲内存就会被切成越来越多的小块。碎片最气人的在于:从统计上看“可用内存”很多,但当你想要一块大的连续内存时,系统翻遍了整个堆都找不到连续的地址空间。

我用个生活里的类比。你有一个停车场,里面车位很多,但经典场景是:A车走了留下一个小空位,B车来了占走了,C车开走又留下一个更小的空位。最后整个停车场零零散散全是空位,但每块空位都只够停自行车,来一辆大巴就进不去。内存也一样,当碎片累积到一定程度,引擎加载一个大纹理或大骨骼网格时会突然失败,但此时总空闲内存明明还非常多。

碎片的元凶不只是“频繁分配”,更是“生命周期混杂”。一个存活很久的对象旁边,如果频繁起灭痒一批临时对象,空闲块就会被切碎。引擎里缓解碎片最有效的办法,不是做一个更聪明的通用分配器,而是让生命周期相近的对象扎堆共存,让生命周期差异大的对象互相隔离。这也是后面说的栈式分配器和对象池的基本动机。

2.3 缓存友好:内存管理的另一笔隐形账

如果你只把内存管理理解成“解决碎片”,那还停留在第一层。现代CPU性能对内存布局极其敏感,L1/ L2/ L3缓存加载都是按缓存行(通常64字节)成块读进来的。如果你频繁访问的数据在物理内存里挨在一起,一次缓存行装载就能把下次要用的数据一并带进来;如果它们散布在堆的各个角落,CPU就只能在主内存和数据缓存之间反复搬运,性能差距可以到数倍甚至一个数量级。

最典型的例子是遍历一个对象数组,对象紧挨着存放,循环顺序访问,性能极高;但如果你用自由链表或者逐个new创建同一批对象,它们物理上不一定相邻,每访问一个对象都可能触发缓存未命中,卡顿就是这么来的。所以做引擎内存管理时,看到“连续数组”“对象池”“紧凑布局”这些词,不要只把它们当成某种优化,要意识到它们本质上是在配合CPU的缓存机制。分配策略不只是内存空间划分,也是访问热点的编排。

3. 实际可用的分配器:栈分配器、对象池与自由列表

3.1 帧分配器:一帧结束,全部作废

引擎里最常见的临时内存请求,生命周期比一帧还要短。比如构建渲染命令时,要给每颗粒子、每条骨骼、每个网格实例填一份临时数据;这些数据在本帧渲染结束后就没有任何存在意义。对这种场景,最合适的是帧分配器,也叫线性分配器或栈式分配器。

它的原理非常简单:在引擎启动时,一次性从系统堆里要一块比较大的内存(比如16MB或64MB,具体规模看项目),内部只保存一个“已用偏移”。每次分配只做三件事:把当前地址向上对齐到指定字节数,检查剩余空间是否够用,然后把偏移往后推。返回一个指针,结束。释放动作就更简单了:直接把偏移重置回模块开始的位置。

class FrameAllocator { public: explicit FrameAllocator(size_t capacity) { buffer = static_cast<uint8_t*>(::malloc(capacity)); offset = 0; } ~FrameAllocator() { ::free(buffer); } void* Allocate(size_t size, size_t align = 16) { uintptr_t current = reinterpret_cast<uintptr_t>(buffer) + offset; uintptr_t aligned = AlignUp(current, align); size_t padding = aligned - current; if (offset + padding + size > capacity) { // 触顶:说明本帧临时内存请求超过了预留量 // 常见做法:记录一条警告,并返回一块独立的慢速临时内存兜底 return ::malloc(size); } offset += padding + size; return reinterpret_cast<void*>(aligned); } void Clear() { offset = 0; } private: uint8_t* buffer; size_t capacity; size_t offset; };

这段代码里我最想强调两个点。第一是 Allocate 里那行 nullptr 兜底,看起来像偷懒,但实际项目里非常必要。你的帧分配器不可能永远够用,一旦突然出现一个超大的网格加载或粒子峰值,没有兜底策略直接断言挂掉,会让玩家在游戏里白白遭遇一次崩溃。恰当的兜底是退回系统分配,同时打一条 Log,让你能在开发期发现真正的问题。第二是 Clear() 构造函数唯一的任务就是把 offset 清零,不需要挨个调用析构函数,更不用归还系统。这比一帧内几百次 malloc/free 快出几个数量级,这种“一次性失效”恰恰是高性能的秘诀。

帧分配器唯一的规矩,就是不允许保留任何跨帧的指针。你可以在当前帧里自由地读写、引用,但帧一旦结束,所有从帧分配器取出来的地址都视为失效。很多人刚上手时总忍不住把粒子数组的地址存到一个全局变量里,下一帧继续用,结果数据被新的一帧覆盖,画面开始闪烁、抖动,异常难查。这是帧分配器最核心的使用禁忌。

Memory 上乘大原则:生命周期清晰,分配器就简单。帧分配器正是把“本帧有效”这个规则吃干榨净。

3.2 对象池:高频创建销毁对象的稳定器

如果说帧分配器服务的是“短命鬼”,那对象池服务的是“高频往复型”对象,比如粒子、弹道、贴花、伤害飘字。这类对象的问题不在于每帧要多少内存,而在于它们创建和销毁得极其频繁,如果每个弹道都走一次系统堆分配,碎片和耗时都会爆炸。对象池的前提是:同一类对象的大小完全一致,而且可以预先知道大概需要多少个实例。

最基本的对象池实现是把连续内存数组切成若干定长槽位,用空闲索引栈维护哪些槽位是空的。分配时从栈里弹出一个索引,用 placement new 构造对象;释放时调用析构函数,再把索引压回栈里。整个过程不涉及任何系统级分配,性能是 O(1),而且因为槽位在内存里连续排列,遍历所有存活对象时缓存友好得让人感动。

template <typename T> class ObjectPool { public: explicit ObjectPool(size_t capacity) { storage.resize(capacity); freeSlots.reserve(capacity); for (size_t i = 0; i < capacity; ++i) { freeSlots.push_back(i); } } template <typename... Args> T* Allocate(Args&&... args) { if (freeSlots.empty()) { return nullptr; // 也可以走溢出池,但业务上应尽量避免 } size_t index = freeSlots.back(); freeSlots.pop_back(); return new (&storage[index]) T(std::forward<Args>(args)...); } void Free(T* obj) { obj->~T(); size_t index = obj - storage.data(); freeSlots.push_back(index); } private: std::vector<T> storage; std::vector<size_t> freeSlots; };

实际操作里要注意三点。第一:分配和回收要严格配对,不能把一个对象池的对象交给另一个池子释放。最好每个池子保存自己的内存上下文和释放校验标记,发现有人给错对象池时直接报错。第二:固定容量意味着池满了要处理,返回 nullptr 或走溢出分配都可以,但你一定要在日志里记录发生了溢出。否则高峰期稍微多出几十个粒子,你只能在性能曲线里看到神秘的停顿,却不知道罪魁祸首是池子不够用。第三:对象池特别适合数量波动稳定、上限可控的对象,如果一种对象的峰值可能从100涨到10万,那就不适合用池子,改用通用分配更合理。

诚实说一句:对象池是柄双刃剑。它给你性能和碎片控制,但也把你锁死在一个硬上限里。真正项目里往往会给对象池加一个“慢速扩展”路径:核心池之外再挂一个溢出池,溢出池可以增长,多付一点分配成本,换来罕见峰值时系统不崩溃。这是一种工程妥协,但很实用。

3.3 自由列表分配器:实时、可回收,但小心代价

自由列表比对象池更通用一些,它适合那些固定大小但生命周期完全没有规律的对象,比如句柄、命令缓冲、异步加载任务描述符。它不再预留一块规整数组,而是把空闲块通过一个链表串起来,分配时从链表头部取一块,释放时再把它挂回链表。好处是灵活、可以动态增减数量,坏处是链表结点会散布在堆各处,遍历时缓存不友好,并且每块内存都要额外存储“下一块”指针,会多出几个字节的开销。

用自由列表有个容易踩的坑:如果把释放回来的块直接重新接到链表头部,那最近释放的块会被反复分配出去,导致那一小块内存特别热,而其他长期不用的块越堆越多,最终形成一种“热点漂移”。模块化后期如果发现某一块 buffer 的访问速度比其他地方慢,打印分配地址分布时经常能看到这种情况。解决办法之一是释放时随机插入到链表中部,或者定期整理链表顺序,虽然看起来多花一点功夫,但对缓存有效。

这里必须老实交代:很多自研引擎实际项目中,最常用的其实只是帧分配器加对象池,自由列表用得反而少。因为自由列表在定位上夹在两者之间:比对象池灵活,但不如对象池数组化缓存友好;比通用分配快,但实现和调试成本又高了。只有在明确知道对象大小固定、数量会动态变化、而且不追求线性遍历的专业场景里,它才值得用。

3.4 对齐与缓存行:容易被忽略,但绝对值得修

写分配器的时候,最容易“Note”不到的是对齐。很多CPU指令要求数据起始地址按2、4、8或16字节对齐,尤其是 SIMD 相关指令往往要求16字节对齐。如果你的渲染系统要把粒子位置批量加载到寄存器里运算,结果给出去的地址是未对齐的,轻则性能骤降,重则直接非法指令崩溃。所以分配器接口里几乎都要带一个对齐参数,且默认值至少是16。

对齐的计算并不复杂:先拿当前地址的低位判断是否已经对齐,不够的部分就补 padding。注意对齐是按“下一个合法对齐地址”来算的,如果你要16字节对齐,当前地址 mod 16 等于9,那就需要补7字节。分配器内部只要维护“已用偏移”时再加上这部分 padding 就行。

缓存行的问题同样隐蔽。CPU缓存行通常是64字节,当多个线程间共享同一个缓存行范围内的数据,哪怕每个线程只改自己那一小段,也会把整条缓存线反复标记为失效,导致其他线程的读写全部变成慢速内存访问。这叫伪共享。引擎里做高并发任务时,应该主动把经常被不同线程改写的对象按缓存行边界隔开,或者干脆让每个线程持有私有的小缓冲区,从根上避免互相污染。

选分配器时常见的原则我整理成了一张表,方便你对照自己的场景快速决策:

分配器类型适合场景不适合场景性能特征
帧分配器/线性分配器生命周期只在本帧内的临时数据需要跨帧保留的引用分配极快,重置极快,无碎片
对象池高频创建销毁、大小固定、数量上限稳定数量剧烈波动、生命周期不规则O(1)分配/释放,缓存友好,有硬上限
自由列表固定大小但生命周期不规则对缓存敏感的线性遍历灵活,但地址分散,调试成本高
通用系统分配低频、大块、生命周期极不规则高频热点代码稳健但慢,无法控制布局

4. 内存崩了怎么办:越界、泄漏与重复释放的排查思路

4.1 崩溃出现时不要猜:先看内存的“指纹”

如果程序时不时就崩,而且永远复现不了同一个点,大概率不是算法逻辑错,而是内存被写坏了。内存块写坏之后不会立刻爆,往往是等另一个对象读取这一段被污染的数据时才崩。所以排查时要学会看“指纹”。

调试版本里可以把内存划分成不同状态值来辅助判断。分配但尚未初始化的内存可以填上 0xCD;已经释放的内存可以填上 0xDD;尚未分配的自由堆空间可以填上 0xCC。不同平台可能会选不同枚举值,但思路一致:当你崩溃时查看变量里的数值,如果发现一个指针指向的地址到处都是 0xCD,说明它在使用前一直没被初始化;如果到处是 0xDD,说明这块内存已经被释放过,你却还在用。这两条线索能帮你立刻锁定越界方向和生命周期的错误。

我印象很深的一次问题是:一个对象成员的值偶尔变成奇怪的数字,而且每次变的模式不同。最初以为是线程同步问题,加了好几层锁还没解决。后来在调试版本里发现,这个对象的隔壁槽位被另一个系统多写了8个字节,正好越过边界踩到了目标对象的尾部,破坏了它的内部指针。如果你没有填充值,这种写越界要排查好几天。填充值一上去,崩溃现场的字节模式直接告诉你“有人越界”。

4.2 记录调用点:从“不知道谁在涨”到“一清二楚”

另一类经典情况是内存泄漏或者峰值过高。游戏跑着跑着内存一路上涨,问题却不是“内存不释放”,而是某个系统持续往全局列表里塞对象,永远不会回收。这时你需要知道两个东西:哪些调用点申请了最多内存、哪些调用点分配了最多次数。

实现方法不复杂:在自定义分配器里维护一张统计表,每次分配时把调用点的文件名、行号、要分配的大小记录下来。大小按“累计分配总量”和“当前存活量”分别累加。到一定时间点导出报表,马上就能看到某一行代码申请了500MB且从未释放。这种工具不需要很复杂,一张哈希表加锁即可,但带来的收益在引擎调试阶段几乎无价。

对重复释放问题,一个简单有效的招是给每块分配出的内存头部写上独特的魔数(magic number)。释放时先检查头部魔数是否还是原值,如果已经被改写,说明这块内存在释放前就被越界踩过;如果检查到某块内存被释放两次,也能通过魔数状态明确地报错。另一个帮手是释放时把一个特殊值填满整块,再下一次分配时检查上次释放状态,这样能顺手抓到“悬垂指针”的使用。

4.3 常见内存故障速查表

问题典型特征优先排查方向
写越界对象内容随机变化、崩溃点不固定检查分配大小与请求大小是否匹配,开启内存填充值
重复释放崩溃在同一个释放点、堆信息损坏释放后指针置空,检查是否多个系统共享所有权
悬垂指针对象释放后再访问,数据看起来是旧数据填0xDD快速暴露,统一对象生命周期管理
内存泄漏长期运行内存持续上涨、峰值异常用调用点统计表定位累计分配最多的代码位置
栈溢出深递归或超大临时数组检查递归深度和栈上大数组,改用帧分配器/堆分配
对齐错误某些平台崩溃,某些平台正常检查SIMD操作地址是否16字节对齐

排查内存问题是我个人认为引擎开发里最锻炼工程敏锐度的环节。没有一次内存崩溃是跟你“无关”的,写错的就是写错了,只是时间还没到。你在早期把引擎每一块的分配、释放、归属都定义清楚,后期就能少掉很多白头发。

5. 落地到项目里的实战套路

5.1 从全局 new 切到分层分配:迁移的节奏感

如果项目一开始所有代码都用全局 new/delete,不要试着一次性全部换成定制分配器。那是大工程,而且一步到位基本等于给自己埋一颗雷。我见过朋友A在某个模拟项目X里直接改了三分之一模块的分配路径,结果一个隐藏的跨模块释放把整个启动流程干碎,查了三天才发现是两个分配器把同一块内存登记到了两套统计体系里。

正确节奏是:先加一层“透明的内存统计层”,所有 new 都先经过它,记下文件、行号、大小。跑通一周后,你手里就有一份完整的分配热点报表:哪些系统分配次数最多、哪些系统生命周期最长。然后再从收益最大的地方动手,比如把临时数据换成帧分配器,把高频创建对象换对象池。每一步切换都单独跑功能回归,确认无异常后再继续。这种渐进式迁移看起来慢,实际却是最稳的。

还有一个容易被忽略的细节:分配器和系统模块的初始化顺序。内存管理器必须在引擎初始化流程的最前面启动,任何其他模块都不允许在它之前申请内存。日志模块虽然看起来人畜无害,但如果它在内存管理器之前被创建,会直接绕过了你的分配统计体系,后续排查就会有一个黑洞。

5.2 那些藏在角落的边界条件

实际操作中还有一批必须处理的边界条件,不处理就会成为定时炸弹。

第一个是零字节分配。有的调用方会传 size=0,如果分配器直接返回空指针,上层可能把它当成“分配失败”然后走异常流程,也可能返回一个有效地址、但逻辑上不该这样。最好在接口层统一处理:要么返回一个共享的最小块,要么明确断言不允许,不要让调用方自己去碰运气。

第二个是分配大小上限。帧分配器和对象池都有容量限制,触顶怎么办?方案不是直接断言挂掉,而是提供一个“慢速溢出”路径,同时记录一条精确日志。生产环境中不可能永远不发生峰值,你需要的不是崩溃,而是有控制地降级。

第三个是线程安全。逻辑线程和渲染线程如果共用同一个非线程安全的分配器,后果非常严重——两个线程同时修改内部偏移,直接写坏分配器状态。常见方案是给每个线程维护一个私有分配器,或者用线程局部缓存来降低锁竞争。记住:分配器的线程模型必须在架构阶段就定下来,不能事后打补丁。

第四个是对齐的默认值。所有分配接口默认对齐至少16字节,这对普通对象数组、SIMD数据和部分GPU回读缓冲都安全。除非极其确定,不要为了省几个字节去用弱对齐方案。省下的那几字节,可能要用一次崩溃来找平。

5.3 给小项目的选型建议

如果你现在还在做小型Demo,我的建议很直白:别一上来就做一套万能内存管理。小型项目用系统分配器,把主要精力放在业务正确性和清晰度上,完全没问题。等到你真的发现性能瓶颈或奇怪的崩溃点,再考虑引入帧分配器和对象池。

选型时先回答四个问题:这个对象是谁分配、谁释放;它的预期生命周期是多久;分配频率高不高;有没有特殊的对齐需求。四个问题答完,用前面那张选型表套一下基本就能定。内存管理说到底不是越复杂越好,而是越贴合你的访问模式越好。你完全可以只在3个热点场景里用定制的分配器,其余地方继续交给通用分配,没有人在乎你的纪念碑,大家在乎的是它跑得稳不快。

如果你问我这一路做下来最深的体会,不是写了多少个分配器,也不是哪种内存池最高效,而是所有内存问题最后都指向同一件事:内存的归属和生命周期清晰了,问题就少了一大半。写游戏引擎时,每一次分配前我都会问自己一句,这块内存到底谁负责释放、什么时候释放。如果回答不上来,再快的分配器也只是让错误发生得晚一点而已。这一课不是我一次学会的,是在崩溃记录里慢慢磨出来的,希望你不用踩这么多坑。

RELATED READING

延伸阅读

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