一、协程的定义
协程,即为协同程序. Unity中的协程由协程函数和协程调度器两部分构成.协程函数使用的是C#的迭代器, 协程调度器则利用了MonoBehaviour中的生命周期函数来实现. 协程函数实现了分步, 协程调度器实现了分时.
注:因为协程分时分步执行的特性,当多个协程的耗时操作挤在同一时间执行也会造成卡顿。
二、协程的用法
using System.Collection;using UnityEngine;// 定义一个协程函数,返回一个迭代器接口IEnumerator CoroutineFunc(){ Debug.Log("第一次进入"); yield return null; Debug.Log("第二次进入"); yield return null;}// 在继承自MonoBehaviour的类中调用此协程函数void Start(){ // 获取迭代器接口 IEnumerator enumerator = CoroutineFunc(); // 返回的Coroutine对象保存起来可用于停止协程 Coroutine coroutine = StartCoroutine(enumerator); // 相当于在外部 yield break; StopCoroutine(coroutine);}
三、Unity规定的协程返回值的含义
含义 | 代码 |
---|---|
下一帧再执行后续代码 | yield return null; yield retun x(x代表任意数字) |
结束该协程 | yield break; |
等待固定时间执行后续代码 | yield return new WaitForSeconds(0.3f); yield return new WaitForSecondsRealtime(0.3f); //不受timescale影响 |
函数执行完毕后执行后续代码 | yield return FunctionName(); |
异步执行完毕后执行后续代码 | yield return AsyncOperation; |
协程执行完毕后执行后续代码 | yield return Coroutine; |
帧渲染完成后执行后续代码 | yield return new WaitForEndOfFrame(); |
物理帧更新后执行后续代码 | yield return new WaitForFixedUpdate(); |
参数为true时执行后续代码 | yield return new WaitUntil(arg); |
参数为false时执行后续代码 | yield return new WaitWhile(arg); |
注: 为了优化性能,yield return 后面需要new的返回值应该预先创建, 而不是在协程函数中反复创建.
各个 yield return 在生命周期的位置
四、协程函数与普通函数的区别
操作 | 协程函数 | 普通函数 |
---|---|---|
返回值 | 可分步返回多次 | 只能返回一次 |
获取返回值的方式 | 调用后执行MoveNext(),通过Current属性获取当前返回值; | 调用函数; |
返回顺序 | 根据实际情况交错返回 | 根据调用顺序返回 |
注:执行协程函数返回的是一个迭代器接口而并非得到结果
五、协程与多线程的联系与区别
区别:
协程 | 多线程 | |
---|---|---|
切换时机 | 自定 | CPU时间片为单位的系统调度 |
CPU核心 | 与主线程在同一核心 | 根据操作系统调度不同 |
对主线程的影响 | 卡顿会影响主线程 | 卡死都不会影响主线程 |
线程同步问题 | 不存在线程同步问题 | 需要注意线程同步问题 |
线程开销 | 不存在线程开销 | 存在线程创建、销毁、切换的开销 |
书写方式 | 与普通函数一致 | 回调函数 |
联系:
协程与多线程都是异步操作,都是为了提高CPU的利用率存在的。
六、Unity协程的原理
using System;using System.Collection;using System.Collection.Gernic;using UnityEngine;// 感谢唐老师的指导public class YieldInstruction{ public IEnumerator ie; public float executeTime;}public class CoroutineMgr : MonoBehaviour{ private List<YieldInstruction> list = new List<YieldInstruction>(); public void StartCoroutine(IEnumerator ie) { ie.MoveNext(); if((ie.Current is null) || (ie.Current is int)) { list.Add(new YieldInstruction{ ie=ie,executeTime=0; }); } else if(ie.Current is WaitForSeconds) { list.Add(new YieldInstruction{ ie=ie, executeTime=Time.time+(ie.Currentas WaitForSeconds).second }); } else if (...) {...} } void Update() { // 倒序遍历方便移除 for(int i=list.Count-1; i>=0; i--) { if(list[i].executeTime<=Time.time) { if(list[i].ie.MoveNext()) { // 如果是已定义的类型 if((ie.Current is null) || (ie.Current is int)) || (ie.Current is WaitForSeconds)) { // 继续指定执行时机 } else { list.RemoveAt(i); } } else { list.RemoveAt(i); } } } }}
七、Unity协程的垃圾的来自何处
在我尚不了解协程的时候, 用协程制作了真炎幸魂的终极技能:八重火垣. 因在协程中循环使用协程, 在我顾忌协程是否有坏处的时候, 发现了这么一句话:
协程的坏处:
协程本质是迭代器,且是基于unity生命周期的,大量开启协程会引起gc
这句话对我小小的心灵造成了巨大的伤害:
"我的杰作刚出生就要宣判死刑了吗!!!!! 不要!!!!!!! 嘤嘤嘤~~~"
即使是现在的我, 依然不太能理解这句话. 一连串的问题从我脑海中冒出来了.
这GC是协程的问题还是大量的问题?
本质是迭代器是坏处? 基于unity声明周期是坏处? 大量开启是协程的问题不是使用者的问题?
同样的东西协程就比普通函数更容易GC吗?
不是只有引用类型会GC吗?
基于Unity生命周期就会GC吗?
垃圾到底在哪里?
垃圾的产生无法控制吗?
这前言不搭后语的一句话真是让人误会, 而且我还发现多处地方都引用了这句话, 却完全没人把这句话说明白.
疑惑存在, 实验开始.
1.当前版本的Unity使用协程是否会产生GC
实验代码:
public class TestMono : MonoBehaviour{ public int times = 100000; public bool useStr = true; void Start() { for (int i = 0; i < times; i++) { if (useStr) StartCoroutine("CoroutineFunc"); else StartCoroutine(CoroutineFunc()); } } IEnumerator CoroutineFunc() { yield return null; }}
实验数据取 Unity Profiler 5秒内 GC Used Memory 最高点
项目/实验次数 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
不启动协程 | 13.1M | 11.8M | 12.2M | 12.1M | 12.4M | 12.4M |
传入字符串 | 24.0M | 28.0M | 27.7M | 28.1M | 28.4M | 28.2M |
传入IEnumerator | 28.6M | 28.2M | 28.7M | 28.5M | 28.6M | 28.5M |
小结: 使用协程的确会产生垃圾, 且两种方式产生的内存不相上下.
2. 协程的垃圾产生在迭代器还是调度器?
由于调度器调度不存在的函数会报错, 所以只能从迭代器入手进行测试.
实验代码:
public class TestMono : MonoBehaviour{ public int times = 100000; public bool useStr = true; // Start is called before the first frame update void Start() { for (int i = 0; i < times; i++) { if (useStr) enumerator = "CoroutineFunc"; else CoroutineFunc(); } } IEnumerator CoroutineFunc() { yield return null; }}
项目/实验次数 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
不启动协程 | 11.9M | 12.0M | 12.0M | 12.2M | 11.7M | 12.0M |
传入字符串 | 12.7M | 11.8M | 12.2M | 12.2M | 12.2M | 12.4M |
传入IEnumerator | 12.4M | 12.5M | 12.4M | 12.6M | 12.5M | 12.6M |
小结: 通过对比一阶段的数据基本可以确定产生垃圾的主要位置在调度器. 但同时能发现迭代器对象也会造成微量的垃圾.
3. 是否只是单纯的使用调度器就会产生大量GC?
实验代码:
public class TestMono : MonoBehaviour{ public int times = 100000; private IEnumerator enumerator; // Start is called before the first frame update void Start() { enumerator = CoroutineFunc(); for (int i = 0; i < times; i++) { StartCoroutine(enumerator); } } IEnumerator CoroutineFunc() { yield return null; }}
项目/实验次数 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
不启动协程 | 17.5M | 15.9M | 16.5M | 16.1M | 16.5M | 16.6M |
传入IEnumerator | 17.4M | 17.0M | 17.5M | 16.9M | 17.4M | 17.5M |
小结: 只是单纯的调用StartCoroutine并不会产生大量垃圾, 那么第一轮的垃圾应该是一个可以遍历的迭代器和调度器共同作用产生的. 可惜IEnumerator禁止使用Reset, 不然可以测试的更全面.
结论
使用协程时会产生垃圾, 且此垃圾不可控制.
原因: 协程函数的调用会实例化一个接口对象, 而接口是引用类型.
StartCoroutine对协程的调用会不可避免的产生较多垃圾.
迭代器对象无法Reset, 想要重复执行相同逻辑只能再次创建迭代器对象.