UE5多线程+windows系统多线程输入实战
UE5里的线程
- GameThread-游戏线程
- DrawThread-渲染线程
- RHIThread-调用各平台绘图API的线程
- 其他线程(网络、文件I/O)
怎么进行多线程
- RunnableThread
- ThreadPool
- TaskGraph
RunnableThread
这种方式是最基础的多线程方式,既创建一个线程任务。
这种方式由“FRunnable”类和“FRunnableThread”两个类相互配合实现
如果用这种方法实现,则需要将我们的业务代码写在继承Runnable类的子类当中,并实现Init(),Run(),Stop(),Exit()四个方法,这里只关心新加的线程是什么逻辑,并不关心多线程具体如何实现。
FRunnableThread既线程本身的抽象,内含静态方法Create,可以通过这个方法创建FRunnable线程。

ThreadPool
固定数量的线程池,适用于持续时间短,比较复杂的异步任务。
线程池在这里创建,位置为FEngineLoop::PreInitPreStartupScreen,还有不同种类的好几个池,这里先不看
Async和AsyncPool函数就是典型的使用线程池的方法
Async(EAsyncExecution::ThreadPool,[](){});//内部调用AddQueuedWork接口
底层的实现方式也是利用了Runnable,网上偷了张图,不做过多介绍了
总结就是线程池当中的基础线程单位FQueuedThread对象继承自FRunnable,这些线程单位通过线程池中的“待执行任务队列”和“空闲线程列表”管理。任务本质其实也就是一个函数,通过封装后通过AddQueuedWork接口传入,一旦传入任务,线程池就会检查是否拥有空闲线程,如果有就直接分配,如果没有就先放进“待执行任务队列”中。
TaskGraph
既任务线程直接产生依赖。典型的多线程GC就是通过这样的方法来实现的。包括GameThread,RenderThread,都是通过TaskGraph管理的。 从根本上讲,TaskGraph只是一个“基于任务的并发系统”,管理线程池只是他功能中的一部分,总而言之,TaskGraph可以用来创建并发任务,同时也提供了等待机制,实现了调度一系列有依赖关系、可分布在不同线程的任务的功能。 关键词:有向无环任务网络(DAG) TaskGraph支持两种线程,NamedThread和AnyThread,NamedThread就是外部创建的,例如GameThread和RenderThread。
具体的实现方法也不深入看了,这里浅尝即止,既有一个任务队列,此外每个任务都有个FGraphEvent对象,此对象维护一个“后续任务列表”;每个任务还对应有一个“后续任务引用”,可以理解成这个任务的“代理”;每个任务还有一个计数器,记录自己依赖了多少别的对象,系统会轮询这个计数器,如果为0就加入总任务队列中待运行。 如果想让A放在B的后面运行,那么就将A的“后续任务引用”加入到B的“后续任务列表”中,一旦B完成了,就会遍历后续任务列表,并将列表中的任务计数器-1。其实也就是是一个拓扑排序。 前面提到的任务队列,也就是线程池中的“工作线程”的任务队列,这些工作线程有不同的优先级,这些优先级决定了CPU调度的优先级,高优先级获得更多CPU时间,在创建任务时,可以配置这些任务的“线程优先级”和“任务优先级”,前者决定去哪个优先级的工作线程,后者决定他在同一个工作线程的任务队列中的优先级。
也可以使用Async来通过TaskGraph来创建多线程任务
Async(EAsyncExecution::TaskGraph,[](){});
还是偷了张图,有空就多看看,请忽略叠了好几层的水印

线程同步的方法
首先上面说的TaskGraph我认为就是一种同步方法,让DAG自动管理不同任务之间的相互依赖,这样就可以无须关心任务同步。 当然UE还提供了一些基础的同步功能。
- 原子操作——Atomics
using RefCountType = std::conditional_t<Mode == ESPMode::ThreadSafe, std::atomic<int32>, int32>;
这个类型就是智能指针中引用计数的类型,这里就用到了原子计数
- 锁——FCriticalSection
- FEvent——线程间通知机制,允许线程等待特定信号后再进行 如果在多线程中有线程不确定什么时候结束,又和其他线程有依赖关系,可以考虑用。可以使用FEvent对事件包装,然后调用Wait方法后再调用Trigger。
Windows多线程输入
实际在公司里做的一个具体的需求,就是需要保证主游戏线程卡顿(如果发生)的同时,保证其输入以及输入时触发的一系列功能(特效、音效)等不受影响。
UE的输入流程
首先看一下UE是怎么处理输入的。
在FEngineLoop中
这个地方只看行的话,是在GEngine->Tick()之前,也就是说是在每次tick,但是在处理主流程逻辑之前进行输入信号处理。
再往里走,遇到一个WinPumpMessages函数
这个函数是一个标准的WINDOWSAPI消息循环,这里稍微扩展一下WindowsOS的一些概念
消息队列:一个由OS直接管理的队列,也分为系统级和应用级,一般消息都是直接发送给系统级的消息队列,然后再由OS分发给各线程的引用级消息队列上 消息循环:一个死循环,通常由“GetMessage/PeekMessage”、“TranslateMessage”和“DispatchMessage”三部分组成,分别代表“从消息队列中获取消息”、“翻译消息生成字符信息”和“分发消息给目标窗口”,这里GetMessage是阻塞式的,也就是拿不到消息就不会返回,而PeekMessage不是。 窗口过程函数:在窗口收到消息时(DispatchMessage)触发的逻辑函数 窗口类:windowsOS在创建窗口时,需要告诉OS这个窗口的类型,比如一些已经预定义的button、edit等,如果需要自定义窗口,则需要自定义窗口类,像这样:
这个UnrealWindow既虚幻编辑器的窗口类定义,一般最重要的是要把窗口过程函数(AppWndProc)定义出来。
如果比较熟悉Windows编程,那么对这一套流程应该很熟悉,后面我实现虚幻上的Windows平台多线程输入也是用的这一套逻辑。
经过UE的窗口过程函数,会走到这里:
意思就是在UE的Windows输入消息真正传入UE的input系统之前,还有一个机会去处理它,那就是传入一个自定义的MessageHandler。可以在一些Actor中依赖一些自定义的MessageHandler,然后这个Actor就可以越过input系统直接获取到WindowsOS的消息了。这里要注意生命周期,不过暂时还不知道有什么具体应用。
再之后就是根据输入信息来处理了,ProcessMessage以及后面的ProcessDeferredMessage都会根据Message的不同类别来进行不同的处理,这里的分类指是否为键盘、是否为键盘中的字符串、是否为连携键(比如ctrl+)等等等等,这里有点意思的是输入法相关也算一次输入信息,比如是英文输入法,那么按下键盘a,处理发布一个KeyDown信息,还会发布一个a的KeyChar信息;如果是中文输入法,那么KeyChar会出现在输入法真正打入一个中文时发布,比如发布一个“啊”字符。
再往后,就到了SlateApplication里面了,从这里开始,一条输入消息算是从原始输入信息正式转换进了UE的inputsystem当中。
再往后的内容,可能更重要些,但和现在要做的需求关联并不是很大。我以后还会总结的。
多线程输入
从上面的分析可知,虚幻引擎是在主流程tick之前进行消息输入处理的,那么可能会发生这样的情况:如果主流程因各种原因卡顿、掉帧,就会导致用户的输入会产生延迟。 所以解决的思路就是利用Runnable单拉一个线程出来,在创建时建立一个新的windows隐藏窗口,然后在这个独立的工作线程中创立一个消息循环,定义窗口过程函数,这样独立线程的输入就不会被主线程影响了。
