Unity 協程運行時的監控和優化
協程 (Coroutine) 是大部分現代編程環境都提供的一個非常有用的機制。它允許我們把不同時刻發生的行為,在代碼中以線性的方式聚合起來。與基於事件與回調的系統相比,以協程方式組織的業務邏輯,可讀性相對好一些。
Unity 內的協程實現是傳統協程的簡化——在主線程內每一幀給定的時間點上,引擎通過一定的調度機制來喚醒和執行滿足條件的協程,以實際上的分時串列化執行迴避了協程之間的通信問題。但由於種種因素,協程的執行情況對程序員而言相對不那麼透明,可以通過一些簡單的機制來對其進行監控和優化。
Warm up: 從復用 Yield 對象說起
先從一個最簡單而直接的改進開始吧。下面一個在每幀結束時執行的協程的例子:
void Start()n{n StartCoroutine(OnEndOfFrame());n}nnIEnumerator OnEndOfFrame()n{n yield return null;nn while (true)n {n //Debug.LogFormat("Called on EndOfFrame.");n yield return new WaitForEndOfFrame();n }n}n
在 Profiler 內可以看到,上面的代碼會導致 WaitForEndOfFrame 對象的每幀分配,給 GC 增加負擔。假設遊戲內有 10 個活躍協程,運行在 60 fps,那麼每秒鐘的 GC 增量負擔是 10 * 60 * 16 = 9.6 KB/s。
我們可以簡單地通過復用一個全局的 WaitForEndOfFrame 對象來優化掉這個開銷:
static WaitForEndOfFrame _endOfFrame = new WaitForEndOfFrame();n
在合適的地方創建一個全局共享的 _endOfFrame 之後,只需要把上面的代碼改為
...n yield return _endOfFrame;n ...n
上面的 9.6 KB/s 的 GC 開銷就被完全避免了,而邏輯上與優化前完全沒有任何區別。
實際上,所有繼承自 YieldInstruction 的用於掛起協程的指令類型,都可以使用全局緩存來避免不必要的 GC 負擔。常見的有:
- WaitForSeconds
- WaitForFixedUpdate
- WaitForEndOfFrame
在 Yielders.cs 這個文件里,集中地創建了上面這些類型的靜態對象,使用時可以直接這樣:
...n yield return Yielders.GetWaitForSeconds(1.0f); // wait for one secondn ...n
Coroutine 的工作原理
觀察調用鏈可知,Unity Coroutine 的調用約定靠返回的 IEnumerator 對象來維繫。我們知道 IEnumerator 的核心功能函數是:
bool MoveNext();n
這個函數在每次被 Unity 協程調度函數 (通常是協程所在類的 SetupCoroutine()) 喚醒時調用,用於驅動對應的協程由上一次 yield 語句開始執行下面的代碼段,直到下一條 yield 語句 (對應返回 true) 或函數退出 (對應返回 false)。
下圖是一次典型的協程調用:
圖中的綠色實心方塊是協程實際的活躍執行時間。可以看出,一個協程的完整生命周期是「在整個生命周期內對其內部所有代碼段的一個遍歷並依次執行」的過程。接管和監控 Coroutine 的行為
問題描述
由於以下幾點問題的存在,協程的執行情況對開發者而言並不透明,很容易在開發過程中引入性能問題。
- 協程 (除了首次執行) 不是在用戶的函數內觸發,而是在單獨的 SetupCoroutine() 內被激活並執行
- 協程的每次活躍執行,在代碼上以單次 yield 為界限。對於具有複雜分支的業務邏輯,尤其是「本來在主流程內,後來被協程化」的代碼,很難看出每一段 yield 的潛在執行量
- 實踐中,如果同時激活的協程較多,就可能會出現多個高開銷的協程擠在同一幀執行導致的卡幀。這一類卡頓難以復現和調查。
中間層 TrackedCoroutine
針對這些情況,我們可以在主流程和協程之間添加一層 Wrapper,來接管和監控實際協程的執行情況。具體地說,可以實現一個純轉發的 IEnumerator,如下的縮減版所示:
public class TrackedCoroutine : IEnumeratorn{n IEnumerator _routine;nn public TrackedCoroutine(IEnumerator routine)n {n _routine = routine;nn // 在這裡標記協程的創建n }nn object IEnumerator.Currentn {n getn {n return _routine.Current;n }n }nn public bool MoveNext()n {n // 在這裡可以:n // 1. 標記協程的執行n // 2. 記錄協程本次執行的時間nn bool next = _routine.MoveNext();nn if (next)n {n // 一次普通的執行n }n elsen {n // 協程運行到末尾,已結束n }nn return next;n }nn public void Reset()n {n _routine.Reset();n }n}n
完整版的代碼見 TrackedCoroutine 類的實現。
有了這樣一個 TrackedCoroutine 之後,我們就可以把正常的
abc.StartCoroutine(xxx());n
替換為
abc.StartCoroutine(new TrackedCoroutine(xxx()));n
啟動函數 InvokeStart()
在 RuntimeCoroutineTracker 類中,可以看到以下兩個介面,針對以 IEnumerator,string,及可選的單參形式等三種形式的協程啟動的封裝。
public class RuntimeCoroutineTrackern{n public static Coroutine InvokeStart(MonoBehaviour initiator, IEnumerator routine);n public static Coroutine InvokeStart(MonoBehaviour initiator, string methodName, object arg = null);n}n
上面的外部調用就可以替換為:
RuntimeCoroutineTracker.InvokeStart(abc, xxx());n
至此,藉由一個中間層 TrackedCoroutine,我們得以接管和監控所有協程的單次運行過程。
監控 Plugins 內的協程
由於 Plugins 目錄單獨編譯,無法直接調用外部的功能,這裡我們為所有的插件提供一個轉發機制,用於把插件內啟動協程的請求轉發到上面的啟動函數。
首先定義兩個委託:
public delegate Coroutine CoroutineStartHandler_IEnumerator(MonoBehaviour initiator, IEnumerator routine);npublic delegate Coroutine CoroutineStartHandler_String(MonoBehaviour initiator, string methodName, object arg = null);n
然後把實際的協程請求轉發給這兩個委託:
public class CoroutinePluginForwardern{n ...nn public static Coroutine InvokeStart(MonoBehaviour initiator, IEnumerator routine)n {n return InvokeStart_IEnumerator(initiator, routine);n }nn public static Coroutine InvokeStart(MonoBehaviour initiator, string methodName, object arg = null)n {n return InvokeStart_String(initiator, methodName, arg);n }nn ...n}n
最後在運行時註冊兩個委託即可:
CoroutinePluginForwarder.InvokeStart_IEnumerator = RuntimeCoroutineTracker.InvokeStart;nCoroutinePluginForwarder.InvokeStart_String = RuntimeCoroutineTracker.InvokeStart;n
完整的代碼實現見 CoroutinePluginForwarder 類。
PerfAssist 組件 - CoroutineTracker (on GitHub)
在上面這些實現的基礎上,前段時間我實現了一個編輯器內的工具面板 CoroutineTracker ,用於幫助開發者監控和分析系統內協程的運行情況。
- PerfAssist/PA_CoroutineTracker
左邊的四列是程序運行時所有被追蹤協程的實時的啟動次數,結束次數,執行次數和執行時間。
當點擊圖形上任何一個位置時,選中該時間點(秒為單位),在圖形上是綠色豎條。此時右邊的數據報表刷新為在這一秒中活動的所有協程的列表,如下圖所示:
注意,該表中的數據依次為:
- 協程的完整修飾名 (mangled name)
- 在選定時間段內的執行次數 (selected execution count)
- 在選定時間段內的執行時間 (selected execution time)
- 到該選中時間為止時總的執行次數 (summed execution count)
- 到該選中時間為止時總的執行時間 (summed execution time)
可以通過表頭對每一列的數據進行排序。
當選中列表中某一個協程時,面板的右下角會顯示該協程的詳細信息,如下圖所示:
這裡有下面的信息:- 該協程的序列 ID (sequence ID)
- 啟動時間 (creation time)
- 結束時間 (termination time)
- 啟動時堆棧 (creation stacktrace)
向下滾動,可看到該協程的完整執行流程信息,如下圖所示:
常見問題調查使用這個工具,我們可以更方便地調查下面的問題:
- yield 過於頻繁的
- 單次運行時間太久的
- 總時間開銷太高的
- 進入死循環,始終未能正確結束掉的
- 遞歸 yield 產生過深執行層次的
[完]
Gu Lu
[2016-12-20][注]
- 本文同時發在我的 blog 和知乎專欄
- 本文已授權侑虎科技的公眾號轉載
- 本文遵循 Creative Commons BY-NC-ND 4.0 許可協議。
- CoroutineTracker 工具是 PerfAssist 套件的一部分,後續的改進和更新都會出現在那裡。
- 如果在使用時遇到問題,歡迎直接在 GitHub 上發 Issues 或 Pull Requests 給我,往往能比評論得到更快速的回復。
[補]
[2017-01-06] 多謝評論中的 @CM 君,此問題已修復。
- @CM 君提到,「hi, 我看到Yielders注釋寫道Dictionary以值類型作Key會產生GC,不是是否有進行過實際的測試。我在Profiler中看過Dictionary的ContainsKey、ContainsValue、[Key]等操作均無GC產生。」
- 這段代碼的原出處在這裡。我在 Unity 5.5.0 下已驗證,以 float 作為 Dictionary 的 Key 時,確實不會像注釋中描述的那樣,產生 GC。最新的 Yielders.cs 中已修復此情況。
[2017-01-06] 評論中提到的找不到文件的情況,是因為所缺的問價在子庫 PA_Common 中,見此頁面上對該子庫的引用。使用 "git submodule ..." 更新到本地即可。
推薦閱讀: