UE5多线程|FRunnableThread

UE5多线程|FRunnableThread

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


UE游戏包与编辑器中都有众多线程,多线程可以充分利用CPU多核特性,提升游戏表现,而且现代CPU核数越来越多,游戏多线程就更有必要了。

一、线程类型

线程可分为专用线程和线程池中线程。

专用线程为GameThread,RenderThread,StatsThread等,它们各自都干专门的事情,比如GameThread用于驱动游戏逻辑,RenderThread用于渲染,StatsThread用于干性能分析。在事情干完后会进入阻塞状态,不消耗CPU资源。

线程池线程包括PoolThread和TaskGraphThread,每个线程用于干多种异步任务。游戏中许多任务并发量大,但持续时间短,比如求解动画蓝图,每帧都开几个线程求解,求解完再销毁线程无疑很浪费。另外有很多琐碎的多线程任务,单独为它们开线程也很浪费。因此UE使用了线程池,池中线程会循环利用,不断执行不同的异步任务,当没有任务时也处于阻塞状态。

使用多线程时,UE已对底层平台接口进行了封装,开发者无需关注平台差异,直接使用UE提供的统一接口即可。

常用的多线程方式包括:RunnableThread、ThreadPool 和 TaskGraph。这三者在底层线程的实现机制上有所不同,对外提供的接口种类丰富,部分接口还支持通过参数指定所使用的线程实现方式。

二、FRunnable & FRunnableThread

基本的线程使用方式为FRunnable与FRunnableThread的组合,用于创建专用线程,比如AsyncLoadingThread与渲染线程等。FRunnable负责逻辑,FRunnableThread负责具体线程,线程承载了逻辑,两者一一对应,好处是上层逻辑与底层平台接口分离。

FRunnable
FRunnable类本身代表一个抽象的“可运行”对象,只有几个接口,不涉及线程细节,是我们写逻辑的地方,理论上可以在任意线程执行。

接口如下:

  • Init Runnable的初始化,初始化可能成功,可能失败,失败会立即返回,线程结束。
  • Run执行逻辑主体,初始化成功后执行。
  • Exit Run中任务执行完后的正常退出接口,执行清理操作。
  • Stop由其他线程调用,用于中途停止该Runnable以及背后的线程,但具体如何停止要我们自己实现。
  • GetSingleThreadInterface,当UE强制单线程模式时,返回用于Tick执行的实例,用的不多。

上述接口不用我们调用,对应线程创建好后会自动调用。

示例FAsyncloadingThread:
该类用于处理异步资源加载,会另开一个线程加载资源,继承了FRunnable,内部的Thread变量存储线程。

Init函数没做什么,Run函数如下:

主体是一个while循环,StopTaskCounter是循环退出条件,为Atomic计数器,当未被设置时就不断处理加载,被设置后即退出。

Stop函数如下,会设置StopTaskCounter变量:

创建线程,启动Run逻辑。

只需要下面一行代码即可,详细会在下文介绍:

FRunnableThread
FRunnableThread是平台线程的抽象,也是基类,与平台相关的线程操作由多个子类完成,包括:

  • FRunnableThreadWin
  • FRunnableThreadUnix
  • FRunnableThreadApple
  • FRunnableThreadAndroid

我们不需要继承和修改这些类,使用即可。

成员变量:

  • FString ThreadName:线程名。
  • FRunnable* Runnable:对应Runnable。
  • FEvent* ThreadInitSyncEvent:同步的Event。
  • EThreadPriority ThreadPriority:线程优先级,UE自己抽象了几个枚举。

接口:

  • Kill:结束线程,UE建议不要用操作系统的Kill接口,强杀线程会导致泄露和死锁,应该调用Runnable的Stop方法。
  • WaitForCompletion:忙等,直到线程执行完。
  • Suspend:让线程挂起或继续执行。
  • SetThreadPriority:设置线程优先级。

FRunnableThreadWin
看下常见的Windows平台子类如何实现。

Windows平台的线程为内核对象,通过HANDLE持有索引,这里的Thread就是底层的线程。

Kill方法内调用了Runnable的Stop,该函数由我们自己实现,然后可选忙等,最后调用操作系统的CloseHandle方法释放线程内核对象。

Windows有TerminateThread方法可以直接结束线程,但平常不推荐调用,有以下几个原因:

  • 线程函数中C++对象的析构函数不会被执行;
  • 线程栈不会被清理,除非调用TerminateThread的线程结束。这是Windows有意为之,加入其他在运行的线程要引用被“杀死”线程堆栈上的值,就会引起非法内存访问;
  • DLL通常会在线程终止时收到通知,但TerminateThread会导致DLL收不到通知,从而不执行正常的清理工作。

Suspend方法调用两个操作系统接口,挂起和恢复:

SetThreadPriority方法同样调用了操作系统接口,只是要做优先级转换,Windows平台线程优先级为0-31,31最高。

Windows中WaitForSingleObject可以实现WaitForCompletion效果:

三、创建线程

使用静态方法Create可创建FRunnableThread和底层线程:

  • InStack为线程栈大小;
  • InThreadPri为线程优先级;
  • InThreadAffinityMask为线程的CPU运行偏好,一般用默认值;
  • InCreateFlags也一般用默认值。

函数内部首先创建NewThread对象,Windows平台即FRunnableThreadWin。如果当前设置了强制单线程模式,还可选创建FakeThread,通过Tick驱动执行。

之后进入CreateInternal函数,调用操作系统接口创建线程。把Runnable属性设置为传入的InRunnable,然后把线程相关参数转化为适配当前操作系统的参数,调用CreateThread Win32API创建线程,线程执行的函数为_ThreadProc。同时注意到CREATE_SUSPENDED参数,线程创建后默认为挂起状态,执行了后面的ResumeThread,才会让改线程运行。ThreadInitSyncEvent用于等待线程的Init执行完毕,执行完后调用线程才会继续。

_ThreadProc函数先向UE的线程管理类注册改线程,然后进入FRunnableThreadWin::Run函数,真正开始逻辑,注意这个Run函数和FRunnable的Run毫无关系。

首先调用Runnable的Inti函数,之后触发ThreadInitSyncEvent,通知调用线程继续。然后执行最主要的Runnable->Run,等Run自动结束了,再调用Runnable->Exit做清理。最后返回ExitCode,改线程终止。

四、使用方式

Runnable有多种使用方式:

1. 手动创建FRunnable和FRunnableThread
可参考前面的FAsyncLoadingThread,适合一个长期任务,而且工作量大。

2. Async函数
有时我们只想在其他线程中执行一个短期任务,线程生命周期不长,此时专门创建一个Runnable子类,并手动创建一个Thread有些繁琐。引擎提供了Async函数,可以只提供我们想要执行的Lambda函数,引擎为我们创建一个Thread,或者从线程池中选择一个Thread来执行逻辑。

使用例子:

第一个参数用于指定线程模式。

Async函数定义如下:

第一个参数为线程执行方式,第二个为传入的函数对象,第三个为执行完的回调。

线程执行方式Execution有如下几种取值:

  • TaskGraph:在TaskGraph框架下执行,会在线程池选一个线程,适合短任务。
  • TaskGraphMainThread:与上面类似,但会用主线程。
  • Thread:创建一个新线程执行,适合长任务。
  • ThreadIfForkSate:不知。
  • ThreadPool:在GlobalThreadPool中选一个线程执行。
  • LargeThreadPool:与上面类似,在LargeThreadPool选线程执行,仅Editor下可用。

这里我们只关注Thread模式,处理分支如下:

创建了一个TAsyncRunnable对象,把Function和Promise传入其中,Promise可理解为上面的CompletionCallback,然后通过FRunnableThread::Create接口创建新线程,执行该Runnable。

TAsyncRunnable是一种特殊类型,它接收一个Function、Promise或Future作为参数。观察其Run方法:在SetPromise时会执行我们指派的任务。这个FRunnable和FRunnableThread对象是匿名的,外部代码仅进行New而不Delete,其生命周期由TAsyncRunnable自身托管。具体的清理做法是将删除操作提交至任务队列(TaskGraph)。之所以不立即Delete,是因为此时Run函数可能尚未执行完毕,立即Delete自身可能导致异常情况。

3. AsyncThread函数
和Async函数类似,内部都用TAsyncRunnable实现,不过它是专门创建匿名线程来执行任务的,因此参数中增加了线程优先级选项,这种用法引擎中不多。

五、线程同步工具

多线程环境下线程同步是个问题,UE提供了多种线程同步工具。

Atomics
Atomics可以原子的改变一个变量,相比锁是更轻量的线程同步工具,线程不需要切换状态,提供更好的性能。从底层视角看,原子操作也是有锁的,现代多核CPU会通过电路信号锁Cache的方式来实现原子操作,只是这个过程很快。原子操作是一些多线程安全类型的实现基石。

使用场景
常见使用场景为实现多线程安全的计数器,比如SharedPtr里的引用计数,下面的SharedReferenceCount类型就是std::atomic。

UE引擎提供了几个类型,是对操作系统和C++ Atomic功能的封装。

FPlatformAtomics
可对一个地址进行原子操作,如Add,Exchange。在不同平台上会调用各自原子操作接口,Windows为Win32Api的_InterlockedExchange等接口。

示例

TAtomic
类似std::atomic,底层使用FPlatformAtomics实现,提供相似接口。在UE5中,已被标记为DEPRECATED,推荐直接使用std::atomic。

FThreadSafeCounter
封装的线程安全计数器,提供Increment、Decrement等接口,底层同样使用FPlatformAtomics实现。

六、锁

锁可以创建一个临界区,在临界区内的代码只允许一个线程执行,其他线程等待。根据临界区执行时间,以及平台实现,锁可能使线程从运行态切换到阻塞态,让出CPU,这个状态切换也需要线程从用户模式切换到内核模式,切换时间大概1000个CPU周期。因此锁是较重的线程同步工具。

FCriticalSection
UE提供了FCriticalSection作为各平台锁的封装。

Windows平台底层使用CriticalSection实现,称为关键段,Windows平台上也有互斥量Mutex,但CriticalSection相比Mutex速度更快。进入临界区需要调用EnterCriticalSection,其内部会先用原子操作interlocked检查是否能访问资源,如果能访问,就接着运行,这个速度很快。如果不能访问,通常先Spin忙等一小段时间,若还不能访问资源,再使用Event内核对象进入阻塞态。当临界区比较短时,可以避免用户模式到内核模式的切换。因此Windows平台会用CriticalSection。

Linux平台底层使用pthread_mutex_t实现。

Windows实现:
初始化CriticalSection,4000表示未获取到锁忙等的CPU周期。

加锁

解锁

示例
通常使用FScopeLock配合FCriticalSection使用,可以通过构造函数与析构函数机制,使作用域内的代码成为临界区。

Event

Event用于多线程的同步,比如上文介绍的Windows线程创建,主线程在调用CreateThread后,调用ThreadInitSyncEvent->Wait(),等待新线程完成初始化工作,新建线程初始化后执行ThreadInitSyncEvent->Trigger(),通知主线程继续执行。

Windows平台底层使用Event内核对象实现Event。Event有自动重置和手动重置概念,自动重置时,一个线程执行Trigger后,只有一个Wait的线程会被唤醒继续执行,手动重置时,所有Wait的线程都会被唤醒,后续要手动调用重置函数,才能使Event变为未触发状态。通常都使用自动重置。

FEvent
UE使用FEvent类型表示一个Event,有Wait、Trigger、Reset等接口。而且Event是内核对象,创建比较昂贵,因此UE使用EventPool来管理这些Event,根据ManualReset分成两个Pool。

EventPool接口:

WindowsRunnableThread中使用方式:


这是侑虎科技第1908篇文章,感谢作者南京周润发供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/xu-chen-71-65

再次感谢南京周润发的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)