多线程概述
在过去的 20 年里,计算机和游戏主机都配备了多核 CPU。随着时间的推移,核心数量不断增加,新一代主机拥有 8 核并支持超线程,而 PC 的核心数量也越来越多,例如一些 ARM 服务器在一个 CPU 中就达到了 80 个真实核心。与此同时,CPU 的单线程性能在相当长一段时间内相对停滞不前,在 CPU 的时钟速度方面达到了 4-5ghz 的 GHZ 屏障。这意味着如今的应用程序需要针对多核进行编程,才能真正充分利用 CPU 的全部功能。
多核 CPU 通常具有多个“独立”核心,每个核心本身都是一个完整的 CPU。每个核心都可以独立执行任意程序。如果 CPU 具有超线程/SMT,则 CPU 将不执行一个线程的程序指令,而是执行多个线程(通常为 2 个)。发生这种情况时,CPU 的内部资源(内存单元、数学单元、逻辑单元、缓存)将在这些多个线程之间共享。
CPU 上的不同核心各自独立工作,CPU 会同步来自一个核心的内存写入,以便其他核心也能看到,如果它检测到一个核心正在写入另一个核心也在写入或读取的相同内存位置。这种同步是有代价的,因此每当您编写多线程程序时,重要的是尽量避免多个线程写入同一内存,或者一个线程写入而其他线程读取。
为了正确同步操作,CPU 还具有可以同步多个核心的特定指令,例如原子指令。这些指令是用于在线程之间进行通信的同步原语的骨干。
如果您想了解有关 CPU 硬件细节以及它们如何映射到 Cpp 编程的更多信息,这个 CPPCon 演示文稿对此进行了很好的解释 1;
在游戏引擎中使用多线程的方式。
起初,游戏引擎完全是单线程的,根本不会使用多个核心。随着时间的推移,引擎被编程为使用越来越多的核心,采用与核心数量相对应的模式和架构。
多线程游戏引擎的第一种也是最经典的方式是创建多个线程,并让每个线程执行自己的任务。
例如,您有一个游戏线程,它运行所有游戏逻辑和 AI。然后您有一个渲染线程,它处理所有与渲染相关的代码,准备要绘制的对象并执行图形命令。
我们可以看到的例子是 Unreal Engine 2、3 和 4。Unreal Engine 4 提供了源代码,因此它可以成为一个很好的例子。Doom 3 (2004) 引擎在这篇文章中也清楚地解释了 2。
Unreal Engine 4 有一个游戏线程和一个渲染线程作为主要线程,然后还有一些其他线程用于助手、音频或加载等。Unreal Engine 中的游戏线程运行开发人员在蓝图和 Cpp 中编写的所有游戏逻辑,并且在每一帧结束时,它会将世界中对象的位置和状态与渲染线程同步,渲染线程将执行所有渲染逻辑并确保显示它们。
虽然这种方法非常流行且非常易于使用,但它的缺点是扩展性极差。您通常会看到虚幻引擎游戏在扩展到 4 个核心以上时会遇到困难,并且在游戏主机上,由于没有用工作填满 8 个核心,性能远低于应有的水平。这种模型的另一个问题是,如果其中一个线程的工作量比其他线程多,那么整个模拟将等待。在虚幻引擎 4 中,游戏线程和渲染线程在每一帧都同步,因此如果其中任何一个线程速度较慢,两者都会因同时运行而变慢。在 UE4 中大量使用蓝图和 AI 计算的游戏将使游戏线程在一个核心中忙于工作,而机器中的所有其他核心都未使用。
增强此架构的常用方法是将其更多地转移到 fork/join 方法中,您有一个主执行线程,在某些时候,部分工作在线程之间拆分。虚幻引擎对动画和物理执行此操作。虽然游戏线程负责引擎的整个游戏逻辑部分,但当它到达必须进行动画处理时,它会将要计算的动画拆分为小任务,并将这些任务分发到其他核心中的辅助线程。这样,虽然它仍然有一个主要的执行时间线,但在某些时候,它可以从未使用的核心获得额外的帮助。这提高了可扩展性,但仍然不够好,因为帧的其余部分仍然是单线程的。
主要从 PS4 这一代游戏主机开始,它们有 8 个非常弱的核心,架构已经演变为试图确保所有核心都在工作并做一些有用的事情。许多游戏引擎为此目的已转向基于任务的系统。在其中,您不会为一项任务分配 1 个线程,而是将您的工作分成小部分,然后让多个线程独立地处理这些部分,并在任务完成后合并结果。与拥有一个专用线程并让它将工作分发给助手的 fork-join 方法不同,您在很大程度上是在助手上完成所有操作。您的主要操作时间线创建为要执行的任务图,然后这些任务分布在各个核心中。一个任务只有在其所有前置任务都完成后才能开始。如果任务系统使用得当,它可以实现非常好的可扩展性,因为所有内容都会自动分配到可用的任意数量的核心。Doom Eternal 就是一个很好的例子,您可以看到它从 4 核 PC 平稳扩展到 16 核 PC。GDC 上关于它的精彩演讲包括 Naughty Dog 的“使用光纤并行化顽皮狗引擎” 3 和 2 个 Destiny 引擎演讲 4 5
实践。
自版本 11 以来,Cpp 在标准库中提供了许多可用于多线程处理的实用程序。入门级的是 std::thread
。这将包装操作系统中的线程。线程的创建成本很高,创建的线程数量超过您拥有的核心数量会因开销而降低性能。即使这样,使用 std::thread
创建类似于上面解释的专用线程的东西也很简单。详细信息请参见此处 6 伪代码
void GameLoop()
{
while(!finish)
{
SyncRenderThread();
PerformGameLogic();
SyncRenderThread();
}
}
void main(){
std::thread gamethread(GameLoop);
while(!finish)
{
SyncGameThread();
PerformRenderLogic();
SyncGameThread();
CopyGameThreadDataToRenderer();
}
}
在此处,mainloop 渲染,并且每帧都与 gamethread 同步。一旦两个线程都完成其工作,renderthread 会将 gamethread 数据复制到其内部结构中。在这样的设计中,游戏线程不能访问渲染器的内部结构,并且渲染器在游戏线程完成执行之前无法访问游戏线程的数据。这两个线程各自独立工作,仅在帧结束时共享。
使用 vkguide gpudriven 章节中的渲染器设计,可以以直接的方式实现类似这样的东西。渲染器不关心游戏逻辑,因此您可以随意设置游戏逻辑,并且在每帧的同步点,您可以创建/删除/移动渲染引擎的可渲染对象。
此设计仅可扩展到 2 个核心,因此我们需要找到一种方法来更多地拆分事物。让我们更深入地了解 PerformGameLogic()
,看看是否有我们可以做些什么来使其更具可扩展性。
伪代码
void PerformGameLogic(){
input->QueryPlayerInput();
player->UpdatePlayerCharacter();
for(AICharacter* AiChar : AICharacters)
{
AiChar->UpdateAI();
}
for(WorldObject* Obj : Objects)
{
Obj->Update();
}
for(Particle* Part : ParticleSystems)
{
Part->UpdateParticles();
}
for(AICharacter* AiChar : AICharacters)
{
AiChar->UpdateAnimation();
}
physicsSystem->UpdatePhysics();
}
在我们的主游戏循环中,我们有一个序列,其中包含每个游戏帧都需要执行的操作。我们需要查询输入、更新 AI、为对象设置动画……。所有这些都是大量工作,因此我们需要找到一些“独立”任务,我们可以安全地将其发送到其他线程。在查看代码库后,我们发现对每个 AiCharacter 执行的 UpdateAnimation() 仅访问该角色,因此如果我们将其发送到多个线程,它是安全的。
这里我们可以使用一些效果很好的东西,称为 ParallelFor。ParallelFor 是一种非常常见的多线程原语,几乎每个多线程库都实现了它。ParallelFor 会自动将 for 循环的每次迭代拆分到多个核心中。鉴于 UpdateAnimation 是在每个角色上完成的并且是独立的,我们可以在此处使用它。
Cpp17 添加了并行化的 std::algorithms,它们非常棒,但目前仅在 Visual Studio 中实现。如果您使用 VS,您可以试用它们,但如果您想要多平台,您将需要寻找替代方案。您可以在此处找到有关并行算法的更多信息 7,如果您想了解有关 std::algorithms 的一般信息,此 cppcon 演讲概述了 8
并行算法之一是 std::for_each()
,它是我们想要的并行 for。如果我们使用它,那么代码现在可以如下所示。
std::for_each(std::execution::par, //we need the execution::par for this foreach to execute parallel. It's on the <execution> header
AICharacters.begin(),
AICharacters.end(),
[](AICharacter* AiChar){ //cpp lambda that executes for each element in the AICharacters container.
AiChar->UpdateAnimation();
});
这样,我们的代码现在类似于虚幻引擎 4,因为它有一个游戏线程,该线程在一段时间内是独立的,但其中的一部分在核心之间并行化。您也可以在渲染器上对可以并行化的事物执行完全相同的操作。
但我们仍然需要更多的并行性,因为使用此模型,只有少量帧使用其他核心,因此我们可以尝试看看是否可以将其转换为任务系统。
作为任务系统的替代品,我们将使用 std::async
。std::async
创建一个轻量级线程,因此我们可以创建许多线程而不会出现太多问题,因为它们比创建 std::thread
便宜得多。对于中/小型生命周期的任务,它们可以很好地工作。请务必检查它们在您的平台上的运行方式,因为它们在 Visual Studio 中实现得非常好,但在 GCC/clang 中它们的实现可能不如那么好。与并行算法一样,您可以找到许多库来实现非常相似的东西。
对于一个运行良好的库,您可以尝试 Taskflow,它有一个 std::async
但更好的等效项,以及更多功能 9
伪代码
auto player_tasks = std::async(std::launch::async,
[](){
input->QueryPlayerInput();
player->UpdatePlayerCharacter();
});
auto ai_task = std::async(std::launch::async,
[](){
//we cant parallelize all the AIs together as they communicate with each other, so no parallel for here.
for(AICharacter* AiChar : AICharacters)
{
AiChar->UpdateAI();
}
});
//lets wait until both player and AI asyncs are finished before continuing
player_tasks.wait();
ai_task.wait();
//world objects cant be updated in parallel as they affect each other too
for(WorldObject* Obj : Objects)
{
Obj->Update();
}
// particles is standalone AND we can update each particle on its own, so we can combine async with parallel for
auto particles_task = std::async(std::launch::async,
[](){
std::for_each(std::execution::par,
ParticleSystems.begin(),
ParticleSystems.end(),
[](Particle* Part){
Part->UpdateParticles();
});
});
// same with animation
auto animation_task = std::async(std::launch::async,
[](){
std::for_each(std::execution::par,
AICharacters.begin(),
AICharacters.end(),
[](AICharacter* AiChar){
AiChar->UpdateAnimation();
});
});
//physics can also be updated on its own
auto physics_task = std::async(std::launch::async,
[](){
physicsSystem->UpdatePhysics();
});
//synchronize the 3 tasks
particles_task.wait();
animation_task.wait();
physics_task.wait();
这样,我们定义了一个任务图及其执行依赖项。我们设法在最后并行运行 3 个任务,即使其中 2 个任务执行并行 for,因此此处的线程处理远优于之前的模型。虽然我们在 ObjectUpdate 部分仍然有一个非常令人遗憾的完全单线程部分,但至少其他事物都得到了合理的并行化。
帧的某些部分会成为任务的瓶颈并且必须在一个核心中运行是很常见的,因此大多数引擎所做的是它们仍然有游戏线程和渲染线程,但每个线程都有自己的并行任务。希望有足够的任务可以独立运行以填满所有核心。
虽然此处的示例使用 async,但您真的希望使用更好的库来执行此操作。您也很可能希望更好地控制执行,而不是启动许多 async 和并行 for。在此处使用 Taskflow 将非常有效。
识别任务。
虽然我们一直在评论诸如 UpdatePhysics() 之类的东西可以重叠运行,但在实践中事情永远不会如此简单。识别哪些任务可以与其他任务同时运行在项目中非常困难,并且在 Cpp 中,无法验证它。如果您使用 rust,则由于 borrowcheck 模型,这会自动实现。
了解给定任务可以与另一个任务并行运行的一种常见方法是“打包”任务可能需要的数据,并确保任务永远不会访问其外部的任何其他数据。在游戏线程和渲染线程的示例中,我们有一个 Renderer 类,其中存储了所有渲染数据,并且游戏线程永远不会触及它。我们也只允许渲染线程在帧中非常特定的点访问游戏线程数据,我们知道游戏线程不在其上工作。
对于大多数任务,在某些情况下,我们无法保证一次只有一个任务在处理它,因此我们需要一种在同时在多个核心上运行的不同任务之间同步数据的方法。
同步数据。
有很多方法可以同步任务处理的数据。通常,您真的希望尽可能减少共享数据,因为它是一个 bug 来源,并且如果同步不当,可能会非常糟糕。有一些效果很好的模式可以使用。
一种常见的方法是让任务将消息发布到队列中,然后另一个任务可以在其他点读取它。假设我们上面的粒子需要删除,但是粒子存储在数组中,并且当其他线程正在处理同一数组的其他粒子时从该数组中删除粒子是保证程序崩溃的一种方式。我们可以将粒子 ID 插入到共享的同步队列中,并在所有粒子完成其工作后,从该队列中删除粒子。
我们不打算查看队列的实现细节。假设它是一个神奇的队列,您可以安全地从多个核心一次性将元素推入其中。周围有很多这样的队列。我为此目的大量使用了 Moodycamel Concurrent Queue 10
伪代码
parallel_queue<Particle*> deletion_queue;
// particles is standalone AND we can update each particle on its own, so we can combine async with parallel for
auto particles_task = std::async(std::launch::async,
[](){
std::for_each(std::execution::par,
ParticleSystems.begin(),
ParticleSystems.end(),
[](Particle* Part){
Part->UpdateParticles();
if(Part->IsDead)
{
deletion_queue.push(Part);
}
});
});
//the other tasks.
particles_task.wait();
//after waiting for the particles task, there is nothing else that touches the particles, so it's safe to apply the deletions
for(Particle* Part : deletion_queue)
{
Part->Remove();
}
这是多线程代码中非常常见的模式,并且非常有用,但与所有事物一样,它也有其缺点。由于所有数据重复,拥有这样的队列会增加应用程序的内存使用量,并且将数据插入到这些线程安全队列中并非免费。
原子操作
用于在核心之间传递数据的核心同步原语是原子操作,而 Cpp 11 之后的版本已将它们集成到 STL 中。原子操作是一组特定的指令,即使多个核心同时执行操作,也能保证良好地工作(如指定)。诸如并行队列和互斥锁之类的东西都是使用它们实现的。原子操作通常比正常操作昂贵得多,因此您不能简单地将应用程序中的每个变量都设为原子变量,因为这会严重损害性能。它们最常用于聚合来自多个线程的数据或进行一些轻量级同步。
作为原子操作是什么的示例,我们将继续上面的粒子系统示例,我们将使用原子加法来添加所有粒子的顶点数量,而无需拆分并行 for。
对于该用途,我们将使用 std::atomic<int>
。您可以拥有多种基本类型的原子变量,例如整数和浮点数。
//create variable to hold how many billboards we have
std::atomic<int> vertex{0};
std::for_each(std::execution::par,
ParticleSystems.begin(),
ParticleSystems.end(),
[](Particle* Part){
Part->UpdateParticles();
if(Part->IsDead)
{
deletion_queue.push(Part);
}
else{
//add the number of vertices in this system
vertexAmount += Part->vertexCount;
}
});
在此示例中,如果我们使用普通整数,则计数很可能不正确,因为每个线程将添加不同的值,并且一个线程很可能覆盖另一个线程,但在此处,我们使用 atomic<int>
,在这样的情况下,它保证具有正确的值。原子数还实现了更抽象的操作,例如比较和交换以及提取和添加,这些操作可用于实现同步数据结构,但是正确地执行此操作是专家的事情,因为这是您可以尝试自己实现的最困难的事情之一。
如果使用不当,原子变量会以非常微妙的方式出错,具体取决于 CPU 的硬件。调试此类错误可能很难做到。
关于使用原子操作实现同步数据结构以及它实际上有多么困难的精彩演示文稿是来自 Cppcon 的本次演讲。 11。有关 std atomic 如何工作的更深入的解释,请参见另一篇演讲。 12
为了真正同步数据结构或多线程访问某些内容,最好使用互斥锁。
互斥锁
互斥锁是一种更高级别的原语,用于控制线程上的执行流。您可以使用它们来确保给定的操作一次仅由一个线程执行。API 详细信息可以在此处找到 13
互斥锁是使用原子操作实现的,但如果它们长时间阻止线程,它们可以要求操作系统将该线程放在后台,并让不同的线程执行。这可能很昂贵,因此互斥锁最好用于您知道不会阻塞太多的操作。
继续该示例,我们将上面的并行队列实现为普通向量,但受互斥锁保护。
//create variable to hold how many billboards we have
std::vector<Particle*> deletion_list;
//declare a mutex for the synchronization.
std::mutex deletion_mutex;
std::for_each(std::execution::par,
ParticleSystems.begin(),
ParticleSystems.end(),
[](Particle* Part){
Part->UpdateParticles();
if(Part->IsDead)
{
//lock the mutex. If the mutex is already locked, the current thread will wait until it unlocks
deletion_mutex.lock();
//only one thread at a time will execute this line
deletion_list.push(Part);
//don't forget to unlock the mutex!!!!!
deletion_mutex.unlock();
}
});
Cpp 互斥锁具有 Lock 和 Unlock 函数,并且还有一个 try_lock 函数,如果互斥锁已锁定且无法再次锁定,则该函数返回 false。
一次只能有一个线程可以锁定互斥锁。如果第二个线程尝试锁定互斥锁,则该第二个线程必须等待直到互斥锁解锁。这可以用于定义保证一次仅为一个线程执行的代码段。
每当使用互斥锁时,非常重要的是,它们的锁定时间要短,并且尽快解锁。如果您正在使用任务系统,则必须在任务完成之前解锁所有互斥锁,因为如果任务在未解锁互斥锁的情况下完成,它将阻止一切。
互斥锁有一个很大的问题,即如果使用不当,程序可能会完全阻塞自身。这被称为死锁,并且在看起来不错的代码中很容易出现死锁,但在某些情况下,2 个线程可能会相互锁定。
有一些方法可以避免死锁,最直接的方法之一是,每次使用互斥锁时,您都不应再获取另一个互斥锁,除非您知道自己在做什么,并且每次锁定互斥锁时,都应尽快解锁。
由于手动调用 lock/unlock 很容易出错,尤其是在函数返回或存在异常的情况下,Cpp STL 具有 std::lock_guard
,它可以自动执行此操作。使用它,上面的代码将如下所示
if(Part->IsDead)
{
//the mutex is locked in the constructor of the lock_guard
std::lock_guard<std::mutex> lock(deletion_mutex);
//only one thread at a time will execute this line
deletion_list.push(Part);
//automatically unlocks
}
Vulkan 多线程处理
我们已经解释了可以在引擎中并行处理事物的方法,但是 GPU 调用本身呢?如果我们谈论的是 OpenGL,那么您可以做的并不多。在 OpenGL 或其他较旧的 API 中,只能从一个线程执行 API 调用。甚至不是一次一个线程,而是一个特定的线程。对于这些 API,渲染器通常会创建一个专用的 OpenGL/API 线程,该线程将执行其他线程发送给它的命令。您可以在 Doom3 引擎和 UE4 引擎上看到这一点。
这些较旧的 API 有些 API 部分可以在非常特定的情况下从多个线程使用,例如 OpenGL 中来自其他线程的纹理加载,但考虑到它们的性质是 API 的扩展,而 API 从未设计为考虑多线程,因此它们的支持可能非常不稳定。
在更新的 API(如 Vulkan 和 DX12)上,我们有一个旨在从多个核心使用的设计。在 Vulkan 的情况下,规范定义了一些关于哪些资源必须受到保护且不能同时使用的规则。我们将看到一些可以在 Vulkan 中进行多线程处理的典型示例及其规则。
对于编译管线,vkCreateShaderModule 和 vkCreateGraphicsPipeline 都允许从多个线程同时调用。多线程着色器编译的常用方法是拥有一个专用于它的后台线程,该线程不断地查看并行队列以接收编译请求,并将编译后的管线放入另一个队列,然后主渲染线程将连接到该队列以进行模拟。如果您想拥有一个没有太多卡顿的引擎,那么这样做非常重要。编译着色器管线可能需要很长时间,因此如果您必须在加载屏幕之外的运行时编译管线,则需要为您的游戏良好运行而实现这种多线程异步编译方案。
对于描述符集构建,也可以从多个线程完成,只要用于分配描述符集的 DescriptorPool 的访问是同步的,并且不同时从多个线程使用。一种非常常见的方法是保留多个 DescriptorPool,并且每当线程需要分配描述符时,它都会“抓取”多个可用描述符池之一,使用它,然后返回它,以便其他线程可以使用同一个池。
命令提交和记录也是完全并行的,但有一些规则。任何时候只有一个线程可以提交到给定的队列。如果您希望多个线程执行 VkQueueSubmit,那么您需要创建多个队列。由于在某些设备中队列的数量可能低至 1 个,因此引擎为此倾向于执行类似于管线编译线程或 OpenGL api 调用线程的操作,并拥有一个专用于仅执行 VkQueueSubmit 的线程。由于 VkQueueSubmit 是一个非常昂贵的操作,因此这可以带来非常好的加速,因为执行该调用的时间是在第二个线程中完成的,并且引擎的主要逻辑不必停止。
当您记录命令缓冲区时,它们的命令池一次只能从一个线程使用。虽然您可以从命令池创建多个命令缓冲区,但您不能从多个线程填充这些命令。如果您想从多个线程记录命令缓冲区,那么您将需要更多命令池,每个线程一个。
Vulkan 命令缓冲区具有用于主命令缓冲区和辅助命令缓冲区的系统。主缓冲区是打开和关闭 RenderPasses 的缓冲区,可以直接提交到队列。辅助命令缓冲区用作“子”命令缓冲区,作为主缓冲区的一部分执行。它们的主要目的是多线程处理。辅助命令缓冲区不能单独提交到队列中。
假设您有一个 ForwardPass 渲染通道。在制作将要提交的主命令缓冲区之前,您需要确保获取 3 个命令池,从中分配 3 个命令缓冲区,然后将它们发送到 3 个工作线程,以分别记录三分之一的转发通道命令。一旦 3 个工作线程完成其工作,您将拥有 3 个辅助命令缓冲区,每个缓冲区都记录了 ForwardPass 渲染通道的三分之一。然后,您可以完成记录主命令缓冲区,该缓冲区将在其渲染通道上执行这 3 个子通道。
伪代码
VkCommandBuffer primaryBuffer = allocate_buffer( main_command_pool );
vkCmdBegin(primaryBuffer, ... );
VkRenderPassBeginInfo renderPassBeginInfo = init_renderpass(forward_pass);
//begin render pass from the main execution
vkCmdBeginRenderPass(primaryBuffer, forward_pass);
//when allocating secondary commands, we need to fill inheritance info struct, to tell the commands what renderpass is their parent.
VkCommandBufferInheritanceInfo inheritanceInfo = init_inheritance_info(forward_pass);
//we can now record the secondary commands
std::array<VkCommandBuffer, 3> subcommands;
//create 3 parallel tasks to each render a section
parallel_for(3,[](int i)
{
//secondary commands have to be created with the inheritance info that links to renderpass
subcommands[i] = allocate_buffer( worker_command_pools[i],inheritanceInfo );
build_scene_section(i, subcommands[i]);
});
//now that the workers have finished writing the commands, we can add their contents to the main command buffer
vkCmdExecuteCommands(primaryBuffer, subcommands.size(), subcommands.data());
//finish the buffer
vkCmdEndRenderPass(primaryBuffer);
vkEndCommandBuffer(primaryBuffer);
这种同步 Vulkan 子命令及其资源的方案可能很难正确实现,并且 Vulkan 命令编码非常非常快,因此您在此处没有进行太多优化。一些引擎实现了它们自己的命令缓冲区抽象,这种抽象更容易从多个线程处理,然后记录线程将非常快速地将这些抽象命令转换为 Vulkan。
由于 Vulkan 命令记录速度如此之快,因此从多个线程记录不会带来很大的优化。但是您的渲染器不仅仅是在深度循环中记录命令,您还必须做更多的工作。通过跨多个线程拆分命令记录,您可以总体上更好地对渲染器内部进行多线程处理。Doom eternal 就是以这样做而闻名的。
数据上传是另一个经常进行多线程处理的部分。在此处,您有一个专用的 IO 线程,该线程会将资源加载到磁盘,并且所述 IO 线程将拥有自己的队列和命令分配器,最好是传输队列。这样,就可以以与主帧循环完全分离的速度上传资源,因此如果上传一组大型纹理需要半秒钟,您也不会遇到卡顿。为此,您需要创建传输或异步计算队列(如果可用),并将该队列专用于加载器线程。一旦您拥有了它,它就类似于在管线编译器线程上评论的内容,并且您有一个 IO 线程,该线程通过并行队列与主模拟循环通信,以异步方式上传数据。一旦传输已上传,并检查到它已使用 Fence 完成,则 IO 线程可以将信息发送到主循环,然后引擎可以将新纹理或模型连接到渲染器。
链接
- [1] CppCon 2016,“想要快速 C++?了解您的硬件!”
- [2] Fabien Sanglard Doom 3 引擎概述
- [3] GDC 使用光纤并行化顽皮狗引擎
- [4] GDC 多线程化整个命运引擎
- [5] GDC 命运的多线程渲染架构
- [6] Cpp 参考 std::thread
- [7] MSVC 博客,使用 C++17 并行算法以获得更好的性能
- [8] CppCon 2018,“一小时内掌握 105 个 STL 算法”
- [9] Github taskflow
- [10] Github 并行队列
- [11] CppCon 2014,“无锁编程(或,玩弄剃刀片),第一部分”
- [12] CppCon 2017,“C++ 原子操作,从基础到高级。它们真正做了什么?”
- [13] Cpp 参考 std::mutex