深入剖析 Unity 协程的实现原理

直击Unity协程源码,C#中的yield return

Posted by SWZ on May 8, 2020

前言

用过Unity的应该都知道协程,今天就给大家来讲解下这个简洁又神奇的设计。一般的使用场景就是需要异步执行的时候,比如下载、加载、事件的延时触发等,函数的返回值是IEnumerator类型,开启一个协程只需要调用StartCoroutine即可,之后Unity会在每一次GameLoop的时候调用协程,具体的时间点可以看文档

说了这么多我们来看下官方对协程给出的定义:

A coroutine is a function that is executed partially and, presuming suitable conditions are met, will be resumed at some point in the future until its work is done.

即协程是一个分部执行,遇到条件(yield return 语句)会挂起,直到条件满足才会被唤醒继续执行后面的代码。

Demo

前面的说的都是概念性的东西,为了给大家更直观的感受,直接上Demo。

private void Start()
{
    StartCoroutine(TestEnumerator());
}

private IEnumerator TestEnumerator()
{
    UnityEngine.Debug.Log("wait for 1s");
    yield return new WaitForSeconds(1f);
    UnityEngine.Debug.Log("wait for 2s");
    yield return new WaitForSeconds(2f);
    UnityEngine.Debug.Log("wait for 3s");
    yield return new WaitForSeconds(3f);
}

上面的执行结果是:

wait for 1s
等待了一秒    
wait for 2s
等待了两秒
wait for 3s
等待了三秒

Yield是什么

看了上面的Demo细心的各位有没有这样的疑惑。

  1. return前面怎么有个yield关键字。
  2. TestEnumerator函数的返回值是IEnumerator类型但是返回的对象并不是该类型。

为了解释这些问题我们先来看下函数的返回值IEnumerator类型的定义:

public interface IEnumerator
{   
    object Current { get; } 
    bool MoveNext(); 
    void Reset(); 
}

其实,C#为了简化我们创建枚举器的步骤,你想想看你需要先实现 IEnumerator 接口,并且实现 Current,、MoveNext、Reset 步骤。C#从2.0开始提供了有yield组成的迭代器块,编译器会自动更具迭代器块创建了枚举器。不信,用Reflector反编译看看:

[CompilerGenerated]
private sealed class <TestEnumerator>d__1 : IEnumerator<object>, IEnumerator, IDisposable
{
    private int <>1__state;
    private object <>2__current;
    public Test <>4__this;

    [DebuggerHidden]
    public <TestEnumerator>d__1(int <>1__state)
    {
        this.<>1__state = <>1__state;
    }

    private bool MoveNext()
    {
        switch (this.<>1__state)
        {
            case 0:
                this.<>1__state = -1;
                UnityEngine.Debug.Log("wait for 1s");
                this.<>2__current = new WaitForSeconds(1f);
                this.<>1__state = 1;
                return true;

            case 1:
                this.<>1__state = -1;
                UnityEngine.Debug.Log("wait for 2s");
                this.<>2__current = new WaitForSeconds(2f);
                this.<>1__state = 2;
                return true;

            case 2:
                this.<>1__state = -1;
                UnityEngine.Debug.Log("wait for 3s");
                this.<>2__current = new WaitForSeconds(3f);
                this.<>1__state = 3;
                return true;

            case 3:
                this.<>1__state = -1;
                return false;
        }
        return false;
    }

    object IEnumerator.Current
    {
        [DebuggerHidden]
        get
        {
            return this.<>2__current;
        }
    }

    ...
}

从中可以得出:

  • yield是个语法糖,编译过后的代码看不到yield

  • 编译器在内部创建了一个枚举类 <TestEnumerator>d__1

  • yield return 被声明为枚举时的下一项,即Current属性,通过MoveNext方法来访问结果

关于更多IEnumerator/yield的细节,推荐参考《深入理解C#》一书的第六章:“实现迭代器的捷径”


StartCoroutine

好了,既然我们已经搞明白了IEnumerator对象是怎么来的了,现在我们就来看看Unity引擎拿到了该对象后是如何处理的吧。

Unity的代码架构是分为三层的,不知道的同学可以看我之前写的一篇文章 Unity中的代码架构

源码分析

Test.cs(Unity逻辑层)

private void Start()
{
    StartCoroutine(TestEnumerator());
}

在Unity的逻辑层进入StartCoroutine的定义你会看到如下代码:

namespace UnityEngine
{
    public class MonoBehaviour : Behaviour
    {
        ...
        public Coroutine StartCoroutine(IEnumerator routine);
        public Coroutine StartCoroutine(string methodName);
        ...
    }
}

发现这些代码已经被封装好编译成了.dll文件,如果想看到具体实现可以在git上获取源码(Unity官方公布了中间层的代码,但是还未公布底层C++的代码)。

MonoBehavior.bindings.cs(Unity中间层)

当你下载好中间层的源码后发现,最核心的实现StartCoroutineManaged2竟然是个被extern修饰的外部函数。

extern Coroutine StartCoroutineManaged(string methodName, object value);
extern Coroutine StartCoroutineManaged2(IEnumerator enumerator);

public Coroutine StartCoroutine(string methodName)
{
    object value = null;
    return StartCoroutine(methodName, value);
}

public Coroutine StartCoroutine(IEnumerator routine)
{
    if (routine == null)
        throw new NullReferenceException("routine is null");

    if (!IsObjectMonoBehaviour(this))
        throw new ArgumentException("Coroutines can only be stopped on a MonoBehaviour");

    return StartCoroutineManaged2(routine);
}

MonoBehavior.cpp(Unity底层)

通过各种途径的尝试终于获得了Unity的底层源码 \(^o^)/,这里因为版权问题大家还是自行从网络渠道获取吧。

MonoBehaviour::StartCoroutineManaged2(ScriptingObjectPtr enumerator)
{
    Coroutine* coroutine = CreateCoroutine(enumerator, SCRIPTING_NULL);
    return 封装过的Coroutine对象;
}

Coroutine* MonoBehaviour::CreateCoroutine(ScriptingObjectPtr userCoroutine, ScriptingMethodPtr method)
{
    获取moveNext;
    获取current;
    
    Coroutine* coroutine = new Coroutine ();
    初始化coroutine对象;    //这个时候就会把moveNext和current传递给coroutine对象
    
    m_ActiveCoroutines.push_back (*coroutine);
    m_ActiveCoroutines.back ().Run ();
    ...
    return coroutine;
}

Coroutine.cpp(Unity底层)

void Coroutine::Run ()
{
    // - Call MoveNext (处理迭代器块的逻辑直到遇到yield return)
    // - Call Current (返回一个条件,何时可以执行下一个moveNext)
    
    //根据IEnumerator的特性,首先得调用下MoveNext,这样current就被赋值了
    bool keepLooping = InvokeMoveNext(&exception);    
    
    ProcessCoroutineCurrent();
}

void Coroutine::ProcessCoroutineCurrent()
{
    //调用Current,并从中取出yield return的返回对象monoWait
    ScriptingInvocation invocation(m_Current);
    ...
    ScriptingObjectPtr monoWait = invocation.Invoke(&exception);
   
    //yield return null
    if (monoWait == SCRIPTING_NULL)
    {
        ...
        //wait的时间就是0,相当于等一帧
        CallDelayed (ContinueCoroutine, m_Behaviour, 0.0F, this, 0.0F, CleanupCoroutine, DelayedCallManager::kRunDynamicFrameRate | DelayedCallManager::kWaitForNextFrame);
        return;
    }
    
    HandleIEnumerableCurrentReturnValue(monoWait);
}

void Coroutine::HandleIEnumerableCurrentReturnValue(ScriptingObjectPtr monoWait)
{
    ScriptingClassPtr waitClass = scripting_object_get_class (monoWait, GetScriptingTypeRegistry());
    const CommonScriptingClasses& classes = GetMonoManager ().GetCommonClasses ();
    
    //yield return new WaitForSeconds()
    if (scripting_class_is_subclass_of (waitClass, classes.waitForSeconds))
    {
        float wait;
        通过monoWait获取需要wait的时间;
        CallDelayed(ContinueCoroutine, m_Behaviour, wait, this, 0.0F, CleanupCoroutine, DelayedCallManager::kRunDynamicFrameRate | DelayedCallManager::kWaitForNextFrame);
        return;  
    }
    
    //yield reuturn new WaitForFixedUpdate()
    if (scripting_class_is_subclass_of (waitClass, classes.waitForFixedUpdate))
    {
        CallDelayed (ContinueCoroutine, m_Behaviour, 0.0F, this, 0.0F, CleanupCoroutine, DelayedCallManager::kRunFixedFrameRate);
        return;  
    }
    
    //yield return new WaitForEndOfFrame()
    if (scripting_class_is_subclass_of (waitClass, classes.waitForEndOfFrame))
    {
        CallDelayed (ContinueCoroutine, m_Behaviour, 0.0F, this, 0.0F, CleanupCoroutine, DelayedCallManager::kEndOfFrame);
        return;  
    }
    
    //yield return 另一个协程
    if (scripting_class_is_subclass_of (waitClass, classes.coroutine))
	{
        Coroutine* waitForCoroutine;
        ...
        if(waitForCoroutine->m_DoneRunning)
        {
            ContinueCoroutine(m_Behavoir, this);
            return;
        }
        ...
	    return;  
	}
    
    //yield return www
    if (scripting_class_is_subclass_of (waitClass, classes.www))
    {
        WWW* wwwPtr;
        if(wwwPtr != NULL)
        {
            //WWW类型比较特殊它本身做了类似的处理,它提供了一个方法CallWhenDone,当它完成的时候直接回调Coroutine。
            wwwPtr->CallWhenDone(ContinueCoroutine, m_Behaviour, this, CleanupCoroutine);
        }
        return;  
    }
}

void Coroutine::ContinueCoroutine (Object* o, void* userData)
{
    Coroutine* coroutine = (Coroutine*)userData;
    if((Object*)coroutine->m_Behaviour != o)
    {
        ...
        reutrn;
    }
    coroutine->Run();
}

CallDelayed.cpp(Unity底层)

//这个枚举型就是下面用到的mode
enum  {
    kRunFixedFrameRate = 1 << 0,
    kRunDynamicFrameRate = 1 << 1,
    kRunStartupFrame = 1 << 2,
    kWaitForNextFrame = 1 << 3,
    kAfterLoadingCompleted = 1 << 4,
    kEndOfFrame = 1 << 5
};

void CallDelayed (DelayedCall *func, PPtr<Object> o, float time, void* userData, float repeatRate, CleanupUserData* cleanup, int mode)
{
    DelayedCallManager::Callback callback;
    
    callback.time = time + GetCurTime ();
    callback.userData = userData;
    callback.call = func;
    callback.cleanup = cleanup;
    callback.object = o;
    callback.mode = mode;
    ...
        
    //将callback保存在DelayedCallManager的Callback List中
    GetDelayedCallManager ().m_CallObjects.insert (callback);
}

void DelayedCallManager::Update (int modeMask)
{
    float time = GetCurTime();
    Container::iterator i = m_CallObjects.begin ();
    
    while (i !=  m_CallObjects.end () && i->time <= time)
    {
        m_NextIterator = i;	m_NextIterator++;
        Callback &cb = const_cast<Callback&> (*i);
        
        // - 确保modeMask匹配
        // - 不执行那些在DelayedCallManager::Update中被添加进来的delayed calls
        if((cb.mode & modeMask) && cb.timeStamp != m_TimeStamp && cb.frame <= frame)
        {
            void* userData = cb.userData;
            DelayedCall* callback = cb.call;
            
            if (!cb.repeat)
            {
                callback列表中移除即将被执行的callback;
                callback (o, userData);    //执行callback
                清除userData;
            }
            else
            {
                //增加时间后并重新插入callback列表中
                cb.time += cb.repeatRate;
                ...
                m_CallObjects.insert (cb);
                
                callback列表中移除即将被执行的callback;
                callback (o, userData);    //执行callback
            }
        }
        
        i = m_NextIterator;
    }
}

详细的流程分析:

  • C#层调用StartCoroutine方法,将IEnumerator对象(或者是用于创建IEnumerator对象的方法名字符串)传入C++层。
  • 通过mono的反射功能,找到IEnuerator上的moveNextcurrent两个方法,然后创建出一个对应的Coroutine对象,把两个方法传递给这个Coroutine对象。
  • 创建好之后这个Coroutine对象会保存在MonoBehaviour一个成员变量List中,这样使得MonoBehaviour具备StopCoroutine功能,StopCoroutine能够找到对应Coroutine并停止。
  • 调用这个Coroutine对象的Run方法。

  • Coroutine.Run中,然后调用一次MoveNext。如果MoveNext返回false,表示Coroutine执行结束,进入清理流程;如果返回true,表示Coroutine执行到了一句yield return处,这时就需要调用invocation(m_Current).Invoke取出yield return返回的对象monoWait,再根据monoWait的具体类型(null、WaitForSeconds、WaitForFixedUpdate等),将Coroutine对象保存到DelayedCallManager的callback列表m_CallObjects中。
  • 至此,Coroutine在当前帧的执行即结束。

  • 之后游戏运行过程中,游戏主循环的PlayerLoop方法会在每帧的不同时间点以不同的modeMask调用DelayedCallManager.Update方法,Update方法中会遍历callback列表中的Coroutine对象,如果某个Coroutine对象的monoWait的执行条件满足,则将其从callback列表中取出,执行这个Coroutine对象的Run方法,回到之前的执行流程中。

至此,Coroutine的整体流程已经分析完毕,实现原理已经很明朗了。


总结

  1. 协程只是看起来像多线程一样,其实还是在主线程上执行。
  2. 协程只是个伪异步,内部的死循环依旧会导致应用卡死。
  3. yield是C#的语法糖,和Unity没有关系。
  4. 避免使用字符串的版本开启一个协程,字符串的版本在运行时要用mono的反射做更多参数检查、函数查询工作,带来性能损失。