剖析虚幻渲染体系(18)- 操作系统(多处理器系统)

18.12 多处理器系统

电子(或光学)组件之间的所有通信最终归结为在它们之间发送定义良好的比特串,不同之处在于所涉及的时间尺度、距离尺度和逻辑组织。一个极端是共享内存多处理器,其中大约有两到1000个CPU通过共享内存进行通信。在这个模型中,每个CPU都有对整个物理内存的平等访问权,并且可以使用LOAD和STORE指令读取和写入单个字,访问一个存储字通常需要1-10纳秒。正如我们将看到的,现在通常在一个CPU芯片上放置多个处理核心,这些核心共享对主存储器的访问(有时甚至共享缓存)。换而言之,共享内存多计算机的模型可以使用物理上分离的CPU、单个CPU上的多个内核或以上两者的组合来实现。虽然下图(a)所示的这个模型听起来很简单,但实际并非如此,通常需要在幕后传递大量信息。

(a) 共享内存的多处理器。(b) 通过消息传递的多计算机。(c) 广域分布式系统。

接下来是上图(b)的系统,其中CPU存储器对通过高速互连连接,这种系统称为消息传递多计算机。每个内存都是单个CPU的本地内存,只能由该CPU访问。CPU通过互连发送多字消息进行通信。有了良好的互连,短消息可以在10–50微秒内发送,但仍比图8-1(a)中的内存访问时间长得多。此设计中没有共享全局内存。多计算机(即消息传递系统)比(共享内存)多处理器更容易构建,但它们更难编程。因此,每种类型都有自己的粉丝。

第三种模型如上图(c)所示,通过广域网(如互联网)连接完整的计算机系统,形成分布式系统。每一个都有自己的内存,系统通过消息传递进行通信。(b)和(c)之间唯一真正的区别是,在后者中,使用的是完整的计算机,消息时间通常为10–100毫秒。这种长延迟迫使这些松散耦合系统以不同于(b)中紧密耦合系统的方式使用。这三种类型的系统在延迟方面相差大约三个数量级,这就是一天和三年之间的差异。

共享内存多处理器(或此后仅为多处理器)是一种计算机系统,其中两个或多个CPU共享对公共RAM的完全访问。在任何CPU上运行的程序都会看到一个正常的(通常是分页的)虚拟地址空间。这个系统唯一不寻常的特性是,CPU可以将一些值写入内存字,然后读回该字并获得不同的值(因为另一个CPU已经更改了它)。当组织正确时,此属性构成处理器间通信的基础:一个CPU将一些数据写入内存,另一个CPU读取数据。

在大多数情况下,多处理器操作系统是正常的操作系统,它们处理系统调用、进行内存管理、提供文件系统和管理I/O设备。然而,在某些领域,它们具有独特的特点,包括进程同步、资源管理和调度。

18.12.1 多处理器硬件

尽管所有多处理器都具有每个CPU都可以寻址所有内存的特性,但有些多处理器还具有每个内存字都可以像其他内存字一样快地读取的附加特性。这些机器被称为UMA(Uniform Memory Access,统一内存访问)多处理器。相反,NUMA(非统一内存访问)多处理器没有此属性。

18.12.1.1 基于总线架构的UMA多处理器

最简单的多处理器基于单个总线,如下图(a)所示,两个或更多CPU和一个或更多内存模块都使用相同的总线进行通信。当CPU想要读取一个内存字时,它首先检查总线是否繁忙。如果总线空闲,CPU将它想要的字的地址放在总线上,断言一些控制信号,并等待直到存储器将想要的字放在总线。

如果CPU想读或写内存时总线正忙,那么CPU只需等待直到总线空闲,这正是问题所在。使用两个或三个CPU,总线的争用将是可管理的,如果是32或64,那将是难以忍受的。系统将完全受到总线带宽的限制,大多数CPU将在大部分时间处于空闲状态。

三种基于总线的多处理器。(a) 没有缓存。(b) 使用缓存。(c) 有缓存和私有内存。

解决方案是向每个CPU添加一个缓存,如上图(b)所示。高速缓存可以位于CPU芯片内部、CPU芯片旁边、处理器板上,或者这三者的组合。由于现在可以从局部缓存中满足许多读取,因此总线流量将大大减少,系统可以支持更多的CPU。通常,缓存不是基于单个字,而是基于32或64字节块。当一个字被引用时,它的整个块(称为缓存行)将被提取到接触它的CPU的缓存中。

每个缓存块被标记为只读(在这种情况下,它可以同时存在于多个缓存中)或读写(在这种情形下,它可能不存在于任何其他缓存中)。如果CPU试图写入一个或多个远程高速缓存中的字,总线硬件将检测到该写入,并在总线上发出信号,通知所有其他高速缓存该写入。如果其他缓存具有“干净”副本,即内存中的内容的精确副本,则它们可以丢弃副本,并让写入程序在修改缓存块之前从内存中获取缓存块。如果某个其他缓存具有“无效”(即已修改)副本,则必须先将其写回内存,然后才能继续写入,或通过总线将其直接传输到写入器。这组规则称为缓存一致性协议(cache-coherence protocol),是众多规则之一。

另一种可能性是上图(c)的设计,其中每个CPU不仅有一个高速缓存,而且还有一个通过专用(专用)总线访问的局部专用内存。为了最佳地使用此配置,编译器应该将所有程序文本、字符串、常量和其他只读数据、堆栈和局部变量放在私有内存中。然后,共享内存仅用于可写共享变量。在大多数情况下,这种谨慎的布局将大大减少总线流量,但它确实需要编译器的积极配合。

18.12.1.2 使用交叉开关的UMA多处理器

即使有最好的缓存,使用单一总线也会将UMA多处理器的大小限制在大约16或32个CPU。除此之外,还需要一种不同类型的互连网络。将n个CPU连接到k个存储器的最简单电路是交叉开关(crossbar switch),如下图所示。交叉开关在电话交换交换机中已经使用了几十年,以任意方式将一组输入线连接到一组输出线。

在水平(输入)线和垂直(输出)线的每个交点处都是交叉点(crosspoint),交叉点是一个小的电子开关,可以根据水平线和垂直线是否连接而电动打开或关闭。在下图(a)中,我们看到三个交叉点同时闭合,允许(CPU、内存)对(010000)、(101101)和(110010)同时连接,还有许多其他的组合。事实上,组合的数量等于8路可以安全放置在棋盘上的不同方式的数量。

(a) 8×8交叉开关。(b) 打开的交叉点。(c) 闭合的交叉点。

交叉开关的一个最好的特性是它是一个非阻塞网络,意味着没有CPU因为某些交叉点或线路已被占用而被拒绝连接(假设内存模块本身可用),并非所有互连都具有这种优良特性。此外,不需要提前规划,即使已经设置了七个任意连接,也始终可以将剩余的CPU连接到剩余的内存。

当然,如果两个CPU想要同时访问同一个模块,那么争用内存仍然存在。然而,通过将内存划分为n个单元,与上上图的模型相比,争用减少了n倍。

交叉开关最糟糕的特性之一是交叉点的数量随着n2n2而增加,例如1000个CPU和1000个内存的系统需要100万个交叉点,如此大的交叉开关不可行。然而,对于中型系统,交叉设计是可行的。

18.12.1.3 使用多级交换网络的UMA多处理器

一种完全不同的多处理器设计基于下图(a)所示的普通2×2,此开关有两个输入和两个输出,到达任一输入行的消息可以切换到任一输出行。出于我们的目的,消息最多包含四个部分,如下图(b)所示。Module(模块)字段指示要使用的内存,地址指定一个Module内的地址,操作码提供操作,如READ或WRITE。最后,可选的Value字段可能包含一个操作数,例如要写入WRITE的32位字。开关检查模块字段,并使用该字段确定消息应在X或Y上发送。

(a) 具有两条输入线a和B以及两条输出线X和Y的2×2开关。(B)消息格式。

18.12.1.4 NUMA多处理器

单总线UMA多处理器通常限制在不超过几十个CPU,交叉或交换多处理器需要大量(昂贵)硬件,并且没有那么大。要获得超过100个CPU,必须付出一些代价,通常,所有内存模块都有相同的访问时间,这种让步导致了NUMA多处理器的思想,如上所述。与UMA类似,它们在所有CPU上提供单一地址空间,但与UMA机器不同,访问本地内存模块比访问远程内存模块更快。因此,所有的UMA程序都将在NUMA机器上运行而无需更改,但性能将比在UMA机器上更差。

NUMA机器有三个关键特征,区别于其他多处理器:

1、所有CPU都有一个可见的地址空间。

2、通过LOAD和STORE指令访问远程存储器。

3、访问远程内存比访问本地内存慢。

当对远程内存的访问时间没有隐藏(因为没有缓存)时,系统称为NC-NUMA(非缓存一致NUMA)。当缓存一致时,系统称为CC-NUMA(缓存一致NUMA)

构建大型CC-NUMA多处理器的一种流行方法是基于目录的多处理器。其想法是维护一个数据库,告诉每个缓存行的位置及其状态。当引用缓存行时,会查询数据库,以找出它的位置以及它是干净的还是脏的。由于该数据库在每一条涉及内存的指令上都会被查询,因此它必须保存在速度极快的专用硬件中,该硬件可以在总线周期的一小部分内做出响应。

为了使基于目录的多处理器的思想更加具体,让我们考虑一个简单的(假设的)示例,一个256节点系统,每个节点由一个CPU和16MB RAM组成,通过本地总线连接到CPU。总内存为232232字节,分为226226个缓存行,每个缓存行64字节。内存在节点之间静态分配,节点0中为0–16M,节点1中为16M–32M,等等。节点通过互连网络连接,如下图(a)所示。每个节点还保存包含其224224字节存储器的218个64字节高速缓存行的目录条目。目前,我们假设一行最多可以保存在一个缓存中。

(a) 基于256节点目录的多处理器。(b) 将32位内存地址划分为字段。(c) 节点36处的目录。

18.12.1.5 多内核(Multicore Chips)

随着芯片制造技术的进步,晶体管越来越小,越来越多的晶体管可以放在芯片上,这种规律被称为摩尔定律,是英特尔联合创始人戈登·摩尔(Gordon Moore)第一次注意到的。1974年,Intel 8080包含2000多个晶体管,而Xeon Nehalem EX CPU拥有20多亿个晶体管。

一个显而易见的问题是:这些晶体管的作用是什么?一个选项是向芯片添加兆字节的缓存,具有4个32MB片上缓存的芯片很常见,但在某些时候,增加缓存大小可能会使命中率仅从99%提高到99.5%,并不会大幅提高应用程序的性能

另一种选择是将两个或多个完整的CPU(通常称为核心)放在同一芯片上(从技术上讲,放在相同的芯片上)。双核、四核和八核芯片已经很常见,甚至可以购买数百核的芯片。毫无疑问,更多的核心正在蓬勃发展。缓存仍然至关重要,遍布整个芯片,例如,Intel Xeon 2651有12个物理超线程内核,提供24个虚拟内核。12个物理核中的每一个具有32KB的L1指令缓存和32KB的L2数据缓存,每个都有256KB的二级缓存,12个内核共享30MB的L3缓存。

虽然CPU可能共享或不共享缓存,但它们始终共享主内存,并且在每个内存字都有唯一值的意义上,此内存是一致的。特殊的硬件电路确保,如果一个字存在于两个或多个高速缓存中,并且其中一个CPU修改了该字,则该字将自动从所有高速缓存中原子化删除,以保持一致性。这个过程称为窥探(snooping)

这种设计的结果是多核芯片只是非常小的多处理器。事实上,多核芯片有时被称为CMP(Chip MultiProcessors,芯片多处理器)。从软件的角度来看,CMP与基于总线的多处理器或使用交换网络的多处理器并没有太大区别。然而,依然存在一些差异。首先,在基于总线的多处理器上,每个CPU都有自己的缓存,常为AMD使用。共享缓存设计被英特尔在其许多处理器中使用,在其他多处理器中不存在。共享的二级或三级缓存可能会影响性能。如果一个内核需要大量的缓存内存,而另一个则不需要,那么这种设计可以让缓存占用者获取它需要的任何东西。另一方面,共享缓存也使得贪婪的内核有可能伤害其他内核。

CMP不同于其较大的同类的一个领域是容错。由于CPU之间的连接如此紧密,共享组件中的故障可能会同时导致多个CPU性能损耗,这在传统的多处理器中不太可能发生。

除了所有核都相同的对称多核芯片之外,多核芯片的另一个常见类别是片上系统(System On a Chip,SoC)。这些芯片有一个或多个主CPU,但也有专用核心,如视频和音频解码器、密码处理器、网络接口等,从而在芯片上形成完整的计算机系统。

18.12.1.6 多核芯片(Manycore Chips)

多核(Multicore)只是指“不止一个核”,但当核的数量远远超出手指计数的范围时,我们使用另一个名称。多核芯片(Manycore)是包含数十、数百甚至数千核的多核芯片。虽然Multicore成为Manycore并没有硬阈值,但一个简单的区别是,如果你不再在乎失去一两个核,你可能会拥有多核。

像Intel的Xeon Phi这样的加速器插件卡拥有超过60个x86内核,其他供应商已经用不同种类的核心突破了100核心的障碍,一千个通用核可能正在研制,很难想象如何处理一千个内核,更不用说如何对它们进行编程了。

大量内核的另一个问题是,保持其缓存一致性所需的机器变得非常复杂和昂贵。许多工程师担心缓存一致性可能无法扩展到数百个内核。有些人甚至主张我们应该完全放弃它。他们担心,硬件中一致性协议的成本将非常高,以至于所有这些闪亮的新内核都不会对性能有太大帮助,因为处理器太忙了,无法将缓存保持在一致状态。更糟糕的是,它需要在(快速)目录上花费太多的内存才能做到这一点。这就是所谓的相干墙(coherency wall)

例如,考虑我们上面讨论的基于目录的缓存一致性解决方案。如果每个目录条目都包含一个位向量来指示哪些内核包含特定的缓存行,那么具有1024个内核的CPU的目录条目将至少128字节长。由于缓存行本身很少大于128字节,导致目录条目大于它跟踪的缓存行的尴尬情况。可能不是我们想要的。

一些工程师认为,唯一能够扩展到大量处理器的编程模型是采用消息传递和分布式存储器的编程模型,也是我们在未来多核芯片中应该期待的。像Intel的48核SCC这样的实验处理器已经降低了缓存一致性,并提供了更快的消息传递的硬件支持。另一方面,其他处理器即使在大的内核计数下也能提供一致性。混合模式也是可能的,例如,一个1024核芯片可以被划分为64个岛(island),每个岛有16个缓存一致性核,同时放弃岛之间的缓存一致性。

数以千计的核已经不再那么特别了。今天最常见的许多核心,图形处理单元,几乎可以在任何没有嵌入式和有监视器的计算机系统中找到。GPU是一个具有专用内存和数千个小内核的处理器,与通用处理器相比,GPU在执行计算的电路上花费了更多的晶体管预算,而在缓存和控制逻辑上花费的更少。它们非常适合并行进行许多小计算,比如在图形应用程序中渲染多边形。它们不擅长连续任务,也很难编程。虽然GPU对操作系统很有用(例如,加密或处理网络流量),但操作系统本身不太可能在GPU上运行。

其他计算任务越来越多地由GPU处理,特别是在科学计算中常见的计算要求较高的任务。用于GPU上的通用处理的术语是你猜到的——GPGPU。不幸的是,对GPU进行高效编程非常困难,需要特殊的编程语言,如OpenGL或NVIDIA的专有CUDA。编程GPU和编程通用处理器之间的一个重要区别是,GPU本质上是“单指令多数据”机器,意味着大量内核执行完全相同的指令,但数据不同。这种编程模型非常适合数据并行,但对于其他编程风格(如任务并行)并不总是很方便。

18.12.1.7 异构多核(Heterogeneous Multicores)

一些芯片在同一芯片上集成了GPU和多个通用内核。类似地,除了一个或多个专用处理器之外,许多SoC还包含通用核。在单个芯片中集成多个不同种类处理器的系统统称为异构多核处理器。异构多核处理器的一个例子是IXP网络处理器系列,最初由Intel于2000年推出,并定期更新最新技术。网络处理器通常包含一个通用控制核心(例如,运行Linux的ARM处理器)和几十个高度专业化的流处理器,这些处理器非常擅长处理网络数据包,而其他处理器则不多,通常用于网络设备,如路由器和防火墙。另一方面,高速网络高度依赖于对内存的快速访问(读取数据包),流处理器有特殊的硬件来实现这一点。

IXP上的流处理器和控制处理器是完全不同的,具有不同的指令集,GPU和通用内核也是如此。然而,在保持相同指令集的同时,也可能引入异构性。例如,一个CPU可以有少量的“大”内核,具有深的流水线和可能高的时钟速度,以及更多的“小”内核,这些内核更简单、更不强大,并且可能在较低的频率下运行。强大的内核是运行需要快速顺序处理的代码所必需的,而小内核对于可以高效并行执行的任务是有用的,例如ARM的big.LITTLE处理器系列。

18.12.2 多处理器操作系统类型

现在让我们从多处理器硬件转向多处理器软件,特别是多处理器操作系统。各种方法是可能的,下面将研究其中的三个。请注意,所有这些都同样适用于多核系统以及具有离散CPU的系统。

18.12.2.1 逐CPU的操作系统

组织多处理器操作系统的最简单可能的方法是将内存静态地划分为尽可能多的分区,并为每个CPU提供自己的私有内存和操作系统的私有副本。实际上,n个CPU然后作为n个独立的计算机运行。一个明显的优化是允许所有CPU共享操作系统代码,并仅对操作系统数据结构进行私有拷贝,如下图所示。

在四个CPU之间划分多处理器内存,但共享操作系统代码的单个副本。标记为Data的框是每个CPU的操作系统专用数据。

这种方案仍然比有n台单独的计算机要好,因为它允许所有的计算机共享一组磁盘和其他I/O设备,还允许灵活地共享内存。例如,即使使用静态内存分配,一个CPU也可以获得额外大的内存,这样它就可以有效地处理大型程序。此外,进程可以通过允许生产者将数据直接写入内存,并允许消费者从生产者写入数据的地方获取数据,从而有效地相互通信。然而,从操作系统的角度来看,让每个CPU都有自己的操作系统是最原始的。

值得一提的是,这种设计的四个方面可能并不明显。

首先,当一个进程进行系统调用时,系统调用会在它自己的CPU上使用操作系统表中的数据结构进行捕获和处理。

第二,由于每个操作系统都有自己的表,它也有自己的进程集,可以自己调度。没有共享进程。如果用户登录到CPU1,他的所有进程都在CPU1上运行。因此,当CPU2加载工作时,CPU1可能处于空闲状态。

第三,没有共享物理页面。当CPU2连续分页时,CPU1可能有空闲页。由于内存分配是固定的,CPU 2无法从CPU 1借用一些页面。

第四,也是最糟糕的一点,如果操作系统维护最近使用的磁盘块的缓冲区缓存,那么每个操作系统都会独立于其他操作系统执行此操作。因此,可能会发生某个磁盘块同时存在于多个缓冲区缓存中,并且是脏的,从而导致不一致的结果。避免此问题的唯一方法是消除缓冲区缓存。这样做并不难,但会严重影响性能。

由于这些原因,该模型很少再用于生产系统。如果每个处理器的所有状态都保持在该处理器的本地,那么很少或没有共享会导致一致性或锁定问题。相反,如果多个处理器必须访问和修改同一个进程表,锁定会很快变得复杂(并且对性能至关重要)。

18.12.2.2主从式多处理器

第二个模型如下图所示,操作系统及其表的一个副本存在于CPU1上,而不是其他任何一个。所有系统调用都被重定向到CPU1进行处理,如果剩余CPU时间,CPU 1也可以运行用户进程。这种模型被称为主从式(master-slave),因为CPU 1是主,而其它CPU是从。

主从式多处理器模型。

主从模型解决了第一个模型的大部分问题。有一个单独的数据结构(例如,一个列表或一组优先级列表),用于跟踪就绪进程。当CPU空闲时,它要求CPU 1上的操作系统运行一个进程,并分配一个进程。因此,永远不会发生一个CPU空闲而另一个CPU过载的情况。类似地,页面可以在所有进程之间动态分配,并且只有一个缓冲区缓存,因此不会发生不一致。

这个模型的问题是,对于许多CPU,主CPU将成为一个瓶颈,因为它必须处理来自所有CPU的所有系统调用。例如,如果所有时间的10%都用于处理系统调用,那么10个CPU将使主机几乎饱和,而20个CPU将完全过载。因此,这个模型对于小型多处理器来说是简单可行的,但对于大型多处理器来说,则不可行。

18.12.2.3 对称多处理器

第三种模型SMP(Symmetric Multiprocessors,对称多处理器)消除了这种不对称性。内存中有一个操作系统副本,但任何CPU都可以运行它。当进行系统调用时,进行系统调用的CPU捕获内核并处理系统调用。SMP模型如下图所示。

SMP架构案例1。

SMP架构案例2。

SMP架构案例3。

该模型动态平衡进程和内存,因为只有一组操作系统表,还消除了主CPU瓶颈,因为没有主CPU。但它引入了自己的问题,特别是,如果两个或多个CPU同时运行操作系统代码,很可能会导致灾难,想象两个CPU同时选择相同的进程运行或要求相同的空闲内存页。解决这些问题的最简单方法是将互斥体(即锁)与操作系统相关联,使整个系统成为一个大的关键区域。当CPU想要运行操作系统代码时,它必须首先获取互斥体。如果互斥锁被锁定,它只会等待。这样,任何CPU都可以运行操作系统,但一次只能运行一个。这种方法叫做大内核锁(big kernel lock)

这种模式很有效,但几乎和主从模式一样糟糕。同样,假设所有运行时间的10%花费在操作系统内部。有了20个CPU,将有很长的CPU队列等待进入。幸运的是,它很容易改进,操作系统的许多部分彼此独立,例如,一个CPU运行调度程序,另一个CPU处理文件系统调用,第三个CPU处理页面错误,这没有问题。

这种观察导致将操作系统拆分为多个独立的关键区域,这些区域彼此不交互。每个关键区域都有自己的互斥体保护,因此一次只能有一个CPU执行它。通过这种方式,可以实现更多的并行性。然而,很可能会发生一些表(如进程表)被多个关键区域使用的情况。例如,进程表不仅用于调度,还用于fork系统调用和信号处理。多个关键区域可能使用的每个表都需要自己的互斥体,这样,每个关键区域一次只能由一个CPU执行,每个关键表一次只能被一个CPU访问。

大多数现代多处理机都使用这种调度,为这样的机器编写操作系统的困难之处并不在于实际代码与常规操作系统有很大的不同,事实并非如此。最困难的部分是将其划分为关键区域,这些区域可以由不同的CPU同时执行,而不会相互干扰,甚至不会以微妙的、间接的方式。此外,两个或多个关键区域使用的每个表都必须由互斥体单独保护,并且使用该表的所有代码都必须正确使用互斥体。

此外,必须非常小心地避免死锁。如果两个关键区域都需要表A和表B,并且其中一个首先获取A,另一个先获取B,那么迟早会发生死锁,没有人会知道原因。理论上,所有的表都可以分配整数值,所有的关键区域都可以按递增的顺序获取表。这种策略避免了死锁,但它要求程序员非常仔细地考虑每个关键区域需要哪些表,并按照正确的顺序发出请求。

随着代码的不断发展,关键区域可能需要一个以前不需要的新表。如果程序员是新手,并且不理解系统的全部逻辑,那么诱惑将是在需要的时候抓住表上的互斥体,并在不再需要时释放它。无论这看起来多么合理,它都可能导致死锁,用户会认为这是系统冻结。要做到这一点并不容易,面对不断变化的程序员,要在一段时间内保持这一点是非常困难的。

18.12.3 多处理器同步

多处理器中的CPU经常需要同步,前面看到内核关键区域和表必须由互斥体保护的情况,下面看看这种同步在多处理器中是如何工作的。

首先,确实需要适当的同步原语。如果单处理器机器(只有一个CPU)上的进程进行了需要访问某个关键内核表的系统调用,那么内核代码可以在访问该表之前禁用中断。然后,它就可以完成工作了,因为它知道在完成之前,它将能够在不需要任何其他过程的情况下完成工作。在多处理器上,禁用中断只影响执行禁用操作的CPU。其他CPU继续运行,仍然可以触及关键表。因此,所有CPU必须使用并遵守适当的互斥协议,以确保互斥工作。

任何实用互斥协议的核心都是一条特殊指令,它允许在一个不可分割的操作中检查和设置内存字,可以使用TSL(测试和设置锁定)来实现关键区域,如前所述,TSL的作用是读取一个内存字并将其存储在寄存器中。同时,它将1(或其他非零值)写入内存字。当然,执行内存读取和内存写入需要两个总线周期。在单处理器上,只要指令不能中途中断,TSL总是按预期工作。

现在想想在多处理器上会发生什么。在下图中,我们看到了最坏的定时,其中用作锁的内存字1000最初为0。在步骤1中,CPU 1读取该字并获得0。在第2步中,在CPU 1有机会将该字重写为1之前,CPU 2进入并将该字作为0读出。在第3步中,CPU将1写入该字。在步骤4中,CPU 2还将1写入字。两个CPU都从TSL指令中得到了0,因此它们现在都可以访问关键区域,互斥失败。

如果无法锁定总线,TSL指令可能会失败。这四个步骤显示了一系列事件,其中显示了故障。

为了防止这个问题,TSL指令必须首先锁定总线,防止其他CPU访问它,然后执行两次内存访问,然后解锁总线。通常,通过使用通常的总线请求协议请求总线,然后断言(即设置为逻辑1值)某些特殊总线,直到两个循环都完成,从而锁定总线。只要这条特殊线路被断言,其他CPU就不会被授予总线访问权。此指令只能在具有使用它们所需线路和(硬件)协议的总线上实现。现代总线都有这些设施,但在早期没有这些设施的总线上,不可能正确实施TSL。这就是彼得森协议被发明的原因:完全在软件中同步。

如果TSL得到正确的实施和使用,它保证了互斥可以发挥作用。然而,这种互斥方法使用自旋锁,因为请求的CPU只是处于一个严密的循环中,尽可能快地测试锁。它不仅完全浪费了请求CPU(或多个CPU)的时间,而且还可能给总线或内存带来大量负载,严重减慢了所有其他CPU正常工作的速度。

乍一看,缓存的存在似乎应该消除总线争用的问题,但事实并非如此。理论上,一旦请求CPU读取了锁字,它应该在缓存中获得一个副本。只要没有其他CPU尝试使用锁,请求的CPU应该能够耗尽其缓存。当拥有锁的CPU向其写入0以释放它时,缓存协议会自动使远程缓存中的所有副本无效,要求再次获取正确的值。

问题是高速缓存在32或64字节的块中运行。通常,锁周围的单字是由持有锁的CPU所需要的,由于TSL指令是写(因为它修改了锁),它需要对包含锁的缓存块进行独占访问。因此,每个TSL都会使锁持有者缓存中的块无效,并为请求的CPU获取一个专用的、独占的副本。一旦锁持有者接触到与锁相邻的单字,缓存块就会移动到其机器上。因此,包含锁的整个缓存块不断地在锁所有者和锁请求者之间穿梭,产生的总线流量甚至超过了对锁字的单独读取。

如果能够消除请求端的所有TSL引发的写入,就可以显著减少缓存抖动(thrashing),可通过让请求的CPU首先进行一次纯读取来查看锁是否空闲来实现。只有当锁看起来是空闲的时,它才会执行TSL来实际获取它。这个小变化的结果是,大多数轮询变成了都是读而不是写。如果持有锁的CPU仅读取同一缓存块中的变量,则它们可以在共享只读模式下各自拥有缓存块的副本,从而消除所有缓存块传输。

当锁最终被释放时,所有者进行写操作,这需要独占访问,从而使远程缓存中的所有副本无效。请求CPU下次读取时,将重新加载缓存块。请注意,如果两个或多个CPU正在争夺同一个锁,可能会发生两个CPU都看到它同时空闲,两个CPU同时执行TSL以获取它。只有其中一个会成功,因此这里没有竞争条件,因为真正的获取是由TSL指令完成的,并且是原子的。看到锁是免费的,然后尝试用TSL立即获取它并不能保证你获得它。但对于算法的正确性,谁获得它并不重要。纯读取的成功只是暗示,将是尝试获取锁的好时机,但并不能保证获取成功。

另一种减少总线流量的方法是使用众所周知的以太网二进制指数退避算法(Ethernet binary exponential backoff algorithm),使用连续轮询可以在轮询之间插入延迟环路。最初,延迟是一条指令,如果锁仍然繁忙,延迟将加倍到两条指令,然后是四条指令,以此类推,直到达到最大值。低的最大值在释放锁时提供快速响应,但在缓存抖动上浪费更多的总线周期。高的最大值可以减少缓存抖动,但代价是不会注意到很快释放的锁。二进制指数回退可以与TSL指令之前的纯读取一起使用,也可以不使用。

一个更好的想法是让每个希望获取互斥锁的CPU都有自己的私有锁变量进行测试,如下图所示。变量应位于其他未使用的缓存块中,以避免冲突。该算法的工作原理是让无法获取锁的CPU分配一个锁变量,并将其自身附加到等待锁的CPU列表的末尾。当当前锁持有者退出关键区域时,它释放列表中第一个CPU正在测试的私有锁(在其自己的缓存中)。然后,该CPU进入临界区域。完成后,它会释放其后继者正在使用的锁,以此类推。尽管协议有点复杂(为了避免两个CPU同时连接到列表的末尾),但它是高效且无饥饿的。

使用多个锁来避免缓存抖动。

18.12.3.1自旋与切换

到目前为止,我们假设需要锁定互斥锁的CPU只是通过连续轮询、间歇轮询或将其自身附加到等待的CPU列表来等待它。有时,请求的CPU除了等待之外别无选择。例如,假设某个CPU处于空闲状态,需要访问共享就绪列表以选择要运行的进程。如果就绪列表被锁定,CPU不能决定暂停正在执行的操作并运行另一个进程,因为这样做需要读取就绪列表。它必须等待,直到它可以获取就绪列表。

然而,在其他情况下,有一个选择。例如,如果CPU上的某个线程需要访问文件系统缓冲区缓存,并且该缓存当前被锁定,则CPU可以决定切换到其他线程而不是等待,是自旋还是切换线程的问题一直是一个研究的问题。请注意,这个问题在单处理器上不会发生,因为当没有其他CPU释放锁时,自旋没有多大意义。如果一个线程试图获取一个锁,但失败了,它总是被阻塞,以给锁所有者一个运行和释放锁的机会。

假设自旋和执行线程切换都是可行的选项,权衡如下。自旋直接浪费CPU周期,反复测试锁不是一件有成效的工作。但是,切换也会浪费CPU周期,因为必须保存当前线程的状态,必须获取就绪列表上的锁,必须选择线程,必须加载其状态,并且必须启动线程。此外,CPU缓存将包含所有错误的块,因此当新线程开始运行时,会发生许多昂贵的缓存未命中。TLB也可能发生故障。最终,必须切换回原始线程,随后会有更多的缓存未命中。执行这两个上下文切换所花费的周期加上所有缓存未命中都被浪费了。

如果已知互斥体通常保持50微秒,并且从当前线程切换需要1毫秒,稍后再切换需要1秒,那么只在互斥体上自旋更有效。另一方面,如果平均互斥体保持10毫秒,那么进行两个上下文切换是值得的。问题是关键区域的持续时间可能会有很大的差异,那么哪种方法更好呢?
一种设计是总是自旋,第二种设计是始终切换,但第三种设计是在每次遇到锁定的互斥体时做出单独的决定。在必须做出决定的时候,不知道是自旋还是切换更好,但对于任何给定的系统,都可以跟踪所有活动,并在稍后离线分析。回想起来,哪一个决定是最好的,在最好的情况下浪费了多少时间。然后,这种事后发现的算法成为衡量可行算法的基准。

几十年来,研究人员一直在研究这个问题。大多数研究都使用一个模型,在该模型中,未能获取互斥体的线程会在一段时间内自旋。如果超过此阈值,则切换。在某些情况下,阈值是固定的,通常是切换到另一个线程然后再切换回来的已知开销。在其他情况下,它是动态的,取决于所观察到的等待互斥体的历史。

当系统跟踪最后几次观察到的自旋时间并假设这一次与之前的自旋时间相似时,就可以获得最佳结果。例如,假设再次进行1毫秒的上下文切换,线程将自旋最多2毫秒,但观察它实际自旋的时间。如果它无法获取锁,并且发现在前三次运行中它平均等待了200微秒,那么它应该在切换前自旋2毫秒。然而,如果它看到它在前一次尝试中自旋了整整2毫秒,它应该立即切换,而不是自旋。

一些现代处理器,包括x86,提供了特殊的指令,使等待在降低功耗方面更加高效。例如,x86上的MONITOR/MWAIT指令允许程序阻塞,直到其他处理器修改之前定义的内存区域中的数据。具体来说,MONITOR指令定义了一个地址范围,应该监视该地址范围的写入。然后,MWAIT指令阻塞线程,直到有人写入该区域。实际上,线程正在自旋,但没有不必要地消耗许多周期。

18.12.4 多处理器调度

在查看如何在多处理器上进行调度之前,有必要确定正在调度什么。在过去,当所有进程都是单线程的时候,进程都是调度的,没有其他可调度的。所有现代操作系统都支持多线程进程,使得调度更加复杂。

线程是内核线程还是用户线程都很重要。如果线程是由用户空间库完成的,并且内核对线程一无所知,那么调度将按每个进程进行,就像它一直做的那样。如果内核甚至不知道线程的存在,它就很难对线程进行调度。

对于内核线程,情况就不同了,内核知道所有线程,并且可以在属于进程的线程中进行选择。在这些系统中,趋势是内核选择要运行的线程,它所属的进程在线程选择算法中只扮演一个小角色(或者可能没有)。下面我们将讨论调度线程,但当然,在具有单线程进程或在用户空间中实现线程的系统中,调度的是进程。

进程与线程不是唯一的调度问题。在单处理器上,调度是一维的。唯一必须(反复)回答的问题是:“下一个应该运行哪个线程?”在多处理器上,调度有两个维度,调度程序必须决定运行哪个线程以及在哪个CPU上运行,额外的维度使多处理器上的调度变得非常复杂。另一个复杂的因素是,在一些系统中,所有线程都是相互关联的,属于不同的进程,彼此之间没有任何关系。在其他应用程序中,它们是分组的,属于同一个应用程序并一起工作。前一种情况的一个例子是独立用户启动独立进程的服务器系统,不同进程的线程是不相关的,每一个进程都可以在不考虑其他进程的情况下进行调度。

后一种情况的例子经常出现在程序开发环境中。大型系统通常由一些头文件组成,其中包含宏、类型定义和实际代码文件使用的变量声明。更改头文件时,必须重新编译包含它的所有代码文件。程序make通常用于管理开发,当make被调用时,它只开始编译那些由于头文件或代码文件的更改而必须重新编译的代码文件,仍然有效的对象文件不会重新生成。

make的原始版本按顺序运行,但为多处理器设计的较新版本可以同时启动所有编译。如果需要10个编译,那么安排其中9个编译立即运行并将最后一个编译保留到很晚的时间是没有意义的,因为用户在最后一个完成之前不会感觉到工作已经完成。在这种情况下,将执行编译的线程视为一个组,并在调度它们时考虑到这一点则有意义。

有时,调度广泛通信的线程比较有用,例如以生产者-消费者的方式,不仅在同一时间,而且在空间上紧密地联系在一起,它们可能会从共享缓存中受益。同样,在NUMA体系结构中,如果它们访问附近的内存,可能会有所帮助。

常见的调度算法有时间分享、空间分享、成组调度等。由于时间分享前面已经介绍过,下面只介绍后两种。

18.12.4.1 空间分享

当线程以某种方式相互关联时,可以使用多处理器调度的另一种通用方法。前面我们提到了并行make的例子。通常情况下,一个进程有多个线程一起工作。例如,如果进程的线程经常通信,那么让它们同时运行是很有用的。跨多个CPU同时调度多个线程称为空间共享(space sharing)

最简单的空间共享算法是这样工作的。假设一次创建了一组相关线程,在创建它时,调度器会检查空闲CPU是否与线程一样多。如果有,每个线程都有自己的专用(即非多程序)CPU,它们都会启动。如果没有足够的CPU,不会启动任何线程。每个线程都会占用它的CPU,直到它终止,这时CPU会被放回可用CPU池中。如果一个线程在I/O上阻塞,它将继续保持CPU,直到线程唤醒为止,CPU一直处于空闲状态。当下一批线程出现时,将应用相同的算法。

在任何时刻,CPU集都会被静态地划分为若干个分区,每个分区都运行一个进程的线程。在下图中,有大小为4、6、8和12个CPU的分区,例如,有2个未分配的CPU。随着时间的推移,分区的数量和大小将随着新线程的创建和旧线程的完成和终止而改变。

一组32个CPU分成四个分区,有两个可用CPU。

必须定期做出调度决定。在单处理器系统中,最短作业优先是一种众所周知的批调度算法。多处理器的类似算法是选择需要最少CPU周期数的进程,即CPU计数×运行时间最小的线程。然而,在实践中,这种信息很少可用,因此算法很难实现。事实上,研究表明,在实践中,先到先得很难做到。

在这个简单的分区模型中,一个线程只需要一些CPU,然后要么得到它们,要么等待它们可用。另一种方法是线程主动管理并行度。管理并行性的一种方法是使用一个中央服务器来跟踪哪些线程正在运行,哪些线程想要运行,以及它们的最小和最大CPU需求是多少。每个应用程序定期轮询中央服务器,询问它可能使用多少CPU。然后,它向上或向下调整线程数以匹配可用的线程数。

例如,一个Web服务器可以有5个、10个、20个或任何其他数量的线程并行运行。如果它目前有10个线程,并且突然对CPU的需求增加,并且它被告知减少到5个线程,那么当下一个5个线程完成其当前工作时,它们被告知退出,而不是被赋予新的工作。该方案允许分区大小动态变化,以比上图的固定系统更好地匹配当前工作负载。

18.12.4.2 分组调度

空间共享的一个明显优势是消除了多道程序设计,从而消除了上下文切换开销。然而,一个同样明显的缺点是,当CPU阻塞并且在再次准备就绪之前没有任何事情可做时,会浪费时间。因此,人们一直在寻找能够同时在时间和空间上进行调度的算法,尤其是那些创建多个线程的线程,这些线程通常需要相互通信。

要了解进程的线程独立调度时可能出现的问题,请考虑一个系统,其中线程A0和A1属于进程a,线程B0和B1属于进程B;线程A1和B1在CPU 1上分时,线程A0和A1需要经常通信。通信模式是A0向A1发送一条消息,A1随后向A0发送一个回复,然后是另一个这样的序列,这在客户机-服务器情况下很常见。假设幸运的是A0和B1首先开始,如下图所示。

属于线程A的两个线程之间的异相通信。

在时间片0中,A0向A1发送一个请求,但A1直到在时间片1中以100毫秒开始运行时才收到请求。它立即发送回复,但A0直到在200毫秒再次运行时才收到回复。最后结果是每200毫秒一个请求-应答序列,性能不是很好。

这个问题的解决方案是分组调度(gang scheduling),是联合调度的产物,它包括三个部分:

1、相关线程组被安排为一个单元,一个组。

2、一个帮派的所有成员同时在不同的分时CPU上运行。

3、所有帮派成员一起开始和结束他们的时间片。

使分组调度工作的诀窍是,所有CPU都是同步调度的,意味着时间被划分为离散的量子,如上图所示。在每个新量子开始时,所有CPU都被重新调度,每个CPU上都会启动一个新线程。在下一个时间段开始时,会发生另一个调度事件。在这两者之间,不进行调度。如果线程阻塞,它的CPU将保持空闲状态,直到时间段结束。

下图给出了分组调度工作的一个例子,有一个多处理器,有六个CPU,由五个进程a到e使用,总共有24个就绪线程。在时隙0期间,线程A0到A6被调度和运行。在时隙1期间,调度并运行线程B0、B1、B2、C0、C1和C2。在时隙2期间,D的五个线程和E0开始运行。属于线程E的其余六个线程在时隙3中运行。然后循环重复,时隙4与时隙0相同,以此类推。

分组调度的思想是让一个进程的所有线程同时在不同的CPU上运行,这样,如果其中一个线程向另一个线程发送请求,它将几乎立即收到消息,并且能够几乎立即回复。在上图中,由于所有A线程都在一起运行,在一个时间段内,它们可以在一个量子段内发送和接收大量消息,从而消除了上上图的问题。

18.12.5 多核和多线程

使用多核系统来支持具有多线程的单个应用程序,例如工作站、视频游戏控制台或运行处理器密集型应用程序的个人计算机上可能出现的应用程序,会带来性能和应用程序设计问题。在本节中,我们阐述一下多核系统上多线程应用程序的一些性能影响。

多核组织的潜在性能优势取决于有效利用应用程序可用的并行资源的能力,性能参数遵循Amdahl定律,下面两图展示了多核的性能影响曲线图:

除了通用服务器软件之外,许多应用程序类别也直接受益于随内核数量扩展吞吐量的能力,以下是其中的几个示例:

  • 多线程原生应用程序:多线程应用程序的特点是具有少量高线程进程,示例包括Lotus Domino或Siebel CRM(客户关系经理)。

  • 多进程应用程序:多进程应用的特点是存在许多单线程进程,示例包括Oracle数据库、SAP和PeopleSoft。

  • Java应用程序:Java应用程序以一种基本的方式拥抱线程。Java语言不仅极大地促进了多线程应用程序,而且Java虚拟机是一个多线程进程,为Java应用程序提供调度和内存管理。可以直接从多核资源中受益的Java应用程序包括应用程序服务器,如Sun的Java application Server、BEA的Weblogic、IBM的Websphere和开源Tomcat应用程序服务器。所有使用Java 2 Platform,Enterprise Edition(2EE平台)应用服务器的应用程序都可以立即从多核技术中受益。

  • 多实例应用程序:即使单个应用程序不能扩展以利用大量线程,也可以通过并行运行应用程序的多个实例从多核架构中获益。如果多个应用程序实例需要某种程度的隔离,则可以使用虚拟化技术(针对操作系统的硬件)为每个应用程序实例提供各自独立的安全环境。

下图显示了游戏引擎Source的渲染模块的线程结构。在这种层次结构中,高级线程根据需要生成低级线程。渲染模块依赖于Source引擎的关键部分,即世界列表,它是游戏世界中视觉元素的数据库表示。第一个任务是确定世界上需要渲染的区域,下一个任务是确定从多个角度观看时场景中的对象,然后是处理器密集型工作。渲染模块必须从多个视角(例如玩家视角、电视显示器的视角和水中反射的视角)来渲染每个对象。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值