文章目录
- 一、概述
- 二、回顾——线程
- 三、线程池
- 四、Task 任务类
- 五、同步和异步
注意:在此仅提及 Unity 开发中会用到的一些功能和特性,对于不适合在 Unity 中使用的内容会忽略。
一、概述
- C# 5
- 调用方信息特性(C# 进阶内容)
- 异步方法 async 和 await
二、回顾——线程
- Unity 支持多线程
- Unity 中开启的多线程不能使用主线程中的对象
- Unity 中开启多线程后一定记住关闭
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
public class Lesson4 : MonoBehaviour
{
Thread t;
// Start is called before the first frame update
void Start()
{
t = new Thread(()=> {
while (true) {
print("123");
Thread.Sleep(1000);
}
});
t.Start(); // 开启线程
print("主线程执行");
}
private void OnDestroy()
{
t.Abort(); // 关闭线程
}
}
三、线程池
命名空间:System.Threading
类名:ThreadPool
在多线程的应用程序开发中,频繁地创建删除线程会带来性能消耗,产生内存垃圾。为了避免这种开销,C# 推出了线程池 ThreadPool 静态类。
ThreadPool 中有若干数量的线程,如果有任务需要处理时,会从线程池中获取一个空闲的线程来执行任务。任务执行完毕后线程不会销毁,而是被线程池回收以供后续任务使用。
当线程池中所有的线程都在忙碌时,又有新任务要处理时,线程池才会新建一个线程来处理该任务;如果线程数量达到设置的最大值,任务会排队,等待其他任务释放线程后再执行。
线程池能减少线程的创建,节省开销,可以减少 GC 垃圾回收的触发。
线程池相当于就是一个专门装线程的缓存池(Unity小框架套课中有对缓存池的详细讲解)
- 优点:节省开销,减少线程的创建,进而有效减少 GC 触发
- 缺点:不能控制线程池中线程的执行顺序,也不能获取线程池内线程取消 / 异常 / 完成的通知
-
获取可用的工作线程数和 I/O 线程数
int num1, num2; ThreadPool.GetAvailableThreads(out num1, out num2); print(num1); // 2000 print(num2); // 200
-
获取线程池中工作线程的最大数目和 I/O 线程的最大数目
ThreadPool.GetMaxThreads(out num1, out num2); print(num1); // 2000 print(num2); // 200
-
设置线程池中可以同时处于活动状态的工作线程的最大数目和 I/O 线程的最大数目
// 大于次数的请求将保持排队状态,直到线程池线程变为可用 // 更改成功返回true,失败返回false if(ThreadPool.SetMaxThreads(20, 20)) { print("更改成功"); } ThreadPool.GetMaxThreads(out num1, out num2); print(num1); // 20 print(num2); // 20
-
获取线程池中工作线程的最小数目和 I/O 线程的最小数目
ThreadPool.GetMinThreads(out num1, out num2); print(num1); // 16 print(num2); // 16
-
设置工作线程的最小数目和 I/O 线程的最小数目
if(ThreadPool.SetMinThreads(5, 5)) { print("设置成功"); } ThreadPool.GetMinThreads(out num1, out num2); print(num1); // 5 print(num2); // 5
-
将方法排入队列以便执行,当线程池中线程变得可用时执行
public static bool QueueUserWorkItem(WaitCallback callBack)
public static bool QueueUserWorkItem(WaitCallback callBack, object state)
其中,state 为 callBack 的参数,不传则默认为 null。
for (int i = 0; i < 10; i++) { ThreadPool.QueueUserWorkItem((obj) => { print("第" + obj + "个任务"); }, i); } print("主线程执行");
从运行结果可看出,控制线程池中线程的执行顺序不确定:
四、Task 任务类
命名空间:System.Threading.Tasks
类名:Task
Task 是在线程池基础上进行的改进,它拥有线程池的优点,同时解决了使用线程池不易控制的弊端。
它是基于线程池的优点对线程的封装,可以让我们更方便高效的进行多线程开发,一个 Task 对象就是一个线程。
(一)创建无返回值的 Task
本质上是从线程池中取出一个线程进行执行。
-
使用 new 传入委托函数
Task t1 = new Task(() => { print("方式一创建"); }); t1.Start(); // 手动开启
-
使用 Task 中的 Run 静态方法传入委托函数
Task t2 = Task.Run(() => { // 直接开启 print("方式二创建"); });
-
使用 Task.Factory 中的 StartNew 静态方法传入委托函数
Task t3 = Task.Factory.StartNew(() => { print("方式三创建"); });
(二)创建有返回值的 Task
在上述基础上添加返回类型的泛型即可。
-
使用 new 传入委托函数
Task t1 = new Task<int>(() => { print("方式一创建"); return 1; }); t1.Start(); // 手动开启
-
使用 Task 中的 Run 静态方法传入委托函数
Task t2 = Task.Run<string>(() => { // 直接开启 print("方式二创建"); return "2"; });
-
使用 Task.Factory 中的 StartNew 静态方法传入委托函数
Task t3 = Task.Factory.StartNew<float>(() => { print("方式三创建"); return 3.0f; });
-
获取返回值
print(t1.Result); // 1 print(t2.Result); // 2 print(t3.Result); // 3
注意:
Result 获取结果时会阻塞线程,如果 task 没有执行完成,会等待 task 执行完成获取到 Result 然后再执行后边的代码。
(三)同步执行 Task
使用上述三种 Start、Run 和 StartNew 方法会在创建时异步启动 Task。
如果需要同步执行,则只能使用 new Task 的方式并使用 RunSynchronously 方法。
-
异步执行
Task t = new Task(()=> { Thread.Sleep(1000); print("哈哈哈"); }); t.Start(); print("主线程执行");
图2 异步执行结果 -
同步执行
Task t = new Task(()=> { Thread.Sleep(1000); print("哈哈哈"); }); t.RunSynchronously(); print("主线程执行");
图3 同步执行结果
(四)阻塞 Task
-
Wait
:等待任务执行完毕,再执行后面的内容。Task t1 = Task.Run(() => { for (int i = 0; i < 5; i++) { print("t1:" + i); } }); t1.Wait(); print("主线程执行");
当 t1 线程执行完毕后,才会执行主线程中的打印内容。
图4 Wait执行结果 -
WaitAny
:传入任务中任意一个任务结束就继续执行。Task t1 = Task.Run(() => { for (int i = 0; i < 5; i++) { print("t1:" + i); } }); Task t2 = Task.Run(() => { for (int i = 0; i < 50; i++) { print("t2:" + i); } }); Task.WaitAny(t1, t2); print("主线程执行");
在这里,t1 执行完成后将会执行主线程的打印,但是 t2 线程仍继续执行。因为主线程与 t2 线程先后顺序无法控制,因此 t1 线程执行完成后没有立即打印主线程的内容。
图5 WaitAny执行结果 -
WaitAll
:任务列表中所有任务执行结束就继续执行。t1、t2 线程都执行完成后,主线程才打印内容。
图6 WaitAll执行结果
(五)延续 Task
-
传入任务完毕后再执行某任务
-
WhenAll + ContinueWith
-
WhenAll
:创建一个任务,该任务将在所有提供的任务完成后完成。public static Task WhenAll(params Task[] tasks)
-
ContinueWith
:创建在目标任务完成时异步执行的延续。public Task ContinueWith(Action<Task> continuationAction)
-
Task.WhenAll(t1, t2).ContinueWith((t) => { print("一个新的任务开始了"); });
-
ContinueWhenAll
:创建在一组指定任务完成时启动的延续任务。public Task ContinueWhenAll(Task[] tasks, Action<Task[]> continuationAction)
Task.Factory.ContinueWhenAll(new Task[] { t1, t2 }, (t) => { print("一个新的任务开始了"); });
-
-
传入任务只要有一个执行完毕后再执行某任务
-
WhenAny + ContinueWith
-
WhenAny
:创建一个任务,该任务将在提供的任何任务完成时完成。public static Task WhenAny(params Task[] tasks)
-
ContinueWith
:创建在目标任务完成时异步执行的延续。public Task ContinueWith(Action<Task> continuationAction)
-
Task.WhenAny(t1, t2).ContinueWith((t) => { print("一个新的任务开始了"); });
-
ContinueWhenAny
:创建一个延续任务,该任务将在提供集中的任何任务完成后启动。public Task ContinueWhenAny(Task[] tasks, Action<Task[]> continuationAction)
Task.Factory.ContinueWhenAny(new Task[] { t1, t2 }, (t) => { print("一个新的任务开始了"); });
-
(六)取消 Task
-
加入 bool 标识,控制线程内死循环的结束
public class Lesson5 : MonoBehaviour { private bool isRuning = true; // 循环标识 public Task t; // Start is called before the first frame update private void Start() { t = Task.Run(() => { print("一个新的任务开始了"); int i = 0; while (isRuning) { // 通过 isRuning 标识控制循环 print(i++); Thread.Sleep(1000); } }); } // Update is called once per frame void Update() { if (Input.GetKeyDown(KeyCode.Space)) { // 按下空格停止线程 isRuning = false; } } }
-
CancellationTokenSource
:取消令牌(标识)源类-
控制循环取消
IsCancellationRequested:获取是否已请求取消此取消令牌源。
public class Lesson5 : MonoBehaviour { public CancellationTokenSource c; public Task t; // Start is called before the first frame update private void Start() { t = Task.Run(() => { print("一个新的任务开始了"); int i = 0; while (!c.IsCancellationRequested) { // IsCancellationRequested 默认为 false print(i++); Thread.Sleep(1000); } }); } // Update is called once per frame void Update() { if (Input.GetKeyDown(KeyCode.Space)) { // 按下空格停止线程 c.Cancel(); // 使用 Cancel 方法停止 } } }
-
延迟取消
CancelAfter:在指定的毫秒数后计划对此取消令牌源执行取消操作。
public class Lesson5 : MonoBehaviour { public CancellationTokenSource c; public Task t; // Start is called before the first frame update private void Start() { t = Task.Run(() => { print("一个新的任务开始了"); int i = 0; while (!c.IsCancellationRequested) { print(i++); Thread.Sleep(1000); } }); } // Update is called once per frame void Update() { if (Input.GetKeyDown(KeyCode.Space)) { c.CancelAfter(5000); // 延迟 5s 取消 } } }
-
取消后执行逻辑
Token.Register:注册取消此取消令牌时将调用的委托。
public class Lesson5 : MonoBehaviour { public CancellationTokenSource c; public Task t; // Start is called before the first frame update private void Start() { t = Task.Run(() => { print("一个新的任务开始了"); int i = 0; while (!c.IsCancellationRequested) { print(i++); Thread.Sleep(1000); } }); c.Token.Register(() => { print("任务取消了"); }); // 取消回调,线程被取消后将执行打印 } // Update is called once per frame void Update() { if (Input.GetKeyDown(KeyCode.Space)) { c.CancelAfter(5000); // 延迟 5s 取消 } } }
-
(七)小结
- Task 类是基于 Thread 的封装
- Task 类可以有返回值,Thread 没有返回值
- Task 类可以执行后续操作,Thread 没有这个功能
- Task 可以更加方便的取消任务,Thread 相对更加单一
- Task 具备 ThreadPool 线程池的优点,更节约性能
五、同步和异步
同步和异步主要用于修饰方法
- 同步方法:
当一个方法被调用时,调用者需要等待该方法执行完毕后返回才能继续执行。 - 异步方法:
当一个方法被调用时立即返回,并获取一个线程执行该方法内部的逻辑,调用者不用等待该方法执行完毕。
简单理解:把一些不需要立即得到结果且耗时的逻辑设置为异步执行,可以提高程序的运行效率,避免由于复杂逻辑带来的的线程阻塞。
需要处理的逻辑会严重影响主线程执行的流畅性时,需要使用异步编程,比如:
- 复杂逻辑计算时
- 网络下载、网络通讯
- 资源加载时
- 等等
(一)async 关键字
async 和 await 一般需要配合 Task 进行使用。
async 用于修饰函数、lambda 表达式、匿名函数,表示该方法是一个异步方法。
public class Lesson6 : MonoBehaviour
{
void start() {
Test(); // 打印 "123"
}
public async void Test() { // 方法中没有 await 关键字,则视为同步方法
print("123");
}
}
上述代码声明了一个方法 Test(),在 void 前面添加关键字 async,表示该方法是异步的。在该方法内没有 await 关键字,因此编译器会发出警告,并将该方法默认视为同步方法。
声明异步方法时,最好在函数名称后加上 Aysnc,以表示该方法为异步方法。
下面总结了几点说明:
- 在异步方法中使用 await 关键字(不使用编译器会给出警告但不报错),否则异步方法会以同步方式执行;
- 异步方法名称建议以 Async 结尾;
- 异步方法的返回值只能是 void、Task、Task<>;
- 异步方法中不能声明使用 ref 或 out 关键字修饰的变量。
(二)await 关键字
await 用于在函数中和 async 配对使用,主要作用是等待某个逻辑结束。
此时逻辑会返回函数外部继续执行,直到等待的内容执行结束后,再继续执行异步函数内部逻辑。
在一个 async 异步函数中可以有多个 await 等待关键字。
使用 await 等待异步内容执行完毕(一般和 Task 配合使用)遇到 await 关键字时:
- 异步方法将被挂起;
- 将控制权返回给调用者;
- 当 await 修饰内容异步执行结束后,继续通过调用者线程执行后面内容。
public class Lesson6 : MonoBehaviour
{
void start() {
print("1");
TestAsync();
print("2");
}
public async void TestAsync()
{
print("3"); // 1
await Task.Run(() => { // 2
Thread.Sleep(5000);
});
print("4"); // 3
}
}
上述代码执行时,先打印主函数中的 “1”,然后进入 TestAsync() 异步函数,打印 “3”,遇到 await 关键字后,开启新的进程执行进程代码,TestAsync() 方法被挂起,执行主函数后面的代码,即接着打印 “2”。直到 Task 任务完成后才继续 TestAsync() 方法后面的代码,因此最后打印 “4”。
(三)举例
- 使用异步方法模拟复杂寻路计算:
public class Lesson6 : MonoBehaviour
{
void start() {
// 利用Task新开线程进行计算 计算完毕后再使用 比如复杂的寻路算法
CalcPathAsync(this.gameObject, Vector3.zero);
}
public async void CalcPathAsync(GameObject obj, Vector3 endPos) {
print("开始处理寻路逻辑");
int value = 10;
await Task.Run(() => {
Thread.Sleep(1000); // 处理复杂逻辑计算,通过休眠模拟计算的复杂性
value = 50;
// 不能在多线程里访问Unity主线程场景中的对象,这样写会报错
// print(obj.transform.position); xxx
});
print("寻路计算完毕 处理逻辑" + value);
obj.transform.position = Vector3.zero;
}
}
- 使用异步方法实现简单计时器:
public class Lesson6 : MonoBehaviour
{
CancellationTokenSource source;
void start() {
TimerAsync();
}
public async void TimerAsync()
{
source = new CancellationTokenSource();
int i = 0;
while (!source.IsCancellationRequested) {
print(i);
await Task.Delay(1000); // 延时 1000 ms
++i;
}
}
}
(四)资源加载
(Addressables 的资源异步加载是可以使用 async 和 await 的)
Unity 中大部分异步方法是不支持异步关键字 async 和 await 的,我们只有使用协同程序进行使用。
虽然官方不支持,但是存在第三方的工具(插件)可以让 Unity 内部的一些异步加载的方法支持 异步关键字:https://github.com/svermeulen/Unity3dAsyncAwaitUtil。
虽然 Unity 中的各种异步加载对异步方法支持不太好,但是当我们用到 .Net 库中提供的一些 API 时,可以考虑使用异步方法
- Web 访问:HttpClient;
- 文件使用:StreamReader、StreamWriter、JsonSerializer、XmlReader、XmlWriter 等等;
- 图像处理:BitmapEncoder、BitmapDecoder。
一般 .Net 提供的 API 中,方法名后面带有 Async 的方法都支持异步方法。