LOADING

加载过慢请开启缓存 浏览器默认开启

从IEnumerator到UnityCoroutine——Unity协程的背后原理

2024/11/29

本篇文章可以回答以下问题:

  1. MonoBehaviour.StartCoroutine接收的参数为什么是IEnumeratorIEnumerator和协程有什么关系?
  2. 既然协程函数返回值声明是IEnumerator,为什么函数内yield return的又是不同类型的返回值?
  3. yield是什么,常见的yield returnyield break是什么意思,又有什么区别?
  4. 为什么使用了yield return就可以使代码“停”在那里,达到某种条件后又可以从“停住”的地方继续执行?
  5. 具体的,yield return new WaitForSeconds(3)yield return webRequest.SendWebRequest(),为什么可以实现等待指定时间或是等待请求完成再接着执行后面的代码?

在Unity中使用协程

游戏里面经常会出现类似的淡入淡出效果:“一个游戏物体的颜色渐渐变淡,直至消失。”
下面是一个错误的实现

  void Fade() 
{
    float alpha = 1.0f;
    while(alpha > 0)
    {
        alpha -= Time.deltaTime;
        Color c = renderer.material.color;
        c.a = alpha;
        renderer.material.color = c;
    }
}
  

上面代码的思路很简单,让所渲染对象的Alpha通道从1开始不断递减直至0。但是,函数被调用之后将运行到完成状态然后返回,函数内的一切逻辑都会在同一帧内完成。物体会瞬间消失,而不会淡出。

将函数改写为协程就可以修正这个错误。

IEnumerator Fade()
{
    float alpha = 1.0f;
    while(alpha > 0)
    {
        alpha -= Time.deltaTime;
        Color c = renderer.material.color;
        c.a = alpha;
        renderer.material.color = c;
        yield return null;//协程会在这里被暂停,直到下一帧被唤醒。
    }
}
//Fade不能直接调用,需要使用StartCoroutine(方法名(参数列表))的形式进行调用。
StartCoroutine(Fade());

这种以IEnumerator为返回值的函数,在C#里面被称之为迭代器函数,在Unity里面也可以被称为协程函数。 当协程函数运行到yield return null语句时,协程会被暂停,Unity继续执行其它逻辑,并在下一帧唤醒协程。

Unity官方对于协程的定义是这样的:

A coroutine is like a function that has the ability to pause execution and return control to Unity but then to continue where it left off on the following frame.
By default, a coroutine is resumed on the frame after it yields but it is also possible to introduce a time delay using WaitForSeconds

简单的说,协程就是一种特殊的函数,它可以主动的请求暂停自身并提交一个唤醒条件,Unity会在唤醒条件满足的时候去重新唤醒协程。

生命周期图Base64

MonoBehaviour.StartCoroutine()方法可以开启一个协程,这个协程会挂在该MonoBehaviour下。

MonoBehaviour生命周期的UpdateLateUpdate之间,会检查这个MonoBehaviour下挂载的所有协程,并唤醒其中满足唤醒条件的协程。

要想使用协程,只需要以IEnumerator为返回值,并且在函数体里面用yield return语句来暂停协程并提交一个唤醒条件。然后使用StartCoroutine来开启协程。

IEnumerator CoroutineA(int arg1, string arg2)
{
    Debug.Log("协程A被开启");
    yield return null;
    Debug.Log("暂停了一帧");
    yield return new WaitForSeconds(1.0f);
    Debug.Log("暂停了一秒");
    yield return StartCoroutine(CoroutineB(arg1, arg2));
    Debug.Log("CoroutineB运行结束后协程A才被唤醒");
    yield return new WaitForEndOfFrame();
    Debug.Log("在这一帧的最后,协程被唤醒");
    Debug.Log("协程A运行结束");
}
IEnumerator CoroutineB(int arg1, string arg2)
{
    Debug.Log($"协程B被开启了,可以传参数,arg1={arg1}, arg2={arg2}");
    yield return new WaitForSeconds(3.0f);
    Debug.Log("协程B运行结束");
}

注意:

  1. 协程是挂在MonoBehaviour上的,必须要通过一个MonoBehaviour才能开启协程。
  2. MonoBehaviourDisable的时候协程会继续执行,只有MonoBehaviour被销毁的时候协程才会被销毁。
  3. 协程看起来有点像是轻量级线程,但是本质上协程还是运行在主线程上的,协程更类似于Update()方法,Unity会每一帧去检测协程需不需要被唤醒。一旦你在协程中执行了一个耗时操作,很可能会堵塞主线程。

Unity协程的底层原理

协程分为两部分,协程与协程调度器:协程仅仅是一个能够中间暂停返回的函数,而协程调度是在MonoBehaviour的生命周期中实现的。 准确的说,Unity只实现了协程调度部分,而协程本身其实就是用了C#原生的”迭代器方法“。

IEnumerator是什么

public interface IEnumerator
{
    object Current { get; }//获取集合当前迭代位置的元素
    bool MoveNext();//将当前迭代位置推进到下一个位置,如果成功推进到下一个位置则返回true,否则已经推进到集合的末尾返回false
    void Reset();//将当前迭代位置设置为初始位置(该位置位于集合中第一个元素之前,所以当调用Reset方法后,再调用MoveNext方法,Current值则为集合的第一个元素)
}

枚举器接口提供了一种通用的遍历集合的方法,任意一个集合只要实现自己的IEnumerator,它的使用者就可以通过IEnumerator迭代集合中的元素,而不用针对不同的集合采用不同的迭代方式。

foreach只是C#提供的语法糖,本质上也是采用IEnumerator来遍历集合的。在编译时编译器会将的foreach循环进行下面的代码转换:

foreach (var item in collection)
{
   Console.WriteLine(item.ToString());
}
//转换后的代码
{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())  // 判断是否成功推进到下一个元素(可理解为集合中是否还有可供迭代的元素)
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    } finally
    {
        // dispose of enumerator.
    }
}

yield

yield是C#的关键字,是快速定义迭代器的语法糖。只要是yield出现在其中的方法就会被编译器自动编译成一个迭代器,对于这样的函数可以称之为迭代器函数。迭代器函数的返回值就是自动生成的迭代器类的一个对象。

这个类它会记录当前迭代的状态,包括本地变量的值与迭代器在集合中的位置;它会把yield return x翻译成“将Current设为x,记录当前的状态,并暂停执行”这一系列操作。

//eg.Form Microsoft
var numbers = ProduceEvenNumbers(5);
Console.WriteLine("Caller: about to iterate.");
foreach (int i in numbers)
{
    Console.WriteLine($"Caller: {i}");
}

IEnumerable<int> ProduceEvenNumbers(int upto)
{
    Console.WriteLine("Iterator: start.");
    for (int i = 0; i <= upto; i += 2)
    {
        Console.WriteLine($"Iterator: about to yield {i}");
        yield return i;
        Console.WriteLine($"Iterator: yielded {i}");
    }
    Console.WriteLine("Iterator: end.");
}
// Output:
// Caller: about to iterate.
// Iterator: start.
// Iterator: about to yield 0
// Caller: 0
// Iterator: yielded 0
// Iterator: about to yield 2
// Caller: 2
// Iterator: yielded 2
// Iterator: about to yield 4
// Caller: 4
// Iterator: yielded 4
// Iterator: end.

Unity中的协程利用了迭代器的机制实现了**“执行一系列操作”而非“遍历一个集合”的功能。关键点在于:在这里一个协程中的“集合”**就是所有yield return的返回值,比如{null, new WaitForSeconds(5)}就是下方协程的集合。当我们在遍历这个集合时,所有真正有用的语句都会一并被执行。

IEnumerator MyCoroutine()
{
    //真正有用的语句
    yield return null;
    //真正有用的语句
    yield return new WaitForSeconds(5);
}

yield return语句可以返回一个值,表示迭代得到的当前元素,yield break语句可以用来终止迭代,表示当前没有可被迭代的元素了。

以能“停住”的地方为分界线,编译器会为不同分区的语句按照功能逻辑生成一个个对应的代码块。yield语句就是这条分界线,想要代码“停住”,就不执行后面语句对应的代码块,想要代码恢复,就接着执行后面语句对应的代码块。而调度上下文的保存,是通过将需要保存的变量都定义成成员变量来实现的。

协程本体:C#的迭代器函数

C#中的迭代器方法其实就是一个协程,你可以使用yield来暂停,使用MoveNext()来继续执行。 当一个方法的返回值写成了IEnumerator类型,他就会自动被解析成迭代器方法*(后文直接称之为协程)*,你调用此方法的时候不会真的运行,而是会返回一个迭代器,需要用MoveNext()来真正的运行。

static void Main(string[] args)
{
    IEnumerator it = Test();//仅仅返回一个指向Test的迭代器,不会真的执行。
    Console.ReadKey();
    it.MoveNext();//执行Test直到遇到第一个yield
    System.Console.WriteLine(it.Current);//输出1
    Console.ReadKey();
    it.MoveNext();//执行Test直到遇到第二个yield
    System.Console.WriteLine(it.Current);//输出2
    Console.ReadKey();
    it.MoveNext();//执行Test直到遇到第三个yield
    System.Console.WriteLine(it.Current);//输出test3
    Console.ReadKey();
}
static IEnumerator Test()
{
    System.Console.WriteLine("第一次执行");
    yield return 1;
    System.Console.WriteLine("第二次执行");
    yield return 2;
    System.Console.WriteLine("第三次执行");
    yield return "test3";
}
  • 执行Test()不会运行函数,会直接返回一个IEnumerator
  • 调用IEnumeratorMoveNext()成员,会执行协程直到遇到第一个yield return或者执行完毕。
  • 调用IEnumeratorCurrent成员,可以获得yield return后面接的返回值,该返回值可以是任何类型的对象

那么我们都知道yield return new WaitForSeconds(5)会停5秒,yield return null会停一帧,那只有写yield return null才会延迟到下一帧执行吗?

下面这段伪代码展示了StartCoroutine()的内部原理:

void MyStartCoroutine()
{
	Update//每一帧都执行
	{
        MoveNext();
        if(Current is WaitForSeconds)
        {
        	Stop for x seconds;//具体逻辑可能就是我们写的一个简单的计时器,timer=time+=Time.dealtTime这样
        }
        else if(Current is WaitFixedUpdate)
        {
        	Stop for fixedupdate;
        }
        else
        	//···
	}
}

所以我们如果在yield return后面写一个new GameObject();编译器也不会报错,这些奇奇怪怪的返回类型根本在StartCoroutine()里面没有对应的情况,所以它不会执行特定的操作,依然默认间隔一帧,然后执行MoveNext(),甚至写new GameObject();,确实会把Current的值设置为新实例化一个GameObject,场景里面真的会出现新的GameObject。所以写yield return null只是为了不发生奇奇怪怪的事,并非它是特定的延迟一帧。

  1. IEnumerator中的yield return可以返回任意类型的对象,事实上它还有泛型版本IEnumerator<T>,泛型类型的迭代器中只能返回T类型的对象。Unity原生协程使用普通版本的IEnumerator

  2. 函数调用的本质是压栈,协程的唤醒也一样,调用IEnumerator.MoveNext()时会把协程方法体压入当前的函数调用栈中执行,运行到yield return后再弹栈。

协程调度:MonoBehaviour生命周期中实现

  1. C#层调用StartCoroutine方法,将IEnumerator对象(或者是用于创建IEnumerator对象的方法名字符串)传入C++层。
  2. 通过mono的反射功能,找到IEnumerator上的moveNextcurrent两个方法,然后创建出一个对应的Coroutine对象,把两个方法传递给这个Coroutine对象。【创建好之后这个Coroutine对象会保存在MonoBehaviour一个成员变量List中,这样使得MonoBehaviour具备StopCoroutine功能,StopCoroutine能够找到对应Coroutine并停止。】
  3. 调用这个Coroutine对象的Run方法
  4. Coroutine.Run中,然后调用一次MoveNext。①如果MoveNext返回false,表示Coroutine执行结束,进入清理流程;②如果返回true,表示Coroutine执行到了一句yield return处,这时就需要调用invocation(m_Current).Invoke取出yield return返回的对象monoWait,再根据monoWait的具体类型(nullWaitForSecondsWaitForFixedUpdate等),将Coroutine对象保存到DelayedCallManagercallback列表m_CallObjects中。
  5. 至此,Coroutine在当前帧的执行即结束。
  6. 之后游戏运行过程中,游戏主循环的PlayerLoop方法会在每帧的不同时间点以不同的modeMask调用DelayedCallManager.Update方法,Update方法中会遍历callback列表中的Coroutine对象,如果某个Coroutine对象的monoWait的执行条件满足,则将其从callback列表中取出,执行这个Coroutine对象的Run方法,回到之前的执行流程中。

可以模仿Unity自己实现一个简单的协程调度:

void YieldWaitForSeconds()
{
    //定义一个移除列表,当一个协程执行完毕或者唤醒条件的类型改变时,应该从当前协程列表中移除。
    List<WaitForSeconds> removeList = new List<WaitForSeconds>();
    foreach(IEnumerator w in m_WaitForSeconds) //遍历所有唤醒条件为WaitForSeconds的协程
    {
        if(Time.time >= w.beginTime() + w.interval) //检查是否满足了唤醒条件
        {
            //尝试唤醒协程,如果唤醒失败,则证明协程已经执行完毕
            if(it.MoveNext();)
            {
                //应用新的唤醒条件
                if(!(it.Current is WaitForSeconds))
                {
                    removeList.Add(it);
                       //在这里写一些代码,将it移到其它的协程队列里面去
                }
            }
            else 
            {
                removeList.Add(it);
            }
        }
    }
    m_WaitForSeconds.RemoveAll(removeList);
}

Unity协程只是个伪异步,内部的死循环依旧会导致应用卡死

Unity之外的协程

进程,线程与协程

一个进程可以有多个线程,一个线程可以有多个协程

进程其实就是程序运行的实例:程序本身只是存储在外存上的冷冰冰的二进制流,计算机将这些二进制流读进内存并解析成指令和数据然后执行,程序便成为了进程。

每一个进程都独立拥有自己的指令和数据,所以称为资源分配的基本单位。其中数据又分布在内存的不同区域,一个运行中的进程所占有的内存大体可以分为四个区域:栈区、堆区、数据区、代码区。其中代码区存储指令,另外三个区存储数据。

线程是处理器调度和执行的基本单位,一个线程往往和一个函数调用栈绑定,一个进程有多个线程,每个线程拥有自己的函数调用栈,同时共用进程的堆区,数据区,代码区。操作系统会不停地在不同线程之间切换来营造出一个并行的效果,这个策略称为时间片轮转法。线程是并行的,多个线程可以同时运行,而多线程共享资源,所以访问全局变量的时候要加锁。

一切用户自己实现的,类似于线程的轮子,都可以称之为是协程。

协程有什么样的行为,完全由实现协程的程序员来决定。


参考与引用:

Unity协程的原理与应用

【迭代器模式】深入理解协程