协程“伪异步”
Unity引擎的主线程对开发者而言完全可以看做是单线程执行,即便引擎底层可能对多核心多线程处理做了优化和封装,但在不使用Unity官方给出的JobSystem时,这些多线程机制对开发者而言都是透明的,无需关心。
而为了满足特定需求场景中开发者对于“异步”或者“多线程”的需求,Unity提供了一个简单易用的“伪异步”解决方案,这便是协程Coroutine。
在详细理解协程之前,可以先从一个简单的范例出发,看看协程究竟能干些什么。
private IEnumerator MoveToTarget(Vector3 targetPosition, float time) { Vector3 startPosition = transform.position; float timer = 0f; while(timer < time) { timer += Time.deltaTime; transform.position = Vector3.Lerp(startPosition, targetPosition, timer / time); yield return null; } } // 在MonoBehavior中启动协程 StartCoroutine(MoveToTarget(pos, 1f));
范例中的协程实现了一个简单的物体位移功能,接受两个参数,分别是目的地坐标以及移动时间,距离相同的前提下时间越短则速度越快。
仅从协程本身的代码来看,忽略yield return相关行的话,实现过程和直接在Update中进行位移并没有什么本质区别,都是通过deltaTime参数和Lerp方法来对坐标进行插值运算得出每帧的运行结果。
那么为什么要用协程,直接在Update中编写功能代码不好吗?
即便不考虑过于庞大的臃肿的Update方法对运行和维护有怎样的影响,光是各种控制变量就很容易让开发者陷入混乱之中。
想想一个复杂运动的物体,如果所有的运动控制过程全部编写在Update方法中,那想必会有大量bool类型的控制变量以及令人眼花缭乱的if结构,使用协程正是为了避免这种情况的出现。
现在回到协程本身,它在Unity中是“伪异步”,顾名思义它不是真正意义上的异步,因为协程最终还是在主线程上执行的,它没有按照传统意义的异步操作去开辟新线程来处理需求。
这也就是为什么协程在Unity内被广泛使用,作为“单线程”引擎,Unity不允许在子线程中访问包括游戏对象以及脚本组件在内的代码,换言之一个简单的坐标移动功能都无法放入子线程实现,只能采用回调机制,上下文切换的开销大到难以接受。
而协程则以自己的方式实现了类似“异步”和“多线程”的效果,启动协程之后便可以将其视为一段异步代码,近似认为协程是和主线程同时执行的“线程”。
协程这一功能的实现,依靠的是枚举器这个工具,作为C#中的一种重要功能接口,枚举器IEnumerator存在于很多集合数据类型中,方便对列表或者字典结构的数据进行枚举。
枚举器接口的源代码如下
public interface IEnumerator { object Current { get; } bool MoveNext(); void Reset(); }
从它要实现的功能上看很好理解,Current作为访问当前对象的属性,MoveNext方法依次枚举需要遍历的对象,而Reset方法则让索引重置为未访问状态。
这东西用在数据结构里当然毫无问题,那么协程又是怎么利用这一接口实现“伪异步”功能的呢?
其核心内容就在yield return语句上。
yield关键字是C#的一个特性,本质上来说它是个语法糖,功能是为它所处的方法体隐式创建一个枚举器类,并返回类的实例。
所以说凡是用到了yield关键字的方法,其返回类型要么是IEnumerator,要么是IEnumerator<?>。
从这个角度出发再去看协程就会发现,其实协程根本不是一个方法块,而是利用yield隐式创建了一个类,将方法中的代码根据yield语句的分布放入IEnumerator接口的MoveNext方法体中,而主线程本质上只是将这个IEnumerator实现类保存下来并每帧都调用MoveNext而已。
所以说即便不使用yield这个语法糖,我们也能实现协程的功能,只要自定义一个类来实现IEnumerator接口即可。
public class MoveCoroutine : IEnumerator { public object Current => null; private Transform source; private Vector3 startPostion; private Vector3 targetPosition; private float moveTime; private float timer; public MoveCoroutine(Transform src) { source = src; timer = 0f; } public IEnumerator MoveToTarget(Vector3 target, float time) { targetPosition = target; moveTime = time; Reset(); return this; } public bool MoveNext() { if (timer < moveTime) { timer += Time.deltaTime; source.position = Vector3.Lerp(startPostion, targetPosition, timer / moveTime); return true; } else { return false; } } public void Reset() { timer = 0f; startPostion = source.position; } } // 创建并启动协程 MoveCoroutine task = new MoveCoroutine(transform); StartCoroutine(task.MoveToTarget(Vector3.one, 5f));
当然了,实际使用中还是用yield关键字比较方便,除非有什么特殊需求,不然语法糖用起来总是很便捷的。
除了这种直接编写的协程之外,Unity还有一些协程的辅助类,比如WaitForSeconds和AsyncOperation等,它们均继承自YieldInstruction基类,主要是为了让协程的功能更加丰富。
毕竟默认情况下,协程就只能一路MoveNext下去,各种控制方法或者进度追踪都要自己写,但使用辅助类可以拓展协程的功能,方便实现延迟或者进度追踪一类的需求。
线程“真异步”
虽然说Unity内的许多操作都只能在主线程进行,一旦开辟子线程执行代码会报错,但有些情景里使用子线程是可行甚至是必然的选择,更何况Unity本身也并未禁止多线程代码的运行,因此在恰当的地方使用子线程会是一种很好的优化方案。
但必须始终记住的是,子线程内不可访问游戏对象或者组件以及相关方法,只用于处理数据或者逻辑,任何要与主线程发生关系的地方都必须进行回调和上下文切换。
正因如此,开辟子线程这种做法的泛用性并不高,通常而言只适用于有高时间消耗并且难以拆分到多帧内完成的任务。
相对典型的例子有文件读写,大规模寻路,网络传输等,这些场景下基本都只涉及数据和逻辑处理,但时间消耗极大且不方便拆分到多帧中运行,因此使用子线程会比较合适。
想要在C#中使用子线程,那自然离不开Threading命名空间的一系列工具,最简单的线程创建方法是直接新建Thread类。
// 不带参数的线程调用 Thread th1 = new Thread(() => { // 子线程的工作 }); th1.Start(); // 启动时无需参数 // 带参数的线程调用 Thread th2 = new Thread(obj => { // 子线程的工作 }); th2.Start(obj); // 启动时给定参数
直接创建线程的方式在子线程比较多或者创建时机难以把握的情况下可能不太好用,可以自行编写一套线程管理代码,也可以使用C#自带的线程池机制。
// 无参数启动线程 ThreadPool.QueueUserWorkItem(obj => { // 子线程工作 }); // 带参数启动线程 ThreadPool.QueueUserWorkItem(obj => { // 子线程工作 }, obj);
光有子线程可能还是略有不足,大部分时候在子线程处理完自己的工作后都应该告知主线程,让逻辑代码可以继续按要求运行,但这个举动不能从子线程直接访问到主线程,必须做一层回调封装。
比如使用队列来缓存每个子线程运行结束后的回调方法,主线程则每帧都取出队列中的回调方法进行调用,这一做法隐藏着子线程资源竞争的问题,因此可能需要考虑给队列加锁或者采用并发队列。
最终可以得到一个简单的多线程工具
public class AsyncExecutor : MonoBehaviour { public const int ASYNC_CALLBACK_TO_MAIN_THREAD_PER_FRAME = 8; public delegate OutputParam ExecuteFunc(InputParam param); public delegate void CallbackFunc(OutputParam param); /// <summary> /// 安排异步工作到子线程 /// </summary> /// <param name="func"></param> /// <param name="args"></param> /// <param name="callback"></param> public static void ScheduleAsyncTask(ExecuteFunc func, InputParam args, CallbackFunc callback = null) { ThreadPool.QueueUserWorkItem(state => { OutputParam result = func.Invoke(args); if (callback != null) { CallbackMethod method = new CallbackMethod(callback, result); Instance.CallbackToMainThread(method); } }); } public static AsyncExecutor Instance { get; private set; } private ConcurrentQueue<CallbackMethod> callbackQueue; private void Awake() { callbackQueue = new ConcurrentQueue<CallbackMethod>(); Instance = this; DontDestroyOnLoad(gameObject); } /// <summary> /// 将回调加入主线程队列 /// </summary> /// <param name="method"></param> public void CallbackToMainThread(CallbackMethod method) { callbackQueue.Enqueue(method); } private void Update() { if (callbackQueue != null) { // 每帧执行固定数量的回调 for (int i = 0; i < ASYNC_CALLBACK_TO_MAIN_THREAD_PER_FRAME; i++) { if (callbackQueue.Count > 0) { CallbackMethod method; if (callbackQueue.TryDequeue(out method)) { method.callbackFunc?.Invoke(method.callbackParam); } else { break; } } else { break; } } } } /// <summary> /// 子线程输入参数 /// </summary> public class InputParam { public object[] args; public InputParam(int count) { args = new object[count]; } } /// <summary> /// 子线程输出结果 /// </summary> public class OutputParam { public object result; } /// <summary> /// 回调方法结构 /// </summary> public class CallbackMethod { public CallbackFunc callbackFunc; public OutputParam callbackParam; public CallbackMethod(CallbackFunc func, OutputParam param) { callbackFunc = func; callbackParam = param; } } } // 调用方式如下 AsyncExecutor.ScheduleAsyncTask(function, args, callback);
将该组件挂载到游戏对象上之后即可使用。
简化多线程异步调用
多线程异步看起来比协程要复杂许多,实际使用中更是有大量可能出现的问题需要注意,光是理清多线程调用时哪些代码是在子线程中运行,哪些代码是在主线程中运行就很费工夫。
关键是还不能不区分清楚,因为子线程里运行涉及游戏对象的代码时Unity会报错,这给多线程的使用带来的不小的障碍。
关于这个问题,C#的另一特性将有所帮助,那便是async和await关键字,它们的功能并不相同,但几乎总是一同出现,因此放在一起理解才比较合适。
这两个关键字的主要作用便是简化异步线程的调用和回调过程,它们依然是一种语法糖,隐藏在背后的还是子线程和回调那一套,只是让调用过程看起来更加简单了而已。
在使用这个语法糖之前,首先要了解相关的背景知识,也就是C#的异步工具之一Task组件。
Task组件在线程池的基础上进一步优化的结果,提供了比线程池更丰富的控制接口,使得它在多线程方面比线程池更加优秀。
简单的使用Task代码如下
// 直接创建 Task task = new Task(() => { // 异步工作代码 }); task.Start(); Task.WaitAll(task); // 静态方法启动 Task task = Task.Run(() => { // 异步工作代码 }); Task.WaitAll(task); // 工厂方法 Task task = Task.Factory.StartNew(() => { // 异步工作代码 }); Task.WaitAll(task);
需要注意的是WaitAll方法是同步阻塞的调用模式,因此在所有的Task没有完成之前都会阻塞当前线程,为了避免这种情况可以设置等待超时或者采用WhenAll设置异步回调。
而async和await关键字就是在Task组件的基础上进一步简化了调用方式的语法糖,其中async关键字仅用于修饰方法体,而且被修饰的方法体中必须有await关键字的存在。
至于await关键字则是用在Task类型的变量之前,表示对指定的Task进行异步等待的,两者配合实现异步线程和回调的方式如下。
// 异步方法 private async void StartAsyncTask() { Debug.Log("Before Async Task Start"); await Task.Run(() => { // 异步工作代码 }); Debug.Log("After Async Task Start"); } // 直接调用即可实现异步功能 StartAsyncTask();
实践表明,await关键字将所在的方法拆分成了两个部分,关键字之前的代码会在调用时立刻运行,随后Task启动并在子线程内执行,方法直接返回并执行主线程后面的代码。
直到Task运行结束后,会自动回调一个方法,方法内即是此前被await关键字拆开的下半部分,换言之这部分代码将在主线程上执行。
无参数的Task看起来只是简化了调用,但Task和await关键字的配合还有更加方便的使用场景,那便是有返回值的Task。
示例如下
Text text; // 异步方法 private async void StartAsyncTask() { text.text = "Before Async Task"; string result = await Task.Run(() => { string ret; // 异步工作代码 return ret; }); text.text = result; } // 直接调用即可实现异步功能 StartAsyncTask();
带返回值的Task可以用await修饰后直接将返回值给到主线程,因此后续可以将返回值赋给主线程才能操作的组件或者游戏对象。
这种异步方法可以用于异步加载或者数据处理,然后很方便地在主线程中展示结果,极大简化了开发者进行异步多线程的操作。
Unity的JobSystem
作为和ECS一同推出的Unity官方异步多线程工具,JobSystem的使用并不如普通的多线程或者async和await那么方便,它有自己的一套开发和运行规则,因此学习成本会稍微高一些。
比如说JobSystem中只能使用值类型或者Native系列数据集合,这是由于IJob接口的使用规范要求每个单独的Job都只能是struct,由此引出的各种使用不便是需要时间和成本去克服的。
关于JobSystem的使用,参考官方文档是最佳的学习途径。
总结
异步多线程在游戏开发中是个值得关注和投入时间学习的工具,它不仅能解决很多运行效率方面的问题,同时也为很多特殊的游戏开发需求提供了解决方案。
暂无关于此日志的评论。