UE4 多线程 在UE4中使用多线程的方式非常丰富,在Engine初始化时我们就能看出一些端倪。除了最原始的使用FRunnable和FRunnableThread的方式,我们几乎还可以使用两种方式进行线程操作。

FRunnable是 其代码执行体,供给给FRunnableThread使用,而FRunnableThread则是真正的线程所在。

在我们引擎初始化时,我们就可以看到两种线程的创建方式。

线程池

线程池创建

第一种方式是使用线程池.从初始化时的表现来看,我们

GThreadPool = FQueuedThreadPool::Allocate();
int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfWorkerThreadsToSpawn();
if (FPlatformProperties::IsServerOnly())
{
    NumThreadsInThreadPool = 1;
}
GThreadPool->Create(NumThreadsInThreadPool, StackSize * 1024,TPri_SlightlyBelowNormal)

首先我们要看的是数量问题,如果我们是serve 的话,我们只有一个线程,如果我们是game 的话,会根据我们的电脑核数创建,最多不超过16个。

线程池的类型是FQueuedThreadPoolBase,其中的线程对象为FQueuedThread。创建后存储在

//QueuedThreads是目前存在的可供使用的线程,之后会根据使用情况进行衰减

TArray<FQueuedThread*> QueuedThreads;

/* All threads in the pool. */

TArray<FQueuedThread> AllThreads;

我们再来看一下他们的优先级问题,所有的优先级都标注为SlightlyBelowNormal,也就是说他们都是要比TPri_Normal低,所以我们可以知道我们一般的常用线程并不是在这里。

在这里的线程的名称都是PoolThread %d

栈大小是说我们的线程私有栈的大小,我们知道进程是内存分配的单位,线程栈的空间开辟在所属进程的堆区,线程与其所属的进程共享进程的用户空间,所以线程栈之间可以互访。线程栈的起始地址和大小存放在pthread_attr_t 中,栈的大小并不是用来判断栈是否越界,而是用来初始化避免栈溢出的缓冲区的大小(或者说安全间隙的大小)。如果我们不指定大小=0的话,会使用和当前线程一样的大小。

我们这里使用线程池的目的就是因为线程过多会带来调度开销,进而影响缓存局部性和整体性能。频繁创建和销毁线程也会带来极大的开销。通常我们更加关心的是任务可以并发执行,并不想管理线程的创建,销毁和调度。通过将任务处理成队列,交由线程池统一执行,可以提升任务的执行效率。

线程池使用

所有的线程任务通过

void AddQueuedWork(IQueuedWork* InQueuedWork) override

加入到线程池中,如果我们有剩余的线程,直接执行,如果没有剩余的线程,我们记录到

TArray<IQueuedWork*> QueuedWork;

我们可以看出,真正的任务线程其实是IQueuedWork。而我们要使用线程池来进行多线程的操作,直接继承IQueuedWork就可以完成操作。

TaskGraph

TaskGraph的创建

第二种是使用TaskGraph的方式

FTaskGraphInterface::Startup(FPlatformMisc::NumberOfCores());

FTaskGraphInterface::Get().AttachToThread(ENamedThreads::GameThread);

创建TaskGraph,调用new FTaskGraphImplementation(NumThreads);

首先还是要确定我们的线程数。之后我们会根据我们要创建的线程数来创建线程

每个线程的类为FWorkerThread,不过在这个时候只是预先存储的位置,还不能称之为真正的线程

其主要的名称是依次根据ENamedThreads来进行的

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,

};

伤处所有都是有名字的,我们之后会创建没有名字的Thread,TaskGraphThreadHP %d。

前面提到的FWorkerThread虽然可以理解为工作线程,但其实他不是真正的线程。FWorkerThread里面有两个重要成员,一个是FRunnableThread* RunnableThread,也就是真正的线程。另一个是FTaskThreadBase* TaskGraphWorker,即继承自FRunnable的线程执行体。FTaskThreadBase有两个子类,FTaskThreadAnyThread和FNamedTaskThread,分别表示非指定名称的任意Task线程执行体和有名字的Task线程执行体。我们平时说的渲染线程、游戏线程就是有名称的Task线程,而那些我们创建后还没有使用到的线程就是非指定名称的任意线程。

对于有名字的线程,我们是在外部进行创建他们的Runnable,而对于没有名字的线程,要在初始化的时候就创建他们的Runnable。

对于没有名字的线程,优先级是逐级递减的

WorkerThreads[ThreadIndex].TaskGraphWorker = new
FTaskThreadAnyThread(ThreadIndexToPriorityIndex(ThreadIndex));

对于有名字的线程的创建。

WorkerThreads[ThreadIndex].TaskGraphWorker = new FNamedTaskThread;

有名字的线程在这里只是简单的标记一下,在后面再创建他们的RunnableThread。没有名字的线程是在这里就创建它的线程。

WorkerThreads[ThreadIndex].RunnableThread =FRunnableThread::Create(&Thread(ThreadIndex), \*Name, StackSize, ThreadPri,Affinity);

TaskGraph的使用

TaskGraph的QueueTask是我们使用的手段。

我们对于将要执行它的线程会把task都添加到队列里面,然后线程会依次进行执行。

比如说经常使用的ENQUEUE_RENDER_COMMAND,就是把lamda传送到渲染线程执行。

ExecuteTask。

中间的过程异常的复杂。暂且就算了把

线程同步

FScopeLock[8] 区域锁

FCriticalSection 临界区

FRWLock[9] 读写锁

FSpinLock

FSemaphore[10]信号量(不是所有平台都支持)

FEvent[11] 事件

FScopedEvent[12]区域事件

线程安全的几个类:

FThreadSafeCounter[14]计数器

FThreadSingleton 单例类

FThreadIdleStats 线程空闲状态统计类

TLockFreePointerList

TQueue[15]队列

https://zhuanlan.zhihu.com/ue4cpp

https://www.zhihu.com/people/zou-pan-pan-76/posts

https://zhuanlan.zhihu.com/p/80676205

https://docs.unrealengine.com/zh-CN/Programming/Rendering/ThreadedRendering/index.html

https://zhuanlan.zhihu.com/p/38881269