C# job system in Unity

What is job?

In c# job system, a job is a small amount of work that executes on data. It is just like a simple method call in c# which received parameter and work on some data. There are basically two types of jobs.

  • Self-Contained job
  • Dependant job

A self-contained job does some amount of work on data and completed, but it is not always true, sometimes we need some job to finished its task on data and then this data used for the second job for executes its task. Let’s say if JobX is depended on JobY then job system ensure that JobX executes after JobY completed.

Before you proceed further, think about the safety of your code that uses memory effectively and gives you the highest performance benefits. so let’s first check what should we remind during c# job system code writing.

How job system provides safety?

Most of the developers who have work with multi-threaded code know that writing thread-safe code is difficult and in extremely rare cases race condition can occur. A race condition can be hard to find because of its indeterministic behavior and timing. One more issue occurs in the thread code is context switching. This is resource intensive so we have to avoid whenever possible.

The C# job system can provide safety from race condition and avoiding context switching. Each job can only access blittable data types. Every job needed some data to work on, so the c# job system copies the data and sends it to the job. Now job has a separate copy of data to execute some work on that. This will avoid the race condition and context switching. Unity will throw an exception when it detects any race condition in the editor. In other words, you can saw the full exception details in th console.

How to avoid these exceptions?

You should do two things to avoid these exceptions is, completing one job before starting another or make job dependent to another job.

How C# job system differ than creating threads?

As we have discussed earlier, writing a thread safe code is difficult and it has there own challenges. To overcome these concerns we have used JOB SYSTEM. As we know unity creates the thread for every logical core in the system, by means that main thread is working on one core, graphics thread on another and all others are working threads running on the remaining cores.

Everything we have work in unity is running on the main thread. We have only one thread to run our monobehaviours, co-routine, I/O operations, and all other things. But now c# job system provides the way to utilize the worker thread by running job in it.

Basic data structures used in the c# job system

Native containers

Native containers are manage value type(struct) structure used to store a pointer to an allocate unmanageable memory, which directly accesses native memory through a safe c# wrapper. This native memory is not garbage-collected, so it’s the developer duty to call dispose() method of native containers when the job is finished

Types of native containers

  • Native Array
  • Native Slice
  • Native List
  • Native Hash Map
  • Native Multi Hash Map
  • Native Queue (FIFO)

Native container allocater

When creating native containers, we have to choose how this container allocate memory based on its usage. We have three types of memory allocaters.

  • Allocator.Temp : Fastest allocation among all.
  • Allocator.TempJob : Slower allocation.
  • Allocator.Persistent : Slowest allocation.
Example : 
// a Temp array of 15 floats
NativeArray<float> result = new NativeArray<float>(15, Allocator.Temp);

Define a Job

It is much similar to function but as a struct.

public struct MyFirstJob
{
}

You define your very first job, now decide what type of job is this.

  • IJob: A very simple job that executes only once.
  • IJobParallelFor: Job that executes on a range of values.
  • IJobParallelForTransform: A job with transform access.
public struct MyFirstJob : IJob
{
    public void Execute()
    {
    }
}

Every job need some input and output for its execution.

public struct MyFirstJob: IJob
{
    public float number;                  // a blittable type
    public NativeArray<float> data;     // a native container

    public void Execute()
    {
       data[0] += number; //Visible outside the job
    }
}
public struct MyFirstParallelJob: IJobParallelFor
{
    public float number;                  // a blittable type
    public NativeArray<float> data;     // a native container

    public void Execute(int index)
    {
       data[index] += number;
    }
}
public struct MyFirstTransformJob: IJobParallelForTransform
{
    public float number;                  // a blittable type
    public NativeArray<float> data;     // a native container

    public void Execute(int index,TransformAccess transform)
    {
       transform.position += Vector3.One * (data[index] + number);
    }
}

Create a Job

Creating job is much similar to create a struct. You can create job in main thread.

float myNumber = 10;
NativeArray<float> myData = new NativeArray<float>(1, Allocator.TempJob);
myData[0] = 2;
MyFirstJob simpleJob = new MyFirstJob()
{
   data = myData;
   number = myNumber;
};

MyFirstParallelJob parallelJob  = new MyFirstParallelJob()
{
   data = myData;
   number = myNumber;
};

MyFirstTransformJob transfromJob = new MyFirstTransformJob()
{
   data = myData;
   number = myNumber;
};

Scheduling a Job

Scheduling a job puts it in the job queue system, which execute on the worker thread.

//Schedule a job
JobHandle jobHandle = simpleJob.Schedule();

Schedule job is ready to begin. We get a job handle after scheduling a for future references. We can also apply the job dependency to another the by schedule method.

For example... job2 will not run until job1 complete
JobHandle jobHandle1 = job1.Schedule(); //Schedule with no dependacy
JobHandle jobHandle2 = job2.Schedule(jobHandle1); //Schedule with dependancy
// Schedule a Parallel job
JobHandle handle = parallelJob.Schedule(myData.Length, 1);
// Schedule a Transform job
// Get or create a managed array of Transform objects
Transform[] transforms = new [] { transform };
 
// Wrap the managed array in a TransformAccessArray
TransformAccessArray myArray = new TransformAccessArray(transforms);
 
// Schedule with no dependencies
JobHandle jobHandle1 = transformJob.Schedule(myArray);
 
// Schedule with dependencies
JobHandle jobHandle2 = transformJob.Schedule(accessArray, jobHandle1);

All of above will run on the main thread, for executes a job in worker thread we have to call following code.

// Job started executing in the worker thread
JobHandle.ScheduleBatchedJobs();

After scheduling the job, looks for the next frame to check if its complete or not. To do so,

//...next frame or later in the main thread
JobHandle.Complete();

After finishing a job, save the job result for your reference and delete the job data from memory.

// Save
float val = simpleJob.data[0];
//Dispose
myData.Dispose();

A job is operated on the worker thread rather than the main thread, so any static data access inside job may crash unity.

Handle Complex dependancy

Meanwhile you noticed that you can schedule dependency of only one job to another.

// dependancy order => x -> y -> z
// x is not run until y completed and y is not
// run until z is finished.
JobHandle x = jobX.Schedule();
JobHandle y = jobY.Schedule(x);
JobHandle z = jobZ.Schedule(y);

We know that Schedule() method can only take a single handle to make dependency. Some job may depend on more than one job. So, we have the option to Combine these dependencies to one handle and schedule it.

JobHandle x = jobX.Schedule();
JobHandle y = jobY.Schedule();
JobHandle z = jobZ.Schedule();

JobHandle combined = JobHandle.CombineDependencies(x, y, z);

// A will run after X, Y, and Z complete.
JobHandle a = jobA.Schedule(combined);

You can handle more complex dependency by combine two job that depends on one job or vice versa.

Conclusion

As a result we conclude that IJob is run on a single frame and execute a simple task and other jobs are run and divided into chunks to run concurrently to use all possible core of the system. This system gives our project a high performance and optimized reusable code. You can use the job system with classic monobehaviour system and new ECS system. We will deep dive into c# job system in the future post with some examples.

Leave a Reply