引擎之旅 Chapter.2 執行緒庫

2022-09-09 18:00:21

預備知識可參考我整理的部落格

程式碼結構

一個簡單的執行緒庫需要實現的功能主要有:

  • 建立和結束一個執行緒
  • 設定執行緒的優先順序
  • 提供一些執行緒排程的介面
  • 查詢執行緒的狀態
  • 退出一個執行緒
  • 多執行緒執行時同步的解決方案
  • 執行緒池(非必要):多用於網路請求、單一且快速能解決的任務。

利用C++類別的生命週期,,我們可以實現一個執行緒的建立放在建構函式上,結束放在解構函式上。當想要實現一個特殊執行緒時,就採用繼承的方式拓展這個執行緒類。

  • 一個基本的類框架如下
//Thread.h     執行緒基礎類別
class Thread
{
    public:
        Thread()
        {
            //Create a thread
            //函數入口為:ThreadMain((void)this);
        }
        
        ~Thread()
        {
            //Terminate a thread
        }
        
    
    protected:
        //執行緒執行的純虛擬函式,子類重寫這個函數來說明執行緒需要執行的任務
        virtual int Run()=0;  
        
    private:
        //此函數會呼叫(Thread*)param->Run();
        static unsigned _stdcall ThreadMain(void* param);
}


//ThreadSync.h    執行緒同步的方式
//1.原子操作函數
//2.關鍵段
//3.事件核心物件
//4.可等待的計時器核心物件
//5.號誌核心物件
//6.互斥量核心物件

執行緒同步的實現

首先我們要明確的一點是:使用者方式的執行緒同步較為簡單且獨立,僅作稍微的封裝為引擎統一風格的程式碼即可;而物件核心的同步方式是比較統一的,它們的阻塞與恢復是由等待函數(WaitForSingleObject或WaitForMultipleObjects)來實現的,引起它們其實可以統一為一種型別。

原子函數與關鍵段

使用者方式的執行緒同步比較簡單,Windows API也給的比較清楚,下面是相關的程式碼展示。

Interlocked家族函數的封裝

  • 程式碼
//原子操作:++
//*pValue++
FORCEINLINE void TInterlockedIncrement(unsigned long long* pValue)
{
	::InterlockedIncrement(pValue);

//原子操作:--
//*pValue--
FORCEINLINE void TInterlockedDecrement(unsigned long long* pValue)
{
	::InterlockedDecrement(pValue);

//原子操作:+=
//*added+=addNum
FORCEINLINE void TInterlockedExchangeAdd(PLONG added, LONG addNum)
{
	::InterlockedExchangeAdd(added, addNum);

//原子操作:-=
//*added-=addNum
FORCEINLINE void TInterlockedExchangeSub(PULONG subed, LONG subNum)
{
	::InterlockedExchangeSubtract(subed, subNum);

//原子操作:=
//target=lvalue;
FORCEINLINE LONG TInterlockedExchange(PLONG target, LONG value)
{
	return ::InterlockedExchange(target, value);

//原子操作:=
//pTarget=&pVal
FORCEINLINE PVOID TInterlockedExchangePointer(PVOID* pTarget, PVOID pVal)
{
	return ::InterlockedExchangePointer(pTarget, pVal);

//原子操作:
//if(*pDest==compare)
//  *pDest=value;
FORCEINLINE LONG TInterlockedCompareExchange(PLONG pDest, LONG value, LONG compare)
{
	return ::InterlockedCompareExchange(pDest, value, compare);

//原子操作:
//if(*pDest==pCompare)
//  pDest=&value;
FORCEINLINE PVOID TInterlockedCompareExchangePointer(PVOID* ppDest, PVOID value, PVOIpCompare)
{
	//如果ppvDestination和pvCompare相同,則執行ppvDestination=pvExchange,否則不變
	return ::InterlockedCompareExchangePointer(ppDest, value, pCompare);
}

其實上面的程式碼就是將Windows API 修改了函數命名。我個人認為,這種寫程式碼的方式是有益處。因為執行緒庫這一塊的程式碼是較為底層的部分,如果上層直接呼叫API,一旦遇到了Windows API過時等問題導致的實現方式要修改的情況,你就需要一個專案一個專案的去修改名稱,這是不嚴謹的。程式碼的底層要儘可能地隱藏程式碼的實現部分,僅提供功能介面。

  • 用例:兩個執行緒同時對一個變數進行++操作
int m_gCount=0;    //全域性變數

class Thread1 : public Thread
{
    //...
    
    virtual int Run()
    {
        TInterlockedIncrement(&((unsigned long long)m_gCount));
    }
}

class Thread2 : public Thread
{
    //...
    
    virtual int Run()
    {
        TInterlockedIncrement(&((unsigned long long)m_gCount));
    }
}

關鍵段的封裝

  • 程式碼
//Defines [.h]
//-----------------------------------------------------------------------
class TURBO_CORE_API CriticalSection
{
    public:
        CriticalSection();   //初始化關鍵段變數
	    ~CriticalSection();  //刪除關鍵段變數
	    
	    //掛起式關鍵段存取:即若有其他執行緒存取時,則呼叫處會掛起等待
	    inline void Lock();
		//結束存取關鍵段
		inline void Unlock();
		//非掛起式關鍵段存取
		//若有其他執行緒存取此關鍵段,則返回FALSE。可以存取則放回TRUE
		inline bool TryLock();
		
	private:
		CRITICAL_SECTION m_cs;
}

//implement[.cpp]
//-----------------------------------------------------------------------
TurboEngine::Core::CriticalSection::CriticalSection()
{
	::InitializeCriticalSection(&m_cs);
}

TurboEngine::Core::CriticalSection::~CriticalSection()
{
	::DeleteCriticalSection(&m_cs);
}

inline void TurboEngine::Core::CriticalSection::Lock()
{
	::EnterCriticalSection(&m_cs);
}

inline void TurboEngine::Core::CriticalSection::Unlock()
{
	::LeaveCriticalSection(&m_cs);
}

inline bool TurboEngine::Core::CriticalSection::TryLock()
{
	return ::TryEnterCriticalSection(&m_cs);
}

inline void TurboEngine::Core::CriticalSection::SetSpinCount(DWORD dwSpinCount)
{
	::SetCriticalSectionSpinCount(&m_cs, dwSpinCount);
}
  • 用例:兩個執行緒同時對一個變數進行++操
CriticalSection m_cs;
int m_gCount=0;

class Thread1 : public Thread
{
    //...
    
    virtual int Run()
    {
        m_cs.Lock();  //若有其他執行緒存取m_gCount則執行緒掛起等待
        m_gCount++;
        m_cs.Unlock();
    }
}

class Thread2 : public Thread
{
    //...
    
    virtual int Run()
    {
        if(m_cs.TryLock())
        {
            m_gCount++;
            m_cs.Unlock();
        }
    }
}

核心物件的同步方式

程式碼結構

  • SyncKernelObject
    • SyncTrigger
    • SyncTimer
    • SyncSemaphore
    • SyncMutex

SyncKernelObject基礎類別

基礎類別理所應當的封裝了執行緒同步核心物件所需要的一些變數和函數。我們都知道,對於所有的同步核心物件,實現同步都依賴與Wait函數,因此,我也把Wait函數封裝在了父類別上。基礎類別的程式碼如下所示:

//Defines [.h]
//-----------------------------------------------------------------------------------------------------------------------
class TURBO_CORE_API SyncKernelObject
{
    public:
        //等待得狀態
        enum WaitState : DWORD
	    {
	    	Abandoned = WAIT_ABANDONED,      //佔用此核心物件的執行緒突然被終止時,其他等待的執行緒中的其中一個會收到WAIT_ABANDONED
	    	Active = WAIT_OBJECT_0,      //等待的物件被觸發
	    	TimeOut = WAIT_TIMEOUT,      //等待超時
	    	Failded = WAIT_FAILED,       //給WaitForSingleObject傳入了無效引數
	    	Null = Failded - 1           //佔用了一個似乎沒有相關值得變數表示控制程式碼為NULL(Failed-1)
	    };
	
	public:
			SyncKernelObject(PSECURITY_ATTRIBUTES psa = NULL, LPCWSTR objName = NULL);
			~SyncKernelObject();

	public:
		//獲取核心物件的控制程式碼
		inline HANDLE GetHandle() { return m_KernelObjHandle; }
		//獲取核心物件的名稱
		inline const LPCWSTR GetName()   { return m_Name; }
		//獲取核心物件的安全性結構體
		inline PSECURITY_ATTRIBUTES GetPsa() { return m_psa; }
		//(靜態函數)多個核心物件的等待函數
		inline static DWORD Waits(DWORD objCount, CONST HANDLE* pObjects, BOOL waitAll, DWORDwaitMilliSeconds)
		{
			return WaitForMultipleObjects(objCount, pObjects, waitAll, waitMilliSeconds);
		}


	protected:
		//自身相關的等待函數
		WaitState Wait(DWORD milliSeconds);
	
	protected:
	    HANDLE  m_KernelObjHandle;    //核心物件控制程式碼
	    LPCWSTR m_Name;               //核心物件名稱,預設為NULL
	    PSECURITY_ATTRIBUTES m_psa;   //安全性相關得結構體,通常為NULL
}

SyncTrigger

事件核心物件。我更願意稱它為觸發器、開關。作為一個觸發器,它存在啟用與非啟用兩種狀態,我們可以利用這種狀態靈活的控制執行緒同步問題。

//Defines [.h]
class TURBO_CORE_API SyncTrigger : public SyncKernelObject
{
public:
	SyncTrigger(bool bManual, bool isInitialActive, LPCWSTR objName = NULLPSECURITY_ATTRIBUTES psa = NULL);
	~SyncTrigger()
	
	//時間核心物件的等待函數(呼叫父類別的Wait函數)
	WaitState CheckWait(DWORD waitMilliSeconds)
	
	//當前是否為啟用狀態
	bool IsTrigger();
	
	//設定當前狀態為啟用
	bool SetActive();
	
	//設定當前狀態為未啟用
	bool SetInactive();
};
  • 函數解析:
    • SyncTrigger:唯一建構函式。bManual為是否是手動重置,isInitialActive為初始啟用的狀態。
    • CheckWait:常規的核心物件Wait函數
    • IsTrigger:等待時間為0的Wait函數,用於獲取當前Trigger的觸發狀態
    • SetActive:將Trigger設定為觸發狀態
    • SetInactive:Trigger設定為非觸發狀態
  • 用例
//利用觸發器作為執行緒退出的標記(可以避免強行終止執行緒的操作)

SyncTrigger m_Trigger(true,false);  //手動重置、初始狀態為非啟用的觸發器
//某個執行緒的入口函數
virtual DWORD WINAPI Run()
{
    //若此觸發器未啟用,則持續迴圈
    while(!m_Trigger.IsTrigger())
    {
        //TO-DO
    }
    
    //退出執行緒
    return 0;
}

//當需要退出該執行緒時,可以呼叫如下,執行緒可跳出執行的迴圈
m_Trigger.SetActive();  //啟用此觸發器

SyncTimer

計時器核心物件顧名思義,就是和時間相關的控制器。當SyncTimer的核心物件設定為自動重置時,此計時器可以週期性的設定核心物件為啟用狀態,這就是SyncTimer的主要功能。類的屬性和函數如下所示:

class TURBO_CORE_API SyncTimer : public SyncKernelObject
{
public:
	SyncTimer(bool bManual, LPCWSTR objName = NULL, PSECURITY_ATTRIBUTES psa = NULL);
	~SyncTimer()
	//核心物件的等待函數(呼叫父類別的Wait函數)
	WaitState CheckWait(DWORD waitMilliSeconds);
	
	//當前是否為啟用狀態
	bool IsTrigger();
	
	//開始計時器
	bool StartTimer(const LARGE_INTEGER* startTime, LONG circleMilliSeconds);
	
	//取消計時器
	bool CancelTimer();
};
  • 函數簡析
    • SyncTimer:唯一建構函式。bManual為是否是手動重置
    • CheckWait:常規的核心物件Wait函數
    • IsTrigger:等待時間為0的Wait函數,用於獲取當前Trigger的觸發狀態
    • StartTimer:startTime為起始的事件,具體如何賦值可以參考MSDN檔案;circleMilliSeconds為週期觸發的時 長(毫秒)。注意:此引數只有在核心物件為自動重置模式才有意義。
    • CancelTimer:取消開始的計時器
  • 用例
//每秒鐘SyncTimer啟用一次的程式程式碼

SyncTimer m_gSyncTimer(false);   //自動重置的計時器核心物件

//某個執行緒的入口函數
virtual DWORD WINAPI Run()
{
    //若此觸發器未啟用,則持續迴圈
    while(!m_Trigger.IsTrigger())
    {
        //使用計時器
        if (m_gSyncTimer.IsTrigger())
		    cout << "SyncTimer激發一次\n";
    }
    
    //退出執行緒
    return 0;
}


//注意startTime的引數如何編寫:
LARGE_INTEGER liDueTime;
liDueTime.QuadPart = 0;
m_gSyncTimer.StartTimer(&liDueTime, 1000);  //設定計時器為1S鍾啟用一次

startTime:如果值是正的,代表一個特定的時刻。如果值是負的,代表以100納秒為單位的相對時間

SyncSemaphore

class TURBO_CORE_API SyncSemaphore : public SyncKernelObject
{
public:
	SyncSemaphore(LONG initialCount, LONG maximumCount, LPCWSTR objName = NULLPSECURITY_ATTRIBUTES psa = NULL);
	~SyncSemaphore();
	
	//申請使用一個資源(此時的參照計數將會減1)
	WaitState Lock(DWORD dwMilliseconds);
	
	//釋放一個資源
	//releaseCount:釋放的數量
	//oldResCount:未釋放前資源的數量
	bool Unlock(DWORD releaseCount = 1, LPLONG oldResCount = NULL);
};
  • 函數簡析
    • SyncSemaphore: 唯一建構函式。initialCount:資源建立後立即佔用的數量;maximumCount核心物件管理資源的最大數量
    • Lock:申請使用一個資源
    • Unlock:釋放資源

SyncMutex

//互斥核心物件
//可以理解為核心物件版的關鍵段
class TURBO_CORE_API SyncMutex : public SyncKernelObject
{
public:
	SyncMutex(bool initialOccupied, LPCWSTR objName = NULL, PSECURITY_ATTRIBUTES psa NULL);
	~SyncMutex();
	
	//掛起式申請存取(若申請存取的變數被佔用時則執行緒掛起)
	void Lock();
	
	//結束存取
	bool Unlock();
	
	//非掛起式存取
	//若有其他執行緒存取此關鍵段,則返回FALSE。可以存取則放回TRUE
	bool TryLock(DWORD milliSeconds=0);
};
  • 函數簡析(略),和關鍵段功能相同
  • 用例
//Run1()和Run2()不會發生存取衝突而引發未知結果

SyncMutex m_gMutex(false);
int  m_gSyncCounter1=0;

//某個執行緒的入口函數
virtual DWORD WINAPI Run1()
{
//若此觸發器未啟用,則持續迴圈
    while(!m_Trigger.IsTrigger())
    {
        if (m_gMutex.TryLock())
        {
            cout << "執行緒[" << GetThreadId() << "]完成一次累加:[" << m_gSyncCounter1 << "]" << "\n";
            m_gMutex.Unlock();
        }
    }
}

//某個執行緒的入口函數
virtual DWORD WINAPI Run2()
{
//若此觸發器未啟用,則持續迴圈
    while(!m_Trigger.IsTrigger())
    {
        if (m_gMutex.TryLock())
        {
            cout << "執行緒[" << GetThreadId() << "]完成一次累加:[" << m_gSyncCounter1 << "]" << "\n";
            m_gMutex.Unlock();
        }
    }
}

執行緒類的實現

上一節我們講了執行緒同步的方式,通過編寫的執行緒同步程式碼。我們使用多執行緒的時候可以正確的存取一些公共變數。那麼關鍵的執行緒類我們該如何實現呢。自己對執行緒理解如下圖所示。

相關基礎類別的定義程式碼如下:

//引擎執行緒基礎類別
		class TURBO_CORE_API Thread
		{
		public:
			enum class PriorityLevel : int
			{
				TimeCritical = THREAD_PRIORITY_TIME_CRITICAL,
				Highest = THREAD_PRIORITY_HIGHEST,
				AboveNormal = THREAD_PRIORITY_ABOVE_NORMAL,
				Normal = THREAD_PRIORITY_NORMAL,
				BelowNormal = THREAD_PRIORITY_BELOW_NORMAL,
				Lowest = THREAD_PRIORITY_LOWEST,
				Idle = THREAD_PRIORITY_IDLE
			};

			enum class ThreadState
			{
				Initialized,
				Running,
				Suspend,
				Stop,
			};

		public:
			//執行緒建構函式
			//priorityLevel:執行緒優先順序,預設為<normal>
			//stackSize:執行緒的堆疊大小,預設為<0>
			Thread(PriorityLevel priorityLevel = PriorityLevel::Normal, unsigned int stackSize = 0);
			~Thread();

			//開啟執行緒
			void Start();

			//掛起執行緒
			//return->返回掛起前的掛起計數
			int Suspend();

			//恢復執行緒。
			//[注意,恢復一次不一定會立即執行]
			//return->返回恢復前的掛起係數
			int Resume();

			//終止執行緒
			bool Stop();
			
			//是否允許動態提升優先順序
			//Notes:在當前優先順序的範圍內各個切片時間上下浮動,但不會跳到下一個優先順序
			//當前的優先順序是一個優先順序範圍,而不是具體的等級
			bool IsAllowDynamicPriority();

			//啟用or禁止動態提升優先順序
			bool SetPriorityBoost(bool bActive);

			//設定執行緒優先順序
			bool SetPriority(PriorityLevel priority);

			//當前執行緒的優先順序
			PriorityLevel GetCurrentPriority();

			//執行緒是否存在
			bool IsAlive();

			//當前執行緒的狀態
			ThreadState GetCurrentState();

			//獲取執行緒Id
			DWORD GetThreadId();

			//執行緒名稱
			virtual const CHAR* ThreadName() = 0;

		protected:
			//執行緒的主邏輯函數
			virtual DWORD WINAPI Run() = 0;

			//執行緒函數入口
			static unsigned _stdcall ThreadEnterProc(void* param);

		protected:
			HANDLE        m_ThreadHandle = NULL;     //執行緒控制程式碼
			unsigned int  m_ThreadStackSize = 0;     //執行緒堆疊大小
			ThreadState   m_CurrentState;            //當前執行緒的狀態
			PriorityLevel m_CurrentPriority;         //當前執行緒的優先順序
			SyncTrigger   m_TerminateThreadTrigger;  //終止執行緒的觸發器
		};
	}

具體如何是實現,如果說熟悉Windows提供的執行緒API,我想很快就能實現。那麼如何開啟一個執行緒呢。既然上面的基礎類別基本實現了對一個執行緒建立、銷燬、排程的函數。那麼每個執行緒的差異點應該在兩個虛擬函式上。

//定義執行緒名稱的位置
virtual const CHAR* ThreadName() = 0;

//執行緒入口函數的實現程式碼放置的位置
virtual DWORD WINAPI Run() = 0;
  • 用例:定義一個渲染執行緒並開啟
class RenderThread : public Thread
{
public:
    virtual const CHAR* ThreadName()
    {
        return "RenderThread";
    }

protected:
    virtual DWORD WINAPI Run()
    {
        //StartRender
        while(!gameStop)
        {
            RenderOpaque();
            RenderTransparent();
            //...
        }
    }
}

//開啟渲染執行緒
RenderThread m_gRenderThread;
m_gRenderThread.Start();

結語

上面的執行緒類和執行緒同步類共同構成了引擎簡單的執行緒庫。當然,真正可用的遊戲引擎,其執行緒庫不可能這麼簡單,但是,對於目前而言,這也足夠使用。

礙於篇幅,很多程式碼僅提供了類的定義,關於類的實現,請參考Github上的專案。