~/blog $ cat unreal-engine-5-multithreading.md

Multithreading in Unreal Engine 5

Multithreading is a powerful technique in game development that allows multiple tasks to run concurrently, leveraging the full potential of modern multi-core processors. In a game, numerous processes such as rendering, physics calculations, AI, and input handling need to execute simultaneously. Properly implemented multithreading can significantly improve the performance and responsiveness of your game by ensuring these processes run smoothly without blocking each other.

Unreal Engine 5 provides robust support for multithreading, with several threading models and a range of tools and classes for managing threads and tasks efficiently. This guide walks through the engine’s threading model, the core APIs for creating and scheduling work on other threads, synchronization, and practical use cases — with working C++ examples along the way.

Why Use Multithreading in Unreal Engine?

  • Improved performance. Distributing tasks across multiple threads takes full advantage of multi-core processors, resulting in faster and more efficient execution.
  • Smooth gameplay. Multithreading helps maintain a smooth experience by ensuring that resource-intensive work like rendering and physics does not block the game thread.
  • Scalability. Properly implemented multithreading lets your game scale with hardware advancements, delivering better performance on modern systems.

Understanding the Threading Model

Unreal Engine 5 employs a sophisticated threading model to manage the various tasks that need to run simultaneously in a game. Understanding this model is crucial for using multithreading effectively in your projects.

Game Thread

The game thread is the primary thread, where most gameplay logic runs. It handles:

  • Input processing
  • Actor updates and ticking
  • Game state management
  • Blueprint and gameplay logic execution

The game thread operates on a variable time step: the time elapsed between frames (DeltaTime) can vary, and gameplay code adjusts its calculations based on the actual elapsed time. Certain subsystems, such as physics simulation, instead advance on a fixed time step to keep behavior stable and deterministic, independent of the variable frame times of the game thread.

While the game thread is responsible for most core game logic, offloading heavy work to other threads is exactly what keeps it responsive.

Render Thread (and the RHI Thread)

The render thread is dedicated to rendering. It runs about a frame behind the game thread, translating the scene state the game thread produced into rendering commands. Its key responsibilities include:

  • Preparing rendering data (visibility, mesh batching, draw lists)
  • Generating platform-agnostic rendering commands

Below the render thread sits the RHI thread (Render Hardware Interface), which takes those commands and submits them to the graphics API (DirectX, Vulkan, Metal). This game thread / render thread / RHI thread pipeline lets each stage work on a different frame in parallel, which is how Unreal achieves smooth frame rates.

Task Graph System

The Task Graph System is a framework for managing asynchronous tasks and parallel execution. It lets you schedule small units of work that run concurrently across the engine’s worker threads, with support for dependencies between tasks. Its two core types are:

  • FGraphEventRef: a reference to a task/event, used to track completion and express dependencies.
  • FFunctionGraphTask: a helper for creating and dispatching tasks from lambdas.
FGraphEventRef MyTask = FFunctionGraphTask::CreateAndDispatchWhenReady([]()
{
    // Task code here
});
MyTask->Wait(); // Optionally block until the task completes

Worker Threads

Unreal Engine maintains a pool of worker threads that execute Task Graph tasks and other background work that does not require immediate attention, such as:

  • Asset streaming and loading
  • Physics-related computations
  • AI processing

Worker threads offload work from the game thread and render thread, keeping gameplay smooth and responsive.

Example: Loading Assets Without Blocking

Background asset loading keeps the game thread free while assets stream in. Note that LoadObject is synchronous — it blocks the calling thread until the asset is loaded — so it is not a multithreading tool:

// Synchronous: blocks the game thread until the texture is fully loaded
UTexture2D* MyTexture = LoadObject<UTexture2D>(nullptr, TEXT("/Game/Textures/MyTexture"));

For non-blocking loads, use FStreamableManager::RequestAsyncLoad instead (covered in the practical examples below).

Basic Multithreading Concepts

Before diving into implementation, it helps to nail down some fundamentals.

Threads and Concurrency

Threads are the smallest units of processing that an operating system can schedule. Multithreading is a CPU’s ability to execute multiple threads concurrently; in a multithreaded application, different parts of the program run simultaneously across CPU cores.

Concurrency refers to a program making progress on multiple tasks at the same time, achieved by letting independent pieces of work run on separate threads.

Synchronous vs. Asynchronous Execution

  • Synchronous execution: tasks run sequentially, each one finishing before the next starts. This wastes time whenever a task waits on I/O or another slow operation.
  • Asynchronous execution: tasks run concurrently without waiting for each other, making better use of resources and often dramatically improving performance.

Key Multithreading Mechanisms in Unreal Engine

1. FRunnable and FRunnableThread

  • FRunnable: an interface that defines the work a dedicated thread will perform, via the virtual methods Init, Run, Stop, and Exit.
  • FRunnableThread: the class that creates and manages the OS thread running an FRunnable object.
class FMyRunnable : public FRunnable
{
public:
    virtual bool Init() override { return true; }
    virtual uint32 Run() override
    {
        // Thread work here
        return 0;
    }
    virtual void Stop() override {}
    virtual void Exit() override {}
};

FMyRunnable* MyRunnable = new FMyRunnable();
FRunnableThread* MyThread = FRunnableThread::Create(MyRunnable, TEXT("MyThread"));

2. The Task Graph System

A high-level abstraction for scheduling small units of work that can run in parallel on the engine’s worker threads, with dependency tracking via FGraphEventRef.

3. Async() and FAsyncTask

The Async() function (from "Async/Async.h") runs a lambda on a background thread, the thread pool, or the Task Graph, and returns a TFuture you can use to retrieve the result. For class-based background work, the FAsyncTask<T> and FAutoDeleteAsyncTask<T> templates run a task object on the engine’s thread pool.

A quick note on naming: there is no FAsync class in Unreal. The API is the free function Async(), plus the FAsyncTask family. Older tutorials (including the original version of this article) sometimes refer to an “FAsync” utility — the concepts are the same, but the correct symbol is Async().

UObjects Are Not Thread-Safe

This is the single most important rule of Unreal multithreading: UObjects — including actors, components, and controllers — are not thread-safe. Spawning actors, modifying UObject properties, calling gameplay functions, and broadcasting delegates must all happen on the game thread. The garbage collector also has no visibility into references held by your own threads.

When background work needs to affect the game world, compute the result off-thread, then marshal it back to the game thread:

Async(EAsyncExecution::ThreadPool, []()
{
    const float Result = DoExpensiveMath(); // Safe: pure computation

    AsyncTask(ENamedThreads::GameThread, [Result]()
    {
        // Safe: back on the game thread.
        // Spawn actors, modify UObjects, fire delegates here.
    });
});

Keep this pattern in mind — we will use it in the practical examples later.

Creating and Managing Threads

For long-running background work, Unreal Engine provides the FRunnable and FRunnableThread classes. These give you a dedicated OS thread whose lifecycle you fully control.

The FRunnable Interface

Implement FRunnable to define the work your thread performs:

  • Init(): called once when the thread starts, before Run. Return false to abort the thread. Use it for initialization.
  • Run(): the main body of the thread. The thread ends when this method returns; if you want a persistent thread, loop here until you are asked to stop.
  • Stop(): called (from another thread) to request early termination. Typically it sets a flag that Run checks.
  • Exit(): called on the thread after Run finishes. Use it for cleanup.

Step-by-Step Guide to Creating a Thread

1. Define the runnable class

class FMyRunnable : public FRunnable
{
public:
    FMyRunnable() {}
    virtual ~FMyRunnable() {}

    virtual bool Init() override
    {
        UE_LOG(LogTemp, Warning, TEXT("Thread initialized"));
        return true;
    }

    virtual uint32 Run() override
    {
        for (int32 i = 0; i < 10 && !bStopRequested; ++i)
        {
            UE_LOG(LogTemp, Warning, TEXT("Thread running: %d"), i);
            FPlatformProcess::Sleep(1.0f); // Sleep for 1 second
        }
        return 0;
    }

    virtual void Stop() override
    {
        // Called from another thread: request a graceful shutdown
        bStopRequested = true;
    }

    virtual void Exit() override
    {
        UE_LOG(LogTemp, Warning, TEXT("Thread exiting"));
    }

private:
    std::atomic<bool> bStopRequested{ false };
};

Note the std::atomic<bool> stop flag: Stop() runs on a different thread than Run(), so the flag they share must be safe for concurrent access.

2. Create and start the thread

FMyRunnable* MyRunnable = new FMyRunnable();
FRunnableThread* MyThread = FRunnableThread::Create(MyRunnable, TEXT("MyThread"));

3. Stop and clean up the thread

MyRunnable->Stop();              // Request shutdown
MyThread->WaitForCompletion();   // Block until Run() returns
delete MyThread;
delete MyRunnable;

Practical Example: A Prime Number Thread

Let’s implement a more realistic example that performs a computational task on a separate thread.

class FPrimeNumberRunnable : public FRunnable
{
private:
    int32 MaxNumber;
    TArray<int32> PrimeNumbers;

public:
    explicit FPrimeNumberRunnable(int32 InMaxNumber)
        : MaxNumber(InMaxNumber)
    {
    }

    virtual bool Init() override
    {
        UE_LOG(LogTemp, Warning, TEXT("Prime number thread initialized"));
        return true;
    }

    virtual uint32 Run() override
    {
        for (int32 Num = 2; Num <= MaxNumber; ++Num)
        {
            bool bIsPrime = true;
            const int32 Limit = FMath::FloorToInt(FMath::Sqrt(static_cast<float>(Num)));
            for (int32 Div = 2; Div <= Limit; ++Div)
            {
                if (Num % Div == 0)
                {
                    bIsPrime = false;
                    break;
                }
            }
            if (bIsPrime)
            {
                PrimeNumbers.Add(Num);
            }
        }
        return 0;
    }

    virtual void Stop() override {}

    const TArray<int32>& GetPrimeNumbers() const
    {
        return PrimeNumbers;
    }
};

Running it and collecting the results:

FPrimeNumberRunnable* PrimeRunnable = new FPrimeNumberRunnable(100);
FRunnableThread* PrimeThread = FRunnableThread::Create(PrimeRunnable, TEXT("PrimeThread"));

// Block until the thread finishes (fine in a test; avoid on the game thread in shipping code)
PrimeThread->WaitForCompletion();

for (int32 Prime : PrimeRunnable->GetPrimeNumbers())
{
    UE_LOG(LogTemp, Warning, TEXT("Prime number: %d"), Prime);
}

delete PrimeThread;
delete PrimeRunnable;

Reading GetPrimeNumbers() is safe here only because WaitForCompletion() guarantees the worker has finished before we touch the array.

Dedicated threads are best for long-lived background work (audio decoding, network polling, procedural generation services). For short bursts of work, the Task Graph and thread pools below are cheaper and simpler.

Task Graph System

The Task Graph System is a framework for managing and executing tasks concurrently. It breaks complex work into smaller units that are scheduled across all available cores, and it understands dependencies between tasks.

Key concepts:

  • Tasks: units of work that can execute concurrently.
  • Events (FGraphEventRef): handles that signal task completion.
  • Dependencies: relationships that define execution order.

Simple Task

FGraphEventRef SimpleTask = FFunctionGraphTask::CreateAndDispatchWhenReady([]()
{
    UE_LOG(LogTemp, Warning, TEXT("Simple task is running"));
});
SimpleTask->Wait(); // Optionally block until the task completes

Wait() blocks the calling thread, so use it sparingly — especially on the game thread.

Tasks with Dependencies

You can pass a prerequisite event so a task only starts after another finishes:

// Create the first task
FGraphEventRef FirstTask = FFunctionGraphTask::CreateAndDispatchWhenReady([]()
{
    UE_LOG(LogTemp, Warning, TEXT("First task is running"));
});

// The second task will not start until the first completes
FGraphEventRef SecondTask = FFunctionGraphTask::CreateAndDispatchWhenReady([]()
{
    UE_LOG(LogTemp, Warning, TEXT("Second task is running"));
}, TStatId(), FirstTask);

SecondTask->Wait();

Parallel Processing Example

Splitting a computation into several concurrent tasks:

void ParallelComputation(int32 Start, int32 End)
{
    for (int32 i = Start; i < End; ++i)
    {
        // Perform computation here
    }
}

const int32 NumTasks = 4;
const int32 Range = 1000 / NumTasks;
TArray<FGraphEventRef> Tasks;

for (int32 i = 0; i < NumTasks; ++i)
{
    const int32 Start = i * Range;
    const int32 End = Start + Range;

    Tasks.Add(FFunctionGraphTask::CreateAndDispatchWhenReady([Start, End]()
    {
        ParallelComputation(Start, End);
    }));
}

for (const FGraphEventRef& Task : Tasks)
{
    Task->Wait();
}

ParallelFor: The Easy Path

For the common “run this loop body across all cores” case, Unreal ships a ready-made helper:

#include "Async/ParallelFor.h"

TArray<int32> Values;
Values.SetNum(1000);

ParallelFor(Values.Num(), [&Values](int32 Index)
{
    Values[Index] = Index * Index; // Each index runs exactly once, possibly on different threads
});

ParallelFor blocks until every iteration completes, and the calling thread participates in the work.

The UE5 Tasks System: UE::Tasks

Unreal Engine 5 also introduced a newer, cleaner tasks API in the UE::Tasks namespace that Epic recommends for new code. It is built on the same scheduler but has a friendlier interface:

#include "Tasks/Task.h"

UE::Tasks::FTask MyTask = UE::Tasks::Launch(UE_SOURCE_LOCATION, []()
{
    UE_LOG(LogTemp, Warning, TEXT("UE::Tasks task is running"));
});

MyTask.Wait(); // Optionally block until it completes

Dependencies are expressed with Prerequisites:

using namespace UE::Tasks;

FTask First = Launch(UE_SOURCE_LOCATION, []() { /* step 1 */ });
FTask Second = Launch(UE_SOURCE_LOCATION, []() { /* step 2 */ }, Prerequisites(First));

Second.Wait();

If you are starting a new UE5 project, prefer UE::Tasks::Launch over raw FFunctionGraphTask — it is less boilerplate and supports returning results, nested tasks, and pipes.

Async Tasks: Async() and FAsyncTask

Unreal Engine provides convenient tools for fire-and-forget or result-returning background work without managing threads yourself.

The Async() Function

Async() (from "Async/Async.h") runs a callable asynchronously and returns a TFuture<T> for retrieving the result:

#include "Async/Async.h"

void RunAsyncTaskWithResult()
{
    TFuture<int32> Future = Async(EAsyncExecution::Thread, []() -> int32
    {
        // Perform a computation
        return 42;
    });

    // Get() blocks until the result is ready — avoid calling it on the game thread
    const int32 ComputedResult = Future.Get();
    UE_LOG(LogTemp, Warning, TEXT("Computed result: %d"), ComputedResult);
}

If you don’t want to block, chain a continuation with Future.Next(...) instead of calling Get().

Controlling Where the Task Runs

The EAsyncExecution enum selects the execution backend:

  • EAsyncExecution::Thread: spawns a dedicated background thread for the task.
  • EAsyncExecution::ThreadPool: runs the task on the engine’s global thread pool (GThreadPool).
  • EAsyncExecution::TaskGraph: runs the task on the Task Graph.
  • EAsyncExecution::TaskGraphMainThread: runs the task on the game thread — handy for bouncing results back.
TFuture<int32> Future = Async(EAsyncExecution::ThreadPool, []() -> int32
{
    return 42;
});

There is also AsyncPool(...) for running work on a specific FQueuedThreadPool of your own, and the AsyncTask(ENamedThreads::GameThread, ...) function you saw earlier for dispatching a lambda to a named thread.

Class-Based Tasks: FAsyncTask and FNonAbandonableTask

For heavier task objects, derive from FNonAbandonableTask and wrap it in FAsyncTask (blocking handle) or FAutoDeleteAsyncTask (self-deleting, fire-and-forget):

#include "Async/AsyncWork.h"

class FMyAsyncTask : public FNonAbandonableTask
{
    friend class FAsyncTask<FMyAsyncTask>;

public:
    void DoWork()
    {
        UE_LOG(LogTemp, Warning, TEXT("Simple async task is running"));
    }

    FORCEINLINE TStatId GetStatId() const
    {
        RETURN_QUICK_DECLARE_CYCLE_STAT(FMyAsyncTask, STATGROUP_ThreadPoolAsyncTasks);
    }
};

void RunSimpleAsyncTask()
{
    // FAutoDeleteAsyncTask cleans itself up when DoWork finishes
    (new FAutoDeleteAsyncTask<FMyAsyncTask>())->StartBackgroundTask();
}

Be careful with plain FAsyncTask lifetimes: the wrapper must outlive the running work. Don’t declare it as a local variable and let it go out of scope while the task is still executing — heap-allocate it and call EnsureCompletion() before deleting.

Cancelling Async Tasks

Cooperative cancellation works the same way as with FRunnable: expose a thread-safe flag the work loop checks. FAsyncTask constructs the inner task itself (forwarding constructor arguments), and you reach it through GetTask():

#include "Async/AsyncWork.h"

class FMyCancellableTask : public FNonAbandonableTask
{
    friend class FAsyncTask<FMyCancellableTask>;

public:
    std::atomic<bool> bShouldCancel{ false };

    void DoWork()
    {
        for (int32 i = 0; i < 10; ++i)
        {
            if (bShouldCancel)
            {
                UE_LOG(LogTemp, Warning, TEXT("Async task was cancelled"));
                return;
            }
            UE_LOG(LogTemp, Warning, TEXT("Async task running: %d"), i);
            FPlatformProcess::Sleep(1.0f); // Simulate work
        }
    }

    FORCEINLINE TStatId GetStatId() const
    {
        RETURN_QUICK_DECLARE_CYCLE_STAT(FMyCancellableTask, STATGROUP_ThreadPoolAsyncTasks);
    }
};

void RunCancellableAsyncTask()
{
    FAsyncTask<FMyCancellableTask>* Task = new FAsyncTask<FMyCancellableTask>();
    Task->StartBackgroundTask();

    // Simulate some condition that triggers cancellation
    FPlatformProcess::Sleep(3.0f);
    Task->GetTask().bShouldCancel = true;

    Task->EnsureCompletion(); // Wait for DoWork to return
    delete Task;
}

Synchronization Primitives

In multithreaded code, synchronization primitives ensure threads can safely access shared resources without data corruption or race conditions. Unreal Engine 5 provides several.

The Core Primitives

1. FCriticalSection

  • A mutual-exclusion lock (mutex). Only one thread can own it at a time, preventing simultaneous access to a shared resource.
  • Use it to protect critical sections of code where shared data is read or written.

2. FScopeLock

  • An RAII helper that locks an FCriticalSection on construction and releases it automatically when it goes out of scope.
  • Prefer it over manual Lock()/Unlock() calls — it makes forgetting to unlock impossible.

3. FEvent

  • A signaling primitive: one thread waits, another triggers. Useful for “wake me when the work is done” coordination.

For simple shared counters and flags, std::atomic<T> is lighter than a lock. (Unreal’s own TAtomic is deprecated; Epic recommends std::atomic directly.) And for passing data between threads, TQueue offers lock-free single-producer/single-consumer and multi-producer modes.

Using FCriticalSection and FScopeLock

Two tasks incrementing a shared counter safely:

#include "HAL/CriticalSection.h"
#include "Misc/ScopeLock.h"

int32 SharedCounter = 0;
FCriticalSection CriticalSection;

void IncrementCounter()
{
    for (int32 i = 0; i < 1000; ++i)
    {
        FScopeLock Lock(&CriticalSection);
        SharedCounter++;
    }
}

void RunMultithreadedIncrement()
{
    FGraphEventRef Task1 = FFunctionGraphTask::CreateAndDispatchWhenReady([]()
    {
        IncrementCounter();
    });

    FGraphEventRef Task2 = FFunctionGraphTask::CreateAndDispatchWhenReady([]()
    {
        IncrementCounter();
    });

    Task1->Wait();
    Task2->Wait();

    UE_LOG(LogTemp, Warning, TEXT("Final counter value: %d"), SharedCounter);
}

Without the lock, the final value would be unpredictable — increments from the two threads would interleave and overwrite each other.

Using FEvent for Signaling

One thread signals completion; another waits for it:

#include "HAL/Event.h"
#include "HAL/Runnable.h"
#include "HAL/RunnableThread.h"

class FSignalingRunnable : public FRunnable
{
private:
    FEvent* CompletionEvent;

public:
    explicit FSignalingRunnable(FEvent* InCompletionEvent)
        : CompletionEvent(InCompletionEvent)
    {
    }

    virtual bool Init() override { return true; }

    virtual uint32 Run() override
    {
        FPlatformProcess::Sleep(2.0f); // Simulate work
        CompletionEvent->Trigger();    // Signal completion
        return 0;
    }

    virtual void Stop() override {}
};

void RunSignalingTask()
{
    // Borrow an event from the engine's pool (bIsManualReset = true)
    FEvent* CompletionEvent = FPlatformProcess::GetSynchEventFromPool(true);

    FSignalingRunnable* Runnable = new FSignalingRunnable(CompletionEvent);
    FRunnableThread* Thread = FRunnableThread::Create(Runnable, TEXT("SignalingThread"));

    CompletionEvent->Wait(); // Block until the worker triggers the event

    UE_LOG(LogTemp, Warning, TEXT("Task completed"));

    Thread->WaitForCompletion();
    delete Thread;
    delete Runnable;
    FPlatformProcess::ReturnSynchEventToPool(CompletionEvent);
}

Introducing Delays with FPlatformProcess::Sleep

FPlatformProcess::Sleep pauses the calling thread for a duration — useful for simulating work or throttling background loops. Never call it on the game thread, where it directly stalls the frame:

void DelayedTask()
{
    UE_LOG(LogTemp, Warning, TEXT("Task started"));
    FPlatformProcess::Sleep(2.0f); // Sleep for 2 seconds
    UE_LOG(LogTemp, Warning, TEXT("Task completed after delay"));
}

void RunDelayedTask()
{
    FGraphEventRef Task = FFunctionGraphTask::CreateAndDispatchWhenReady([]()
    {
        DelayedTask();
    });

    Task->Wait();
}

Thread Pools

Thread pools let you execute many tasks concurrently without the overhead of constantly creating and destroying threads. A pool holds a set of pre-allocated, reusable threads; each scheduled task is picked up by an idle pool thread, and the thread returns to the pool when the task finishes.

Benefits of Thread Pools

  • Reduced overhead: threads are reused instead of created per task.
  • Better throughput: tasks execute concurrently across cores.
  • Simplified management: the pool handles scheduling for you.

Creating a Custom Thread Pool

Unreal’s pool class is FQueuedThreadPool. One important detail: AddQueuedWork accepts an IQueuedWork*, not an FRunnable*. Work items implement DoThreadedWork() (the payload) and Abandon() (called if the pool shuts down before the item runs):

#include "Misc/IQueuedWork.h"
#include "Misc/QueuedThreadPool.h"

class FMyQueuedWork : public IQueuedWork
{
public:
    explicit FMyQueuedWork(int32 InTaskId) : TaskId(InTaskId) {}

    virtual void DoThreadedWork() override
    {
        UE_LOG(LogTemp, Warning, TEXT("Task %d is running"), TaskId);
        FPlatformProcess::Sleep(1.0f); // Simulate work
        delete this;                   // Work items manage their own cleanup
    }

    virtual void Abandon() override
    {
        delete this; // Pool is shutting down before this item ran
    }

private:
    int32 TaskId;
};

void RunThreadPoolExample()
{
    FQueuedThreadPool* ThreadPool = FQueuedThreadPool::Allocate();
    ThreadPool->Create(4, 128 * 1024, TPri_Normal); // 4 threads, 128 KB stack each

    for (int32 i = 0; i < 10; ++i)
    {
        ThreadPool->AddQueuedWork(new FMyQueuedWork(i));
    }

    // ... later, when the pool is no longer needed:
    ThreadPool->Destroy();
    delete ThreadPool;
}

You can also run lambdas on your own pool with AsyncPool(*ThreadPool, [](){ /* work */ });.

Using the Global Thread Pool

Most of the time you don’t need a custom pool — the engine’s global pool (GThreadPool) is already sized for the machine. The easiest way to use it is FAutoDeleteAsyncTask:

#include "Async/AsyncWork.h"

class FMyPooledTask : public FNonAbandonableTask
{
    friend class FAsyncTask<FMyPooledTask>;

public:
    explicit FMyPooledTask(int32 InTaskId) : TaskId(InTaskId) {}

    void DoWork()
    {
        UE_LOG(LogTemp, Warning, TEXT("Task %d is running"), TaskId);
        FPlatformProcess::Sleep(1.0f); // Simulate work
    }

    FORCEINLINE TStatId GetStatId() const
    {
        RETURN_QUICK_DECLARE_CYCLE_STAT(FMyPooledTask, STATGROUP_ThreadPoolAsyncTasks);
    }

private:
    int32 TaskId;
};

void RunGlobalThreadPoolExample()
{
    for (int32 i = 0; i < 10; ++i)
    {
        (new FAutoDeleteAsyncTask<FMyPooledTask>(i))->StartBackgroundTask();
    }
}

Ordering Pooled Work with Dependencies

Don’t make one pool task block waiting on another — that wastes a pool thread and invites deadlocks when the pool is saturated. Express ordering declaratively instead, with the Task Graph or UE::Tasks prerequisites:

using namespace UE::Tasks;

FTask Task1 = Launch(UE_SOURCE_LOCATION, []() { UE_LOG(LogTemp, Warning, TEXT("Task 1")); });
FTask Task2 = Launch(UE_SOURCE_LOCATION, []() { UE_LOG(LogTemp, Warning, TEXT("Task 2")); }, Prerequisites(Task1));
FTask Task3 = Launch(UE_SOURCE_LOCATION, []() { UE_LOG(LogTemp, Warning, TEXT("Task 3")); }, Prerequisites(Task2));

Task3.Wait(); // Task1 -> Task2 -> Task3, guaranteed

Performance Considerations

Multithreading can improve performance dramatically — or quietly make things worse. Keep these practices in mind.

Profiling and Optimizing Multithreaded Code

  • Use profiling tools. Unreal Insights captures per-thread timelines, task execution times, CPU/GPU usage, and memory allocation. Profile before and after adding threading — measure, don’t guess.
  • Minimize lock contention. Lock contention happens when threads compete for the same lock. Keep critical sections short, and prefer fine-grained locks over one big lock around an entire data structure.
  • Avoid blocking operations on hot paths. Waiting on I/O, network, or long computations on the game thread causes frame drops. Push that work to background threads or async APIs.
  • Size thread pools sensibly. Too many threads oversubscribes the CPU and adds context-switching overhead. Thread count should roughly track available cores — which the engine’s global pool already does.
  • Optimize data access patterns. Organize data for cache locality: contiguous memory, sequential access, aligned allocations. Cache misses can eat all the gains from parallelism.

Avoiding Common Pitfalls

  • Race conditions: multiple threads touching shared data simultaneously produce unpredictable results. Protect shared state with FCriticalSection/FScopeLock or atomics, and keep shared mutable state to a minimum.
  • Deadlocks: two threads each waiting for a lock the other holds. Avoid them with consistent lock ordering and by never holding a lock while waiting on another thread.
  • False sharing: threads writing to different variables that share a cache line invalidate each other’s caches. Pad or align hot per-thread data to avoid it.

Practical Example: Reducing Lock Contention

Consider several threads appending results to a shared array. The naive version takes the lock once per element:

class FContendedTask : public FRunnable
{
public:
    FContendedTask(TArray<int32>* InSharedData, FCriticalSection* InLock)
        : SharedData(InSharedData), Lock(InLock) {}

    virtual bool Init() override { return true; }

    virtual uint32 Run() override
    {
        for (int32 i = 0; i < 1000; ++i)
        {
            FScopeLock ScopeLock(Lock);
            SharedData->Add(i); // Acquires and releases the lock 1,000 times
        }
        return 0;
    }

    virtual void Stop() override {}

private:
    TArray<int32>* SharedData;
    FCriticalSection* Lock;
};

The optimized version accumulates into a thread-local buffer and merges once:

class FBatchedTask : public FRunnable
{
public:
    FBatchedTask(TArray<int32>* InSharedData, FCriticalSection* InLock)
        : SharedData(InSharedData), Lock(InLock) {}

    virtual bool Init() override { return true; }

    virtual uint32 Run() override
    {
        TArray<int32> LocalData;
        LocalData.Reserve(1000);

        for (int32 i = 0; i < 1000; ++i)
        {
            LocalData.Add(i); // No lock needed: this data is thread-local
        }

        // One lock acquisition for the whole batch
        FScopeLock ScopeLock(Lock);
        SharedData->Append(LocalData);
        return 0;
    }

    virtual void Stop() override {}

private:
    TArray<int32>* SharedData;
    FCriticalSection* Lock;
};

void RunBatchedTasks()
{
    TArray<int32> SharedData;
    FCriticalSection Lock;

    TArray<FBatchedTask*> Tasks;
    TArray<FRunnableThread*> Threads;

    for (int32 i = 0; i < 4; ++i)
    {
        FBatchedTask* Task = new FBatchedTask(&SharedData, &Lock);
        Tasks.Add(Task);
        Threads.Add(FRunnableThread::Create(Task, *FString::Printf(TEXT("BatchedTaskThread%d"), i)));
    }

    for (int32 i = 0; i < Threads.Num(); ++i)
    {
        Threads[i]->WaitForCompletion();
        delete Threads[i];
        delete Tasks[i];
    }

    UE_LOG(LogTemp, Warning, TEXT("Final size of shared data: %d"), SharedData.Num());
}

Same result, a tiny fraction of the lock traffic. This “compute locally, merge once” pattern applies to almost any parallel accumulation.

Practical Examples and Use Cases

Multithreading can enhance many parts of a game, from AI to asset loading. The recurring theme in all of these examples: heavy computation goes to background threads, but anything touching UObjects comes back to the game thread.

1. Multithreading in AI Calculations

AI work can be computationally intensive, especially with complex behaviors and many NPCs. The safe pattern is to run the expensive, pure-math part (scoring, spatial queries over your own data, planning) off-thread, then issue the actual AI commands on the game thread. Calling AAIController or navigation-system functions directly from a worker thread is not safe — they are UObjects.

#include "Async/Async.h"
#include "AIController.h"

void RunAsyncTargetSelection(AAIController* AIController, const TArray<FVector>& CandidatePoints)
{
    TWeakObjectPtr<AAIController> WeakController(AIController);

    Async(EAsyncExecution::ThreadPool, [WeakController, CandidatePoints]()
    {
        // Safe off-thread: pure math over plain data
        FVector BestPoint = FVector::ZeroVector;
        float BestScore = -FLT_MAX;

        for (const FVector& Point : CandidatePoints)
        {
            const float Score = ComputeExpensiveScore(Point); // Your heuristic
            if (Score > BestScore)
            {
                BestScore = Score;
                BestPoint = Point;
            }
        }

        // Actor interaction must return to the game thread
        AsyncTask(ENamedThreads::GameThread, [WeakController, BestPoint]()
        {
            if (AAIController* Controller = WeakController.Get())
            {
                Controller->MoveToLocation(BestPoint);
            }
        });
    });
}

Note the TWeakObjectPtr: the controller might be destroyed while the background task runs, and the weak pointer lets us check safely instead of dereferencing a dangling pointer. For pathfinding specifically, also look at UNavigationSystemV1::FindPathAsync, which the engine already runs asynchronously for you.

2. Parallelizing Asset Loading

Loading assets synchronously stalls the game thread. FStreamableManager streams assets in the background and invokes your callback on the game thread when they’re ready:

#include "Engine/AssetManager.h"
#include "Engine/StreamableManager.h"

void LoadAssetAsync(const FString& AssetPath)
{
    FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
    FSoftObjectPath SoftPath(AssetPath);

    // Call this from the game thread; the completion delegate also fires on the game thread
    Streamable.RequestAsyncLoad(SoftPath, [SoftPath]()
    {
        if (UObject* LoadedAsset = SoftPath.ResolveObject())
        {
            UE_LOG(LogTemp, Warning, TEXT("Asset loaded: %s"), *SoftPath.ToString());
        }
    });
}

Because the callback runs on the game thread, it’s safe to use the loaded asset immediately — assign it to components, spawn actors with it, and so on.

3. Parallel Processing in Gameplay Systems

Gameplay systems with independent, expensive computations (simulation ticks over many entities, procedural generation, batch scoring) parallelize well. Pooled tasks are a much better fit than dedicated threads for short bursts like this:

#include "Async/Async.h"

void RunGameplayTasks()
{
    TArray<TFuture<void>> Futures;

    for (int32 i = 0; i < 10; ++i)
    {
        Futures.Add(Async(EAsyncExecution::ThreadPool, [i]()
        {
            // Pure gameplay computation here — no UObject access
            UE_LOG(LogTemp, Warning, TEXT("Gameplay task %d is running"), i);
            FPlatformProcess::Sleep(0.5f); // Simulate work
        }));
    }

    for (TFuture<void>& Future : Futures)
    {
        Future.Wait();
    }
}

Extract the data you need from actors on the game thread first, process it in parallel, then apply the results back on the game thread.

4. Multithreading in Animation Systems

Animation is a case where you should lean on the engine’s built-in threading rather than rolling your own. Never call UAnimInstance functions from your own threads — anim instances are UObjects tied to game-thread state.

Instead, Unreal already evaluates animation on worker threads when Allow Multi-Threaded Animation Update is enabled (Project Settings > Engine > General Settings — it is on by default). The mechanism is the FAnimInstanceProxy pattern: the proxy is a plain (non-UObject) struct that mirrors the data the anim graph needs, and the engine updates and evaluates it on worker threads.

// Heavy per-frame animation work belongs in the proxy, which the engine
// already runs on worker threads when multithreaded animation is enabled.
struct FMyAnimInstanceProxy : public FAnimInstanceProxy
{
    virtual void Update(float DeltaSeconds) override
    {
        FAnimInstanceProxy::Update(DeltaSeconds);
        // Thread-safe: work with data cached from the game thread,
        // compute weights, blend values, procedural adjustments...
    }
};

The practical guidance: keep your NativeUpdateAnimation (game thread) limited to copying data from actors into the proxy, and do the heavy math in the proxy’s Update. Custom anim graph nodes should follow the same split. See Epic’s animation optimization documentation for details.

Wrapping Up

Multithreading in Unreal Engine 5 is a deep toolbox, but the essentials fit in a few rules of thumb:

  • Use FRunnable/FRunnableThread for long-lived dedicated threads, and the Task Graph, UE::Tasks, Async(), or thread pools for everything else.
  • ParallelFor is the shortest path to data-parallel loops.
  • Protect shared state with FCriticalSection/FScopeLock, signal with FEvent, and prefer std::atomic for simple flags and counters.
  • Above all: UObjects live on the game thread. Compute in the background, then marshal results back with AsyncTask(ENamedThreads::GameThread, ...).

Applied carefully — and verified with Unreal Insights — these tools let you take full advantage of modern multi-core hardware while keeping your game responsive and stable.