剖析虚幻渲染体系(02)- 多线程渲染

 

 

2.1 多线程编程基础

为了更平稳地过渡,在真正进入UE的多线程渲染知识之前,先学习或重温一下多线程编程的基础知识。

2.1.1 多线程概述

多线程(Multithread)编程的思想早在单核时代就已经出现了,当时的操作系统(如Windows95)就已经支持多任务的功能,其原理就是在单核中切换不同的上下文(Context),以便每个进程中的线程都有时间获得执行指令的机会。

但到了2005年,当单核主频接近4GHz时,CPU硬件厂商英特尔和AMD发现,速度也会遇到自己的极限:那就是单纯的主频提升,已经无法明显提升系统整体性能。

随着单核计算频率摩尔定律的缓慢终结,Intel率先于2005年发布了奔腾D和奔腾四至尊版840系列,首次支持了两个物理级别的线程计算单元。此后十多年,多核CPU得到蓬勃发展,由AMD制造的Ryzen 3990X处理器已经拥有64个核心128个逻辑线程。

锐龙(Ryzen)3990X的宣传海报中赫然凸显的核心与线程数量。

硬件的多核发展,给软件极大的发挥空间。应用程序可以充分发挥多核多线程的计算资源,各个应用领域由此也产生多线程编程模型和技术。作为游戏的发动机Unreal Engine等商业引擎,同样可以利用多线程技术,以便更加充分地提升效率和效果。

使用多线程并发带来的作用总结起来主要有两点:

  • 分离关注点。通过将相关的代码与无关的代码分离,可以使程序更容易理解和测试,从而减少出错的可能性。比如,游戏引擎中通常将文件加载、网络传输放入独立的线程中,既可以不阻碍主线程,也可以分离逻辑代码,使得更加清晰可扩展。
  • 提升性能。人多力量大,这样的道理同样用到CPU上(核多力量大)。相同量级的任务,如果能够分散到多个CPU中同时运行,必然会带来效率的提升。

但是,随着CPU核心数量的提升,计算机获得的效益并非直线提升,而是遵循Amdahl's law(阿姆达尔定律),Amdahl's law的公式定义如下:

\[S_{latency}(s) = \frac{1}{(1-p) + \frac{p}{s}} \]

公式的各个分量含义如下:

  • \(S_{latency}\):整个任务在多线程处理中理论上获得的加速比。
  • \(s\):用于执行任务并行部分的硬件资源的线程数量。
  • \(p\):可并行处理的任务占比。

举个具体的栗子,假设有8核16线程的CPU用于处理某个任务,这个任务有70%的部分是可以并行处理的,那么它的理论加速比为:

\[S_{latency}(16) = \frac{1}{(1-0.7) + \frac{0.7}{16}} = 2.9 \]

由此可见,多线程编程带来的效益并非跟核心数呈直线正比,实际上它的曲线如下所示:

阿姆达尔定律揭示的核心数和加速比图例。由此可见,可并行的任务占比越低,加速比获得的效果越差:当可并行任务占比为50%时,16核已经基本达到加速比天花板,无论后面增加多少核心数量,都无济于事;如果可并行任务占比为95%时,到2048个核心才会达到加速比天花板。

虽然阿姆达尔定律给我们带来了残酷的现实,但是,如果我们能够提升任务并行占比到接近100%,则加速比天花板可以得到极大提升:

\[S_{latency}(s) = \frac{1}{(1-p) + \frac{p}{s}} = \frac{1}{(1-1) + \frac{1}{s}} = s \]

如上公式所示,当\(p=1\)(即可并行的任务占比100%)时,理论上的加速比和核心数量成线性正比!!

举个具体的例子,在编译Unreal Engine工程源码或Shader时,由于它们基本是100%的并行占比,理论上可以获得接近线性关系的加速比,在多核系统中将极大地缩短编译时间。

利用多线程并发提高性能的方式有两种:

  • 任务并行(task parallelism)。将一个单个任务分成几部分,且各自并行运行,从而降低总运行时间。这种方式虽然看起来很简单直观,但实际操作中可能会很复杂,因为在各个部分之间可能存在着依赖。
  • 数据并行(data parallelism)。任务并行的是算法(执行指令)部分,即每个线程执行的指令不一样;而数据并行是指令相同,但执行的数据不一样。SIMD也是数据并行的一种方式。

上面阐述了多线程并发的益处,接下来说说它的副作用。总结起来,副作用如下:

  • 导致数据竞争。多线程访问常常会交叉执行同一段代码,或者操作同一个资源,又或者多核CPU的高度缓存同步问题,由此变化带来各种数据不同步或数据读写错误,由此产生了各种各样的异常结果,这便是数据竞争。
  • 逻辑复杂化,难以调试。由于多线程的并发方式不唯一,不可预知,所以为了避免数据竞争,常常加入复杂多样的同步操作,代码也会变得离散、片段化、繁琐、难以理解,增加代码的辅助,对后续的维护、扩展都带来不可估量的阻碍。也会引发小概率事件难以重现的BUG,给调试和查错增加了数量级的难度。
  • 不一定能够提升效益。多线程技术用得到确实会带来效率的提升,但并非绝对,常和物理核心、同步机制、运行时状态、并发占比等等因素相关,在某些极端情况,或者用得不够妥当,可能反而会降低程序效率。

2.1.2 多线程概念

本小节将阐述多线程编程技术中常涉及的基本概念。

  • 进程(Process)

进程(Process)是操作系统执行应用程序的基本单元和实体,它本身只是个容器,通常包含内核对象、地址空间、统计信息和若干线程。它本身并不真正执行代码指令,而是交由进程内的线程执行。

对Windows而言,操作系统在创建进程时,同时也会给它创建一个线程,该线程被称为主线程(Primary thread, Main thread)。

对Unix而言,进程和主线程其实是同一个东西,操作系统并不知道有线程的存在,线程更接近于lightweight processes(轻量级进程)的概念。

进程有优先级概念,Windows下由低到高为:低(Low)、低于正常(Below normal)、正常(Normal)、高于正常(Above normal)、高(High)、实时(Real time)。(见下图)

默认情况下,进程的优先级为Normal。优先级高的进程将会优先获得执行机会和时间。

  • 线程(Thread)

线程(Thread)是可以执行代码的实体,通常不能独立存在,需要依附在某个进程内部。一个进程可以拥有多个线程,这些线程可以共享进程的数据,以便并行或并发地执行多个任务。

在单核CPU中,操作系统(如Windows)可能会采用轮循(Round robin)的方式进行调度,使得多个线程看起来是同时运行的。(下图)

在多核CPU中,线程可能会安排在不同的CPU核心同时运行,从而达到并行处理的目的。

采用SMP的Windows在多核CPU的执行示意图。等待处理的线程被安排到不同的CPU核心。

每个线程可拥有自己的执行指令上下文(如Windows的IP(指令寄存器地址)和SP(栈起始寄存器地址))、执行栈和TLS(Thread Local Storage,线程局部缓存)。

Windows线程创建和初始化示意图。

线程局部存储(Thread Local Storage)是一种存储持续期,对象的生命周期与线程一样,在线程开始时分配,线程结束时回收。每个线程有该对象自己的实例,访问和修改这样的对象不会造成竞争条件(Race Condition)。

线程也存在优先级概念,优先级越高的将优先获得执行指令的机会。

线程的状态一般有运行状态、暂停状态等。Windows可用以下接口切换线程状态:

// 暂停线程
DWORD SuspendThread(HANDLE hThread);
// 继续运行线程
DWORD ResumeThread(HANDLE hThread);

同个线程可被多次暂停,如果要恢复运行状态,则需要调用同等次数的继续运行接口。

  • 协程(Coroutine)

协程(Coroutine)是一种轻量级(lightweight)的用户态线程,通常跑在同一个线程,利用同一个线程的不同时间片段执行指令,没有线程、进程切换和调度的开销。从使用者角度,可以利用协程机制实现在同个线程模拟异步的任务和编码方式。在同个线程内,它不会造成数据竞争,但也会因线程阻塞而阻塞。

  • 纤程(Fiber)

纤程(Fiber)如同协程,也是一种轻量级的用户态线程,可以使得应用程序独立决定自己的线程要如何运作。操作系统内核不知道纤程的存在,也不会为它进行调度。

  • 竞争条件(Race Condition)

同个进程允许有多个线程,这些线程可以共享进程的地址空间、数据结构和上下文。进程内的同一数据块,可能存在多个线程在某个很小的时间片段内同时读写,这就会造成数据异常,从而导致了不可预料的结果。这种不可预期性便造就了竞争条件(Race Condition)

避免产生竞争条件的技术有很多,诸如原子操作、临界区、读写锁、内核对象、信号量、互斥体、栅栏、屏障、事件等等。

  • 并行(Parallelism)

至少两个线程同时执行任务的机制。一般有多核多物理线程的CPU同时执行的行为,才可以叫并行,单核的多线程不能称之为并行。

  • 并发(Concurrency)

至少两个线程利用时间片(Timeslice)执行任务的机制,是并行的更普遍形式。即便单核CPU同时执行的多线程,也可称为并发。

并发的两种形式——上:双物理核心的同时执行(并行);下:单核的多任务切换(并发)。

事实上,并发和并行在多核处理器中是可以同时存在的,比如下图所示,存在双核,每个核心又同时切换着多个任务:

部分参考文献严格区分了并行和并发,但部分文献并不明确指出其中的区别。虚幻引擎的多线程渲染架构和API中,常出现并行和并发的概念,所以虚幻是明显区分两者之间的含义。

  • 线程池(Thread Pool)

线程池提供了一种新的任务并发的方式,调用者只需要传入一组可并行的任务和分组的策略,便可以使用线程池的若干线程并发地执行任务,使得调用者无需接直接触线程的调用和管理细节,降低了调用者的成本,也提升了线程的调度效率和吞吐量。

不过,创建一个线程池时,几个关键性的设计问题会影响并发效率,比如:可使用的线程数量,高效的任务分配方式,以及是否需要等待一个任务完成。

线程池可以自定义实现,也可以直接使用C++、操作系统或第三方库提供的API。

2.1.3 C++的多线程

在C++11之前,C++的多线程支持基本为零,仅提供少量鸡肋的volatile等关键字。直到C++11标准,多线程才真正纳入C++标准,并提供了相关关键字、STL标准库,以便使用者实现跨平台的多线程调用。

当然,对使用者来说,多线程的实现可采用C++11的线程库,也可以根据具体的系统平台提供的多线程API自定义线程库,还可以使用诸如ACE、boost::thread等第三方库。使用C++自带的多线程库,有几个优点,一是使用简单方便,依赖少;二是跨平台,无需关注系统底层。

2.1.3.1 C++多线程关键字

  • thread_local

thread_local是C++是实现线程局部存储的关键,添加了此关键字的变量意味着每个线程都有自己的一份数据,不会共享同一份数据,避免数据竞争。

C11的关键字_Thread_local用于定义线程局部变量。在头文件<threads.h>定义了thread_local为上述关键词的同义。例如:

#include <threads.h>
thread_local int foo = 0;

C++11引入的thread_local关键字用于下述情形:

1、名字空间(全局)变量。

2、文件静态变量。

3、函数静态变量。

4、静态成员变量。

此外,不同编译器提供了各自的方法声明线程局部变量:

// Visual C++, Intel C/C++ (Windows systems), C++Builder, Digital Mars C++
__declspec(thread) int number;

// Solaris Studio C/C++, IBM XL C/C++, GNU C, Clang, Intel C++ Compiler (Linux systems)
__thread int number;

// C++ Builder
int __thread number;
  • volatile

使用了volatile修饰符的变量意味着它在内存中的值可能随时发生变化,也告诉编译器不能做任何优化,每次使用到此变量的值都必须从内存中读取,而不应该直接使用寄存器的值。

举个具体的栗子吧。假设有以下代码段:

int a = 10;
volatile int *p = &a;
int b, c;
b = *p;
c = *p;

p没有volatile修饰,则b = *pc = *p只需从内存取一次p的值,那么bc的值必然是10

若考虑volatile的影响,假设执行完b = *p语句之后,p的值被其它线程修改了,则执行c = *p会再次从内存中读取p的值,此时c的值不再是10,而是新的值。

但是,volatile并不能解决多线程的同步问题,只适合以下三种情况使用:

1、和信号处理(signal handler)相关的场合。

2、和内存映射硬件(memory mapped hardware)相关的场合。

3、和非本地跳转(setjmplongjmp)相关的场合。

  • std::atomic

严格来说atomic并不是关键字,而是STL的模板类,可以支持指定类型的原子操作。

使用原子的类型意味着该类型的实例的读写操作都是原子性的,无法被其它线程切割,从而达到线程安全和同步的目标。

可能有些读者会好奇,为什么对于基本类型的操作也需要原子操作。比如:

int cnt = 0;
auto f = [&]{cnt++;};
std::thread t1{f}, t2{f}, t3{f};

以上三个线程同时调用函数f,该函数只执行cnt++,在C++维度,似乎只有一条执行语句,理论上不应该存在同步问题。然而,编译成汇编指令后,会有多条指令,这就会在多线程中引起线程上下文切换,引起不可预知的行为。

为了避免这种情况,就需要加入atomic类型:

std::atomic<int> cnt{0};    // 给cnt加入原子操作。
auto f = [&]{cnt++;};
std::thread t1{f}, t2{f}, t3{f};

加入atomic之后,所有线程执行后的结果是确定的,能够正常给变量计数。atomic的实现机制与临界区类似,但效率上比临界区更快。

为了更进一步地说明C++的单条语句可能生成多条汇编指令,可借助Compiler Explorer来实时查探C++汇编后的指令:

Compiler Explorer动态将左侧C++语句编译出的汇编指令。上图所示的c++代码编译后可能存在一对多的汇编指令,由此印证atomic原子操作的必要性。

充分利用std::atomic的特性和接口,可以实现很多非阻塞无锁的线程安全的数据结构和算法,关于这一点的延伸阅读,强力推荐《C++ Concurrency In Action》

2.1.3.2 C++线程

C++的线程类型是std::thread,它提供的接口如下表:

接口 解析
join 加入主线程,使得主线程强制等待该线程执行完。
detach 从主线程分离,使得主线程无需等待该线程执行完。
swap 与另外一个线程交换线程对象。
joinable 查询是否可加入主线程。
get_id 获取该线程的唯一标识符。
native_handle 返回实现层的线程句柄。
hardware_concurrency 静态接口,返回硬件支持的并发线程数量。

使用范例:

#include <iostream>
#include <thread>
#include <chrono>

void foo()
{
    // simulate expensive operation
    std::this_thread::sleep_for(std::chrono::seconds(1));
}
 
int main()
{
    std::cout << "starting thread...\n";
    std::thread t(foo); // 构造线程对象,且传入被执行的函数。
 
    std::cout << "waiting for thread to finish..." << std::endl;
    t.join(); // 加入主线程,使得主线程必须等待该线程执行完毕。
 
    std::cout << "done!\n";
}

输出:

starting thread...
waiting for thread to finish...
done!

如果需要在调用线程和新线程之间同步数据,则可以使用C++的std::promisestd::future等机制。示例代码:

#include <vector>
#include <thread>
#include <future>
#include <numeric>
#include <iostream>
 
void accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last,
                std::promise<int> accumulate_promise)
{
    int sum = std::accumulate(first, last, 0);
    accumulate_promise.set_value(sum);  // Notify future
}
 
int main()
{
    // Demonstrate using promise<int> to transmit a result between threads.
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    std::promise<int> accumulate_promise;
    std::future<int> accumulate_future = accumulate_promise.get_future();
    std::thread work_thread(accumulate, numbers.begin(), numbers.end(),
                            std::move(accumulate_promise));
 
    // future::get() will wait until the future has a valid result and retrieves it.
    // Calling wait() before get() is not needed
    //accumulate_future.wait();  // wait for result
    std::cout << "result = " << accumulate_future.get() << '\n';
    work_thread.join();  // wait for thread completion
}

输出结果:

result = 21

但是,std::thread的执行并不能保证是异步的,也可能是在当前线程执行。

如果需要强制异步,则可使用std::async。它可以指定两种异步方式:std::launch::asyncstd::launch::deferred,前者表示使用新的线程异步地执行任务,后者表示在当前线程执行,且会被延迟执行。使用范例:

#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
#include <future>
#include <string>
#include <mutex>
 
std::mutex m;
struct X {
    void foo(int i, const std::string& str) {
        std::lock_guard<std::mutex> lk(m);
        std::cout << str << ' ' << i << '\n';
    }
    void bar(const std::string& str) {
        std::lock_guard<std::mutex> lk(m);
        std::cout << str << '\n';
    }
    int operator()(int i) {
        std::lock_guard<std::mutex> lk(m);
        std::cout << i << '\n';
        return i + 10;
    }
};
 
template <typename RandomIt>
int parallel_sum(RandomIt beg, RandomIt end)
{
    auto len = end - beg;
    if (len < 1000)
        return std::accumulate(beg, end, 0);
 
    RandomIt mid = beg + len/2;
    auto handle = std::async(std::launch::async,
                             parallel_sum<RandomIt>, mid, end);
    int sum = parallel_sum(beg, mid);
    return sum + handle.get();
}
 
int main()
{
    std::vector<int> v(10000, 1);
    std::cout << "The sum is " << parallel_sum(v.begin(), v.end()) << '\n';
 
    X x;
    // Calls (&x)->foo(42, "Hello") with default policy:
    // may print "Hello 42" concurrently or defer execution
    auto a1 = std::async(&X::foo, &x, 42, "Hello");
    // Calls x.bar("world!") with deferred policy
    // prints "world!" when a2.get() or a2.wait() is called
    auto a2 = std::async(std::launch::deferred, &X::bar, x, "world!");
    // Calls X()(43); with async policy
    // prints "43" concurrently
    auto a3 = std::async(std::launch::async, X(), 43);
    a2.wait();                     // prints "world!"
    std::cout << a3.get() << '\n'; // prints "53"
} // if a1 is not done at this point, destructor of a1 prints "Hello 42" here

执行结果:

The sum is 10000
43
Hello 42
world!
53

另外,C++20已经支持轻量级的协程(coroutine)了,相关的关键字:co_awaitco_returnco_yield,跟C#等脚本语言的概念和用法如出一辙,但行为和实现机制可能会稍有不同,此文不展开探讨了。

2.1.3.3 C++多线程同步

线程同步的机制有很多,C++支持的有以下几种:

  • std::atomic

[2.1.3.1 C++多线程关键字](#2.1.3.1 C++多线程关键字)已经对std::atomic做了详细的解析,可以防止多线程之间共享数据的数据竞险问题。此外,它还提供了丰富多样的接口和状态查询,以便更加精细和高效地同步原子数据,常见接口和解析如下:

接口名 解析
is_lock_free 检查原子对象是否无锁的。
store 存储值到原子对象。
load 从原子对象加载值。
exchange 获取原子对象的值,并替换成指定值。
compare_exchange_weak, compare_exchange_strong 将原子对象的值和预期值(expected)对比,如果相同就替换成目标值(desired),并返回true;如果不同,就加载原子对象的值到预期值(expected),并返回false。weak模式不会卡调用线程,strong模式会卡住调用线程,直到原子对象的值和预期值(expected)相同。
fetch_add, fetch_sub, fetch_and, fetch_or, fetch_xor 获取原子对象的值,并对其相加、相减等操作。
operator ++, operator --, operator +=, operator -=, ... 对原子对象响应各类操作符,操作符的意义和普通变量一致。

此外,C++20还支持wait, notify_one, notify_all等同步接口。

利用compare_exchange_weak接口可以很方便地实现线程安全的非阻塞式的数据结构。示例:

#include <atomic>
#include <future>
#include <iostream>

template<typename T>
struct node
{
    T data;
    node* next;
    node(const T& data) : data(data), next(nullptr) {}
};
 
template<typename T>
class stack
{
 public:
    std::atomic<node<T>*> head;    // 堆栈头, 采用原子操作.
 public:
    // 入栈操作
    void push(const T& data)
    {
        node<T>* new_node = new node<T>(data);
 
        // 将原有的头指针作为新节点的下一节点.
        new_node->next = head.load(std::memory_order_relaxed);
 
        // 将新的节点和老的头部节点做对比测试, 如果new_node->next==head, 说明其它线程没有修改head, 可以将head替换成new_node, 从而完成push操作.
        // 反之, 如果new_node->next!=head, 说明其它线程修改了head, 将其它线程修改的head保存到new_node->next, 继续循环检测.
        while(!head.compare_exchange_weak(new_node->next, new_node,
                                        std::memory_order_release,
                                        std::memory_order_relaxed))
            ; // 空循环体
    }
};

int main()
{
    stack<int> s;
    
    auto r1 = std::async(std::launch::async, &stack<int>::push, &s, 1);
    auto r2 = std::async(std::launch::async, &stack<int>::push, &s, 2);
    auto r3 = std::async(std::launch::async, &stack<int>::push, &s, 3);
    
    r1.wait();
    r2.wait();
    r3.wait();
    
    // print the stack's values
    node<int>* node = s.head.load(std::memory_order_relaxed);
    while(node)
    {
        std::cout << node->data << " ";
        node = node->next;
    }
}

输出:

2 3 1

由此可见,利用原子及其接口可以很方便地进行多线程同步,而且由于是多线程异步入栈,栈的元素不一定与编码的顺序一致。

以上代码还涉及内存访问顺序的标记:

  • 排序一致序列(sequentially consistent)。
  • 获取-释放序列(memory_order_consume, memory_order_acquire, memory_order_release和memory_order_acq_rel)。
  • 自由序列(memory_order_relaxed)。

关于这方面的详情可以参看第一篇的内存屏障或者《C++ concurrency in action》的章节5.3 同步操作和强制排序

  • std::mutex

std::mutex即互斥量,它会在作用范围内进入临界区(Critical section),使得该代码片段同时只能由一个线程访问,当其它线程尝试执行该片段时,会被阻塞。std::mutex常与std::lock_guard,示例代码:

#include <iostream>
#include <map>
#include <string>
#include <chrono>
#include <thread>
#include <mutex>
 
std::map<std::string, std::string> g_pages;
std::mutex g_pages_mutex;    // 声明互斥量
 
void save_page(const std::string &url)
{
    // simulate a long page fetch
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::string result = "fake content";
     
    // 配合std::lock_guard使用, 可以及时进入和释放互斥量.
    std::lock_guard<std::mutex> guard(g_pages_mutex);
    g_pages[url] = result;
}
 
int main() 
{
    std::thread t1(save_page, "http://foo");
    std::thread t2(save_page, "http://bar");
    t1.join();
    t2.join();
 
    // safe to access g_pages without lock now, as the threads are joined
    for (const auto &pair : g_pages) {
        std::cout << pair.first << " => " << pair.second << '\n';
    }
}

输出:

http://bar => fake content
http://foo => fake content

此外,手动操作std::mutex的锁定和解锁,可以实现一些特殊行为,例如等待某个标记:

#include <chrono>
#include <thread>
#include <mutex>

bool flag;
std::mutex m;

void wait_for_flag()
{
    std::unique_lock<std::mutex> lk(m); // 这里采用std::unique_lock而非std::lock_guard. std::unique_lock可以实现尝试获得锁, 如果当前以及被其它线程锁定, 则延迟直到其它线程释放, 然后才获得锁.
    while(!flag)
    {
        lk.unlock(); // 解锁互斥量
        std::this_thread::sleep_for(std::chrono::milliseconds(100));  // 休眠100ms,在此期间,其它线程可以进入互斥量,以便更改flag标记。
        lk.lock();   // 再锁互斥量
    }
}
  • std::condition_variable

std::condition_variablestd::condition_variable_any都是条件变量,都是C++标准库的实现,它们都需要与互斥量配合使用。由于std::condition_variable_any更加通用,会在性能上产生更多的开销。故而,应当首先考虑使用std::condition_variable

利用条件变量的接口,结合互斥量的使用,可以很方便地执行线程间的等待、通知等操作。示例:

#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
 
std::mutex m;
std::condition_variable cv;    // 声明条件变量
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread()
{
    // 等待直到主线程改变ready为true.
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{return ready;});
 
    // 获得了互斥量的锁
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // 发送数据给主线程
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // 手动解锁, 以便主线程获得锁.
    lk.unlock();
    cv.notify_one();
}
 
int main()
{
    std::thread worker(worker_thread);
 
    data = "Example data";
    // send data to the worker thread
    {
        std::lock_guard<std::mutex> lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();
 
    // wait for the worker
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, []{return processed;});
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

输出:

main() signals data ready for processing
Worker thread is processing data
Worker thread signals data processing completed
Back in main(), data = Example data after processing
  • std::future

C++的future(期望)是一种可以访问未来的返回值的机制,常用于多线程的同步。可以创建future的类型有: std::async, std::packaged_task, std::promise。

future对象可以执行wait、wait_for、wait_until,从而实现事件等待和同步,示例代码:

#include <iostream>
#include <future>
#include <thread>
 
int main()
{
    // 从packaged_task获取的future
    std::packaged_task<int()> task([]{ return 7; }); // wrap the function
    std::future<int> f1 = task.get_future();  // get a future
    std::thread t(std::move(task)); // launch on a thread
 
    // 从async()获取的future
    std::future<int> f2 = std::async(std::launch::async, []{ return 8; });
 
    // 从promise获取的future
    std::promise<int> p;
    std::future<int> f3 = p.get_future();
    std::thread( [&p]{ p.set_value_at_thread_exit(9); }).detach();
     
    // 等待所有future
    std::cout << "Waiting..." << std::flush;
    f1.wait();
    f2.wait();
    f3.wait();
    std::cout << "Done!\nResults are: " << f1.get() << ' ' << f2.get() << ' ' << f3.get() << '\n';
    t.join();
}

输出:

Waiting...Done!
Results are: 7 8 9

2.1.4 多线程实现机制

多线程按并行内容可分为数据并行和任务并行两种。其中数据并行是不同的线程携带不同的数据执行相同的逻辑,最经典的数据并行的应用是MMX指令、SIMD技术、Compute着色器等。任务并行是不同的线程执行不同的逻辑,数据可以相同,也可以不同,例如,游戏引擎经常将文件加载、音频处理、网络接收乃至物理模拟都放到单独的线程,以便它们可以并行地执行不同的任务。

多线程如果按划分粒度和方式,则有线性划分、递归划分、任务类型划分等。

线性划分法的最简单应用就是将连续数组的元素平均分成若干份,每份数据派发到一个线程中执行,例如并行化的std::for_each和UE里的ParallelFor

线性划分示意图。连续数据被均分为若干份,接着派发到若干线程中并行地执行。

在线性划分并行执行结束后,通常需要由调用线程合并和同步并行的结果。

递归划分法是将连续数据按照某种规则划分成若干份,每一份又可继续划分成更细粒度,直到某种规则停止划分。常用于快速排序。

快速排序有两个最基本的步骤:将数据划分到中枢(pivot)元素之前或之后,然后对中枢元素之前和之后的两半数组再次进行快速排序。由于只有在一次排序结束后才能知道哪些项在中枢元素之前和之后,所以不能通过对数据的简单(线性)划分达到并行。当要对这种算法进行并行化,很自然的会想到使用递归。每一级的递归都会多次调用quick_sort函数,因为需要知道哪些元素在中枢元素之前和之后。

递归划分法示意图。

将一个大框架内的逻辑划分成若干个子任务,它们之间通常保持独立,也可以有一定依赖,每个任务派发到一个线程执行,这就意味着真正意义上的线程独立,每个线程只需要关注自己所要做的事情即可。

任务划分示意图。

合理地安排和划分子任务,减少它们之间的依赖和等待同步,是提升运行效率的有利武器。不过,要做到这点,往往需要经过精细的设计实现以及反复调试和修改。

上面这种实现机制常被称为Fork-Join(分叉-合并)并行模型,它和串行模型的运行机制对比如下图:

上:串行运行模型;下:Fork-Join并行运行模型。

GDC2010的演讲Task-based Multithreading - How to Program for 100 cores详细阐述了如何采用基于Task的多线程运行机制:

基于Task的多线程比基于线程的架构要好很多,可以更加充分地利用多核优势,使得每个核心都保持忙碌状态:

该文还提到了如何将基于任务的多线程应用于排序、迷宫寻路等实际案例中。

 

2.2 现代图形API的多线程特性

2.2.1 传统图形API的多线程特性

OpenGL及DirectX10之前版本的图形API,所有的绘制指令是线性和阻塞式的,意味着每次调用Draw接口都不会立即返回,会卡住调用线程。这种CPU和GPU的交互机制在单核时代,对性能的影响不那么突出,但是随着多核时代的到来,这种交互机制显然会严重影响运行性能。

若游戏引擎的渲染器仍然是单线程的,这常常导致CPU的性能瓶颈,阻碍了利用多核计算资源来提高性能或丰富可视化内容。

传统图形API线性执行绘制指令示意图。

单线程渲染器通常会导致单个 CPU 内核满负荷运行,而其他内核保持相对空闲,且性能低于可玩的帧率。

传统图形API在单线程单Context下设置渲染状态调用绘制指令,并且绘制指令是阻塞式的,CPU和GPU无法并行运行,其它CPU核心也会处于空闲等待状态。

在这些传统图形API架构多线程渲染,必须从软件层面着手,开辟多个线程,用于单独处理逻辑和渲染指令,以便解除CPU和GPU的相互等待耦合。早在SIGGraph2008有个Talk(Practical Parallel Rendering with DirectX 9 and 10)专门讲解如何在DirectX9和10实现软件级的多线程渲染,核心部分就是在软件层录制(Playback)D3D的绘制命令(Command)。

Practical Parallel Rendering with DirectX 9 and 10中提出的一种软件级的多线程渲染架构。

不过,这种软件层面的命令录制存在多种问题,不支持部分图形API(如状态查询),需额外的命令缓存区记录绘制指令,命令阶段无法创建真正的GPU资源等等。

DirectX11尝试从硬件层面解决多线程渲染的问题。它支持了两种设备上下文:即时上下文(Immediate Context)延迟上下文(Deferred Context)。不同的延迟上下文可以同时在不同的线程中使用,生成将在“即时上下文”中执行的命令列表。这种多线程策略允许将复杂的场景分解成并发任务。

DirectX11的多线程模型。

不同的延迟上下文可以同时在不同的线程中使用,生成将在即时上下文中执行的命令列表。这种多线程策略允许将复杂的场景分解成并发任务。此外,延迟上下文在某些驱动的支持下,可实现硬件级的加速,而不必在即时上下文执行Command List。

为什么使用Deferred Context的Command List提前录制绘制指令会比直接使用Immediate Context调用绘制指令的效率更高?

答案在于Command List内部会对已经录制的指令做高度的优化,执行这些优化后的指令会明显提升效率,比直接单独每条调用图形API会高效得多。

在D3D11中命令列表中的命令是被快速记录下来,而不是立即执行的,直到程序调用ExecuteCommandList方法(调用即返回,不等待)才被GPU真正的执行,此时那些使用延迟渲染设备接口的CPU线程以及主渲染线程又可以去干别的事情了,比如继续下一帧的碰撞检测、物理变换、动画插值、光照准备等等,从而为记录形成新的命令列表做准备。

不过,基于DirectX11的多线程架构,由于硬件层面的加速不是必然支持,所有在Deferred Context记录的指令连同Immediate Context的指令必须由同一个线程(通常是渲染线程)提交给GPU执行。

DirectX11下的多线程架构示意图。

这种非硬件支持的多线程渲染只是节省了部分CPU时间(多线程录制指令和绘制同步等待),并不能从硬件层面真正发挥多线程的威力。

2.2.2 DirectX12的多线程特性

相较于DirectX11过渡性的伪多线程模型(称之伪,是因为当时的大多数驱动并不支持DirectX11的硬件级多线程),DirectX 12 多线程则通过显著减少 API 调用额外开销得到了很大的改进,它取消了 DirectX 11 的设备上下文的概念,直接使用Command List来调用 D3D APIs,然后通过命令队列将命令列表提交给 GPU,并且所有 DirectX 12显卡都支持 DirectX 12 多线程的硬件加速。

DirectX12的多线程模型。

从原理上来看,DirectX12与DirectX11多线程渲染框架是类似的,都是通过在不同的CPU线程中录制命令列表(Command Lists),最后再统一执行的方式完成多线程渲染。它们都从根本上屏蔽了令人发指的Draw Call同步调用,而改为CPU和GPU完全异步(并行)执行的方式,从而在整体渲染效率和性能上获得巨大的提升。

对于DirectX12,用户层面有3种命令队列(Command Queue):复制队列(Copy Queue)计算队列(Compute Queue)3D队列(3D Queue),它们可以并行地执行,并且通过栅栏(Fence)、信号(Signal)或屏障(Barrier)来等待和同步。

GPU硬件层面则有3种引擎:复制引擎(Copy Engine)计算引擎(Compute Engine)3D引擎(3D Engine),它们也可以并行地执行,并且通过栅栏(Fence)、信号(Signal)或屏障(Barrier)来等待和同步。

命令队列可驱动GPU硬件的若干引擎,但有一定的限制,更具体地,3D Queue可以驱动GPU硬件的3种引擎,Compute Queue只能驱动Compute Engine和Copy Engine,Copy Queue仅可以驱动Copy Engine。

在CPU层面,可以有若干个线程,每个线程可创建产生若干个命令列表(Command List),每个命令列表可进入3种Command Queue的其中一种。当这些命令被GPU执行时,每种指令列表里的命令会压入不同的GPU引擎,以便它们并行地执行。(下图)

DirectX12中的CPU线程、命令列表、命令队列、GPU引擎之间的运行机制示意图。

2.2.3 Vulkan的多线程特性

作为跨平台图形API的新生代表Vulkan,摒弃了传统图形API的弊端,直面多核时代的优势,从而从设计和架构上发挥了并行渲染的威力。

综合上看,Vulkan和DirectX12是非常接近的,都有着Command Buffer、CommandPool、Command Queue和Fence等核心概念,并行模式也非常相似:在不同的CPU线程并行地生成Command Buffer指令,最后由主线程收集这些Command Buffer并提交至GPU:

Vulkan图形API并行示意图。

并且,Vulkan的CommandPool可以每帧被不同的线程创建,以便减少同步等待,提升并行效率:

Vulkan中的CommandPool在不同帧之间的并行示意图。

此外,Vulkan也存在着和DirectX12类似的各种同步机制:

Vulkan同步机制:semaphore(信号)用于同步Queue;Fence(栅栏)用于同步GPU和CPU;Event(事件)和Barrier(屏障)用于同步Command Buffer。

关于Vulkan的更多用法、剖析、对比可参见文献Evaluation of multi-threading in Vulkan

2.2.4 Metal的多线程特性

Metal作为iOS和MacOS系统的专属图形API,也是新生代的代表,它既兼容OpenGL这种传统的图形API用法,也支持类似Vulkan、DirectX12的新一代图形API理念和架构。从使用者层面来看,Metal是比较友善的,提供了结构更清晰、概念更友好的API。

从OpenGL迁移到新生代图形API的成本和收益对比。横坐标是从OpenGL(或ES)迁移其它图形API的成本,纵坐标是潜在的性能收益。可见Metal的迁移成本较低,但潜在的性能比也没有Vulkan和DirectX12高。

Metal如同Vulkan和DirectX,有着很多相似的概念,诸如Command、Command Buffer、Command Queue及各类同步机制。

Metal基础概念关系一览表。其中Command Encoder有3种类型:MTLRenderCommandEncoder、MTLComputeCommandEncoder和MTLBlitCommandEncoder。CommandEncoder录制命令之后,塞入Command Buffer,最终进入Command Queue命令队列。

有了类似的概念和机制,Metal同样可以方便地实现多线程录制命令,且从硬件层面支持多线程调度:

Metal多线程模型示意图。图中显示了3个CPU线程同时录制不同类型的Encoder,每个线程都有专属的Command Buffer,最终这些Command Buffer统一汇入Command Queue交由GPU执行。

 

2.3 游戏引擎的多线程渲染

在正式讲解UE的多线程渲染之前,先了解一下其它主流商业引擎的多线程架构和设计。

2.3.1 Unity

Unity的渲染体系中有几个核心概念,一个是Client,运行于主线程(逻辑线程),负责产生渲染指令;另一个是Worker Thread,工作线程,用于协助处理主线程或生成渲染指令等各类子工作。Unity的渲染架构中支持以下几种模式:

  • Singlethreaded Rendering

单线程渲染模式,此模式下只有单个Client组件,没有工作线程。唯一的Client在主线程中产生所有的渲染命令(rendering command,RCMD),并且拥有图形设备对象,也会在主线程向图形设备产生调用图形API命令(graphics API,GCMD),它的调度示意图如下:

这种模式下,CPU和GPU可能会相互等待,无法充分利用多核CPU,性能比较最差。

  • **Multithreaded Rendering **

多线程渲染模式,这种模式下和单线程对比,就是多了一条工作线程,即用于生成GCMD的渲染线程,其中渲染线程跑的是GfxDeviceClient对象,专用于生成对应平台的图形API指令:

  • Jobified Rendering

作业化渲染模式,此模式下有多个Client对象,单个渲染线程。此外,有多个作业对象,每个作业对象跑在专用独立的线程,用于生成即时图形命令(intermediate graphics commands,IGCMD)。此外,还有一个工作线程(渲染线程)用于将作业线程生成的IGCMD转换成图形API的GCMD,运行示意图如下:

  • Graphics Jobs

图形化作业渲染模式,此模式下有多个Client,多个工作线程,没有渲染线程。主线程上的多个Client对象驱动工作线程上的对应图形设备对象,直接生成GCMD,从而避免生成Jobified Rendering模式的IGCMD中间指令。只在支持硬件级多线程的图形API上可启用,如DirectX12、Vulkan等。运行示意图如下:

2.3.2 Frostbite

Frostbite(寒霜)引擎在早期的时候,将每一帧分成个步骤:裁剪、构建、渲染,每个步骤所需的数据都放到双缓冲内(double buffer),采用级联方式运行,应用简单的同步流程。它的运行示意图如下:

而经过多年的进化,Frostbite在前几年采用了帧图(Frame Graph)的多线程渲染模式。该模式旨在将引擎的各类渲染功能(Feature)和上层渲染逻辑(Renderer)和下层资源(Shader、RenderContext、图形API等)隔离开来,以便做进一步的解耦、优化,其中最重要的优化即开启多线程渲染。

FrameGraph是高层级的Render Pass和资源的代表,包含了一帧中所用到的所有信息。Pass之间可以指定顺序和依赖关系,下图是其中的一个示例:

寒霜引擎采用帧图方式实现的延迟渲染的顺序和依赖图。

其中帧图的每一帧信息都有三个阶段:建立(Setup)、编译(Compile)和执行(Execute)。

建立阶段就是创建各个Render Pass、输入纹理、输出纹理、依赖资源等等信息。

编译阶段的工作主要是剔除未使用的Render Pass和资源,计算资源生命周期,以及根据使用标记创建对应的GPU资源,创建GPU资源时又做了大量的优化,诸如:简化显存分配算法,在第一次使用时申请最后一次使用后释放,异步计算外部资源的生命周期,源于绑定标记的精确资源管理,扁平化所有资源的引用以提升GPU高速缓存的命中率等等。编译阶段采用线性遍历所有的RenderPass,遍历时会计算资源引用次数、资源的最初和最后使用者、异步等待点和资源屏障等等。

执行阶段就按照Setup的顺序执行(编译阶段也不会重新排序),只遍历那些未被剔除的Render Pass并执行它们的回调函数。如果是立即模式,则直接调用设备上下文的API。执行阶段才会根据编译阶段生成的handle真正获取GPU资源。

最关键的是整个过程通过依赖图(Dependency Grahp)实现自动化异步计算。异步机制在主时间轴开始,会自动同步在不同Queue里的资源,同时会扩展它们的生命周期,以防意外释放。当然,这个自动化系统也有副作用,如额外增加一定量的内存,可能会引发不可预期的性能瓶颈。所以,寒霜引擎支持手动模式,以便按照预期控制和更改异步运行方式,从而逐Render Pass选择性加入。

下图可以比较简洁明了说明异步计算的运行机制:

寒霜引擎异步计算示意图。其中SSAO、SSAO Filter的Pass放入到异步队列,它们会写入和读取Raw AO的纹理,即便在同步点之前结束,但Raw AO的生命周期依然会被延长到同步点。

总之,帧图的渲染架构得益于记录了该帧所有的信息,以至于可以通过资源别名(Resource Aliasing)节省大量的内存和显存,可以实现半自动化的异步计算,可以简化渲染管线控制,可以制作出更加良好的可视化和诊断工具。

2.3.3 Naughty Dog Engine

顽皮狗的游戏引擎采用的也是作业系统,允许非GPU端的逻辑代码加入到作业系统。作业直接可以开启和等待其它作业,对调用者隐藏内存管理细节,提供了简洁易用的API,性能优化放在了第二位。

其中作业系统运行于纤程(Fiber),每个纤程类似局部的线程,用户层提供栈空间,其上下文包含少量的纤程状态,以便减少寄存器的占用。实际地运行在线程上,协作型的多线程模型。由于纤程非系统级的线程,切换上下文会非常快,只保存和恢复寄存器的状态(程序计数,栈指针,gpr等),故而开销会很小。

作业系统会开辟若干条工作线程,每条工作线程会锁定到GPU硬件核心。线程是执行单元,纤程是上下文,作业总是在线程的上下文内执行,采用原子计数器来同步。下图是顽皮狗引擎的作业系统架构图:

顽皮狗引擎作业系统架构图。拥有6个工作线程,160个纤程,3个作业队列。

作业可以向作业队列添加新的作业,同时等待中的作业会放到专门的等待列表,每个等待中的作业会有引用计数,直到引用计数为0,才会从等待队列中出列,以便继续执行。

在顽皮狗引擎内,除了IO线程之外的所有东西都是作业,包括游戏物体更新、动作更新和混合、射线检测、渲染命令生成等等。可见将作业系统发挥得淋漓尽致,最大程度提升了并行的比例和效率。

为了提升帧率,将游戏逻辑和渲染逻辑相分离,并行地执行,不过处理的是不同帧的数据,通常游戏数据领先渲染数据一帧,而渲染逻辑又领先GPU数据一帧。

通过这样的机制,可以避免CPU线程之间以及CPU和GPU之间的同步和等待,提升了帧率和吞吐量。

此外,它的内存分配也做了精致的管理,比如引入了带标签的内存堆(Tagged Heap),内存堆以2M为一块(Block),每个Block带有一个标签(Game、Render、GPU之一),分配器分配和释放内存时是在标签堆里执行,避免直接向操作系统获取:

此外,分配器支持为每个工作线程分配一个专属的块(跟TLS类似),避免数据同步和等待的时间,避免数据竞险。

2.3.4 Destiny’s Engine

命运(Destiny)是一款第一人称的动作角色扮演MMORPG,它使用的引擎也被称为命运引擎(Destiny’s Engine)。

命运引擎在多线程架构上,采用的技术有基于任务的并行处理,作业管理设计和同步处理,作业的执行也是在纤程上。作业系统执行作业的优先级是FIFO(先进先出),作业图是异源架构,作业之间存在依赖,但没有栅栏。

它将每一帧分成几个步骤:模拟游戏物体、物体裁剪、生成渲染命令、执行GPU相关工作、显示。在线程设计上,会创建6条系统线程,每条线程的内容依次是:模拟循环,其它作业,渲染循环,音频循环,作业核心和调试Log,异步任务、IO等。

在处理帧之间的数据,也是分离开游戏模拟和渲染逻辑,游戏模拟总是领先渲染一帧。游戏模拟完之后,会将所有数据和状态拷贝一份(镜像,Mirror),以供下一帧的渲染使用:

命运引擎为了最大化CPU和GPU的并行效率,采取了动态加载平衡(dynamic load balancing)和智能作业合批(smart job batching),具体做法是将所有渲染和可见性剔除的工作加入到任务系统,保持低延迟。下图是并行化计算视图作业的图例:

此外,还将模拟逻辑从渲染逻辑中抽离和解耦,采用完全的数据驱动的渲染管线,所有的排序、内存分配、遍历等算法都遵循了高速缓存一致性(结构体小量化,数据对齐,使得单个结构体数据能一次性被加载进高速缓存行)。

 

2.4 UE的多线程机制

本章节主要剖析一下UE的多线程基础、设计及架构,以便后面更好地切入到多线程渲染。

2.4.1 UE的多线程基础

  • TAtomic

UE的原子操作并没有使用C++的Atomic模板,而是自己实现了一套,叫TAtomic。它提供的功能有加载、存储、赋值等操作,在底层实现上,会采用平台相关的原子操作接口实现:

// Engine\Source\Runtime\Core\Public\Templates\Atomic.h

template <typename T>
FORCEINLINE T Load(const volatile T* Element)
{
    // 采取平台相关的接口加载原子值.
    auto Result = FPlatformAtomics::AtomicRead((volatile TUnderlyingIntegerType_T<T>*)Element);
    return *(const T*)&Result;
}

template <typename T>
FORCEINLINE void Store(const volatile T* Element, T Value)
{
    // 采取平台相关的接口存储原子值.
    FPlatformAtomics::InterlockedExchange((volatile TUnderlyingIntegerType_T<T>*)Element, *(const TUnderlyingIntegerType_T<T>*)&Value);
}

template <typename T>
FORCEINLINE T Exchange(volatile T* Element, T Value)
{
    // 采取平台相关的接口交换原子值.
    auto Result = FPlatformAtomics::InterlockedExchange((volatile TUnderlyingIntegerType_T<T>*)Element, *(const TUnderlyingIntegerType_T<T>*)&Value);
    return *(const T*)&Result;
}

在内存顺序上,不像C++提供了四种模式,UE做了简化,只提供了两种模式:

enum class EMemoryOrder
{
    Relaxed,    // 顺序松散, 不会引起重排序
    SequentiallyConsistent    // 顺序一致
};

需要注意的是,TAtomic虽然是模板类,但只对基本类型生效,UE是通过父类TAtomicBaseType_T来达到检测的目的:

template <typename T>
class TAtomic final : public UE4Atomic_Private::TAtomicBaseType_T<T>
{
    static_assert(TIsTrivial<T>::Value, "TAtomic is only usable with trivial types");
    
    (......)
}
  • TFuture

UE实现了类似C++的Future和Promise对象,是模板类,抽象了返回值类型。以下是TFuture的声明:

// Engine\Source\Runtime\Core\Public\Async\Future.h

template<typename InternalResultType>
class TFutureBase
{
public:
    bool IsReady() const;
    bool IsValid() const;

    void Wait() const
    {
        if (State.IsValid())
        {
            while (!WaitFor(FTimespan::MaxValue()));
        }
    }
    bool WaitFor(const FTimespan& Duration) const
    {
        return State.IsValid() ? State->WaitFor(Duration) : false;
    }
    bool WaitUntil(const FDateTime& Time) const
    {
        return WaitFor(Time - FDateTime::UtcNow());
    }

protected:
    typedef TSharedPtr<TFutureState<InternalResultType>, ESPMode::ThreadSafe> StateType;

    const StateType& GetState() const;

    template<typename Func>
    auto Then(Func Continuation);

    template<typename Func>
    auto Next(Func Continuation);

    void Reset();

private:

    /** Holds the future's state. */
    StateType State;
};

template<typename ResultType>
class TFuture : public TFutureBase<ResultType>
{
    typedef TFutureBase<ResultType> BaseType;

public:

    ResultType Get() const
    {
        return this->GetState()->GetResult();
    }

    TSharedFuture<ResultType> Share()
    {
        return TSharedFuture<ResultType>(MoveTemp(*this));
    }
};
  • TPromise

TPromise通常要和TFuture配合使用,如下所示:

template<typename InternalResultType>
class TPromiseBase : FNoncopyable
{
    typedef TSharedPtr<TFutureState<InternalResultType>, ESPMode::ThreadSafe> StateType;

    (......)
    
protected:
    const StateType& GetState();

private:
    StateType State; // 存储了Future的状态.
};

template<typename ResultType>
class TPromise : public TPromiseBase<ResultType>
{
public:
    typedef TPromiseBase<ResultType> BaseType;

public:
    // 获取Future对象
    TFuture<ResultType> GetFuture()
    {
        check(!FutureRetrieved);
        FutureRetrieved = true;

        return TFuture<ResultType>(this->GetState());
    }
    
    // 设置Future的值
    FORCEINLINE void SetValue(const ResultType& Result)
    {
        EmplaceValue(Result);
    }

    FORCEINLINE void SetValue(ResultType&& Result)
    {
        EmplaceValue(MoveTemp(Result));
    }

    template<typename... ArgTypes>
    void EmplaceValue(ArgTypes&&... Args)
    {
        this->GetState()->EmplaceResult(Forward<ArgTypes>(Args)...);
    }

private:
    bool FutureRetrieved;
};
  • ParallelFor

ParallelFor是UE内置的支持多线程并行处理任务的For循环,在渲染系统中应用得相当普遍。它支持以下几种并行方式:

enum class EParallelForFlags
{
    None, // 默认并行方式
    ForceSingleThread = 1, // 强制单线程, 常用于调试.
    Unbalanced = 2, // 非任务平衡, 常用于具有高度可变计算时间的任务.
    PumpRenderingThread = 4, // 注入渲染线程. 如果是在渲染线程调用, 需要保证ProcessThread空闲状态.
};

支持的ParallelFor调用方式如下:

inline void ParallelFor(int32 Num, TFunctionRef<void(int32)> Body, bool bForceSingleThread, bool bPumpRenderingThread=false);

inline void ParallelFor(int32 Num, TFunctionRef<void(int32)> Body, EParallelForFlags Flags = EParallelForFlags::None);

template<typename FunctionType>
inline void ParallelForTemplate(int32 Num, const FunctionType& Body, EParallelForFlags Flags = EParallelForFlags::None);

inline void ParallelForWithPreWork(int32 Num, TFunctionRef<void(int32)> Body, TFunctionRef<void()> CurrentThreadWorkToDoBeforeHelping, bool bForceSingleThread, bool bPumpRenderingThread = false);

inline void ParallelForWithPreWork(int32 Num, TFunctionRef<void(int32)> Body, TFunctionRef<void()> CurrentThreadWorkToDoBeforeHelping, EParallelForFlags Flags = EParallelForFlags::None);

ParallelFor是基于TaskGraph机制实现的,由于TaskGraph后面才提到,这里就不涉及其实现。下面展示UE的一个应用案例:

// Engine\Source\Runtime\Engine\Private\Components\ActorComponent.cpp

// 并行化增加Primitive到场景的用例.
void FRegisterComponentContext::Process()
{
    FSceneInterface* Scene = World->Scene;
    ParallelFor(AddPrimitiveBatches.Num(), // 数量
        [&](int32 Index) //回调函数, Index返回索引
        {
            if (!AddPrimitiveBatches[Index]->IsPendingKill())
            {
                Scene->AddPrimitive(AddPrimitiveBatches[Index]);
            }
        },
        !FApp::ShouldUseThreadingForPerformance() // 是否多线程处理
    );
    AddPrimitiveBatches.Empty();
}
  • 基础模板

UnrealTemplate.h定义了很多基础模板,用于数据转换、拷贝、转移等功能。下面例举部分常见的函数和类型:

模板名 解析 stl映射
template
ReferencedType* IfAThenAElseB(ReferencedType* A,ReferencedType* B)
返回A ? A : B -
template
void Move(T& A,typename TMoveSupportTraits::Copy B)
释放A,将B的数据替换到A,但不会影响B的数据。 -
template
void Move(T& A,typename TMoveSupportTraits::Move B)
释放A,将B的数据替换到A,但会影响B的数据。 -
FNoncopyable 派生它即可实现不可拷贝的对象。 -
TGuardValue 带作业域的值,可指定一个新值和旧值,作用域内是新值,离开作用域变成旧值。 -
TScopeCounter 带作用域的计数器,作用域内计数器+1,离开作用域后计数器-1 -
template
typename TRemoveReference::Type&& MoveTemp(T&& Obj)
将引用转换成右值,可能会修改源值。 std::move
template
T CopyTemp(T& Val)
强制创建右值的拷贝,不会改变源值。 -
template
T&& Forward(typename TRemoveReference::Type& Obj)
将引用转换成右值引用。 std::forward
template <typename T, typename ArgType>
T StaticCast(ArgType&& Arg)
静态类型转换。 static_cast

2.4.2 UE的多线程实现

UE的多线程实现上并没有采纳C++11标准库的那一套,而是自己从系统级做了封装和实现,包括系统线程、线程池、异步任务、任务图以及相关的通知和同步机制。

2.4.2.1 FRunnable

FRunnable是所有可以在多个线程并行地运行的物体的父类,它提供的基础接口如下:

// Engine\Source\Runtime\Core\Public\HAL\Runnable.h

class CORE_API FRunnable
{
public:
    virtual bool Init();    // 初始化, 成功返回True.
    virtual uint32 Run();    // 运行, 只有Init成功才会被调用.
    virtual void Stop();    // 请求提前停止.
    virtual void Exit();    // 退出, 清理数据.
};

FRunnable及其子类是可运行于多线程的对象,而与之对立的是只在单线程运行的类FSingleThreadRunnable

// Engine\Source\Runtime\Core\Public\Misc\SingleThreadRunnable.h

// 多线程禁用下的单线程运行的物体
class CORE_API FSingleThreadRunnable
{
public:
    virtual void Tick();
};

FRunnable的子类非常多,以下是常见的部分核心子类及其解析。

  • FRenderingThread:运行于渲染线程上的对象。后面有章节会专门剖析。

  • FRHIThread:运行于RHI线程上的对象。后面有章节会专门剖析。

  • FRenderingThreadTickHeartbeat:运行于心跳渲染线程上的物体。

  • FTaskThreadBase:在线程执行的任务父类,后面会有章节专门解析这部分。

  • FQueuedThread:可存储在线程池的线程父类。提供的接口如下:

    // Engine\Source\Runtime\Core\Private\HAL\ThreadingBase.cpp
    
    class FQueuedThread : public FRunnable
    {
    protected:
        FEvent* DoWorkEvent; // 任务执行完毕的事件.
        TAtomic<bool> TimeToDie; // 是否需要超时.
        IQueuedWork* volatile QueuedWork; // 被执行的任务.
        class FQueuedThreadPoolBase* OwningThreadPool; // 所在的线程池.
        FRunnableThread* Thread; // 真正用于执行任务的线程.
    
        virtual uint32 Run() override;
        
    public:
        virtual bool Create(class FQueuedThreadPoolBase* InPool,uint32 InStackSize,EThreadPriority ThreadPriority);
        bool KillThread();
        void DoWork(IQueuedWork* InQueuedWork);
    };
    
  • TAsyncRunnable:异步地在单独线程运行的任务,是个模板类,声明如下:

    // Engine\Source\Runtime\Core\Public\Async\Async.h
    
    template<typename ResultType>
    class TAsyncRunnable: public FRunnable
    {
    public:
        virtual uint32 Run() override;
    
    private:
        TUniqueFunction<ResultType()> Function;
        TPromise<ResultType> Promise;
        TFuture<FRunnableThread*> ThreadFuture;
    };
    
  • FAsyncPurge:辅助类,提供销毁位于工作线程的UObject对象。

由此可见,FRunnable对象并不能独立存在,总是要依赖线程来真正地执行任务。

另外,还需要特意提出:FRenderingThread、FQueuedThread听名字像是真正的线程,然而并不是,只是用于处理某些特定任务的可运行物体,实际上还是要依赖它们内部FRunnableThread的成员对象来执行。

2.4.2.2 FRunnableThread

FRunnableThread是可运行线程的父类,提供了一组用于管理线程生命周期的接口。它提供的基础接口和解析如下:

// Engine\Source\Runtime\Core\Public\HAL\RunnableThread.h

class CORE_API FRunnableThread
{
    static uint32 RunnableTlsSlot;    // FRunnableThread的TLS插槽索引.

public:
    static uint32 GetTlsSlot();
    // 静态类, 用于创建线程, 需提供一个FRunnable对象, 用于线程执行的任务.
    static FRunnableThread* Create(FRunnable* InRunnable, const TCHAR* ThreadName, uint32 InStackSize = 0,
                                   EThreadPriority InThreadPri, uint64 InThreadAffinityMask,EThreadCreateFlags InCreateFlags);

    // 设置线程优先级.
    virtual void SetThreadPriority( EThreadPriority NewPriority );
    // 暂停/继续运行线程
    virtual void Suspend( bool bShouldPause = true );
    // 销毁线程, 通常需要指定等待标记bShouldWait为true, 否则可能引起内存泄漏或死锁!
    virtual bool Kill( bool bShouldWait = true );
    // 等待执行完毕, 会卡调用线程.
    virtual void WaitForCompletion();
    
    const uint32 GetThreadID() const;
    const FString& GetThreadName() const;

protected:
    FString ThreadName;
    FRunnable* Runnable; // 被执行对象
    FEvent* ThreadInitSyncEvent; // 线程初始化完成同步事件, 防止线程未初始化完毕就执行任务.
    uint64 ThreadAffinityMask; // 亲和标记, 用于线程倾向指定的CPU核心执行.
    TArray<FTlsAutoCleanup*> TlsInstances; // 线程消耗时需要一起清理的Tls对象.
    EThreadPriority ThreadPriority;
    uint32 ThreadID;

private:
    virtual void Tick();
};

需要注意的是,FRunnableThread提供了静态创建接口,创建线程时需要指定一个FRunnable对象,作为线程执行的任务。它是一个基础父类,下面是继承自它的部分核心子类及解析:

  • FRunnableThreadWin:Windows平台的线程实现。它的接口和实现如下:

    // Engine\Source\Runtime\Core\Private\Windows\WindowsRunnableThread.h
    
    class FRunnableThreadWin : public FRunnableThread
    {
        HANDLE Thread; // 线程句柄
        
        // 线程回调接口, 创建线程时作为参数传入.
        static ::DWORD STDCALL _ThreadProc( LPVOID pThis )
        {
            check(pThis);
            return ((FRunnableThreadWin*)pThis)->GuardedRun();
        }
    
        uint32 GuardedRun();
        uint32 Run();
    
    public:
        // 转换优先级
        static int TranslateThreadPriority(EThreadPriority Priority)
        {
            switch (Priority)
            {
            case TPri_AboveNormal: return THREAD_PRIORITY_HIGHEST;
            case TPri_Normal: return THREAD_PRIORITY_HIGHEST - 1;
            case TPri_BelowNormal: return THREAD_PRIORITY_HIGHEST - 3;
            case TPri_Highest: return THREAD_PRIORITY_HIGHEST;
            case TPri_TimeCritical: return THREAD_PRIORITY_HIGHEST;
            case TPri_Lowest: return THREAD_PRIORITY_HIGHEST - 4;
            case TPri_SlightlyBelowNormal: return THREAD_PRIORITY_HIGHEST - 2;
            default: UE_LOG(LogHAL, Fatal, TEXT("Unknown Priority passed to TranslateThreadPriority()")); return TPri_Normal;
            }
        }
        
        // 设置优先级
        virtual void SetThreadPriority( EThreadPriority NewPriority ) override
        {
            // Don't bother calling the OS if there is no need
            ThreadPriority = NewPriority;
            // Change the priority on the thread
            ::SetThreadPriority(Thread, TranslateThreadPriority(ThreadPriority));
        }
        
        virtual void Suspend( bool bShouldPause = true ) override
        {
            check(Thread);
            if (bShouldPause == true)
            {
                SuspendThread(Thread);
            }
            else
            {
                ResumeThread(Thread);
            }
        }
    
        virtual bool Kill( bool bShouldWait = false ) override
        {
            check(Thread && "Did you forget to call Create()?");
            bool bDidExitOK = true;
            // 先停止Runnable对象, 使得其有清理数据的机会
            if (Runnable)
            {
                Runnable->Stop();
            }
            // 等待线程处理完毕.
            if (bShouldWait == true)
            {
                // Wait indefinitely for the thread to finish.  IMPORTANT:  It's not safe to just go and
                // kill the thread with TerminateThread() as it could have a mutex lock that's shared
                // with a thread that's continuing to run, which would cause that other thread to
                // dead-lock.  (This can manifest itself in code as simple as the synchronization
                // object that is used by our logging output classes.  Trust us, we've seen it!)
                WaitForSingleObject(Thread,INFINITE);
            }
            // 关闭线程句柄
            CloseHandle(Thread);
            Thread = NULL;
    
            return bDidExitOK;
        }
    
        virtual void WaitForCompletion( ) override
        {
            // Block until this thread exits
            WaitForSingleObject(Thread,INFINITE);
        }
    
    protected:
    
        virtual bool CreateInternal( FRunnable* InRunnable, const TCHAR* InThreadName,
            uint32 InStackSize = 0,
            EThreadPriority InThreadPri = TPri_Normal, uint64 InThreadAffinityMask = 0,
            EThreadCreateFlags InCreateFlags = EThreadCreateFlags::None) override
        {
            static bool bOnce = false;
            if (!bOnce)
            {
                bOnce = true;
                ::SetThreadPriority(::GetCurrentThread(), TranslateThreadPriority(TPri_Normal)); // set the main thread to be normal, since this is no longer the windows default.
            }
    
            check(InRunnable);
            Runnable = InRunnable;
            ThreadAffinityMask = InThreadAffinityMask;
    
            // 创建初始化完成同步事件.
            ThreadInitSyncEvent    = FPlatformProcess::GetSynchEventFromPool(true);
    
            ThreadName = InThreadName ? InThreadName : TEXT("Unnamed UE4");
    
            // Create the new thread
            {
                LLM_SCOPE(ELLMTag::ThreadStack);
                LLM_PLATFORM_SCOPE(ELLMTag::ThreadStackPlatform);
                // add in the thread size, since it's allocated in a black box we can't track
                LLM(FLowLevelMemTracker::Get().OnLowLevelAlloc(ELLMTracker::Default, nullptr, InStackSize));
                LLM(FLowLevelMemTracker::Get().OnLowLevelAlloc(ELLMTracker::Platform, nullptr, InStackSize));
    
                // 调用Windows API创建线程.
                Thread = CreateThread(NULL, InStackSize, _ThreadProc, this, STACK_SIZE_PARAM_IS_A_RESERVATION | CREATE_SUSPENDED, (::DWORD *)&ThreadID);
            }
    
            // If it fails, clear all the vars
            if (Thread == NULL)
            {
                Runnable = nullptr;
            }
            else
            {
                // 加入到线程管理器中.
                FThreadManager::Get().AddThread(ThreadID, this);
                ResumeThread(Thread);
    
                // Let the thread start up
                ThreadInitSyncEvent->Wait(INFINITE);
    
                SetThreadPriority(InThreadPri);
            }
    
            // 清理同步事件
            FPlatformProcess::ReturnSynchEventToPool(ThreadInitSyncEvent);
            ThreadInitSyncEvent = nullptr;
            return Thread != NULL;
        }
    };
    

    从上面代码可看出,Windows平台的线程直接调用Windows API创建和同步信息,从而实现线程的平台抽象,从平台依赖抽离出来。

  • FRunnableThreadPThread:POSIX Thread(简称PThread)的父类,常用于类Unix POSIX 系统,如Linux、Solaris、Apple等。其实现和Windows平台类似,这里就不展开其代码解析了。它的子类有:

    • FRunnableThreadApple:苹果系统(MacOS、iOS)的线程。

    • FRunnableThreadAndroid:安卓系统的线程。

    • FRunnableThreadUnix:Unix系统的线程。

  • FRunnableThreadHoloLens:HoloLens系统的线程。

  • FFakeThread:假线程,多线程被禁用后的代替品,实际运行于单个线程。

FRunnable和FRunnableThread是相辅相成的,缺一而不可,一个是运行的载体,一个是运行的内容。下面是它们的一个应用示例:

// 派生FRunnable
class FMyRunnable : public FRunnable
{
    bool bStop;
public:
    virtual bool Init(void) 
    {
        bStop = false;
        return true;
    }

    virtual uint32 Run(void)
    {
        for (int32 i = 0; i < 10 && !bStop; i++)
        {
            FPlatformProcess::Sleep(1.0f);
        }

        return 0;
    }

    virtual void Stop(void)
    {
        bStop = true;
    }

    virtual void Exit(void)
    {
    }

};

void TestRunnableAndRunnableThread()
{
    // 创建Runnable对象
    FMyRunnable* MyRunnable = new FMyRunnable;
    // 创建线程, 传入MyRunnable
    FRunnableThread* MyThread = FRunnableThread::Create(MyRunnable, TEXT("MyRunnable"));
    
    // 暂停当前线程
    FPlatformProcess::Sleep(4.0f);

    // 等待线程结束
    MyRunnable->Stop();
    MyThread->WaitForCompletion();

    // 清理数据.
    delete MyThread;
    delete MyRunnable;
}

细心的同学应该有注意到,创建线程的时候,会将线程加入到FThreadManager中,也就是说所有的线程都由FThreadManager来管理。以下是FThreadManager的声明:

// Engine\Source\Runtime\Core\Public\HAL\ThreadManager.h

class FThreadManager
{
    FCriticalSection ThreadsCritical; // 修改线程列表Threads的临界区
    static bool bIsInitialized;

    TMap<uint32, class FRunnableThread*, TInlineSetAllocator<256>> Threads; // 线程列表, 注意数据结构是Map, Key是线程ID.

public:
    void AddThread(uint32 ThreadId, class FRunnableThread* Thread); // 增加线程
    void RemoveThread(class FRunnableThread* Thread); // 删除线程

    void Tick(); // 帧更新, 只对FFakeThread起作用.

    const FString& GetThreadName(uint32 ThreadId);
    void ForEachThread(TFunction<void(uint32, class FRunnableThread*)> Func); // 遍历线程
    
    static bool IsInitialized();
    static FThreadManager& Get();
};

2.4.2.3 QueuedWork

本节将阐述UE的队列化QueuedWork体系,包含IQueuedWork、TAsyncQueuedWork、FQueuedThreadPool、FQueuedThreadPoolBase等。

  • IQueuedWork和TAsyncQueuedWork

IQueuedWork是一组抽象接口,存储着一组队列化的任务对象,会被FQueuedThreadPool线程池对象执行。IQueuedWork的接口如下:

// Engine\Source\Runtime\Core\Public\Misc\IQueuedWork.h

class IQueuedWork
{
public:
    virtual void DoThreadedWork() = 0; // 执行队列化的任务.
    virtual void Abandon() = 0; // 提前放弃执行, 并通知队列里的所有对象清理数据.
};

由于IQueuedWork只是抽象类,并没有实际执行代码,故而主要子类TAsyncQueuedWork承担了实现代码的任务,以下是TAsyncQueuedWork的声明和实现:

// Engine\Source\Runtime\Core\Public\Async\Async.h

template<typename ResultType>
class TAsyncQueuedWork : public IQueuedWork
{
public:
    virtual void DoThreadedWork() override
    {
        SetPromise(Promise, Function);
        delete this;
    }

    virtual void Abandon() override
    {
        // not supported
    }

private:
    TUniqueFunction<ResultType()> Function; // 被执行的函数列表.
    TPromise<ResultType> Promise; // 用于同步的对象
};
  • FQueuedThreadPool和FQueuedThreadPoolBase

与FRunnable和FRunnableThread类似,TAsyncQueuedWork也不能独立地执行任务,需要依赖FQueuedThreadPool来执行。下面是FQueuedThreadPool的声明:

// Engine\Source\Runtime\Core\Public\Misc\QueuedThreadPool.h

// 执行IQueuedWork任务列表的线程池.
class FQueuedThreadPool
{
public:
    // 创建指定数量、栈大小和优先级的线程。
    virtual bool Create( uint32 InNumQueuedThreads, uint32 StackSize = (32 * 1024), EThreadPriority ThreadPriority=TPri_Normal ) = 0;
    // 销毁线程内的后台线程.
    virtual void Destroy() = 0;
    // 加入队列化任务. 如果有可用的线程, 则立即执行; 否则会稍后再执行.
    virtual void AddQueuedWork( IQueuedWork* InQueuedWork ) = 0;
    // 撤销指定队列化任务.
    virtual bool RetractQueuedWork( IQueuedWork* InQueuedWork ) = 0;
    // 获取线程数量.
    virtual int32 GetNumThreads() const = 0;

public:
    // 创建线程池对象.
    static FQueuedThreadPool* Allocate();
    // 重写栈大小.
    static uint32 OverrideStackSize;
};

上面可以看出,FQueuedThreadPool是抽象类,只提供接口,并没有实现。实际上,实现是在FQueuedThreadPoolBase中,如下:

// Engine\Source\Runtime\Core\Private\HAL\ThreadingBase.cpp

class FQueuedThreadPoolBase : public FQueuedThreadPool
{
protected:
    TArray<IQueuedWork*> QueuedWork; // 需要执行的任务列表
    TArray<FQueuedThread*> QueuedThreads; // 线程池内的可用线程
    TArray<FQueuedThread*> AllThreads;    // 线程池内的所有线程
    FCriticalSection* SynchQueue; // 同步临界区
    bool TimeToDie; // 超时标记

public:
    FQueuedThreadPoolBase()
        : SynchQueue(nullptr)
        , TimeToDie(0)
    { }
    virtual ~FQueuedThreadPoolBase()
    {
        Destroy();
    }

    virtual bool Create(uint32 InNumQueuedThreads,uint32 StackSize = (32 * 1024),EThreadPriority ThreadPriority=TPri_Normal) override
    {
        // 处理同步锁.
        bool bWasSuccessful = true;
        check(SynchQueue == nullptr);
        SynchQueue = new FCriticalSection();
        FScopeLock Lock(SynchQueue);
        // Presize the array so there is no extra memory allocated
        check(QueuedThreads.Num() == 0);
        QueuedThreads.Empty(InNumQueuedThreads);

        if( OverrideStackSize > StackSize )
        {
            StackSize = OverrideStackSize;
        }

        // 创建线程, 注意创建的是FQueuedThread.
        for (uint32 Count = 0; Count < InNumQueuedThreads && bWasSuccessful == true; Count++)
        {
            FQueuedThread* pThread = new FQueuedThread();
            // 利用FQueuedThread对象创建真正的线程.
            if (pThread->Create(this,StackSize,ThreadPriority) == true)
            {
                QueuedThreads.Add(pThread);
                AllThreads.Add(pThread);
            }
            else
            {
                // 创建失败, 清理线程对象.
                bWasSuccessful = false;
                delete pThread;
            }
        }
        // 创建线程池失败, 清理数据.
        if (bWasSuccessful == false)
        {
            Destroy();
        }
        return bWasSuccessful;
    }

    virtual void Destroy() override
    {
        if (SynchQueue)
        {
            {
                FScopeLock Lock(SynchQueue);
                TimeToDie = 1;
                FPlatformMisc::MemoryBarrier();
                // Clean up all queued objects
                for (int32 Index = 0; Index < QueuedWork.Num(); Index++)
                {
                    QueuedWork[Index]->Abandon();
                }
                // Empty out the invalid pointers
                QueuedWork.Empty();
            }
            // 等待所有线程执行完成, 注意这里并没有使用同步时间, 而是使用类似自旋锁的机制.
            while (1)
            {
                {
                    // 访问AllThreads和QueuedThreads的数据时先锁定临界区. 防止其它线程修改数据.
                    FScopeLock Lock(SynchQueue);
                    if (AllThreads.Num() == QueuedThreads.Num())
                    {
                        break;
                    }
                }
                FPlatformProcess::Sleep(0.0f); // 切换当前线程时间片, 防止当前线程占用cpu时钟.
            }
            // 删除所有线程.
            {
                FScopeLock Lock(SynchQueue);
                // Now tell each thread to die and delete those
                for (int32 Index = 0; Index < AllThreads.Num(); Index++)
                {
                    AllThreads[Index]->KillThread();
                    delete AllThreads[Index];
                }
                QueuedThreads.Empty();
                AllThreads.Empty();
            }
            // 删除同步锁.
            delete SynchQueue;
            SynchQueue = nullptr;
        }
    }

    int32 GetNumQueuedJobs() const
    {
        return QueuedWork.Num();
    }
    virtual int32 GetNumThreads() const 
    {
        return AllThreads.Num();
    }
    
    // 加入队列化任务.
    void AddQueuedWork(IQueuedWork* InQueuedWork) override
    {
        check(InQueuedWork != nullptr);

        if (TimeToDie)
        {
            InQueuedWork->Abandon();
            return;
        }

        check(SynchQueue);

        FQueuedThread* Thread = nullptr;

        {
            // 操作线程池里的所有数据前都需要锁定临界区.
            FScopeLock sl(SynchQueue);
            const int32 AvailableThreadCount = QueuedThreads.Num();
            
            // 没有可用线程, 加入任务队列, 稍后再执行.
            if (AvailableThreadCount == 0)
            {
                QueuedWork.Add(InQueuedWork);
                return;
            }
            
            // 从可用线程池中获取一个线程, 并将其从可用线程池中删除.
            const int32 ThreadIndex = AvailableThreadCount - 1;

            Thread = QueuedThreads[ThreadIndex];
            QueuedThreads.RemoveAt(ThreadIndex, 1, /* do not allow shrinking */ false);
        }

        // 执行任务
        Thread->DoWork(InQueuedWork);
    }

    virtual bool RetractQueuedWork(IQueuedWork* InQueuedWork) override
    {
        if (TimeToDie)
        {
            return false; // no special consideration for this, refuse the retraction and let shutdown proceed
        }
        check(InQueuedWork != nullptr);
        check(SynchQueue);
        FScopeLock sl(SynchQueue);
        return !!QueuedWork.RemoveSingle(InQueuedWork);
    }
    
    // 如果有可用任务,则获取一个并执行, 否则将线程回归可用线程池. 此接口由FQueuedThread调用.
    IQueuedWork* ReturnToPoolOrGetNextJob(FQueuedThread* InQueuedThread)
    {
        check(InQueuedThread != nullptr);
        IQueuedWork* Work = nullptr;
        // Check to see if there is any work to be done
        FScopeLock sl(SynchQueue);
        if (TimeToDie)
        {
            check(!QueuedWork.Num());  // we better not have anything if we are dying
        }
        if (QueuedWork.Num() > 0)
        {
            // Grab the oldest work in the queue. This is slower than
            // getting the most recent but prevents work from being
            // queued and never done
            Work = QueuedWork[0];
            // Remove it from the list so no one else grabs it
            QueuedWork.RemoveAt(0, 1, /* do not allow shrinking */ false);
        }
        if (!Work)
        {
            // There was no work to be done, so add the thread to the pool
            QueuedThreads.Add(InQueuedThread);
        }
        return Work;
    }
};

上面的接口ReturnToPoolOrGetNextJob并非FQueuedThreadPoolBase调用,而是由正在执行任务且执行完毕的FQueuedThread对象主动调用,如下所示:

uint32 FQueuedThread::Run()
{
    while (!TimeToDie.Load(EMemoryOrder::Relaxed))
    {
        bool bContinueWaiting = true;
        
        (......)
        
        // 让事件等待.
        if (bContinueWaiting)
        {
            DoWorkEvent->Wait();
        }

        IQueuedWork* LocalQueuedWork = QueuedWork;
        QueuedWork = nullptr;
        FPlatformMisc::MemoryBarrier();
        check(LocalQueuedWork || TimeToDie.Load(EMemoryOrder::Relaxed)); // well you woke me up, where is the job or termination request?
        // 不断地从线程池获取任务并执行, 直到线程池的所有任务执行完毕.
        while (LocalQueuedWork)
        {
            // 执行任务.
            LocalQueuedWork->DoThreadedWork();
            // 从线程池获取下一个任务.
            LocalQueuedWork = OwningThreadPool->ReturnToPoolOrGetNextJob(this);
        }
    }
    return 0;
}

从上面可以看出,FQueuedThreadPool和FQueuedThread的数据和接口巧妙地配合,从而并行化地执行任务。

  • GThreadPool

线程池的机制已经讲述完毕,下面讲一下UE的全局线程池GThreadPool的初始化过程,此过程在FEngineLoop::PreInitPreStartupScreen中,1.4.6.1 引擎预初始化已经有提及:

// Engine\Source\Runtime\Launch\Private\LaunchEngineLoop.cpp

int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
    (......)
    
    {
        TRACE_THREAD_GROUP_SCOPE("ThreadPool");
        // 创建全局线程池
        GThreadPool = FQueuedThreadPool::Allocate();
        int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfWorkerThreadsToSpawn();

        // 如果是纯服务器模式, 线程池只有一个线程.
        if (FPlatformProperties::IsServerOnly())
        {
            NumThreadsInThreadPool = 1;
        }
        // 创建工作线程相等的线程数量.
        verify(GThreadPool->Create(NumThreadsInThreadPool, StackSize * 1024, TPri_SlightlyBelowNormal));
    }
    
    (......)
}

如果需要GThreadPool为我们做事,则使用示例如下:

// Engine\Source\Runtime\Engine\Private\ShadowMap.cpp

// 多线程编码纹理
if (bMultithreadedEncode)
{
    // 完成的任务计数器.
    FThreadSafeCounter Counter(PendingTextures.Num());
    // 待编码的纹理任务列表
    TArray<FAsyncEncode<FShadowMapPendingTexture>> AsyncEncodeTasks;
    AsyncEncodeTasks.Empty(PendingTextures.Num());
    // 创建所有任务, 加入到AsyncEncodeTasks列表中.
    for (auto& PendingTexture : PendingTextures)
    {
        PendingTexture.CreateUObjects();
        // 创建AsyncEncodeTask
        auto AsyncEncodeTask = new (AsyncEncodeTasks)FAsyncEncode<FShadowMapPendingTexture>(&PendingTexture, LightingScenario, Counter, TextureCompressorModule);
        // 将AsyncEncodeTask加入全局线程池并执行.
        GThreadPool->AddQueuedWork(AsyncEncodeTask);
    }
    // 如果还有任务未完成, 则让当前线程进入睡眠状态.
    while (Counter.GetValue() > 0)
    {
        GWarn->UpdateProgress(Counter.GetValue(), PendingTextures.Num());
        FPlatformProcess::Sleep(0.0001f);
    }
}

2.4.2.4 TaskGraph

TaskGraph直译是任务图,使用的图是DAG(Directed Acyclic Graph,有向非循环图),可以指定依赖关系,指定前序和后序任务,但不能有循环依赖。它是UE内迄今为止最为复杂的并行任务系统,涉及的概念、运行机制的复杂度都陡增,本节将花大篇幅描述它们,旨在阐述清楚它们的机制和原理。

  • FBaseGraphTask

FBaseGraphTask是运行于TaskGraph的任务,是个基础父类,其派生的具体任务子类才会执行任务。它的声明(节选)如下:

// Engine\Source\Runtime\Core\Public\Async\TaskGraphInterfaces.h

class FBaseGraphTask
{
protected:
    FBaseGraphTask(int32 InNumberOfPrerequistitesOutstanding);
    
    // 先决任务完成或部分地完成.
    void PrerequisitesComplete(ENamedThreads::Type CurrentThread, int32 NumAlreadyFinishedPrequistes, bool bUnlock = true);
    
    // 带条件(前置任务都已经执行完毕)地执行任务
    void ConditionalQueueTask(ENamedThreads::Type CurrentThread)
    {
        if (NumberOfPrerequistitesOutstanding.Decrement()==0)
        {
            QueueTask(CurrentThread);
        }
    }

private:
    // 真正地执行任务, 由子类实现.
    virtual void ExecuteTask(TArray<FBaseGraphTask*>& NewTasks, ENamedThreads::Type CurrentThread)=0;
    
    // 加入到TaskGraph任务队列中.
    void QueueTask(ENamedThreads::Type CurrentThreadIfKnown)
    {
        checkThreadGraph(LifeStage.Increment() == int32(LS_Queued));
        FTaskGraphInterface::Get().QueueTask(this, ThreadToExecuteOn, CurrentThreadIfKnown);
    }

    ENamedThreads::Type ThreadToExecuteOn; // 执行任务的线程类型
    FThreadSafeCounter  NumberOfPrerequistitesOutstanding; // 执行任务前的计数器
};
  • TGraphTask

FBaseGraphTask的唯一子类TGraphTask承接了完成执行任务的代码。TGraphTask的声明和实现如下:

template<typename TTask>
class TGraphTask final : public FBaseGraphTask
{
public:
    // 构造任务的辅助类.
    class FConstructor
    {
    public:
        // 创建TTask任务对象, 然后设置TGraphTask任务的数据, 以便在适当时机执行.
        template<typename...T>
        FGraphEventRef ConstructAndDispatchWhenReady(T&&... Args)
        {
            new ((void *)&Owner->TaskStorage) TTask(Forward<T>(Args)...);
            return Owner->Setup(Prerequisites, CurrentThreadIfKnown);
        }

        // 创建TTask任务对象, 然后设置TGraphTask任务的数据, 并持有但不执行.
        template<typename...T>
        TGraphTask* ConstructAndHold(T&&... Args)
        {
            new ((void *)&Owner->TaskStorage) TTask(Forward<T>(Args)...);
            return Owner->Hold(Prerequisites, CurrentThreadIfKnown);
        }

    private:
        TGraphTask*                Owner; // 所在的TGraphTask对象.
        const FGraphEventArray*    Prerequisites; // 先决任务.
        ENamedThreads::Type        CurrentThreadIfKnown;
    };

    // 创建任务, 注意返回的是FConstructor对象, 以便对任务执行后续操作.
    static FConstructor CreateTask(const FGraphEventArray* Prerequisites = NULL, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread)
    {
        int32 NumPrereq = Prerequisites ? Prerequisites->Num() : 0;
        if (sizeof(TGraphTask) <= FBaseGraphTask::SMALL_TASK_SIZE)
        {
            void *Mem = FBaseGraphTask::GetSmallTaskAllocator().Allocate();
            return FConstructor(new (Mem) TGraphTask(TTask::GetSubsequentsMode() == ESubsequentsMode::FireAndForget ? NULL : FGraphEvent::CreateGraphEvent(), NumPrereq), Prerequisites, CurrentThreadIfKnown);
        }
        return FConstructor(new TGraphTask(TTask::GetSubsequentsMode() == ESubsequentsMode::FireAndForget ? NULL : FGraphEvent::CreateGraphEvent(), NumPrereq), Prerequisites, CurrentThreadIfKnown);
    }

    void Unlock(ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread)
    {
        ConditionalQueueTask(CurrentThreadIfKnown);
    }
    
    FGraphEventRef GetCompletionEvent()
    {
        return Subsequents;
    }

private:
    // 执行任务
    void ExecuteTask(TArray<FBaseGraphTask*>& NewTasks, ENamedThreads::Type CurrentThread) override
    {
        (......)
        
        // 处理后续任务.
        if (TTask::GetSubsequentsMode() == ESubsequentsMode::TrackSubsequents)
        {
            Subsequents->CheckDontCompleteUntilIsEmpty(); // we can only add wait for tasks while executing the task
        }
        
        // 执行任务
        TTask& Task = *(TTask*)&TaskStorage;
        {
            FScopeCycleCounter Scope(Task.GetStatId(), true); 
            Task.DoTask(CurrentThread, Subsequents);
            Task.~TTask();
            checkThreadGraph(ENamedThreads::GetThreadIndex(CurrentThread) <= ENamedThreads::GetRenderThread() || FMemStack::Get().IsEmpty()); // you must mark and pop memstacks if you use them in tasks! Named threads are excepted.
        }
        
        TaskConstructed = false;
        
        // 执行后序任务.
        if (TTask::GetSubsequentsMode() == ESubsequentsMode::TrackSubsequents)
        {
            FPlatformMisc::MemoryBarrier();
            Subsequents->DispatchSubsequents(NewTasks, CurrentThread);
        }
        
        // 释放任务对象数据.
        if (sizeof(TGraphTask) <= FBaseGraphTask::SMALL_TASK_SIZE)
        {
            this->TGraphTask::~TGraphTask();
            FBaseGraphTask::GetSmallTaskAllocator().Free(this);
        }
        else
        {
            delete this;
        }
    }
    
    // 设置先决任务.
    void SetupPrereqs(const FGraphEventArray* Prerequisites, ENamedThreads::Type CurrentThreadIfKnown, bool bUnlock)
    {
        checkThreadGraph(!TaskConstructed);
        TaskConstructed = true;
        TTask& Task = *(TTask*)&TaskStorage;
        SetThreadToExecuteOn(Task.GetDesiredThread());
        int32 AlreadyCompletedPrerequisites = 0;
        if (Prerequisites)
        {
            for (int32 Index = 0; Index < Prerequisites->Num(); Index++)
            {
                check((*Prerequisites)[Index]);
                if (!(*Prerequisites)[Index]->AddSubsequent(this))
                {
                    AlreadyCompletedPrerequisites++;
                }
            }
        }
        PrerequisitesComplete(CurrentThreadIfKnown, AlreadyCompletedPrerequisites, bUnlock);
    }

    // 设置任务数据.
    FGraphEventRef Setup(const FGraphEventArray* Prerequisites = NULL, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread)
    {
        FGraphEventRef ReturnedEventRef = Subsequents; // very important so that this doesn't get destroyed before we return
        SetupPrereqs(Prerequisites, CurrentThreadIfKnown, true);
        return ReturnedEventRef;
    }

    // 持有任务数据.
    TGraphTask* Hold(const FGraphEventArray* Prerequisites = NULL, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread)
    {
        SetupPrereqs(Prerequisites, CurrentThreadIfKnown, false);
        return this;
    }

    // 创建任务.
    static FConstructor CreateTask(FGraphEventRef SubsequentsToAssume, const FGraphEventArray* Prerequisites = NULL, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread)
    {
        if (sizeof(TGraphTask) <= FBaseGraphTask::SMALL_TASK_SIZE)
        {
            void *Mem = FBaseGraphTask::GetSmallTaskAllocator().Allocate();
            return FConstructor(new (Mem) TGraphTask(SubsequentsToAssume, Prerequisites ? Prerequisites->Num() : 0), Prerequisites, CurrentThreadIfKnown);
        }
        return FConstructor(new TGraphTask(SubsequentsToAssume, Prerequisites ? Prerequisites->Num() : 0), Prerequisites, CurrentThreadIfKnown);
    }

    TAlignedBytes<sizeof(TTask),alignof(TTask)> TaskStorage; // 被执行的任务对象.
    bool                        TaskConstructed;
    FGraphEventRef                Subsequents; // 后续任务同步对象.
};
  • TAsyncGraphTask

上面可知TGraphTask虽然是任务,但它执行的实际任务是TTask的模板类,UE的注释里边给出了TTask的基本形式:

class FGenericTask
{
    TSomeType    SomeArgument;
public:
    FGenericTask(TSomeType InSomeArgument) // 不能用引用, 可用指针代替之.
        : SomeArgument(InSomeArgument)
    {
        // Usually the constructor doesn't do anything except save the arguments for use in DoWork or GetDesiredThread.
    }
    ~FGenericTask()
    {
        // you will be destroyed immediately after you execute. Might as well do cleanup in DoWork, but you could also use a destructor.
    }
    FORCEINLINE TStatId GetStatId() const
    {
        RETURN_QUICK_DECLARE_CYCLE_STAT(FGenericTask, STATGROUP_TaskGraphTasks);
    }

    [static] ENamedThreads::Type GetDesiredThread()
    {
        return ENamedThreads::[named thread or AnyThread];
    }
    void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
    {
        // The arguments are useful for setting up other tasks. 
        // Do work here, probably using SomeArgument.
        MyCompletionGraphEvent->DontCompleteUntil(TGraphTask<FSomeChildTask>::CreateTask(NULL,CurrentThread).ConstructAndDispatchWhenReady());
    }
};

然而,我们如果需要定制自己的任务,直接使用或派生TAsyncGraphTask类即可,无需另起炉灶。TAsyncGraphTask和其父类FAsyncGraphTaskBase声明如下:

// Engine\Source\Runtime\Core\Public\Async\Async.h

// 后序任务模式
namespace ESubsequentsMode
{
    enum Type
    {
        TrackSubsequents, // 追踪后序任务
        FireAndForget     // 无需追踪任务依赖, 可以避免线程同步, 提升执行效率.
    };
}

class FAsyncGraphTaskBase
{
public:
    TStatId GetStatId() const
    {
        return GET_STATID(STAT_TaskGraph_OtherTasks);
    }
    
    // 任务后序模式.
    static ESubsequentsMode::Type GetSubsequentsMode()
    {
        return ESubsequentsMode::FireAndForget;
    }
};

template<typename ResultType>
class TAsyncGraphTask : public FAsyncGraphTaskBase
{
public:
    // 构造任务, InFunction就是需要执行的代码段.
    TAsyncGraphTask(TUniqueFunction<ResultType()>&& InFunction, TPromise<ResultType>&& InPromise, ENamedThreads::Type InDesiredThread = ENamedThreads::AnyThread)
        : Function(MoveTemp(InFunction))
        , Promise(MoveTemp(InPromise))
        , DesiredThread(InDesiredThread)
    { }

public:
    // 执行任务
    void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
    {
        SetPromise(Promise, Function);
    }

    ENamedThreads::Type GetDesiredThread()
    {
        return DesiredThread;
    }

    TFuture<ResultType> GetFuture()
    {
        return Promise.GetFuture();
    }

private:
    TUniqueFunction<ResultType()> Function; // 被执行的函数对象.
    TPromise<ResultType> Promise; // 同步对象.
    ENamedThreads::Type DesiredThread; // 期望执行的线程类型.
};
  • FTaskThreadBase

FTaskThreadBase是执行任务的线程父类,定义了一组设置、操作任务的接口,声明如下:

class FTaskThreadBase : public FRunnable, FSingleThreadRunnable
{
public:
    FTaskThreadBase()
        : ThreadId(ENamedThreads::AnyThread)
        , PerThreadIDTLSSlot(0xffffffff)
        , OwnerWorker(nullptr)
    {
        NewTasks.Reset(128);
    }

    // 设置数据.
    void Setup(ENamedThreads::Type InThreadId, uint32 InPerThreadIDTLSSlot, FWorkerThread* InOwnerWorker)
    {
        ThreadId = InThreadId;
        check(ThreadId >= 0);
        PerThreadIDTLSSlot = InPerThreadIDTLSSlot;
        OwnerWorker = InOwnerWorker;
    }

    // 从当前线程初始化.
    void InitializeForCurrentThread()
    {
        // 设置平台相关的TLS.
        FPlatformTLS::SetTlsValue(PerThreadIDTLSSlot, OwnerWorker);
    }

    ENamedThreads::Type GetThreadId() const;
    
    // 用于带名字的线程处理任务直到线程空闲或RequestQuit被调用.
    virtual void ProcessTasksUntilQuit(int32 QueueIndex) = 0;

    // 用于带名字的线程处理任务直到线程空闲或RequestQuit被调用.
    virtual uint64 ProcessTasksUntilIdle(int32 QueueIndex);
    
    // 请求退出. 会导致线程空闲时退出到调用者. 如果是带名字的线程, 在ProcessTasksUntilQuit中用以返回给调用者; 无名线程则直接关闭.
    virtual void RequestQuit(int32 QueueIndex) = 0;

    // 入队任务, 假设this线程和当前线程一样. 如果是带名字的线程, 会直接进入私有的队列.
    virtual void EnqueueFromThisThread(int32 QueueIndex, FBaseGraphTask* Task);

    // 入队任务, 假设this线程和当前线程不一样.
    virtual bool EnqueueFromOtherThread(int32 QueueIndex, FBaseGraphTask* Task);
    
    // 唤醒线程.
    virtual void WakeUp();
    
    // 查询任务是否在处理中.
    virtual bool IsProcessingTasks(int32 QueueIndex) = 0;

    // 单线程帧更新
    virtual void Tick() override
    {
        ProcessTasksUntilIdle(0);
    }

    // FRunnable API

    virtual bool Init() override
    {
        InitializeForCurrentThread();
        return true;
    }
    virtual uint32 Run() override
    {
        check(OwnerWorker); // make sure we are started up
        ProcessTasksUntilQuit(0);
        FMemory::ClearAndDisableTLSCachesOnCurrentThread();
        return 0;
    }
    virtual void Stop() override
    {
        RequestQuit(-1);
    }
    virtual void Exit() override
    {
    }
    virtual FSingleThreadRunnable* GetSingleThreadInterface() override
    {
        return this;
    }

protected:
    ENamedThreads::Type        ThreadId; // 线程id(线程索引)
    uint32                    PerThreadIDTLSSlot; // TLS槽.
    FThreadSafeCounter        IsStalled; // 阻塞计数器. 用于触发阻塞信号.
    TArray<FBaseGraphTask*> NewTasks; // 待处理的任务列表.
    FWorkerThread* OwnerWorker; // 所在的工作线程对象.
};

FTaskThreadBase只是抽象类,具体的实现由子类FNamedTaskThread和FTaskThreadAnyThread完成。

其中FNamedTaskThread处理带名字线程的任务:

// 带名字的任务线程.
class FNamedTaskThread : public FTaskThreadBase
{
public:
    // 用于带名字的线程处理任务直到线程空闲或RequestQuit被调用.
    virtual void ProcessTasksUntilQuit(int32 QueueIndex) override
    {
        check(Queue(QueueIndex).StallRestartEvent); // make sure we are started up

        Queue(QueueIndex).QuitForReturn = false;
        verify(++Queue(QueueIndex).RecursionGuard == 1);
        
        // 不断地循环处理队列任务, 直到退出、关闭或平台不支持多线程。
        do
        {
            ProcessTasksNamedThread(QueueIndex, FPlatformProcess::SupportsMultithreading());
        } while (!Queue(QueueIndex).QuitForReturn && !Queue(QueueIndex).QuitForShutdown && FPlatformProcess::SupportsMultithreading()); // @Hack - quit now when running with only one thread.
        verify(!--Queue(QueueIndex).RecursionGuard);
    }
    
    // 用于带名字的线程处理任务直到线程空闲或RequestQuit被调用.
    virtual uint64 ProcessTasksUntilIdle(int32 QueueIndex) override
    {
        check(Queue(QueueIndex).StallRestartEvent); // make sure we are started up

        Queue(QueueIndex).QuitForReturn = false;
        verify(++Queue(QueueIndex).RecursionGuard == 1);
        uint64 ProcessedTasks = ProcessTasksNamedThread(QueueIndex, false);
        verify(!--Queue(QueueIndex).RecursionGuard);
        return ProcessedTasks;
    }
    
    // 处理任务.
    uint64 ProcessTasksNamedThread(int32 QueueIndex, bool bAllowStall)
    {
        uint64 ProcessedTasks = 0;

        (......)
        
        TStatId StallStatId;
        bool bCountAsStall = false;
        
        (......)

        while (!Queue(QueueIndex).QuitForReturn)
        {
            // 从队列首部获取任务.
            FBaseGraphTask* Task = Queue(QueueIndex).StallQueue.Pop(0, bAllowStall);
            TestRandomizedThreads();
            if (!Task)
            {
                if (bAllowStall)
                {
                    {
                        FScopeCycleCounter Scope(StallStatId);
                        Queue(QueueIndex).StallRestartEvent->Wait(MAX_uint32, bCountAsStall);
                        if (Queue(QueueIndex).QuitForShutdown)
                        {
                            return ProcessedTasks;
                        }
                        TestRandomizedThreads();
                    }
                    continue;
                }
                else
                {
                    break; // we were asked to quit
                }
            }
            else // 任务不为空
            {
                // 执行任务.
                Task->Execute(NewTasks, ENamedThreads::Type(ThreadId | (QueueIndex << ENamedThreads::QueueIndexShift)));
                ProcessedTasks++;
                TestRandomizedThreads();
            }
        }
        return ProcessedTasks;
    }
    
    virtual void EnqueueFromThisThread(int32 QueueIndex, FBaseGraphTask* Task) override
    {
        checkThreadGraph(Task && Queue(QueueIndex).StallRestartEvent); // make sure we are started up
        uint32 PriIndex = ENamedThreads::GetTaskPriority(Task->ThreadToExecuteOn) ? 0 : 1;
        int32 ThreadToStart = Queue(QueueIndex).StallQueue.Push(Task, PriIndex);
        check(ThreadToStart < 0); // if I am stalled, then how can I be queueing a task?
    }

    virtual void RequestQuit(int32 QueueIndex) override
    {
        // this will not work under arbitrary circumstances. For example you should not attempt to stop threads unless they are known to be idle.
        if (!Queue(0).StallRestartEvent)
        {
            return;
        }
        if (QueueIndex == -1)
        {
            // we are shutting down
            checkThreadGraph(Queue(0).StallRestartEvent); // make sure we are started up
            checkThreadGraph(Queue(1).StallRestartEvent); // make sure we are started up
            Queue(0).QuitForShutdown = true;
            Queue(1).QuitForShutdown = true;
            Queue(0).StallRestartEvent->Trigger();
            Queue(1).StallRestartEvent->Trigger();
        }
        else
        {
            checkThreadGraph(Queue(QueueIndex).StallRestartEvent); // make sure we are started up
            Queue(QueueIndex).QuitForReturn = true;
        }
    }

    virtual bool EnqueueFromOtherThread(int32 QueueIndex, FBaseGraphTask* Task) override
    {
        TestRandomizedThreads();
        checkThreadGraph(Task && Queue(QueueIndex).StallRestartEvent); // make sure we are started up

        uint32 PriIndex = ENamedThreads::GetTaskPriority(Task->ThreadToExecuteOn) ? 0 : 1;
        int32 ThreadToStart = Queue(QueueIndex).StallQueue.Push(Task, PriIndex);

        if (ThreadToStart >= 0)
        {
            QUICK_SCOPE_CYCLE_COUNTER(STAT_TaskGraph_EnqueueFromOtherThread_Trigger);
            checkThreadGraph(ThreadToStart == 0);
            TASKGRAPH_SCOPE_CYCLE_COUNTER(1, STAT_TaskGraph_EnqueueFromOtherThread_Trigger);
            Queue(QueueIndex).StallRestartEvent->Trigger();
            return true;
        }
        return false;
    }

    virtual bool IsProcessingTasks(int32 QueueIndex) override
    {
        return !!Queue(QueueIndex).RecursionGuard;
    }

private:
    // 线程任务队列.
    struct FThreadTaskQueue
    {
        FStallingTaskQueue<FBaseGraphTask, PLATFORM_CACHE_LINE_SIZE, 2> StallQueue; // 阻塞的任务队列.

        uint32 RecursionGuard; // 防止循环(递归)调用.
        bool QuitForReturn; // 是否请求退出.
        bool QuitForShutdown; // 是否请求关闭.
        FEvent*    StallRestartEvent; // 当线程满载时的阻塞事件.
    };

    FORCEINLINE FThreadTaskQueue& Queue(int32 QueueIndex)
    {
        checkThreadGraph(QueueIndex >= 0 && QueueIndex < ENamedThreads::NumQueues);
        return Queues[QueueIndex];
    }
    FORCEINLINE const FThreadTaskQueue& Queue(int32 QueueIndex) const
    {
        checkThreadGraph(QueueIndex >= 0 && QueueIndex < ENamedThreads::NumQueues);
        return Queues[QueueIndex];
    }

    FThreadTaskQueue Queues[ENamedThreads::NumQueues]; // 带名字线程专用的任务队列.
};

FTaskThreadAnyThread用于处理无名线程的任务,由于无名线程有很多个,所以处理任务时和FNamedTaskThread有所不同:

class FTaskThreadAnyThread : public FTaskThreadBase
{
public:
    virtual void ProcessTasksUntilQuit(int32 QueueIndex) override
    {
        if (PriorityIndex != (ENamedThreads::BackgroundThreadPriority >> ENamedThreads::ThreadPriorityShift))
        {
            FMemory::SetupTLSCachesOnCurrentThread();
        }
        check(!QueueIndex);
        do
        {
            // 处理任务
            ProcessTasks();            
        } while (!Queue.QuitForShutdown && FPlatformProcess::SupportsMultithreading()); // @Hack - quit now when running with only one thread.
    }

    virtual uint64 ProcessTasksUntilIdle(int32 QueueIndex) override
    {
        if (!FPlatformProcess::SupportsMultithreading())
        {
            // 处理任务
            return ProcessTasks();
        }
        else
        {
            check(0);
            return 0;
        }
    }
    
    (......)

private:

#if UE_EXTERNAL_PROFILING_ENABLED
    static inline const TCHAR* ThreadPriorityToName(int32 PriorityIdx)
    {
        PriorityIdx <<= ENamedThreads::ThreadPriorityShift;
        if (PriorityIdx == ENamedThreads::HighThreadPriority)
        {
            return TEXT("Task Thread HP"); // 高优先级的工作线程
        }
        else if (PriorityIdx == ENamedThreads::NormalThreadPriority)
        {
            return TEXT("Task Thread NP"); // 普通优先级的工作线程
        }
        else if (PriorityIdx == ENamedThreads::BackgroundThreadPriority)
        {
            return TEXT("Task Thread BP"); // 后台优先级的工作线程
        }
        else
        {
            return TEXT("Task Thread Unknown Priority");
        }
    }
#endif

    // 此处的处理任务与FNamedTaskThread有区别, 在于获取任务的方式不一样, 是从TaskGraph系统中的无名任务队列获取任务的.
    uint64 ProcessTasks()
    {
        LLM_SCOPE(ELLMTag::TaskGraphTasksMisc);

        TStatId StallStatId;
        bool bCountAsStall = true;
        uint64 ProcessedTasks = 0;
        
        (......)
        
        verify(++Queue.RecursionGuard == 1);
        bool bDidStall = false;
        while (1)
        {
            // 从TaskGraph系统中的无名任务队列获取任务的.
            FBaseGraphTask* Task = FindWork();
            if (!Task)
            {
                (......)

                TestRandomizedThreads();
                if (FPlatformProcess::SupportsMultithreading())
                {
                    FScopeCycleCounter Scope(StallStatId);
                    Queue.StallRestartEvent->Wait(MAX_uint32, bCountAsStall);
                    bDidStall = true;
                }
                if (Queue.QuitForShutdown || !FPlatformProcess::SupportsMultithreading())
                {
                    break;
                }
                TestRandomizedThreads();
                
                (......)
                
                continue;
            }
            TestRandomizedThreads();
            
            (......)
            
            bDidStall = false;
            Task->Execute(NewTasks, ENamedThreads::Type(ThreadId));
            ProcessedTasks++;
            TestRandomizedThreads();
            if (Queue.bStallForTuning)
            {
                {
                    FScopeLock Lock(&Queue.StallForTuning);
                }
            }
        }
        verify(!--Queue.RecursionGuard);
        return ProcessedTasks;
    }

    // 任务队列数据.
    struct FThreadTaskQueue
    {
        FEvent* StallRestartEvent;
        uint32 RecursionGuard;
        bool QuitForShutdown;
        bool bStallForTuning;
        
        FCriticalSection StallForTuning; // 阻塞临界区
    };

    // 从TaskGraph系统中获取任务.
    FBaseGraphTask* FindWork()
    {
        return FTaskGraphImplementation::Get().FindWork(ThreadId);
    }

    FThreadTaskQueue Queue; // 任务队列, 只有第一个用于无名线程.

    int32 PriorityIndex;
};
  • ENamedThreads

在理解TaskGraph的实现和使用之前,有必要理解ENamedThreads相关的机制。ENamedThreads是一个命名空间,此空间内提供了编解码线程、优先级的操作。它的声明和解析如下:

namespace ENamedThreads
{
    enum Type : int32
    {
        UnusedAnchor = -1,
        
        // ----专用(带名字的)线程----
#if STATS
        StatsThread, // 统计线程
#endif
        RHIThread,   // RHI线程
        AudioThread, // 音频线程
        GameThread,  // 游戏线程
        ActualRenderingThread = GameThread + 1, // 实际渲染线程. GetRenderingThread()获取的渲染可能是实际渲染线程也可能是游戏线程.

        AnyThread = 0xff,  // 任意线程(未知线程, 无名线程)

        // ----队列索引和优先级----
        MainQueue =            0x000, // 主队列
        LocalQueue =        0x100, // 局部队列

        NumQueues =            2,
        ThreadIndexMask =    0xff,
        QueueIndexMask =    0x100,
        QueueIndexShift =    8,

        // ----队列任务索引、优先级----
        NormalTaskPriority =    0x000, // 普通任务优先级
        HighTaskPriority =        0x200, // 高任务优先级

        NumTaskPriorities =        2,
        TaskPriorityMask =        0x200,
        TaskPriorityShift =        9,
        
        // ----线程优先级----
        NormalThreadPriority = 0x000, // 普通线程优先级
        HighThreadPriority = 0x400,   // 高线程优先级
        BackgroundThreadPriority = 0x800, // 后台线程优先级

        NumThreadPriorities = 3,
        ThreadPriorityMask = 0xC00,
        ThreadPriorityShift = 10,

        // 组合标记
#if STATS
        StatsThread_Local = StatsThread | LocalQueue,
#endif
        GameThread_Local = GameThread | LocalQueue,
        ActualRenderingThread_Local = ActualRenderingThread | LocalQueue,

        AnyHiPriThreadNormalTask = AnyThread | HighThreadPriority | NormalTaskPriority,
        AnyHiPriThreadHiPriTask = AnyThread | HighThreadPriority | HighTaskPriority,

        AnyNormalThreadNormalTask = AnyThread | NormalThreadPriority | NormalTaskPriority,
        AnyNormalThreadHiPriTask = AnyThread | NormalThreadPriority | HighTaskPriority,

        AnyBackgroundThreadNormalTask = AnyThread | BackgroundThreadPriority | NormalTaskPriority,
        AnyBackgroundHiPriTask = AnyThread | BackgroundThreadPriority | HighTaskPriority,
    };

    struct FRenderThreadStatics
    {
    private:
        // 存储了渲染线程,注意是原子操作类型。
        static CORE_API TAtomic<Type> RenderThread;
        static CORE_API TAtomic<Type> RenderThread_Local;
    };

    // ----设置和获取渲染线程接口----
    Type GetRenderThread();
    Type GetRenderThread_Local();
    void SetRenderThread(Type Thread);
    void SetRenderThread_Local(Type Thread);

    extern CORE_API int32 bHasBackgroundThreads;   // 是否有后台线程
    extern CORE_API int32 bHasHighPriorityThreads; // 是否有高优先级线程
    
    // ----设置和获取线程索引、线程优先级、任务优先级接口----
    Type GetThreadIndex(Type ThreadAndIndex);
    int32 GetQueueIndex(Type ThreadAndIndex);
    int32 GetTaskPriority(Type ThreadAndIndex);
    int32 GetThreadPriorityIndex(Type ThreadAndIndex);
    
    Type SetPriorities(Type ThreadAndIndex, Type ThreadPriority, Type TaskPriority);
    Type SetPriorities(Type ThreadAndIndex, int32 PriorityIndex, bool bHiPri);
    Type SetThreadPriority(Type ThreadAndIndex, Type ThreadPriority);
    Type SetTaskPriority(Type ThreadAndIndex, Type TaskPriority);
}
  • FTaskGraphInterface

上面提到了很多任务类型,本节才真正涉及这些任务的管理器和工厂FTaskGraphInterface。FTaskGraphInterface就是任务图的管理者,提供了任务的操作接口:

class FTaskGraphInterface
{
    virtual void QueueTask(class FBaseGraphTask* Task, ENamedThreads::Type ThreadToExecuteOn, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread) = 0;

public:
    // FTaskGraphInterface对象操作接口
    static CORE_API void Startup(int32 NumThreads);
    static CORE_API void Shutdown();
    static CORE_API bool IsRunning();
    static CORE_API FTaskGraphInterface& Get();
    
    // 线程操作接口.
    virtual ENamedThreads::Type GetCurrentThreadIfKnown(bool bLocalQueue = false) = 0;
    virtual    int32 GetNumWorkerThreads() = 0;
    virtual bool IsThreadProcessingTasks(ENamedThreads::Type ThreadToCheck) = 0;
    virtual void AttachToThread(ENamedThreads::Type CurrentThread)=0;
    virtual uint64 ProcessThreadUntilIdle(ENamedThreads::Type CurrentThread)=0;
    
    // 任务操作接口.
    virtual void ProcessThreadUntilRequestReturn(ENamedThreads::Type CurrentThread)=0;
    virtual void RequestReturn(ENamedThreads::Type CurrentThread)=0;
    virtual void WaitUntilTasksComplete(const FGraphEventArray& Tasks, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread)=0;
    virtual void TriggerEventWhenTasksComplete(FEvent* InEvent, const FGraphEventArray& Tasks, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread, ENamedThreads::Type TriggerThread = ENamedThreads::AnyHiPriThreadHiPriTask)=0;
    void WaitUntilTaskCompletes(const FGraphEventRef& Task, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread);
    void TriggerEventWhenTaskCompletes(FEvent* InEvent, const FGraphEventRef& Task, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread, ENamedThreads::Type TriggerThread = ENamedThreads::AnyHiPriThreadHiPriTask);
    virtual void AddShutdownCallback(TFunction<void()>& Callback) = 0;
    static void BroadcastSlow_OnlyUseForSpecialPurposes(bool bDoTaskThreads, bool bDoBackgroundThreads, TFunction<void(ENamedThreads::Type CurrentThread)>& Callback);
};

FTaskGraphInterface的实现是在FTaskGraphImplementation类中,FTaskGraphImplementation采用了特殊的线程对象WorkerThreads(工作线程)来作为执行的载体,当然如果是专用的(带名字的线程,如GameThread、RHI、ActualRenderingThread)线程,则会进入专用的任务队列。由于它的实现细节很多,后面再展开讨论。

  • FTaskGraphImplementation

FTaskGraphImplementation继承并实现了FTaskGraphInterface的接口,部分接口和实现如下:

// Engine\Source\Runtime\Core\Private\Async\TaskGraph.cpp

class FTaskGraphImplementation : public FTaskGraphInterface
{
public:
    static FTaskGraphImplementation& Get();

    // 构造函数, 计算任务线程数量, 创建专用线程和无名线程等.
    FTaskGraphImplementation(int32)
    {
        bCreatedHiPriorityThreads = !!ENamedThreads::bHasHighPriorityThreads;
        bCreatedBackgroundPriorityThreads = !!ENamedThreads::bHasBackgroundThreads;

        int32 MaxTaskThreads = MAX_THREADS; // 最大任务线程数量默认是83.
        int32 NumTaskThreads = FPlatformMisc::NumberOfWorkerThreadsToSpawn(); // 根据硬件核心数量获取任务线程数量.

        // 处理不能支持多线程的平台.
        if (!FPlatformProcess::SupportsMultithreading())
        {
            MaxTaskThreads = 1;
            NumTaskThreads = 1;
            LastExternalThread = (ENamedThreads::Type)(ENamedThreads::ActualRenderingThread - 1);
            bCreatedHiPriorityThreads = false;
            bCreatedBackgroundPriorityThreads = false;
            ENamedThreads::bHasBackgroundThreads = 0;
            ENamedThreads::bHasHighPriorityThreads = 0;
        }
        else
        {
            LastExternalThread = ENamedThreads::ActualRenderingThread;
        }
        
        // 专用线程数量
        NumNamedThreads = LastExternalThread + 1;
        // 计算工作线程集数量, 与是否开启线程高优先级、是否创建后台优先级线程有关。
        NumTaskThreadSets = 1 + bCreatedHiPriorityThreads + bCreatedBackgroundPriorityThreads;
        // 计算真正需要的任务线程数量, 最大不超过83个.
        NumThreads = FMath::Max<int32>(FMath::Min<int32>(NumTaskThreads * NumTaskThreadSets + NumNamedThreads, MAX_THREADS), NumNamedThreads + 1);
        NumThreads = FMath::Min(NumThreads, NumNamedThreads + NumTaskThreads * NumTaskThreadSets);

        NumTaskThreadsPerSet = (NumThreads - NumNamedThreads) / NumTaskThreadSets;

        ReentrancyCheck.Increment(); // just checking for reentrancy
        PerThreadIDTLSSlot = FPlatformTLS::AllocTlsSlot();
        
        // 创建所有任务线程.
        for (int32 ThreadIndex = 0; ThreadIndex < NumThreads; ThreadIndex++)
        {
            check(!WorkerThreads[ThreadIndex].bAttached); // reentrant?
            // 根据是否专用线程分别创建线程.
            bool bAnyTaskThread = ThreadIndex >= NumNamedThreads;
            if (bAnyTaskThread)
            {
                WorkerThreads[ThreadIndex].TaskGraphWorker = new FTaskThreadAnyThread(ThreadIndexToPriorityIndex(ThreadIndex));
            }
            else
            {
                WorkerThreads[ThreadIndex].TaskGraphWorker = new FNamedTaskThread;
            }
            WorkerThreads[ThreadIndex].TaskGraphWorker->Setup(ENamedThreads::Type(ThreadIndex), PerThreadIDTLSSlot, &WorkerThreads[ThreadIndex]);
        }

        TaskGraphImplementationSingleton = this; // 赋值this到TaskGraphImplementationSingleton, 以便外部可获取.

        // 设置无名线程的属性.
        for (int32 ThreadIndex = LastExternalThread + 1; ThreadIndex < NumThreads; ThreadIndex++)
        {
            FString Name;
            const ANSICHAR* GroupName = "TaskGraphNormal";
            int32 Priority = ThreadIndexToPriorityIndex(ThreadIndex);
            EThreadPriority ThreadPri;
            uint64 Affinity = FPlatformAffinity::GetTaskGraphThreadMask();
            if (Priority == 1)
            {
                Name = FString::Printf(TEXT("TaskGraphThreadHP %d"), ThreadIndex - (LastExternalThread + 1));
                GroupName = "TaskGraphHigh";
                ThreadPri = TPri_SlightlyBelowNormal; // we want even hi priority tasks below the normal threads

                // If the platform defines FPlatformAffinity::GetTaskGraphHighPriorityTaskMask then use it
                if (FPlatformAffinity::GetTaskGraphHighPriorityTaskMask() != 0xFFFFFFFFFFFFFFFF)
                {
                    Affinity = FPlatformAffinity::GetTaskGraphHighPriorityTaskMask();
                }
            }
            else if (Priority == 2)
            {
                Name = FString::Printf(TEXT("TaskGraphThreadBP %d"), ThreadIndex - (LastExternalThread + 1));
                GroupName = "TaskGraphLow";
                ThreadPri = TPri_Lowest;
                // If the platform defines FPlatformAffinity::GetTaskGraphBackgroundTaskMask then use it
                if ( FPlatformAffinity::GetTaskGraphBackgroundTaskMask() != 0xFFFFFFFFFFFFFFFF )
                {
                    Affinity = FPlatformAffinity::GetTaskGraphBackgroundTaskMask();
                }
            }
            else
            {
                Name = FString::Printf(TEXT("TaskGraphThreadNP %d"), ThreadIndex - (LastExternalThread + 1));
                ThreadPri = TPri_BelowNormal; // we want normal tasks below normal threads like the game thread
            }
            
            // 计算线程栈大小.
#if WITH_EDITOR
            uint32 StackSize = 1024 * 1024;
#elif ( UE_BUILD_SHIPPING || UE_BUILD_TEST )
            uint32 StackSize = 384 * 1024;
#else
            uint32 StackSize = 512 * 1024;
#endif
            // 真正地创建工作线程的执行线程.
            WorkerThreads[ThreadIndex].RunnableThread = FRunnableThread::Create(&Thread(ThreadIndex), *Name, StackSize, ThreadPri, Affinity); // these are below normal threads so that they sleep when the named threads are active
            WorkerThreads[ThreadIndex].bAttached = true;
            
            if (WorkerThreads[ThreadIndex].RunnableThread)
            {
                TRACE_SET_THREAD_GROUP(WorkerThreads[ThreadIndex].RunnableThread->GetThreadID(), GroupName);
            }
        }
    }
    
    // 入队任务.
    virtual void QueueTask(FBaseGraphTask* Task, ENamedThreads::Type ThreadToExecuteOn, ENamedThreads::Type InCurrentThreadIfKnown = ENamedThreads::AnyThread) final override
    {
        TASKGRAPH_SCOPE_CYCLE_COUNTER(2, STAT_TaskGraph_QueueTask);

        if (ENamedThreads::GetThreadIndex(ThreadToExecuteOn) == ENamedThreads::AnyThread)
        {
            TASKGRAPH_SCOPE_CYCLE_COUNTER(3, STAT_TaskGraph_QueueTask_AnyThread);
            // 多线程支持下的处理.
            if (FPlatformProcess::SupportsMultithreading())
            {
                // 处理优先级.
                uint32 TaskPriority = ENamedThreads::GetTaskPriority(Task->ThreadToExecuteOn);
                int32 Priority = ENamedThreads::GetThreadPriorityIndex(Task->ThreadToExecuteOn);
                if (Priority == (ENamedThreads::BackgroundThreadPriority >> ENamedThreads::ThreadPriorityShift) && (!bCreatedBackgroundPriorityThreads || !ENamedThreads::bHasBackgroundThreads))
                {
                    Priority = ENamedThreads::NormalThreadPriority >> ENamedThreads::ThreadPriorityShift; // we don't have background threads, promote to normal
                    TaskPriority = ENamedThreads::NormalTaskPriority >> ENamedThreads::TaskPriorityShift; // demote to normal task pri
                }
                else if (Priority == (ENamedThreads::HighThreadPriority >> ENamedThreads::ThreadPriorityShift) && (!bCreatedHiPriorityThreads || !ENamedThreads::bHasHighPriorityThreads))
                {
                    Priority = ENamedThreads::NormalThreadPriority >> ENamedThreads::ThreadPriorityShift; // we don't have hi priority threads, demote to normal
                    TaskPriority = ENamedThreads::HighTaskPriority >> ENamedThreads::TaskPriorityShift; // promote to hi task pri
                }
                
                uint32 PriIndex = TaskPriority ? 0 : 1;
                check(Priority >= 0 && Priority < MAX_THREAD_PRIORITIES);
                {
                    TASKGRAPH_SCOPE_CYCLE_COUNTER(4, STAT_TaskGraph_QueueTask_IncomingAnyThreadTasks_Push);
                    // 将任务压入待执行队列, 且获得并执行可执行的任务索引(可能无).
                    int32 IndexToStart = IncomingAnyThreadTasks[Priority].Push(Task, PriIndex);
                    if (IndexToStart >= 0)
                    {
                        StartTaskThread(Priority, IndexToStart);
                    }
                }
                return;
            }
            else
            {
                ThreadToExecuteOn = ENamedThreads::GameThread;
            }
        }
        
        // 以下是不支持多线程的处理.
        ENamedThreads::Type CurrentThreadIfKnown;
        if (ENamedThreads::GetThreadIndex(InCurrentThreadIfKnown) == ENamedThreads::AnyThread)
        {
            CurrentThreadIfKnown = GetCurrentThread();
        }
        else
        {
            CurrentThreadIfKnown = ENamedThreads::GetThreadIndex(InCurrentThreadIfKnown);
            checkThreadGraph(CurrentThreadIfKnown == ENamedThreads::GetThreadIndex(GetCurrentThread()));
        }
        {
            int32 QueueToExecuteOn = ENamedThreads::GetQueueIndex(ThreadToExecuteOn);
            ThreadToExecuteOn = ENamedThreads::GetThreadIndex(ThreadToExecuteOn);
            FTaskThreadBase* Target = &Thread(ThreadToExecuteOn);
            if (ThreadToExecuteOn == ENamedThreads::GetThreadIndex(CurrentThreadIfKnown))
            {
                Target->EnqueueFromThisThread(QueueToExecuteOn, Task);
            }
            else
            {
                Target->EnqueueFromOtherThread(QueueToExecuteOn, Task);
            }
        }
    }

    virtual    int32 GetNumWorkerThreads() final override;
    virtual ENamedThreads::Type GetCurrentThreadIfKnown(bool bLocalQueue) final override;
    virtual bool IsThreadProcessingTasks(ENamedThreads::Type ThreadToCheck) final override;

    // 将当前线程导入到指定Index.
    virtual void AttachToThread(ENamedThreads::Type CurrentThread) final override;
    
    // ----处理任务接口----
    
    virtual uint64 ProcessThreadUntilIdle(ENamedThreads::Type CurrentThread) final override;
    virtual void ProcessThreadUntilRequestReturn(ENamedThreads::Type CurrentThread) final override;
    virtual void RequestReturn(ENamedThreads::Type CurrentThread) final override;
    virtual void WaitUntilTasksComplete(const FGraphEventArray& Tasks, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread) final override;
    virtual void TriggerEventWhenTasksComplete(FEvent* InEvent, const FGraphEventArray& Tasks, ENamedThreads::Type CurrentThreadIfKnown = ENamedThreads::AnyThread, ENamedThreads::Type TriggerThread = ENamedThreads::AnyHiPriThreadHiPriTask) final override;
    virtual void AddShutdownCallback(TFunction<void()>& Callback);

    // ----任务调度接口----

    // 开启指定优先级和索引的任务线程.
    void StartTaskThread(int32 Priority, int32 IndexToStart);
    void StartAllTaskThreads(bool bDoBackgroundThreads);
    FBaseGraphTask* FindWork(ENamedThreads::Type ThreadInNeed);
    void StallForTuning(int32 Index, bool Stall);
    void SetTaskThreadPriorities(EThreadPriority Pri);

private:
    // 获取指定索引的任务线程引用.
    FTaskThreadBase& Thread(int32 Index)
    {
        checkThreadGraph(Index >= 0 && Index < NumThreads);
        checkThreadGraph(WorkerThreads[Index].TaskGraphWorker->GetThreadId() == Index);
        return *WorkerThreads[Index].TaskGraphWorker;
    }

    // 获取当前线程索引.
    ENamedThreads::Type GetCurrentThread();
    int32 ThreadIndexToPriorityIndex(int32 ThreadIndex);

    enum
    {
        MAX_THREADS = 26 * (CREATE_HIPRI_TASK_THREADS + CREATE_BACKGROUND_TASK_THREADS + 1) + ENamedThreads::ActualRenderingThread + 1,
        MAX_THREAD_PRIORITIES = 3
    };

    FWorkerThread        WorkerThreads[MAX_THREADS]; // 所有工作线程(任务线程)对象数组.
    int32                NumThreads;       // 实际上被使用的线程数量.
    int32                NumNamedThreads;  // 专用线程数量.
    int32                NumTaskThreadSets;// 任务线程集合数量.
    int32                NumTaskThreadsPerSet; // 每个集合拥有的任务线程数量.
    
    bool                bCreatedHiPriorityThreads;
    bool                bCreatedBackgroundPriorityThreads;

    ENamedThreads::Type LastExternalThread;
    FThreadSafeCounter    ReentrancyCheck;
    uint32                PerThreadIDTLSSlot;

    TArray<TFunction<void()> > ShutdownCallbacks; // 销毁前的回调.

    FStallingTaskQueue<FBaseGraphTask, PLATFORM_CACHE_LINE_SIZE, 2>    IncomingAnyThreadTasks[MAX_THREAD_PRIORITIES];
};

总结起来,TaskGraph会根据线程优先级、是否启用后台线程创建不同的工作线程集合,然后创建它们的FWorkerThread对象。入队任务时,会将任务Push到任务列表IncomingAnyThreadTasks(类型是FStallingTaskQueue,线程安全的无锁的链表)中,并取出可执行的任务索引,根据任务的属性(希望在哪个线程执行、优先级、任务索引)启用对应的工作线程去执行。

TaskGraph涉及的工作线程FWorkerThread声明如下:

struct FWorkerThread
{
    FTaskThreadBase*    TaskGraphWorker; // 所在的FTaskThread对象(被FTaskThread对象拥有)
    FRunnableThread*    RunnableThread;  // 真正执行任务的可运行线程.
    bool                bAttached; // 是否附加的线程.(一般用于专用线程)
};

由此可见,TaskGraph最终也是借助FRunnableThread来执行任务。TaskGraph系统总算是和FRunnableThread联系起来,形成了闭环。

至此,终于将TaskGraph体系的主干脉络阐述完了,当然,还有很多技术细节(如同步事件、触发细节、调度算法、无锁链表以及部分概念)并没有涉及,这些就留给读者自己去研读UE源码探索了。

 

2.5 UE的多线程渲染

前面做了大量的基础铺垫,终于回到了主题,讲UE的多线程渲染相关的知识。

2.5.1 UE的多线程渲染基础

2.5.1.1 场景和渲染模块主要类型

UE的场景和渲染模块涉及到概念非常多,主要类型和解析如下:

类型 解析
UWorld 包含了一组可以相互交互的Actor和组件的集合,多个关卡(Level)可以被加载进UWorld或从UWorld卸载。可以同时存在多个UWorld实例。
ULevel 关卡,存储着一组Actor和组件,并且存储在同一个文件。
USceneComponent 场景组件,是所有可以被加入到场景的物体的父类,比如灯光、模型、雾等。
UPrimitiveComponent 图元组件,是所有可渲染或拥有物理模拟的物体父类。是CPU层裁剪的最小粒度单位,
ULightComponent 光源组件,是所有光源类型的父类。
FScene 是UWorld在渲染模块的代表。只有加入到FScene的物体才会被渲染器感知到。渲染线程拥有FScene的所有状态(游戏线程不可直接修改)。
FPrimitiveSceneProxy 图元场景代理,是UPrimitiveComponent在渲染器的代表,镜像了UPrimitiveComponent在渲染线程的状态。
FPrimitiveSceneInfo 渲染器内部状态(描述了FRendererModule的实现),相当于融合了UPrimitiveComponent and FPrimitiveSceneProxy。只存在渲染器模块,所以引擎模块无法感知到它的存在。
FSceneView 描述了FScene内的单个视图(view),同个FScene允许有多个view,换言之,一个场景可以被多个view绘制,或者多个view同时被绘制。每一帧都会创建新的view实例。
FViewInfo view在渲染器的内部代表,只存在渲染器模块,引擎模块不可见。
FSceneViewState 存储了有关view的渲染器私有信息,这些信息需要被跨帧访问。在Game实例,每个ULocalPlayer拥有一个FSceneViewState实例。
FSceneRenderer 每帧都会被创建,封装帧间临时数据。下派生FDeferredShadingSceneRenderer(延迟着色场景渲染器)和FMobileSceneRenderer(移动端场景渲染器),分别代表PC和移动端的默认渲染器。

2.5.1.2 引擎模块和渲染模块代表

UE为了结构清晰,减少模块之间的依赖,加速迭代速度,划分了很多模块,最主要的有引擎模块、渲染器模块、核心、RHI、插件等等。上一小节提到了很多概念和类型,它们有些存在于引擎模块(Engine Module),有些存在于渲染器模块(Renderer Module),具体如下表:

Engine Module Renderer Module
UWorld FScene
UPrimitiveComponent / FPrimitiveSceneProxy FPrimitiveSceneInfo
FSceneView FViewInfo
ULocalPlayer FSceneViewState
ULightComponent / FLightSceneProxy FLightSceneInfo

2.5.1.3 游戏线程和渲染线程代表

游戏线程的对象通常做逻辑更新,在内存中有一份持久的数据,为了避免游戏线程和渲染线程产生竞争条件,会在渲染线程额外存储一份内存拷贝,并且使用的是另外的类型,以下是UE比较常见的类型映射关系(游戏线程对象以U开头,渲染线程以F开头):

Game Thread Rendering Thread
UWorld FScene
UPrimitiveComponent FPrimitiveSceneProxy / FPrimitiveSceneInfo
- FSceneView / FViewInfo
ULocalPlayer FSceneViewState
ULightComponent FLightSceneProxy / FLightSceneInfo

游戏线程代表一般由游戏游戏线程操作,渲染线程代表主要由渲染线程操作。如果尝试跨线程操作数据,将会引发不可预料的结果,产生竞争条件。

/** SceneProxy在注册进场景时,会在游戏线程中被构造和传递数据。 */
FStaticMeshSceneProxy::FStaticMeshSceneProxy(UStaticMeshComponent* InComponent):
    FPrimitiveSceneProxy(...),
    Owner(InComponent->GetOwner()) <======== 此处将AActor指针被缓存
    ...

    /** SceneProxy的DrawDynamicElements将被渲染器在渲染线程中调用 */
    void FStaticMeshSceneProxy::DrawDynamicElements(...)
    {
        if (Owner->AnyProperty) <========== 将会引发竞争条件!  游戏线程拥有AActor、UObject的所有状态!!并且UObject对象可能被GC掉,此时再访问会引起程序崩溃!!
    }

部分代表比较特殊,如FPrimitiveSceneProxy、FLightSceneProxy ,这些场景代理本属于引擎模块,但又属于渲染线程专属对象,说明它们是连接游戏线程和渲染线程的桥梁,是线程间传递数据的工具人。

2.5.2 UE的多线程渲染总览

默认情况下,UE存在游戏线程(Game Thread)、渲染线程(Render Thread)、RHI线程(RHI Thread),它们都独立地运行在专门的线程上(FRunnableThread)。

游戏线程通过某些接口向渲染线程的Queue入队回调接口,以便渲染线程稍后运行时,从渲染线程的Queue获取回调,一个个地执行,从而生成了Command List。

渲染线程作为前端(frontend)产生的Command List是平台无关的,是抽象的图形API调用;而RHI线程作为后端(backtend)会执行和转换渲染线程的Command List成为指定图形API的调用(称为Graphical Command),并提交到GPU执行。这些线程处理的数据通常是不同帧的,譬如游戏线程处理N帧数据,渲染线程和RHI线程处理N-1帧数据。

但也存在例外,比如渲染线程和RHI线程运行很快,几乎不存在延迟,这种情况下,游戏线程处理N帧,而渲染线程可能处理N或N-1帧,RHI线程也可能在转换N或N-1帧。但是,渲染线程不能落后游戏线程一帧,否则游戏线程会卡住,直到渲染线程处理所有指令。

除此之外,渲染指令是可以并行地被生成,RHI线程也可以并行地转换这些指令,如下所示:

UE4并行生成Command list示意图。

开启多线程渲染带来的收益是帧率更高,帧间变化频率降低(帧率更稳定)。以Fortnite(堡垒之夜)移动端为例,在开启RHI线程之前,渲染线程急剧地上下波动,而加了RHI线程之后,波动平缓许多,和游戏线程基本保持一致,帧率也提升不少:

2.5.3 游戏线程和渲染线程的实现

2.5.3.1 游戏线程的实现

游戏线程被称为主线程,是引擎运行的心脏,承载主要的游戏逻辑、运行流程的工作,也是其它线程的数据发起者。

游戏线程的创建是运行程序入口的线程,由系统启动进程时被同时创建的(因为进程至少需要一个线程来工作),在引擎启动时直接存储到全局变量中,且稍后会设置到TaskGraph系统中:

// Engine\Source\Runtime\Launch\Private\LaunchEngineLoop.cpp

int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
    (......)
    
    // 获取当前线程id, 存储到全局变量中.
    GGameThreadId = FPlatformTLS::GetCurrentThreadId();
    GIsGameThreadIdInitialized = true;

    FPlatformProcess::SetThreadAffinityMask(FPlatformAffinity::GetMainGameMask());
    // 设置游戏线程数据(但很多平台都是空的实现体)
    FPlatformProcess::SetupGameThread();
    
    (......)
    
    if (bCreateTaskGraphAndThreadPools)
    {
        SCOPED_BOOT_TIMING("FTaskGraphInterface::Startup");
        FTaskGraphInterface::Startup(FPlatformMisc::NumberOfCores());
        // 将当前线程(主线程)附加到TaskGraph的GameThread命名插槽中. 这样主线程便和TaskGraph联动了起来.
        FTaskGraphInterface::Get().AttachToThread(ENamedThreads::GameThread);
    }
}

以上代码也说明:主线程、游戏线程和TaskGraph系统的ENamedThreads::GameThread其实是一回事,都是同一个线程!

经过上面的初始化和设置后,其它地方就可以通过TaskGraph系统并行地处理任务了,也可以访问全局变量,以便判断游戏线程是否初始化完,当前线程是否游戏线程:

bool IsInGameThread()
{
    return GIsGameThreadIdInitialized && FPlatformTLS::GetCurrentThreadId() == GGameThreadId;
}

2.5.3.2 渲染线程的实现

渲染线程与游戏不同,是一条专门用于生成渲染指令和渲染逻辑的独立线程。RenderingThread.h声明了全部对外的接口,部分如下:

// Engine\Source\Runtime\RenderCore\Public\RenderingThread.h

// 是否启用了独立的渲染线程, 如果为false, 则所有渲染命令会被立即执行, 而不是放入渲染命令队列.
extern RENDERCORE_API bool GIsThreadedRendering;

// 渲染线程是否应该被创建. 通常被命令行参数或ToggleRenderingThread控制台参数设置.
extern RENDERCORE_API bool GUseThreadedRendering;

// 是否开启RHI线程
extern RENDERCORE_API void SetRHIThreadEnabled(bool bEnableDedicatedThread, bool bEnableRHIOnTaskThreads);

(......)

// 开启渲染线程.
extern RENDERCORE_API void StartRenderingThread();

// 停止渲染线程.
extern RENDERCORE_API void StopRenderingThread();

// 检查渲染线程是否健康(是否Crash), 如果crash, 则会用UE_Log输出日志.
extern RENDERCORE_API void CheckRenderingThreadHealth();

// 检查渲染线程是否健康(是否Crash)
extern RENDERCORE_API bool IsRenderingThreadHealthy();

// 增加一个必须在下一个场景绘制前或flush渲染命令前完成的任务.
extern RENDERCORE_API void AddFrameRenderPrerequisite(const FGraphEventRef& TaskToAdd);

// 手机帧渲染前序任务, 保证所有渲染命令被入队.
extern RENDERCORE_API void AdvanceFrameRenderPrerequisite();

// 等待所有渲染线程的渲染命令被执行完毕. 会卡住游戏线程, 只能被游戏线程调用.
extern RENDERCORE_API void FlushRenderingCommands(bool bFlushDeferredDeletes = false);

extern RENDERCORE_API void FlushPendingDeleteRHIResources_GameThread();
extern RENDERCORE_API void FlushPendingDeleteRHIResources_RenderThread();

extern RENDERCORE_API void TickRenderingTickables();

extern RENDERCORE_API void StartRenderCommandFenceBundler();
extern RENDERCORE_API void StopRenderCommandFenceBundler();

(......)

RenderingThread.h还有一个非常重要的宏ENQUEUE_RENDER_COMMAND,它的作用是向渲染线程入队渲染指令。下面是它的声明和实现:

// 向渲染线程入队渲染指令, Type指明了渲染操作的名字.
#define ENQUEUE_RENDER_COMMAND(Type) \
    struct Type##Name \
    {  \
        static const char* CStr() { return #Type; } \
        static const TCHAR* TStr() { return TEXT(#Type); } \
    }; \
    EnqueueUniqueRenderCommand<Type##Name>

上面最后一句使用了EnqueueUniqueRenderCommand命令,继续追踪之:

// TSTR是渲染命令名字, LAMBDA是回调函数.
template<typename TSTR, typename LAMBDA>
FORCEINLINE_DEBUGGABLE void EnqueueUniqueRenderCommand(LAMBDA&& Lambda)
{
    typedef TEnqueueUniqueRenderCommandType<TSTR, LAMBDA> EURCType;

    // 如果在渲染线程内直接执行回调而不入队渲染命令.
    if (IsInRenderingThread())
    {
        FRHICommandListImmediate& RHICmdList = GetImmediateCommandList_ForRenderCommand();
        Lambda(RHICmdList);
    }
    else
    {
        // 需要在独立的渲染线程执行
        if (ShouldExecuteOnRenderThread())
        {
            CheckNotBlockedOnRenderThread();
            // 从GraphTask创建任务且在适当时候入队渲染命令.
            TGraphTask<EURCType>::CreateTask().ConstructAndDispatchWhenReady(Forward<LAMBDA>(Lambda));
        }
        else // 不在独立的渲染线程执行, 则直接执行.
        {
            EURCType TempCommand(Forward<LAMBDA>(Lambda));
            FScopeCycleCounter EURCMacro_Scope(TempCommand.GetStatId());
            TempCommand.DoTask(ENamedThreads::GameThread, FGraphEventRef());
        }
    }
}

上面说明如果是有独立的渲染线程,最终会将渲染命令入队到TaskGraph的任务Queue中,等待合适的时机在渲染线程中被执行。其中TEnqueueUniqueRenderCommandType就是专用于渲染命令的特殊TaskGraph任务类型,声明如下:

class RENDERCORE_API FRenderCommand
{
public:
    // 所有渲染指令都必须在渲染线程执行.
    static ENamedThreads::Type GetDesiredThread()
    {
        check(!GIsThreadedRendering || ENamedThreads::GetRenderThread() != ENamedThreads::GameThread);
        return ENamedThreads::GetRenderThread();
    }

    static ESubsequentsMode::Type GetSubsequentsMode()
    {
        return ESubsequentsMode::FireAndForget;
    }
};

template<typename TSTR, typename LAMBDA>
class TEnqueueUniqueRenderCommandType : public FRenderCommand
{
public:
    TEnqueueUniqueRenderCommandType(LAMBDA&& InLambda) : Lambda(Forward<LAMBDA>(InLambda)) {}
    
    // 正在执行任务.
    void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
    {
        TRACE_CPUPROFILER_EVENT_SCOPE_ON_CHANNEL_STR(TSTR::TStr(), RenderCommandsChannel);
        FRHICommandListImmediate& RHICmdList = GetImmediateCommandList_ForRenderCommand();
        Lambda(RHICmdList);
    }

    (......)
    
private:
    LAMBDA Lambda; // 缓存渲染回调函数.
};

为了更好理解入队渲染命令操作,举个具体的例子,以增加灯光到场景为例:

void FScene::AddLight(ULightComponent* Light)
{
    (......)

    // Send a command to the rendering thread to add the light to the scene.
    FScene* Scene = this;
    FLightSceneInfo* LightSceneInfo = Proxy->LightSceneInfo;

    // 这里入队渲染指令, 以便在渲染线程将灯光数据传递到渲染器.
    ENQUEUE_RENDER_COMMAND(FAddLightCommand)(
        [Scene, LightSceneInfo](FRHICommandListImmediate& RHICmdList)
        {
            CSV_SCOPED_TIMING_STAT_EXCLUSIVE(Scene_AddLight);
            FScopeCycleCounter Context(LightSceneInfo->Proxy->GetStatId());
            Scene->AddLightSceneInfo_RenderThread(LightSceneInfo);
        });
}

ENQUEUE_RENDER_COMMAND(FAddLightCommand)代入前面解析过的宏和模板,并展开,完整的代码如下:

struct FAddLightCommandName
{
    static const char* CStr() { return "FAddLightCommand"; }
    static const TCHAR* TStr() { return TEXT("FAddLightCommand"); }
};

EnqueueUniqueRenderCommand<FAddLightCommandName>(
    [Scene, LightSceneInfo](FRHICommandListImmediate& RHICmdList)
    {
        CSV_SCOPED_TIMING_STAT_EXCLUSIVE(Scene_AddLight);
        FScopeCycleCounter Context(LightSceneInfo->Proxy->GetStatId());
        Scene->AddLightSceneInfo_RenderThread(LightSceneInfo);
    })
{
    typedef TEnqueueUniqueRenderCommandType<FAddLightCommandName, LAMBDA> EURCType;

    // 如果在渲染线程内直接执行回调而不入队渲染命令.
    if (IsInRenderingThread())
    {
        FRHICommandListImmediate& RHICmdList = GetImmediateCommandList_ForRenderCommand();
        Lambda(RHICmdList);
    }
    else
    {
        // 需要在独立的渲染线程执行
        if (ShouldExecuteOnRenderThread())
        {
            CheckNotBlockedOnRenderThread();
            // 从GraphTask创建任务且在适当时候入队渲染命令.
            TGraphTask<EURCType>::CreateTask().ConstructAndDispatchWhenReady(Forward<LAMBDA>(Lambda));
        }
        else // 不在独立的渲染线程执行, 则直接执行.
        {
            EURCType TempCommand(Forward<LAMBDA>(Lambda));
            FScopeCycleCounter EURCMacro_Scope(TempCommand.GetStatId());
            TempCommand.DoTask(ENamedThreads::GameThread, FGraphEventRef());
        }
    }
}

FRenderingThread承载了渲染线程的主要工作,它的部分接口和实现代码如下:

// Engine\Source\Runtime\RenderCore\Private\RenderingThread.cpp

class FRenderingThread : public FRunnable
{
private:
    bool bAcquiredThreadOwnership;    // 当没有独立的RHI线程时, 渲染线程将被其它线程捕获.

public:
    FEvent* TaskGraphBoundSyncEvent; // TaskGraph同步事件, 以便在主线程使用渲染线程之前就将渲染线程绑定到TaskGraph体系中.

    FRenderingThread()
    {
        bAcquiredThreadOwnership = false;
        // 获取同步事件.
        TaskGraphBoundSyncEvent    = FPlatformProcess::GetSynchEventFromPool(true);
        RHIFlushResources();
    }

    // FRunnable interface.
    virtual bool Init(void) override
    {
        // 获取当前线程ID到全局变量GRenderThreadId, 以便其它地方引用.
        GRenderThreadId = FPlatformTLS::GetCurrentThreadId();
        
        // 处理线程捕获关系.
        if (!IsRunningRHIInSeparateThread())
        {
            bAcquiredThreadOwnership = true;
            RHIAcquireThreadOwnership();
        }

        return true; 
    }
    
    (......)
    
    virtual uint32 Run(void) override
    {
        // 设置TLS.
        FMemory::SetupTLSCachesOnCurrentThread();
        // 设置渲染线程平台相关的数据.
        FPlatformProcess::SetupRenderThread();

        (......)
        
        {
            // 进入渲染线程主循环.
            RenderingThreadMain( TaskGraphBoundSyncEvent );
        }
        
        FMemory::ClearAndDisableTLSCachesOnCurrentThread();
        return 0;
    }
};

可见它在运行之后会进入渲染线程逻辑,这里再进入RenderingThreadMain代码一探究竟:

void RenderingThreadMain( FEvent* TaskGraphBoundSyncEvent )
{
    LLM_SCOPE(ELLMTag::RenderingThreadMemory);
    
    // 将渲染线程和局部线程线程插槽设置成ActualRenderingThread和ActualRenderingThread_Local.
    ENamedThreads::Type RenderThread = ENamedThreads::Type(ENamedThreads::ActualRenderingThread);

    ENamedThreads::SetRenderThread(RenderThread);
    ENamedThreads::SetRenderThread_Local(ENamedThreads::Type(ENamedThreads::ActualRenderingThread_Local));
    
    // 将当前线程附加到TaskGraph的RenderThread插槽中.
    FTaskGraphInterface::Get().AttachToThread(RenderThread);
    FPlatformMisc::MemoryBarrier();

    // 触发同步事件, 通知主线程渲染线程已经附加到TaskGraph, 已经准备好接收任务.
    if( TaskGraphBoundSyncEvent != NULL )
    {
        TaskGraphBoundSyncEvent->Trigger();
    }

    (......)
    
    // 渲染线程不同阶段的处理.
    FCoreDelegates::PostRenderingThreadCreated.Broadcast();
    check(GIsThreadedRendering);
    FTaskGraphInterface::Get().ProcessThreadUntilRequestReturn(RenderThread);
    FPlatformMisc::MemoryBarrier();
    check(!GIsThreadedRendering);
    FCoreDelegates::PreRenderingThreadDestroyed.Broadcast();
    
    (......)
    
    // 恢复线程线程到游戏线程.
    ENamedThreads::SetRenderThread(ENamedThreads::GameThread);
    ENamedThreads::SetRenderThread_Local(ENamedThreads::GameThread_Local);
    FPlatformMisc::MemoryBarrier();
}

不过这里还留有一个很大的疑问,那就是FRenderingThread只是获取当前线程作为渲染线程并附加到TaskGraph中,并没有创建线程。那么是哪里创建的渲染线程呢?继续追踪,结果发现是在StartRenderingThread()接口中创建了FRenderingThread实例,它的实现代码如下(节选):

// Engine\Source\Runtime\RenderCore\Private\RenderingThread.cpp

void StartRenderingThread()
{
    (......)

    // Turn on the threaded rendering flag.
    GIsThreadedRendering = true;

    // 创建FRenderingThread实例.
    GRenderingThreadRunnable = new FRenderingThread();

    // 创建渲染线程!!
    GRenderingThread = FRunnableThread::Create(GRenderingThreadRunnable, *BuildRenderingThreadName(ThreadCount), 0, FPlatformAffinity::GetRenderingThreadPriority(), FPlatformAffinity::GetRenderingThreadMask(), FPlatformAffinity::GetRenderingThreadFlags());
    
    (......)

    // 开启渲染命令的栅栏.
    FRenderCommandFence Fence;
    Fence.BeginFence();
    Fence.Wait();

    (......)
}

如果继续追踪,会发现StartRenderingThread()是在FEngineLoop::PreInitPostStartupScreen中调用的。

至此,渲染线程的创建、初始化以及主要接口的实现都剖析完了。

2.5.3.3 RHI线程的实现

RHI线程的工作是转换渲染指令到指定图形API,创建、上传渲染资源到GPU。它的主要逻辑在FRHIThread中,实现代码如下:

// Engine\Source\Runtime\RenderCore\Private\RenderingThread.cpp

class FRHIThread : public FRunnable
{
public:
    FRunnableThread* Thread;    // 所在的RHI线程.

    FRHIThread()
        : Thread(nullptr)
    {
        check(IsInGameThread());
    }
    
    void Start()
    {
        // 开始时创建RHI线程.
        Thread = FRunnableThread::Create(this, TEXT("RHIThread"), 512 * 1024, FPlatformAffinity::GetRHIThreadPriority(),
            FPlatformAffinity::GetRHIThreadMask(), FPlatformAffinity::GetRHIThreadFlags()
            );
        check(Thread);
    }

    virtual uint32 Run() override
    {
        LLM_SCOPE(ELLMTag::RHIMisc);
        
        // 初始化TLS
        FMemory::SetupTLSCachesOnCurrentThread();
        // 将FRHIThread所在的RHI线程附加到askGraph体系中,并指定到ENamedThreads::RHIThread。
        FTaskGraphInterface::Get().AttachToThread(ENamedThreads::RHIThread);
        // 启动RHI线程,直到线程返回。
        FTaskGraphInterface::Get().ProcessThreadUntilRequestReturn(ENamedThreads::RHIThread);
        // 清理TLS.
        FMemory::ClearAndDisableTLSCachesOnCurrentThread();
        return 0;
    }
    
    // 单例接口。
    static FRHIThread& Get()
    {
        static FRHIThread Singleton; // 使用了局部静态变量,可以保证线程安全。
        return Singleton;
    }
};

可见RHI线程不同于渲染线程,是直接在FRHIThread对象内创建实际的线程。而FRHIThread的创建也是在StartRenderingThread()中:

void StartRenderingThread()
{
    (......)

    if (GUseRHIThread_InternalUseOnly)
    {
        FRHICommandListExecutor::GetImmediateCommandList().ImmediateFlush(EImmediateFlushType::DispatchToRHIThread);        
        if (!FTaskGraphInterface::Get().IsThreadProcessingTasks(ENamedThreads::RHIThread))
        {
            // 创建FRHIThread实例并启动它.
            FRHIThread::Get().Start();
        }
        DECLARE_CYCLE_STAT(TEXT("Wait For RHIThread"), STAT_WaitForRHIThread, STATGROUP_TaskGraphTasks);
        
        // 创建RHI线程拥有者捕获任务, 让游戏线程等待.
        FGraphEventRef CompletionEvent = TGraphTask<FOwnershipOfRHIThreadTask>::CreateTask(NULL, ENamedThreads::GameThread).ConstructAndDispatchWhenReady(true, GET_STATID(STAT_WaitForRHIThread));
        QUICK_SCOPE_CYCLE_COUNTER(STAT_StartRenderingThread);
        // 让游戏线程或局部线程等待RHI线程处理(捕获了线程拥有者, 大多数图形API为空)完毕.
        FTaskGraphInterface::Get().WaitUntilTaskCompletes(CompletionEvent, ENamedThreads::GameThread_Local);
        // 存储RHI线程id.
        GRHIThread_InternalUseOnly = FRHIThread::Get().Thread;
        check(GRHIThread_InternalUseOnly);
        GIsRunningRHIInDedicatedThread_InternalUseOnly = true;
        GIsRunningRHIInSeparateThread_InternalUseOnly = true;
        GRHIThreadId = GRHIThread_InternalUseOnly->GetThreadID();
        
        GRHICommandList.LatchBypass();
    }
    
    (......)
}

那么渲染线程如何向RHI线程入队任务呢?答案就在RHICommandList.h中:

// Engine\Source\Runtime\RHI\Public\RHICommandList.h

// RHI命令父类
struct FRHICommandBase
{
    FRHICommandBase* Next = nullptr; // 指向下一条RHI命令.
    // 执行RHI命令并销毁.
    virtual void ExecuteAndDestruct(FRHICommandListBase& CmdList, FRHICommandListDebugContext& DebugContext) = 0;
};

// RHI命令结构体
template<typename TCmd, typename NameType = FUnnamedRhiCommand>
struct FRHICommand : public FRHICommandBase
{
    (......)

    void ExecuteAndDestruct(FRHICommandListBase& CmdList, FRHICommandListDebugContext& Context) override final
    {
        (......)
        
        TCmd *ThisCmd = static_cast<TCmd*>(this);

        ThisCmd->Execute(CmdList);
        ThisCmd->~TCmd();
    }
};

// 向RHI线程发送RHI命令的宏.
#define FRHICOMMAND_MACRO(CommandName)                                \
struct PREPROCESSOR_JOIN(CommandName##String, __LINE__)                \
{                                                                    \
    static const TCHAR* TStr() { return TEXT(#CommandName); }        \
};                                                                    \
struct CommandName final : public FRHICommand<CommandName, PREPROCESSOR_JOIN(CommandName##String, __LINE__)>

RHI线程的相关实现机制跟渲染线程类型,且更加简洁。以下是它的使用示范:

// Engine\Source\Runtime\RHI\Public\RHICommandList.h
FRHICOMMAND_MACRO(FRHICommandDrawPrimitive)
{
    uint32 BaseVertexIndex;
    uint32 NumPrimitives;
    uint32 NumInstances;
    
    FORCEINLINE_DEBUGGABLE FRHICommandDrawPrimitive(uint32 InBaseVertexIndex, uint32 InNumPrimitives, uint32 InNumInstances)
        : BaseVertexIndex(InBaseVertexIndex)
        , NumPrimitives(InNumPrimitives)
        , NumInstances(InNumInstances)
    {
    }
    RHI_API void Execute(FRHICommandListBase& CmdList);
};

// Engine\Source\Runtime\RHI\Public\RHICommandListCommandExecutes.inl
void FRHICommandDrawPrimitive::Execute(FRHICommandListBase& CmdList)
{
    RHISTAT(DrawPrimitive);
    INTERNAL_DECORATOR(RHIDrawPrimitive)(BaseVertexIndex, NumPrimitives, NumInstances);
}

由此可见,所有的RHI指令都是预先声明并实现好的,目前存在的RHI渲染指令类型达到近百种(如下),渲染线程创建这些声明好的RHI指令即可在合适的被推入RHI线程队列并被执行。

FRHICOMMAND_MACRO(FRHICommandUpdateGeometryCacheBuffer)
FRHICOMMAND_MACRO(FRHISubmitFrameToEncoder)
FRHICOMMAND_MACRO(FLocalRHICommand)
FRHICOMMAND_MACRO(FRHISetSpectatorScreenTexture)
FRHICOMMAND_MACRO(FRHISetSpectatorScreenModeTexturePlusEyeLayout)
FRHICOMMAND_MACRO(FRHISyncFrameCommand)
FRHICOMMAND_MACRO(FRHICommandStat)
FRHICOMMAND_MACRO(FRHICommandRHIThreadFence)
FRHICOMMAND_MACRO(FRHIAsyncComputeSubmitList)
FRHICOMMAND_MACRO(FRHICommandWaitForAndSubmitSubListParallel)
FRHICOMMAND_MACRO(FRHICommandWaitForAndSubmitSubList)
FRHICOMMAND_MACRO(FRHICommandWaitForAndSubmitRTSubList)
FRHICOMMAND_MACRO(FRHICommandSubmitSubList)
FRHICOMMAND_MACRO(FRHICommandBeginUpdateMultiFrameResource)
FRHICOMMAND_MACRO(FRHICommandEndUpdateMultiFrameResource)
FRHICOMMAND_MACRO(FRHICommandBeginUpdateMultiFrameUAV)
FRHICOMMAND_MACRO(FRHICommandEndUpdateMultiFrameUAV)
FRHICOMMAND_MACRO(FRHICommandSetGPUMask)
FRHICOMMAND_MACRO(FRHICommandWaitForTemporalEffect)
FRHICOMMAND_MACRO(FRHICommandBroadcastTemporalEffect)
FRHICOMMAND_MACRO(FRHICommandSetStencilRef)
FRHICOMMAND_MACRO(FRHICommandDrawPrimitive)
FRHICOMMAND_MACRO(FRHICommandDrawIndexedPrimitive)
FRHICOMMAND_MACRO(FRHICommandSetBlendFactor)
FRHICOMMAND_MACRO(FRHICommandSetStreamSource)
FRHICOMMAND_MACRO(FRHICommandSetViewport)
FRHICOMMAND_MACRO(FRHICommandSetStereoViewport)
FRHICOMMAND_MACRO(FRHICommandSetScissorRect)
FRHICOMMAND_MACRO(FRHICommandSetRenderTargets)
FRHICOMMAND_MACRO(FRHICommandBeginRenderPass)
FRHICOMMAND_MACRO(FRHICommandEndRenderPass)
FRHICOMMAND_MACRO(FRHICommandNextSubpass)
FRHICOMMAND_MACRO(FRHICommandBeginParallelRenderPass)
FRHICOMMAND_MACRO(FRHICommandEndParallelRenderPass)
FRHICOMMAND_MACRO(FRHICommandBeginRenderSubPass)
FRHICOMMAND_MACRO(FRHICommandEndRenderSubPass)
FRHICOMMAND_MACRO(FRHICommandBeginComputePass)
FRHICOMMAND_MACRO(FRHICommandEndComputePass)
FRHICOMMAND_MACRO(FRHICommandBindClearMRTValues)
FRHICOMMAND_MACRO(FRHICommandSetGraphicsPipelineState)
FRHICOMMAND_MACRO(FRHICommandAutomaticCacheFlushAfterComputeShader)
FRHICOMMAND_MACRO(FRHICommandFlushComputeShaderCache)
FRHICOMMAND_MACRO(FRHICommandDrawPrimitiveIndirect)
FRHICOMMAND_MACRO(FRHICommandDrawIndexedIndirect)
FRHICOMMAND_MACRO(FRHICommandDrawIndexedPrimitiveIndirect)
FRHICOMMAND_MACRO(FRHICommandSetDepthBounds)
FRHICOMMAND_MACRO(FRHICommandClearUAVFloat)
FRHICOMMAND_MACRO(FRHICommandClearUAVUint)
FRHICOMMAND_MACRO(FRHICommandCopyToResolveTarget)
FRHICOMMAND_MACRO(FRHICommandCopyTexture)
FRHICOMMAND_MACRO(FRHICommandResummarizeHTile)
FRHICOMMAND_MACRO(FRHICommandTransitionTexturesDepth)
FRHICOMMAND_MACRO(FRHICommandTransitionTextures)
FRHICOMMAND_MACRO(FRHICommandTransitionTexturesArray)
FRHICOMMAND_MACRO(FRHICommandTransitionTexturesPipeline)
FRHICOMMAND_MACRO(FRHICommandTransitionTexturesArrayPipeline)
FRHICOMMAND_MACRO(FRHICommandClearColorTexture)
FRHICOMMAND_MACRO(FRHICommandClearDepthStencilTexture)
FRHICOMMAND_MACRO(FRHICommandClearColorTextures)
FRHICOMMAND_MACRO(FRHICommandSetGlobalUniformBuffers)
FRHICOMMAND_MACRO(FRHICommandBuildLocalUniformBuffer)
FRHICOMMAND_MACRO(FRHICommandBeginRenderQuery)
FRHICOMMAND_MACRO(FRHICommandEndRenderQuery)
FRHICOMMAND_MACRO(FRHICommandCalibrateTimers)
FRHICOMMAND_MACRO(FRHICommandPollOcclusionQueries)
FRHICOMMAND_MACRO(FRHICommandBeginScene)
FRHICOMMAND_MACRO(FRHICommandEndScene)
FRHICOMMAND_MACRO(FRHICommandBeginFrame)
FRHICOMMAND_MACRO(FRHICommandEndFrame)
FRHICOMMAND_MACRO(FRHICommandBeginDrawingViewport)
FRHICOMMAND_MACRO(FRHICommandEndDrawingViewport)
FRHICOMMAND_MACRO(FRHICommandInvalidateCachedState)
FRHICOMMAND_MACRO(FRHICommandDiscardRenderTargets)
FRHICOMMAND_MACRO(FRHICommandDebugBreak)
FRHICOMMAND_MACRO(FRHICommandUpdateTextureReference)
FRHICOMMAND_MACRO(FRHICommandUpdateRHIResources)
FRHICOMMAND_MACRO(FRHICommandCopyBufferRegion)
FRHICOMMAND_MACRO(FRHICommandCopyBufferRegions)
FRHICOMMAND_MACRO(FRHICommandClearRayTracingBindings)
FRHICOMMAND_MACRO(FRHICommandRayTraceOcclusion)
FRHICOMMAND_MACRO(FRHICommandRayTraceIntersection)
FRHICOMMAND_MACRO(FRHICommandRayTraceDispatch)
FRHICOMMAND_MACRO(FRHICommandSetRayTracingBindings)
FRHICOMMAND_MACRO(FClearCachedRenderingDataCommand)
FRHICOMMAND_MACRO(FClearCachedElementDataCommand)

2.5.4 游戏线程和渲染线程的交互

本节将讲述各个线程之间的数据交换机制和实现细节。首先看看游戏线程如何将数据传递给渲染线程。

游戏线程在Tick时,会通过UGameEngine、FViewport、UGameViewportClient等对象,才会进入渲染模块的调用:

void UGameEngine::Tick( float DeltaSeconds, bool bIdleMode )
{
    UGameEngine::RedrawViewports()
    {
        void FViewport::Draw( bool bShouldPresent)
        {
            void UGameViewportClient::Draw()
            {
                // 计算ViewFamily、View的各种属性
                ULocalPlayer::CalcSceneView();
                // 发送渲染命令
                FRendererModule::BeginRenderingViewFamily()
                {
                    World->SendAllEndOfFrameUpdates();
                    // 创建场景渲染器
                    FSceneRenderer* SceneRenderer = FSceneRenderer::CreateSceneRenderer(ViewFamily, ...);
                    // 向渲染线程发送绘制场景指令.
                    ENQUEUE_RENDER_COMMAND(FDrawSceneCommand)(
                    [SceneRenderer](FRHICommandListImmediate& RHICmdList)
                    {
                        RenderViewFamily_RenderThread(RHICmdList, SceneRenderer)
                        {
                            (......)
                            // 调用场景渲染器的绘制接口.
                            SceneRenderer->Render(RHICmdList);
                            (......)
                        }
                        FlushPendingDeleteRHIResources_RenderThread();
                    });
                }
}}}}

前面章节也提到,渲染线程使用的是SceneProxy和SceneInfo等对象,那么游戏的Actor组件是如何跟场景代理的数据联系起来的呢?又是如何更新数据的?

先弄清楚游戏组件向SceneProxy传递数据的机制,答案就藏在FScene::AddPrimitive

// Engine\Source\Runtime\Renderer\Private\RendererScene.cpp

void FScene::AddPrimitive(UPrimitiveComponent* Primitive)
{
    (......)
    
    // 创建图元的场景代理
    FPrimitiveSceneProxy* PrimitiveSceneProxy = Primitive->CreateSceneProxy();
    Primitive->SceneProxy = PrimitiveSceneProxy;
    if(!PrimitiveSceneProxy)
    {
        return;
    }

    // 创建图元场景代理的场景信息
    FPrimitiveSceneInfo* PrimitiveSceneInfo = new FPrimitiveSceneInfo(Primitive, this);
    PrimitiveSceneProxy->PrimitiveSceneInfo = PrimitiveSceneInfo;
    
    (......)

    FScene* Scene = this;

    ENQUEUE_RENDER_COMMAND(AddPrimitiveCommand)(
        [Params = MoveTemp(Params), Scene, PrimitiveSceneInfo, PreviousTransform = MoveTemp(PreviousTransform)](FRHICommandListImmediate& RHICmdList)
        {
            FPrimitiveSceneProxy* SceneProxy = Params.PrimitiveSceneProxy;
            
            (......)

            SceneProxy->CreateRenderThreadResources();
            // 在渲染线程中将SceneInfo加入到场景中.
            Scene->AddPrimitiveSceneInfo_RenderThread(PrimitiveSceneInfo, PreviousTransform);
        });
}

上面有个关键的一句Primitive->CreateSceneProxy()即是创建组件对应的PrimitiveSceneProxy,在PrimitiveSceneProxy的构造函数中,将组件的所有数据都拷贝了一份:

FPrimitiveSceneProxy::FPrimitiveSceneProxy(const UPrimitiveComponent* InComponent, FName InResourceName)
:
    CustomPrimitiveData(InComponent->GetCustomPrimitiveData())
,    TranslucencySortPriority(FMath::Clamp(InComponent->TranslucencySortPriority, SHRT_MIN, SHRT_MAX))
,    Mobility(InComponent->Mobility)
,    LightmapType(InComponent->LightmapType)
,    StatId()
,    DrawInGame(InComponent->IsVisible())
,    DrawInEditor(InComponent->GetVisibleFlag())
,    bReceivesDecals(InComponent->bReceivesDecals)

(......)

{
    (......)
}

拷贝数据之后,游戏线程修改的是PrimitiveComponent的数据,而渲染线程修改或访问的是PrimitiveSceneProxy的数据,彼此不干扰,避免了临界区和锁的同步,也保证了线程安全。不过这里还有疑问,那就是创建PrimitiveSceneProxy的时候会拷贝一份数据,但在创建完之后,PrimitiveComponent是如何向PrimitiveSceneProxy更新数据的呢?

原来是ActorComponent有几个标记,只要这几个标记被标记为true,便会在适当的时机调用更新接口,以便得到更新:

// Engine\Source\Runtime\Engine\Classes\Components\ActorComponent.h

class ENGINE_API UActorComponent : public UObject, public IInterface_AssetUserData
{
protected:
    // 以下接口分别更新对应的状态, 子类可以重写以实现自己的更新逻辑.
    virtual void DoDeferredRenderUpdates_Concurrent()
    {
        (......)
        
        if(bRenderStateDirty)
        {
            RecreateRenderState_Concurrent();
        }
        else
        {
            if(bRenderTransformDirty)
            {
                SendRenderTransform_Concurrent();
            }
            if(bRenderDynamicDataDirty)
            {
                SendRenderDynamicData_Concurrent();
            }
        }
    }
    virtual void CreateRenderState_Concurrent(FRegisterComponentContext* Context)
    {
        bRenderStateCreated = true;

        bRenderStateDirty = false;
        bRenderTransformDirty = false;
        bRenderDynamicDataDirty = false;
    }
    virtual void SendRenderTransform_Concurrent()
    {
        bRenderTransformDirty = false;
    }
    virtual void SendRenderDynamicData_Concurrent()
    {
        bRenderDynamicDataDirty = false;
    }
    
private:
    uint8 bRenderStateDirty:1; // 组件的渲染状态是否脏的
    uint8 bRenderTransformDirty:1; // 组件的变换矩阵是否脏的
    uint8 bRenderDynamicDataDirty:1; // 组件的渲染动态数据是否脏的
};

上面protected的接口就是用于刷新组件的数据到对应的SceneProxy,具体的组件子类可以重写它,以定制自己的更新逻辑,比如ULightComponent的变换矩阵更新逻辑如下:

// Engine\Source\Runtime\Engine\Private\Components\LightComponent.cpp

void ULightComponent::SendRenderTransform_Concurrent()
{
    // 将变换信息更新到场景.
    GetWorld()->Scene->UpdateLightTransform(this);
    Super::SendRenderTransform_Concurrent();
}

而场景的UpdateLightTransform会将组件的数据组装起来,并将数据发送到渲染线程执行:

// Engine\Source\Runtime\Renderer\Private\RendererScene.cpp

void FScene::UpdateLightTransform(ULightComponent* Light)
{
    if(Light->SceneProxy)
    {
        // 组装组件的数据到结构体(注意这里不能将Component的地址传到渲染线程,而是将所有要更新的数据拷贝一份)
        FUpdateLightTransformParameters Parameters;
        Parameters.LightToWorld = Light->GetComponentTransform().ToMatrixNoScale();
        Parameters.Position = Light->GetLightPosition();
        FScene* Scene = this;
        FLightSceneInfo* LightSceneInfo = Light->SceneProxy->GetLightSceneInfo();
        // 将数据发送到渲染线程执行.
        ENQUEUE_RENDER_COMMAND(UpdateLightTransform)(
            [Scene, LightSceneInfo, Parameters](FRHICommandListImmediate& RHICmdList)
            {
                FScopeCycleCounter Context(LightSceneInfo->Proxy->GetStatId());
                // 在渲染线程执行数据更新.
                Scene->UpdateLightTransform_RenderThread(LightSceneInfo, Parameters);
            });
    }
}

void FScene::UpdateLightTransform_RenderThread(FLightSceneInfo* LightSceneInfo, const FUpdateLightTransformParameters& Parameters)
{
    (......)

    // 更新变换矩阵.
    LightSceneInfo->Proxy->SetTransform(Parameters.LightToWorld, Parameters.Position);
        
    (......)
}

至此,组件如何向场景代理更新数据的逻辑终于理清了。

需要特别提醒的是,FScene、FSceneProxy等有些接口在游戏线程调用,而有些接口(一般带有_RenderThread的后缀)在渲染线程调用,切记不能跨线程调用,否则会产生竞争条件,甚至引发程序崩溃。

2.5.5 游戏线程和渲染线程的同步

前面也提到,游戏线程不可能领先于渲染线程超过一帧,否则游戏线程会等待渲染线程处理完。它们的同步机制涉及两个关键的概念:

// Engine\Source\Runtime\RenderCore\Public\RenderCommandFence.h

// 渲染命令栅栏
class RENDERCORE_API FRenderCommandFence
{
public:
    // 向渲染命令队列增加一个栅栏. bSyncToRHIAndGPU是否同步RHI和GPU交换Buffer, 否则只等待渲染线程.
    void BeginFence(bool bSyncToRHIAndGPU = false); 

    // 等待栅栏被执行. bProcessGameThreadTasks没有作用.
    void Wait(bool bProcessGameThreadTasks = false) const;

    // 是否完成了栅栏.
    bool IsFenceComplete() const;

private:
    mutable FGraphEventRef CompletionEvent; // 处理完成同步的事件
    ENamedThreads::Type TriggerThreadIndex; // 处理完之后需要触发的线程类型.
};

// Engine\Source\Runtime\Engine\Public\UnrealEngine.h
class FFrameEndSync
{
    FRenderCommandFence Fence[2]; // 渲染栅栏对.
    int32 EventIndex; // 当前事件索引
public:
    // 同步游戏线程和渲染线程. bAllowOneFrameThreadLag是否允许渲染线程一帧的延迟.
    void Sync( bool bAllowOneFrameThreadLag )
    {
        Fence[EventIndex].BeginFence(true); // 开启栅栏, 强制同步RHI和GPU交换链的.

        bool bEmptyGameThreadTasks = !FTaskGraphInterface::Get().IsThreadProcessingTasks(ENamedThreads::GameThread);
        
        // 保证游戏线程至少跑过一次任务.
        if (bEmptyGameThreadTasks)
        {
            FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
        }

        // 如果允许延迟, 交换事件索引.
        if( bAllowOneFrameThreadLag )
        {
            EventIndex = (EventIndex + 1) % 2;
        }

        (......)
        
        // 开启栅栏等待.
        Fence[EventIndex].Wait(bEmptyGameThreadTasks);
    }
};

FFrameEndSync的使用是在FEngineLoop::Tick中:

// Engine\Source\Runtime\Launch\Private\LaunchEngineLoop.cpp

void FEngineLoop::Tick()
{
    (......)
    
    // 在引擎循环的帧末尾添加游戏线程和渲染线程的同步事件.
    {
        static FFrameEndSync FrameEndSync; // 局部静态变量, 线程安全.
        static auto CVarAllowOneFrameThreadLag = IConsoleManager::Get().FindTConsoleVariableDataInt(TEXT("r.OneFrameThreadLag"));
        // 同步游戏和渲染线程, 是否允许一帧的延迟可由控制台命令控制. 默认是开启的.
        FrameEndSync.Sync( CVarAllowOneFrameThreadLag->GetValueOnGameThread() != 0 );
    }
    
    (......)
}

 

2.6 多线程渲染结语

并行计算架构已然成为现代引擎的标配,UE的多线程渲染是随着多核CPU和新一代图形API诞生而必然的产物。但就目前而言,渲染线程很多时候还是单条的(虽然可以借助TaskGraph部分地并行)。理想情况下,是多条渲染线程并行且不依赖地生成渲染命令,并且不需要主线程来驱动,任何线程都可作为工作线程(亦即没有UE的命名线程),任何线程都可发起计算任务,避免操作系统级别的功能线程。而这需要操作系统、图形API、计算机语言共同地不断演化才可达成。

最近发布的UE4.26已经在普及RDG,RDG可以自动裁剪、优化渲染Pass和资源,是提升引擎整体并行处理的一大利器。

这篇文章原本预计2个月左右完成,然而实际上花了3个多月,几乎耗尽了笔者的所有业余时间。原本还有很多技术章节需要添加,但篇幅和时间都超限了,只好作罢。希望此系列文章对学习UE的读者们有帮助,感谢关注和收藏。

 

特别说明

  • 感谢所有参考文献的作者,部分图片来自参考文献和网络,侵删。
  • 本系列文章为笔者原创,只发表在博客园上,欢迎分享本文链接,但未经同意,不允许转载
  • 系列文章,未完待续,完整目录请戳内容纲目
  • 系列文章,未完待续,完整目录请戳内容纲目
  • 系列文章,未完待续,完整目录请戳内容纲目

 

参考文献

posted @ 2021-01-25 21:38  0向往0  阅读(6582)  评论(12编辑  收藏  举报