【Unity】Unity中的非同步程式設計技術詳解
非同步程式設計技術對於很多手遊開發者來說,都是不可避免的話題,因為手遊的遊戲邏輯包含太多需要併發或者希望能夠並行的邏輯。現在的手機硬體發展迅速,多核已成為主導趨勢,對於3A級大作來說,如何充分利用手機多核的效能從而解放主執行緒壓力就顯得尤其重要。本文將由Unity技術支援工程師劉偉賢,為大家分享Unity中的一些非同步程式設計技術,來釋放硬體的潛能。
【關於多執行緒】
其實眾多的Unity遊戲效能負荷集中在主執行緒。從Unity 5.4開始已將大量的Camera.Render工作從主執行緒當中移除,但這還遠遠不夠。
我們為PS 4平臺添加了其它Worker Thread對原生渲染的支援,這意味著渲染請求可以直接呼叫底層原生的渲染API,而不需要再與Unity的渲染執行緒進行互動。這可以大幅提高遊戲的渲染效率,某些情況下游戲的實際幀率可以提高到原來的2.5倍之多。
當然,Unity還會持續新增對其他平臺原生渲染API的支援。
在最新的Unity 5.6中,我們加入了對Vulkan的支援。目前Vulkan僅支援Windows Standalone、Linux Standalone和Android平臺,而Unity編輯器暫時還不支援Vulkan。
【關於Unity Job System(Worker Thread)】
Unity會致力於把一些計算量比較重的系統挪到其他的工作執行緒上去,例如粒子系統、動畫系統、布料計算、遮擋剔除、視錐體剔除、蒙皮和靜態裁剪等等。Unity Job System就是為了更好地讓這些底層系統能夠在多執行緒下安全高效的並行運作。也就是我們經常在Profiler底下看到的多個Worker Threads。
下面來看一個具體的例子。
可以看到在Particle System的執行過程中,有一個渲染執行緒叫PutGeometryJobFence,它用於安排各個工作執行緒進行各個粒子的GeometryJob,等所有的GeometryJob完成以後就會進行渲染。
從上面的例子可以看出,Unity內部的Job System是基於任務式的高效安全的多執行緒系統,非常高效且安全,它基於無鎖棧與無鎖佇列構建這個系統,通過彙編來編寫,針對不同的CPU有不同的實現。所以,高度優化的Jobs之間存在一定的依賴關係,並且會根據主執行緒的需要去調整它們的優先順序。
下面來看一個例子。當我們為Woker Thread安排了一個動畫的Job,但如果這時通過指令碼去刪除Animator和Transform元件,就需要馬上完成所有的Jobs並且將Animator/Transform資料的處理完成。
關於非同步操作
Unity還提供了一系列的非同步操作,方便大家根據需要進行選擇。這些非同步操作包括Resources的載入、Asset Bundle的載入、場景的載入,NavMesh的生成等等。當然,這些非同步操作的底層實現各不相同。有些是獨立執行緒處理的,有些則是利用Unity內部的JobSystem,也有一些是二者結合使用的。
上圖的示例中,呼叫AssetBundle.LoadFromFileAsync時馬上建立一個AsyncOperation,然後馬上交由Job System去處理檔案的IO相關操作,等Job System處理完之後就交由PreloadManager去處理Asset Bundle的載入相關操作,而PreloadManager就是叫“UnityPreload”的獨立載入執行緒,而且它是基於佇列式的載入管理。
【協程】
協程並非多執行緒,它還是在主執行緒上執行的,但協程可以將一個函式分成多個部分來順序執行,從而實現等同併發的處理方式。
在使用協程時要特別注意它們的呼叫時機。 C#編譯器會幫我們建立一個協程的類,而在開啟一個協程時就會建立對應的物件,這個物件用來維護多次呼叫時協程的狀態。正是要維護這些狀態,所以協程內的本地變數也需要放到堆上,啟動一個Coroutine所引起的記憶體消耗等同於一個類的固定成本加上這個 Coroutine所用到的區域性變數總記憶體。而協程的生命週期就是跟著MonoBehaviour來走的。
需要注意的是:
為了減少主執行緒的CPU開銷,需要避免在協程內進行一些阻塞的操作;
在協程內分配的資源要在協程結束以後才會釋放,所以不要在協程內迴圈分配資源;
儘可能使用最少的協程數去完成最多的操作;
使用巢狀式的協程有助於保持程式碼簡潔且易於維護,但它們也比較容易導致較高的記憶體開銷。
【執行緒】
首先區別於協程,執行緒是並行的,是利用了硬體的多核特性。而協程是併發的,它其實還是在主執行緒執行。C#的執行緒是基於.Net的實現,Mono進一步細分作業系統的程序到一個輕量級的託管子程序,這就是AppDomain。而在AppDomain中又可以存在一個或者多個的託管執行緒,也就是Sytem.Threading.Thread。
- 託管執行緒並不需要對映到單獨的原生執行緒上;
- 原則上,託管執行緒就是虛擬的;System.Threading.Thread.CurrentThread.ManagedThreadId返回的是託管執行緒ID,ID是穩定的;
- System.AppDomain.GetCurrentThreadId()返回的是原生執行緒ID,ID是不穩定的。
【執行緒相關的一些老生常談】
資料衝突 - 不同步地去訪問共享資料就會帶來資料衝突,可以通過加鎖來保護資料,但是鎖的開銷不小。
鎖(Lock) - 不能是值型別,因為值型別在每次裝箱時都是不同的物件,所以根本沒有辦法保證鎖的有效性。
死鎖 - 兩個或兩個以上的執行緒在執行過程中,競爭資源造成了阻塞。可以通過破壞他們產生的四個條件,或者利用諸如銀行家演算法等來避免。
考慮使用無鎖的資料結構設計導致執行緒餓死 - 避免餓死就應該採用佇列的方式,保證每個執行緒都有機會獲得請求的資源。 當然實現方式可以有很多變化,比如優先順序、時間片等,都是“佇列”的特殊形式。
【C# Job System】
寫好執行緒安全的程式碼並非易事,而C# Job System就用於幫助大家解決一切痛點,它將Unity底層的Job System開放給C#層的使用,所以具備Unity Job System的特性,基於任務式的高效安全的多執行緒和多工之間可以存在依賴關係。
編碼風格和介面宣告:
除此以外C# Job System還提供了對執行緒安全相關的檢查及報錯提示機制,確保開發者在開發過程中安全和高效。該項新功能即將與大家見面,請保持關注。