本文主要介绍 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 ; } }
当运行上述代码时,会发现 Unity 应用在 5 秒内无任何的相应,这是因为协程是运行在 Unity 的 UI 线程上(并非工作线程)的,而 Thread.Sleep(5000)
会阻塞 UI 线程,导致应用无响应。
协程的工作原理
可以看到上述的协程函数返回了一个 IEnumerator
对象,在 Yield 是如何工作的 中我们知道 yield
会在每次调用 IEnumerator
的 MoveNext
时返回一个值或终止迭代,并且会保持当前迭代器的状态。
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
表示协程函数执行完毕。
进一步验证
如果进一步的,在 MoveNext
和 Current
中添加一些调试信息,可以看到在调用 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)
自定义协程机制
简易 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
类的 Start
和 Update
函数,且需要记录每一次循环的时间差作为每一帧的 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 ); } } }
此时运行结果为:
增加 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 ); } }
此时再拓展 MySimpleBehaviour
类,在其中调用 StartCoroutine
来启动定义的协程 Foo
,Bar
,而 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
则没有被处理。
针对这个问题,我们需要做的就是对调用 StartCoroutine
的 IEnumerator
管理其整个调用栈,而不是仅仅是根节点。修改 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 ) { m_ActiveCoroutines.RemoveAt(index); 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); } }
其中的关键是检查当前 IEnumerator
的 Current
是否为 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 ){ m_ActiveCoroutines.RemoveAt(index); index--; }
MoveNext
的执行频率是每一帧( Thread.Sleep(10)
) 控制的,因此这里的 WaitForSeconds
会有一定的误差,但是整体的执行逻辑是正确的。
Reference
How do Unity’s coroutines actually work? - Oliver Booth
Fetching Title#8pok