Skip to main content

โš™๏ธ Lesson 3.2: Your First Job โ€” IJob & Scheduling

Your game runs almost everything on one thread โ€” the main thread โ€” while the other seven or eleven cores in the machine sit idle. The C# Job System is Unity's safe way to put those cores to work. In this lesson you write a real job, hand it packed data, schedule it onto worker threads, and collect the result without a single race condition.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what the Job System is and how it uses Unity's worker thread pool
  • Write an IJob โ€” a struct whose fields are its data and whose Execute() is its work
  • Move data into a NativeArray so a job can touch it safely
  • Schedule a job, receive a JobHandle, and Complete() it
  • Apply the golden rule โ€” never touch a job's data between Schedule and Complete
  • Chain jobs with dependencies

Estimated Time: 60 minutes  ยท  Prerequisite: Lesson 3.1 (Data-Oriented Thinking)

In This Lesson

The Idle Cores Problem

Unity's core loop is single-threaded by design: your Update(), physics step, and animation all run in sequence on the main thread. That's simple to reason about, but it means a heavy per-frame computation โ€” 50,000 particles, a flow field, thousands of spatial-audio distance checks โ€” blocks everything else while a modern CPU's other cores do nothing.

The C# Job System lets you package a chunk of work as a job and hand it to Unity's worker thread pool โ€” a set of background threads Unity keeps warm, roughly one per CPU core. The scheduler spreads jobs across those threads, so eight cores can chew through the work in a fraction of the wall-clock time. Crucially, it does this safely: the system's job is to make multithreading approachable without the classic nightmares of race conditions and locks.

Serial main-thread work versus jobs spread across worker threads Top: one long bar on the main thread doing all the work serially. Bottom: the same work split into four short bars running at the same time across four worker threads, finishing in roughly a quarter of the wall-clock time, while the main thread is freed. Without jobs โ€” everything on the main thread main 50,000 updates โ€” one long serial pass (blocks the frame) time โ†’ With jobs โ€” split across worker threads mainworker 1worker 2worker 3worker 4 Schedule Complete 12,500 12,500 12,500 12,500 same work, ~ยผ the wall-clock time โ€” and the main thread is free meanwhile
Figure 1: A job splits one serial pass across the worker pool. The main thread schedules the work, does other things, then completes it once the workers finish.

Anatomy of an IJob

A job is a struct that implements a job interface. The simplest is IJob: it runs once, on one worker thread. Its fields are its input and output, and its Execute() method is the work:

using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

// A job is a struct. Its fields are the ONLY data it may touch.
struct MoveJob : IJob
{
    public float deltaTime;                 // plain value, copied in
    public NativeArray<float3> positions;   // shared buffer it reads + writes
    [ReadOnly] public NativeArray<float3> velocities;  // read-only input

    public void Execute()
    {
        for (int i = 0; i < positions.Length; i++)
            positions[i] += velocities[i] * deltaTime;
    }
}

Notice what's not here: no MonoBehaviour, no references to scene objects, no calls into the Unity API. A job is a self-contained bundle of blittable data and pure logic. That restriction is exactly what lets Unity run it on another thread safely โ€” there's nothing shared to trip over except the containers you explicitly hand it.

โš ๏ธ Jobs can't touch managed objects or the Unity API

Inside Execute() you cannot use GameObject, Transform, Debug.Log (mostly), managed arrays, or reference-type fields. A job sees only blittable value types and NativeContainers. If you need transform data, you copy it into a NativeArray<float3> first and copy results back after โ€” or you use ECS (Module 4), which stores it that way to begin with.

Data Lives in NativeArrays

A job can't take an ordinary C# float3[] โ€” managed arrays live on the garbage-collected heap and can't be safely shared across threads. Instead the Job System uses NativeContainers, the most common being NativeArray<T>: a fixed-size, unmanaged (GC-free) buffer of blittable values that Unity can track across threads. You allocate one, fill it, hand it to the job, and (critically) dispose it when done:

int count = 50000;
// Allocator.TempJob = short-lived, for data handed to a job this frame.
var positions  = new NativeArray<float3>(count, Allocator.TempJob);
var velocities = new NativeArray<float3>(count, Allocator.TempJob);
// ... fill them from your data ...

var job = new MoveJob {
    deltaTime  = Time.deltaTime,
    positions  = positions,
    velocities = velocities
};

NativeContainers are their own topic โ€” allocators, the safety system, and leak detection โ€” and they get the full treatment in Lesson 3.3. For now, the mental model is: a NativeArray is a T[] you're allowed to share with a job, and that you are responsible for freeing.

Schedule & Complete

You don't call Execute() yourself. You schedule the job, which queues it on the worker pool and immediately returns a JobHandle โ€” a receipt you use to wait for it. Calling Complete() on that handle blocks the main thread until the job has finished, after which its results are safe to read:

// Queue the job onto a worker thread โ€” returns instantly.
JobHandle handle = job.Schedule();

// ... the main thread is free here to do other work ...

// Block until the job is done; now the data is safe to touch.
handle.Complete();

// Read results back out, then release the native memory.
for (int i = 0; i < positions.Length; i++)
    myTransforms[i].position = positions[i];

positions.Dispose();
velocities.Dispose();

Between Schedule() and Complete(), the job may be running on another thread โ€” so the data it owns is off-limits to you. Complete() is the synchronisation point that hands ownership back.

flowchart LR A["Allocate & fill
NativeArrays"] --> B["job.Schedule()
โ†’ JobHandle"] B --> C["main thread does
other work"] B -. runs on worker .-> W["Execute()"] C --> D["handle.Complete()
(wait for worker)"] W --> D D --> E["read results
+ Dispose()"]

Figure 2: The lifecycle. Schedule hands work to a worker; the main thread is free until Complete synchronises and returns the data.

The Golden Rule

There is one rule that prevents the entire category of multithreading bugs:

Never read or write a job's data between Schedule() and Complete().

While the job is in flight, its NativeArrays belong to the worker thread. If the main thread also pokes at them, you have a data race โ€” the exact bug the Job System exists to prevent. And it does prevent it: Unity's safety system tracks who owns each container and throws a clear InvalidOperationException the moment you break the rule, instead of letting you ship a heisenbug. You get told off immediately, in the editor, with a message that names the container.

The takeaway: schedule your work, leave its data alone, and only touch it again after Complete(). The safety system has your back while you learn the discipline.

Chaining with Dependencies

Real frames have jobs that depend on each other โ€” compute forces, then integrate velocities, then move positions. You express "B must wait for A" by passing A's handle into B's Schedule():

JobHandle forcesHandle = forcesJob.Schedule();

// integrateJob won't start until forcesJob is done โ€” pass the dependency.
JobHandle integrateHandle = integrateJob.Schedule(forcesHandle);

// moveJob waits for integrate.
JobHandle moveHandle = moveJob.Schedule(integrateHandle);

// Completing the last handle completes the whole chain.
moveHandle.Complete();

The scheduler now knows the ordering and guarantees each job sees the previous one's finished output โ€” while still running independent chains in parallel on other workers. Dependencies are also how the safety system reasons about who may touch a container when: if two jobs write the same array, you must make one depend on the other, or Unity refuses to schedule them.

โœ… Pro Tip

JobHandle.CombineDependencies(a, b, c) merges several handles into one, for when a job depends on all of them. And JobHandle.ScheduleBatchedJobs() nudges the scheduler to start queued jobs immediately rather than at the next sync point.

Schedule Early, Complete Late

The Job System only buys you time if the workers actually run while the main thread does something else. Scheduling a job and completing it on the very next line gives you the overhead of threading with none of the benefit โ€” the main thread just waits.

The idiomatic pattern is schedule as early as possible, complete as late as possible. A common shape: schedule heavy jobs at the start of the frame (e.g. in Update), let the rest of your game logic run, then complete them at the end (e.g. in LateUpdate) when you finally need the results. The wider that gap, the more free parallelism you get.

โš ๏ธ Don't complete a job the frame after you schedule it โ€” usually

A job whose results you don't need this frame can span frames, but a NativeArray allocated with Allocator.TempJob is expected to live at most a few frames, and leaving jobs uncompleted for long stretches complicates the safety system's bookkeeping. For per-frame work, complete within the same frame. For genuinely long-running background work, that's a more advanced pattern we flag but don't rely on here.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Write a distance job

Objective: Build and run a complete job end to end.

  1. Write an IJob struct DistanceJob with a [ReadOnly] NativeArray<float3> points, a float3 target, and a NativeArray<float> results.
  2. In Execute(), fill results[i] = math.distance(points[i], target).
  3. In a MonoBehaviour, allocate the arrays (Allocator.TempJob), fill points, schedule the job, Complete(), read results, then Dispose() both arrays.
๐Ÿ’ก Hint

Fields you only read should be marked [ReadOnly] โ€” it lets the safety system allow other jobs to read the same array simultaneously. Forgetting Dispose() triggers a leak warning in the console a few frames later, naming the allocation.

โœ… Solution sketch
struct DistanceJob : IJob {
    [ReadOnly] public NativeArray<float3> points;
    public float3 target;
    public NativeArray<float> results;
    public void Execute() {
        for (int i = 0; i < points.Length; i++)
            results[i] = math.distance(points[i], target);
    }
}
// caller:
var pts = new NativeArray<float3>(n, Allocator.TempJob);
var res = new NativeArray<float>(n, Allocator.TempJob);
/* fill pts */
var h = new DistanceJob { points = pts, target = t, results = res }.Schedule();
h.Complete();
/* use res */
pts.Dispose(); res.Dispose();

๐ŸŽฏ Quick Quiz

Question 1: Why must a job be a struct whose fields are only blittable values and NativeContainers?

Question 2: What does job.Schedule() return, and what does it do?

Question 3: You schedule a job, then immediately read its NativeArray before calling Complete(). What happens?

Summary

๐ŸŽ‰ Key Takeaways

  • The Job System runs work on Unity's worker thread pool so idle cores share the load safely.
  • An IJob is a struct; its fields are its data, Execute() is its work, and it may touch only blittable values + NativeContainers.
  • Job data lives in NativeArrays (GC-free, thread-shareable) that you must Dispose().
  • Schedule() returns a JobHandle; Complete() waits for the worker and hands the data back.
  • Golden rule: never touch a job's data between Schedule and Complete โ€” the safety system enforces it.
  • Express ordering with dependencies (Schedule(handle)); schedule early, complete late to maximise parallelism.

๐Ÿš€ What's Next?

You leaned on NativeArray and Allocator.TempJob without really meeting them. In Lesson 3.3: NativeContainers & the Safety System we go deep on native memory โ€” allocators, [ReadOnly]/[WriteOnly], leak detection, and the rules that keep parallel jobs honest.

โš™๏ธ You've gone multithreaded

Struct + fields + Execute, then Schedule/Complete around work you leave alone. That single shape scales from one background task to the parallel jobs and ECS systems in the rest of this course.