AlicizaX/Client/Assets/ObjectPoolSystemDesign.md

924 lines
20 KiB
Markdown
Raw Normal View History

2026-03-23 20:56:26 +08:00
# 高性能对象池系统设计文档
## 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<TItem, TKey>`
5. `PoolPolicy`
6. `PoolDiagnostics`
职责如下:
- `PoolService`:全局入口,负责生命周期、配置装载、域管理、更新调度。
- `PoolDomain`:逻辑域隔离。比如 `UI`、`FX`、`Gameplay`、`Net`、`Addressable` 各自独立。
- `PoolRegistry`:注册池定义、类型工厂、键类型、扩展策略。
- `PoolCore<TItem, TKey>`:真正的池实现,处理获取、归还、预热、扩容、淘汰。
- `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<T> : IPool
{
PoolHandle<T> Rent();
bool TryRent(out PoolHandle<T> handle);
void Return(in PoolHandle<T> handle);
void Prewarm(int count);
void Trim(int count);
}
public interface IPoolFactory<T>
{
T Create();
void Destroy(T item);
}
public interface IPoolLifecycle<T>
{
void OnCreate(T item);
void OnRent(T item);
void OnReturn(T item);
void OnReset(T item);
bool CanRecycle(T item);
}
```
### 5.2 句柄设计
`PoolHandle<T>` 是值类型句柄,不直接暴露池内部节点引用:
```csharp
public readonly struct PoolHandle<T>
{
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<int> _inactiveQueue`
- `FastMap<TKey, Bucket>``Dictionary<TKey, Bucket>`
- `BitSet _inUseBits`
- `BitSet _dirtyBits`
其中 `Slot` 包含:
```csharp
internal struct Slot<T>
{
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<T>` 和泛型多值字典上,原因是:
- 节点对象本身有额外内存占用。
- 指针跳转多,缓存局部性差。
- 按名字查桶后仍可能线性扫完整桶。
- 热点路径结构太泛化,不够专门化。
更好的做法是:
- 预先把每个对象映射到 `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<T>` 给业务热路径。
- 不在热点路径 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<GameObject>`
- `OnReset` 里只做必要重置,不做全组件反射扫描
### 12.2 Component 池
对外返回 `TComponent`,内部仍以宿主实例为核心管理,避免组件和对象分离失控。
### 12.3 纯托管对象池
适合:
- 临时上下文
- 行为树节点运行数据
- 网络解析缓存对象
- UI 虚拟列表单元数据容器
优先使用泛型池,不依赖 Unity 生命周期。
### 12.4 集合池
单独提供高频集合池:
- `ListPool<T>`
- `DictionaryPool<TKey, TValue>`
- `HashSetPool<T>`
- `StringBuilderPool`
但集合池必须分离于主对象池系统,不要共用复杂的池元数据。
原因:
- 集合池需求简单
- 热点极多
- 追求极低开销
### 12.5 原生内存包装池
支持包装:
- `NativeArray`
- `NativeList`
- `UnsafeList`
但要求:
- 主线程与 Job 边界清晰
- 回收前必须完成 Job 依赖
- 诊断系统记录 owner 和 frame fence
### 12.6 异步资源实例池
例如 Addressables
- `Create` 可能异步
- `Return` 后不能立刻销毁
- 实例可能依赖引用计数
对此引入 `AsyncPoolAdapter<T>`
- 支持 `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<T>
{
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<T>`
- `ConcurrentPool<T>`
原因:
- 主线程池要极致轻量
- 并发池要接受额外同步成本
### 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<T>`
- 直创建直销毁
指标:
- CPU
- GC Alloc
- Peak Memory
- P99 延迟
- Trim 尖峰
## 21. 推荐的最小落地版本
如果让我先做第一版,不会一次塞满所有高级功能,而是按下面顺序:
### Phase 1
- `MainThreadPool<T>`
- 值类型句柄
- 固定数组槽位
- 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` 旁边给你起一版可落地的代码骨架。