本文共 4682 字,大约阅读时间需要 15 分钟。
◆◆◆◆
先从一个最简单而直接的改进开始吧。下面是一个在每帧结束时执行协程的例子:
void Start(){ StartCoroutine(OnEndOfFrame());}IEnumerator OnEndOfFrame(){ yield return null; while (true) { //Debug.LogFormat("Called on EndOfFrame."); yield return new WaitForEndOfFrame(); }}
在 Profiler 内可以看到,上面的代码会导致 WaitForEndOfFrame 对象的每帧分配,给 GC 增加负担。假设游戏内有 10 个活跃协程,运行在 60 fps,那么每秒钟的 GC 增量负担是 10 * 60 * 16 = 9.6 KB/s。
我们可以简单地通过复用一个全局的 WaitForEndOfFrame 对象来优化掉这个开销:
static WaitForEndOfFrame _endOfFrame = new WaitForEndOfFrame();
在合适的地方创建一个全局共享的 _endOfFrame 之后,只需要把上面的代码改为:
... yield return _endOfFrame; ...
上面的 9.6 KB/s 的 GC 开销就被完全避免了,而逻辑上与优化前完全没有任何区别。
实际上,所有继承自 YieldInstruction 的用于挂起协程的指令类型,都可以使用全局缓存来避免不必要的 GC 负担。常见的有:
在 Yielders.cs这个文件里,集中地创建了上面这些类型的静态对象,使用时可以直接这样:
... yield return Yielders.GetWaitForSeconds(1.0f); // wait for one second ...
◆◆◆◆
观察调用链可知,Unity Coroutine 的调用约定靠返回的 IEnumerator 对象来维系。我们知道 IEnumerator 的核心功能函数是:
bool MoveNext();
这个函数在每次被 Unity 协程调度函数 (通常是协程所在类的 SetupCoroutine()) 唤醒时调用,用于驱动对应的协程由上一次 yield 语句开始执行下面的代码段,直到下一条 yield 语句 (对应返回 true) 或函数退出 (对应返回 false)。
下图是一次典型的协程调用:
图中的绿色实心方块是协程实际的活跃执行时间。可以看出,一个协程的完整生命周期是“在整个生命周期内对其内部所有代码段的一个遍历并依次执行”的过程。◆◆◆◆
一、问题描述
由于以下几点问题的存在,协程的执行情况对开发者而言并不透明,很容易在开发过程中引入性能问题。
协程 (除了首次执行) 不是在用户的函数内触发,而是在单独的 SetupCoroutine() 内被激活并执行。
协程的每次活跃执行,在代码上以单次 yield 为界限。对于具有复杂分支的业务逻辑,尤其是“本来在主流程内,后来被协程化”的代码,很难看出每一段 yield 的潜在执行量。
实践中,如果同时激活的协程较多,就可能会出现多个高开销的协程挤在同一帧执行导致的卡帧。这一类卡顿难以复现和调查。
二、中间层 TrackedCoroutine
针对这些情况,我们可以在主流程和协程之间添加一层 Wrapper,来接管和监控实际协程的执行情况。具体地说,可以实现一个纯转发的 IEnumerator,如下的缩减版所示:
public class TrackedCoroutine : IEnumerator{ IEnumerator _routine; public TrackedCoroutine(IEnumerator routine) { _routine = routine; // 在这里标记协程的创建 } object IEnumerator.Current { get { return _routine.Current; } } public bool MoveNext() { // 在这里可以: // 1. 标记协程的执行 // 2. 记录协程本次执行的时间 bool next = _routine.MoveNext(); if (next) { // 一次普通的执行 } else { // 协程运行到末尾,已结束 } return next; } public void Reset() { _routine.Reset(); }}
完整版的代码见 TrackedCoroutine类的实现:
有了这样一个 TrackedCoroutine 之后,我们就可以把正常的
abc.StartCoroutine(xxx());
替换为
abc.StartCoroutine(new TrackedCoroutine(xxx()));
三、启动函数 InvokeStart()
在 RuntimeCoroutineTracker 类中,可以看到以下两个接口,针对以 IEnumerator,string,及可选的单参形式等三种形式的协程启动的封装。
public class RuntimeCoroutineTracker{ public static Coroutine InvokeStart(MonoBehaviour initiator, IEnumerator routine); public static Coroutine InvokeStart(MonoBehaviour initiator, string methodName, object arg = null);}
上面的外部调用就可以替换为:
RuntimeCoroutineTracker.InvokeStart(abc, xxx());
至此,藉由一个中间层 TrackedCoroutine,我们得以接管和监控所有协程的单次运行过程。
四、监控 Plugins 内的协程
由于 Plugins 目录单独编译,无法直接调用外部的功能,这里我们为所有的插件提供一个转发机制,用于把插件内启动协程的请求转发到上面的启动函数。
首先定义两个委托:
public delegate Coroutine CoroutineStartHandler_IEnumerator(MonoBehaviour initiator, IEnumerator routine);public delegate Coroutine CoroutineStartHandler_String(MonoBehaviour initiator, string methodName, object arg = null);
然后把实际的协程请求转发给这两个委托:
public class CoroutinePluginForwarder{ ... public static Coroutine InvokeStart(MonoBehaviour initiator, IEnumerator routine) { return InvokeStart_IEnumerator(initiator, routine); } public static Coroutine InvokeStart(MonoBehaviour initiator, string methodName, object arg = null) { return InvokeStart_String(initiator, methodName, arg); } ...}
最后在运行时注册两个委托即可:
CoroutinePluginForwarder.InvokeStart_IEnumerator = RuntimeCoroutineTracker.InvokeStart;CoroutinePluginForwarder.InvokeStart_String = RuntimeCoroutineTracker.InvokeStart;
完整的代码实现见 CoroutinePluginForwarder类:
◆◆◆◆
在上面这些实现的基础上,前段时间我实现了一个编辑器内的工具面板 CoroutineTracker ,用于帮助开发者监控和分析系统内协程的运行情况。
PerfAssist/PA_CoroutineTracker:
一、功能介绍
左边的四列是程序运行时所有被追踪协程的实时的启动次数,结束次数,执行次数和执行时间。
当点击图形上任何一个位置时,选中该时间点(秒为单位),在图形上是绿色竖条。此时右边的数据报表刷新为在这一秒中活动的所有协程的列表,如下图所示: 注意,该表中的数据依次为:可以通过表头对每一列的数据进行排序。
当选中列表中某一个协程时,面板的右下角会显示该协程的详细信息,如下图所示:
这里有下面的信息:
向下滚动,可看到该协程的完整执行流程信息,如下所示:
二、常见问题调查
使用这个工具,我们可以更方便地调查下面的问题:
CoroutineTracker 工具是 PerfAssist套件的一部分,后续的改进和更新都会出现在那里。
原文出处:侑虎科技 本文作者:admin 转载请与作者联系,同时请务必标明文章原始出处和原文链接及本声明。