從新建文件夾開始構建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更加自由也更加基礎一些。

而每個鏈表的結點我們可以這樣設計:

pic1.png

這次我依舊使用了使用代理類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”。所以我們很有必要為引擎構建一套線程管理單元。

下圖便是我為本引擎設計的線程管理單元的大致結構:

pic2.png

本線程管理單元由四個部分組成:線程管理器(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. 本篇結語

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM