赞
踩
线程和协程都是进程的子集,一个进程可以有多个协程,一线程也可以有多个协程,进程基于程序主体。
IO密集型一般使用多线程或多进程。CPU密集型一般使用多进程。强调非阻塞异步并发的一般都用协程。
进程是系统分配资源和调度资源的一个独立单位,每个进程都有自己的独立内存空间,不同进程间可以进行进程间通信。进程重量级比较大,占据独立内存,上下文进程间的切换开销(栈寄存器、虚拟内存、文件句柄)比较大,但相对稳定安全。进程的上级为操作系统,有自己固定的堆栈。
进程间通信通常有以下几种方式:
对于游戏开发者来说,最为常用的无疑是Socket,这是长连接网络游戏的核心。
线程也被称为轻量级进程,是操作系统调度(CPU调度)执行的最小单位,是进程的子集。
线程本身基本不拥有资源,而是访问隶属于进程的资源,一个进程拥有至少一个或多个线程,线程间共享进程的地址空间。
由于线程是阻塞式的,如果想要同步执行IO,每个IO都必须开启一个新线程,多线程开销较大,适合多任务处理,进程崩溃不影响其他进程,而线程只是一个进程的不同执行路线。
线程有自己的堆栈,却没有单独的地址空间,进程死就等于所有线程死,所以多进程要比多线程健壮。但在进程切换时,消耗资源较大,效率较差。
线程是并发的,且是阻塞式同步的,一旦资源死锁,线程将陷入混乱。在同步线程的执行过程中,线程的执行切换是由CPU轮转时间片的分配来决定的。
线程开销包括以下几个方面:
上下文切换过程如下:
协程是比线程更轻量级的存在,协程不由操作系统内核所管理,而是完全由程序所控制(也就是在用户态执行)。
协程的好处是性能大幅提升,不会像线程切换那样消耗资源。同一时间只能执行某个协程,开辟多个协程开销不大。适合对任务进行分时处理。
协程有自己的寄存器和上下文栈。协程调度切换时,将寄存器和上下文栈保存到其他地方,并在协程切换回来时恢复之前保存的寄存器和上下文栈。由于直接对栈进行操作,基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文切换非常快。
一个线程可以有多个协程,一个进程也可以单独拥有多个协程。线程和进程都是同步机制,而协程是异步机制,无需阻塞。协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用时的状态。多协程间对CPU的使用是依次进行的,每次只有一个协程工作,而其他协程处于休眠状态。
实际上多个协程是在一个线程中的,只不过每个协程对CPU进行分时。协程可以访问和使用Unity的所有方法和Component。函数(子程序)的调用是通过栈实现的,一个线程就是执行一个函数,函数调用总是一个入口,一个返回,调用顺序是明确的,而协程在函数内部是可以中断的,然后转而执行其他函数,在适当的时候再返回来继续执行。函数(子程序)的切换不是由线程切换,而是程序自身控制,因此没有线程切换开销。和多线程相比,线程越多,协程的性能优势就越明显,切协程因为依次执行,不存在线程安全问题,变量访问不会冲突,共享资源也无需加锁,只需要判断状态即可,所以执行效率比线程高很多。
yield:暂停,通常用 yield return null 来暂停协程。
StartCoroutine(方法名()):恢复执行。
WaitForSeconds:引入时间延迟,默认情况下,协程将在 yield 后的帧上恢复。使用 yield return new WaitForSecond(.1f) 后,将延迟0.1秒后执行协程。
关于协程的使用,其实有点像 ES7 的 Async / await 以用来改变执行顺序,由于不需要切换上下文,所以执行效率相对较高。比如,执行一个网络请求,用 yield return WWW; 就能实现异步回调,而U3D的生命周期本身就提供了很多可以使用的 yield节点,这样就能方便的使用异步了。
例如:
IEnumerator Start()
{
string url = "https://xxxx.xxxx.xxxx/xxxx.jpg";
WWW www = new WWW(url);
yield return WWW;
renderer.material.mainTexture = www.texture;
}
当程序执行到 yield return WWW; 时就不会直接往下执行了,而是等到网络请求结束后的第一帧的WWW协程节点触发时才继续执行,也就是说,当网络请求结束后,纹理才会被替换。
从程序的角度讲,协程的核心就是迭代器。想要定义一个协程方法有两个因素,第一:方法的返回值为 IEnumerator 。第二,方法中有 yield关键字。当代码满足以上两个条件时,此方法的执行就具有了迭代器的特质,其核心就是 MoveNext方法。方法内的内容将会被分成两部分:yield 之前的代码和 yield 之后的代码。yield之前的代码会在第一次执行MoveNext时执行, yield之后的代码会在第二次执行MoveNext方法时执行。而在Unity中,MoveNext的执行时机是以帧为单位的,无论你是设置了延迟时间,还是通过按钮调用MoveNext,亦或是根本没有设置执行条件,Unity都会在每一帧的生命周期中判断当前帧是否满足当前协程所定义的条件,一旦满足,当前帧就会抽出CPU时间执行你所定义的协程迭代器的MoveNext。注意,只要方法中有yield语句,那么方法的返回值就必须是 IEnumerator ,不然无法通过编译。
前面的讲解可能会比较晦涩,我们可以结合代码来看:
private IEnumerator Fun1()
{
for (int i = 0; i < 5; i++)
{
print(i + "---" + Time.frameCount);
yield return null;
}
}
当我们定义一个这样的方法,我们的协程就诞生了。那么如何访问这个方法呢?你可能会说,xxx.Fun1(); 不就行了吗?实际上,如果这样调用,方法将不会被执行。那到底怎么调用呢?请看下面的代码:
private IEnumerator iterator; private void OnGUI() { if (GUILayout.Button("启动")) { // Fun1(); 直接这样调用是没有用的,需要先获取迭代器 // 获取迭代器,此时for循环仍然不会执行,必须要调用了MoveNext方法后才会执行。 iterator = Fun1(); } // 每当我们点击一次这个按钮,for循环就会执行一次,实际上是只执行到yield之前就停下了。 if (GUILayout.Button("调用MoveNext")) { // 每调用一次MoveNext,Fun1中的代码就会开始执行,直到执行到看到yield为止。 iterator.MoveNext(); } }
此时程序的运行结果是,每当我们点击一次 “调用MoveNext” 按钮,print就会执行一次,直到for循环执行完毕为止。
从上面的代码我们看到,调用协程方法需要先获取迭代器,然后执行MoveNext方法,此时需要注意的是,每次点击按钮后代码执行的分水岭不再是方法中发for循环,而是yield。代码的执行顺序是从Fun1方法的第一行开始执行,直到第一次执行到yield停止。再次调用MoveNext方法后,程序会描你前面所执行的yield指定的条件是否已经满足,假如满足了条件,就会从上一次yield的下一行开始执行,直到再次遇到yield后再停止。也就是说,第二次的执行从yield的后半段开始,再到下一次yield的前半段结束。直到方法执行结束也没有遇到下一个yield,这个协程才真的算是结束了。下面总结几点:
了解了MoveNext的执行时机后,我们是不是每次都要手动的去调用MoveNext呢?其实大部分情况下我们都不需要手动调用MoveNext,而是使用StartCoroutine() 方法,具体使用方法如下:
private IEnumerator iterator; private void OnGUI() { if (GUILayout.Button("启动")) { // 获取迭代器,此时for循环仍然不会执行,必须要调用了MoveNext方法后才会执行。 iterator = Fun1(); } if (GUILayout.Button("开启协程")) { // 当我们调用了这个StartCoroutine方法,就等于把这个迭代器iterator的执行交给了程序端。 // 程序会逐帧扫描上次yield指定的条件是否已经满足,如果满足,就继续执行下一次。 StartCoroutine(iterator); } } private IEnumerator Fun1() { for (int i = 0; i < 5; i++) { print(i + "---" + Time.frameCount); yield return null; } }
此时程序运行的结果是连续五帧,每一帧执行一次print。
这次我们不再手动调用MoveNext方法,而是使用StartCoroutine方法。当我们调用了这个StartCoroutine方法,就等于把这个迭代器iterator的MoveNext执行交给了程序端。程序会逐帧扫描上次yield指定的条件是否已经满足,如果满足,就继续执行下一次。yield暂停后下一次执行的时机并不是准确的时间节点执行,而是在某一帧的生命周期中判断当前协程是否到达了执行时机(即满足yield执行条件),如果不满足就继续进行下一帧,如果满足就继续执行yield后面的内容。
这样每一帧执行一次好像用处不大啊,怎么办?这时候就用到了yield的条件,通过这个条件就可以控制执行时机,并且我们还可以更简单的使用StartCoroutine方法,代码如下:
private IEnumerator iterator; private void OnGUI() { if (GUILayout.Button("一键开启协程")) { // 不用在单独获取迭代器对象,直接将协程方法的执行结果返回给StartCoroutine方法,此时协程就开始执行了。 StartCoroutine(Fun1()); } } private IEnumerator Fun1() { for (int i = 0; i < 5; i++) { print(i + "---" + Time.frameCount); yield return new WaitForSeconds(1);// 一秒钟执行一次yield。 } }
此时程序的运行结果是每秒(大概为一秒,实际上并不是准确的一秒)执行一次print。
我们注意到代码中有两处改动,第一处是调用StartCoroutine时,不再需要单独将迭代器对象拿到再传参,而是直接将Fun1方法的执行结果作为参数传入StartCoroutine方法中,这是StartCoroutine方法的常用调用方式。关键的改动在于第二处,yield语句后面加入了执行条件(等待一秒的条件)。在每一帧的生命周期中,有很多个针对不同协程条件的判断节点,而WaitForSeconds的判断节点是在Update之后,LateUpdate之前。也就是说,如果代码中指定的条件是WaitForSeconds,当Update执行结束,就会判断yield指定的条件是否已经满足了,如果满足,就会在此时完成这个协程的下一步操作(也就是执行到下一次yield为止)。具体Unity生命周期的细节,可以参看另一篇文章:【Unity】Unity生命周期
yield WaitForSeconds所在的生命周期节点如图:
StartCoroutine是否只做了MoveNext的事呢?实际上StartCoroutine并不只是用于将迭代器的指针移到了下一步,StartCoroutine方法的作用是管理了一次协程执行的全过程,它包含了开启协程、移动指针、自然结束协程、初始化协程等一系列操作。也就是说,每当我们调用一次StartCoroutine方法,一个协程就被开启了一次。
与之对应的,有Start就会有Stop,Unity在MonoBehaviour中为我们提供了多个协程方法,以便我们调用,例如StopCoroutine、StopAllCoroutines等。
Unity的协程是作用在游戏对象上的,协程开启后,简单的禁用脚本组件是不会停止协程的,只有当前物体本身被禁用才会终止协程。
另外,由于协程的启动(StartCoroutine)会有一定的内存消耗,而yield不会有后续消耗,所以尽量不要频繁的调用StartCoroutine方法来开启协程。当然,在与进程、线程比较时,协程的消耗无疑是最小的。所以如果需要用到异步操作,请尽情的使用协程吧。
可以被yield return的对象有:
public class CoroutineTest : MonoBehaviour { private Coroutine coroutine; private void Start() { print("a : " + Time.frameCount); coroutine = StartCoroutine(Fun1()); print("d : " + Time.frameCount); StartCoroutine(Fun2()); print("f : " + Time.frameCount); } private IEnumerator Fun1() { for (int i = 0; i < 2; i++) { print(i + " : b : " + Time.frameCount); yield return new WaitForSeconds(1); print(i + " : c : " + Time.frameCount); } } private IEnumerator Fun2() { for (int i = 0; i < 2; i++) { print(i + " : e : " + Time.frameCount); yield return coroutine; print(i + " : g : " + Time.frameCount); } } }
执行结果如下:
由上图可以看出,a、0-b、d、0-e、f在第一帧就被执行了,而0-c、1-b则在一秒后的第90帧执行了,最后1-c、0-g、1-e、1-g在又一秒后的第292帧执行了(我本地环境并未锁帧,所以一秒钟不是稳定的60帧,实际执行间隔就是一秒,请勿在此处生疑)。由此我们可以总结几点:
所以,yield return Coroutine的机制是等待指定的协程完全结束后才继续执行的,而不是与指定协程进行穿插执行。这一点一定要明确。
协程通常有两个作用:1、延时调用;2、分解操作。
案例1
当玩家按下某个按键后触发一个渐变功能,此功能并不需要每一帧都渐变,这时就可以使用协程,按照一定的时间间隔调用。大致代码如下:
IEnumerator FadeOut() { Color c = renderer.material.color; do { c.a -= 0.02f; // 改变颜色 yield return new WaitForSeconds(0.2f); // 延迟0.2秒执行 } while (c.a > 0); if (c.a < 0) { c.a = 0; } } void Update() { if (Input.GetKeyDown("f")) { StartCoroutine(FadeOut()); } }
案例2
用协程嵌套实现寻路功能,这样做的好处有:1、让Update不再臃肿;2、让逐帧操作变成了单次调用,增加代码可读性。
将类似的需要逐帧或跳帧操作的功能都用协程封装成工具类,代码的可读性就会大大增强,且运行效率也有所提升。
/// <summary> /// 嵌套协程实现寻路 /// </summary> public class PathFinding : MonoBehaviour { public Transform[] wayPoints; public float moveSpeed; public IEnumerator FindPath(Transform[] wayPoints) { for (int i = 0; i < wayPoints.Length; i++) { yield return StartCoroutine(MoveToTarget(wayPoints[i].position)); } } private IEnumerator MoveToTarget(Vector3 position) { transform.LookAt(position); while (Vector3.Distance(transform.position, position) > 0.1f) { transform.position = Vector3.MoveTowards(transform.position, position, moveSpeed); yield return new WaitForFixedUpdate(); } } private void OnGUI() { if (GUILayout.Button("走你")) { StartCoroutine(FindPath(wayPoints)); } } }
案例3
给每个敌人加一个警报检测,这种功能可以放在Update中执行,但每一帧执行没有必要,这时就可以使用协程,将此功能从Update中剥离出来。
案例4
当程序需要异步加载资源或者获取网络资源时,可以使用WWW协程。
案例5
创建补间动画。
案例6
打字机效果。
案例7
定时器操作。
在Unity的生命周期中,有很多步骤都涉及到协程,我们可以通过协程来实现在生命周期的不同步骤下执行任务,协程是依赖于迭代器原理执行的,其本身并不能够加快程序运行速度,但其功能却能实现前后台的异步、定时操作任务等,且其代码规格使其从Update中剥离出来,既简化了Update,又增加了功能代码的可读性。大型游戏甚至可以做一套协程管理器来实现功能的管理,以及代码的审核,让代码更具可维护性。
注意:启动一个协程会消耗少量的内存,在方法调用时却不会有后续消耗。如果内存消耗和垃圾回收是严重的问题,应该尝试避免产生太多短时间的协程,并避免在运行时调用太多 StartCoroutine() 。
更多内容请查看总目录【Unity】Unity学习笔记目录整理
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。