Unity协程和线程的区别深入理解(附实验展示)

写在前面

前几天被问到线程和协程的区别,发现网上主要是从理论上来讲两者的区别理解起来很抽象,所以我下来做了几个实验来体现线程与协程的区别。
测试代码见:https://github.com/hahahappyboy/UnityProjects/tree/main/%E5%8D%8F%E7%A8%8B%E7%BA%BF%E7%A8%8B%E5%8C%BA%E5%88%AB%E6%B5%8B%E8%AF%95/Assets

协程、进程、线程的概念

进程: 是系统进行资源分配的基本单位,进程是线程的容器。打开unity的程序就是开始了一个进程,每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大。
线程: 是操作系统能够进行资源调度的最小单位(即CPU调度和分派的基本单位),是进程中实际运作的单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的公共资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少。
注意1:属于同一进程的多个线程之间的切换不会引起进程的切换,只有属于不同进程的线程之间的切换才会引起进程的切换。例如QQ间线程的切换(换一个人聊天)不会引起进程的切换,而QQ线程切换为音乐线程会引起进程的切换
注意2:CPU时间片是直接分配给线程的,线程拿到CPU时间片就能执行了,CPU时间片不是先分给进程然后再由进程分给进程下的线程的。所有的进程并行,线程并行都是看起来是并行,其实都是CPU片轮换使用。线程分到了CPU时间片,就可以认为这个线程所属的进程在运行,这样就看起来是进程并行。

协程: 具有多返回点的方法,把时间分片,协程也是运行在Unity主线程里的,是主线程的一部分,Unity只有主线程有些运算不希望放在同一帧里取运算,在同一帧里会卡顿,就可以用协程把他分散不同的帧里运算,这样就可以有一个流畅的体验。将协程代码中由yield return 语句分割的部分分配到每一帧执行。
在这里插入图片描述在这里插入图片描述

进程与线程的区别

进程是资源分配和拥有的单位,同一个进程内的线程共享进程的资源
线程是处理器调度的基本单位,但进程不是

协程与线程的区别

协程Coroutine——”伪异步“, 协程不是线程,也不是异步执行的,协程只是把yield return之间的语句分割的部分分配到每一帧执行 (实验1、2)。就和monobehaviour的update函数一样也是在脚本生命周期里执行,属于生命周期的一部分。即unity在每一帧都会处理对象上的协程,也就是说,协程跟update一样都是unity每帧会去处理的函数(update之前,lateupdate之后),所以协程不是异步。除非你在协程里面去开启异步,如网络请求WWW ,才能让协程去创建线程实现异步 (实验3、5)
线程Thread——“真异步 ”, 开启线程后线程和脚本生命周期没有关系了属于两条并行的逻辑,你做你的我做我的。但子线程内不可访问游戏对象或者组件以及相关的Unity API。即unity可以开多线程,但是不能操作unity的api,如调用transform。

在Unity的主线程里只会有一个处于运行状态的协程, 即便不同脚本开启多个协程或则同一脚本开启多给协程也是一个一个顺序执行 (实验4、6),Unity中有个协程调度器,里面有个协程列表协程调度器按照协程列表去执行,从而保证主线程里只能有一个处于运行状态的协程
所以协程之间不会出现临界区资源访问的问题,而但是你开多线程去访问临界资源就会出现问题,因为你没办法控制这个线程会执行到什么时候转换为下一个线程(因为线程是在CPU循环占用的)。 (多线程实验)

实验1:协程中执行普通函数

代码:
在协程里用一个for循环模仿10个耗时操作

public class 协程测试1 : MonoBehaviour
{
    
    
    void Start(){
    
    
        Debug.Log("Start开始");
        StartCoroutine(协程1());//开启协程
        Debug.Log("Start结束");

    }
    IEnumerator 协程1(){
    
    
        for (int i = 0; i < 10; i++) //循环C
        {
    
    
            yield return 耗时操作1(); //协程1
        }
    }
    public int 耗时操作1(){
    
    
        Debug.Log("耗时操作开始");
        for (int i = 0; i < 10000; i++){
    
    
        }
        Debug.Log("耗时操作结束");

        return 1;
    }
    // 更新数据
    private int frame = 0;
    void Update(){
    
    
        frame++;
        Debug.Log("第"+frame+"帧的"+"Update");
    }
    //晚于更新
    void LateUpdate(){
    
    
        Debug.Log("第"+frame+"帧的"+"LateUpdate");
    }
}

结果
可以看到,耗时操作执行完成后Unity才会执行接下来的生命周期函数,也就说Unity只是把10个耗时操作通过yield return分在了10帧执行,而不是1帧。但是协程并没有脱离脚本的生命周期,也是在生命周期中每帧执行(Update之后LateUpdate之前)。
在这里插入图片描述

实验2:协程中开启另一个协程

代码: 在协程里再开个协程执行,去执行耗时操作

public class 协程测试2 : MonoBehaviour
{
    
    
    void Start(){
    
    
        Debug.Log("Start开始");
        StartCoroutine(协程1());//开启协程
        Debug.Log("Start结束");

    }
    IEnumerator 协程1(){
    
    
        Debug.Log("协程开始");
        for (int i = 0; i < 10; i++) //循环C
        {
    
    
            yield return StartCoroutine(耗时操作1()); //协程1
        }
        Debug.Log("协程开始");
    }
    IEnumerator 耗时操作1(){
    
    
        Debug.Log("耗时操作开始");
        for (int i = 0; i < 10000; i++){
    
    
        }
        Debug.Log("耗时操作结束");
        yield return null;
    }
    // 更新数据
    private int frame = 0;
    void Update(){
    
    
        frame++;
        Debug.Log("第"+frame+"帧的"+"Update");
    }
    //晚于更新
    void LateUpdate(){
    
    
        Debug.Log("第"+frame+"帧的"+"LateUpdate");
    }
}

结果
可以看到与实验1并没有什么区别,协程会等开启的协程的yield return执行完成后,才执行下面的生命周期函数。也是每帧的执行,没有脱离生命周期。
在这里插入图片描述

实验3:协程中开启WWW请求

代码: 在协程里开启WWW请求

public class 协程测试3 : MonoBehaviour
{
    
    
    void Start(){
    
    
        Debug.Log("Start开始");
        StartCoroutine(协程1());//开启协程
        Debug.Log("Start结束");
    }
    IEnumerator 协程1(){
    
    
        Debug.Log("协程开始");
        for (int i = 0; i < 10; i++) //循环C
        {
    
    
            Debug.Log("耗时操作开始");
            UnityWebRequest www = UnityWebRequest.Get("www.baidu.com");
            yield  return www.SendWebRequest();
            Debug.Log("耗时操作结束");
        }
        Debug.Log("协程结束");
    }
 
    // 更新数据
    private int frame = 0;
    void Update(){
    
    
        frame++;
        Debug.Log("第"+frame+"帧的"+"Update");
    }
    //晚于更新
    void LateUpdate(){
    
    
        Debug.Log("第"+frame+"帧的"+"LateUpdate");
    }
}

结果
可以看到第1次www操作第1帧开始,第2帧lateupdate后才结束。第2次www操作第2帧lateupdate后开始,第9帧才结束。这才是真正的异步,原因是SendWebRequest函数本身就是真异步操作,是单独开一个线程来向服务器请求数据(多线程模式),从而让该函数脱离了Unity生命周期的循环,只有在该函数执行完后才会回到yield return的地方,继续往下执行,在这期间协程会每帧都检测该函数是否执行完毕。
在这里插入图片描述
在这里插入图片描述

实验4:一个脚本中多个协程访问临界资源

代码: 在线程中访问临界资源

public class 协程测试4 : MonoBehaviour
{
    
    
    private static int n = 5;
    void Start(){
    
    
        for (int i = 0; i < 10; i++) {
    
    
            StartCoroutine(协程1(i));//开启协程
        }
    }
    IEnumerator 协程1(int id){
    
    
        if (n==5) {
    
    
            n++;
            Debug.Log("id="+id+" n="+n);
        }
        n = 5;
        yield return null;
        Debug.Log("id=" + id + "执行完毕");
    }
    // 更新数据
    private int frame = 0;
    void Update(){
    
    
        frame++;
        Debug.Log("第"+frame+"帧的"+"Update");
    }
    //晚于更新
    void LateUpdate(){
    
    
        Debug.Log("第"+frame+"帧的"+"LateUpdate");
    }
}

结果
可以看出即便开多个协程,但在同一个monobehavior脚本里同一时间只会有一个协程在执行,原因很简单因为协程就是在生命周期里执行的,生命周期是顺序执行的,那么开启的协程也是按顺序执行的。
在这里插入图片描述

实验5:线程中访问临界资源

代码: 线程中访问临界资源

public class 线程 {
    
    
    // private static object lockObject = new object();
    private static int n = 5;
    public static void Main() {
    
    
        for (int i = 0; i < 10; i++) {
    
    
            new Thread(ThreadMain).Start();
        }
    }
     public static void ThreadMain() {
    
    
         // lock (lockObject) {
    
    
             if (n==5) {
    
    
                 n++;
                 Console.WriteLine("n="+n);
             }
             n = 5;
         // }     
     }
}

结果
可以看到在不加锁的情况下,因为线程是异步的,其中有线程执行n++后还没打印,CPU就换为其他线程执行了n=5,才导致打印了5
在这里插入图片描述

多线程实验:协程中开启异步操作

代码: 协程中开启异步操作

public class 协程测试5 : MonoBehaviour
{
    
    
    void Start(){
    
    
        Debug.Log("Start开始");
        StartCoroutine(协程1());//开启协程
        Debug.Log("Start结束");

    }
    IEnumerator 协程1(){
    
    
      
       yield return 耗时操作1(); //协程1
      
    }
    private async Task<int> 耗时操作1()
    {
    
    
        return await Task.Run( () => {
    
    
            Debug.Log("耗时操作开始");
            for (int i = 0; i < 10000000; i++) //循环B
            {
    
    
            }
            Debug.Log("耗时操作结束");
            return 1;
        });
    }
    // 更新数据
    private int frame = 0;
    void Update(){
    
    
        frame++;
        Debug.Log("第"+frame+"帧的"+"Update");
    }
    //晚于更新
    void LateUpdate(){
    
    
        Debug.Log("第"+frame+"帧的"+"LateUpdate");
    }
}

结果
可以看到在协程中开启异步后,过了两帧才执行完,而不想在协程中开启协程那种每帧去执行。因为异步是开线程执行,此时已经脱离了主线程。
在这里插入图片描述

实验6:多脚本开协程去访问临界资源

代码: 多脚本开协程去访问临界资源

public static class  临界资源 {
    
    
        public static int n = 5;
        public static void ThreadMain() {
    
    
                if (n==5) {
    
    
                        n++;
                        Debug.Log("n="+n);
                }
                n = 5;
        }
}

public class 协程测试6 : MonoBehaviour
{
    
    
    void Start(){
    
    
        StartCoroutine(协程1());//开启协程

    }
    IEnumerator 协程1(){
    
    
       yield return 抢占支援(); //协程1
    }
    public int 抢占支援()
    {
    
    
        临界资源.ThreadMain();
         return 1;
    }
 
}

结果
即便开了100个脚本,全部打印的n=6。原因是在unity的主线程里只会有一个处于运行状态的协程,即便不同脚本开启多个协程或则同一脚本开启多给协程也是一个一个顺序执行。这是因为unity中有个协程调度器,里面有个协程列表协程调度器按照协程列表去执行,从而保证主线程里只能有一个处于运行状态的协程。
在这里插入图片描述

(2023/05/24更新)实验7:协程受挂载对象和脚本失落销毁的影响

代码:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CoroutineExample : MonoBehaviour
{
    
    
    private Coroutine myCoroutine;

    private void Start()
    {
    
    
        // 启动协程
        myCoroutine = StartCoroutine(MyCoroutine());
    }

    private void Update() {
    
    
        Debug.Log("Update执行");
    }
    private void LateUpdate() {
    
    
        Debug.Log("LateUpdate执行");
    }
    private IEnumerator MyCoroutine()
    {
    
    
        Debug.Log("Coroutine开始");

        // 执行一段时间的协程逻辑
        yield return new WaitForSeconds(4f);
        Debug.Log("Coroutine执行4s");

        // 继续执行协程
        yield return new WaitForSeconds(4f);
        Debug.Log("Coroutine又执行4秒");
        
        // 继续执行协程
        yield return new WaitForSeconds(2f);
        Debug.Log("Coroutine又又执行2秒");
    }

    private void OnDisable()
    {
    
    
        // 当脚本禁用时停止协程的执行
        // StopCoroutine(myCoroutine);
        // Debug.Log("Coroutine停止,因为物体失活");
    }

    private void OnDestroy()
    {
    
    
        Debug.Log("脚本或物体被销毁");
    }
}

结果
1.当挂载对象和脚本销毁后,协程不执行。
2.当挂载对象失活时,协程不执行,激活后也不会执行。这是为了逻辑一致性,
对象的激活状态通常与其在游戏中的逻辑一致性相关。当对象失活时,通常表示该对象
在当前场景中不处于活动状态,对象其他行为也停止,通过暂停协程的执行,确保了对象的行为与其激活状态保持一致,不会出现逻辑错误。
3.当只有脚本失活时,协程继续执行(一般在OnDisable0中停止所有需要持续执行的协程)。这是为了保留状态和数据,协程可以在脚本重新激活后继续执行,并目保留之前的状态和数据。这对于需要在中断或暂停期间保存和恢复状态的情况非常有用。例如,在游戏中的对话系统中,可以使用协程来显示对话框,允许玩家暂停或关闭对话框,然后在需要时继续显示对话框,而不会丢失之前的对话进度。

写在后面

哎,Unity工作好难找啊,55555

猜你喜欢

转载自blog.csdn.net/iiiiiiimp/article/details/130061403
今日推荐