Skip to main content

๐Ÿงฑ Lesson 3.3: NativeContainers & the Safety System

Jobs run on data, and that data lives in NativeContainers โ€” unmanaged, garbage-free collections Unity can safely share across threads. Get their allocators, disposal, and the safety system right and multithreading becomes routine; get them wrong and you get leaks, exceptions, or (without the safety net) silent corruption. This lesson makes native memory second nature.

๐ŸŽฏ Learning Objectives

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

  • Explain why jobs need NativeContainers instead of managed collections
  • Choose the right container โ€” NativeArray, NativeList, NativeHashMap, NativeReference
  • Pick the correct allocator (Temp, TempJob, Persistent) for a container's lifetime
  • Dispose containers correctly โ€” directly, via a job handle, or automatically
  • Use [ReadOnly]/[WriteOnly] and understand how the safety system catches races and leaks

Estimated Time: 60 minutes  ยท  Prerequisite: Lesson 3.2 (Your First Job)

In This Lesson

Why Not Just Use List<T>?

A regular List<T> or T[] is a managed object: it lives on the garbage-collected heap, it can be moved by the GC, and its memory layout isn't guaranteed. None of that is safe to hand to another thread, and all of it generates the GC pressure you learned to avoid in Lesson 2.3.

NativeContainers solve both problems. They wrap a block of unmanaged memory (allocated outside the GC heap), so they generate zero garbage and never move. They store only blittable value types, so their bytes can be shared across threads directly. And they carry safety handles that let Unity track who is allowed to read or write them at any moment. The trade-off: because they're outside the GC, you are responsible for freeing them.

๐Ÿ“– Definition

Blittable types have an identical representation in managed and native memory โ€” the numeric primitives, boolish flags, and structs made only of those (including Unity.Mathematics types like float3). Reference types (string, arrays, class instances) are not blittable and can't live in a NativeContainer.

The Container Family

NativeArray<T> is the workhorse, but the Unity.Collections package ships a whole family for different shapes of data:

ContainerUse it forNotes
NativeArray<T>A fixed-size buffer of valuesThe default; index like an array
NativeList<T>A growable listAdd/remove; resizes like List<T>
NativeHashMap<K,V>A dictionaryAlso NativeParallelHashMap for parallel writes
NativeQueue<T>A FIFO queueProducer/consumer between jobs
NativeReference<T>A single shared valueReturn one result from a job (e.g. a sum)

They behave like their managed cousins but with the native rules. For example, a growable list you fill from a job:

using Unity.Collections;

var results = new NativeList<int>(Allocator.TempJob);   // starts empty, grows
results.Add(42);
results.Add(7);
int first = results[0];
results.Dispose();

// A single value out of a reduction job:
var total = new NativeReference<float>(Allocator.TempJob);
// ... job writes total.Value ...
float sum = total.Value;
total.Dispose();
๐Ÿ’ก Fixed strings. Since string isn't blittable, jobs use FixedString32Bytes, FixedString128Bytes, etc. โ€” inline, stack-sized strings you can store in a NativeContainer. Handy for names or tags inside ECS components.

Allocators & Lifetimes

Every NativeContainer is created with an allocator, and choosing the right one is about how long the container needs to live. There are three you'll use constantly:

โšก Allocator.Temp

Fastest, for data used within a single function call / frame on one thread. Auto-freed very quickly. Cannot be passed to a job that outlives the call. Great for scratch buffers.

๐Ÿงต Allocator.TempJob

For data handed to a job, expected to live at most a few frames. This is the default when scheduling work. If you don't dispose it within ~4 frames, Unity warns you.

โ™พ๏ธ Allocator.Persistent

For data that lives a long time โ€” allocated once (e.g. in Awake/OnCreate), reused across many frames, freed on teardown. Slowest to allocate, so don't do it per frame.

The rule of thumb: scratch data inside one method โ†’ Temp; buffers you fill and hand to this frame's jobs โ†’ TempJob; long-lived buffers you allocate once and keep โ†’ Persistent. A frequent beginner mistake is allocating a big Persistent array every frame instead of once โ€” that's just slow malloc/free churn dressed up as native code.

Disposal & Leak Detection

Because NativeContainers live outside the GC, forgetting to Dispose() one is a genuine memory leak โ€” the native block is never reclaimed. Unity ships a leak detection system that catches this: allocate a TempJob container and fail to free it in time, and the console prints a warning that even points back to the allocation site.

var buffer = new NativeArray<float3>(1000, Allocator.Persistent);
try
{
    // ... use buffer across frames ...
}
finally
{
    if (buffer.IsCreated) buffer.Dispose();   // always free it
}

For long-lived Persistent containers on a MonoBehaviour or system, the natural place to dispose is OnDestroy() (or a system's OnDestroy). Guard with IsCreated so you never double-dispose. Think of every new NativeArray(...) as opening a resource that you are on the hook to close.

โš ๏ธ NativeContainers are copied by value โ€” but share memory

A NativeArray is a struct, so assigning it copies a lightweight handle, not the data โ€” both copies point at the same native memory. Dispose one and the other becomes invalid. Don't keep stray copies around expecting independent buffers; there's one owner and one Dispose().

The Safety System

In the editor and development builds, every NativeContainer carries an AtomicSafetyHandle that tracks who may touch it right now. This is what threw the exception in Lesson 3.2 when we broke the golden rule, and it's what makes parallel jobs trustworthy. It enforces three things:

  • No use-after-dispose โ€” touching a freed container throws immediately, instead of reading garbage.
  • No unsynchronised sharing โ€” if a job is writing a container, nothing else (another job or the main thread) may read or write it until that job completes, unless you declared a dependency.
  • Read/write intent โ€” the [ReadOnly] and [WriteOnly] attributes tell the system your access pattern.

Those attributes aren't just documentation โ€” they unlock parallelism. Mark an input [ReadOnly] and the safety system knows many jobs can read that array at the same time (readers don't conflict). Leave it read-write and it must assume you might write, so it serialises access:

struct FlockJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float3> allPositions;  // shared read โ€” many jobs OK
    public NativeArray<float3> velocities;               // this job's output
    public void Execute(int i) { /* read allPositions, write velocities[i] */ }
}

โœ… Pro Tip

The safety system runs in the editor and Development Builds; in a shipping build it's compiled out for speed. So always develop and test with it on โ€” it turns latent race conditions into loud, immediate, fixable errors long before players ever run the release build.

Disposing Alongside a Job

There's a subtlety with the "schedule early, complete late" pattern: if a container is still being used by an in-flight job, you can't dispose it on the main thread yet โ€” the safety system won't let you. Two clean options:

// Option A: dispose AFTER the job that uses it, without blocking now.
// Dispose(handle) schedules the free to happen once the job finishes.
JobHandle handle = job.Schedule();
positions.Dispose(handle);
velocities.Dispose(handle);
// (no Complete() needed here if you don't need results this line)

// Option B: let the job own the deallocation.
struct SumJob : IJob
{
    [ReadOnly] [DeallocateOnJobCompletion] public NativeArray<float> input;
    public NativeReference<float> result;
    public void Execute() { /* ... */ }   // 'input' is freed automatically after
}

container.Dispose(jobHandle) queues the free to run when that job completes โ€” no main-thread stall. [DeallocateOnJobCompletion] hands the responsibility to the job itself for inputs you won't need afterward. Both keep the free correctly ordered after the work that reads the data.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Choose the allocator

Objective: Match lifetime to allocator.

Pick Temp, TempJob, or Persistent for each: (a) a scratch array of neighbour indices used and discarded inside one method, no job; (b) a positions buffer you fill this frame, hand to a job, and read back at frame end; (c) a 100k-element grid you allocate in Awake and reuse every frame for the object's whole life.

โœ… Answers

(a) Temp โ€” one call, one thread, auto-freed fast. (b) TempJob โ€” handed to a job, lives a frame or two. (c) Persistent โ€” allocated once, kept for the lifetime, disposed in OnDestroy. Allocating that grid every frame instead of once would be the classic performance mistake.

๐Ÿ‹๏ธ Exercise 2: Fix the leak and the race

Read this snippet and name two bugs:

var data = new NativeArray<float>(1000, Allocator.TempJob);
var handle = new MyJob { data = data }.Schedule();
float x = data[0];   // (1)
// ...no Dispose anywhere... (2)
โœ… Answers

(1) Reading data[0] after Schedule() but before Complete() breaks the golden rule โ€” the safety system throws. Move the read after handle.Complete(). (2) data is never disposed โ€” a leak; add data.Dispose(handle) (or Complete() then data.Dispose()). Leak detection will flag it in a few frames.

๐ŸŽฏ Quick Quiz

Question 1: Why can't a job take an ordinary managed List<float>?

Question 2: You allocate a 100k-element buffer once in Awake and reuse it every frame. Which allocator?

Question 3: What does marking a job's input array [ReadOnly] enable?

Summary

๐ŸŽ‰ Key Takeaways

  • NativeContainers hold blittable values in unmanaged, GC-free memory that's safe to share with jobs โ€” but you must free them.
  • Pick the container by shape: NativeArray (buffer), NativeList (growable), NativeHashMap (dictionary), NativeReference (one value).
  • Match allocator to lifetime: Temp (one call), TempJob (a few frames of job data), Persistent (allocate once, keep).
  • Dispose everything; leak detection flags what you forget. Guard with IsCreated; a NativeArray copy shares memory.
  • The safety system blocks use-after-dispose and unsynchronised sharing; [ReadOnly]/[WriteOnly] declare intent and unlock parallel reads.
  • Free in-flight data with Dispose(handle) or [DeallocateOnJobCompletion] so the free is ordered after the job.

๐Ÿš€ What's Next?

You have safe, packed, thread-shareable data. In Lesson 3.4: Parallel Jobs & Burst Compilation we split work across every core with IJobParallelFor and switch on the Burst compiler to turn that C# into vectorised native code โ€” the payoff the whole module has been building toward.

๐Ÿงฑ Native memory, mastered

Allocate with the right lifetime, declare read/write intent, dispose exactly once. With those habits the safety system stops being a nag and starts being the reason your multithreaded code just works.