虚幻多线程简介
虚幻引擎为了多平台的特性,对线程实现进行封装,抽象为FRunnable类,使用时需要继承FRunnable类,并重写Init、Run、Exit函数。
Init中执行所有内容的初始化,如果初始化失败将自行退出线程并返回错误代码,否则将会执行Run函数,Run函数中逻辑执行完毕后将调用Exit函数退出线程。
虚幻多线程的使用
创建线程
我们创建一个类并继承FRunnable,重写Init、Run、Exit函数。
1 | class UMyThread : public FRunnable |
添加功能
完成线程类的创建,假设现在需要在线程中控制一个Actor的移动,那么我们就创建一个Actor
1 | UCLASS() |
并添加定义
1 | AMyActor::AMyActor() |
为了实现Actor的移动,我们需要将Actor作为构造函数参数传入线程类中,并实现线程类中函数声明。
1 | //UMyThread()->UMyThread(AMyActor* MyActor) |
启动线程
我们在AMyACtor中实现启动线程的功能
首先在引擎中添加输入事件并绑定,在Actor类中添加如下代码:
1 | void BeginThread(); |
对BeginThread函数进行实现
1 | void AMyActor::BeginThread() |
将Actor拖入场景中,此时运行游戏,按下输入事件,如果看到打印消息并且物体移动,则说明线程创建并运行成功。
增加暂停功能
希望给线程增加暂停和退出功能,所以提供bPause和bExit两个变量,并将Run逻辑更改为:
1 | whlie(!bExit){ |
同时对外提供两个函数用于修改bPause和bExit的值,但Run并没有被真正的挂起,while依然在执行,所以提供另一种方法。
为了阻塞线程,我们增加一个FEvent变量并在构造函数中获取ThreadEvent:
1 | FEvent* ThreadEvent; |
随后对暂停逻辑进行修改:
1 | while(!bExit){ |
为了重新唤醒线程,我们需要用到Trigger函数,现提供一个WakeUpThread函数用来唤醒线程:
1 | void WakeUpThread() |
此外还可以通过UE提供给我们的方法来挂起线程,在之前我们使用FRunnableThread::Create来创建了一个可执行线程,它提供了一个名为Suspend(bool) 的方法用于直接控制线程的暂停与唤醒:
1 | // Suspend的源码,可以比较方便的控制线程的挂起与唤醒 |
这种方式虽然便捷但是是直接挂起线程,有时候我们希望当前线程能够执行完当前一次循环的内容后再将线程挂起,使用时需要考虑不同情况。
另一种启动线程的方式
上述是再外部创建了一个线程实例,现在在类内提供一个线程实例:
1 | FRunnableThread* ThreadInstance; |
这时大部分操作就都在类内完成,在外部创建时需要提供相应的接口来完成对应的操作。
归还资源
FRunnable类本身,以及FEvent和ThreadInstance都是我们申请来的,需要进行相应的归还,否则就会造成内存泄漏。
对于类本身,在线程结束时,即Exit被调用时,我们便不再需要这个类了,所以可以在Exit中调用delete来删除自身
对于FEvent并不能直接删除,而是要归还给线程池,所以在析构函数中进行如下操作:
1 | UMyThread::~UMyThread() |
单个线程与多个线程
当希望该线程类只有一个实例时,可以考虑将构造函数声明为私有,并在需要时创建并返回,这是一种方法,更推荐的做法时使用static来做,利用static构造函数只调用一次的特性来创建一个实例并在需要时将其返回。<—单例模式的创建方法
当希望有多个线程并方便管理时,可以使用虚幻提供的TMap来存储FRunnable。
虚幻多线程的锁
在原生的C++中为了保证多个线程对数据的读写操作的唯一性一般会使用原子变量或者锁来操作,在UE中同样可以使用原子变量或锁。
原子变量
直接使用TAtomic的模板类
使用锁
虚幻提供了三种锁:自旋锁、读写锁、临界区
- 自旋锁(FSpinLock):是忙-等待的一种锁,当一个线程尝试获取锁失败后会一直请求获取直到获取锁。因为会占用较多CPU资源而造成性能损失
- 读写锁(FRWLock):用于保护读写的共享资源,允许多个线程对资源进行度操作,但只能有一个线程进行写的操作与C++中的共享锁类似
- 临界区(FCriticalSection):采用互斥变量来保护临界区,确保同时只有一个线程访问临界区,并且提供较低的竞争代价
使用方法都与原生C++类似,不再过描述。
死锁问题
在原生C++中锁使用不当(由于多次上锁或者忘记解锁)会造成死锁,而在UE中同样会存在着这样的问题,为了减少这一问题,可以考虑使用FScopeLock。
该锁利用RAII机制在获取锁时加锁,离开作用域时自动解锁,使用时便不用过多担心重复上锁以及忘记解锁。
虚幻异步与AsyncTask
AsyncTask系统
AsyncTask系统是一套基于线程池的异步处理系统,同样是使用Runnable实现的,只不过UE对其作了封装使其更加易用。
使用异步时需要继承FNonAbandonableTask。
官方的使用样例:
1 | class ExampleAysncTask:public FNonAbandonableTask |
案例中需声明一个友元类便于线程池访问,DoWork里面执行具体的逻辑。
AsyncTask同时包含以下特点:
- FAsyncTask是一个模板类,真正的AsyncTask需要手动完成构建并实现DoWork函数,随后将自己构建的类作为模板参数传递
- FAsyncTask默认使用UE提供的线程池
- FAsyncTask并不一定新开线程执行,也可以在当前线程执行,使用StartSynchronousTask直接在当前线程执行任务
- FAsyncTask包含一个DoneEvent,任务完成后会自动激活该事件,可以调用EnsureCompletion等待任务完成时做一些其它操作
- 完成后需要手动delete
- 此外还有AutoDeleteAsyncTask,只能使用UE提供的线程池并且会在执行完成后自动删除
关于NonAbandonableTask,如果手动调用Abandon函数,也会执行DoWork函数。
UE的线程池与任务队列
UE中的线程池为FQueueThreadPool,线程池中维护了多个线程FQueueThread和多个任务队列IQueuedWork。
相比于一般的线程,FQueueThread里面多了一个FEvent* DoWorkEvent成员,与一般的线程池类似,在没有任务的时候将线程挂起,在添加并分配任务时将线程激活,使用AddQueuedWork并执行了DoWorkEvent的Trigger函数后会被激活。
线程池任务IQueuedWork本身是一个接口,所以得有具体实现,而AsyncTask就是对IQueuedTask的一个具体实现。
TaskGraph系统
TaskGraph是UE的一套抽象异步处理系统,可以创建多个多线程任务同时指定各个任务之间的依赖关系,按照一定的顺序依次处理任务
后续待更新…
参考文档
更多详细信息可以查看官方文档:https://docs.unrealengine.com/4.27/en-US/API/Runtime/Core/HAL/ 在其中的FRunnable和FRunnableThread等文档中可以找到更多的信息。
在知乎文章可以学习比较详细的使用:
https://zhuanlan.zhihu.com/p/133921916
https://zhuanlan.zhihu.com/p/38881269
- 本文作者: KongXinQing
- 本文链接: https://13114987559.github.io/2023/09/25/essay/虚幻多线程与异步操作/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!