参考链接:Unity 协程(Coroutine)原理与用法详解_unity coroutine-CSDN博客
为啥在Unity中一般不考虑多线程
- 因为在
Unity
中,只能在主线程中获取物体的组件、方法、对象,如果脱离这些,Unity
的很多功能无法实现,那么多线程的存在与否意义就不大了
既然这样,线程与协程有什么区别呢:
- 对于协程而言,同一时间只能执行一个协程,而线程则是并发的,可以同时有多个线程在运行
- 两者在内存的使用上是相同的,共享堆,不共享栈
其实对于两者最关键,最简单的区别是微观上线程是并行(对于多核CPU)的,而协程是串行的,如果你不理解没有关系,通过下面的解释你就明白了
关于协程
1,什么是协程
协程,从字面意义上理解就是协助程序的意思,我们在主任务进行的同时,需要一些分支任务配合工作来达到最终的效果
稍微形象的解释一下,想象一下,在进行主任务的过程中我们需要一个对资源消耗极大的操作时候,如果在一帧中实现这样的操作,游戏就会变得十分卡顿,这个时候,我们就可以通过协程,在一定帧内完成该工作的处理,同时不影响主任务的进行
2,协程的原理
首先需要了解协程不是线程,协程依旧是在主线程中进行
然后要知道协程是通过迭代器来实现功能的,通过关键字IEnumerator来定义一个迭代方法,注意使用的是IEnumerator,而不是IEnumerable:
两者之间的区别:
IEnumerator
:是一个实现迭代器功能的接口IEnumerable
:是在IEnumerator
基础上的一个封装接口,有一个GetEnumerator()
方法返回IEnumerator
3、协程的使用
首先通过一个迭代器定义一个返回值为IEnumerator
的方法,然后再程序中通过StartCoroutine
来开启一个协程即可:
//通过迭代器定义一个方法
IEnumerator Demo(int i)
{
//代码块
yield return 0;
//代码块
}
//在程序种调用协程
public void Test()
{
//第一种与第二种调用方式,通过方法名与参数调用
StartCoroutine("Demo", 1);
//第三种调用方式, 通过调用方法直接调用
StartCoroutine(Demo(1));
}
在一个协程开始后,同样会对应一个结束协程的方法StopCoroutine与StopAllCoroutines两种方式,但是需要注意的是,两者的使用需要遵循一定的规则,在介绍规则之前,同样介绍一下关于StopCoroutine重载:
- StopCoroutine(string methodName):通过方法名(字符串)来进行
- StopCoroutine(IEnumerator routine):通过方法形式来调用
- StopCoroutine(Coroutine routine):通过指定的协程来关闭
刚刚我们说到他们的使用是有一定的规则的,那么规则是什么呢,答案是前两种结束协程方法的使用上,如果我们是使用StartCoroutine(string methodName)来开启一个协程的,那么结束协程就只能使用StopCoroutine(string methodName)和StopCoroutine(Coroutine routine)来结束协程,可以在文档中找到这句话:
Unity生命周期:
首先解释一下位于Update与LateUpdate之间这些yield 的含义:
- yield return null; 暂停协程等待下一帧继续执行
- yield return 0或其他数字; 暂停协程等待下一帧继续执行
- yield return new WairForSeconds(时间); 等待规定时间后继续执行
- yield return StartCoroutine("协程方法名");开启一个协程(嵌套协程)
接下来看几个特殊的yield,他们是用在一些特殊的区域,一般不会有机会去使用,但是对于某些特殊情况的应对会很方便
- yield return GameObject; 当游戏对象被获取到之后执行
- yield return new WaitForFixedUpdate():等到下一个固定帧数更新
- yield return new WaitForEndOfFrame():等到所有相机画面被渲染完毕后更新
- yield break; 跳出协程对应方法,其后面的代码不会被执行
通过上面的一些yield一些用法以及其在脚本生命周期中的位置,我们也可以看到关于协程不是线程的概念的具体的解释,所有的这些方法都是在主线程中进行的,只是有别于我们正常使用的Update与LateUpdate这些可视的方法
5、协程几个小用法
5.1、将一个复杂程序分帧执行:
如果一个复杂的函数对于一帧的性能需求很大,我们就可以通过yield return null
将步骤拆除,从而将性能压力分摊开来,最终获取一个流畅的过程,这就是一个简单的应用
5.3、异步加载等功能
只要一说到异步,就必定离不开协程,因为在异步加载过程中可能会影响到其他任务的进程,这个时候就需要通过协程将这些可能被影响的任务剥离出来
常见的异步操作有:
- AB包资源的异步加载
- Reaources资源的异步加载
- 场景的异步加载
- WWW模块的异步请求
参考链接:迭代器 - C# | Microsoft Learn
可根据需要提供尽可能多的 yield return
语句来满足方法需求:
public IEnumerable<int> GetSetsOfNumbers()
{
int index = 0;
while (index < 10)
yield return index++;
yield return 50;
index = 100;
while (index < 110)
yield return index++;
}
上述所有示例都有一个异步对应项。 在每种情况下,将 IEnumerable<T>
的返回类型替换为 IAsyncEnumerable<T>
。 例如,前面的示例将具有以下异步版本:
public async IAsyncEnumerable<int> GetSetsOfNumbersAsync()
{
int index = 0;
while (index < 10)
yield return index++;
await Task.Delay(500);
yield return 50;
await Task.Delay(500);
index = 100;
while (index < 110)
yield return index++;
}
迭代器方法有一个重要限制:在同一方法中不能同时使用 return
语句和 yield return
语句。 以下代码无法编译
有时,正确的做法是将迭代器方法拆分成 2 个不同的方法。 一个使用 return
,另一个使用 yield return
。 考虑这样一种情况:需要基于布尔参数返回一个空集合,或者返回前 5 个奇数。 可编写类似以下 2 种方法的方法:
public IEnumerable<int> GetSingleDigitOddNumbers(bool getCollection)
{
if (getCollection == false)
return new int[0];
else
return IteratorMethod();
}
private IEnumerable<int> IteratorMethod()
{
int index = 0;
while (index < 10)
{
if (index % 2 == 1)
yield return index;
index++;
}
}
看看上面的方法。 第 1 个方法使用标准 return
语句返回空集合,或返回第 2 个方法创建的迭代器。 第 2 个方法使用 yield return
语句创建请求的序列。
参考链接:Unity协程的原理与应用 - 知乎 (zhihu.com)
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](https://docs.unity3d.com/ScriptReference/WaitForSeconds.html)
简单的说,协程就是一种特殊的函数,它可以主动的请求暂停自身并提交一个唤醒条件,Unity会在唤醒条件满足的时候去重新唤醒协程。
2. 如何使用
MonoBehaviour.StartCoroutine()
方法可以开启一个协程,这个协程会挂在该MonoBehaviour
下。
在MonoBehaviour
生命周期的Update
和LateUpdate
之间,会检查这个MonoBehaviour
下挂载的所有协程,并唤醒其中满足唤醒条件的协程。
要想使用协程,只需要以IEnumerator
为返回值,并且在函数体里面用yield return
语句来暂停协程并提交一个唤醒条件。然后使用StartCoroutine
来开启协程。
思考:协程能做的Update都能做,那为什么我们需要协程呢? 答:使用协程,我们可以把一个跨越多帧的操作封装到一个方法内部,代码会更清晰。
4. 注意事项
- 协程是挂在MonoBehaviour上的,必须要通过一个MonoBehaviour才能开启协程。
- MonoBehaviour被Disable的时候协程会继续执行,只有MonoBehaviour被销毁的时候协程才会被销毁。
- 协程看起来有点像是轻量级线程,但是本质上协程还是运行在主线程上的,协程更类似于
Update()
方法,Unity会每一帧去检测协程需不需要被唤醒。一旦你在协程中执行了一个耗时操作,很可能会堵塞主线程。这里提供两个解决思路:(1) 在耗时算法的循环体中加入yield return null
来将算法分到很多帧里面执行;(2) 如果耗时操作里面没有使用Unity API,那么可以考虑在异步线程中执行耗时操作,完成后唤醒主线程中的协程。
二. Unity协程的底层原理
协程分为两部分,协程与协程调度器:协程仅仅是一个能够中间暂停返回的函数,而协程调度是在MonoBehaviour的生命周期中实现的。 准确的说,Unity只实现了协程调度部分,而协程本身其实就是用了C#原生的”迭代器方法“。
1. 协程本体:C#的迭代器函数
许多语言都有迭代器的概念,使用迭代器我们可以很轻松的遍历一个容器。 但是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
后面接的返回值,该返回值可以是任何类型的对象。
这里有两个要注意的地方:
IEnumerator
中的yield return
可以返回任意类型的对象,事实上它还有泛型版本IEnumerator<T>
,泛型类型的迭代器中只能返回T
类型的对象。Unity原生协程使用普通版本的IEnumerator
,但是有些项目(比如倩女幽魂)自己造的协程轮子可能会使用泛型版本的IEnumerator<T>
- 函数调用的本质是压栈,协程的唤醒也一样,调用
IEnumerator.MoveNext()
时会把协程方法体压入当前的函数调用栈中执行,运行到yield return
后再弹栈。这点和有些语言中的协程不大一样,有些语言的协程会维护一个自己的函数调用栈,在唤醒的时候会把整个函数调用栈给替换,这类协程被称为有栈协程,而像C#中这样直接在当前函数调用栈中压入栈帧的协程我们称之为无栈协程。关于有栈协程和无栈协程的概念我们会在后文四. 跳出Unity看协程中继续讨论
Unity中的协程是无栈协程,它不会维护整个函数调用栈,仅仅是保存一个栈帧。
3. Unity协程的架构
基类:YieldInstruction 其它所有协程相关的类都继承自这个类。Unity的协程只允许返回继承自YieldInstruction
的对象或者null
。如果返回了其他对象则会被当成null处理。
协程类:Coroutine 你可以通过yield return
一个协程来等待一个协程执行完毕,所以Coroutine
也会继承自YieldInstruction
。 Coroutine
仅仅代表一个协程实例,不含任何成员方法,你可以将Coroutine
对象传到MonoBehaviour.StopCoroutine
方法中去关闭这个协程。
遗憾的是,Unity关于协程的这套都是在C++层实现的并且几乎没有暴露出C#接口,所以扩展起来会比较麻烦。
三. 扩展Unity的协程
这部分看原文
四. 跳出Unity看协程
1. 进程,线程与协程
进程是操作系统资源分配的基本单位 线程是处理器调度与执行的基本单位
这是操作系统书上对进程与线程的抽象描述。具体一点的说,进程其实就是程序运行的实例:程序本身只是存储在外存上的冷冰冰的二进制流,计算机将这些二进制流读进内存并解析成指令和数据然后执行,程序便成为了进程。
每一个进程都独立拥有自己的指令和数据,所以称为资源分配的基本单位。其中数据又分布在内存的不同区域,我们在C语言课程中学习过内存四区的概念,一个运行中的进程所占有的内存大体可以分为四个区域:栈区、堆区、数据区、代码区。其中代码区存储指令,另外三个区存储数据。
线程是处理器调度和执行的基本单位,一个线程往往和一个函数调用栈绑定,一个进程有多个线程,每个线程拥有自己的函数调用栈,同时共用进程的堆区,数据区,代码区。操作系统会不停地在不同线程之间切换来营造出一个并行的效果,这个策略称为时间片轮转法。
那么协程在其中又处于什么地位呢? 一切用户自己实现的,类似于线程的轮子,都可以称之为是协程。
C#中的迭代器方法是协程; Unity在迭代器的基础上扩展出来的协程模块是协程; 你在操作系统实验中模仿线程自己写出来的"线程"也是协程; ........
协程有什么样的行为,完全由实现协程的程序员来决定(线程和进程都是操作系统中写死的),这就导致了不同开发框架下的协程差别很大。有的协程有自己的函数调用栈,有的协程共用线程的函数调用栈;有的协程是单线程上的,有的协程可以多线程调度;有的协程和线程是一对多的关系,有的协程和线程是多对多的关系。
操作系统可以有多个进程 一个进程对应一个或多个线程 线程和协程的对应关系,由具体的开发框架决定
2. 不同框架下协程的共同点
虽然不同开发框架下的协程各不一样,但是这些协程基本上还是有一些共性的
(1) 协程有yield和resume操作
协程可以通过yield
操作挂起,通过resume
操作恢复。yield
一般是协程主动调用,resume
一般是调度器调用。 大多数协程库都支持这两个操作,无非是可能API的名字不一样。 比如C#中,resume
操作就是MoveNext
(2) 协程调度是非抢占式的
线程调度是抢占式的:操作系统会主动中断当前执行中的线程,然后把CPU控制权交给别的线程,就好像有很多线程去争抢CPU的控制权一样。
协程调度是非抢占式的:协程需要主动调用yield
来释放CPU控制权,协程的运行中间不会被系统中断打断。
可以看看这个:(扩展)
浅谈倩女手游中的资源更新 - 知乎 (zhihu.com)
IFramework/Example-Readme/Bind.md at master · OnClick9927/IFramework (github.com)
参考文章:当我们在说协程时,我们在说些什么? - Lyon Gu - 博客园 (cnblogs.com)
协程可以将一个方法,放到多个帧内执行,在很大程度上提高了性能。但协程也是有缺陷的:
- 不支持返回值;
- 不支持异常处理;
- 不支持泛型;
- 不支持锁;
- …