Unity/C++/C# 多线程与协程(Coroutine)原理与用法详解

参考资料:

https://docs.microsoft.com/zh-cn/dotnet/csharp/iterators

Unity 协程(Coroutine)原理与用法详解_unity coroutine-优快云博客

Unity 场景异步加载(加载界面的实现)_unity异步加载场景-优快云博客

什么是协程?

协程,从字面意义上理解就是协助程序的意思,我们在主任务进行的同时,需要一些分支任务配合工作来达到最终的效果

稍微形象的解释一下,想象一下,在进行主任务的过程中我们需要一个对资源消耗极大的操作时候,如果在一帧中实现这样的操作,游戏就会变得十分卡顿,这个时候,我们就可以通过协程,在一定帧内完成该工作的处理,同时不影响主任务的进行

协程与线程的区别?

  • 线程和进程都是同步机制,而协程是异步机制。
  • 线程是抢占式,而协程是非抢占式的。需要用户释放使用权切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。
  • 一个线程可以有多个协程,一个进程也可以有多个协程。
  • 协程不被操作系统内核管理,而完全是由程序控制。线程是被分割的CPU资源,协程是组织好的代码流程,线程是协程的资源。但协程不会直接使用线程,协程直接利用的是执行器关联任意线程或线程池。
  • 协程能保留上一次调用时的状态。

Unity中的协程

为啥在Unity中一般不考虑多线程?

因为在Unity中,只能在主线程中获取物体的组件、方法、对象,如果脱离这些,Unity的很多功能无法实现,那么多线程的存在与否意义就不大了

Unity中如何控制协程?

简单回答: 在update后面会调用yield return, 协程里面是个状态机

底层机制:

  • C#层调用StartCoroutine方法,将IEnumerator对象(或者是用于创建IEnumerator对象的方法名字符串)传入C++层。
  • 通过mono的反射功能,找到IEnumerator上的moveNextcurrent两个方法,然后创建出一个对应的Coroutine对象,把两个方法传递给这个Coroutine对象。【创建好之后这个Coroutine对象会保存在MonoBehaviour一个成员变量List中,这样使得MonoBehaviour具备StopCoroutine功能,StopCoroutine能够找到对应Coroutine并停止。】
  • 调用这个Coroutine对象的Run方法。
  • Coroutine.Run中,然后调用一次MoveNext。①如果MoveNext返回false,表示Coroutine执行结束,进入清理流程;②如果返回true,表示Coroutine执行到了一句yield return处,这时就需要调用invocation(m_Current).Invoke取出yield return返回的对象monoWait,再根据monoWait的具体类型(null、WaitForSeconds、WaitForFixedUpdate等),将Coroutine对象保存到DelayedCallManager的callback列表m_CallObjects中。
  • 至此,Coroutine在当前帧的执行即结束。
  • 之后游戏运行过程中,游戏主循环的PlayerLoop方法会在每帧的不同时间点以不同的modeMask调用DelayedCallManager.Update方法,Update方法中会遍历callback列表中的Coroutine对象,如果某个Coroutine对象的monoWait的执行条件满足,则将其从callback列表中取出,执行这个Coroutine对象的Run方法,回到之前的执行流程中

Unity协程的底层实现

协程是通过迭代器来实现功能的,通过关键字IEnumerator来定义一个迭代方法,注意使用的是IEnumerator,而不是IEnumerable

两者之间的区别:

  • IEnumerator:是一个实现迭代器功能的接口
  • IEnumerable:是在IEnumerator基础上的一个封装接口,有一个GetEnumerator()方法返回IEnumerator

在迭代器中呢,最关键的是yield 的使用,这是实现我们协程功能的主要途径,通过该关键方法,可以使得协程的运行暂停、记录下一次启动的时间与位置等等:

Unity中协程的使用

首先通过一个迭代器定义一个返回值为IEnumerator的方法,然后再程序中通过StartCoroutine来开启一个协程即可:

在正式开始代码之前,需要了解StartCoroutine的两种重载方式:

StartCoroutine(string methodName):这种是没有参数的情况,直接通过方法名(字符串形式)来开启协程
StartCoroutine(IEnumerator routine):通过方法形式调用
StartCoroutine(string methodName,object values):带参数的通过方法名进行调用

 	//通过迭代器定义一个方法
 	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):通过指定的协程来关闭

关于yield

如果你了解Unity的脚本的生命周期,你一定对yield这几个关键词很熟悉,没错,yield 也是脚本生命周期的一些执行方法,不同的yield 的方法处于生命周期的不同位置,可以通过下图查看:

通过这张图可以看出大部分yield位置UpdateLateUpdate之间,而一些特殊的则分布在其他位置,这些yield 代表什么意思呢,又为啥位于这个位置呢

首先解释一下位于Update与LateUpdate之间这些yield 的含义:

yield return null; 暂停协程等待下一帧继续执行

yield return 0或其他数字; 暂停协程等待下一帧继续执行

yield return new WairForSeconds(时间); 等待规定时间后继续执行

yield return StartCoroutine("协程方法名");开启一个协程(嵌套协程)

Unity协程使用场合?

1.将一个复杂程序分帧执行

2.进行计时器工作

3.异步加载等功能

  • AB包资源的异步加载
  • Reaources资源的异步加载
  • 场景的异步加载
  • WWW模块的异步请求

C#多线程

前台线程和后台线程有什么区别?

通过将 Thread.IsBackground 属性设置为 true,就可以将线程指定为后台线程

前台线程: 应⽤必须结束掉所有的前台线程才能结束程序,只要有⼀个前台线程没退出进程就不会⾃动

退出,当然线程是依附在进程上的,所以你直接把进程KO掉了的话⾃然所有前台线程也会退出。

后台线程: 进程可以不考虑后台直接⾃动退出,进程⾃动退出后所有的后台线程也会⾃动销毁

Task状态机的实现和⼯作机制是什么?

在C#中,Task 类是用于表示异步操作的一种方式。Task状态机实现了异步编程模型,其中异步操作以状态机的形式表达

Task状态机的实现

Task状态机的实现基于 async 和 await 关键字。async 方法通过状态机的方式来处理异步操作,await 关键字用于暂停异步方法的执行,等待异步操作完成。

async Task MyAsyncMethod()
{
  Console.WriteLine("Before await");
  await SomeAsyncOperation(); // 这里会生成状态机代码
  Console.WriteLine("After await");
}

上述代码中,await SomeAsyncOperation() 的执行会生成状态机代码,将异步操作的执行过程以状态机的形式表示,使得在异步操作未完成之前,MyAsyncMethod 可以被暂时挂起而不阻塞线程。

Task状态机的工作机制

异步方法启动: 当调用一个使用 async 修饰的异步方法时,该方法的执行将立即返回一个 Task 对象,表示异步操作的状态。

生成状态机: 在编译时,编译器会对包含 await 关键字的异步方法生成一个状态机。这个状态机是一个类,负责跟踪异步操作的状态和执行过程。

挂起和继续: 当 await 关键字遇到一个尚未完成的异步操作时,异步方法会被暂停,状态机会将控制权还给调用者。当异步操作完成时,状态机会通过回调机制通知异步方法继续执行。

非阻塞: 异步方法的执行过程中,不会阻塞线程。线程可以继续执行其他任务,提高了程序的并发性。

Task 完成: 当异步操作完成时,相关的 Task 对象的状态会从等待状态变为完成状态,可以通过 Task 的 Result 属性获取异步操作的结果。

使用async和await的异步编程模型可以使异步代码更加清晰、简洁,并提供更好的可读性和维护性。

await的作⽤和原理,并说明和GetResult()有什么区别?

await 关键字是用于在异步方法中等待异步操作完成的关键字,其主要作用是使异步代码更加清晰和易读。await 关键字的原理是将异步操作挂起,不阻塞线程的同时允许其他任务执行,直到异步操作完成后恢复执行异步方法。

await 的作用

挂起执行: 当遇到 await 关键字时,异步方法的执行会在此处暂停,控制权会返回给调用者,而不会阻塞线程。

异步等待: await 会等待异步操作完成。一旦异步操作完成,异步方法会继续执行。

简化异步代码: 通过 await,异步代码可以以同步的形式编写,提高代码的可读性和维护性。

await 的原理

生成状态机: 编译器在编译时会生成一个状态机,用于跟踪异步操作的状态和执行过程。

任务挂起: 当 await 遇到一个未完成的异步操作时,它会将异步方法的执行挂起,控制权返回给调用者。

注册回调: await 会注册一个回调,当异步操作完成时,回调会通知状态机,继续执行异步方法

区别与GetResult()

await: 在异步方法中使用 await 关键字时,异步操作完成后会自动恢复执行异步方法。此时,线程不会被阻塞,其他任务可以执行

async Task MyAsyncMethod()
{
  Console.WriteLine("Before await");
  await SomeAsyncOperation(); // 挂起,等待异步操作完成
  Console.WriteLine("After await");
}

GetResult(): 使用 GetResult() 方法时,会立即等待异步操作的完成,并获取其结果。这样会阻塞当前线程,直到异步操作完成。这种方式适用于需要立即获取异步操作结果的情况,但不适用于需要保持非阻塞的异步执行的情况

void MyMethod()
{
  Console.WriteLine("Before GetResult");
  SomeAsyncOperation().GetResult(); // 立即等待异步操作完成
  Console.WriteLine("After GetResult");
}

总体来说,await 更适合异步编程的场景,使得代码更加清晰和异步执行。而 GetResult() 则是一种同步等待异步操作完成的方式,适用于某些需要立即获取结果的情况。但在大多数情况下,应优先选择使用 await

 Task和Thread有区别吗?

Task

Higher-level Abstraction: Task 是一个更高级的抽象,它建立在线程之上,并提供对异步操作的支持。Task 是以任务(Task)的形式表示的,可以用于表示并行执行的工作单元。

Asynchronous Programming Model: Task 主要用于支持异步编程模型(Async Programming Model),通过 async 和 await 关键字可以更轻松地处理异步操作。

Cancellation and Continuation: Task 提供了对任务的取消(Cancellation)和延续(Continuation)的支持,允许在任务完成时执行额外的操作。

Task Parallel Library (TPL): Task 是 Task Parallel Library (TPL) 的一部分,该库提供了高级的并行性和并发性支持。

Thread

Lower-level Construct: Thread 是一个更低级别的构造,直接表示一个执行线程。它是操作系统提供的线程的直接映射,更接近硬件级别。

Synchronous and Blocking: Thread 主要用于同步和阻塞式的多线程编程。线程启动后,会一直执行直到任务完成或显式终止。

No Built-in Support for Asynchronous Programming: Thread 没有内置的异步编程支持,需要使用传统的同步和锁定机制来处理并发问题。

Explicit Management: 开发人员需要显式管理线程的生命周期、同步和互斥,这可能导致更复杂的代码。

总结:

如果你需要执行一些并行的、异步的工作,并且希望更高级的抽象和异步编程模型,那么使用 Task 是更合适的选择。

如果你需要更底层、直接控制线程的执行,或者在一些传统的多线程方案中使用线程的概念,那么使用 Thread 是合适的。

通常情况下,在现代C#开发中,更推荐使用 Task 和异步编程的方式来处理并发和多线程问题。

线程池的有点和不足

优点:减⼩线程创建和销毁的开销,可以复⽤线程;也从⽽减少了线程上下⽂切换的性能损失;在GC回

收时,较少的线程更有利于GC的回收效率。缺点:线程池⽆法对⼀个线程有更多的精确的控制,如了解

其运⾏状态等;不能设置线程的优先级;加⼊到线程池的任务(⽅法)不能有返回值;对于需要⻓期运⾏的任务就不适合线程池

Mutex和Lock之间的异同

Mutex 和 lock 都是用于多线程编程中实现同步和互斥的机制

Mutex
系统级别

Mutex 是一个系统级别的互斥锁,可以用于不同进程之间的同步。在同一进程内,可以通过命名 Mutex 实现跨线程同步。

跨进程

Mutex 可以用于跨进程同步,这意味着不同进程中的线程可以使用同一个 Mutex 进行同步。

释放所有者

Mutex 是手动释放的,它允许一个线程获取锁并在后续的某个时间释放。如果线程崩溃,Mutex 不会自动释放,需要手动进行处理。

Mutex mutex = new Mutex();
mutex.WaitOne();
// 临界区
mutex.ReleaseMutex();

lock
应用级别

lock 是 C# 中的关键字,用于实现应用级别的互斥锁。它是在编程语言层面上提供的语法糖,用于简化 Monitor 类的使用。

单进程

lock 仅在同一进程内的不同线程之间起作用,不能跨进程使用。

自动释放

lock 是自动释放的,一旦进入临界区,当代码块执行完成或发生异常时,锁会自动释放。

object lockObject = new object();
lock (lockObject)
{
  // 临界区
}
选择使用的考虑因素

如果需要跨进程同步,则应选择 Mutex。如果只需要在同一进程内的线程之间同步,lock 更为简单

如果希望锁在退出临界区时自动释放,可以选择 lock。如果需要手动控制锁的释放,可以选择 Mutex

lock 是更轻量级的机制,通常在单进程场景下性能更好。但在跨进程场景中,需要使用 Mutex。

C++线程与进程

C++如何避免死锁?

操作系统里面讲到,破坏死锁产生的四个条件中的一个就可以(互斥、不可剥夺、循环等待、请求和保持),这里需要展开来说:

  • 加锁的时候使用try_lock,如果获取不到锁则释放自身的所有的锁;
  • 使用mutex加锁的时候按照地址从小到大进行顺序加锁;
  • 将线程锁设置为PTHREAD_MUTEX_ERRCHECK,死锁会返回错误,不过效率较低。

一些操作线程的函数

pthread_create: 创建一个线程,返回0表示线程创建成功。例子

pthread_t pthread_self()获取进程id

int pthread_join(pthread_t tid, void** retval) 等待线程结束

void pthread_exit(void *retval) 结束线程

int pthread_detach(pthread_t tid) 主线程、子线程均可调用。主线程中pthread_detach(tid),子线程中pthread_detach(pthread_self()),调用后和主线程分离,子线程结束时自己立即回收资源。

一些进程有关的函数

进程结构由以下几个部分组成:代码段,堆栈段,数据段。代码段是静态的二进制码,多个程序可以共享,父进程与子进程除了pid不一样,其它都一样。父进程通过fork产生一个子进程。

父进程与子进程通过写时复制(Copy on Write)技术共享页面,只有当子进程需要写入页面才进行复制。如果子进程想要运行自己的代码段,就需要execv().

pid_t fork(void): 创建进程,返回一个非负整数,父进程返回子进程的pid,子进程返回0;

void exit(int status): 结束进程;

pid_t getpid(void): 获取进程pid;

pid_t getppid(void): 获取父进程pid

程序退出方式

正常退出方式有:return, _exit(), exit()

exit()其实是对_exit() 的一个封装,都会终止进程并做相关的首尾工作,最主要的区别是exit()会调用终止处理程序和清除I/O缓存。

return和exit的区别,exit是函数,有参数,执行完后控制权交还给OS,return 可以在函数中,调用后控制权返回给上一级函数,若是main函数,则返还给OS。

还有一些其它退出方式:

abort(),异常程序终止,同时发送SIGABRT给调用进程。

接能导致进程终止的信号,比如cltr+c就是SIGINT信号.

C++原子变量atomic

原子变量可以看作是一种特殊的类型,它具有类似于普通变量的操作,但是这些操作都是原子级别的,即要么全部完成,要么全部未完成。C++标准库提供了丰富的原子类型,包括整型、指针、布尔值等,使用方法也非常简单,只需要通过std::atomic<T>定义一个原子变量即可,其中T表示变量的类型。

在普通的变量中,并发的访问它可能会导致数据竞争,竞争的后果会导致操作过程不会按照正确的顺序进行操作。atomic对象可以通过指定不同的memory orders来控制其对其他非原子对象的访问顺序和可见性,从而实现线程安全

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值