岩棉厂家
免费服务热线

Free service

hotline

010-00000000
岩棉厂家
热门搜索:
行业资讯
当前位置:首页 > 行业资讯

分享游戏多线编程方法的定义及作用

发布时间:2021-01-20 09:13:06 阅读: 来源:岩棉厂家

本文是我在电子游戏开发过程中对多线编程开发方法的一点心得,目标是解释多线编程在高低水准下的工作原理,以及探讨能够使用这些技巧的通见情况。我甚至可能触及一些你可以自己试试的情况,以及某些对其进行调试的技巧。希望你阅读愉快并从中得到收获。

什么是多线程技术?

让我们先从什么是线程,以及使用更多线程的好处开始吧。

每个在现代计算机(例如你的PC、游戏主机或智能手机)上运行的程序都是一个过程。这些程序可能拥有子过程,但为了简便起见,我就不再赘述这些子过程了。这些过程可能含有大量与之相关的线程。这些线程负责执行运行程序的实际代码。线程可以独立运行代码但会共享内存。这允许它们在相同数据上执行操作,但执行的不同的计算。事实上它们共享的内存是其功能的强大双刃剑。

现代CPU通常是由数个核心组成。每个核心一次可运行一个线程(但超线程可以交错运行两个,但我在此不对其加以介绍了)。含有单个核心的计算机模拟多任务的方法就是让操作系统在线程之间随意切换。所以虽然单一线程具有执行性,多个线程却有可能让一切同时运行。这意味着如果你的程序想充分利用人铁CPU所拥有的计算资源,你就不会让线程的数量超过核心数量,因为你想在自己的线程执行之间进行切换。这种在执行线程之间的切换就是所谓的情境切换,虽然它的成本并不会过于昂贵,但为了实现理想运行速度,你仍然要尽量避免这种情况。

一个线程的主要部分是程序计数器以及其他寄存器、堆栈以及任何相关的本地数据存储。程序计数器会追踪当前在执行哪一部分代码。寄存器则追踪当前执行代码的值。堆栈则托管那些与寄存器不适应的值。本地数据是程序指定的,但通常会追踪该线程的局部数据。本地存储会通过查找表来完成,由操作系统来管理,让每个线程来查找指定索引以便找到数据存储之处。简而言之,这可以让你访问看似全局变量,但实际上是针对各个线程的特定变量。

鉴于这些考虑,多线编程就是你在程序中使用多个线程。通常情况下,使用这一方法可以比使用单个线程更快地执行程序。另一个通用方法是将你的UI同程序的其他元素相隔开,这样它就会一直具有响应性。其难度在于要用这种方法设计系统,以便它们能够以这些方式利用多个线程。

为什么需要多线编程?

简而言之,我们需要这种方法是为了最大化地利用可用资源。但真正的问题在于,“为什么我们现在需要使用它?”这与计算发展趋势有关。

自CPU问世以来,硬件制造商也不断增加计算机的运行速度。但是在不到十年之前,消费者产品达到了4GHz。这很大程度上与电迁移效应有关,因为CPU已经变得越来越小,其频率也随着释放更多热量而不断增长。虽然我们当前的消费者产品处理器已经接近于5GHz,但这是芯片设计师需要考虑的问题。这意味着其中一个关键创新是将更多CPU核心引进单一芯片。所以你不能再寄希望于游戏能够通过新硬件而运行得更快,而要在游戏设计上下功夫,让它在多核心上运行多个线程。

多线程计算要求游戏适应的首个领域之一就是微软Xbox 360和Playstation 3的发布,有两个相似但又不同的原因。360拥有三个采用超级线程技术(HTT)的核心,这意味着它可以合理地同行运行6个线程。而PS3则拥有一个HTT核心,7个协同处理元素(SPE),采用了一个称为Cell Broadband Engine的新结构。360拥有统一的内存,所有线程都可以访问所有内存。这就很容易部署多线程应用了,因为你无需担心谁会访问内存的问题。每个SPE都只有一个有限的内存,这意味着作为开发者你得谨慎设计你的算法。

此外,从纯运行表现的角度来看,游戏主机中的CPU要比传统桌面电脑更低。虽然它们的计时速度很相似,但它们采用的是更简单的芯片,没有大量类似于无序执行等高级技术。所以这就迫使开发者采用多线策略,以便让自己的游戏脱颖而出。

在桌面PC领域,多核CPU快速成为标准,但开发者却没有及时让自己的程序与时俱进,并利用额外核心。这很大程度上要归咎于两个因素。其一就是桌面领域有更广泛的硬件支持,所以他们倾向于以最低标准来设计游戏。这意味着他们想支持更旧,并且只含有一个核心的系统。其二就是多线程编程更困难,它需要采用不同的处理方法。再加上许多游戏团队一般会重用技术这一现实,为了利用多个核心,开发者就需要重写大量内容。此外,图像通常是游戏中的一大瓶颈,向GPU提交所有的绘制内容要受限于API的一系列惯例(通常是DirectX或OpenGL)。只有最新版本的API(发布于数年之前)有可能最大化的利用现代CPU中的可用核心。现在有了最新的次世代主机,开发者就没有理由不使用多线程游戏引擎来制作顶级游戏了。

下一步就是了解线程之间如何相互通话。这一点很重要,也正是多线编程为何如此具有魅力的原因。为简便起见,我将以基于X86-64结构来叙述,但根据我的经验这也适用于所有计算设备。

读取内存

让我们看看当一个线程读取内存时会发生什么情况。首先,处理器会请求来自特定存储地址的负载存储到一个本地寄存器。这样我们就可以在单次过程进行处理,我将避免讨论虚拟内存地址和TLB之后的细节,所以只需要将其视为一个实体存储地址即可。

之后就会产生多种数据高速缓存,以便查看这个地址是否存在。现代多核芯片含有3个层次的缓存是个极为普遍的现象。在每个缓存层次中,你会获得更多可用的存储器,还有更长的访问潜伏。这些滞后的缓存甚至可以被多个核心所共享。我们将遇到最糟糕的情况,即该地址并不存在任何缓存中,这就是所谓的缓存丢失。如果它存在于缓存中,就可以称为缓存命中,该地址的数据也会更快到达核心。

4960X-Core(from altdevblogaday)

所以在该地址到达计算机的主内存后,该位置的数据就开始自己返回处理器的漫长旅程了。注意在现实世界中的虚拟存储地址,数据位置可能实际上是在硬盘驱动器,这意味着我们必须在自己能够访问之前等待缓存定位我们的需求。在返回我们等待的核心时,每个缓存层次都要更新以便存储数据,这样未来的任何访问都会更为迅速。

因为在缓存之外的每段旅程都会很缓慢,单次读取就会涉及该地址所请求的数据。这个数据的大小与单次缓存线大小相当,通常是64或128字节。缓存通过将内存划分为对齐的缓存线并对其进行存储而运行,但其中的运行细节并非本文讨论的范畴。需要注意的是,在任何共享缓存中,如果某一线程的缓存引进了数据,那么其他任何线程也可能会需要读取该数据。尽管缓存布局与特定处理器类型有关(执行线程的核心有可能变化,除非你正确调整了其中的密切关系),这种优化类型可能难以真正执行。

memory hierarchy(from altdevblogaday)

写入内存

一个程序如果没有向内存写入什么东西,实际上就没毫无用处了,所以让我们简要概括一个线程写入内存时会发生什么情况。

这要从指定写入什么数据,以及写入哪个特定存储地址开始,其原理与之前的读取内存一样。核心将执行这种编写指令,这将由存储总线传送到缓存。缓存将调查总线以确认正在写入哪个地址,并据此更新它们的入口。只有在更新数据的缓存线被取代之后,更新数据最终才会到达主内存。所幸,执行编写指令的线程在转向下一个指令之前并不需要等待编写完成。

你需要记住的一件事就是在现代无序CPU(以及编译程序优化)中,你编写代码的顺序并不一定是其执行的顺序。虽然这种做法可以确保代码运行功能与你之前所指定的一样,但这并不适用于其他线程。这种做法是为了补偿读取(或写入)来自缓存数据的时间延迟。你可以引进存储器障碍(游戏邦注:或称为存储器围墙)向编译程序和处理器发处不要执行这一操作的信号。当我们拥有多个线程时这一点就会变得很重要,因为我们将需要协调它们的行动,并执行特定的操作步骤。还要注意的是,你能将存储器障碍与关键字活性混淆起来,后者在C/C++多线程方面不利于你完成这一操作。

当多线程写入同个数据位置时,最后一个执行者通常就是赢家(实际的行为可能要取决于基本硬件)。但线程是由操作系统所控制,要保证哪一个是赢家几乎是不可能的。所以你有多个线程写入同一个区域时就要极端谨慎。

所幸我们还有一些工具有助于我们完成操作,接下来我就来谈谈这种情况。

原子操作

最后,我们将接触线程之间的关键通话环节,这就是原子操作。正如我之前所言,处理多个线程在同个数据上的操作时,确保操作顺序几乎是不可能的。即使某个线程在另一者之前执行,该线程也可能被抢先并处下落后状态。这可能在任何时候发生且不受你的控制。除此之外,一个线程也可能延迟等待一个读取或写入操作(某个线程到达缓存,而另一个却没有)。所以你永远无法指望一个线程优先,或多个线程在没有同步能力的情况下以这种方式进行组织。原子操作担任了这个重要的角色。这些可以直接在CPU上执行,因为操作不可以中断(在特定约束条件以单个指令执行多个操作),所以它们将以一系列方法执行,无论是否存在其他线程或操作系统的干扰。

基本的原子操作就是比较和切换。它的作用顾名思义就是在与不同数据切换之前对比数据。这样你就知道自己在运行还没有变化的数据。

让我们看看使用一些伪代码所做变量例子。假设我们将比较与切换功能称为返回布尔数以指示它是否成功的CAS。

// our function signature

bool CAS(void* AddressToWrite, int CompareValue, int SwapValue);

static int x = 0;

local int y = x;

while (CAS(&x, y, y + 1) == false)

{

y = x; // fetch the new value

}假设这发生于可以被多个线程调用的函数,我们就必须保证任何其他线程从我们将它读到本地变量到增加它的这段时间中都可以改变X值。如果发生了这一情况,那么我们就必须读取新的值并再次尝试,记住我们可能会因为其他线程而落后。但是,我们最后都会成功,除非我们陷入了其他线程持续击中这部分代码等情况,而在正常程序中这种情况几乎不可能发生。

还是使用我们的比较与切换函数,执行一个简单的互斥锁。我们可以用一个变量作为锁值。之后我们可以尝试通过查看该值是否为0获取该锁,并将其设置为1,并以一种相似但相反的方法释放该锁。以下就是其中的一些伪代码。

Lock:

static int lock = 0;

while (CAS(&lock, 0, 1) == false);

Unlock:

// technically this should always succeed assuming

// we successfully locked it in the first place

while (CAS(&lock, 1, 0) == false);

七星彩大公鸡官方下载

三国挂机传奇破解版

澳洲幸运5计划人工稳版

234彩票下载手机版官网