背景 本篇为UE多线程的使用与底层代码实现的问题。多线程是优化项目性能的重要方式之一。
在系统组工作过程中,听到最多的是优化是对于Flush的禁止工作。防止Loading资源的时候占用资源,这是一个庞大且精细的过程,在顶层编码的时候也需要去注意某些Sync操作是否会引发Flush。Flush这种同步加载的堵塞操作,需要去分析Flush的操作处理。故有此篇作为基础篇,里面涉及到部分的os的基础概念的实现,后续可以去复习一下os的pv操作部分。
本章梳理一下对UE多线程的顶层调用处理。
2025.11.06 关于FQueuedThreadPool 和TaskGraph 后续再研究,埋个坑。
引擎的线程 在创建自己的多线程之前,需要先了解UE本身已经运行在多个线程上了,最主要的是:
游戏线程(Game Thread):
这是最主要的线程,大部分的蓝图和C++游戏逻辑都在这里执行。
负责处理玩家输入、Actor的Tick、蓝图逻辑、物理模拟的触发(但计算本身不在主线程)、游戏状态更新等。 当这个线程的任务太重,游戏就会卡顿,帧率(FPS)下降。
渲染线程(Render Thread)
负责接收游戏线程传来的渲染指令(称为”渲染命令”) ,并将其提交给GPU执行。
它和游戏线程是并行工作的。游戏线程在准备下一帧的数据时,渲染线程正在提交上一帧的渲染命令。它们之间通过一个”渲染命令队列”进行通信。
RHI线程(Rendering Hardware Interface Thread):
在部分平台和配置下,渲染命令会进一步从一个渲染线程分发到RHI线程,再由RHI线程与GPU驱动通信,进一步减轻渲染线程的负担。
RHI线程通过配置来控制是否开启。
多线程工具 对各个平台线程实现进行了封装,抽象出了FRunnable ,引擎中大部分都是继承与这个类经常处理。
FRunnable 这种方式的核心包含三个结构,分别是FRunnable、FRunnableThread以及FThreadManager
Runnable
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #include "HAL/Runnable.h" class MyRunnable : public FRunnable {public : virtual bool Init () override ; virtual uint32 Run () override ; virtual void Stop () override ; virtual void Exit () override ; }; bool MyRunnable::Init () { return true ; }uint32 MyRunnable::Run () { return 0 ; }void MyRunnable::Stop () {}void MyRunnable::Exit () {}
FRunnableThread
创建调用FRunnableThread::Create 这是一个静态工厂方法,用于完成FRunnableThread的创建
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ------------------------RunnableThread.h---------------------- #include "HAL/RunnableThread.h" static FRunnableThread * Create ( class FRunnable * InRunnable, const TCHAR * ThreadName, uint32 InStackSize, EThreadPriority InThreadPri, uint64 InThreadAffinityMask ) ;------------------------------------------------------------------------------ #include "HAL/RunnableThread.h" FRunnable * Runnable = new MyRunnable (); FRunnableThread* RunnableThread = FRunnableThread::Create (Runnable, TEXT ("LaLaLaDeMaXiYa!" ));
对于FRunnableThread的工作有:
调用各个平台内部的 API 创建线程
调用可执行体的 Init()、Run()、Exit()
提供管理线程生命周期的各种方法
兼容不支持多线程的平台:不过这个得在实现自定义 FRunnable 的时候,同时继承 FSingleThreadRunnable 并重载 Tick() 方法,使用 Tick 来调用可执行体
FThreadManager 通过FRunnableThread 创建的线程是通过 FThreadManager 进行统一管理
CreateInternal根据平台的不同实现不同,常用平台中,Android和iOS都是采用的 pthread标准线程库,Windows平台是单独实现的。线程创建完毕后会统一调用
1 FThreadManager::Get ().AddThread (ThreadID, this );
线程本身添加至管理器。如 WindowsRunnableThread.h
FRunnableThreadWin::CreateInternal 函数。标准线程对象 FRunnableThreadPThread 则是在入口点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 virtual PthreadEntryPoint GetThreadEntryPoint () { return _ThreadProc; } static void *STDCALL _ThreadProc(void *pThis) { check (pThis); FRunnableThreadPThread* ThisThread = (FRunnableThreadPThread*)pThis; ThisThread->ThreadID = FPlatformTLS::GetCurrentThreadId (); FThreadManager::Get ().AddThread (ThisThread->ThreadID, ThisThread); FPlatformProcess::SetThreadAffinityMask (ThisThread->ThreadAffinityMask); ThisThread->PreRun (); ThisThread->Run (); ThisThread->PostRun (); pthread_exit (NULL ); return NULL ; }
有以下API:
FThreadManager::AddThread
FThreadManager::RemoveThread
FThreadManager::ForEachThread
FThreadManager::Tick
对fake thread及其对应的runnable objects进行tick
在FEngineLoop::Tick方法中被调用
AsyncTask 这是一种基于线程池 (利用ue4底层的线程池机制来调度任务)的异步任务处理系统,这套系统同样是基于Runnable 实现的,在实际工作中,我们经常会遇到需要将部分代码放在特定的线程中进行执行
注:Andriod多线程开发里面也会用到AsyncTask,二者的实现原理非常相似。
这个方法调用GraphTask创建了一个立刻执行的任务,可以看成是是TaskGraph的简单版本。 这个需要执行的任务可以指定执行的线程,需要注意的是,如果某个任务从AnyThread改成GameThread执行,AsyncTask下面的代码也是不会阻塞的。这个时候还是单线程,只是传入的Lambda方法会在主线程一帧里的其他地方调用,GameThread执行由于没有线程切换,因此整体时间消耗会少于AnyThread,不过问题则在于加长了整体的单帧时间消耗。
FQueuedThreadPool UE里面的线程池,FQueuedThreadPool。和一般的线程池实现类似,线程池里面维护了多个线程FQueuedThread与多个任务队列IQueuedWork,线程是按照队列的方式来排列的。在引擎PreInit的时候执行相关的初始化操作,代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 if (FPlatformProcess::SupportsMultithreading ()){ { GThreadPool = FQueuedThreadPool::Allocate (); int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfWorkerThreadsToSpawn (); if (FPlatformProperties::IsServerOnly ()) { NumThreadsInThreadPool = 1 ; } verify (GThreadPool->Create (NumThreadsInThreadPool, 128 * 1024 )); } #ifUSE_NEW_ASYNC_IO { GIOThreadPool = FQueuedThreadPool::Allocate (); int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfIOWorkerThreadsToSpawn (); if (FPlatformProperties::IsServerOnly ()) { NumThreadsInThreadPool = 2 ; } verify (GIOThreadPool->Create (NumThreadsInThreadPool, 16 * 1024 , TPri_AboveNormal)); } #endif #ifWITH_EDITOR GLargeThreadPool = FQueuedThreadPool::Allocate (); int32 NumThreadsInLargeThreadPool = FMath::Max (FPlatformMisc::NumberOfCoresIncludingHyperthreads () - 2 , 2 ); verify (GLargeThreadPool->Create (NumThreadsInLargeThreadPool, 128 * 1024 )); #endif }
使用AsyncTask 创建一个FTestAsycTask类继承与FNonAbandonableTask
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 class FTestAsyncTask : public FNonAbandonableTask{ friend class FAutoDeleteAsyncTask <FTestAsyncTask>; int32 TargetCounter; double OldTime; public : FTestAsyncTask (int32 Target) : TargetCounter (Target) , OldTime (0 ) { } void DoWork () { for (int32 i = 0 ; i < TargetCounter; ++i) { FPlatformProcess::Sleep (0.01f ); } UE_LOG (LogTemp, Log, TEXT ("FTestAsyncTask::DoWork() 完成。处理了 %d 个计数。" ), TargetCounter); } FORCEINLINE TStatId GetStatId () const { RETURN_QUICK_DECLARE_CYCLE_STAT (FTestAsyncTask, STATGROUP_ThreadPoolAsyncTasks); } };
为什么要继承FNonAbandonableTask?
当线程池被销毁的时候,会调用Abandon函数。继承FNonAbandonableTask的话这个时候就不会丢弃而且等待执行完。如果需要丢弃则不继承,并且自己实现CanAbandon 和Abandon 函数。源码里可丢弃的任务参考:FAsyncStatsFile 。
StartBackgroundTask 和StartSynchronousTask
StartBackgroundTask 会利用线程池里空闲的线程来执行。
StartSynchronousTask 则是主线程执行。
只有Synchronous以后主线程是会等AsyncTask里面的逻辑执行完了之后才会继续往下走。而使用Background主线程不会阻塞。
AsyncTask系统实现的多线程与你自己字节继承FRunnable实现的原理相似,还可以利用UE4提供的线程池。当使用多线程不满意时也可以调用StartSynchronousTask改成主线程执行。
Async Async除了上述几种方式,还有AsyncTask、Async、AsyncThread、AsyncPool等几个全局方法 。
核心:Async:通用异步执行。/AsyncTask:使用TaskGraph执行异步操作。/AsyncThread:使用单独的线程执行异步操作。/AsyncPool:使用线程池执行异步操作。
Async全局方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 #include "TestAsyncActor.h" #include "TestAsyncTask.h" ATestAsyncActor::ATestAsyncActor () { PrimaryActorTick.bCanEverTick = true ; } void ATestAsyncActor::TestAsyncTaskClass () { OldTime = FPlatformTime::Seconds (); (new FAutoDeleteAsyncTask <FTestAsyncTask>(TargetCounter))->StartBackgroundTask (); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT (__FUNCTION__"@%u wait millisecond(%f) main thread end." ), __LINE__, (End-OldTime)*1000 ); } void ATestAsyncActor::TestAsyncTaskClass_Synchronous () { OldTime = FPlatformTime::Seconds (); (new FAutoDeleteAsyncTask <FTestAsyncTask>(TargetCounter))->StartSynchronousTask (); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT (__FUNCTION__"@%u wait millisecond(%f) main thread end." ), __LINE__, (End-OldTime)*1000 ); } void ATestAsyncActor::TestAsyncTaskFunc_AnyThread () { OldTime = FPlatformTime::Seconds (); AsyncTask (ENamedThreads::AnyThread, [=]() { double Result = 0 ; for (int32 i = 0 ; i < TargetCounter; i++) { float j = i; Result += FMath::Sqrt (j) / TargetCounter; } UE_LOG (LogTemp, Log, TEXT ("@%u wait TargetCounterR(%f)" ), __LINE__, Result); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT ("@%u wait millisecond(%f) end." ), __LINE__, (End-OldTime)*1000 ); }); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT (__FUNCTION__"@%u wait millisecond(%f) main thread end." ), __LINE__, (End-OldTime)*1000 ); } void ATestAsyncActor::TestAsyncTaskFunc_GameThread () { OldTime = FPlatformTime::Seconds (); AsyncTask (ENamedThreads::GameThread, [=]() { double Result = 0 ; for (int32 i = 0 ; i < TargetCounter; i++) { float j = i; Result += FMath::Sqrt (j) / TargetCounter; } UE_LOG (LogTemp, Log, TEXT ("@%u wait TargetCounterR(%f)" ), __LINE__, Result); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT ("@%u wait millisecond(%f) end." ), __LINE__, (End-OldTime)*1000 ); }); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT (__FUNCTION__"@%u wait millisecond(%f) main thread end." ), __LINE__, (End-OldTime)*1000 ); } void ATestAsyncActor::TestAsyncFunc_NoReturn () { OldTime = FPlatformTime::Seconds (); Async (EAsyncExecution::TaskGraph, [=]() { double Result = 0 ; for (int32 i = 0 ; i < TargetCounter; i++) { float j = i; Result += FMath::Sqrt (j) / TargetCounter; } UE_LOG (LogTemp, Log, TEXT ("@%u wait TargetCounterR(%f)" ), __LINE__, Result); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT ("@%u wait millisecond(%f) end." ), __LINE__, (End-OldTime)*1000 ); }); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT (__FUNCTION__"@%u wait millisecond(%f) main thread end." ), __LINE__, (End-OldTime)*1000 ); } void ATestAsyncActor::TestAsyncFunc_WithReturn () { OldTime = FPlatformTime::Seconds (); TFuture<double > FutureResult = Async (EAsyncExecution::TaskGraph, [=]() { double Result = 0 ; for (int32 i = 0 ; i < TargetCounter; i++) { float j = i; Result += FMath::Sqrt (j) / TargetCounter; } double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT ("@%u wait millisecond(%f) end." ), __LINE__, (End-OldTime)*1000 ); return Result; }); UE_LOG (LogTemp, Log, TEXT ("@%u wait TargetCounterR(%f)" ), __LINE__, FutureResult.Get ()); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT (__FUNCTION__"@%u wait millisecond(%f) main thread end." ), __LINE__, (End-OldTime)*1000 ); } void ATestAsyncActor::TestAsyncFunc_ParallelFor () { OldTime = FPlatformTime::Seconds (); int Internal = TargetCounter / 10 ; auto FutureResult = Async (EAsyncExecution::TaskGraph, [=]() { TArray<double > ResultArray; ResultArray.Init (0 , 10 ); ParallelFor (ResultArray.Num (), [&ResultArray,Internal,this ](int32 Index) { for (int32 i = Index * Internal; i < (Index + 1 ) * Internal; i++) { float j = i; ResultArray[Index] += FMath::Sqrt (j)/TargetCounter; } }); double Sum = 0 ; for (int32 j = 0 ; j < ResultArray.Num (); j++) { Sum += ResultArray[j]; } UE_LOG (LogTemp, Log, TEXT ("@%u wait TargetCounterR(%f)" ), __LINE__, Sum); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT ("@%u wait millisecond(%f) end." ), __LINE__, (End-OldTime)*1000 ); return Sum; }); double End = FPlatformTime::Seconds (); UE_LOG (LogTemp, Log, TEXT (__FUNCTION__"@%u wait millisecond(%f) main thread end." ), __LINE__, (End-OldTime)*1000 ); }
TaskGraph 通过TaskGraph系统来异步完成一些自定义任务
这是一套抽象的异步任务处理系统,通过这套系统,我们可以创建多个多线程任务,并且指定各个任务之间的依赖关系,并按照该关系来依次处理任务,所有任务依赖关系形成一张有向无环图。
askGraph任务图,是用来解决多线程中任务需要先后执行顺序的问题 。
我们以游戏开发中工作流为例:
首先是策划提出需求案子
然后美术设计概念图
程序在案子提出后开发特性
等上面全部完成后策划进行验收
这是最简单的情况,现在程序特性比较复杂,将分给三个程序员分别开发(即子任务 )。
使用 先创建两个任务,一个表示工作内容FWorkTask ,一个表示汇报FReportTask 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 #include "Async/TaskGraphInterfaces.h" void TaskGraphExample () { FGraphEventRef TaskA = FFunctionGraphTask::CreateAndDispatchWhenReady ([]() { HeavyStageA (); }, TStatId (), nullptr , ENamedThreads::AnyBackgroundHiPriTask); FGraphEventRef TaskB = FFunctionGraphTask::CreateAndDispatchWhenReady ([]() { HeavyStageB (); }, TStatId (), nullptr , ENamedThreads::AnyBackgroundHiPriTask); TArray<FGraphEventRef> Prereqs{ TaskA, TaskB }; FGraphEventRef TaskC = FFunctionGraphTask::CreateAndDispatchWhenReady ([]() { MergeResults (); }, TStatId (), &Prereqs, ENamedThreads::AnyBackgroundThreadNormalTask); } ----------------------------------------------- ○简单案例2 ,自定义类 Plain Text class FMySimpleTask { public : FMySimpleTask (int32 InValue) : Value (InValue) {} FORCEINLINE TStatId GetStatId () const { RETURN_QUICK_DECLARE_CYCLE_STAT (FMySimpleTask, STATGROUP_TaskGraphTasks); } static FORCEINLINE ENamedThreads::Type GetDesiredThread () { return ENamedThreads::AnyThread; } static FORCEINLINE ESubsequentsMode::Type GetSubsequentsMode () { return ESubsequentsMode::TrackSubsequents; } void DoTask (ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent) { Result = Value * Value; UE_LOG (LogTemp, Log, TEXT ("Task completed: %d^2 = %d" ), Value, Result); } int32 GetResult () const { return Result; } private : int32 Value; int32 Result = 0 ; }; void ExecuteSimpleTask () { FGraphEventRef TaskEvent = TGraphTask<FMySimpleTask>::CreateTask () .ConstructAndDispatchWhenReady (666 ); FTaskGraphInterface::Get ().WaitUntilTaskCompletes (TaskEvent); }
多线程同步 std::atomic 原子机制 Atomic operations(原子操作) 保证CPU在读取和写入内存时总线操作是不可分割的。它是许多高级同步机制的基础,主要优势是可以进行比较快的进行比较和解锁操作。一个用Atomics实现的样例如下:
1 2 3 4 5 6 7 8 class FThreadSafeCounter {public :int32 Add ( int32 Amount ) { return FPlatformAtomics::InterlockedAdd (&Counter, Amount); } private : volatile int32 Counter; };
Locking 锁机制 UE提供了四种不同的锁:
FCriticalSection:临界区
根据各个平台的互斥锁进行的抽象
Windows 平台是基于Windows平台的临界区
iOS, Android,Linux平台则是使用的POSIX的线程标准实现
FSpinLock:自旋锁
FScopeLock:区域锁,基于作用域的锁,在构造时对当前区域加锁,离开作用域时执行析构并解锁,类似class有
FScopedMovementUpdate
FScopeCycleCounter
FScopedEvent
FRWLock:读写锁
Signaling 信号机制 类型为FSemaphore,是一种信号量与互斥锁类型,这种类型包含了一种信号机制,但不是所有平台都支持。更加常用的线程间通信机制是 FEvent
Waiting and FEvent 这是一种等待同步机制,包含如下的一些事件类型:
FEvent:阻塞直至被触发或者超时,经常被用来激活其他工作线程
FScopedEvent:这是对FEvent的一次包装,阻塞在域代码退出时
other 使用的一些tips:
UE4常见的容器类【TArray, TMap, TSet】通常都不是线程安全的,需要我们仔细编写代码保证线程安全
常见的线程安全类有:
FThreadSafeCounter计数器
FThreadSingleton 单例类
FThreadIdleStats 线程空闲状态统计类
TLockFreePointerList 无锁队列
TQueue队列
总结 多线程同步
同步工具
核心用途
适用场景
FCriticalSection / FScopeLock
互斥访问(一次一个线程)
通用共享数据保护
FRWLock / FRWScopeLock
读写分离(多读一写)
读多写少的配置、资源表
FEvent
线程间通知和等待
生产者-消费者、任务开始/完成信号
FSpinLock
极短临界区的忙等待锁
高性能场景下的微秒级操作保护
std::atomic
轻量级的不可分割操作
计数器、状态标志
避免在GameTread上调用Wait()或者EnsureCompletion()之类的阻塞函数。使用RAII(FScopeLock, FRWScopeLock)是避免死锁的有效手段,尽量避免手动调用lock/unlock。
临界区的设置不要太大或太小,太大会产生不必要的开销,太小可能无法起到保护作用FQueuedThreadPool。
工具
工具
适用场景
复杂度
使用度
Async/AsyncTask/AsyncThread/AsyncPool
简单的、一次性的异步任务,配合回调。
简单
最常用
FAsyncTask
需要定义明确任务类的短时可重用的计算
中等
常用
ParallelFor
并行化处理大型数组或数据集合。
简单
常用于数据并行
FRunnable
长时间运行、有独立生命周期的后台服务。
较复杂
有特定需求时使用
TaskGraph
复杂的、有依赖关系的并行任务链。
复杂
主要用于有复杂依赖的需求时
1.面对平行计算大量数据(计算密集型),且相互之间没有依赖的时候考虑使用ParallelFor。当需要快速实现一个简单的,一次性的异步操作,例如一次I/O操作,优先从Async/AsyncTask/AsyncThread/AsyncPool里面选择。
2.任务逻辑复杂,需要良好封装,或者打算多次复用的,考虑AsyncTask。
3.如果有复杂前置任务依赖的场景,使用TaskGraph。
4.如果是长时间持有运行的,需要自己管理生命周期的,通过自定义FRunnable实现。
从1到4,依次优先考虑。
类图
参考 UE4 C++基础 - 多线程
【UE】 UE 多线程框架 - 简书
《Exploring in UE4》多线程机制详解
UE 多线程案例-CSDN博客
【UE·引擎篇】Runnable、TaskGraph、AsyncTask、Async多线程开发指南 - 知乎
5.1 进程、线程基础知识 | 小林coding | Java面试学习