From 2edd89f5915f0840d32ae26e279480285eaa948a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=80=9D=E6=B5=B7?= <1464576565@qq.com> Date: Mon, 23 Mar 2026 20:56:26 +0800 Subject: [PATCH] Create ObjectPoolSystemDesign.md --- Client/Assets/ObjectPoolSystemDesign.md | 923 ++++++++++++++++++++++++ 1 file changed, 923 insertions(+) create mode 100644 Client/Assets/ObjectPoolSystemDesign.md diff --git a/Client/Assets/ObjectPoolSystemDesign.md b/Client/Assets/ObjectPoolSystemDesign.md new file mode 100644 index 0000000..d2848e4 --- /dev/null +++ b/Client/Assets/ObjectPoolSystemDesign.md @@ -0,0 +1,923 @@ +# 高性能对象池系统设计文档 + +## 1. 设计目标 + +本文设计一个面向 Unity 大型项目的对象池系统,目标不是“能用”,而是同时满足以下要求: + +- 高性能:热点路径稳定 O(1) 或近似 O(1),避免全表扫描进入业务帧。 +- 低内存:控制元数据膨胀,支持容量上限、分层配额、预热与回收策略。 +- 0GC:运行时热点路径不产生托管垃圾,调试和统计默认不侵入业务路径。 +- 高扩展:支持 GameObject、纯 C# 对象、句柄对象、资源实例、复合对象、分片对象。 +- 易调试:运行时诊断与编辑器面板分离,支持快照、采样、事件流和错误定位。 +- 适合大型项目:支持多域、多场景、多模块、多团队协作,不依赖单一对象模型。 +- 支持复杂池子:支持多 Key、多状态、多生命周期、多租户、异步预热、延迟销毁。 + +这套系统默认服务“小众但大型”的 Unity 项目,即: + +- 代码量大,模块边界复杂。 +- 有长期演进需求,不能接受一次性写死。 +- 对线上性能和可观测性要求高。 +- 池子类型多,不只是子弹和特效。 + +## 2. 非目标 + +以下能力不作为第一阶段核心目标: + +- 透明跨线程直接复用 UnityEngine.Object。 +- 自动把任意未池化代码转换成池化逻辑。 +- 用一套 API 完全覆盖所有业务差异。 +- 在所有对象类型上强行追求完全一致的内部实现。 + +原因很简单:对象池的统一接口可以统一,但底层策略不能假装一样。GameObject、原生资源句柄、纯托管对象、本地缓存块,本质差异很大,强行统一通常会换来性能和可维护性同时变差。 + +## 3. 总体架构 + +系统分为 6 层: + +1. `PoolService` +2. `PoolDomain` +3. `PoolRegistry` +4. `PoolCore` +5. `PoolPolicy` +6. `PoolDiagnostics` + +职责如下: + +- `PoolService`:全局入口,负责生命周期、配置装载、域管理、更新调度。 +- `PoolDomain`:逻辑域隔离。比如 `UI`、`FX`、`Gameplay`、`Net`、`Addressable` 各自独立。 +- `PoolRegistry`:注册池定义、类型工厂、键类型、扩展策略。 +- `PoolCore`:真正的池实现,处理获取、归还、预热、扩容、淘汰。 +- `PoolPolicy`:定义创建、重置、校验、回收、容量、淘汰和调试采样策略。 +- `PoolDiagnostics`:只读观测层,负责统计快照、事件记录、调试视图和导出。 + +核心原则: + +- 业务代码不直接碰底层容器。 +- 池核心不直接依赖编辑器。 +- 统计系统不阻塞业务路径。 +- 高级功能通过策略和扩展挂上去,而不是污染主流程。 + +## 4. 模块拆分 + +建议目录: + +```text +Runtime/ + Pool/ + Core/ + Handle/ + Policy/ + Registry/ + Domain/ + Diagnostics/ + Adapters/ + Unsafe/ +Editor/ + Pool/ + Window/ + Inspector/ + Profiler/ +Tests/ + Runtime/ + Editor/ +``` + +### 4.1 Core + +核心只保留高频逻辑: + +- `Rent` +- `Return` +- `TryRent` +- `Prewarm` +- `Trim` +- `Shutdown` + +### 4.2 Policy + +策略层负责差异化: + +- 创建策略 +- 重置策略 +- 释放策略 +- 容量策略 +- 淘汰策略 +- 健康检查策略 +- 调试命名策略 + +### 4.3 Diagnostics + +诊断层只订阅事件,不进入关键路径的复杂计算。 + +### 4.4 Adapters + +适配层处理: + +- `GameObject` +- `Component` +- `ScriptableObject` +- `NativeArray` 包装 +- `Addressables` 实例 +- 自定义资源句柄 + +## 5. 核心对象模型 + +我不会让业务对象强制继承一个 `ObjectBase`。继承式对象池扩展性很差,会把“池系统”反向侵入所有业务对象。 + +我会采用“组合 + 句柄”模型。 + +### 5.1 核心接口 + +```csharp +public interface IPool +{ + PoolId Id { get; } + PoolRuntimeInfo RuntimeInfo { get; } +} + +public interface IPool : IPool +{ + PoolHandle Rent(); + bool TryRent(out PoolHandle handle); + void Return(in PoolHandle handle); + void Prewarm(int count); + void Trim(int count); +} + +public interface IPoolFactory +{ + T Create(); + void Destroy(T item); +} + +public interface IPoolLifecycle +{ + void OnCreate(T item); + void OnRent(T item); + void OnReturn(T item); + void OnReset(T item); + bool CanRecycle(T item); +} +``` + +### 5.2 句柄设计 + +`PoolHandle` 是值类型句柄,不直接暴露池内部节点引用: + +```csharp +public readonly struct PoolHandle +{ + public readonly T Value; + internal readonly int SlotIndex; + internal readonly ushort Version; + internal readonly PoolId PoolId; +} +``` + +作用: + +- 防止重复归还。 +- 防止把 A 池的对象还到 B 池。 +- 支持失效检测。 +- 允许调试系统用句柄定位历史事件。 + +这个设计比“直接传对象归还”更适合大型项目,因为大型项目里误还、重还、跨池还、跨域还都很常见。 + +## 6. 数据结构设计 + +### 6.1 主存储结构 + +每个池维护以下固定结构: + +- `Slot[] _slots` +- `int _freeHead` +- `int _activeCount` +- `int _inactiveCount` +- `FastQueue _inactiveQueue` +- `FastMap` 或 `Dictionary` +- `BitSet _inUseBits` +- `BitSet _dirtyBits` + +其中 `Slot` 包含: + +```csharp +internal struct Slot +{ + public T Item; + public int NextFree; + public int NextInBucket; + public int PrevInBucket; + public int StateFlags; + public int LastRentTick; + public int LastReturnTick; + public ushort Version; + public int RentCount; + public int OwnerThreadId; +} +``` + +设计意图: + +- 不用链表节点对象,避免额外分配和引用跳转。 +- 尽量数组化,提升缓存命中率。 +- 用索引串联空闲链和桶链,避免托管容器层层嵌套。 + +### 6.2 Why not LinkedList / MultiDictionary + +我不会把“按名字多值管理”建立在 `LinkedListNode` 和泛型多值字典上,原因是: + +- 节点对象本身有额外内存占用。 +- 指针跳转多,缓存局部性差。 +- 按名字查桶后仍可能线性扫完整桶。 +- 热点路径结构太泛化,不够专门化。 + +更好的做法是: + +- 预先把每个对象映射到 `slotIndex`。 +- 每个 Key 有自己的空闲链和活跃链。 +- 对“可租用集合”和“所有对象集合”分开维护。 + +### 6.3 双索引模型 + +对于复杂项目,我会同时维护两种索引: + +- `Item -> SlotIndex`:归还、校验、调试定位。 +- `Key -> AvailableHead`:按键快速获取可用对象。 + +这样 `Rent(key)` 不需要扫描整个桶,只需要从该 Key 的可用链表头部直接拿一个元素。 + +这比“遍历名字桶,找到第一个没在用的对象”要稳定得多。 + +## 7. 核心状态机 + +单个池内对象状态定义: + +- `Uninitialized` +- `Available` +- `Reserved` +- `InUse` +- `Returning` +- `Disposed` + +### 7.1 状态迁移 + +正常路径: + +- Create -> Available +- Available -> Reserved +- Reserved -> InUse +- InUse -> Returning +- Returning -> Available +- Available -> Disposed + +异常保护: + +- 已归还句柄再次归还,直接拒绝。 +- 版本不匹配,直接判定句柄失效。 +- 正在释放或已销毁对象,拒绝租用。 + +`Reserved` 这个状态不是多余的,它是给以下场景准备的: + +- 异步加载后的延迟激活 +- 跨系统交接 +- 带初始化流水线的对象 +- 需要分两阶段构建的复杂实例 + +## 8. 0GC 设计原则 + +0GC 不是口号,必须明确哪些路径严格零分配。 + +### 8.1 严格 0GC 的路径 + +- `Rent` +- `TryRent` +- `Return` +- `Fast Validate` +- `按 Key 查询可用数量` +- `池内状态位更新` + +### 8.2 允许分配的路径 + +- 首次创建池 +- 扩容 +- 预热 +- 编辑器快照 +- 导出 CSV / JSON +- 显式调试采样 + +### 8.3 实现要求 + +- 不在热点路径创建委托、闭包、枚举器类。 +- 不返回 `IEnumerable` 给业务热路径。 +- 不在热点路径 new `List<>`。 +- 不依赖反射创建对象。 +- 不在热点路径拼接字符串。 +- 不在运行时默认收集完整调用栈。 + +### 8.4 时间戳设计 + +我不会用 `DateTime.UtcNow` 记录热点使用时间。 + +我会统一使用: + +- `int tick` +- `uint frameIndex` +- `double realtimeSinceStartup` + +其中热路径记录 `frameIndex` 或逻辑 `tick`,只有调试导出时再转换成人类可读时间。 + +原因: + +- 更轻 +- 更稳定 +- 更容易做排序和过期比较 +- 更适合回放与录制 + +## 9. 获取与归还算法 + +### 9.1 Rent + +目标复杂度: + +- `Rent()`:O(1) +- `Rent(key)`:O(1) + +基本步骤: + +1. 从 `key available head` 拿 `slotIndex` +2. 校验 `slot` 状态和版本 +3. 从可用链移除 +4. 标记 `InUse` +5. 更新计数、tick、统计 +6. 调用 `OnRent` +7. 返回值类型句柄 + +### 9.2 Return + +目标复杂度: + +- `Return(handle)`:O(1) + +基本步骤: + +1. 校验池 ID +2. 校验版本号 +3. 校验状态必须为 `InUse` +4. 调用 `CanRecycle` +5. 调用 `OnReturn` +6. 调用 `OnReset` +7. 放回 `key available chain` +8. 更新统计 + +### 9.3 扩容 + +扩容不是每次 `+1`,而是分级策略: + +- 小池:2 倍扩容 +- 中池:1.5 倍扩容 +- 大池:固定块增长 + +原因: + +- 小池要减少扩容次数 +- 大池要控制峰值浪费 + +## 10. 容量与回收策略 + +大型项目不能只有一个 `Capacity`。 + +我会支持以下层级: + +- `MinCapacity` +- `SoftCapacity` +- `HardCapacity` +- `PrewarmCapacity` +- `PerKeySoftCapacity` +- `PerDomainBudget` + +### 10.1 容量语义 + +- `MinCapacity`:至少保留,不主动裁剪。 +- `SoftCapacity`:超过后进入延迟裁剪候选。 +- `HardCapacity`:超过后禁止继续扩容,走降级策略。 +- `PerDomainBudget`:整个域共享内存预算,防止单池失控。 + +### 10.2 回收策略 + +不做“每次超容立刻全量扫描”,而是做三段式: + +1. 标记超容 +2. 放入裁剪队列 +3. 在预算帧内分批处理 + +这很关键。大型项目最怕把维护性工作同步砸进战斗帧。 + +### 10.3 裁剪候选组织 + +每个池维护一个“可裁剪最小堆”或分层队列,排序因子可配置: + +- 最久未使用 +- 最低优先级 +- 冷热分层 +- Key 局部配额 +- 是否锁定 + +这样 `Trim` 可以渐进式处理,而不是每次遍历全池。 + +## 11. 多 Key / 复杂池支持 + +一个现代对象池不能只支持“按 name 拿对象”。 + +我会支持以下 Key 模型: + +- `PoolKey.None` +- `int` +- `FixedString64` +- 复合 Key +- 结构化 Key + +例如: + +```csharp +public struct FxPoolKey +{ + public int FxId; + public byte QualityLevel; + public byte Theme; +} +``` + +应用场景: + +- 同一特效不同质量等级 +- 同一 UI Cell 不同模板 +- 同一角色不同皮肤部件 +- 同一网络包对象不同协议版本 + +### 11.1 多态池 + +支持一个池管理多个派生类型,但不是默认开启。 + +理由: + +- 多态池灵活,但查找和重置逻辑更复杂。 +- 默认建议一池一类型,特殊场景再用联合池。 + +联合池通过注册表完成: + +- `BaseTypePool` +- 子类型工厂映射 +- 子类型生命周期映射 + +## 12. 特殊类型池设计 + +### 12.1 GameObject 池 + +特点: + +- 需要处理激活状态 +- 需要处理父节点 +- 需要处理场景归属 +- 需要处理组件重置成本 + +建议: + +- 池内对象挂 `PooledInstanceTag` +- 禁止业务直接 `Destroy` +- 使用 `PoolHandle` +- `OnReset` 里只做必要重置,不做全组件反射扫描 + +### 12.2 Component 池 + +对外返回 `TComponent`,内部仍以宿主实例为核心管理,避免组件和对象分离失控。 + +### 12.3 纯托管对象池 + +适合: + +- 临时上下文 +- 行为树节点运行数据 +- 网络解析缓存对象 +- UI 虚拟列表单元数据容器 + +优先使用泛型池,不依赖 Unity 生命周期。 + +### 12.4 集合池 + +单独提供高频集合池: + +- `ListPool` +- `DictionaryPool` +- `HashSetPool` +- `StringBuilderPool` + +但集合池必须分离于主对象池系统,不要共用复杂的池元数据。 + +原因: + +- 集合池需求简单 +- 热点极多 +- 追求极低开销 + +### 12.5 原生内存包装池 + +支持包装: + +- `NativeArray` +- `NativeList` +- `UnsafeList` + +但要求: + +- 主线程与 Job 边界清晰 +- 回收前必须完成 Job 依赖 +- 诊断系统记录 owner 和 frame fence + +### 12.6 异步资源实例池 + +例如 Addressables: + +- `Create` 可能异步 +- `Return` 后不能立刻销毁 +- 实例可能依赖引用计数 + +对此引入 `AsyncPoolAdapter`: + +- 支持 `ReserveAsync` +- 支持加载中状态 +- 支持失败回滚 +- 支持 warmup pipeline + +## 13. 扩展点设计 + +### 13.1 Policy 驱动 + +每个池通过配置组合: + +- `Factory` +- `Lifecycle` +- `CapacityPolicy` +- `TrimPolicy` +- `ValidationPolicy` +- `DebugNameProvider` + +### 13.2 Feature Flag + +不是所有池都启用所有能力。 + +例如按位配置: + +- `TrackStackTrace` +- `TrackOwner` +- `EnableDoubleReturnCheck` +- `EnableLeakDetection` +- `EnablePerItemMetrics` +- `EnableAsyncReserve` + +低端池只开基础能力,高风险池再开强诊断。 + +### 13.3 自定义验证器 + +```csharp +public interface IPoolValidator +{ + PoolValidationResult ValidateOnRent(T item); + PoolValidationResult ValidateOnReturn(T item); +} +``` + +用来做: + +- GameObject 是否被外部销毁 +- 资源引用是否断裂 +- Component 缓存是否失效 +- Native 容器是否已释放 + +## 14. 调试与观测设计 + +调试要“非常轻松方便”,但不能靠在 Inspector 每帧遍历整个池。 + +我的做法是“事件流 + 快照缓存 + 编辑器订阅”。 + +### 14.1 运行时观测层 + +运行时只维护固定大小统计: + +- 总租用次数 +- 总归还次数 +- 当前活跃数 +- 当前空闲数 +- 峰值活跃数 +- 创建次数 +- 销毁次数 +- 扩容次数 +- 裁剪次数 +- 失败租用次数 + +这些都存在 struct 里,常量开销更新。 + +### 14.2 环形事件缓冲 + +仅在开启诊断时记录事件到无锁环形缓冲: + +- Rent +- Return +- Grow +- Trim +- LeakWarning +- InvalidReturn +- DestroyOutsidePool + +每条事件固定大小,不分配字符串正文,只记录: + +- `PoolId` +- `SlotIndex` +- `Frame` +- `EventType` +- `Arg0` +- `Arg1` + +UI 读取时再解码。 + +### 14.3 快照机制 + +编辑器面板不直接扫运行时容器,而是请求一个“快照构建器”: + +- 默认每 500ms 构建一次 +- 只在窗口可见时工作 +- 可按域、池名、类型过滤 +- 可显示 Top N 热池 + +快照属于调试层分配,不污染业务路径。 + +### 14.4 泄漏检测 + +泄漏检测分等级: + +- Level 0:只统计未归还数量 +- Level 1:记录最近 owner id +- Level 2:记录采样调用栈 + +调用栈采样不能默认全开,只能: + +- 仅编辑器 +- 仅开发构建 +- 仅采样部分对象 +- 仅指定池启用 + +### 14.5 Debug 面板能力 + +我会做以下视图: + +- 域总览 +- 池列表 +- 单池详情 +- Key 分布 +- 活跃对象列表 +- 最近错误事件 +- 租用热点排行 +- 裁剪压力排行 +- 扩容时间线 +- 泄漏嫌疑列表 + +并支持: + +- 搜索 +- 排序 +- 冻结快照 +- 导出报告 +- 点击对象定位场景实例 + +## 15. 线程模型 + +Unity 项目里线程问题不能模糊。 + +### 15.1 主线程池 + +默认所有 Unity 对象池都归主线程管理。 + +### 15.2 Worker 池 + +纯托管对象池可选线程安全版本。 + +线程安全不是给所有池统一加锁,而是分实现: + +- `MainThreadPool` +- `ConcurrentPool` + +原因: + +- 主线程池要极致轻量 +- 并发池要接受额外同步成本 + +### 15.3 Job 边界 + +如果池对象会跨 Job 使用,必须通过显式借用协议: + +- 主线程 `Rent` +- 写入 job token +- job 完成后 `Return` + +不允许在 Job 内直接操作托管池。 + +## 16. 错误防护 + +大型项目里最值钱的是“错误早点炸,而且能查到是谁干的”。 + +系统必须捕获: + +- 双重归还 +- 跨池归还 +- 归还已失效句柄 +- 外部销毁池对象 +- 未初始化对象进入可用链 +- 锁定对象被裁剪 +- 异步加载失败后错误入池 + +### 16.1 处理策略 + +不同构建等级不同处理: + +- `Editor/Dev`:强校验 + 报错 + 事件记录 +- `Release`:快速失败 + 计数器上报 + 可选降级 + +### 16.2 降级策略 + +当池进入异常状态,可选: + +- 拒绝继续租用 +- 回退到直创建直销毁 +- 标记池熔断 +- 上报监控并隔离该域 + +## 17. 配置系统 + +大型项目不能全靠代码硬编码。 + +建议配置对象: + +- `PoolDomainConfig` +- `PoolDefinitionAsset` +- `PoolRuntimeOverride` + +配置字段包括: + +- PoolId +- 类型 +- Key 类型 +- 预热数量 +- 容量阈值 +- 扩容策略 +- 裁剪策略 +- 调试等级 +- 泄漏检测等级 +- 场景切换策略 +- 低内存回调策略 + +### 17.1 配置分层 + +- 默认配置 +- 平台配置 +- 场景配置 +- 运行时覆盖 + +例如移动端和 PC 不应该共享完全相同的池容量。 + +## 18. 生命周期与场景切换 + +对象池需要明确域生命周期: + +- `Global` +- `Scene` +- `Match` +- `Feature` +- `Temporary` + +### 18.1 场景池 + +进入场景时预热,退出场景时可批量回收或转移。 + +### 18.2 Global 池 + +如 UI 和通用 FX,不随场景直接销毁,但需要做场景引用清洗。 + +### 18.3 Temporary 池 + +例如一次性事件活动模块,生命周期结束直接销毁整个域。 + +## 19. 性能预算建议 + +建议在设计时就定义预算: + +- `Rent/Return` 平均 < 0.5us +- `Trim` 支持分帧预算,如每帧最多处理 16 或 32 个候选 +- Debug 关闭时每池统计更新为常量成本 +- 快照构建不进入游戏主热帧 + +### 19.1 大池优化建议 + +当池数量 > 100 或对象总量 > 100000 时: + +- 禁止全池排序式裁剪 +- 禁止调试全量对象枚举默认开启 +- 使用域级调度队列 +- 使用 Top-K 热点缓存代替实时全排序 + +## 20. 测试策略 + +必须覆盖: + +- 单线程正确性 +- 高频 Rent/Return 压测 +- 大容量扩缩容 +- Key 分桶正确性 +- 双重归还检测 +- 无效句柄检测 +- 异步加载失败回滚 +- 场景切换清理 +- 泄漏检测 +- 调试快照一致性 + +### 20.1 Benchmark + +基准要对比: + +- 当前实现 +- 新实现 +- Unity 内置 `ObjectPool` +- 直创建直销毁 + +指标: + +- CPU +- GC Alloc +- Peak Memory +- P99 延迟 +- Trim 尖峰 + +## 21. 推荐的最小落地版本 + +如果让我先做第一版,不会一次塞满所有高级功能,而是按下面顺序: + +### Phase 1 + +- `MainThreadPool` +- 值类型句柄 +- 固定数组槽位 +- O(1) Rent/Return +- 基础预热和扩容 +- 基础统计 +- Dev 下双重归还检测 + +### Phase 2 + +- 多 Key 支持 +- 分帧 Trim +- 域与预算 +- GameObject / Component 适配器 +- 编辑器快照面板 + +### Phase 3 + +- 异步资源实例池 +- 泄漏采样 +- Native 包装池 +- 并发池 +- 报表导出和线上遥测 + +## 22. 我会避免的设计 + +以下方案我不会采用: + +- 强制所有池对象继承统一基类 +- 用字符串当唯一主 Key 贯穿所有热路径 +- 超容后立刻全表扫描清理 +- 每帧在 Inspector 里直接遍历完整池状态 +- 在热路径用 `DateTime.UtcNow` +- 在热路径构造 `List<>`、`ToArray()`、LINQ +- 为了“统一”而让所有池都加线程锁 +- 调试信息和业务数据共用同一套重型结构 + +## 23. 总结 + +如果要同时做到高性能、低内存、0GC、高扩展、易调试,我的核心思路是: + +- 用数组槽位和索引链代替托管链表对象。 +- 用句柄和版本号解决大型项目里的误用问题。 +- 用多索引结构保证 `Rent/Return` 真正稳定。 +- 用分帧预算和候选队列替代同步全表释放。 +- 让调试成为旁路系统,而不是主路径负担。 +- 不强迫所有类型共用一种对象模型,而是统一接口、分离适配器。 + +这套设计的关键不是“对象池”,而是“把对象池当成一套运行时基础设施”来做。只有这样,它才扛得住大型项目、复杂类型、长期演进和多人协作。 + +## 24. 后续实现建议 + +如果继续往下落地,我建议按这个顺序开发: + +1. 先实现纯 C# 泛型池核心,完成句柄、槽位、版本、O(1) 可用链。 +2. 再实现 GameObject 适配器,不要一开始就把 Unity 特性揉进核心。 +3. 接着做域、预算和分帧裁剪,否则大项目上线后一定出现维护尖峰。 +4. 最后做调试窗口和快照系统,确保调试能力强但不反噬运行时。 + +如果你愿意,我下一步可以直接基于这份文档,在 `Packages/com.alicizax.unity.framework/Runtime/ABase/ObjectPool` 旁边给你起一版可落地的代码骨架。