C# Job System
以下内容为笔者人工翻译,部分理解可能会有偏差,欢迎指正!
NativeContainer相关内容,请参考笔者另一篇译文:https://mp.csdn.net/console/editor/html/105369322
overview
Unity C#Job 允许使用者编写简单易用的多线程代码,并且提供了和Unity本身良好的交互。
编写多线程代码可以提供很好的性能表现,比如非常重要的帧率表现,使用Job的BurstCompile可以生成更高质量的代码,并且可以在移动设备上减少电量的持续消耗。
一个很重要的地方是,job系统整合了Unity的内部资源(Unity本地的工作系统),业务代码和Unity共享工作线程。这个合作避免了创建大于内核数量的线程,从而避免了cpu资源的争夺。
什么是Job System?
Job System通过创建一个Job而不是线程来管理多线程代码,Job System在多个内核之间管理工作线程,通常一个cpu内核有一个初始线程,来避免线程上下文的切换。
Job System将多个Job放入一个工作队列去执行,工作线程从这里获取并执行他们,这个系统同样需要管理他们的依赖,并确保jobs按照合适的顺序执行。
what is a job?
Job是一个很小的工作单元,接收参数并操作数据,类似一个函数方法的执行行为,job可能是独立的,也可能依赖于其他job的执行结果。
什么是job之间的依赖?
在复杂的系统里,比如游戏开发的需求,通常job并不会是独立的,一个job通常是为另一个job准备数据,Job系统支持了这个特性,如果任务a依赖于任务b,系统会保证他们有正确的执行顺序。
C# Job System里的安全系统
当你编写多线程代码的时候,通常会有Condition Race的风险,当一个操作的输出依赖于其控制范围之外的另一个进程的执行时间时,就会出现Condition Race。
Condition Race通常不算是bug,但是是一种不确定的行为,当一个Condition Race引发一个bug,由于时间的不确定性,将会很难发现bug的真正原因,因为只能依赖Condition Race才能重现bug。在代码中debug可能导致问题莫名消失,因为断点调试和log会改变线程的时间节点,Condition Race大大增加了多线程开发的难度。
安全系统
为了让多线程开发变得简单,C# job system检测所有潜在的Condition Race,并且规避可能导致的bug。
比如:当在你的主线程代码中向job发送了一个数据的引用,此时无法查证当主线程在读取数据的同时,是否有job在尝试写入,此时便产生Condition Race。
C# JobSystem通过发送每个job一份操作数据的拷贝,而不是主线程数据的引用,来解决Condition Race问题。这份拷贝隔离了数据,因此得以解决Condition Race问题。
Job System需要拷贝数据,意味着job只能访问blittable的数据类型,这些数据类型在本地和托管代码中不需要转换。
job system可以使用memcpy来拷贝blittable的数据,并且在unity的本地堆和托管堆之间转换,他在调度job的时候,使用memcpy来将数据放入本地内存,并赋予数据的管理权限。
创建Jobs
在Unity中创建一个Job的话,需要实现IJob接口,IJob可以让你的Job和其他正在运行的Job同时执行,tips:job是指Unity中任何实现IJob接口的结构。
通过以下步骤创建Job:
1. 创建一个实现IJob的结构
2. 添加需要的成员变量(blittable type或NativeContainer类型)
3. 在你的结构中创建一个Excute方法,来实现Job需要的功能
执行Job的时候,每个内核执行一次Excute方法,注意:设计Job的时候,谨记他们是在操作一份数据的copy,除非使用NativeContainer。因此在主线程中访问数据的唯一办法是写入nativeContainer。
调度Job
通过以下步骤在主线程中调度一个Job:
1. 实例化Job
2. 添加Job数据
3. 调用Schedule方法
调用Schedule方法会将该Job放入Job队列,在适当的时间执行,一旦被调度好之后,就没办法被打断。
注意:只能在主线程中执行Schedule方法
JobHandle和依赖:
Job的Schedule方法会返回一个JobHandle,这个HobHandle在代码中可以作为其他Job的依赖,如果一个Job依赖另外一个 Job的执行结果,可以将第一个job的handle作为第二个job的schedule方法的参数。
合并依赖:
如果一个job有多个依赖,可以用JobHandle.CombineDependencies方法去合并,该方法可以让你把多个依赖作为 Schedule方法的参数。
在主线程中等待Jobs
在主线程使用Jobhandle等待Job执行完成,需要执行JobHandle的Complete方法,因此主线程可以安全的访问Job的NativeContainer数据。
注意:Jobs不会在你Schedule的时候立刻执行,如果在主线程中等待Jobs执行完毕,并且需要访问NativeContainer的数据,可以调用JobHandle.Complete方法,这个方法会清空内存中缓存的所有Job并开始执行进程,调用JobHandle.Complete会返回给主线程该Job的NativeContainer所有权,你需要在主线程中执行handler的Complete方法来安全的访问数据。还有一种使用依赖关系的方式来获取Jobs的NativeContainer的所有权,比如,你可以直接调用JobA的Complete方法,或者调用依赖JobA的JobB的Complete方法,两种结果都能达到一样的结果。
此外,如果不需要读写数据,但是需要直接清空合并的Job依赖,可以调用JobHandle.ScheduleBatchedJobs静态方法,但是注意,这个方法的调用会对性能有负面的影响。
C#Jobs系统注意事项和排错
使用Unity的Job System,需要注意以下几点:
不要从Job中访问静态数据
从Job中访问静态数据,会绕过所有的安全系统(Safety System),如果你访问了错误的数据,很可能导致Unity莫名崩溃,比如,在domain reloads的时候访问Monobehaviour会导致崩溃。
注意:由于这个风险,未来的Unity版本会通过静态分析的方式避免Jobs全局变量的访问,如果你在一个Job中访问静态数据,需要意识到你的代码可能会在未来的Unity版本中挂掉。
清空调度好的合并任务
如果你想让你的任务开始执行,可以使用JobHandle.ScheduleBatchedJobs方法,注意:该方法的调用可能会导致性能问题,不去清空合并的任务会导致任务调度延迟,直到主线程获得执行结果,任何其他情况,使用JobHandle.Complete来开始处理流程。
注意:Unity ECS系统已经隐式处理好了,因此没必要调用JobHandle.ScheduleBatchedJobs
不要尝试直接修改NativeContainer中的内容
由于没有办法返回引用,因此不可能直接修改NativeContainer的内容,比如 nativeArray[0]++,或者var temp = nativeArray[0]; temp++,不会真正修改NativeArray的内容。
相反,你必须根据索引拷贝数据到一个临时的内存,修改这份拷贝,并重新保存回去,如下:
MyStruct temp = myNativeArray[i];
temp.memberVariable = 0;
myNativeArray[i] = temp;
调用 JobHandle.Complete 来重新获得操作权限
主线程需要等待Job依赖执行完毕才能重新获得线程使用权限,不能仅仅使用JobHandle.IsCompleted来判定,必须调用Jobhandle.Complete来重新获取NativeContainer的操作权限,Complete方法的调用同样会清除Safe System的状态,不这么做会导致内存泄漏。每一帧调度一个依赖于上一帧的job同样会发生这种情况。
在主线程中使用 Schedule和Complete
你只能在主线程中调用Schedule和Complete方法,如果一个Job依赖另一个Job,使用JobHandle来管理依赖,而不是在Job中调度Job
在正确的时机使用Schedule和Complete
在你获取到一个Job的数据之后,尽可能快的调用他的Schedule方法,并且在你只在你需要结果的时候再调用Complete方法,在不需要等待且和其他Job不存在冲突的时候尽早调度你的Job,是一个很好的习惯。例如:如果在一帧的结束和下一帧的开始之间有一段时间没有Job在运行,并且可以接受有一帧延迟,那么可以将Job在一帧的结束时调度,并在下一帧中使用它的结果。或者,如果你的游戏将这段转换时间用在了其他Job上,但是在当前帧的其他地方还有一段未被充分利用的时间,那么在那里调度你的工作将会更有效率。
把NativeArray类型设置为只读
需要注意的是,Jobs默认拥有NativeContainer的读写权限,在合适的地方使用[ReadOnly]标签来提升性能表现
检查数据依赖
在Unity的Profiler窗口,WaitForJobGroup标记表示主线程在等待一个工作线程的Job执行结束,这个标记表明你生成了一个你需要解决的数据依赖,通过JobHandle.Complete方法来跟踪哪里导致了你的主线程被强制等待依赖的数据。
Debuging Jobs
Job有一个Run方法,可以在主线程中替代Schedule方法来立即执行job,可以用这个来调试job
不要在Job中分配托管内存
在Job中分配托管内存会非常非常慢,而且Job系统不能使用BurstCompile来提升代码性能,Burst是一个基于LLVM的编译后台,让很多事情变得简单,通过Jobs系统生成高度优化的机器编码在各个平台上获得良好的性能表现。