当前位置:   article > 正文

【Unity】Unity 进程、线程、协程_unity进程线程和协程

unity进程线程和协程


进程、线程、协程的关系

线程和协程都是进程的子集,一个进程可以有多个协程,一线程也可以有多个协程,进程基于程序主体。

IO密集型一般使用多线程或多进程CPU密集型一般使用多进程强调非阻塞异步并发的一般都用协程

进程、线程、协程关系图

在这里插入图片描述

进程

进程是系统分配资源和调度资源的一个独立单位,每个进程都有自己的独立内存空间不同进程间可以进行进程间通信。进程重量级比较大,占据独立内存,上下文进程间的切换开销(栈寄存器、虚拟内存、文件句柄)比较大,但相对稳定安全。进程的上级为操作系统,有自己固定的堆栈。

进程间通信(IPC)

进程间通信通常有以下几种方式:

  • 管道(Pipe):管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。其本质是内核中固定大小的缓冲区。
  • 命名管道(Named Pipes):“命名管道”又名“命名管线”(Named Pipes),命名管道支持可靠的、单向或双向的数据通信。不同于匿名管道的是:命名管道可以在不相关的进程之间和不同计算机之间使用,服务器建立命名管道时给它指定一个名字,任何进程都可以通过该名字打开管道的另一端,根据给定的权限和服务器进程通信。
  • 消息队列(MQ,Message Quene):消息队列用于在进程间通信的过程中将消息按照队列存储起来,常见的MQ有ActiveMQ、RocketMQ、RabbitMQ、Kafka等。
  • 信号量(Semaphore):有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量。
  • 共享内存(Share Memory):共享内存是三个IPC机制中的一个。它允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在进行的进程之间传递数据的一种非常有效的方式。
  • 套接字(Socket):就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。

对于游戏开发者来说,最为常用的无疑是Socket,这是长连接网络游戏的核心。

进程状态图

在这里插入图片描述

线程

线程也被称为轻量级进程,是操作系统调度(CPU调度)执行的最小单位,是进程的子集。

线程本身基本不拥有资源,而是访问隶属于进程的资源,一个进程拥有至少一个或多个线程,线程间共享进程的地址空间

由于线程是阻塞式的,如果想要同步执行IO,每个IO都必须开启一个新线程,多线程开销较大,适合多任务处理,进程崩溃不影响其他进程,而线程只是一个进程的不同执行路线。

线程有自己的堆栈,却没有单独的地址空间进程死就等于所有线程死,所以多进程要比多线程健壮。但在进程切换时,消耗资源较大,效率较差。

线程是并发的,且是阻塞式同步的,一旦资源死锁,线程将陷入混乱。在同步线程的执行过程中,线程的执行切换是由CPU轮转时间片的分配来决定的

线程状态图

在这里插入图片描述

线程开销

线程开销包括以下几个方面:

  • 线程内核对象(Thread kernel object):包含一组对线程进行描述的属性。该数据结构中还包括线程上下文(Thread context)。上下文是一个内存块,其中包含了CPU的寄存器集合
  • 线程环境块(Thread environment block)用户模式中分配和初始化的一个内存块应用程序代码能够快速访问的地址空间
  • 用户模式栈用户存储传给方法的局部变量和实参,此外还包含一个地址,指出当前方法返回时线程接着应该从什么地方开始执行。默认情况下,windows为每个线程的用户模式栈分配1MB的内存。(对于本地程序而言,这只是一个虚拟地址,并没有对应真正的物理内存空间。但是对于CLR线程,CLR强制分配1MB的屋里内存空间给线程)。
  • 内核模式栈:用于存储内核模式的方法的局部变量和实参,同时也包含方法的返回地址。
  • DLL线程加载和线程分离通知:任何时候在进程中创建一个线程,都会调用该进程中加载的所有DLL的DllMain方法,并向该方法传递一个DLL_THREAD_ATTACH标志。类似的,任何时候一个线程终止,都会调用该进程中所有的DLL的DllMain方法,并向该方法传递一个DLL_THREAD_DETACH标志。(托管的DLL不会接收到这两个通知)。

上下文切换

上下文切换过程如下:

  1. CPU寄存器中的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中
  2. 从现有线程集合中选出一个线程供调度(这个线程要切换到的线程)。如果该线程由另一个进程拥有,Windows在开始执行代码或者接触任何数据之前,还必须切换CPU“看见”的虚拟地址空间。
  3. 将所选上下文结构中的值加载到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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

当程序执行到 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;
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

当我们定义一个这样的方法,我们的协程就诞生了。那么如何访问这个方法呢?你可能会说,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();
	}
	
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

此时程序的运行结果是,每当我们点击一次 “调用MoveNext” 按钮,print就会执行一次,直到for循环执行完毕为止。

从上面的代码我们看到,调用协程方法需要先获取迭代器,然后执行MoveNext方法,此时需要注意的是,每次点击按钮后代码执行的分水岭不再是方法中发for循环,而是yield。代码的执行顺序是从Fun1方法的第一行开始执行直到第一次执行到yield停止。再次调用MoveNext方法后,程序会描你前面所执行的yield指定的条件是否已经满足,假如满足了条件,就会从上一次yield的下一行开始执行,直到再次遇到yield后再停止。也就是说,第二次的执行从yield的后半段开始,再到下一次yield的前半段结束。直到方法执行结束也没有遇到下一个yield,这个协程才真的算是结束了。下面总结几点:

  1. 协程方法的调用看的是迭代器,必须要获取到这个迭代器,然后将索引移动到下一步(也就是执行MoveNext方法)才会真的开始执行方法内容。
  2. 代码的停止是以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;
	}
}
  • 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

此时程序运行的结果是连续五帧,每一帧执行一次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。
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

此时程序的运行结果是每秒(大概为一秒,实际上并不是准确的一秒)执行一次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对象

可以被yield return的对象有:

  1. null或数字:在Update后执行,适合分解耗时的逻辑处理
  2. WaitForFixedUpdate:在FixedUpdate后执行,适合分解物理操作。
  3. WaitForSeconds:在指定时间后执行,适合延迟调用
  4. WaitForSecondsRealtime:在指定时间后执行,适合延迟调用。不受时间缩放影响
  5. WaitForEndOfFrame:在每帧结束后执行,适合相机跟随操作。
  6. Coroutine:在另一个协程执行完毕后再执行
  7. WaitUntil:在委托返回true时执行,适合等待某一操作
  8. WaitWhile:在委托返回false时执行,适合等待某一操作
  9. WWW:在请求结束后执行,适合加载数据,如文件、贴图、材质等。

yield return coroutine执行顺序

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);
        }
    }
}
  • 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

执行结果如下:
在这里插入图片描述

由上图可以看出,a、0-b、d、0-e、f在第一帧就被执行了,而0-c、1-b则在一秒后的第90帧执行了,最后1-c、0-g、1-e、1-g在又一秒后的第292帧执行了(我本地环境并未锁帧,所以一秒钟不是稳定的60帧,实际执行间隔就是一秒,请勿在此处生疑)。由此我们可以总结几点:

  1. yield return Coroutine前面的代码的第一次执行是StartCoroutine后立即执行的。
  2. yield return Coroutine前面的代码的第二次执行是等待另一个协程完全执行过后才执行的。
  3. yield return Coroutine后面的代码是等待前面的协程全部执行完成后才执行的。

所以,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());
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

案例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));
        }
    }
}
  • 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

案例3
给每个敌人加一个警报检测,这种功能可以放在Update中执行,但每一帧执行没有必要,这时就可以使用协程,将此功能从Update中剥离出来。

案例4
当程序需要异步加载资源或者获取网络资源时,可以使用WWW协程。

案例5
创建补间动画。

案例6
打字机效果。

案例7
定时器操作。

协程总结

在Unity的生命周期中,有很多步骤都涉及到协程,我们可以通过协程来实现在生命周期的不同步骤下执行任务,协程是依赖于迭代器原理执行的,其本身并不能够加快程序运行速度,但其功能却能实现前后台的异步定时操作任务等,且其代码规格使其从Update中剥离出来,既简化了Update,又增加了功能代码的可读性。大型游戏甚至可以做一套协程管理器来实现功能的管理,以及代码的审核,让代码更具可维护性。

注意:启动一个协程会消耗少量的内存,在方法调用时却不会有后续消耗。如果内存消耗和垃圾回收是严重的问题,应该尝试避免产生太多短时间的协程,并避免在运行时调用太多 StartCoroutine()


更多内容请查看总目录【Unity】Unity学习笔记目录整理

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/笔触狂放9/article/detail/103345
推荐阅读
相关标签
  

闽ICP备14008679号