本篇文章可以回答以下问题:
MonoBehaviour.StartCoroutine
接收的参数为什么是IEnumerator
,IEnumerator
和协程有什么关系?- 既然协程函数返回值声明是
IEnumerator
,为什么函数内yield return
的又是不同类型的返回值? yield
是什么,常见的yield return
,yield break
是什么意思,又有什么区别?- 为什么使用了
yield return
就可以使代码“停”在那里,达到某种条件后又可以从“停住”的地方继续执行? - 具体的,
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会在唤醒条件满足的时候去重新唤醒协程。
MonoBehaviour.StartCoroutine()
方法可以开启一个协程,这个协程会挂在该MonoBehaviour
下。
在MonoBehaviour
生命周期的Update
和LateUpdate
之间,会检查这个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运行结束");
}
注意:
- 协程是挂在
MonoBehaviour
上的,必须要通过一个MonoBehaviour
才能开启协程。 MonoBehaviour
被Disable
的时候协程会继续执行,只有MonoBehaviour
被销毁的时候协程才会被销毁。- 协程看起来有点像是轻量级线程,但是本质上协程还是运行在主线程上的,协程更类似于
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
- 调用
IEnumerator
的MoveNext()
成员,会执行协程直到遇到第一个yield return
或者执行完毕。 - 调用
IEnumerator
的Current
成员,可以获得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
只是为了不发生奇奇怪怪的事,并非它是特定的延迟一帧。
-
IEnumerator
中的yield return
可以返回任意类型的对象,事实上它还有泛型版本IEnumerator<T>
,泛型类型的迭代器中只能返回T
类型的对象。Unity原生协程使用普通版本的IEnumerator
。 -
函数调用的本质是压栈,协程的唤醒也一样,调用
IEnumerator.MoveNext()
时会把协程方法体压入当前的函数调用栈中执行,运行到yield return
后再弹栈。
协程调度:MonoBehaviour
生命周期中实现
- C#层调用
StartCoroutine
方法,将IEnumerator
对象(或者是用于创建IEnumerator
对象的方法名字符串)传入C++层。 - 通过mono的反射功能,找到
IEnumerator
上的moveNext
、current
两个方法,然后创建出一个对应的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
方法,回到之前的执行流程中。
可以模仿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之外的协程
进程,线程与协程
一个进程可以有多个线程,一个线程可以有多个协程
进程其实就是程序运行的实例:程序本身只是存储在外存上的冷冰冰的二进制流,计算机将这些二进制流读进内存并解析成指令和数据然后执行,程序便成为了进程。
每一个进程都独立拥有自己的指令和数据,所以称为资源分配的基本单位。其中数据又分布在内存的不同区域,一个运行中的进程所占有的内存大体可以分为四个区域:栈区、堆区、数据区、代码区。其中代码区存储指令,另外三个区存储数据。
线程是处理器调度和执行的基本单位,一个线程往往和一个函数调用栈绑定,一个进程有多个线程,每个线程拥有自己的函数调用栈,同时共用进程的堆区,数据区,代码区。操作系统会不停地在不同线程之间切换来营造出一个并行的效果,这个策略称为时间片轮转法。线程是并行的,多个线程可以同时运行,而多线程共享资源,所以访问全局变量的时候要加锁。
一切用户自己实现的,类似于线程的轮子,都可以称之为是协程。
协程有什么样的行为,完全由实现协程的程序员来决定。
参考与引用: