回顧和今天的計劃
我們在這里會實時編碼一個完整的游戲,沒有使用引擎或庫,一切都由我們自己做所有的編程工作,游戲中的每一部分,無論需要做什么,我們都親自實現,并展示如何完成這些任務。今天,我們正在處理資產系統的最后一部分——內存管理。昨天,我已經簡要介紹了一下關于這個資產系統的一些內容,今天我想簡單地實現它,讓大家能夠看到最基本的實現方式。之后,我們會逐步過渡到更復雜的、適合實際發布版本的實現方式。
使用操作系統的虛擬內存系統解決我們的內存管理問題
今天我們將討論如何使用虛擬內存來解決內存管理問題,特別是在游戲的地址空間方面。之前在直播前有觀眾提到,是否可以通過虛擬內存來解決這個問題,我的回答是,如果你想要一個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位系統上使用可能會有些問題,因此有可能會選擇實現自己的分配器。今天展示了如何讓內存分配工作,但還未涉及內存布局部分。雖然目前沒有決定最終的方案,但有很大的可能性會采用自定義分配器,而不是依賴平臺提供的分配器。