在Unity中StartCoroutine / yield return在底层是如何运作的?

Number of views 39

我理解协程的原理。我知道如何在Unity中使用C#实现API提及的StartCoroutine / yield return的调用方式,例如,通过StartCoroutine调用一个返回IEnumerator的方法,在这个方法中做一些事情,然后使用yield return new WaitForSeconds(1);等待一秒钟,再做其他的事情。

我的问题是:背后究竟发生了什么?StartCoroutine到底做了什么?WaitForSeconds返回的是什么样的IEnumerator?StartCoroutine是如何将控制权移交回给被调用方法的非协程部分?这一切是如何与Unity的主线程交互的?

1 Answers

在Unity中,IEnumerator 是一个接口,用于实现协程(Coroutine)。协程是一种特殊的函数,它可以在多个帧之间暂停和恢复执行,这在执行长时间操作时非常有用,比如加载资源、等待网络请求等。
正如上述AI的回复,协程的返回对象是IEnumerator,也就是非泛型枚举器的基类接口,IEnumerator的定义大体如下:

public interface IEnumerator
{
    object Current { get; }

    bool MoveNext();
    void Reset();
}
  • Current属性:用于获取集合中当前迭代位置对应的元素
  • MoveNext方法:用于将迭代位置的索引推进到下个位置,如果成功则返回true,已经指向到末尾则返回false
  • Reset方法:将当前迭代位置索引重置为初始位置,初始位置通常为第一个元素之前,这样再次调用MoveNext时,Current值即为迭代器中的第一个元素

枚举器可用于读取集合中的数据,但不能用于修改基础集合。

最初,枚举器位于集合中的第一个元素之前。 在读取 Current值之前,必须调用 MoveNext 方法将枚举器前进到集合的第一个元素; 否则,Current 未定义。

Current 返回相同的对象,直到调用 MoveNext 或 Reset。 MoveNext 将 Current 设置为下一个元素。

如果 MoveNext 传递集合的末尾,则枚举器位于集合中的最后一个元素之后,MoveNext 返回 false。 当枚举器处于此位置时,对 MoveNext 的后续调用也会返回 false。 如果最后一次调用 MoveNext 返回 false,则 Current 未定义。

若要再次将 Current 设置为集合的第一个元素,可以调用 Reset(如果已实现)后跟 MoveNext。 如果未实现 Reset,则必须创建新的枚举器实例才能返回到集合的第一个元素。

如果对集合进行了更改(例如添加、修改或删除元素),则枚举器的行为是未定义的。

枚举器不具有对集合的独占访问权限;因此,通过集合进行枚举本质上不是线程安全的过程。 即使集合同步,其他线程仍可以修改集合,这会导致枚举器引发异常。 若要保证枚举期间的线程安全性,可以在整个枚举期间锁定集合,也可以捕获由其他线程所做的更改导致的异常。

更多迭代器相关资料可以查阅IEnumerator 接口

yield return

在Unity中一个IEnumerator函数通常有以下几种不同的yield return定义:

  1. yield return null:这告诉Unity在当前帧结束时暂停协程的执行,并在下一帧开始时继续执行。
  2. yield return new WaitForSeconds(time):这告诉Unity等待指定的时间后继续执行协程。
  3. yield return StartCoroutine(anotherCoroutine):这允许一个协程等待另一个协程完成。
  4. yield return new WaitUntil(condition):这告诉协程等待直到指定的条件为真。
  5. yield return new WaitWhile(condition):这告诉协程等待直到指定的条件为假。

假设我们在Unity中定义了如下的迭代器函数:

IEnumerator TestEnumerator()
{
    yield return null;
    Debug.Log("我是第一个yield return执行后执行的");
    yield return null;
    Debug.Log("我是第二个yield return执行后执行的");
    yield break;
    Debug.Log("我是yield break执行后执行的");
    yield return null;
}

此时,我们不使用StartCoroutine对TestEnumerator函数开启协程,而是通过直接调用的方式:

void Start()
{
    IEnumerator test = TestEnumerator();
}

此时控制台将不会有任何输出。然后调用test.MoveNext()后,第一个yield return才执行:

void Start()
{
    IEnumerator test = TestEnumerator();
    Debug.Log("第一个yield return执行" + test.MoveNext());
}

此时输出:
image.png
如果继续调用MoveNext:

void Start()
{
    IEnumerator test = TestEnumerator();
    Debug.Log("第一个yield return执行" + test.MoveNext());
    Debug.Log("第二个yield return执行" + test.MoveNext());
}

此时控制台将输出:
image.png
也就是当第二次调用test.MoveNext()时,第一个yield return与第二个yield return之间的代码将会执行,此时Current指向了第二个yield return。再次调用test.MoveNext后:

void Start()
{
    IEnumerator test = TestEnumerator();
    Debug.Log("第一个yield return执行" + test.MoveNext());
    Debug.Log("第二个yield return执行" + test.MoveNext());
    Debug.Log("第三个yield break执行即中止" + test.MoveNext());
}

输出结果为:
image.png
从输出结果看最后一次的test.MoveNext()返回了false,也就是Current指向了yield break并返回了false,上面说到,MoveNext返回false,说明迭代器终止,为了验证该结论,我们可以再调用MoveNext():

void Start()
{
    IEnumerator test = TestEnumerator();
    Debug.Log("第一个yield return执行" + test.MoveNext());
    Debug.Log("第二个yield return执行" + test.MoveNext());
    Debug.Log("第三个yield break执行即中止" + test.MoveNext());
    Debug.Log("第四个yield return无法执行" + test.MoveNext());
}

输出结果为:
image.png
也就是yield break确实让迭代器终止了。

为什么yield return能暂停代码的执行?

首先,我没有实际看过Unity的任何源码,但是综合各方面资料来看,一个IEnumerator函数的定义会在编译阶段内部自定义了一个类似xxxTestEnumeratorxxx的类或结构体,内部会把IEnumerator函数内的局部变量变为类或结构体中的属性,而yield return之间的代码会封装在该类或结构体中的xxxMoveNextxxx()函数中,每个yield return间的代码段会抽取到该MoveNext函数内。以下是实现该原理的伪代码:

using System;
using System.Collections;

public class CoroutineSimulator : IEnumerator
{
    private enum State
    {
        Start,
        WaitForOne,
        PrintSurprise,
        WaitForThree,
        End
    }

    private State currentState;

    public CoroutineSimulator()
    {
        currentState = State.Start;
    }

    public object Current => null; // 实现 IEnumerator 接口的 Current 属性

    public bool MoveNext()
    {
        switch (currentState)
        {
            case State.Start:
                Console.WriteLine("Coroutine starts.");
                currentState = State.WaitForOne;
                break;

            case State.WaitForOne:
                Console.WriteLine("Waited for 1 second.");
                currentState = State.PrintSurprise;
                break;

            case State.PrintSurprise:
                Console.WriteLine("Surprise!");
                currentState = State.WaitForThree;
                break;

            case State.WaitForThree:
                Console.WriteLine("Waited for 3 seconds.");
                currentState = State.End;
                break;

            case State.End:
                Console.WriteLine("Coroutine has ended.");
                return false; // 结束协程
        }

        return true; // 继续协程
    }

    public void Reset()
    {
        currentState = State.Start; // 重置协程状态
    }
}

class Program
{
    static void Main(string[] args)
    {
        CoroutineSimulator coroutine = new CoroutineSimulator();

        while (coroutine.MoveNext()) // 模拟协程的执行
        {
            // 在这里,每次调用 MoveNext 都会执行协程的下一个步骤
        }
    }
}

大致是这么个意思。回归到Unity内置的StartCoroutine,它内部的MoveNext当条件满足的时候会自动调用,如:yield return null,当下一帧到来的时候就会自动调用MoveNext()

在Unity中,StartCoroutineyield return的底层运作涉及到对协程(Coroutines)的管理和调度。为了回答你的问题,我们来逐步解析这些机制。

1. 协程的基本概念

协程是一种特殊的函数,它可以暂停执行并在稍后恢复。在Unity中,协程通常用于处理需要长时间运行的任务,而不需要阻塞主线程。

2. StartCoroutine的工作原理

当你调用StartCoroutine时,Unity会将协程添加到一个队列中,并开始调度它。这个过程涉及以下几个步骤:

  • 创建IEnumerator对象:当你定义一个返回IEnumerator的方法时,Unity会在内部创建一个IEnumerator对象。这个对象包含了协程的执行逻辑。

  • 注册协程StartCoroutine方法会将这个IEnumerator对象注册到Unity的协程管理器中。协程管理器负责调度协程的执行。

3. yield return的工作原理

当你在协程中使用yield return时,你实际上是在告诉协程在某个条件满足后继续执行。yield return可以返回多种类型的值,其中最常见的就是WaitForSeconds

WaitForSeconds

WaitForSeconds是一个特殊的类,它的实现如下:

public class WaitForSeconds : IEnumerator
{
    private float endTime;

    public WaitForSeconds(float seconds)
    {
        endTime = Time.realtimeSinceStartup + seconds;
    }

    public bool MoveNext()
    {
        return Time.realtimeSinceStartup < endTime;
    }

    public void Reset()
    {
        throw new NotImplementedException();
    }

    public object Current => null;
}

当你写yield return new WaitForSeconds(1);时,Unity会创建一个WaitForSeconds实例,并将其传递给协程管理器。协程管理器会定期检查这个实例的状态,并在指定的时间后恢复协程的执行。

4. 控制权的移交

当你在一个协程中写yield return new WaitForSeconds(1);时,Unity的协程管理器会暂停当前协程的执行,并在一秒后恢复。这意味着在这一秒内,控制权会被交还给Unity的主线程,从而允许其他任务(如渲染、输入处理等)继续执行。

5. Unity主线程的交互

Unity的游戏循环是基于事件驱动的,这意味着主线程会不断地处理各种事件(如输入、更新、渲染等)。当协程暂停时,控制权会被交还给主线程,这样主线程可以处理其他任务。一旦协程暂停的条件满足(例如,WaitForSeconds的时间到了),协程管理器会恢复协程的执行。

总结

  • StartCoroutine将协程注册到协程管理器中。
  • yield return用于暂停协程的执行,并在特定条件满足后恢复。
  • WaitForSeconds是一个特殊类,用于在指定时间后恢复协程的执行。
  • 控制权在协程暂停时被交还给主线程,主线程可以处理其他任务。

通过这种方式,Unity能够高效地管理协程,同时确保游戏的流畅性和响应性。