p1

协程 (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)。

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

2016-12-20-unity-coroutine-optimizing

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

接管和监控 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

p1

功能介绍

左边的四列是程序运行时所有被追踪协程的实时的启动次数,结束次数,执行次数和执行时间。

p2

当点击图形上任何一个位置时,选中该时间点(秒为单位),在图形上是绿色竖条。

此时右边的数据报表刷新为在这一秒中活动的所有协程的列表,如下图所示:

p3

注意,该表中的数据依次为:

  • 协程的完整修饰名 (mangled name)
  • 在选定时间段内的执行次数 (selected execution count)
  • 在选定时间段内的执行时间 (selected execution time)
  • 到该选中时间为止时总的执行次数 (summed execution count)
  • 到该选中时间为止时总的执行时间 (summed execution time)

可以通过表头对每一列的数据进行排序。

当选中列表中某一个协程时,面板的右下角会显示该协程的详细信息,如下图所示:

p4

这里有下面的信息:

  • 该协程的序列 ID (sequence ID)
  • 启动时间 (creation time)
  • 结束时间 (termination time)
  • 启动时堆栈 (creation stacktrace)

向下滚动,可看到该协程的完整执行流程信息,如下图所示:

p5

常见问题调查

使用这个工具,我们可以更方便地调查下面的问题:

  • yield 过于频繁的
  • 单次运行时间太久的
  • 总时间开销太高的
  • 进入死循环,始终未能正确结束掉的
  • 递归 yield 产生过深执行层次的


Gu Lu


[注]


[补]

  • [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 ..." 更新到本地即可。

Comments
Write a Comment

Tags

随笔   游戏开发   Programming   C/C++   优化   Unity   C++   知乎   游戏设计   比特币   Unity3D   区块链   软件开发   Bitcoin   引擎设计   系统架构   Production   idtech   中国文化   加密货币   项目管理   游戏评论   资源管理   资源流水线   效率   道德经   网络   方法论   模板编程   Blockchain   Lua   Blockchain Computing   Oculus   GDC   渲染   VR   PerfAssist   Unity MemoryProfiler   BCH   经济学   信息过载   行业报告   字体   Productivity   图形   网络编程   Dice   协程   EMC   Premake   测试   中间件   Game Engine   新手引导   区块链游戏   Methodology   CI   命令行解析   goroutine   ndk   Ethereum   nanomsg   自动化   Scripting   摘录   Debugging   同步技术   cppcon   C++模板   DOOM3   技术评估   Unity GC   C++11   学习方法   Surface Pro 3   Engine Evaluation   CRT   文化   笔记   golang   图形编程   多线程   ETH   Bitcoin Cash   cppcon14   Bitcoin SV   Visual Studio   Unity Coroutine   跨语言可变参数列表   团队协作   货币   Deployment   Visual Assist   工程改进   Michael Abrash   exp   开放世界   量子计算   域名   虚拟现实   系统重构   slua   遮挡剔除   完美转发   协作式调度   Modern C++   类型推导   Memory Debugging   个人成长   小故事   BTC   暴雪   产品   历史   错误处理   Unity Profiler   MOD  

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。