本文主要介绍 Unity 中的协程(Coroutine)和 Yield 的关系,建议先阅读 Yield 了解 Yield 的基本概念。

#Unity 协程

Unity 依赖 Yield 实现了异步编程的 协程(Coroutine)

Yield 是 C# 提供的一种延迟获取一系列数据中每一个元素的方式:

yield 用来在 Iterator 中返回数据的关键字,yield return 和直接使用 return 不同的点在于它可以在返回数据的同时保持当前迭代器的状态并在下次对 Iterator 调用 MoveNext 时继续迭代器的执行,yield break 则是用来结束迭代器。
———— C# Yield 关键字

可以认为 yield 实现了 迭代器模式 ,它可以用来延迟数据的获取(例如针对一个集合,不需要一次性获得所有数据,而是在每一个数据真正使用时才获取)。
———— C# Yield 关键字

一个协程的示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections;
using System.Threading;
using UnityEngine;

public class Coroutines : MonoBehaviour
{
private void Start()
{
StartCoroutine(MySimpleCoroutine());
}

private IEnumerator MySimpleCoroutine()
{
Debug.Log("Hello from the coroutine!");
Thread.Sleep(5000);

yield break; // necessary for IEnumerator
}
}

当运行上述代码时,会发现 Unity 应用在 5 秒内无任何的相应,这是因为协程是运行在 Unity 的 UI 线程上(并非工作线程)的,而 Thread.Sleep(5000) 会阻塞 UI 线程,导致应用无响应。

#协程的工作原理

可以看到上述的协程函数返回了一个 IEnumerator 对象,在 Yield 是如何工作的 中我们知道 yield 会在每次调用 IEnumeratorMoveNext 时返回一个值或终止迭代,并且会保持当前迭代器的状态。

Unity 中协程的实现原理即是在每一次调用 StartCoroutine 时,会将协程函数的 IEnumerator 对象加入到一个队列中,然后在每一帧调用 MoveNext 来执行协程函数,直到协程函数执行完毕,或调用 StopCoroutine 后将其从队列中移除。

#自定义协程函数

可以通过在 Unity 中自定义一个 yield return 返回的数据类型来实现自定义的协程,以便验证上述协程的工作原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class WaitForTime : IEnumerator
{
private readonly DateTime m_EndTime;
public WaitForTime(TimeSpan time) { m_EndTime = DateTime.Now + time; }

public bool MoveNext() => DateTime.Now < m_EndTime;
public void Reset() { }
public object Current => null;
}

public class CoroutineSample : MonoBehaviour
{
private void Start() { StartCoroutine(WaitForTimeSample()); }

private IEnumerator WaitForTimeSample()
{
Debug.Log("Enter WaitForTimeSample");
yield return new WaitForTime(TimeSpan.FromSeconds(5));
Debug.Log("Exit WaitForTimeSample");
}
}

运行后,可以看到在打印了 Enter WaitForTimeSample 后,等待 5 秒后才打印了 Exit WaitForTimeSample,且在此期间 Unity 仍然可以响应其他的操作。

运行结果

之所以如此,是因为 WaitForTime 类实现了 IEnumerator 接口,且在 MoveNext 中判断了是否到达了指定的时间,每一帧 Unity 都会调用 MoveNext 来判断是否继续执行协程函数,在时间不到目标时间时会返回 true 表示 MoveNext 仍然可以继续执行,而在时间到达目标时间后会返回 false 表示协程函数执行完毕。

#进一步验证

如果进一步的,在 MoveNextCurrent 中添加一些调试信息,可以看到在调用 StartCoroutine 时会触发一次 MoveNext,后续 Unity 每帧都会通过 SetupCoroutine 函数再次触发 MoveNext,以 MoveNext 为例子,调用堆栈为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 开启协程时调用 MoveNext

Called MoveNext
UnityEngine.Debug:Log (object)
WaitForTime:MoveNext () (at Assets/Scripts/WaitForTime.cs:12)
UnityEngine.MonoBehaviour:StartCoroutine (System.Collections.IEnumerator)
CoroutineSample:Start () (at Assets/Scripts/CoroutineSample.cs:8)

# 后续每帧都会d调用 MoveNext
Called MoveNext
UnityEngine.Debug:Log (object)
WaitForTime:MoveNext () (at Assets/Scripts/WaitForTime.cs:12)
UnityEngine.SetupCoroutine:InvokeMoveNext (System.Collections.IEnumerator,intptr)

更详细的 Debug 信息

#自定义协程机制

#简易 Behaviour 实现

本节的展示内容因为涉及到要修改 Terminal 的标题,所以需要 .Net Tool,关于创建 .Net Tool 可以参考 创建 .Net Tools

本节会尝试编写一个纯粹的 Console 应用,实现类似 Unity 的协程机制,即通过 yield return 来实现异步编程。首先我们通过 C# 实现极简版的 MonoBehaviour,及将 Console 标题修改的派生类 MySimpleBehaviour

1
2
3
4
5
6
7
8
9
10
public abstract class Behaviour
{
public virtual void Start() { }
public virtual void Update() { }
}

public class MySimpleBehaviour : Behaviour
{
public override void Update() { Console.Title = $"Running at {1 / Time.deltaTime:F0} FPS"; }
}

其中 Time 是自定义的表示时间的类,其实现如下:

1
2
3
4
public static class Time
{
public static float deltaTime { get; internal set; }
}

主入口 Program 类需要触发所有的 MonoBehaviour 类的 StartUpdate 函数,且需要记录每一次循环的时间差作为每一帧的 deltaTime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
internal static class Program
{
private static readonly List<Behaviour> s_Behaviours = [];
private static long s_LastFrameTime = DateTime.Now.Ticks;

private static void Main()
{
s_Behaviours.Add(new MySimpleBehaviour());

foreach (Behaviour behaviour in s_Behaviours)
{
behaviour.Start();
}

while (true)
{
long frameTime = DateTime.Now.Ticks;
long deltaTime = frameTime - s_LastFrameTime;
s_LastFrameTime = frameTime;
Time.deltaTime = (float) TimeSpan.FromTicks(deltaTime).TotalSeconds;

foreach (Behaviour behaviour in s_Behaviours)
{
behaviour.Update();
}

Thread.Sleep(10); // Make fps not too high
}
}
}

此时运行结果为:
简易 Behavior 运行结果

#增加 Coroutine 机制

Behavior 的基类中提供函数 StartCoroutine,其实现逻辑为将 IEnumerator 对象加入到一个队列中,然后在每一帧调用 MoveNext 来执行协程函数,直到协程函数执行完毕:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class Behaviour
{
private readonly List<IEnumerator> m_ActiveCoroutines = new();
public virtual void Start() { }
public virtual void Update() { }

protected void StartCoroutine(IEnumerator coroutine) { m_ActiveCoroutines.Add(coroutine); }

internal void UpdateCoroutines()
{
for (int i = m_ActiveCoroutines.Count - 1; i >= 0; i--)
{
IEnumerator coroutine = m_ActiveCoroutines[i];
if (!coroutine.MoveNext())
{
m_ActiveCoroutines.RemoveAt(i);
}
}
}
}

Program 中,在每一帧再额外调用 UpdateCoroutines

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
internal static class Program
{
// ....
while (true)
{
// ....
foreach (Behaviour behaviour in s_Behaviours)
{
behaviour.Update();
behaviour.UpdateCoroutines();
}

Thread.Sleep(10); // Make fps not too high
}
// ....
}

此时再拓展 MySimpleBehaviour 类,在其中调用 StartCoroutine 来启动定义的协程 FooBar,而 Foo 会再嵌入Foo2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class MySimpleBehaviour : Behaviour
{
public override void Start()
{
base.Start();

StartCoroutine(Foo());
StartCoroutine(Bar());
}

public override void Update() { Console.Title = $"Running at {1 / Time.deltaTime:F0} FPS"; }

private IEnumerator Foo()
{
yield return Foo2();
Console.WriteLine("Foo Finished!");
}

private IEnumerator Foo2()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine(i);
yield return new WaitForSeconds(1);
}

Console.WriteLine("Foo2 Finished!");
}

private IEnumerator Bar()
{
yield return new WaitForSeconds(2);
Console.WriteLine("Bar Finished!");
}
}

此时运行程序会发现,立即就打印出了 Foo Finished!,和 Bar Finished!,并没有预期的等待时间,且没有预期的打印出 Foo2 Finished!。这是因为我们在 StartCoroutine 时只是将最根结点的 IEnumerator 添加进了队列,在 UpdateCoroutine 中也仅仅对这些根节点的 IEnumerator 调用了 MoveNext, 被 yield return new 所构建的 IEnumerator 则没有被处理。

针对这个问题,我们需要做的就是对调用 StartCoroutineIEnumerator 管理其整个调用栈,而不是仅仅是根节点。修改 Behavior 函数如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public abstract class Behaviour
{
private readonly List<Stack<IEnumerator>> m_ActiveCoroutines = new();
public virtual void Start() { }
public virtual void Update() { }

protected void StartCoroutine(IEnumerator coroutine)
{
m_ActiveCoroutines.Add(new Stack<IEnumerator>(new[] {coroutine}));
}

internal void UpdateCoroutines()
{
for (int index = 0; index != m_ActiveCoroutines.Count; ++index)
{
Stack<IEnumerator> instructions = m_ActiveCoroutines[index];
if (instructions.Count == 0)
{
// Remove this coroutine
m_ActiveCoroutines.RemoveAt(index);
// To avoid skipping the next coroutine
index--;
}
else
{
IEnumerator instruction = instructions.Peek();
if (instruction.MoveNext())
{
if (instruction.Current is IEnumerator nestedInstruction)
{
instructions.Push(nestedInstruction);
}
}
else
{
instructions.Pop();
}
}
}
}
}

其中将对 IEnumerator 的管理变为了 Stack<IEnumerator>,在调用 StartCoroutine 时,将根节点的 IEnumerator 加入到 Stack 中作为第一个元素。

UpdateCoroutines 的函数改变较大,其核心语句为:

1
2
3
4
5
6
7
if (instruction.MoveNext())
{
if (instruction.Current is IEnumerator nestedInstruction)
{
instructions.Push(nestedInstruction);
}
}

其中的关键是检查当前 IEnumeratorCurrent 是否为 IEnumerator,如果是则说明存在嵌套的 IEnumerator(由 yield return new 产生的),则将其加入到 Stack 中,以便在下一次调用 MoveNext 时处理。

如果 instruction.MoveNext() 返回 false,则说明当前 IEnumerator 执行完毕,通过 instructions.Pop() 将其从 Stack 中移除。

1
2
3
4
5
6
7
8
if (instruction.MoveNext())
{
// ...
}
else
{
instructions.Pop();
}

当一个 Stack 中的所有 IEnumerator 执行完毕后,将其从 List 中移除,其中 index-- 是为了避免在循环中移除元素导致遗漏下一个元素:

1
2
3
4
5
6
7
if (instructions.Count == 0)
{
// Remove this coroutine
m_ActiveCoroutines.RemoveAt(index);
// To avoid skipping the next coroutine
index--;
}

自定义 Coroutine 运行

MoveNext 的执行频率是每一帧( Thread.Sleep(10)) 控制的,因此这里的 WaitForSeconds 会有一定的误差,但是整体的执行逻辑是正确的。

示例代码可见:Custom Coroutine

#Reference

How do Unity’s coroutines actually work? - Oliver Booth

Fetching Title#8pok