回顾与今天的计划
我们在完成一款游戏的制作。这个游戏没有使用任何引擎或新库,而是从零开始编写的完整游戏代码库,您可以自行编译它,并且它是一个完整的游戏。更特别的是,这个游戏甚至没有使用显卡,所有的渲染工作都由我们自己处理,虽然听起来有些疯狂。
但实际上,不那么疯狂的是我们的资源加载系统。昨天我们做了一些工作,需要继续完善。今天的任务是完成昨天没有做完的部分,主要是为资源加载系统提供基础功能,保持资源系统中内存的稳定使用,同时确保游戏运行流畅。
昨天我给大家展示了如何将操作系统作为内存管理的工具,我们展示了这个过程,但有一个关键点需要完成,而昨天因为时间不够没有演示完。所以今天我们需要继续完善这个部分。
防止被锁定的资源(那些用于后台任务的资源)被驱逐
目前,尽管我们的系统在技术上已经基本完成,但仍然存在一个关键的漏洞。我们已经预见到这个问题,并且有一个解决方案,但实际上我们还没有完全实现这个解决方案。
这个问题出现在资源加载到系统中的时候。我们现在希望控制资源使用的内存量,也就是说,加载的资源必须遵守一定的内存限制。这意味着,加载的资源可能会被卸载。
具体来说,当一个资源被加载进系统时,它的状态会被标记为“已加载”。然而,在每一帧的结束时,某个资源可能会被卸载,以腾出空间加载新的资源。这对正常的渲染工作没有问题,但如果有一些后台任务在运行并且这些任务正在进行渲染(例如,我们的地面区块),那么就需要确保这些任务正在使用的资源不会被卸载。如果这些资源被卸载,后台任务的代码就会崩溃,因为它们会试图使用一个已经不存在的资源。
为了避免这种情况,我们需要知道某个资源是否还存在,同时还需要判断它是否正在被后台任务使用。因此,我们决定使用“加载”和“锁定”这两个标志来标记资源的状态。资源“加载”表示资源已经加载,但如果没有后台任务在使用它,它可能会被卸载。而“锁定”表示资源正在被某个后台任务使用,因此不应该被卸载。
接下来,我们将开始处理这个“锁定”标志,确保它能够有效地管理资源的卸载问题。
我们从不锁定任何资源;让我们锁定一些
目前的问题在于,我们从未真正将任何资产设置为锁定(locked)状态,而这正是导致漏洞的原因。如果查看代码,可以发现这一操作实际上从未发生。因此,在某些情况下,当资产进入evict asset(资产驱逐)流程时,需要确保只有**已加载(loaded)状态的资产才会进入这一流程,而这一点已经在代码中通过断言(assert)**进行了检查,以防止尝试卸载未加载的资产。
接下来的目标是确保正确使用锁定(locked)状态,避免资产在被后台任务使用时被错误地回收。在代码中,可以找到多个调用AddAssetHeaderToList(将资产头部添加到列表)的地方,任何调用这个函数的代码都应该确保,最终的资产状态只能是已加载(loaded),而不能是已锁定(locked)。如果某个资产已经被锁定,那么当前的处理方式就不适用了,因此需要增加逻辑来检查资产是否可以被锁定。
在调用load bitmap(加载位图)时,需要引入对锁定状态的处理。如果资产应该被锁定,则应设置为已锁定(locked),否则仍然保持已加载(loaded)。这一改动需要在多个地方进行,包括添加资产头部到列表的地方,以及资产状态的初始化过程中。因此,需要确保:
- 如果资产是锁定状态(locked),则不执行驱逐(evict asset)操作。
- 在资产加载时,根据情况决定将其设置为“已加载”还是“已锁定”。
此外,在load bitmap(加载位图)函数内部,需要引入对锁定机制的支持,虽然目前的实现方式可能不够整洁,但可以先逐步优化。在此过程中,也检查了handmade assets load calls(手动资产加载调用),发现了一些资产加载逻辑的存放位置,但具体作用暂时不太明确。此外,还检查了prefecture(预取机制)的使用情况,发现它用于声音(sound)的预加载,但未用于位图(bitmap),因此在位图的加载流程中需要单独处理锁定逻辑。
最终目标是确保后台任务正在使用的资产不会被错误地卸载,而当前正在调整的代码是第一步,为后续进一步优化和完善锁定机制提供基础。
不锁定声音
在当前的逻辑下,实际上**声音(sound)资源并不需要锁定(locked)。由于声音资源在加载过程中不会出现与位图(bitmap)相同的问题,因此它们始终可以保持未锁定(unlocked)状态。这意味着,不需要对声音资源应用锁定机制,只需要处理位图资源(bitmap)**的锁定逻辑即可。
此外,预取(prefetch)机制中的资源同样不适用于锁定。任何通过预取加载的资源都不应被锁定,因此锁定机制仅适用于位图加载。
基于以上分析,锁定机制的应用范围将严格限定在位图加载(bitmap loads)。为了保持代码的完整性,我们需要明确区分哪些资源需要锁定,哪些不需要,并确保所有声音资源始终处于未锁定状态。
当前的调整思路如下:
- 声音资源不需要锁定,可以直接忽略锁定逻辑。
- 预取资源不适用于锁定机制,同样无需考虑锁定问题。
- 仅位图资源需要锁定,当它们被后台任务使用时,才会进入“已锁定(locked)”状态,避免被错误回收。
因此,接下来的调整会专注于位图的加载,确保load bitmap(加载位图)和push bitmap(推送位图)逻辑正确应用了锁定机制,并保证代码逻辑清晰,避免不必要的复杂性。
渲染组知道一个资源是否应该被锁定
当前的目标是让**渲染组(render group)**本身具备判断资产是否需要被锁定(locked)的能力,而不是依赖调用方(caller)来确保正确处理锁定逻辑。这样可以减少潜在的错误,使锁定逻辑更加安全和稳定。
为了实现这一点,需要引入一种机制,使得渲染组可以自主判断是否应该对某个资产进行锁定。这样,在进行渲染时,代码可以自动确保后台任务所依赖的资产不会被错误地卸载,而不必完全依赖调用方手动管理锁定状态。
目前的思路包括:
- 渲染组(render group)本身存储资产的锁定状态,确保所有渲染过程中使用的资产不会被回收。
- 不再完全信任调用方(caller)处理锁定逻辑,而是让渲染组内部管理资产的锁定与解锁。
- 引入断言(assert)机制,确保代码逻辑能够正确执行,并防止错误的锁定或解锁操作。
通过这种方式,渲染过程可以更加稳健,避免因资产错误回收而导致的崩溃问题。同时,这种方法也有助于简化代码,使得锁定逻辑更加清晰和可维护。
防止调用者犯错
为了确保资产管理的正确性,需要在渲染过程中强制执行**资产锁定(locked)的规则,并引入断言(assert)**来防止意外的逻辑错误。
当前的思路是:
- 渲染组(render group)本身需要知道资产是否应该被锁定,而不是依赖调用方(caller)来做决定。
- 避免人为错误,通过系统设计使得调用方无法轻易误用资产锁定机制。
- 在渲染组初始化时,增加“资产是否应该被锁定”的标志位,确保整个渲染流程中的资产锁定规则始终一致。
- 在“获取位图(GetBitmap)”操作中,检查资产状态,并通过断言(assert)验证其正确性:
- 如果当前渲染任务要求资产必须被锁定,那么资产的状态必须为已锁定(locked)。
- 如果资产本身没有要求被锁定,那么它可以处于**已加载(loaded)**状态。
- 通过断言,确保这两种情况之一必须成立,否则代码会触发错误提示,防止错误传播。
具体实现方式:
- 在渲染组初始化时(AllocateRenderGroup),增加一个“资产是否应该锁定(AssetsShouldBeLocked)”的参数,使得渲染任务在执行时自动遵循正确的资产锁定规则。
- 在获取位图(GetBitmap)时,检查资产的当前状态:
- 如果渲染组要求锁定资产,则资产必须处于“已锁定(locked)”状态,否则触发断言错误。
- 如果渲染组不要求锁定资产,则允许资产处于“已加载(loaded)”状态。
- 在填充地表块(FillGroundChunk)时,明确规定这些资产必须锁定,而在游戏渲染时,则可以不锁定资产。
- 检查“添加到资产列表”逻辑,确保不会错误地将已锁定的资产加入到普通的**已加载(loaded)**列表中,以防止状态管理错误。
发现的问题:
在代码调试过程中,发现某些资产的状态异常,导致本应**已锁定(locked)的资产被意外添加到了已加载(loaded)**列表中。这可能意味着某些逻辑路径允许了不符合预期的状态转换,因此需要:
- 重新检查**加载位图(load bitmap)的逻辑,确保如果资产需要被锁定,它不会被错误地添加到已加载(loaded)**的资产列表中。
- 通过断言(assert)进一步检查在添加到列表时的资产状态,防止**已锁定(locked)**的资产被误加入普通列表。
最终目标是让整个系统自动管理**资产锁定(locked)**规则,而不会因为手动管理失误而导致崩溃或错误加载资产的问题。
防止在资源完全加载之前将其驱逐
在当前的资产管理系统中,存在一个潜在的问题,即资产在被加入列表(list)时,可能尚未完全加载,但在某些情况下,系统可能会尝试释放(evict)这些尚未加载的资产。这种情况虽然极端,但仍然需要考虑并加以防范。
发现的问题
-
资产被加入列表时,可能尚未完成加载(loaded)
- 由于当前的实现是单线程的(single-threaded),在将资产添加到列表时,它们的状态可能仍然处于**未加载(not loaded)**状态。
- 这样,在系统尝试释放最早使用的资产时,可能会意外地选择了一个尚未加载完成的资产,导致逻辑上的不一致。
-
在执行资产回收(evict asset)时,无法保证被选中的资产已加载
- 现有的逻辑在选择要释放的资产时,会从列表中挑选最早使用的资产(Least Recently Used, LRU)。
- 但如果这个资产尚未加载完成,那么系统仍然会尝试释放它,导致不可预料的问题。
解决方案
-
在释放(evict)资产时,确保所选资产必须已经加载(loaded)
- 在执行资产回收时,首先检查该资产是否已经加载。
- 如果资产尚未加载,则跳过当前资产,并等待下一个帧(frame)再重新检查。
- 这样可以确保不会错误地释放仍在加载中的资产。
-
优化
evict asset
逻辑- 通过迭代检查(iterate over the list),确保挑选出来的资产是已经完成加载的。
- 如果在列表中找不到符合条件的资产,则跳过当前帧的回收操作,等待下一个帧处理。
-
修改
evict asset
代码,确保不会错误释放未加载的资产- 通过**获取资产状态(get state)**来判断资产是否已经加载完成。
- 只有在资产状态明确为**已加载(loaded)**时,才执行释放操作。
- 如果发现当前选择的资产尚未加载,则直接跳过,并在下一帧重新评估。
-
调整
slot
变量的使用方式- 由于
slot
变量在多个地方使用,建议直接传递 slot,而不是重复计算,以提高代码可读性和维护性。 - 避免错误使用
asset
变量,实际应使用header
,因为这里涉及的是资产的头部信息(header)而非资产本身。
- 由于
最终目标
- 确保资产在被释放(evict)时,一定已经完成加载(loaded)。
- 防止系统在错误的时间点释放错误的资产,导致渲染或任务崩溃。
- 提高资产管理的稳定性,使得资产加载、使用和回收的逻辑更加健壮和可维护。
测试更改
测试之前先改一下内存大小
出现一直无线循环
修复Bug之后
目前的测试结果表明,系统的资产管理逻辑已经按照预期运行。在设定了一个极低的资产加载阈值(threshold)后,系统只能在单帧(single frame)内存储资产,这导致了资产在不同帧之间不断被加载和释放,从而产生明显的闪烁效果(flashing)。
当前状态分析
-
资产的动态管理
- 由于加载阈值被设定得极低,系统在每一帧都需要重新加载和释放资产,导致屏幕上出现闪烁效果。
- 这种行为是符合预期的,说明资产管理系统能够正确地处理加载和回收的逻辑。
-
验证资产锁定机制的有效性
- 在该测试条件下,如果存在问题,可能会导致资产在后台任务使用时被错误地释放,从而引发崩溃或渲染错误。
- 但目前没有出现此类问题,说明**“锁定(locked)”机制能够正常工作,确保被后台任务使用的资产不会被错误回收**。
-
持续优化与收尾工作
- 现有系统的资产加载、使用和释放逻辑已经基本完善。
- 但仍然需要进一步检查代码,确保所有边界情况都被正确处理,以提高系统的稳定性和可靠性。
- 由于当前的测试环境是极端情况(每帧都在回收资产),实际运行时可以适当提高资产加载阈值,以减少不必要的重新加载,提高运行效率。
下一步行动
- 完成代码的细节调整,确保所有变量和函数的逻辑清晰合理。
- 优化资产加载策略,避免不必要的频繁加载,提高系统性能。
- 进行更多测试,模拟不同负载情况,观察系统在各种场景下的稳定性。
- 最终收尾,确保资产管理系统可以在不同的使用环境下稳定运行。
将最近使用的资源移到链表的前面
资产访问时更新最近使用顺序
目前的资产管理系统在访问**位图(bitmap)或音效(sound)**时,并不会将其移动到使用列表的前端。这意味着,最少最近使用(LRU,Least Recently Used)的判断可能不准确,从而影响资产管理的正确性。因此,需要在每次访问资产时,将该资产对应的内存头部(memory header)移动到列表的前端,确保资产的最近使用顺序是正确的。
当前问题与解决方案
-
现状分析
- 资产管理系统通过**链表(linked list)**维护已加载资产的顺序。
- 但在调用
GetBitmap
或GetSound
时,并不会调整顺序。 - 这导致某些频繁使用的资产仍可能被错误回收,因为链表的顺序并未正确反映访问频率。
-
目标
- 每次访问资产后,将该资产的**头部(header)**移动到链表的前端,使其成为最近使用的资产。
- 这样,在执行**资产回收(eviction)**时,最久未使用的资产才会被正确回收,而不是某个仍然被频繁使用的资产。
-
实现方式
- 定义
MoveHeaderToFront
函数,用于将资产头部移动到链表的前端。 - 修改
GetBitmap
/GetSound
,在访问资产时调用MoveHeaderToFront
,确保最近使用的资产被正确排列。 - 调整链表结构,通过重新设定前后指针来移动资产头部。
- 定义
代码实现逻辑
-
获取资产头部
- 从
AssetSlot
反向获取AssetMemoryHeader
,以便操作其在链表中的位置。 - 由于
AssetSlot
结构设计较为复杂,获取AssetMemoryHeader
可能较为麻烦,但可以通过SlotIndex
计算其位置。
- 从
-
移除资产头部
- 通过调整前后指针(prev/next),将该资产从当前链表位置中移除。
- 使前一个节点的
Next
指向后一个节点,后一个节点的Prev
指向前一个节点,让该节点脱离链表。
-
重新插入到链表前端
- 设定新的前后关系,将资产头部插入到链表的前端(紧跟在**哨兵节点(sentinel)**之后)。
- 让
LoadedAssetsSentinel
变成新插入节点的Prev
,原来的链表头成为Next
,完成插入操作。
代码设计
// 将指定的资产头部移动到已加载资产链表的前端
internal void MoveHeaderToFront(game_assets *Assets, asset_slot *Slot) {
// 获取该 slot 对应的资产内存头部
asset_memory_header *Header = ? ; // 需要找到 Slot 对应的 Header
// 1. 先将 Header 从当前链表位置移除
// 让 Header 的下一个节点的 Prev 指向 Header 的 Prev
Header->Next->Prev = Header->Prev;
// 让 Header 的前一个节点的 Next 指向 Header 的 Next
Header->Prev->Next = Header->Next;
// 2. 将 Header 重新插入到链表的前端(紧跟在 LoadedAssetSentinel 之后)
Header->Prev = &Assets->LoadedAssetSentinel; // 设置 Header 的前驱为链表哨兵节点
Header->Next = Assets->LoadedAssetSentinel.Next; // 设置 Header 的后继为当前链表的第一个节点
// 3. 重新链接新位置的前后指针
Header->Prev->Next = Header; // 让哨兵节点的 Next 指向 Header
Header->Next->Prev = Header; // 让原本的第一个节点的 Prev 指向 Header
}
进一步优化
当前的方法使用双向链表(double linked list)进行最近使用更新,但链表操作的时间复杂度为 O(1),适用于小规模数据结构。如果需要优化,可以考虑:
- 使用 哈希表(hash map)+ 双向链表,实现更高效的 LRU 逻辑,类似 LRU 缓存 的实现方式。
- 使用优先队列(heap) 维护最少最近使用的资产,提高查询效率。
- 批量更新访问顺序,而不是在每次访问时都调整链表,减少不必要的指针操作。
最终结论
- 当前方案:每次访问资产后,通过
MoveHeaderToFront
调整链表顺序,保证最少最近使用(LRU)机制正确。 - 代码逻辑:从
AssetSlot
获取AssetMemoryHeader
,调整前后指针,并重新插入到链表前端。 - 优化方向:若性能存在瓶颈,可考虑使用哈希表 + 链表或优先队列来提高效率。
通过这一调整,可以确保资产管理系统正确维护最近使用的资产顺序,避免错误回收仍在使用的资源,提高整体系统的稳定性和性能。
从槽位中获取头部很棘手
在当前的代码逻辑中,我们遇到了一个问题,那就是如何从 slot
获取 header
,这实际上是一个比较棘手的问题。如果回顾之前的实现方式,可以发现它使用了一种较为复杂的方式来处理这个问题。因此,我们需要找到一种更直接的方法来实现这一目标。
思路调整
既然 AddAssetHeaderToList
始终以相同的方式添加资产头部,我们可以直接调用它,而不必关心 slot
到 header
的映射方式。因此,我们可以创建一个 InsertAssetHeaderAtFront
函数,该函数的逻辑与 AddAssetHeaderToList
相同,从而避免重复代码。
inline void InsertAssetHeaderAtFront(game_assets *Assets, asset_memory_header *Header) {
asset_memory_header *Sentinel = &Assets->LoadedAssetSentinel;
Header->Prev = Sentinel;
Header->Next = Sentinel->Next;
Header->Next->Prev = Header;
Header->Prev->Next = Header;
}
这样,我们可以在 MoveHeaderToFront
中调用 InsertAssetHeaderAtFront
,而不必额外处理 SlotIndex
,从而简化逻辑。
如何正确获取 Header
问题的关键在于如何获取 header
。在当前的代码架构下,这并不是直接可行的。我们需要确保:
- 我们能够从
slot
确定header
。 - 数据结构的组织方式便于查找
header
。 - 操作逻辑尽可能简单,避免冗余计算。
我们目前的方式是:
- 通过
GetSizeOfAsset
获取资产大小 - 通过
GetAssetType
获取资产类型 - 进而确定
header
所在的内存位置
但这种方式较为繁琐,并且 GetSizeOfAsset
依赖 slot index
,所以可能并不是最优解。
优化点
- 调整数据存储结构,使得
header
可以更直接地被访问。 - 在
slot
结构中直接存储header
指针,避免每次都重新计算内存位置。 - 在
MoveHeaderToFront
里,直接使用slot->Header
,从而避免额外查找。
代码调整
在 MoveHeaderToFront
里,我们可以这样做:
inline void MoveHeaderToFront(game_assets *Assets, uint32 SlotIndex, asset_slot *Slot) {
asset_memory_size Size = GetSizeOfAsset(Assets, GetType(Slot), SlotIndex);
void *Memory = 0;
if (GetType(Slot) == AssetState_Bitmap) {
Memory = Slot->Bitmap.Memory;
} else {
Assert(GetType(Slot) == AssetState_Sound);
Memory = Slot->Sound.Samples[0];
}
asset_memory_header *Header = (asset_memory_header *)((uint8 *)Memory + Size.Data);
RemoveAssetHeaderFromList(Header);
InsertAssetHeaderAtFront(Assets, Header);
}
这样,我们不再需要在 MoveHeaderToFront
里计算 header
的位置,而是直接利用 slot->Header
。这需要保证 asset_slot
结构包含 header
指针,并在创建 slot
时正确赋值。
其他改进
- 修改
GetSizeOfAsset
使其直接接受asset
,而不是slot index
,减少不必要的索引查找。 - 对
MoveHeaderToFront
的调用进行优化,使其尽可能在必要的地方调用,而非在每次访问时都调用,减少不必要的链表操作。 - 调整
bitmap
和sound
的存储方式,使得header
访问更加直接,而不需要switch
判断类型再查找对应的内存地址。
总结
目前的主要问题是:
header
的获取方式过于复杂,导致代码逻辑冗余。- 资产类型不同,导致
header
的访问方式变得繁琐。 MoveHeaderToFront
操作复杂,需要调整数据结构以简化访问方式。
通过上述优化,我们可以使得 header
访问更加直接,减少不必要的计算和索引查找,从而提高代码的可读性和执行效率。
MoveHeaderToFront 只应影响已加载的位图
在当前的代码逻辑中,MoveHeaderToFront
只适用于已加载的位图(bitmaps),因为其他类型的资源实际上并没有在链表中。因此,只有加载的位图会被处理,其它未加载的资源不会受到影响。这种处理方式存在一定的问题,因为理论上即使资源尚未加载,也应该能够访问它们的 header
,但现有逻辑并没有考虑未加载资源的情况。
为了解决这个问题,应该在调用 MoveHeaderToFront
时,额外检查资源的加载状态。可以通过 get_state
方法来获取 slot
的状态,确保只有加载了的资源(即状态为已加载的资源)才会被移到链表的前面。如果资源尚未加载或被锁定,则不应该进行此操作。
因此,改进的步骤是:
- 检查资源的加载状态:在调用
MoveHeaderToFront
时,首先确认slot
的状态是“已加载”或者“未锁定”。 - 确保未加载的资源不被处理:对于未加载或锁定的资源,跳过
MoveHeaderToFront
操作。 - 确保
slot
的状态与资源状态一致:通过get_state
或类似函数,保证资源的状态是正确的,避免处理那些不应该操作的资源。
这种修改可以避免不必要的错误,确保只有正确状态下的资源才会被移到链表的前面,优化资源管理的效率。
决定清理已加载资源的代码
当前的代码逻辑变得越来越混乱,已经到了需要清理的阶段。问题不仅仅是结构凌乱,还包括一些隐藏的潜在问题。例如,当资源第一次被插入时,实际上我们并不知道资源是否被“锁定”,而“锁定”只是一个标志,这让代码的管理变得更加复杂。由于这个标志没有被明确管理或检查,导致在处理资源时,可能会出现无法预料的情况。
为了改进,应该对代码进行重构和清理,尤其是在插入资源时,明确管理资源的状态(如是否被锁定),避免在错误的情况下进行不必要的操作。这种重构不仅能简化代码,也有助于提高代码的可维护性和效率。
“锁定”作为一个标志,独立于资源加载阶段
目前代码中的一个问题是关于“锁定”状态的管理。具体来说,资源的状态不应该仅仅由是否加载来决定是否被锁定。锁定状态和加载状态是两个独立的标志,资源在加载时应该立即设置为锁定状态,而无论它是否已加载。锁定的状态与资源的其他状态无关,资源即使在队列中未加载,仍然是锁定的。
为了简化和明确这一点,建议将资源的状态(如锁定)独立出来,作为一个标志,而不再和加载状态捆绑在一起。每个资源应该有一个“是否被锁定”的标志,不论它是否已经加载。这样一来,所有操作都会基于这个标志来判断资源的状态,避免出现复杂的逻辑判断。
在代码中,当尝试加载一个位图时,资源的状态应该被立即设置为“锁定”。在之后的操作中,我们只需要检查资源是否被锁定,并且只有那些锁定的资源才会被移动到前端或参与其他操作。这样就避免了重复检查和不必要的复杂逻辑。
另外,代码中还有一些待清理的部分,虽然功能上已经能运行,但结构上还不够简洁。接下来需要进行一些重构,以确保资源管理更为高效和清晰。最终的目标是将这些资源的操作简化,使代码更加整洁和易于维护。
最后,关于如何合理地测试这些变化,问题在于资源的大小设置。如果资源大小设置得太小,可能会导致系统无法正常运行,进而需要频繁的分页;而如果设置得太大,则可能无法判断系统是否能正常处理资源的加载和锁定状态。因此,合理的资源大小配置是测试的重要一环,需要进行调优以确保能够充分验证这些修改。
检查 Windows 任务管理器中的游戏提交大小。
目前,系统依然存在频繁地分页操作,导致了游戏角色在转向时出现闪烁的现象。这是因为资源在内存中被反复分页进出,虽然在某些情况下可以有效地保持内存使用量,但也导致了不必要的性能开销。内存的使用需要进一步优化,以减少不必要的内存分页,并确保系统在高负荷时能够稳定运行。
从调试的角度来看,内存管理的情况还算不错。虽然内存使用有波动,但基本上能够保持在合适的范围内,确保系统不会因为内存不足而崩溃。然而,当前的情况仍然不够完美,还需要加载更多资源并进一步调整内存的使用策略。之后可以考虑进一步加载更多的资源进行测试,查看系统是否能够处理更多的资产,同时保持良好的性能。
当前的调试和测试还不够全面,许多操作只能通过外部观察来推测效果,缺乏详细的调试信息。为了更好地理解系统是否正常工作,需要增加一些调试代码,来实时监控资源的加载、分页和锁定等操作。这些调试信息可以帮助确认系统在实际运行中是否达到预期效果,并进一步分析哪些部分可能仍然存在问题。
总结来说,虽然系统在目前的状态下能够工作,但为了进一步提高其稳定性和性能,还需要进行更多的优化和调试,特别是在内存管理和资源加载方面。
简化已加载资源的基础架构
现在,我们需要简化现有的设计,因为目前在资产槽、资产和内存头之间存在太多复杂的操作。这个设计结构给系统带来了不必要的复杂性,需要重新整理和简化。为了让代码更加清晰和高效,必须采取一种更智能的方式来处理这些数据结构。
具体来看,我们拥有的数据包括:
- 数据偏移量:这个偏移量指示文件中数据的位置。
- 第一个签名和下一个签名:这些用于标识数据的范围或标签区间。
- 位图和声音的实际数据:这部分数据用于处理文件中的图像和声音资源。
除此之外,还需要存储一个文件索引,它实际上是文件集合中的一个远程索引,用来标识文件在集合中的位置。
通过简化这些数据结构,可以减少内存开销,同时不增加额外的内存负担。这意味着,我们可以通过优化现有数据的组织方式,使系统更加高效,减少不必要的复杂操作。在此过程中,不需要添加额外的内存来存储更多数据,而是通过重新组织现有数据来提高效率。
合并资源槽和资源
可以考虑将“资产槽的状态 asset_slot”和“文件索引”这两者合并,实际上,这样做并不会有太大的问题。虽然之前考虑过将它们分开,但现在似乎没有必要强行分开。这样合并之后,减少了一个需要关注的东西,也使得代码更简洁,虽然这种改动需要先做好评估,但如果之后需要再拆分,它也可以被轻松处理。
如果将“资产槽asset_slot”和“资产asset”合并成一个实体,那么整个代码中就不需要同时处理这两个对象。举个例子,可以将“资产槽”简单地合并为“资产”,这样所有涉及到“资产槽”的代码都只需要处理“资产”对象。这是一个简单且直接的变化,通过这样简化代码,可以让开发过程变得更高效。
具体来说,“资产槽”可以变成“资产”,然后把所有涉及到“资产槽”的变量、函数都直接改成“资产”。比如,之前的“资产槽”索引会变成“资产”索引。这样处理后,代码的可读性和一致性都会得到提升。
然而,需要注意的是,“资产内存头”目前仍然被存储在加载的“资产”中。如何处理这个内存头的问题,仍然需要进一步思考。合并后,可能会有一些需要特别关注的地方,但目前来看,这样的合并是一种合理的简化方式。
将所有必要的资源操作内容集中到资源头部
为了简化代码结构,可以考虑将“资产”与“资产内存头”合并为一个单独的对象。这样,资产本身可以持有一个指向资产内存头的指针,资产内存头包含了有关该资产的所有信息,比如资产的类型、位图和声音等数据。
首先,将“资产类型”和“资产内存头”合并,使得“资产”对象不仅包含资产的基本信息,还可以通过其内存头访问所有相关的资源数据。例如,资产的位图或声音数据就可以直接从内存头中获取,而无需分别管理这些信息。
接下来,考虑将“锁定状态”也包含在内存头中,而不是单独管理。这会使得代码更加清晰,避免多个地方去处理锁定的状态,集中管理会更方便。
合并后的设计中,资产的内存头负责处理所有关于资产的数据,而资产本身只是一个指向内存头的指针。这种设计使得资产与内存头的管理变得更加紧密和简洁。
在代码实现方面,首先要为每个资产分配内存并初始化内存头。内存头的分配可以通过 acquire asset memory
函数完成,确保每个资产都能正确地加载其数据。当需要操作位图或其他资源时,只需通过资产的内存头来访问相关数据。这样,内存管理变得更直接,避免了复杂的状态检查和内存操作。
这种做法的一个重要优势是,资产和资源的加载变得更加集中和易于理解。之前需要在多个地方管理和处理资源的情况现在可以合并为一个清晰的结构,减少了代码重复和潜在的错误点。
通过这种方式,代码变得更加简洁和清晰,不再需要管理多个不同的结构,而是将所有资源信息集中在资产内存头中,极大地简化了处理流程。
存储已加载资源的大小,而不是其类型
在这个设计中,如果不再关心资产类型,我们可以简化内存管理结构。通过仅存储资产的总大小(total size
),而不是存储类型信息,可以进一步简化代码。这样,我们只需要存储每个资产的总大小,而无需关心具体类型,从而减少了不必要的信息存储。
在处理资产内存头时,重点只放在保存总大小这一信息上。这意味着,当为资产分配内存时,我们只需要知道资产的总大小,而不需要处理类型等其他细节。此时,内存头中的数据结构将仅仅包含资产的总大小,而不再需要存储具体的类型或其他不必要的内容。
在具体的实现中,当资产被添加到列表中时,系统只需要将该资产的总大小保存下来,而不需要处理其他信息。这可以通过直接将“总大小”赋值给资产的内存头来实现。通过这种方式,资产的内存管理变得更加简洁,不再需要过多的操作。
这种设计方法的好处是,大大减少了需要维护的复杂信息,只保留了最必要的内存数据,从而使得代码更简洁和易于管理。通过消除对类型的依赖,资产管理变得更加灵活,减少了多余的存储和处理步骤。
一些调试
在此过程中,资产头(asset header)出现为 null
的问题发生在尝试访问资产位图时。问题的根源在于,资产并未正确添加到列表中,导致无法获取有效的资产头。资产头应在分配内存时通过 acquire memory
设置,确保每个资产在加载时都有一个有效的内存头。
为了解决这个问题,应该从列表中正确移除无效资产,并确保每个资产的头部信息被正确初始化和设置。错误的代码部分涉及到对资产列表的错误操作,未能在合适的时机更新资产头,因此导致了无法访问有效的资产头。
此外,内存释放操作需要被优化,特别是在 release asset memory
后,将资产状态设置为 unloaded
,这表明内存已经释放,资产不再被占用。
在进一步的调试过程中,发现一些多余的处理仍然存在,尤其是 asset memory type
和 get size of asset
相关的代码。由于这些操作不再需要,应该将它们移除,以避免不必要的复杂性。
通过简化和修复这些步骤,代码结构逐渐清晰,减少了冗余和不必要的内存操作,使得资产的加载和释放变得更加简洁和高效。同时,处理资产状态和内存时,需要确保状态转换的正确性,避免误用未加载的资源。
总结来说,主要问题是资产头未正确初始化和无效资产的管理。通过修正列表操作、内存管理和状态设置,代码变得更加稳定。
移除 GetSizeOfAsset
在此过程中,决定简化 GetSizeOfAsset
的实现方式,而不再将其作为一个独立的功能,而是根据资产类型(例如音频)直接计算其大小。通过这一改变,代码的复杂度得到了减少,同时也能更高效地管理资产的大小。
具体来说,之前的实现方式是单独计算每种资产的大小,而现在的做法是直接在需要时计算,避免了多余的代码。这种方式可以直接通过简单的计算表达式(例如,size = section size + data size + total size
)来得到资产的大小,并且对于位图部分也采用了相同的方式进行处理。
通过这种简化,代码变得更加清晰和紧凑,同时去除了不必要的复杂性和冗余部分。每个部分的功能更加明确,减少了重复代码,提高了代码的可读性和维护性。
最后,通过这些调整,代码行数被有效地减少,整个结构变得更加简洁。在做出这些改进后,代码的功能得到了增强,同时保持了良好的清晰度,减少了冗余的计算和操作。
总体来说,这些调整优化了代码结构,使其更加高效,简洁,并且避免了不必要的复杂度。
使用双向链表来计算最少使用的资源看起来是个简单而巧妙的技巧。它有什么缺点?
使用链表方式计算最少使用资产(least-used assets)看起来是一个简单且整洁的技巧,但它有一些明显的缺点。首先,最大的问题是,这种方式需要做太多的工作。理想情况下,判断一个资产是否在某一帧被使用所需的信息其实非常简单,可能只需要一个整数,表示该资产是否在某一帧被使用。这样更新一个整数的成本远远低于更新链表的开销。
链表的方式需要更新资产的前后指针。每次操作都会写入8字节的数据,因为每个节点需要更新它自己的前后指针,还需要更新相邻节点的指针,来维持链表的完整性。因此,每次移动链表中的一个节点,至少需要处理6到8字节的数据——包括删除操作、修改指针和插入操作。这比更新一个整数所需的成本高得多。
而且,链表的节点通常散布在内存的不同位置,每次操作都会导致多个缓存行被加载,从而增加了内存的访问开销,降低了效率。相比之下,直接更新一个简单的整数或较少字节的结构,所产生的开销就要小得多,效率也更高。
当然,这种方式的缺点并不意味着它一定是不可行的,尤其在某些情况下,它的开销可能并不明显,特别是当资源的使用频率相对较低,或者其他工作量远大于更新资产状态时。因此,是否使用这种链表方式,取决于具体的应用场景和性能需求,虽然它的确存在一定的开销,但在某些情况下,可能是可接受的。
能否解释一下你更改资源加载代码,避免位图和声音闪烁的部分?
在这个代码修改中,主要目的是减少资产加载时的闪烁问题,特别是在图像和声音的加载过程中。为了做到这一点,我们采用了一种基于链表的方式来管理最常用的资源。下面是这个修改的详细解释:
首先,整个过程基于一个“最近使用列表”,即一个双向循环链表。链表中包含一个哨兵节点,它不包含实际数据,只作为链表的头尾连接。哨兵节点的前指针指向链表的尾部,而后指针指向链表的头部,形成一个循环结构。
当一个资产被加载时,它会被插入到这个链表中,紧挨着哨兵节点的位置。链表的结构使得“最最近使用的资产”总是位于链表的头部。而当一个资产被使用时,它也会被移动到链表的头部,这样我们就可以始终保持链表头部是最新被使用的资产。
为了减少闪烁,我们的目标是避免清除最近使用的资产。因此,我们改变了资源清除的策略:我们不再直接移除最不常用的资源,而是从链表尾部开始,逐个移除最近最少使用的资产,直到我们达到设定的内存目标。具体来说,首先查找哨兵节点的前指针,它指向的是距离最久未使用的资产,接着移除它,再检查下一个资源,直到释放足够的内存为止。
通过这种方式,链表的结构始终保持最新使用的资产在前,而较久未使用的资产则在链表的尾部。每次使用资产时,只需要将其从当前的位置移除,再插入到链表头部,从而更新使用顺序。
这样修改后的方案可以显著减少闪烁现象,因为我们避免了过早清除最近使用的资产,确保了内存管理的平滑性和效率。同时,也保持了链表的高效性,因为每次资产的插入和移除操作都只是对链表的指针进行修改,而不需要进行复杂的数据拷贝或重新加载。
总的来说,这种方案通过巧妙利用链表的结构,减少了内存管理的开销,并有效避免了频繁的资源加载和卸载,从而降低了界面和资源加载过程中的闪烁问题。
每帧操作双向链表时,是否会有缓存丢失的问题?
在操作双向链表时,确实有潜在的缓存失效(cache miss)问题,特别是每帧都在修改链表时。理论上,这种情况是有可能会影响性能的,因为每次修改链表时,都会操作“下一个指针”和“上一个指针”这两个字段,尤其是在内存中分布不连续时,会导致缓存失效。然而,在实际情况中,缓存失效的影响可能没有想象中的那么大。
链表的“下一个指针”和“上一个指针”通常是存储在相邻的内存位置,因此它们可能会共享同一个缓存行。虽然每次操作会涉及到多个写操作(大约六次写操作,而不是一次),但由于这些操作可能集中在同一个缓存行内,缓存失效的影响并不会太大。所以,虽然这些操作增加了缓存失效的风险,但实际上它们的性能影响可能并不显著。
实际上,当前阶段考虑缓存失效并不必要,因为代码优化的重点应该放在功能实现上,而不是细节优化。此时,应该更多地关注实现的正确性和基本功能的完成,直到能够实际测量性能时,再决定是否因为链表操作导致的缓存失效需要做优化。
总之,虽然操作双向链表可能引入一些缓存失效问题,但在目前阶段,这个问题不应该是主要的关注点。更重要的是,等到性能测量完成后,才真正评估链表操作对性能的影响,并在必要时进行优化。
你有一个关于音频资源结构体大小的 TODO。你认为保持结构体紧凑有多重要? --撤销了 LoadedBitmap 的大小压缩,因为它现在已包含在资源头部,而不再是资源槽的一部分(黑板)
在处理音频和资产加载时,曾经对于结构体的紧凑性非常关注,特别是在内存管理方面。最初的担忧主要是由于资产槽(asset slot)会随着资产的增加而不断增加,因此导致内存占用的增加。每一个资产槽都会包含一些额外的元数据,例如加载的位图(bitmap)和音频等数据,这会造成内存的浪费。如果游戏中的资产数量非常庞大,比如达到十万个资产,那么每个资产占用的内存就会显著增加,最终导致整个系统的内存开销非常大。
然而,最近的改动解决了这个问题。通过将加载的音频和位图等数据从资产槽中移除,并放到资产的内存头(asset memory header)中,现在不再需要担心资产槽会导致内存过度增长。因为资产内存头只会存在于实际已加载的资产上,而这些资产的实际数据会远远大于内存头的占用空间。因此,内存的使用变得更加高效和可控。
之前,我们担心的是,如果每个资产槽中都包含大量的数据(例如加载的位图、音频等),那么资产槽的数量和数据的大小就会导致内存的爆炸性增长。但现在,通过将这些数据移到资产内存头中,资产槽的大小大大缩减,只有一些必要的元数据和加载所需的信息。这样,内存的占用就变得更加精简,且不再需要为每个资产槽分配过多的内存。
另外,资产内存头的大小是与加载的资产数量相关的。假设一次加载的资产数量控制在一个较小的范围内(比如一千个),那么内存的占用就可以得到有效控制。这使得我们可以灵活地调整加载的资产数量,以满足不同的内存需求。而资产槽的数量是固定的,无法控制,因此我们可以通过调节加载的资产数量来优化内存使用。
总的来说,将数据移到资产内存头中是一个非常有效的优化,它让我们不再需要关注每个资产槽的大小,而是关注实际加载的资产数据的大小。通过这种方式,内存管理变得更加高效,且不会影响性能。
你有硬盘来测试这个系统吗?
在这个系统中,使用的是一个传统的机械硬盘(HDD),而不是固态硬盘(SSD)。这种硬盘通过旋转的盘片来存储和读取数据,是一种较为老旧的硬盘类型,和现代的SSD相比,速度要慢很多。SSD通过闪存技术存储数据,读写速度远远超过传统的机械硬盘。
在解锁资源后将资源头部设置为 null 会有问题吗?我以为有一些后台加载的内容
在这个情况下,当资产被驱逐时,我们知道它没有正在进行的后台加载操作,因为加载状态已经被设置为 true
。这意味着一旦资产加载完成,就不会再有后台任务在操作它。因此,设置其头部为零是完全可以接受的,因为此时我们已经释放了头部所在的内存。这也表明,资产的加载过程已经完成,没有进一步的操作需要进行。
堆和栈只是操作系统管理的不同 RAM 区域吗?如果是这样,不同的程序是否会共享栈空间?你能给我一些指引,或者指向我错过的视频吗?
堆和栈确实是RAM的不同区域,由操作系统管理。具体来说,堆并不是一个独立的东西,它是一个在系统的实际内存管理器之上运行的工具性代码。这个实际的内存管理器通常是分页表(page table),也就是虚拟内存的机制。堆实际上是一个内存分配器,用于动态分配内存,坐落在操作系统的管理系统上。
栈则是一个预留的内存空间,这个空间是连续的。每当程序调用一个函数时,栈会增加相应的内存空间;当函数返回时,栈会释放相应的内存。
至于不同程序是否共享栈空间,通常情况下,每个程序会有自己独立的栈和堆区域,程序间不会共享这些空间。每个程序的虚拟内存空间是独立的,操作系统通过虚拟内存和分页管理保证了这一点。
我有点困惑,使用双向链表一段时间后内存布局会是怎样的?到现在为止,内存最终会碎片化吗?
目前,对于内存的布局和碎片化问题,确实存在一些不确定性。当前系统使用的是操作系统提供的虚拟内存管理(虚拟内存分配),而不是自定义的内存管理方案。由于操作系统的内存分配可能会导致内存碎片化,特别是在长时间运行和频繁分配内存的情况下,问题可能会逐渐显现出来。
不过,由于现在是在64位系统上,操作系统的内存管理可能相对健壮,不太容易出现严重的碎片化问题,但这并不能完全排除碎片化的风险。当前系统的内存请求和释放是通过操作系统进行的,所以并不清楚在操作系统的页面表管理下是否会出现严重的碎片化。对于这一点,由于对操作系统的页面表算法并不熟悉,因此也无法给出明确的答案。
因此,下一步的计划是替换操作系统的内存分配调用,转而使用自定义的内存管理方案。这样做后,可以更好地控制内存的分配与释放,避免碎片化问题,并且能更精确地管理内存的使用。
这种交换资源的方法不会导致内存碎片化吧?
对于内存碎片化问题,确实存在一定的复杂性和不确定性,特别是在处理内存分配时。通常,在内存分配过程中,如果分配的内存区域不连续,可能会出现碎片化的情况。举个例子,如果你有两千兆字节的内存,并且在某个地方分配了一块内存,再在另一个地方分配另一块,最后释放了某一块内存,这时就可能出现碎片。如果要分配一个大块的内存,可能无法放入这些碎片化的区域,除非能够将它们移动到其他地方,这样才能避免碎片化的问题。
但是,现代操作系统通常会“作弊”,即操作系统并不在虚拟内存空间中工作,而是在物理内存空间中工作。物理内存是由多个固定大小的页面组成,例如每个页面可能是4KB的大小。操作系统有一个页表,用于管理这些页面。当操作系统分配内存时,它不会关心内存是否连续,而是会根据页表将内存映射到虚拟内存空间。例如,如果应用程序请求16KB的内存,操作系统会分配4个4KB的页面,并将它们分配给应用程序。即使这些页面在物理内存中不是连续的,操作系统可以通过页表将它们映射为连续的虚拟内存区域。
这种虚拟内存映射的机制使得即便物理内存中的页面并非连续,操作系统仍然能将它们呈现给应用程序作为连续的内存区域。这种机制有效避免了碎片化的问题,因为操作系统可以随时重新映射这些页面,而不需要物理内存中的页面一定是连续的。因此,操作系统的虚拟内存管理能够极大地简化碎片化的问题,操作系统通常不太会出现碎片化问题。
但是,关于页表本身是否会发生碎片化的问题,目前并不清楚。操作系统在使用虚拟内存映射时,其内存分配的效率通常比较高,但具体的实现细节和是否会引起页表碎片化,还是一个不确定的问题。不过,考虑到操作系统的虚拟内存管理机制,碎片化通常是可以处理的,所以一般来说操作系统不会出现严重的碎片化问题。
那么,像这样的事情不会发生吧?调用资源驱逐函数,释放发生,解锁发生,线程上下文切换到加载器,某些东西被加载到该槽位中并有头部,线程上下文切换回来,头部被清除为 0
在多线程编程中,有一个关键点需要明确,就是要清楚哪些部分是多线程的,哪些部分不是。否则可能会导致一些难以调试的错误。在这个资产系统中,只有加载资产的工作是多线程的,其他部分的代码都是单线程的。这意味着,唯一需要担心的多线程问题,就是加载资产的工作是否会在数据上发生冲突。
具体来说,加载资产的工作只是简单地将文件中的数据读取到资产块中,这通常不是问题。最需要注意的是,必须确保在文件数据加载完成之前,不要声明资产已经准备好,否则就可能在屏幕上显示一些错误的数据或图像。
在加载工作完成并标记资产为已加载之前,其他代码无法访问或修改这些资产。加载过程本身是由原子比较交换(atomic compare exchange)来进行管理的,这确保了在资产处于“加载中”状态时,其他代码不能再次启动背景任务。更重要的是,当资产处于“加载中”状态时,其他代码也不能使用它。
因此,整个系统的代码大部分都是单线程的。在同一线程中,启动加载资产的任务和驱逐资产的任务永远不会同时发生,也不会发生冲突。这意味着多线程的操作和单线程的操作不会互相干扰。
由于内核缓存了磁盘访问,我们是否在为每个资源付出多次代价?比如,驱逐资源后,是否可能由于操作系统缓存包文件,另一个副本被缓存到物理内存中?
关于是否因为内核缓存磁盘访问而导致为每个资产支付多次费用的情况,答案是肯定的。确实可能发生这种情况,特别是在我们驱逐了资产后,内核可能会再次缓存该资产的副本到物理内存,因为操作系统会缓存包文件的内容。
然而,这种情况并不是特别令人担忧,原因是,预计在这个项目完成之前,我们会对资产文件进行压缩,因此我们实际上不会将数据存储多次。现在,我们希望能够进行非缓存的读取操作,这种操作是可能的,但通常有时也不太可能实现。我们确实有一些方法可以尝试实现非缓存读取,但往往操作系统对缓存的控制较强,因此很难完全绕过。
一种可能的解决方案是使用内存映射文件,但内存映射文件也有其问题,特别是它们在 32 位操作系统上会遇到地址空间的限制,不能加载大于 32 位地址空间的文件,这显然是个问题。因此,这个问题虽然存在,但在实际操作中解决起来并不是特别简单。
至于下一个步骤,讨论是否要编写自己的内存分配器。当前我们使用的是系统的内存分配器,接下来可以考虑是否要进一步扩展功能,编写自己的内存分配器。这是一个值得思考的方向,尤其是在资产系统和内存管理方面取得一定进展之后。