Skip to main content

16 / 16

Threads and async work

Touch UObjects and engine state on the game thread. Move only pure computation off it.

Almost all of the engine assumes the game thread. Async work is safe when it copies what it needs, does arithmetic, and returns before touching anything the engine owns.

Rules

  1. 01Read and write UObject state on the game thread only. IsInGameThread() is the check when a function can be reached from either side.
  2. 02Copy the inputs a background task needs before dispatching it. A lambda that captures this and reads a member creates a race condition.
  3. 03Capture a TWeakObjectPtr rather than a raw pointer, and resolve it only after hopping back to the game thread.
  4. 04Return results with AsyncTask(ENamedThreads::GameThread, ...). Applying them straight from the worker is the most common async crash in a gameplay codebase.
  5. 05Use ParallelFor for a fixed amount of work whose iterations do not depend on each other. The body runs on several threads at once, so it must not write shared state without a lock.
  6. 06Guard shared data with an FCriticalSection and a scoped FScopeLock. A manual lock and unlock pair leaks the lock on an early return.
  7. 07Spawning, physics queries, component updates, and most gameplay APIs are game-thread only. Assume an engine call is unsafe off the game thread unless its documentation says otherwise.
  8. 08A task that outlives the object that started it needs a cancellation path. Firing into a destroyed owner is a crash that only appears under load.

Where the work runs

Game thread

The work touches UObjects, spawns, queries physics, or updates components. Everything in the engine is safe here.

CostsAnything slow here adds directly to frame time.

ParallelFor

One pass over a known number of independent items, short enough that blocking the calling thread is fine. Spreads across worker threads with nothing to pass back.

CostsStill blocks the caller until every iteration finishes, and shared writes need a lock.

AsyncTask on a background thread

The job is long enough that the frame should not wait, and the inputs can be copied. Returns through a second hop to the game thread.

CostsThe result lands a frame or more later, so the caller needs a defined "in progress" state.

FRunnable

A thread has to live across many frames, such as a streaming or network worker. Gives you full control of the loop and shutdown.

CostsYou own the lifetime, the synchronization, and the shutdown path. That is a lot more to get wrong.

Compute off the game thread, apply on it

The worker copies what it needs, runs the arithmetic in parallel, and hops back before touching the component.

TerrainAnalysisComponent.cppcpp
void UTerrainAnalysisComponent::StartAnalysis()
{
    // Copy the inputs. The background task must not read UObject state.
    TArray<FVector> Points = CollectSamplePoints();
    TWeakObjectPtr<UTerrainAnalysisComponent> WeakThis(this);

    AsyncTask(
        ENamedThreads::AnyBackgroundThreadNormalTask,
        [Points = MoveTemp(Points), WeakThis]()
    {
        TArray<float> Scores;
        Scores.SetNumUninitialized(Points.Num());

        ParallelFor(Points.Num(), [&Points, &Scores](const int32 Index)
        {
            Scores[Index] = UE::ActionGame::Private::ScoreSample(Points[Index]);
        });

        AsyncTask(
            ENamedThreads::GameThread,
            [WeakThis, Scores = MoveTemp(Scores)]() mutable
        {
            if (UTerrainAnalysisComponent* Component = WeakThis.Get())
            {
                Component->ApplyScores(MoveTemp(Scores));
            }
        });
    });
}
Why
UObject state is only safe to read and write on the game thread. Copying the inputs up front means the background task never dereferences the component, and the weak pointer check covers destruction during the work.
Costs
Copying inputs costs memory and a pass over the data. The result also arrives a frame or more later, so the caller needs a defined state for "still working".
Instead
Use ParallelFor on its own when the work is short enough to block the game thread. Use FRunnable or a task graph chain when the job is long-lived rather than one pass.

All 16 rules