移动/游戏
Unreal Engine 异步与多线程任务:安全选择…
在 Unreal Engine 中需要后台计算、并行执行、线程同步或任务调度时,选择合适异步 API 并安全回传游戏线程。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 38/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 55/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 60/100 ×0.15
适用场景
Unreal Engine 异步与多线程特定
解决思路
- 先读取 `.agents/ue-project-context.md`,确认引擎版本:`UE::Tasks::Launch` 是 UE 5.0+ 现代首选 API,`FAsyncTask` 和 TaskGraph 仍完全支持;明确要卸载的工作、是否需要 UObject 访问、可接受的延迟/吞吐权衡。
- 向用户确认不清楚的点:卸载类型(CPU-bound 计算、I/O 等待、周期后台工作)、UObject 交互(后台是否读写 UObject 状态)、生命周期(一次性、重复、长驻线程)、结果交付(fire-and-forget 还是游戏线程需要结果回传)。
- 理解 UE 线程模型:Game Thread(所有 UObject 访问、Blueprint 执行、gameplay 逻辑,用 `IsInGameThread()` 检查)、Render Thread(渲染命令、场景代理更新,用 `IsInRenderingThread()` 检查)、RHI Thread(GPU 命令提交,平台相关)、Worker Threads(未命名池线程,数量随 CPU 核数扩展)。
- 遵守黄金规则:UObject 仅限 game thread;后台线程不要读取 UPROPERTY、不要调用 UFUNCTION、不要调用 `GetWorld()`、不要生成 UObject;违反会导致依赖 GC 时序的间歇性崩溃。
- 按需求选择最简单 API:`AsyncTask(GameThread, Lambda)` 用于后台派发到游戏线程,一次性,无结果;`UE::Tasks::Launch` 用于通用异步(UE5+ 首选),一次性,返回 `TTask<T>`;`Async(EAsyncExecution, Lambda)` 用于灵活调度并返回 `TFuture`,一次性;`FAsyncTask<T>` 用于可复用池化工作单元;`FAutoDeleteAsyncTask<T>` 用于即发即弃池化工作;`TGraphTask<T>` 用于复杂依赖图,一次性,返回 `FGraphEvent`;`ParallelFor` 用于数据并行循环,阻塞,无结果;`FRunnable + FRunnableThread` 用于长驻专用线程,持久,手动管理。
- 仅在需要专用长驻线程(如 socket listener、file watcher、连续处理循环)时使用 `FRunnable`;一次性工作优先用 `UE::Tasks::Launch` 或 `FAsyncTask`。
- 实现 `FRunnable` 生命周期:`Init()`(新线程)-> `Run()`(新线程)-> `Exit()`(新线程,在 Run 返回后);`Stop()` 由外部调用请求关闭。
- 使用 `FRunnableThread::Create` 签名:`static FRunnableThread* Create(FRunnable*, const TCHAR* ThreadName, uint32 StackSize = 0, EThreadPriority = TPri_Normal, uint64 AffinityMask, EThreadCreateFlags)`。
- `FRunnable` 关键点:`Stop()` 只发信号,不阻塞;`Kill(true)` 调用 `Stop()` 后等待完成;`Kill` 后始终 `delete` `FRunnableThread*`;在 `Run()` 循环中使用 `std::atomic<bool> bShouldStop`,并在 `Stop()` 中设置它;完整模板见 `references/threading-patterns.md`。
- 对引擎线程池 `GThreadPool` 上的可复用工作单元,使用 `FAsyncTask`/`FAutoDeleteAsyncTask`:继承 `FNonAbandonableTask` 并实现 `DoWork()` + `GetStatId()`。
- 定义 `FAsyncTask` 任务类:`class FMyComputeTask : public FNonAbandonableTask`,`friend class FAsyncTask<FMyComputeTask>`,构造函数用 `MoveTemp` 接收数据,`DoWork()` 执行计算,`GetStatId()` 用 `RETURN_QUICK_DECLARE_CYCLE_STAT(FMyComputeTask, STATGROUP_ThreadPoolAsyncTasks)`。
- 可复用 `FAsyncTask` 用法:`auto* Task = new FAsyncTask<FMyComputeTask>(MoveTemp(Data)); Task->StartBackgroundTask(); Task->EnsureCompletion(); int32 R = Task->GetTask().Result; delete Task;`。
- 即发即弃 `FAsyncTask` 用法:`(new FAutoDeleteAsyncTask<FMyComputeTask>(MoveTemp(Data)))->StartBackgroundTask();`;`IsWorkDone()` 是非阻塞完成检查;`Cancel()` 在尚未启动时阻止执行;`StartSynchronousTask()` 在调用线程内联运行。
- 对复杂依赖链使用 TaskGraph:每个任务声明前置条件,调度器处理顺序;任务类实现 `GetSubsequentsMode()` 返回 `ESubsequentsMode::TrackSubsequents`,`GetDesiredThread()` 返回 `ENamedThreads::AnyThread`,`GetStatId()`,以及 `DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)`。
- 用前置条件派发 TaskGraph:`FGraphEventArray Prerequisites; Prerequisites.Add(SomePriorEvent); FGraphEventRef TaskEvent = TGraphTask<FMyGraphTask>::CreateTask(&Prerequisites).ConstructAndDispatchWhenReady(42); FTaskGraphInterface::Get().WaitUntilTaskCompletes(TaskEvent, ENamedThreads::GameThread);`。
- 快速派发无需自定义类时使用:`AsyncTask(ENamedThreads::GameThread, [this]() { MyActor->UpdateHealth(NewValue); });`。
- UE 5.0+ 新代码推荐 `UE::Tasks::Launch`:包含 `#include "Tasks/Task.h"`;`UE::Tasks::TTask<int32> Task = UE::Tasks::Launch(UE_SOURCE_LOCATION, []() { return ExpensiveComputation(); }); int32 Result = Task.GetResult();`。
- 用 `UE::Tasks::Prerequisites` 建立依赖:`UE::Tasks::TTask<FVector> TaskA = UE::Tasks::Launch(UE_SOURCE_LOCATION, []() { return ComputePosition(); }); UE::Tasks::TTask<void> TaskB = UE::Tasks::Launch(UE_SOURCE_LOCATION, [&TaskA]() { ProcessPosition(TaskA.GetResult()); }, UE::Tasks::Prerequisites(TaskA));`。
- 使用 `TTask<T>` API:`GetResult()` 阻塞并返回结果;`IsCompleted()` 非阻塞;`Wait()` / `Wait(FTimespan)` 定时阻塞;`TryRetractAndExecute()` 在尚未启动时内联运行(work stealing);`FTaskEvent` 用于手动同步,调用 `Trigger()` 解除依赖任务阻塞。
- 用 `Async()` 做最灵活的一次性调度,返回 `TFuture<T>` 并控制执行上下文:`TFuture<FMyResult> Future = Async(EAsyncExecution::ThreadPool, []() -> FMyResult { return ComputeResult(); }, []() { /* completion callback */ }); FMyResult R = Future.Get();`;`Get()` 会阻塞但不会使 future 失效。
- 按执行上下文选择 `EAsyncExecution`:`TaskGraph` 经 TaskGraph 的 Worker;`TaskGraphMainThread` 经 TaskGraph 的 Game Thread;`Thread` 新建专用线程;`ThreadPool` 使用 `GThreadPool` worker;`LargeThreadPool` 使用 `GLargeThreadPool`(仅 WITH_EDITOR)。
- 使用便捷函数:`AsyncPool(GThreadPool, Lambda)`、`AsyncThread(Lambda, StackSize, Priority)`。
- 使用 `TFuture<T>` API:与 `std::future` 不同,`Get()` 不会失效,可安全多次调用;`Consume()` 像 `std::future::get()` 一样失效;`IsReady()` 非阻塞检查;`Wait()` / `WaitFor(FTimespan)` 阻塞但不消费;`Then(Continuation)` / `Next(Continuation)` 链式续接,续接可在任意线程运行;`Share()` 转换为 shared future。
- 用 `TPromise<T>` 解耦生产者-消费者:`TPromise<FMyData> Promise; TFuture<FMyData> Future = Promise.GetFuture();`(只调用一次);在异步中 `Async(EAsyncExecution::ThreadPool, [P = MoveTemp(Promise)]() mutable { P.SetValue(GenerateData()); });`;在游戏线程 `FMyData Result = Future.Get();`。
- 对每次迭代独立的数据并行循环使用 `ParallelFor`:`ParallelFor(Meshes.Num(), [&Meshes](int32 Index) { ProcessMesh(Meshes[Index]); });`;调用线程参与,函数阻塞直到所有迭代完成。
- 用 `MinBatchSize` 避免小工作量开销:`ParallelFor(TEXT("ProcessMeshes"), Meshes.Num(), 64, [&Meshes](int32 Index) { ProcessMesh(Meshes[Index]); });`。
- 按需设置 `EParallelForFlags`:`None`=0 默认;`ForceSingleThread`=1 调试时顺序运行;`Unbalanced`=2 迭代成本可变;`PumpRenderingThread`=4 等待时泵送渲染命令;`BackgroundPriority`=8 降低 worker 优先级。
- 当 worker 需要 scratch memory 时用 `ParallelForWithTaskContext` 提供每 worker 上下文对象,避免每次迭代分配。
- 游戏线程安全:UE 线程 bug 是静默的,会破坏状态、引发 GC 竞争,并且只在负载下复现;完整模式见 `references/thread-safety-guide.md`。
- 遵守 UObject 访问规则:所有 UObject 访问必须在 game thread,后台线程不读、不写、不调用函数;GC 可在 tick 之间销毁 UObject,lambda 捕获的裸 `UObject*` 可能悬空;生成、销毁、修改组件仅限 game thread。
- 使用安全回传模式:`AsyncTask(ENamedThreads::GameThread, [WeakActor = TWeakObjectPtr<AActor>(MyActor)]() { if (AActor* Actor = WeakActor.Get()) { Actor->UpdateFromBackgroundWork(NewData); } });`;始终捕获 `TWeakObjectPtr`,绝不捕获裸 `UObject*`。
- 非 UObject 共享数据跨线程时用 `TSharedPtr<T, ESPMode::ThreadSafe>`;默认 `ESPMode::NotThreadSafe` 的引用计数非原子。
- 用 `FCriticalSection`(递归互斥锁,等同于 `UE::FPlatformRecursiveMutex`)保护共享数据:同线程可多次加锁而不死锁;用 `FScopeLock Lock(&DataLock);` 进行 RAII 加解锁,例如 `void AddPosition(const FVector& Pos) { FScopeLock Lock(&DataLock); SharedPositions.Add(Pos); }`。
- 用 `FRWLock` 读写锁:允许多个读者或一个独占写者;它不是递归锁,不要嵌套;读用 `FReadScopeLock RL(CacheLock)`,写用 `FWriteScopeLock WL(CacheLock)`。
- 线程信号优先用 `FEventRef` 而不是裸 `FEvent*`:`FEventRef WorkReady(EEventMode::AutoReset); WorkReady->Trigger(); WorkReady->Wait();`。
- 不要用已废弃的 `FThreadSafeCounter` 和 `FThreadSafeBool`;直接使用 `std::atomic<int32>` 和 `std::atomic<bool>`。
- 用 `TQueue` 做无锁生产者-消费者:`TQueue<FMyMessage, EQueueMode::Mpsc> MessageQueue;`;生产者任意线程 `MessageQueue.Enqueue(FMyMessage{...});`;消费者游戏线程 tick 中 `FMyMessage Msg; while (MessageQueue.Dequeue(Msg)) { ProcessMessage(Msg); }`;`Spsc` 单生产者单消费者稍快;`Mpsc` 多生产者单消费者最常用;`Peek()` 只读不出队。
- 避免从后台线程访问 UObject 并直接修改:错误做法是 `Async(EAsyncExecution::ThreadPool, [this]() { MyActor->Health = ComputeNewHealth(); });`;正确做法是在线程池计算,再用 `AsyncTask(ENamedThreads::GameThread, ...)` 和 `TWeakObjectPtr` 回游戏线程应用结果。
- 避免跨线程使用默认 `ESPMode` 的 `TSharedPtr`:错误做法是 `auto Data = MakeShared<FMyData>();`;正确做法是 `auto Data = MakeShared<FMyData, ESPMode::ThreadSafe>();`。
- 避免嵌套获取 `FRWLock` 导致死锁:错误做法是 `FReadScopeLock Outer(Lock); FReadScopeLock Inner(Lock);`;正确做法是一次获取,完成所有读取后释放。
- 避免 `ParallelFor` 中共享可变状态:错误做法是 `int32 Total = 0; ParallelFor(Data.Num(), [&](int32 i) { Total += Data[i]; });`;正确做法是 `std::atomic<int32> Total{0}; ParallelFor(Data.Num(), [&](int32 i) { Total.fetch_add(Data[i], std::memory_order_relaxed); });`。
- 相关技能:涉及 `TSharedPtr`、`TWeakObjectPtr`、GC 生命周期和智能指针规则时看 `ue-cpp-foundations`;涉及游戏线程 tick 流和 Actor 生命周期时看 `ue-gameplay-framework`;涉及 RPC 派发线程和复制回调时看 `ue-networking-replication`;涉及 `FStreamableManager` 异步加载和软引用时看 `ue-data-assets-tables`。
适用边界 · 注意事项
- 网络异步(RPC、复制)不适用本技能,应使用 `ue-networking-replication`。
- 资产流送/异步加载不适用本技能,应使用 `ue-data-assets-tables`。
- 若只需一次性短任务,不要用 `FRunnable`/`FRunnableThread`,优先 `UE::Tasks::Launch` 或 `FAsyncTask`,避免过度设计。