引擎之旅 Chapter.2 執行緒庫

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

程式碼結構

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

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

利用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();
        }
    }
}

內核對象的同步方式

程式碼結構

image

  • 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上的項目。