Unite 2017 | Unity中的异步编程技术详解(附演讲视频)

发表于2017-06-22
评论0 4.8k浏览

异步编程技术对于很多手游开发者来说,都是不可避免的话题,因为手游的游戏逻辑包含太多需要并发或者希望能够并行的逻辑。现在的手机硬件发展迅速,多核已成为主导趋势,对于3A级大作来说,如何充分利用手机多核的性能从而解放主线程压力就显得尤其重要。本文将由Unity技术支持工程师刘伟贤,为大家分享Unity中的一些异步编程技术,来释放硬件的潜能。


欢迎观看演讲视频来了解Unity中的异步编程技术(视频时长33分钟,流量党请注意):



关于多线程

其实众多的Unity游戏性能负荷集中在主线程。从Unity 5.4开始已将大量的Camera.Render工作从主线程当中移除,但这还远远不够。


 

我们为PS 4平台添加了其它Worker Thread对原生渲染的支持,这意味着渲染请求可以直接调用底层原生的渲染API,而不需要再与Unity的渲染线程进行交互。这可以大幅提高游戏的渲染效率,某些情况下游戏的实际帧率可以提高到原来的2.5倍之多。

 

当然,Unity还会持续添加对其他平台原生渲染API的支持。



在最新的Unity 5.6中,我们加入了对Vulkan的支持。目前Vulkan仅支持Windows Standalone、Linux Standalone和Android平台,而Unity编辑器暂时还不支持Vulkan。


关于Unity Job System(Worker Thread)

Unity会致力于把一些计算量比较重的系统挪到其他的工作线程上去,例如粒子系统、动画系统、布料计算、遮挡剔除、视锥体剔除、蒙皮和静态裁剪等等。Unity Job System就是为了更好地让这些底层系统能够在多线程下安全高效的并行运作。也就是我们经常在Profiler底下看到的多个Worker Threads。

 

下面来看一个具体的例子。



可以看到在Particle System的运行过程中,有一个渲染线程叫PutGeometryJobFence,它用于安排各个工作线程进行各个粒子的GeometryJob,等所有的GeometryJob完成以后就会进行渲染。

 

从上面的例子可以看出,Unity内部的Job System是基于任务式的高效安全的多线程系统,非常高效且安全,它基于无锁栈与无锁队列构建这个系统,通过汇编来编写,针对不同的CPU有不同的实现。所以,高度优化的Jobs之间存在一定的依赖关系,并且会根据主线程的需要去调整它们的优先级

 

下面来看一个例子。当我们为Woker Thread安排了一个动画的Job,但如果这时通过脚本去删除Animator和Transform组件,就需要马上完成所有的Jobs并且将Animator/Transform数据的处理完成。


关于异步操作

Unity还提供了一系列的异步操作,方便大家根据需要进行选择。这些异步操作包括Resources的加载、Asset Bundle的加载、场景的加载,NavMesh的生成等等。当然,这些异步操作的底层实现各不相同。有些是独立线程处理的,有些则是利用Unity内部的JobSystem,也有一些是二者结合使用的。 


上图的示例中,调用AssetBundle.LoadFromFileAsync时马上创建一个AsyncOperation,然后马上交由Job System去处理文件的IO相关操作,等Job System处理完之后就交由PreloadManager去处理Asset Bundle的加载相关操作,而PreloadManager就是叫“UnityPreload”的独立加载线程,而且它是基于队列式的加载管理。

 

协程

协程并非多线程,它还是在主线程上执行的,但协程可以将一个函数分成多个部分来顺序执行,从而实现等同并发的处理方式。



使用协程时要特别注意它们的调用时机。 C#编译器会帮我们创建一个协程的类,而在开启一个协程时就会创建对应的对象,这个对象用来维护多次调用时协程的状态。正是要维护这些状态,所以协程内的本地变量也需要放到堆上,启动一个Coroutine所引起的内存消耗等同于一个类的固定成本加上这个 Coroutine所用到的局部变量总内存。而协程的生命周期就是跟着MonoBehaviour来走的

 

需要注意的是:

  • 为了减少主线程的CPU开销,需要避免在协程内进行一些阻塞的操作;

  • 在协程内分配的资源要在协程结束以后才会释放,所以不要在协程内循环分配资源;

  • 尽可能使用最少的协程数去完成最多的操作;

  • 使用巢状式的协程有助于保持代码简洁且易于维护,但它们也比较容易导致较高的内存开销。

 

线程

首先区别于协程,线程是并行的,是利用了硬件的多核特性。而协程是并发的,它其实还是在主线程执行。C#的线程是基于.Net的实现,Mono进一步细分操作系统的进程到一个轻量级的托管子进程,这就是AppDomain。而在AppDomain中又可以存在一个或者多个的托管线程,也就是Sytem.Threading.Thread。

 


  • 托管线程并不需要映射到单独的原生线程上;

  • 原则上,托管线程就是虚拟的;System.Threading.Thread.CurrentThread.ManagedThreadId返回的是托管线程ID,ID是稳定的;

  • System.AppDomain.GetCurrentThreadId()返回的是原生线程ID,ID是不稳定的。

 

线程相关的一些老生常谈


数据冲突 - 不同步地去访问共享数据就会带来数据冲突,可以通过加锁来保护数据,但是锁的开销不小。



锁(Lock) - 不能是值类型,因为值类型在每次装箱时都是不同的对象,所以根本没有办法保证锁的有效性。


死锁 - 两个或两个以上的线程在执行过程中,竞争资源造成了阻塞。可以通过破坏他们产生的四个条件,或者利用诸如银行家算法等来避免。



考虑使用无锁的数据结构设计导致线程饿死 - 避免饿死就应该采用队列的方式,保证每个线程都有机会获得请求的资源。 当然实现方式可以有很多变化,比如优先级、时间片等,都是“队列”的特殊形式。


C# Job System

写好线程安全的代码并非易事,而C# Job System就用于帮助大家解决一切痛点,它将Unity底层的Job System开放给C#层的使用,所以具备Unity Job System的特性,基于任务式的高效安全的多线程和多任务之间可以存在依赖关系。



编码风格和接口声明:


 

除此以外C# Job System还提供了对线程安全相关的检查及报错提示机制,确保开发者在开发过程中安全和高效。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引