zoukankan      html  css  js  c++  java
  • 深入解读Job System(1)

    https://mp.weixin.qq.com/s/IY_zmySNrit5H8i0CcTR7Q

    通常而言,最好不要把Unity实体组件系统ECS和Job System看作互相独立的部分,要把它们看作用于大幅提升游戏性能的组合系统。

    本系列文章我们将深入了解使用二者开发项目的过程,从而使项目获得高性能。今天我们来了解ECS和Job System的基础知识,了解ECS请阅读:《详解实体组件系统ECS》。

    什么是Job System

    一些人认为Unity无法进行多线程处理,那个观点是错的,因为这是可以实现的,但是你可能无法使用任何Unity中特定的命名空间。你可以多线程处理不同类型的任务,只要任务不需要在主线程外访问Transform或游戏对象即可,所以在独立线程执行一些Vector3数学运算是没有问题的。

    如果你非常了解Unity相关知识,或许你已经知道引擎的部分功能已经实现多线程处理。现在加入Job System后,Unity允许我们利用它的多线程处理功能。

    Job System允许我们轻松编写多线程代码,从而实现高性能游戏体验。它不仅能改善帧率,而且在做移动开发时,它还能显著改善移动设备的电池寿命。

    通过该功能,我们能够编写和Unity引擎功能共享工作线程的代码。

    什么是多线程处理

    通常在单线程程序中,每次只处理一个执行调用,一次只输出一个结果。

    程序性能主要取决于加载和完成所用的时间。单线程会按线性顺序进行处理,需要的时间会比双线程同时处理更长,这种多个线程同时处理就是我们说的多线程处理。

    多线程处理会利用CPU功能来同时在多个内核处理多个线程。

    默认情况下,“主线程”会在程序开始时运行。主线程会创建新线程来处理任务。这些新线程会并行运行,通常在完成后将结果与主线程同步。

    多线程处理方法适合用来处理多个需要长时间运行的任务。然而,游戏开发代码通常带有很多需要同时执行的小指令。如果为每个小指令都创建一个线程,结果会得到很多线程,每个线程的生命周期都很短。从而导致CPU和操作系统处理能力达到极限。

    你可以通过线程池来解决线程生命周期的问题,然而即使使用线程池,还是会同时有很多活动线程。如果线程数量比CPU内核数量多,会造成线程互相竞争CPU资源,并且频繁切换上下文(Context switching)。

    上下文切换是指切换线程时,会保存当前进程的执行状态,然后处理另一线程,在重构第一个线程后,继续处理该线程。上下文切换是个资源密集型过程,所以要尽量避免该过程。

    Job System和传统多线程的区别

    在多线程处理时,要打开线程然后提供任务。你需要注意将辅助线程合并到主线程的时间,还要正确关闭线程。所以多线程处理需要你管理很多操作。

    Job System使用不同的方法,因为我们不会创建任何线程,而是会使用Unity在多个内核上的工作线程,给它们提供任务-Unity称之为Jobs作业。很容易看出,这种方法更为简单,因为避免了管理线程时可能遇到的问题。不仅如此,我们还不必担心出现竞态条件。

    通过内置的安全检查,Job System可以检测所有潜在的竞态条件。通过给每个作业发送需要处理的数据副本而不是在主线程引用数据,Job System可以避免发生竞态条件,进而消除竞态条件,因为现在处理的是独立数据而不是它的引用。

    因此,作业只能访问blittable数据类型。当在托管代码和本地代码之间传递数据时,该类型数据不需要转换。

    Unity使用C++方法复制的内存块在Unity的托管部分和本地部分复制和传递数据。在调度作业时,我们会将数据放入本地内存,并在执行作业的同时允许托管部分访问数据副本。

    你甚至不必担心发生上下文切换和CPU争用,因为Unity通常在每个CPU内核有一个工作线程,作业会在这些线程间同步调度。

    Job System中,所有作业都会放入队列中。空闲工作线程会获取作业,并按照队列的顺序执行。为了确保作业按照所需顺序执行,我们可以利用作业依赖。

    Job是什么

    总的来说,每个作业(Job)都可以看作是方法调用,每个作业在创建时会得到数据和参数,之后用于执行过程。作业可以是独立的,这意味着当它们什么时候完成对我们来说并不重要。或者在更合理情况下,它们可以拥有依赖。依赖能为我们带来便利,因为它能让代码在正确的时间执行。

    对多线程处理来说,这非常重要,你需要确保执行过程能避免发生竞态条件,这意味着一项任务不必等待其它任务完成才执行,那样会造成延迟。

    所以基本上,依赖意味着我们的第二个任务依赖于第一个任务,第二个任务会在第一个任务完成后才开始执行。

    句法

    每个作业都需要实现以下三个类型的其中一个类型:IJob、IJobParallelFor或 IJobParallelForTransform。

    IJobParallelFor用于需要多次并行执行单个任务的作业。JobParallelForTransform和IJobParallelFor差不多,尤其是用于处理Unity Transform时。

    这些类型实际上都是接口,因此只要脚本中没有Execute函数,编译器就会出问题。还要记住,作业必须是nullable类型,这意味着它必须是struct,并且在任何情况下都不能是类,这是因为内存分配问题。

    Unity创建新容器是为了让我们能够很容易就写出线程安全的代码。

    using Unity.Collections;
    using Unity.Jobs;

    /*作业(Job)需要是可空类型,这意味着它们必须为struct结构…

    每个作业都必须继承自IJobParallelFor、IJobParallelForTransform或IJob*/

    Every job has to inherit from either IJobParallelFor, IJobParallelForTransform or IJob */
    public struct MyJob : IJobParallelFor {

    /*在作业中,需要定义所有用于执行作业和输出结果的数据

    Unity会创建内置数组,它们大体上和普通数组差不多,但是需要自己处理分配和释放设置*/

     public NativeArray<Vector3> waypoints;
     public float offsetToAdd;

    /*所有作业都需要Execute函数*/

     public void Execute(int i)
     {

      /*该函数会保存行为。要执行的变量必须在该struct开头定义。*/

       waypoints[i] = waypoints[i] * offsetToAdd;
     }
    }

    调度作业

    现在已创建MyJob.cs struct,要如何使它工作呢?我们必须调度它。

    通常该过程非常简单,但需要注意,每个作业都需要被调度。那意味着我们首先发起作业,添加数据,然后发送到队列中等待执行。一旦该过程发生,我们就无法中断该过程。

    Unity提供的常见句法参考中的作业代码如下:

    // 创建单个浮点数的本地数组(NativeArray)来存储结果。为了更好说明功能,该示例会等待作业完成。

    NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

    // 设置作业数据

    MyJob jobData = new MyJob();
    jobData.a = 10;
    jobData.b = 10;
    jobData.result = result;

    // 调度作业

    JobHandle handle = jobData.Schedule();

    // 等待作业完成

    handle.Complete();

    //NativeArray的所有副本都指向相同内存,你可以在NativeArray的副本中访问结果。

    float aPlusB = result[0];

    // 释放结果数组分配的内存

    result.Dispose();

    这些正确的代码,它可以正常执行,但带有一些缺点,因为在调度完成后进行完成调用会产生短暂的等待时间,在性能分析器中,该时间称为“Idle Time”。

    相反如果你习惯调度作业,性能分析器中显示的等待时间将最小化,而且会得到不错的性能,至少在旧机器上效果会很明显。

    高效调度作业

    在调度作业后,因为工作线程没有时间完成任何任务。这造成在调度调用期间会产生空闲时间,会对性能产生影响。

    本示例中,我们会创建struct,保存对句柄和本地数组的引用。为什么保存这些内容?

    保存句柄是为了在之后调用作业,保存本地数组是因为需要释放本地数组,NativeArray和常规数组的工作方式差不多,但是需要设置Allocator,用来定义数组在内存中的保留时间,本示例中使用Allocator.TempJob。

    我们还需要在调用完成时释放内存,然后复制数据。我们创建了JobResultAndHandle的引用,然后对它调用ScheduleJob()。这会使我们的作业开始调度,而且它的引用会保存在列表中。

    然后我们可以查看列表中的每个条目,调用完成,复制执行数据,然后弃用NativeArray来释放内存。

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using Unity.Collections;
    using Unity.Jobs;

    public class MyJobScheduler : MonoBehaviour 
    {
     Vector3[] waypoints;
     float offsetForWaypoints;

      //我们将保存结果和句柄的列表

     List<JobResultAndHandle> resultsAndHandles = new List<JobResultAndHandle>();

     void Update() 
     {

    /*我们会在需要时创建新的JobResultANdHandle(该代码不必在Update方法中,因为它只是个示例)

    然后我们会给ScheduleJob方法提供引用。*/


       JobResultAndHandle newResultAndHandle = new JobResultAndHandle();
       ScheduleJob(ref newResultAndHandle);

       /*如果ResultAndHAndles的列表非空,我们会在该列表进行循环,了解是否有需要调用的作业。*/

       if(resultsAndHandles.Count > 0)
       {
         for(int i = 0; i < resultsAndHandles.Count; i++){
           CompleteJob(resultsAndHandles[i]);
         }
       }
     }

      /* ScheduleJob会获取JobResultAndHandle的引用,初始化并调度作业。

     void ScheduleJob(ref JobResultAndHandle resultAndHandle)
     {

        //我们会填充内置数组,设置合适的分配器

       resultAndHandle.waypoints = new NativeArray<Vector3>(waypoints, Allocator.TempJob);

       //我们会初始化作业,提供需要的数据

       MyJob newJob = new MyJob
       {
         waypoints = resultAndHandle.waypoints,
         offsetToAdd = offsetForWaypoints,
       };

      //设置作业句柄并调度作业

       resultAndHandle.handle = newJob.Schedule();
       resultsAndHandles.Add(resultAndHandle);
     }

      //完成后,我们会复制作业中处理的数据,然后弃用弃用内置数组

      //这一步很有必要,因为我们需要释放内存

     void CompleteJob(JobResultAndHandle resultAndHandle)
     {
       resultsAndHandles.Remove(resultAndHandle);

       resultAndHandle.handle.Complete();
       resultAndHandle.waypoints.CopyTo(waypoints);
       resultAndHandle.waypoints.Dispose();
     }
    }

    struct JobResultAndHandle
    {
     public NativeArray<Vector3> waypoints;
     public JobHandle handle;
    }

    JobHandles和依赖

    对作业调用Schedule()会使它返回JobHandle。JobHandle对保留作业的引用非常有用,但也可以将它们用作其它作业的依赖。这是什么意思呢?

    如果某个作业依赖其它作业的结果,我们可以将其它作业的句柄作为参数传递到myjobs调度方法中,这样能让该作业完成后执行我们的作业。

    前文中提到的竞态条件问题、线程等待线程的问题,以及使用多线程代码的缺点问题都可以通过传递句柄来轻松避免。

    小结

    本文我们了解了Job System的基础知识,在下一篇中我们将以网格变形项目为示例,讲解Job System的使用,尽请期待!更多Unity最新功能介绍尽在Unity官方中文论坛(UnityChina.cn)!

    本文来源:http://www.itskristin.me/

  • 相关阅读:
    JDBC 复习4 批量执行SQL
    JDBC 复习3 存取Oracle大数据 clob blob
    Oracle复习
    Linux命令(1)grep
    JDBC 复习2 存取mysql 大数据
    JDBC 复习1 DBUtil
    php 环境搭建问题
    Windows 批处理 bat 开启 WiFi 菜单选项 设置ID PWD
    Bat 批处理启动和停止Oracle 服务
    docker 学习1 WSL docker ,Windows docker
  • 原文地址:https://www.cnblogs.com/nafio/p/9794275.html
Copyright © 2011-2022 走看看