All skills
quodsoler avatar

/ue-async-threading

@f3742d7

Use when offloading work off the game thread, dispatching results back to it, running data-parallel loops, or scheduling timers and tickers in UE C++. Also use when the user mentions 'UE::Tasks::Launch', 'FPipe', 'FTaskEvent', 'AsyncTask', 'Async()', 'TFuture', 'TPromise', 'ParallelFor', 'FRunnable', 'FAsyncTask', 'FCriticalSection', 'FRWLock', 'UE::FMutex', 'TMpscQueue', 'IsInGameThread', 'FTSTicker', 'SetTimer', 'thread safety'. For async asset loading, see ue-data-assets-tables; for smart pointers and GC lifetime, see ue-cpp-foundations.

Use this Skill: https://skilld.dev/gh/quodsoler/unreal-engine-skills/ue-async-threading

This session only. Nothing lands on disk.

referencesthreading-patterns.md

≈4.9k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Threading Patterns Reference

Complete templates for UE 5.8 async and threading patterns. Each snippet compiles against the 5.8 headers named in its include list; user types follow the FMy*/AMy* convention.


FRunnable Subclass Template

Dedicated thread with cooperative shutdown through std::atomic<bool> and an event so the loop never spins.

#include "HAL/Runnable.h"
#include "HAL/RunnableThread.h"
#include "HAL/Event.h"
#include "HAL/PlatformProcess.h"
#include "Containers/MpscQueue.h"
#include <atomic>

class FMyBackgroundWorker : public FRunnable
{
public:
    FMyBackgroundWorker() = default;

    ~FMyBackgroundWorker()
    {
        StopThread();
    }

    // Call from the game thread
    void StartThread()
    {
        if (Thread == nullptr)
        {
            Thread = FRunnableThread::Create(
                this,
                TEXT("MyBackgroundWorker"),
                0,                                   // InStackSize: 0 = platform default
                TPri_BelowNormal,                    // stay below the game thread
                FPlatformAffinity::GetPoolThreadMask(),
                EThreadCreateFlags::None);
        }
    }

    // Call from the game thread; safe to call twice
    void StopThread()
    {
        if (Thread != nullptr)
        {
            Thread->Kill(true);                      // calls Stop(), then blocks until Run() returns
            delete Thread;
            Thread = nullptr;
        }
    }

    // Any thread
    void Submit(int32 WorkItem)
    {
        Queue.Enqueue(WorkItem);
        WakeEvent->Trigger();
    }

    // --- FRunnable (HAL/Runnable.h:32-61) ---
    virtual bool Init() override
    {
        return true;                                 // new thread; return false to abort
    }

    virtual uint32 Run() override
    {
        while (!bStopRequested.load(std::memory_order_relaxed))
        {
            int32 Item = 0;
            while (Queue.Dequeue(Item))
            {
                ProcessItem(Item);
            }
            WakeEvent->Wait(100);                    // ms; wakes early on Trigger()
        }
        return 0;
    }

    virtual void Stop() override
    {
        bStopRequested.store(true, std::memory_order_relaxed);   // called from the killing thread; signal only
        WakeEvent->Trigger();
    }

    virtual void Exit() override
    {
        // new thread, after Run() returns: release thread-local resources
    }

private:
    void ProcessItem(int32 Item)
    {
        // pure data work; no UObject access here
    }

    FRunnableThread* Thread = nullptr;
    FEventRef WakeEvent{ EEventMode::AutoReset };    // HAL/Event.h:136 — pooled FEvent, released in the destructor
    TMpscQueue<int32> Queue;
    std::atomic<bool> bStopRequested{ false };
};

FRunnableThread::Create(FRunnable*, const TCHAR* ThreadName, uint32 InStackSize = 0, EThreadPriority InThreadPri = TPri_Normal, uint64 InThreadAffinityMask = FPlatformAffinity::GetNoAffinityMask(), EThreadCreateFlags InCreateFlags = EThreadCreateFlags::None) (HAL/RunnableThread.h:44). If the platform reports FPlatformProcess::SupportsMultithreading() == false, GetSingleThreadInterface() must return an FSingleThreadRunnable* that the engine ticks instead, otherwise return nullptr and the feature is unavailable there.


FNonAbandonableTask + FAsyncTask Template

Thread-pool work unit. FAsyncTask<T> constructs T from the forwarded arguments; you read the result through GetTask(). Construction happens inside FAsyncTask<T>, so the friend declaration lets the constructor be private; GetTask() reads happen in your code, so the result members must be public.

#include "Async/AsyncWork.h"

class FMyChunkProcessTask : public FNonAbandonableTask
{
public:
    friend class FAsyncTask<FMyChunkProcessTask>;
    friend class FAutoDeleteAsyncTask<FMyChunkProcessTask>;

    FMyChunkProcessTask(TArray<FVector> InRawVertices, float InScale)
        : RawVertices(MoveTemp(InRawVertices))
        , Scale(InScale)
    {}

    // Result: read after IsDone()/EnsureCompletion()
    TArray<FVector> ProcessedVertices;

    void DoWork()
    {
        ProcessedVertices.Reserve(RawVertices.Num());
        for (const FVector& V : RawVertices)
        {
            ProcessedVertices.Add(V * Scale);
        }
    }

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

private:
    TArray<FVector> RawVertices;
    float Scale;
};

// --- Owner-managed: keep the pointer, poll, then collect ---
TArray<FVector> Vertices;
FAsyncTask<FMyChunkProcessTask>* Task = new FAsyncTask<FMyChunkProcessTask>(MoveTemp(Vertices), 2.0f);
Task->StartBackgroundTask(GBackgroundPriorityThreadPool);     // default pool is GThreadPool
// ... later, once per frame:
if (Task->IsDone())
{
    TArray<FVector> Result = MoveTemp(Task->GetTask().ProcessedVertices);
    delete Task;
    Task = nullptr;
}
// ... or force completion (runs inline if not started yet):
// Task->EnsureCompletion(); delete Task;

// --- Fire-and-forget: deletes itself after DoWork, no result retrieval ---
(new FAutoDeleteAsyncTask<FMyChunkProcessTask>(MoveTemp(Vertices), 2.0f))->StartBackgroundTask();

Member signatures (Async/AsyncWork.h): StartBackgroundTask(FQueuedThreadPool* InQueuedPool = GThreadPool, EQueuedWorkPriority InQueuedWorkPriority = EQueuedWorkPriority::Normal, EQueuedWorkFlags InQueuedWorkFlags = EQueuedWorkFlags::None, int64 InRequiredMemory = -1, const TCHAR* InDebugName = nullptr) (:423), StartSynchronousTask(...) (:415), EnsureCompletion(bool bDoWorkOnThisThreadIfNotStarted = true, bool bIsLatencySensitive = false) (:433), bool Cancel() (:486), bool WaitCompletionWithTimeout(float TimeLimitSeconds) (:512), bool IsDone() (:544), bool IsWorkDone() const (:558), TTask& GetTask() (:631).


TGraphTask Template with Prerequisites

Legacy TaskGraph class-based task; still compiles and interoperates with UE::Tasks (an FGraphEventRef is a valid prerequisite for UE::Tasks::Launch). Prefer UE::Tasks for new code.

#include "Async/TaskGraphInterfaces.h"

class FMySmoothPathTask
{
public:
    explicit FMySmoothPathTask(TArray<FVector>& InOutPath) : Path(InOutPath) {}

    static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }
    ENamedThreads::Type GetDesiredThread() { return ENamedThreads::AnyBackgroundThreadNormalTask; }
    TStatId GetStatId() const { RETURN_QUICK_DECLARE_CYCLE_STAT(FMySmoothPathTask, STATGROUP_TaskGraphTasks); }

    void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
    {
        for (FVector& Point : Path)
        {
            Point = SmoothPoint(Point);
        }
    }

private:
    TArray<FVector>& Path;
};

// Step A: no prerequisites
TArray<FVector> PathData;
FGraphEventRef StepA = TGraphTask<FMySmoothPathTask>::CreateTask(nullptr).ConstructAndDispatchWhenReady(PathData);

// Step B: same task type again, after A (FGraphEventArray = TArray<FGraphEventRef, TInlineAllocator<4>>)
FGraphEventArray StepAPrereq;
StepAPrereq.Add(StepA);
FGraphEventRef StepB = TGraphTask<FMySmoothPathTask>::CreateTask(&StepAPrereq).ConstructAndDispatchWhenReady(PathData);

// Lambda form without a class (Async/TaskGraphInterfaces.h:1135)
FGraphEventRef StepC = FFunctionGraphTask::CreateAndDispatchWhenReady(
    [&PathData]() { FinalizePath(PathData); }, TStatId(), &StepAPrereq, ENamedThreads::AnyThread);

// Wait on the game thread (Async/TaskGraphInterfaces.h:414)
FTaskGraphInterface::Get().WaitUntilTaskCompletes(StepC, ENamedThreads::GameThread);

// Or feed the legacy event into UE::Tasks: a single FGraphEventRef is a valid prerequisites argument (Tasks/TaskPrivate.h:266)
UE::Tasks::TTask<void> After = UE::Tasks::Launch(UE_SOURCE_LOCATION, []() { PublishPath(); }, StepC);

UE::Tasks Chain with FTaskEvent and Cancellation

#include "Tasks/Task.h"
#include "Tasks/Pipe.h"                     // FPipe (Tasks/Task.h only forward-declares it)
#include "Tasks/TaskConcurrencyLimiter.h"   // FTaskConcurrencyLimiter

using namespace UE::Tasks;

// Step 1: produce
TTask<TArray<FVector>> PosTask = Launch(UE_SOURCE_LOCATION, []() -> TArray<FVector>
{
    TArray<FVector> Positions;
    FillPositions(Positions);
    return Positions;
});

// Step 2: consume step 1 (capture the handle by value; GetResult() is non-const so the lambda is mutable)
TTask<TArray<FVector>> SmoothTask = Launch(UE_SOURCE_LOCATION, [PosTask]() mutable -> TArray<FVector>
{
    TArray<FVector> Raw = PosTask.GetResult();
    SmoothPositions(Raw);
    return Raw;
}, Prerequisites(PosTask), ETaskPriority::BackgroundNormal);

// Step 3: gated by an event the game thread triggers later
FTaskEvent PublishGate{ UE_SOURCE_LOCATION };
FCancellationToken Cancel;
TTask<void> PublishTask = Launch(UE_SOURCE_LOCATION, [SmoothTask, &Cancel]() mutable
{
    if (Cancel.IsCanceled()) { return; }
    const TArray<FVector>& Final = SmoothTask.GetResult();
    PublishPositions(Final);
}, Prerequisites(SmoothTask, PublishGate));

PublishGate.Trigger();                                            // releases step 3 once step 2 is done

// Nested task: parent is not complete until the nested one is, without blocking a worker
TTask<void> Parent = Launch(UE_SOURCE_LOCATION, []()
{
    TTask<void> Child = Launch(UE_SOURCE_LOCATION, []() { ChildWork(); });
    AddNested(Child);
});

// Group wait with timeout; WaitAny returns the index of the first completed task or INDEX_NONE
TArray<FTask> All{ PublishTask, Parent };
const bool bDone = Wait(All, FTimespan::FromMilliseconds(2.0));
const int32 FirstDone = WaitAny(All, FTimespan::Zero());

// Game-thread body via extended priority (no AsyncTask needed)
Launch(UE_SOURCE_LOCATION, []() { check(IsInGameThread()); }, ETaskPriority::Normal, EExtendedTaskPriority::GameThreadNormalPri);

// Pipe: serialize access to one resource
FPipe StatsPipe{ TEXT("StatsPipe") };
StatsPipe.Launch(UE_SOURCE_LOCATION, []() { AccumulateStats(0); });
StatsPipe.Launch(UE_SOURCE_LOCATION, []() { AccumulateStats(1); });   // runs strictly after the previous pipe task
StatsPipe.WaitUntilEmpty();

// Concurrency limiter: at most 3 tasks in flight, each gets a unique slot index for scratch buffers
FTaskConcurrencyLimiter Limiter(3, ETaskPriority::BackgroundHigh);
for (int32 Index = 0; Index < 32; ++Index)
{
    Limiter.Push(UE_SOURCE_LOCATION, [Index](uint32 Slot) { DecompressInto(Index, Slot); });
}
Limiter.Wait(FTimespan::FromSeconds(10.0));

ParallelFor Variants

#include "Async/ParallelFor.h"

TArray<UStaticMesh*> Meshes;

// Basic — equal-cost iterations (Async/ParallelFor.h:526)
ParallelFor(Meshes.Num(), [&Meshes](int32 Index) { ProcessMesh(Meshes[Index]); });

// Named, with MinBatchSize — avoids task overhead on small ranges (:543)
ParallelFor(TEXT("MeshProcess"), Meshes.Num(), 128, [&Meshes](int32 Index) { ProcessMesh(Meshes[Index]); });

// Flags — variable-cost iterations at background priority
ParallelFor(Meshes.Num(), [&Meshes](int32 Index) { ProcessMesh(Meshes[Index]); },
    EParallelForFlags::Unbalanced | EParallelForFlags::BackgroundPriority);

// Per-task context — one FMyScratch per worker task, Body(ContextType&, int32) (:792)
struct FMyScratch
{
    TArray<FVector> TempBuffer;
};
TArray<FMyScratch> Contexts;
ParallelForWithTaskContext(TEXT("GenNormals"), Contexts, Meshes.Num(), 64,
    [&Meshes](FMyScratch& Scratch, int32 Index)
    {
        Scratch.TempBuffer.Reset();
        ComputeNormals(Meshes[Index], Scratch.TempBuffer);
    });
// After the call, Contexts holds every worker's scratch — reduce here on the calling thread.

// Custom context constructor — ContextConstructor(int32 ContextIndex, int32 NumContexts) (:694)
ParallelForWithTaskContext(TEXT("GenNormalsSized"), Contexts, Meshes.Num(),
    [](int32 ContextIndex, int32 NumContexts) { FMyScratch S; S.TempBuffer.Reserve(1024); return S; },
    [&Meshes](FMyScratch& Scratch, int32 Index) { ComputeNormals(Meshes[Index], Scratch.TempBuffer); });

// Reuse contexts you already own (:815)
ParallelForWithExistingTaskContext(MakeArrayView(Contexts), Meshes.Num(), 64,
    [&Meshes](FMyScratch& Scratch, int32 Index) { ComputeNormals(Meshes[Index], Scratch.TempBuffer); });

// Do caller-side work first, then help with the loop (:571)
ParallelForWithPreWork(Meshes.Num(), [&Meshes](int32 Index) { ProcessMesh(Meshes[Index]); },
    []() { PrepareCaches(); });

CVar Async.ParallelFor.DisableOversubscription (backed by GParallelForDisableOversubscription, Async/ParallelFor.h:43) prevents ParallelFor from waking additional workers when the scheduler is already saturated.


Async() with EAsyncExecution Modes

#include "Async/Async.h"
#include "Misc/FileHelper.h"

// ThreadPool — CPU work
TFuture<int32> F1 = Async(EAsyncExecution::ThreadPool, []() { return HeavyCompute(); });

// TaskGraphMainTick — game thread, inside a Tick; safe for UObject code and delegates
TFuture<void> F2 = Async(EAsyncExecution::TaskGraphMainTick, []() { check(IsInGameThread()); });

// Thread — dedicated thread for blocking I/O (FFileHelper::LoadFileToArray, Misc/FileHelper.h:79)
TFuture<TArray<uint8>> F3 = Async(EAsyncExecution::Thread, []()
{
    TArray<uint8> Bytes;
    FFileHelper::LoadFileToArray(Bytes, TEXT("C:/Temp/Input.bin"), 0);
    return Bytes;
});

// Completion callback — runs on the thread that finished the work
TFuture<float> F4 = Async(EAsyncExecution::TaskGraph, []() { return 1.0f; }, []() { NotifyDone(); });

// Convenience wrappers (Async/Async.h:407, :430) — AsyncPool takes FQueuedThreadPool&
TFuture<int32> F5 = AsyncPool(*GThreadPool, []() { return Compute(); }, nullptr, EQueuedWorkPriority::Low);
TFuture<void> F6 = AsyncThread([]() { BlockingIOWork(); }, 0 /*StackSize*/, TPri_Normal);

TPromise/TFuture Producer-Consumer

#include "Async/Future.h"
#include "Async/Async.h"

struct FMyStats
{
    int32 PlayerCount = 0;
    TArray<float> FrameTimes;
};

// Producer — move the promise into the work, hand the future to one consumer below
TFuture<FMyStats> StartGatherStats()
{
    TPromise<FMyStats> Promise;
    TFuture<FMyStats> Future = Promise.GetFuture();           // exactly once per promise

    Async(EAsyncExecution::ThreadPool, [P = MoveTemp(Promise)]() mutable
    {
        FMyStats Stats;
        Stats.PlayerCount = GatherPlayerMetrics();
        GatherFrameMetrics(Stats.FrameTimes);
        P.SetValue(MoveTemp(Stats));                          // or P.EmplaceValue(...)
    });
    return Future;
}

// Consume each future ONE way: Then()/Next() move its state into the continuation and
// invalidate it ("This invalidate this future", Async/Future.h:669); a second call asserts.

// Consumer A — continuation with the future (Then) or the value (Next); runs where the promise was fulfilled
void ConsumeWithThen(TFuture<FMyStats> Future)
{
    Future.Then([](TFuture<FMyStats> Completed)
    {
        const FMyStats& Stats = Completed.Get();              // Get() keeps the future valid
        UE_LOG(LogMyGame, Log, TEXT("Players: %d"), Stats.PlayerCount);
    });
}

// Consumer B — hop to the game thread before touching UObjects
void ConsumeOnGameThread(TFuture<FMyStats> Future, AMyActor* MyActor)
{
    Future.Next([Weak = TWeakObjectPtr<AMyActor>(MyActor)](FMyStats Stats)
    {
        AsyncTask(ENamedThreads::GameThread, [Weak, Stats = MoveTemp(Stats)]()
        {
            if (AMyActor* Actor = Weak.Get()) { Actor->ApplyStats(Stats); }
        });
    });
}

// Consumer C — poll from Tick instead of blocking
void PollFromTick(TFuture<FMyStats>& Future)
{
    if (Future.IsReady()) { FMyStats Stats = Future.Consume(); }   // Consume() moves out and invalidates
}

FTSTicker and FTimerManager

#include "Containers/Ticker.h"
#include "TimerManager.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h"

struct FMyStats;                                               // defined in the TPromise/TFuture example above

UCLASS()
class MYGAME_API AMyActor : public AActor
{
    GENERATED_BODY()

public:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

    void OnFire();
    void OnNextTick();
    void ApplyResult(float Value);
    void ApplyStats(const FMyStats& Stats);

private:
    bool PollNetwork(float DeltaTime);

    FTSTicker::FDelegateHandle PollHandle;
    FTimerHandle FireHandle;
    FTimerHandle OnceHandle;
};

void AMyActor::BeginPlay()
{
    Super::BeginPlay();

    // Engine-wide ticker; return true to keep ticking (Containers/Ticker.h:45)
    PollHandle = FTSTicker::GetCoreTicker().AddTicker(
        FTickerDelegate::CreateUObject(this, &AMyActor::PollNetwork), 0.0f);

    FTimerManager& Timers = GetWorldTimerManager();

    // Method pointer: (Handle, Obj, Method, Rate, bLoop = false, FirstDelay = -1)  TimerManager.h:167
    Timers.SetTimer(FireHandle, this, &AMyActor::OnFire, 1.0f, true);

    // Delegate: (Handle, Delegate, Rate, bLoop, FirstDelay = -1)  TimerManager.h:178
    Timers.SetTimer(OnceHandle, FTimerDelegate::CreateWeakLambda(this, [this]() { OnFire(); }), 3.0f, false);

    // Parameters struct  TimerManager.h:124,211
    FTimerManagerTimerParameters Params;
    Params.bLoop = true;
    Params.bMaxOncePerFrame = true;                            // collapse catch-up ticks after a hitch
    Params.FirstDelay = 0.5f;
    Timers.SetTimer(FireHandle, this, &AMyActor::OnFire, 0.1f, Params);

    // Next tick  TimerManager.h:247
    Timers.SetTimerForNextTick(this, &AMyActor::OnNextTick);
}

void AMyActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    FTSTicker::RemoveTicker(PollHandle);                       // static (Containers/Ticker.h:66)
    GetWorldTimerManager().ClearAllTimersForObject(this);      // TimerManager.h:291
    Super::EndPlay(EndPlayReason);
}

bool AMyActor::PollNetwork(float DeltaTime)
{
    return true;                                               // false removes the ticker
}

void AMyActor::OnFire() {}
void AMyActor::OnNextTick() {}
void AMyActor::ApplyResult(float Value) {}
void AMyActor::ApplyStats(const FMyStats& Stats) {}

Queries (TimerManager.h:304-444): PauseTimer(Handle), UnPauseTimer(Handle), IsTimerActive(Handle), IsTimerPaused(Handle), TimerExists(Handle), GetTimerRate(Handle), GetTimerElapsed(Handle), GetTimerRemaining(Handle). FTimerHandle::IsValid() / Invalidate() (Engine/TimerHandle.h:24,30). Without an Actor: GetWorld()->GetTimerManager() (Engine/World.h:4289) or UGameInstance::GetTimerManager() (Engine/GameInstance.h:424) for timers that must survive level transitions.


Lock-Free Producer/Consumer with TMpscQueue

#include "Containers/MpscQueue.h"

struct FMyMessage
{
    int32 Id = 0;
    FVector Position = FVector::ZeroVector;
};

TMpscQueue<FMyMessage> Messages;               // many producers, exactly one consumer

// Producers — any thread; Enqueue forwards constructor arguments
Messages.Enqueue(FMyMessage{ 7, FVector(1.f, 2.f, 3.f) });

// Consumer — one thread only (game thread Tick)
FMyMessage Msg;
while (Messages.Dequeue(Msg))
{
    HandleMessage(Msg);
}
if (const FMyMessage* Front = Messages.Peek()) { }             // consumer-only peek, nullptr when empty

TSpscQueue<T> (Containers/SpscQueue.h) has the same API for the single-producer case. Neither queue provides Num(); track counts with a std::atomic<int32> if needed.

Source: SKILL.md on GitHub

No alerts2d5 checks · Risk SAFE
  • Gen Agent Trust Hub2d

    This skill provides comprehensive documentation and templates for Unreal Engine threading and async patterns. It focuses on engine-native APIs and best practices for thread safety. No malicious patterns or security risks were identified.

  • Socket2d

    No alerts

  • Snyk2d

    Risk: LOW · No issues

  • Runlayer6mo

    3 files scanned · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at f3742d7. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 3 days ago
metadata
{
  "version": "2.0.0",
  "engine": "5.8"
}

README badge

README badge for quodsoler/unreal-engine-skills/ue-async-threading