1. 从一次崩溃日志说起为什么基础架构决定了引擎的生死很多人第一次接触游戏引擎注意力都放在渲染效果、物理模拟、粒子特效这些“看得见”的东西上。我当年也是这么想的——直到我参与的一个小项目在运行了四十分钟后毫无征兆地闪退日志里只有一行冷冰冰的Access violation reading address 0x00000000。排查了整整两天最后发现问题出在资源句柄的引用计数上一个纹理在异步加载完成之前就被场景卸载逻辑释放了而渲染线程还在拿着那个已经失效的指针往显存里写数据。这件事让我彻底改变了对引擎开发的认知。游戏引擎真正的难点从来不在某个炫酷的特效实现而在于基础架构能否在长时间、高负载、多线程并发的条件下保持稳定。渲染管线、物理系统、音频系统、脚本虚拟机这些子系统每一个单独拿出来都能写一本书但它们全部建立在同一套地基之上——内存怎么分配、数据怎么组织、对象怎么管理、模块之间怎么通信。地基没打好上面盖什么都是危房。这篇文章面向的是正在学习游戏引擎架构的开发者无论你是刚看完《游戏引擎架构》前几章的学生还是已经用 Unity 或 Unreal 做过几个项目、想深入理解底层机制的工程师我都会从最基础的内存管理和数据结构讲起逐步展开到引擎的模块化设计、对象生命周期管理、资源引用系统以及多线程环境下的基础架构考量。每一部分我都会解释“为什么这样设计”而不只是“是什么”因为架构决策背后的权衡逻辑才是真正能迁移到你实际项目中的东西。需要提前说明的是本文讨论的“基础架构”不涉及具体的渲染算法如延迟渲染和前向渲染的对比、不涉及物理引擎的积分器选择、也不涉及动画混合树的具体实现。这些属于各自子系统的核心内容后续会单独展开。基础架构关注的是所有子系统共享的底层设施内存、容器、句柄、模块边界、初始化顺序、线程模型。这些东西在代码里往往不显眼但它们决定了引擎能不能撑住一个完整项目的生命周期。2. 内存管理引擎架构里最容易被低估的基石2.1 为什么通用分配器在游戏引擎里不够用C 默认的new和delete底层调用的是通用堆分配器它在设计时考虑的是通用性——能处理各种大小的分配请求、能适应不同的分配释放模式、能在多线程环境下工作。但游戏引擎的运行特征和通用程序有本质区别每帧要处理成千上万次小对象的创建和销毁粒子、事件、临时变换矩阵同时又有大量长生命周期的大块内存纹理、网格、音频缓冲区而且对帧时间的确定性要求极高——你不能接受某一帧因为分配器触发了一次系统调用而多花了三毫秒。通用分配器在这种场景下会暴露三个问题。第一是碎片化频繁分配释放不同大小的内存块会让堆变得千疮百孔最终导致明明总空闲内存足够却找不到一块连续的大内存。第二是不确定性通用分配器的内部策略如空闲链表遍历、合并相邻块在最坏情况下耗时不可预测这对需要稳定 16.6ms 帧时间的游戏来说是不可接受的。第三是缓存不友好通用分配器返回的内存地址在物理上可能分散得很远导致 CPU 缓存命中率下降。我见过不少团队在项目初期直接用new分配所有游戏对象到了中期发现帧率波动严重才开始做内存池改造结果牵一发动全身改造成本极高。所以我的建议是从第一天起就把内存分配策略纳入架构设计哪怕初期只实现最简单的池分配器。2.2 栈分配器、池分配器与线性分配器的适用边界引擎中常用的分配器有三类各自适用于不同的场景。栈分配器Stack Allocator的工作方式就像函数调用栈分配时移动栈顶指针释放时回退指针。它的分配和释放都是 O(1)而且天然缓存友好因为连续分配的内存是相邻的。它适用于生命周期严格嵌套的场景比如一帧内的临时计算——帧开始时标记栈顶帧结束时回退到标记位置所有帧内临时数据一次性释放。但它的限制也很明显不能单独释放中间某个分配必须按后进先出的顺序释放。池分配器Pool Allocator把内存预先切分成固定大小的块用一个空闲链表管理。分配时从链表头取一块释放时放回链表头。它适用于大量同类型对象的创建销毁比如粒子系统中的粒子对象、事件系统中的事件对象。池分配器的关键参数是块大小和池容量——块大小要略大于对象实际大小考虑对齐池容量要根据峰值使用量估算。如果池耗尽需要有溢出策略是动态扩容、还是复用最老的对象、还是直接丢弃请求这取决于具体子系统的需求。线性分配器Linear Allocator是最简单的分配器维护一个指针每次分配就往前移动释放只能整体重置。它适用于加载阶段的资源分配——关卡加载时把所有资源一次性分配好关卡卸载时整体释放。它的优点是极快且无碎片缺点是无法处理运行时的动态释放。下面这张表可以帮助你快速判断在什么场景下选什么分配器分配器类型分配复杂度释放方式适用场景典型使用者栈分配器O(1)后进先出回退帧内临时数据渲染命令缓冲、临时变换矩阵池分配器O(1)单块归还同类型对象频繁创建销毁粒子、事件、网络消息线性分配器O(1)整体重置加载阶段批量分配关卡资源、纹理元数据通用堆分配器O(log n) 至 O(n)任意释放长生命周期、大小不一引擎启动阶段、编辑器工具在实际引擎中这些分配器通常不是互斥的而是分层组合。比如一个典型的做法是引擎启动时从系统申请一大块虚拟内存然后在这块内存上划分出不同的区域分别交给栈分配器、池分配器和线性分配器管理。这样既减少了系统调用次数又能在引擎内部实现精细的内存控制。2.3 内存对齐与缓存友好那些教科书不会强调的细节内存对齐是另一个容易被忽视但影响巨大的问题。CPU 访问未对齐的内存时可能需要两次总线周期才能读完一个数据在某些架构上甚至会直接触发异常。游戏引擎中常见的对齐要求包括SIMD 指令要求 16 字节或 32 字节对齐缓存行通常 64 字节对齐GPU 资源上传可能要求 256 字节对齐。我在实际项目中的做法是所有分配器都接受一个对齐参数默认按 16 字节对齐特殊子系统可以指定更高要求。分配器在计算块大小时需要把对齐开销算进去。比如池分配器的块大小如果是 48 字节按 16 字节对齐后实际占用 48 字节因为 48 是 16 的倍数但如果块大小是 50 字节对齐后就要占用 64 字节。这个开销在池容量很大时不可忽视所以设计对象大小时尽量让它自然对齐。缓存友好性则涉及数据布局的选择。面向对象编程习惯把每个对象的数据和虚表指针放在一起但这对缓存不友好——遍历一个对象数组时每个对象的虚表指针和实际数据可能分散在不同的缓存行里。一种常见的优化是数据与行为分离把频繁遍历的数据如位置、速度放在连续的数组里把行为逻辑放在单独的系统函数中处理。这就是所谓“面向数据的设计”Data-Oriented Design的核心思想在 Unity 的 DOTS 和 Unreal 的 Mass 框架中都有体现。注意数据与行为分离不是银弹。它适合粒子、子弹、人群这类数量大且行为简单的对象但对于逻辑复杂的对象如玩家角色、任务 NPC传统的面向对象组织方式可能更清晰。架构决策要看具体场景不要为了“先进”而先进。3. 数据结构选型引擎里没有“最好”只有“最合适”3.1 从对象查找说起数组、哈希表与稀疏集的取舍引擎运行时需要频繁地根据 ID 查找对象根据实体 ID 找到对应的组件数据、根据资源句柄找到纹理对象、根据网络连接 ID 找到玩家状态。这个查找操作的性能直接影响帧时间所以数据结构的选择至关重要。数组加索引是最简单的方案对象存在连续数组里ID 就是数组下标。查找是 O(1)遍历缓存友好。但它的问题在于对象删除后数组会出现空洞如果直接留空会浪费内存如果移动末尾元素填补空洞又会导致 ID 失效除非 ID 和索引分离用额外的映射表维护。哈希表解决了 ID 不连续的问题任意 ID 都能映射到存储位置。但哈希表的查找常数比数组大而且哈希冲突处理会带来不确定性。在游戏引擎中哈希表通常用于资源路径到资源句柄的映射、字符串到 ID 的映射这类不需要每帧遍历的场景。稀疏集Sparse Set是游戏引擎中非常实用的一种结构特别适合组件存储。它由两个数组组成一个稀疏数组用 ID 直接索引存储的是该 ID 对应对象在密集数组中的位置一个密集数组紧凑存储所有有效对象。查找时先查稀疏数组拿到密集数组下标再取数据。删除时把密集数组末尾元素移到被删除位置更新稀疏数组对应项。这样既保证了 O(1) 查找又保证了密集数组无空洞、缓存友好还支持高效遍历。我参与过的一个项目中组件系统最初用std::unordered_mapEntityID, Component存储后来改成稀疏集遍历所有组件的耗时从每帧 1.2ms 降到了 0.3ms。这个提升在组件数量达到几万时非常明显。3.2 空间划分结构四叉树、八叉树与 BVH 的实战差异游戏世界里的对象需要做空间查询视锥剔除、碰撞检测、范围查询。暴力遍历所有对象在对象数量少时可行但一旦超过几千个每帧的查询开销就会变得不可接受。空间划分结构就是用来加速这类查询的。四叉树适用于 2D 场景或高度范围不大的 3D 场景如 RTS 游戏的地面单位管理。它把空间递归分成四个象限每个节点存储落在该区域内的对象。查询时只遍历与查询范围相交的节点。四叉树的缺点是对象分布不均匀时树会失衡比如所有单位都挤在地图一角树会退化成链表。八叉树是四叉树在 3D 空间的扩展每个节点分八个子节点。它适用于对象在三维空间均匀分布的场景比如飞行射击游戏中的子弹和敌机。但八叉树的节点分裂和合并需要维护成本动态对象频繁移动时可能频繁触发树结构调整。BVHBounding Volume Hierarchy包围体层次结构是另一种思路不划分空间而是把对象按包围盒层次组织成二叉树。它特别适合动态场景因为对象移动时只需要更新从叶子到根的包围盒不需要重建整棵树。现代游戏引擎的物理系统和光线追踪普遍采用 BVH 的变体。选择哪种结构关键看你的场景特征。对象静态或缓慢移动、分布均匀四叉树/八叉树简单有效对象高速移动、分布不均BVH 的更新成本更低。我在一个弹幕射击项目中试过八叉树和 BVH最终选了 BVH因为子弹每帧都在移动八叉树的节点重组开销太大了。3.3 句柄系统为什么引擎不直接传指针初学者常问为什么引擎不直接传对象指针而要搞一套句柄系统答案涉及三个问题生命周期安全、内存重定位、跨模块解耦。直接传指针的风险在于指针指向的对象可能已经被释放但持有指针的代码并不知道。这就是所谓的“悬空指针”。在单线程、生命周期清晰的场景下靠代码规范可以避免大部分问题但游戏引擎是多线程的资源加载、对象销毁可能发生在任意线程靠人工保证指针有效性几乎不可能。句柄系统通过间接层解决这个问题。句柄通常是一个整数 ID引擎内部维护一个句柄表把 ID 映射到实际对象地址。对象销毁时句柄表对应项被标记为无效后续通过该句柄访问会返回空或触发断言而不是访问已释放的内存。更进一步句柄表可以在对象移动时更新地址这样外部代码持有的句柄始终有效不需要关心对象在内存中的实际位置。句柄的另一个好处是解耦。渲染模块不需要知道纹理对象的具体类型只需要一个纹理句柄物理模块不需要知道碰撞体的具体实现只需要一个碰撞体句柄。模块之间通过句柄通信而不是通过具体类型指针降低了编译依赖和耦合度。实现句柄系统时需要注意句柄 ID 的复用问题。如果对象 A 被销毁后它的 ID 被分配给新对象 B那么仍然持有旧句柄的代码会错误地访问到 B。解决方案是在句柄中嵌入一个版本号或叫代数每次 ID 复用时版本号递增句柄比较时同时比较 ID 和版本号。这样旧句柄的版本号对不上就会被正确识别为无效。4. 模块化与初始化顺序引擎启动时到底发生了什么4.1 子系统划分的粒度太粗和太细都是灾难游戏引擎由多个子系统组成渲染、物理、音频、输入、脚本、资源、网络、UI。这些子系统怎么划分、边界在哪里是架构设计的核心问题之一。划分太粗的典型表现是“大杂烩”模块渲染模块里混着资源加载逻辑物理模块里直接操作渲染数据。这样做的短期好处是“方便”——不用定义接口直接调用。但长期代价极高模块之间形成循环依赖改一处牵动全身无法单独测试无法替换实现。划分太细的问题则是过度工程每个小功能都做成独立模块模块间通信开销超过实际计算开销初始化顺序变得极其复杂调试时需要在十几个模块间跳转。我的经验是按“变化频率”和“依赖方向”来划分。变化频率相近的功能放在同一模块比如渲染相关的着色器管理、材质系统、渲染管线可以放在渲染模块内。依赖方向要形成有向无环图资源模块被渲染和物理依赖但资源模块不依赖它们。如果发现两个模块互相依赖说明划分有问题需要提取公共部分或重新定义边界。一个实用的检验方法是尝试单独编译某个模块如果它依赖了五个其他模块的头文件那可能划分得太细或边界不清晰如果它包含了整个引擎一半的代码那可能划分得太粗。4.2 初始化与关闭的逆序原则一个真实的事故复盘引擎启动时需要按正确顺序初始化各子系统关闭时按相反顺序清理。这个原则听起来简单但实际项目中出问题的情况非常多。我经历过一次典型事故资源模块在渲染模块之前关闭导致渲染模块在清理时试图释放纹理资源但资源管理器已经销毁句柄表已经无效最终触发空指针访问。复盘时发现关闭顺序是手动维护的一个列表新加入的模块没有正确插入到列表中。正确的做法是让初始化顺序由依赖关系自动决定。每个模块声明它依赖哪些模块引擎启动时做拓扑排序确保被依赖的模块先初始化。关闭时按拓扑排序的逆序执行。这样新增模块时只需要声明依赖不需要手动调整顺序。另一个细节是部分初始化失败的处理。如果物理模块初始化成功但音频模块初始化失败引擎应该回滚已初始化的模块还是带着残缺状态继续运行这取决于失败模块是否关键。我的做法是关键模块资源、渲染、输入失败则整体启动失败非关键模块音频、网络失败则降级运行并记录警告。但降级运行需要确保其他模块不会因为该模块缺失而崩溃这要求在模块接口层面就考虑“空实现”的情况。4.3 模块间通信直接调用、事件总线与依赖注入模块划分清楚之后下一个问题是它们怎么通信。常见方案有三种。直接调用是最简单的方式模块 A 持有模块 B 的接口指针直接调用 B 的方法。它的优点是直观、性能好、调试方便。缺点是形成编译期依赖A 必须包含 B 的头文件B 的接口变化会导致 A 重新编译。在模块数量少、依赖关系稳定时这是合理的选择。事件总线是另一种常见方案模块向总线注册事件处理器其他模块向总线发送事件总线负责分发。它解耦了发送者和接收者发送者不需要知道谁在监听。但事件总线的缺点是调试困难——事件流是隐式的很难追踪一个事件最终被谁处理了。而且事件总线的性能开销比直接调用大不适合每帧调用成千上万次的高频通信。依赖注入在引擎中也有应用模块不主动查找依赖而是由引擎在初始化时把依赖注入进去。这种方式便于测试可以注入 mock 对象和替换实现如用不同平台的音频后端。但依赖注入增加了架构复杂度在小型引擎中可能得不偿失。实际引擎中往往是混合使用核心路径用直接调用保证性能跨模块通知用事件总线解耦平台相关实现用依赖注入替换。关键是不要教条地只用一种方案而是根据通信的频率、方向、耦合要求来选择。5. 对象生命周期与资源引用谁负责释放什么时候释放5.1 引用计数、垃圾回收与手动管理的三角关系对象和资源的生命周期管理是引擎架构中最容易出 bug 的地方。三种主流方案各有优劣。手动管理是最直接的谁创建谁释放或者由某个明确的所有者负责释放。它的优点是零运行时开销、释放时机完全可控。缺点是容易遗漏或重复释放尤其是在异常路径和提前返回的代码中。C 引擎通常以手动管理为主辅以 RAII 包装来降低出错概率。引用计数是折中方案每个对象维护一个计数器被引用时加一引用移除时减一减到零时自动释放。它的优点是释放时机确定最后一个引用消失时立即释放不需要暂停程序做垃圾回收。缺点是循环引用会导致内存泄漏而且计数器的原子操作在多线程环境下有性能开销。Unreal 的 UObject 系统使用的就是改进版的引用计数加标记清除。垃圾回收在脚本语言和托管运行时中常见运行时定期扫描对象图标记可达对象回收不可达对象。它的优点是程序员不需要关心释放缺点是回收时机不确定可能造成帧时间尖峰。Unity 的 C# 层使用垃圾回收但引擎核心的 C 层仍然以手动管理和引用计数为主。我的建议是核心引擎层用引用计数加手动管理脚本层可以用垃圾回收但要注意控制脚本对象的创建频率避免频繁触发 GC。对于跨语言边界的对象要明确所有权归属避免两边都试图释放。5.2 资源加载的异步性与引用有效性一个纹理加载引发的血案资源加载通常是异步的发起加载请求后立即返回实际加载在后台线程进行完成后通知主线程。这期间发起加载的代码可能已经不再需要这个资源比如玩家已经离开了那个区域也可能在资源还没加载完时就试图使用它。我踩过的一个坑是场景加载时发起纹理异步加载同时立即用纹理句柄去创建材质。材质创建时访问纹理数据但纹理还在加载中数据为空导致材质创建失败。修复方案是引入资源状态机资源有“未加载”“加载中”“已加载”“加载失败”四种状态使用资源的代码必须检查状态或者注册回调在资源就绪后执行。更稳健的做法是资源句柄的延迟解析句柄在资源未就绪时也可以传递和存储但实际访问数据时会阻塞等待或返回占位资源。这样调用方不需要关心加载状态引擎内部处理等待逻辑。但阻塞等待要小心死锁——如果主线程等待一个需要主线程参与才能完成的加载任务就会死锁。所以异步加载的回调通常在主线程的特定阶段如帧末统一处理。引用有效性方面除了前面提到的句柄版本号机制还需要考虑资源卸载的时机。一个资源可能被多个对象引用只有当所有引用都释放后才能卸载。引用计数在这里发挥作用但要注意循环引用材质引用纹理纹理的某个元数据又引用了材质就会形成循环。解决方案是明确所有权方向——通常纹理不拥有材质材质拥有纹理打破循环。5.3 对象池与延迟销毁避免帧内释放引发的连锁反应在游戏运行时对象的创建和销毁如果直接调用分配器和释放器会带来两个问题一是分配释放的开销分散在帧内各处难以控制帧时间二是对象销毁时可能触发连锁反应如销毁一个父对象导致所有子对象被销毁在遍历过程中修改容器会导致迭代器失效。对象池解决第一个问题预先创建一批对象使用时从池中取不用时归还池中而不是真正释放内存。这样分配释放都是 O(1)且内存地址稳定有利于缓存。对象池的关键参数是初始容量和最大容量初始容量根据典型场景估算最大容量防止内存无限增长。当池耗尽时可以选择扩容、复用最老对象或拒绝请求取决于具体需求。延迟销毁解决第二个问题对象标记为“待销毁”但不立即从容器中移除而是等到帧末或安全点统一处理。这样遍历过程中不会遇到容器被修改的情况。延迟销毁的队列通常是一个数组帧末遍历数组执行实际的销毁逻辑。销毁顺序也需要注意如果对象 A 和 B 互相引用销毁 A 时可能访问到已销毁的 B所以延迟销毁队列通常按依赖关系排序或者分批次销毁。我在项目中还遇到过一个细节问题延迟销毁的对象在标记后、实际销毁前仍然可能被其他代码访问到。所以标记销毁时要把对象状态设为“无效”所有访问路径都要检查这个状态。这增加了代码的防御性负担但比访问已释放内存安全得多。6. 多线程基础架构不是所有东西都适合并行6.1 主线程、工作线程与任务图的基本模型现代游戏引擎几乎都采用多线程架构主线程负责游戏逻辑和帧同步工作线程负责可以并行的计算如动画求值、物理模拟、粒子更新渲染线程负责向 GPU 提交命令。线程之间通过任务队列或任务图通信。任务图是一种比简单任务队列更结构化的模型任务之间有依赖关系引擎根据依赖关系调度任务执行。比如“更新所有角色的动画”这个任务可以拆分成多个子任务并行执行但“收集动画结果并更新骨骼矩阵”这个任务必须等所有动画子任务完成后才能开始。任务图自动处理这些依赖开发者只需要声明任务和依赖不需要手动管理线程同步。任务图的关键设计点是任务粒度。任务太粗并行度不够工作线程闲置任务太细调度开销超过计算开销。经验法则是一个任务的计算量至少要在几十微秒级别才值得作为独立任务调度。对于更细粒度的并行应该用 SIMD 或 GPU 计算而不是多线程。6.2 数据竞争与无锁编程什么时候该用什么时候该躲多线程最大的风险是数据竞争两个线程同时读写同一块内存结果取决于调度顺序导致不可复现的 bug。避免数据竞争的基本方法是加锁但锁会带来性能开销和死锁风险。无锁编程通过原子操作和内存序来避免锁但它极其难写对。我见过太多“看起来没问题”的无锁队列在压力测试下崩溃。无锁编程的适用场景很窄通常是单个生产者单个消费者的队列或者简单的原子计数器。对于复杂的共享数据结构加锁通常是更务实的选择。更安全的并行策略是数据隔离每个线程操作自己的数据副本最后合并结果。比如粒子系统可以把粒子分成 N 组每组由一个工作线程独立更新互不干扰。物理模拟可以把不同区域的刚体分给不同线程只要它们不碰撞。数据隔离避免了锁但需要仔细设计数据划分确保线程之间没有隐藏的共享状态。注意即使逻辑上数据是隔离的如果两个线程访问的内存位于同一个缓存行仍然会发生“伪共享”False Sharing导致缓存行在两个核心之间反复同步性能急剧下降。解决方案是让每个线程的数据按缓存行对齐或者填充到 64 字节的倍数。6.3 帧同步与线程同步的边界哪些操作必须回到主线程多线程架构中有些操作必须在主线程执行创建和销毁 GPU 资源因为图形 API 通常要求在主线程或特定线程调用、修改场景图结构因为其他线程可能正在遍历、触发脚本回调因为脚本虚拟机通常是单线程的。这些操作需要从工作线程“跳回”主线程执行。实现方式通常是命令队列工作线程把需要在主线程执行的操作封装成命令推入队列主线程在帧的特定阶段如帧开始或帧末取出命令并执行。命令队列需要线程安全通常用无锁队列或加锁的环形缓冲实现。命令队列的容量需要预先分配避免运行时动态扩容。如果队列满了工作线程应该等待还是丢弃命令这取决于命令的重要性。对于资源创建命令等待是合理的对于非关键的统计信息更新丢弃也可以接受。关键是不要在主线程等待工作线程否则可能死锁。另一个边界问题是帧同步主线程在帧末等待所有工作线程完成本帧任务然后交换缓冲区、提交渲染命令、进入下一帧。这个等待是必要的否则工作线程可能还在写上一帧的数据而主线程已经开始修改下一帧的状态。等待的实现通常用条件变量或原子标志加自旋具体选择取决于等待时间的预期长度。7. 一些踩坑之后才明白的道理架构设计最怕的是“想当然”。我最初做引擎时觉得内存管理就是调用new和delete数据结构就是std::vector和std::unordered_map模块划分就是按文件夹分。直到项目规模上来帧率不稳定、崩溃难以复现、编译时间爆炸才意识到基础架构的每一个决策都有代价只是代价可能在几个月后才显现。关于内存我现在会从第一天就引入分配器抽象层哪怕初期只是对new的简单包装。这样后期替换成池分配器或栈分配器时不需要改动业务代码。关于数据结构我会先问“这个数据每帧遍历几次”“查找频率多高”“对象数量级是多少”再决定用数组、哈希表还是稀疏集而不是默认用std::unordered_map。关于模块划分我会画依赖关系图确保没有循环依赖并且每个模块的公开接口尽可能窄。还有一个教训是不要过早优化但也不要过早放弃优化。有些架构决策如数据布局、线程模型后期修改成本极高需要在初期就考虑清楚而有些优化如特定算法的常数优化可以等到性能分析指出瓶颈后再做。区分这两类决策是架构师的核心能力之一。最后分享一个实用技巧在引擎中内置一个简单的内存统计和性能计数器系统实时显示各子系统的内存占用、分配次数、帧时间分布。这个系统在开发期可能看起来多余但当性能问题出现时它是定位问题的第一手数据。我现在的习惯是任何新子系统接入引擎时第一件事就是把它纳入统计系统这样它的资源消耗始终是可见的。