Files
Misaki.HighPerformance/docs/documents/articles/Misaki.HighPerformance.Jobs/creating-jobs.md

8.1 KiB

Creating and Scheduling Jobs

To create and run a job, you must:

  1. Define a struct or class that implements one of the job interfaces.
  2. Schedule the job on a JobScheduler instance.
  3. Wait for the job to complete before accessing its results.

Job types

Type Description
IJob A single job that runs once on one worker thread
IJobParallelFor A parallel job that runs Execute once per index
IJobParallel A parallel job that runs Execute once per range segment
ICustomJob<TSelf> A job with user-defined execution and cleanup function pointers

Managed and unmanaged jobs

Jobs can hold both unmanaged data (pointers, primitive types, blittable structs) and managed data (arrays, List<T>, strings, class references). The job scheduler copies the job data into an internal pool at schedule time and frees it after completion.

// Managed job: holds an array reference
public struct ArraySumJob : IJob
{
    public int[] data;     // managed array
    public int* result;    // pointer to output

    public void Execute(ref readonly JobExecutionContext ctx)
    {
        int sum = 0;
        for (int i = 0; i < data.Length; i++)
            sum += data[i];
        *result = sum;
    }
}

This differs from many job systems that restrict payloads to blittable types only. However, using managed references inside jobs means standard threading rules still apply — multiple jobs must not write to the same managed object simultaneously without proper synchronization.

Create a scheduler

Before you can schedule jobs, create a JobScheduler with a description that defines the thread count and dependency capacity.

using Misaki.HighPerformance.Jobs;

JobSchedulerDesc desc = new JobSchedulerDesc
{
    ThreadCount = Environment.ProcessorCount,
    ThreadPriority = ThreadPriority.Normal,
    DependencyChainCapacity = 64,
};

JobScheduler scheduler = new JobScheduler(in desc);

ThreadCount controls how many worker threads the scheduler spawns. DependencyChainCapacity is the maximum number of dependency edges the scheduler can track simultaneously. Set this to a value that covers the peak number of outstanding dependencies your workload needs.

The scheduler also reserves one helper thread slot for external threads. Use scheduler.ThreadLocalCount when allocating thread-local storage to ensure every possible executor has a valid slot.

IJob

IJob runs a single unit of work once on one worker thread. Implement the Execute method with the work you want to perform.

public struct ApplyVelocityJob : IJob
{
    public Vector3* position;
    public Vector3 velocity;
    public float deltaTime;

    public void Execute(ref readonly JobExecutionContext ctx)
    {
        *position += velocity * deltaTime;
    }
}

To schedule the job, call Schedule on the scheduler. This returns a JobHandle that you can use to track completion.

Vector3 pos = new Vector3(0, 0, 0);
Vector3 vel = new Vector3(10, 0, 0);

ApplyVelocityJob job = new ApplyVelocityJob
{
    position = &pos,
    velocity = vel,
    deltaTime = 0.016f,
};

JobHandle handle = scheduler.Schedule(ref job);
scheduler.Wait(handle);

// Result: pos == (0.16, 0, 0)

IJobParallelFor

IJobParallelFor runs the same operation across a range of indices in parallel. Each worker thread picks up batches of indices, processes them, then steals remaining batches from other workers.

This is useful for updating arrays of entities, processing particle data, or any operation where each element is independent.

public struct UpdatePositionJob : IJobParallelFor
{
    public Vector3* positions;
    public Vector3 velocity;
    public float deltaTime;

    public void Execute(int index, ref readonly JobExecutionContext ctx)
    {
        positions[index] += velocity * deltaTime;
    }
}

Schedule a parallel-for job with the total iteration count and the batch size. The batch size controls how many indices each worker claims at once. Smaller batches give better load balancing. Larger batches reduce stealing overhead.

const int entityCount = 10000;

UpdatePositionJob job = new UpdatePositionJob
{
    positions = positionsPtr,
    velocity = new Vector3(10, 0, 0),
    deltaTime = 0.016f,
};

JobHandle handle = scheduler.ScheduleParallelFor(ref job, entityCount, 64);
scheduler.Wait(handle);

IJobParallel

IJobParallel is similar to IJobParallelFor, but receives a start and end index instead of a single index. This is useful when the work per batch has setup overhead that you want to amortize across multiple elements.

public struct ProcessChunkJob : IJobParallel
{
    public float* data;
    public int* output;

    public void Execute(int startIndex, int endIndex, ref readonly JobExecutionContext ctx)
    {
        float sum = 0;
        for (int i = startIndex; i < endIndex; i++)
        {
            sum += data[i];
        }
        // Store per-chunk result
        output[startIndex] = (int)sum;
    }
}

Schedule it the same way as a parallel-for job:

JobHandle handle = scheduler.ScheduleParallel(ref job, totalLength, batchSize);
scheduler.Wait(handle);

ICustomJob

ICustomJob<TSelf> gives you full control over execution and cleanup by letting you provide function pointers. This is useful when you need custom resource management or when the job's execution logic isn't known until runtime.

public unsafe struct MyCustomJob : ICustomJob<MyCustomJob>
{
    public int* value;

    public static void Execute(ref MyCustomJob job, ref JobRanges jobRanges, ref readonly JobExecutionContext ctx)
    {
        *job.value += 1;
    }

    public static void Free(ref MyCustomJob job)
    {
        // Clean up any unmanaged resources here
    }
}

Schedule it using ScheduleCustom with a CustomJobDesc:

int value = 0;

MyCustomJob customJob = new MyCustomJob { value = &value };

CustomJobDesc<MyCustomJob> desc = new CustomJobDesc<MyCustomJob>
{
    data = ref customJob,
    pExecutionFunc = &MyCustomJob.Execute,
    pFreeFunc = &MyCustomJob.Free,
    jobRanges = JobRanges.Single,
    priority = JobPriority.Normal,
};

JobHandle handle = scheduler.ScheduleCustom(ref desc);
scheduler.Wait(handle);

Run inline

You can also run a job immediately on the calling thread. This is useful for debugging or when the work is too small to justify threading overhead.

// IJob
job.Run(default);

// IJobParallelFor
job.Run(totalIterations, default);

// IJobParallel
job.Run(totalIterations, default);

For struct jobs, use RunRef to avoid a copy:

ref MyJob jobRef = ref someJob;
jobRef.RunRef(default);

Priority

You can assign a priority when scheduling a job.

JobHandle handle = scheduler.Schedule(ref job, JobPriority.High);

For more information on how priorities affect scheduling, see Threading Fundamentals.

preferLocal

When you schedule with preferLocal: true, the scheduler pushes the job onto the calling thread's local queue first. This keeps the job's data hot in the CPU cache for that thread.

JobHandle handle = scheduler.Schedule(ref job, preferLocal: true);

Use this when the calling thread is likely to be the one that executes the job, such as when scheduling from a dedicated system thread.

Wait for completion

After scheduling, call one of the wait methods to block until the job finishes:

// Block until a single job completes
scheduler.Wait(handle);

// Block until all specified jobs complete
scheduler.WaitAll(handle1, handle2);

// Block until any of the specified jobs completes
JobHandle completed = scheduler.WaitAny(handle1, handle2);

By default, Wait helps execute the job inline while waiting. Pass inlineExecution: false to disable this.

Dispose

When you no longer need the scheduler, call Dispose to stop all worker threads and release resources:

scheduler.Dispose();

Additional resources