๐งต Lesson 11.1: Async & Multithreading
Almost all your gameplay code runs on one thread โ the game thread โ and blocking it drops your frame rate. When you have heavy, self-contained work (pathfinding, procedural generation, big data crunching), moving it off the game thread keeps the game smooth. But threading in Unreal has firm rules, and breaking them crashes spectacularly.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain the game thread and why blocking it hurts
- State the cardinal rule: don't touch UObjects off the game thread
- Run background work with
AsyncTaskand the Tasks system - Return safely to the game thread to apply results
- Parallelize a loop with
ParallelFor
Estimated Time: 60 minutes
Engine Version: Unreal Engine 5.8
In This Lesson
The Game Thread
Nearly everything you've written in this course โ Tick, BeginPlay, overlaps, RPCs, timers โ runs on the game thread. It processes one frame at a time: input, gameplay, then hands off to the render thread. If your code takes 20 ms on the game thread, that's a frame that took at least 20 ms โ visible stutter.
๐ When to reach for a thread
Threading is not a default โ most gameplay belongs on the game thread. Move work off it only when it's heavy, self-contained, and not needed this exact frame: pathfinding for many agents, procedural mesh generation, parsing a large file, expensive analysis. If the work is small or must affect this frame, keep it on the game thread. Premature threading adds complexity and bugs for no gain.
The Cardinal Rule
One rule dwarfs all others in Unreal threading:
โ ๏ธ Do NOT touch UObjects (actors, components) off the game thread
The engine's object system, garbage collector, and most of the API are not thread-safe. Reading or writing an actor, spawning, destroying, or calling most engine functions from a background thread causes race conditions and crashes โ often intermittent ones that are agony to debug. Background threads should work with plain data (numbers, arrays, non-UObject structs), then hand results back to the game thread to apply.
owns UObjects"] -->|"copy plain DATA"| BG["Background thread
crunches data only"] BG -->|"send RESULT back"| GT2["Game thread
applies result to UObjects"]
๐ Why plain data?
An FVector, a TArray<float>, a non-UObject struct โ these you can safely process on any thread as long as no other thread touches the same instance concurrently. A UObject*, though, might be garbage-collected or modified by the game thread at any moment. This is where the two-memory-worlds distinction from Lesson 2.5 pays off again: UObjects have engine-managed lifetime and rules; plain data is yours to move.
AsyncTask & the Tasks System
The simplest way to run work off the game thread is AsyncTask โ hand it a lambda and a thread pool to run on.
#include "Async/Async.h"
void AWorldGenerator::StartGeneration()
{
// Copy any input we need as PLAIN DATA before going async.
const int32 Seed = GenerationSeed;
// Run heavy work on a background thread pool.
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this, Seed]()
{
// BACKGROUND THREAD: only plain-data work here. No UObject access!
TArray<FVector> Points = ComputeHeightfield(Seed); // pure computation
// Marshal the result back to the game thread to apply it (next section).
AsyncTask(ENamedThreads::GameThread, [this, Points]()
{
// GAME THREAD: now it's safe to touch actors/components.
BuildMeshFromPoints(Points);
});
});
}
Modern UE also has the Tasks system (UE::Tasks) with dependencies and results โ a cleaner API for structured async work:
#include "Tasks/Task.h"
UE::Tasks::TTask<int32> Task = UE::Tasks::Launch(UE_SOURCE_LOCATION, []()
{
return ExpensiveComputation(); // returns a value, off the game thread
});
// Later: Task.GetResult() (blocks if not ready) or chain another task on it.
โ Capture plain data, not UObjects, in the lambda
Notice the background lambda captures Seed (an int) โ not live actor state. Capturing this is common for scheduling the return, but you must not dereference UObjects on the background thread. If the object could be destroyed before the task finishes, capture a TWeakObjectPtr (Lesson 2.5) and validate it back on the game thread before use.
Returning to the Game Thread
The pattern above shows the key move: after background work, schedule an AsyncTask(ENamedThreads::GameThread, ...) to apply results. This "hop back" is mandatory โ it's the only place you may touch UObjects again.
๐ The full round trip
- Game thread: gather inputs as plain data.
- Background thread: do the heavy computation on that data only.
- Game thread (hop back): apply the result to actors/components โ spawn, update, whatever.
This is exactly how the engine's own async systems work (async asset loading, async saves from Lesson 7.3, async traces). You're following a well-worn path, not inventing one.
โ ๏ธ Beware the object dying mid-task
Between launching a task and its completion, the owning actor could be destroyed (level change, gameplay). On the game-thread hop-back, re-check validity โ if (!IsValid(this)) return; or resolve a TWeakObjectPtr โ before applying results. A task that blindly writes to a freed actor crashes. Symmetric to every lifetime check you've done since Module 2.
ParallelFor
Sometimes you don't need a separate long-running thread โ you have a big loop of independent work you want spread across cores now. ParallelFor runs loop iterations in parallel and returns when all are done.
#include "Async/ParallelFor.h"
// Process a big array of plain data across all available cores.
TArray<float> Results;
Results.SetNum(Inputs.Num());
ParallelFor(Inputs.Num(), [&](int32 Index)
{
// Runs on multiple threads. Each iteration touches ONLY its own index โ
// no shared writes, no UObjects. That independence is what makes it safe.
Results[Index] = ExpensiveTransform(Inputs[Index]);
});
// Here, all iterations are complete โ safe to use Results.
โ ๏ธ Iterations must be independent
ParallelFor is safe only when each iteration works on separate data โ like writing to its own Results[Index]. If two iterations write the same variable (a shared counter, a shared container's Add), you get a data race. And still no UObject access inside. Used correctly on independent plain-data work, ParallelFor is a near-free speedup on multi-core machines.
๐ก Measure before you thread. Threading adds complexity and risk. The next lesson is about profiling โ always confirm a piece of code is actually a bottleneck (and worth the threading complexity) before parallelizing it. Optimizing code that wasn't slow is wasted effort and new bugs.
Hands-on Exercise & Quiz
๐๏ธ Exercise: Offload a heavy calculation
Objective: Structure an async job correctly.
- You need to compute influence-map values for a 256ร256 grid (pure math) and then spawn markers at high-value cells.
- Sketch the three-step flow: what runs on the game thread, what on a background thread, and where the hop-back happens.
- Which step is allowed to spawn the marker actors, and why not the others?
- How would you guard against the owning manager being destroyed before the job finishes?
โ Answers
- Game thread: read inputs as plain data. Background: compute the 256ร256 grid (plain arrays). Game-thread hop-back: spawn markers.
- Only the game-thread hop-back may spawn โ spawning is UObject/engine work and is not thread-safe (the cardinal rule).
- Capture a
TWeakObjectPtrto the manager (or checkIsValid(this)) on the hop-back before spawning.
๐ฏ Quick Quiz
Question 1: The cardinal rule of Unreal threading is:
Question 2: After computing on a background thread, to apply results to actors you must:
Question 3: ParallelFor is safe when:
Summary
๐ Key Takeaways
- Gameplay runs on the game thread; slow work there stalls the frame. Offload only heavy, self-contained, not-this-frame work.
- Cardinal rule: never touch UObjects/actors/most engine API off the game thread โ background threads work with plain data only.
- Run background work with
AsyncTaskor theUE::Taskssystem; capture plain data (orTWeakObjectPtr), not live UObject state. - Hop back to the game thread (
AsyncTask(ENamedThreads::GameThread, ...)) to apply results โ and re-check validity, since the owner may have died. ParallelForspeeds up big loops of independent plain-data work; measure first โ don't thread what isn't slow.
๐ Additional Resources
๐ What's Next?
We keep saying "measure first." The final foundation lesson makes that concrete: profiling with Unreal Insights, the stat commands, and the optimization patterns that actually move the needle โ so you fix real bottlenecks, not imagined ones.
๐ Lesson complete!
You can move work off the game thread safely. Now let's learn what to move.