• Unity 协程运行时的监控和优化


    我是快乐的搬运工: http://gulu-dev.com/post/perf_assist/2016-12-20-unity-coroutine-optimizing#toc_0

    ------------------------------------------------------------------------ 分割线 ----------------------------------------------------------------------------

    目录:

    Warm up: 从复用 Yield 对象说起
      Coroutine 的工作原理
      接管和监控 Coroutine 的行为 ◦ 问题描述
      中间层 TrackedCoroutine
      启动函数 InvokeStart()
      监控 Plugins 内的协程

    PerfAssist 组件 - CoroutineTracker (on GitHub) ◦ 功能介绍
      常见问题调查

    协程 (Coroutine) 是大部分现代编程环境都提供的一个非常有用的机制。它允许我们把不同时刻发生的行为,在代码中以线性的方式聚合起来。与基于事件与回调的系统相比,以协程方式组织的业务逻辑,可读性相对好一些。

    Unity 内的协程实现是传统协程的简化——在主线程内每一帧给定的时间点上,引擎通过一定的调度机制来唤醒和执行满足条件的协程,以实际上的分时串行化执行回避了协程之间的通信问题。但由于种种因素,协程的执行情况对程序员而言相对不那么透明,可以通过一些简单的机制来对其进行监控和优化。

    Warm up: 从复用 Yield 对象说起

    先从一个最简单而直接的改进开始吧。下面一个在每帧结束时执行的协程的例子:

    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 负担。常见的有:

        •WaitForSeconds 
        •WaitForFixedUpdate 
        •WaitForEndOfFrame 
    

     在 Yielders.cs 这个文件里,集中地创建了上面这些类型的静态对象,使用时可以直接这样:

        ...
        yield return Yielders.GetWaitForSeconds(1.0f);  // wait for one second
        ...
    

    Coroutine 的工作原理

    观察调用链可知,Unity Coroutine 的调用约定靠返回的 IEnumerator 对象来维系。我们知道 IEnumerator 的核心功能函数是:

    bool MoveNext();
    

    这个函数在每次被 Unity 协程调度函数 (通常是协程所在类的 SetupCoroutine()) 唤醒时调用,用于驱动对应的协程由上一次 yield 语句开始执行下面的代码段,直到下一条 yield 语句 (对应返回 true) 或函数退出 (对应返回 false)。

    下图是一次典型的协程调用:

    图中的绿色实心方块是协程实际的活跃执行时间。可以看出,一个协程的完整生命周期是“在整个生命周期内对其内部所有代码段的一个遍历并依次执行”的过程。

    接管和监控 Coroutine 的行为

    问题描述

    由于以下几点问题的存在,协程的执行情况对开发者而言并不透明,很容易在开发过程中引入性能问题。

    1. 协程 (除了首次执行) 不是在用户的函数内触发,而是在单独的 SetupCoroutine() 内被激活并执行
    2. 协程的每次活跃执行,在代码上以单次 yield 为界限。对于具有复杂分支的业务逻辑,尤其是“本来在主流程内,后来被协程化”的代码,很难看出每一段 yield 的潜在执行量
    3. 实践中,如果同时激活的协程较多,就可能会出现多个高开销的协程挤在同一帧执行导致的卡帧。这一类卡顿难以复现和调查。

    中间层 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 类中,可以看到以下两个接口,针对以 IEnumeratorstring,及可选的单参形式等三种形式的协程启动的封装。

    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 类。

    PerfAssist 组件 - CoroutineTracker (on GitHub)

    在上面这些实现的基础上,前段时间我实现了一个编辑器内的工具面板 CoroutineTracker ,用于帮助开发者监控和分析系统内协程的运行情况。

    https://github.com/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 产生过深执行层次的
  • 相关阅读:
    最详细易懂的CRC-16校验原理(附源程序)(转)
    深入理解Java的接口和抽象类(转)
    同步、更新、下载Android Source & SDK from 国内镜像站(转载)
    django字段查询参数及聚合函数
    django不返回QuerySets的API
    django查询集API
    django查询操作
    django用包来组织模型
    django模型的继承
    django模型的元数据Meta
  • 原文地址:https://www.cnblogs.com/crazytomato/p/8178493.html
Copyright © 2020-2023  润新知