回顾和今天的计划
我们在这里会实时编码一个完整的游戏,没有使用引擎或库,一切都由我们自己做所有的编程工作,游戏中的每一部分,无论需要做什么,我们都亲自实现,并展示如何完成这些任务。今天,我们正在处理资产系统的最后一部分——内存管理。昨天,我已经简要介绍了一下关于这个资产系统的一些内容,今天我想简单地实现它,让大家能够看到最基本的实现方式。之后,我们会逐步过渡到更复杂的、适合实际发布版本的实现方式。
使用操作系统的虚拟内存系统解决我们的内存管理问题
今天我们将讨论如何使用虚拟内存来解决内存管理问题,特别是在游戏的地址空间方面。之前在直播前有观众提到,是否可以通过虚拟内存来解决这个问题,我的回答是,如果你想要一个64位的地址空间来运行游戏,这是完全可行的,但如果不是这样,可能会面临一些问题,比如虚拟页表的空间不足,尤其是在32位系统下,这个问题会更为明显。Windows在32位系统中还有一些其他的问题需要考虑,这可能让虚拟内存的使用变得不那么理想。
至于是否必须发布一个32位版本的 game,我们目前还没有决定。我并不想断言“不要做32位版本”,因为当我们准备发布时,我们可以根据需要做出选择。如果决定支持32位版本,我们当然可以调整代码架构来实现这一点。现在,我们的代码并没有被架构成让这个操作变得不可能,所以即使我们目前主要开发64位版本,也可以在以后再考虑是否需要支持32位。
值得注意的是,今天,许多游戏开发者选择只支持64位操作系统的游戏,因为这种做法是完全可行的,而且可能会赚到不错的收入。然而,另一方面,这样做也可能会导致我们失去一部分玩家群体,因为有15%的Steam用户仍然使用32位操作系统。所以这是一个需要考虑的问题。
我想展示一种方法,先做一个简单版本的系统,大家可以看到基本思路。这个简单版本是基于64位内存空间的,肯定能够在64位系统上运行,但在32位系统上可能表现不好。接下来,我可以展示如何使用虚拟内存相关的API(如 VirtualAlloc 和 VirtualFree)来实现这一点,这些操作非常直接,也许今天我们就可以做这个,因为如我所说,我们需要先实现一个简单的版本。
目前,资产系统的内存已经被严格限制了,因为它并没有实现虚拟内存的管理功能。当我们尝试实例化英雄角色时,程序会立即触发一个断言,提示资产系统内存不足。这是因为在加载英雄资产时,尝试执行push size操作时,内存空间已经满了,导致无法继续分配内存。因此,接下来我们需要想办法解决这个问题。
跟踪内存负载
问题是,我们需要开始考虑如何检测是否出现内存不足的情况。如果发现内存不足,我们就需要进行资产驱逐,比如删除一些旧的资产,以确保始终有足够的内存来加载我们即将需要的新资产。这样做可以确保在游戏运行过程中,始终能够保证内存空间足够,避免因内存不足而导致的崩溃或性能问题。
使用一种分配方案,从操作系统获取内存并在驱逐时归还
我们可以开始更改为一种分配方案,通过操作系统获取内存,并在每次加载资产时将内存释放回操作系统。这种方式非常简单,可以通过手动平台API实现,特别是在64位操作系统上,这个方法应该不会有太多问题。虚拟内存分配应该在Windows上运行得相当快,我们当然可以进行性能测试来确认这一点。如果我们希望实现这一点,我们可以让平台API具备从操作系统获取内存和将内存返回操作系统的能力。
我们可以为此定义两个函数,分别是platform_allocate_memory
和platform_deallocate_memory
。这些函数的实现非常简单,platform_allocate_memory
会接收一个大小参数,并返回一个内存指针,而platform_deallocate_memory
则是标准的内存释放操作。
当我们有了这两个函数后,我们还可以进行更多的修改。比如,后期我们可以将内存管理改为按需增长,即不需要一开始就分配所有的内存,而是允许游戏在运行时动态增长内存。这也非常简单,只需要进行少量修改,代码不会有太大的变动。
这些功能并不是仅仅为了调试,我们可以将它们作为实际的操作平台调用。在游戏发布时是否允许这种动态内存管理,我们还没有决定,但无论如何,这种方式是可行的,并且实现起来非常直接。
总的来说,通过这些平台调用,我们可以非常方便地从操作系统申请和释放内存。这种实现方式非常简单,几乎没有复杂的部分。如果你跟随手工英雄的进程,你应该已经很清楚如何使用这些API,这只是一个简单的示范,目标是展示如何灵活地管理内存。
实现 platform_allocate_memory
我们可以通过在平台API中添加platform_allocate_memory
和platform_deallocate_memory
函数来管理内存。这些函数将分别调用操作系统的VirtualAlloc
和VirtualFree
函数来分配和释放内存。这两者的实现都非常简单,只需调用操作系统提供的API即可。
在实现platform_allocate_memory
时,我们向操作系统请求分配指定大小的虚拟内存。操作系统会根据传入的大小进行分配,成功后返回一个指向分配内存的指针。如果分配失败,返回null
指针,调用者会知道无法从操作系统获取更多内存,处理这种情况就可以了。
对于platform_deallocate_memory
,它的工作就是释放已经分配的内存。VirtualFree
会根据传入的指针来释放内存,操作非常简单。虽然我们可以验证一下VirtualFree
是否允许传入空指针,但这并不影响功能的实现。
这些操作的实现方式非常基础,几乎不需要任何复杂的处理。我们只需要确保在平台API表中加入allocate_memory
和deallocate_memory
的定义。然后,我们就可以随时通过这些接口分配和释放内存,整个过程非常直接。
通过这种方法,我们不需要做任何复杂的内存管理操作,内存的分配和释放都由操作系统来处理。这样做的好处是,我们不需要手动管理内存的分配和回收,只需依赖操作系统提供的内存管理功能。
这些变化破坏了循环实时代码编辑功能
为了处理内存管理问题,特别是如何在加载图像时确保足够的内存可用,需要考虑一些关键因素。当前在调用 load bitmap
时,内存不足的问题暴露出来。此时,不能简单地在 load bitmap
过程中直接释放内存,因为该函数是在帧的处理中被调用的,而这些帧可能正在使用某些已经加载的位图资源。这样如果在处理中随意释放内存,可能会导致程序出错或出现资源冲突。
因此,需要确保有一种安全的方式来释放内存,以便加载新的位图。为此,无法仅依赖 load bitmap
函数本身来进行内存释放,因为它运行在帧的处理中,释放内存可能会影响当前正在使用的资源。相反,必须在更合适的时机,保证释放的内存不会影响正在使用的资源。
两种方法:a) 将 LoadBitmap 调用推迟到帧结束时,b) 保持一定的空闲空间,确保加载始终可能
为了应对内存不足的问题,提出了几种解决方案。一种方法是缓冲所有加载位图的请求,这样可以在需要时批量处理加载请求。另一种方法是始终保持一定量的内存空闲,这样就能够确保在分配内存后,可以稍后再进行内存释放。
在当前的简化实现中,采取了不设置硬性内存限制的策略,而是使用软性限制来解决这个问题。软性限制意味着不会立即强制限制内存的使用,而是确保在需要加载新资源时有足够的内存空间,具体的内存管理将稍后处理。这样做的目的是简化当前的实现,同时避免因硬性内存限制而导致不必要的复杂度。
跟踪资产系统中使用的内存量 (AcquireAssetMemory)
为了追踪实际使用的内存,决定在资产加载时记录每次分配的内存量。首先,初始化总内存使用量为0。每次加载资产时,会通过一个函数来处理内存分配,并且计算分配的内存大小。
具体操作是,在加载资产时,调用AcquireAssetMemory
函数,该函数接收资产数据和所需的内存大小。然后执行内存分配,成功后会将所分配的内存大小加到总内存使用量中。最终,只有在内存成功分配后,才会增加内存使用量。
这个过程的关键是通过调用AllocateMemory
函数分配内存,并根据实际分配的内存大小更新内存使用情况,从而可以准确追踪整个资产系统的内存消耗。
RealeaseAssetMemory 需要我们提供要释放的资产大小
我们需要进行内存释放,因此会调用释放资产内存的函数,并将内存归还给系统。为了实现这一点,我们会调用一个释放函数,并传递一个 void
指针。但问题在于,我们需要知道这块内存的大小,因此必须在释放函数中额外传递内存大小参数。虽然这样做有些别扭,但我们必须跟踪内存的大小。因此,我们会直接将大小参数传入释放函数。
调用该函数后,它将执行 platform_release_memory
,实际上是 DeallocateMemory
,并传入要释放的内存地址。前提是这块内存地址非零(即有效)。此外,我们还需要减少 assets.TotalMemoryUsed
的值,减去被释放的内存大小。
当前的实现不会涉及多线程,因此不必考虑并发安全问题。但是,如果未来我们计划从多个线程调用该函数,就需要使用原子加 (atomic add
) 或原子减 (atomic decrement
) 来确保操作的线程安全性。目前暂时没有多线程调用的计划,所以可以不考虑这个问题。但需要记住,一旦在多线程环境下使用该函数,现有实现就会存在安全隐患。例如,如果 LoadAssetWork
开始涉及并发操作,我们就必须考虑线程安全的问题。
此外,还有一个问题需要注意。当前的实现并未正确处理文件流的情况,即在释放内存时,并不会处理文件流导致的内存残留。因此,我们需要确保无论如何都正确释放文件流占用的内存。实际上,即使文件读取失败,也应该进行某种操作,例如填充一个无效的数据块,或者将内存区域清零。这样做更加安全,并能避免潜在的问题。
关于文件读取失败后的处理,我们可能会填充一块无效数据,例如全部置零。检查代码后发现,已经有一个 ZeroSize
相关的方法,因此可以利用它来清空内存。
在 platform
层面,我们有一个内存分配的函数,同时也有一个内存释放的函数。我们需要在正确的位置调用它们,以确保系统资源得到合理管理。
使用平台调用代替内存区域
我们可以实现一种机制,使内存的分配和释放更加高效。具体来说,当需要分配位图内存时,不再使用 PushSize
,而是调用 AcquireAssetMemory
,并将 game_assets
以及所需的内存大小作为参数传入。同样的,在声音资源的分配过程中,也可以使用 AcquireAssetMemory
,传递 Assets
和所需的大小MemorySize,而不是 PushSize
。
这样一来,游戏运行时会直接从操作系统获取内存,整个流程变得更加流畅,避免了之前可能存在的问题。然而,目前仍然存在一个问题,即内存的分配和回收仍然只是简单地获取和释放指定大小的内存,并没有达到理想的状态。因此,需要继续优化和完善这个流程,以确保资源管理的合理性和高效性。
跟踪内存使用情况,并在帧结束时释放内存 (EvictAssetsAsNecessary)
我们需要检查当前正在使用的资产内存总量,并在其过高时主动释放部分内存。为此,需要在一个合适的时间点执行该操作,以确保不会影响正在使用的资产。
一个理想的时机是在每一帧结束时,也就是所有临时内存都已释放、所有资源清理完毕的时候。在这一时刻,可以让资产管理系统检查当前的内存占用情况,并释放一些不再需要的资产,使其回到合理的工作集大小。
可以在 game.cpp
里实现这一逻辑,在帧结束时调用 FreeAssetsAsNecessary
或 EvictAssetsAsNecessary
,并传入 TranState->Assets
作为参数。这样就能确保在固定时间点执行清理,避免在多线程环境下进行不必要的阻塞操作。通过这种方式,可以确保资产系统有一个独立的时间段来回收不必要的内存。
EvictAssetsAsNecessary
的具体实现将包含一个循环逻辑,该循环会持续检查 TotalMemoryUsed
是否超过了设定的 TargetMemoryUsed
(即目标内存占用阈值)。如果当前占用的内存超出了目标值,就尝试释放某个资产,直到总占用内存降至合理范围内。
如果能够成功释放资产,就继续循环;如果无法释放,则跳出循环,并触发错误处理逻辑。因为理论上不会出现无法释放资产的情况,所以如果发生了,则说明程序存在 Bug,需要进一步调查和修复。
接下来,需要具体实现这一逻辑,以确保资产系统能够高效管理内存,避免过度占用系统资源。
驱逐最近最少使用的资产
我们需要找到最近最少使用(LRU)的资产,并释放其占用的内存。为此,需要一个能够跟踪资产使用情况的机制,例如一个用于管理资产槽(slot)的数据结构。可以实现一个 GetLeastRecentlyUsedAsset
函数,该函数返回最久未使用的资产槽索引。
在执行资产回收时,首先调用 GetLeastRecentlyUsedAsset
来获取最久未使用的资产槽索引。如果返回的索引不是 0(即该索引有效,指向某个可释放的资产),就可以进行回收。
接着,使用该索引找到对应的资产,并调用 EvictAsset
函数来释放该资产所占用的内存。EvictAsset
的作用是彻底移除该资产,使其不再被系统占用,并释放其相关资源。这一过程可以封装成 internal void EvictAsset(game_assets *Assets, uint32 SlotIndex)
,该函数负责从系统中移除该资产,使其彻底消失。
整个过程可以总结如下:
- 通过
GetLeastRecentlyUsedAsset
获取最久未使用的资产槽索引。 - 如果索引有效,则调用
EvictAsset(Assets, SlotIndex)
释放该资产。 EvictAsset
负责清理该资产的所有相关数据,并释放内存,使系统资源得到回收。
最终,EvictAsset
使资产彻底离开系统,不再占用资源,就像某个被排除在外的角色一样,它已不再属于当前的资源集合,必须被移除。
EvictAsset
EvictAsset
的作用是将资产从“已加载”状态转换为“已释放”状态。为了实现这一点,需要先确定资产槽(slot)的位置,然后检查该槽的状态,确保它确实处于“已加载”状态。
在资产管理系统中,并非所有状态的资产都可以被移除。例如,已锁定(locked)的资产无法被回收,而排队等待加载(queued)的资产也不应被移除。因此,如果尝试回收一个未处于“已加载”状态的资产,应该触发错误。
假设资产确实处于“已加载”状态,接下来的步骤就是将其状态转换为“未加载”并释放内存。实现方式是调用 ReleaseAssetMemory
来回收该资产的内存。
一个主要的问题是,释放内存时需要知道资产的具体大小,但目前系统中并没有存储每个资产的大小信息。因此,需要找到一个方法,使得在释放内存时能够轻松获取资产的大小。
在当前系统架构下,获取内存指针相对简单,因为每个资产在其对应的槽中都有内存地址记录。但是,资产的大小信息没有统一的管理方式,因此在回收内存时可能会遇到困难。为了简化内存管理流程,可以对资产槽(slot)结构进行改进,使内存管理更加规范化。
一个可能的优化方向是统一不同类型资产(如位图和音频)的内存管理方式。如果能够在资产槽中存储资产类型信息(如标记它是“位图”还是“音频”),那么在释放时就可以通过这个信息来确定相应的大小,而无需额外的处理逻辑。
目前的问题在于,不同类型的资产可能存储方式不同。例如,在文件格式中,数据只是简单地存储在文件中,而其余的信息则是通过额外的计算得到的。这种方式虽然有一定的灵活性,但在内存释放时会带来额外的复杂性。因此,需要权衡是否继续沿用这种方法,还是调整存储结构,使内存管理更加规范和统一。
总体而言,需要做的优化包括:
- 确保只能回收“已加载”状态的资产,避免错误地释放正在使用的资产。
- 改进资产槽的结构,存储更多的元数据(如资产类型和大小),以便释放内存时能够正确计算大小。
- 统一不同类型资产的内存管理,避免在释放时额外判断资产类型,简化回收逻辑。
- 检查文件格式的存储方式,确保文件数据能有效映射到内存管理结构,减少不必要的计算和存储开销。
需要进一步思考的是,如何在不增加太多额外开销的情况下,使整个资产管理系统更加高效和易维护。
在 AssetState 中区分位图和声音
为了更高效地管理资产,我们需要在资产槽(slot)中记录资产的类型,例如区分它是位图(bitmap)还是音频(sound)。目前的系统中,并没有一个直接的方式来存储这个信息,而是在不同的地方进行判断和处理。为了优化这一点,我们可以在资产状态(asset_state)中引入额外的标志位,使其既能表示当前的加载状态,又能区分资产类型。
一种方法是利用状态字段的高位来存储资产类型。例如:
- 设定一个
AssetState_Bitmap
标志,用于标识位图类型资产。 - 设定一个
AssetState_Sound
标志,用于标识音频类型资产。 - 低 8 位用于存储资产的具体状态(如
LOADED
、LOCKED
等),高位用于存储类型信息。
这样,在检查资产状态时,可以直接屏蔽掉类型信息,仅关注加载状态。例如,通过 AssetState_Mask
获取资产的基础状态,而高位仍可用于资产类型识别。这种方式的优点是:
- 统一管理资产类型信息,不需要在不同代码部分进行额外判断。
- 简化加载和卸载逻辑,只需要检查状态字段即可确定资产的类型和当前状态。
- 避免额外的数据结构,减少存储开销,提高访问效率。
在实现过程中,需要修改 uint32 GetState(asset_slot *Slot)
之类的函数,使其能够正确解析状态字段。例如:
- 低 8 位用于表示
LOADED
、LOCKED
等状态。 - 高位用于存储
BITMAP
或SOUND
类型信息。 - 通过位掩码(mask)操作,可以分别获取类型和状态信息。
这样,在资产管理系统中,任何时候查看一个资产槽时,都能立即知道该资产是位图还是音频,而不需要额外的计算或存储。这种方法虽然有些“临时拼凑”(janky),但它能有效地完成任务,并使资产管理更加直观和高效。
最终,在卸载资产时,可以通过检查 AssetState_Loaded
确保资产处于可卸载状态,并利用新的类型信息正确地释放内存。这样,整个资产管理流程就更加清晰和统一了。
计算资产占用的内存量
为了正确释放资产槽(slot)所占用的内存,我们需要计算该槽实际占用的内存大小,并释放相应的内存。因此,我们需要创建一个 GetSizeOfAsset
函数,该函数能够根据资产的类型(如位图或音频)来计算其所需的内存量。
具体实现思路
-
添加资产类型掩码(Type Mask)
- 在资产状态字段中,添加一个类型掩码(
AssetState_StateMask
),用于区分资产是位图(bitmap)还是音频(sound)。 - 这样,我们可以通过
GetType(asset_slot *Slot)
函数快速获取资产的类型,而不需要额外的存储结构。
- 在资产状态字段中,添加一个类型掩码(
-
实现
GetSizeOfAsset
函数- 该函数接受资产的索引(SlotIndex)和类型Type,并返回该资产实际占用的内存大小。
- 通过
GetSizeOfAsset
获取资产类型,使用不同的计算方式计算大小:- 位图(bitmap): 计算方式为
width × height × 4
(假设 4 字节颜色通道)。 - 音频(sound): 计算方式为
ChennelCount × sample_rate × SampleCount
。 sample_rate指sizeof(int16)
- 位图(bitmap): 计算方式为
- 通过
assert
机制确保只有位图或音频类型的资产被计算,如果未来扩展了其他资产类型,可以及时发现错误并修正。
-
在释放内存时使用
GetSizeOfAsset
- 在释放资产槽(evict asset)时,调用
GetSizeOfAsset
计算该资产的大小,然后调用ReleaseAssetMemory
释放内存。 - 这样可以确保计算出的内存大小与申请时一致,避免重复计算或不一致的问题。
- 在释放资产槽(evict asset)时,调用
优化点
- 减少重复计算:
- 确保
GetSizeOfAsset
计算出的内存大小在整个代码中保持一致,不会在多个地方以不同方式计算同一个资产的大小,以防止计算误差或代码冗余。
- 确保
- 提高可读性:
- 通过
GetSizeOfAsset
获取资产类型,使得代码逻辑清晰,便于维护和扩展。
- 通过
- 安全性检查:
- 在计算和释放内存时,通过
assert
机制检查状态,防止错误释放未加载的资产,确保系统稳定性。
- 在计算和释放内存时,通过
最终效果
通过上述改进,我们可以准确地计算每个资产槽的内存大小,并在合适的时机释放它们,从而更高效地管理游戏资产的内存使用,提高系统的稳定性和性能。
消除重复计算
为了优化内存管理并减少代码重复,我们引入了 asset_memory_size
这一结构,旨在更高效地计算和存储资产的内存信息。
核心优化点
-
拆分内存计算逻辑
- 之前,
channel size
和pitch
在多个地方被重复使用,容易导致维护上的问题,例如某个地方修改计算方式,但另一个地方仍然使用旧逻辑,最终导致 bug。 - 现在,我们将
asset_memory_size
作为一个统一的结构,存储total size
(总大小)和section size
(行大小或通道大小),以便在所有需要的地方复用。
- 之前,
-
新增
asset_memory_size
结构- 这个结构包含:
TotalSize
:资产占用的总内存大小。SectionSize
:资产的行大小(bitmap 的pitch
)或通道大小(sound 的channel size
)。
- 这样,每次调用
GetSizeOfAsset
时,我们不仅可以获得总大小TotalSize
,还可以获取SectionSize
,从而避免重复计算。
- 这个结构包含:
-
位图(bitmap)和音频(sound)的计算方式
- 位图计算
SectionSize = width × 4
(假设每像素 4 字节)。TotalSize = SectionSize × height
。
- 音频计算
SectionSize = ChennelCount × sizeof(int16)
(单个通道的大小)。TotalSize = SectionSize × ChennelCount
(所有通道的总大小)。
- 这样,我们只需计算
SectionSize
,然后直接用于TotalSize
计算,减少冗余代码。
- 位图计算
-
统一
GetSizeOfAsset
的使用- 在释放资产(eviction)时,直接调用
GetSizeOfAsset
来获取TotalSize
,用于正确释放内存。 - 在使用资产时,也可以通过
SectionSize
获取pitch
或channel size
,确保内存布局一致。
- 在释放资产(eviction)时,直接调用
具体代码调整
- 定义
asset_memory_size
结构struct asset_memory_size {uint32_t TotalSize;uint32_t SectionSize; };
- 修改
GetSizeOfAsset
asset_memory_size GetSizeOfAsset(game_assets *Assets, uint32 Type, uint32 SlotIndex) {asset_memory_size Result = {};asset *Asset = Assets->Assets + SlotIndex;if (Type == AssetState_Sound) {hha_sound *Info = &Asset->HHA.Sound;Result.Section = Info->SampleCount * sizeof(int16);Result.Total = Info->ChennelCount * Result.Section;} else {Assert(Type == AssetState_Bitmap);hha_bitmap *Info = &Asset->HHA.Bitmap;uint16 Width = SafeTruncateUInt16(Info->Dim[0]);uint16 Height = SafeTruncateUInt16(Info->Dim[1]);Result.Section = 4 * Width;Result.Total = Height * Result.Section;}return Result;}
- 在内存分配和释放时使用
internal void EvictAsset(game_assets *Assets, uint32 SlotIndex) {asset_slot *Slot = Assets->Slots + SlotIndex;Assert(GetState(Slot) == AssetState_Loaded);asset_memory_size Size = GetSizeOfAsset(Assets, GetType(Slot), SlotIndex);ReleaseAssetMemory(Assets, Size.Total, Memory);Slot->State = AssetState_Unloaded;}
附加优化
- 增加
safe_truncate
以安全转换数据类型- 在
u32
转换为s16
时,我们没有现成的safe_truncate
,因此新增safe_truncate_s16
以确保转换安全,防止数据溢出。
inline int16 SafeTruncateInt16(int32 Value) {Assert(Value <= 32767);Assert(Value >= -32768);int16 Result = (int16)Value;return Result;}
- 这样,在涉及
pitch
或channel size
计算时,可以安全地转换,避免溢出问题。
- 在
最终效果
- 通过
asset_memory_size
结构,减少重复计算,提高代码可读性和可维护性。 - 统一
SectionSize
和TotalSize
计算,避免不同地方计算方式不一致的问题。 - 使用
SafeTruncateInt16
保障数据类型转换安全,避免溢出错误。 - 这样不仅优化了代码结构,还提高了资产管理的稳定性,使内存计算更加直观可靠。
找到要释放的内存块的位置
我们通过 GetType(Slot)
来确定内存的存放位置,以便更合理地管理和释放内存。此外,为了优化 最近最少使用(LRU, Least Recently Used) 资产的查找,我们引入了一种简单的数据结构来追踪资产的访问顺序。
优化点
1. 通过 GetType(Slot)
统一内存位置判断
- 以前,在释放内存时,我们需要分别判断 sound 和 bitmap 资产的存储方式,代码较为分散且容易出错。
- 现在,我们用
GetType(Slot)
统一判断:internal void EvictAsset(game_assets *Assets, uint32 SlotIndex) {asset_slot *Slot = Assets->Slots + SlotIndex;Assert(GetState(Slot) == AssetState_Loaded);asset_memory_size Size = GetSizeOfAsset(Assets, GetType(Slot), SlotIndex);void *Memory = 0;if (GetType(Slot) == AssetState_Sound) {Memory = Slot->Sound.Samples[0];} else {Assert(GetType(Slot) == AssetState_Bitmap);Memory = Slot->Bitmap.Memory;}ReleaseAssetMemory(Assets, Size.Total, Memory);Slot->State = AssetState_Unloaded;}
- 这样,代码更加清晰,并且如果未来新增了其他类型的资产(如视频、模型等),只需扩展
GetType(Slot)
的处理逻辑即可。 - 额外添加
assert
断言 以确保未来新增资产类型时不会漏掉相应处理。
2. 设计 LRU 资产回收机制
问题:
- 需要一个机制来找到 最近最少使用(LRU)的资产,以便在内存不足时优先释放。
- 最简单的方法是 双向链表,但它会额外占用内存。
解决方案:
-
使用一个 带头结点(sentinel)的双向链表 来维护所有已加载的资产。
【sentinel】 n. 哨兵, 标记 vt. 警戒, 守卫 [计] 标记 名词复数形式: sentinels; 过去分词: sentinelled; -
每次 访问 资产时,将其 移动到链表头部,表示最近使用过。
-
需要释放内存时,从 链表尾部 找到最久未使用的资产并释放。
数据结构:
struct asset_memory_header {asset_memory_header *Next;asset_memory_header *Prev;
};
使用双向链表跟踪最近最少使用的资产
我们打算实现一个简单的双向链表,来管理和跟踪已加载的资产(比如声音或位图)。每当某个资产被使用时,我们将其移到链表的前端,这样链表末尾的节点就会一直是最久未使用的资产。这种做法非常简单,几乎没有复杂的内容。
链表的构建和操作
-
内存计算:
- 计算每个资产的内存大小时,会包含额外的资产内存头部(header)。这意味着每当我们分配一个资产内存时,就会将这个内存头部附加到数据部分后面。头部的作用是提供关于该资产的信息,这样我们就可以根据内存的位置访问它。
- 内存计算会分为两部分:一个是数据大小,另一个是头部的大小。我们希望在加载数据时知道实际加载的数据大小,而不是额外的内存头部。
-
内存结构:
- 在加载资产时,我们将内存的大小与资产头部结合,并计算出总的内存需求。这使得我们可以明确知道需要加载多少数据。通过修改
size
字段来实现,这样计算出的内存大小就能直接用于加载。 - 例如,我们会把内存位置向前推进,跳过数据部分,得到内存头部的位置。之后,加载的数据就是从内存的起始位置到数据大小的部分,而头部则位于数据之后的位置。
- 在加载资产时,我们将内存的大小与资产头部结合,并计算出总的内存需求。这使得我们可以明确知道需要加载多少数据。通过修改
-
双向链表:
- 双向链表是一种非常方便的数据结构,每个节点不仅有指向下一个节点的指针(
next
),还包含指向前一个节点的指针(prev
)。这使得我们在遍历或操作链表时可以很方便地从任意位置删除或插入节点。 - 每当一个资产被使用时,它会被移动到链表的前端。链表的末尾则会一直保持为“最久未使用”的资产。
- 双向链表是一种非常方便的数据结构,每个节点不仅有指向下一个节点的指针(
-
避免多线程问题:
- 在处理链表时,避免在多线程环境中进行修改。因为在多线程环境下,修改双向链表结构可能会导致错误,尤其是在节点插入或删除时。
- 因此,我们选择在非多线程环境下进行操作,确保操作的原子性和稳定性。
-
添加资产到链表:
- 每当有新资产被加载进内存时,会生成一个包含内存地址和资产大小的资产内存头部。然后,我们将这个头部添加到链表中。
- 这个操作非常直接,只需在加载资产时,把这个资产的内存头部加入链表即可。为了防止出现多线程冲突,所有对链表的操作都会在单线程环境下进行。
实现的简要总结:
- 通过使用双向链表,我们能够有效地管理和跟踪已加载的资产,并且每当资产被使用时,我们可以快速地将其移动到链表的前端,使得末尾的资产始终是最久未使用的。
- 采用内存头部的方式,既可以方便地跟踪资产的内存使用,又可以避免额外的计算和存储开销。
- 我们选择在单线程环境下进行链表的操作,以避免多线程引发的问题,保证程序的稳定性。
双向链表理论 (黑板)
双向链表(Double Linked List)是一种非常实用的数据结构,它允许在列表中的元素前后进行快速操作。每个节点不仅包含指向下一个节点的指针(next
),还包含指向上一个节点的指针(prev
)。通过这种方式,每个节点都能知道自己前面的节点和后面的节点,提供了比单向链表(Single Linked List)更灵活的操作方式。
双向链表的结构
-
节点(Node):每个节点包含三个部分:
- 数据部分:存储节点的实际数据。
- 前驱指针(prev):指向前一个节点。
- 后继指针(next):指向下一个节点。
例如,节点的结构可以表示为:
struct Node {Node* prev;Node* next;Data data; };
双向链表的优点
-
灵活性:由于每个节点都有指向前一个节点和后一个节点的指针,双向链表比单向链表具有更多的灵活性。比如,在双向链表中,可以方便地从当前节点访问前一个节点,而在单向链表中,只能从当前节点访问下一个节点。
-
删除节点:在双向链表中,删除某个节点非常简单。因为每个节点都能访问到前驱节点和后继节点的指针,所以可以轻松地将前驱节点的
next
指针指向后继节点,而后继节点的prev
指针指向前驱节点,完成节点的删除。相比之下,在单向链表中,删除某个节点时,如果没有指向前驱节点的指针,则无法直接删除。 -
插入和移动节点:双向链表可以在任何位置进行节点插入或删除操作,而不需要遍历整个链表,提供了非常高效的插入和删除操作。
如何操作双向链表
-
删除节点:假设我们有一个要删除的节点,双向链表使得删除变得非常简便。通过访问节点的前驱节点和后继节点,我们可以直接修改它们的指针,跳过要删除的节点:
prevNode->next = targetNode->next; targetNode->next->prev = prevNode;
-
插入节点:要在双向链表的某个位置插入节点,首先需要将新节点的前驱指针指向前一个节点,后继指针指向下一个节点,然后更新相邻节点的指针:
newNode->prev = prevNode; newNode->next = prevNode->next; prevNode->next->prev = newNode; prevNode->next = newNode;
-
遍历双向链表:双向链表可以从头到尾遍历,也可以从尾到头遍历,这取决于如何使用
next
和prev
指针:- 正向遍历:从头开始,依次访问
next
指针。 - 反向遍历:从尾开始,依次访问
prev
指针。
- 正向遍历:从头开始,依次访问
与单向链表的区别
-
单向链表(Single Linked List):每个节点只有一个指针,指向下一个节点。删除节点时,如果我们只能访问当前节点,无法直接回到前一个节点,这使得删除操作变得更加困难。
在双向链表中,通过前驱指针,我们可以轻松地删除节点并操作列表。 -
双向链表的“多余性”:双向链表相比单向链表来说,确实提供了更多的操作能力,但也带来了额外的空间开销(每个节点需要两个指针)。因此,尽管双向链表提供了更强大的操作灵活性,但它的内存开销也比单向链表大。
总结
双向链表是一个非常灵活且强大的数据结构,特别适用于需要频繁插入、删除或双向遍历的场景。尽管它的内存开销比单向链表要大,但它提供了更高效的操作,能够更方便地进行节点的移动、删除和插入。
AddAssetHeaderToList
在实现链表时,采用了一个名为“哑元”(sentinel)的技术来简化插入操作。这个哑元头部是一个虚拟的节点,存在于链表的结构中,但并不指向任何实际的资产或数据。通过这种方式,我们能保证链表的头部始终有一个指针可以操作,从而避免了在处理链表时的特殊边界情况。
哑元节点(Sentinel Node)的使用
-
哑元节点的目的:
哑元节点充当链表的起始点,它并不代表任何实际的资产。其唯一作用是作为链表操作的起始点,使得插入和删除操作更为简洁,因为不再需要考虑链表为空或只有一个元素的特殊情况。 -
插入新节点的过程:
- 当需要将一个新的节点(比如新加载的资产)插入到链表时,我们将新节点插入到哑元节点之后,即链表的最前面。
- 哑元节点的
next
指针需要指向新插入的节点。 - 新插入的节点的
previous
指针需要指向哑元节点,而它的next
指针则指向原本位于哑元节点之后的节点。 - 这样,我们通过更新这些指针,使得新节点顺利插入链表,并且原本位于该位置的节点的
previous
指针也需要更新,指向新插入的节点。
-
具体步骤:
- 设置哑元节点的
next
指针:将哑元节点的next
指针指向新插入的节点。 - 设置新节点的
previous
指针:新节点的previous
指针需要指向哑元节点,这样确保了新节点可以正确回溯到哑元节点。 - 设置新节点的
next
指针:新节点的next
指针指向原本在哑元节点后面的位置(即哑元节点的next
指向的节点)。 - 更新原节点的
previous
指针:原本在哑元节点之后的节点的previous
指针需要更新,指向新插入的节点。
- 设置哑元节点的
-
插入后的结构:
- 在插入操作完成后,新节点就位于哑元节点之后,成为链表的第一个有效节点。链表的其他节点则继续按照原来的顺序排列。
- 这种方法确保了链表的操作更加简洁,因为每次插入都不需要考虑链表是否为空,也不需要对空链表和只有一个节点的情况进行特殊处理。
通过这种方式,链表的插入和删除操作变得更加统一和简化,因为哑元节点保证了每次操作都能从一个稳定的起点开始。
指针的语义设置
为了简化节点插入操作,我们通过调整链表中节点的 previous
和 next
指针,使得插入操作更加直观且便于实现。具体来说,在插入节点时,我们首先设置新节点的 previous
和 next
指针,使其指向当前节点前后的相应节点。然后,我们通过调整这些节点的指针,使得链表结构保持一致。
具体操作步骤:
-
设置新节点的指针:
- 新节点的
previous
指针应该指向当前节点的前一个节点(即插入位置的前一个节点)。 - 新节点的
next
指针应该指向当前节点的下一个节点(即插入位置的后一个节点)。
- 新节点的
-
调整相邻节点的指针:
- 当前节点前一个节点的指针:当前节点前一个节点的
next
指针应该指向新节点。 - 当前节点后一个节点的指针:当前节点后一个节点的
previous
指针应该指向新节点。
- 当前节点前一个节点的指针:当前节点前一个节点的
-
插入完成:
- 新节点的
previous
和next
指针已经被设置好,使得新节点被正确地插入到链表的合适位置。 - 同时,原本位于新节点前后的节点的指针也被更新,确保它们都指向新节点。
- 新节点的
总结:
这种方法通过设置新节点的前后指针,并让相邻节点的指针指向新节点,确保链表结构的一致性。这个过程可以通过简单的指针调整来完成,不需要额外复杂的逻辑操作。
RemoveAssetHeaderFromList
在处理双向链表时,移除和插入资产头部(Asset Header)操作非常简便。我们需要做的只是调整相邻节点的指针,确保链表的连接不会中断。
移除资产头部(Remove Asset Header)操作:
- 要移除一个节点(即资产头部),只需调整当前节点的相邻节点的指针即可:
- 前一个节点的
next
指针需要指向当前节点的下一个节点。 - 后一个节点的
previous
指针需要指向当前节点的前一个节点。
- 前一个节点的
- 完成上述步骤后,当前节点就被从链表中移除,链表结构保持完整。
插入资产头部(Add Asset Header)操作:
- 插入时,我们只需要设置新节点的
previous
和next
指针,使其正确指向新节点前后的节点。- 新节点的
previous
指针指向当前节点的前一个节点。 - 新节点的
next
指针指向当前节点的下一个节点。
- 新节点的
- 然后,调整相邻节点的指针:
- 前一个节点的
next
指针指向新节点。 - 后一个节点的
previous
指针指向新节点。
- 前一个节点的
资产管理流程:
- 在执行资产回收时,首先需要通过
RemoveAssetHeaderFromList
移除资产头部。 - 为了简化操作,资产头部(asset_memory_header)成为了关键的数据结构,用于进行资源管理。
- 通过调用
get least recently used asset
可以获取最不常用的资产,这时只需根据链表的尾部(Sentinel之前的节点)找到最少使用的资产。
改进和优化:
- 在资产添加到链表时,我们可以强制设置资产的
SlotIndex
,确保链表中的每个资产都有一个有效的标识。 - 每当资产被访问或使用时,我们需要确保它被移动到链表的前端,标记为“最近使用”,这样可以实现 LRU(最近最少使用)缓存策略。
关于 Sentinel 的使用:
- 为了简化链表操作,使用了一个 “sentinel” 节点,作为链表的基础节点,始终存在于链表中,确保链表至少有一个节点。Sentinel 的
next
和previous
指针在链表操作时始终指向有效的节点,避免了链表为空的情况。 - 在初始化时,需要确保 sentinel 节点的
next
和previous
都指向它自己,这样在链表为空的情况下,也能保证操作的正确性。
通过这些方法,整个资产管理流程变得更简洁高效,同时也能保证内存管理和资源回收的灵活性。
初始哨兵设置
在启动时,采用了一个循环链表的结构,其中的 Sentinel
节点既是头节点,也是尾节点。该 Sentinel
节点的 previous
指针指向它自身,next
指针也指向它自身。这样一来,当插入新的节点时,链表的结构保持一致,不需要额外的检查。
Sentinel 节点的作用:
- 使用一个
Sentinel
节点的好处是,链表总是有一个节点存在,即使链表为空。通过这种方式,链表的操作变得简单,因为我们不需要检查是否为空链表。当我们插入新的节点时,新的节点会将自己的previous
指针指向Sentinel
,并且它的next
指针指向Sentinel
原先指向的节点,这样就保证了链表的完整性。 - 如果没有使用
Sentinel
,每次操作时就必须检查链表是否为空,因为链表的previous
或next
指针可能为空,这会增加代码复杂性。而使用Sentinel
之后,链表始终有一个固定的基础节点,避免了这种空指针检查的问题。
内存分配和资产管理:
- 在进行内存分配时,发现了一个小错误,就是在分配内存时没有考虑到总内存的大小,导致了分配时出现了问题。解决方法是修正为正确的
AcquireAssetMemory
计算,并且调整了Size.Total
和Size.Data
,确保内存分配时使用正确的值。
资产回收机制:
- 当前系统中,资产会在内存达到一定限制时被随机逐出。这个机制虽然能确保系统不会超出设定的内存限制,但并没有按照某种特定的顺序进行清除,仅仅是随机逐出一些资产。这样的做法简单,但在某些场景下可能需要更加精确的控制和优化,保证最不常用的资产优先被清除。
总体来看,使用 Sentinel
节点让链表的操作变得更加简单高效,避免了空链表的情况并减少了错误发生的可能性。在内存管理上,也通过随机逐出资产来控制内存的使用量,虽然这是一种简化的做法,但能够快速有效地保持系统在内存限制内。
我们应该避免驱逐被锁定的资产
在资产管理中,存在一种“锁定资产”的概念,这类资产是不允许被逐出的,因为它们正在被后台任务使用。为了确保不会在后台任务正在使用时错误地逐出这些资产,我们需要在将资产添加到链表时,检查它是否被锁定。如果是锁定资产,则需要避免将其添加到逐出队列中。
锁定资产的处理:
- 锁定资产是指在某些特殊情况下,例如后台工作线程正在使用这些资产时,这些资产必须保持在内存中,不能被逐出。为了实现这一点,必须确保在资产进入链表时,不会将锁定的资产错误地添加进来。
- 在实现时,计划中已经考虑到了资产的锁定状态,但目前似乎还没有正确地实现设置资产为“锁定”状态的功能。这是一个需要补充的部分,尤其是在后台任务使用资产时,必须保证这些资产在后台工作过程中不会被释放或逐出。
接下来的步骤:
- 为了解决锁定资产的问题,需要实现一个机制,使得在后台任务使用资产时,能够将这些资产标记为锁定状态。只有当后台任务结束后,资产才能被解锁并且有可能被逐出。
- 这个过程将在下一次的开发工作中实现,即将在明天的工作中完成。具体来说,需要添加一个资产锁定的功能,确保后台任务能够安全地使用资产,而不会因错误的逐出操作导致崩溃或数据丢失。
总结:
- 在当前的设计中,资产的锁定功能尚未完善,未来将加入锁定机制来防止在后台任务使用期间错误逐出资产。通过对资产的状态进行管理,确保系统的稳定性和内存的合理使用。
双向链表的类型及其实现概述
在双向链表的实现中,存在两种主要的方式:带哨兵节点(Sentinel)和不带哨兵节点(Non-Sentinel)。
不带哨兵节点的链表:
这种方式需要显式地定义链表的头指针(first)和尾指针(last)。在这个链表中,第一个节点的前指针指向空(NULL),而最后一个节点的后指针也指向空(NULL)。此时,链表的第一个节点和最后一个节点需要特殊处理,在插入和删除操作时需要不断检查这些指针是否为空,增加了代码的复杂性。
带哨兵节点的链表:
哨兵节点方法简化了链表的管理。哨兵节点始终存在,并且永远不会被移除。无论链表的长度如何,哨兵节点始终作为链表的起点和终点。具体而言:
- 哨兵节点的前指针指向链表的最后一个节点,哨兵节点的后指针指向链表的第一个节点。
- 这样,链表始终保持圆形结构(circular linked list),即最后一个节点的后指针指回哨兵节点,而哨兵节点的前指针指向最后一个节点,形成一个循环。这样,插入和删除操作变得非常简便,因为无需担心链表为空或只有一个元素的特殊情况。
操作简化:
通过使用哨兵节点,链表的第一个节点和最后一个节点不再需要显式存储。它们可以通过访问哨兵节点的**后指针(first)和前指针(last)**来隐式获取。哨兵节点使得每次添加或删除元素时,操作都是一致的,不需要额外的空值检查,因为链表始终有一个完整的节点结构。
- 添加元素:直接插入到哨兵节点周围,哨兵节点的前指针和后指针自动更新,保持链表结构的完整性。
- 删除元素:只需要调整相邻节点的指针,无需特殊处理边界情况。
总结:
使用哨兵节点的双向链表通过简化链表的管理和减少空指针检查,使得链表操作更加简洁高效。通过保持链表的圆形结构,所有操作都可以视作在一个始终存在的结构上进行,避免了额外的判断逻辑,极大地简化了代码的复杂性。
哪个函数拥有指向链表头指针的所有权?
在链表的管理中,通常会有一个问题是“谁拥有链表头指针”的问题。这个问题意味着,需要明确哪个部分的代码或模块负责管理和维护链表的头指针。
链表头指针的所有权
- 拥有链表头指针的模块是指负责管理整个链表的模块或部分。通常,链表的头指针是链表的关键部分,它指向链表的第一个元素(或哨兵节点),并且用于管理链表的操作,如插入、删除、遍历等。
- 这个“拥有”指的是对链表的控制权,比如在需要对链表进行修改(如插入新节点或删除节点)时,这个拥有链表头指针的模块将负责进行相应操作。
管理头指针的责任
- 需要确保头指针始终指向正确的节点。
- 在进行链表操作时(例如,插入或删除节点),必须正确地维护头指针的指向,避免出现指针错误或内存泄漏。
- 如果有多个模块需要访问或修改链表,必须确保对头指针的访问是安全的,避免竞争条件或不一致的状态。
总结
“拥有链表头指针”意味着对链表的管理和控制,确保链表结构在操作中始终保持一致和有效。这个责任通常由特定的模块或函数来承担,确保链表的正确操作和内存管理。
如果你关心缓存,链表不是你总是被告知不要使用的吗?
在处理链表时,通常会听到关于缓存友好的建议,尤其是当链表的数据量较大时。如果代码频繁访问链表,可能会遇到缓存未命中(cache miss)的问题,这会导致性能下降。然而,在不确定代码是否会频繁操作链表时,过早优化缓存并不总是明智的做法。
链表和缓存的关系
- 缓存问题:如果链表的元素分散存储在内存中,访问这些元素时可能会导致缓存未命中。链表的节点在内存中的分布通常是不连续的,因此每次访问节点时,CPU可能需要从内存中获取数据,这可能会降低性能。
- 优化思路:如果发现链表访问性能成为瓶颈,可以考虑将链表节点集中分配在一块内存区域中,这样可以提高数据的缓存命中率,从而改善性能。这种方法会将链表节点组织成一个大的连续内存块,而不是单独分散存储。
是否优化链表的缓存性能?
- 在没有明确的性能瓶颈时,过早地考虑链表的缓存友好性并不必要。优化代码时应根据实际情况进行,例如,如果链表代码并未频繁执行,就不需要在这方面过多优化。
- 在代码执行时,最好使用适合当前需求的数据结构。如果链表能满足当前的需求,就可以继续使用。只有在实际发现链表操作导致性能问题时,才需要考虑将链表替换为其他更合适的、更快的数据结构。
总结
链表在某些场景下可能不适合处理大规模、高频率的数据操作,但如果链表是当前最佳的选择,就不需要立即担心缓存问题。首先确保代码的正确性和简洁性,只有在性能成为瓶颈时,才需要考虑优化数据结构。
platform_allocate_memory 函数是否可以分配比请求的更多的字节,并将大小存储在那里,以避免需要将其传递给 free 函数?
平台的分配函数通常会分配比请求的稍多的字节,并将额外的空间用于存储与该内存块相关的数据,以避免将其传递给释放函数。但这种做法并不总是理想的,因为通常在分配时,已经知道需要的准确大小,例如在某些情况下,已经明确了内存的需求,所以直接按照所需的大小进行分配会更加简便。
在这种情况下,采取一种方法是在每个内存块的末尾附加一个列表头,避免了额外的内存管理复杂性。通过这种方式,可以直接管理内存块的大小和其他元数据,而不需要额外的空间分配和复杂的指针操作。这种做法简化了内存分配过程,使得内存管理更加直接和高效。
在每个资产结构的末尾都有一个列表头,这样做会不会导致缓存大量失效,因为资产结构可能很大?
即使资产结构体可能很大,这种结构不会显著影响缓存,因为缓存是基于较小的内存块(cache line)进行优化的。所以,触及资产结构末尾的链接与触及其他部分的链接没有太大区别。要使这个链表结构更加适应缓存,可以采取的措施是将链表的链接数据块集中处理。具体来说,当前的结构是“资产数据 -> 链接 -> 资产数据 -> 链接”,如果要提高缓存效率,可以将这些链接数据放在一个单独的缓冲区中,这个缓冲区专门存储所有的链接,像是一个独立的区域存储链接(链接 -> 链接 -> 链接),这样每个缓存行可以包含多个链接,从而提高缓存的命中率。
在 RemoveAssetHeaderFromList 中,是否有意义将正在移除的头节点的 prev 和 next 指针清零,还是这只是多余的清理?这样做有什么利弊?
在从链表中移除节点时,清除被移除节点的前驱指针并不是必需的操作,但为了调试方便,可以做一些额外的检查。例如,可以将被移除节点的 header next
和 header previous
设为零,这样就可以通过调试检查来确认是否出现了问题。这并不会影响性能,因为这个操作的频率通常较低。如果这个操作频繁发生,并且成为性能瓶颈,那么可能需要重新考虑使用链表结构,而选择其他更适合高频操作的数据结构。总的来说,进行这种额外的调试检查是没问题的,但要根据具体情况决定是否进行。
在实际游戏中是否会有一个“头颅喷泉”,可能作为万圣节的物品?
在实际游戏中,可能会有一个“喷泉的头”作为某种物品出现,或许它可以作为万圣节的特别道具。这听起来是个不错的创意。
完成这个之后,你将如何重新启用实时代码重载功能?
重新启用实时代码重载其实非常简单,即使我们坚持当前的方案。只需让平台代码的循环实时编辑保留一组头文件,并且当进行保存操作时,将这些头文件写入磁盘即可。然而,我甚至建议可以考虑不这样做,而是在进行实时代码编辑时完全使资源缓存失效,这样我们就不需要存储瞬时内存区域。
至于内联函数,它们基本上是一种将函数的代码嵌入调用点的方法,这样可以减少函数调用的开销,尤其是在一些频繁调用的小函数中。通过这种方式,函数调用的指令被直接替换为函数体的代码,从而避免了调用栈和跳转的成本。但需要注意的是,过多使用内联函数可能导致代码膨胀,因为每次调用函数时都会嵌入一份副本。
你能简要讲解一下内联函数吗?
内联函数其实就是给编译器一个提示,告诉它这个函数很可能是小的,应该直接嵌入到调用它的地方,而不是通过传统的函数调用来执行。这样做的目的是希望通过内联优化提高性能,减少调用的开销。编译器收到这个提示后,可能会决定直接把函数的代码插入到调用位置,而不是产生额外的函数调用指令,从而优化代码执行的效率。
然而,需要注意的是,内联函数并不强制要求编译器一定要进行内联,它只是给编译器的一个建议。现代编译器会根据自身的判断来决定是否进行内联,只有在使用了强制内联(如使用__forceinline
)时,才会强制要求编译器进行内联。所以,使用inline
关键字并不意味着编译器一定会把函数进行内联,它依赖于编译器的优化决策。
此外,过度使用内联函数可能会导致代码膨胀,因为每个调用内联函数的地方都会嵌入该函数的完整代码,这样可能会增加代码的大小和复杂性。因此,是否使用内联函数需要根据具体情况权衡使用。
你最喜欢实现哪种经典数据结构?
在谈到实现数据结构时,最喜欢的结构是单链表,因为它非常简单且实现起来很轻松,操作起来也很方便。有些操作,比如添加元素到链表中,甚至可以通过原子交换来完成,这让整个过程变得非常高效和有趣。移除元素时可能需要一些额外的原子交换操作,但总的来说,添加操作的简单性和高效性让单链表成为了一个非常吸引人的选择。
至于系统是否支持热加载的问题,虽然没有详细说明,但通常热加载指的是在运行时动态加载和更新代码或资源,而不需要重新启动系统或应用。如果系统的设计支持这种动态加载机制,那么它可以通过特定的机制来更新资源或功能,而不干扰当前运行的进程或服务。
这个系统是否/将来是否支持资产的热加载?
关于资产的热加载,系统本身并不支持这一功能,因为没有涉及到艺术家的工作,也没有相关的需求。所有的资产文件都是批量提供的,因此并不需要支持热加载。然而,如果有需要,也可以很容易地实现热加载功能。实现方法很简单,例如,可以为加载位图的代码添加功能,检查文件是否存在并从外部加载位图。系统已经有加载位图的代码,如果需要实现这一功能,只需要在加载过程中检查文件路径是否存在,然后从指定位置加载文件。尽管目前没有这个需求,但如果需要热加载,实际上是非常简单且明显的。
编写你自己的非阻塞动态分配器,而不是使用操作系统的内存系统,是否有意义?
讨论了使用自己的非阻塞动态分配器而不是依赖操作系统的内存系统。对于64位系统,操作系统的内存分配器可能足够使用,但在32位系统上使用可能会有些问题,因此有可能会选择实现自己的分配器。今天展示了如何让内存分配工作,但还未涉及内存布局部分。虽然目前没有决定最终的方案,但有很大的可能性会采用自定义分配器,而不是依赖平台提供的分配器。