本篇序言
從本篇開始,我們要開始構建引擎核心中的系統組件部分,廣義上講其實我們從開始到現在一直都是在構建引擎核心中的系統部分,但嚴格的定義中系統組件大概有這么幾個:記憶體管理,執行緒管理,檔案管理,時間系統,特殊格式檔案處理(比如XML,json檔案等),接下來的文章更新間隔可能會長一些,說不定哪天就斷更了,誒嘿,
正如在上一篇文章中我所承諾的那樣,接下來我會用大概3至4篇博文的長度來描述本引擎執行緒管理和記憶體管理部分,這兩個部分同時也是系統組件中不怎么容易理解并且工程量最大的兩個模塊,我會盡量用通俗易懂的方式描述,希望可以為各位的游戲引擎開發提供幫助,
1. 記憶體管理(第一部分,理論)
這是我們的引擎開發到目前所面臨的最復雜的一個單元,所以,我會在我們的引擎專案之外創建一個新專案用來編碼與測驗我們的記憶體管理單元與執行緒管理單元,等我們的以上兩個單元完全沒有什么問題時,我們再將它們遷移回我們的引擎專案當中,這兩個單元說是很復雜,其實也不怎么復雜(聽君一席話,如聽一席話),至少比《雷神之錘Ⅲ》的那個求平方根倒數的“WTF”函式要好理解得多,那么讓我們開始吧!
還記得上篇文章中我所講到的我們一直在進行的“危險行為”么?盲目地使用大量的new以及delete而不加管理很容易造成記憶體泄漏,空指標呼叫,野指標等問題,這只是其一,其二,直接使用new分配會浪費大量的運行時間,為什么這么說?如果各位有使用虛幻或者unity的記憶體監視的經驗的話應該可以發現小記憶體的分配與釋放是最頻繁的,比如游戲內事件物件,紋理,AI-Controller物件或者Shader物件等,這些小記憶體大致都不超過32千位元組(實際上32KB大小的記憶體分配也不怎么頻繁,這里其實是取經驗數字),而new與delete進行分配與釋放小記憶體空間的操作所消耗的時間成本可是非常大的,所以我們為什么不在引擎初始化前就在記憶體中專門劃分一塊區域用來分配給這些小記憶體的相關操作?也就是說,小于某個大小標準的記憶體分配要求我們可以在我們的專門區域為其分配記憶體空間,也就是將這部分小記憶體的操作權從作業系統交到我們引擎的手上,而大于這個標準的我們直接為其分配記憶體,這樣可以在可接受范圍內的記憶體浪費的情況下保證引擎運行的高效性,
其實我上面描述的這個演算法就是虛幻引擎3的記憶體管理辦法,當然在細節上,我最終在引擎中的實作和虛幻3是不同的,但是大致思路一樣,然而這個分配演算法由于其精妙性也導致了它的理解門檻有些高,所以希望各位有一定的計算機組成原理以及作業系統的基礎,沒有的話也沒太大關系,我會對一些概念做詳細說明,涉及的相關知識點不是很多,所以還請不用擔心,
就像我在上面提到過的,在開發除錯階段我們需要記憶體監視來告訴我們我們的記憶體占用、記憶體對齊情況、記憶體塊分配、記憶體塊釋放以及記憶體塊大小等資訊,而語言層面自帶的記憶體分配可并沒有這方面的介面供我們呼叫,所以我們就必須要自己組織記憶體管理的資料結構,保證在使用作業系統提供的API時可以跟蹤到具體位置,說的這么危言聳聽,但其實很簡單,在開發調式模式下,我們的記憶體管理并不需要遵循我上述說到的快速分配演算法,我們主要是為了讓游戲開發人員在開發程序中可以通過記憶體監視追蹤到出問題的地方,所以我們可以這樣去設計在開發除錯階段的記憶體管理器:
就像上一篇文章中我們構建渲染鏈的設想一樣,引擎是不知道你會申請分配多少或釋放多少記憶體,對于B/S的管理系統來說服務器使用一個線性Pool以及排隊等候就可以解決問題,但游戲引擎的實時性要高于B/S的管理系統,游戲玩家可不希望因為預留線性空間不足導致只扣礦石而不生產單位,從而導致貽誤戰機,目前比較經濟的一個方法也就是使用鏈表來管理這些記憶體塊,
所以,該怎么做?
為了避開系統的自動分配從而導致很難跟蹤記憶體(雖說各種IDE或者作業系統也提供了一大堆的記憶體跟蹤管理的工具以及插件,不過想要找到自己申請分配的那塊記憶體,不多下點功夫還是很難找的到的,多數游戲開發人員并不想將大量的時間與精力浪費到一連串的16進制數里面,而且游戲引擎是一套工具集,它有義務為開發人員提供更直觀的記憶體監視結果),我們有兩條路可走:C語言的malloc或者是匯編,不過由于我們的引擎只能在x64環境下運行(這是當初構建專案時已經設計好的),也就導致了我們無法直接在代碼檔案中使用嵌入式匯編陳述句“__asm”,還有,因為本人技術不過關,win32的一些指令到了x64就要重新考慮了,而且malloc的分配后的記憶體結構也便于理解,所以我們選擇malloc以及free,比起new以及delete更加自由也更加基礎一些,
而每個鏈表的結點我們可以這樣設計:

這次我依舊使用了使用代理類MemoryBlock,但代理類與被代理物件并不是通過指標相聯系了,而是將它們通過一段連續的記憶體聯系起來,因為這次我們的被代理物件就是記憶體塊啊(笑),而且代理類沒有義務也沒有權限去了解被代理記憶體中物件的型別,這種聯系方式使得記憶體在釋放時也會更有效率一些,
好了,大致的設計構想我們已經總結完畢,接下來讓我們考慮一些小細節問題,假設我們最終寫成的分配器以及釋放器的宣告如下所示:
void* memAllocate(unsigned long long _udlLength, bool _bIsArray); // 分配器
void memDeallocate(void* _pBlock, bool _bIsArray); // 釋放器
看起來并沒有什么問題,很好解釋:分配器需要的引數是待分配記憶體的長度以及是否是陣列的條件值,釋放器需要的引數是待釋放的指標地址以及是否是陣列的條件值,也就是說在每次分配記憶體時我們都需要向里面填入相應的引數,這就是對比new來說一個稍微麻煩的一點了,既然new運算子有這么好的特性,那我們就把它用在我們的分配器上,但有聰明的同學會立馬提出質疑:你不是說過不能隨便用new以及delete關鍵字嗎?那么,我們是否可以通過多載這兩個關鍵字得到我們想要的效果?幸運的是,C++支持對new進行運算子多載,所以,我們的作業立馬就容易得多,我們可以在引擎的作用域內多載new以及delete運算子,然后在多載函式體里呼叫我們的分配器與釋放器,用這種“偷天換日”的方法在不影響游戲開發人員的開發效率下完成引擎對記憶體的管理作業,
也就是說,我們可以這么去寫:
// 負責單個記憶體塊分配作業
void* operator new(unsigned long long _udlLength)
{
return memAllocate(_udlLength, false);
}
// 負責陣列分配作業
void* operator new[](unsigned long long _udlLength)
{
return memAllocate(_udlLength, true);
}
// 負責單個記憶體塊釋放作業
void operator delete(void* _pBlock)
{
return memDeallocate(_pBlock, false);
}
// 負責陣列釋放作業
void operator delete[](void* _pBlock)
{
return memDeallocate(_pBlock, true);
}
在后面的第二部分,我們會開始著手構建在除錯環境下的記憶體分配與管理器,
2. 執行緒管理單元(第一部分)
我們先不著急接著往下進行我們的記憶體管理部分,在繼續之前請容大家與我解決一些小障礙,就像我在構建渲染核心時候說的一樣,OpenGL的“初始化——渲染回圈——釋放”是一個典型的狀態機模型,也就是說它是遵循單執行緒模式的,而且引擎的所有非初始化型別的處理陳述句都必須運行在渲染回圈中以確保實時更新,這看起來并沒有什么問題,但問題也正出在這里,過長的處理步驟必然會占用更多的處理器時間,也就是說回圈體中的一次回圈執行時間會更長,這樣必然導致游戲幀數低下,所以我們必須要將某些對實時性要求不高的處理陳述句從渲染回圈中抽離出來,專門為它們開辟一個或者多個執行緒,充分利用多核處理器的優勢(因為現在也沒多少人用單核處理器了),在不影響內容展現的同時,達到引擎運行的高效率,
有兩種實作多執行緒的方法:一種是作業系統提供的API(系統層面),另一種是在C++11中開始提供的thread標準庫(語言層面),這里我們選擇語言層面的實作,舉個例子來說明一下兩者的差別以及我為什么要選用語言層面的實作:
我們先來看一下Windows啟動執行緒的方法:(摘自Microsoft Docs)
uintptr_t _beginthreadex( // NATIVE CODE
void *security,
unsigned stack_size,
unsigned ( __stdcall *start_address )( void * ),
void *arglist,
unsigned initflag,
unsigned *thrdaddr
);
因為其他的引數目前沒有必要去深入,所以挑兩個最重要的講:函式指標start_address以及空指標arglist,start_address也就是我們要在執行緒中運行的函式,而且Windows在它的引數方面有著嚴格的定義,即必須為void*,空指標arglist就是傳參的,Windows只給我們提供了一個引數的預留位置,如果我們想要將更多的引數傳入函式內,我們就必須要用到結構體了,這不失為一種巧妙的方法,然而,在對執行緒需求量大的時候,越積越多的結構體定義除了降低代碼可讀性外并沒有任何好處,而且,我們傳入的是指標,也就是說我們可能要面臨著更頻繁且更復雜的記憶體分配,雖說作業系統層面的執行緒管理更加高效,但以這種犧牲掉記憶體操作時間來換取執行緒的高運行效率的做法恐恕本人無法接受,當然,它也有優點,比如它擁有對于安全性的操作,執行緒控制權等等標準庫所沒有的功能,
接下來我們再看一下標準庫啟動執行緒的方法:
template<class _Func, class..._Args>
std::thread::thread(_Func _f, _Args... _args);
這里也就是標準庫的一大強項:支持可變引數模板,也就是說我們完全沒必要宣告為每一個待處理函式設計宣告一個新的結構體,直接將引數傳入即可,而且相比Windows最大的一個優點就是支持多平臺遷移,但標準庫的執行緒操作簡單到過于離奇,所以執行緒相關的操作問題還得我們自己去考慮,
看到這也許各位會問,那我們就直接呼叫相關層面的API即可,為什么還要大費周章的創建一個執行緒管理器?正如字面意思,就是為了方便管理執行緒,如果各位還是有些不理解,還請隨我繼續接下來的探索:
標準庫中是以類的形式抽象執行緒的,即我們創建一條新執行緒就是創建了一個新的物件,但由于標準庫啟動執行緒是使用上述的建構式的,也就是說我們可以在任何時間任何地方更改我們執行緒物件所負責的執行緒而不必關心當前負責的執行緒是否結束,聽起來就是一個極富災難性的操作,而且在分配執行緒后而不及時識訓輕者會造成系統資源浪費,重者則直接導致“Abort() has called”,所以我們很有必要為引擎構建一套執行緒管理單元,
下圖便是我為本引擎設計的執行緒管理單元的大致結構:

本執行緒管理單元由四個部分組成:執行緒管理器(SPThreadManager,繼承自引擎的鏈表資料結構),執行緒物件(SPThreadObject),執行緒ID(SPThread)以及互斥量(SPThreadMtx),而用戶創建新執行緒時得到的只是執行緒ID,這一點是從Windows那里學來的,就如Windows的HANDLE一樣,用戶不能直接通過執行緒ID來訪問執行緒,他們需要本引擎提供的方法以及他們手里掌握的執行緒ID來對執行緒進行操作,這樣大大增加了引擎對執行緒的掌控,互斥量作為對執行緒相關操作限制的開關存在,
先來介紹一下執行緒ID,雖說用0~32767作為執行緒ID完全夠用,但總感覺缺少了一點專業氣息,顯得我是一個“new money”(笑),實際上還有一點考慮,我們希望在后續的引擎功能擴展中加入執行緒的相關限制,而且我們希望這些限制保存在ID里而不是再開辟一至多塊記憶體用來存放布林值,這時候一個整型數字可就不能再存放這么多資訊了,所以我們使用GUID作為我們引擎的ID,幸運的是,Windows提供了這方面的生成API:coCreateGuid,我們將其稍微的包裝一下就可以得到我們的執行緒ID物件了,宣告如下:
class SPGlobalID
{
public:
// 建構式與解構式,在初始化的時候就生成一個GUID
SPGlobalID();
SPGlobalID(const SPGlobalID&);
~SPGlobalID();
// 用來做比較用的,主要對比的是GUID值
bool operator ==(Shadow::SPGlobalID& _right);
bool operator !=(Shadow::SPGlobalID& _right);
// 獲取相關值,只能用作列印或其他不修改資料的用途
const GUID& GetWPId() const;
const std::string& GetSId() const;
// 設定友元函式,用來在除錯資訊上輸出ID
friend std::ostream& operator <<(std::ostream& _outObj, SPGlobalID& _id);
private:
std::string s_ID;
GUID wp_ID;
};
在解決了ID的問題后,我們接下來著手解決執行緒管理單元,與記憶體管理以及渲染鏈一樣,引擎除了幾個自己用的基礎的執行緒外,根本不知道開發人員在開發游戲或插件時又會申請分配多少個執行緒,所以,在這里我們還是使用鏈表來解決問題,在前面講記憶體管理的時候我曾說過,頻繁的記憶體分配與釋放會消耗大量的CPU時間,而鏈表則是這其中的常客了,這里我借鑒了一些來自于后端開發者的經驗:執行緒池,也就是說引擎在初始化時會首先創建一定數量的執行緒物件構成一個執行緒池,如果分配行為產生,則會首先在執行緒池中尋找空閑執行緒物件,執行緒池內沒有空閑執行緒物件時才會申請一個新的執行緒物件,這樣在一定程度上可以減少CPU時間的浪費,下面是執行緒管理單元的宣告:
// 正如各位所看到的,我使用typedef來對執行緒物件以及執行緒ID做了名稱定義
typedef std::thread SPThreadObject;
typedef SPGlobalID SPThread;
class SPThreadManager :
protected LinklistManager<SPThreadObject, SPThread>
{
public:
SPThreadManager();
~SPThreadManager();
// 以下兩個方法便是向執行緒管理單元申請分配執行緒以及釋放執行緒
SPThread ApplyThread();
void TerminateThread(SPThread&);
// 由于我們需要使用可變引數模板,而且這個函式內也并未出現分支判斷結構,所以定義在這里
template <class Fp, class ...Args>
void ThreadStart(SPThread _thread, Fp _func, Args... _args)
{
LinkListNode<SPThreadObject, SPThread>* ln_pointer = this->operator[](_thread);
ln_pointer->GetContent() = std::thread(_func, _args...);
}
// 停止相關執行緒
// 但是我們沒有理由強制停止,所以這個方法僅僅是呼叫了一下joinable而已
void ThreadEnd(SPThread);
private:
// 這里我用標準庫的鏈表來標記已被占用執行緒以及空閑執行緒(執行緒池)
std::vector<SPThread> stdv_originalPool;
std::list<SPThread> stdl_idleThreads;
std::list<SPThread> stdl_occupiedThreads;
};
3. 本篇結語
先寫到這里,由于記憶體管理器部分還有許多待除錯的地方未完成,所以文章記錄也不能很好地進行下去,只有準備萬全,才可拿得出手——這是本人的編程守則,也是為了在畢業設計答辯的時候不至于演示翻車(笑),本次內容不多,但復雜的地方還是有,也希望大家可以慢慢消化,為后面的理解提供基礎,俗話也是這么說的:“老鼠拖木锨,大頭在后面“,好的,下次見~
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/296256.html
標籤:其他
