资源的内存管理及多线程预读

 

网络游戏的 client 开发中,很重要的一块就是资源管理。游戏引擎的好坏在此高下立现。这方面我做过许多研究和一些尝试。近年写的 blog 中,已有两篇关于这个话题的:基于垃圾回收的资源管理动态加载资源

最近在重构引擎,再次考虑这一个模块的设计时,又有了一些不算新的想法。今天写了一天程序,一半时间在考虑接口的设计,头文件改了又改。最终决定把想到的东西在这里写出来,算是对自己思考过程的一个梳理。

如今的 PC 网络游戏早就今非昔比。为了满足玩家感官上的需要,以及机器性能的提升,整个游戏 client 的数据资源已经开始论 G 计算。在 64 位操作系统普及之前,Windows 下那 2G 用户可用地址空间开始有点相形见绌了。换句话说,无论你多么不重视资源的内存管理,原来用过的最苯最有效的方案:只加载不释放资源的做法,开始不太适用了。设计引擎的程序员必须考虑适时的把一些已读入内存的数据清出内存,并在需要的时候再读回来。当然,如何有效的管理内存也是我这些年做程序员一贯的课题,今天的局面只是逼更多的人跟我一起来研究解决方案 :D

到了 3d 技术普及的今天,3d 游戏的资源面临另一个问题:资源的交叉引用。例如:一张贴图可能被好几个模型引用,但是我们只需要在内存中保留一个拷贝。这种引用关系有可能错综复杂,在场景的构建上尤其突出。一旦我们需要实现无缝连接的超大场景,每个地图区域上物体之间的都会发现直接或间接的关联关系。房子的旁边的树,树的旁边是房子,人物在场景中行动,不停的有新的资源被请求使用,也会有一些资源可以暂时清出内存。

一开始我希望用 gc 技术来简化这个问题的解决方案,经过一段时间后,目前的思路有所改变。我希望可以在内存里保留所有资源的数据头(即部分数据),而在不需要完整数据的时候,将资源的一部分数据卸载。资源数据在游戏开发期就把关联数据构建好,这样所有的资源数据的头信息会一点点加载进内存,完善整个游戏所有资源之间的关系网。在管理这些数据的时候,即不需要引用记数又不需要 gc 链。以目前的系统结构,内存消耗也是可控的。

在软件运行中,会有大量的内存分配请求不再被回收(除了进程结束),我们可以专门写一个特别的内存分配器做这件事情。实现的算法非常简单:递增堆指针,并在堆不够的时候向系统申请新的内存页,足够了。

下面来看一下资源的动态加载方案:

显然,多线程读取资源是首选,我们也不排斥单线程方案。另外有些资源是动态计算出来,或是并不从硬盘加载。所以我们需要同时支持多个加载器。加载器这个东西必须被抽象出来方便后期对引擎的二次开发。资源管理的框架不需要干涉加载的细节,也就是说,它不能有任何线程相关的代码,但需要为异步加载提供接口上的方便。

引擎使用者应该了解的细节越少越好。他只需要通知引擎准备加载什么资源。这通常用字符串来定位,或者通过关联信息。当然,有时候也需要一些额外的请求,比如在内存拮据的时候通知资源管理模块回收适当的内存。

对于加载器开发者,我们需要暴露更多的内部信息,但不是无条件的全部公开。加载器不需要了解管理器的内存数据结构,甚至不必看到资源数据节点上的数据结构。它只需要提供函数供回调加载数据就够了。不过因为我们需要支持异步加载的可能,所以在回调函数的接口设计上,需要传递一个类似状态机的东西,可以分步加载数据。

定下这些需求,下面可以着手设计了。

我希望资源数据被放在硬盘上的假想的数据库里,通常这个数据库就用本地文件系统模拟。或是在发布时打包成一个数据包。我可以用分段的字符串的形式定位数据库中的具体资源;但不需要每个资源都必须有字符串路径,匿名的资源只以唯一数字 id 的形式放在数据库内,这个数字 id 可以存在于别的资源的关联信息中。例如大部分贴图文件就不需要有名字,它们只会被模型引用,而不会由引擎直接加载。

资源的加载通常分两个阶段,数据头加载和全部数据加载。数据头很小,可以常驻内存。通过数据头可以得到数据的尺寸大小信息。这个信息可以帮助管理器的决策。另外,关于匿名资源的加载还会多一个阶段,从 id 映射到数据本身。(对于具名资源,文件头加载完毕后,对应 id 就自然获得)

我设计了一个树结构来保存具名资源,每个具名资源都可以通过类似 URL 的字符串形式得到,并在第一次请求时立刻把文件头加载进内存保存在这个树结构里。这棵数的每个枝干都有一个字符串名字。btw, 为了提高效率,不要使用 string 作 key 的字典结构。因为所有可能出现的字符串是非常有限的,我们可以设计一个字符串池,内存以 hash 表形式组织。这个字符串池也只只增加而不释放的。这样,每个做 key 的 string 都会有一个全局唯一的指针来表示了。这样做可以提高许多树检索的效率,并减少内存消耗。

另外,再用一个 hash 表来保存所有资源 id 的映射。

每个资源都会有一个小结构常驻内存,所以以上两个结构中都可以直接用指针对资源结构做引用。甚至不需要引用计数。这些资源结构我用链表串将起来,每次加载请求,如果内存中不存在,就创建新的节点,并插入链表头部。

加载请求是不会立刻要求加载完整资源数据的。在主逻辑线程运行的间隙,我们可以适量的从这个链表的逐个节点上取得可能的关联资源并做新的加载请求。这部分通常并不需要多线程工作,因为数据头的加载通常非常的快,并且可控,不会影响玩家的操作感。

当程序真正需要用这些资源的数据时,可以通过资源管理模块向加载器发起请求,要求立即加载数据。这时候除了满足加载请求完,还需要把资源本身调整到链表尾部,表示其刚被使用,不要立刻回收。

当内存拮据的时候,我们就可以从链表头部开始一点点回收内存了。

这里,加载器可以做成多线程的工作方式,并且 lock-free 。不过要做到真的 lock-free ,有一些先决条件:首先,我们的内存分配器不设定锁,那么数据加载线程就不得自行分配内存。这一点比较容易实现。因为一旦得到资源的数据头,就能准确计算出整个资源消耗的内存量,可以一次分配出来。而资源数据加载的时候,只在这块已申请内存块中做切割,不会有任何副作用了。

数据加载线程序可以不断的去完成队列中的数据加载请求,一旦加载完毕,就在资源结构中做好标记。而主线程收到数据使用请求时,检查到标记后直接把完整的数据指针复制过来即可。如果没有完成,则自行申请另一块内存,阻塞做数据加载。这样是为了避免冲突,当然也有可能浪费,两条线程恰巧加载同一份资源。由于单个资源体积都不大,这点时间浪费是可以接受的,而空间在数据回收阶段可以完全收回。

ps. 真正用到多线程的项目,我只写过一个。那就是大话西游里的地图动态加载模块。01 年的时候,为了实现这个模块,整整调试了一个月才稳定下来。那是第一次写多线程程序,真是往事不堪回首。做梦都会遇到程序 crash 。后来大话西游II 以及梦幻西游都重写了 client ,就是这个模块无人敢动,一直保留到现在。也算是经受了千万用户的考验了。最后的效果虽然令人满意,玩家可以享受到无缝的场景跳转体验,并且 client 只用了 16M 内存就做到了这一点(完整的地图资源如今已经上 G 了);但是我自己知道,那段代码是极其缺少美感。

至此之后,我不轻易用多线程的设计。我想,只有真正免锁的多线程程序才会显得漂亮吧。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值