1. 程式人生 > 其它 >從新建資料夾開始構建ShadowPlay Engine(5)

從新建資料夾開始構建ShadowPlay Engine(5)

本篇序言

從本篇開始,我們要開始構建引擎核心中的系統元件部分,廣義上講其實我們從開始到現在一直都是在構建引擎核心中的系統部分,但嚴格的定義中系統元件大概有這麼幾個:記憶體管理,執行緒管理,檔案管理,時間系統,特殊格式檔案處理(比如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. 本篇結語

先寫到這裡,由於記憶體管理器部分還有許多待除錯的地方未完成,所以文章記錄也不能很好地進行下去。只有準備萬全,才可拿得出手——這是本人的程式設計守則,也是為了在畢業設計答辯的時候不至於演示翻車(笑)。本次內容不多,但複雜的地方還是有,也希望大家可以慢慢消化,為後面的理解提供基礎,俗話也是這麼說的:“老鼠拖木杴,大頭在後面“。好的,下次見~