在 Xbox 360 和 Windows 上針對多核心撰寫程式碼
多年來,處理器的效能穩步提高,遊戲和其他專案已經獲得了這種增加的力量的好處,而不必做任何特別的事情。
規則已變更。 單一處理器核心的效能現在非常緩慢,如果有的話。 不過,一般計算機或控制台中可用的運算能力會持續成長。 差別在於,大部分的效能提升現在都來自在單一機器中擁有多個處理器核心,通常是在單一晶元中。 Xbox 360 CPU 在一個晶元上擁有三個處理器核心,2006 年售出的大約 70% 的電腦處理器是多核心。
可用處理能力的增加和過去一樣戲劇性,但現在開發人員必須撰寫多線程程序代碼,才能使用此能力。 多線程程式設計帶來了新的設計和程式設計挑戰。 本主題提供一些有關如何開始使用多線程程式設計的建議。
良好設計的重要性
良好的多線程程序設計非常重要,但可能非常困難。 如果您隨意將主要遊戲系統移至不同的線程,您可能會發現每個線程大部分時間都在等待其他線程。 這種類型的設計會導致複雜度增加且偵錯工作顯著,幾乎沒有效能提升。
每當線程必須同步處理或共享數據時,就有可能發生數據損毀、同步處理額外負荷、死結和複雜度。 因此,您的多線程設計必須清楚記錄每個同步處理和通訊點,而且應該盡可能將這類點降到最低。 當線程需要通訊時,編碼工作將會增加,如果程式代碼影響太多原始程式碼,可能會降低生產力。
多線程最簡單的設計目標是將程式代碼分成大型獨立部分。 如果您接著將這些片段限制為每個畫面只進行幾次通訊,您將會看到多線程的大幅加速,而不會有不當的複雜度。
一般線程工作
一些類型的工作證明是可以放在個別線程上。 下列清單並非詳盡,但應該提供一些想法。
轉譯
轉譯 - 可能包括步行場景圖形,或可能只呼叫 D3D 函式 - 通常佔 CPU 時間的 50% 或更多。 因此,將轉譯移到另一個線程可能會有顯著的優點。 更新線程可以填入某種轉譯描述緩衝區,轉譯線程接著可以處理。
遊戲更新線程一律是轉譯線程之前的一個畫面,這表示在用戶動作顯示在畫面上之前,它需要兩個畫面。 雖然這種增加的延遲可能是個問題,但從分割工作負載的幀速率會增加通常會讓總延遲保持可接受。
在大部分情況下,所有轉譯仍會在單一線程上完成,但與遊戲更新不同。
D3DCREATE_MULTITHREADED旗標有時會用來允許在一個線程上進行轉譯,並在其他線程上建立資源;Xbox 360 上會忽略此旗標,您應該避免在 Windows 上使用。 在 Windows 上,指定此旗標會強制 D3D 花費大量時間進行同步處理,進而減緩轉譯線程的速度。
檔案解壓縮
載入時間一律太長,而且將數據串流至記憶體而不會影響幀速率可能會很困難。 如果所有數據在光碟上積極壓縮,則硬碟或光碟的數據傳送速率不太可能是限制因素。 在單個線程處理器上,通常沒有足夠的處理器時間可供壓縮,以協助載入時間。 不過,在多處理器系統上,檔案解壓縮會使用CPU循環,否則會浪費;它可改善載入時間和串流;並節省光碟上的空間。
請勿使用檔案解壓縮做為應該在生產期間完成的處理取代。 例如,如果您在層級載入期間投入額外的線程來剖析 XML 數據,您就不會使用多線程來改善玩家的體驗。
使用檔案解壓縮線程時,您仍應該使用異步檔案 I/O 和大型讀取,以最大化數據讀取效率。
圖形浮點
有許多圖形美觀可改善遊戲的外觀,但並非絕對必要。 這些包括程式性產生的雲動畫、布和頭髮模擬、程式波、程式植被、更多粒子或非遊戲物理等專案。
由於這些效果不會影響遊戲,因此不會造成棘手的同步處理問題,因此它們可以在每一畫面或較不頻繁地與其他線程同步處理一次。 此外,在 Windows 的遊戲上,這些效果可以為具有多核心 CPU 的遊戲玩家增加價值,同時在單一核心電腦上以無訊息方式省略,從而輕鬆調整各種功能。
物理特性
物理通常不能放在與遊戲更新平行執行的個別線程上,因為遊戲更新通常需要立即物理計算的結果。 多線程物理的替代方法是在多個處理器上執行它。 雖然這可以完成,但這是需要經常存取共用數據結構的複雜工作。 如果您可將物理工作負載保持在足夠低,以符合主線程,您的作業會更簡單。
支援在多個線程上執行物理特性的連結庫可供使用。 不過,這可能會導致問題:當您的遊戲執行物理時,它會使用許多線程,但其餘時間卻很少使用。 在多個線程上執行物理需要解決這個問題,以便將工作負載平均分散到框架上。 如果您撰寫多線程物理引擎,則必須仔細注意所有數據結構、同步處理點和負載平衡。
多線程設計範例
Windows 遊戲需要在具有不同 CPU 核心數目的電腦上執行。 大多數遊戲機仍然只有一個核心,雖然雙核心機器的數量正在快速增長。 Windows 的一般遊戲可能會將其工作負載分成一個線程以進行更新和轉譯,並選擇性背景工作線程以新增額外的功能。 此外,可能會使用一些用於執行檔案 I/O 和網路功能的背景線程。 圖 1 顯示線程,以及主要數據傳輸點。
圖 1. Windows 遊戲中的線程設計
典型的 Xbox 360 遊戲可以使用額外的 CPU 密集型軟體線程,因此可能會將其工作負載分解成更新線程、轉譯線程和三個背景工作線程,如圖 2 所示。
圖 2. Xbox 360 遊戲中的線程設計
除了檔案 I/O 和網路功能之外,這些工作都可能會耗用足夠的 CPU,以受益於自己的硬體線程。 這些工作也有可能獨立到足以讓整個框架執行,而不需要通訊。
遊戲更新線程會管理控制器輸入、AI 和物理,並準備其他四個線程的指示。 這些指令會放入遊戲更新線程所擁有的緩衝區中,因此不需要同步處理,因為會產生指示。
在畫面結尾,遊戲更新線程會將指令緩衝區交給其他四個線程,然後開始在下一個畫面上工作,填入另一組指令緩衝區。
由於更新和轉譯線程會彼此在lockstep中運作,因此其通訊緩衝區只會進行雙重緩衝處理:在任何指定時間,更新線程會在轉譯線程從另一個線程讀取時填滿一個緩衝區。
其他背景工作線程不一定系結至幀速率。 解壓縮一段數據可能需要比框架少得多,或可能需要許多畫面格。 即使布和頭髮模擬也不需要完全以幀速率執行,因為較不頻繁的更新可能相當可接受。 因此,這三個線程需要不同的數據結構,才能與更新線程和轉譯線程通訊。 它們都需要可保存工作要求的輸入佇列,而轉譯線程需要一個數據佇列,以保存線程所產生的結果。 在每個框架結束時,更新線程會將工作要求的區塊新增至背景工作線程的佇列。 在每個畫面格只新增一次清單,可確保更新線程將同步處理額外負荷降至最低。 每個背景工作線程會盡可能快速地從工作佇列提取指派,並使用看起來像這樣的迴圈:
for(;;)
{
while( WorkQueueNotEmpty() )
{
RemoveWorkItemFromWorkQueue();
ProcessWorkItem();
PutResultInDataQueue();
}
WaitForSingleObject( hWorkSemaphore );
}
由於數據會從更新線程移至背景工作線程,然後從轉譯線程到轉譯線程,因此在一些動作進入畫面之前,可能會有三個以上的畫面延遲。 不過,如果您將延遲容錯工作指派給背景工作線程,則這不應該是問題。
替代的設計是讓數個背景工作線程全部從相同的工作佇列繪製。 這可提供自動負載平衡,並讓所有背景工作線程更可能保持忙碌。
遊戲更新線程必須小心,不要給背景工作線程太多工作,否則工作佇列可能會持續成長。 更新線程的管理方式取決於背景工作線程執行的工作類型。
同時多線程和線程數目
所有線程都不會建立相等。 兩個硬體線程可能位於不同的晶元上、在同一個晶元上,甚至是在同一個核心上。 遊戲程式設計人員要注意的最重要組態是一個核心上的兩個硬體線程:同時多線程(SMT)或 超執行緒技術(HT 技術)。
SMT 或 HT 技術線程會共用 CPU 核心的資源。 因為它們共用執行單位,所以執行兩個線程的最大速度,而不是一個通常是10到20%,而不是兩個獨立硬體線程的100%。
更重要的是,SMT 或 HT 技術線程會共用 L1 指令和數據快取。 如果記憶體存取模式不相容,它們最終可能會對快取進行爭鬥,並導致許多快取遺漏。 在最糟的情況下,執行第二個線程時,CPU 核心的總效能實際上可能會降低。 在 Xbox 360 上,這是一個相當簡單的問題。 已知 Xbox 360 的設定是三個 CPU 核心,每個核心有兩個硬體線程,而開發人員會將其軟體線程指派給特定的 CPU 線程,並可測量其線程設計是否提供額外的效能。
在 Windows 上,情況更為複雜。 線程數目及其設定會因計算機而異,並判斷組態很複雜。 GetLogicalProcessorInformation 函式提供不同硬體線程之間關聯性的相關信息,而且此函式可在 Windows Vista、Windows 7 和 Windows XP SP3 上使用。 因此,您現在必須使用 Intel 和 AMD 提供的 CPUID 指令和演算法,以決定您有多少個可用的「真實」線程。 如需詳細資訊,請參閱參考。
DirectX SDK 中的 CoreDetection 範例包含使用 GetLogicalProcessorInformation 函式或 CPUID 指令傳回 CPU 核心拓撲的範例程式代碼。 如果 目前平臺上不支援 GetLogicalProcessorInformation ,則會使用 CPUID 指令。 您可以在下列位置找到 CoreDetection:
-
來源:
-
DirectX SDK root\Samples\C++\Misc\CoreDetection
-
可執行:
-
DirectX SDK root\Samples\C++\Misc\Bin\CoreDetection.exe
最安全的假設是每個 CPU 核心不超過一個 CPU 密集線程。 擁有比 CPU 核心更多的 CPU 密集線程可提供很少或沒有好處,並帶來額外額外負荷和額外線程的複雜度。
建立線程
建立線程是相當簡單的作業,但有許多潛在的錯誤。 下列程式代碼顯示建立線程、等候線程終止,然後清除的適當方式。
const int stackSize = 65536;
HANDLE hThread = (HANDLE)_beginthreadex( 0, stackSize,
ThreadFunction, 0, 0, 0 );
// Do work on main thread here.
// Wait for child thread to complete
WaitForSingleObject( hThread, INFINITE );
CloseHandle( hThread );
...
unsigned __stdcall ThreadFunction( void* data )
{
#if _XBOX_VER >= 200
// On Xbox 360 you must explicitly assign
// software threads to hardware threads.
XSetThreadProcessor( GetCurrentThread(), 2 );
#endif
// Do child thread work here.
return 0;
}
當您建立線程時,您可以選擇指定子線程的堆疊大小,或指定零,在此情況下,子線程會繼承父線程的堆疊大小。 在 Xbox 360 上,當線程啟動時,堆疊會完全認可,指定零可能會浪費大量的記憶體,因為許多子線程不需要和父代一樣多的堆疊。 在 Xbox 360 上,堆疊大小也必須是 64 KB 的倍數。
如果您使用 CreateThread 函式來建立線程,則 C/C++ 執行時間 (CRT) 將不會在 Windows 上正確初始化。 建議您改用 CRT _beginthreadex 函式。
CreateThread 或 _beginthreadex 的傳回值是線程句柄。 此線程可用來等候子線程終止,這比在檢查線程狀態的迴圈中旋轉更為簡單且更有效率。 若要等候線程終止,只要使用線程句柄呼叫 WaitForSingleObject 即可。
在線程終止且線程句柄已關閉之前,線程的資源將不會釋出。 因此,當您完成線程句柄時,請務必使用 CloseHandle 關閉線程句柄。 如果您要等候線程以 WaitForSingleObject 終止,請務必在等候完成後才關閉句柄。
在 Xbox 360 上,您必須使用 XSetThreadProcessor 明確地將軟體線程指派給特定硬體線程。 否則,所有子線程都會維持在與父系相同的硬體線程上。 在 Windows 上,您可以使用 SetThreadAffinityMask 來強烈建議您線程執行所在的作業系統。 這項技術通常應該避免在 Windows 上,因為您不知道系統上可能執行的其他進程。 通常最好讓 Windows 排程器將您的線程指派給閑置的硬體線程。
建立線程是昂貴的作業。 線程應該很少建立和終結。 如果您發現自己想要經常建立和終結線程,請改用等候工作的線程集區。
同步處理線程
若要讓多個線程一起運作,您必須能夠同步處理線程、傳遞訊息,以及要求資源的獨佔存取權。 Windows 和 Xbox 360 隨附一組豐富的同步處理基本類型。 如需這些同步處理基本類型的完整詳細數據,請參閱平台檔。
獨佔存取
取得資源的獨佔存取權、數據結構或程式代碼路徑是常見的需求。 取得獨佔存取權的其中一個選項是 mutex,其一般用法如下所示。
// Initialize
HANDLE mutex = CreateMutex( 0, FALSE, 0 );
// Use
void ManipulateSharedData()
{
WaitForSingleObject( mutex, INFINITE );
// Manipulate stuff...
ReleaseMutex( mutex );
}
// Destroy
CloseHandle( mutex );
The kernel guarantees that, for a particular mutex, only one thread at a time can
acquire it.
The main disadvantage to mutexes is that they are relatively expensive to acquire
and release. A faster alternative is a critical section.
// Initialize
CRITICAL_SECTION cs;
InitializeCriticalSection( &cs );
// Use
void ManipulateSharedData()
{
EnterCriticalSection( &cs );
// Manipulate stuff...
LeaveCriticalSection( &cs );
}
// Destroy
DeleteCriticalSection( &cs );
重要區段具有與 Mutex 類似的語意,但它們只能用來在進程內同步處理,而不是在進程之間同步處理。 其主要優點是,其執行速度比 Mutex 快約 20 倍。
事件
如果兩個線程可能是更新線程和轉譯線程,會輪流使用一對轉譯描述緩衝區,則需要一種方法來指出它們何時使用其特定緩衝區完成。 這可以藉由將事件與每個緩衝區產生關聯,以建立事件(配置給 CreateEvent)。 當線程使用緩衝區完成時,可以使用 SetEvent 來發出此訊號,然後在另一個緩衝區的事件上呼叫 WaitForSingleObject。 這項技術可輕易推斷到資源三重緩衝。
信號燈
旗號用來控制可執行的線程數目,而且通常用來實作工作佇列。 一個線程會將工作新增至佇列,並在將新專案新增至佇列時使用 ReleaseSemaphore。 這可讓一個背景工作線程從等候線程的集區釋放。 背景工作線程只會呼叫 WaitForSingleObject,當它傳回時,他們知道佇列中有工作專案。 此外,必須使用重要區段或其他同步處理技術,以確保安全存取共用工作佇列。
避免 SuspendThread
有時候當您想要讓線程停止其執行作業時,使用 SuspendThread 而不是正確的同步處理基本類型會很誘人。 這總是一個壞主意,很容易導致死結和其他問題。 SuspendThread 也會與 Visual Studio 調試程序互動不良。 避免 SuspendThread。 請改用 WaitForSingleObject。
WaitForSingleObject 和 WaitForMultipleObjects
WaitForSingleObject 函式是最常用的同步處理函式。 不過,有時候您希望線程等到同時滿足數個條件,或直到滿足其中一組條件為止。 在此情況下,您應該使用 WaitForMultipleObjects。
Interlocked 函式和無鎖定程序設計
有一系列函式可用於執行簡單的安全線程作業,而不需要使用鎖定。 這些是 Interlocked 函式系列,例如 InterlockedIncrement。 這些函式加上使用謹慎設定旗標的其他技術,一起稱為無鎖定程序設計。 無鎖定程序設計可能非常棘手,在 Xbox 360 上比在 Windows 上要困難得多。
如需沒有鎖定之程式設計的詳細資訊,請參閱 Xbox 360 和 Microsoft Windows 的無鎖定程式設計考慮。
將同步處理最小化
某些同步處理方法比其他方法快。 不過,與其選擇可能最快的同步處理技術來優化程式代碼,通常比較不常同步處理。 這比同步處理頻率太快,而且可讓您更輕鬆地進行偵錯的程序代碼。
某些作業,例如記憶體配置,可能必須使用同步處理基本類型才能正確運作。 因此,從預設共用堆積執行頻繁的配置會導致頻繁的同步處理,這會浪費一些效能。 避免頻繁配置或使用個別線程堆積(如果您使用 HeapCreate 使用HEAP_NO_SERIALIZE),可以避免這種隱藏的同步處理。
隱藏同步處理的另一個原因是D3DCREATE_MULTITHREADED,這會導致 Windows 上的 D3D 在許多作業上使用同步處理。 (Xbox 360 上會忽略旗標。
個別線程數據也稱為線程本機記憶體,可以是避免同步處理的重要方式。 Visual C++ 可讓您使用 __declspec(thread) 語法將全域變數宣告為每個線程。
__declspec( thread ) int tls_i = 1;
這可讓進程中的每個線程擁有自己的tls_i複本,而不需要同步處理即可安全地且有效率地加以參考。
__declspec(thread) 技術不適用於動態載入的 DLL。 如果您使用動態載入的 DLL,則必須使用 TLSAlloc 系列函式來實作線程本機記憶體。
終結執行緒
終結線程的唯一安全方法是讓線程本身結束,方法是從主線程函式傳回,或是讓線程呼叫 ExitThread 或 _endthreadex。 如果使用 _beginthreadex 建立線程,則應該使用_endthreadex或從主線程函式傳回,因為使用 ExitThread 將無法正確釋放 CRT 資源。 永遠不要呼叫 TerminateThread 函式,因為線程不會正確清除。 線程應該總是自殺—他們不應該被謀殺。
OpenMP \(英文\)
OpenMP 是一種語言延伸模組,可藉由使用 pragmas 引導編譯程式平行化迴圈,將多線程新增至程式。 Windows 和 Xbox 360 上的 Visual C++ 2005 支援 OpenMP,並可搭配手動線程管理使用。 OpenMP 可以是程式代碼多線程部分的便利方式,但不太可能是理想的解決方案,尤其是遊戲。 OpenMP 可能更適用於較長時間執行的生產工作,例如處理藝術和其他資源。 如需詳細資訊,請參閱 Visual C++ 檔或移至 OpenMP 網站。
程式碼剖析
多線程分析很重要。 最後很容易發生長時間的停滯,線程會互相等候。 這些攤位可能很難找到和診斷。 若要協助識別它們,請考慮將檢測新增至您的同步處理呼叫。 取樣分析工具也可以協助識別這些問題,因為它可以記錄計時資訊,而不會大幅改變時間資訊。
時間
rdtsc 指令是取得 Windows 上準確計時資訊的其中一種方式。 不幸的是,rdtsc 有多個問題,使得它成為您出貨標題的選擇不佳。 rdtsc 計數器不一定在 CPU 之間同步處理,因此當您的線程在硬體線程之間移動時,可能會有很大的正面或負面差異。 視電源管理設定而定,rdtsc 計數器遞增的頻率也會隨著您的遊戲執行而變更。 若要避免這些困難,您應該偏好 QueryPerformanceCounter 和 QueryPerformanceFrequency,以取得出貨遊戲中的高精確度計時。 如需計時的詳細資訊,請參閱 遊戲計時和多核心處理器。
偵錯
Visual Studio 完全支援 Windows 和 Xbox 360 的多線程偵錯。 Visual Studio 線程視窗可讓您在線程之間切換,以查看不同的呼叫堆疊和局部變數。 [線程] 視窗也可讓您凍結和解除凍結特定線程。
在 Xbox 360 上,您可以使用 監看視窗中的 @hwthread中繼變數來顯示目前選取的軟體線程執行所在的硬體線程。
如果您以有意義的方式命名線程,線程視窗會更容易使用。 Visual Studio 和其他 Microsoft 調試程式可讓您命名線程。 實作下列 SetThreadName 函式,並在啟動時從每個線程呼叫它。
typedef struct tagTHREADNAME_INFO
{
DWORD dwType; // must be 0x1000
LPCSTR szName; // pointer to name (in user address space)
DWORD dwThreadID; // thread ID (-1 = caller thread)
DWORD dwFlags; // reserved for future use, must be zero
} THREADNAME_INFO;
void SetThreadName( DWORD dwThreadID, LPCSTR szThreadName )
{
THREADNAME_INFO info;
info.dwType = 0x1000;
info.szName = szThreadName;
info.dwThreadID = dwThreadID;
info.dwFlags = 0;
__try
{
RaiseException( 0x406D1388, 0,
sizeof(info) / sizeof(DWORD),
(DWORD*)&info );
}
__except( EXCEPTION_CONTINUE_EXECUTION ) {
}
}
// Example usage:
SetThreadName(-1, "Main thread");
核心調試程式 (KD) 和 WinDBG 也支援多線程偵錯。
測試
多線程程序設計可能很棘手,而且某些多線程Bug很少出現,因此難以尋找和修正。 清除它們的最佳方式之一是在各種計算機上進行測試,尤其是具有四個或多個處理器的計算機。 在單個線程計算機上完美運作的多線程程式代碼可能會在四處理器計算機上立即失敗。 AMD 和 Intel CPU 的效能和計時特性可能會有很大的差異,因此請務必根據這兩個廠商的 CPU 在多處理器電腦上進行測試。
Windows Vista 和 Windows 7 改善
針對以較新版本 Windows 為目標的遊戲,有許多 API 可以簡化可調整多線程應用程式的建立。 這特別適用於新的 ThreadPool API 和一些額外的 syncrhonziation 基本類型(條件變數、精簡讀取/寫入器鎖定,以及一次性初始化)。 您可以在下列 MSDN Magazine 文章中找到這些技術的概觀:
在這些 操作系統上使用 Direct3D 11 功能 的應用程式也可以利用並行物件建立的新設計,以及延後的內容命令清單,以取得更好的多線程轉譯延展性。
摘要
透過仔細的設計,將線程之間的互動降到最低,您可以透過多線程程序設計取得大量的效能提升,而不需要在程序代碼中增加過多的複雜度。 這可讓您的遊戲程式代碼進行下一波處理器改進,並提供更具吸引力的遊戲體驗。
參考資料
- Jim Beveridge & Robert Weiner, Multithreading Applications in Win32, Addison-Wesley, 1997
- Chuck Walbourn、 Game Timing and Multicore Processors, Microsoft Corporation, 2005
- MSDN 連結庫: GetLogicalProcessorInformation
- OpenMP \(英文\)