UE Async and Threading
Target engine: UE 5.8. APIs below are verified against the 5.8 headers; older forms are listed under "Deprecated — do not use".
This skill covers moving work off the game thread and bringing results back: the UE::Tasks system (Tasks/Task.h, Tasks/Pipe.h), Async/TFuture (Async/Async.h, Async/Future.h), thread-pool FAsyncTask (Async/AsyncWork.h), ParallelFor, dedicated FRunnable threads, locks and lock-free queues, FTSTicker and FTimerManager. Everything except timers is in the Core module; FTimerManager is in Engine; ENQUEUE_RENDER_COMMAND is in RenderCore. Build.cs: PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" }); and add "RenderCore" only when enqueuing render commands.
Context
Read .agents/ue-project-context.md if it exists (module names, conventions, enabled plugins, GAS/networking setup). Do not stop if it is missing.
Identify the area from the request and the codebase. Ask only when two plausible readings would produce different code.
Threading Model
Golden rule: UObjects are game-thread-only. Compute off-thread on plain data, then apply results on the game thread through a TWeakObjectPtr (see Thread Safety Rules).
Pattern Selection
UE::Tasks
Handle API (Tasks/Task.h:36-95): IsValid(), IsCompleted(), Wait(), Wait(FTimespan Timeout) returns bool, TryRetractAndExecute() runs the task inline if not started, GetResult() waits then returns ResultType& (check(IsValid())). Free functions: Wait(FTask&), Wait(Collection, FTimespan), WaitAny(Collection, Timeout) returns the index, Any(Collection) returns an FTask completed when any input completes, AddNested(Task) from inside a running task so the parent is not complete until the nested one is.
Priorities (Async/Fundamental/Task.h:20, Tasks/TaskPrivate.h:59-93): ETaskPriority::High | Normal (= Default) | BackgroundHigh | BackgroundNormal | BackgroundLow | Inherit. EExtendedTaskPriority::None | Inline | TaskEvent | GameThreadNormalPri | GameThreadHiPri | GameThreadNormalPriLocalQueue | GameThreadHiPriLocalQueue | RenderThreadNormalPri | RenderThreadHiPri | RHIThreadNormalPri | RHIThreadHiPri (plus LocalQueue variants). ETaskFlags::None | DoNotRunInsideBusyWait. Passing EExtendedTaskPriority::GameThreadNormalPri runs the body on the game thread.
A legacy TaskGraph FGraphEventRef can be passed directly as the prerequisites argument of Launch (Tasks/TaskPrivate.h:266), and collections of them work in WaitAny/Any (Tasks/Task.h:417, 468), so UE::Tasks can wait on TaskGraph work. Full chained example: threading-patterns.md [blocked].
FPipe and FTaskConcurrencyLimiter
FPipe executes its tasks one after another (FIFO when no extra prerequisites), so it replaces a dedicated thread guarding a resource. The pipe must outlive its last task; ~FPipe() asserts !HasWork().
FPipe::Launch(const TCHAR*, TaskBody, [Prerequisites,] ETaskPriority = Default, EExtendedTaskPriority = None, ETaskFlags = None) (Tasks/Pipe.h:63,90). FTaskConcurrencyLimiter may be destroyed before its tasks finish; its Wait is satisfied once and never re-arms (Tasks/TaskConcurrencyLimiter.h:171-212).
Async, TFuture and AsyncTask
TFuture<T> (Async/Future.h:210-440): Get(), IsReady(), IsValid(), Wait(), WaitFor(const FTimespan&), WaitUntil(const FDateTime&), Then(Func) receives TFuture<T>, Next(Func) receives T (both move the state out and invalidate this future, Async/Future.h:669), Consume() moves the value out and invalidates, Share() gives TSharedFuture<T>, Reset(). TPromise<T> (:527): GetFuture(), SetValue(const T&), SetValue(T&&), EmplaceValue(Args&&...). Continuations run on whichever thread completes the promise — hop to the game thread explicitly.
FAsyncTask and FAutoDeleteAsyncTask
Reusable work unit on a FQueuedThreadPool. Subclass FNonAbandonableTask (Async/AsyncWork.h:666), implement DoWork() and GetStatId(). FAsyncTask<T> constructs T inside its own constructor, so with the friend declaration T's constructor may be private (the engine's own example in AsyncWork.h:26-42 does this). Members your code reads through Task->GetTask() must be public, because that access happens outside the friend.
Other members (Async/AsyncWork.h:415-558): StartSynchronousTask(...) runs inline; Cancel() returns true if it was still queued; WaitCompletionWithTimeout(float TimeLimitSeconds); IsWorkDone() is the cheap non-blocking check. Pass GBackgroundPriorityThreadPool as the pool for low-priority work. Full template: threading-patterns.md [blocked].
ParallelFor
Also available: ParallelForTemplate(...) (no TFunctionRef indirection, :496), ParallelForWithPreWork(...) (:571, run caller-side work before helping), ParallelForWithTaskContext(OutContexts, Num, ContextConstructor, Body, Flags) (:722), ParallelForWithExistingTaskContext(TArrayView<ContextType> Contexts, Num, MinBatchSize, Body, Flags) (:815). The caller participates and blocks until every iteration finishes. CVar Async.ParallelFor.DisableOversubscription (GParallelForDisableOversubscription, Async/ParallelFor.h:43) stops ParallelFor from waking extra workers.
FRunnable and FRunnableThread
Use only for a dedicated, long-lived thread. Lifecycle on the new thread: Init() → Run() → Exit(). Stop() is called from outside by Kill(); it must only signal.
EThreadPriority (GenericPlatform/GenericPlatformAffinity.h:25): TPri_Normal, TPri_AboveNormal, TPri_BelowNormal, TPri_Highest, TPri_Lowest, TPri_SlightlyBelowNormal, TPri_TimeCritical. Always Kill(true) then delete the FRunnableThread*; killing without waiting leaks and can deadlock (header comment HAL/RunnableThread.h:77-79). Sleep with FPlatformProcess::Sleep(float Seconds) or block on an FEventRef instead of spinning. Full template with shutdown: threading-patterns.md [blocked].
Synchronization
Events: FEventRef Event(EEventMode::AutoReset) (HAL/Event.h:129-139) is the RAII pooled FEvent: Event->Trigger(), Event->Wait(), Event->Wait(uint32 WaitTimeMs), Event->Reset(). Raw pooling: FEvent* E = FPlatformProcess::GetSynchEventFromPool(bool bIsManualReset = false) / FPlatformProcess::ReturnSynchEventToPool(E) (GenericPlatformProcess.h:786,799). UE::FManualResetEvent (Async/ManualResetEvent.h): Notify(), Wait(), WaitFor(FMonotonicTimeSpan), Reset(). Between tasks prefer UE::Tasks::FTaskEvent — waiting on it does not block a worker.
Atomics: std::atomic<T> (<atomic>, already included by Templates/Atomic.h). FThreadSafeCounter, FThreadSafeBool and TAtomic are marked deprecated in their headers (see Deprecated). Use std::memory_order_relaxed for pure flags and counters, acquire/release when the atomic publishes other data.
Queues: TMpscQueue<T> (Containers/MpscQueue.h) and TSpscQueue<T> (Containers/SpscQueue.h): Enqueue(Args&&...), bool Dequeue(T& Out), TOptional<T> Dequeue(), T* Peek(), IsEmpty(). Single consumer only. TQueue<T, EQueueMode> still compiles but is marked "planned for deprecation" (Containers/Queue.h:11).
Shared pointers: TSharedPtr, TSharedRef, TWeakPtr and MakeShared default to ESPMode::ThreadSafe (Templates/SharedPointerFwd.h:24-27, SharedPointer.h:2110); the refcount is atomic, the pointee is not protected. Opt into ESPMode::NotThreadSafe only for hot single-thread paths.
Tickers and Timers
Both run on the game thread. FTSTicker is engine-wide and survives level changes; FTimerManager is per UWorld and pauses with it.
FTickerDelegate is DECLARE_DELEGATE_RetVal_OneParam(bool, FTickerDelegate, float) (Containers/Ticker.h:21). Subclass FTSTickerObjectBase and override virtual bool Tick(float DeltaTime) = 0 for an object that registers itself (:136-158).
FTimerDelegate is TDelegate<void(), FNotThreadSafeNotCheckedDelegateUserPolicy> (TimerManager.h:23); create it with CreateUObject, CreateWeakLambda, CreateLambda, CreateSP, CreateStatic (Delegates/DelegateSignatureImpl.inl). SetTimer overloads take a method pointer, FTimerDelegate, FTimerDynamicDelegate, TFunction<void(void)>&&, or no callback (handle-only countdown). Blueprint-facing: UKismetSystemLibrary::K2_SetTimer(UObject* Object, FString FunctionName, float Time, bool bLooping, bool bMaxOncePerFrame = false, float InitialStartDelay = 0.f, float InitialStartDelayVariance = 0.f) and K2_ClearAndInvalidateTimerHandle(const UObject* WorldContextObject, UPARAM(ref) FTimerHandle& Handle) (Kismet/KismetSystemLibrary.h:902,832). Timer examples: threading-patterns.md [blocked].
Thread Safety Rules
- Game thread only: any
UPROPERTYread or write,UFUNCTIONcall,GetWorld(), spawning, destroying, component changes, delegates on UObjects, timers, tickers. Guard entry points withcheck(IsInGameThread()). - Never capture raw
UObject*orthisinto deferred work. CaptureTWeakObjectPtr<T>(UObject/WeakObjectPtrTemplates.h:25) and resolve withGet()on the game thread, or build delegates withCreateWeakLambda. - GC can run between the launch and the callback.
FGCScopeGuard(UObject/GarbageCollection.h:117) blocks GC for a scope; use it only for short read-only access from a worker, never around blocking waits. - Render thread:
ENQUEUE_RENDER_COMMAND(MyCommand)([Data](FRHICommandListImmediate& RHICmdList) { UploadOnRenderThread(RHICmdList, Data); });(RenderCore/Public/RenderingThread.h:1087, moduleRenderCore);FlushRenderingCommands()from the game thread drains it. Check withIsInRenderingThread(). - Shared data needs its own lock even inside a thread-safe
TSharedPtr; the refcount is atomic, the payload is not. - Prefer lock-free hand-off:
TMpscQueue,std::atomic, double-buffering, or oneFPipeper resource.
Full patterns, lock ordering, double buffering, FScopedSlowTask and sanitizer notes: thread-safety-guide.md [blocked]. Async asset loading (FStreamableManager::RequestAsyncLoad) belongs to ue-data-assets-tables.
Deprecated — do not use
Common Mistakes
Touching a UObject from a worker: GC and other game-thread writes race with you.
Blocking the game thread right after launching: Task.GetResult() or Future.Get() on the next line turns async into sync. Poll IsCompleted()/IsReady() in Tick, chain with Prerequisites, or hop back with AsyncTask(ENamedThreads::GameThread, Lambda).
Private members in an FNonAbandonableTask: friend class FAsyncTask<T> covers the constructor (it runs inside FAsyncTask), but not GetTask().Result read from your code. Make the result members public.
Nested FRWLock acquisition: FRWLock is not recursive; a read lock inside a read lock (or a write inside a read) deadlocks. Acquire once per call path or switch to FCriticalSection.
Shared mutable state inside ParallelFor:
Destroying an FPipe or FRunnable owner with work in flight: call Pipe.WaitUntilEmpty() and Thread->Kill(true) before the destructor body runs; ~FPipe() asserts !HasWork().
Blocking inside FRunnable::Stop(): Stop() runs on the caller's thread while Run() is still executing; only set an atomic flag or trigger an event, then let Kill(true) wait.
Raw-delegate timers on a dying actor: FTimerDelegate::CreateLambda([this]{}) keeps calling after EndPlay; use CreateWeakLambda/CreateUObject and ClearAllTimersForObject(this).
Related Skills
ue-cpp-foundations—TSharedPtr/TWeakObjectPtr/TStrongObjectPtrsemantics, GC lifetime, subsystems tableue-data-assets-tables—FStreamableManager,UAssetManager, async asset loading and soft referencesue-testing-debugging— Unreal Insights task and thread traces,statcommands, logging, automation tests for async codeue-procedural-generation— long-running generation onFAsyncTask/UE::Tasks,ProceduralMeshComponenthand-off to the game threadue-mass-entity—ParallelForEachEntityChunk,EParallelExecutionFlags, processor threading rulesue-networking-replication— RPC and replication callbacks always run on the game threadue-blueprint-cpp-interop— exposing C++ to Blueprint: UFUNCTION/UPROPERTY meta keys, latent actions and async nodesue-niagara-effects— Niagara systems, user parameters, data interfaces and data channelsue-serialization-savegames— USaveGame, FArchive, actor snapshots and config persistence


