Multithreading In Unity: Solutions For Not Freezing The Main Thread

The problem: Unity’s main thread handles both game logic and UI, so heavy computations can freeze the interface

Unity’s game engine operates on a single-threaded architecture where the main thread is responsible for running gameplay code, handling user input, audio processing, and rendering the user interface. This presents a problem for performance: any intensive computations on the main thread can cause the game to freeze momentarily while blocking the thread. From the player’s perspective, this manifests as choppy framerates, unresponsive inputs, and interface freezes – seriously degrading the gameplay experience.

For example, procedural generation algorithms, physics calculations, and complex AI logic can overwhelm Unity’s main thread with long-running updates that prevent the thread from rendering frames in a steady cadence. Coroutines allow these operations to yield control back to the main thread, but do not solve the underlying issue of overloading a single thread.

Understanding Unity’s single-threaded architecture

At its core, Unity employs a single-threaded programming model centred around the main thread. This main thread handles crucial tasks like:

  • Executing gameplay code in MonoBehaviour updates and coroutines
  • Performing physics calculations and component interactions
  • Handling user input from mouse, keyboard etc.
  • Playing back audio clips and processing audio sources
  • Rendering the 3D scene and updating the user interface

The problem arises when intensive workloads on the main thread end up blocking rendering and user interface tasks. Unity’s architecture leaves little CPU time for the critical visual feedback loops that maintain fluid UI and gameplay.

The need for multithreading

Multithreading can mitigate this by offloading operations to separate CPU threads, allowing the main thread to focus on time-sensitive UI and input handling. For example, a graphics workload could process textures on a background thread while physics runs on another.

This parallel execution prevents a single expensive task from dominating the main thread. The key benefit is that gameplay critical rendering, audio, and input handling can still occur without interference despite complex background processes.

Solutions

Coroutines for non-blocking operations

A simple way to avoid main thread blocking is by using coroutines instead of regular functions. Coroutines allow stopping and starting a function over multiple frames by yielding control back to Unity’s main thread whenever needed.

For example, a procedural generation coroutine can yield and continue generating the level across multiple frames instead of executing in one intensive burst:

IEnumerator GenerateLevel() {

  // yield to main thread at end of each step 
  yield return GenerateTiles();  
  yield return GenerateProps();
  yield return GenerateEnemies();

}

This prevents the entire generation process from blocking the main thread in a single frame. Use coroutines whenever an operation can be split across frames.

Jobs and the burst compiler

Unity’s job system and burst compiler allow multithreaded workloads to leverage multiple CPU cores. By defining jobs and job-ified systems, intensive computations can run in parallel on separate threads.

For example, navmesh baking can be moved to a job while the main thread focuses on gameplay code:

var bakeJob = navMesh.UpdateNavMesh(bounds);

// execute job on separate thread
JobHandle handle = bakeJob.Schedule();  

// continue gameplay on main thread
Player.Update();
EnemyManager.Update();

// wait for job to complete
handle.Complete();

The job system integrates well with entity-component patterns, allowing easy multi-threading of existing gameplay systems. Burst compilation also provides performance improvements over regular C# jobs.

Threads for intensive background work

For precise control, threads allow running intensive algorithms completely separately from the main thread and Unity’s update loop:

Thread backgroundThread = new Thread(RunBackgroundAlgo);
backgroundThread.Start();

This executes RunBackgroundAlgo() on a separate thread, preventing main thread blocking. However threads require careful handling of shared data and synchronization to avoid race conditions.

Communicating across threads

Sharing data between threads requires synchronization constructs like mutexes to prevent simultaneous access. For example:

 
Mutex dataMutex = new Mutex();

void MainThread() {

  dataMutex.WaitOne(); // lock access  
  objectA.ModifySharedData();
  dataMutex.ReleaseMutex();

}

void BackgroundThread() {

  dataMutex.WaitOne(); // lock access
  objectB.ReadSharedData(); 
  dataMutex.ReleaseMutex();  
}

This ensures only one thread accesses the shared data at a time. Other communication options include thread-safe collections or dispatching events across threads.

Example job system code

Here is an example of implementing a multi-threaded job in Unity to preprocess assets in the background:

public class AssetPreprocessor : MonoBehaviour {

  AsyncOperation processOp;

  IEnumerator Start() {

    // run job on thread pool
    var processJob = new PreprocessAssetsJob();
    processOp = processJob.Schedule(); 

    // allow main thread to continue during job
    yield return null;

    // wait for job to complete
    processOp.Complete();

    // use finished results
    ApplyFinishedAssets();

  }

}

// job definition
[BurstCompile]
public struct PreprocessAssetsJob : IJob {

  public void Execute() {
    
    // intensive asset processing  

  }

}

This moves asset preprocessing to a job while allowing the main thread to continue operating unaffected. The job API integrates well with existing game code.

Best practices

Identify performance bottlenecks

Profile Unity projects with the built-in profiler to identify specific systems or bottlenecks causing main thread congestion. Common culprits include:

  • Physics engines performing intensive collision calculations
  • Monobehaviours with frequent updates occupying CPU time
  • Scene rendering bottlenecks from excessive draw calls
  • Scripts creating garbage collection pressure

Optimizing or multi-threading these hot paths can greatly reduce main thread strain. The profiler quantifies exact CPU time consumed by various operations.

Use the profiler

Unity’s profiler provides metrics for multithreading performance:

  • Time spent on the game thread vs render thread vs worker threads
  • Number of threads currently active
  • Fraction of time cores are underutilized

This data helps add threading until CPU utilization saturates across all cores, minimizing main thread strain.

Limit communication across threads

Frequent communication introduces synchronization overhead that can negate multithreading gains. Batch data exchanges using thread-safe collections, and minimize sharing mutable state.

Apps with purely independent parallel workloads see the greatest gains. Identify self-contained jobs not requiring continual inter-thread coordination.

Other optimization techniques

Alongside multithreading, other optimizations help reduce main thread workload:

Object pooling

Object pooling reduces expensive allocations/deallocations by reusing instantiated objects. This saves CPU time on the main thread by avoiding garbage collection spikes.

Reducing draw calls

Minifying draw calls via batching cuts graphics workload on the main thread. Fewer individual objects to render reduces CPU strain during scene rendering.

Batching

Batching meshes, shaders, and materials into combined assets skips per-object operations. Rendering batched objects is significantly faster due to fewer distinct GPU commands.

Limiting garbage collection

Creating excessive garbage triggers pauses for collection. Reusing objects and buffers limits allocations to reduce this workload on the main thread.

Leave a Reply

Your email address will not be published. Required fields are marked *