在UE4中使用多线程的方式非常丰富,在Engine初始化时我们就能看出一些端倪。除了最原始的使用FRunnable和FRunnableThread的方式,我们几乎还可以使用两种方式进行线程操作。
在我们引擎初始化时,我们就可以看到两种线程的创建方式。
一. 标准多线程实现FRunnable
FRunnable 是指标准多线程,适合长期连续的操作。我们只需要进行简单的继承,就可以实现一个标准的多线程实例。
1.1 FRunable的细节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class FSimpleRunnable :public FRunnable
{
public:
FSimpleRunnable();
~FSimpleRunnable();
private:
// 必须实现的几个
virtual bool Init() override;
virtual uint32 Run() override;
virtual void Stop() override;
virtual void Exit() override;
};
|
- Runnable对象初始化调用 Init(),并通过返回值确定是否成功。初始化失败,则该线程停止执行,并返回一个错误码;成功,则会执行 Run()。
- 如果Run()中的方法不是永远循环的,就可以直接退出。如果Run永远循环,线程无法退出。就算游戏主线程停止了,这个线程还在继续运行。
- 当Run执行完毕后,则会调用 Exit() 执行清理操作。
我们继承FRunnable实现这些接口,但是其FRunnable本身并没有线程功能,其线程本身是FRunnableThread
。
1
2
|
void WaitForCompletion(); // 阻塞调用例程,直到线程执行完毕
bool Kill(bool bShouldWait); // 强制杀掉线程
|
- 如果调用 FRunnableThread的Kill(bool bShouldWait=false) 函数,会先执行 runnable 对象的 stop(),然后根据 bShouldWait 参数决定是否等待线程执行完毕。如果不等待,则强制杀死线程,可能会造成内存泄漏。
- 调用 FRunnableThread的WaitForCompletion() 函数,将阻塞调用线程直到线程执行完毕。
1.2 FRunable的使用
在使用上,我们通过FRunnableThread::Create进行线程的创建。将会调用Init和Run。
1
2
|
FRunnable* SimpleRunnable = new FSimpleRunnable();
FRunnableThread* SimpleRunnableThread = FRunnableThread::Create(SimpleRunnable, TEXT("MySimpleRunnable"));
|
那怎么退出呢。如果Run中的代码执行完了,会自动执行Stop和Exit退出,否则就需要手动去中断。
二.使用线程池的AsyncTask
线程过多会带来调度开销,进而影响缓存局部性和整体性能。频繁创建和销毁线程也会带来极大的开销。通常我们更加关心的是任务可以并发执行,并不想管理线程的创建,销毁和调度。通过将任务处理成队列,交由线程池统一执行,可以提升任务的执行效率。UE4提供了对应的线程池来满足我们的需求。
FRunable属于基于线程(thread-based)的模式。在这种开发模式下,程序员必须非常仔细的处理线程间的同步、共享数据等问题。而我们现在几乎都在使用基于线程的多线程模式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
template<typename TTask>
class FAsyncTask
: private IQueuedWork
{
TTask Task;
FThreadSafeCounter WorkNotFinishedCounter;
FEvent* DoneEvent;
FQueuedThreadPool* QueuedPool;
template <typename Arg0Type, typename... ArgTypes>
FAsyncTask(Arg0Type&& Arg0, ArgTypes&&... Args)
: Task(Forward<Arg0Type>(Arg0), Forward<ArgTypes>(Args)...)
{
Init();
}
|
我们对于FAsyncTask并不是使用FRunable继承的方式来进行使用,在我们使用的时候是使用模板来进行任务的指定。在使用时非常的简单,只需要指定对应的任务模板就可以
1
2
3
4
5
6
7
8
9
10
|
void Example()
{
// 1. 创建一个异步任务对象
FAsyncTask<ExampleAsyncTask>* MyTask = new FAsyncTask<ExampleAsyncTask>( 5 );
// 2. 调用开始任务的函数
MyTask->StartBackgroundTask();
// -- or --
MyTask->StartSynchronousTask();
}
|
- FAsyncTask接受可变参数的构造函数,根据TTask的不同,进而构造不同的参数。
- 我们有两种运行方式,StartSynchronousTask使用当前的Task进行,而StartBackgroundTask将Task放到队列末尾。
如果我们使用StartBackgroundTask,将使用全局的线程池将自己加入里面。
2.1线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class FQueuedThreadPoolBase : public FQueuedThreadPool
{
protected:
TArray<IQueuedWork*> QueuedWork;
TArray<FQueuedThread*> QueuedThreads;
TArray<FQueuedThread*> AllThreads;
FCriticalSection* SynchQueue;
bool TimeToDie;
}
|
线程池是FQueuedThreadPool。和一般的线程池实现类似,线程池里面维护了多个线程FQueuedThread与多个任务队列IQueuedWork。AllThreads时所有的线程,名称都是PoolThread %d
。
当我们把FAsyncTask加载进来的时候,首先我们看是满员,如果线程都正在使用的话,会把对应的任务加载到QueuedWork
中日后处理。如果有可用的线程,那么会给这个IQueuedWork分配对应的FQueuedThread。
在引擎开始初始化时就会进行线程池的创建。如果我们是server的话,我们只有一个线程,如果我们是game的话,会根据我们的电脑核数创建,最多不超过16个。
1
2
3
4
5
6
7
|
GThreadPool = FQueuedThreadPool::Allocate();
int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfWorkerThreadsToSpawn();
if (FPlatformProperties::IsServerOnly())
{
NumThreadsInThreadPool = 1;
}
GThreadPool->Create(NumThreadsInThreadPool, StackSize * 1024,TPri_SlightlyBelowNormal)
|
FQueuedThread 是线程池里面的线程实例,实际线程依旧是之前的FRunnableThread
封装。
三.TaskGraph
更为复杂的多线程结构是TaskGraph。由于基于任务的线程模式几乎必须要手动的进行同步和模式数据的管理,因此对于那些重量级别的常驻线程需要另一个更为强大的工具进行处理。
Graph的意思其实是因为可以指定前后的执行顺序而得名,也就是设定这些Task之间的依赖。
3.1 数据逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class FTaskGraphImplementation : public FTaskGraphInterface
{
FWorkerThread WorkerThreads[MAX_THREADS];
}
struct FWorkerThread
{
/** The actual FTaskThread that manager this task **/
FTaskThreadBase* TaskGraphWorker;
/** For internal threads, the is non-NULL and holds the information about the runable thread that was created. **/
FRunnableThread* RunnableThread;
/** For external threads, this determines if they have been "attached" yet. Attachment is mostly setting up TLS for this individual thread. **/
bool bAttached;
};
|
首先线程类型为FWorkerThread,其依旧是封装了FRunnableThread作为真正的线程体,而FTaskThreadBase是对FRunnable的又一个封装结构。FTaskThreadBase又分为FTaskThreadAnyThread和FNamedTaskThread分别表示非指定名称的任意Task线程执行体和有名字的Task线程执行体。
在创建时调用Startup默认构建24个FWorkerThread工作线程(这里支持最大的线程数量也就是24),其中里面有5个是默认带名字的线程。例如RHIThread,AudioThread,GameThread,ActualRenderingThread都是有名线程,其他是无名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
enum Type
{
UnusedAnchor = -1,
\* The always-present, named threads are listed next *\
#if STATS
StatsThread,
#endif
RHIThread,
AudioThread,
GameThread,
// The render thread is sometimes the game thread and is sometimes the actual rendering thread
ActualRenderingThread = GameThread + 1,
};
```C++
FTaskGraphInterface::Startup(FPlatformMisc::NumberOfCores());
FTaskGraphInterface::Get().AttachToThread(ENamedThreads::GameThread);
|
对于有名字的线程,我们是在外部进行创建他们的Runnable,而对于没有名字的线程,要在初始化的时候就创建他们的Runnable。TaskGraph的使用异常的简单,我们只需要调用分配给线程里面默认的Queue。
1
|
virtual void QueueTask(class FBaseGraphTask* Task, ENamedThreads::Type ThreadToExecuteOn, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread) = 0;
|
比如说经常使用的ENQUEUE_RENDER_COMMAND,就是把lamda传送到渲染线程执行。
3.2 线程任务
线程里面的任务的创建其实非常复杂。第一个参数就是我们的依赖数组,会根据依赖关系的前后顺序来保证结果正确,第二个参数是当前的线程。
1
2
3
4
5
6
7
8
9
10
|
static FConstructor CreateTask(const FGraphEventArray* Prerequisites = NULL, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread)
{
int32 NumPrereq = Prerequisites ? Prerequisites->Num() : 0;
if (sizeof(TGraphTask) <= FBaseGraphTask::SMALL_TASK_SIZE)
{
void *Mem = FBaseGraphTask::GetSmallTaskAllocator().Allocate();
return FConstructor(new (Mem) TGraphTask(TTask::GetSubsequentsMode() == ESubsequentsMode::FireAndForget ? NULL : FGraphEvent::CreateGraphEvent(), NumPrereq), Prerequisites, CurrentThreadIfKnown);
}
return FConstructor(new TGraphTask(TTask::GetSubsequentsMode() == ESubsequentsMode::FireAndForget ? NULL : FGraphEvent::CreateGraphEvent(), NumPrereq), Prerequisites, CurrentThreadIfKnown);
}
|
也就是说,一个Tas会依赖多个GraphEvent对象, 该TaskGraph对象在收到所有先决事件触发后,才能执行任务,我们想要运行一个Task则需要唤醒所有依赖的Task。