UE 入坑系列(六):多线程
参考:
预备知识
进程 & 线程 & 异步
线程是调度的基本单位,而进程则是资源拥有的基本单位。多线程是实现异步的一种方式
-
进程
在多道程序同时运行的背景下,进程之间需要共享系统资源,因此会导致各程序在执行过程中出现相互制约的关系,程序的执行会表现出间断性的特征。这些特征都是在程序的执行过程中发生的,是动态的过程,而传统的程序本身是一组指令的集合,是一个静态的概念,无法描述程序在内存中的执行情况,即我们无法从程序的字面上看出它何时执行、何时停顿,也无法看出它与其他执行程序的关系,因此,程序这个静态概念已不能如实反映程序并发执行过程的特征。为了深刻描述程序动态执行过程的性质乃至更好地支持和管理多道程序的并发执行,人们引入了进程的概念。
进程就是 process,它是一个程序的运行实例。当我们启动一个程序,操作系统会创建一块内存给代码和运行时的数据使用,并且创建一个线程来处理任务,这样一个环境就叫做进程。线程是进程的一个执行任务或者控制单元,负责当前进程中程序的执行。一个进程至少有一个线程,一个进程也可以运行多个线程,多个线程之间可以进行数据的共享。
进程与线程有如下几个特点:
- 进程中一个线程崩溃,整个进程就会崩溃。
- 同一进程中的不同线程之间可以数据共享。
- 进程关闭后,内存会进行正确的回收。
- 不同进程之间的内容相互隔离,如果想要访问的话需要依靠进程间通讯机制也就是IPC。
-
线程
引入进程的目的时更好地使多道程序并发执行,提高资源利用率和系统吞吐量;而引入线程的目的则是减小程序在并发执行时所付出的时空开销,提高操作系统的并发性能。
线程最直接的理解就是“轻量级进程”,它是一个基本的CPU执行单元,也是程序执行流的最小单元,由线程ID、程序计数器、寄存器集合和堆栈组成。线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。一个线程可以创建和撤销另一个线程,同一进程中的多个线程之间可以并发执行。
引入线程后,进程的内涵发生了改变,进程只作为除CPU外的系统资源的分配单元,而线程则作为处理机的分配单元。由于一个进程内部有多个线程,若线程的切换发生在同一个进程内部,则只需要很少的时空开销。
-
线程与进程的比较
线程与进程的比较如下:
- 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
- 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
线程相比进程能减少开销,体现在:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
所以,不管是时间效率,还是空间效率线程比进程都要高。
-
异步编程
异步编程是让程序并发运行的一种手段。它允许多个事件同时发生,当程序调用需要长时间运行的方法时,它不会阻塞当前的执行流程,程序可以继续运行。多线程是实现异步的一种方式。
多线程
多线程是优化项目性能的重要方式之一,游戏也不例外。在UE4里面,我们可以自己继承 FRunnable 接口创建单个线程,也可以直接创建AsyncTask 来调用线程池里面空闲的线程,还可以通过 TaskGraph 系统来异步完成一些自定义任务。虽然本质相同,但是用法不同。
FRunnable
我们先从最基本的创建方式谈起,这里的“标准”只是一个修饰。其实就是创建一个继承自 FRunnable 的类,把这个类要执行的任务分发给其他线程去执行。FRunnable 就是一个很简单的类,里面只有5,6个函数接口,为了与真正的线程区分,我这里称 FRunnable 为“线程执行体”。
1 | // Runnable.h |
看起来这么简单个类,我们是不是可以不继承他,单独写一个类再把这几个接口放进去呢?当然不行,实际上,在实现多线程的时候,我们需要将FRunnable作为参数传递到真正的线程里面,然后才能通过线程去调用FRunnable的Run,也就是我们具体实现的类的Run方法(通过虚函数覆盖父类的Run)。所谓真正的线程其实就是FRunnableThread,不同平台的线程都继承自他,如FRunnableThreadWin,里面会调用Windows平台的创建线程的API接口。下图给出了FRunnable与线程之间的关系类图:

在实现的时候,你需要继承FRunnable并重写他的那几个函数,Run()里面表示你在线程里面想要执行的逻辑。
案例
从自定义Actor子类ATestRunnableActor里获取一个数字,然后在多线程里实现一个计数器,当计数器大于这个数字时,线程退出。
1 | // TestRunnable.h |
1 | // TestRunnable.cpp |
这里需要注意如果我们同时在多个线程里去读和写Actor的数据会引起线程不同步的问题,需要加锁 FScopeLock。
FScopeLock是UE提供的一种基于作用域的锁,思想类似RAII机制。在构造时对当前区域加锁,离开作用域时执行析构并解锁。UE里面有很多带有“Scope”关键字的类,如移动组件中的FScopedMovementUpdate,Task系统中的FScopeCycleCounter,FScopedEvent等,他们的实现思路是类似的。
然后创建一个线程类FRunnableThread来使用FTestRunnable:
1 | void ATestRunnableActor::BeginPlay() |
总结
FRunnable(线程执行体)和 FRunnableThread(线程类)是最简单的实现多线程方式,它只有创建、暂停、销毁、等待完成等基础功能。在实战中也较少用到。
AsyncTask系统
说完了UE4“标准”线程的使用,下面我们来谈谈稍微复杂一点的AsyncTask系统。AsyncTask系统是一套基于线程池的异步任务处理系统。如果你没有接触过UE4多线程,用搜索引擎搜索UE4多线程时可能就会看到类似下面这样的用法:
1 | //AsyncWork.h |
没错,这就是官方代码里面给出的一种异步处理的解决方案示例。不过你可能更在意的是这个所谓多线程的用法,看起来非常简单,但是却找不到任何带有“Thread”或“Runnable”的字样,那么他也是用Runnable的方式做的么?答案肯定是Yes。只不过封装的比较深,需要我们深入源码才能明白其中的原理。
为了更高效地利用线程,而不是每个任务都创建一个线程,UE中提供了线程池的方案,可以将多个任务分配在N个线程中执行。任务过多时,排队执行,也可以撤销排队。
线程池

接口层:
IQueuedWork- 任务接口,继承使用。FQueuedThreadPool- 线程池的接口类,常用操作:AddQueuedWork- 把任务放入线程池中执行,若有空闲线程,直接分配给空闲线程,若没有空闲线程,放入线程池维护的队列,后台线程会从队列中自己拿任务执行。RetractQueuedWork- 撤回指定任务,只能撤回正在排队的,已经在执行的没法撤回。
实现层:
FQueuedThreadPoolBase- 线程池的实现类QueueWork- 排队的任务QueuedThreads- 空闲的线程AllThreads- 所有线程(FQueueThread)
FQueuedThread- 线程池的后台线程实现- 线程运行时,若没有任务则挂起,有任务时执行任务,执行完一个任务后,从线程池队列中再拿一个执行(
FQueuedThreadPoolBase::ReturnToPoolOrGetNextJob),直到没有任务,再次挂起自己 - 若目前线程为空闲,放入一个任务后,执行该线程的
DoWork,结束挂起开始执行任务
- 线程运行时,若没有任务则挂起,有任务时执行任务,执行完一个任务后,从线程池队列中再拿一个执行(
运行示意图:

AsyncTask
线程池的任务IQueuedWork本身是一个接口,所以得有具体实现。这里你就应该能猜到,所谓的AsynTask其实就是对IQueuedWork的具体实现。这里AsynTask泛指FAsyncTask与FAutoDeleteAsyncTask两个类,我们先从FAsyncTask说起。
FAsyncTask有几个特点,
FAsyncTask是一个模板类,真正的AsyncTask需要你自己写。通过DoWork提供你要执行的具体任务,然后把你的类作为模板参数传过去- 使用
FAsyncTask就默认你要使用UE提供的线程池FQueuedThreadPool,在引擎PreInit的时候会初始化线程池并返回一个指针GThreadPool。在执行FAsyncTask任务时,如果你在执行StartBackgroundTask的时候会默认使用GThreadPool线程池,当然你也可以在参数里面指定自己创建的线程池 - 创建
FAsyncTask并不一定要使用新的线程,你可以调用函数StartSynchronousTask直接在当前线程上执行任务 FAsyncTask本身包含一个DoneEvent,任务执行完成的时候会激活该事件。当你想等待一个任务完成时再做其他操作,就可以调用EnsureCompletion函数,他可以从队列里面取出来还没被执行的任务放到当前线程来做,也可以挂起当前线程等待DoneEvent激活后再往下执行
FAutoDeleteAsyncTask与FAsyncTask是相似的,但是有一些差异,
- 默认使用UE提供的线程池
FQueuedThreadPool,可以通过参数指定使用其他线程池 FAutoDeleteAsyncTask在任务完成后会通过线程池的Destroy函数删除自身或者在执行DoWork后删除自身,而FAsyncTask需要手动delete- 包含FAsyncTask的特点1和特点3
总的来说,AsyncTask系统实现的多线程与你自己继承FRunnable实现的原理相似,不过他在用法上比较简单,而且还可以直接借用UE4提供的线程池,很方便。
案例
使用 AsyncTask 计算一个 1 到 1000w 的开根号,并求和,最后除以 1000w 的简单逻辑。并且计算主线程执行时长和逻辑计算总时长,来比较不同方法之间的差距。
1 | // TestAsyncTask.h |
为什么要继承
FNonAbandonableTask?
当线程池被销毁的时候,会调用Abandon函数。继承FNonAbandonableTask的话这个时候就不会丢弃而且等待执行完。如果需要丢弃则不继承,并且自己实现CanAbandon和Abandon函数。源码里可丢弃的任务参考:FAsyncStatsFile。
1 | // TestAsyncTask.cpp |
然后在自定义Actor类ATestAsyncActor使用FAutoDeleteAsyncTask来传入我们刚才写的Task。FAutoDeleteAsyncTask顾名思义就是任务执行完就会自动删除。
1 | void ATestAsyncActor::TestAsyncTaskClass() |
StartBackgroundTask和StartSynchronousTask的区别:
StartBackgroundTask会利用线程池里空闲的线程来执行。StartSynchronousTask则是主线程执行。

可以看到只有Synchronous以后主线程是会等AsyncTask里面的逻辑执行完了之后才会继续往下走。而使用Background主线程不会阻塞。
既然StartSynchronousTask会阻塞主线程,那我用AsyncTask的意义何在呢?直接一开始就单线程不就完事了?
问得好,这个方法即使是在ue4源码里用到的地方也极少。我认为这个方法的意义在于给AsyncTask多了一点灵活性,当我们在使用多线程时发现部分逻辑代码只能跑在主线程或者它跑异步线程其实并没有变快,这个时候想把它改成单线程的时候就很方便。
总结
AsyncTask系统实现的多线程与你自己字节继承FRunnable实现的原理相似,还可以利用UE4提供的线程池。当使用多线程不满意时也可以调用StartSynchronousTask改成主线程执行。
TaskGraph
鸽了



