從新建資料夾開始構建ShadowPlay Engine遊戲引擎(6)

PixelXi'An發表於2022-01-30

本篇序言

在經歷了為期很長時間的除錯以及思維糾錯後,我們可以開始實現我們的記憶體管理模組了,我在前面說過如果各位要繼續跟著學習的話可能會需要一定的計算機組成原理和作業系統的知識,不過在莽程式碼的過程中,我逐漸發現由於本引擎無論是從設計思路上還是程式碼實現上實在是太過於Rookie。所以秉承著簡單到底的精神,我將一部分比較貼近底層的實現交給作業系統自己去完成,我們只要完成相關邏輯就好,所以也就不強制要求大家掌握這些方面的知識。畢竟本人技術力和相關經驗也不是爐火純青,能開發完一個初級的遊戲引擎已經是謝天謝地了(笑),同時也感謝看到這裡的各位,各位能在本人這極度生草的表達下還能堅持不懈地看到這裡,想必這定是不亞於黑魂的極度煎熬和痛苦,本人深感欽佩並表以衷心的感謝。接下來廢話不多說,讓我們開始今天的內容。

1. 記憶體管理(第二部分,除錯模式)

首先讓我來填一下上次留下的坑:我們一起完成除錯模式下引擎的記憶體管理。關於記憶體管理我準備分為兩部分去講,一部分是本節的除錯模式,另一部分是接下面兩節的Release模式。關於我為什麼會這麼分其實是有理由的,在遊戲開發過程中,開發者會選擇除錯模式對工程專案內的所有資料進行跟蹤除錯,我們的記憶體空間也不例外,比如某個由於未能及時釋放記憶體而導致的崩潰錯誤是很難通過單步跟蹤找出來的,尤其是在程式碼量巨大的情況下,而且就像我在上一篇說的,開發者並不想面對VS記憶體偵錯程式裡那宛如《大悲咒》一般的一大堆十六進位制數,畢竟GamePlay就已經夠讓他們頭禿了。至少我們現在可以做的就是為開發者找到未按規範使用的記憶體單元並定位出來,並通過Log列印在控制檯上好讓開發者順利找到問題所在。

接下來聊一下實現思路:首先,我們的記憶體管理必須實現“管理”,也就是說,引擎可以找到全域性範圍內(這裡的全域性範圍就是指遊戲程式)的所有動態記憶體並加以管制,這是其一。其二,我們必須要讓記憶體塊預留一定的空間來記住其申請位置。所以我們的大體思路也便明瞭起來:代理類,也就是說我們可以使用代理類來完成這方面的工作,然後組成一個代理類連結串列,申請記憶體的時候就新新增代理結點上去,釋放記憶體的時候就找到相應的代理結點並釋放掉。話說的這麼好聽,具體怎麼實現?

還記得我上一篇博文裡擺出的那張圖嗎?沒錯,就是它:

pic1.png

這裡的這個代理類並不是我們常識中所說的那個代理類(如果有對代理類概念不熟悉的同學,可以翻看我以前的部落格有關於引擎渲染鏈那一節的內容,那裡有對代理類這個概念的描述),常識中的代理類是獨立於被代理物件之外的,而這個代理類幾乎就是被代理物件自己,原因也很簡單,由於指向我們動態申請的空間的指標有可能是任意型別的指標,所以普通代理類中指向單一型別內容的指標就不再適合這種情況了。可能有人會說:我們可以嘗試著讓引擎中所有的類(甚至包括我們自己設計的類)都繼承自同一個最基礎的基類不就好了,類似於Java那樣。這確實是一個討巧的辦法,但我們不敢保證所有被動態申請的記憶體是我們或者說是引擎定義的類,畢竟基礎資料型別(int、float等)以及標準庫的相關類(string、vector等)也可以拿來申請動態記憶體並交由我們的引擎管理。而且我們這麼設計的代理類同時也節約了一部分非必要的記憶體,畢竟時間複雜度以及空間複雜度在實時渲染裡面是完全失靈的,實時渲染只講究絕對的效率,也就是在算複雜度時常常被我們忽略的常數k。

接下來讓我們開始實現:首先我們可以得到除錯模式下的記憶體管理單元的類定義:

class SPDbgMemManage : public SPMemManage
{
public:
    /**
    * 建構函式,首先構造一個記憶體管理鏈的頭節點並呼叫其建構函式,
    * 或許有同學會問:你不是說要記憶體管理嗎,那這裡的記憶體分配怎麼算?
    * 這裡很好解釋,由於記憶體管理單元本身的一切記憶體分配與釋放流程是可控的,
    * 也就是說,在引擎設計階段我們就知道它該在哪裡做出什麼樣的記憶體操作。
    * 所以在這裡就沒有必要再進行記憶體管理了。
    */
	SPDbgMemManage()
	{
		mb_blockChain = (SPMemDbgBlock*)malloc(sizeof(SPMemDbgBlock));
		mb_blockChain->SPMemDbgBlock::SPMemDbgBlock();
	}
	~SPDbgMemManage()
	{
        // 解構函式,這裡會呼叫一個TerminateBlock()方法,
        // 不過不用著急,我後面會說它是幹什麼的。
		TeminateBlock();
        // 釋放掉記憶體管理鏈頭節點所佔用的記憶體,
        // 由於記憶體管理鏈裡的頭節點只負責相關資料的存取,
        // 所以沒有必要呼叫其解構函式,可直接釋放。
		free(mb_blockChain);
	}
	
    // 這個方法負責記憶體分配,它會返回分配得到的空間的指標
    void* memAllocate(MemLength _uiLength, bool _bIsArray);
    // 這個方法負責回收分配出去的記憶體。
	void memDeallocate(void* _pAddr);
    /** 
    * 為了保證我們的每個被分配出去的記憶體可被跟蹤,我設計了一個資料結構,
    * 叫做CallerLoc,內部實現其實很簡單,就是一個字串型別和一個整數型別,
    * 分別儲存分配操作所在的原始碼位置以及所在行。
    * 在以後如果發生了由於疏忽導致的分配的記憶體未被釋放的情況,
    * 記憶體管理單元可以通過每個記憶體塊裡的這個資料結構快速定位未被釋放記憶體所在程式碼的位置,
    * 並將其列印出來提示給開發者。
    */
	void SetCallerLine(void* _pAddr, std::string _sFileDir, unsigned int _uiLine);
    // 搜尋符合條件的記憶體塊,條件就是指向記憶體單元的指標
	SPMemDbgBlock* SearchBlock(void* p_Addr);

private:
    /**
    * 這就是我們前面提到過的方法,試想一下,
    * 我們的引擎在結束執行前的收尾工作時遇到了一部分開發者分配的記憶體未被回收,
    * 這時引擎該怎麼辦?
    * 是直接丟擲異常停止執行還是說只是跳出警告但還是按照正常流程進行?
    * 我們的引擎其實可以將其強制釋放並報出警告資訊提示開發者,
    * 這麼做的原因是我們的記憶體管理單元此時處在除錯階段,
    * 除錯階段可以這麼去做,但同時很危險的是,它不會呼叫被釋放記憶體的析構,
    * 所以這種強制釋放記憶體的操作也只能在除錯階段使用。
    */
	void TeminateBlock();
    // 多執行緒互斥量
	SPThreadMtx tm_memMtx;
    // 記憶體管理鏈
	SPMemDbgBlock* mb_blockChain;
    // 記憶體管理鏈上此時擁有的記憶體塊數量
	unsigned int i_blockNum = 0;
};

接下來我們的操作就是簡單粗暴且“平易近人”的連結串列操作了,先放出連結串列節點定義:

class SPMemDbgBlock
{
public:
    // 這裡就是負責連結串列節點定位的資料結構的Get方法
	CallLoc& GetCallLoc()
	{
		return cl_callerLoc;
	}
private:
    // 建構函式,為了防止這種敏感的物件被肆意篡改,
    // 所以將預設建構函式的可見性設定為private.
	SPMemDbgBlock() :
		mb_previous(nullptr),
		mb_next(nullptr),
		i_memLength(0),
		b_isArray(false)
	{
		// Nothing here, do not waste your time.
	}
    
    // 這句話就表明這個類“一生只愛一個人”,也就是我們的除錯記憶體管理單元
    // 也就只有它才擁有訪問這個類的最高許可權。
	friend class SPDbgMemManage;
    // 負責定位的資料結構物件
	CallLoc cl_callerLoc;
    // 我也不知道我究竟是為啥會做一個雙向連結串列出來,
    // 後期除錯的時候在這裡栽了一個大跟頭,
    // 其實可以用單向連結串列實現,實現簡單同時也相比於雙向連結串列節約出一個指標來
	SPMemDbgBlock* mb_previous;
	SPMemDbgBlock* mb_next;
    // 本記憶體結點所代理的記憶體塊的大小
	MemLength i_memLength;
    // 判定記憶體塊的性質(順序表還是單個空間)
	bool b_isArray;
};

好了,在做完了以上兩個重要組成部分的定義之後,我們接下來開始挑最重要的兩個部分來談談:Allocate以及Deallocate。我會放出它們實現的方法,具體每一步的意義與解釋我會以註釋的形式標明:

Allocate:

void* SPDbgMemManage::memAllocate(MemLength _uiLength, bool _bIsArray)
{
    /** 
    * 試想這樣一個問題:目前為止我們所考慮的許多情況都是基於單執行緒的,
    * 但是如果此時渲染執行緒和場景讀取執行緒同時需要進行記憶體的分配請求,我們的引擎該怎麼辦?
    * 確實,我們可以在兩個執行緒內同時執行這個方法,同時為其分配記憶體,
    * 但你需要知道的一個前提是,我們不能預測引擎在執行時會同時開闢幾個執行緒,
    * 這也就意味著我們不能在記憶體管理器中留足夠的連結串列頭節點來專門為每一個執行緒進行記憶體管理。
    * 當然目前有些引擎確實擁有這方面的技術,但至少我們的引擎暫時是建立在這方面不可知的條件下,
    * 即表明在上述情況中我們的記憶體管理單元極有可能因為多執行緒的情況而導致失去一部分記憶體的控制權。
    * 這種情況所造成的後果是災難性的,上一個執行緒的記憶體塊可能會被下一個執行緒覆蓋掉,
    * 所以我們需要在這裡為這個函式新增一個互斥鎖來保證這種情況不會發生。
    */
	while (!this->tm_memMtx.ThreadLock());
    // 這裡獲得記憶體塊代理類(記憶體管理鏈的節點)的長度。
	MemLength ui_Block = sizeof(SPMemDbgBlock);
    // 分配空間,分配的空間長度是連續的,組成也就是“節點 + 記憶體塊”。
	void* m_TempMem = (void*)malloc(_uiLength + sizeof(SPMemDbgBlock));
    // 相信這種分支判斷的意義大家在C語言以及資料結構中已經瞭如指掌了,不再贅述
	if (m_TempMem)
	{
         // 接下來我們需要一個記憶體塊型別的指標幫我們代為完成任務
         // 這麼做的目的是,void* 型別的指標無法參與任何操作與運算,
         // 它只有傳遞地址內容的作用。
		SPMemDbgBlock* mb_TempBlock = (SPMemDbgBlock*)m_TempMem;
         // 在得到新空間後按照常理,我們需要手動呼叫節點的建構函式。
		mb_TempBlock->SPMemDbgBlock::SPMemDbgBlock();
         // 接下來的幾步操作就是純粹的連結串列操作了,這裡不做贅述。
		mb_TempBlock->b_isArray = _bIsArray;
		mb_TempBlock->i_memLength = _uiLength;
		mb_TempBlock->mb_next = this->mb_blockChain->mb_next;
		mb_TempBlock->mb_previous = this->mb_blockChain;
		this->mb_blockChain->mb_next = mb_TempBlock;
		if (mb_TempBlock->mb_next != nullptr)
		{
			mb_TempBlock->mb_next->mb_previous = mb_TempBlock;
		}
         // 當然,請記住,在一切操作做完後還請把互斥鎖開啟,
         // 以便於其他執行緒訪問此函式
		this->tm_memMtx.ThreadUnlock();
        /**
        * 這裡有點說頭,我來和大家仔細談一談:
        * 這裡的意思是,我先讓目標指標跳躍一個位置(而並不是跳躍一個位元組或者是一位),
        * 而這一個位置對映的長度就是連結串列節點類的長度,
        * 至於為什麼之後我需要用強制型別轉換(void*)呢?
        * 一個原因是為了迎合函式定義裡面 void* 的返回值,
        * 還有一個最重要的原因是:如果我並沒有做強制型別轉換,C++是會按照原本指標的型別進行記憶體截斷。
        * 這裡的記憶體截斷並不是說真的截掉並將截掉的部分歸還,而是說C++會按照原本的型別去組織這段記憶體,
        * 當我們申請分配的記憶體大小小於連結串列節點類時,這段記憶體的指標卻還是連結串列節點類的指標,
        * 那麼就會有一部分原本不屬於我們所申請分配的記憶體裡的內容被篡改(因為它們連著我們申請分配的記憶體單元)
        * 而這部分記憶體裡存的什麼內容沒人知道,這就要看具體的作業系統如何組織記憶體了,
        * 這也表明,我們有可能更改了作業系統自身的記憶體資料也說不定,這種結果是災難性的(育碧直呼內行)。
        */
		return (void*)(mb_TempBlock + 1);
	}
    // 在分配記憶體失敗時需要做的操作。
	this->tm_memMtx.ThreadUnlock();
	return nullptr;
}

Deallocate:

void SPDbgMemManage::memDeallocate(void* _pAddr)
{
    // 與Allocate一致,這裡不再說明
	while (!this->tm_memMtx.ThreadLock());
    // 這裡我們會使用本類裡的方法SearchBlock去尋找管理相關記憶體的節點。
	SPMemDbgBlock* mb_tgtBlock = this->SearchBlock(_pAddr);
    // 如果引擎要是沒有找到,那就說明已經釋放掉了或者說根本就不存在,
    // 不過我們並不會像CL或者GCC那樣直接Abort,
    // 而是彈出警告告訴開發者,指標所指向的記憶體不存在,
    // 接下來究竟是終止執行還是無傷大雅地繼續執行那就看開發者自己了。
	if (mb_tgtBlock == nullptr)
    {
		// print log: Warning: Mem has be deallocated.
		std::cout << "Warning: Mem \"" << _pAddr << "\" has be deallocated." << std::endl;
		this->tm_memMtx.ThreadUnlock();
		return;
	}
    // 接下來的內容都是記憶體釋放以及連結串列操作,這裡不再過多贅述。
	mb_tgtBlock->mb_previous->mb_next = mb_tgtBlock->mb_next;
	if (mb_tgtBlock->mb_next == nullptr)
	{
		free((void*)mb_tgtBlock);
		this->tm_memMtx.ThreadUnlock();
		return;
	}
	mb_tgtBlock->mb_next->mb_previous = mb_tgtBlock->mb_previous;
	free((void*)mb_tgtBlock);
	this->tm_memMtx.ThreadUnlock();
}

在這些工作做完後,我們的記憶體管理單元工作暫時告一段落,接下來我們要做的就是開始設計Release模式的記憶體管理單元了,這裡的內容比較難以理解,但本人也會竭盡全力去用簡單易懂的方式和各位說明,所以接下來建議各位可以稍微覆盤一下前面的知識,同時可以衝杯咖啡休息一下,在所有準備工作完成後繼續我們接下來的內容。

2. 記憶體管理(第三部分:Release模式,理論)

在上一篇博文裡面我曾說過,單個記憶體空間的分配與釋放過程要佔用一部分的CPU時間,舉個簡單的例子說明一下:我們假設某家銀行有很多保險櫃,管理保險櫃的管理員我們假設他就是CPU,如果我們此時要將我們的資料檔案儲存進去,那麼首先,管理員需要通過一定的手段來找到適合存放我們資料檔案的保險櫃,有時還有可能是多個保險櫃,即使是有查詢表一類的比較快速的存在,但還是會消耗一部分時間,找到後,我們才能將我們的資料檔案儲存進去。同樣的,我們要取出我們的檔案時,管理員也要執行相似的活動再次消耗一部分時間。回到我們的引擎上來:如果我們需要引擎分配一個小的容量儲存空間,它其實耗費的時間相比於大容量儲存空間的分配過程沒有多少變化,這顯然很浪費。有沒有什麼解決辦法?當然有,而且有很多。不過我們這裡主要討論的是來自虛幻引擎3的解決方法。(關於虛幻引擎4的原始碼和文件我也沒看,所以說虛幻引擎4究竟有沒有繼承使用這套記憶體管理方法也不好說)

在記憶體管理上,虛幻引擎3給出的解決方案是對於小於某一個值的記憶體,會通過記憶體池來分配,而大於這個值的記憶體,才會去使用直接分配策略。這麼說有點抽象,讓我來和各位細講一下:

首先我們需要了解記憶體池的概念,記憶體池其實是一整塊比較大的記憶體空間,而在它其中又分為了多個分配粒度,也就是一個個的小記憶體塊,這些記憶體塊在物理上是連續的,記憶體池的分配策略就是:在引擎初始化階段會事先申請分配一至二個定長記憶體池,如果發現了小於分配粒度的記憶體分配請求,那麼引擎會直接將記憶體池中空閒的記憶體塊直接分配出來,這樣會減少一部分的CPU時間。這時有同學會說:這還是沒什麼變化嘛,這也要CPU查詢空閒記憶體啊。但是,請試想一下,是在一個有著幾十甚至上百個程式以及複雜記憶體排程的作業系統環境下分配一塊空閒記憶體方便還是在一個只有16個或者32個記憶體塊的記憶體池裡分配一塊空閒記憶體空間方便?想必各位心裡已經有了答案。在某一個記憶體池再無任何可分配空間後,引擎會將其歸於“已佔用”的記憶體池連結串列中,如果此時再有小記憶體需要分配操作,那麼,引擎就需要再次像作業系統申請分配一另個記憶體池了,這樣也確實會耗掉一部分時間,但比起每次都進行一系列複雜記憶體排程來說,這種記憶體池的分配方法降低了不少時間消耗。

接下來就是要討論分配粒度的問題了,如何確定一個合適的分配粒度以及記憶體池也是一門學問,設立的太大那必然會造成記憶體空間的浪費,設立的太小卻又會造成需要頻繁向作業系統申請分配造成時間上的浪費,所以我們必須要找到一個折中方法。這裡提供一個參考值:頁面大小。我們可以以頁面大小作為我們的分配粒度,然後以“頁面大小 * 2的n次冪”作為記憶體池大小,因為作業系統每次分配記憶體必然是以頁面大小為一個單位來進行記憶體分配的,這個頁面大小具體是多少,取決於CPU,不過目前市面上大部分CPU都是以4096位元組為一個頁面,也就是4kB/頁。看起來是不是很小,並不是,甚至比起我們引擎執行時的記憶體分配情況,這簡直要大上太多。接下來就是記憶體池的大小,既然我們的引擎是64bit的,那麼我們就把記憶體池大小也設定為64kB吧(這個純屬個人喜好,與64bit沒有直接關係),而且64是2的6次方,也容易進行記憶體池的記憶體塊的分割。

在處理完這些問題後,我們就需要開始進行相關內容的設計了,所以接下來請做好準備,一大波殭屍正在來襲(笑)。

3. 記憶體管理(第四部分:Release模式,實現)

讓我們首先從記憶體池的實現開始講起,其實在真正的虛幻引擎3中,記憶體池的實現是一共有42種的,這42種記憶體池在大小上並無區別,最主要的區別是在分配粒度上,甚至精細到8位元組大小的記憶體分配,最多是32kBytes。因為沒有人知道在具體的情況下具體需要分配多少記憶體,分配的要求視情況而定,這42種記憶體池的分配粒度取值其實也是經驗數字,也就是通過窮舉法儘量得到合適的分配粒度。不過鑑於本人目前過草的技術力,編寫這麼多的記憶體管理鏈的管理邏輯怕不是要瘋掉,照著虛幻引擎的標準去製作引擎也沒辦法在明年畢業答辯前準備好,所以綜合考慮,我目前會設計4種不同分配粒度的記憶體池,分別是2kBytes、4kBytes、8kBytes、16kBytes(換算過來就是半頁長、全頁長、雙頁長、四頁長)。當然,這些都是以頁面大小為4kBytes為基礎。基於此所分配出來的記憶體塊數量也不同,分別是32塊、16塊、8塊、4塊,其實還是可以再分下去的,但是已經沒有必要了,再分下去的話只會造成記憶體分配過於頻繁佔據大量時間的結果,這顯然是我們不想看到的。

在開始實現之前,讓我們先放出這麼幾個巨集定義:

#define SP_MEM_POOL_PAGE_NUM 16
#define SP_MEM_POOL_BLOCK_A_NUM 32
#define SP_MEM_POOL_BLOCK_B_NUM SP_MEM_POOL_PAGE_NUM
#define SP_MEM_POOL_BLOCK_C_NUM 8
#define SP_MEM_POOL_BLOCK_D_NUM 4

其中ABCD分別代表四種相同大小但不同規格的記憶體池,巨集定義所代表的整型數值為各種記憶體池內記憶體塊的個數。

首先,讓我們對記憶體池類進行一個定義,定義如下:

class SPMemPool
{
public:
    // 建構函式,引數分別是指向真實記憶體區域的指標、
    // 記憶體池總體大小、以及記憶體池的記憶體塊個數。
    // 這裡主要是使用“大小/個數”來計算當前記憶體池的StorageType
    // MemLength就是自定義的一個unsigned long long的b
	SPMemPool(
		void* _pPoolMem,
		MemLength _iPoolMaxSize,
		MemLength _iPoolBlockSize
	);
    // 解構函式,目前來看沒什麼好說的
	~SPMemPool();
	// 獲取記憶體塊計數器資料,在後面我們會用到,所以將其開放出來。
	int GetMemCounter() { return this->i_memCounter; }
	// 在目標記憶體池中,找到還未被儲存的記憶體塊所在的整數索引。
    // 關於這個整數索引是什麼,我稍後會講。
	int GetFirstBlankBlock();
	// 獲取本條記憶體池的儲存型別,其實就是記憶體池中儲存塊的數量
	int GetStorageType() 
	{
		return this->bp_array[0]->i_storageType;
	}
	// 下面兩個是自增和自減的符號過載,主要是對記憶體塊計數器的自增與自減。
	void operator++();
	void operator--();
	// 通過對符號【】的過載,我們可以省掉一部分繁瑣操作便可達到讀取索引對應的記憶體地址。
	void* operator[](int _index) 
	{
		return (void*)((this->bp_array[_index]) + 1);
	}
	// 切換記憶體塊儲存識別符號。
	void SwitchStorage(int _index, int _iStatus) 
	{
		this->bp_array[_index]->i_isStorage = _iStatus;
	}
	// 對指向下一個記憶體池的指標成員的Get與Set方法。
	SPMemPool* GetNextPool() { return this->mp_next; }
	void SetNextPool(SPMemPool* _mpNext) { this->mp_next = _mpNext; }

private:
    // 指向下一個記憶體池的next指標
	SPMemPool* mp_next;
    // 記憶體塊索引陣列
	MemBlockPoint** bp_array;
    // 指向自己所管理的真實記憶體區域地址
	void* p_poolMem;
    // 記憶體池大小
	MemLength i_poolMaxSize;
    // 記憶體池對應的記憶體塊大小
	MemLength i_poolBlockSize;
    // 記憶體塊計數器
	int i_memCounter;
};

在上面的定義中,有這麼一個東西需要給大家解釋一下:整數索引。在我們記憶體池的定義中,有一個私有資料成員“bp_array”,接下來放出它的定義:

struct MemBlockPoint
{
    // 指向單一記憶體塊地址的指標
	void* p_blockPos = nullptr;
    // 判定是否被儲存
	int i_isStorage = 0;
};

在這裡使用整型值來確定對應的記憶體塊的儲存狀態而不是布林值,只是因為為了記憶體對齊好看一些(不過Windows還是會按照16位元組儲存,對哦,誒嘿)。

綜上所述,我們目前的一個記憶體池結構大概可以這樣去表示:

pic2.png

在記憶體池的表述完成後,我們開始設計記憶體管理器,由於在前面我已經講過我們的記憶體管理器擁有四種不同規格的記憶體池可以分配,所以我們至少得在記憶體管理器的私有資料成員中新增八個記憶體池指標,即每種規格的記憶體池都需要兩個指標(blank以及full)來管理,因為我們知道頻繁進行申請與釋放操作的記憶體單元有很大的機率會存在在blank所指向的記憶體池裡,所以設定兩個記憶體池指標會方便在釋放過程中快速定位我們所需要釋放的單元。在這些概念都已然明瞭後,我們便可以放出記憶體管理器的定義了:

class SPRlsMemManage : public SPMemManage
{
public:
    // 建構函式與解構函式,目前暫時沒有什麼可以在裡面完善的內容
	SPRlsMemManage();
	~SPRlsMemManage();

    // 繼承自記憶體管理器基類SPMemManage的兩個方法:記憶體分配與釋放
	void* memAllocate(MemLength _uiLength, bool _bIsArray);
	void memDeallocate(void* _pAddr, MemLength _uiLength);

private:
    // 私有化複製建構函式,畢竟我們的記憶體管理器是要掌握全域性記憶體分配的
	SPRlsMemManage(SPRlsMemManage&);

    // 在記憶體管理器析構的同時,對殘存在記憶體池指標上的所有記憶體池進行強制釋放
    // 下面的強制釋放記憶體塊方法也是同理。
	void TerminatePools(SPMemPool*&);
	void TerminateMemBlocks();

    // 多執行緒互斥量物件
	SPThreadMtx tm_memMtx;

    // 八個記憶體池指標
	SPMemPool* mp_mBlock;	// Minimum (half) page.
	SPMemPool* mp_mFull;
	SPMemPool* mp_eBlock;	// Entire page.
	SPMemPool* mp_eFull;
	SPMemPool* mp_dBlock;	// Double pages.
	SPMemPool* mp_dFull;
	SPMemPool* mp_qBlock;	// Quad pages.
	SPMemPool* mp_qFull;

    // 當分配的記憶體大於最大記憶體塊的大小時,記憶體管理器會為其直接分配記憶體
    // 這裡的原理和除錯模式下的記憶體分配器一致,不再過多贅述。
	SPRlsMemBlock* mb_memBlock;

    // 記憶體池的各個引數:記憶體塊大小,記憶體池大小等
	MemLength ml_poolSize;
	MemLength ml_pageSize;
	MemLength ml_mBlockSize;
	MemLength ml_eBlockSize;
	MemLength ml_dBlockSize;
	MemLength ml_qBlockSize;
};

參考上面的定義,我們也便可以給出release模式下記憶體管理器的結構表示:

pic3.png

接下來,讓我們一步步實現其中的各個方法,首先是記憶體分配,我們知道的是在分配前,分配器需要知道需分配的大小。這一點與vc runtime中new的定義是一樣的,畢竟只有知道大小,我們才可以為其制定分配策略,所以我們大致可以這麼做:

void* memAllocate(MemLength _uiLength, bool _bIsArray)
{
    if(/*待分配大小小於半頁長*/)
    {
        // 分配最小池
    }
    else if(/*待分配大小位於半頁長與全頁長之間*/)
    {
        // 分配全頁面池
    }
    else if(/*待分配大小位於全頁長與雙頁長之間*/)
    {
        // 分配雙頁面池
    }
    else if(/*待分配大小位於雙頁長與四頁長之間*/)
    {
        // 分配四頁面池
    }
    else
    {
        // 直接分配
    }
}

接下來就是各個記憶體池的分配邏輯了,由於除了記憶體池規格外其餘都差不多,所以這裡只以最小池的分配邏輯說明:首先我們需要考慮的是最普遍的情況,即在已知存在的記憶體池中分配記憶體,那麼我們首先要做的就是在這個已知存在的記憶體池中尋找空記憶體塊,那麼我們的邏輯可以這麼寫:

if(this->mp_mBlock->mp_next)
{
	// Allocate pointer from pool which is exist.
	void* p_tgtPointer;
    // 這裡呼叫了MemPool裡面的GetFirstBlankBlock方法:尋找第一個未被儲存的儲存塊
	p_tgtPointer = this->mp_mBlock->mp_next->GetFirstBlankBlock().p_blockPos;
    // 對找到的記憶體塊的屬性設定
    // 其實這裡我們完全不用擔心我們找不到空塊,因為我們的記憶體池在每次分配完後都會進行自檢
    // 及池子是否已滿,若是滿了,則自動掛至Full指標。
	this->mp_mBlock->mp_next->GetFirstBlankBlock().i_isStorage = 1;
	this->mp_mBlock->mp_next->i_blockCounter += 1;
    // 這裡我們需要考慮的是假如我們此時分配的記憶體恰好是最後一個空塊
    // 也就是說當它被分配出去後,池子恰好滿了。
    // 我們這個時候就要將這個記憶體池掛至Full指標。
	if (this->mp_mBlock->mp_next->i_blockCounter == this->mp_mBlock->mp_next->qword_storageType)
	{
		// Pool is full after allocate.
		this->mp_mBlock->mp_next->i_isFull = 1;
        
        // 以下是基礎指標操作,不做過多的贅述。
		SPMemPool* mp_tempPointer = this->mp_mBlock->mp_next;
		mp_tempPointer->mp_previous = this->mp_mFull;
		this->mp_mBlock->mp_next = mp_tempPointer->mp_next;
		if (mp_tempPointer->mp_next)
			this->mp_mBlock->mp_next->mp_previous = this->mp_mBlock;
		mp_tempPointer->mp_next = this->mp_mFull->mp_next;
		this->mp_mFull->mp_next = mp_tempPointer;
		if (mp_tempPointer->mp_next)
			mp_tempPointer->mp_next->mp_previous = mp_tempPointer;
	}
	this->tm_memMtx.ThreadUnlock();
	return p_tgtPointer;
}

接下來我們要考慮的是第二種情況,即上一次分配後我們正好沒有待用的空池了,這一次分配我們需要重新向作業系統申請分配一個新池,那麼邏輯如下:

else
{
	// Allocate new pool.
	void* p_tempPointer = nullptr;
	p_tempPointer = malloc(sizeof(SPMemPool) + this->ml_poolSize);
	if (!p_tempPointer)
	{
		this->tm_memMtx.ThreadUnlock();
		return nullptr;
	}
	SPMemPool* mp_tempPool = (SPMemPool*)p_tempPointer;
	mp_tempPool->SPMemPool::SPMemPool
	(
		nullptr, this->mp_mBlock, this->ml_poolSize,
		SP_MEM_POOL_BLOCK_A_NUM
	);
    // 關於這裡的+1是由於我們已經將指標強制轉換為了SPMemPool型別,
    // 所以+1正好就是跳過一個SPMem物件所佔據的記憶體空間
    // 而不是跳過一個位元組,還請注意。
	MemBlockPoint* bp_tempPointer = (MemBlockPoint*)(mp_tempPool + 1);
	for (int i = 0; i < SP_MEM_POOL_BLOCK_A_NUM; i++)
	{
		mp_tempPool->bp_array[i].i_isStorage = 0;
		mp_tempPool->bp_array[i].p_blockPos = 
			(void*)(bp_tempPointer + (i * (this->ml_poolSize / SP_MEM_POOL_BLOCK_A_NUM) / sizeof(MemBlockPoint)));
	}
	mp_tempPool->bp_array[0].i_isStorage = 1;
	mp_tempPool->i_blockCounter += 1;
	this->mp_mBlock->mp_next = mp_tempPool;
	this->tm_memMtx.ThreadUnlock();
	return mp_tempPool->bp_array[0].p_blockPos;
}

記憶體分配的邏輯解決後,接下來就是記憶體釋放了,我們也需要待釋放記憶體的大小,畢竟這樣可以讓我們的記憶體管理器更快的定位記憶體所在位置,所以它也會具有一個類似於Allocate的框架:

if(/*待釋放大小小於半頁長*/)
{
    // 在最小池裡面找
}
else if(/*待釋放大小位於半頁長與全頁長之間*/)
{
    // 在全頁面池裡找
}
else if(/*待釋放大小位於全頁長與雙頁長之間*/)
{
    // 在雙頁面池裡找
}
else if(/*待釋放大小位於雙頁長與四頁長之間*/)
{
    // 在四頁面池裡找
}
else
{
    // 在SPRlsMemBlock鏈裡面找
}

接下來同理,我也會只以半頁長的釋放為例進行邏輯講解:

// Pool deallocate.
if (_uiLength <= this->ml_mBlockSize)
{
    /*
    * 首先為了能快速定位需要釋放的記憶體地址的位置,我們會在未滿池中去找
    * 這是由於小記憶體單元的釋放次數與其自身大小大致可以說是成反比,
    * 所以首先我們假設我們需要釋放的記憶體存在於未滿池中。
    */
	if (this->mp_mBlock->mp_next)
	{
		SPMemPool* mp_tempPointer = nullptr;
		mp_tempPointer = this->mp_mBlock->mp_next;
		while (mp_tempPointer)
		{
			for (int i = 0; i < mp_tempPointer->qword_storageType; i++)
			{
                // 由於我們是將指標儲存在一個定長陣列內,所以我們可以直接對陣列進行遍歷查詢。
				if ((_pAddr == mp_tempPointer->bp_array[i].p_blockPos) && (mp_tempPointer->bp_array[i].i_isStorage))
				{
					mp_tempPointer->bp_array[i].i_isStorage = 0;
					mp_tempPointer->i_blockCounter -= 1;
					if (!mp_tempPointer->i_blockCounter)
					{
                          // 若是此時釋放掉記憶體後我們的池子正好變成了空池,那麼就將其釋放掉。
						mp_tempPointer->mp_previous->mp_next = mp_tempPointer->mp_next;
						if (mp_tempPointer->mp_next)
							mp_tempPointer->mp_next->mp_previous = mp_tempPointer->mp_previous;
						free((void*)mp_tempPointer);
					}
					_pAddr = nullptr;
					this->tm_memMtx.ThreadUnlock();
					return;
				}
			}
			mp_tempPointer = mp_tempPointer->mp_next;
		}
	}
	
    /*
    * 這裡就是第二種假設:在未滿池中未找到我們需要的地址,那麼就在已滿池中去尋找
    */
	if (this->mp_mFull->mp_next)
	{
		SPMemPool* mp_tempPointer = nullptr;
		mp_tempPointer = this->mp_mFull->mp_next;
		while (mp_tempPointer)
		{
			for (int i = 0; i < mp_tempPointer->qword_storageType; i++)
			{
				if ((_pAddr == mp_tempPointer->bp_array[i].p_blockPos) && (mp_tempPointer->bp_array[i].i_isStorage))
				{
                     // 找到以後可不是隻進行釋放就完事了
                     // 因為釋放掉以後它肯定就是未滿池了,這時候我們就要將其掛至未滿池的指標了。
					mp_tempPointer->bp_array[i].i_isStorage = 0;
					mp_tempPointer->i_blockCounter -= 1;
					mp_tempPointer->mp_previous->mp_next = mp_tempPointer->mp_next;
					if (mp_tempPointer->mp_next)
						mp_tempPointer->mp_next->mp_previous = mp_tempPointer->mp_previous;
					mp_tempPointer->mp_next = this->mp_mBlock->mp_next;
					if (this->mp_mBlock->mp_next)
						this->mp_mBlock->mp_next->mp_previous = mp_tempPointer;
					this->mp_mBlock->mp_next = mp_tempPointer;
					mp_tempPointer->mp_previous = this->mp_mBlock;
							
					_pAddr = nullptr;
					this->tm_memMtx.ThreadUnlock();
					return;
				}
			}
			mp_tempPointer = mp_tempPointer->mp_next;
		}
	}
	// print log: Warning: Mem has been deallocated.
    // 若是實在未找到,那別無他法,只能輸出一個警告資訊告訴開發者未找到或者已被釋放。
	string info = string("Mem has been deallocated.");
	WarningLog(SHADOW_ENGINE_LOG, info);
	this->tm_memMtx.ThreadUnlock();
	return;
}

4. 本篇結語

這次貌似是目前碼的字數最多的一次,也算是拖更了這麼久攢一次大的放出來,畢竟是一個比較龐大的系統,說的詳細一點還是比較好的,在達到了覆盤思路的同時也有利於讓各位理解,不過本人生草的表達能力也說不出個什麼花來,如果可以讓各位理解那是最好,如果沒有理解的話也歡迎提出問題,本人會逐一解答。由於這是我的技術博文,並不涉及到任何商業運營,所以可能不會像商業運營的UP那樣快速更新以及回覆。雖說目前研考也結束了,但之後的許多事情也把人壓得抽不出太多時間,所以博文更新時間還是不定期的,這一點還請大家多多諒解,為下一次的內容做一下預告:目前我們只是完成了設計與實現,所以我們接下來就需要將其順利移植到我們的引擎上並能讓引擎正常工作,再一個,我們也會去實現引擎內的基本物件系統,好的,暫時就先說到這裡,祝願看到這篇博文的各位新年快樂!下次見!(溜~)

相關文章