资源不是对象:为什么大型引擎最终都会走向 Handle
很多程序员最开始设计资源系统时,都会采用类似的写法:
Texture* texture;
Mesh* mesh;
Material* material;
这本质上是资源即对象的思想。
事实上,在项目早期,这种设计通常运行得很好。通常,在一个项目刚刚做起来的时候,这种编写清晰而简单的设计的感受让人愉悦,可问题出现在项目膨胀之后。
指针看起来简单
Texture* texture = load_texture("wall.png");
renderer.draw(texture);
访问成本很低,并且逻辑直观,不需要额外查表、资源管理器、间接层,也就是说,查询一个资源就是资源本身的事情,不需要耦合一大堆 Context、Manager。
这个做法在早期非常好用,甚至用不上 C++,C 语言就可以实现;性能上,想要更快只能把整个资源放到栈上,或者手动写出比编译器更优秀的汇编代码,当然,这是几乎不可能的。
第一个问题:热重载
假设纹理允许被热更新,系统检测到文件变化。
你可能会想,直接改即可。
问题来了,如果直接改,我们需要处理访问冲突问题:同一个资源是很有可能被不同线程使用的。我们很容易想到:用读写锁,理论可行,但新的问题出现了:一些资源访问周期可能非常长,比如纹理操作,无论是上传资源、解码文件,都是重大耗时工作,如果我们在热路径上需要等待可能漫长的解锁,代码的效率会变得极其低下,接近于甚至是不如串行。
于是,我们需要采取RCU的思想,在修改资源的时候,不立即销毁旧资源,而是另行创建一份,让每一次不可分割事务的texture访问到最新的资源,而在确认资源不被引用后安全销毁。
看起来就像这样:
texture = reload_texture();
第二个问题:Streaming
现代引擎不会让所有资源永久驻留内存。
例如:
玩家在城市,森林资源不需要加载。
进入森林后:城市资源卸载,森林资源加载。
此时资源地址可能发生变化,而指针天然绑定具体地址,地址变化意味着所有引用失效。
我们的指针炸了,你可能会想到这么写:
struct TextureImplement {
TextureMetadata metadata;
FileHandle fileHandle; // nullable
HostMemoryHandle hostMemoryHandle; // nullable
DeviceMemoryHandle deviceMemoryHandle; // nullable
}
typedef TextureImplement* Texture;
非常好,你发明了 Vulkan 式 Handle,不过实际写法会更复杂。
第三个问题:GPU 资源重建
对于 Vulkan 或 D3D12 等具体 Backend。
资源并不总是永久存在。
例如:
- Swapchain 重建
- Device Lost
- 显存迁移
- Streaming Upload
这些操作都可能导致底层资源被重新创建。
从系统角度看,这是同一个纹理;从内存角度看,这是新的对象;指针无法表达这种关系。
所以,我们在上一个问题的代码当中添加了 nullable 标签。
第四个问题:资源生命周期不属于调用者
这是最关键的问题。
对于普通对象:
Foo foo;
创建者负责销毁,生命周期非常明确,明确得可以跟 Rust 坐一桌。
而资源通常不同。例如: Texture 可能同时被:
- Renderer
- Material
- Scene
- Streaming System
引用。
所以我们需要一个 Table 统一管理这些 Texture 。
既然有 Table 了,要指针干什么呢?有下标就够了,不需要指针,而且从 Table 绕一层,方便管理。
你重新发明了 Handle。
此时资源已经脱离对象模型,而成为了系统状态。
Handle 的出现
于是很多大型系统开始采用另一种方式:
TextureID texture;
Handle 不直接指向资源,而只负责标识资源。例如:
struct TextureHandle {
uint32_t index;
};
真正的资源存放在 ResourceTable 中,于是结构变成:
Handle -> Resource Table -> Texture
Handle 真正解决了什么
很多人把 Handle 的意义理解为指针,尤其是看了 Windows 和 Vulkan 的 API 之后,因为这两家的 Handle 的确是以指针实现的(这种做法大量存在,因为 Batch 化对于它们没有任何意义,其本身高适应性的需求也无法写出万用的 Table 模型),但是其实际原理远远不是 C++ 的 pointer to class 那么简单。
Windows 不公布源码,我们无从得知其实现,只能反汇编推断,但是我们可以获取 vulkan 的开源 driver。
Handle 的意义是引入一层间接性,这层间接性允许系统修改资源,而不影响外部引用。
例如,Texture A 被重新加载,只需要更新 Table[index] 即可。
所有 Handle 自动获得新资源,而无需修改调用方。
Generation Counter
新的问题又出现了。
假设:
Handle(index = 5)
对应 Texture A。
随后,Texture A 被销毁,资源槽位被复用,新的 Texture B 再次占用:
index = 5
此时旧 Handle 会错误指向新资源。这就是大名鼎鼎的 ABA 问题
ABA Problem
于是进一步演化:
struct TextureHandle {
uint32_t index;
uint32_t generation;
};
访问时验证:
handle.generation == table[index].generation
不一致直接判定失效。
最终形态
经过多年演化,许多大型引擎都会形成类似结构:
TextureID -> Resource Manager -> Resource Table -> Texture -> BackendAPI
用户永远不会直接接触底层资源。
资源可以:
- 热重载
- Streaming
- 延迟销毁
- 异步创建
- GPU重建
而 Handle 始终保持稳定。
资源不是对象
很多工程师第一次接触 Handle 时都会觉得:为什么不直接传指针?因为他们仍然把资源看作对象。
而大型系统最终会发现:
- 对象属于某个作用域
- 资源属于整个系统
- 对象强调拥有权
- 资源强调身份
Handle 的本质并不是指针替代品,而是一种身份标识。
当资源开始拥有独立于对象的生命周期时, Handle 几乎会自然出现。
这也是为什么游戏引擎、数据库、浏览器甚至操作系统内核,都在不同程度上使用 Handle 体系。
因为对于大型系统而言:资源最重要的属性不是它在哪里,而是它是谁。
当然,访问一个对象还得同时传入 Manager 和 ID 的编码风格,还是需要时间适应的


