โ๏ธ 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 whoseExecute()is its work - Move data into a
NativeArrayso a job can touch it safely - Schedule a job, receive a
JobHandle, andComplete()it - Apply the golden rule โ never touch a job's data between
ScheduleandComplete - 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.
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.
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.
- Write an
IJobstructDistanceJobwith a[ReadOnly] NativeArray<float3> points, afloat3 target, and aNativeArray<float> results. - In
Execute(), fillresults[i] = math.distance(points[i], target). - In a MonoBehaviour, allocate the arrays (
Allocator.TempJob), fillpoints, schedule the job,Complete(), readresults, thenDispose()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
IJobis astruct; 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 mustDispose(). Schedule()returns aJobHandle;Complete()waits for the worker and hands the data back.- Golden rule: never touch a job's data between
ScheduleandCompleteโ 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.