Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

指令集基本原理

引言

本附录主要介绍指令集体系结构。我们主要关注四个主题。

  • 第一,对各种指令集进行了分类并对各种方法的优势和劣势进行某种量化评估。
  • 第二,给出一些指令集测量数据,并对其进行分析,这些数据大体与特定的指令集无关。
  • 第三,讨论语言与编译器问题以及它们对指令集体系结构的影响。
  • 最后,展示这些思想如何在MIPS指令集中得到反映,MIPS 指令集是-种典型的RISC体系结构。
  • 在附录的末尾是有关指令集设计的一些谬论和易犯错误。

在这个附录中,我们将研究大量体系结构方面的测试结果。显然,这些测量结果依赖于被测程序和用于进行这些测量的编译器。

指令集体系结构的分类

处理器中的内部存储类型是最基本的区别,所以在这一节,我们将主要关注体系结构中这一部分的各种选项。主要选项包括栈、累加器或寄存器组。操作数可以显式命名,也可以隐式命名:在栈体系结构中,操作数隐式位于栈的顶部,而在累加器体系结构中,操作数隐式为累加器。通用寄存器体系结构只有显式操作数,或者为寄存器,或者为存储器位置。图A-1显示了此类体系结构的框图,表A-1显示了代码序列 C=A+B 在这三类指令集中通常是如何显示的。显式操作数也许可以直接从存储器访问,也可能需要首先加载到临时存储中,具体取决于体系结构的类别及特定指令的选择。


图A-1 4类指令集体系结构的操作数位置。这些箭头指示操作数是算术逻辑单元(ALU)操作的输入还是结果,或者既是输入又是结果。较浅的阴影表示输入,深色阴影表示结果。在(a)中,栈顶寄存器(TOS)指向顶部输入操作数,它与下面的操作数合并在一起。第一个操作数从栈中移走,结果占据第二个操作数的位置,对TOS进行更新,以指向结果值。所有操作数都是隐式的。在(b)中,累加器既是隐式输入操作数,也是结果。在(c)中,一个输入操作数是寄存器,一个在存储器中,结果保存在寄存器中。在(d)中,所有操作数都是寄存器,而且和栈体系结构类似,只能通过独立指令将其传送到存储器中:在(a)中为push或pop,在(d)中为1oad或store

  • 注意,对于栈和累加器体系结构,Add指令拥有隐式操作数,对于寄存器体系结构拥有显式操作数。假定A、B和C都属于存储器,A和B的值不能被销毁。图A-1显示了针对每类体系结构的Add运算。

如上面的图和表所示,实际上有两种类型的寄存器计算机。一类可以用任意指令来访问存储器,称为寄存器-存储器体系结构,另一类则只能用载入和存储指令来访问存储器,称为载入-存储体系结构。第三类将所有操作数都保存在存储器中,称为存储器-存储器体系结构。一些指令集体系结构的寄存器要多于单个累加器,但对这些特殊寄存器的使用设置了一些限制。 此类体系结构有时被称为扩展累加器或专用寄存器计算机。

尽管大多数早期计算机都使用栈或累加器类型的体系结构,但在1980年之后的几乎所有新体系结构都使用了载入-存储寄存器体系结构。通用寄存器(GPR)计算机之所以会出现,其主要原因有两个。第一,寄存器(类似于处理器内部其他形式的存储器)快于存储器。第二,对编译器来说,使用寄存器要比使用其他内部存储形式的效率更高。例如,在寄存器计算机中,在对表达式(AXB) - (BXC) - (AXD)求值时,可以按任意顺序执行乘法计算,这种做法的效率更高一些,可能是操作数位置的原因,也可能是流水线因素的原因。不过,在栈计算机上,硬件只能按唯一的顺序对表达式进行求值, 这是因为操作数是隐藏在栈中的,它必须多次载入操作数。

更重要的是,寄存器可用于保存变量。当变量被分配到寄存器中时,可以降低存储器通信流量、加快程序速度(由于寄存器的速度快于存储器),提高代码密度(由于寄存器的名称位数少于存储器位置的名称位数)。

在A.8节将会解释,编译器编写入员希望所有寄存器都是等价的、无保留的。较早的计算机在满足这一期望方面打了折扣,将一些寄存器专门用于一些特殊应用,显著降低了通用寄存器的数量。如果真正通用寄存器的数目过小,那尝试将变量分配到寄存器中就没有什么好处。

编译器将所有未确认用途的寄存器保留给表达式求值使用。有多少个寄存器才是足够的呢?其答案当然取决于编译器的有效性。大多数编译器会为表达式求值保留一些寄存器,为参数传递使用一些,其余寄存器可用于保存变量。现代编译器技术能够有效地使用大量寄存器,从而增加了最新体系结构的寄存器数目。

有两个重要指令集特性可以用来区分GPR体系结构。这两个特性都关注一个典型算术或逻辑指令(ALU指令)操作的本质。第一个特性关注一个ALU指令是有两个还是三个操作数。在三操作数格式中,指令包含一个结果操作数和两个源操作数。在两操作数格式中,操作数之一既是运算的源操作数,又是运算的结果操作数。GPR体系结构的第二个区别是考虑ALU指令中可能有多少个操作数是存储器地址。一个典型ALU指令所支持的存储器操作数数量可以是
0~3个。表A-2给出了这两种特性的组合及其计算机示例。尽管共有7种可能组合方式,但其中3种就可以对几乎全部现有计算机进行分类。前面曾经提到,这3种是载入存储(也称为寄存器-寄存器)、寄存器-存储器和存储器-存储器。

ALU指令中没有存储器引用的计算机称为载入-存储或寄存器-寄存器计算机。每条典型ALU指令中有多个存储器操作数的指令称为寄存器-存储器或存储器-存储器,具体取决于它们是拥有一个还是 一个以上的存储器操作教。

表A-3显示了每一类型的优势和劣势。当然,这些优势和劣势不是绝对的:它们是定性的,它们的实际影响取决于编译器和实现策略。采用存储器存储器运算的GPR计算机很容易被编译器忽略,用作一种载入存储计算机。体系结构方面最普遍的影响之一是对指令编码和执行一项任务所需指令数的影响。在附录C和第3章中可以了解这些不同体系结构对实现方法的影响。

  • 符号(m,n)表示存储器操作数有m个,共有n个操作数。一般来说,可选项较少的计算机简化了编译器的任务,因为编译器需要作出的决策较少。具有大量灵活指令格式的计算机减少了程序编码需要的位数。寄存器的数目对指令大小也有所影响,因为对于指令中的每个寄存器分类符,需要log2(寄存器个数)。因此,对于寄存器-寄存器体系结构而言,寄存器个数加倍需要增加3个位,大约是32位指令的10%。

存储器寻址

一个体系结构,无论是载入存储式,还是允许任何操作数都是存储器引用,它都必须定义如何解释存储器地址以及如何指定这些地址。这里给出的测量值大体与计算机无关,但并非绝对如此。在某些情况下,这些测量值受编译器技术的影响很大。由于编译器技术扮演着至关重要的角色,所以这些测量都是使用一种优化编译器测得的。

解释存储器地址

根据地址和长度会访问到什么对象呢?本书中讨论的所有指令集都是字节寻址的,提供对字节(8位)半字(16位)和字(32位)的访问方式。大多数计算机还提供了对双字(64位)的访问。关于如何对一个较大对象中的字节进行排序,有两种不同的约定方式。小端字节顺序将地址为“…x000” 的字节放在双字的最低有效位置(小端)。字节的编号为:

7 6 5 4 3 2 1 0

大端字节顺序将地址为“x.. .x000”的字节放在双字的最高有效位置(大端)。字节的编号为:

0 1 2 3 4 5 6 7

在同一台计算机内部进行操作时,字节顺序通常不会引起人们的注意——只有那些将相同位置同时作为字和字节进行访问的程序才会注意到这一区别。但是,在采用不同排序方式的计算机之间交换数据时,字节顺序就会成为一个问题。在对比字符串时,小端排序也不能与字的正常排序方式相匹配。字符串在寄存器中是反向表示的,如backwards显示为“SDRAWKCAB”。

第二个存储器问题是:在许多计算机中,对大于一字节的对象进行寻址时都必须是对齐的。大小为s字节的对象,字节地址为A,如果A mod s=0,则对该对象的寻址是对齐的。图A-2显示了寻址为对齐和不对齐时的地址。

图A-2在字节寻址计算机中,字节、半宇、字和双字对象的对齐与未对齐地址。对于每种未对齐示例,一些对象需要两次存储器访问才能完成。每个对齐对象总是可以在一次存储器访问中完成,只要存储器与对象的宽度相同即可。本图显示的存储器宽度为8个字节。标记各列的字节偏移指定了该地址的低3位

由于存储器通常与一个字或双字的倍数边界对齐,所以非对齐寻址会增加硬件复杂性。一个非对齐存储器寻址可能需要多个对齐的存储器引用。因此,即使在允许非对齐寻址的计算机中,采用对齐寻址的程序也可以运行得更快些。即使数据是对齐的,要支持字节、半字和字寻址也需要一个对齐网络来对齐64位寄存器中的字节、半字和字。例如,在表A-4中,假定从低3位取值为4的地址中读取一个字节。我们需要右移3个字节,对准64位寄存器中正确位置的字节。根据具体指令,计算机可能还需要对这个量进行符号扩展。存储过程很容易:只有存储器的寻址字节可被修改。在某些计算机中,字节、半字和字操作不会影响到寄存器的上半部分。尽管本书中讨论的所有计算机都允许对存储器进行字节、半字和字访问,但只有IBM 360/370、Intel 80x86和VAX支持对不足完整宽度的寄存器操作数进行ALU运算。

既然已经讨论了存储器寻址的各种解释方法,现在可以讨论指令用来指定地址的方式了,这些方式称为寻址方式。

寻址方式

给定地址后,我们就知道了去访问存储器的哪些字节。在这一小节中,我们将研究寻址方式——体系结构如何指定要访问对象的地址。除了存储器中的位置之外,寻址方式还指定常量和寄存器。在使用存储器位置时,由寻址方式指定的实际存储器地址称为有效地址。

表A-4显示了在最近计算机中用到的所有数据寻址方式。立即数或直接操作数寻址通常被看作存储器寻址模式(即使它们访问的值位于指令流中也是如此),不过,由于寄存器通常没有存储器地址,所以我们将它们分离出来。我们已经将那些依赖于程序计数器的寻址模式(称为PC相对寻址)分离出来。PC相对寻址主要用于在控制转移指令中指定代码地址,A.6 节将对此进行讨论。

  • 在自动递增/递减和比例寻址方式中,变量d指定所访问数据项的大小(即,该指令访问的是1、2、4或8字节中的哪一种)。只有当被访问元素位于存储器中的连续位置时,这些寻址方式才有用。RISC计算机使用位移量寻址来模拟寄存器间接寻址(地址为0)和直接寻址(基址寄存器中为0)。在我们的测量结果中,使用为每种模式显示的第一个名称。在A.9.5节定义了用作硬件描述的C语言扩展。

Mem[Regs[R1]]是指存储器位置的内容,这一位置的地址由寄存器1(R1)的内容给出。对于小于一个字的数据 ,我们将在后面介绍用于访问和转移此类数据的扩展。

寻址模式能够大幅臧少指令数目,它们也会增加构建计算机的复杂度,对于实施这些方式的计算机,还可能增加每条指令的平均时钟周期数目(CPI)。 因此,各种寻址模式的使用对于帮助架构师选择包含哪些功能是十分重要的。

图A-3中给出在VAX体系结构上对3个程序中寻址方式使用样式的测量结果。在这个附录中,我们使用较旧的VAX体系结构进行一些测量, 这是因为它拥有最丰富的寻址方式,对存储器寻址的限制最少。例如,表A-2给出了VAX支持的所有方式。

图A-3 存储器寻址方式(包括立即数)的用法小结。几乎所有存储器访问都采用这几种主要寻址方式。一半的操作数引用采用寄存器寻址方式,而另一半则采用存储器寻址方式(包括立即数)。当然,编译器会影响到选用哪种寻址模式。VAX上的存储器间接寻址方式可使用位移量、自动递增或自动递减来形成初始存储器地址;在这些程序中,几乎所有存储器间接引用都以位移量寻址方式为基准方式。

位移量寻址方式,

在使用位移量类型的寻址方式时,一个主要问题就是所用位移量的范围。根据所使用的各种位移量大小,可以决定支持哪些位移量大小。由于位移量字段的大小直接影响到指令的长度,所以其选择非常重要。图A-4是利用基准测试程序对载入-存储体系结构中数据访问进行的测量结果。

图A-4 位移值的分布非常广泛。既存在大量小数值,又有相当数量的大数值。位移值的广泛分布是由于变量有多个存储区域,而且访问它们的位移量不同,而且编译器使用的总寻址机制也各不相同。x轴是位移量以2为底的对数值,即表示该位移量所需要的字节大小。x轴上的零表示位移值0的百分比。该曲线没有包含符号位,存储布局对它会有严重影响。大多数位移值是正数,但最大的位移值(14位以上)为负值。

立即数或直接操作数寻址方式

在进行算术运算、比较(主要用于分支)和移动时,如果希望将常量放在寄存器中,可以使用立即数。后一种情景可用于写在代码中的常量(这种常量较小)和地址常量(这种常量可能很大)。对于立即数的使用,重点是要知道是需要对所有运算都支持立即数,还是仅对一部分运算支持立即数。图A-5显示了在一个指令集中,立即数在一般类型的整数和浮点运算中的使用频率。

图A-5 大约有四分之一的数据传送和ALU运算拥有立即操作数。下面的长条表示整数程序在大约五分之一的指令中使用立即数,而浮点程序在大约六分之一的指令中使用立即数。对于载入操作,载入立即数指令将16位载入一个32位寄存器的任一半中。载入立即数并不是严格意义上的载入,因为它们并不访问存储器。偶尔会使用一对载入立即数来载入32位常量,但这种情况很少见。

另一个重要指令集测量是立即数的取值范围。与位移值相似,立即数取值的大小也影响到指令长度。如图A-6所示,小立即数的应用最多。不过,有时也会使用大型立即数,更多的是用在地址计算中。

图A-6 立即数值的分布。 x轴给出表示立即值取值大小所需要的位数——0表示立即数字段值为0。大多数立即数取值为正数。对于CINT2000,大约20%为负数,对于CFP2000,大约30%为负数。这些测量是在Alpha上执行的,其中最大立即数为16位,被测程序与图A-4中相同。对VAX进行的类似测量(它支持32位立即数)表明:大约20%~25%的立即数长于16位。因此,16 位的长度覆盖大约80%,8位可以覆盖大约50%

小结:存储器寻址

首先,我们预测一种新的体系结构至少支持以下寻址方式:位移量寻址、立即数寻址和寄存器间接寻址,这主要是因为它们非常普及。图A-3显示它们代表了我们测量中所使用的75%~99%的寻址方式。其次,我们预测位移量寻址方式中的地址大小至少为12~16位,根据图A-4的图题,这些大小将占到位移量的75%~99%。最后,我们预测立即数字段的大小至少为8~16位。这一说法在它提到的图题中没有得到证实。

我们已经介绍了指令集分类并决定采用寄存器-寄存器体系结构,再加上前面关于数据寻址模式的建议,下面将介绍数据的大小与意义。

操作数的类型与大小.

如何指定操作数的类型呢?通常,通过在操作码中进行编码来指定操作数的类型。或者,用一些可以被硬件解读的标签对数据进行标记。这些标签指定操作数的类型,并相应选择操作。

我们首先从台式机和服务器体系结构开始。通常操作数的类型(整数、单精度浮点、字符等)有效地确定了其大小。常见操作数类型包括字符(8位)半字(16位)字(32位)、单精度浮点(也是1个字)和双精度浮点(2个字)。整数几乎都是用二进制补码数字表示的。字符通常用ASCII表示,但随着计算机的国际化,16位Unicode(在Java中使用)也正在普及。几乎所有的计算机都遵循了相同的浮点标准——IEEE标准。

一些体系结构提供了对字符串的操作,不过这些操作通常都十分有限,将字符串中的每个字符都看作单个字符。支持对字符串执行的典型操作包括比较和移动。

对于商务应用程序,一些体系结构支持二进制格式,通常称为压缩十进制或二进制编码十进制——用4个位对0至9的数值进行编码,两个十进制数位被压缩到两个字节中。数值字符串有时称为非压缩十进制,通常提供在被称为压缩和解压缩的操作之间来回转换。

使用十进制操作数的一个理由是获得与二进制数字完全匹配的结果,这是因为一些十进制小数无法用二进制准确表示。十进制中的准确计算在二进制中可能十分接近但并非完全准确。

我们的SPEC基准测试使用字节或字符、半字(短整数)字(整数)双字(长整数)和浮点数据类型。图A-7给出了为这些程序引用存储器对象的大小动态分布。对不同数据类型的访问频率有助于确定哪些类型最为重要,应当加以高效支持。计算机是否应当拥有64位访问路径,或者用两个时钟周期来访向一个双字是否可行?我们前面曾经看到,字节访问需要一个对齐网络:将字节作为基元类型提供支持有多么重要?图A-7使用存储器引用来查看被访问数据
的类型。

图A-7 对于基准测试程序,所访问数据的大小分布。双字数据类型用于表示浮点程序中的双精度浮点值,还用于表示地址,这是因为该计算机使用64位地址。在采用32位地址的计算机上,64 位地址将被32位地址代替,所以整数程序中的几乎所有双字访问都变为单字访问

在一些体系结构中,寄存器中的对象可以作为字节或半字进行访问。但是,这种访问是非常罕见的——在VAX上,不超过12%的寄存器引用采用这种方式,也就是这些程序中所有操作数访问的大约6%。

指令集中的操作

大多数指令集体系结构支持的操作符可以如表A-5那样进行分类。关于所有体系结构的一条经验规律是:执行最多的指令是一个指令集中的简单操作。例如,表A-6给出了10种简单指令,对于一组在流行80x86上运行的整数程序,这10种简单指令占所执行指令的96%。因为它们很常见,所以这些指令的实现应当确保它们能够快速完成。

  • 在不同体系结构中,指令集中对系统功能的支持有所不同,但所有计算机都必须对一些基本的系统功能提供指令支持。指令集对后4类的支持数量可能为0,也可能包含大量特殊指令。在任何计算机中都提供浮点指令,供那些大量使用浮点数的应用程序使用。这些指令有时是可选指令集的一部分。十进制和字符串指令有时是基元类型,比如在VAX或IBM360中,也可能是由编译器使用更简单的指令合成的。图形指令通常会对许多较小的数据项进行并行操作。例如,对2个64位操作数执行8个8位加法。

前面曾经提到,表A-6中的指令在每一台计算机的每个应用程序(台式机、服务器和嵌入式)中都可以找到,会对表A-5中的操作进行一些变化,而这主要取决于该指令集包含哪些数据类型。

  • 简单指令是这个列表的主体,占所执行指令的96%。 这些百分数是5个SPECint92程序的均值。.

控制流指令

由于分支与跳转行为的测量在相当程度上与其他测量值和程序无关,所以我们现在研究控制流指令的使用,它与上一节的操作之间没有什么共同点。

关于改变控制流的指令,没有非常一致的术语。在20世纪50年代,它们通常被称为转移(transfer)。在20世纪60年代,开始使用分支(branch)一词。后来计算机还另外引入了一些名称。在本书中,当控制中的改变是无条件时,我们使用跳转(jump),当改变是有条件时,使用分支。

我们可以区分4种不同类型的控制流变化:

  • 条件分支;
  • 跳转;
  • 过程调用;
  • 过程返回;

由于每个事件都是不同的,可能使用不同指令,可能拥有不同行为,所以我们希望知道这些事件的相对频率。图A-8中给出了这些控制流指令在一个载入-存储计算机上的出现频率,我们就是在这种计算机上运行基准测试的。

图A-8 将控制流指令分为三类:调用或返回、跳转和条件分支。条件分支显然占绝大多数。每种类型的计数分别用三个长条来显示。用于收集这些统计数字的程序和计算机与图A-3中的数字相同

控制流指令的寻址方式

控制流指令中的目标地址在任何情况下都必须指定。在绝大多数情况下,这个目标是在指令中明确指定的,但过程返回是一个重要例外,这是因为在编译时无法知道要返回的目标位置。指定目标的最常见方法是提供一个将被加到程序计数器(PC)的位移量。这类控制流指令被称为PC相对指令。由于目标位置通常与当前指令的距离较近,而且,在指定相对于当前PC的位置时,需要的位数较少,所以PC相对分支或跳转具备一些优势。采用PC相对寻址还可以使代码的运行不受装载位置的影响。这一特性称为位置无关,可以在链接程序时减少一些工作,而且对于在执行期间进行动态链接的程序也比较有用。

如果在编译时不知道目标位置,为了实现返回和间接跳转,需要一种不同于PC相对寻址的方法。这时,必须有一种动态指定目标的方法,使目标能够在运行时发生变化。这种动态寻址可能非常简单,只需要给出包含目标地址的寄存器名称即可;跳转可能允许使用任意寻址方式来提供目标地址。

这些寄存器间接跳转对于其他4种重要功能也是有用的。

  • case或switch语句,大多数编程语言中都会有这些语句(用于选择几种候选项之一)。
  • 虚拟函数或虚拟方法,存在于诸如C++或Java之类的面向对象式语言中(允许根据参数类型调用不同例程)。
  • 高阶函数或函数指针,存在于诸如C或C++等语言中(它允许以参数方式传递一些函数,提供面向对象编程的一种好处)。
  • 动态共享库(允许仅当程序实际调用一个库时才在运行时加载和链接,而不是在运行程序之前进行静态加载和链接)。

在所有这4种情况下,目标地址在编译时都是未知的,因此,通常是在寄存器间接跳转之前从存储器加载到寄存器中。

由于分支通常使用PC相对寻址来指定其目标,一个重要的问题就是关注分支目标距离分支有多远。了解这些位移量的分布有助于选择支持哪些分支偏移量,从而会影响到指令长度和编码。图A-9给出了指令中PC相对分支的位移量分布。这些分支中大约有75%是正向的。

图A-9 分支距离(以目标与分支指令之间的指令数来表示)。整数程序中的最常见分支是转向可以用4~8位编码的目标地址。这一结果告诉我们,短位移量字段对于分支指令通常足够了,有了较小分支位移量的较短指令,设计者可以提高编码密度。这些测量结果是在载入-存储计算机上测得的,所有指令都与字边界对齐。对于同一程序,如果体系结构需要的指令较少(比如VAX),那分支距离就较短。但是,如果计算机的指令长度是变化的,可以与任意字节连接对齐,则表示该位移量所需要的位数可能会增加。

条件分支选项

由于大多数控制流改变都是分支,所以决定如何指定分支条件是很重要的。表A-7列出了当前使用的3种主要技术及其优缺点。

分支的最明显特性之一是大量比较都是简单的测试,其中有很多是与0进行比较。因此,一些体系结构选择将这些比较当作特殊情景进行处理,特别是在使用比较并分支指令中。图A-10给出了条件分支中用到的各种不同比较的频率。

  • 尽管条件代码可以由ALU运算设定(用于其他目的),但对程序的测量显示,这种情况会很少发生。当条件代码由一大组指令或一组偶然选定的指令设定,而不是由指令中的一个比特来设定时,就会出现条件代码的重要实现问题。拥有比较和分支指令的计算机通常会限制比较范围,使用条件寄存器进行更复杂的比较。通常,对于根据浮点比较进行的分支和基于整数比较进行的分支会采用不同技术。由于根据浮点比较执行的分支数目要远小于根据整数比较进行的分支数目,所以这种做法是合理的。

图A-10 条件分支中不同比较类型的使用频率。编译器与体系结构的这种组合中,小于(或等于)分支占主导地位。这些测量值包含了分支中的整数和浮点比较。用于收集这些统计数字的程度和计算机与图A4中相同

过程调用选项

过程调用和返回包括控制转移,还可能涉及一些状态保存过程;至少必须将返回地址保存在某个地方,有时保存在特殊的链接寄存器中,有时只是保存在GPR中。一些较早的体系结构提供了一种用于保存许多寄存器的机制,而较新的体系结构需要编译器为所存储和恢复的每个寄存器生成存储和载入操作。

在保存寄存器时,有两种基本约定:要么保存在调用位置,要么保存在被调用的过程内部。调用者保存是指发出调用的过程必须保存它希望在调用之后进行访问的寄存器,因此,被调用的过程不需要为寄存器操心。被调用者保存与之相反:被调用过程必须保存它希望使用的寄存器,而调用者不受限制。在某些时间必须使用调用者保存方法,其原因在于两种不同过程中对全局可见变量的访向样式。例如,假定我们有一个过程P1,它调用过程P2,这两个过程都对全局变量x进行处理。如果P1已经将x分配给一个寄存器,它必须确保在调用P2之前将x保存到P2知晓的一个位置。编译器希望知道被调用过程可能在什么时候访问寄存器分配量,由于不同进程可能是分别编译的,所以增加了获知这一信息的难度。假定P2可能不会触及x,但可能调用另一个可能访向x的进程P3,而P2和P3是分别编译的。由于这些复杂性的存在,大多数编译器会采用比较稳健的方式,由调用者将所有可能在调用期间访问的变量都保存起来

在可以采用任一约定的情况下,有些程序更适于采用被调用者保存,有些程序更适于采用调用者保存。结果,今天的大多数实际系统都采用这两种机制的组合方式。这一约定在应用程序二进制接口(ABI)中指定,它确定了一些基本规则,指出哪些寄存器应当由调用者保存,哪些应当由被调用者保存。

小结:控制流指令

控制流指令属于执行频率最高的一部分指令。尽管条件分支有许多选项,但我们希望新体系结构中的分支寻址能够跳转到分支指令之前或之后数百条指令处。这一要求意味着PC相对分支位移量至少为8位。我们还希望看到跳转指令采用寄存器间接和PC相对寻址,来支持过程返回和当前系统的许多其他功能。

我们现在已经从汇编语言程序员或编译器编写人员的层次,完成了对指令体系结构的浏览。我们介绍了一种采用位移量、立即数和寄存器间接寻址方式的载入-存储体系结构。所介绍的数据为8位、16位、32位和64位整数,还有32位和64位浮点数。指令包括简单操作、PC相对条件分支、用于过程调用的跳转和链接指令,还有用于过程返回的寄存器间接跳转(还有其他一些应用)。

现在我们需要选择如何采用一种便于硬件执行的方式来表示这一体系结构。

指令集编码

显然,上述选择会影响到如何对这些指令进行编码,表示为供计算机执行的二进制形式。这种表示形式不仅影响到程序经过编译后的大小,还会影响到处理器的实现,处理器必须对这种表示形式进行译码,快速找出操作和操作数。操作通常在一个称为操作码的字段中指定。后面将会看到,一个重要决定是如何通过编码将寻址方式与操作结合在一起。

这一决定取决于寻址方式的范围和操作码与寻址方式之间的独立程序。一些较早的计算机有1~5个操作数,每个操作数有10种寻址方式。对于如此大量的组合情况,通常需要为每个操作数使用独立地址标识符:地址标识符告诉使用哪种寻址方式来访问该操作数。

另一个极端是仅有一个存储器操作数、仅有一或两种寻址方式的载入存储计算机;显然,在这种情况下,可以将寻址方式作为操作码的一部分进行编码。

在对指令进行编码时,由于寄存器字段和寻址方式字段可能在-条指令中出现许多次,所以寄存器数目和寻址方式的数目都对指令大小有显著影响。事实上,对于大多数指令,对寻址方式字段和寄存器字体进行编码时所占用的位数,要远多于指定操作码所占用的位数。在对指令集进行编码时,架构师必须平衡以下几种竞争力量。

  1. 希望允许尽可能多的寄存器和寻址方式。
  2. 寄存器字段和寻址方式字段的大小对平均指令大小存在影响,从而对平均程序大小产生影响。
  3. 希望编码后的指令长度易于以流水线实施方式处理。至少,架构师希望指令长度为字节的总数,而不是任意位长度。

图A-11给出三种常见的指令集编码选择。第一种称为变长编码,这是因为它几乎允许对所有操作使用所有寻址方式。当存在许多寻址方式和操作时,这是最佳选择。第二种选择称为定长编码,因为它将操作和寻址方式合并到操作码中。通常,采用定长编码时,所有指令的大小都相同;当寻址方式与操作数较少时,其效果最好。变长编码与定长编码之间进行的权衡是程序大小与处理器译码的难易程度。变长编码在表示程序时尽力减少所用位数,各条指令的大小和要执行的工作量可能会有很大变化。


图A~11指令编码的三种基本变体:变长编码、定长编码、混合编码。变长格式可以支持任何数目的操作数,每个地址标识符确定操作数的寻址方式和标识符的长度。这种方式的代码表示长度通常是最短的,因为不会包含没有使用的字段。定长格式中的操作数个数总是相同的,寻址方式作为操作码的一部分进行指定。它生成的代码规模通常是最大的。尽管字段的位置不会变化,但不同指令会将其用于不同目的。混合编码方法拥有多种由操作码指定的格式,添加了一到两个字段来指定寻址方式,还有一到两个字段来指定操作数地址

让我们看一条80x86指令,作为变长编码的一个例子。

1
add EAX, 1000(EBX)

add是指一条有两个操作数的32位整数加法指令,这个操作码占1个字节。80x86地址标识符为1或2个字节,指定源/目标寄存器(EAX)和第二个操作数的寻址方式(在这个例子中为位移量)与基址寄存器(EBX)。这一组合占用1个字节来指定操作数。在32位模式中,地址字段的大小为1或4个字节。由于1000大于2^8,所以这条指令的长度是1+1+4=6个字节。

80x86指令的长度介于1~17个字节之间。80x86程序通常短于RISC体系结构,后者使用定长格式。

有了变长编码和定长编码这两种指令集设计的极端情况之后,立即就可以想到第三种选择:降低变长体系结构中指令大小与任务的变化程度,但提供多种指令长度,以缩小代码尺寸。这种混合式方法是第三种编码选择,稍后将会看到其示例。

RISC中 的精简代码

嵌入式应用程序中的成本和缩短代码非常重要,随着RISC计算机开始进入这一领域,32位定长格式已经成为一种负担。为应对这一情况,几家制造商提供了其RISC指令集的一种新混合版本,同时拥有16位和32位指令。这些较窄的指令支持较少的运算、较小的地址:与立即数字段、较少的寄存器和两地址格式,而不是RISC计算机的典型三地址格式。

小结:指令集编码

前几节讨论了指令集设计时所作的决策,这些决策决定了架构师是否能够在变长指令编码和定长指令编码之间进行选择。给定选择之后,那些看重代码规模多于性能的架构师会选择变长编码,而看重性能多于看重代码规模的架构师则会选择定长编码。附录E给出了架构师的13个选择结果示例。在第3章和附录C中进一步讨论这种变化对处理器性能的影响。我们几乎已经为将在A.9节介绍的MIPS指令集体系结构奠定了基础。但在开始介绍之前,先来简要地了解一下编译器技术及其对程序特性的影响,这也会有所帮助。

交叉问题:编译器的角色

今天,几乎所有的台式机和服务器应用程序都是用高级语言编写的。这种开发意味着:由于所执行的大多数指令都是编译器的输出,所以指令集体系结构基本上就是编译器目标。在这些应用程序的早期,在体系结构方面作出的决策经常是为了简化汇编语言编程,或者是针对特定内核。由于编译器会显著影响到计算机的性能,所以理解今天的编译器技术对于设计、高效实现指令集是至关重要的。

体系结构方面的选择会影响到为一台计算机生成的代码质量和为其构造优良编译器的复杂性,这种影响可能是正面的,也可能是负面的。在这一节,我们主要从编译器的视角来讨论指令集的关键目标。首先回顾对当前编译器的剖析。接下来讨论编译器技术如何影响架构师的决策,还有架构师如何增大或降低编译器生成良好代码的难度。最后回顾编译器和多媒体处理。

目前编译器的结构

首先让我们看看今天的最佳编译器是什么样的。图A-12显示了目前编译器的结构。

图A-12 编译器通常包括2~4遍扫描(pass),一些优化程度更高的编译器会有更多遍扫描。当输入相同时,以不同优化级别编译的程序应当给出相同结果,图上的结构将这种可能性增至最大。优化扫描的设计是希望获得最优代码,如果希望加快编译速度,并且可以接受较低质量的代码,那就可以跳过优化扫描。扫描就是编译器读取和转换整个程序的一个阶段(phase)。由于优化扫描是独立的,所以有多种语言使用了相同的优化和代码生成扫描。一种新的语言只需要一个新的前端即可

编译器编写人员的首要目标是正确性——所有有效程序的编译结果都必须正确。第二个目标通常是编译后的代码速度。通常,还有一整套目标排在这两个目标之后,包括快速编译、支持调试、语言之间的互操作性。正常情况下,编译器中的各次扫描将更抽象的高级表示转换为逐渐降低层级的表示方式。最后到达指令集级别。这种结构可以帮助控制转换的复杂度,更容易编写出没有错误的编译器。

正确编写编译器是一件很复杂的事情,而所能完成的优化程度主要受这一复杂度的限制。尽管采用多遍扫描结构可以帮助降低编译器的复杂性,但它也意味普编译器必须进行排序,某些转换必须在其他转换之前完成。在图A-12所示的优化编译器框图中可以看出,某些高级优化要在执行很久之后,才可能知道最终代码会是什么样子。一旦执行这种转换,编译器就不太可能再返回并重新审视所有步骤,甚至撤消这些转换,这样的成本太过高昂。无论是编译时间还是复杂性,都不允许进行这种迭代。因此,编译器假定最后的几个步骤有能力处理特殊的问题。

例如,在知道被调用过程的确切大小之前,编译器通常就必须选择对哪些进程调用进行内联展开。编译器编写人员将这一问题称为阶段排序问题。这种转换排序如何与指令集体系结构互动呢?一种名为全局公共子表达式消去法的优化提供了一个很好的例子。这种优化找出一个表达式计算相同取值的两个实例,并将第一次计算的结果值保存在临时存储位置。然后利用这个临时值,清除这一公共表达式的第二次计算。为使这一优化发挥显著效用,必须将临时值分配到寄存器中。否则,先将临时值存储在存储器中,之后再重新载入它,其成本将会抵消因为不用重复计算该表达式所节省的成本。

的确存在一些情况:如果没有将临时值保存到寄存器中,这一优化会减缓代码的运行速度。寄存器分配通常是全局优化扫描即将结束、马上要生成代码时进行的,所以阶段排序使上述问题变得复杂。因此,执行这一优化的优化程序必须假定寄存器分配器会将这一临时值分配到寄存器中。根据转移类型,可以将现代编译器执行的优化进行如下分类:

  • 高级优化一般对源代码执行,并将输出结果传送给之后的优化扫描;
  • 本地优化仅对直行代码段(编译器设计者称为基本块)内的代码进行优化;
  • 全局优化将本地优化扩展到分支范围之外,并引入了一组专为优化循环的转换;
  • 寄存器分配将寄存器与操作数关联在一起;
  • 与处理器相关的优化尝试充分利用特定的体系结构知识;

寄存器分配

鉴于寄存器分配在加快代码速度和使其他优化发挥效用方面扮演的角色,可以说它是最重要的优化之一。今天的寄存器分配算法以一种称为图形着色的技术为基础。这种图形着色技术背后的基本思想是构造一幅图,用来表示可能执行的寄存器分配方案,然后利用这个图来分配寄存器。大致来说,问题在于如何使用有限种颜色,使相关图中两个相邻节点的颜色都不相同。这种方法的重点是将活跃变量全部分配到寄存器中。图形着色问题的求解时间通常是图形大小的指数函数。不过,有一些启发式算法在实际中的应用效果很好,生成分配结果的时间近似与图形大小成线性关系。

当至少有16个通用寄存器(多多益善)可用于为整数变量进行全局分配,而且有其他寄存器分配为浮点变量时,图形着色方法的效果最好。遗憾的是,如果寄存器的数目很少,图形着色的启发式算法很可能会失败,所以图形着色的效果不是太好。

优化对性能的影响

有时很难将一些较简单的优化(本地优化和与处理器相关的优化)与代码生成器中完成的转换隔离开来。典型优化的示例在表A-8中给出。表A-8的最后一列指明对源程序执行所列优化转换的频率。

  • 第三列给出一些常见优化在一组12个小型Fortran和Pascal程序中的静态应用频率。在测量过程中,编译器共完成了9个本地与全局优化。图中给出了这些优化中的6种,剩下3种的总静态频率占18%。“未测量”是指没有测上该优化方法的使用次数。与处理器相关的优化通常是在代码生成器中完成,所有这些优化都未在此次试验中测量。所示百分比是特定类型的静态优化所占的比例。

图A-13显示了对两个程序的指令进行各种优化的效果。在这个例子中,与未经优化的程序相比,已优化程序执行的指令数会减少大约25%~90%。该图表明在提议新指令集功能之前首先浏览已优化代码的重要性,因为编译器可能会将架构师正在尝试改进的指令完全清除。

图A-13 当编译器优化级别变化时,SPEC2000 中lucas和mcf程序中指令数目的变化。第0级表示未优化代码。第1级包含本地优化、代码调度和本地寄存器分配。第2级包括全局优化、循环转换(软件流水线)和全局寄存器分配。第3级增加了过程整合。

编译器与高级语言之间的互动显著影响着程序利用指令集体系结构的方式。这里有两个重要问题:如何对变量进行分配和寻址?需要多少个寄存器才能对变量进行适当分配?为了回答这些问题,必须看看当前高级语言用来保存数据的三个独立区域。

  • 栈用于分配本地变量。栈会在进程调用与返回时相应增大或缩小。栈内的对象是相对于栈指针进行行寻址的,这些对象主要是标量(单变量),而不是数组。栈用于活动记录,而不是用于表达式求值。因此,几乎从来不会在栈中压入或弹出数值。
  • 全局数据区用于静态分配所声明的对象,比如全局变量和常量。这些对象中有很大一部分都是数组或者其他聚合数据结构。
  • 堆用于分配那些不符合栈规则的动态对象。堆中的对象用指针访问,通常不是标量。

对于分配到栈中的对象,寄存器分配的处理效率要远高于对全局变量的处理效率,而寄存器分配对于分配到堆中的对象基本上不可能实现,因为它们是用指针访问的。全局变量和一些栈变量也不可能分配,因为它们具有别名,也就是说有多种方法可以引用变量的地址,从而不能合法地将其放到寄存器中。

例如,考虑以下代码序列,其中&返回变量的地址,*取得指针所指向的对象:

1
2
3
4
p = &a;  // 将a的地址放入p中
a = .. // 直接为a赋值
*p = .. // 使用ρ为a赋值
...a... // 访问a

不可能跨过对*p的赋值而对变量a进行寄存器分配,同时还不生成错误代码。在使用别名时,通常很难甚至不可能判断指针可能指向哪些对象,所以会导致一个实质性问题。编译器必然为保守的,如果有指针可能指向过程中的多个本地变量之一,某些编译器就不会在寄存器中分配该过程的任意本地变量。

架构师如何帮助编译器编写人员

今天,编译器的复杂性并非来自对诸如A=B+C等简单语句的转换。大多数程序都具有局部简单性,简单转换的效果很好。之所以会有这种复杂性,是因为程序规模庞大而且其全局互动非常复杂,还因为编译器的结构决定了在判定哪种代码序列最佳时,一次只能判断一步。编译器编写人员在工作时,通常会遵循他们自己对一条体系结构基础原理的推论:加快常见情况的速度、保证少见情况的正确性。

一些指令集特性可以为编译器编写人员提供帮助。这些特性不应被看作需要严格执行的规则,而应当看到一种指南,便于编写出生成高效、正确代码的编译器。

  • 提供正则性——只要可能,指令集的三个要素操作、数据类型和寻址方式,就应当是正交的。如果体系结构的两个方面互不影响,就说它们是正交的。以操作和寻址方式为例,如果对于任何一个可以应用寻址方式的操作,都可以向其应用所有寻址方式,那就说操作和寻址是正交的。这种正则性有助于简化代码生成过程,如果在决定生成何种代码时,需要分散在编译器的两遍扫描中做出决策,那这一特性尤为重要。这一特性的一个典型反例是:限制可供特定指令类型使用的寄存器。针对专用寄存器体系结构的编译器通常会陷入这种两难境地。因为这一限制,编译器可能会发现有许多空闲寄存器,但却都不适用!
  • 提供原型而非解决方案——与一种语言构造或内核功能“相匹配”的特殊功能通常是无法使用的。为支持高级语言所做的尝试可能仅对一种语言有效,也可能与该种语言的正确、高效实现相偏离,可能有点过头,也可能有所不及。
  • 简化候选项之间的权衡——对于编译器编写人员来说,最艰巨的任务之一就是对于所出现的任何一段代码,都能指出哪种指令序列最为适合。设计者所
    做的任何事情,只要能够帮助编译器编写人员了解替代代码序列的成本,就能帮助改进代码。在进行这种复杂权衡时,最困难的情景之一发生在寄存器存储器体系结构中,就是判断一个变量的引用次数达到多大数值以后,将其载入寄存器的成本才会更低一些。
  • 提供一些指令,将编译时的已知量绑定为常量——编译器编写人员特别讨厌处理器在运行时费力解读一个在编译时就已经知晓的取值。有些指令需要解读在编译时就已经固定的取值,这就是以上原则的绝佳反例之一。例如, VAX进程调用指令(calls)会动态解释一个掩码,这个掩码说明在进行调用时要保存哪些寄存器,但它在编译时就已经固定下来了。

编译器对多媒体指令的支持

SIMD指令实际是一种出色体系结构的简化版本,它拥有自己的编译器技术。多媒体内核最初是为科学代码发明的,通常也是可以向量化的,当然通常是处理较短的向量。我们可以将Intel 的MMX和SSE或者PowerPC的AltiVec 看作简单的短向量计算机:MMX的向量可以有8个8位元素、4个16位元素或2个32位元素,AItiVec 的向量长度是以上长度的两倍。它们被实现为宽寄存器中的相邻窄元素。

这些微处理器体系结构将向量寄存器大小设定到体系结构内部:对于MMX,元素大小的总和限制为64位,AltiVec 限制为128位。当Intel决定扩展到128位向量时,它添加了一整套新指令,名为流式SIMD扩展(SSE)。

向量计算机的一个主要优势是:一次载入许多元素,然后将执行与数据传输重叠起来,从而隐藏存储器访问的延迟。向量寻址方式的目标是收集散布在存储器中的数据,以紧凑方式放置它们,便于对其进行高效处理,然后再将处理结果放回所属位置。向量计算机包括步幅寻址和集中/分散寻址,以提高可向量化程序的数目。步幅寻址在每次访问之间跳过数量固定的一些字,所以顺序寻址经常被称为单位步幅寻址。集中与分散寻址在另一个向量寄存器中查找其地址:将其看作向量计算机的寄存器间接寻址。与之相对,从向量的角度来看,这些短向量SIMD计算机仅支持单位步幅访问:存储器访向一次从单个宽存储器位置载入或存储所有元素。由于多媒体应用程序的数据经常是一些流,起始点和终止点都在存储器中,步幅寻址方式和集中/分散寻址方式是成功实现向量化的必备条件。

下面的例子将一个向量计算机与MMX进行对比,将像素的色彩表示方式由RGB(红、绿、蓝)转换为YUV (发光度色度),每个像素用3个字节表示。这种转换只需要三行C代码,放在循环中即可:

1
2
3
Y = (9798*R  + 19235*G + 3736*B) / 32768;
U = (-4784*R - 9437*G + 4221*B) / 32768 + 128;
V = (20218*R - 16941*G - 3277*B) / 32768 + 128;

宽度为64位的向量计算机可以同时计算8个像素。一个采用步幅寻址的媒体向量计算机将执行以下操作:

  • 3次向量载入(以获得RGB);
  • 3次向量相乘(转换R);
  • 6次向量乘加(转换G和B);
  • 3次向量移位(除以32768);
  • 2次向量加(加上128);
  • 3次向量存储(存储YUV)。

总共有20条指令用于执行前面C代码中转换8个像素的20个操作。(由于向量可能有32 个64 位元素,这一代码实际上可以转换最多32x 8 = 256个像素。)

与之相对,Intel网站显示一个对8个像素执行相同计算的库例程使用了116条MMX指令和6个80x86指令。指令数之所以会增加到6倍是因为没有步幅存储器访问,需要大量的指令来载入RGB像素并解包,然后再打包并存储YUV像素。

采用受体系结构限制的短向量,而且没有多少寄存器和简单的存储器寻址方式,就很难利用向量化编译器技术。因此,这些SIMD指令更可能出现于硬编码库中,而不是编译后的代码中。

小结:编译器的角色

这一节给出了几点建议。第一,我们希望一种新的指令集体系结构中至少拥有16个通用寄存器(另外用于浮点数的寄存器不计在内),以简化使用图形着色的寄存器分配。关于正交性的建议意味着所支持的全部寻址方式都适用于所有传送数据的指令。最后的三点建议(提供原型而非解决方案、简化候选项之间的权衡、不要在运行时绑定常量)都意味着注重简单性是最稳妥的。换句话说,要想清楚,在指令集设计中,少就是多。SIMD扩展是一个出色营销的例子,而不是软硬件协调设计的杰出成果。

融会贯通:MIPS体系结构

在这一节,我们介绍一种名为MIPS的简单64位载入-存储体系结构。MIPS和RISC系列的指令集体系结构的基础与前几节讨论的内容相似。下面回顾一 下我们在每一节对桌面应用程序的期望。

  • A.2节——以载入-存储体系结构使用通用寄存器。
  • A.3节——支持以下寻址方式:位移量(地址偏移大小为12~16位)立即数(大小为8~16位)和寄存器间接寻址。
  • A.4节——支持以下数据大小和类型: 8位、16位、32位和64位整数以及64位IEEE 754浮点数。
  • A.5节——支持以下简单指令(它们占所执行指令的绝大多数):载入、存储、加、减、移动寄存器和移位。
  • A.6节——相等、 不相等、小于、分支(长度至少为8位的PC相对地址)、跳转、调用和返回。
  • A.7节——如果关注性能则使用定长指令编码, 如果关注代码规模则使用变长指令编码。
  • A.8节——至少提供 16个通用寄存器,确保所有寻址模式可应用于所有数据传送指令,希望获得最小规模的指令集。这一节并没有包含浮点程序,但它们经常使用独立的浮点寄存器。其理由是增大寄存器的总数,但不会在指令格式或通用寄存器堆的速度方面产生问题。不过,这两个方面并非相互独立。

我们在介绍MIPS时,将展示它是如何遵循这些建议的。与最近的大多数计算机类似, MIPS强调:

  • 简单的载入-存储指令集;
  • 针对流水线效率的设计,包括定长指令集编码;
  • 编译器目标的效率。

从1985年诞生第一个MIPS处理器以来,已经发布了MIPS的许多版本。我们将使用其中一个现在被称为MIPS64的子集,它经常被简写为MIPS。

MIPS的寄存器

MIPS64有32个64位通用寄存器(GPR),郎R0、R…..R31。GPR有时也称为整数寄存器。此外,还有一组32位浮点寄存器(FPR),即F0、F…..F31,它可以保存32个单精度(32位)值或32位双精度(64位)值。(在保存一个单精度数时,另一半FPR没有使用。)它提供了单精度和双精度浮点运算(32位和64位)。MIPS还包括在单个64位浮点寄存器中对两个单精度操作数进行运算的指令。

R0的值总是0。一些特殊寄存器可以与通用寄存器进行相互转换。其中一个例子就是浮点状态寄存器,用于保存有关浮点运算结果的信息。还有一些指令用于在FPR和GPR之间移动数据。

MIPS的数据类型

MIPS的数据类型包括8位字节、16 位半字、32 位字和64位双字整型数据和32位单精度与64位双精度浮点数据。添加半字是因为它们在诸如C之类的语言中存在这一类型,而且在一些关注数据结构大小的程序(比如操作系统)中非常普遍。出于类似原因添加了单精度浮点操作数。

MIPS64操作对64位整数和32位或64位浮点数进行操作。字节、半字和字被载入通用寄存器中,并通过重复0或符号位来填充GPR的64个位。一旦载入之后,就可以用64位整数运算对其进行操作。

MIPS的数据传输的寻址方式

仅有的数据寻址方式就是立即数寻址和位移寻址,均采用16位字段。寄存器间接寻址通过在16位位移字段中放置0来实现,而采用16位字段的寻址则是以寄存器0为基址寄存器来完成的。尽管这种体系结构中仅支持两种寻址方式,但通过包含0提供了四种有效方式。MIPS存储器可以用64位地址进行字节寻址。它有一个方式位,允许软件选择大端或小端。由于它是一个载入-存储体系结构,介于存储器与GPS或FRP之间的所有引用都是通过载入或存储完成的。通过支持上述数据类型,涉及GPR的存储器访问可以是字节、半字、字或双字。FPR可以载入和存储单精度或双精度数。所有存储器访问都必须对齐。

MIPS指令格式

由于MIPS只有两种寻址方式,所以能把它们编码到操作码中。为便于处理器实现流水线和译码,所有指令的长度都是32位,其中有一个6位的主操作码。图A-14显示了指令布局。这些格式非常简单,提供16位字段用于位移量寻址、立即数常量寻址或PC相对分支寻址。

图A-14 MIPS的指令布局。所有指令均采用三种类型之一进行编码 ,在每种格式的相同位置有公共字段

MIPS 操作

MIPS支持前面推荐的简单操作及其他一些操作。共有四大类指令:载入与存储、ALU运算、分支与跳转、浮点运算。

任意通用或浮点寄存器都可以载入或存储,只是载入R0没有任何效果。表A-9给出载入与存储指令的一些例子。单精度浮点数占据浮点寄存器的一半。单、双精度之间的转换必须显式完成。浮点格式为IEEE754。表A-12中列出了这部分给出的全部MIPS指令。

  • 均使用单一寻址方式,需要存储器值对齐。当然,载入和存储都可用于全部所示数据类型。

为理解这些图形,需要对最初在A.3.2节使用的C描述语言再进行些扩展。

  • 只要所传送数据的长度不够明确,则向符号←附加一个下标。因此,←n表示传送一个n位量。我们使用x, y←z表示应当将z传送给x和y
  • 使用一个下标来表示选择字段中的某一位。在标记字段中的各个位时,最高有效位从0开始。下标可能是单个数位,也可能是一个子范围。
  • 变量Mem用作一个表示主存储器的数组,它是按字节地址索引,可以传送任意数目的字节。
  • 使用一个上标来表示复制字段(例如,048 给出长度为48位的全零字段)。
  • 使用符号#将两个字段串联在一起, 可能出现在数据传输的任一端。

例如,假定R8和R10为64位寄存器:

1
Regs[R10]32, 63 ←32 (Mem[Regs [R8]]10) 24 ## Mem[Regs{R8]]

上式的含义是对某一存储器位置的字节进行符号扩展(该存储器位置由寄存器R8的内容寻址),构成一个32位量,存储在寄存器R10的低位。(R10的高位不变。)

所有ALU指令都是寄存器-寄存器指令。表A-10给出了算术/逻辑指令的一些例子。这些操作包括简单的算术和逻辑运算:加、减、AND、 OR、XOR 和移位。所有这些指令的立即数形式都是用16位符号扩展立即数提供的。操作LUI (加载高位立即数)加载一个寄存器的第32~47位,并将该寄存器的其他位设置为0。LUI 允许在两条指令中内置一个32位常数,也可以在一个额外指令中使用任意常数32位地址进行数据传送。

前面曾经提到,R0用于合并常见操作。载入常数的操作,其实就是执行一个源操作数为R的加立即数指令,寄存器-寄存器移动就是源操作数之一为 R0的加法。我们有时会使用助记符LI(表示载入立即数, load immediate)来表示前者,用助记符MOV表示后者。

MIPS控制流指令

MIPS提供比较指令,它比较两个寄存器,查看第一个寄存器是否小于第二个寄存器。如果该条件为真,则这些指令在目标寄存器中放入1 (表示真);否则,放入数值0。因为这些操作会对寄存器进行“置位”,所以它们被称为“相等置位”、“不相等置位”、“小于置位”,等等。这些比较指令还有立即数形式。

控制是通过一组跳转指令和一组分支指令处理的。表A-11给出了一些典型的分支与跳转指定。通过两种指定目标地址的方法以及是否设定链接来区分四种跳转指令。两种跳转指令使用一个进行两位移位的26位偏移量,然后替代程序计数器的低28位,以确定目标地址(程序计数器是指该跳转指令后面指令的程序计数器)。另两种跳转指令指定了包含目标地址的寄存器。共有两类跳转:单纯跳转和跳转并链接(用于过程调用)。后者将返回地址(下-条顺序指令的地址)放在R31中。

所有分支都是有条件的。分支条件由指令指定,它可以测试寄存器源操作数是否为0;寄存器可以包含一个数据值或比较的结果。还有一些条件分支指令用于判断一个寄存器是否为负数,以及两个寄存器是否相等。分支目标地址由一个16位有符号偏移量指定,该偏移量被左移两位,然后加到指向下一顺序指令的程序计数器。还有一个分支用来关注浮点条件分支的浮点状态寄存器,如下文所述。

条件分支是流水线执行的主要挑战;因此,许多体系结构增加了用于将简单分支转换为条件算术指令的指令。MIPS 包含在等于零或不等于零时执行的条件移动。目标寄存器的值或者保持不变,或者由源寄存器之一的副本 替代,具体取决于其他源寄存器的值是否为零。

MIPS浮点运算

浮点运算操控浮点寄存器,并指出运算是以单精度还是双精度执行。操作MOV.S和MOV.D将一个单精度(Mov.S)或双精度(MOV.D)浮点寄存器复制到另一个同种类型的寄存器中。操作MFC1、MTC1、 DMFC1 和DMTC1在单精度或双精度寄存器与整数寄存器之间移动数据,而且还提供了整数向浮点的转换,反之亦然。

浮点运算为加、臧、乘、除;后缀D表示双精度,后缀S表示单精度(例如, ADD.D、 ADD.S、SUB.D、SUB.S、MUL.D、MUL.S、DIV.D、DIV.S)。 浮点比较指令会对特殊浮点状态寄存器中的一个位进行置位,有一对分支指令可以检测这个位,即: BC1T 和BC1F,也就是浮点真分支和浮点假
分支。

为进一步提高图形例程的性能, MIPS64中提供了一些可以针对64位高低位执行2个32位浮点操作的指令。这些成对单精度操作包括:ADD.PS、 SUB.PS、MUL.PS和DIV.PS。(它们使用双精度载入和存储指令进行载入和存储。)MIPS64认识到多媒体应用程序的重要性,还引入了整数与浮点乘加指令: MADD、 MADD.S、MADD.D和MADD.PS。在这些合并操作中,寄存器的宽度都相同。表A-12给出了一部分 MIPS64操作的清单及其含义。


MIPS指令集的使用

为使读者了解哪些指令使用得更为频繁,表A-13中给出了5个SPECint2000程序中各指令及指令类别的使用频率,表A-14给出了对于5个SPECfp2000程序的相同数据。


存储器层次结构回顾

引言

本附录对存储器层次结构进行了快速回顾,包括缓存与虚拟存储器的基础知识、性能公式、简单优化。第一节介绍下面36个术语。

  • 缓存(cache)
  • 全相联(fully associative)
  • 写入分派(write allocate)
  • 虚拟存储器(virtual memory)
  • 重写(脏)位(dirtybit)
  • 统一缓存(unifed cache)
  • 存储器停顿周期(memorystallcycles)
  • 块偏移(block offset)
  • 每条指令缺失数(misses per instruction)
  • 直接映射(direct mnapped)
  • 写回(write back)
  • 块(block)
  • 有效位(valid bit)
  • 数据缓存(data cache)
  • 局域性(locality)
  • 块地址(block address)
  • 命中时间(hittime)
  • 地址跟踪(address trace)
  • 直写(write through)
  • 缓存缺失(cache miss)
  • 组(set)
  • 指令缓存(instruction cache)
  • 页面错误(page fault)
  • 随机替换(random replacement)
  • 存储器平均访问时间(average memory access time)
  • 缺失率(missrate)
  • 索引字段(index feld)
  • 缓存命中(cache hit)
  • n路组相联(n-way set associative)
  • 无写入分派(no-write allocate)
  • 页(page)
  • 最近最少使用(least-recently used)
  • 写入缓冲区(write buffer)
  • 缺失代价(miss penalty)
  • 标志字段(tagfield)
  • 写停顿(write stall)

缓存是指地址离开处理器后遇到的最高级或第一级存储器层次结构。由于��域性原理适用于许多级别,而且充分利用局域性来提高性能的做法非常普遍,所以现在只要利用缓冲方法来重复使用常见项目,就可以使用缓存,文件缓存、名称缓存等都是一些实例。

如果处理器在缓存中找到了所需要的数据项,就说发生了缓存命中。如果处理器没有在缓存中找到所需要的数据项,就是发生了缓存缺失。从主存储器中提取固定大小且包含所需字的数据集,并将其放在缓存中,这个数据集称为块。时域局域性告诉我们:我们很可能会在不远的将来再用到这个字,所以把它放在缓存中是有用的,在这里可以快速访问它。由于空域局域性,马上用到这个块中其他数据的可能性也很高。

缓存缺失需要的时间取决于存储器的延迟和带宽。延迟决定了提取块中第一个字的时间,带宽决定了提取这个块中其他内容的时间。缓存缺失由硬件处理,会导致采用循序执行方式的处理器暂停或停顿,直到数据可用为止。在采用乱序执行方式时,需要使用该结果的指令仍然必须等待,但其他指令可以在缺失期间继续进行。

与此类似,程序引用的所有对象不一定都要驻存在主存储器中。虚拟存储器意味着一些对象可以驻存在磁盘上。地址空间通常被分为固定大小的块,称为页。在任何时候,每个页要么在主存储器中,要么在磁盘上。当处理器引用一个页中既不在缓存中也不在主存储器中的数据项时,就会发生页错误,并把整个页从磁盘移到主存储器中。由于页错误消耗的时间太长,所以它们由软件处理,处理器不会停顿。在进行磁盘访问时,处理器通常会切换到其他某一任务。从更高级别来看,缓存和主存储器在对引用局域性的依赖性方面、在大小和单个位成本等方面的关系,类似于主存储器与磁盘的相应关系。

表B-1给出了各种计算机(从高端台式机到低端服务器)每一级存储器层次结构的大小与访问时间范围。

  • 嵌入式计算机可能没有磁盘存储,存储器和缓存也要小得多。在移向层次结构的更低级别时,访问时间延长,从而有可能以较低的反应速度来管理数据传输。实现技术显示了这些功能所用的典型技术。

缓存性能回顾

由于局域性的原因,再加上存储器越小其速度越快,所以存储器层次结构可以显著提高性能。评价缓存性能的一种方法是扩展第1章给出的处理器执行时间公式。我们现在考虑处理器在等待存储器访问而停顿时的周期数目,称为存储器停顿周期。性能结果为处理器周期与存储器停顿周期之和与时钟周期时间的乘积:

CPU执行时间 = (CPU时钟周期+存储器停顿周期) x时钟周期时间

此公式假定CPU时钟周期包括处理缓存命中的时间,并假定处理器在发生缓存缺失时停顿。

存储器停顿周期数取决于缺失数目和每次缺失的成本,后者称为缺失代价:

存储器停顿周期 = 缺失数 x 缺失代价 = IC x 缺失/指令 x 缺失代价 = IC x 存储器访问/指令 x 缺失率 x 缺失代价

最后一种形式的优点在于其各个分量容易测量。我们已经知道如何测量指令数(IC)。(对于推测处理器,只计算提交的指令数。)可以采用同一方式来测量每条指令的存储器引用数;每条指令需要一次指令访问,很容易判断它是否还需要数据访问。

注意,我们计算出缺失代价,作为平均值,但下面将其作为常数使用。在发生缺失时,缓存后面的存储器可能因为先前的存储器请求或存储器刷新而处于繁忙状态。在处理器、总线和存储器的不同时钟之间进行交互时,时钟周期数也可能发生变化。所以请记住,为缺失代价使用单一数值是一种简化。

缺失率分量就是缓存访问中导致缺失的访问比例(即,导致缺失的访问数除以总访问数)。缺失率可以用缓存模拟器测量,它会取得指令与数据引用的地址跟踪,模拟该缓存行为,以判断哪些引用命中,哪些缺失,然后汇报命中与缺失总数。今天的许多微处理器提供了用于计算缺失与存储器引用数的硬件,这一缺失率测量方式要容易得多、快得多。

由于读取和写入操作的缺失率和缺失代码通常是不同的,所以上面的公式得出的是一个近似值。存储器停顿时钟周期可以用每条指令的存储器访问次数、读写操作的缺失代价(以时钟周期为单位入读写操作的缺失率来定义:

存储器停顿时钟周期 = IC x 每条指令的读取操作 x 读取缺失率 x 读取缺失代价 + IC x 每条指令的写入操作 x 写入缺失率 x 写入缺失代价

我们通常会合并读写操作,求出读取与写入操作的平均缺失度与缺失代价,以简化上面的完整公式:

存储器停顿时钟周期 = IC x 存储器访问/指令 x 缺失率 x 缺失代价

缺失率是缓存设计中最重要的度量之一,但在后面各节将会看到,它不是唯一的度量标准。

假定有一个计算机,当所有存储器访向都在缓存中命中时,其每条指令的周期数(CPI)为1.0。仅有的数据访问就是载入和存储,占总指令数的50%。如果缺失代价为25个时钟周期,缺失率为2%,当所有指令都在缓存中命中时,计算机可以加快多少?

首先计算计算机总是命中时的性能:

CPU执行时间 = (CPU时钟周期+存储器停顿周期) x 时钟周期 = (IC x CPI+0) x 时间周期 = IC x 1.0 x 时间周期

现在,对于采用实际缓存的计算机,首先计算存储器停顿周期:

存储器停顿周期 = IC x 存储器访问数/指令 x 缺失率 x 缺失代价 = IC x (1+0.5) x 0.02 x 25 = IC x 0.75

式中,中间项(1+0.5)表示每条指令有1次指令访问和0.5次数据访问。总性能为:

CPU执行时间(缓存) = (IC x 1.0 + IC x 0.75) x 时间周期 = 1.75 x IC x 时钟周期

性能比是执行时间的倒数:

CPU执行时间(缓存)/CPU执行时间 = (1.75 x IC x 时钟周期) / (1.0 x IC x 时钟周期) = 1.75

没有缓存缺失时,计算机的速度为有缺失时的1.75倍。

一些设计师在测量缺失率时更愿意表示为每条指令的缺失数,而不是每次存储器引用的缺失数。这两者的关系为:
缺失数/指令数 = 缺失数 x 存储器访问数 / 指令数 = 缺失数 x 存储器访问数 / 指令数

如果知道每条指令的平均存储器访问数,后面一个公式是有用的,因为这样可以将缺失率转换为每条指令的缺失数,反之亦然。例如,我们可以将上面示例中每次存储器引用的缺失率转换为每条指令的缺失:

缺失数/指令数 = 缺失数 x 存储器访问数/指令数 = 0.02 x 1.5 = 0.030

顺便说一下, 每条指令的缺失数经常以每千条指令的缺失数给出,以显示整数而非小数。因此,上面的答案也可以表示为每1000条指令发生30次缺失。

表示为“每条指令的缺失数”的好处在于它与硬件实现无关。例如,推测处理器提取的指令数大约是实际提交指令数的两倍,如果测量每次存储器引用而非每条指令的缺失数,那么就可以人为地降低缺失率。其缺点在于每条指令的缺失数与体系结构相关;例如,对于80x86与MIPS,每条指令的存储器访问平均数可能会有很大不同。因此,对于仅使用单一计算机系列的架构师,最常使用的是每条指令的缺失数,不过与RISC体系结构的相似性也可以让人们深入理解其他体系结构。

为了展示这两个缺失率公式的等价性,让我们重做上面的例题,这一次假定每千条指令的缺失率为30。根据指令个数,存储器停顿时间为多少?

重新计算存储器停顿周期:存储器停顿周期 = 缺失数 x 缺失代价 = IC x 缺失数/指令数 x 缺失代价 = IC/1000 x 缺失数/(指令数x1000) x 缺失代价 = IC/1000 x 30 x 25 = IC x 0.75

得到的答案与前面的例题相同,表明这两个公式的等价性。

4个存储器层次结构问题

我们通过回答有关存储器层次结构第一级的4个常见问题来继续对缓存的介绍。

  • 问题1:一个块可以放在上-级的什么位置? (块的放置)
  • 问题2:如果一个块在上一级中,如何找到它? (块的识别)
  • 问题3:在缺失时应当替换哪个块? (块的替换)
  • 问题4:在写入时会发生什么? (写入策略)

这些问题的答案可以帮助我们理解存储器在层次结构不同级别所做的不同折中;因此,我们对每个示例都会问这四个问题。

问题1:一个块可以放在缓存中的什么位置?

图B-1显示,根据对块放置位置的限制,可以将缓存组织方式分为以下三类。

  • 如果每个块只能出现在缓存中的一个位置,就说这一缓存是直接映射的。这种映射通常是:(块地址)MOD(缓存中的块数)
  • 如果一个块可以放在缓存中的任意位置,就说该缓存是全相联的。
  • 如果一个块可以放在缓存中由有限个位置组成的组(set)内,则该缓存是组相联的。组就是缓存中的一组块。块首先映射到组,然后这个块可以放在这个组中的任意位置。通常以位选择方式来选定组:(块地址)MOD(缓存中的组数),如果组中有n个块,则称该缓存放置为n路组相联。

从直接映射到全相联的缓存范围实际上就是组相联的一个统一体。直接映射就是一路组相联,拥有m个块的全相联缓存可以称为“m路组相联”。同样,直接映射可以看作是拥有m个组,全相联可以看作拥有一个组。

图B-1 这一缓存示例有8个块帧,存储器有32个块。三种缓存选项由左向右给出。在全相联中,来自较低层级的块12可以进入该缓存8个块帧的任意一个。采用直接映射时,块12只能放在块帧4(12 mod 8)中。组相联拥有这两者的一些共同特性,允许这个块放在第0组的任意位置(12 mod 4)。由于每个组中有两个块,所以这意味着块12可以放在缓存的块0或块1中。实际缓存包含数千个块帧,实际存储器包含数百万个块。拥有四个组、每组两个块的组相联组织形式称为两路组相联。假定缓存中没有内容,而且所关心的块地址确认了较低级别的块12

今天的绝大多数处理器缓存为直接映射、两路组相联或四路组相联,其原因将在稍后介绍。

问题2:如果一个块就在缓存中,如何找到它?

缓存中拥有每个块帧的地址标志,给出块地址。每个缓存块的标志中可能包含所需要的信息,会对这些标志进行查看,以了解它是否与来自处理器的块地址匹配。由于速度非常重要,所以会对所有可能标志进行并行扫描,这是一条规则。

必须存在一种方法来获知缓存块中不包含有效信息。最常见的过程是向标志中添加一个有效位,表明这一项是否包含有效地址。如果没有对这个位进行置位,那就不可能存在对这一地址的匹配。

在继续讨论下一问题之前,先来研究一个处理器地址与缓存的关系。图B-2显示了址是如何划分的。第一次划分是在块地址和块偏移之间,然后再将块帧地址进一步分为标志字段和索引字段。块偏移字段从块中选择期望数据,索引字段选择组,通过对比标志字段来判断是否命中。尽管可以对标志之外的更多地址位进行对比,但并不需要如此,原因如下所述。

  • 在对比中不应使用偏移量,因为整个块或者存在或者不存在,因此,根据定义,所有块偏移都会导致匹配。
  • 核对索引是多余的,因为它是用来选择待核对组的。例如,存储在第0组的地址,其索引字段必须为0,否则就不能存储在第0组中;第1组的索引值必须为1,以此类推。这一优化通过缩小缓存标志的宽度来节省硬件和功率。

图B-2 组相联或直接映射缓存中地址的三个组成部分。标志用于检查组中的所有块,索引用于选择该组。块偏移是块中所需数据的地址。全相联缓存没有索引字段

如果总缓存大小保持不变,增大相联度将提高每个组中的块数,从而降低索引的大小、增大标志的大小。即,图B-2中的标志索引边界因为相联度增大而向右移动,到端点处就是全相关缓存,没有索引字段。

问题3:在缓存缺失时应当替换哪个块?

当发生缺失时,缓存控制器必须选择一个用期望数据替换的块。直接映射布置方式的好处就是简化了硬件判决——事实上,简单到没有选择了:只会查看一个块帧,以确定是否命中,而且只有这个块可被替换。对于全相联或组相联布置方式,在发生缺失时会有许多块可供选择。主要有以下三种策略用来选择替换哪个块。

  • 随机——为进行均匀分配,候选块是随机选择的。一些系统生成伪随机块编号,以实现可重复的行为,这在调试硬件时有一定的用处。
  • 最近最少使用(LRU)——为尽量避免抛弃不久就会用到的信息,会记录下对数据块的访问。依靠过去行为来预测未来,将替换掉未使用时间最久的块。LRU依赖于局域性的一条推论:如果最近用过的块很可能被再次用到,那么放弃最近最少使用的块是一种不错的选择。
  • 先入先出(FIFO)——因为LRU的计算可能非常复杂,所以这一策略是通过确定最早的块来近似LRU,而不是直接确定LRU。

随机替换的一个好处是易于用硬件实现。随着要跟踪块数的增加,LRU的成本也变得越得来高,通常只能采用近似法。一种常见的近似方法(经常称为“伪LRU”)是为缓存中的每个组设定一组比特,每个比特应于缓存中的一路,一路就是组相联缓存中的条(bank);四路组相联缓存中有四路。在访向一组时开启一个特定比特,这一比特与包含所需块的路相对应;如果与一个组相关联的所有比特都被开启,除最近刚被开启的比特之外,将所有其他比特关闭。在必须替换一个块时,处理器从相应被关闭的路中选择一个块,如果有多种选择,则随机选定。这种方法会给出近似LRU,这是因为自上次访问组中的所有块之后,被替换块再没有被访问过。

表B-2给出了LUR、随机和FIFO替换方式中的缺失率之差。

  • 对于最大的缓存,LRU和随机方式之间没有什么差别,当缓存较小时,LUR胜过其他几种方式。当缓存较小时,FIFO通常优于随机方式。

问题4:在写入时发生什么?

大多数处理器缓存访问都是读取操作。所有指令访问都是读取,大多数指令不会向存储器写入数据。要加快常见情景的执行速度,就意味着要针对读取操作对缓存进行优化,尤其是处理器通常会等待读取的完成,而不会等待写入操作。但Amdahl定律提醒我们,高性能设计不能忽视写入操作的速度。

幸运的是,常见情景也是容易提升速度的情景。可以在读取和比对标志的同时从缓存中读取块,所以只要有了块地址就开始读取块。如果读取命中,则立即将块中所需部分传送给处理器。如果读取缺失,那就没有什么好处。

不能对写入操作应用这一优化。要想修改一个块,必须先核对标志,以查看该地址是否命中。由于标志核对不能并行执行,所以写入操作需要的时间通常要长于读取。另一种复杂性在于处理器还指定写入的大小,通常介于1~8个字节之间;只能改变一个块的相应部分。而读取则与之不同,可以毫无顾虑地访问超出所需的更多字节。写入策略通常可以用来区分缓存设计。在写入缓存时,有下面两种基本选项。

  • 直写——信息被写入缓存中的块和低一级存储器中的块。
  • 写回——信息仅被写到缓存中的块。修改后的缓存块仅在被替换时才被写到主存储器。

为减少在替换时写回块的频率,通常会使用一种称为重写(脏)位的功能。这一状态位表示一个块是脏的(在缓存中经历了修改)还是干净的(未被修改)。如果它是干净的,则在缺失时不会写回该块,因为在低级存储器中可以看到缓存中的相同信息。

写回和直写策略都有自己的优势。采用写回策略时,写入操作的速度与缓存存储器的速度相同,一个块中的多个写入操作只需要对低一级存储器进行一次写入。由于一些写入内容不会进入存储器,所以写回方式使用的存储器带宽较少,使写回策略对多处理器更具吸引力。由于写回策略对存储器层次结构其余部分及存储器互连的使用少于直写,所以它还可以节省功耗,对于嵌入式应用极具吸引力。

相对于写回策略,直写策略更容易实现。缓存总是清洁的,所以它与写回策略不同,读取缺失永远不会导致对低一级存储器的写入操作。直写策略还有一个好处:下一级存储器中拥有数据的最新副本,从而简化了数据一致性。数据一致性对于多处理器和IO来说非常重要。多级缓存使直写策略更适于高一级缓存,这是因为写入操作只需要传播到下一个较低级别,而不需要传播到所有主存储器。

稍后将会看到,IO和多处理器有些反复无常:它们希望为处理器缓存使用写回策略,以减少存储器通信流量,又希望使用直写策略,以与低级存储器层次结构保持缓存一致。如果处理器在直写期间必须等待写入操作的完成,则说该处理器处于写入停顿状态。减少写入停顿的常见优化方法是写入缓冲区,利用这一优化,数据被写入缓冲区之后,处理器就可以立即继续执行,从而将处理器执行与存储器更新重叠起来。稍后将会看到,即使有了写入缓冲区也会发生写入停顿。

由于在写入时并不需要数据,所以在发生写入缺失时共有以下两种选项。

  • 写入分派——在发生写入缺失时将该块读到缓存中,随后对其执行写入命中操作。在这一很自然的选项中,写入缺失与读取缺失类似。
  • 无写入分派——这显然是一种不太寻常的选项,写入缺失不会影响到缓存。而是仅修改低一级存储器中的块。

因此,在采用无写入分派策略时,在程序尝试读取块之前,这些块一直都在缓存之外,但在采用写入分派策略时,即使那些仅被写入的块也会保存在缓存中。让我们看一个例子。

假定一个拥有许多缓存项的全相联写回缓存,在开始时为空。下面是由5个存储器操作组成的序列(地址放在中括号内):

1
2
3
4
5
Write Mem[100];
Write Mem[100];
Read Mem[200];
Write Mem[200];
Write Mem[100] .

在使用无写入分派和写入分派时,命中数和缺失数为多少?

对于无写入分派策略,地址100不在缓存中,在写入时不进行分派,所以前两个写入操作将导致缺失。地址200 也不在缓存中,所以该读取操作也会导致缺失。接下来对地址200进行的写入将会命中。最后一个对地址100的写入操作仍然是缺失。所以对无写入分派策略,其结果是4次缺失和1次命中。

对于写入分派策略,前面对地址100 和地址200的访问导致缺失,由于地址100和地址200都可以在缓存中找到,所以其他写入操作将会命中。因此,采用写入分派时,其结果为2次缺失和3次命中。

任何一种写入缺失策略都可以与直写或写回策略起使用。通常,写回缓存采用写入分派策略,希望对该块的后续写入能够被缓存捕获。直写缓存通常使用无写入分派策略。其原因在于:即使存在对该块的后续写入操作,这些写入操作仍然必须进入低一级存储器,那还有什么好处呢?

举例:Opteron数据缓存

为了展示这些思想的本质,图B-3给出了AMD Opteron微处理器中数据缓存的组织方式。该缓存包含65536 (64KB)字节的数据,块大小为64字节,采用两路组相联布置方式、最近使用最少替代策略、写缺失时采用写入分派。

图B-3 Opteron 微处理器中数据缓存的组织方式。这个64 KB的缓存为两路组相联,块大小为64字节。长为9位的索引从512个组中进行选择。一次读取命中的四个步骤 (按发生顺序表示为带圆圈的数字)标记了这一组织方式。块偏移量的三位加上索引,提供了RAM地址,恰好选择8个字节。因此,该缓存保存了由4096个64位字组成的群组,每个群组包含512个组的一半。从低级存储器至缓存的线路用于在发生缺失时载入缓存,不过未在这一示例中 展示。离开处理器的地址大小为40位,这是因为它是物理地址而不是虚拟地址。图B-14解释了Opteron 如何从虚拟地址映射到物理地址,以进行缓存访问

我们通过图B-3中标注的命中步骤来跟踪一次缓存命中的过程。(这4个步骤用带圆圈的数字表示。)如B.5节所述,Opteron 向缓存提供48位虚拟地址进行标志比对,它将同时被翻译为40位物理地址。

Opteron之所以没有利用虚拟地址的所有64位,是因为它的设计者认为还没有人会需要那么大的虚拟地址空间,而较小的空间可以简化Opteron虚拟地址的映射。设计者计划在未来的微处理器中增大此虚拟地址。

进入缓存的物理地址被分为两个字段:34 位块地址和6位块偏移量(64=2^6,34+6=40)。块地址进一步分为地址标志和缓存索引。第1步显示了这一划分。

缓存索引选择要测试的标志,以查看所需块是否在此缓存中。索引大小取决于缓存大小、块大小和级相联度。Opteron 缓存的组相联度被设置为2,索引计算如下:

2^索引 = 缓存大小/(块大小x组相联度) = 65536/(64x2) = 512 = 2^9

因此,索引宽9位,标志宽34-9=25位。尽管这是选择正确块所需要的索引,但64个字节远多于处理器希望一次使用的数目。因此,将缓存存储器的数据部分安排为宽8个字节更有意义一些,这是64位Opteron处理器的自然数据字。因此,除了用于索引正确缓存块的9个位之外,还使用来自块偏移量的另外3个位来索引恰好8个字节。索引选择是图B-3中的第2步。

在从缓存中读取这两个标志之后,将它们与处理器所提供块地址的标志部分进行对比。这一对比是图中第3步。为了确保标志中包含有效信息,必须设置有效位,否则,对比结果将被忽略。假定有一个标志匹配,最后一步是通知处理器,使用2选1多工器的获胜输入从缓存中载入正确数据。Opteron可以在2个时钟周期内完成这四个步骤,因此,如果后面2个时钟周期中的指令需要使用载入结果,那就得等待。

在Opteron中,写入操作的处理要比读取操作更复杂,这一点与在任何缓存中都是一致的。如果要写入的字在缓存中,前三步相同。由于Opteron是乱序执行的,所以只有在它发出指令已提交而且缓存标志比对结果显示命中的信号之后,才会将数据写到缓存中。

到目前为止,我们假定的是缓存命中的常见情景。那在缺失时会发生什么情况呢?在读取缺失时,缓存会向处理器发出信号,告诉它数据还不可用,并从下一级层级结构中读取64个字节。对于该块的前8个字节,延迟为7个时钟周期,对于块的其余部分,为每8个字节需要2个时钟周期。由于数据缓存是组相联的,所以需要选择替换哪个块。Opteron 使用LRU (选择在最早之前被引用的块),所以每次访问都必须更新LRU位。替换一个块意味着更新数据、地址标志、有效位和LRU位。

由于Opteron使用写回策略,旧的数据块可能已经被修改,所以不能简单地放弃它。Opteron为每个块保存1个脏位,以记录该块是否被写入。如果“牺牲块”被修改,它的数据和地址就被发送给牺牲块缓冲区。(这种结构类似于其他计算机中的写入缓冲区。)Opteron有8个牺牲块的空间。它会将牺牲块写到低一级层次结构,这一操作与其他缓存操作并行执行。如果牺牲块缓冲区已满,缓存就必须等待。

由于Opteron在读取缺失和写入缺失时都会分派一个块,所以写入缺失与读取缺失非常类似。

我们已经看到数据缓存是如何工作的,但它不可能提供处理器所需要的所有存储器:处理器还需要指令。尽管可以尝试用一个缓存来提供数据、指令两种缓存,但这样可能会成为瓶颈。例如,在执行载入或存储指令时,流水化处理器将会同时请求数据字和指令字。因此,单个缓存会表现为载入与存储的结构性冒险,从而导致停顿。解决这一问题的一种简单方法是分开它:一个缓存专门用于指令,另一个缓存专门用于数据。最近的处理器中都使用了独立缓存,包括Opteron在内。因此,它有一个64 KB的指令缓存和64 KB的数据缓存。

处理器知道它是在发射一个指令 地址,还是一个数据地址,所以可能存在用于这两者的独立端口,从而使存储器层次结构和处理器之间的带宽加倍。采用分离缓存还提供了分别优化每个缓存的机会:采用不同的容量、块大小和相联度可能会得到更佳性能。

表B-3显示指令缓存的缺失率低于数据缓存。指令与数据缓存分离,消除了因为指令块和数据块冲突所导致的缺失,但这种分离固定了每种类型所能使用的缓存空间。与缺失率相比,哪个更重要呢?要公平地对比指令数据分离缓存和统一缓存, 需要总缓存大小相同。例如,分离的16 KB指令缓存和16 KB数据缓存应当与32KB统一缓存相对比。要计算分离指令与数据缓存的平均缺失率,需要知道对每种缓存的存储器引用百分比。从附录A中的数据可以找到:

指令引用为100%(100%+26%+10%),大约为74%;数据引用为(26%+ 10%)(100%+26%+10%),大约为26%。稍后将会看到,分割对性能的影响并非仅限于缺失率的变化。

缓存性能

由于指令的数目与硬件无关,所以用这个数值来评价处理器性能是很有诱惑力的。由于缺失率也与硬件的速度无关,所以评价存储器层次结构性能的相应焦点就主要集中在缺失率上。后面将会看到,缺失率可能与指令数目一样产生误导。存储器层次结构性能的一个更好度量标准是存储器平均访问时间:

存储器平均访问时间=命中时间+缺失时间x缺失代价

式中,命中时间是指在缓存中命中的时间;其他两项已经在前面看到过。平均访问时间的各个分量可以用绝对时间衡量,比如,一次命中的时间为0.25~1.0 ns,也可以用处理器等待该存储器的时间周期数来衡量,比如一次缺失代价为150~200 个时钟周期。注意,存储器平均访问时间仍然是性能的间接度量;尽管它优于缺失率,但并不能替代执行时间。

这个公式可以帮助我们决定是选择分离缓存还是统一缓存。

16KB指令缓存加上16KB数据缓存相对于一个32KB统一缓存,哪一种的缺失率较低?利用表B-3中的缺失率数据来帮助计算正确答案,假定36%的指令为数据传输指令。假定一次命中需要1个时钟周期,缺失代价为100个时钟周期。对于统一缓存,如果仅有一个缓存端口来满足两个同时请求,一次载入或存储命中另需要一个时钟周期。利用第3章的流水线技术,统一缓存会导致结构性冒险。每种情况下的存储器平均访问时间为多少?假定采用具有写入缓冲区的直写缓存,忽略由于写入缓冲区导致的停顿。

首先让我们将每千条指令的缺失数转换为缺失率。求解上面的一般公式,缺失率为:

缺失率 = ( (缺失率/1000条指令) / 1000) / (存储器访问数/指令数)

由于每次指令访问都正好有一次存储器访问来提取指令,所以指令缺失率为:

缺失率(16 KB指令) = 3.82 / 1000 = 0.004

由于36%的指令为数据传输,所以数据缺失率为:

缺失率(16 KB数据) = 40.9 / 1000 / 0.36 = 0.114

统一缺失率需要考虑指令和数据访问:

缺失率(32 KB统一) = 43.3 / 1000 / (1.00 +0.36) = 0.0318

如上所述,大约74%的存储器访问为指令引用。因此,分离缓存的总缺失率为:

(74% x 0.004)+(26% x 0.114) = 0.0326

因此,32KB 统一缓存的实际缺失率略低于两个16 KB缓存。

存储器平均访问时间公式可分为指令访问和数据访问:

存储器平均访问时间 = 指令百分比 x (命中时间+指令缺失率x缺失代价) + 数据百分比 x (命中时间+数据缺失率x缺失代价)

因此,每种组织方式的时间为:

存储器平均访问时间(分离) = 74% x (1 + 0.004 x 200) + 26% x (1 + 0.114 x 200) = (74% x 1.80)+(26% x 23.80) = 7.52

存储器平均访问时间(统一) = 74% x (1 + 0.0318 x 200) + 26% x (1 + 1 + 0.0318 x 200) = 7.62

因此,在这个示例中,尽管分离缓存(每时钟周期提供两个存储器端口,从而避免了结构性冒险)的实际缺失率较差,但其存储器平均访问时间要优于单端口统一缓存。

存储器平均访问时间与处理器性能

一个显而易见的问题是:因缓存缺失导致的存储器平均访问时间能否预测处理器性能。

首先,还有其他原因会导致停顿,比如使用存储器的I/O 设备产生争用。由于存储器层次结构导致的停顿远多于其他原因导致的停顿,所以设计人员经常假定所有存储器停顿都是由于缓存缺失导致的。我们这里也采用这一简化假定,但在计算最终性能时,一定要考虑所有存储器停顿。

第二,上述问题的回答也受处理器的影响。采用循序执行处理器,那回答基本上就是肯定的。处理器会在缺失期间停顿,存储器停顿时间与存储器平均访问时间存在很强的相关性。现在假定采用循序执行,但下一小节会返回来讨论乱序处理器。

如上一节所述,可以为CPU时间建立如下模型:

CPU时间 = (CPU执行时钟周期+存储器停顿时钟周期) x 时钟周期时间

这个公式会产生一个问题:一次缓存命中的时钟周期应看作CPU执行时钟周期的一部分,还是存储器停顿时钟周期的一部分?尽管每一种约定都有自己的正当理由,但最为人们广泛接受的是将命中时钟周期包含在CPU执行时钟周期中。

我们现在可以研究缓存对性能的影响了。

让我们对第一个示例使用循序执行计算机。假定缓存缺失代价为200个时钟周期,所有指令通常都占用1.0个时钟周期(忽略存储器停顿)。假定平均缺失率为2%,每条指令平均有1.5 次存储器引用,每千条指令的平均缓存缺失数为30。如果考虑缓存的行为特性,对性能的影响如何?使用每条指令的缺失数及缺失率来计算此影响。

CPU时间=ICx( CPI执行 + 存储器停顿时钟周期/指令数)x时钟周期时间

其性能(包括缓存缺失)为:

CPU时间包括缓存= IC x [1.0+(30/1000 X 200)] x周期时钟时间 = ICx7.00x时钟周期时间

现在使用缺失率计算性能:

CPU时间 = IC x (CPU执行+缺失率x存储器访问/指令x缺失代价)x时钟周期时间

CPU时间(包括缓存) = IC x [1.0x (1.5 x 2% X 200)] x 时钟周期时间 = ICx7.00x时钟周期时间

在有、无缓存情况下,时钟周期时间和指令数均相同。因此,CPU时间提高至7倍,CPI 从“完美缓存”的1.00增加到可能产生缺失的缓存的7.00。在根本没有任何存储器层次结构时,CPU 将再次升高到1.0+200x1.5=301,比带有缓存的系统长出40多倍。

如上例所示,缓存特性可能会对性能产生巨大影响。此外,对于低CPI、高时钟频率的处理器,缓存缺失会产生双重影响。

  1. CPI(执行)越低,固定数目的缓存缺失时钟周期产生的相对影响越高。
  2. 在计算CPI时,一次缺失的缓存缺失代价是以处理器时钟周期进行计算的。因此,即使两个计算机的存储器层次结构相同,时钟频率较高的处理器在每次缺失时会占用较多的时钟周期,CPI的存储器部分也相应较高。

对于低CPI、高时钟频率的处理器,缓存的重要性更高,因此,如果在评估此类计算机的性能时忽略缓存行为,其危险性更大。Amdahl 定律再次发挥威力!

尽管将存储器平均访问时间降至最低是一个合理的目标(在本附录中大多使用这一目标),但请记住,最终目标是缩短处理器执行时间。下面的例子说明如何区分这两者。

两种不同缓存组织方式对处理器性能的影响如何?假定完美缓存的CPI为1.6,时钟周期时间为0.35 ns,每条指令有1.4 次存储器引用,两个缓存的大小都是128 KB,两者的块大小都是64字节。一个缓存为直接映射,另一个为两路组相联。图B-3显示,对于组相联缓存,必须添加一个多工器,以根据标志匹配在组中的块之间作出选择。由于处理器的速度直接与缓存命中的速度联系在一起,所以假定必须将处理器时钟周期时间扩展1.35 倍,才能与组相联缓存的选择多工器相适应。对于一级近似,每一种缓存组织方式的缓存缺失代价都是65纳秒。

首先,计算存储器平均访问时间,然后再计算处理器性能。假定命中时间为1 个时钟周期,128 KB直接映射缓存的缺失率为2.1%,同等大小的两路组相联缓存的缺失率为1.9%。

存储器平均访问时间为:

存储器平均访问时间=命中时间+缺失率x缺失代价

因此,每种组织方式的时间为:

存储器平均访问时间(一路) = 0.35+(0021 x 65)=1.72ns

存储器平均访问时间(两路) = 0.35 x 1.35+(0019 X 65)=1.71ns

这一存储器平均访问时间优于两路组相联缓存。

处理器性能为:

CPU时间= IC x ( CPI(执行) + 缺失数/指令数 x 缺失代价) x 时钟周期时间 = IC x [(CPI(执行) x 时钟周期时间) + (缺失率x(存储器访问次数/指令数)x缺失代价x时钟周期时间)]

将(缺失代价x时钟周期时间)代以65ns,可得每种缓存组织方式的性能为:

CPU时间(一路)= ICx [1.6 x 0.35 + (0.021x1.4x65)] = 2.47 x IC
CPU时间(两路)= ICx [1.6 x 0.35 x 1.35 + (0.019x1.4x65)] = 2.49 x IC

相对性能为:

CPU时间(两路)/CPU时间(一路) = (2.49x指令数)/(2.47x指令数) = 1.01

与存储器平均访问时间的对比结果相反,直接映射缓存的平均性能略好一些,这是因为尽管两组组相联的缺失数较少,但针对所有指令扩展了时钟周期。由于CPU时间是我们的基本评估,而且由于直接映射的构建更简单一些,所以本示例中的优选缓存为直接映射。

缺失代价与乱序执行处理器

对于乱序执行处理器,如何定义“缺失代价”呢?是存储器缺失的全部延迟,还是仅考虑处理器必须停顿时的“暴露”延迟或无重叠延迟?对于那些在完成数据缺失之前必须停顿的处理器,不存在这一问题。

让我们重新定义存储器停顿,得到缺失代价的一种新定义,将其表示为非重叠延迟:

存储器停顿周期/缺失数 = 缺失数/指令数 x (总缺失代价-直叠缺失延迟)

与此类似,由于一些乱序处理器会拉长命中时间,所以性能公式的这一部分可以除以总命中延迟减去重叠命中延迟之差。可以对这一公式进一 步扩 展,将总缺失延迟分解为没有争用时的延迟和因为争用导致的延迟,以考虑乱序处理器中的存储器资源。我们仅关注缺失延迟。我们现在必须决定以下各项。

  • 存储器延迟长度——在乱序处理器中如何确定存储器操作的起止时刻。
  • 延迟重叠的长度——如何确定与处理器相重叠的起始时刻(或者说,在什么时刻我们说存储器操作使处理器停顿)。

由于乱序执行处理器的复杂性,所以不存在单一的准确定义。

由于在流水线退出阶段只能看到已提交的操作,所以我们说:如果处理器在一个时钟周期内没有退出(retire)最大可能数目的指令,它就在该时钟周期内停顿。我们将这一停顿记在第一条未退出指令的账上。这一定义绝不像看上去那么简单。例如,为缩短特定停顿时间而应用某一种优化,并不一定总能缩短执行时间,这是因为此时可能会暴露出另一种类型的停顿(原本隐藏在所关注的停顿背后)。

关于延迟,我们可以从存储器指令在指令窗口中排队的时刻开始测量,也可以从生成地址的时刻开始,还可以从指令被实际发送给存储器系统的时刻开始。只要保持一致,任何一种选项都是可以的。

让我们重做上面的例题,但这一次假定具有较长时钟周期时间的处理器支持乱序技术,但仍采用直接映射缓存。假定65 ns的缺失代价中有30%可以重叠,也就是说,CPU存储器平均停顿时间现在为45.5 ns。

乱序计算机的存储器访问时间为:

存储器平均访问时间(一路、乱序) = 0.35 x 1.35 + (0.021 x45.5)= 1.43 ns .

乱序缓存的性能为:

CPU时间(一路、乱序) = IC x [1.6 x 0.35 x 1.35 + (0.021 x 1.4 x 45.5)] = 2.09 x IC

因此,尽管乱序计算机的时钟周期时间要慢得多,直接映射缓存的缺失率也更高一些,但如果它能隐藏30%的缺失代价,那仍然可以稍快一些。

总而言之,尽管乱序处理器存储器停顿的定义和测量比较复杂,但由于它们会严重影响性能,所以应当了解这些问题。这一复杂性的出现是因为乱序处理器容忍由缓存缺失导致一定的延迟,不会对性能造成伤害。因此,设计师在评估存储器层次结构的权衡时,通常使用乱序处理器与存储器的模拟器,以确保一项帮 助缩短平均存储器延迟的改进能够真的有助于提高程序性能。为了帮助总结本节内容,同时也作为一个方便使用的参考,图B-4列出了本附录中的缓存公式。



图B-4 本附录中的性能公式汇总。第一个公式计算缓存索引大小,其余公式帮助评估性能。后两个公式处理多级缓存

6种基本的缓存优化

存储器平均访问时间公式为我们提供了一个框架,用于展示提高缓存性能的缓存优化方法:

存储器平均访问时间=命中时间+缺失率x缺失代价

因此,我们将6种缓存优化分为以下3类。

  • 降低缺失率——较大的块、较大的缓存、较高的关联度。
  • 降低缺失代价——多级缓存,为读取操作设定高于写入操作的优先级。
  • 缩短在缓存中命中的时间——在索引缓存时避免地址转换。

改进缓存特性的经典方法是降低缺失率,我们给出3种实现技术。为了更好地理解导致缺失的原因,首先介绍一个模型,将所有缺失分为3个简单类别。

  • 强制缺失(Compulsory)——在第一次访问某个块时,它不可能会在缓存中,所以必须将其读到缓存中。这种缺失也被称为冷启动缺失或首次引用缺失。
  • 容量缺失(Capacity)——如果缓存无法容纳程序执行期间所需要的全部块,由于一些块会被放弃,过后再另行提取,所以会(在强制缺失之外)发生容量缺失。
  • 冲突缺失(Conflict)——如果块布置策略为组相联或直接映射,则会(在强制缺失和容量缺失之外)发生冲突缺失,这是因为如果有太多块被映射到一个组中,这个组中的某个块可能会被放弃,过后再另行提取。这种缺失也被称为碰撞缺失。其要点就是:由于对某些常用组的请求数超过n,所以本来在全相联缓存中命中的情景会在n路组相联缓存中变为缺失。

表B-4显示了根据3C分类后的缓存缺失相对频率。强制缺失在无限缓存中发生,容量缺失在全相联缓存中发生。冲突缺失在从全相联变为八路相联、四路相联…时发生。图B-5以图形方式展示相同数据。上图显示绝对缺失率,下图绘制了当缓存大小变化时,各类缺失占总缺失数的百分比曲线。

  • 强制缺失与缓存大小无关,而容量缺失随容量的增加而降低,冲突缺失随相联度的增大而降低。

图B-5以围形方式显示了相同数据。注意,在不超过128KB时,大小为N的直接映射缓存的缺失率大约与大小为N/2的两路组相联缓存的缺失率相同。大于128KB的缓存不符合这一规则。注意,“容量” 列给出的也是全相联缺失率。

图B-5 根据表B-4中的3C数据,每种不同缓存大小的总缺失率(上)和缺失率分布(下)。上图显示实际数据缓存缺失率,下图显示每个类别的百分比

为了展示相联度的好处,将冲突缺失划分为每次相联度下降时所导致的缺失。共有4类冲突缺失,其计算方式如下所示。

  • 八路——从全相联(无冲突)到八路相联时产生的冲突缺失。
  • 四路——从八路相联到四路相联时产生 的冲突缺失。
  • 两路——从四路相联到两路相联时产生的冲突缺失。
  • 一路——从两路相联到一路相联(直接映射)时产生的冲突缺失。

从图中可以看出,SPEC2000程序的强制缺失率非常低,对许多长时间运行的程序都是如此。

从概念上来讲,冲突缺失是最容易避免的:全相联布置策略就可以避免所有冲突缺失。但是,全相联的硬件实现成本非常高昂,可能会降低处理器时间频率,从而降低整体性能。除了增大缓存之外,针对容量缺失没有什么办法了。如果上一级存储器远小于程序所需要的容量,那就会有相当一部分时间用于在层次结构的两级之间移动数据,我们说这种存储器层次结构将会摆动。由于需要太多的替换操作,所以摆动意味着计算机的运行速度接近于低级存储器的速度,甚至会因为缺失开销变得更慢。

另外一种降低3C缺失的方法是增大块的大小,以降低强制缺失数,但稍后将会看到,大型块可能会增加其他类型的缺失。

3C分类使我们可以更深入地了解导致缺失的原因,但这个简单的模型也有它的局限性;它让我们深入地了解了平均性能,但不能解释个体缺失。例如,由于较大的缓存可以将引用扩展到更多个块中,所以改变缓存大小会改变冲突缺失和容量缺失。因此,当缓存大小变化时,一个缺失可能会由容量缺失变为冲突缺失。注意,3C分类还忽略了替换策略,一方面是因为其难以建模,另一方面是因为它总体来说不太重要。但在具体环境中,替换策略可能会实际导致异常行为,比如,在大相联度下得到较低的缺失率,这与3C模型的结果矛盾。

遗憾的是,许多降低缺失率的技术也会增加命中时间或缺失代价。在使用3种优化方法降低缺失率时,必须综合考虑提高整体系统速度的目标,使两者达到平衡。第一个例子显示了平衡观点的重要性。

第一种优化方法:增大块大小以降低缺失率

降低缺失率的最简单方法是增大块大小。图B-6针对一组程序及缓存大小,给出了块大小;与缺失率的折中。较大的块大小也会降低强制缺失。这一降低是因为局域性原理分为两个部分:时间局域性和空间局域性。较大的块充分利用了空间局域性的优势。

图B-6 对于5种不同大小的缓存,缺失率与块大小的相互关系。注意,如果与缓存大小相比,块大小过大,则缺失率实际上会上升。每条曲线表示一个不同大小的缓存。

同时,较大的块也会增加缺失代价。由于它们降低了缓存中的块数,所以较大块可能会增大冲突缺失,如果缓存很小,甚至还会增加容量缺失。显然,没有理由要将块大小增大到会升高缺失率的程度。如果它会增加存储器平均访问时间,那降低缺失率也没有什么好处。缺失代价的增加会超过缺失率的下降。

  • 注意,对于4KB缓存,块大小为256字节时的缺失率高于32字节。在本例中,缓存大小必须为256KB,以使块大小为256字节时能够降低缺失。

表B-5显示了图B-6中绘制的实际缺失率。假定存储器系统的开销为80个时钟周期,然后每2个时钟周期提交16 个字节。因此,它可以在82个时钟周期内提供16个字节,在84个时钟周期内提供32个字节,以此类推。对于表B-5中的每种缓存大小,哪种缓存大小的存储器平均时间最短?

如果我们假定命中时间为1个时钟周期,与块大小无关,那么在4KB缓存中,对16字节块的访问时间为:

存储器平均访问时间 = 1+(8.57% x 82) = 8.027时钟周期

在256 KB缓存中,对256字节块的存储器平均访问时间为:

存储器平均访问时间 = 1+(0.49% x 112) = 1.549时钟周期

表B-6显示了这两个极端值之间所有块与缓存大小的存储器平均访问时间。粗体项目表示对于给定缓存大小能够实现最快访问的块大小:若缓存大小为4KB,则块大小为32字节时的访问速度最快;若缓存大小大于4 KB,则块大小应为64字节。事实上,这些数值也正是当前处理器缓存的常见块大小。

  • 注意,绝大多数的块大小为32字节和64字节。每种缓存大小的最短平均访问时间用黑体标出。

在所有这些技术中,缓存设计者都在尝试尽可能同时降低缺失率和缺失代价。块大小的选择有赖于低级存储器的延迟和带宽。高延迟和高带宽鼓励采用大块,因为缓存在每次缺失射能够获取的字节可以多出许多,而缺失代价却很少增加。相反,低延迟和低带宽则鼓励采用小块,因为这种情况下采用较大块不会节省多少时间。例如,一个小块的两倍缺失代价可能接近一个两倍大小块的缺失代价。更多的小块还可能减少冲突缺失。注意,图B-6和表B-6给出了基于缺失率最低、存储器平均时间最短选择块大小时的差别。

在了解了较大块对强制缺失和容量缺失的正面与负面影响之后,下面两小节将研究较高容量和较高相联度的可能性。

第二种优化方法:增大缓存以降低缺失率

降低表B-4和图B-5中容量缺失的最明显方法是增加缓存的容量,其明显的缺点是可能延长命中时间、增加成本和功耗。这一技术在片外缓存中尤其常用。

第三种优化方法:提高相联度以降低缺失率

表B-4和图B-5显示了缺失率是如何随着相联度的增大而得以改善的。从中可以看出两个一般性的经验规律。第一条规律是:对于这些特定大小的缓存,从实际降低缺失数的功效来说,八路组相联与全相联是一样有效的。通过对比表B-4中的八路项目与容量缺失列可以看出这差别,其中的容量缺失是使用全相联缓存计算得出的。

从图中观察得到的第二条规则称为2:1缓存经验规律:大小为N的直接映射缓存与大小为N/2的两路组相联缓存具有大体相同的缺失率。这一规律对3C图形中小于128KB的缓存也是成立的。

与许多此类示例类似,要改善存储器平均访问时间的一个方面,可能会导致另一方面的恶化。增大块大小可以降低缺失率,但会提高缺失代价;增大相联度可能会延长命中时间。因此,加快处理器时钟速度的压力鼓励使用简单的缓存设计,但提高相联度的回报是提高缺失代价,如下例所示。

假定提高相联度将会延长时钟周期时间,如下所示:

  • 时钟周期时间(两路) = 1.36 x 时钟周期时间(一路)
  • 时钟周期时间(四路) = 1.44 x 时钟周期时间(一路)
  • 时钟周期时间(八路) = 1.52 x 时钟周期时间(一路)

假定命中时间为1个时钟周期,直接映射情景的缺失代价为到达第2级缓存的25个时钟周期,在第2级缓存中绝对不会缺失,还假定不需要将缺失代价舍入为整数个时钟周期。

每种相联度的存储器平均访问时间为:

存储器平均访问时间(八路) = 命中时间(八路) + 缺失率路x缺失代价(八路) = 1.52+缺失率(八路)x25

  • 存储器平均访问时间(四路) = 1.44+缺失率(四路) x 25
  • 存储器平均访问时间(两路) = 1.36+缺失率(两路) x 25
  • 存储器平均访问时间(一路) = 1.00+缺失率(一路) x 25

每种情况下的缺失代价相同,所以我们使其保持25个时钟周期。例如,对于一个4 KB的直接映射缓存,存储器平均访问时间为:

存储器平均访问时间(一路) = 1.00+ (0.098 x 25) = 3.44

对于512 KB八路组相联缓存,该时间为:

存储器平均访问时间(八路) = 1.52+(0.006x25) = 1.66

利用这些公式及表B-4中的缺失率,表B-7给出了每种缓存和相联度时的存储器平均访问时间。该表显示,对于不大于8KB、不超过四路相联度的缓存,本例中的公式成立。从16 KB开始,较大相联度的较长命中时间超过了因为缺失降低所节省的时间。

注意,在本例中,我们没有考虑较慢时钟频率对程序其余部分的影响,因此低估了直接映射缓存的收益。

第四种优化方法:采用多级缓存降低缺失代价

降低缓存缺失已经成为缓存研究的传统焦点,但缓存性能公式告诉我们:通过降低缺失代价同样可以获得降低缺失率所带来的好处。此外,图2-2显示的技术趋势表明:处理器的速度增长快于DRAM,从而使缺失代价的相对成本随时间的推移而升高。处理器与存储器之间的性能差距让架构师开始思考这样一个问题:是应当加快缓存速度以与处理器速度相匹配呢?还是让缓存更大一些,以避免加宽处理器与主存储器之间的鸿沟?

一个回答是:两者都要。在原缓存与存储器之间再添加一级缓存可以简化这一决定。第一级缓存可以小到足以与快速处理器的时钟周期时间相匹配。而第二级缓存则大到足以捕获本来可能进入主存储器的访问,从而降低实际缺失代价。尽管再添加一级层次结构的思路非常简单,但它增加了性能分析的复杂程度。第二级缓存的定义也并非总是那么简单。首先让我们为一个二级缓存定义存储器平均访问时间。用下标L1和L2分别指代第一级、 第二级缓存,原公式为:

存储器平均访问时间=命中时间L1 + 缺失率L1 x 缺失代价L1

缺失代价L1=命中时间L2 + 缺失率L2 x 缺失代价L2

存储器平均访问时间=命中时间L1 + 缺失率L1 x (命中时间L2 + 缺失率L2 x 缺失代价12)

在这个公式中,第二级缺失率是针对第-级缓存未能找到的内容进行测量的。为了避免模糊,对二级缓存系统采用以下术语。

  • 局部缺失率——此比值即是缓存中的缺失数除以对该缓存进行存储器访问的总数。可以想到,对于第一级缓存, 它等于缺失率L1,对于第二级缓存,它等于缺失率L2
  • 全局缺失率——缓存中的缺失数除以处理器生成的存储器访问总数。利用以上术语,第一级缓存的全局缺失率仍然为缺失率L1,但对于第二级缓存则为缺失率L1x缺失率L2

第二级缓存的这一局部缺失率很大,这是因为第一级缓存已经提前解决了存储器访问中便于实现的部分。这就是为什么说全局缺失率是一个更有用的度量标准:它指出在处理器发出的存储器访问中,有多大比例指向了存储器。

这是一个让每条指令缺失数度量闪光的地方。利用这一度量标准,不用再担心局部缺失率或全局缺失率的混淆问题,只需要扩展每条指令的存储器停顿,以增加第二级缓存的影响。

每条指令的平均存储器停顿时间=每条指令的缺失数L1x命中时间L2 + 每条指令的缺失数L2x缺失代价L2

假定在1000次存储器引用中,第一级缓存中有40次缺失,第二级缓存中有20次缺失。各缺失率等于多少?假定L2缓存到存储器的缺失代价为200个时钟周期,L2缓存的命中时间为10个时钟周期,L1 的命中时间为1个时钟周期,每条指令共有1.5次存储器引用。每条指令的存储器平均访问时间和平均停顿周期为多少?

第一级缓存的缺失率(局部缺失率或全局缺失率)为40/1000=4%。第二级缓存的局部缺失率为20/40=50%。第二级缓存的全局缺失率为20/1000=2%。则:

存储器平均访问时间=命中时间L1+缺失率L1 x (命中时间L2+缺失率L2x缺失代价L2) = 1+4% x (10+50% x 200) = 5.4个时钟周期

为了知道每条指令会有多少次缺失,我们将1000次存储器引用除以每条指令的1.5次存储器引用,得到667条指令。因此,我们需要将缺失数乘以1.5,得到每千条指令的缺失数。于是得到每千条指令的L1缺失数为40x 1.5=60次,L2缺失数为20x1.5= 30次。关于每条指令的平均存储器停顿,假定缺失数在指令与数据之间是均匀分布的:

每条指令的平均存储器停顿=每条缺失的缺失数L1 x 命中时间L2 + 每条指令的缺失数L2 x 缺失代价L2 = (60/1000) x 10 + (30/1000) x 200 = 0.060 x 10 + 0.030 x 200 = 6.6个时钟周期

如果从存储器平均访问时间(AMAT)中减去L1命中时间,然后再乘以每条指令的平均存储器引用数,则可以得到每条指令平均存储器停顿值:(5.4- 1.0) x 1.5 = 4.4 x 1.5 - 6.6个时钟周期

注意,这些公式是针对混合式读取与写入操作的,假定采用写回第一级缓存。显然,直写第一级缓存会将所有写入操作都发往第二二级,而不是仅限于缺失,而且还可能使用写入缓冲区。

图B-7和图B-8显示了一个设计中的缺失率和相对执行时间是如何随着第一二级缓存的大小而变化的。从两个图中可以有两点体悟。第一,如果第二级缓存远大于第一级缓存,则全局缓存缺失率与第二级缓存的单一缓存缺失率非常类似。第二,局部缓存缺失率不是第二级缓存的良好度量标准;它是第一级缓存缺失率的函数,因此可以通过改变第一级缓存而变化。所以,在评估第一级缓存时,应当使用全局缓存缺失率。

图B-7 多级缓存的缺失率随缓存大小的变化。小于两个64 KB一级缓存总和的第二级缓存没有什么意义,从其高缺失率中可以反映出这一点。大于256 KB之后,单个缓存在全局缺失率在10%以内。单级缓存的缺失率随大小的变化是根据第二级缓存的局部缺失率和全局缺失率绘制的,采用的是32 KB一级缓存。L2缓存(统一缓存)为两路组相联,采用替换策略。分别有独立的L2指令与数据缓存,它们都是64 KB两路组相联,采用LRU替换策略。L1与L2缓存的块大小均为64字节。

图B-8 相对执行时间与第二级缓存大小的关系。图中的每两个长条表示一次L2缓存命中的不同时钟周期。引用执行时间1.00 是指一个8192 KB第二级缓存在第二级命中的延迟为1个时钟周期。

有了这些定义,我们可以考虑第二级缓存的参数。两级缓存之间的首要区别就是第一级缓存的速度影响着处理器的时钟频率,而第二级缓存的速度仅影响第一级缓存的缺失代价。因此,我们可以在第二级缓存中考虑许多不能用于第一级缓存的替代选项。在设计第二级缓存时,主要有两个问题:是否要降低CPI的存储器平均访问时间部分?其成本有多高?

首先要决定的是第二级缓存的大小。由于第一级缓存中的所有内容都可能在第二级缓存中,所以第二级缓存应当远大于第一级缓存。如果第二级缓存只是稍大一点,那局部缺失率将会很高。这一观察结果激励了巨型第二级缓存的设计。

给定以下数据,第二级缓存相联度对其缺失代价的影响如何?

  • 直接映射的命中时间L2为10个时钟周期。
  • 两路组相联将命中时间增加0.1个时钟周期,达到10.1个时钟周期。
  • 直接映射的局部缺失率L2为25%。
  • 缺失代价I2为200个时钟周期。
  • 两路组相联直接映射的局部缺失率L2为20%。

对于直接映射第二级缓存,第一级缓存缺失代价为:
缺失代价(一路L2) = 10 + 25% x 200 = 60.0个时钟周期

加上相联度的成本仅使命中成本增加0.1个时钟周期,由此得到新的第一级缓存缺失代价为:

缺失代价(两路L2) = 10.1 + 20% x 200 = 50.1个时钟周期

事实上,第二级缓存几乎总是与第一级缓存、处理器同步。相应地,第二级命中时间必须为整数个时钟周期。如果幸运的话,我们可以将第二级命中时间缩短到10个时钟周期;如果不够幸运,则会舍入到11个周期。相对于直接映射第二级缓存,任一选项都是一种改进:

  • 缺失代价两路L2 = 10+20% x 200 = 50.0个时钟周期
  • 缺失代价两路12 = 11+20% x 200 = 51.0个时钟周期

现在我们可以通过降低第二级缓存的缺失率来降低缺失代价了。

另一条关注事项涉及第一级缓存中的数据是否在第二级缓存中。多级包含是存储器层次结构的一种自然策略:L1 数据总是出现在L2中。这种包含性是我们所希望的,因为仅通过检查第二级缓存就能确定IO与缓存之间(或多处理器中的缓存之间)的一致性。

包含性的一个缺点就是:测量结果可能提议对较小的第一级缓存使用较小的块,对较大的第二级缓存使用较大的块。例如,Pentium 4的L1缓存中的块为64个字节,L2缓存中的块为128个字节。包含性仍然能够得到保持,但在第二级缺失时要做更多工作。如果一级块所映射的二级块将被替换,则第二级缓存必须使所有此类一级块失效,从而会略微提高第一级缺失率。为了避免此类问题,许多缓存设计师使所有各级缓存的块大小保持一致。

但是,如果设计师只能承受略大于L1缓存的L2缓存呢?它是不是有很大一部分空间要被用作L1缓存的冗余副本?在此种情况下,可以使用一种明显相反的策略:多级互斥,L1中的数据绝对不会出现在L2缓存中。典型情况下,在采用互斥策略时,L1中的缓存缺失将会导致L1与L2的块交换,而不是用L2块来替代L1块。这一策略防止了L2缓存中的空间浪费。例如,AMD Opteron芯片使用两个64 KB L1缓存和1 MB L1缓存来执行互斥策略。

这些问题表明,尽管一些新手可能会独立地设计第一级和第二级缓存,但在给定一个兼容第二级缓存时,第一级缓存设计师的任务要简单一些。比如,如果下一级有写回缓存来为重复写入操作提供支持,而且使用了多级包含,那使用直写的风险就会小一些。所有缓存设计的基础都是在加速命中和减少缺失之间实现平衡。对于第二级缓存,命中数要比第一级缓存中少得多,所以重心更多地偏向减少缺失。因为这一认知,人们开始采用大得多的缓存和降低缺失率的技术,比如更高的相联度和更大的块。

第五种优化方法:使读取缺失的优先级高于写入缺失,以降低缺失代价

这一优化方法在完成写入操作之前就可以为读取操作提供服务。我们首先看一下写入缓冲区的复杂性。

采用直写缓存时,最重要的改进就是一个大小合适的写入缓冲区。但是,由于写入缓冲区可能包含读取缺失时所需要的更新值,所以它们的确会使存储器访问变得复杂。

看以下序列:

1
2
3
SW R3, 512(R0)   ;M[512] ← R     (cache index 0)
LW R1, 1024(R0) ;R1 ← M[1024] (cache index 0)
LW R2, 512(R0) ;R2 ← M[512] (cache index 0)

假定有一个直接映射直写缓存,它将512和1024映射到同一块中,假定有一个四字写入缓存区,在读取缺失时不会进行检查。R2中的值是否总等于R3中的值?

使用第2章的术语,这是存储器中的一个“写后读”数据冒险。我们通过跟踪一次缓存访问来了解这种危险性。R3的数据在存储之后被放在写入缓冲区中。随后的载入操作使用相同的缓存索引,因此产生一次缺失。第二条载入指令尝试将位置512处的值放到寄存器R2 中,这样也会导致一次缺失。如果写入缓冲区还没有完成向存储器中位置512的写入,对位置512的读取就会将错误的旧值放到缓存块中,然后再放入R2中。如果没有事先防范,R3是不等于R2的!

摆脱这一两难境地的最简单方法是让读取缺失一直等待到写入缓冲区为空为止。一种替代方法是在发生读取缺失时检查写入缓冲区的内容,如果没有冲突而且存储器系统可用,则让读取缺失继续。几乎所有桌面与服务器处理器都使用后一方法,使读取操作的优先级高于写入操作。

处理器在写回缓存中的写入成本也可以降低。假定一次读取缺失将替换一个脏服务器块。我们不是将这个脏块写到存储器中,然后再读取存储器,而是将这个脏块复制到缓冲区中,然后读存储器,然后再写存储器。这样,处理器的读取操作将会很快结束(处理器可能正在等待这一操作的完成)。和前一种情况类似,如果发生了读取缺失,处理器或者停顿到缓冲区为空,或者检查缓冲区中各个字的地址,以了解是否存在冲突。

命中时间会影响到处理器的时钟频率,所以它是至关重要的;在今天的许多处理器中,缓存访问时间限制都限制了时钟频率,即使那些使用多个时钟周期来访问缓存的处理器也是如此。因此,缩短命中时间可以对各个方面提供帮助,从而具有多重重要性,超出了存储器平均访问时间公式的限制。

第六种优化方法:避免在索引缓存期间进行地址转换,以缩短命中时间

即使一个小而简单的缓存也必须能够将来自处理器的虚拟地址转换为用以访问存储器的物理地址。如B.4节所述,处理器就是将主存储器看作另一级存储器层次结构,因此,必须将存在于磁盘上的虚拟存储器地址映射到主存储器。

根据“加快常见情景速度”这一指导原则,我们为缓存使用虚拟地址,因为命中的出现频率当然远高于缺失。这种缓存被称为虚拟缓存,而物理缓存用于表示使用物理地址的传统缓存。问题是:在索引缓存中应当使用虚拟地址还是使用物理地址,在标志对比中应当使用虚拟地址还是使用物理地址。如果对索引和标志都完全采用虚拟寻址,那在缓存命中时就可以省掉地址转换的时间。

那为什么不是所有体系结构都构建虚拟寻址的缓存呢?一个原因是要提供保护。在将虚拟地址转换为物理地址时,无论如何都必须检查页级保护。一种解决方案是在缺失时从TLB复制保护信息,添加一个字段来保存这一信息,然后在每次访问虚拟寻址缓存时进行核对。另一个原因是:在每次切换进程时,虚拟地址会指向不同的物理地址,需要对缓存进行刷新。图B-9显示了这一刷新对缺失率的影响。一种解决方案是增大缓存地址标志的宽度,增加一个进程识别符标志(PID)。如果操作系统将这些标志指定给进程,那么只需要在PID被回收时才刷新缓存;也就是说,PID 可以区分缓存中的数据是不是为此这个程序准备的。图B-9显示了通过PID避免缓存刷新而对缺失率的改善。

图B-9 一个程序的缺失率随虚拟寻址缓存大小的变化,分三种情况测量:没有进程切换(单进程)、使用进程识别符标志(PID)进行进程切换,有进程切换但没有PID,即清除(purge) 模式。PID使单进程绝对缺失率增加0.3%~0.6%,比清除模式节省0.6%~4.3%。

虚拟缓存没有更加普及的第三个原因是操作系统和用户程序可能为同一物理地址使用两种不同的虚拟地址。这些重复地址称为同义地址或别名地址,可能会在虚拟缓存中生成同一数据的两个副本;如果其中一个被修改了,另一个就会包含错误值。而采用物理缓存是不可能发生的,因为这些访问将会首先被转换为相同的物理缓存块。

同义地址问题的硬件解决方案称为别名消去,保证每个缓存块都拥有一个独一无二的物理地址。软件可以强制这些别名共享某些地址位,从而大大简化了这一问题。 比如Sun要求所有别名地址的后面18位都必须相同;这一限制称为页面着色。注意,页面着色就是向虚拟存储器应用的组相联映射:使用64(2%)个组来映射4 KB(212)个页面,确保物理地址和虚拟地址的后18位匹配。这一限制意味着不大于 218 (256 K)字节直接映射缓存绝对不会为块使用重复的物理地址。从缓存的角度来看,页着色有效地增大了页偏移,因为软件保证了虚拟、物理页地址的最后几位是相同的。

最后一部分与虚拟地址相关的领域是I/O。I/O 通常使用物理地址,从而需要映射到虚拟地址,以与虚拟地址进行交换。一种使虚拟缓存与物理缓存均能实现最佳性能的备选方法是使用一部分页偏移量(也就是虚拟地址与物理地址保持一致的那一部分)来索引缓存。在使用索引读取缓存的同时,地址的虚拟部分被转换,标志匹配使用了物理地址。

这一备选方法允许缓存读取操作立即开始,而标志对比仍然使用物理地址。这种虚拟索引、物理标志备选方法的局限性是直接映射缓存不能大于页大小。为了利用这一技巧,虚拟页大小至少为2(9+6)个字节,即32 KB。如果不是这样,则必须将该索引的一部分由虚拟地址转换为物理地址。图B-10显示了在使用这一技术时的缓存、转换旁视缓冲区(TLB)和虚拟存储器的组织方式。

图B-10 一种从虛拟地址到L2缓存访问的虚设存储器层次结构的整体图像。页大小为16 KB。TLB是拥有256项的两路组相联。L1 缓存是一个直接映射16 KB,L2缓存是一个总容量为4 MB的四路组相联。这两者的块大小都是64个字节。虚拟地址为64位,物理地址为40位

相联度可以将此索引保存在地址的物理部分,但仍支持大型缓存。回想一下,索引的大小受以下公式的控制:

2^索引 = 缓存大小/(块大小x组相联)

例如,使相联度和缓存大小同时加倍并不会改变索引的大小。作为一个极端示例,IBM 3033缓存是一个十六路组相联,尽管研究表明:在八路以上的组相联中,对缺失率没有什么好处。尽管IBM体系结构中存在页大小为4KB这一障碍,这一高相联度允许使用物理索引对64 KB缓存进行寻址。

基本缓存优化方法小结

本节介绍了用于降低缺失率与缺失代价、缩短命中时间的技术,这些技术通常会影响到存储器平均访向公式的其他部分,还会影响到存储器层次结构的复杂性。表B-8总结了这些技术,并估计了对复杂性的影响,“+”表示该技术对该因素有改进,“-” 表示该技术对该因素有伤害,空白表示没有影响。本图中任何一项优化方法都不能对一个以上的类别提供帮助。

虛拟存储器

必须有一种方法,用于在许多进程之间共享较少量的物理空间。

其中一种做法——虚拟存储器,将物理存储器划分为块,并分配给不同的进程。这种方法必然要求采用一种保护机制来限制各个进程,使其仅能访问属于自己的块。虚拟存储器的许多形式还缩短了程序的启动时间,因为程序启动之前不再需要物理存储器中的所有代码和数据。尽管由虚拟存储器提供的保护对于目前的计算机来说是必需的,但共享并不是发明虚拟内存的原因。如果一个程序对物理内存来说变得过于庞大,就需要由程序员负责将其装进去。程序员将程序划分为片段,然后确认这些互斥的片断,在执行时间根据用户程序控制来加载或卸载这些覆盖段(overlay)。程序员确保程序绝对不会尝试访问超出计算机现有的物理主存储器,并确保会在正确的时间加载正确的覆盖段。容易想到,这种责任降低了程序员的生产效率。

虚拟存储器的发明是为了减轻程序员的这一负担;它自动管理表示为主存储器和辅助存储的两级存储器层次结构。图B-11显示了程序从虚拟存储器到物理存储器的映射,共有4个页面。

除了共享受保护的存储器空间和自动管理存储器层次结构之外,虚拟存储器还简化了为执行程序而进行的加载过程。这种被称为再定位(relocation)的机制允许同一程序在物理存储器中的任意位置运行。图B-11中的程序可以定位在物理存储器中的任何位置,也可以放在磁盘上,只需要改变它们之间的映射即可。(在虚拟存储器普通之前,处理器中包含一个用于此目的的再定位寄存器。)硬件解决方案的一种替代方法是使用软件,在每次运行一个程序时,改变其所有地址。

图B-11 左侧给出位于相邻虚拟地址空间中的逻辑程序。它包括A、B、C和D4个页。这些块中有3个的实际位置在物理主存储器中,另一个位于磁盘上

第1章中几个有关缓存的一般性存储器层次结构思想与虚拟存储器类似,当然,其中有许多术语不同。页或段表示块,页错误或地址错误用于缺失。有了虚拟存储器,处理器会给出虚拟地址,由软硬件组合方式转换为物理地址,再来访问主存储器。这一过程称为存储器映射或地址转换。今天,由虚拟地址控制的两级存储器层次结构为DRAM和磁盘。表B-9显示了虚拟存储器存储器层次结构参数的典型范围。

除了表B-9中提到的量化区别之外,缓存与虚拟存储器之间还有其他一些区别,如下所述。

  • 发生缓存缺失时的替换主要由硬件控制,而虚拟存储器替换主要由操作系统控制。缺失代价越长,正确作出决定就显得越重要,所以操作系统可以参与其中,花费一些时间来决定要替换哪些块。
  • 处理器地址的大小决定了虚拟存储器的大小,但缓存大小与处理器地址大小无关。
  • 除了在层次结构中充当主存储器的低一级后援存储之外,辅助存储还用于文件系统。事实上,文件系统占用了大多数辅助存储。它通常不在地址空间中。

虚拟存储器还包含几种相关技术。虚拟存储器系统可分为两类:页,采用大小固定的块;段,采用大小可变的块。页大小通常固定为4096至8 192字节,而段大小是变化的。任意处理器所支持的最大段范围为216个字节至232个字节,最小段为1个字节。图B-11显示了这两种方法可以如何划分代码和数据。


图B-11 分页和分段方式对程序的划分示例

是使用页虚拟存储器还是段虚拟存储器,这一决定会影响处理器。页寻址方式的地址是单一固定大小,分为页编号和页内偏移量,与缓存寻址类似。单一地址对分段地址无效,可变大小的段需要1个字来表示段号,1个字表示段内的偏移量,总共2个字。对编译器来说,不分段地址空间更简单一些。

这两种方法的优缺点已经在操作系统教科书中进行了很好的阐述,表B-10总结了这些观点。由于替换问题(表中第三行),今天很少再有计算机使用纯粹的分段方法。一些计算机使用一种名为页式分段的混合方式,在这种方式中,一个段由整数个页组成。由于存储器不需要是连续的,也不需要所有段都在主存储器中,从而简化了替换过程。最近的一种混合方式是由计算机提供多种页面大小,较大页面的大小为最小页面大小的整数倍,且为2的幂。

  • 这两者都可能浪费存储器,取决于块大小及各分段能否很好地容纳于主存储器中。采用不受限指针的编程语言需要传递段和地址。一种称为页式分段的混合方法可以发挥这两者的最佳状态:分段由页组成,所以替换一个块是很轻松的,而一个段仍被看作一个逻辑单位。

再谈存储器层次结构的4个问题

我们现在已经为回答虚拟存储器的四个存储器层次结构问题做好了准备。

问题1:一个块可以放在主存储器的什么位置?

虚拟存储器的缺失代价涉及旋转磁存储设备的访问,因此非常高。如果在较低缺失率与较简单放置算法之间进行选择,操作系统设计人员通常选择较低缺失率,因为其缺失代价可能会高得离谱。因此,操作系统允许将块放在主存储器中的任意位置。根据图B-1中的术语,这一策略可以标记为全相联的。

问题2:如果一个块在主存储器中,如何找到它?

分页和分段都依靠一种按页号或段号索引的数据结构。这种数据结构包含块的物理地址。对于分段方式,会将偏移量加到段的物理地址中,以获得最终物理地址。对于分页方式,该偏移量只是被串接到这一物理分布地址。

图B-13 通过页表将虚拟地址映射到物理地址

这一包含物理页地址的数据结构通常采用一种分页表的形式。这种表通常根据虚拟页号进行索引,其大小就是虚拟地址空间中的页数。如果虚拟地址为32位、4 KB页、每个页表项(PTE)大小为4字节,则页表的大小为(2^32/2^12) x 2^2 = 2^22即4 MB。

为了缩小这一数据结构,一些计算机向虚拟地址应用了一种散列功能。这种散列允许数据结构的长度等于主存储器中物理页的数目。这一数目可以远小于虚拟页的数目。这种结构被称为反转分页表。利用前面的例子,一个512 MB的物理存储器可能只需要1 MB (8 x 512 MB/4KB)的反转分页表;每个页表项另外需要4字节,用于表示虚拟地址。

为了缩短地址转换时间,计算机使用一个专门进行这些地址变换的缓存,称为变换旁视缓冲区,或者简称为变换缓冲区。

问题3:在虛拟存储器缺失时应当替换哪个块?

前面曾经提到,操作系统的最高指导原则是将页错误降至最低。几乎所有操作系统都与这一指导原则保持一致,尝试替换最近使用最少(LRU)的块,这是因为如果用过去的信息来预测未来,将来用到这种块的可能性最低。

为了帮助操作系统评估LRU,许多处理器提供了一个使用位或参考位,从逻辑上来说,只要访问一个页,就应对其进行置位。(为了减少工作,通过仅在发生转换缓冲区缺失时对其进行置位,稍后将对此进行介绍。)操作系统定期对这些使用位清零,之后再记录它们,以判断在一个特定时间段时使用了哪些页。通过这种方式进行跟踪,操作系统可以选择最近引用最少的一个页。

问题4:在写入时发生什么?

主存储器的下一级包含旋转磁盘,其访问会耗时数百万个时钟周期。由于访问时间的巨大差异,还没有人构建一种虚拟存储器操作系统,在处理器每次执行存储操作时将主存储器直写到磁盘上。因此,这里总是采用写回策略。

由于对低一级的非必需访问会带来如此之高的成本,所以虚拟存储器系统通常会包含一个重写位。利用这一重写位,可以仅将上次读取磁盘之后经过修改的块写至磁盘。

快速地址变换技术

分页表通常很大,从而存储在主存储器中,有时它们本身就是分页的。分页意味着每次存储器访问在逻辑上至少要分两次进行,第一次存储器访问是为了获得物理地址,第二次访问是为了获得数据。第2章曾经提到,我们使用局域性来避免增加存储器访问次数。将地址变换局限在一个特殊缓存中,存储器访问就很少再需要第二次访问来转换数据。 这一特殊地址变换缓存被称为变换旁视缓冲区(TLB),有时也称为变换缓冲区(TB)。

TLB项就像是一个缓存项目,其中的标志保存了虚拟地址部分,数据部分保存了特殊页帧编号、保护字段、有效位,通常还有一个使用位和重写位。要改变页表中某一项的特殊页帧编号或保护字段,操作系统必须确保旧项不在TLB中;否则,系统就不能正常运行。注意,这个重写位意味着对应页曾被改写过,而不是指TLB中的地址变换或数据缓存中的特殊块经过改写。操作系统通过改变页表中的值,然后再使相应TLB项失效来重置这些位。在从分页表中重新加载该项时,TLB会获得这些位的准确副本。

图B-14给出了Opteron 数据TLB组织方式,并标出了每一个变换步骤。这个TLB使用全相联布置;因此,变换首先向所有标志发送虚拟地址(步骤1和步骤2)。当然,这些标志必须标记为有效,以允许进行匹配。同时,根据TLB中的保护信息核对存储器访问的类型,以确认其是否有效(也在步骤2中完成)。


图B-14 在地址变换期间Opteron数据TLB的操作。一次 TLB命中的4个步骤用带圆圈的数字显示。这个TLB有40项。

和缓存中的理由相似,TLB中也不需要包含页偏移量的12个位。匹配标志通过一个40选1多工器,高效地发送相应的物理地址(步骤3)。然后将页偏移量与物理页帧合并,生成一个完整的物理地址(步骤4)。地址大小为40位。

选择页大小

最显而易见的体系结构参数是页大小。页大小的选择实际就是在偏向较大页与偏向较小页的力量之间进行平衡的问题。以下因素偏向较大尺寸。

  • 页表的大小与页大小成反比,因此,增大页的大小可以节省存储器(或其他用于存储器映射的资源)。
  • B.3节曾经提到,分页较大时,可以允许缓存命中时间较短的较大缓存。
  • 与传递较小页相比,从(向)辅助存储传递较大页(有可能通过网络)的效率更高一些。
  • TLB项目的数量受限,所以分页较大意味着可以高效地映射更多存储器,从而可以降低TLB缺失数量。

由于最后这个原因,近来的微处理器决定支持多种页大小;对于一些程序,TLB缺失对CPI的重要性可能与缓存缺失相同。

采用较小分页的主要动机是节省存储。当虚拟内存的相邻区域不等于页大小的整数倍时,采用较小页可以减少存储的浪费空间。页面中这种未使用存储器的术语名称为内部碎片。假定每个进程有三个主要段(文本、堆和栈),每个进程的平均浪费存储量为页大小的1.5倍。当然,当页大小非常大(超过32KB)时,那就可能浪费存储(主存储器和辅助存储器)和I/O带宽了。

最后一项关注是进程启动时间;许多进程都很小,较大的页面可能会延长调用一个进程的时间。

虚拟存储器和缓存小结

由于虚拟存储器、TLB、第一级缓存、第二级缓存都映射到虚拟与物理地址空间的一部分,所以人们可能会混淆哪些位去了哪里。图B-15给出了一个从64位虚拟地址到41位物理地址的假设示例,它采用两级缓存。这一L1缓存的缓存大小和页大小都是8 KB,所以它是虚拟寻址、物理标记的。L2缓存为4 MB。这两者的块大小都是64个字节。

第一,64位虚拟地址在逻辑上被划分为虚拟页号和页偏移量。前者被发送到TLB,以备变换为物理地址,后者的高位被发送到L1缓存,充当索引。如果TLB匹配命中,则将物理页号发送到L1缓存标志,检查是否匹配。如果匹配,则是L1缓存命中。块偏移随后为处理器选择该字。

如果L1缓存核对显示为缺失,则使用物理地址尝试L2缓存物理地址的中间部分用作4MB L2缓存的索引。将所得到的L2缓存标志与物理地址的上半部分对比,以检查是否匹配。如果匹配,我们得到一次L2缓存命中,数据被送往处理器,它使用块偏移量来选择所需字。在L2缺失时,会使用物理地址从存储器获取该块。

尽管这是一个简单示例,但该图与真实缓存之间的主要区别只是重复问题。因为,只有一个L1缓存。如果有两个L1缓存,会重复该图的上半部分。注意,这会导致拥有两个TLB,而这正是典型情况。因此,一个缓存和TLB用于指令,由PC驱动,一个缓存和TLB用于数据,由实际地址驱动。

第二种简化是所有缓存与TLB都是直接映射的。如果有任何一个是n路组相联的,则会将每一组标志存储器、比较器和数据存储器重复n次,并用一个 n选1多工器将数据存储器连接在一起,以选择命中内容。当然,如果总缓存大小保持不变,则缓存索引也会收缩log2n 位,如图B-4中的公式所示。


图B-15 一个从虚拟地址到L2缓存访问的虚设存储器层次结构的整体图像。页大小为8 KB。TLB为直接映射,有256项。L1缓存为直接映射8 KB,L2缓存为直接映射,大小为4 MB。两者的块都是64字节。虚拟地址为64位,物理地址为41位。这一简单图像与实际缓存的主要差别在于这一图像多个组成部分的重复

虚拟存储器的保护与示例

在多重编程中,计算机由几个并行执行的程序共享,它的发明为程序之间的保护和共享提供了新的要求。这些要求与今天计算机中的虚拟存储器紧密捆绑在一起,所以我们在这里用两个虚拟存储器的示例来介绍这一主题。

多重编程导致了进程概念的出现。打个比方,进程就是程序呼吸的空气和生活的空间——即一个正在运行的程序加上持续运行它所需要的所有状态。时分共享是多重编程的一种变体,由几个同时进行交互的用户来共享处理器和存储器,给人的感觉是所有用户都拥有自己的计算机。因此,它在任何时刻都必须能够从一个进程切换到另一进程。 这种交换被称为进程切换或上下文切换。

一个进程无论是从头到尾持续执行,还是被反复中断,与其他进程进行切换,其运行都必须正常进行。维护正确进程行为的责任由程序和操作系统的设计者共同分担。计算机设计师必须确保进程状态的处理器部分能够保存和恢复。操作系统设计师必须确保这些进程不会相互干扰对方的计算。

保护一个进程的状态免受其他进程损害的最安全方法就是将当前信息复制到磁盘上。但是,一次进程切换可能需要几秒的时间一这对时分共享环境来说过长了。这一问题的解决方法是由操作系统对主存储器进行划分,使几个不同进程能够在存储器同时拥有自己的状态。这种划分意味着操作系统设计师需要计算机设计师的帮助,以提供保护,使一个进程无法修改其他进程。除了保护之外,计算机还为进程之间共享代码和数据提供了支持,允许进程之间进行通信,或者通过减少相同信息的副本数目来节省存储器。

保护进程

使进程拥有自己的分页表,分别指向存储器的不同页面,这样可以为进程提供保护,避免相互损害。显然,必须防止用户修改它们的分页表,或者以欺骗方式绕过保护措施。

分段虛拟存储器举例:Intel Pentium中的保护方式

最早的8086使用分段寻址,但它没有提供任何虚拟存储器或保护。分段拥有基础寄存器,但没是界限寄存器,也没有访问核查,在能够载入分段寄存器之前,必须将相应段载入物理存储器中。Intel对虚拟存储器和保护的专注在8086的后续产品中得到体现,扩展了一些字段来支持更大地址。这种保护机制非常精巧,精心设计了许多细节,以尝试避免安全漏洞。我们将其称为IA-32。接下来的几页内容将重点介绍Intel的一些安全措施。

第一种增强是将传统的两级保护模型加倍: IA-32 有四级保护,最内层(0)对应于传统的内核模式,最外层(3)是权限最少的模型。IA-32 为每一级提供独立栈,以避免突破不同级别之间的安全措施。还有一些与传统分页表类似的数据结构,其中包含了段的物理地址,还有一个对变换地址进行的核对清单。

Intel设计师并没有就此驻足不前。IA-32 划分了地址空间,让操作系统和用户都能访问整个空间。IA-32用户能够在保持全面保护的情况下调用这一空间 中的操作系统例程,甚至还可以向其传送参数。由于操作系统栈不同于用户栈,所以这一安全调用可不是一 个简单操作。另外,LA-32允许操作系统为那些传递给被调用例程的参数保持这些被调用例程的保护级别禁止用户进程要求操作系统间接访问一些该进程自己不能访问的东西,就可以防止这一潜在保护漏洞。

Intel设计师有一条指导原则:尽可能对操作系统持怀疑态度,并支持共享和保护。作为这种受保护共享的一个应用示例,假定有一个薪金支付系统,它要填写支票,并更新有关本年度截至当前为止的总薪金和津贴支付信息。因此,我们希望赋予该程序读取薪金、当前信息和修改当前信息的功能,但不能修改薪金。稍后将会看到支持这些功能的机制。

增加界限检查和存储器映射

增强Intel 处理器的第一个步骤是利用分段寻址来检查界限和提供基址。IA-32中的分段寄存器中包含的不是基址,而是指向虚拟存储器数据结构的索引,这种结构称为描述符表。描述符表扮演着传统分页表的角色。IA-32 上与页表项等价的是段描述符。它包含可以在PTE中找到的字段,如下所述。

  • 存在位——等价于 PTE有效位,用于表明这是一个有效变换。
  • 基址字段——等价于一个页帧地址,包含该段第一个字节的物理地址。
  • 访问位——类似于某些体系结构中的引用位或使用位,可以为替换算法提供帮助。
  • 属性字段——为使用这一段的操作指定有效操作和保护级别。

还有一个在分页系统中没有出现的界限段,它确定这一分段有效偏移量的上限。图B-16给出了IA-32段描述符的示例。


图B-16 IA-32 段描述符由其属性字段中的位进行区分。基址位、界限位、存在位、可读位和可写位的用途都是不言自明的。D是指令的默认寻址大小:16位或32位。G是段界限的粒度:0表示采用字段,1表示采用4 KB的页。在开启分页以设置页表大小时,将G设置为1。 DPL表示描述符权限级别一根据代码权限级别核对 DPL,以查看是否允许访问。一致性是指代码采用被调用代码的权限级别,而不是调用者的权限级别;用于库例程。向下扩展段颠倒检查过程,以基址字段为高位标记,界限字段为低位标记。可以猜到,这种方式用于向下发展的栈段。字数控制从当前栈向调用门上新栈复制的字数。调用门描述符的其他两个字段——目标选择位和目标偏移量,分别选择该调用目标的描述符及其内部的偏移量。IA-32 保护模型中的段描述符远不止这三种

除了这种段式寻址之外,IA-32 提供了一种可选的分页系统。32位地址的上面部分选择段描述符,中间部分是描述符所选页表中的索引。下 面介绍不依赖分页的保护系统。

增加共享和保护

为提供受保持的共享,地址空间的一伴由所有进程共享,另一半由各进程独享,分别称为全局地址空间和局部地址空间。为每一半都提供一个拥有适当名字的描述符表。指向共享段的描述符被放在全局描述符表中,而指向专用段的描述符则被放在局部描述符表中。程序向IA-32 段寄存器中载入一个索引和一个位,索引指向描述符表,这一个位表明程序希望获得哪个表。根据描述符中的属性对操作进行检查,将来自处理器的偏移量加到描述符中的基址来构成物理地址,前提是这一偏移量要小于界限字段。每个段描述符都有一个独立的2位字段,提供这个段的合法访问级别。仅当程序尝试以段描述符中的较低保护级别使用段时,才会发生违反错误。

我们现在可以介绍如何调用上述薪金支付程序来更新当前信息,但不允许它更新薪金数据。可以向程序提供该信息的一个描述符,描述符的可写字段被清零,表明程序能够读取数据但不能写数据。然后可以提供一个受信任的程序,它只会写入当前最新信息。在向这一程序提供的描述符中,其可写字段已被置位(见图B-16)。薪金支付程序使用一个代码段描述符来调用受信任的代码,描述符的一致性字段已被置位。这种设置意味着被调用程序取得了被调用代码的权限级别,而不是调用者的权限级别。因此,薪金支付程序可以读取薪金信息,并调用受信任的程序来更新当前总值,但薪金支付程序不能修改这些薪金信息。

增加从用户到操作系统门的安全调用,为参数继承保护级别

允许用户介入操作系统是非常大胆的一步。但是,硬件设计师如何能在不信任操作系统或其他代码的情况下增加安全系统的可能性呢? IA-32 方法是限制用户能够进入代码段的位置,将参数安全地放到正确的栈中,并确保用户参数不会取得被调用代码的保护级别。为限制进入其他代码,IA-32 提供了一种被称为调用门(call gate)的特殊段描述符,用属性字段中的一位来识别。与其他描述符不同,调用门是一个对象在存储器中的完整物理地址;处理器提供的偏移量被忽略。如上所述,它们的目的是防止用户随机进入一段受保护或拥有更高权限的代码段中。在我们这个编程示例中,这意昧着薪金支付程序唯一能够调用受信任代码的位置就是在准确的边界位置。一致性段的正常工作需要这一限制。

如果调用者和被调用者“相互怀疑”,都不相信对方,那会怎么样呢?在图B-16询问描述符的字数字段中可以找到解决方案。当一条调用指令调用一个调用门描述符时,描述符将局部栈中的一些字复制到这个段级别相对应的栈中,字的数量由描述符指定。这一复制过程允许用户首先将参数压入局部栈中,从而实现参数传递。随后由硬件将参数安全地传送给正确的栈。在从调用门返回时,会将参数从栈中弹出,并将返回值复制到正确的栈中。注意,这一模型与目前在寄存器中传递参数的实际做法不兼容。

这一机制仍然未能关闭潜在的安全漏洞:操作系统以操作系统的安全级别来使用作为参数传递的用户地址,而不是使用用户级别。IA-32在每个处理器段寄存器中专门拿出2个位来指定所请求的保护级别,从而解决了上述问题。当这些地址参数载入段寄存器时,它们将所请求的保护级别设置为正确值。IA-32硬件随后使用所请求的保护级别来防止出现欺骗:对于使用这些参数的系统例程,如果其权限保护级别高于被请求级别,则不允许该例程访问任何段。

分页虚拟存储器举例: 64位Opteron存储器管理

常用的模型是在80386中引入的一种平面32位地址空间,它将段寄存器的所有基址值都设置为0。因此,AMD在64位模式中摒弃了多个段。它假定段基址为0,忽略了界限字段。页大小为4KB、2 MB和4MB。

AMD64体系结构的64位虚拟地址被映射到52位物理地址,当然,具体实现可以采用较少的位数,以简化硬件。例如,Opteron 使用48位虚拟地址和40位物理地址。AMD64需要虚拟地址的高16 位就是低48位的符号扩展,这称为规范格式。

64位地址空间页表的大小是惊人的。因此,AMD64使用了一种多级层次结构分页表来映射地址空间,使其保持合理大小。级别数取决于虚拟地址空间的大小。图B-17显示了Opteron的48位虚拟地址的四级变换。

这些页表中各个表的偏移量来自4个9位字段。地址变换时,首先将第一个偏移量加到页映射第4级基址寄存器,然后从这个位置读取存储器,获取下一级页表的基址。然后再将下一级地址偏移量添加到这个新获取的地址,再次访问存储器,以确定第三个页表的基址。再次重复以上过程。最后一个地址字段被加到这一最终基址,使用两者之和读取存储器,获得所引用页面的物理地址。这个地址与12位页面偏移量串接在一起,获得完整的物理地址。注意,Opteron体系结构中的页表可以放在单个4KB页中。

Opteron在每个页表中使用64位的项目。前12位为保留位,供以后使用,接下来的52位包含物理页帧编号,最后12位提供保护、使用信息。尽管不同页表级别之间的字段会有所变化,但基本上都有以下位。

  • 存在位——表明该页存在于存储器中。
  • 读取/写入位——表明一个页是只读的,还是读写的。
  • 用户/管理员位——表明用户是可以访问该页,还是仅限于上面三个权限级别。
  • 脏位——表明该页是否已经被修改。
  • 访问位——表明自该位上次清零以来,是否曾读取或写入过该页。
  • 页面大小位——表明最后一级是4 KB页面,还是4 MB页面;如果是后者,则Opteron只使用3个页面级别,而不是4个。
  • 不执行位——未出现在 80386保护机制中,添加这个位是为了防止代码在某些页内执行。
  • 页级缓存禁用——表明是否可以缓存该页。
  • 页级直写——表明该页对数据缓存应用写回还是直写。


图B-17 Opteron 虚拟地址的映射。拥有4个页表级别的Opteron虚拟存储器实现方式支持40位的有效物理地址大小。每个页表有512项,所以每一级字段的宽度为9位。AMD64体系结构文档允许虚拟地址大小从当前的48位增长到64位,允许物理地址从当前的40位增长到52位

由于Opteron在TLB缺失时通常会经历四级页表,所以有3个可能位置来核对保护限制。Opteron仅服从底级PTE,检查其他各项只是为了确保设置了有效位。由于该项目的长度为8个字节,每个页表有512项,而且Opteron拥有大小为4 KB的页,所以这些页表的长度恰好为一页。 这些四级字段的每一个字段长9位,页偏移量为12位。这一推导留出64-(4 x 9 + 12)=16位进行符号扩展,以确保地址的规范化。

尽管我们已经解释了合法地址的转换,那什么会防止用户创建非法地址转换并阻止故障发生呢?这些页表本身是受保护的,用户程序不能对其进行写入。因此,用户可以尝试任意虚拟地址,但操作系统通过控制页表项来控制访问哪个物理存储器。为了实现进程之间的存储器共享,在每个地址空间中都设置一个页表项,指向同一个物理存储器页。

Opteron采用4个TLB来缩短地址转换时间,两个TLB用于指令访问,两个用于数据访问。与多级缓存类似,Opteron 通过两个较大的L2 TLB来减少TlB缺失:一个用于指令,一个用于数据。表B-11介绍了数据TLB。


小结:32 位Intel Pentium与64位AMD Opteron的保护对比

Opteron中的存储器管理是当今大多数桌面或服务器计算机的典型代表,依靠页级地址变换和操作系统的正确操作,为共享计算机的多个进程提供安全性。尽管Intel也提出了一些替代方案,但它还是沿袭了AMD的领先作法,接纳了AMD64体系结构。因此,AMD和Intel都支持80x86的64位扩展;但出于兼容性原因,这两者都支持复杂的分段保护机制。

结语

要想制造出能够跟上处理器步伐的存储器系统,其难度极大,无论多么先进的计算机,其主存储器的制造原材料都与最廉价的计算机一致,这一事实为上述难度添加了新的注脚。这里能为我们提供帮助的是局域性原理一当前计算机中存 储器层次结构的各个级别(从磁盘到TLB)都证明了它的正确性。

但是,到存储器的相对延迟不断增加,2011 年达到数百个时钟周期,这就意味着,如果程序员和编译器编写入员希望自己的程序能够正常执行,就必须了解缓存和TLB的参数。

流水线:基础与中级概念

引言

我们在本附录中首先介绍流水线的基础知识,包括数据路径含义的讨论、冒险的介绍、流水线性能的研究。这一节介绍基本的五级RISC,它是本附录其余部分的基础。C.2 节介绍冒险问题、它们为什么会导致性能问题、应当如何应对。C.3节讨论如何实际实现这个简单的五级流水线,重点是控制和如何应对冒险。C.4节讨论流水线和指令集设计各个方面之间的相互关系,讨论了有关异常的重要主题以及它们与流水线的相互交互。C.5节讨论如何扩展五级流水线,以处理运行时间更长的浮点指令。C.6节在一个案例研究中将这些概念结合在一起,这个案例研究的对象是一个深度流水线处理器——MIPS R4000/4400,它既包括八级整数流水线,又包括浮点流水线。C.7节介绍了动态调度的概念,以及记分板在动态调度实现中的用法。它是作为交叉问题介绍的。C.7节还简单探讨了第3章中介绍的更复杂Tomasulo 算法。

什么是流水线

流水线是一种将多条指令重叠执行的实现技术。一条指令的执行需要多个操作,流水线技术充分利用了这些操作之间的并行性。就像装配线一样,不同步骤并行完成不同指令的不同部分。这些步骤中的每一步都称为流水级或流水段。流水级前后相连形成流水线一指令在一端进入,通过这些流水级,在另一段退出。

指令流水线的吞吐量由指令退出流水线的频率决定。由于流水线级是连在一起的,所以所有流水级都必须做好同时工作的准备。将一条指令在流水线中下移一步所需的时间为处理器周期。由于所有各级同时进行,所以处理器周期的长度由最缓慢流水线级所需时间决定。在计算机中,这一处理器周期通常为1个时钟周期(有时为2个,但要少见得多)。流水线设计者的目标是平衡每条流水线的长度。如果各级达到完美平衡,那么每条指定在流水线处理器中的时间(假定为理想条件)等于:

非流水线机器上每条指令的时间/流水级的数目

在这些条件下,因为实现流水线而得到的加速比等于流水级的数目,但一般情况下,这些流水线之间不会达到完美平衡;此外,流水线还会产生一些开销。因此,在流水线处理器上,处理每条指令的时间不会等于其最低可能值,但可以非常接近。

流水线可以缩短每条指令的平均执行时间。根据我们认证的基准,这一缩短量可以计作每条指令时钟周期数(CPI)的下降、时钟周期时间的缩短,或者这两者的组合。如果在开始时,处理器需要多个时钟周期来处理一条指令,那我们通常将流水线的作用看作是降低了CPI。

如果在开始时,处理器需要一个(长)时钟周期来处理一条指令,那就认为流水线缩短了时钟周期时间。流水线技术开发了串行指令流中各指令之间的并行度。它与某些加速技术不同,其真正的好处在于它对程序员是透明的。在这一附录中,我们将首先使用一个经典的五级流水线来介绍流水线的概念;其他章节研究了更复杂流水线技术在现代处理器的应用。在更深入地讨论流水线及其在处理器的应用之前,我们需要一个简单的指令集,下面将对此进行介绍。

RISC指令集基础知识

在本书中,我们一直使用RISC(精简指令集计算机)体系结构或载入-存储体系结构来说明基本概念,当然,本书介绍的几乎所有思想都适用于其他处理器。本节介绍典型RISC 体系结构的核心。RISC体系结构有几个关键属性,这些属性大大简化了其实现方式。

  • 所有数据操作都是对寄存器中数据的操作,通常会改变整个寄存器(每个寄存器为32位或64位)。
  • 只有载入和存储操作会影响到存储器,它们分别将数据从存储器移到寄存器或从寄存器移到存储器。通常存在一些可以载入或存储低于一个完整寄存器的内容。
  • 指令格式的数量很少,所有指令通常都是同一种大小。

这些简单属性极大地简化了流水线的实现,这也是如此设计这些指令集的原因。为与本书其他部分保持一致, 我们使用MIPS64,它是MIPS指令集的64位版本。这些扩展64位指令通常在助记符的开头或末尾加上字母D加以标识。例如,DADD是加法指令的64位版本,而LD则是载入指令的64位版本。

与其他RISC体系结构类似,MIPS 指令集提供了32个寄存器,不过寄存器0的值总是0。大多数RISC体系结构,比如MIPS,通常有以下三类指令(更多详细信息请参见附录A)。

(1) ALU指令——这些指令取得两个寄存器或者一个寄存器与一个符号扩展立即数(称为ALU立即数指令,它们在MIPS中有一个16位偏移量),对它们进行操作,然后将结果存储到第三个寄存器中。典型操作包括加(DADD)、减(DSUB)和逻辑运算(例如AND或OR),后者不区分32位和64位版本。这些指令的立即数版本使用相同助记符,但添加了后缀I。在MIPS中,包括算术运算的有符号形式和无符号形式;无符号形式的末尾有一个字母U(例如,DADDU、DSUBU、DADDIU),不会生成溢出异常(因此在32位和64位模式中是一样的)。

(2)载入和存储指令——这些指令获取一个寄存器源(称为基址寄存器)和一个立即数字段(在MIPS中为16位,称为偏移量)作为操作数。基址寄存器的内容与符号扩展偏移量之和(称为有效地址)用作存储器地址。对于载入指令,使用第二个寄存器操作数,用于存放从存储器载入的数据。对于存储指令,第二个寄存器操作数是要存入存储器的数据源。载入字(LD)和存储字(SD)等指令载入或存储整个64位寄存器内容。

(3)分支与跳转——分支是指控制的条件转移。在RISC体系结构中,通常有两种方式来指定分支条件:采用一组条件位(有时称为条件码),或者通过寄存器对之间、寄存器与零之间的有限对比来设定。MIPS采用后者。对于本附录,我们仅考虑两个寄存器是否相等。在所有RISC体系结构中,分支目的地都是通过将符号扩展偏移量(MIPS中为16位)加到当前程序计数器中获得的。在许多RISC体系结构中都提供了无条件跳转,但本附录中不会介绍跳转。

RISC指令集的简单实现

为了理解如何以流水线形式来实现RISC指令集,需要理解在没有流水线时它是如何实现的。这一节给出了一种简单实现,每一条指令 最多需要5个时钟周期。我们将这一基本实现扩展到流水线版本,从而大幅降低CPI。在所有不采用流水线的实现方式中,我们给出的方式并非最经济或性能最高的。它的设计只是可以很自然地引向流水线实现。实现此指令集需要引入几个不属于该体系结构的临时寄存器,引入它们是为了简化流水线。我们的实现将仅关注RISC体系结构中整数操作部分的流水线,这部分操作包括载入-存储字、分支和整数ALU操作。

这个RISC子集中的每条指令都可以在最多5个时钟周期内实现。这5个时钟周期如下所述。

  • 指令提取周期(IF)
    • 将程序计数器(PC)发送到存储器,从存储器提取当前指令。向程序计数器加4 (因为每条指令的长度为4个字节),将程序计数器更新到下一个连续程序计数器。
  • 指令译码/寄存器提取周期(ID)
    • 对指令进行译码,并从寄存器堆中读取与寄存器源说明符相对应的寄存器。在读取寄存器时对其进行相等测试,以确定是否为分支。必要时,对指令的偏移量字段进行符号扩展。符号扩展后的偏移量添加到所实现的程序计数器上,计算出可能的分支目标地址。在较为积极的实现方式中,如果这一条件判断的结果为真,则可以将分支目标地址存储到程序计数器中,以在这一级的末尾完成分支。
    • 指令译码与寄存器的读取是并行执行的,这可能是因为在RISC体系结构中,寄存器说明符位于固定位置。这一技术称为固定字段译码。注意,我们可能会读取一个不会使用的寄存器。由于一个指令的立即数部分也位于同一位置,所以在需要符号扩展立即数时,也是在这一周期计算的。
  • 执行/有效地址周期(EX)
    • ALU对上一周期准备的操作数进行操作,根据指令类型执行三条指令之一。
      • 存储器引用——ALU 将基址寄存器和偏移量加到一起,形成有效地址。
      • 寄存器-寄存器ALU指令——ALU 对读自寄存器堆的值执行由ALU操作码指定的操作。
      • 寄存器-立即数ALU指令——ALU对读自寄存器堆的第一个值和符号扩展立即数执行由ALU操作码指定的操作。
    • 在载入-存储体系结构中,有效地址与执行周期可以合并到一个时钟周期中,这是因为没有指令需要计算数据地址并对数据执行操作。
  • 存储器访问(MEM)
    • 如果该指令是一条载入指令,则使用上一周期计算的有效地址从存储器中读取数据。如果是一条存储指令,则使用有效地址将从寄存器堆的第二个寄存器读取的数据写入存储器。
  • 写回周期(WB)
    • 寄存器寄存器ALU指令或载入指令。
    • 将结果写入寄存器堆,无论是来自寄存器系统(对于载入指令),还是来自ALU(对于ALU指令)。

在这一实现中,分支指令需要2个周期,存储指令需要4个周期,所有其他指令需要5个周期。假定分支频率为12%,存储频率为10%,对于这一典型指令分布, 总CPI 为4.54。但是,无论是在获取最佳性能方面,还是在给定性能级别的情况尽量减少使用硬件方面。

RISC处理 器的经典五级流水线

我们几乎不需要进行什么改变就能实现上述执行过程的流水化,只要在每个时钟周期开始一条新的指令就行。上一节的每个时钟周期都变成一个流水
线级——流水线中的一个周期。这样会得到表C-1所示的执行模式,这是绘制流水线结构的典型方式。尽管每条指令需要5个周期才能完成,但在每个时钟周期内,硬件都会启动一条新的命令,执行5个不同指令的某一部分。

  • 在每个时钟周期,提取另一条指令,并开始它的五周期执行过程。如果在每个时钟周期都启动一条指令,其性能最多可达到非流水化处理器的5倍。流水线中各个阶段的名称与非流水线实现方式中各个周期的名称相同: IF=指令提取、ID=指令译码、EX=执行、MEM=存储器访问、WB=写回。

首先,我们必须确定在处理器的每个时钟周期都会发生什么,确保不会在同一时钟周期内对相同数据路径源执行两个不同操作。例如,不能要求同一个ALU同时计算有效地址和执行减法操作。因此,我们必须确保流水线中的指令重叠不会导致这种冲突。幸运的是,RISC指令集比较简单,使资源评估变得相对容易。图C-1以流水线形式绘制了一个RISC数据路径的简化版本。可以看到,主要功能单元是在不同周期使用的,因此多条指令的执行重叠不会入多少冲突。从以下三点可以看出这一事实。


图C-1 流水线可以看作一系列随时间移动的数据路径。本图给出了数据路径不同部分之间的重叠,时钟周期5 (CC5)表示稳定状态。由于寄存器用作ID级中的一个源和WB级中的目的地,所以它出现两次。我们表示它在该级的一个部分进行读取,在另一部分进行写入,分别用左右两侧的实线或虚线来表示。缩写IM表示指令存储器、DM表示数据存储器、CC表示时钟周期

第一,我们使用分离的指令存储器和数据存储器,我们通常用分离的指令和数据缓存来实现它们。在使用存储器时,在指令提取和数据存储器访向之间可能会发生冲突,而使用分离缓存则可以消除这种冲突。注意,如果我们的流水线处理器的时钟周期等于非流水线版本的时钟周期,则存储器系统必须提供5倍的带宽。这一需求的增加是提高性能的一种成本。

第二,在两个阶段都使用了寄存器堆:一个是在ID中进行读取,一个是在WB中进行写入。这些用法是不同的,所以我们干脆在两个地方画出了寄存器堆。因此,每个时钟周期需要执行两次读取和一次写入。为了处理对相同寄存器的多次读取和一次写入,我们在时钟周期的前半部分写寄存器,在后半部分读寄存器。

第三,图C-1没有涉及程序计数器。为了在每个时钟周期都启动一条新指令,我们必须在每个时钟周期使程序计数器递增并存储它,这必须在IF阶段完成,以便为下一条指令做好准备。此外,还必须拥有一个加法器,在ID期间计算潜在的分支目标。另外一个问题是分支在ID级改变程序计数器。

尽管确保流水线中的指令不会试图在相同时间使用硬件资源是至关重要的,我们还必须确保不同流水级中的指令不会相互干扰。这种分离是通过在连续流水级之间引入流水线寄存器来完成的,这样会在时钟周期的末尾,将一个给定流水级得出的所有结果都存储到寄存器中,在下一个时钟周期用作下一级的输入。图C-2给出了画有这些流水线寄存器的流水线。

图C-2 此流水线显示了连续流水级之间的流水线寄存器。注意,寄存器防止流水线相邻级中两条不同指令之间的干扰。在将一条给定指令的数据从一级带至另一级的过程中,寄存器也扮演着重要角色。寄存器的边沿触发特性(也就是说,取值在时钟沿即时改变)是非常关键的。否则,来自一条指
令的数据可能会干扰另一指令的执行!

尽管许多图形都为了简便而省略了这些寄存器,但它们是流水线正常操作所必需的。当然,即使在一些没有 采用流水化的多周期数据路径中也需要类似寄存器(因为 只有寄存器中的值能够在跨过时钟边界之后仍然得以保存)。在流水化处理器中,如果要将中间结果从一级传送到另一级,而源位置与目标位置可能并非直接相邻,流水线寄存器也会在这种传送过程种扮演关键角色。例如,要在存储指令中存储的寄存器值是在ID期间读取的,但要等到MEM才会真正用到;它在MEM级中通过两个流水线寄存器传送给数据存储器。与此类似,ALU指令的结果是在EX期间计算的,但要等到WB才会实际存储;它通过两个流水线寄存器才到达那里。有时对流水线寄存器进行命名是有用的,根据这些寄存器所连接的流水级对其进行命名,所以这些
寄存器称为IF/ID、ID/EX、EXMEM和MEM/WB。

流水化的基本性能问题

流水化提高了CPU指令吞吐量(单位时间内完成的指令数),但不会缩短单条指令的执行时间。事实上,由于流水线控制会产生开销,它通常还会稍微延长每条指令的执行时间。尽管单条指令的运行速度并没有加快,指令吞吐量的增长意味着程序可以更快速地运行,总执行时间缩短。除了因为流水线延迟产生的局限之外,流水级之间的失衡和流化化开销也会造成限制。流水级之间的不平衡会降低性能,这是因为时钟的运行速度不可能快于最缓慢的流水级。

流水线开销包含流水线寄存器延迟和时钟偏差。流水线寄存器增加了建立时间,也就是在发出触发写操作的时钟信号之前,寄存器输入必须保持稳定的时间,而且时钟周期的传播也会产生延迟。时钟偏差是时钟到达任意两个寄存器时刻之间的最大延迟,时钟周期的下限也受此因素的影响。如果时钟周期小于时钟偏差与延迟开销之和,那时钟周期中就没有留给有用工作的时间了,所以再增加流水线也就没用了。

考虑上一节的非流水化处理器。假定其时钟周期为1 ns,ALU运算和分支需要4个周期,存储器操作需要5个周期。假定这些操作的相对频率分别为40%、20%和40%。假设由于时钟偏差和建立时间的原因,对处理器实现流水化使时钟增加了0.2 ns的开销。忽略所有延迟影响,通过流水线获得的指令执行速率加速比为多少?

在非流水化处理器上,指令平均执行时间为:

指令平均执行时间=时钟周期x平均CPI = Ins x [(40% + 20%)x4 + 40%x5] = Ins x 4.4 = 4.4ns

在流水线实现方式中,时钟的运行速度必须等于最慢流水级的速度加上开销时间,也就是1+0.2=1.2 ns;这就是指令平均执行时间。因此,通过流水化获得的加速比为:

流水化加速比 = 非流水化指令平均执行时间/流水化指令平均执行时间 = 4.4ns/1.2ns = 3.7倍

0.2 ns的开销基本上确定了流水化的效能限度。如果此开销不受时钟周期变化的影响,那么从Amdahl定律可知,这一开销限制了加速比。

如果流水线中每条指令独立于所有其他指令,那这种简单的RISC 流水线对于整数指令可以正常运行。实际上,流水线中的指令可能是相互依赖的;这是下一节的主题。

流水化的主要阻碍——流水线冒险

有一些被称为冒险的情景,会阻止指令流中的下一条指令在其自己的指定时钟周期内执行。冒险降低了流水化所能获得的理想加速比。共有以下三类冒险。

  1. 结构冒险,在重叠执行模式下,如果硬件无法同时支持指令的所有可能组合方式,就会出现资源冲突,从而导致结构冒险。
  2. 数据冒险,根据流水线中的指令重叠,指令之间存在先后顺序,如果一条指令取决于先前指令的结果,就可能导致数据冒险。
  3. 控制冒险,分支指令及其他改变程序计数器的指令实现流水化时可能导致控制冒险。

流水线中的冒险会使流水线停顿。为了避免冒险,经常要求在流水线中的一些指令延迟时,其他一些指令能够继续执行。对于本附录中讨论的流水线,当一条指令被停顿时,在停顿指令之后发射的所有指令也被停顿(这些指令在流水线中的位置不会远于停顿指令)。而在停顿指令之前发射的指令必须继续执行(它们在流水线中的位置要更远一些),否则就永远不会清除冒险情况。结果,在停顿期间不会提取新的指令。

带有停顿的流水线性能

停顿会导致流水线性能下降,低于理想性能。现在让我们看一个简单的公式,求解流水化的实际加速比,首先从上一节的公式开始:

流水化加速比=非流水化指令平均执行时间/流水化指令平均执行时间 = (非流水化CPI x 非流水化时钟周期)/(流水化CPI x流水化时钟周期) = (非流水化CPI/流水化CPI)x(非流水化时钟周期/流水化时钟周期)

流水化可以看作CPI或时钟周期时间的降低。由于传统上使用CPI来比较流水线,所以让我们从这里开始。流水化处理器的理想CPI几乎总等于1。因此,可以计算流水化CPI为:

流水化CPI = 理想CPI+每条指令的流水线停顿时钟周期 = 1+每条指令的流水线停顿时钟周期

如果忽略流水化的周期时间开销,并假定流水级之间达到完美平衡,则两个处理器的周期时间相等,得到:

加速比=非流水化CPI/(1+每条指令的流水线停顿周期)

一种简单而重要的情景是所有指令的周期数都相同,必然等于流水级数目(也称为流水线深度)。在这种情况下,非流水化CPI等于流水线的深度,得到:

加速比=流水深度/(1+每条指令的流水线停顿周期)

如果没有流水线停顿,由此公式可以得到一个很直观的结果:流水化可以使性能提高的倍数等于流水线深度。

或者,如果将流水化看作时钟周期时间的改善,那可以假定非流水化处理器的CPI以及流水化处理器的CPI为1。于是得到:

流水化加速比=(非流水化CPI/流水化CPI)x(非流水化时钟周期/流水化时钟周期) = (1/(1+每条指令的流水线停顿周期))x(非流水化时钟周期/流水化时钟周期)

当流水级达到完美平衡,而且没有开销时,流水化处理器的时钟周期小于非流水化处理器的时钟周期,缩小因子等于流水线深度:

流水化时钟周期=非流水化时钟周期/流水线深度

流水线深度=非流水化时钟周期/流水化时钟周期

从而得到以下公式:

流水化加速比=(1/(1+每条指令的流水线停顿周期))x(非流水化时钟周期/流水化时钟周期) = (1/(1+每条指令的流水线停顿周期))x流水线深度

因此,如果没有停顿,则加速比等于流水级数,与我们对理想情况的直观感受一致。

结构冒险

当处理器以流水线方式工作时,指令的重叠执行需要实现功能单元的流水化和资源的复制,以允许在流水线中出现所有可能的指令组合。如果由于资源冲突而不能容许某些指令组合,就说该处理器存在结构冒险

结构冒险最常见于某功能单元未能完全流水化的情况。这时,一系列使用该非流水化单元的指令不能以每时钟周期执行一条指令的速度前进。结构冒险另一常见出现方式是某一资源的复制不足以执行流水线中的所有指令组合。例如,处理器可能仅有一个寄存器堆写端口,但在特定情况下,流水线可能希望在一个时钟周期内执行两个写操作。这就会生成结构冒险。

当指令序列遇到这种冒险时,流水线将会使这些指令中的一个停顿,直到所需单元可用为止。这种停顿会增大CPI值,不再是其通常的理想值1。一些流水化处理器为数据和指令共享单存储器流水线。结果,当指令中包含数据存储器引用时,它会与后面指令的指令引用冲突,如图C-3所示。为避免这一冒险,在发生数据存储器访问时,我们使流水线停顿一个时钟周期。停顿通常被称为流水线气泡,或就称为气泡,因为它会飘浮穿过流水线,占据空间却不执行有用工作。在讨论数据冒险时,将会看到另一种停顿类型。

图C-3 只要发生存储器引用,仅有一个存储器端口的处理器就会生成一次冲突. 在这个示例中,载入指令因为数据访问而使用存储器的同时,指令3希望从存储器中提取一条指令

设计者经常使用仅含有流水级名称的简图来表示停顿行为,如表C-2所示。在表C-2中展示停顿时,指出那些没有执行操作的周期,只是将指令3向右移动(使其执行过程的开始与结束都推后1个周期)。当流水线气泡穿过流水线时,其效果就是占据了该指令时隙的资源。

  • 载入指令实际强占了指令提取周期,导致流水线停顿——在4号时钟周期没有启动指令(它通常会启动指令i+3)。因为被提取的指令停顿,所以流水线中位于停顿指令之前的所有其他指令都可以正常进行。停顿周期将继续穿过流水线,所以在8号时钟周期中没有完成指令。有时在绘制这些流水线表时,让流水线占据整个水平行,指令3被移到下一行;无论采用哪种画法,效果都是一样的,因为指令i+3直到5号周期才开始执行。因为以上形式占据的空间较少,所以我们采用了这一形式。注意,本图假定指令i+1和i+2不是存储器引用。

让我们看看载入结构冒险的成本有多高。假定数据引用占总体的40%,流水化处理器的理想CPI为1(忽略结构冒险)。假定与没有冒险的处理器相比,有结构冒险处理器的时钟频率为其1.05 倍。不考虑所有其他性能损失,有结构冒险和无结构冒险相比,哪种流水线更快?快多少?

有几种方法可以求解这一问题。 最简单的一种可能就是计算两种处理器的平均指令时间:

平均指令时间 = CPI x 时钟周期时间

由于没有停顿,所以理想处理器的平均指令时间就是时钟周期时间理想。有结构冒险处理器的平均指令时间为:

平均指令时间=CPIx时钟周期时间 = (1+0.4x1)x(时钟周期时间(理想)/1.05) = 1.3x时钟周期时间(理想)

显然,没有结构冒险的处理器更快一些; 根据平均指令时间的比值,我们可以得出结论,无冒险处理器的速度快1.3倍。

为避免出现这种结构冒险,设计师可以为指令提供独立的存储器访问,既可以将缓存分为独立的指令缓存和数据缓存,也可以使用一组缓冲区来保存指令,这种缓冲区通常称为指令缓冲区。

如果其他因素相同,无结构冒险处理器的CPI总是更低一些。那设计师为什么还会允许结构冒险呢?其主要目的是为了降低单元成本,因为要实现所有功能单元的流水化,或者复制它们,成本都太高昂了。例如,那些在每个周期内支持指令与数据缓存访问的处理器需要有2倍的总存储器带宽,通常要求管脚处也可以承受较高的带宽。

与此类似,要完全实现浮点(FP)乘法器需要耗用大量门电路。如果结构冒险很罕见,那就不值得花费这么大的代价来避免它。

数据冒险

流水化的主要效果是通过重叠指令的执行过程来改变它们的相对执行时间。这种重叠引入了数据与控制冒险。当流水线改变对操作数的读写访问顺序,使该顺序不同于在非流水化处理器上依次执行指令时的顺序,这时可能发生数据冒险。考虑以下指令的流水化执行:

1
2
3
4
5
DADD     R1, R2, R3
DSUB R4, R1, R5
AND R6, R1, R7
OR R8, R1, R9
XOR R10, R1, R11

DADD之后的所有指令都用到了DADD 指令的结果。如图C-4所示,DADD 指令在WB流水级写入R1的值,但DSUB指令在其ID级中读取这个值。这一问题称为数据冒险。除非提前防范这种问题,否则DSUB指令将会读取错误值并试图使用它。事实上,DSUB 指令使用的值甚至是不确定的:我们可能假定DSUB使用的R1值总是由DADD之前的指令赋值,认为这种假定是合乎逻辑的,但事实并非总是如此。如果在DADD和DSUB指令之间发生中断,DADD 的WB级将结束,而该点的R1值将是DADD的结果。这种不可预测的行为显然是不可接受的。

AND指令也会受到这种冒险的影响。从图C-4 中可以看出,在5号时钟周期之前,R1的写入操作是不会完成的。因此,在4号时钟周期读取寄存器的AND指令会得到错误结果。

图C-4在后面三条指令中使用DADD指令的结果时,由于要等到这些指令读取寄存器之后才会向其中写入,所以会导致冒险。

XOR指令可以正确执行,因为它的寄存器读取是在6号时钟周期进行的,这时寄存器写入已经完成。OR指令的执行也不会招致冒险,因为我们在该时钟周期的后半部分执行寄存器堆读取,而写入是在前半部分执行的。

下一小节将讨论一种技术,用于消除涉及DSUB和AND指令的冒险停顿。

利用转发技术将收据冒险停顿减至最少

图C-4中提出的问题可以使用一种称为转发(forwarding)的简单硬件技术来解决(这一技术也称为旁路,有时也称为短路)。转发技术的关键是认识到DSUB要等到DADD实际生成结果之后才会真正用到它。DADD 将此结果放在流水线寄存器中,如果可以把它从这里转移到DSUB需要的地方,那就可以避免出现停顿。根据这一观察结果,转发的工作方式如下所述。

  1. 来自EX/MEM和MEMWB流水线寄存器的ALU结果总是被反馈回ALU的输入端。
  2. 如果转发硬件检测到前一个ALU操作已经对当前AlU操作的源寄存器进行了写入操作,则控制逻辑选择转发结果作为ALU输入,而不是选择从寄存器堆中读取的值。

注意,采用转发技术后,如果DSUB停顿,则DADD将会完成,不会触发旁路。当两条指令之间发生中断时,这一关系同样成立。

如图C4中的示例所示,我们需要转发的结果可能不只来自前一条指令,还可能来自提前两个周期启动的指令。图C-5显示了带有旁通路径的示例,它重点突出了寄存器读取与写入的时机。这一代码序列可以无停顿执行。

图C-5 一组依赖DADD结果的指令使用转发路径来避免数据冒险。DSUB 和AND指令的输入是从流水线寄存器转发到第一个ALU输入。0R接收的结果是通过寄存器堆转发而来的,这一点很容易实现,只需要在周期的后半部分读取寄存器、在前半部分写入寄存器就能轻松完成,如寄存器上的虚线所示。注意,转发结果可以到达任一ALU输入;事实上,ALU输入既可以使用来自相同流水线寄存器的转发输入,也可以使用来自不同流水线寄存器的转发输入。例如,当AND指令为AND R6,R1,R4时就会发生这种情况

可以将转发技术加以推广,将结果直接传送给需要它的功能单元:可以将一个功能单元输出到寄存器中的结果直接转发到另一个功能单元的输入,而不仅限于同一单元的输出与输入之间。例如以下序列:

1
2
3
DADD      R1, R2, R3
LD R4,0(R1)
SD R4,12(R1)

为防止这一序列中出现停顿,我们需要将ALU输出值和存储器单元输出值从流水线寄存器转发到ALU和数据存储器输入。图C-6给出了这一示例的所有转发路径。


图C-6 在MEM期间执行的存储操作需要转发操作数。载入结果由存储器输出转发到要存储的存储器输入端。此外,ALU指令被转发到ALU输入,供载入和存储指令进行地址计算(这与转发到另一个ALU操作没有区别)。如果存储操作依赖与其直接相邻的前一个ALU操作(图中未示出),则需要转发其结果,以防止出现停顿

需要停顿的数据冒险

遗憾的是,并非所有潜在数据冒险都可以通过旁路方式处理。考虑以下指令序列:

1
2
3
4
LD      R1, 0(R2)
DSUB R4,R1,R5
AND R6,R1,R7
OR R8,R1,R9

这一示例中旁通路径的流水化数据路径如图C-7所示。这种情况不同于背靠背ALU操作的情景。LD指令在4号时钟周期(其MEM周期)结束之前不会得到数据,而DSUB指令需要在该时钟周期的开头就得到这一数据。因此,因为使用载入指令结果而产生的数据冒险无法使用简单的硬件消除。如图C-7所示,这种转发路径必须进行时间上的回退操作。我们能够立即将该结果从流水线寄存器转发给ALU,供AND操作使用,该操作是在载入操作之后两个时钟周期启动的。与此类似,OR 指令也没有问题,因为它是通过寄存器堆接收这个值的。对于DSUB指令,转发结果在时钟周期结束时才会抵达,这显然太晚了,因为这一指令需要在此时钟周期开始时启动。

载入指令有一种不能单由转发来消除的延迟。而是需要增加一种称为流水线互锁,以保持正确的执行模式。一般情况下, 流水线互锁会检测冒险,并在该冒险被清除之前使流水线停顿。在这种情况下,互锁使流水线停顿,让希望使用某一数据的指令等待,直到源指令生成该数据为止。这种流水线互锁引入一次停顿或气泡,就像应对结构冒险时所做的一样。停顿指令的CPI会增加,延长数目等于停顿的长度(在本例中为1个时钟周期)。

图C-7 载入指令可以将其结果旁路至AND和OR指令,但不能旁路至DSUB,因为这将意味着在“负向时间”上转发结果

表C-3使用流水级名称显示了停顿前后的流水线。因为停顿会导致从DSUB开始的指令在时间上向后移动1个周期,转发给AND指令的数据现在是通过寄存器堆到达的,而对于OR指令根本不需要转发。由于插入了气泡,需要增加一个周期才能完成这一序列。4号时钟周期内没有启动指令(在6号周期没有指令完成)。

分支冒险

对于我们的MIPS流水线,控制冒险造成的性能损失可能比数据冒险还要大。在执行分支时,修改后的程序计数器的值可能等于(也可能不等于)当前值加4。回想一下,如果分支将程序计数器改为其目标地址,它就是选中分支;否则就是未选中分支。如果指令i为选中分支,通常会等到ID末尾,完成地址计算和对比之后才会改变程序计数器。表C-4表明,处理分支的最简单方法是:一旦在ID期间(此时对指令进行译码)检测到分支,就对该分支之后的指令重新取值。第一个IF周期基本上是一次停顿,因为它从来不会执行有用工作。读者可能已经注意到,如果分支未被选中,由于事实上已经正确地提取了指令,所以IF级的重复是不必要的。表C-4分支在五级流水线中导致一个周期的停顿分支指令

  • 分支指令之后的指令已被提取,但随后被忽略,在已经知道分支目标之后,重新开始提取操作。如果分支未被选中,则分支后续指令的第二个IF就有些多余了,这一点可能比较明显。

如果每个分支产生一个停顿周期, 将会使性能损失10%~30%,具体取决于分支频率,所以我们将研究一些用于应对这一损失的技术。

降低流水线分支代价

有许多方法可以处理由分支延迟导致的流水线停顿,我们在这一小节讨论4种简单的编译时机制。在这4种机制中,分支的操作是静态的,也就是说,在整个执行过程中,它们对每条分支来说都是固定的。软件可以尝试利用硬件机制和分支行为方面的知识将分支代价降至最低。

处理分支的最简单机制是冻结或冲刷流水线,保留或删除分支之后的所有指令,直到知道分支目标为止。这种解决方案的吸引力主要在于其软硬件都很简单。这也是表C-4所示流水线中较早使用的解决方案。在这种情况下,分支代价是固定的,不能通过软件来缩减。一种性能更高但仅略微复杂一点的机制是将每个分支都看作未选中分支,允许硬件继续执行,就好像该分支未被执行一样。这时必须非常小心,在确切知道分支输入之前,不要改变处理器状态。这一机制的复杂性在于必须要知道处理器状态可能何时被指令改变,以及如何“撤销”这种改变。

在简单的五级流水线中,这种预测未选中机制的实现方式是继续提取指令,就好像分支指令是一条正常指令一样。流水线看起来好像没有什么异常发生。但是,如果分支被选中,就需要将已提取的指令转为空操作,重新开始在目标地址位提取指令。表C-5显示了这两种情况。一种替代机制是将所有分支都看作选中分支。只要对分支指令进行了译码并计算了目标地址,我们就假定该分支将被选中,开始在目标位置提取和执行。因为在我们的五级流水线中,不可能在知道分支输出结果之前知道目标地址,所以这种方法对于这一流水线没有好处。在一些处理器中,特别是那些拥有隐性设定条件代码或者拥有更强大分支条件的处理器中,是可以在分支输出之前知道分支目标的,这时,预测选中机制可能就有意义了。

无论是在预测选中还是预测未选中机制中,编译器总是可以通过代码组织方式,使最频繁的路径与硬件选择相匹配,从而提高性能。我们的第四种机制为编译器提高性能提供了更多的机会。

  • 当分支未被选中时(在ID期间确定),我们提取未选中指令,继续进行。当在ID期间确定选中该分支时,则在分支目标处重新开始提取。这将导致该分支后面的所有指令停顿1个时钟周期。在某些处理器中使用的第四种机制称为延迟分支。这一技术在早期RISC处理器中的使用非常广泛,在五级流水线中的工作状态相当好。在延迟分支中,带有一个分支延迟的执行周期为:
  • 分支指令
  • 依序后续指令
  • 选中时的分支目标

依序后续指令位于分支延迟时隙中。无论该分支是否被选中,这一指令都会执行。表C-6中给出具有分支延迟的五级流水线的流水线行为特性。尽管分支延迟可能长于一个1周期,但在实际中,几乎所有具有延迟分支的处理器都只有单个指令延迟;如果流水线的潜在分支代价更长,则使用其他技术。

  • 延迟时隙中的指令(对于MIPS,只有一个延迟时隙)被执行。如果分支未被选中,则继续执行分支延迟指令之后的指令;如果分支被选中,则继续在分支目标处执行。当分支延迟时隙中的指令也是分支时,其含义有些模糊:如果该分支未被选中,延迟分支时隙中的分支应当怎么办呢?由于这一混淆,采用延迟分支的体系结构经常禁止在延迟时隙中放入分支。

编译器的任务是让后续指令有效并可用。因此使用了多种优化方式。图C-8给出了三种可以调度分支延迟的方式。

图C-8 分支延迟时隙的调度。每一对中的上框显示调度之前的代码;下框表示调度后的代码。在(a)中,延迟时隙内载入的是分支之前的一条不相关指令。这是最佳选择。策略(b)与(c)在策略(a)不可能实现时使用。在(b)和(c)的序列中,由于分支条件中使用了R1,所有不能将DADD指令移到分支之后(它的目的地是R1 )。在(b)中,分支延迟时隙中填充了分支的目标指令;这时一般需要复制目标指令,因为其他路径也可能会到达这一目标指令。当分支的选中机率很高时(比如循环分支)优选策略(b)。最后,如(c)中所示,可以用未被选中的指令填充延迟时隙。为使这一优化方法能够对(b)和(c)有效,当分支进入非预测方向时,必须可以执行经过移动的指令。这里所说的“可以”是指浪费了工作量,但程序仍能正确执行。例如,当分支进入非预测方向时,R7成为一个未被用到的临时寄存器,那(c)策略就属于这一情景

延迟分支调度的局限性源于: (1)对于可排在延迟时隙中的指令有限制;(2)我们在编译时预测一个分支是否可能被选中的能力有限。为了提高编译器填充分支延迟时隙的能力,大多数具有条件分支的处理器都引入了一种取消或废除分支。在取消分支中,指令包含了预测分支的方向。当分支的行为与预期一致时,分支延迟时隙中的指令就像普通的延迟分支一样执行。 当分支预测错误时,分支延迟时隙中的指令转为空操作。

分支机制的性能

这些机制的各自实际性能怎么样呢?假定理想CPI为1,考虑分支代价的实际流水线加速比为:

流水线加速比 = 流水线深度 / (1+分支导致的流水线停顿周期)

由于:

分支导致的流水线停顿周期 = 分支频率 x 分支代价

得到:

流水线加速比 = 流水线深度 / (1+分支频率x分支代价)

分支频率和分支代价可能都存在因为无条件分支和有条件分支导致的分量。但是,由于后者出现得更为频繁,所以它们起主导作用。

对于一个更深的流水线,比如在MIPS R4000中,在知道分支目标地址之前至少需要三个流水级,在计算分支条件之前需要增加一个周期,这里假定条件比较时寄存器中没有停顿。三级延迟导致表C-7中所列三种最简单预测机制的分支代价。假定有如下频率,计算因分支使该流水线的CPI增加了多少。

  • 无条件分支:4%
  • 有条件分支、未选中:6%
  • 条件分支、选中:10%

表C-7三种最简单预测机制对于一个较深流水线的分支代价

分支机制 无条件分支代价 未选中分支代价 选中分支代价
冲刷流水线 2 3 3
预测选中 2 3 2
预测未选中 2 0 3

将无条件、有条件未选中和有条件选中分支的相对频率乘以各自的代价,就可以求出CPI。结果如表C-8所示。

这些机制之间的差别大体随这一较长的延迟而增大。如果基础CPI为1,分支是唯一的停顿源,则理想流水线的速度是使用停顿流水线机制的流水线的1.56倍。 预测未选中机制在相同假定条件下优于停顿流水线机制,为其1.13 倍。

通过预测降低分支成本

当流水线变得越来越深,而且分支的潜在代价增加时,仅使用延迟分支及类似机制就不够了。这时需要寻求一种更积极的方式来预测分支。这些机制分为两类:依赖编译时可用信息的低成本静态机制;根据程序特性对分支进行动态预测的策略。下面将讨论这两种方法。

静态分支预测

改进编译时分支预测的一种重要方式是利用先前运行过程收集的一览数据。之所以值得这样做,是因为人们观测到分支的行为特性经常是双峰分布的;也就是说,各个分支经常是严重偏向于选中或未选中两种情景之一。图C-9显示了使用这一策略成功地进行了分支预测。使用相同输入数据来运行程序,以收集一览数据;其他研究表明,如果改变输入,使不同运行的一览数据发生变化,只会使基于一览数据的预测准确度有微小变化。

任意分支预测机制的有效性都同时取决于机制的精度和条件分支的频率,在SPEC中,其变化范围为3%~24%。 整数程序的错误预测率较高,此类程序的分支频率通常较高,这一事实是静态分支预测的主要限制。在下一节中,我们考虑动态分支预测器,最新的处理器都采用这种机制。

图C-9 对于一种基于一览数据的预测器,SPEC92的错误预测率变化幅度很大,但浮点程序通常优于整型程序,前者的平均错误预测率为9%,标准偏差为4%,后者的平均错误预测率为15%,标准偏差为5%。实际性能取决于预测精度和分支频率,其变化范围为3%~24%

动态分支预测和分支预测缓冲区

最简单的动态分支预测机制是分支预测缓冲区或分支历史表。分支预测缓冲区是一个小型存储器,根据分支指令地址的低位部分进行索引。这个存储器中包含一个位(bit),表明该分支最近是否曾被选中。这一机制是最简单的缓冲区形式;它没有标志,仅当分支延迟过长,超过可能目标PC计算所需要的时间时,用于缩短分支延迟。

采用这样一种缓冲区时,我们事实上并不知道预测是否正确——它也可能是由另外一个具有相同低位地址的分支放入的。这个预测就是一种提示,我们假定它是正确的,并开始在预测方向上开始提取。如果这一提示最终是错误的,那将预测位反转后存回。这个缓冲区实际上就是一个缓存,对其所有访问都会命中,而且在后面可以看到,缓冲区的性能取决于两点:对所关注分支的预测频繁程度,该预测在匹配时的准确度。在分析性能之前,对分支预测机制的精度进行一点微小而重要的提升是很有用的。

这种简单的1位预测机制在性能上有一处短板: 即使某个分支几乎总是被选中,在其未被选中时,我们也可能会得到两次错误预测,而不是一次, 因为错误预测会导致该预测位反转。

为了弥补这一弱点,经常使用2位预测机制。在两位预测机制中,预测必须错过两次之后才会进行修改。图C-10给出了2位预测机制的有限状态处理器。

分支预测缓冲可以实现为一个小的特殊“缓存”,在IF流水线中使用指令地址进行访问,或者实现为一对比特,附加到指令缓存中的每个块,并随指令一起提取。如果指令的译码结果为一个分支,并且该分支被预测为选中,则在知道PC之后立即从目标位置开始提取。否则,继续进行顺序提取和执行。如图C-10所示,如果预测结果错误,将改变预测位。

图C-10 2位预测机制中的状态。许多分支被选中和不被选中的概率并非均等,而是严重偏向其中一种状态,对于此类分支,2位预测器的错误预测率经常低于1位预测器。在这种预测器中,使用2个数位对系统中的4种状态进行编码。这一2位机制实际上是一种更具一般性的机制的具体化,这种通用机制对于预测缓冲区中的每一项都有n位饱和计数器。对于一个n位计数器,其取值介于2至2^n-1之间:当计数器大于或等于其最大值(2^n-1)的一半时,分支被预测为选中;否则,预测其未选中。对n位预测器的研究已经证明,2位预测器的效果几乎与n位预测器相同,所以大多数系统都采用2位分支预测器,而不是更具一般性的n位预测器

在实际应用程序中,如果使用每项两位的分支预测缓冲区,可以预测得到什么样的准确度?图C-11显示,对于SPEC89基准测试,一个拥有4096项的分支预测缓冲区将得到超过99%~82%的预测准确度,或者说错误预测率为1%~18%。根据2005年的标准,一个4K项的缓冲区(比如得出上述结果的缓冲区)算是很小了,较大的缓冲区可以得到更好点的结果。

由于我们尝试开发更多的IP,所以分支预测的准确度变得非常关键。在图C-11中可以看出,整数程序的预测器精度低于循环密集的科学程序(前者的分支频率通常也更高一些)。我们可以采用两种方式来解决这一问题: 增大缓冲区的大小,增加每种预测机制的准确度。但如图C-12所示,一个拥有4000项的缓冲区,其性能大体与无限缓冲区相当,至少对于SPEC这样的基准测试如此。图C-12中的数据清楚地表明缓冲区的命中率并非主要限制因素。前面曾经提到,仅提高每个预测器的位数而不改变预测器结构,其影响也是微乎其微的。因此,我们需要研究一下如何提高每种预测器的精度。


图C-11 对于SPEC89基准测试,4096 项2位预测缓冲区的预测准确度。整数基准测试(gcc、espresso、eqntott和li )的预测错误率大体高于浮点程序,前者的均值为11%,后者为4%。忽略浮点内核(nasa7、matrix300和tomcatv)仍然会使浮点基准测试的准确度高于整数基准测试。收集这些数据及本节其余数据的分支预测研究采用IBM Power体系结构及针对该系统的优化代码执行。


图C-12 对于SPEC89基准测试,4096项2位预测缓冲区与无限缓冲区的预测准确度对比。尽管这些数据是针对一部分 SPEC基准测试的较早版本收集的,但较新版本的结果也大体相当,只是可能需要8000项来匹配一个无限2位预测器

如何实现流水化

在开始介绍基本流水化之前,需要回顾一下MIPS非流水版本的一种简单实现。

MIPS的简单实现

本节将继续C.1节的风格,首先给出一种简单的非流水化实现,然后是流水化实现。但我们这一次的例子是专门针对MIPS体系结构的。
在这一小节,我们主要关注MIPS中一部分整数运算的流水线,其中包括载入-存储字、等于零时分支和整数ALU运算。在本附录的后半部分将整合这些基础浮点操作。尽管我们仅讨论MIPS的一个子集,但这些基本原则可以扩展到对所有指令的处理。

每种MIPS指令都可以在最多5个时钟周期中实现。这5个时钟周期分述如下。

(1)指令提取周期(IF)

1
2
IR <- Mem[PC];
NPC ← PC+4;

操作——送出PC,并将指令从存储器提取到指令寄存器中(IR);将PC递增4,以完成下一顺序指令的寻址。IR用于保存将在后续时钟周期中需要的指令;与此类似,寄存器NPC用于保存下一顺序PC。

(2)指令译码/寄存器提取周期(ID )。

1
2
3
A←Regs[rs];
B←Regs[rt];
Imm←IR的符号扩展立即数字段;

操作——对该指令进行译码,并访问寄存器堆,以读取寄存器(rs和rt为寄存器识别符)。通用寄存器的输出被读入两个临时寄存器(A和B)中,供之后的时钟周期使用。IR 的低16位也进行了符号扩展,存储在临时寄存器Imm中,供下一个周期使用。

指令译码与寄存器读取是并行完成的,这一点之所以成为可能,是因为在MIPS格式中,这些字段放在固定位置。因为在所有MIPS格式中,指令的立即数部分都位于同一位置,所以在这一周期还会计算符号扩展立即数,以备在下一周期使用。

(3)执行/实际地址周期(EX)

ALU对前一周期准备的操作数进行操作,根据MIPS指令类型执行以下4种功能之一。

  • 存储器引用:
    • ALUOutput ← A + Imm;
    • 操作——ALU将操作数相加,得到实际地址,并将结果放在寄存器ALUOutput中。
  • 寄存器-寄存器ALU指令:
    • ALUOutput ← A func B;
    • 操作——ALU 对寄存器A和寄存器B中的取值执行由功能代码指定的操作。结果放在临时寄存器ALUOutput中。
  • 寄存器立即数ALU指令:
    • ALUOutput ← A op Imm;
    • 操作——ALU 对寄存器A和寄存器Imm中的值执行由操作代码指定的操作。结果放在临时寄存器ALUOutput中。
  • 分支:
    • ALUOutput ← NPC + (Imm << 2);
    • Cond ← (A==0)
    • 操作——ALU将NPC加到Imm中的符号扩展立即数,将该立即数左移2位,得到一个字偏移量,以计算分支目标的地址。检查已经在上一周期读取的寄存器A,以确保该分支是否被选中。由于我们仅考虑分支的一种形式(BEQZ),所以是与0进行对比。注意,BEQZ实际上是一个伪指令,它会转换为一个以R0为操作数的BEQ。为简单起见,我们这里仅考虑这一种分支形式。

MIPS的载入-存储体系结构意味着实际地址与执行周期可以合并到一个时钟周期中,因为此时没有指令需要同时计算数据地址和指令目标地址,并对数据执行操作。各种形式的跳转指令未包含在上述整数指令中,它们与分支相类似。

(4)存储器访问/分支完成计算(MEM )。

对所有指令更新PC: PC←NPC;

  • 存储器引用:
    • LMD ← Mem[ALUOutput]Mem[ALUQutput]←B;
    • 操作——在需要时访问存储器。 如果指令为载入指令,则从存储器返回数据,将其放入LMD(载入存储器数据)寄存器中;如果是存储指令,则将来自B寄存器的数据写入存储器。无论是哪种情况,所使用的地址都是在上一周期计算得出并放在寄存器ALUOutput中的地址。
  • 分支:
    • if (cond) PC ← ALU0utput
    • 操作——如果该指令为分支指令,则用寄存器ALUOutput中的分支目标地址代替PC。

(5)写回周期(WB )。

  • 寄存器-寄存器ALU指令:
    • Regs[rd]←ALU0utput;
  • 寄存器立即数ALU指令:
    • Regs[rt]←ALUOutput;
  • 载入指令:
    • Regs[rt]←LMD;
    • 操作——无论结果来自存储器系统(在LMD中),还是ALU(在ALUOutput中),都将其写到寄存器堆中,寄存器目标字段也在两个位置之一(rd或rt)之一,具体取决于实际操作码。

图C-13显示了一条指令是如何流经数据路径的。在每个时钟周期结束时,在该时钟周期计算并会在后面时钟周期用到的所有值(无论是供本条指令使用,还是供下一条指令使用)都被写入存储设备中,可能是存储器、通用寄存器、PC或临时寄存器(即,LMD、lmm、A、B、IR、NPC、ALUOutput或Cond )。临时寄存器在一个指令的时钟周期之间保存值,而其他存储元件则是状态的可视部分,在连续指令之间保存值。

尽管今天的所有处理器都是流水化的,但这种多周期实现方式合理地近似呈现了早些时候是如何实现大多数处理器的。可以使用一种简单的有限状态机来实现一种遵循上述5周期结构的控制。对于更复杂的处理器,可以使用微代码控制。在任何一种情况下,类似上述内容的指令序列决定着控制结构。

在这种多周期实现中可以消除一些硬件冗余。 例如,有两个ALU: 一个用于使PC递增,一个用于实际地址和ALU计算。由于不会在同一时钟周期用到它们,所以可以通过添加多工器和共享同一ALU来合并它们。同样,由于数据和指令访问发生在不同时钟周期内,所以指令和数据可以存储在相同存储器中。

我们没有优化这一简单实现方式,而是保持图C-13所示设计方式,为流水化实现提供了更好的基础。

图C-13 MIPS 数据路径的实现允许每条指令在4或5个时钟周期内执行。尽管PC显示在指令提取使用的数据路径部分、寄存器显示在指令译码/寄存器提取使用的数据路径部分,实际上这些功能单元都是由一条指令读取和写入的。尽管这些功能单元显示在对其进行读取的周期内,实际上,PC是在存储器访问时钟周期内写入,寄存器是在写回时钟周期内写入的。在这两种情况下,在后续流水级中的写入是由多工器输出指示(在存储器访问或写回中),它将一个取值带回PC或寄存器。流水线的大多数复杂性是由这些反向流动信号引入的,因为它们表明存在冒险的可能

作为本节所讨论多周期设计的一种替代方式,我们也可以将CPU实现为每条指令占用1个长时钟周期。在这种情况下,由于一条指令之内的时钟周期之间不需要任何通信,所以将会删除临时寄存器。每条指令都在1个长时钟周期内执行,在该时钟周期结束时将结果写入数据存储器、寄存器或PC。此类处理器的CPI为1。不过,这时的时钟周期大约等于多周期处理器时钟周期的5倍,这是因为所有指令都需要遍历全部功能单元。设计人员从来不会使用这种单周期实现方式,其原因有两个。第一,单周期实现对于大多数CPU的效率极低,因为对于不同指令,它们的任务量会有合理的变动,从而时钟周期时间也会变动。第二,单周期实现需要重复功能单元,而在多周期实现中,这些功能单元是可以共享的。不过,这种单周期数据路径可以让我们说明流水化是如何从时钟周期时间的角度(而非CPI的角度)来为处理器提供改进的。

MIPS基本流水线

和以前一样,只需要在每个时钟周期启动一个新指令,几乎不需要什么改变就可以对图C-13的数据路径实现流水线。因为每个流水级在每个时钟周期都是活动状态,所以流水级中的所有操作都必须在1个时钟周期内完成,任何操作组合都必须能够立即发生。此外,数据路径的流水化要求必须将流水级之间传递的数值放在寄存器中。图C-14 显示的MIPS流水线中包含了每个流水级之间的适当寄存器,称为流水线寄存器或流水线锁存器。用这些寄存器相联的流水线名称对其进行标记。图C-14的绘制方式清楚地显示了各级之间通过流水线寄存器的连接。

用于在一条指令的时钟周期之间保存临时值的所有寄存器都包含在这些流水线寄存器中。指令寄存器(IR)是IF/ID寄存器的一部分,当它们用于提供寄存器名称时,对其字段进行标记。这些流水线寄存器用于从一个流水级向下一个流水级传送数据和控制。在后续流水级上需要的所有值都必须放在这样一个寄存器中,并从一个流水线寄存器中复制到下一个寄存器,直到不再需要这些值为止。如果我们尝试仅在早期未流水化数据路径中使用临时寄存器,就有可能在完成所有应用之前覆盖这些值。例如,寄存器操作数的字段,用于在载入或ALU操作中执行写入操作,这一字段是从MEM/WB流水线寄存器而非IF/ID寄存器中提供的。这是因为我们希望一个载入或ALU操作写入该操作指定的寄存器,而不是该指令当前从IF传送到ID的寄存器字段!这一目标寄存器字段就是从一个流水线寄存器复制到下一个寄存器,直到在WB级用到它为止。


图C-14 通过增加一组寄存器实现流水化的数据路径,每对流水线之间一个寄存器。寄存器用于从一个流水级向下一个流水级传送值和控制信息。我们也可以将PC看作一个流水线寄存器,它位于流水线的IF级之前,指向每个流水级的一个流水线寄存器。回想一下,PC是一个边缘触发寄存器,在时钟周期结束时对其进行写入;因此,在写入PC时不存在争用条件。PC的多工器已被移动,恰好在一个流水级(IF)中写入PC。如果我们没有移动它,那在发生分支时可能会出现冲突,因为两条指令都会尝试将不同值写到PC中。大多数数据路径是由左向右流动,即从较早时间移向较晚时间。从右向左流动的数据(携带着分支的写回信息和PC信息)增加了流水线的复杂性

任何一条指令在某一时刻恰好在一个流水级中处于活动状态;任何以指令名义执行的操作都发生在一对流水线寄存器之间。因此,我们还可以通过以下方式来研究流水线的行为:查看在不同指令类型下,各流水级上必须执行什么操作。表C-9展示了这一观点。流水线寄存器的字段命名显示了数据在流水级之间的流动。注意,前两级的操作与当前指令类型无关。由于要等到ID级结束时才会对指令进行译码,所以前两级的操作必须与当前指令无关。IF行为取决于EXMEM中的指令是否为选中分支。如果是,则会在IF结束时将EXMEM中分支指令的分支目标地址写入PC中;如果不是,则写回递增后的PC。寄存器源操作数的固定位置编码对于在ID期间提取寄存器是至关重要的。

  • 在IF中,除了提取指令和计算新PC之外,我们还将递增后的PC存储到PC和流水线寄存器(NPC)中,供以后计算分支目标地址时使用。这个结构与图C-14中的组织方式相同,在正使用两个来源之一更新PC。在D中,我们提取寄存器,对IR(立即数字段)的低16位进行符号扩展,并沿R和NPC进行传送。在EX期间执行ALU运算或地址计算,沿IR和B寄存器进行传送(如果该指令为存储指令)。如果该指令为选中分支,我们还将cond的值设置为1。在MEM阶段,我们循环使用存储器,必要时写PC,并传送在最后流水级中使用的值。最后,在WB期间用ALU输出值或载入值来更新寄存器字段。为简单起见,我们总是将整个R从一级传送到下一级,实际上,在一条指令沿流水线流动时,对IR的需要越来越少。

为了控制这一简单流水线,我们只需要决定如何设定图C-14 数据路径中4个多工器的控制方式。ALU级的2个多工器根据指令类型设定,由ID/EX寄存器的IR字段规定。上面的ALU输入多工器根据该指令是否为分支来进行设定,下面的多工器根据该指令是寄存器-寄存器ALU操作还是任意其他类型的操作来设定。If级中的多工器选择是使用递增PC 的值,还是EX/MEM.ALUOutput(分支目标)的值来写入PC。这个多工器由EX/MEM.cond字段控制。第
四个多工器由WB级的指令是载入指令还是ALU指令来控制。除了这4个多工器之外,还需要另外1个多工器,虽然未在图C-14中画出,但只要看一下ALU操作的WB级就可以清楚地看出是存在该选择器的。根据指令类型(是寄存器寄存器ALU,还是ALU立即数或载入),目标寄存器字段为两个不同位置中的一个。因此,我们需要一个多工器来选择MEM/WB寄存器中IR的正确部分,以指定寄存器目标字段,假定该指令写入一个寄存器。

实现MIPS流水线的控制

将一条指令从指令译码级(I)移入此流水线执行级(EX)的过程通常称为指令发射,已经执行这一步骤的指令称为已发射。对于MIPS整数流水线,所有数据冒险都可以在该流水线的ID阶段进行检查。如果存在数据冒险,该指令将在被发射之前停顿。与此类似,我们可以确定在ID期间需要哪种转发,并设定适当的控制。如果在流水线早期检查互锁,除非整个处理器停顿,否则硬件从来不需要挂起一条已经改变处理器状态的指令,从而降低了硬件的复杂性。

或者,我们可以在使用操作数的一个时钟周期之始(对于此流水线来说,为EX和MEM)检查冒险或转发。为了说明这两种方法之间的区别,我们将展示如何通过在ID中进行检查来消除因为载入指令所导致的写后读(RAW)冒险互锁(称为载入互锁),并说明如何实现指向ALU输入的转发路径。表C-10列出了我们必须处理的各种环境。

  • 这个表显示,对于跟在写指令后面的两条指令,只需要对其目标和源进行对比。在发生停顿时,一旦继续执行后,流水线相关性与第三种情况类似。当然,涉及R0的冒险是可以忽略的,这是因为寄存器中总是包含零,而且上述测试经扩展后可以完成这一任务。

接下来实现载入互锁。如果存在一个因为载入指令导致的RAW冒险,当需要该载入数据的指令存位于ID级时,该载入数据将位于EX级。因此,我们可以用一个很小的表来描述所有可能存在的情景,可以直接将其转换为实现方式。表C-11显示了当使用载入结果的指令位于ID级时,检测到负载互锁的逻辑。

  • 表中第1行和第2行测试载入目标寄存器是否为ID中寄存器——寄存器操作的源寄存器之一。表中第3行判断载入目标寄存器是否为载入或存储有限地址、ALU立即数或分支测试的源。请记住,IF/ID 寄存器保存着ID中指令的状态,它可能会用到载入结果,而ID/EX保存着EX中指令的状态,它是载入指令。

一旦检测到冒险,控制单元必须插入流水线停顿,并防止IF和ID级中的指令继续前进。前面曾经看到,所以控制信息都承载于流水线寄存器中。(仅承载指令就足够了,因为所有控制都是由其派生而来的。)因此,在检测冒险时,只需要将ID/EX流水线寄存器的控制部分改为全0,它正好是一个空操作(一个不做任何事情的指令,比如DADD R0, R0, R0)。此外,我们只篅循环使用IF/ID 寄存器中的内容,以保存被停顿的指令。在具有更复杂冒险的流水线中,这些思想同样适用:对比一组流水线寄存器,并转换为空操作,以防止错误执行。

转发逻辑的实现尽管需要考虑更多种情况,但大体类似。要实现转发逻辑,关键是要注意到流水线寄存器中既包含了要转发的数据,也包含了源、目标寄存器字段。所有转发在逻辑上都是从ALU或数据存储器的输出到ALU输入、数据存储器输入或零检测单元。因此,我们可以对比EXMEM和MEMWB级中所包含IR的目标寄存器与ID/EX和EXMEM寄存器中所包含IR的源寄存器,以此来实现转发。表C-12显示了这些对比,以及当转发结果的目的地是EX中当前指令的ALU输入时,可能执行的转发操作。

  • 为了判断是否应当发生转发操作,一共需要10次不同比较。顶部和底部的ALU输入分别指代与第一、第二ALU源操作数相对应的输入,如图C-13及图C15中所示。请记住,EX中目标指令的流水线销存器是ID/EX,而源值来自EX/MEM或MEM/WB的ALUOutput部分,或者MEMWB的LMD部分。有一个复杂问题未能通过这一逻辑解决:处理多条向相同寄存器进行写入的指令。例如,在代码序列DADD R1, R2, R3; DADDI: R1, R1, #2; DSUB R4, R3, R1期间,该逻辑必须确保DSUB指令使用的是DADDI指令的教据,而不是DADD指令的结果。为了处理这一情景,可以扩展上述逻辑:仅在未对相同输入转发来自EXMEM的结果时,才检测来自MEMWB的转发。由于DADDI结果将位于EX/MEM中,所以将转发该结果,而不是MEMWB中的DADD结果。

除了在需要启用转发路径时必须确定的比较器和组合逻辑之外,还必须扩大ALU输入端的多工器,并添加一些连接,这些连接源于转发结果所用的流水线寄存器。图C-15给出了流水化数据路径的相关段,其中添加了所需要的多工器和连接。

图C-15 向ALU转发结果需要在每个ALU多工器上另外增加三个输入,增加三条指向这些新输入的路径。这些路径对应于以下三者的一个旁路: (1)EX结束时的ALU输出;(2)MEM级结束时的ALU输出;(3)MEM级结束时中的存储器输出

对于MIPS,冒险检测和转发硬件是相当简单的;我们将会看到,当为处理浮点数而对这一流水线进行扩展时,事情多少会变得复杂一些。在此之前,需要处理分支。

处理流水线中的分支

在MIPS中,分支(BEQ和BNE)需要检测一个寄存器,看其是否等于另一个寄存器(该寄存器可能为R0)。如果仅考虑BEQZ和BNEZ的情景(它们需要零检测),那有可能通过将零检测移到周期内部,在ID周期结束时完成此判断。为了充分利用尽早判断出该分支是否命中的优势,都必须尽早计算PC(无论命中还是未命中)。在ID期间计算分支目标地址需要增加一个加法器,因为前面一直用于实现此功能的主ALU在EX之前是不可用的。图C-16给出了修订后的流水化数据路径。通过增加独立的加法器,并在ID期间作出分支判断,分支仅需要停顿1个时钟周期。尽管这样将分支延迟缩减为1个周期,但这意味着如果一个 ALU指令后面跟有一个依赖该指令结果的分支,那就会招致数据冒险停顿。表C-13显示了表C-9中修订后的流水线表的分支部分。

在一些处理器中,分支冒险的代价(以时钟周期为单位)比我们这里的例子中更昂贵,因为评估分支条件和计算目的地的时间可能更长一些。例如,拥有独立译码和寄存器提取级的处理器可能存在分支延迟(控制冒险的长度),其长度至少为1个时钟周期。除非经过处理,否则分支延迟会转变为分支代价。许多实现更复杂指令集的较旧CPU,其分支延迟为4个时钟周期,甚至更长一些,而大型深度流水化处理器的分支代价经常为6或7个时钟周期。一般来说,流水线越深,以时钟周期度量的分支代价就越糟。当然,较长分支代价的相对性能性能影响取决于处理器的总CPI。低CPI的处理器可以承受更昂贵的分支,这是因为分支导致处理器性能下降的百分比较低。

图C-16 将零检测和分支目标计算移到流水线的ID级中,可以缩减因为分支冒险导致的停顿。注意,我们已经进行了两处重要改变,每个改变消除了分支指令的三周期停顿之一。第一处变化是将分支目标地址计算和分支条件判断移到ID周期。第二处变化是在IF阶段写入指令的PC,或者使用在ID阶段计算的分支目标地址,或者使用在IF期间计算的递增PC。在对比中,图C-14从EXMEM寄存器中获取分支目标地址,并在MEM时钟周期内写入结果。在图C-14中曾经提到,可以将PC看作流水线寄存器(例如,作为ID/IF的一部分),会在每个IF周期结束时向其中写入下一条指令的地址

  • 它使用一个独立的加法器(和图C-16一样),在ID期间计算分支目标地址。新操作或发生变化的操作以黑体标出。由于分支目标地址加法是在ID期间发生的,所以针对所有指令都会经过这一步骤;分支条件(Regs[IF/ID. IR6..10] op 0)也对所有指令有效。是选择顺序PC还是分支目标PC,仍然是在IF期间决定的,但它现在仅使用来自ID级的取值,这些值与上一条指令设定的值相对应。这一改变使分支代价降低两个周期,一个周期是因为提前计算分支目标和条件,一个周期是因为在同一时钟周期内控制PC选择,而没有推至下一周期。由于cond的值被设置为0,所以除非ID中的指令为选中分支,则该处理器必须在ID结束之前对指令进行译码。因为该分支在ID结束时完成,所以分支指令没有用到EX、MEM和WB级。对于偏移量长于分支的跳转,还会进一步增加复杂性。我们可以增加一个加法器,将IR中低26位左移2位后的值与PC相加,以应对上述复杂性。

妨碍流水线实现的难题

处理异常

异常情景在流水化CPU中更难处理:由于指令的重叠,更难以判断一条指令是否能安全比改变CPU的状态。在流水化CPU中,指令是一段一段地执行, 在几个时钟周期内不会完成。不幸的是,流水线中的其他指令可能会引发一些异常,强制CPU在流水线中的指令尚未完成中止执行。

异常的类型与需求

对于一些改变指令正常执行顺序的异常情景,在不同CPU中会采用不同的术语来进行指述。人们会使用中断、错误和异常等词,但具体用法并不一致。我们用异常一词来涵盖包括下列内容的所有此类机制:

  • I/O设备需求
  • 从用户程序调用操作系统服务
  • 跟踪指令执行
  • 断点(程序员请求的中断)
  • 整数算术溢出
  • 浮点算术异常
  • 页面错误(不在主存储器中)
  • (在需要对齐时)存储器访问未对齐
  • 违反存储器保护规则
  • 使用未定义或未实现的指令
  • 硬件故障
  • 电源故障

当我们希望引用这些异常中的某一具体类别时,将使用一个较长的名称,比如I/O中断、浮点异常或页面错误。表C-14显示了上述常见异常事件的各种不同名称。


  • IBM 360和80x86上的每个事件都称为中断,而680x0中的每个事件称为异常。VAX将事件划分为中断或异常。在VAX中断中使用了设备、软件和紧急等修饰词语,而VAX异常则细分为故障、陷阱和中止。

尽管我们使用异常一词来涵盖所有这些事件,但各种事件都有自己的重要特性,决定了硬件中需要采取什么操作。关于异常的需求,可以从五个半独立的方面进行描述。

  1. 同步与异步——如果每次以相同数据和存储器分配执行程序时,事件都在同一位置发生,那事件就是同步的。由于硬件故障等异常,可能会由CPU和存储器外部的设备导致异步事件。异步事件通常可以在完成当前指令后处理,使其处理变得更容易一些。
  2. 用户请求与强制——如果用户任务直接请求某一事件,那它就是用户请求事件。在某种意义上,用户请求的异常不是真正的异常,因为它们是可预测的。但是,由于对这些用户请求事件使用了用于存储和恢复状态的相同机制,所以也将它们看作异常。因为对于触发这一异常的指令来说,其唯一功能就是引发该异常,所以用户请求异常总是可以在完成该指令之后加以处理。强制异常是由某一不受用户程序控制的硬件事件导致的。强制异常不可预测,所以更难以实现。
  3. 用户可屏蔽与用户不可屏蔽——如果一个事件可以借由用户任务来屏蔽或禁用,那它就是用户可屏蔽的。这一屏蔽只是控制硬件是否对异常进行回应。
  4. 指令内部与指令之间一这种分类取决于妨碍指令完成的事件是发生在执行过程中间(无论多短),还是被看作是发生在指令之间。发生在指令内部的异常通常是同步的,因为就是这条指令触发了异常。在指令内部发生的异常实现起来要难于指令之间的异常,因为该指令必须被停止、重新启动。发生在指令内部的异步异常是因为灾难性情景(例如,硬件故障)出现的,所以总会导致程序终止。
  5. 恢复与终止——如果程序的执行总是在中断之后停止,那它就是终止事件。如果程序的执行在中断之后继续,那它就是恢复事件。终止执行的异常实现起来更容易一些,因为CPU不需要在处理异常之后重启同一程序。

表C-15根据这5个类别对表C-14中的示例进行了划分。难点在于实现指令之间发生的中断,对于这种中断必须恢复指令的执行。此类异常的实现要求必须调用另一个程序,以保存所执行程序的状态、解决导致异常的原因,然后恢复程序的状态,之后才再次尝试导致该异常的指令。对正在执行的程序来说,这个过程事实上必须是不可见的。如果流水线使处理器能够在不影响程序执行的前提下处理异常、保存状态并重新启动,那就说流水线或处理器是可重新启动的。早期的超级计算机和微处理器通常缺少这一特性,但今天的几乎所有处理器都支持这一特性,至少整数流水线是这样的,因为虚拟存储器的实现需要这一特性。

表C-15 用5个类别来确定表C-14所示不同异常类型所需要的操作

  • 必须允许恢复的异步被标记为恢复,尽管软件可能选择终止程序。发生在指令内部的可恢复、同步、强制异常是最难实现的。我们可能希望违反存储器保护访问的异常总是导致终止;但是,现代操作系统使用存储器保护来裣测事件,比如首次尝试使用一个页面,或者首次尝试写入一个页面。因此,CPU应当能够在此类异常之后恢复执行。

停止和恢复执行

和在非流水化实现中一样,最困难的异步有两种特征:

  1. 发生在指令内部(即,在指令执行过程期间发生,与EX或MEM流水级相对应);
  2. 必须可以重新启动。

比如,在MIPS流水线中,由数据提取导致的虚拟存储器页面错误只可能发生在该指令MEM级的某一时间之后。在出现该错误时,会有其他几条指令正在执行。页面错误必须是可重新启动的,需要另一进程(比如操作系统)的干预。因此,必须能够安全关闭流水线并保存其状态,使指令能够以正确状
态重新启动。重启过程的实现通常是通过保存待重启指令的PC来实现的。如果被重启的指令不是分支,则继续提取依次排在后面的指令,以正常方式开始执行。如果被重启的指令为分支指令,则重新计算分支条件,根据计算结果提取目标指令或直通指令。在发生异步时,流水线控制可以采取以下步骤安全地保存流水线状态。

  1. 在下一个IF向流水线中强制插入一个陷阱指令。
  2. 在选中该陷阱指令之前,禁止错误指令的所有写入操作,禁止流水线中后续所有指令的写入操作。可以通过以下方式来实现:从生成该异常的指令开始(不包括该指令之前的指令),将流水线中所有指令的流水线锁存置零。这样可以禁止在处理异常之前对未完成指令的状态进行任何更改。
  3. 在操作系统的异常处理例程接收控制权之后,它会立即保存故障指令的PC。后面从异常返回时会用到这个值。

当我们使用延迟分支时,如上一节所述,由于流水线中的指令可能不是顺序相关的,所以仅用一个PC不再可能重建处理器的状态。因此,我们需要保存和恢复的PC数目等于分支延迟的长度加1。这一步骤是在上述第(3)步完成的。

在处理异常之后,特殊指令通过重新加载PC并重启指令流(在MPS中使用指令RFE)从异常中返回处理器。如果流水线可以停止,使紧临错误指令之前的指令能够完成,使其之后的指令可以从头重新启动,那就说该流水线拥有精确异常。在理想情况下,错误指令可能还没有改变状态,要正确地处理一些异常,要求错误指令不产生任何影响。对于其他异常,比如浮点异常,某些处理器上的错误指令会首先写入其结果,然后才能处理异常。在此种情况下,即使目标位置与源操作数之一的目标位置相同,也必须准备硬件来提取源操作数。因为浮点操作可能持续许多个周期,所以其他某一指令非常可能已经写入了这些源操作数(在下一节将会看到,浮点操作经常是乱序完成的)。为了克服这一问题,最近的许多高性能CPU引入了两种操作模
式。一种模式有精确异常,另一种(快速或性能模式)则没有精确异常。当然,精确异常模式要慢一些,因为它允许浮点指令之间的重叠较少。在一些高性能CPU中,精确模式通常要慢得多(相差10倍以上),因此仅能用于代码调试。

在许多系统中对精确异常的支持是必备功能,而在其他一些系统中,它“只是”存在一定的价值,因为它可以简化操作系统接口。至少,任何需要分页或IEEE算术陷阱处理程序的处理器都必须使其异常为精确异常,可以用硬件实现,也可以辅以一定的软件支持。对于整数流水线,创建精确异常的任务比较简单,支持虚拟内存是存储器引用支持精确异常的极大动力。

MIPS中的异常

表C-16显示了MIPS流水级,以及每一级中可能发生哪些问题异常。采用流水化时,由于有多条指令同时执行,所以在同一时钟周期中可能出现多个异常。例如,考虑如下指令序列:

1
2
LD     IF     ID     EX     MEM     WB
DADD IF ID EX MEM WB

这一对指令可能同时导致数据页面错误和算术异常,这是因为ID 处于MEM级,而DADD位于EX级。要处理这一情景,可以先处理数据页面错误然后再重启执行过程。第二个异常将再次发生(如果软件正确,第一个异常将不再发生),这样,在发生第二个异常时,就可以单独对其进行处理。

  • 由指令或数据存储器访问产生的异常可能占到8种情景的6种。现实中的情景并不像这个简单例子中那样明了。异常可能乱序发生;也就是说,可能在一条指令先行产生异常之后,排在前面的指令才产生异常。再次考虑上述序列,LD后面跟着DADD。当LD处于IF时可能产生数据页面错误,而DADD指令位于IF时可能会产生指令页面错误。指令页面错误尽管由后一指令导致,但实际上它将会首先发生。

由于我们正在实现精确异常,所以流水线需要首先处理由LD指令导致的异常。为了解释如何实现这一过程,我们将位于LD指令位置的指令称为i,将位于DADD 指令位置的指令称为i+1。流水线不能在发生异常时直接处理它,因为这样会导致这些异常的发生顺序不同于非流水化顺序。硬件会将一条给定指令 产生的所有异常都记录在一个与该指令相关联的状态向量中。这个异常状态向量将一直随该指令 向流水线下方移动。一且在异常状态向量中设定了异常指示,则关闭任何可能导致数据值写入(包括寄存器写入和存储器写入)的控制信号。由于存储指令可能在MEM期间导致异常,所以硬件必须准备好在存储指令产生异常时阻止其完成。

当一条指令进入WB时(或者将要离开MEM时),将检查异步状态向量。如果发现存在任何异常,则按照它们在非流水化处理器中的发生顺序进行处理——首先处理与最早指令相对应的异常(通常位于该指令的最早流水级)。这样可以保证:指令i引发的所有异常将优先得到处理,早于指令i+1引发的所有异常。当然,任何在较早流水级中以指令i名义采取的操作都是无效的,但由于对寄存器堆和存储器的写入操作都被禁用,所以还没有改变任何状态。

一些处理器拥有功能更强大、运行时间更长的指令,下一小节将介绍在此类处理器的流水线中实现异常时存在的问题。

指令集的复杂性

所有MIPS指令的结果都不会超过1个,我们的MIPS流水线仅在指令执行结束时写入结果。在保证一条指令完成时,称为已提交。在MIPS整数流水线中,当所有指令到达MEM级的末尾(或者WB的开头),而且没有指令在该级之前更新状态,则说这些指令已提交。因此,精确异常非常简单。一些处理器的指令会在指令执行中间更改状态,更改状态时,该指令及其之前的指令可能还未完成。例如,IA-32体系结构中的自动递增寻址模式可以在一条指令的执行过程中更新寄存器。在这种情况下,如果该指令由于异常而终止,则会使处理器状态发生变化。尽管我们知道哪些指令会导致异常,但由于该指令处于半完成状态,所以在未添加硬件支持的情况下,异常是不准确的。在这样一个非精确异常之后重启指令流是有难度的。我们也可以避免在指令提交之前更新状态,但这种做法的难度很大,或者成本很高,这是因为可能会用到经过更新的状态:考虑一条VAX指令,它多次递增同一寄存器。为了保持精确异常模型,大多数拥有此类指令的处理器能够在提交指令之间回退所做的状态更改。如果发生异常,处理器将使用这一功能将处理器状态还原为开始中断指令之前的值。

一些在执行期间更新存储器状态的指令也会增加难度。为中断和重启这些指令,规定这些指令使用通用寄存器作为工作寄存器。因此,部分完成指令的状态总是位于寄存器中,这些寄存器在发生异常时被保存,在异常之后恢复,使指令可以继续执行。在VAX中,有另外一个状态位记录指令何时开始更新存储器状态,从而在重启流水线时,CPU知道是从头开始重启指令,还是从指令的中间开始。IA-32字符串指令也使用寄存器作为工作存储器,这样,在保存和恢复寄存器时,也会保存和恢复这些指令的状态。

奇数个状态位可能会导致另外一组不同的难题:可能另外增加流水线冒险,可能需要额外的硬件来进行保存和恢复。条件代码就是这种情况的一个好例子。许多处理器隐式设定条件码,将其作为指令的一部分。这种方法具有一定的优势,因为条件码将条件的判断与实际分支分离开来。但是,在调度条件代码设定与分支之间的流水线延迟时,由于大多数指令都会设定条件码,而且不能在条件判定与分支之间的延迟时隙中使用,所以隐式设定条件可能会增加调度难度。

另外,在具有条件码的处理器中,处理器必须判断何时确定分支条件。这就需要找出分支之前最后一次设置条件代码是在什么时候。 在大多数隐式设定条件码的处理器中,其实现方式是推迟分支条件判断,直到先前的所有指令都有机会设定条件代码为止。当然,显式设定条件码的体系结构允许在条件测量与待调度分支之间插入延迟;但是,流水线控制必须跟踪最后一条设定条件码的指令,以便知道何时确定分支条件。实际上,必须将条件码当作一个操作数对待,需要进行RAW冒险检测,就像MIPS必须对寄存器进行检测一样。流水线中最后一个棘手领域是多周期操作。

假定我们尝试实现下面这样一个VAX指令序列的流水化:

1
2
3
4
MOVL        R1, R2                 ;在寄存器之间移动
ADDL3 42(R1), 56(R1)+,@(R1) ;对存储器位置求和
SUBL2 R2, R3 ;减去寄存器
MOVC3 @(R1)[R2], 74(R2), R3 ;移动字符串

这些指令所需要的时钟周期数有很大差别,低至1 个,高至数百个。它们所需要的数据存储器访问数也不一定,有的不需要访向数据存储器,有的可能需要数百次访问。数据冒险非常复杂,在指令之间和指令内部均会发生。一种简单的解决方案是让所有指令的执行周期数相同,但这种解决方案是不可接受的,因为它会引入数目庞大的冒险和旁通条件,形成-条极长的流水线。在指令级实现VAX的流水化是很困难的,但VAX 8800设计师找到了-种非常聪明的解决方案。他们实现了微指令执行的流水化。微指令就是一种简 单指令,在序列中用于实现更复杂的指令集。由于微指令都很简单(它们看起来与MIPS非常相似),所以流水线控制要容易得多。从1995年开始,所有Intel IA-32微处理器都使用这一策略将IA-32 指令转换为微操作,然后再实现微操作的流水化。

作为对比,载入-存储处理器拥有一些简 单操作,完成的工作数量相似,更容易实现流水化。如果架构师认识到指令集设计与流水化之间的关系,他们就可以设计出能够高效流水化的设计体系结构。20 世纪80年代,人们认识到指令集的复杂性会增加流水化的难度、降低流水化的效率。20 世纪90年代,所有公司都转向更简单的指令集,目标在于降低积极实现的复杂性。

扩展MIPS流水线,以处理多周期操作

我们现在希望研究如何扩展MIPS流水线,以处理浮点运算。这一节的重点是介绍基本方法和各种候选设计方案,最后是对一种MIPS浮点流水线的性能测量。要求所有MTPS浮点运算都在1个时钟周期内完成是不太现实的,甚至在2个时钟周期内也有很大难度。这样做就意味着要么接受缓慢的时钟,要么在浮点单元中使用大量逻辑,或者同时接受两者。而实际情况是,浮点流水线将会允许更长的操作延迟。如果我们设想浮点指令拥有与整数指令相同的流水线,那就容易理解了,当然流水线中会有两处重要改变。第一,为了完成操作,EX周期可能要根据需要重复多次,不同操作的重复次数可能不同。第二,可能存在多个浮点功能单元。如果待发射指令会导致它所用功能单元的结构性冒险,或者导致数据冒险,将会出现停顿。

针对本节,我们假定MIPS实现中有以下4个独立的功能单元。

  1. 主整数单元,处理载入和存储、整型ALU操作,还有分支。
  2. 浮点与整数乘法器。
  3. 浮点加法器,处理浮点加、减和转换。
  4. 浮点和整型除法器。

如果我们还假定这些功能单元的执行级没有实现流水化,那么图C-17给出了最终的流水线结构。由于EX未被流水化,所以在前一指令离开EX之前,不会发射任何其他使用这一功能单元的指令。另外,如果一条指令不能进入EX级,该指令之后的整个流水线都会被停顿。


图C-17 增加了三个未流水化浮点功能单元的MIPS流水线。因为每个时钟周期仅发射一条指令,所以所有指令都会经历整型运算的标准流水线。只是浮点运算在到达EX级时会循环。在它们完成EX级之后,则进入MEM和WB级,以完成执行

事实上,中间结果可能不会像图C-17所建议的那样围绕EX单元循环;而是在EX流水级拥有一些大于1的时钟延迟。我们可以推广如图C-17所示的浮点流水线结构,以允许实现某些级的流水化,并允许多个操作同时进行。为了描述这样一个流水线,我们必须定义功能单元的延迟以及启动间隔(或称重复间隔)。我们采用之前的相同方式来定义延迟:生成结果的指令与使用结果的指令之间的周期数。起始间隔或重复间隔是指在发出两个给定类型的操作之间必须间隔的周期数。例如,我们将使用如表C-17所示的延迟和启动间隔。根据这一延迟定义,整型ALU运算的延迟为0,因为其结果可以在下一时钟周期使用;载入指令的延迟为1,因为这些结果可以相隔一个周期之后使用。由于大多数操作都会在EX的开头使用其操作数,所以延迟通常是指eX之后的级数(一条指令在EX生成结果),例如,ALU运算之后有0个流水级,而载入指令则有1级。一个重要的例外是存储指令,它会在一个周期之后使用被存储的值。因此,存储指令的延迟是针对被存储的值而言,而不是针对基址寄存器,所以少1个周期。流水线延迟基本上等于执行流水线深度减去1个时钟周期,而流水线深度等于从EX级到生成结果的流水级之间的级数。因此,对于上面的示例流水线,浮点加法中的级数为4,而浮点乘法的级数为7。为了获得更高的时钟频率,设计师需要降低每个流水级中的逻辑级数,而这样会增大更复杂操作所需要的流水级数。高时钟频率的代价是延长了操作的延迟。


表C-17中的示例流水线结构允许多达4个同时执行的浮点加、7个同时执行的浮点/整数乘,和一个浮点除。图C-18说明了如何通过扩展图C-17来绘制这个流水线。在图C-18中,重复间隔是通过增加额外的流水级来实现的,它们由增加的流水线寄存器隔开。由于这些单元是相互独立的,所以我们分别对各级进行命名。需要多个时钟周期的流水级,比如除法单元,将被进一步细分,以显示这些流水级的延迟。由于它们不是完整的流水级,所以只有一个操作是活动的。这一流水线结构还可以使用本附录前面的类似图表展示,表C-18显示了一组独立的浮点运算和浮点载入、存储指令。自然,浮点运算的较长延迟增加了RAW冒险和所导致停顿的频率,在本节后面将会看到这一点。


图C-18 一条支持同时执行多个浮点操作的流水线。浮点乘法器和加法器被完全流水化,深度分别为7级和4级。浮点除法器未被流水化,而是需要24个时钟周期才能完成。在未招致RAW停顿的情况下,从发射浮点操作到使用操作结果之间的指令延迟由执行级中消耗的周期数来决定。例如,浮点加之后的第四条指令可以使用浮点加的结果。对于整数AlU操作,执行流水线的深度总是为1,下一条指令就可以使用这些结果

  • 用斜体表示的流水级显示了需要数据的位置,而用粗体表示的流水级显示了有结果可用的位置。指令标记符上的“.D”扩展表示双精度(64位)浮点运算。浮点载入和存储使用64位路径连向存储器,所以流水线时序与整数载入或存储一样。

图C-18中的流水线结构需要另外引入流水线寄存器(例如,A1/A2、 A2/A3、A3/A4),并修改连向这些寄存器的连接。ID/EX 寄存器必须进行扩展,以将ID 连接到EX、DIV、M1和A1;我们可以用标记ID/EX、ID/DIV、ID/M1或ID/A1来引用与之后流水线之一相关联的寄存器部分。ID 与所有其他流水级之间的流水线寄存器可以看作逻辑分离的寄存器,事实上也确实可以实现为分离寄存器。由于在一个流水级中只能同时有一个操作,所以控制信息可以与该流水级头部的寄存器关联在一起。

长延迟流水线 中的冒险与转发

对于如图C-18所示的流水线,冒险检测与转发有许多不同方面。

  1. 因为除法单元未被完全流水化,所以可能发生结构冒险。需要对这些冒险进行检测,还将需要停顿指令发射。
  2. 因为指令的运行时间不同,所以一个周期内需要的寄存器写入次数可能会大于1。
  3. 由于指令不会循序到达WB,所以有可能存在写后写(WAW)冒险。注意,由于寄存器读总是在ID中发生,所以不可能存在读后写(WAR)冒险。
  4. 指令的完成顺序可能不同于其发射顺序,从而导致异常问题。
  5. 由于操作的延迟较长,所以RAW冒险的停顿将会变得更为频繁。

由于操作延迟较长而导致停顿的增加,基本上与整数流水线一样。在描述这一浮点流水线中出现的新问题并探讨其解决方案之前,先让我们研究一下RAW冒险的可能影响。表C-19给出了一个典型浮点代码序列和最终的停顿。在这一节的末尾,将会研究这一浮点流水线中我们所选部分SPEC的性能。

  • 与较浅的整数流水线相比,较长的流水线大体会增大停顿频率。这个序列中的每条指令都会依赖于先前的指令,而且只要数据可用就可以继续进行,这里假定流水线具有完全旁通和转发。S.D必须多停顿一个周期,使其MEM不会与ADD.D冲突。添加硬件可以很轻松地处理这种情况。

现在看看因为写入导致的问题,如前面列表中第(2)项和第(3)项所述。如果我们假定浮点寄存器堆有一个写端口,那么浮点操作序列(以及浮点载入指令与浮点运算的结合)可能导致寄存器写端口的冲突。考虑表C-20所示的流水线序列。在时钟周期11中,所有3条指令将到达WB,希望写入寄存器堆。由于仅有一个寄存器堆写入端口,所以处理器必须依次完成各条指令。这个单一寄存器端口就代表着一种结构化冒险。我们可以增加端口的数目来解决这一问题,但由于所增加的写入端口可能很少用到,所以这种解决方案可能并没有什么吸引力。之所以很少用到这些端口,是因为写入端口的最大稳定状态数为1。 我们选择检测对写端口的访问,将其作为结构胃险加以实施。

  • 这不是最坏情况,因为浮点单元中的先前除法操作也可能在同一时钟周期完成。注意,尽管在时钟周期10中,MUL.D、ADD.D和L.D都处于MEM级,但仅有L.D在实际使用该存储器,所以关于MEM不存在结构性冒险。

共有两种不同方法来实现这一互锁。 第一种方法是跟踪ID级对写端口的使用,并在一条指令发射之前使其停顿,就像对于任何其他结构冒险一样。可以用一个移位寄存器来跟踪写端口的使用,这个移位寄存器可以指示已发射指令将会在何时使用这个寄存器堆。如果ID中的指令需要与已发射指令同时使用寄存器堆,那ID中的指令将会停顿一个周期。在每个时钟周期,保留寄存器将会移动1位。这种实现有一个好处一它能保持一个特性:所有互连检测与停顿插入都在ID流水级内进行。其成本是需要增加移位寄存器和写冲突逻辑。我们在本节始终假定采用这一机制。

一种替代方案是当一个冲突指令尝试进入MEM级或WB级时,使其停顿。如果我们等到冲突指令希望进入MEM或WB级时才使其停顿,可以选择停顿任一指令。一种简单的启动式方法(尽管有时是次优方法)是为那些延迟最长的单元赋予优先级,这是因为它是最可能导致另一指令因RAW冒险而停顿的指令。这种方案的好处在于在进入容易检测冲突的MEM或WB级之前不需要检测冲突。缺点是,由于停顿现在可能会出现在两个地方,所以使流水线控制变得复杂。注意,在进入MEM之前的停顿将会导致EX、A4或M7流水级被占用,可能会强制停顿返回流水线中。同样,WB之前的停顿将会导致MEM倒退。

我们的另一个问题是可能出现WAW冒险。为了看到这些冒险的存在,考虑表C-20中的示例。如果L.D指令早一个周期发射,且其目的地为F2,则会产生WAW冒险,因为它会早于ADD.D一个周期写入F2。注意,只有当ADD.D的结果被改写,而且从来没有任何指令使用这一结果时,才会发生这一冒险!如果在ADD.D和L.D之间会用到F2,那流水线将会因为RAW冒险而需要停顿,在ADD.D完成之前不会发射L.D。对于这个流水线,我们可以宣称只有在发射无用指令时才会发生WAW冒险,但仍然必须检测这些冒险,并在完成工作时确保L.D的结果出现在F2中。

有两种可能方式来处理这一WAW冒险。第一种方法是延迟载入指令的发射,直到ADD.D进入MEM为止。第二种方法是废除ADD.D的结果:检测冒险并改变控制,使ADD.D不会写入其结果。之后L.D就可以立即发射。由于这种冒险非常少见,所以两种方案都很有效一可以选择任何一种易于实现的方案。在任一情况下, 都可以在发射L.D的ID期间检测冒险,使L.D停顿或者使ADD.D成为空操作都很容易。难以处理的情景是检测L.D可能在ADD.D之前完成,因为这时需要知道流水线的长度和ADD.D 的当前位置。幸运的是,这一代码序列 (两个写操作之间没有插入读操作)很少出现,所以可以使用一种简单的解决方案:如果ID中的一条指令希望和一条已经发射的指令同时写同一寄存器,就不要向EX发射指令。

在检测可能出现的冒险时,必须考虑浮点指令之间的冒险,以及浮点指令与整型指令之间的冒险。除了浮点载入-存储和浮点-整数寄存器移动之外,浮点与整数寄存器是相互分离的。所有整数指令都是针对整数寄存器进行操作,而浮点操作仅对它们自己的寄存器进行操作。因此,在检测浮点与整数指令之间的冒险时,只需要考虑浮点载入-存储和浮点寄存器移动。流水线控制的这种简化是整数和浮点数据采用分离寄存器堆的另一项好处。(其主要好处是在各寄存器堆数目不变的情况下使寄存器数目加倍,还能在不增加各寄存器端口的情况下增加带宽。除了需要增加寄存器堆之外,其主要缺点是偶尔需要在两组寄存器之间进行移动所带来的微小成本。)假定流水线在ID中进行所有冒险检测,必须在执行3种检查之后才能发射一条指令。

(1)检查结构冒险——一直等到所需要功能单元不再繁忙为止(在这个流水线中,只有除法操作需要),并确保在需要寄存器写端口时可用。

(2)检查RAW数据冒险——一直等到源寄存器未被列为流水线寄存器中的目的地为止。(当这一指令需要结果时,这些寄存器不可用。)这里需要进行大量检查,具体取决于源指令和目标指令,前者决定结果何时可用,后者决定何时需要该取值。例如,如果ID中的指令是一个浮点运算,其源寄存器为F2,那F2在ID/A1、A1/A2或A2/A3中不能被列为目的地,它们与一些浮点加法指令相对应,当ID中的指令需要结果时,这些指令还不能完成。(ID/A1 是ID输出寄存器中被发送给A1的部分。)如果我们希望重叠执行除法的最后几个周期,由于需要将除法接近完成时的情景作为特殊情况加以处理,所以除法运算需要的技巧性更强-些。实际上,设计师可能会忽略这一优化,以简化发射测试。

(3)检查WAW数据冒险——判断A1,… A4,D,M1,…,M7中是否有任何指令的目标寄存器与这一指令相同。如果确实如此,则暂停发射ID中的指令。尽管对于多周期浮点运算来说,冒险检测要更复杂一些,但其概念与MIPS整数流水线是一样的。对于转发逻辑也是如此。可通过以下方式来实现转发:检查EXMEM、A4/MEM、M7MEM、D/MEM或MEM/WB寄存器中的目标寄存器是否为浮点指令的源寄存器之一。如果确实如此,则必须启用适当的输入多工器,以选择转发数据。

保持精确异常处理

用以下代码序列可以说明长时间运行指令所导致的另一个问题:

1
2
3
DIV.D     F0,F2, F4
ADD.D F10,F10,F8
SUB.D F12,F12,F14

这一代码序列看起来非常简单;其中没有相关性。但是,由于指令的完成顺序可能不同于其发射顺序,所以会出现一个问题。在这个示例中,我们可能预期ADD.D和SUB.D先于DIV.D之前完成。这称为乱序完成,在拥有长时操作的流水线中很常见。由于冒险检测会禁止违反指令之间的任何相关性,那乱序完成为什么会成为一个问题呢?假定在ADD.D已经完成而DIV.D还未完成时,SUB.D导致了浮点算术异常,最终会出现我们应当尽力避免的不精确异常。看起来,我们似乎可以像对整数流水线那样,通过清空浮点流水线来解决这一问题。但是,异常的发生位置可能无法让我们清空流水线。例如,如果DIV.D 决定在完成加法之后获取浮点算术异常,那可能无法获得硬件级别的精确异常。事实上,由于ADD.D 破坏了它的一个操作数,所以即使在软件的帮助下,也无法恢复到DIV.D之前的状态。这一问题的出现是因为指令的完成顺序与其发射顺序不同。共有四种可能方法来处理乱序完成情况。

第一种是忽略问题,容忍非精确异常。20世纪60年代和70年代早期采用这一方法。在某些超级计算机中仍在使用这种方法,在这种超级计算机中,某些特定类型的异常或者不允许出现,或者由硬件进行处理,不需要使流水线停止。在大多数现代处理器中很难使用这一方法,因为虚拟存储器等功能和IEEE 浮点标准都需要通过软件和硬件的组合来实现精确异常。前面曾经提到,最近的一些处理器已经通过引入两种执行模式解决了这一问题,一种模式速度很快,但可能是非精确的,另一种模式较慢,但却是精确的。在实现较慢的精确模式时,或者使用模式切换,或者显式插入一些指令,用于测试浮点异常。无论采用哪种实现方式,浮点流水线中所允许的重叠和重新排序数目都受到严重限制,以实现在同一时间只有一条浮点指令是活动的。

第二种方法是缓冲一个操作的结果,直到先前发射的所有操作都完成为止。一些CPU实际上使用了这一方案,但是,当操作的运行时间差别很大时,由于要缓冲的结果数变得非常庞大,所以这种方法的成本就会非常高昂。此外,必须绕过来自队列的结果,以便在等待较长指令的同时继续发射指令。这就需要大量比较器和一个非常大的乘法器。

这一基本方法有两种可行的变化形式。第一种形式是历史文件。历史文件跟踪寄存器的原始值。在发生异常而且必须将状态回滚到某一乱序完成的指令之前时,可以从历史文件中恢复寄存器的原始值。在诸如VAX之类的处理器上,采用一种类似技术来实现自动递增和自动递减寻址。另一种方法是未来文件,它跟踪寄存器的较新值;当所有先前指令均已完成时,则从未来文件中更新主寄存器堆。当发生异常时,主寄存器堆拥有中断状态的精确值。

所使用的第三种方法是允许异常变得不十分精确,但保存足够的信息,以便陷阱处理例程可以生成精确的异常序列。这意味着要知道流水线中有哪些操作以及其PC。因此,在处理异常之后,由软件完成那些最后完成指令之前的所有指令,然后该序列就能重新启动了。考虑以下最糟代码序列:

1
2
3
指令1——最终中断执行的长时运行指令。
指令2, ...,指令m-1 ——系列未完成的指令。
指令n——一条已完成指令。

给定流水线中所有指令的PC和异常返回PC之后,软件就可以得到指令1和指令n的状态。由于指令n已经完成,所以我们希望在指令n+1 重新开始执行。在处理异常之后,软件必须模拟指令1,…,指令m-1的执行。然后,我们可以从异常中返回,并在指令m重新启动。由处理器正确执行这些指令的复杂性才是这一方案的主要挑战。

对于简单的类MIPS流水线,有一个重要简化:如果指令2,…,指令n都是整数指令,那我们就知道当指令n完成时,指令2,…,指令n-1也都已完成。因此,只有浮点操作需要处理。为使这一情况易于处理,必须限制可以重叠执行的浮点指令数。例如,如果我们仅重叠两条指令,那只有中断指令需要由软件来完成。如果浮点流水线很深,或者如果存在大量浮点功能单元,那这一限制可能会限制吞吐量。SPARC 体系结构中使用了这一方法,以允许重叠浮点与整数操作。最后一种方法是一种混合方案,它仅在确保所发射指令之前的所有指令都已完成,而且没有导致异常时,才允许继续指令发射。这样就能确保在发生异常时,中断指令之后的指令都不会完成,而中断指令之前的全部指令都可以完成。这意味着有时要停顿CPU,以保持精确异常。为使这一方案有效,浮点功能单元必须在EX流水级的早期判断是否可能存在异常

MIPS浮点流水线的性能

图C-18中的MIPS浮点流水线既可以对除法单元生成结构性停顿,也可以对RAW冒险生成停顿(它还可能拥有WAW冒险,但在实际中很少发生)。图C-19以各实例为基础,列出了每种浮点操作的停顿周期数目(即,每个浮点基准测试的第一个长条表示每个浮点加、减或转换的浮点结果停顿数)。可以看到,每个操怍的停顿周期与浮点运算的延迟相关,介于功能单元延迟的46%~59%之间。

图C-19 对于SPEC89浮点基准测试,每种主要浮点运算类型的停顿。

图C-20给出了5种SPECfp基准测试整数与浮点停顿的完整分类。图中共给出4类停顿:浮点结果停顿、浮点比较停顿、载入与分支延迟、浮点结构延迟。编译器尝试在调度分支延迟之前调度载入与浮点延迟。每个指令的总停顿数介于0.65~1.21之间。

图C-20 针对5种SPEC89浮点基准测试,MIPS浮点流水线上发生的停顿。在所有情况下,浮点结果停顿都占绝大多数,每条指令平均为0.71次停顿,也就是停顿周期的82%。比较操作生成的停顿数为每条指令平均0.1次,为第二大停顿源。除法结构性冒险的影响仅在doduc测试中比较明显一些

融会贯通: MIPS R4000流水线

在本节,我们将研究MIPS R4000 处理器系列的流水线结构和性能,该系列包括4400。R4000实现MIPS64,但它为整数和浮点程序使用的流水线深度都超过我们使用的五级流水线设计。这一较深流水线可以将五级整数流水线分解为八级,以允许实现更高的时钟频率。由于缓存访问的时间要求很高,所以通过分解存储器访问可以获得更多的流水级。这种更深的流水线有时被称为超流水线。

图C-21显示了八级流水线结构,其中使用了数据路径的抽象版本。图C-22显示了流水线中连续指令的重叠。注意,尽管指令和数据存储器占用多个周期,但它们已经完全实现流水化,所以在每个时钟 周期都可以开始一条新指令。事实上,流水线会在完成缓存命中检测之前使用数据。每一流水级的过程如下所述。

  • IF——指令提取的前半部分,PC选择与指令缓存访问的初始化实际上发生在这里。
  • IS——指令提取的后半部分,完成指令缓存访问。
  • RF——指令译码与寄存器提取、冒险检查、指令缓存命中检测。
  • EX——执行,包括实际地址计算、ALU操作和分支目标计算与条件判断。
  • DF——数据提取,数据缓存访问的前半部分。
  • DS——数据提取的后半 部分,完成数据缓存访问。
  • TC——标记检查 ,判断数据缓存访问是否命中。
  • WB——载入和寄存器-寄存器操作的写回过程。


图C-21 R4000 的八级流水线结构使用流水化指令与数据缓存。图中对流水级进行了标记,它们的详细功能用文字描述。垂直虚线表示流水级界限以及流水线锁的位置。指令实际上在IS结束时可供使用,但标记检查是在RF完成的,与此同时提取寄存器值。因此,我们将指令存储器标记为在整个RF中运行。由于在知道缓存访问是否命中之前,不能将数据写入寄存器,所以数据存储器访问需要TC级

图C-22 R4000 整数流水线的结构导致了2周期载入延迟。由于数据值在DS结束时可用,而且可能被旁路,所以有可能产生2周期延迟。如果TC中的标记检查显示这是一次缺失,则流水线将回退一个周期,在此时刻有正确数据可供使用

除了显著增加所需要的转发数量之外,这种较长延迟的流水线既会增加载入延迟,又会增加分支延迟。由于数据值在DS的末尾才可用,所以图C-22将载入延迟显示为2个周期。表C-21显示了在载入指令之后立即使用的简略流水线调度。它显示在3个或4个周期之后使用载入指令的结果时,就需要进行转发。

图C-23显示基本分支延迟为3个周期,这是因为分支条件是在EX期间计算的。MIPS体系结构有一个延迟1周期的分支。R4000 为该分支延迟的其余2个周期使用预测未选中策略。如表C-22所示,未选中分支就是延迟1个周期的分支,而选中分支是在一个1周期延迟时隙之后跟有2个空闲周期。这一指令集提供了一种类似于分支的指令,前面已经对其进行了介绍,它可以帮助填充该延迟时隙。流水线互锁一方面要插入选中分支的2周期分支停顿代价,另一方面也造成因为使用载入结果而导致的数据冒险停顿。

  • 通常的转发路径可在2个周期之后使用,所以DADD和DSUB通过停顿之后的转发来获取其取值。OR指令从寄存器堆中获取该值。由于载入之后的两条指令可能是独立的,因此不会停顿,所以旁路可能指向载入之后3或4个周期的指令。


深度流水线除了增加载入与分支的停顿之外,还会增加ALU运算的转发级别数。在MIPS五级流水线中,两个寄存器-寄存器ALU指令之间的转发可能发生于ALUMEM或MEMWB寄存器。在R4000流水线中,ALU旁路可能有4种可能来源: EX/DF、DF/DS、DS/TC和TC/WB。表C-22如表中上半部分所示,选中分支有一个1周期延迟时隙,后面跟有一个2周期停顿,而如表中下半部分所示,未选中分支只有一个1周期延迟时隙

浮点流水线

R4000浮点单元由3个功能单元缓存:浮点除法器、浮点乘法器和浮点加法器。加法器逻辑在乘法或除法的最后一个步骤使用。双精度浮点运算可能占用2个周期(对于求相反数)到112个周期(对于求平方根)。此外,各种单元的起始速度不同。浮点功能单元可以看作拥有8个不同流水级,如表C-23中所列;以不同顺序组合这些流水级,即可执行各种浮点运算。

这些流水级的每个流水级都有单个副本,各种指令对一个流水级可以使用0次或多次,使用顺序也可以不同。表C-24给出了最常见双精度浮点运算所使用的延迟、初始速率和流水级。

  • 延迟值假定目标指令是一个浮点运算。当目标指令为存储指令时,延迟会少1个周期。流水级的显示顺序就是各个运算使用它们的顺序。标记S+A表示在这个时钟周期内同时使用S和A流水级。标记D28表示D流水级在一行中使用28次。

根据表C-24中的信息,我们可以判断一个由不同独立浮点运算组成的序列是否可以无停顿发射。如果因为该序列的时序而对于共享流水级发生冲突,则需要停顿。表C-25、表C-26、表C-27、表C-28给出了4种可能存在的常见两指令序列:乘法后面跟有加法、加法后面跟有乘法、除法后面跟有加法、加法后面跟有除法。这些表中显示了第二条指令所有感兴趣的起始位置,以及第二条指令在每个位置是发射还是停顿。当然,可能一共有三条指令是活动的,在这种情况下,发生停顿的可能性要高得多,列表也要更为复杂。

  • 第二列指出一个特定类型的指令在n个周期之后发射时是否会停顿,其中n为时钟周期编号,在此周期内发生第二指令的U级。导致停顿的流水级用黑体表示。注意,这个表仅给出了乘法指令与时钟周期1、7之间发射的一个加法指令之间的交互。在这种情况下,如果加法指令在乘法之后4或5个周期发射,则该加法指令会停顿;否则,它会无停顿发射。注意,如果加法指令在周期4发射,由于它在下一个时钟周期仍然会与乘法指令相冲突,所以加法指令会停顿2个周期;但是,如果加法指令在周期5发射,由于这样会消除冲突,所以它仍然仅停顿1个时钟周期。

  • 除法在周期0处开始,在周期35处完成;表中给出除法的最后10个周期。由于除法指令大量使用了加法指令所需要的舍入硬件,因此,只要加法指令是在周期28至周期33中的任一周期中启动,该除法指令都会使其停顿。注意,在周期28处启动的加法指令将一直停顿到周期36。如果加法指令在除法指令之后立即启动,由于加法指令可能在除法指令用到共享流水级之前完成,所以不会导致冲突,如同我们在表C-26中的乘加一样。和前面的表一样,这个示例假定在时钟周期26和35之间只有一个加法指令到达U级。

  • 如果除法指令晚于加法1个周期启动,则除法指令会停顿,但在此之后不存在冲突。

R4000流水线的性能

本节,我们将研究在R4000流水线结构上运行SPEC92基准测试时所发生的停顿。流水线停顿或损失的原因共有四大类。

  1. 载入停顿——在载入之后1或2个周期再使用载入结果时导致的延迟。
  2. 分支停顿——每个选中分支上发生的两周期停顿再加上未填充或已取消分支延迟时隙。
  3. 浮点结果停顿——因为浮点操作数的RAW冒险所导致的停顿。
  4. 浮点结构停顿——因为浮点流水线中功能单元的冲突产生发射限制,进而导致的延迟。

图C-24给出了对于10个SPEC92基准测试,R4000 流水线的流水线CPI分类。表C-29给出了相同的数据。


图C-24 10个SPEC92基准测试的流水线CPI,假定采用完美缓存。流水线CPI的变化范围为1.2~2.8。最左边的5个程序为整数程序,分支延迟是CPI的主要组成因素。最右边5个程序为浮点程序,浮点结果停顿是其主要因素。表C-29给出了绘制这一图形的数值

  • 主要因素为浮点结果停顿(对于分支和浮点输入均是如此)和分支停顿,载入停顿和浮点结构性停顿的影响很小。

根据图C-24和表C-29中的数据,可以看出深度流水线的代价。与经典五级流水线相比,R4000流水线的分支延迟要长得多。较长的分支延迟会显著增加在分支上花费的周期数,特别是对于分支频率较高的整数程序。浮点程序一个值得注意的影响是: 与结构性冒险相比,浮点功能单元的延迟会导致更多的停顿,主要源于初始间隔限制和不同浮点指令对功能单元的冲突。因此,降低浮点运算的延迟应当是第一目标, 而不是实现功能单元的更多流水线或重复。当然,降低延迟可能会增加结构性停顿,这是因为许多潜在的结构性停顿被隐藏在数据冒险之后。

交叉问题

RISC指令 集及流水线效率

我们已经讨论了指令集简化对于构建流水线的好处。简单指令集还有另外一个好处:这样可以更轻松地调度代码,以提高流水线的执行效率。为了解这一点,考虑一个简单示例:假定我们需要对存储器中的两个值相加,并将结果存回存储器。在一些高级指令集中,这一任务只需要一条指令;而在其他一些指令集中则需要两条或三条指令。一个典型的RISC 体系结构需要四条指令(两条载入指令、一条加法指令和一条存储指令)。在大多数流水线中,不可能在没有插入停顿的情况下顺序调度这些指令。对于RISC指令集,各个操作都是单独的指令,可以使用编译器进行各别调度或动态硬件调度技术进行各别调度。这些效率优势如此明显,再加上其实现非常容易,所以复杂指令集的几乎所有近期流水线实现实际上都将其复杂指令转换为类似于RISC的简单操作,然后再对这些操作进行调度和流水化。

动态调度流水线

简单流水线提取一条指令并发射它,除非流水线中的已有指令和被提取的指令之间存在数据相关性,而且不能通过旁路或转发来隐藏。转发逻辑降低了实际流水线延迟,使特定的相关性不会导致冒险。如果存在不可避免的冒险,则冒险检测硬件会使流水线停顿(从使用该结构的冒险开始)。在清除这种相关性之前,不会提取或发射新指令。为了弥补这些性能损失,编译器可以尝试调度指令来避免冒险;这种方法称为编译器调度或静态调度。几种早期处理器使用了另外一种名为动态调度的方法,硬件借此方法重新安排指令的执行过程,以减少停顿。

到目前为止,本附录讨论的所有技术都使用循序指令发射,这意味着如果一条指令在流水线中停顿,将不能处理后续指令。在采用循序发射时,如果两条指令之间存在冒险,即使后面存在一些不相关的、不会停顿的指令,流水线也会停顿。在前面开发的MIPS流水线中,结构性冒险和数据冒险都是在指令译码(ID)期间进行检查的:当一条指令可以正确执行时,该指令是从ID发射出去的。为使一条指令在其操作数可用时立即开始执行,不受其先前停顿指令的影响,我们必须将发射过程分为两部分:检查结构性冒险,等待数据冒险的消失。循序对指令进行译码和发射;但是,我们希望指令在其数据操作数可用时立即开始执行。因此,流水线是乱序执行的,也就暗示是乱序完成的。为了实现乱序执行,我们必须将ID流水级分为两级。

  1. 发射——指令译码,检查结构性冒险。
  2. 读取操作数——等到没有数据冒险,随后读取操作数。

IF级进入发射级,EX级跟在读取操作数级之后,这一点与MIPS流水线中一样。同MIPS浮点流水线一样,执行可能占用多个周期,具体取决于所执行的操作。因此,我们可能需要区分一条指令何时开始执行,何时完成执行;在这两个时刻之间,指令处于执行过程中。这样就允许多条指令同时处于执行过程中。除了对流水线结构的修改之外,我们还将改变功能单元设计:改变单元数、操作延迟和功能单元流水化,以更好地探索这些更高级的流水线技术。

采用记分卡的动态调度

在动态调度流水线中,所有指令都循序通过发射级(循序发射);但是,它们可能在第二级(读取操作数级)停顿,或绕过其他指令,然后进行乱序执行状态。记分卡技术在有足够资源、没有数据依赖性时,允许指令乱序执行;这一功能是在CDC 6600记分卡中开发的,并因此而得名。

在我们了解如何在MIPS流水线中使用记分卡之前,非常重要的一点是要观察到当指令乱序执行时可能会出现WAR冒险,这种冒险在MPS浮点或整数流水线中是不存在的。例如,考虑以下代码序列:

1
2
3
DIV.D         F0,F2,F4
ADD.D F10,F0,F8
SUB.D F8,F8,F14

ADD. D和SUB.D之间存在一种反相关性:如果流水线在ADD.D之前执行SUB.D,它将违犯反相关性,产生错误的执行结果。与此类似,为避免违反输出相关性,也必须检查WAW冒险(例如,当SUB.D的目标寄存器为F10时将会发生此种冒险)。后面将会看到,记分卡通过停顿反相关中涉及的后续指令,避免了这两种冒险。记分卡的目标是:通过尽早执行指令,保持每时钟周期1条指令的执行速率(在没有结构性冒险时)。因此,当下一条要执行的指令停顿时,如果其他指令不依赖于任何活动指令或停顿指令,则发射和执行这些指令。记分卡全面负责指令发射与执行,包括所有冒险检测任务。要充分利用乱序执行,需要在其EX级中同时有多条指令。这一点可以通过多个功能单元、流水化功能单元或同时利用两者来实现。由于这两种功能(流水化功能单元和多个功能单元)对于流水线控制来说基本上是等价的,所以我们将假定处理器拥有多个功能单元。CDC 6600拥有16个独立的功能单元,包括4个浮点单元、5个存储器引用单元和7个整数运算单元。在采用MIPS体系结构的处理器上,记分卡主要在浮点单元上发挥作用,因为其他功能单元的延迟非常小。让我们假定一共有两个乘法器、一个加法器、一个除法单元和一个完成所有存储器引用、分支和整数运算的整数单元。图C-25给出了该处理器的基本结构。


图C-25 带有记分卡的MIPS处理器的基本结构。记分卡的功能是控制指令执行(垂直控制线)。所有数据在寄存器堆和总线上的功能单元之间流动(水平线,在CDC6600中称为干线)。共有两个浮点乘法器、一个浮点除法器、一个浮点加法器和一个整数单元。一组总线(两个输入和一个输出)充当一组功能单元。记分卡的细节在表C-30至表C-33中给出

每条指令都进入记分卡,在这里构建一条数据相关性记录;这一步与指令发射相对应,并替换MIPS流水线中的ID步骤。记分卡随后判断指令什么时候能够读取它的操作数并开始执行。如果记分卡判断该指令不能立即执行,它监控硬件中的所有变化,以判断该指令何时能够执行。记分卡还控制一条指令什么时候能将其结果写到目标寄存器中。因此,所有冒险检测与解决都集中在记分卡。我们后面将会看到记分卡的一张表格(表C-30),但首先需要理解流水线发射与执行部分的步骤。

每条指令需要经历4个执行步骤。(由于我们现在主要考虑浮点运算,所以不考虑存储器访问步骤。)我们先粗略地查看一下这些步骤,然后再详细研究记分卡如何记录一些必要信息,用于判断执行过程何时由一个步骤进行到下一个步骤。这四个步骤代替了标准MIPS流水线中的ID、EX和WB步骤,如下所示。
(1)发射——如果指令的一个功能单元空闲,没有其他活动指令以同一寄存器为目标寄存器,则记分卡向功能单元发射指令,并更新其内部数据结构。这一步代替了MIPS流水线中ID步骤的一部分。只要确保没有其他活动功能单元希望将自己的结果写入目标寄存器,就能保证不会出现WAW冒险。如果存在结构性冒险或WAW冒险,则指令发射停顿,在清除这些冒险之前,不会再发射其他指令。当发射级停顿时,会导致指令提取与发射之间的缓冲区填满;如果缓冲区只是一项,则指令提取立即停顿。如果缓冲区是拥有多条指令的队列,则在队列填满后停顿。

(2)读取操作数——记分卡监视源操作数的可用性。如果先前发射的活动指令都不再写入源操作数,而该源操作数可用。当源操作数可用时,记分卡告诉功能单元继续从寄存器读取操作数,并开始执行。记分卡在这一步动态解决 RAW冒险,可以发送指令以进行乱序执行。这一步和发射步骤一起,完成了简单MIPS流水线中ID步骤的功能。

(3)执行——功能单元接收到操作数后开始执行。结果准备就绪后,它通知记分卡已经完成执行。这一步代替了MIPS流水线中的EX步骤,在MIPS浮点流水线中耗用多个周期。

(4)写结果——一旦记分卡知道功能单元已经完成执行,则检查WAR冒险,并在必要时停顿正在完成的指令。

如果有一个与我们先前示例相类似的代码序列,其中ADD.D和SUB.D都使用F8,则存在WAR冒险。在这个示例中,有如下代码:

1
2
3
DIV.D     F0,F2,F4
ADD.D F10,F0,F8
SUB.D F8,F8,F14

ADD.D有一个源操作数为F8,就是SUB.D的目标寄存器。但ADD.D实际上取决于前面的一条指令。记分卡仍将SUB.D停顿于它的写结果阶段,直到ADD.D读取它的操作数为止。一般来说,在以下情况下,不能允许一条正在执行的指令写入其结果:

  • 在正在执行的指令前面(即按发射顺序)有一条指令还没有读取其操作数;
  • 这些操作数之一与正执行指令的结果是同一寄存器。

如果不存在这一WAR冒险,或者已经清除,则记分卡会告诉功能单元将其结果存储到目标寄存器中。这一步骤代替了简单MIPS流水线中的WB步骤。

乍看起来,记分卡在区分RAW和WAR冒险时似乎会有困难。

因为只有当寄存器堆中拥有一条指令的两个操作数时,才会读取这些操作数,所以记分卡未能利用转发。只有当寄存器都可用时才会进行读取。这一代价并没有读者最初想象得那么严重。这里与我们前面的简单流水线不同,指令会在完成执行之后立即将结果写入寄存器堆(假定没有WAR冒险),而不是等待可能间隔几个周期的静态指定写入时隙。由于结果的写入和操作数的读取不能重叠,所以仍然会增加一个周期的延迟。我们需要增加缓冲,以消除这一开销。记分卡根据自已的数据结构,通过与功能单元的沟通来控制指令从一个步骤到下一个步骤的进展。但这种做法有一点点复杂。指向寄存器堆的源操作数总线和结果总线数目是有限的,所以可能会存在结构性冒险。记分卡必须确保允许进入第(2)、(4)步的功能单元数不会超过可用总线数。这里不会进行深入讨论,仅提及CDC 6600在解决这一问题时,将16个功能单元分为四组,并为每一组提供一组总线,称为数据干线。在一个时钟周期内,一个组中只有一个单元可以读取其操作数或写入其结果。

现在让我们看一个拥有五个功能单元的MIPS记分卡所保持的详尽数据结构。表C-30显示了在如下这一简单指令序列执行时,记分卡中的信息。

1
2
3
4
5
6
L.D      F6 ,34(R2)
L.D F2 ,45(R3)
MUL.D F0,F2,F4
SUB.D F8,F6,F2
DIV.D F10,F0,F6
ADD.D F6, F8,F2

  • 每个功能单元在功能单元状态表中有一个对应项。一旦发射一条指令后,就在功能单元状态表中保留其操作数记录。最后,寄存器状态表指示哪个单元将生成每个未给出的结果;项数与寄存器数相等。指令状态表表明: (1)第一个L.D已经完成并写入其结果,(2) 第二个L.D已经完成执行,但还没有写入其结果。MUL.D、SUB.D 和DIV.D都已经发射,但正在停顿,等待其操作数。功能单元状态表明第一个乘法单元正在等待整数单元,加法单元正在等待整数单元,除法单元正在等待第一个乘法单元。ADD.D 指令因为结构性冒险而停顿,当SUB.D完成时将会清除这一冒险。如果这些记分卡中某个中的项目没有用到,则保持为空。例如,Rk字段在载入时没有用到,Mult2单元没有用到,因此它们的字段没有意义。另外,一旦读取一个操作数之后,Rj和Rk字段将被设置为“否”。表C-33表明了最后一步为什么至关重要。

记分卡共有三个部分,如下所述。

  • 指令状态——指出该指令处于四个步骤中的哪一步。
  • 功能单元状态——指出功能单元(FU)的状态。共有9个字段用来表示每个功能单元的状态。
    • 忙——指示该单元是否繁忙。
    • Op一在此单元中执行的运算 (例如,加或减)。
    • Fi——目标寄存器。
    • Fj, Fk——源寄存器编号 。
    • Qj, Qk——生成源寄存器Fj、 Fk的功能单元。
    • Rj, Rk——指示Fj、 Fk已准备就绪但尚未读取的标记。在读取操作数后将其设置为“否”。
  • 寄存器结果状态——如果一条活动指令以该寄存器为目标寄存器,则指出哪个功能单元将写入每个寄存器。只要没有向该寄存器写入的未完成指令,则将此字段设置为空。

现在让我们看一下在表C-30中开始的代码序列如何继续执行。之后,我们就能更详细地研究记分卡用于控制执行的条件了。

假定浮点功能单元的EX周期延迟如下(选择这些延迟是为了说明行为特性,并非代表性数值):加法为2个时钟周期,乘法为10个时钟周期,除法为40个时钟周期。利用表C-30前面的代码段,并从表C-30中指令状态指示的时刻开始,说明当MUL.D和DIV.D分别准备好写入结果状态时,状态表中是什么样的。

从第二个L.D到MUL.D、ADD.D和SUB.D,从MUL.D到DIV.D和从SUB.D到ADD.D,存在RAW数据冒险。在DIV.D和ADD.D以及SUB.D之间存在WAR数据冒险。最后,加法功能单元对于ADD.D和SUB.D中存在结构性冒险。当MUL.D和DIVD准备好写入其结果时,这些表分别如表C-31和表C-32所示。


现在,我们可以研究一下为使每条指令能够继续,记分卡必须做些什么,以此来详细了解记分卡是如何工作的。表C-33说明,为使每条指令能够继续执行,记分卡需要些什么,并记录在指令继续执行时需要哪些必要的操作。记分卡记录操作数标志符信息,比如寄存器编号。例如,在发射指令时,必须记录源寄存器。因为我们将寄存器的内容称为Regs[D],其中D为寄存器名称,所以不存在模糊性。例如,Fj[FU]←S1会导致寄存器名称S1被放在Fj[FU]中,而不是寄存器S1的内容。


记分卡的成本和收益也是人们的关注点。CDC 6600设计师测量到FORTRAN程序的性能改进因数为1.7,对于人工编码的汇编语言改进因数为2.5。但是,这些数据是在软件流水线调度、半导体主存储器和缓存(缩短了存储器访问时间)之前的那一时期测得的。CDC 6600上记分卡所拥有的逻辑数与一个功能单元相当,这是相当低的。主要成本在于存在大量总线——其数量大约是CPU循序执行(或者每个执行周期仅启动一条执行)时所需数量的四倍。人们近来对动态调度的关注有所增加,其目的就是希望在每个时钟周期内发射更多条指令(反正都要支付增加总线带来的成本),一些很自然地以动态调度为基础的思想也是提升此关注度的推动因素。记分卡利用可用ILP,在最大程度上降低因为程序真数据相关所导致的停顿数目。在消除停顿方面,记分卡受以下几个因素的影响。

(1)指令间可用并行数——这一因素决定了能否找到要执行的独立指令。如果每条指令都依赖于它前面的指令,那就找不到减少停顿的动态调度方案。如果必须从同一基本模块中选择同时存在于流水线中的指令(在6600中就是如此),那这一限制是 十分严重的。

(2)记分卡的项数——这一因素决定 了流水线为了查找不相关指令可以向前查找多少条指令。这组作为潜在执行对象的指令被称为窗口。记分卡的大小决定了窗口的大小。在这一节,我们假定窗口不会超过一个分支,所以窗口(及记分卡)总是包含来自单个基本模块的直行代码。第3章说明如何将窗口扩展到超出一个分支之外。

(3)功能单元的数目和类型——这一因素决定 了结构性冒险的重要性,它可能会在使用动态调度时增加。

(4)存在反相关和输出相关——它们会 导致WAR和WAW停顿。

谬论与易犯错误

乍看起来,WAW冒险似乎永远不可能在一个代码序列中出现,因为没有哪个编译器会生成对同一寄存器的两次写入操作,却在中间没有读取操作,但当序列出乎意料之外时,却可能发生WAW冒险。例如,第一次写入操作可能在-个选中分支的延迟时隙中,而调度器认为该分支未被选中。下面是可能导致这一情景的代码序列:

1
2
3
4
        BNEZ R1 , foo
DIV.D F0,F2,F4 ;从未被选中移入延迟时隙
...
foo: L.D F0,qrs

如果该分支被选中,则在DIV.D可以完成之前,L.D将到达WB,导致WAW冒险。硬件必须检测这一冒险,并暂停发射L.D。另外一种可能发生这种情景的方式是第二次读取操作存在于陷阱例程中。一条要写入结果的指令导致陷阱中断,当陷阱处理器中的一条指令完成对同一寄存器的写入之后,原指令继续完成,这时就会发生上述情景。硬件也必须检测并阻止这一情景。

向量、SIMD和GPU体系结构中的数据级并行

引言

由于单条指令可以启动许多数据运算,所以SIMD在能耗效率方面可能要比多指令多数据(MIMD)更高效一些,MIMD每进行一次数据运算都需要提取和执行一条指令。 这两个答案使SIMD对于个人移动设备极具吸引力。最后,SMID与MIMD相比的最大优势可能就是:由于数据操作是并行的,所以程序员可以采用顺序思维方式但却能获得并行加速比。

本章介绍SIMD的3种变体:向量体系结构、多媒体SIMD指令集扩展和图形处理单元(GPU)。

第一种变体的出现要比其他两个早30年以上,它实际上就是以流水线形式来执行许多数据操作。与其他SIMD变体相比,这些向量体系结构更容易理解和编译,但过去一直认为它们对于微处理器来说太过昂贵了,这一看法直到最近才有所改变。这种体系结构的成本,一部分用在晶体管上,另一部分用于提供足够的DRAM带宽,因为它广泛依赖于缓存来满足传统微处理器的存储器性能要求。

第二种SIMD变体借用SIMD名称来表示基本同时进行的并行数据操作,在今天支持多媒体应用程序的大多数指令集体系结构中都可以找到这种变体。x86体系结构的SIMD指令扩展是在1996年以MMX(多媒体扩展)开始的,在接下来的10年间出现了几个SSE(流式SIMD扩展)版本,一直发展到今天的AVX (高级向量扩展)。为了使x86计算机达到最高计算速度,通常需要使用这些SIMD指令,特别是对于浮点程序。

SIMD的第三种变体来自GPU社区,它的潜在性能要高于当今传统多核计算机的性能。尽管GPU的一些特征与向量体系结构相同,但它们有自已的一些独特特征,部分原因在于它们的发展生态系统。在GPU的发展环境中,除了GPU及其图形存储器之外,还有系统处理器和系统存储器。事实上,为了辨识这些差别,GPU社区将这种体系结构称为异类。

对于拥有大量数据并行的问题,所有这三种SIMD变体都有一个共同的好处:与经典的并行MIMD编程相比,程序员的工作更轻松-些。为了对比SIMD与MIMD的重要性,图4-1绘制了x86计算机中MIMD的核心数与SIMD模式中每个时钟周期的32位及64位运算数随时间的变化曲线。

对于x86计算机,我们预期每个芯片上每两年增加两个核心,SIMD 的宽度每四年翻一番。给定这些假设,在接下来的10年里,由SIMD并行获得的潜在加速比为MIMD并行的两倍。因此,尽管MIMD并行最近受到的关注要多得多,但理解SIMD并行至少与理解MIMD并行一样重要。对于同时具有数据级并行和线程级并行的应用程序,2020年的潜在加速比将比今天的加速比高一个数量级。

向量体系结构

执行可向量化应用程序的最高效方法就是向量处理器。

向量体系结构获得在存储器中散布的数据元素集,将它们放在一些大型的顺序寄存器堆中,对这些寄存器堆中的数据进行操作,然后将结果放回存储器中。一条指令对数据向量执行操作,从而会对独立数据元素进行数十个“寄存器-寄存器”操作。

这些大型寄存器堆相当于由编译器控制的缓冲区,一方面用于隐藏存储器延迟,另一方面用于充分利用存储器带宽。由于向量载入和存储是尝试流水化的,所以这个程序仅在每个向量载入或存储操作中付出较长的存储器延迟时间,而不需要在载入或存储每个元素时耗费这一时间,从而将这一延迟时间分散在比如64个元素上。事实上,向量程序会尽力使存储器保持繁忙状态。

VMIPS

我们首先看一个向量处理器,它由图4-2所示的主要组件组成。这个处理器大体以Cray-1为基础,它是本节讨论的基础。我们将这种指令集体系结构称为VMIPS;它的标量部分为MIPS,它的向量部分是MIPS的逻辑向量扩展。这一小节的其他部分研究VMIPS的基本体系结构与其他处理器有什么关系。

VMIPS的基本结构。这一处理器拥有类似于MIPS的标量体系结构。它还有8个64元素向量寄存器,所有功能单元都是向量功能单元。这一章为算术和存储器访问定义了特殊的向量指令。图中显示了用于逻辑运算与整数运算的向量单元,所以VMIPS看起来像是一种通常包含此类单元的标准向量处理器;但是,我们不会讨论这些单元。这些向量与标量寄存器有大量读写端口,允许同时进行多个向量运算。一组交叉交换器(粗灰线)将这些端口连接到向量功能单元的输入和输出

VMIPS指令集体系结构的主要组件如下所示。

  • 向量寄存器——每个向量寄存器都是一个固定长度的寄存器组,保存一个向量。VMIPS有8个向量寄存器,每个向量寄存器保留64个元素,每个元素的宽度为64位。向量寄存器堆需要提供足够的端口,向所有向量功能单元馈送数据。这些端口允许将向量操作高度重叠,发送到不同向量寄存器。利用一对交叉交换器将读写端口(至少共有16个读取端口和8个写入端口)连接到功能单元的输入或输出。
  • 向量功能单元——每个单元都完全实现流水化,它可以在每个时钟周期开始一个新的操作。需要有个控制单元来检测冒险,既包括功能单元的结构性冒险,又包括关于寄存器访问的数据冒险。图4-2显示VMIPS有5个功能单元。为简单起见,我们仅关注浮点功能单元。
  • 向量载入/存储单元——这个向量存储器单元从存储器中载入向量或者将向量存储到存储器中。VMIPS向量载入与存储操作是完全流水化的,所以在初始延迟之后,可以在向量寄存器与存储器之间以每个时钟周期一个字的带宽移动字。这个单元通常还会处理标量载入和存储。
  • 标量寄存器集合——标量寄存器还可以提供数据,作为向量功能单元的输入,还可以计算传送给向量载入存储单元的地址。它们通常是MIPS的32个通用寄存器和32个浮点寄存器。在从标量寄存器堆读取标量值时,向量功能单元的一个输入会闩锁住这些值。

表4-1列出了VMIPS向量指令。在VMIPS中,向量运算使用的名字与标量MIPS指令的名字相同,但后面追加了字母“VV”。 因此,ADVV.D就是两个双精度向量的加法。向量指令的输入或者为一对向量寄存器(ADDVV.D),或者为一个向量寄存器和一个标量寄存器,通过附加“VS”来标识(ADDVS.D)。在后一种情况下,所有操作使用标量寄存器的相同值来作为一个输入:运算ADDVS.D将向量寄存器中的每个元素都加上标量寄存器的内容。向量功能单元在发射时获得标量值的一个副本。大多数向量运算有一个向量目标寄存器,尽管其中一些(比如入口计数)会产生标量值,这个值将存储在标量寄存器中。

除了向量寄存器外,还有两个特殊寄存器VLR和VM,下面将进行讨论。这些特殊寄存器假定存在子MIPS协处理器1空间中,与FPU寄存器位于一起。后面将解释带有步幅的运算以及索引创建及索引载入/存储操作的应用。

名字LV和SV表示向量载入和向量存储,它们载入或存储整个双精度数据向量。一个操作数是要载入或存储的向量寄存器,另一个操作数是MIPS通用寄存器,它是该向量在存储器中的起始地址。后面将会看到,除了这些向量寄存器之外,我们还需要两个通用寄存器:向量长度寄存器和向量遮罩寄存器。当向量长度不是64时使用前者,当循环中涉及IF语句时使用后者。

功率瓶颈使架构师非常看重具有以下特点的体系结构:一方面能够提供高性能,另一方面又不需要高度乱序超标量处理器的能耗与设计复杂度。向量指令天生就与这一趋势吻合,架构师可以用它们来提高简单循序标量处理器的性能,而又不会显著增大能耗要求和设计复杂度。在实践中,开发人员可以采用向量指令的方式来表达许多程序,采用数据级并行可以很高效地在复杂乱序设计中运行。

采用向量指令,系统可以采用许多方式对向量数据元素进行运算,其中包括对许多元素同时进行操作。因为有了这种灵活性,向量设计可以采用慢而宽的执行单元,以较低功率获得高性能。此外,向量指令集中各个元素是相互独立的,这样不需要进行成本高昂的相关性检查就能调整功能单元,而超标量处理器是需要进行这检查的。

向量本身就可以容纳不同大小的数据。因此,如果一个向量寄存器可以容纳64个64位元素,那同样可以容纳128个32位元素、256个16位元素,甚至512个8位元素。向量体系结构之所以既能用于多媒体应用,又能用于科学应用,就是因为具备这种硬件多样性。

向量处理器如何工作:一个示例

通过查看VMIPS的向量循环,可以更好地理解向量处理器。让我们来看一个典型的向量问题,在本节将一直使用这个例子:

1
Y=a × X + Y

X和Y是向量,最初保存在存储器中,a是标量。这个问题就是所谓的SAXPY或DAXPY循环,它们构成了Linpack基准测试的内层循环。SAXPY表示“单精度a × X加Y” (single- precision a x X plus Y);DAXPY表示“双精度a × X加Y” (double precision a × X plus Y)。Linpack是一组线性代数例程,Linpack 基准测试包括执行高斯消去法的例程。

现在假定向量寄存器的元素数或者说其长度为64,与我们关心的向量运算长度匹配。(稍后将取消这一限制。)

给出DAXPY循环的MIPS和VMIPS代码。假定X和Y的起始地址分别为Rx和Ry。

MIPS代码如下。

1
2
3
4
5
6
7
8
9
10
11
        L.D       F0,a        ;载入标量a
DADDIU R4,Rx,#512 ;要载入的最后地址
Loop: L.D F2,0(Rx) ;载入X[i]
MUL.D F2,F2,F0 ;ax X[i]
L.D F4,0(Ry) ;载入Y[i]
ADD.D F4,F4,F2 ;axX[i] + YEi]
S.D F4,9(Ry) ;存储到Y[i]
DADDIU Rx,Rx,#8 ;递增X的索引
DADDIU Ry,Ry,#8 ;递增Y的索引
DSUBU R20,R4,Rx ;计算范围.
BNEZ R20, Loop ;检查是否完成.

下面是DAXPY的VMIPS代码:
1
2
3
4
5
6
L.D       F0,a        ;载入标量a
LV V1,R ;载入向量X
MULVS.D V2,V1,F0 ;向量-标量乘
LV V3,Ry ;载入向量Y
ADDVV.D V4,V2,V3 ;相加
SV V4,Ry ;存储结果

最引入注目的差别在于向量处理器大幅缩减了动态指令带宽,仅执行6条指令,而MIPS几乎要执行600条。这一缩减是因为向量运算是对64个元素执行的,在MIPS中差不多占据一半循环的开销指令在VMIPS代码中是不存在的。当编译器为这样一个序列生成向量指令时,所得到的代码会将大多数时间花费在向量运行模式中,我们将这种代码称为已向量化或可向量化。如果循环的迭代之间没有相关性(这种相关被称为循环间相关,见4.5节),那么这些循环就可以向量化。

MIPS与VMIPS之间的另一个重要区别是流水线互锁的频率。在简单的MIPS代码中,每个ADD.D都必须等待MUL.D,每个S.D都必须等待ADD.D。在向量处理器中,每个向量指令只会因为等待每个向量的第一个元素而停顿,然后后续元素会沿着流水线顺畅流动。因此,每条向量指令仅需要一次流水线停顿,而不是每个向量元素需要一次。向量架构师将元素相关操作的转发称为链接(chaining),因为这些相关操作是被“链接”在一起的。在这个例子中,MIPS中的流水线停顿频率大约比VMIPS高64倍。软件流水线或循环展开可以减少MIPS中的流水线停顿,但很难大幅缩减指令带宽方面的巨大差别。

向量执行时间

向量运算序列的执行时间主要取决于3个因素:(1)操作数向量的长度;(2)操作之间的结构冒险;(3)数据相关。给定向量长度和初始速率(初始速率就是向量单元接受新操作数并生成新结果的速率),我们可以计算一条向量指令的执行时间。所有现代向量计算机都有具备多条并行流水线(或车道)的向量功能单元,它们在每个时钟周期可以生成两个或更多个结果,但这些计算机还可能拥有一些未完全流水化的功能单元。为简便起见,我们的VMIPS实现方式有一条车道,各个操作的初始速率为每个时钟周期一个元素。 因此,一条向量指令的执行时间(以时钟周期为单位)大约就是向量长度。

为了简化对向量执行和向量性能的讨论,我们使用了一种护航指令组(convoy)的概念,它是一组可以一直执行的向量指令。稍后可以看到,我们可以通过计算护航指令组的数目来估计一段代码的性能。护航指令组中的指令不能包含任何结构性冒险,如果存在这种冒险,则需要在不同护航指令组中序列化和启动这些指令。为了保持分析过程的简单性,假定在开始执行任意其他指令(标量或向量)之前,护航指令都必须已经完成。

除了具有结构性冒险的向量指令序列之外,具有写后读相关冒险的序列也应该位于不同护航指令组中,但通过链接操作可以允许它们位于同一护航指令组中。链接操作允许向量操作在其向量源操作数的各个元素变为可用状态之后立即启动:链中第一个功能单元的结果被“转发”给第二个功能单元。在实践中经常采用以下方式来实现链接:

允许处理器同时读、写一个特定的向量寄存器,不过读写的是不同元素。早期的链接实现类似于标量流水线中的转发,但这限制了链中源指令与目标指令的定时。最近的链接实现采用灵活链接,这种方式允许向量指令链接到几乎任意其他活动向量指令,只要不生成结构性冒险就行。所有现代向量体系结构都支持灵活链接,这也是本章的假设之一。

为了将护航指令组转换为执行时间,我们需要有一种定时度量,用来估计护航指令组的时间。这种度量被称为钟鸣(chime),就是执行护航指令组所花费的时间单位。执行由m个护航指令组构成的向量序列需要m次钟鸣。当向量长度为n时,对于VMIPS来说,大约为mxn个时钟周期。钟鸣近似值忽略处理器特有的一些开销,许多此类开销都依赖于向量长度。因此,以钟鸣为单位测量时间时,对于长向量的近似要优于对短向量的近似。我们将使用钟鸣测量结果(而不是每个结果的时钟周期),用来明确表示忽略了特定的开销。

如果知道向量序列中的护航指令组数,那就知道了用钟鸣表示的执行时间。在以钟鸣为单位测试执行时间时,所忽略的一个开销源是对单个时钟周期内启动多条向量指令的限制。如果在一个时钟周期内只能启动一条向量指令(大多数向量处理器都是如此),那钟鸣数会低估护航指令组的实际执行时间。由于向量的长度通常远大于护航指令组中的指令数,所以简单地假定这个护航指令组是在一次钟鸣中执行的。

给出以下代码序列在护航指令组中是如何排列的,假定每个向量功能单元只有一个副本:

1
2
3
4
5
LV        V1,Rx      ;载入向量X
MULVS.D V2,V1,FO ;向量-标量乘
LV V3,Ry ;载入向量Y
ADDVV.D V4,V2,V3 ;两个向量相加
SV V4,Ry ;存储所得之和

这个向量序列将花费多少次钟鸣?每个FLOP(浮点运算)需要多少个时钟周期(忽略向量指令发射开销)?

第一个护航指令组从第一个LV指令处开始。MULVS.D依赖于第一个LV,但链接操作允许它位于同一护航指令组中。第二个LV指令必须放在另一个护航指令组中,因为它与上一个LV指令的载入存储单元存在结构性冒险。ADDVV.D 与第二个LV相关,但它也可以通过链接操作位于同一护航指令组中。最后,SV与第二个护航指令组中的LV存在结构冒险,所以必须把它放在第三护航指令组中。通过这一分析,将得出向量指令在护航指令组的如下排列:

1
2
3
1. LV     MULYS.D
2. LV ADDVV.D
3. SV

这个序列需要3个护航指令组。由于这一序列需要3次钟鸣,而且每个结果有2个浮点运算,所以每个FLOP的时钟周期数目为1.5(忽略任何向量指令发射开销)。注意,尽管我们允许LV和MULVS.D都在第一护航指令组中执行,但大多数向量计算机将需要两个时钟周期来启动这些指令。这个例子表明,钟鸣近似值对于长向量是相当准确的。例如,对于包括64个元素的向量来说,用钟鸣表示的时间为3,所以这个序列将需要大约64x3=192个时钟周期。在两个分离时钟周期中发射护航指令组的开销很小。

另一个开销源要比发射限制明显得多。钟鸣模型中忽略的最重要开销源就是向量启动时间。启动时间主要由向量功能单元的流水线延迟决定。对于VMIPS,我们使用与Cray-1相同的流水线深度,不过在更多的现代处理器中,这些延迟有增加的趋势,特别是向量载入操作的延迟。所有功能单元都被完全流水化。浮点加的流水线深度为6个时钟周期、浮点乘为7个、浮点除为20个、向量载入为12个。

有了这些向量基础知识之后,接下来的几小节将介绍一些优化方式,或者用来提高性能,或者增加可以在向量体系结构中完美运行的程序类型。具体来说,它们将回答如下问题。

  • 向量处理器怎样执行单个向量才能快于每个时钟周期一个元素?每个时钟周期处理多个元素可以提高性能。
  • 向量处理器如何处理那些向量长度与向量寄存器长度(对于VMIPS,此长度为64)不相同的程序?由于大多数应用程序向量与体系结构向量长度不匹配,所以需要一种高效的解决方案来处理这一常见情景。
  • 如果要向量化的代码中含有If语句,会发生什么?如果可以高效地处理条件语句,就能向量化更多的代码。
  • 向量处理器需要从存储器系统中获得什么?没有充分的存储器带宽,向量执行可能会徒劳无益。
  • 向量处理器如何处理多维矩阵?为使向量体系结构能够很好地工作,必须对这个常见数据结构进行向量化。
  • 向量处理器如何处理稀疏矩阵?这一常 见数据结构也必须进行向量化。
  • 如何为向量计算机进行编程?如果体系结构方面的创新不能与编译器技术相匹配,那可能不会被广泛应用。

多条车道:每个时钟周期超过一个元素

向量指令集的一个重要好处是它允许软件仅使用一条很短的指令就能向硬件传送大量并行任务。一条向量指令可以包含数十个独立运算,而其编码使用的位数与一条传统的标量指令相同。向量指令的并行语义允许实现方式在执行这些元素运算时使用:深度流水化的功能单元(就像我们目前研究过的VMIPS实现方式一样);一组并行功能单元;或者并行与流水线功能单元的组合方式。图4-3说明如何使用并行流水线来执行一个向量加法指令,从而提
高向量性能。


图4-3 使用多个功能单元提高单个向量加法指令C=A+B的性能。左边的向量处理器(a)有一条加法流水线,每个时钟周期可以完成一次加法。右边的向量处理器(b)有4条加法流水线,每个时钟周期可以完成4次加法。一条向量加法指令中的元素交错存在于4条流水线中。通过这些流水线结合在一起的元素集被称为元素组

VMIPS指令集有一个特性:所有向量算术指令只允许一个向量寄存器的元素N与其他向量寄存器的元素N进行运算。这一特性极大地简化了一个高度并行向量单元的构造,将其结构设定为多个并行车道。和高速公路一样,我们可以通过添加更多车道来提高向量单元的峰值吞吐量。图4-4给出了一种四车道向量单元的结构。这样,从单车道变为四车道之后,将一次钟鸣的时钟周期数由64个变为16个。由于多车道非常有利,所以应用程序和体系结构都必须支持长向量;否则,它们的快速执行速度会耗尽指令带宽。


图4-4 包含4个车道的向量t单元的结构。向量寄存器存储分散在各个车道中,每个车道保存每个向量寄存器每4个元素中的1个。此图显示了三个向量功能单元:一个浮点加法、一个浮点乘法和一个载入-存储单元。向量算术单元各包含4条执行流水线,每个车道1条,它们共同完成一条向量指令。注意,向量寄存器堆的每一部分只需要为其车道本地的流水线提供足够的端口即可。

每个车道都包含向量寄存器堆的一部分和来自每个向量功能单元的一个执行流水线。每个向量功能单元使用多条流水线,以每个时钟周期一个元素组的速度执行向量指令,每个车道一条流水线。第一个车道保存所有向量寄存器的第一个元素(元素0),所以任何向量指令的第一个元素都会将其源操作数与目标操作数放在第一车道中。这种分配方式使该车道本地的算术流水线无须与其他车道通信就能完成运算。主存储器的访问也只需要车道内的连接。邇过避免车道间的通信减少了构建高并行执行单元所需要的连接成本与寄存器堆端口,有助于解释向量计算机为什么能够在每个时钟周期内完成高达64个运算(跨越16个车道的2个算术单元和2个载入存储单元)。

增加多个车道是一种提高向量性能的常见技术,它不需要增加太多控制复杂性,也不需要对现有机器代码进行修改。它还允许设计人员在晶片面积、时钟频率、电压和能耗之间进行权衡,而且不需要牺牲峰值性能。如果向量处理器的时钟频率减半,只需要使车道数目加倍就能保持原性能。

向量长度寄存器:处理不等于64的循环

向量寄存器处理器有一个自然向量长度,这一长度由每个向量寄存器中的元素数目决定。对于VMIPS来说,这一长度为64,它不大可能与程序中的实际向量长度相匹配。此外,在实际程序中,特定向量运算的长度在编译时通常是未知的。事实上,一段代码可能需要不同的向量长度。例如,考虑以下代码:

1
2
for(i = 0; i < n; i = i + 1)
Y[] = a * X[i] + Y[i];

所有这些向量运算的大小都取决于n,而它的取值不可能在运行之前获知。n的值还可能是某个过程(该过程中包含上述循环)的参数,从而会在执行时发生变化。

对这些问题的解决方案就是创建一个向量长度寄存器(VLR)。VLR控制所有向量运算的长度,包括向量载入与存储运算。但VLR中的值不能大于向量寄存器的长度。只要实际长度小于或等于最大向量长度(MVL),就能解决上述问题。MVL确定了体系结构的一个向量中的数据元素数目。这个参数意味着向量寄存器的长度可以随着计算机的发展而增大,不需要改变指令集;

如果n的值在编译时未知,从而可能大于MVL,那该怎么办呢?为了解决向量长于最大长度的第二问题,可以使用一种名为条带挖掘(strip mining)的技术。条带挖掘是指生成一些代码,使每个向量运算都是针对小于或等于MVL的大小来完成的。我们创建两个循环,一个循环处理迭代数为MVL倍数的情况,另一个循环处理所有其他迭代及小于MVL的情况。在实践中,编译器通常会生成一个条带挖掘循环,为其设定一个参数,通过改变长度来处理这两种情况。我们以C语言给出DAXPY循环的条带挖掘版本:

1
2
3
4
5
6
7
low = 0;
VL = (n%MVL); /*使用求模运算%找出不规则大小部分*/
for (j = 0; j <= (n/MVL); j = j + 1) { /*外层循环*/
for (i = low; i < (1ow+VL); i = i + 1) /*执行长度VL */
Y[i] = a * X[i] + Y[i];/*主运算*/
low = low + VL; /*开始下一个向量*/
VL = MVL; /*将长度复位为最大向量长度*/

n/MVL项表示截短整数除法。这一循环的效果是将向量分段,然后由内层循环进行处理。第一段的长度为(n%MVL),所有后续段的长度为MVL。图4-5说明如何将这个长向量分到各个段中。

图4-5 用条带处理的任意长度的向量。除第一块外,所有其他块的长度都是MVL,充分利用了向量处理器的功能。本图中使用变量m来表示表达式(n%MVL),以上代码的内层循环可以进行向量化,长度为VL,或者等于(n%MVL),或者等于MVL。在此代码中,必须对VLR寄存器设置两次,也就是在代码中为变量VL进行赋值时各设置一次。

向量遮罩寄存器:处理向量循环中的IF语句

根据Amdahl定律,我们知道对于中低向量化级别的程序,加速比是非常有限的。循环内部存在条件(IF语句)稀疏矩阵是向量化程度较低的两个主要原因。如果程序的循环中包含IF语句,由于IF语句会在循环中引入控制相关,所以不能使用前面讨论的技术以向量模式运行这种程序。同样,利用前面看到的各项功能也不能高效地实现稀疏矩阵。我们现在将讨论处理条件执行的策略,稀疏矩阵留待后文讨论。考虑以C语言编写的以下循环:

1
2
3
for(i = 0; i < 64; i = i + 1)
if (X[i] != 0)
x[i] = X[i] - Y[i];

由于这一循环体需要条件执行,所以它通常是不能进行向量化的;但是,如果对于X[i]≠0的迭代可以运行内层循环,那就可以实现减法的向量化。这一功能的常见扩展称为向量遮罩控制。遮罩寄存器可以用来实现一条向量指令中每个元素运算的条件执行。向量遮罩控制使用布尔向量来控制向量指令的执行,就像条件执行指令使引用布尔条件来决定是否执行标量指令一样。在启用向量遮罩寄存器时,任何向量指令都只会针对符合特定条件的向量元素来执行,即这些元素在向量遮罩寄存器中的相应项目为1。目标向量寄存器中的其他项目(在遮罩寄存器中的相应项目为1)不受这些向量操作的影响。清除向量遮罩寄存器会将其置为全1,后续向量指令将针对所有向量元素执行。我们现在可以为以上循环使用下列代码,假定X、Y的起始地址分别为Rx和Ry:
1
2
3
4
5
6
LV      V1,Rx      ;将向量X载入V1
LV V2,Ry ;载入向量Y
L.D F0,#0 ;将浮点零载入F0
SNEYS.D V1,F0 ;若Vl(i)!=F0,则将VM(i)设置为1
SUBVV.D V1,V1,V2 ;在向量t遮罩下执行减法
SV V1,Rx ;将结果存到X中

编译器写入程序调用转换过程,使用条件执行IF转换将IF语句修改为直行代码序列。但是,使用向量遮罩寄存器是有开销的。对于标量体系结构,条件执行的指令在不满足条件时也需要执行时间。不过,通过消除分支和有关的控制相关性,即使有时会做一些无用功,也可以加快条件指令的执行速度。与此类似,对于采用向量遮罩执行的向量指令,即使遮罩为0的元素,仍然会占用相同的执行时间。与此类似,即使遮罩中有大量0,使用向量遮罩控制的速度也仍然远快于使用标量模式的速度。

在4.4节将会看到,向量处理器与GPU之间的一个区别就是它们处理条件语句的方式不同。向量处理器将遮罩寄存器作为体系结构状态的一部分,依靠编译器来显式操控遮罩寄存器。而GPU则是使用硬件来操控GPU软件无法看到的内部遮罩寄存器,以实现相同效果。在这两种情况下,无论遮罩是1还是0,硬件都需要花费时间来执行向量元素,所以GFLPS速率在使用遮罩时会下降。

内存组:为向量载入/存储单元提供带宽

载入存储向量单元的行为要比算术功能单元的行为复杂得多。载入操作的开始时间就是它从存储器向寄存器中载入第一个字的时间。如果在无停顿情况下提供向量的其他元素,那么向量初始化速率就等于提取或存储新字的速度。这一初始化速率不一定是一个时钟周期,因为存储器组的停顿可能会降低有效吞吐量,这一点不同于较简单的功能单元。

一般情况下,载入存储单元的起始代价要高于算术单元的这一代价——在许多处理器中要多于100个时钟周期。对于VMIPS,我们假定起始时间为12个时钟周期,与Cray-1相同。(最近的向量计算机使用缓存来降低向量载入与存储的延迟。)为了保持每个时钟周期提取或存储一个字的初始化速率,存储器系统必须能够生成或接受这么多的数据。将访问对象分散在多个独立的存储器组中,通常可以保证所需速率。稍后将会看到,拥有大量存储器组可以很高效地处理那些访问多行或多列数据的向量载入或存储指令。

大多数向量处理器都使用存储器组,允许进行多个独立访问,而不是进行简单的存储器交错,其原因有以下3个。

  • 许多向量计算机每个时钟周期可以进行多个载入或存储操作,存储器组的周期时间通常比处理器周期时间高几倍。为了支持多个载入或存储操作的同时访问请求,存储器系统需要有多个组,并能够独立控制对这些组的寻址。
  • 大多数向量处理器支持载入或存储非连续数据字的功能。在此类情况下,需要进行独立的组寻址,而不是交叉寻址。
  • 大多数向量计算机支持多个共享同-存储器系统的处理器,所以每个处理器会生成其自己的独立寻址流。

这些特征综合起来,就有了大量的独立存储器组,如下例所示。

Cray T90 (Cray T932)的最高配置有32个处理器,每个处理器每个时钟周期可以生成4个载入操作和2个存储操作。处理器时钟周期为2.167 ns,而存储器系统所用SRAM的周期时间为15 ns。计算:为使所有处理器都以完全存储器带宽运行,最少需要多少个存储器组。

每个时钟周期产生的最大存储器引用数目为192:每个处理器每个时钟周期产生6次引用共32个处理器。每个SRAM组的繁忙时钟周期数为15/2.167=6.92,四舍五入为7个处理器时钟周期。因此,至少需要192 x 7= 1344个存储器组!Cray T932实际上有1024个存储器组,所以早期型号不能让所有处理器都同时维持完全带宽。后来对存储器进行升级时,用流水化同步SRAM代替了15 ns的异步SRAM,存储器周期时间缩短一半,从而可以提供足够的带宽。

从更高一级的角度来看,向量载入存储单元与向量处理器中的预取单元扮演着类似的角色,它们都是通过向处理器提供数据流来尝试提供数据带宽。

步幅:处理向量体系结构中的多维数组

向量中的相邻元素在内存中的位置可能并不一定是连续的。考虑下面一段非常简单的矩阵乘法C语言代码:

1
2
3
4
5
6
for(i = 0; i < 100; i = i + 1) 
for(j= 0; j < 100; j = j + 1) {
A[i][j] = 0.0;
for(k = 0; k < 100; k = k + 1)
A[i][j] = A[i][j] + B[i][k] * D[k][j];
}

我们可以将B的每一行与 D的每一列的乘法进行向量化,以k为索引变量对内层循环进行条带挖掘。

为此,我们必须考虑如何对B中的相邻元素及D中的相邻元素进行寻址。在为数组分配内存时,该数组是线性化的,其排序方式要么以行为主(如C语言),要么以列为主(如Fortran语言)。这种线性化意味着:要么行中的元素在内存中不相邻,要么列中的元素在内存中不相邻。例如,上面的C代码是按照以行为主的排序来分配内存的,所以内层循环中各次迭代在访问D元素时,这些元素之间的间隔等于行大小乘以8 (每一项的字节数),共为800个字节。在第2章中,我们已经知道在基于缓存的系统中通过分层有可能提高局域性。对于没有缓存的向量处理器,需要使用另一种方法来提取向量在内存中不相邻的元素。

对于那些要收集到一个寄存器中的元素,它们之间的距离称为步幅。在这个例子中,矩阵D的步幅为100个双字(800个字节),矩阵B的步幅可能为1个双字(8个字节)。对于以列为主的排序(Fortran语言采用这一顺序),这两个步幅的大小会颠倒过来。矩阵D的步幅为1,也就是说连续元素之间相隔1个双字(8个字节),而矩阵B的步幅为100,也就是100个双字(800个字节)。因此,如果不对循环进行重新排序,编译器就不能隐藏矩阵B和D中连续元素之间的较长距离。

将向量载入向量寄存器后,它的表现行为就好像它的元素在逻辑上是相邻的。仅利用具有步幅功能的向量载入及向量存储操作,向量处理器可以处理大于1的步幅,这种步幅称为非单位步幅。向量处理器的主要优势之一就是能够访问非连续存储器位置,并对其进行调整,放到一个密集结构中。缓存在本质上是处理单位步幅数据的。增大块大小有助于降低大型科学数据集(其步幅为单位步幅)的缺失率,但增大块大小也可能会对那些以非单位步幅访问的数据产生负面影响。尽管分块技术可以解决其中一些问题,但高效访问非连续数据的功能仍然是向量处理器的一个优势。

在VMIPS结构中,可寻址单位为1个字节,所以我们示例的步幅将为800。由于矩阵的大小在编译时可能是未知的,或者就像向量长度一样,在每次执行相同语句时可能会发生变化,所以必须对步幅值进行动态计算。向量步幅可以和向量起始地址一样,放在通用寄存器中。然后,VMIPS指令LVWS (load vector with stride)将向量提取到向量寄存器中。同样,在存储非单位步幅向量时,使用指令SVwS (store vector with stride)。为了支持大于1的步幅,会使存储器系统变得复杂。在引入非单位步幅之后,就有可能频繁访问同一个组。当多个访问对一个存储器组产生竞争时,就会发生存储器组冲突,从而使某个访问陷入停顿。如果满足以下条件则会产生组冲突,从而产生停顿。

组数/步幅与组数的最小公倍数 < 组繁忙时间

假定有8个存储器组,组繁忙时间为6个时钟周期,总存储器延迟为12个时钟周期。要以步幅1完成一个64元素的向量载入操作,需要多长时间?步幅为32呢?

由于组数大于组繁忙时间,所以当步幅为1时,该载入操作将耗费12+64=76个时钟周期,也就是每个元素需要1.2个时钟周期。最糟糕的步幅是存储器组数目的倍数,在本例中就是步幅为32、存储器组为8的情况。(在第一次访问之后,)对存储器的每次访问都会与上一次访问发生冲突,必须等候长度为6个时钟周期的组繁忙时间。总时间为12+1+6x63=391个时钟周期,即每个元素6.1个时钟周期。

集中-分散:在向量体系结构中处理稀疏矩阵

前面曾经提到,稀疏矩阵是很常见的,所以非常需要一些技术,能够以向量模式运行那些处理稀疏矩阵的程序。在稀疏矩阵中,向量的元素通常以某种紧凑形式存储,然后对其进行间接访问。假定有一种简化的稀疏结构,我们可能会看到类似下面的代码:

1
2
for(i = 0; i < n; i = i + 1)
A[K[i]] = A[K[i]] + C[M[i]];

这一代码实现数组A与数组C的稀疏向量求和,用索引向量K和M来指出A与C中的非零元素。(A和C的非零元素数必须相等,共有n个,所以K和M的大小相同。)用于支持稀疏矩阵的主要机制是采用索引向量的集中-分散操作。这种运算的目的是支持在稀疏矩阵的压缩表示(即不包含零)和正常表示(即包含零)之间进行转换。集中操作是取得索引向量,并在此向量中提取元素,元素位置的确定是将基础地址加上索引向量中给定的偏移量。其结果是向量寄存器中的一个密集向量。在以密集形式对这些元素进行操作之后,再使用同一索引向量,通过分散存储操作,以扩展方式存储这一稀疏向量。对此类操作的硬件支持被称为集中-分散,几乎所有现代向量处理器都具备这-功能。VMIPS指令为LVI(载入索引向量,也就是集中)和SVI(存储索引向量,也就是分散)。例如,如果Ra、Rc、Rk和Rm中包含以上序列中向量的起始地址,就可以用向量指令来对内层循环进行编码,如下所示:
1
2
3
4
5
6
LV      Vk, Rk        ;载入K
LVI Va,(Ra+Vk) ;载入A[K[]]
LV Vm,Rm ;载入M
LVI VC, (Rc+Ym) ;载入C[M[]]
ADDVV.D Va, Va, Vc ;求和
SVI (Ra+Vk), Va ;存储A[K[]]

这一技术允许以向量模式运行带有稀疏矩阵的代码。简单的向量化编译器可能无法自动实现以上源代码的向量化,因为编译器可能不知道K的元素是离散值,因此也就不存在相关性。相反,应当由程序员发出的指令告诉编译器,可以放心地以向量模式来运行这一循环。尽管索引载入与存储(集中与分散)操作都可以流水化,但由于存储器组在开始执行指令时是未知的,所以它们的运行速度通常远低于非索引载入或存储操作。每个元素都有各自的地址,所以不能对它们进行分组处理,在存储器系统的许多位置都可能存在冲突。因此,每次访问都会招致严重的延迟。但是,如果架构师不是对此类访问采取放任态度,而是针对这一情景进行设计,使用更多的硬件资源,那存储器系统就能提供更好的性能。在4.4节将会看到,在GPU中,所有载入操作都是集中,所有存储都是分散。为了避免在常见的单位步幅情景中缓慢运行,应当由GPU程序员来确保一次集中或分散操作中的所有地址都处于相邻位置。此外,GPU硬件在执行时间必须能够识别这些地址序列,将集中与分散操作转换为更高效的存储器单位步幅访问。

向量体系结构编程

向量体系结构的优势在于编译器可以在编译时告诉程序员:某段代码是否可以向量化,通常还会给出一些暗示,说明这段代码为什么不能向量化。这种简单的执行模型可以让其他领域的专家了解如何通过修改自己的代码来提高性能。

让我们看一下在Perfect Club基准测试中观测到的向量化水平,用以指示科学程序中所能实现的向量化水平。表4-2显示了两种代码版本在Cray Y-MP上运行时,以向量模式运行的运算比例。第一个版本仅对原代码进行了编译器优化,而第二个版本则利用了Cray Research程序员团队给出的一些提示。对向量处理器上的应用程序性能进行多次研究后发现,编译器向量化水平的变化范围很大。

对于编译器自身不能很好地完成向量化的代码来说,根据大量提示进行修改后的版本会大幅提高向量化水平,现在有超过50%的代码可以进行向量化了。平均向量化水平从大约70%提高至大约90%。

SIMD 指令集多媒体扩展

SIMD多媒体扩展源于一个很容易观察到的事实:许多媒体应用程序操作的数据类型要比对32位处理器进行针对性优化的数据类型更窄一些。假定有一个256位加法器,通过划分这个加法器中的进位链,处理器可以同时对一些短向量进行操作,这些向量可以是32个8位操作数、16个16位操作数、8个32位操作数或者4个64位操作数。这些经过划分的加法器的额外成本很小。表4-3总结了典型的多媒体SIMD指令。和向量指令一样,SIMD指令规定了对数据向量的相同操作。一些向量机器拥有大型寄存器堆,比如VMIPS向量寄存器,8个向量寄存器中的每一个都可以保存64个64位元素,SIMD指令与之不同,它指定的操作数较少,因此使用的寄存器堆也较小。

向量体系结构专门针对向量化编译器提供了一流的指令集,与之相对,SIMD扩展主要进行了以下3项简化。

  • 多媒体SIMD扩展固定了操作代码中数据操作数的数目,从而在x86体系结构的MMX、SSE和AVX扩展中添加了数百条指令。向量体系结构有一个向量长度寄存器,用于指定当前操作的操作数个数。一些程序的向量长度小于体系结构的最大支持长度,由于这些向量寄存器的长度可以变化,所以也能够很轻松地适应此类程序。此外,向量体系结构有一个隐含的最大向量长度,它与向量长度寄存器相结合,可以避免使用大量操作码。
  • 多媒体SIMD没有提供向量体系结构的更复杂寻址模式,也就是步幅访问和集中分散访问。这些功能增加了向量编译器成功向量化的程序数目。
  • 多媒体SIMD通常不会像向量体系结构那样,为了支持元素的条件执行而提供遮罩寄存器。这些省略增大了编译器生成SIMD代码的难度,也加大了SIMD汇编语言编程的难度。

对于x86体系结构,1996年增加的MMX指令重新确定了64位浮点寄存器的用途,所以基本指令可以同时执行8个8位运算或4个16位运算。这些指令与其他各种指令结合在一起,包括并行MAX和MIN运算、各种遮罩和条件指令、通常在数字信号处理器中进行的运算以及人们相信在重要媒体库中有用的专用指令。注意,MMX重复使用浮点数据传送指令来访问存储器。

1999年推出的后续流式SIMD扩展(SSE)添加了原来宽128位的独立寄存器,所以现在的指令可以同时执行16个8位运算、8个16位运算或4个32位运算。它还执行并行单精度浮点运算。由于SSE拥有独立寄存器,所以它需要独立的数据传送指令。Intel 很快在2001年的SSE2、2004 年的SSE3和2007年的SSE4中添加了双精度SIMD浮点数据类型。拥有四个单精度浮点运算或两个并行双精度运算的指令提高了x86计算机的峰值浮点性能,只要程序员将操作数并排放在一起即可。在每一代计算机中都添加了一些专用指令,用于加快一些重要的特定多媒体功能的速度。

2010年增加的高级向量扩展(AVX)再次将寄存器的宽度加倍,变为256位,并提供了一些指令,将针对所有较窄数据类型的运算数目翻了一番。表4-4给出了可用于进行双精度浮点计算的AVX指令。AVX进行了一些准备工作,以便在将来的体系结构中将宽度扩展到512位和1024位。


256位AVX的紧缩双精度是指以SIMD模式执行的4个64位操作数。当AVX指令的宽度增大时,数据置换指令的添加也变得更为重要,以允许将来自宽寄存器中不同部分的窄操作数结合起来。AVX 中的一些指令可以在256位寄存器中分散32位、64位或128位操作数。比如,BROADCAST在AVX寄存器中将一个64位操作数复制4次。AVC还包含大量结合在一起的乘加/乘减指令,这里仅给出了其中的两个。

一般来说,这些扩展的目的是加快那些精心编制的库函数运行速度,而不是由编译器来生成这些库,但近来的x86编译器正在尝试生成此类代码,尤其是针对浮点计算密集的应用程序。

既然有这些弱点,那多媒体SIMD扩展为什么还如此流行呢?第一,它们不需要花费什么成本就能添加标准算术单元,而且易于实施。第二,与向量体系结构相比,它们不需要什么额外状态,上下文切换次数总是要考虑这一因素。第三,需要大量存储器带宽来支持向量体系结构,而这是许多计算机所不具备的。第四,当一条能够生成64个存储器访问的指令在向量中间发生页面错误时,SIMD不必处理虚拟内存中的问题。SIMD扩展对于操作数的每个SIMD组使用独立的数据传送(这些操作数在存储器中是对齐的),所以它们不能跨越页面边界。固定长度的简短SIMD“向量”还有另一个好处:能够很轻松地引入一些符合新媒体标准的指令,比如执行置换操作的指令或者所用操作数少于或多于所生成向量的指令。最后,人们还关注向量体系结构在使用缓存方面的表现。最近的向量体系结构已经解决了所有这些问题,但由于过去些缺陷的影响,架构师还是对向量抱有怀疑态度。

为了了解多媒体指令是什么样子的,假定我们向MIPS中添加了256位SIMD多媒体指令。在这个例子中主要讨论浮点指令。对于一次能够对4个双精度运箅数执行操作的指令添加后缀“4D”。 和向量体系结构一样,可以把SIMD处理器看作是拥有车道的处理器,在本例中为4个车道。MIPS SIMD会重复利用浮点寄存器,作为4D指令的操作数,就像原始MIP中的双精度运算重复利用单精度寄存器一样。这一示例显示了DAXPY循环的MIPS SIMD代码。假定X和Y的起始地址分别为Rx和Ry。用下划线划出为添加SIMD而对MIPS代码进行的修改。

下面是MIPS代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        L.D        F0,a       ;载入标量a
MOV F1, FQ ;将a复制到F1,以完成SIMD MUL
MOV F2, FQ ;将a复制到F2,以完成SIMD MUL
MOV F3, F0 ;将a复制到F3,以完成SIMD MUL
DADDIU R4,Rx,#512 ;要载入的最后一个地址
Loop: L.4D F4,0(Rx) ;载入X[i], X[i+1], X[i+2],X[i+3]
MUL.4D F4.F4,F0 ;a.X[i], a*X[i+1],a*X[i+2], a*X[i+3]
L.4D F8,0(Ry) ;载入Y[i], Y[i+1], Y[i+2],Y[i+3]
ADD.4D F8,F8,F4 ;a*X[i]+Y[i], ..., a*X[i+3]+Y[i+3]
S.4D F8,0(Rx) ;存储到Y[i], Y[i+1], Y[i+2], Y[i+3]
DADDIU Rx,Rx,#32 ;将索引递增至X
DADDIU Ry,Ry,#32 ;将索引递增至Y
DSUBU R20,R4,RX ;计算范围
BNEZ R20,Loop ;检查是否完成

这些修改包括将所有MPS双精度指令用对应的4D等价指令代替,将递增步长由8变为32,将寄存器由F2和F4改为F4和F8,以在寄存器堆中为4个连续双精度操作数获取足够的空间。所以,对于标量a,每个SIMD车道都将拥有自己的一个副本,我们将F0的值复制到寄存器F1、F2和F3。(真正的SIMD指令扩展有一条指令,可以向组中的所有其他寄存器广播一个值。)因此,这一乘法将完成F4*F0F5*F1F6*F2F7*F3。尽管SIMD MIPS没有像VMIPS那样,将动态指令宽带降低100倍,但也降低了4倍,共有149条,而MIPS则为578条指令。

多媒体SIMD体系结构编程

由于SIMD多媒体扩展的特有本质,使用这些指令的最简便方法就是通过库或用汇编语言编写。

最近的扩展变得更加规整,为编译器提供了更为合理的目标。通过借用向量化编译器的技术,这些编译器也开始自动生成SIMD指令。例如,目前的高级编译器可以生成SIMD浮点指令,大幅提高科学代码的性能。但是,程序员必须确保存储器中的所有数据都与运行代码的SIMD单元的宽度对齐,以防止编译器为本来可以向量化的代码生成标量指令。

Roofline 可视性能模型

有一种直观的可视方法可以对比各种SIMD体系结构变体的潜在浮点性能,那就是Roofline模型。它将浮点性能、存储器性能和运算密度汇总在一个两维图形中。运算密度等于浮点运算数与所访问存储器字节的比值。其计算方法为:获取一个程序的总浮点运算数,然后再除以在程序执行期间向主存储器传送的总数据字节。图4-6给出了几种示例内核的相对运算密度。

图4-6 运算密度,定义为:运行程序时所执行的浮点运算数除以在主存储器中访问的字节数。一些内核的运算密度会随问题的规模(比如密集矩阵)而缩放,但有许多核心的运算密度与问题规模无关

峰值浮点性能可以使用硬件规范求得。这一实例研究中的许多核心都不能放到片上缓存中,所以峰值性能是由缓存背后的存储器系统确定的。注意,我们需要的是可供处理器使用的蜂值存储器带宽。要求出峰值存储器性能,其中一种方法是运行Stream基准测试。

图4-7在左侧给出NEC SX-9向量处理器的Roofline模型,在右侧给出Intel Core i7 920多核计算机的相应模型。垂直的Y轴是可以实现的浮点性能,为2 ~ 256 GFLOP/s。水平的X轴是运算密度,在两个图中都是从1/8 FLOP/DARM访问字节到16 FLOP/DARM访向字节。注意,该图为对数-对数图尺,Roofline 对于一种计算机仅完成一次。对于一个给定内核,我们可以根据它的运算密度在X轴上找到一个点。如果过该点画一条垂线,此内核在该计算机上的性能必须位于该垂线上的某一位置。 我们可以绘制一个水平线,显示该计算机的浮点性能。显然,由于硬件限制,实际浮点性能不可能高于该水平线。

如何绘制峰值存储器性能呢?由于X轴为FLOP/字节,Y轴为FLOP/s,所以字节/s就是图中45度角的对角线。因此,我们可以画出第三条线,显示该计算机的存储器系统对于给定运算密度所能支持的最大浮点性能。我们可以用公式来表示这些限制,以绘制图4-7中的相应曲线:

可获得的GFLOP/s=Min(峰值存储器带宽x运算密度,峰值浮点性能)


图4-7 左为NEC SX-9向量处理器上的Rofine模型,右为采用SIMD扩展的Intel Core 1i7 920多核计算机的相应模型。 这个Roofie模型针对的单位步幅的存储器访问和双精度浮点性能。NEC SC-9是在2008 年发布的超级向量计算机,耗费了数百万美元。根据Stream基准测试,它的峰值DP FP性能为102.4 GFLOP/s,峰值存储器宽度为162GB/s。Core i7 920的峰值DP FP性能为42.66 GFLOP/s和峰值存储器带宽为16.4 GB/s。在运算密度为4FLOP/字节处的垂直虚线显示两个处理器都以峰值性能运行。在这个示例中,102.4 GFL0P/s处的Sx-9要比42.66 GFLOP/s处的Core i7快2.4倍。在运算密度为0.25 FLOP/字节处,SX-9 为40.5 GFLOP/s,比Corei7的4.1GFLOP/s快10倍

水平线和对角线给出了这个简单模型的名字,并指出了它的取值。Roofline 根据内核的运算密度设定了其内核的性能上限。如果我们把运算密度看作是触及房顶的柱子,它既可能触及房顶的平坦部分(表示这一性能是受计算功能限制的),也可能触及房顶的倾斜部分(表示这一性能最终受存储器带宽的限制)。在图4-7中,右侧的垂直虚线(运算密度为4)是前者的示例,左侧的垂直虚线(运算密度为1/4)是后者的示例。给定一台计算机的Rooline模型,就可以重复应用它,因为它是不会随内核变化的。

注意对角线与水平线交汇的“屋脊点”,通过它可以深入了解这台计算机的性能。如果它非常靠右,那么只有运算密度非常高的内核才能实现这台计算机的最大性能。如果它非常靠左,那么几乎所有内核都可能达到最高性能。后面将会看到,与其他SIMD处理器相比,这个向量处理器的存储器带宽要高得多,屋脊点非常靠左。

图4-7显示sx-9的峰值计算性能比Core i7快2.4倍,但存储器性能要快10倍。对于运算密度为0.25的程序,sx-9 快10倍(40.5 GFLOP/s比4.1 GFLOP/s)。更宽的存储器带宽将屋脊点从Corei7的2.6移动到sx-9的0.6,这就意味着有更多的程序可以在这个向量处理器上达到峰值计算性能。

图形处理器

GPU和CPU在计算机体系结构谱系中不会上溯到同一个祖先,GPU的祖先是图形加速器,而极强的图形处理能力正是GPU得以存在的原因。尽管GPU正在转向主流计算领域,但它们不能放弃继续在图形处理领域保持优异表现的责任。因此,对于能够出色处理图形的硬件,当架构师询问应当如何进行补充才能提高更广泛应用程序的性能时,GPU的设计就可能体现出更重要
的价值。

GPU编程

关于如何表示算法中的并行,CUDA在某种方式上与我们的思考与编码方式相吻合,可以更轻松、更自然地表达超越任务级别的并行。

CPU程序员的挑战不只是在GPU上获得出色的性能,还要协调系统处理器与GPU上的计算调度、系统存储器与GPU存储器之间的数据传输。GPU几乎拥有所有可以由编程环境捕获的并行类型:多线程、MIMD、SIMD,甚至还有指令级并行。

NVIDIA决定开发一种与C类似的语言和编程环境,通过克服异质计算及多种并行带来的双重挑战来提高GPU程序员的生产效率。这一系统的名称为CUDA,表示“计算统一设备体系结构”(Compute Unifed Device Architecture)。CUDA为系统处理器(主机)生成C/C++,为GPU(设备,也就是CUDA中的D)生成C和C++方言。一种类似的编程语言是OpenGL。

NVIDIA认为,所有这些并行形式的统一主题就是CUDA线程。以这种最低级别的并行作为编程原型,编译器和硬件可以将数以千记的CUDA线程聚合在一起,利用CPU中的各种并行类型:多线程、MIMD、SIMD和指令级并行。因此,NVIDIA将CUDA编程模型定义为“单指令多线程”(SIMT)。这些线程进行了分块,在执行时以32个线程为一组,称为线程块。我们将执行整个线程块的硬件称为多线程SIMD处理器。

我们只需要几个细节就能给出CUDA程序的示例。

  • 为了区分GPU(设备)的功能与系统处理器(主机)的功能,CUDA使用__device____global__表示前者,使用___host__表示后者。
  • 被声明为__device____global__functions的CUDA变量被分配给GPU存储器(见下文),可以供所有多线程SIMD处理器访问。
  • 对于在GPU上运行的函数name进行扩展函数调用的语法为:name<<<dimGrid, dimBlock>>>(... parameter list ...),其中dimGrid和dimB1ock规定了代码的大小(用块表示)和块的大小(用线程表示)。
  • 除了块识别符(blockIdx)和每个块的线程识别符(threadIdx) 之外,CUDA还为每个块的线程数提供了一个关键字(blockDim),它来自上一个细节中提到的dimBlock参数。

在查看CUDA代码之前,首先来看看4.2节DAXPY循环的传统C代码:

1
2
3
4
5
6
7
//调用DAXPY
daxpy(n, 2.0, x, y);
// C语言编写的DAXPY
void daxpy(int n, double a, double *x, double *y) {
for(int i = 0; i < n; ++ i)
y[i] = a*x[i] + y[i];
}

下面是CUDA版本。我们在一个多线程SIMD处理器中启动n个线程,每个向量元素一个线程,每个线程块256个CUDA线程。GPU功能首先根据块ID、每个块的线程数以及线程ID来计算相应的元素索引i。只要这个索引没有超出数组的范围(i<n),它就会执行乘法和加法。
1
2
3
4
5
6
7
8
9
10
11
//调用DAXPY,每个线程块中有256个线程
__host__
int nblocks = (n + 255) / 256;
daxpy<<<nblocks, 256>>>(n, 2.0, x, y);
// CUDA中的DAXPY
__device__
void daxpy(int n, double a, double *X, double *y) {
int i = blockIdx.x * blockDim.x + threadIdx.X;
if(i < n)
y[i] = a*x[i] + y[i];
}

对比c代码和CUDA代码,我们可以看出一种用于实现数据并行CUDA代码并行化的共同模式。C版本中有一个循环的所有迭代都与其他迭代相独立,可以很轻松地将这个循环转换为并行代码,其中每个循环迭代都变为一个独立线程。(前面曾经提到,向量化编译器也要求循环的迭代之间没有相关性,这种相关被称为循环间相关。)程序员通过明确指定网格大小及每个SIMD处理器中的线程数,明确指出CUDA中的并行。由于为每个元素都分配了一个线程,所以在向存储器中写入结果时不需要在线程之间实行同步。

行执行和线程管理由GPU硬件负责,而不是由应用程序或操作系统完成。为了简化硬件处理的排程,CUDA要求线程块能够按任意顺序独立执行。尽管不同的线程块可以使用全局存储器中的原子存储器操作进行协调,但它们之间不能直接通信。

马上可以看到,许多GPU硬件概念在CUDA中不是非常明显。从程序员生产效率的角度来看,这是一件好事,但大多数程序员使用GPU而不是CPU来提高性能。重视性能的程序员在用CUDA编写程序时必须时刻惦记着GPU硬件。他们知道需要将控制流中的32个线程分为一组,以从多线程SIMD处理器中获得最佳性能,并在每个多线程SIMD处理器中另外创建许多线程,以隐藏访向DRAM的延迟。它们还需要将数据地址保持在一个或一些存储器块的局部范围内,以获得所期望的存储器性能。

和许多并行系统一样,CUDA在生产效率和性能之间进行了一点折中:提供一些本身固有的功能,让程序员能够显式控制硬件。一方面是生产效率,另-方面是使程序员能够表达硬件所能完成的所有操作,在并行计算中,这两个方面之间经常会发生竞争。了解编程语言在这一著名的生产效率与性能大战中如何发展,了解CUDA是否能够在其他GPU或者其他类型的体系结构中变得普及,都将是非常有意义的一件事。

NVIDIA GPU计算结构

上文提到的这些罕见传统可以帮助解释为什么GPU拥有自己的体系结构类型,为什么拥有与CPU独立的专门术语。理解GPU的一个障碍就是术语,有些词汇的名称甚至可能导致误解。表4-5从左至右列出了本节使用的一些更具描述性的术语、主流计算中的最接近术语、我们关心的官方NVIDIA GPU术语,以及这些术语的简短描述。本节的后续部分将使用该表左侧的描述性术语来解释GPU的微体系结构特征。

这11种术语分为4个组。从上至下为:程序抽象、机器对象、处理硬件和存储器硬件。表4-8将向量术语与这里的最接近术语关联在一起,表4-10和表4-11揭示了官方CUDA NVIDIA和AMD术语与定义,以及OpenCL使用的术语。

我们将以NVIDIA系统为例,它们是GPU体系结构的代表。具体来说,我们将使用上面CUDA并行编程语言的术语,以Fermi体系结构为例。

和向量体系结构一样,GPU只能很好地解决数据级并行问题。这两种类型都拥有集中分散数据传送和遮罩寄存器,GPU 处理器的寄存器要比向量处理器更多。由于它们没有一种接近的标量处理器,所以GPU有时会在运行时以硬件实现一些功能,而向量计算机通常是在编译时用软件来实现这些功能的。与大多数向量体系结构不同的是,GPU 还依靠单个多线程SIMD处理
器中的多线程来隐藏存储器延迟。但是,要想为向量体系结构和GPU编写出高效代码,程序员还需要考虑SIMD操作分组。

网格是在GPU上运行、由一组线程块构成的代码。表4-5给出了网格与向量化循环、线程块与循环体(已经进行了条带挖掘,所以它是完整的计算循环)之间的相似之处。作为一个具体例子,假定我们希望把两个向量乘在一起,每个向量的长度为8192个元素。本节中,我们将反复使用这一示例。图4-8给出了这个示例与前两个GPU术语之间的关系。执行所有8192个
元素乘法的GPU代码被称为网格(或向量化循环)。为了将它分解为更便于管理的大小,网格可以由线程块(或向量化循环体)组成,每个线程块最多512个元素。注意,一条SIMD指令一次执行32个元素。由于向量中有8192个元素,所以这个示例中有16个线程块(16=8192/512)。网络和线程块是在GPU硬件中实现的编程抽象,可以帮助程序员组织自己的CUDA代
码。(线程块类似于一个向量长度为32的条带挖掘向量循环。)

线程块调度程序将线程块指定给执行该代码的处理器,我们将这种处理器称为多线程SIMD处理器。线程块调度程序与向量体系结构中的控制处理器有某些相似。它决定了该循环所需要的线程块数,在完成循环之前,一直将它们分配给不同的多线程SIMD处理器。在这个示例中,会将16个线程块发送给多线程SIMD处理器,计算这个循环的所有8192个元素。

图4-8 网格(可向量化循环)、线程块(SIMD 基本块)和SIMD指令线程与向量-向量乘法的对应,每个向量的长度为8192个元素。每个SIMD指令线程的每条指令计算32个元素,在这个示例中,每个线程块包含16个SIMD指令线程,网格包含16个线程块。硬件线程块调度程序将线程块指定给多线程SIMD处理器,硬件线程调度程序选择某个SIMD指令线程来运行一个SIMD处理器中的每个时钟周期。只有同一线程块中的SIMD线程可以通过本地存储器进行通信。

图4-9显示了多线程SIMD处理器的简化框图。它与向量处理器类似,但它有许多并行功能单元都是深度流水化的,而不是像向量处理器一样只有一小部分如此。在图4-8中的编程示例中,向每个多线程SIMD处理器分配这些向量的512个元素以进行处理。SIMD处理器都是具有独立PC的完整处理器,使用线程进行编程。

图4-9 多线程SIMD处理器的简化框图。它有16个SIMD车道。SIMD线程调度程序拥有大约48个独立的SIMD指令线程,它用一个包括48个PC的表进行调度

GPU硬件包含一组用来执行线程块网络(向量化循环体)的多线程SIMD处理器,也就是说,GPU是一个由多线程SIMD处理器组成的多处理器。

Fermi体系结构的前四种实现拥有7、11、14 或15个多线程SIMD处理器;未来的版本可能仅有2个或4个。为了在拥有不同个多线程SIMD处理器的GPU型号之间实现透明的可伸缩功能,线程块调度程序将线程块(向量化循环主体)指定给多线程SIMD处理器。图4-10给出了Fermi 体系结构的GTX 480实现的平面图。

图4-10 Fermi GTX 480 GPU的平面图。本图显示了16个多线程SIMD处理器。在左侧突出显示了线程块调度程序。GTX480有6个GDDR5端口,每个端口的宽度为64位,支持最多6GB的容量。主机接口为PCI Express 2.0 x 16。Giga线程是将线程块分发给多处理器的调度程序名称,其中每个处理器都有其自己的SIMD线程调度程序

具体地说,硬件创建、管理、调度和执行的机器对象是SIMD指令线程。它是一个包含专用SIMD指令的传统线程。这些SIMD指令线程有其自己的PC,它们运行在多线程SIMD处理器上。SIMD线程调度程序包括一个记分板,让你知道哪些SIMD指令线程已经做好运行准备,然后将它们发送给分发单元,以在多线程SIMD处理器上运行。它与传统多线程处理器中的硬件线程调度程序相同,就是对SIMD指令线程进行调度。因此GPU硬件有两级硬件调度程序:

  1. 线程块调度程序,将线程块(向量化循环体)分配给多线程SIMD处理器,确保线程块被分配给其局部存储器拥有相应数据的处理器,
  2. SIMD 处理器内部的SIMD线程调度程序,由它来调度应当何时运行SIMD指令线程。

这些线程的SIMD指令的宽度为32,所以这个示例中每个SIMD指令线程将执行32个元素运算。在本示例中,线程块将包含512 * 32=16 SIMD线程。

由于线程由SIMD指令组成,所以SIMD处理器必须拥有并行功能单元来执行运算。我们称之为SIMD车道,它们与4.2节的向量车道非常类似。

每个SIMD处理器中的车道数在各代GPU中是不同的。对于Fermi,每个宽度为32的SIMD指令线程被映射到16个物理SIMD车道,所以一个SIMD指令线程中的每条SIMD指令需要两个时钟周期才能完成。每个SIMD指令线程在锁定步骤执行,仅在开始时进行调度。将SIMD处理器类比为向量处理器,可以说它有16个车道,向量长度为32,钟鸣为2个时钟周期。

根据定义,由于SIMD指令的线程是独立的,SIMD线程调度程序可以选择任何已经准备就绪的SIMD指令线程,而不需要一直盯着线程序列中的下一条SIMD指令。SIMD线程调度程序包括一个记分板,用于跟踪多达48个SIMD线程,以了解哪个SIMD指令已经做好运行准备。之所以需要这个记分板,是因为存储器访问指令占用的时钟周期数可能无法预测,比如存储器组的冲突就可能导致这一现象。 图4-11给出的SIMD线程调度程序在不同时间以不同顺序选取SIMD指令线程。GPU架构师假定GPU应用程序拥有如此之多的SIMD指令线程,因此,实施多线程既可以隐藏到DRAM的延迟,又可以提高多线程SIMD处理器的使用率。

但是,为了防止损失,最近的NVIDIA Fermi GPU包含了一个L2缓存。


图4-11 SIMD 指令线程的调度。调度程序选择一个准备就绪的SIMD指令线程,并同时向所有执行该SIMD线程的SIMD车道发出一条指令。由于SIMD指令线程是独立的,所以调度程序可以每次选择不同的SIMD线程

继续探讨向量乘法示例,每个多线程SIMD处理器必须将两个向量的32个元素从存储器载入寄存器中,通过读、写寄存器来执行乘法,然后将乘积从寄存器存回存储器中。为了保存这些存储器元素,SIMD处理器拥有32 768个32位寄存器,给人以深刻印象。就像向量处理器样,从逻辑上在向量车道之间划分这些寄存器,这里自然是在SIMD车道之间划分。每个SIMD线程被限制为不超过64个寄存器,所以我们可以认为一个SIMD线程最多拥有64个向量寄存器,每个向量寄存器有32个元素,每个元素的宽度为32位。(由于双精度浮点操作数使用两个相邻的32位寄存器,所以另一种意见是每个SIMD线程拥有32个各包括32个元素的向量寄存器,每个宽度为64位。)

由于Fermi拥有16个物理SIMD车道,各包含2048个寄存器。(GPU没有尝试根据位来设计硬件寄存器,使其拥有许多读取端口和写入端口,而是像向量处理器一样,使用较简单的存储器结构,但将它们划分为组,以获得足够的带宽。)每个CUDA线程获取每个向量寄存器中的一个元素。为了用16个SIMD车道处理每个SIMD指令线程的32个元素,线程块的CUDA线程可以共同使用2048个寄存器的一半。

为了能够执行许多个SIMD指令线程,需要在创建SIMD指令线程时在每个SIMD处理器上动态分配一组物理寄存器,并在退出SIMD线程时加以释放。

注意,CUDA线程就是SIMD指令线程的垂直抽取,与-个SIMD车道上执行的元素相对应。要当心,CUDA线程与POSIX线程完全不同;不能从CUDA线程进行任意系统调用。现在可以去看看GPU指令是什么样的了。

NVIDA GPU指令集体系结构

与大多数系统处理器不同,NVIDIA 编译器的指令集目标是硬件指令集的一种抽象。 PTX(并行线程执行)为编译器提供了一种稳定的指令集,可以实现各代GPU之间的兼容性。它向程序员隐藏了硬件指令集。PTX指令描述了对单个CUDA线程的操作,通常与硬件指令一对一映射,但一个PTX可以扩展到许多机器指令,反之亦然。PTX使用虚拟寄存器,所以编译器指出一个SIMD线程需要多少物理向量寄存器,然后,由优化程序在SIMD线程之间划分可用的寄存器存储。这一优化程序还会清除死亡代码,将指令打包在一起,并计算分支发生发散的位置和发散路径可能会聚的位置。

尽管x86微体系结构与PTX之间有某种类似,这两者都会转换为一种内部形式(x86的微指令),区别在于:对于x86,这一转换是在执行过程中在运行时以硬件实现的,而对于GPU,则是在载入时以软件实现的。

PTX指令的格式为:opcode.type d, a, b, c;,其中d是目标操作数,a、b和c是源操作数;操作类型如表4-6所示。


源操作数为32位或64位整数或常值。目标操作数为寄存器,存储指令除外。

表4-7显示了基本PTX指令集。所有指令都可以由1位谓词寄存器进行判定,这些寄存器可以由设置谓词指令(setp)来设定。控制流指令为函数ca1l和return,线程exit、branch 以及线程块内线程的屏障同步(bar.sync)。在分支指令之前放置谓词就可以提供条件分支。编译器或PTX程序员将虚拟寄存器声明为32位或64位有类型或无类型值。例如,R0,R1,…用于32位值,RD0,R1…用于64位寄存器。回想一下,将虚拟寄存器指定给物理寄存器的过程是在载入时由PTX进行的。

表4-7基本PTX GPU线程指令

下面的PTX指令序列是4.4.1节DAXPY循环一次迭代的指令:

1
2
3
4
5
6
7
8
shl.u32 R8, b1ockIdx, 9   ; 线程块ID *块大小(512 或29)
add,u32 R8, R8, threadIdx ; R8 = i =我的CUDA线程ID
sh1.u32 R8, R8, 3 ; 字节偏移
ld.global.f64 RD0, [X+R8] ; RD0 = X[i]
ld.global.f64 RD2, [Y+R8] ; RD2 = Y[i]
mul.f64 RD0, RD0, RD4 ; 在RD0中求乘积RD0 = RD0 * RD4 (标量a)
add.f64 RD0, RD0, RD2 ; 在RD0中求和RD0 = RD0 + RD2 (Y[i])
st.global.f64 [Y+R8], RD0 ; Y[i] = sum (X[i]*a + Y[i])

如上所述,CUDA编程模型为每个循环迭代指定一个CUDA,为每个线程块指定一个唯一的识别编号(blockIdx),也为块中的每个CUDA线程指定一个唯一识别编号(threadIdx)。因此,它创建8192个CUDA线程,并使用唯一编号完成数组中每个元素的寻址,因此,不存在递增和分支编码。前3条PTX指令在R8中计算出唯一的元素字节偏移,会将这一偏移量加到数组的基地址中。以下PTX指令载入两个双精度浮点操作数,对其进行相乘和相加,并存储求和结果。(下面将描述与CUDA代码if (i < n)相对应的PTX代码。)

注意,GPU与向量体系结构不同,它们没有分别用于顺序数据传送、步幅数据传送和集中-分散数据传送的指令。所有数据传送都是集中-分散的!为了重新获得顺序(单位步幅)数据传送的效率,GPU包含了特殊的“地址接合”硬件,用于判断SIMD指令线程中的SIMD车道什么时候一同发出顺序地址。运行时硬件随后通知存储器接口单元来请求发送32个顺序字的分块
传送。为了实现这一重要的性能改进,GPU程序员必须确保相邻的CUDA线程同时访问可以接合为一个或一些存储器或缓存块的相邻地址,我们的示例就是这样做的。

GPU中的条件分支

和单位步幅数据传送的情况一样,向量体系结构和GPU在处理IF语句方面非常相似,前者主要以软件实现这一机制,硬件支持非常有限,而后者则利用了更多的硬件。后面将会看到,除了显式谓词寄存器之外,GPU分支硬件使用了内部遮罩、分支同步栈和指令标记来控制分支何时分为多个执行路径,这些路径何时会汇合。

在PTX汇编程序级别,一个CUDA线程的控制流是由PTX指令分支、调用、返回和退出描述的,另外还要加上每条指令的各个按线程车道给出的谓词来描述,这些谓词由程序员用每个线程车道的1位谓词寄存器指定。PTX汇编程序分析了PTX分支图,对其进行优化,实现最快速的GPU硬件指令序列。

在GPU硬件指令级别,控制流包括分支、跳转、索引跳转、调用、索引调用、返回、退出和管理分支同步栈的特殊指令。GPU硬件提供了每个拥有自己栈的SIMD线程;一个堆栈项包含一个标识符标记、一个目标指令地址和一个目标线程活动遮罩。有一些GPU特殊指令为SIMD项目压入栈项,还有一些特殊指令和指令标记用于弹出栈项或者将栈展开为特殊项,并跳转到具有目标线程活动遮罩的目标指令地址。GPU硬件指令还拥有一些为不同车道设置的不同谓词(启用/禁用),这些谓词是利用每个车道的1位谓词寄存器指定的。

PTX汇编程序通常会将用PTX分支指令编码的简单外层IF/THEN/ELSE语句优化为设有谓词的GPU指令,不采用任何GPU分支指令。更复杂控制流的优化通常会混合采用谓词与GPU分支指令,这些分支指令带有一些特殊指令和标记,当某些车道跳转到目标地址时,这些GPU分支指令会使用分支同步栈压入一个栈项,而其他各项将会失败。在这种情况下,NVIDIA 称
为发生了分支分岔。当SIMD车道执行同步标记或汇合时,也会使用这种混合方式,它会弹出一个栈项,并跳转到具有栈项线程活动遮罩的栈项地址。

PTX汇编程序识别出循环分支,并生成GPU分支指令,跳转到循环的顶部,用特殊栈指令来处理各个跳出循环的车道,并在所有车道完成循环之后,使这些SIMD车道汇合。GPU索引跳转和索引调用指令向栈中压入项目,以便在所有车道完成开关语句或函数调用时,SIMD线程汇合。

GPU设定谓词指令(表4-7中的setp)对IF语句的条件部分求值。PTX分支指令随后将根据该谓词来执行。如果PTX汇编程序生成了没有GPU分支指令的有谓词指令,它会使用各个车道的谓词寄存器来启用或禁用每条指令的每个SIMD车道。IF语句THEN部分线程中的SIMD指令向所有SIMD车道广播操作。谓词被设置为1的车道将执行操作并存储结果,其他SIMD车道不会执行操作和存储结果。对于ELSE语句,指令使用谓词的补数(与THEN语句相对),所以原来空闲的SIMD车道现在执行操作,并存储结果,而它们前面的对应车道则不会执行相关操作。在ELSE语句的结尾,会取消这些指令的谓词,以便原始计算能够继续进行。因此,对于相同长度的路径,IF-THEN-ELSE 的工作效率为50%。

IF语句可以嵌套,因而栈的使用也可以嵌套,所以PTX汇编程序通常会混合使用设有谓词的指令和GPU分支与特殊分支指令,用于复杂控制流。注意,尝试嵌套可能意味着大多数SIMD车道在执行嵌套条件语句期间是空闲的。因此,等长路径的双重嵌套IF语句的执行效率为25%,三重嵌套为12.5%,以此类推。与此类似的情景是仅有少数几个遮罩位为1时向量处理器的运行情况。

具体来说,PTX汇编程序在每个SIMD线程中的适当条件分支指令上设置“分支同步”标记,这个标记会在栈中压入当前活动遮罩。如果条件分支分岔(有些车道进行跳转,有些失败),它会压入栈项,并根据条件设置当前内容活动遮罩。分支同步标记弹出分岔的分支项,并在ELSE部分之前翻转遮罩位。在IF语句的末尾,PTX汇编程序添加了另一个分支同步标记,它会将先前的活动遮罩从栈中弹出,放入当前的活动遮罩中。

如果所有遮罩位都被设置为1,那么THEN结束的分支指令将略过ELSE部分的指令。当所有遮罩位都为零时,对于THEN部分也有类似优化,条件分支将跳过THEN指令。并行的IF语句和PTX分支经常使用没有异议的分支条件(所有车道都同意遵循同一路径),所以SIMD指令不会分岔到各个不同的车道控制流。PTX汇编程序对此类分支进行了优化,跳过SIMD线程中所有车道都不会执行的指令块。这种优化在错误条件检查时是有用的,在这种情况下,必须进行测试,但很少会被选中。

以下是一个类似于4.2节的条件语句,其代码为:

1
2
3
4
if (X[i] != 0)
X[i] = X[i] - Y[i];
else
X[i] = Z[i];

这个IF语句可以编译为以下PTX指令(假定R8已经拥有经过调整的线程ID),*Push*Comp*Pop表示由PTX汇编程序插入的分支同步标记,用于压入旧遮罩、对当前遮罩求补,弹出恢复旧遮罩:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ld.global .f64 RD0, [X+R8]    ; RD0 = X[i]
setp.neq.s32 P1, R00, #0 ; Pl是谓词寄存器l
@lP1, bra ELSE1, *Push ; 压入旧遮罩,设定新遮罩位
; if P1为假,则转至ELSE1
ld.g1oba1. f64 RD2, [Y+R8] ; RD2 =Y[i]
sub.f64 RD0, RD0, RD2 ; RD0中的差
st.global.f64 [X+R8], RD0 ; X[i] = RDC
@P1, bra ENDIF1, *Comp ; 对遮罩位求补
; if P1为真,则转至ENDIF1
ELSE1:
ld.globa1.f64 RD0, [Z+R8] ; RDO = Z[i]
st.global.f64 [X+R8], RD ; X[i] = RD0
ENDIF1:
<next instruction>, *Pop ; 弹出以恢复旧遮罩

同样,IF-THEN-ELSE 语句中的所有指令通常都是由SIMD处理器执行。一些SIMD车道是为THEN语句启用的,另一些车道是为ELSE指令启用的。前面曾经提到,在非常常见的情况中,各个车道都一致选择设定谓词的分支,比如,根据参数值选择分支,而所有车道的这个参数值都相同,所有活动遮罩位或者都为0,或者都为1,因此,分支会跳过THEN指令或ELSE指令。这一灵活性清楚地表明元素有其自己的程序计数器,但是,在最缓慢的情况下,只有一个SIMD车道可以每两个时钟周期存储其结果,其余车道则会闲置。在向量体系结构中有种与之类似的最缓慢情景,那就是仅有一个遮罩位被设置为1时进行操作的情况。这一灵活性可能会导致GPU编程新手无法获得较佳性能,但在早期编程开发阶段可能是有帮助的。但要记住,在一个时钟周期内,SIMD车道的唯一选择就是执行在PTX指令中指定的操作或者处于空闲状态;两个SIMD车道不能同时执行不同指令。

这一灵活性还有助于解释为SIMD指令线程中每个元素指定的名称——CUDA线程,它会给人以独立运行的错觉。编程新手可能会认为这一线程抽象意味着GPU能够更出色地处理条件分支。一些线程会沿一条路径执行,其他线程则会沿另一路径执行,只要你不着急,那似乎就是如此。每个CUDA线程要么与线程块中的所有其他线程执行相同指令,要么就处于空闲状态。利用这一同步可以较轻松地处理带有条件分支的循环,这是因为遮罩功能可以关闭SIMD车道,自动检测循环的结束点。

最终得到的性能结果可能会与这种简单的抽象不相符。如果编写一些程序,以这种高度独立的MIMD模式来操作SIMD车道,就好像是编写了一些程序,在一个物理存储器很小的计算机上使用大量虚拟地址空间。这两种程序都是正确的,但它们的运行速度可能非常慢,程序员可能会对结果感到不快。

向量编译器可以用遮罩寄存器做到GPU用硬件完成的小技巧,但可能需要使用标量指令来保存、求补和恢复遮罩寄存器。条件执行就是这样一个例子:GPU在运行时用硬件完成向量体系结构在编译时完成的工作。有一种优化方法,可以在运行时针对GPU应用,但不能在编译时对向量体系结构应用,那就是在遮罩位全0或全1时略过THEN或ELSE部分。因此,GPU执行条件分支的效率决定了分支的分岔频率。例如,某个特征值计算具有深度条件嵌套,但通过代码测试表明,大约82%的时钟周期发射将32个遮罩位中的29至32位设置为1,所以GPU执行这一代码的效率可能要超出人们的预期。

注意,同一机制处理向量循环的条带挖掘——当元素数与硬件不完全匹配时。本节开始的例子表明,用一个If语句检查SIMD车道元素数(在上例中,该数目存储在R8中)是否小于限值(i<n),并适当设置遮罩。

NVIDIA GPU存储器结构

图4-12给出了NVIDIA GPU的存储器结构。多线程SIMD处理器中的每个SIMD车道获得片外DRAM的一个专用部分,称之为专用存储器,用于栈帧、溢出寄存器和不能放在寄存器中的私有变量。SIMD车道不共享专用存储器。最近的GPU将这一专用存储器缓存在L1和L2缓存中,用于辅助寄存器溢出并加速函数调用。

图4-12 GPU存储器结构。GPU存储器由所有网格(向量化循环)共享,本地存储器由线程块(向量化循环体)中的所有SIMD指令线程共享,专用存储器由单个CUDA线程专用

我们将每个多线程SIMD处理器本地的片上存储器称为本地存储器。这一存储器由多线程SIMD处理器内的SIMD车道共享,但这一存储器不会在多线程SIMD处理器之间共享。多线程SIMD处理器在创建线程块时,将部分本地存储器动态分配给此线程块,当线程块中的所有线程都退出时,释放此存储器。这一本地存储器部分由该线程块专用。

最后,我们将由整个GPU和所有线程块共享的片外DRAM称为GPU存储器。这里的向量乘法示例仅使用GPU存储器。

被称为主机的系统处理器可以读取或写入GPU存储器。本地存储器不能供主机使用,它是每个多线程SIMD专用的。专用存储器也不可供主机使用。

GPU通常不是依赖大型缓存来包含应用程序的整个工作集,而是使用较少的流式缓存,依靠大量的SIMD指令多线程来隐藏DRAM的较长延迟,其主要原因是它们的工作集可能达到数百MB。在利用多线程隐藏DRAM延迟的情况下,系统处理器中供缓存使用的芯片面积可以用于计算资源和大量的寄存器,以保存许多SIMD指令线程的状态。如前文所述,向量的载入和存储与之相对,是将这些延迟分散在许多元素之间,因为它只需要有一次延迟,随后即可实现其余访问的流水化。

尽管隐藏存储器延迟是一种优选方法,但要注意,最新的GPU和向量处理器都已经添加了缓存。例如,最近的Fermi体系结构已经添加了缓存,但它们要么被看作带宽滤选器,以减少对GPU存储器的要求,要么被看作有限几种变量的加速器,这些变量的修改不能通过多线程来隐藏。因此,用于栈帧、函数调用和寄存器溢出的本地存储器与缓存是绝配,这是因为延迟对于函数调用是有影响的。由于片上缓存访问所需要的能量要远远小于对多个外部DRAM芯片的访问,所以使用缓存还可以节省能量。

为了提高存储器带宽、降低开销,如上所述,当地址属于相同块时,PTX数据传送指令会将来自同一SIMD线程的各个并行线程请求接合在一起,变成单个存储器块请求。对GPU程序设置的这些限制,多少类似于系统处理器程序在硬件预取方面的一些准则。GPU存储器控制器还会保留请求,将一些请求一同发送给同一个打开的页面,以提高存储器带宽。

Fermi GPU体系结构中的创新

为了提高硬件利用率,每个SIMD处理器有两个SIMD线程调度程序和两个指令分派单元。双重SIMD线程调度程序选择两个SIMD指令线程,并将来自每个线程的一条指令发射给由16个SIMD车道、16个载入存储单元或4个特殊功能单元组成的集合。因此,每两个时钟周期将两个SNMD指令线程调度至这些集合中的任何一个。由于这些线程是独立的,所以不需要检查指令流中的数据相关性。这一创新类似于多线程向量处理器,它可以发射来自两个独立线程的向量指令。

图4-13展示了发射指令的双重调度程序,图4-14展示了Fermi GPU的多线程SIMD处理器的框图。


图4-13 Fermi 双SIMD线程调度程序的框图。将这一设计与图4-11中的单SIMD线程设计进行对比


图4-14 Fermi GPU多线程SIMD处理器的框图。每个SIMD车道有一个流水线浮点单元、一个流水线整数单元、还有某些逻辑,用于将指令和操作数分发给这些单元,以及一个队列用于保存结果。4个特殊函数单元(SFU)计算诸如平方根、求倒数、正弦和余弦等函数

Fermi引入了几种创新,使GPU与主流系统处理器的接近程度远远超过Tesla和前几代GPU体系结构。

  • 快速双精度浮点运算——Fermi对比发现,传统处理器的相对双精度速度大约为单精度速度的一半,是先前Tesla代处理器单精度速度的十分之一。也就是说,当准确性需要双精度时,使用单精度在速度方面没有太大的诱惑力。在使用乘加指令时,峰值双精度性能从过去GPU的78 GFLOP/s增长到515 GFLOP/s。
  • GPU存储器——尽管GPU的基本思想是使用足够多的线程来隐藏DRAM延迟,但仍然需要在线程之间使用一些变量,比如前面提到的局部变量。Fermi 在GPU中为每个多线程SIMD处理器包含了L1数据缓存和L1指令缓存,还包含了由所有多线程SIMD处理器共享的单个768KB L2缓存。如上所述,除了降低对GPU存储器的带宽压力之外,缓存因为驻留在芯片上,不用连到片外DRAM,所以还能节省能量。L1缓存实际上与本地存储器使用同一SRAM。Fermi有一个模式位,为用户提供了两种使用64KB SRAM的选择:两种16KBL1缓存和48KB本地存储器,另一种是48KBL1缓存和16KB本地存储器。注意,GTX 480有一个倒转的存储器层次结构:聚合寄存器堆的大小为2 MB,所有L1数据缓存的大小介于0.25与0.75 MB之间(取决于它们是16KB,还是48 KB),L2缓存的大小为0.75 MB。了解这一反转比值对GPU应用程序的影响是有意义的。
  • 全部GPU存储器的64位寻址和统一地址空间——利用这一创新可以非常轻松地提供C和C++所需要的指针。
  • 纠错码检测和纠正存储器与寄存器中的错误,为了提高数千个服务器上长期运行的应用程序的可靠性,ECC是数据中心的一种标准配置。
  • 更快速的上下文切换——由于多线程SIMD处理器拥有大量状态,所以Fermi以硬件支持大幅加速上下文的切换速度。Fermi 可以在不到25微秒内完成切换,比之前的处理器大约快10倍。
  • 更快速的原子指令——这一特征最早包含 在Telsa体系结构中,Fermi将原子指令的性能提高了5~20倍,达到几微秒的级别。有一个与L2缓存相关的特殊硬件单元(不是在多线程SIMD处理器内部)用来处理原子指令。

向量体系结构与GPU的相似与不同

我们已经看到,向量体系结构与GPU之间确实有许多相似之处。这些相似之处和GPU那些怪异的术语一样,也让体系结构圈的人们难以真正了解新奇的GPU本质。既然我们现在已经了解了向量体系结构和GPU的一些内幕,那就可以体味一下 它们的相似与不同了。这两种体系结构都是为了执行数据级并行程序而设计的,但它们选取了不同的路径,对比它们是希望更深入地了解DLP硬件到底需要什么。表4-8首先给出向量术语,然后给出GPU中最接近的对等术语。

SIMD处理器与向量处理器类似。GPU中的多个SIMD处理器像独立MIMD核心一样操作,就好像是许多向量计算机拥有多个向量处理器。这种观点将NVIDIA GTX 480看作一个具有多线程硬件支持的15核心机器,其中每个核心有16个车道。两者之间最大的区别是多线程,它是GPU的基本必备技术,而大多数向量处理器则没有采用。

看一下这两种体系结构中的寄存器,VMIPS寄存器堆拥有整个向量,也就是说,由64个双精度值构成的连续块。相反,GPU中的单个向量会分散在所有SIMD车道的寄存器中。VMIPS处理器有8个向量寄存器,各有64个元素,总共512个元素。一个GPU的SIMD指令线程拥有多达64个寄存器,各有32个元素,总共2048个元素。这些额外的GPU寄存器支持多线程。

图4-15的左边是向量处理器执行单元的框图,右侧是GPU的多线程SIMD处理器。为便于讲解,假定向量处理器有4个车道,多线程SIMD处理器也有4个SIMD车道。此图表明,4个SIMD车道的工作方式非常像4车道向量单元,SIMD处理器的工作方式与向量处理器非常类似。


图4-15 左侧为具有4个车道的向量处理器,右侧为GPU的多线程SIMD处理器。(GPU通常有8~ 16个SIMD车道。)控制处理器为标量一向量运算提供标量操作数,为对存储器进行单位步幅或非单位步幅访问而递增地址,执行其他“记账类型” (accounting-type)的运算。只有当地址接合单元可以发现本地寻址时,才会在GPU中实现峰值存储器性能。与此类似,当所有内部遮罩位被设置为相同时,会实现峰值计算性能。注意,SIMD处理器中每个SIMD线程有一个PC,以帮助实现多线程

实际上,GPU中的车道要多很多,所以GPU“钟鸣”更短一些。尽管向量处理器可能拥有2~8个车道,向量长度例如为32 (因此,钟鸣为4~16个时钟周期),多线程SIMD处理器可能拥有8~16个车道。SIMD线程的宽度为32个元素,所以GPU钟鸣仅为2或4个时钟周期。这一差别就是为什么要使用“SIMD 处理器”作为更具描述性术语的原因,这一术语更接近于SIMD设计,而不是传统的向量处理器设计。与向量化循环最接近的GPU术语是网格,PTX指令与向量指令最接近,这是因为SIMD线程向所有SIMD车道广播PTX指令。

关于两种体系结构中的存储器访问指令,所有GPU载入都是集中指令,所有GPU存储都是分散指令。如果CUDA线程的地址引用同一缓存/存储器块的邻近地址,那GPU的地址接合单元将会确保较高的存储器带宽。向量体系结构采用显式单位步幅载入并存储指令,而GPU编程则采用隐式单位步幅,这两者的对比说明为什么在编写高效GPU代码时,需要程序员从SIMD运算的角度来思考,尽管CUDA编程模型与MIMD看起来非常类似。由于CUDA线程可以生成自己的地址、步幅以及集中-分散,所有在向量体系结构和GPU中都可以找到寻址向量。我们已经多次提到,这两种体系结构采用了非常不同的方法来隐藏存储器延迟。向量体系结构通过深度流水化访问让向量的所有元素分担这一延迟,所以每次向量载入或存储只需要付出一次延迟代价。因此,向量载入和存储类似于在存储器和向量寄存器之间进行的块传送。与之相对的是,GPU使用多线程隐藏存储器延迟。(一些研究人员正在研究为向量体系结构添加多线程,以实现这两者的最佳性能。)

关于条件分支指令,两种体系结构都使用遮罩寄存器来实现。两个条件分支路径即使在未存储结果时也会占用时间以及(或者)空间。区别在于,向量编译器以软件显式管理遮罩寄存器,而GPU硬件和汇编程序则使用分支同步标记来隐式管理它们,使用内部栈来保存、求补和恢复遮罩。

前面曾经提到,GPU的条件分支机制很好地处理了向量体系结构的条带挖掘问题。如果向量长度在编译时未知,那么程序必须计算应用程序向量长度的模和最大向量长度,并将它存储在向量长度寄存器中。条带挖掘循环随后将向量长度寄存器重设剩余循环部分的最大向量长度。这种情况用GPU处理起来要更容易一些,因为它们将会一直迭代循环,直到所有SIMD车道到达循环范围为止。在最后一次迭代中,一些SIMD车道将被遮罩屏蔽,然后在循环完成后恢复。

向量计算机的控制处理器在向量指令的执行过程中扮演着重要角色。它向所有向量车道广播操作,并广播用于向量标量运算的标量寄存器值。它还执行一些在GPU中显式执行的隐式计算,比如自动为单位步幅和非单位幅载入、存储指令递增存储器地址。GPU中没有控制处理器。最类似的是线程块调度程序,它将线程块(向量循环体)指定给多线程SIMD处理器。GPU中的运行时硬件机制一方面生成地址,另一方面还会查看它们是否相邻,这在许多DLP应用程序中都是很常见的,其功耗效率可能要低于控制处理器。

向量计算机中的标量处理器执行向量程序的标量指令。也就是说,它执行那些在向量单元中可能速度过慢的运算。尽管与GPU相关联的系统处理器与向量体系结构中的标量处理器最为相似,但独立的地址空间再加上通过PCle总线传送,往往会耗费数千个时钟周期的开销。对于在向量计算机中执行的浮点计算,标量处理器可能要比向量处理器慢一些,但它们的速度比值不会达到系统处理器与多线程SIMD处理器的比值(在给定开销的前提下)。

因此,GPU中的每个“向量单元”必须执行本来指望在向量计算机标量处理器上进行的计算。也就是说,如果不是在系统处理器上进行计算然后再发送结果,而是使用谓词寄存器和内置遮罩禁用其他SIMD车道,仅留下其中一个SIMD车道,并用它来完成标量操作,那可以更快一些。向量计算机中比较简单的标量处理器可能要比GPU解决方案更快一些、功耗效率更高一些。如果系统处理器和GPU将来更紧密地结合在一起,那了解一下系统处理器能否扮演标量处理器在向量及多媒体SIMD体系结构中的角色,那将是很有意义的。

多媒体SIMD计算机与GPU之间的相似与不同

从较高级别的角度来看,具有多媒体SIMD指令扩展的多核计算机的确与GPU有一些相似之处。表4-9总结了它们之间的相似与不同。

这两种多处理器的处理器都使用多个SIMD车道,只不过GPU的处理器更多一些,车道数要多很多。它们都使用硬件多线程来提高处理器利用率,不过GPU为大幅增加线程数目提供了硬件支持。由于GPU中最近的一些创新,现在这两者的单、双精度浮点运算性能比相当。它们都使用缓存,不过GPU使用的流式缓存要小一些,多核计算机使用大型多级缓存,以尝试完全包含整个工作集。它们都使用64位地址空间,不过GPU中的物理主存储器要小得多。尽管GPU支持页面级别的存储器保护,但它们都不支持需求分页。除了在处理器、SIMD车道、硬件线程支持和缓存大小等大量的数字差异之外,还有许多体系结构方面的区别。在传统计算机中,标量处理器和多媒体SIMD指令紧密集成在一起;它们由GPU中的I/O总线隔离,它们甚至还有独立的主存储器。GPU中的多个SIMD处理器使用单一地址空间,但这些缓存不是像传统的多核计算机那样是一致的。多媒体SIMD指令与GPU不同,它不支持集中-分散存储器访问。

小结

现在,GPU的神秘面纱已经揭开,可以看出GPU实际上就是多线程SIMD处理器,只不过与传统的多核计算机相比,它们的处理器更多、每个处理器的车道更多,多线程硬件更多。例如,Fermi GTX 480拥有15个SIMD处理器,每个处理器有16个车道,为32个SIMD线程提供硬件支持。Fermi甚至包括指令级并行,可以从两个SIMD线程向两个SIMD车道集合发射指令。另外,它们的缓存存储器较少——Fermi 的L2缓存为0.75 MB,而且与标量处理器不一致。

CUDA编程模型将所有这些形式的并行包含在一种抽象中,即CUDA线程中。因此,CUDA程序员可以看作是在对数千个线程进行编程,而实际上他们是在许多SIMD处理器的许多车道上执行各个由32个线程组成的块。希望获得良好性能的CUDA程序员一定要记住 ,这些线程是分块的,一次执行32个,而且为了从存储器系统获得良好性能,其地址需要是相邻的。尽管本节使用了CUDA和NVIDIA GPU,但我们确信在OpenCL编程语言和其他公司的GPU中也采用了相同思想。

在读者已经很好地理解了GPU的工作原理之后,现在可以揭示真正的术语了。表4-10和4-11将本节的描述性术语及定义与官方CUDANVIDIA和AMD术语及定义对应起来,而且还给出了OpenCL术语。我们相信,GPU 学习曲线非常陡峭,一部分原因就是因为使用了如下术语:用“流式多处理器”表示SIMD处理器,“线程处理器”表示SIMD车道,“共享存储器”表示本地存储器,而本地存储器实际上并非在SIMD处理器之间共享!我们希望,这种“两步走”方法可以帮助读者更快速地沿学习曲线上升,尽管这种方法有些不够直接。

检测与增强循环强并行

程序中的循环是我们前面讨论以及将在第5章讨论的许多并行类型的根源。本节,我们讨论用于发现并行以在程序中加以开发的编译器技术,以及这些编译器技术的硬件支持。我们准确地定义一个循环何时是并行的(或可向量化的)、相关性是如何妨碍循环成为并行的,以及用于消除几类相关性的技术。发现和利用循环级并行对于开发DLP和TLP以及将在附录H中研究的更主动静态ILP方法(例如,VLIW)都至关重要。

循环级并行通常是在源代码级别或接近级别进行分析的,而在编译器生成指令之后,就完成了对ILP的大多数分析。循环级分析需要确定循环的操作数在这个循环的各次迭代之间存在哪种相关性。就目前来说,我们将仅考虑数据相关,在某一时刻写入操作数,并在稍后时刻读取时会出现这种相关性。名称相关也是存在的,利用第3章讨论的重命名技术可以消除这种相关。

循环级并行的分析主要是判断后续迭代中的数据访问是否依赖于在先前迭代中生成的数据值;这种相关被称为循环间相关。我们在第2章和第3章考虑的大多数示例都没有循环间相关,而是循环级并行的。为了了解一个循环是并行的,让我们首先看看源代码:

1
2
for (i=999; i>=0; i=i-1) 
x[i] = x[i] + s;

在这个循环中,对x[i]的两次使用是相关的,但这是同一个迭代内的相关,不是循环间相关。在不同迭代中对i的连续使用之间存在循环间相关,但这种相关涉及一个容易识别和消除的归纳变量。

要寻找循环之间的并行,需要识别诸如循环、数组引用和归纳变量计算之类的结构,所以与机器码级别相比,编译器在源代码级别或相近级别进行这一分析要更轻松一些。让我们看一个更复杂的例子。

考虑下面这样一个循环:

1
2
3
for (i=0; i<100; i=i+1) {
A[i+1] = A[i] + C[i]; /*S1 */
B[i+1] = B[i] + A[i+1]; /* S2 */

假定A、B和C是没有重叠的不同数组。(在实践中,这些数组有时可能相同,或可能重叠。因为这些数组可能是作为参数传递给包含这一循环的过程,为了判断数组是否重叠或相同,通常需要对程序进行复杂的过程间分析。)在这个循环中,语句S1和S2之间的数据相关如何?

共有以下两种不同相关。

  • S1使用一个在先前迭代中由S1计算的值,这是因为迭代i计算A[i+1],然后在迭代i+1中读取它。对B[i]B[i+1]来说,S2 也是如此。
  • S2使用由同一迭代中S1计算的值A[i+1]。这两种相关是不同的,拥有不同的效果。为了了解它们如何不同,我们假定此类相关只能同时存在一个。因为语句S1依赖于S1的先前迭代,所以这种相关是循环间相关。这种相关迫使这个循环的连续迭代必须按顺序执行。

第二种相关(S2对S1的依赖)位于一个迭代内,不是循环间相关。因此,如果它是仅有的相关,那这个循环的多个迭代就能并行执行,只要一个迭代中的每对语句保持相对顺序即可。我们在2.2节的例子中看到过这种类型的相关,通过循环展开可以暴露这种并行。这种循环内的相关是很常见的,例如,使用链接(chaining)的向量指令序列就存在此类相关。

还有可能存在一种不会妨碍并行的循环间相关,如下例所示。

考虑下面这样一个循环:

1
2
3
4
for (i=0; i<100; i=i+1) {
A[i] = A[i] + B[i]; /* S1 */
B[i+1] = C[i] + D[i]; /* S2 */
}

S1和S2之间是什么样的相关?这一循环是否为并行的?如果不是,说明如何使之成为并行循环。

语句S1使用了在上一次迭代中由语句S2指定的值,所以在S2与S1之间存在循环间相关。尽管存在这一循环间相关,依然可以使这一循环变为并行。与前面的循环不同,这种相关不存在环式相关:这些语句都没有依赖于自身,而且尽管S1依赖于S2,但S2没有依赖于S1。如果可以将一个循环改写为没有环式相关的形式,那这个循环就是并行的,因为没有这种环式相关形式就意味着这种相关性对语句进行了部分排序。

尽管以上循环中没有环式相关,但必须对其进行转换,以符合部分排序,并暴露出并行。两个观察结果对于这一转换至关重要。

  • 不存在从S1到S2的相关。如果存在这种相关,那就可能存在环式相关,那循环就不是并行的。由于没有其他相关,所以两个语句之间的互换不会影响S2的执行。
  • 在循环的第一次迭代中,语句S2依赖于B[0]值,它是在开始循环之前计算的。这两个观察结果可以让我们用以下代码序列来代替以上循环:
1
2
3
4
5
6
A[0] = A[0] + B[0];
for (i=0; i<99; i=i+1) {
B[i+1] = C[i] + D[i];
A[i+1] = A[i+1] + B[i+1];
}
B[100] = C[99] + D[99];

这两个语句之间的相关不再是循环间相关,所以循环的各次迭代可以重叠,只要每次迭代中的语句保持相对顺序即可。

我们的分析需要首先找出所有循环间相关。这一相关信息是不确切的,也就是说,它告诉我们此相关可能存在。考虑以下示例:

1
2
3
4
for (i=0;i<100;i=i+1) {
A[i] = B[i] + C[i];
D[i] = A[i] * E[i];
}

这个例子中对A的第二次引用不需要转换为载入指令,因为我们知道这个值是由上一个语句计算并存储的;因此,对A的第二个引用可能就是引用计算A的寄存器。执行这一优化需要知道这两个引用总是指向同一存储器地址,而且不存在对相同位置的干扰访问。通常,数据相关分析只会告诉我们一个引用可能依赖于另一个引用;要确定两个引用一定指向同一地址,那就需要进行更复杂的分析。在上面的例子中,进行这一简单分析就足够了,因为这两个引用都处于同一基本块中。

循环间相关经常会是一种递归(recurrence)形式。如果要确定变量的取值,需要先知道该变量在前面迭代中的取值时,就会发生递归,这个先前迭代往往就是前面的相邻近代,如以下代码段所示:

1
2
3
for (i=1;i<100;i=i+1) {
Y[i] = Y[i-1] + Y[i];
}

检测递归是非常重要的,原因有两个:其一,一些体系结构(特别是向量计算机)对执行递归提供特殊支持;其二,在ILP环境中,仍然可能开发相当数量的并行。

查找相关

显然,查找程序中的相关对于确定哪些循环可能包含并行以及如何消除名称相关都很重要。诸如C或C++语言中存在数组和指针,Fortran 中存在按引用传送的参数传递,这些也都增加了相关分析的复杂度。由于标量变量引用明确指向名称,所以用别名对它们进行分析是比较轻松的,因为指针和引用参数会增加分析过程的复杂性和不确定性。

编译器通常是如何检测相关的呢?几乎所有相关分析算法都假定数组索引是仿射的(affine)。用最简单的话说,一维数组索引可以写为ai + b的形式,其中a和b是常数,i是循环索引变量,也就说这个索引是仿射的。如果多维数组每一维的索引都是仿射的,那就称这个多维数组的索引是仿射的。稀疏数组访问(其典型形式为x[y[i]])是非仿射访问的主要示例之一。要判断一个循环中对同一数组的两次访问之间是否存在相关,等价于判断两个仿射函数能否针对不同索引取同一个值(这些索引当然没有超出循环范围)。例如,假定我们已经以索引值ai + b存储了一个数组元素,并以索引值ci + d从同一数组中载入,其中i是FOR循环索引变量,其变化范围是m~n。如果满足以下两个条件,则存在相关性。

  1. 有两个迭代索引j和k,它们都在循环范围内。即m≤j≤n、m≤k≤n
  2. 此循环以索引aj+b存储一个数组元素,然后以ck+d提取同一数组元素。即aj+b=ck+d

一般来说,我们在编译时不能判断是否存在相关。例如,a、b、c和d的值可能是未和的(它们可能是其他数组中的值),从而不可能判断是否存在相关。在其他情况下,在编译时进行相关测试的开销可能非常高,但的确可以确定是否存在;例如,可能要依靠多重嵌套循环的迭代索引来进行访问。但是,许多项目主要包含一些简单的索引,其中a、b、c和d都是常数。对于这些情况,有可能设计出一些合理的测试程序,在编译时测试。

举个例子,最大公约数(GCD)测试非常简单,但足以判定不存在相关的情况。它基于以下事实:如果存在循环间相关,那么GCD(c,a)必须能够整除(d-b)。(回想一下,有两个整数x、y,在计算y/x除法运算时,如果能够找到一个整数商,使运算结果没有余数,则说x能够整除y。)

使用GCD测试判断以下循环中是否存在相关:

1
2
3
for (i=0; i<100; i=i+1) {
X[2*i+3] = X[2*i] * 5.0;
}

给定值a=2、b=3、c=2、d=0,那么GCD(a,c)=2d-b = -3。由于2不能整除-3,所以不可能存在相关。

GCD测试足以确保不存在相关,但在某些情况下,GCD测试成功但却不存在相关。例如,这种情况可能因为GCD测试没有考虑循环范围。

一般来说,要确定是否实际存在相关,就是一个NP完全(NP-complete)问题。但实际上,有许多常见情况能够以低成本来准确分析。最近出现了一些既准确又高效的方法,它们使用不同层次的精确测试,通用性和成本都有所提高。(如果一个测试能够确切地判断是否存在相关,就说这一测试是确切的。尽管一般情况是“NP 完全”的,但对于一些有一定限制的情况,是存在确切测试的,其成本也要低廉得多。)

除了检测是否存在相关以外,编译器还希望划分相关的种类。编译器可以通过这种分类来识别名称相关,并在编译时通过重命名和复制操作来消除这些相关。

下面的循环有多种类型的相关。找出所有真相关、输出相关和反相关,并通过重命名消除输出相关和反相关。

1
2
3
4
5
6
for (i=0; i<100; i=i+1) {
Y[i]=X[i]/c;/*S1*/
X[i]=X[i]+c;/*S2*/
Z[i]=Y[i]+c;/*S3*/
Y[i]=c-Y[i];/*S4*/
}

4个语句之间存在以下相关。

  1. 由于Y[i]的原因,从S1至S3、从S1至S4存在真相关。这些相关不是循环间相关,所以它们并不妨碍将该循环看作是并行的。这些相关将强制S3和S4等待S1完成。
  2. 从S1到S2有基于X[i]的反相关。
  3. 从S3到S4有关于Y[i]的反相关。
  4. 从S1到S4有基于Y[i]的输出相关。

以下版本的循环消除了这些假(或伪)相关。

1
2
3
4
5
6
for (i=0; i<100; i=i+1) {
T[i] = X[u] / c; /* Y重命名为T,以消除输出相关*/
X1[i] = X[i] + c; /* X重命名为X1,以消除反相关*/
Z[i] = T[i] + c;/*Y重命名为T,以消除反相关*/
Y[i] = c - T[i];
}

在这个循环之后,变量X被重命名为X1。在此循环之后的代码中,编译器只需要用x1来代替名称X即可。在这种情况下,重命名不需要进行实际的复制操作,通过替换名字或寄存器分配就可以完成重命名。但在其他情况下,重命名是需要复制操作的。

相关分析是一种非常关键的技术,不仅对开发并行如此,对于第2章介绍的转换分块也是如此。相关分析是检测循环级别并行的一种基本工具。要针对向量计算机、SIMD 计算机或多处理器进行有效的程序编译,都依赖于这一分析。相关分析的主要缺点是它仅适用于非常有限的一些情况,也就是用于分析单个循环嵌套中引用之间的相关以及使用仿射索引功能的情景。

因此,在许多情况下,面向数组的相关分析不能告诉我们希望知道的内容;例如,在分析用指针而不是数据索引完成的访问时,可能要困难得多。(对于许多为并行计算机设计的科学应用程序,Fortran 仍然优于C和C++,上述内容就是其中一个理由。)同理,分析过程调用之间的引用也极为困难。因此,尽管依然需要分析那些以顺序语言编写的代码,但我们也需要一些编写显式并行循环的方法,比如OpenMP和CUDA。

消除相关计算

上面曾经提到,相关计算的最重要形式之一就是“递归”。点积是递归的一个完美示例:

1
2
for (i = 9999; i>=0; i=i-1)
sum=sum+x[i]*y[i];

这个循环不是并行的,因为它的变量求和存在循环间相关。但是,我们可以将它转换为一组循环,其中一个是完全并行的,而另一个可以是部分并行的。 第-个循环将执行这一循环的完全并行部分。它看起来如下所示:
1
2
for (i=9999; i>=0; i=i-1)
sum[i] = x[i] * y[i];

注意,这一求和已经从标量扩展到向量值(这种转换被称为标量扩展),通过这一转换使新的循环成为完全并行的循环。但是,在完成转换时,需要进行约简步骤,对向量的元素求和。类似如下所示:
1
2
for (i=9999; i>=0; i=1-1)
finalsum = finalsum + sum[i];

尽管这个循环不是并行的,但它有一种非常特殊的结构,称为约简(reduction)。一般来说,任何函数都可用作约简运算符,常见情况中包含着诸如max和min之类的运算符。

在向量和SIMD体系结构中,约简有时是由特殊硬件处理的,使约简步骤的执行速度远快于在标量模式中的完成速度。具体做法是实施一种技术,类似于可在多处理器环境中实现的技术。尽管一般转换可以使用任意个处理器,但为简便起见,假定有10个处理器。在对求和进行约简的第一个步骤中,每个处理器执行以下运算(p是变化范围为0~9的进程号):

1
2
for (i=999; i>=0; i=i-1)
finalsum[p] = finalsum[p] + sum[1+1000*p];

这个循环在10个处理器中的每个处理器上对1000个元素求和,它是完全并行的。最后用简单的标量循环来完成最后10个总和的计算。向量和SIMD处理器中使用了类似的方法。

以上转换依赖于加法的结合性质,观察到这一点是非常重要的。尽管拥有无限范围与精度的算术运算具有结合性质,但计算机运算却不具备结合性:对于整数运算来说,是因为其范围有限;对于浮点运算来说,既有范围原因,又有精度原因。因此,使用这些重构技术有时可能会导致一些错误行为,尽管这种现象很少会发生。为此,大多数编译器要求显式启用那些依赖结合性的优化。

交叉问题

能耗与DLP:慢而宽与快而窄

数据级并行体系结构的主要功耗优势来自第1章的能耗公式。由于我们假定有充足的数据级并行,所以,如果将时钟频率折半、执行资源加倍:将向量计算机的车道数加倍,将多媒体SIMD的寄存器和ALU加宽、增加GPU的SIMD车道数,那性能是一样的。如果我们在降低时钟频率的同时降低电压,那就可以降低计算过程的功耗和功率,同时保持峰值性能不变。因此,DLP处理器的时钟频率可以低于系统处理器,后者依靠高时钟频率来获取性能。

与乱序处理器相比,DLP 处理器可以采用较简单的控制逻辑,在每个时钟周期中启动大量计算;例如,这一控制对于向量处理器中的所有车道都是相同的,没有用于决定多指令发射的逻辑和推测执行逻辑。利用向量体系结构还可以轻松地关闭芯片中的未使用部分。在发射指令时,每条向量指令都明确指明它在大量周期内所需要的全部资源。

分组存储器和图形存储器

4.2节提到了实际存储器带宽对于向量体系结构支持单位步幅、非单位步幅和集中-分散访问的重要性。

为了实现高性能,GPU也需要充足的存储器带宽。专为GPU设计的特殊DRAM芯片可以帮助提供这一带宽,这种芯片被称为GDRAM,即图形DARM。与传统DARM芯片相比,GDRAM芯片的带宽较高,容量较低。为了提供这一带宽,GDRAM芯片经常被直接焊接在GPU所在的同一电路板上,而不是像系统存储器那样设置在DIMM模块中,DIMM是插在主板插槽中的。DIMM模块便于系统升级和提供更大的容量,这一点与GDRAM不同。这一有限容量(2011年大约为4 GB)与解决更大问题的目标相冲突,随着GPU计算能力的增长,这冲突将成为它的一个必然趋势。

为了提供最佳性能,GPU 试图考虑GDRAM的所有特性。它们在内部通常被安排为4~8组,行数是2的幂(通常为16384),每行的位数也是2的幂(通常为8192)。

在给出计算任务及图形加速任务对GDRAM的所有潜在要求之后,存储器系统可能会面对大量的不相关请求。然而,这种多样性会伤害到存储器性能。为了应对这种情况,GPU的存储器控制器为不同GDRAM组设定分离的通信量限度队列,要等到具有足够的通信量后才会打开一行,并同时传送所请求的全部数据。这一延迟提升了带宽,但使延迟时间增长,控制器必须确保所有处理过程不会因为等待数据而“挨饿”,否则,相邻的处理器可能会处于空闲状态。

步幅访问和TLB缺失

步幅访问的一个问题是它们如何与转换旁视缓冲区(TLB)进行交换,以在向量体系结构或GPU中获得虚拟存储器。(GPU使用TLB来实现存储器映射。)根据TLB的组织方式以及存储器中受访数组的大小,甚至有可能在每次访问数组的元素时都会遇到一次TLB缺失。

线程级并行

引言

多重处理的重要性在不断提升,这反映了以下几个重要因素。

  • 如何使性能的增长速度超过基础技术的发展速度呢?除了ILP之外,我们所知道的唯一可伸缩、通用方式就是通过多重处理。
  • 随着云计算和软件即服务变得越来越重要,人们对高端服务器的兴趣也在增加。
  • 因特网上有海量数据可供利用,推动了数据密集型应用程序的发展。
  • 人们认识到提高台式计算机性能的重要性在下降(至少图形处理功能之外的性能如此),要么是因为当前的性能已经可以接受,要么是因为计算高度密集、数据高度密集的应用程序都是在云中完成的。
  • 人们更深入地了解到如何才能有效地利用多处理器,特别是在服务器环境中如何加以有效利用,这种环境中存在大量自然并行,而并行源于大型数据集、科学代码中的自然并行,或者大量独立请求之间的并行(请求级并行)。
  • 通过可复用设计而不是独特设计来充分发挥设计投入的效用,所有多处理器设计都具备这一特点。

本章主要研究线程级并行(TLP)的开发。TLP意味着存在多个程序计数器,因此主要通过MIMD加以开发。尽管MIMD的出现已经有几十年了,但在从嵌入式应用到高端服务器的计算领域中,线程级并行移向前台还是最近的事情。同样,线程级并行大量用于通用应用程序而不只是科学应用程序,也是最近的事情。

这一章的重点是多处理器,我们将多处理器定义为由紧耦合处理器组成的计算机,这些处理器的协调与使用由单一处理器系统控制,通过共享地址空间来共享存储器。此类处理器通过两种不同的软件模型来开发线程级并行。第一种模型是运行一组紧密耦合的线程,协同完成同一项任务,这种情况通常被称为并行处理。第二种模型是执行可能由一或多位用户发起的多个相对独立的进程,这是一种请求级并行形式,其规模要远小于将在下一章研究的内容。请求级并行可以由单个应用程序开发(这个应用程序在多个处理器上运行,比如响应查询请求的数据库程序),也可以由多个独立运行的应用程序开发,通常称为多重编程。

本章所研究多处理器的典型范围小到一个双处理器,大至包括数十个处理器,通过存储器的共享进行通信与协调。尽管通过存储器进行共享隐含着对地址空间的共享,但并不一定意味着只有一个物理存储器。这些多处理器既包括拥有多个核心的单片系统(称为多核),也包括由多个芯片构成的计算机,每个芯片可能采用多核心设计。

除了真正的多处理器之外,我们还将再次讨论多线程主题,这一技术支持多个线程以交错形式在单个多发射处理器上运行。许多多核处理器也包括对多线程的支持。

我们的焦点是含有中小量处理器(2 ~ 32个)的多处理器。无论是在数量方面,还是在金额方面,此类设计都占据着主导地位。对于更大规模的多处理器设计领域(33个或更多个处理器),我们仅给予一点点关注。

多处理器体系结构:问题与方法

为了充分利用拥有n个处理器的MIMD多处理器,通常必须拥有至少n个要执行的线程或进程。单个进程中的独立线程通常由程序员确认或由操作系统创建(来自多个独立请求)。在另一极端情况下,一个线程可能由一个循环的数十次迭代组成,这些迭代是由开发该循环数据并行的并行编译器生成的。指定给一个线程的计算量称为粒度大小,尽管这一数值在考虑如何高效开发线程级并行时非常重要,但线程级并行与指令级并行的重要定性区别在于:线程级并行是由软件系统或程序员在较高层级确认的;这些线程由数百条乃至数百万条可以并行执行的指令组成。

线程还能用来开发数据级并行,当然,其开销可能高于使用SIMD处理器或GPU的情况。这一开销意味着粒度大小必须大到能够足以高效开发并行。例如,尽管向量处理器或GPU也许能够高效地实现短向量运算的并行化,但当并行分散在许多线程之间时,粒度大小会非常小,导致在MIMD中开发并行的开销过于昂贵,无法接受。根据所包含的处理器数量,可以将现有共享存储器的多处理器分为两类,而处理器的数量又决定了存储器的组织方式和互连策略。我们是按照存储器组织方式来称呼多处理器的,因为处理器的数目是多还是少,是可能随时间变化的。

第一类称为对称(共享存储器)多处理器(SMP),或集中式共享存储器多处理器,其特点是核心数目较少,通常不超过8个。由于此类多处理器中的处理器数目如此之小,所以处理器有可能共享一个集中式存储器,所有处理器能够平等地访问它,这就是对称一词的由来。在多核芯片中,采用一种集中方式在核心之间高效地共享存储器,现有多核心都是SMP。当连接一个以上的多核心时,每个多核心会有独立的存储器,所以存储器为分布式,而非集中式。

SMP体系结构有时也称为一致存储器访问(UMA)多处理器,这一名称来自以下事实:所有处理器访问存储器的延迟都是一致的,即使存储器的组织方式被分为多个组时也是如此。图5-1展示了这类多处理器的基本结构。SMP的体系结构将在5.2节讨论,我们将结合一种多核心来解释这种体系结构。

图5-1 基于多核芯片的集中式共享存储器多处理器的基本结构。多处理器缓存子系统共享同一物理存储器,通常拥有一级共享缓存、一或多级各核心专用缓存。这一结构的关键特性是所有处理器对所有存储器的访问时间一致。在多芯片版本中,将省略共享缓存,将处理器连接至存储器的总线或互连网络分布在芯片之间,而不是一块芯片内部

在另一种设计方法中,多处理器采用物理分布式存储器,称为分布式共享存储器(DSM)。图5-2展示了此类多处理器的基本结构。为了支持更多的处理器,存储器必须分散在处理器之间,而不应当是集中式的;否则,存储器系统就无法在不大幅延长访问延迟的情况下为大量处理器提供带宽支持。随着处理器性能的快速提高以及处理器存储器带宽需求的相应增加,越来越小的多处理器都优选采用分布式存储器。多核心处理器的推广意味着甚至两芯片多处理器都采用分布式存储器。处理器数目的增大也提升了对高宽带互连的需求。直联网络(即交换机)和间接网络(通常是多维网络)均被用于实现这些互连。

图5-2 2011年的分布式存储器多处理器的基本体系结构通常包括一个带有存储器的多核心多处理器芯片,可能带有I/O和一个接口,连向连接所有节点的互连网络。每个处理器核心共享整个存储器,当然,在访问隶属于该核心芯片的本地存储器时,其速度要远远高于访问远端存储器的速度将存储器分散在节点之间,既增加了带宽,也缩短了到本地存储器的延迟。DSM多处理器也被称为NUMA(非一致存储器访问),这是因为它的访问时间取决于数据字在存储器中的位置。DSM的关键缺点是在处理器之间传送数据的过程多少变得更复杂了一些,DSM需要在软件中多花费一些力气,以充分利用分布式存储器提升的存储器带宽。因为所有多核心多处理器(处理器芯片或插槽多于一个)都使用了分布式存储器,所以我们将从这个角度来解释分布式存储器多处理器的工作方式。

在SMP和DSM这两种体系结构中,线程之间的通信是通过共享地址空间完成的,也就是说,任何一个拥有正确寻址权限的处理器都可以向任意存储器位置发出存储器引用。与SMP和DSM相关联的共享存储器一词是指共享地址空间这一事实。

与之相对,下一章的集群和仓库级计算机看起来更像是由网络连接的独立计算机,如果两个处理器之间没有运行软件协议加以辅助,那一个处理器就无法访问另一个处理器的存储器。在此类设计中,利用消息传送协议在处理器之间传送数据。

并行处理的挑战

多处理器的应用范围很广,既可用于运行基本上没有通信的独立任务,也可以运行一些必须在线程之间进行通信才能完成任务的并行程序。有两个重要的障碍使并行处理变得极富挑战性。这些障碍的难易程度是由应用方式和体系结构来确定的。

第一个障碍与程序中有限的可用并行相关,第二个障碍源于通信的成本较高。由于可用并行的有限性,所以很难在所有并行处理器中都实现良好的加速比。

并行处理的第二个重要挑战涉及并行处理器进行远程访问所带来的长时延迟。在现有的共享存储器多处理器中,分离核心之间的数据通信通常需要耗费35 ~ 50个时钟周期,分离芯片上的核心之间进行数据通信可能耗费100个时钟周期到500甚至更多个时钟周期(对于大规模多处理器而言),具体取决于通信机制、互连网络的类型以及多处理器的规模。长时间的通信延迟显然会造成实际影响。让我们看一个简单的例子。

假定有一个应用程序运行在包含32个处理器的多处理器上,它在引用远程存储器时需要的时间为200 ns。对于这一应用程序,假定除涉及通信的引用之外,其他所有引用都会在本地存储器层次结构中命中,这一假定稍微有些乐观了。处理器会在远程请求时停顿,处理器时钟频率为3.3 GHz。如果基础CPI (假定所有引用都在缓存中命中)为0.5,请对比在没有通信、0.2%的指令涉及远程通信引用这两种情况下,多处理器会快多少?

首先计算每条指令占用的时钟周期数更容易一些。当涉及0.2%的远程引用时,多处理器的实际CPI为:

CPI=基础CPI+远程请求率x远程请求成本=0.5+0.2%x远程请求成本

远程请求成本为:远程访问成本/周期时间 = 200 ns/0.3 ns = 666个周期

因此,我们可以得出CPI:CPI=0.5+1.2=1.7

当所有引用均为本地引用时,多处理器要快出1.7/0.5 =3.4倍。实际的性能分析要复杂得多,因为某些非通信引用会在本地层次结构中缺失,远程访问时间也不是一个常数值。例如,在进行远程引用时,由于许多引用试图利用全局互连从而导致争用,增大延迟,从而使远程引用的成本大幅增加。

这些问题(并行度不足、远程通信延迟太长)是多处理器应用中最大的两个性能难题。应用程序并行度不足的问题必须通过软件来解决,在软件中采用一些能够提供更佳并行性能的新算法,而且软件系统应当尽量利用所有处理器来运行软件。远程延迟过长而导致的影响可以由0] 体系结构和程序员来降低。例如,我们可以利用硬件机制(比如缓存共享数据)或软件机制(比如重新调整数据的结构,增加本地访问的数量)来降低远程访问的频率。我们可以利用多线程或利用预取来尝试容忍这些延迟。

集中式共享存储器体系结构

人们发现,使用大型、多级缓存可以充分降低处理器对存储器带宽的需求,这一重要发现刺激了集中式存储器多处理器的发展。最初,这些处理器都是单核心的,经常会占据整个主板,存储器被放置在共享总线上。后来,更高性能的处理器、存储器需求超出了一般总线的能力,最近的微处理器直接将存储器连接到单一芯片中,有时称其为后端或存储器总线,以便将它与连接至I/O的总线区分开来。在访问一个芯片的本地存储器时,无论是为了I/O操作,还是为了从另一个芯片进行访问,都需要通过“拥有”该存储器的芯片。因此,对存储器的访问是非对称的:对本地存储器的访问更快一些,而对远程存储器的远程要慢一些。在多核心结构中,存储器由一个芯片上的所有核心共享,但是从一个多核心的存储器访问另一个核心的存储器时,仍然是非对称的。

采用对称共享存储器的计算机通常支持对共享数据与专用数据的缓存。专用数据供单个处理器使用,而共享数据则由多个处理器使用,基本上是通过读写共享数据来实现处理器之间的通信。在缓存专用项目时,它的位置被移往缓存,缩短平均访问时间并降低所需要的存储器带宽。由于没有其他处理器使用数据,所以程序行为与单处理器中的行为相同。在缓存共享数据时,可能会在多个缓存中复制共享值。除了降低访问延迟和所需要的存储器带宽之外,这一复制过程还可以减少争用,当多个处理器同时读取共享数据项时可能会出现这种争用。不过,共享数据的缓存也引入了一个新问题:缓存一致性。

什么是多处理器缓存一致性

遗憾的是,缓存共享数据会引入一个新的问题,这是因为两个不同的处理器是通过各自的缓存来保留存储器视图的,如果不多加防范,它们最终可能会看到两个不同值。表5-1展示了这一问题,并说明两个不同处理器如何将同一位置的内容看作两个不同值。这一难题一般被称为缓存一致性问题。注意,存在一致性问题是因为既拥有全局状态(主要由主存储器决定),又拥有本地状态(由各个缓存确定,它是每个处理器核心专用的)。因此,在一个多核心中,可能会共享某一级别的缓存(比如L3),而另外一些级别的缓存则是专用的(比如L1和L2),一致性问题仍然存在,必须加以解决。

  • 我们最初假定两个缓存都没有包含该变量, X的值为1。我们还假定采用直写缓存,写回缓存会增加一些复杂性,但与此类似。在处理器A写入X值后,A的缓存和存储器中都包含了新值,但B的缓存中没有,如果B读取X的值,将会收到数值1!

通俗地说,如果在每次读取某一数据项时都会返回该数据项的最新写入值,那就说这个存储器系统是一致的。尽管这一定义看起来是正确的,但它有些含混,而且过于简单;实际情况要复杂得多。这一简单定义包含了存储器系统行为的两个方面,这两个方面对于编写正确的共享存储器程序都至关重要。第一个方面称为一致性(coherence),它确定了读取操作可能返回什么值。第二个方面称为连贯性(consistency),它确定了一个写入值什么时候被读取操作返回。首先来看一致性。

如果存储器系统满足以下条件,则说它是一致的。

  • 处理器P读取位置X,在此之前是由P对X进行写入,在P执行的这一写入与读取操作之间,没有其他处理器对位置X执行写入操作,此读取操作总是返回P写入的值。
  • 一个处理器向位置X执行写入操作之后,另一个处理器读取该位置,如果读写操作的间隔时间足够长,而且在两次访问之间没有其他处理器向X写入,那该读取操作将返回写入值。
  • 对同一位置执行的写入操作被串行化,也就是说,在所有处理器看来,任意两个处理器对相同位置执行的两次写入操作看起来都是相同顺序。例如,如果数值1、数值2被依次先后写到一个位置,那处理器永远不可能先从该位置读取到数值2,之后再读取到数值1。

第一个特性只是保持了程序顺序——即使在单处理器中,我们也希望具备这一特性。第二个特性定义了一致性存储器视图的含义:如果处理器可能持续读取到一个旧数据值,我们就能明确地说该存储器是不一致的。

对写操作串行化的要求更加微妙,但却同等重要。假定我们没有实现写操作的串行化,而且处理器P1先写入地址X,然后是P2写入地址X。对写操作进行串行化可以确保每个处理器在某一时刻看到的都是由P2写入的结果。如果没有对写入操作进行串行化,那某些处理器可能会首先看到P2的写入结果,然后看到P1的写入结果,并将P1写入的值无限期保存下去。避免
此类难题的最简单方法是确保对同一位置执行的所有写入操作,在所有处理器看来都是同一顺序;这一特性被称为写入操作串行化。

尽管上述三条属性足以确保一致性了,但什么时候才能看到写入值也是一个很重要的问题。比如,我们不能要求在某个处理器向X中写入一个取值之后,另一个读取x的处理器能够马上看到这个写入值。比如,如果一个处理器对X的写入操作仅比另一个处理器对X的读取操作提前很短的一点时间,那就不可能确保该读取操作会返回这个写入值,因为写入值当时甚至可能还没有离开处理器。写入值到底在多久之后必须能被读取操作读到?这一问题由存储器连贯性模型回答。

一致性和连贯性是互补的:一致性确定了向同一存储器位置的读写行为,而连贯性则确定了有关访问其他存储器位置的读写行为。现在,作出以下两条假定。第一,只有在所有处理器都能看到写入结果之后,写入操作才算完成(并允许进行下一次写入)。第二,处理器不能改变有关任意其他存储器访问的任意写入顺序。这两个条件是指:如果一个处理器先写入位置A,然后再写入位置B,那么任何能够看到B中新值的处理器也必须能够看到A的新值。这些限制条件允许处理器调整读取操作的顺序,但强制要求处理器必须按照程序顺序来完成写入操作。

一致性的基本实现方案

多处理器与I/O的一致性问题尽管在起源上有些类似,但它们却有着不同的特性,会对相应的解决方案产生影响。在IO情景中很少会出现存在多个数据副本的事件(这是一个应当尽可能避免出现的事件),在多个处理器上运行的程序与此不同,它通常会在几个缓存中拥有同一数据的多个副本。在一致性多处理器中,缓存提供了共享数据项的迁移与复制。一致性缓存提供了迁移,可以将数据项移动到本地缓存中,并以透明方式加以使用。这种迁移既缩短了访问远程共享数据项的延迟,也降低了对共享存储器的带宽要求。

一致性缓存还为那些被同时读取的共享数据提供复制功能,在本地缓存中制作数据项的个副本。复制功能既缩短了访问延迟,又减少了对被读共享数据项的争用。支持迁移与复制功能对于共享数据的访问性能非常重要。因此,多处理器没有试图通过软件来避免这一问题的发生,而是采用了一种硬件解决方案,通过引入协议来保持缓存的一致性。

为多个处理器保持缓存一致性的协议被称为缓存一致性协议。实现缓存一致性协议的关键在于跟踪数据块的所有共享状态。目前使用的协议有两类,分别采用不同技术来跟踪共享状态。

  • 目录式——特定物理存储器块的共享状态保存的位置称为目录。共有两种不同类型的目录式缓存一致性,它们的差异很大。在SMP中,可以使用一个集中目录,与存储器或其他某个串行化点相关联,比如多核心中的最外层缓存。在DSM中,使用单个目录没有什么意义,因为这种方法会生成单个争用点,当多核心中拥有8个或更多个核心时,由于其存储器要求的原因,很难扩展到许多个多核芯片。分布式目录要比单个目录更复杂。
  • 监听式——如果一个缓存拥有某一物理存储器块中的数据副本,它就可以跟踪该块的共享状态,而不是将共享状态保存在同一个目录中。在SMP中,所有缓存通常都可以通过某种广播介质访问(比如将各核心的缓存连接至共享缓存或存储器的总线),所有缓存控制器都监听这一介质,以确定自己是否拥有该总线或交换访问上所请求块的副本。监听协议也可用作多芯片多处理器的一致性协议,有些设计在每个多核心内部目录协议的顶层支持监听协议!

采用微处理器(单核)的多处理器和缓存通过总线连接到单个共享存储器,所以监听协议的应用越来越多。总线提供了一种非常方便的广播介质,用于实现监听协议。在多核体系结构中,所有多核都共享芯片上的某一级缓存,所以这一状况有了大幅改变。因此,一些设计开始转而使用目录协议,因为其开销较低。为便于读者熟悉这两种协议,我们在这里重点介绍监听协议,在谈到DSM体系结构时再讨论目录协议。

监听一致性协议

有两种方法可以满足上一小节讨论的一致性需求。一种方法是确保处理器在写入某一数据项之前,获取对该数据项的独占访问。这种类型的协议被称为写入失效协议(write invalid protocol),因为它在执行写入操作时会使其他副本失效。到目前为止,这是最常用的协议。独占式访问确保在写入某数据项时,不存在该数据项的任何其他可读或可写副本:这一数据项的所有其他缓存副本都作废。

表5-2给出了一个失效协议的例子,它采用了写回缓存。为了了解这一协议如何确保一致性,我们考虑在处理器执行写入操作之后由另一个处理器进行读取的情景:由于写操作需要独占访问,所以进行读取的处理器所保留的所有副本都必须失效(这就是这一协议名称的来历)。

因此,在进行读取操作时,会在缓存中发生缺失,将被迫提取此数据的新副本。对于写入操作,我们需要执行写入操作的处理器拥有独占访问,禁止任何其他处理器同时写入。如果两个处理器尝试同时写入同一数据,其中一个将会在竞赛中获胜(稍后将会看到如何确定哪个处理器获胜),从而导致另一处理器的副本失效。另一处理器要完成自己的写入操作,必须获得此数据的新副本,其中现在必须包含更新后的取值。因此,这一协议实施了写入串行化。

  • 我们假定两个缓存在开始时都没有保存X的内容,存储器中的X值为0。处理器和存储器内容给出了完成处理器及总线操作之后的取值。空格表示没有操作或没有缓存副本。当B中发生第二次缺失时,处理器A反馈该取值,同时取消来自存储器的响应。此外,B缓存中的内容和X的存储器内容都被更新。存储器的这一更新过程是在存储器块变为共享时进行的,这种更新简化了协议,但只能在替换该块时才可能跟踪所有权,并强制进行写回。这就需要引入另外一个名为“拥有者”的状态,表示某个块可以共享,但当拥有该块的处理器在改变或替换它时,需要更新所有其他处理器和存储器。如果多核处理器使用了共享缓存(比如L3),那么所有存储器都是透过这个共享缓存看到的;在这个例子中,L3的行为就像存储器一样,一致性必须由每个核心的专用L1和L2处理。正是由于这一观察结果,一些设计人员选择在多核心处理器中使用目录协议。为使这一方法生效,L3缓存必须是包含性的。

失效协议的一种替代方法是在写入一个数据项时更新该数据项的所有缓存副本。这种类型的协议被称为写入更新或写入广播协议。由于写入更新协议必须将所有写入操作都广播到共享缓存线上,所以它要占用相当多的带宽。为此,最近的多处理器已经选择实现一种写失效协议,本章的后续部分将仅关注失效协议。

基本实现技术

实现失效协议的关键在于使用总线或其他广播介质来执行失效操作。在较早的多芯片多处理器中,用于实现一致性的总线是共享存储器访问总线。在多核心处理器中,总线可能是专用缓存(Intel Core i7中的L1和L2)和共享外部缓存(i7中的L3)之间的连接。为了执行一项失效操作,处理器只获得总线访问,并在总线上广播要使其失效的地址。所有处理器持续监听该总线,观测这些地址。处理器检查总线上的地址是否在自己的缓存中。如果在,则使缓存中的相应数据失效。

在写入一个共享块时,执行写入操作的处理器必须获取总线访问权限来广播其失效。如果两个处理器尝试同时写入共享块,当它们争用总线时,会串行安排它们广播其失效操作的尝试。第一个获得总线访问权限的处理器会使它正写入块的所有其他副本失效。如果这些处理器尝试写入同一块,则由总线实现这些写入操作的串行化。这一机制有一层隐含意思:在获得总线访问权限之前,无法实际完成共享数据项的写入操作。所有一致性机制都需要某种方法来串行化对同一缓存块的访问,具体方式可以是审行化对通信介质的访问,也可以是对另一共享结构访问的串行化。

除了使被写入缓存块的副本失效之外,还需要在发生缓存缺失时定位数据项。在直写缓存中,可以很轻松地找到一个数据项的最近值,因为所有写入数据都会发回存储器,所以总是可以从存储器中找到数据项的最新值。(对缓冲区的写入操作可能会增加一些复杂度,必须将其作为额外的缓存项目加以有效处理。)

对于写回缓存,查找最新数据值的问题解决起来要困难一些,因为数据项的最新值可能放在专用缓存中,而不是共享缓存或存储器中。令人开心的是,写回缓存可以为缓存缺失和写入操作使用相同的监听机制:每个处理器都监听放在共享总线上的所有地址。如果处理器发现自已拥有被请求缓存块的脏副本,它会提供该缓存块以回应读取请求,并中止存储器(或L3)访问。由于必须从另一个处理器的专用缓存(L1或L2)提取缓存块,所以增加了复杂性,这一提取过程花费的时间通常长于从L3进行提取的时间。由于写回缓存对存储器带宽的需求较低,所以它们可以支持更多、更快速的处理器。结果,所有多核处理器都在缓存的最外层级别使用写回缓存,接下来研究以写回缓存来实现缓存的方法。

通常的缓存标记可用于实施监听过程,每个块的有效位使失效操作的实施非常轻松。读取缺失(无论是由失效操作导致,还是某一其他事件导致)的处理也非常简单,因为它们就是依赖于监听功能的。对于写入操作,我们希望知道是否缓存了写入块的其他副本,如果不存在其他缓存副本,那在写回缓存中就不需要将写入操作放在总线上。如果不用发送写入操作,既可以缩短写入时间,还可以降低所需带宽。

若要跟踪缓存块是否被共享,可以为每个缓存块添加一个相关状态位,就像有效位和重写标志位(dirty bit)一样。通过添加1个位来指示该数据块是否被共享,可以判断写入操作是否必须生成失效操作。在对处于共享状态的块进行写入时,该缓存在总线上生成失效操作,将这个块标记为独占。这个核心不会再发送其他有关该块的失效操作。如果一个缓存块只有唯一副本,则拥有该唯一副本的核心通常被称为该缓存块的拥有者。

在发送失效操作时,拥有者缓存块的状态由共享改为非共享(或改为独占)。如果另一个处理器稍后请求这一缓存块,必须再次将状态改为共享。由于监听缓存也能看到所有缺失,所以它知道另一处理器什么时候请求了独占缓存块,应当将状态改为共享。每个总线事务都必须检查缓存地址标记,这些标记可能会干扰处理器缓存访问。减少这种干扰的一种方法就是复制这些标记,并将监听访问引导至这些重复标记。

另一种方法是在共享的L3缓存使用一个目录,这个目录指示给定块是否被共享,哪些核心可能拥有它的副本。利用目录信息,可以仅将失效操作发送给拥有该缓存块副本的缓存。这就要求L3必须总是拥有L1或L2中所有数据项的副本,这一属性被称为包含。

示例协议

监听一致性协议通常是通过在每个核心中整合有限状态控制器来实施的。这个控制器回应由核心中的处理器和由总线(或其他广播介质)发出的请求,改变所选缓存块的状态,并使用总线访问数据或使其失效。从逻辑上来说,可以看作每个块有一个相关联的独立控制器;也就是说,对不同块的监听操作或缓存请求可以独立进行。在实际实施中,单个控制器允许交错执行以不同块为目标的多个操作。(也就是说,即使仅允许同时执行一个缓存访问或一个总线访问,也可以在一个操作尚未完成之前启动另一个操作。)另外别忘了,尽管我们在以下介绍中以总线为例,但在实现监听协议时可以使用任意互连网络,只要其能够向所有一致性控制器及其相关专用缓存进行广播即可。

我们考虑的简单协议有三种状态:无效、共享和已修改。共享状态表明专用缓存中的块可能被共享,已修改状态表明已经在专用缓存中更新了这个块;注意,已修改状态隐含表明这个块是独占的。表5-3给出了由一个核心生成的请求(在表的上半部分)和来自总线的请求(在表的下半部分)。这一协议是针对写回缓存的,但可以很轻松地将其改为针对直写缓存:对于直写缓存,只需要将已修改状态重新解读为独占状态,并在执行写入操作时以正常方式更新缓存。这一基本协议的最常见扩展是添加一种独占状态,这一状态表明块未被修改,但仅有一个专用缓存保存了这个块。

  • 第四列将缓存操怍类型操作描述为正常命中或缺失(与单处理器缓存看到的情况相同)、替换(单处理器缓存督换缺失)或一致性(保持缓存一致性所需);正常操作或替换操作可能会根据这个块在其他缓存中的状态而产生一致性操作。对于由总线监听到的读取缺失、写入缺失或无效操作,仅当读取或写入地址与本地缓存中的块匹配,而且这个块有效时,才需要采取动作。

在将一个失效动作或写入缺失放在总线上时,任何一个核心,只要其专用缓存中拥有这个缓存块的副本,就会使这些副本失效。对于写回缓存中的写入缺失,如果这个块仅在一个专用缓存中是独占的,那么缓存也会写回这个块;否则,将从这个共享缓存或存储器中读取该数据。图5-3显示了单个专用缓存块的有限状态转换图,它采用了写入失效协议和写回缓存。为简单起见,我们将复制这个协议的三种状态,用以表示根据处理器请求进行的状态转换(左图,对应于表5-3的上半部分),和根据总线请求进行的状态转换(右图,对应于表5-3的下半部分)。图中使用黑体字来区分总线动作,与状态转换所依赖的条件相对。每个节点的状态代表着选定专用缓存块的状态,这一状态由处理器或总线请求指定。

图5-3 专用写回缓存的写入失效、缓存一致性协议,给出了缓存中每个块的状态及状态转换。缓存状态以圆圈表示,在状态名称下面的括号中给出了本地处理器允许执行但不会产生状态转换的访问。导致状态变换的激励以常规字体标记在转换弧上,因为状态转换而生成的总线动作以黑体标记在转换弧上。激励操作应用于专用缓存的块,而不是缓存中的特定地址。因此,在对一个共享状态的块产生读取缺失时,是对这个缓存块的缺失,而不是对不同地址的缺失。图形左侧显示的状态转换是由于此缓存相关处理器的操作而发生的,右侧显示的状态转换是根据总线上的操作而发生的。当处理器请求的地址与本地缓存块的地址不匹配时,会发生独占状态或共享状态的读取缺失以及独占状态的写入缺失。这种缺失是标准缓存替换缺失。在尝试写入处于共享状态的块时,会生成失效操作。每当发生总线事务时,所有包含此总线事务指定缓存块的专用缓存都会执行右图指定的操作。此协议假定,对于在所有本地缓存中都不需要更新的数据块,存储器(或共享缓存)会在发生对该块的读取缺失时提供数据。在实际实现中,这两部分状态图是结合在一起的。实践中,关于失效协议还有许多非常细微的变化,包括引入独占的未修改状态,说明处理器和存储器是否会在缺失时提供数据。在多核芯片中,共享缓存(通常是L3,但有时是L2)充当着存储器的角色,总线就是每个核心的专用缓存与共享缓存之间的总线,再由共享缓存与存储器进行交互

这一缓存协议的所有状态在单处理器缓存中也都是需要的,分别对应于无效状态、有效(与清洁)状态、待清理状态。在写回单处理器缓存中会需要图5-3左半部分中弧线所表示的大多数状态转换,但有一个例外,那就是在对共享块进行写入命中时的失效操作。图5-3中右半部分弧线所表示的状态转换仅对一致性有用,在单处理器缓存控制器中根本不会出现。

前面曾经提到,每个缓存只有一个有限状态机,其激励或者来自所连接的处理器,或者来自总线。图5-4说明图5-3中右半部分的状态转换如何与图中左半部分的状态转换相结合,构成每个缓存块的单一状态图。

囹5-4 缓存一致性状态图,由本地处理器引起的状态转换用黑色表示,由总线行为引起的以灰色表示。和图5-3中一样,有关转换的行为以粗体显示

为了理解这一协议为何能够正常工作,可以观察一个有效缓存块,它要么在一或多个专用缓存中处于共享状态,要么就在一个缓存中处于独占状态。只要转换为独占状态(处理器写入块时需要这一转换),就需要在总线上放置失效操作或写入缺失,从而使所有本地缓存都将这个块记为失效。另外,如果其他某个本地缓存已经将这个块设为独占状态,这个本地缓存会生成写回操作,提供包含期望地址的块。最后,对于处于独立状态的块,如果总线上出现对这个块的读取缺失,拥有其独占副本的本地缓存会将其状态改变共享。

图5-4中用灰色表示的操作用来处理总线上的读取缺失与写入缺失,它们实际上就是协议的监听部分。在这个协议及大多数其他协议中,还保留着另外一个特性:任何处于共享状态的存储器块在其外层共享缓存(L2或L3,如果没有共享缓存就是指存储器)中总是最新的,这一特性简化了实施过程。事实上,专用缓存之外的层级是共享缓存还是存储器并不重要;关键在于来自核心的所有访问都要通过这一层级。

尽管这个简单的缓存协议是正确的,但它省略了许多复杂因素,这些因素大大增加了实施过程的难度。其中最重要的一点是,这个协议假定这些操作具有原子性——在完成一项操作的过程中,不会发生任何中间操作。例如,这里讨论的协议假定可以采用单个原子动作形式来检测写入缺失、获取总线和接收响应。现实并非如此。事实上,即使读取缺失也可能不具备原子性;在多核处理器的L2中检测到缺失时;这个核心必须进行协调,以访问连到共享L3的总线。

非原子性操作可能会导致协议死锁,也就是进入一种无法继续执行的状态。对于多核处理器,处理器核心之间的一致性都在芯片上实施,或者使用监听协议,或者使用简单的集中式目录协议。许多双处理器芯片,包括Intel Xeon和AMD Opteron,都支持多芯片多处理器,这些多处理器可能通过连接高速接口(分别称为Quickpath或Hypertransport)来构建。这些下一级别的互连并不只是共享总线的扩展,而是使用了一种不同方法来实现多核互连。

用多个多核芯片构建而成的多处理器将采用分布式存储器体系结构,而且需要一种机制来保持芯片间的一致性,这一机制要高于、超越于芯片内部的此种机制。在大多数情况下,会使用某种形式的目录机制。

基本一致性协议的扩展

我们刚刚介绍的一致性协议是一种简单的三状态协议,经常用这些状态的第一个字母来称呼这一协议——MSI(Modified、Shared、Invalid)协议。这一基本协议有许多扩展,在本节图形标题中提到了这些扩展。这些扩展是通过添加更多的状态和转换来创建的,这些添加内容对特定行为进行优化,可能会使性能得到改善。下面介绍两种最常见的扩展。

  • MESI向基本的MSI协议中添加了“独占”(Exclusive)状态,用于表示缓存块仅驻存在一个缓存中,而且是清洁的。如果块处于独占状态,就可以对其进行写入而不会产生任何失效操作,当一个块由单个缓存读取,然后再由同一缓存写入时,可以通过这一独占状态得以优化。当然,处于独占状态的块产生读取缺失时,必须将这个块改为共享状态,以保持一致性。因为所有后续访问都会被监听,所以有可能保持这一状态的准确性。具体来说,如果另一个处理器发射一个读取缺失,则状态会由独占改为共享。添加这一状态的好处在于:在由同一核心对处于独占状态的块进行后续写入时,不需要访问总线,也不会生成失效操作,因为处理器知道这个块在这个本地缓存中是独占的;处理器只是将状态改为已修改。添加这一状态非常简单,只需要使用1个位对这个一致状态进行编码,表示为独占状态,并使用重写标志位表示这个块已被修改。流行的MESI协议就采用了这一结构,这一协议是用它所包含的4种状态命名的,即已修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid)。Inteli7 使用了MESI协议的一种变体,称为MESIF,它添加了一个状态(Forward),用于表示应当由哪个共享处理器对请求作出回应。这种协议设计用来提高分布式存储器组成结构的性能。
  • MOESI向MESI协议中添加了“拥有”(Owned)状态,用于表示相关块由该缓存拥有,在存储器中已经过时。在MSI和MESI协议中,如果尝试共享处于“已修改”状态的块,会将其状态改为“共享”(在原共享缓存和新共享缓存中都会做此修改),必须将这个块写回存储器中。而在MOESI协议中,会在原缓存中将这个块的状态由“已修改”改为“拥有”,不再将其写到存储器中。(新共享这个块的)其他缓存使这个块保持共享状态;只有原缓存保持“拥有”
    状态,表示主存储器副本已经过期,指定缓存成为其拥有者。这个块的拥有者必须在发生缺失时提供该块,因为存储器中没有最新内容,如果替换了这个块,则必须将其写回存储器中。AMD Opteron使用了MOESI协议。

对称共享存储器多处理器与监听协议的局限性

随着多处理器中处理器数目的增长,或随着每个处理器对存储器需求的增长,系统中的任何集中式资源都可能变成瓶颈。利用片上提供的更高带宽的连接以及共享的L3缓存(它的速度要比存储器快),设计人员可以尝试以对称形式支持4~8个高性能核心。这种方法不太可能扩展到远远超过8个核心的情况,一旦合并了多个多核心处理器,这种方法就无效了。每个缓存的监听带宽也可能产生问题,因为每个缓存必须检查总线上的所有缺失。我们曾
经提到,复制标记是一种解决方案。

另一种方法是在最外层缓存层级放置一个目录,这一方法已经在最近的某些多核处理器中得到应用。这个目录明确指出哪个处理器的缓存拥有最外层缓存中每一项的副本。这就是Intel在i7和Xeon 7000系统中使用的方法。注意,这个目录的使用不会消除因为处理器之间的共享总线及L3造成的瓶颈,但它实现起来要远比将在5.4节研究的分布式目录机制容易。

设计者如何提高存储器带宽,以支持更多或更快的处理器呢?为了提高处理器与存储器之间的通信带宽,设计者已经采用了多根总线和互连网络,比如交叉开关或小型点对点网络。在此类设计中,可以将存储器系统(主存储器或共享缓存)配置为多个物理组,以提升有效存储器带宽,同时还保持存储器访问时间的一致性。图5-5展示了在使用单芯片多核心来实现系统时,它会是什么样子。尽管利用这种方法可以在一块芯片上实现4个以上核心的互连,但它不能很好地扩展到那些使用多核心构建模块的多芯片多处理器,因为存储器已经连接到各个多核心芯片上,不再是集中式存储器。


图5-5 一种多核心单芯片多处理器,通过分组共享缓存实现一致存储器访问,使用互连网络而不是总线

AMD Opteron表示监听协议与目录协议之间的另一个中间点。存储器被直接连接到每个多核芯片,最多可以连接4个多核心芯片。其系统为NUMA,因为本地存储器多少会更快一些。Opteron使用点对点连接实现其一致性协议,最多向其他3个芯片进行广播。因为处理器之间的链接未被共享,所以一个处理器要想知道失效操作何时完成,唯一的方法就是通过显式确认。因此,一致性协议使用广播来查找可能共享的副本,这一点与监听协议类似,但它却使用确认来确定操作,这一点与目录协议类似。由于在Opteron实现中,本地存储器仅比远程存储器快一点点,所以一些软件把Opteron多处理器看作拥有一致存储器访问。

监听缓存一致性协议可以在没有集中式总线的情况下使用,但仍然需要完成广播,以监听各个缓存,获知潜在共享缓存块的所有缺失。这种缓存一致性通信是处理器规模与速度的另一限制。由于采用较大缓存并不会影响一致性通信,所以当处理器速度很快时,肯定会超出网络的负荷,每个缓存无法响应来自所有其他缓存的监听请求。

当处理器速度以及每个处理器的核心数目增大时,更多的设计人员会选择此类协议来避免监听协议的广播限制。

实施监听缓存一致性

2011年,大多数仅支持单芯多处理器的多核处理器已经选择使用共享总线结构,连接到共享存储器或共享缓存。相反,所有支持16个或更多个核心的多核多处理器系统都使用互连网络,而不是单根总线,设计人员必须面对一项挑战:在实现监听时,没有为实现事件的串行化而简化总线。前面曾经说过,在实际实现前面介绍的监听一致性协议时,最复杂的部分在于:在最近的所有多处理器中,写入缺失与更新缺失都不是原子操作。检测写入或更新缺失、其他处理器与存储器通信、为写入缺失获取最新值、确保所有失效操作可以正常进行、更新缓存,这些步骤不能在单个时钟周期内完成。

在单个多核心芯片中,如果(在改变缓存状态之前)首先协调连向共享缓存或存储器的总线,并在完成所有操作之前保持总线不被释放,那就可以有效地使上述步骤变成原子操作。处理器怎么才能知道所有失效操作何时完成呢?在一些多核处理器中,当所有必要失效操作都已收到并在进行处理时,会使用单根信号线发出信号。收到这一信号之后,生成缺失的处理器就可以释放总线,因为它知道在执行与下一次缺失相关的任意行为之前,可以完成所有必要操作。

只要在执行这些步骤期间独占总线,处理器就能有效地将各个步骤变为原子操作。在没有总线的系统中,我们必须寻找其他某种方法,将缺失过程中的步骤变为原子操作。具体来说,必须确保两个尝试同时写入同一数据块的处理器(这种情景称为竞争)保持严格排序:首先处理一个写入操作,然后再开始执行下一个。这两次写入操作中的哪一个操作会赢得竞争并不重要,因为只会有一个获胜者,它的一致性操作将被首先完成。在监听系统中,为了确保一次竞争仅有一个获胜者,会广播所有缺失,并利用互连网络的一些基本性质。这些性质,以及重启竞争失败者缺失处理的能力,是在无总线情况下实现监听缓存一致性的关键。

还可以将监听式与目录式结合在一起,有一些设计在多核处理器内部使用监听式,在多个芯片之间使用目录式,或者反过来,在多核处理器内部使用目录式,在多个芯片之间使用监听式。

对称共享存储器多处理器的性能

在使用监听式一致性协议的多核处理器中,其性能通常由几种不同现象共同决定。具体来说,总体缓存性能由两个因素共同决定,一个是由单处理器缓存缺失造成的流量,另一个是通信传输导致的流量,它会导致失效及后续缓存缺失。改变处理器数目、缓存大小和块大小能够以不同方式来影响缺失率的这两个分量,最终得到受这两种因素共同影响的总体系统性能。

对单处理器缺失率进行3C分类,即容量(capacity)、强制(compulsory)和冲突(conflict),并深入讨论了应用特性和对缓存设计的可能改进。与此类似,因为处理器之间的通信而导致的缺失(经常被称为一致性缺失)可以分为两种独立源。

第一种来源是所谓的真共享缺失,源自通过缓存一致性机制进行的数据通信。在基于失效的协议中,处理器向共享缓存块的第一次写入操作会导致失效操作,以确保对这个块的拥有关系。此外,当另一处理器尝试修改这个缓存块中的已修改字时,要发生缺失,并传送结果块。由于这两种缺失都是因为处理器之间的数据共享而直接导致的,所以将其划分为真共享缺失。

第二种效果称为假共享缺失,它的出现是因为使用了基于失效的一致性算法,这种算法利用了数据块的有效位,每个缓存块只有一个有效位。如果因为写入块中的某个字(不是正被读取的字)而导致一个块失效(而且后续引用会导致失败),就会发生假共享。如果接收到失效操作的处理器真的正在使用要写入的字,那这个引用就是真正的共享引用,无论块大小如何都会导致缺失。但是,如果正被写入的字和读取的字不同,那就不会因为这一失效操作而传送新值,而只是导致一次额外的缓存缺失,所以它是假共享缺失。在假共享缺失中,块被共享,但缓存中的字都没有被实际共享,如果块大小是单个字,那就不会发生缺失。通过下面的例子可以理解这些共享模式。

假定x1和x2两个字位于同一缓存块中,这个块在P1和P2的缓存中均为共享状态。假定有以下一系列事件,确认每个缺失是真共享缺失,还是假共享缺失,或是命中。如果块大小为一个字节,那么所发生的所有缺失都被认定为真共享缺失。

时序 P1 P2
1 写x1
2 读x2
3 写x1
4 写x2
5 读x2

下面是按时序进行的分类。

  1. 这一事件是真共享缺失,因为x1由P2读取,需要由P2发出失效操作。
  2. 这一事件是假共享缺失,因为x2是由于P1中写入x1而导致失效的,但P2并没有使用x1的值。
  3. 这一事件是假共享缺失,因为包含x1的块是因为P2中的读取操作而被标记为共享状态的,但P2并没有读取x1。在P2读取之后,包含x1的缓存块将处于共享状态;需要一次写入缺失才能获取对该数据块的独占访问。在一些协议中,会将这种情况作为更新请求进行处理,它会生成总线失效,但不会传送缓存块。
  4. 这一事件是假共享缺失,原因与步骤3相同。
  5. 这一事件是真共享缺失,因为正在读取的值是由P2写入的。

尽管我们将会看到真假共享缺失在商业工作负载中的影响,但对于共享大量用户数据的紧耦合应用程序来说,一致性缺失的角色更重要一些。

商业工作负载

在这一节,我们将研究一个四处理器共享存储器多处理器在运行通用商业工作负载时的存储器系统特性。我们讨论的这一研究是在1998年用一个四处理器Alpha系统完成的,但对于一个多处理器在执行此类工作负载时的性能问题,这一研究仍然最全面、最深入。

这一研究中使用的工作负载包括以下3个应用程序。

  1. 根据TPC-B(其存储器特性类似于它的较新版本TPC-C)建模的联机事务处理(OLTP)工作负载,并以Oracle 7.3.2 为底层数据库。
  2. 基于TPC-D的决策支持系统(DSS)工作负载,(TPC-D是广泛使用的TPC-E的较早版本),这一工作负载也以Oracle 7.3.2为底层数据库。
  3. Web索引搜索(AltaVista)基准测试,其基础是对AltaVista 数据库存储器映射版本(200GB)的搜索。深入优化了内层循环。因为搜索结构是静态的,所以线程之间几乎不需要同步。

表5-5显示了在用户模式、内核和空间循环中所用时间的百分比。I0的频率会同时增加内核时间和空闲时间。AltaVista 将整个搜索数据库映射到存储器中,而且经过了广泛调优,它的内核时间或空闲时间最少。

商业工作负载的性能测量

我们先来看看这些基准测试在四处理器系统中的总体处理器执行情况,这些基准测试包括大量的I/O时间,在处理器时间测试数据中忽略了这些时间。我们将6个DSS查询看作一个基准测试,报告其平均特性。这些基准测试的实际CPI变化很大,从AltaVista Web搜索的1.3到DSS工作负载的平均1.6,再到OLTP工作负载的7.0。图5-6显示了如何将执行时间分解为指令执行时间、缓存与存储器系统访问时间及其他停顿(主要是流水线资源停顿,但也
包括转换旁枧缓冲区(TLB)和分支预测错误停顿)。尽管DSS与AltaVista工作负载的性能处于合理范围内,但0LTP工作负载的性能非常差,这是由于存储器层次结构的性能过差所致。

由于OLTP工作负载对存储器系统的要求更多,而且存在大量成本高昂的L3缺失,所以我们主要研究L3缓存大小、处理器数目和块大小对OLTP基准测试的影响。图5-7显示了增大缓存大小的影响,使用两路组相联缓存,缩减大量冲突缺失。随着L3缓存的增大,执行时间会因为L3缺失的减少而缩短。令人惊讶的是,几乎所有这些改进都是在1~2MB范围内发生的,超过这一范围之后,尽管当缓存为2MB和4MB时,缓存缺失仍然是造成大幅性能损失的愿因,但几乎没有多少改进了。问题是为什么呢?


图5-6 3个程序(OLTP、DSS和AltaVista)在商业工作负载中的执行时间分解。DSS数字是6个不同查询的平均值。CPI 的变化很大,从AltaVista较低的1.3,到DSS查询的1.61,再到OLTP的7.0。“其他停顿”包括资源停顿(用21164上的重放陷阱实现)、分支预测错误、存储器屏障和TLB缺失。对于这些基准测试,因为资源而导致的流水线停顿是主要因素。这些数据结合了用户与内核访问的行为。只有OLTP的内核访问占有重要比例,内核访问的表现要优于用户访问!


图5-7 在L3缓存大小变化时,OLTP工作负载的相对性能,L3缓存设定为两路组相联,从1 MB增大到8 MB。空闲时间随缓存大小的增大而延迟,降低了一些性能增益。这一增长是因为当存储器系统停顿较少时,需要更多的服务器进程来隐藏IO延迟。可以重新调整工作负载,以提高计算/通信平衡性能,将空闲时间保持在可控范围内。PAL代码是一组以优先模式执行的专用操作系统级指令序列;TLB缺失处理程序就是这样一个例子

为了更好地理解这个问题的答案,我们需要确定造成L3缺失的因素,以及当L3缓存增长时,它们是如何变化的。图5-8给出了这些数据,显示了来自5个来源的每条指令所造成的存储器访问周期数。当L3的大小为1MB时,L3存储器访问周期的两个最大来源是指令和容量/冲突缺失,当L3较大时,这两个来源降低为次要因素。遗憾的是,强制、假共享和真共享缺失不受增大L3的影响。因此,在4 MB和8 MB时,真共享缺失占主导地位;当L3缓存大小超过2MB时,由于真共享缺失没有变化,从而限制了总体缺失率的减少。

图5-8 当缓存大小增加时,占用存储器访问周期的各项因素会发生偏移。L3 缓存被模拟为两路组相联

增大缓存大小可以消除大多数单处理器缺失,但多处理器缺失不受影响。增大处理器数目如何影响各种不同类型的缺失呢?图5-9给出了这些数据,其中假定所采用的基本配置为2 MB、两路组相联L3缓存。可以预期,真共享缺失率的增加(降低单处理器缺失不会对其有所补偿)导致每条指令的存储器访问周期增大。

我们研究的最后一个问题是:增大块大小对这一工作负载是否有所帮助(增大块大小应当能够降低指令和冷缺失率,在限度范围内,还会降低容量/冲突缺失率,并可能降低真共享缺失率)。图5-10显示了当块大小由32字节变化到256字节时,每千条指令的缺失数目。将块大小由32字节变化到256字节会影响到4个缺失率分量。

  • 真共享缺失率的降低因数大于2,表示真共享模式中存在某种局域性。
  • 强制缺失率显著降低,与我们的预期一致。
  • 冲突/容量缺失有小幅降低(降低因数为1.26,而在增大块大小时的降低因数为8),表示当L3缓存大于2MB时所发生的单处理器缺失没有太高的空间局域性。
  • 假共享缺失率接近翻番,尽管其绝对数值较小。

对指令缺失率缺乏显著影响,这一事实是令人惊讶的。如果有一个仅包含指令的缓存具备这一特性,那就可以得出结论:其空间局域性非常差。在采用混合L2缓存时,诸如指令数据冲突之类的其他影响也可能会导致较大块中产生较高的指令缓存缺失。其他研究已经表明,在大型数据库和OLTP工作负载(它们有许多小的基本块和专用代码序列)的指令流中,空间局域性较低。根据这些数据,可以将块大小为32字节的L3的缺失代价作为基准,将块大小较大的L3的缺失代价表示为前者的倍数。


图5-9当处理器数目增大时,存储器访问周期的各项导致因素因为真共享缺失的培加而增加。由于每个处理器现在必须处理更多的强制缺失,所以强制缺失会稍有增加


图5-10当L3缓存的大小增加时,每千条指令的缺失数目稳定下降,所以L3块大小至少应当为128字节。L3缓存的大小为2 MB,两路组相联

由于现代DDR SDRAM加快了块访问速度,所以这些数字看起来是可以实现的,特别是块大小为128 字节的情况。当然,我们还必须考虑增加存储器通信量以及与其他核心争用存储器的影响。后一效果可能很轻松地抵销通过提高单个处理器性能而获得的增益。

多重编程和操作系统工作负载

我们的下一项研究是包括用户行为和操作系统行为的多重编程工作负载。所使用的工作负载是Andrew基准测试编译阶段的独立副本,这一基准测试模拟了软件开发环境。其编译阶段使用8个处理器执行Unix make命令的一个并行版本。这一 工作负载在8个处理器上运行5.24秒,生成203个进程,对3个不同文件系统执行787次磁盘请求。运行此工作负载使用了128 MB存储器,没有发生分页行为。

此工作负载有3个截然不同的阶段:

  • 编译基准测试,涉及大量计算行为;
  • 将目标文件安装到一个库中;
  • 删除目标文件。

最后一个阶段完全由I/O操作主导;只有两个进程是活动的(每个运行实例有一个进程)。在中间阶段,I/O也扮演着重要角色,处理器大多处于空闲状态。与经过仔细调优的商业工作负载相比,这个总体工作负载涉及的系统操作和IO操作要多得多。

为进行工作负载的测量,我们假定有以下存储器和I/O系统。

  • 第一级指令缓存——32 KB,两路组相联,块大小为64字节,命中时间为1个时钟周期。
  • 第一级数据缓存——32 KB,两路组相联,块大小为32字节,命中时间为1个时钟周期。我们改变L1数据缓存,以研究它对缓存特性的影响。
  • 第二级缓存——1 MB一致缓存,两路组相联,块大小为128字节,命中时间为1个时钟周期。
  • 主存储器——总线上的唯一存储器,访问时间为100个时钟周期。
  • 磁盘系统——固定访问延迟为3 ms (小于正常值,以缩短空闲时间)。

表5-6显示如何针对使用上述参数的8个处理器来分解其执行时间。执行时间被分解为以下4个分量。

  1. 空闲——在内核模式空闲循环中执行。
  2. 用户——以用户模式执行。
  3. 同步——执行或等待同步 变量。
  4. 内核——在既未处于空闲状态也没有进行同步访问的操作系统中执行。

表5-6 多重编程并行“make”工作负载中执行时间的分布

  • 当8个处理器中仅有1个处于活动状态时,空闲时间之所以占很大比例是因为磁盘延迟的原因。

这一多重编程工作负载的指令缓存性能损失非常明显,至少对操作系统如此。当块大小为64字节、采用两路组相联缓存时,操作系统中的指令缓存缺失率由32 KB缓存的1.7%变为256 KB缓存的0.2%。对于各种缓存大小,用户级指令缓存缺失大体为操作系统缺失率的六分之一。这一点部分解释了如下事实:尽管用户代码执行的指令数为内核的9倍,但这些指令占用的时间仅为内核所执行少量指令的4倍。

多重编程和操作系统工作负载的性能

在这一小节,我们研究多重编程工作负载在缓存大小、块大小发生变化时的缓存性能。由于内核特性与用户进程性能之间的差异,我们将这两个分量保持分离。别忘了,用户进程执行的指令是内核的8倍,所以整体缺失率部分由用户代码中的缺失率决定,后面将会看到,这一缺失率通常是内核缺失率的五分之一。

尽管用户代码执行更多的指令,但与用户进程相比,操作系统的特性可能导致更多的缓存缺失,除了代码规模较大和缺少局域性之外,还有两个原因。第一,内核在将页面分配给用户之前,会对所有页面进行初始化。第二,内核实际上是共享数据的,因此其一致性缺失率不可忽视。与之相对,用户进程只有在不同处理器上调度进程时才会导致一致性缺失,而这一部分缺失率是很小的。

图5-11给出了当数据缓存大小、块大小变化时,数据缺失率的内核及用户部分。增大数据缓存大小对用户缺失率的影响要大于对内容缺失率的影响。增大块大小对于两种缺失率都有正面影响,这是因为很大一部分缺失是因为强制和容量导致,这两者都可能通过增大块大小加以改进。由于一致性缺失相对来说更为罕见,所以增大块大小的负面影响很小。为了了解内核与用户进程的行为为什么会不同,我们可以看看内核缺失是如何表现的。


图5-11 在增大L1数据缓存大小(左图)及增大L2数据缓存块大小时(右图),数据缺失率的用户分量及内核分量表现不同。将L1数据缓存由32KB增大到256KB(块大小为32字节)导致用户缺失率的降低大于内核缺失率:用户级缺失率的下降因数大约为3,而内核级缺失率的下降因数仅为13。当L1块大小增大时(保持L1缓存为32 KB),缺失率的用户分量及内核分量都会稳定下降。与增大缓存大小的影响相对,增大块大小会显著降低内核缺失率(当块大小由16字节变为128字节时,内核引用的下降因数仅略低于4,而用户引用则略低于3)

图5-12显示了缓存大小及块大小增大时,内核缺失的变化。这些缺失被分为三类:强制缺失、一致性缺失(由真、假共享导致)和容量/冲突缺失(包括由于操作系统与用户进程之间和多个用户进程之间的干扰所导致的缺失)。图5-12证明:对于内核引用,增大缓存大小只会降低单处理器容量/冲突缺失率。与之相对,增大块大小会导致强制缺失率的降低。当块大小增大时,一致性缺失率没有大幅增大,这意昧着假共享效率可能不是很明显,尽管此类缺失可能会抵消通过降低真共享缺失所带来的增益。


图5-12 在8个处理器上运行多重编程工作负载,当L1数据缓存大小由32 KB变化为256 KB时,内核数据缺失率分量的变化。强制缺失率分量保持不变,因为它不受缓存大小的影响。容量分量的下降因数大于2,而一致性分量几乎翻番。一致性缺失增大的原因在于:发生冲突的项目会由于容量原因而变少,所以失效操作导致发生缺失的可能性会随着缓存大小的增大而增大。可以预料,L1 数据缓存的块大小增加会大幅降低内核引用中的强制缺失率。它对容量缺失率也有显著影响,在块大小的变化范围中,这一缺失率的降低因数为2.4。增加块大小只能少量减少一致性通信流量,它在64字节时稳定下来,在变为128字节时,一致性缺失率没有变化。由于当块大小增加时一致性缺失率没有显著降低,所以因为一致性所导致的缺失率部分由大约7%增长到大,约15%

如果我们研究每次数据引用所需要的字节数,如图5-13所示,可以看到内核的通信流量比较高,会随着块大小的增加而增加。很容易就能看出其原因:当块大小由16字节变为128字节时,缺失率大约下降3.7,但每次缺失传送的字节数增大8倍,所以总缺失通信量仅提高2倍多一点。当块大小由16字节变为128字节时,用户程序的增大也会超过2倍,但它的起始水平要低得多。

对于多重编程工作负载,操作系统对存储器系统的要求要严格得多。如果工作负载中包含了更多的操作系统行为或类似于操作系统的行为,而且其特性类似于这一工作负载测量的结果,那就很难构建貝有足够功能的存储器系统。一种可能是提高性能的方法是让操作系统更多地了解缓存,可能是通过更好的编程环境,也可能通过程序员的帮助来实现。例如,操作系统会为不同系统调用发出的请求而重复利用存储器。尽管被重复利用的存储器将被完全改写,但硬件并没有意识到这一点,它会尝试保持一致性,即使缓存块不会被读取,也会坚持认为存在这一可能性。这一行为类似于在过程调用时重复利用栈位置。IBM Power系列就已经允许编译器在过程调用时指示这种行为类型,最新的AMD处理器也提供类似支持。系统是很难检测这种行为的,所以可能需要程序员提供帮助,其回报可能要大得多。

操作系统与商业工作负载对多处理器存储器系统提出了非常严酷的挑战,而且它们与科学应用程序不同,不太适合进行算法或编译器重构。随着核心数目的增加,预测此类应用程序的行为也会变得更为困难。一些模拟或仿真技术可以用大型应用程序(包括操作系统)对数百个核心进行仿真,它们对于坚持设计的分析与量化方法至关重要。


图5-13当块大小增加时,对于内核分量与用户分量,每次数据引用所需要的字节数据会增加。

分布式共享存储器和目录式一致性

我们在5.2节讨论过,监听协议在每次发生缓存缺失时都需要与所有缓存进行通信,包括对共享数据进行的写入操作。监听式机制没有任何用于跟踪缓存状态的集中式数据结构,这是它的一个基本优点(因为这样可以降低成本),但考虑到可伸缩性时,这也成了它的“阿基里斯脚跟”。例如,考虑一个由四核多核心组成的多处理器,它能够保持每个时钟周期一次数据引用的速率,时钟速率为4 GHz。

尽管这些实验中的缓存很小,但大多数通信量都是一致性通信流量,不受缓存大小的影响。尽管现代总线可以达到4 GB/s的带宽,但170 GB/s还是远远超过了任何总线式系统的能力。在最近几年中,多核处理器的发展迫使所有设计人员转向某种分布式存储器,以支持各个处理器的带宽要求。

我们可以通过分布式存储器来提高存储器带宽和互连带宽,如图5-2 所示;这样会立刻将本地存储器通信与远程存储器通信隔离开来,降低了对存储器系统和互连网络的带宽要求。除非不再需要一致性协议在每次缓存缺失时都进行广播,否则通过分布式存储器不会有太大收益。如前所述,监听式一致性协议的替代方法是目录式协议。目录中保存了每个可缓存块的状态。这个目录中的信息包括哪些缓存(或缓存集合)拥有这个块的副本,它是否需要更新,等等。在一个拥有共享最外层缓存(即L3)的多核心中,实现目录机制比较容易:只需要为每个L3块保存一个位向量,其大小等于核心的数目。这个位向量表示哪些专用缓存的L3中可能拥有一个块的副本,失效操作仅会发送给这些缓存。如果L3是包含性的,那这一方法对于单个多核心是非常有效的,在Intel i7中就是采用了这一机制。

在多核心中使用单个目录时,即使它能避免广播,这种解决方案也是不可能扩展的。这个目录必须是分布式的,但其分布方式必须能够让一致性协议知道 去哪里寻找存储器所有缓存块的目录信息。一个容易想到的解决方案是将这个目录与存储器分布在一起,使不同一致性请求可以进入不同目录,就像不同存储器请求进人不同存储器一样。分布式目录保留了如下特性:块的共享状态总是放在单个已知位置。利用这一性质,再另外维护一些信息 ,指出其他哪些节点可能缓存这个块,就可以让一致性协议避免进行广播操作。图5-14显示了在向每个节点添加目录时,分布式存储器多处理器的样子。


图5-14 向每个节点添加一个目录,以在分布式存储器多处理器中实施缓存一致性。在本例中,节点被显示为单个多核芯片,相关存储器的目录信息可能驻存在多核心处理器的内部,也可能在其外部。每个目录负责跟踪一些缓存,这些缓存共享该节点内部部分存储器的存储器地址。一致性机制可能会维护多核心节点内部的目录信息,并处理所需要的一致性操作

最简单的目录实现方法是将每个存储器块与目录中的一项相关联。在这种实现方式中,信息量与存储器块数(每个块的大小与L2或L3缓存块相同)和节点数的乘积成正比,其中一个节点就是在内部实施一致性的单个多核心处理器或一小组处理器。对于处理器少于数百个的多处理器而言(每个处理器可能是多核的),这一开销不会导致问题,因为当块大小比较合理时,目录幵销是可以忍受的。对于大型多处理器,需要一些方法来高效地扩展目录结构,不过,只有超级计算机规模的系统才需要操心这一点。

目录式缓存一致性协议:基础知识

和监听式协议一样,目录式协议也必须实现两种主要操作:处理读取缺失和处理共享、清洁缓存块的写入操作。(对于当前正被共享的块,其写入缺失的处理就是上述两种操作的组合。)为实现这些操作,目录必须跟踪每个缓存块的状态。在简单协议中,这些状态可能为下列各项之一。

  • 共享:一或多个节点缓存了这个块,存储器的值是最新的(所有缓存中也是如此)。,
  • 未缓存:所有节点都没有这个缓存块的副本。
  • 已修改:只有一个节点有这个缓存块的副本,它已经对这个块进行了写操作,所以存储器副本已经过期。这个处理器被称为这个块的拥有者。

除了跟踪每个潜在共享存储器块的状态之外,我们还必须跟踪哪些节点拥有这个块的副本,在进行写入操作时需要使这些副本失效。最简单的方法是为每个存储器块保存一个位向量,当这个块被共享时,这个向量的每一位指明相应的原处理器芯片(它可能是一个多核心)是否拥有这个块的副本。当存储器块处于独占状态时,我们还可以使用这个位向量来跟踪块的拥有者。为了提高效率,还会跟踪各个缓存中每个缓存块的状态。

每个缓存中状态机的状态与转换都和监听缓存中使用的状态机相同,只不过在发生转换时的操作稍有不同。用于定位一个数据项独占副本并使其失效的过程有所不同,因为它们需要在发出请求的节点与目录之间进行通信,在目录与一或多个远程节点进行通信。在监听式协议中,这两个步骤通过向所有节点进行广播而结合在一起。

在查看这种协议的状态图之前,先来研究一下为了处理缺失和保持一致性而可能在处理器和目录之间传送的消息类型,这样会有所帮助。表5-7给出了节点之间发送的消息类型。本地节点是发出请求的节点。主节点(home node)就是一个地址的存储器位置及目录项所在的节点。物理地址空间是静态分布的,所以事先知道哪个节点中包含给定物理地址的存储器与目录。例如,高阶位可以提供节点编号,低阶位提供节点上存储器内的偏移。本地节点也可能是主节点。当主节点是本地节点时,由于副本可能存储于第三节点上(称为远程节点),所以必须访问该目录。

表5-7在节点之间为保证一致性而发送的可能消息,以及源节点和目标节点、消息内容(p=发出请求的节点编号,A=所请求的地址,D=数据内容),和消息的功能

  • 前3条消息是由本地节点发送到主节点的请求。第四个到第六个是当主节点需要数据来满足读取缺失或写入缺失请求时,向远程节点发送的消息。数据应答消息用于由主节点向发出请求的节点传送一个取值。在两种情况下需要对数据值执行写回操作:一种情况是,如果替换了缓存中的一个数据块,且必须写回到它的主存储器中;另一种情况是,对来自主节点的取数据消息或取数据/失效消息做应答时。只要数据块处于共享状态就执行写回操作,这祥能简化协议中的状态数目,这是因为任何脏数据块必须处于独占状态并且任何共享块总是可以在主存储器中获取。

远程节点是拥有缓存块副本的节点,这一副本可能独占(在此情况下只有一个副本),也可能共享。远程节点也可能与本地节点或主节点相同。在此类情况下,基本协议不会改变,但处理器之间的消息可能会被处理器内部的消息代替。

在这一节,我们采用存储器一致性的一种简单模型。为了在最大程度上减少这种类型的消息及协议的复杂性,我们假定这些消息的接受及处理顺序与其发送顺序相同。这一假定在实际中并不成立,可能会导致额外的复杂性。在这一节,我们利用这一假定来确保在传送新消息之前先处理节点发送的失效操作,就像在讨论监听式协议时的假设一样。和在监听情景中一样,我们省略了一些实现一致性协议所必需的细节。具体来说,要想实现写入操作的串行化,并获知某写入的失效操作已经完成,并不像广播式监听机制那样轻松,而是需要采用明确的确认方法来回应写入缺失和失效请求。

目录式协议举例

目录式协议中缓存块的基本状态与监听式协议中完全相同。目录中的状态也与我们前面展示的状态类似。因此,我们首先看一个简单的状态图,它给出了一个具体缓存块的状态转换,然后再研究与存储器中每一个块相对应的目录项的状态图。和监听情景中一样,这些状态转换图并没有给出一致性协议的所有细节;但是,实际控制器调度依赖于多处理器的大量细节(消息发送特性、缓冲结构,等等)。在这一节,我们给出了基本的协议状态图。

图5-15显示了一个具体缓存对应的协议操作。所使用的符号与上一节相同,来自节点外部的请求用灰色表示,操作用黑色表示。一个具体缓存的状态转换由读取缺失、写入缺失和状态提取请求导致;图5-15显示了这些操作。一个具体缓存也会生成这些读取缺失、写入缺失和失效消息,它们会被发送给主目录。读取缺失与写入缺失要求数值回复,这些事件在改变状态之前会等待回复。如何知道失效操作何时完成,那是另一个问题,另行处理。


图5-15 目录式系统中一个具体缓存块的状态转换图。本地处理器的请求用黑色表示,来自主目录的请求用灰色表示。这些状态与监听式系统中相同,而且事务非常类似,用显式失效与写回请求来代替向总线正式广播的写入缺失。与监听控制器中一样,我们仍然假定在尝试写入共享缓存块时将被作为缺失而进行处理;在实践中,这样的事务可以看作拥有权请求或升级请求,可以在未请求所提取缓存块的同时提交拥有权

图5-15中缓存块状态转换图的操作基本上与监听情景中一样:状态是相同的,激励也几乎相同。写入缺失操作由数据提取和失效操作替代,失效操作由目录控制器选择性地发送,而在监听机制中,写入缺失操作是在总线(或其他网络)上广播的。与监听协议一样,在写入缓存块时,它必须处于独占状态,所有共享块都必须在存储器中进行更新。在许多多核处理器中,在核心之间共享处理器缓存的最外层级,处于这一级别的硬件将在同一芯片上每个核心的专用缓存之间保持一致性,或者使用内部目录实现,或者使用监听实现。因此,只需要与最外层共享缓存进行交互,就能使用芯上多核一致性机制在大量处理器之间扩展一致性。因为这一交互是在L3层级进行的,所以处理器与一致性请求之间的争用就不会导致问题,也可以避免标签的复制。

在目录式协议中,目录实现了一致性协议的另一半。发向目录的一条消息会导致两种不同类型的操作:更新目录状态;发送附加消息以满足请求。目标中的状态表示一个块的三种标准状态;但与监听机制中不同的是,目录状态表示一个存储器块所有缓存副本的状态,而不是表示单个缓存块的相应信息。

存储器块可能未由任何节点缓存,可能缓存于多个节点中并可读(共享),也可能仅在一个节点中独占缓存并可写。除了每个块的状态之外,目录还会跟踪拥有某一缓存块副本的节点集合;我们使用名为共享器的集合来执行这一功能。在节点数少于64的多处理器中(每个节点可能表示4~8倍的处理器),这一集合通常表示为位向量。目录请求需要更新这个共享器集合,还会读取这个集合,以执行失效操作。

图5-16给出了在目录中为回应所接收消息而采取的操作。目录接收三种不同请求:读取缺失、写入缺失和数据写回。目录发送的回应消息用粗体表示,而集合“共享器”的更新用黑色表示。因为所有激励消息都来自外部,所以所有操作都以灰色表示。我们的简化协议假定一些操作是原子操作,比如请求某个值并将其发送给另一个节点。

为了理解这些目录操作,让我们逐个状态查看所接收的请求和所采取的操作。当块处于未缓存状态时,存储器中的副本就是当前值,所以对这个块的请求只能是以下两种。

  • 读取缺失——从存储器向发出请求的节点发送其请求的数据,请求者成为唯一的共享节点。块的状态变为共享。
  • 写入缺失——向发送请求的节点传送取值,该节点变为共享节点。这个块变为独占状态,表明缓存了唯一有效副本。共享器指明拥有者的身份。

当块处于共享状态时,存储器值是最新的,所以可能出现相同的两个请求。

  • 读取缺失——从存储器向发出请求的节点发送其请求的数据,请求者被添加到共享集合中。
  • 写入缺失——向请求节点发送取值。向共享者集合中的所有节点发送失效消息,共享者集合将包含发出请求的节点的身份。这个块的状态变为独占状态。

当块处于独占状态时,这个块的值保存在一个节点的缓存中,这个节点由共享者(拥有者)集合识别,所以共有3种可能的目录请求。

  • 读取缺失——向拥有者发送数据提取消息,它会将拥有者缓存中这个块的状态转变为共享,拥有者将数据发送给目录,再在这里将其写到存储器中,并发给提出请求的处理器。将发出请求的节点的身份添加到共享者集合中,这个集合中仍然包含拥有者处理器的身份(因为这个处理器仍然拥有可读副本)。
  • 数据写回——拥有者正在替换这个块,因此必须将其写回。这个写回操作会更新存储器副本(主目录实际上变为拥有者),这个块现在未被缓存,共享者集合为空。
  • 写入缺失——这个块有一个新的拥有者。向旧拥有者发送一条消息,将其缓存中的这个块失效,并将值发送给目录,从目录中发送给提出请求的节点,这个节点现在变成新的拥有者。共享者被设定为新拥有者的身份,这个块仍然保持独占状态。


图5-16 目录的状态转移图与独立缓存的转移图具有相同的状态和结构。由于所有操作都是由外部导致的,所以均以灰色表示。粗体表示该目录回应请求所采取的操作

图5-16中的状态转换图是一种简化图,与监听式缓存的情景相同。在采用目录式协议时,以及用网络而非总线来实现监听机制时,协议需要处理非原子化存储器转换。

实际多处理器中使用的目录协议还进行了其他一些优化。具体来说,在这种协议中,在针对独占块发生读取缺失或写入缺失时,会首先将这个块发送到主节点上的目录中。再从这里将其存储到主存储器中,并发送给原来发现请求的节点。商用多处理器使用的许多协议都会将数据从拥有者节点直接转发给发出请求的节点(同时对主节点执行写回操作)。由于这些优化方法增大了死锁可能,并增加了必须处理的消息类型,所以通常会提高复杂性。

多芯片一致性和多核一致性有4种组合方式:监听/监听(AMD Opteron)、监听/目录、目录/监听和目录/目录!

同步:基础知识

同步机制通常是以用户级软件例程实现的,这些例程依赖于硬件提供的同步指令。对于较小型的多处理器或低争用解决方案,一种很关键的硬件功能是拥有不可中断的指令或指令序列,它们能以原子方式提取和改变一个值。软件同步机制就是利用这一功能实现的。这一节的重点是锁定及非锁定同步操作的实现。可以非常轻松地利用锁定和非锁定来创建互斥,并实现更复杂的同步机制。在高争用情景中,同步可能会成为性能瓶颈,因为争用会引入更多延迟,在此种多处理器中,延迟可能更大一些。

基本硬件原语

在多处理器中实施同步时所需要的关键功能就是一组能够以原子方式读取和修改存储器位置的硬件原语。没有这一功能,构建基本同步原语的成本就会过高,并随着处理器数目的增大而增大。基本硬件原语有许多替代方式,所有这些方式都能够以原子形式读取和修改一个位置,还有某种方法可以判断读取和写入是否以原子形式执行。这些硬件原语是一些基本构建模块,用于构建各种用户级别的同步操作,包括诸如锁和屏障之类的内容。我们首先来看这样一个硬件原语,说明如何用它来构建某些基本的同步操作。

一种用于构建同步操作的典型操作就是原子交换,它会将寄存器中的一个值与存储器的一个值进行交换。假定我们希望构建一个简单锁,数值0表示这个锁可以占用,数值1表示这个锁不可用。处理器尝试对这个锁进行置位,具体做法是将寄存器中的1与这个锁的相应存储器地址进行交换。如果其他某个处理器巳经申请了访问权,则这一交换指令将返回1,否则返回0。在后一种情况下,这个值也被改变为1,以防止任意进行竞争的交换指令也返回0。

例如,考虑两个处理器,每个处理器都尝试同时进行交换;只有一个处理器将会首先执行交换操作,并返回数值0,第二个处理器进行交换时将会返回1,所以不会存在竞争问题。使用交换原语来实现同步的关键是这个操作具有原子性:这一交换是不可分的,两个同时交换将由写入串行化机制进行排序。如果两个处理器尝试以这种方式对同步变量进行置位,它们不可能认为自己同时对这个变量进行了置位。

还有大量其他原子原语可用于实现同步。它们都拥有一个关键特性:它们读取和更新存储器值的方式可以让我们判断这两种操作是不是以原子形式执行的。在许多较旧的多处理器中存在一种名为测试并置位的操作,它会测试一个值,如果这个值通过测试则对其进行置位。比如,我们可以定义一个操作,它会检测0,并将其值设定为1,其使用方式与使用原子交换的方式类似。另一个原子同步原语是提取并递增:它返回存储器位置的值,并以原子方式使其递增。我们用0值来表示同步变量未被声明,可以像使用交换一样使用提取与递增。

实现单个原子存储器操作会引入一些挑战,因为它需要在单个不可中断的指令中进行存储器读取与写入操作。这一要求增加了一致性实施的复杂性,因为硬件不允许在读取与写入之间插人任何其他操作,而且不能死锁。

替代方法是利用一对指令,其中第二条指令可以返回一个值,根据这个值,可以判断这一对指令是否以原子形式执行。如果任一处理器执行的所有其他指令要么在这对指令之前执行,可么在这对指令之后执行,那就可以认为这对指令具有原子性。因此,如果一个指令对具有原子特性,那所有其他处理器都不能在这个指令对之间改变取值。

这种指令对包含一种名为链接载入或锁定载入的特殊载入指令和一种名为条件存储的特殊存储指令。这些指令是按顺序使用的:对于链接载入指令指定的存储器位置,如果其内容在对同一位置执行条件存储之前发生了改变,那条件存储就会失败。如果在两条指令之间进行了上下文切换,那么存储条件也会失败。条件存储的定义是在成功时返回1,失败时返回0。由于链接载入返回了初始值,而条件存储仅在成功时才会返回1,所以以下序列对R1内容指定的存储器位置实现了一次原子交换:

1
2
3
4
5
6
try:
MOV R3,R4 ; 移动交换值
LL R2,0(R1) ; 链接载入
SC R3,0(R1) ; 条件存储
BEQZ R3,try ; 分支存储失败
MOV R4,R2 ; 将载入值放入 R4中

在这个序列的末尾,R4的内容和R1指定存储器位置的内容已经实现了原子交换(忽略了延迟分支的影响)。在任意时间,如果处理器介入LL和SC指令之间,修改了存储器中的取值,那么SC在R3中返回0,导致此代码序列再次尝试。链接载入/条件存储机制的益处之一就是它能用于构建其他同步原语。例如,下面是原子的“提取并递增”:
1
2
3
4
5
try:
LL R2,0(R1) ; 链接载入
DAODUI R3,R2,#1 ; 递增
SC R3,0(R1) ; 条件存储
BEQZ R3,try ; 条件存储失败

这些指令通常是通过在寄存器中跟踪LL指令指定的地址来实现的,这个寄存器称为链接寄存器。如果发生了中断,或者与链接寄存器中地址匹配的缓存块失效(比如,另一条SC使其失效),链接寄存器将被清除。SC指令只是核查它的地址与链接寄存器中的地址是否匹配。如果匹配,sc将会成功;否则就会失败。在再次尝试向链接载入地址进行存储之后,或者在任何异常之后,条件存储将会失败,所以在选择向两条指令之间插入的指令时必须非常小心。具体来说,只有寄存器寄存器指令才是安全的;否则,就有可能造成死锁情景,处理器永远无法完成SC。此外,链接载入和条件存储之间的指令数应当很小,以尽可能减少无关事件或竞争处理器导致条件存储频繁失败的情景。

使用一致性实现锁

在拥有原子操作之后,就可以使用多处理器的一致性机制来实施自旋锁(spin lock)一处理器持续用循环来尝试获取锁,直到成功为止。在两种情况下会用到自旋锁,一种是程序员希望短时间拥有这个锁,另一种情况是程序员希望当这个锁可用时,锁定过程的延迟较低。因为自旋锁会阻塞处理器,在循环中等待锁被释放,所以在某些情况下不适合采用。最简单的实施方法是在存储器中保存锁变量,在没有缓存一致性时将会使用这种实施方式。处理器可能使用原子操作持续尝试获得锁,测试这一交换过程是否返回了可用锁。为释放锁,处理器只需要在锁中存储数值0即可。下面的代码序列使用原子交换来锁定自旋锁,其地址在R1中:

1
2
3
        DADDUI R2, R0 ,#1
lockit: EXCH R2, 0(R1) ; 原子交换
BNEZ R2, lockit ; 已经锁定?

如果多处理器支持缓存一致性,就可以使用一致性机制将锁放在缓存中,保持锁值的一致性。将锁放在缓存中有两个好处。第一,它允许采用一种实施方式,允许针对本地缓存副本完成“自旋”过程(在一个紧凑循环中尝试测试和获取锁),不需要在每次尝试获取锁时都请求全局存储器访问。第二个好处来自以下观察结果:锁访问中经常存在局域性;也就是说,上次使用了一个锁的处理器,很可能会在不远的将来再次用到它。在此类情况下,锁值可以驻存在这个处理器的缓存中,大幅缩短获取锁所需要的时间。

要实现第一个好处(能够针对本地缓存副本进行循环,不需要在每次尝试获取锁时都生成存储器请求),需要对这个简单的自旋过程进行一点修改。上述循环中每次尝试进行交换时都需要一次写入操作。如果多个处理器尝试获取这个锁,会分别生成这一写入操作。这些写入操作大多会导致写入缺失,因为每个处理器都是尝试获取处于独占状态的锁变量。

因此,应当修改自旋锁过程,使其在自旋过程中读取这个锁的本地副本,直到看到该锁可用为止。然后它尝试通过交换操作来获取这个锁。处理器首先读取锁变量,以检测其状态。处理器不断地读取和检测,直到读取的值表明这个锁未锁定为止。这个处理器随后与所有其他正在进行“自旋等待”的处理器展开竞赛,看谁能首先锁定这个变量。所有进程都使用一条交换指令,这条指令读取旧值,并将数值1存储到锁变量中。唯一的获胜者将会看到0,而失败者将会看到由获胜者放在里面的1。

获胜的处理器在锁定之后执行代码,完成后将0存储到锁定变量中,以释放这个锁,然后再从头开始竞赛。下面的代码执行这一自旋锁:

1
2
3
4
5
lockit:     LD      R2, 0(R1)     ; 载入锁
BNEZ R2, lockit ; 不可用——自旋
DADDUI R2, R0, #1 ; 载入锁定值
EXCH R2, 0(R1) ; 交换
BNEZ R2, lockit ; 如果锁不为 0,则跳转

让我们看看这一“自旋锁”机制是如何使用缓存一致性机制的。表5-8显示了当多个进程尝试使用原子交换来锁定一个变量时的处理器和总线(或目录)操作。一旦拥有锁的处理器将0存储到锁中,所有其他缓存都将失效,必须提取新值以更新它们保存的锁副本。这种缓存首先获取未定值(0)的一个副本,并执行交换。在满足其他处理器的缓存缺失之后,它们发现这个变量已经被锁定,所以必须回过头来进行检测和自旋。

这个例子显示了链接载入/条件存储原语的另一个好处:读取操作与写入操作是明确独立的。链接载入不一定导致任何总线通信。这一事实允许采用以下简单代码序列,它的特性与使用交换的优化版本一样(R1拥有锁的地址,LL代替了LD,SC代替了EXCH):

1
2
3
4
5
1ockit:     LL     R2,0(R1)    ; 链接载入
BNEZ R2,lockit ; 不可用——自旋
DADDUI R2,R0,#1 ; 锁定值
SC R2,0(R1) ; 存储
BEQZ R2,lockit ; 如果失败则跳转

第一个分支构成了自旋循环,第二个分支化解当两个处理器同时看到锁可用时的竞赛。


*本表假定采用写入失效一致性。在开始时,P0拥有这个锁(步骤1),锁的值为1(即被锁定);它最初为独占的,在步骤1开始之前由P0拥有。P0退出并解锁(步骤2)。P1和P2竞赛,看看谁能在交换期间看到未锁定值(步骤3至步骤5)。P2赢得竞赛,进入关键部分(步骤6与步骤7),而P1的尝试失败,所以它开始自旋等待(步骤7和步骤8)。在实际系统中,这些事件将耗费更多时间,远多于8次时钟嘀嗒,因为获取总线和回复缺失所需要的时
间要长得多。一旦到了步骤8,这一过程就可以从P2开始重复,它最终获得独占访问,并将锁设置为0。

存储器连贯性模型:简介

缓存一致性保证了多个处理器看到的存储器内容是一致的。但它并没有回答这些存储器内容应当保持何种程度的一致性。我们问“何种程度的一致性”时,实际是在问一个处理器必须在什么时候看到另一个处理器更新过的值?由于处理器通过共享变量进行通信(用于数据值和.同步两种目的),于是这个问题便简化为:在不同处理器对不同位置执行读取和写入操作时,必须保持哪些特性?

“保持何种程度的一致性”这一问题看起来非常简单,实际上却非常复杂,我们通过一个简单例子来了解一下。下面是来自处理器P1和P2的两段代码,并排列出如下:

1
2
3
4
5
P1:     A=0         P2:     B = 0;
... ...
A=1; B = 1;
L1: if (B = 0) L2: if (A == 0)
... ...

假定这些进程运行在不同处理器上,位置A和B最初由两个处理器进行缓存,初始值为0。如果写入操作总是立刻生效,而且马上会被其他处理器看到,那两个IF语句(标有L1和L2)不可能将其条件计算为真,因为能够到达IF语句,说明A或B中必然已经被指定了数值1。我们假定写入失效被延迟,处理器可以在这一延迟期间继续执行。 因此,P1 和P2在它们尝试读取数值之前,可能还没有(分别)看到B和A的失效。现在的问题是,是否应当允许这一行为?如果应当允许,在何种条件下允许?

存储器连贯性的最简单模型称为顺序连贯性模型。顺序连贯性要求任何程序每次执行的结果都是一样的,就像每个处理器是按顺序执行存储器访问操作的,而且不同处理器之间的访问任意交错在一起。有了顺序连贯性,就不可能再出现上述示例中的某些不明执行情况,因为必须完成赋值操作之后才能启动IF语句。

实现顺序连贯性模型的最简单方法是要求处理器推迟完成所有存储器访问,直到该访问操作所导致的全部失效均告完成为止。当然,如果推迟下一个存储器访问操作,直到前一访问操作完成为止,这种做法同样有效。别忘了,存储器连贯性涉及不同变量之间的操作:两个必须保持顺序的访问操作实际上访问的是不同的存储器位置。在我们的例子中,必须延迟对A或B的读取(A=0或B=0),直到上一次写入操作完成为止(B=1或A=1)。比如,根据顺序连贯性,我们不能简单地将写入操作放在写入缓冲区中,然后继续执行读取操作。

尽管顺序连贯性模型给出了一种简单的编程范例,但它可能会降低性能,特别是当多处理器的处理器数目很大或者互连延迟很长时尤为如此,如下例所示。

假定有一个处理器,一次写入缺失需要 50个时钟周期来确定拥有权,在确定拥有权之后发射每个失效操作需要10个时钟周期,在发射之后,失效操作的完成与确认需要80个时钟周期。假定其他4个处理器共享一个缓存块,如果处理器保持顺序连贯性,一次写入缺失会使执行写入操作的处理器停顿多长时间?假定必须明确确认失效操作之后,一致性控制器才能知道它们已经完成。假定在为写入缺失获得拥有者之后可以继续执行,不需要等待失效;该写入操作需要多长时间?

在等待失效时,每个写入操作花费的时间等于拥有时间再加上完成失效所需要的时间之和。由于失效操作可以重叠,所以只需要为最后一项操心,它是在确定拥有权之后开始的10+10+10+10=40 个时钟周期。因此,写入操作的总时间为50+40+80=170个时钟周期。与之相比,拥有时间只有50 个时钟周期。通过实现适当的写入缓冲区,甚至有可能在确定拥有权之前继续进行。

为了提供更好的性能,研究人员和架构师已经研究了两种不同路径。第一,他们开发了强大的实施方式,能够保持顺序连贯性,但使用延迟隐藏技术来降低代价。第二,他们开发了限制条件较低的存储器一致性模型,支持采用更快速的硬件。这些模型可能会影响程序员看到多处理器的方式,所以在讨论这些低限制模型之前,先来看看程序员有什么期望。

程序员的观点

尽管顺序连贯性模型有性能方面的不足,但从程序员的角度来看,它拥有简单性这一优点。挑战在于,要开发一种编程模型,既便于解释,又支持高性能实施方式。

有这样一种支持更高效实施方式的编程模型,它假定程序是同步的。如果对共享数据的所有访问都由同步操作进行排序,那就说这个程序同步的。如果满足以下条件,就说数据引用是由同步操作进行排序的:在所有可能的执行情景中,一个处理器对某一变量的写入操作与另一个处理器对这个变量的访问(或者为读取,或者为写入)之间由一对同步操作隔离开来,其中一个同步操作在写入处理器执行写入操作之后执行,另一个同步操作在第二个处理器执行访问操作之前执行。如果变量可以在未由同步操作进行排序的情况下更新,此类情景称为数据竞赛,因为操作的执行结果取决于处理器的相对速度,和硬件设计中的竞赛相似,其输出是不可预测的,由此给出另一种同步程序的名字:无数据竞赛。

给出一个简单的例子,变量由两个不同处理器读取和更新。每个处理器用锁定和解锁操作将读取和更新操作包围起来,这两种操作是为了确保更新的互斥和读取操作的连贯性。显然,每个写入操作与另一个处理器的读取操作之间现在都由一对同步操作隔离开来:一个是解锁(在写入操作之后),一个是锁定(在读取操作之前)。当然,如果两个处理器正在写入一个变量,中间没有插入读取操作,那这些写入操作之间也必须由同步操作隔离开。

人们普遍认同“大多数程序都是同步的”这一事实。这一观察结果之所以正确,主要是因为:如果这些访问是非同步的,那么到底哪个处理器赢得数据竞赛就由执行速度决定,从而会影响到程序结果,那程序的行为就可能是不可预测的。即使有了顺序连贯性,也很难理清此类程序。

程序员可能尝试通过构造自己的同步机制来确保排序,但这种做法需要很强的技巧性,可能导致充满漏洞的程序,而且在体系结构上可能不受支持,也就是说在以后各代多处理器中可能无法工作。因此,几乎所有的程序员都选择使用同步库,这些库正确无误,而且针对多处理器和同步类型进行了优化。

最后,标准同步原语的使用可以确保:即使体系结构实现了一种比顺序连贯性模型更宽松的连贯性模型,同步程序也会像硬件实施了顺序连贯性一样运行。

宽松连贯性模型:基础知识

宽松连贯性模型的关键思想是允许乱序执行读取和写入操作,但使用同步操作来实施排序,因此,同步程序的表现就像处理器具备顺序连贯性一样。 这些宽松模型是多种多样的,可以根据它们放松了哪种读取和写入顺序来进行分类。我们利用一组规则来指定顺序,其形式为X→Y,也就是说必须在完成操作X之后才能执行操作Y。顺序连贯性模型需要保持所有4种可能顺序:R→W、R→R、W→R和W→W。宽松模型的确定是看它们放松了这4种顺序中的哪一种。

  1. 放松w→R顺序,将会得到一种称为完全存储排序或处理器连贯性的模型。由于这种排序保持了写入操作之间的顺序,所以许多根据顺序连贯性运行的程序也能在这一模型下运行,不用添加同步。
  2. 放松W→W顺序,将会得到一种称为部分存储顺序的模型。
  3. 放松R→W和R→R顺序,将会得到许多模型,包括弱排序、PowerPC连贯性模型和释放连贯性,具体取决于排序约束条件的细节和同步操作实施排序的方式。

通过放松这些排序,处理器有可能获得显著的性能提升。但是,在描述宽松连贯性模型时存在许多复杂性,包括放松不同顺序的好处与复杂性、准确定义写入完成的含义、决定处理器什么时候看到它自己写入的值。

交叉问题

由于多处理器重新定义了许多系统特性(例如,性能评估、存储器延迟和可伸缩性的重要性),所以它们引入了一些贯穿整个领域的重要设计问题,对硬件和软件都产生影响。在这一节,我们将给出一些与存储器连贯性问题有关的示例。随后研究在向多重处理中添加多线程时所能获得的性能。

编译器优化与连贯性模型

定义存储器连贯性模型的另一个原因是指定合法的编译器优化范围,可以针对共享数据来执行这些优化。在显式并行程序中,除非明确定义了同步点,而且程序被同步,否则编译器不能交换对两个不同共享数据项的读取操作和写入操作 ,因为这种转换可能会影响程序的本来语义。因此,一些相对简单的优化方式也无法实施,比如共享数据的寄存器分配,因为这种转换通常会交换读取和写入操作。在隐式并行程序中,程序必须被同步,而且同步点已知,所以不会出现这一问题。编译器能否从更宽松的连贯性模型中获得明显好处,无论是从研究的角度来看还是从实践的角度来看,这都依然是一个开放性的问题,由于缺乏统一模型,可能会妨碍编译器的部署进程。

利用推测来隐藏严格连贯性模型中的延迟

在第3章曾经看到,可以利用推测来隐藏存储器延迟。还可用来隐藏因为严格连贯性模型导致的延迟,获得宽松存储器模型的大多数好处。其关键思想是:处理器使用动态调度来重新安排存储器引用的顺序,让它们有可能乱序执行。乱序执行存储器引用可能会违犯顺序连贯性,从而影响程序的执行。利用推测处理器的延迟提交功能,可以避免这种可能性。假定一致性协议是以失效操作为基础的:如果处理器在提交存储器引用之前,收到该存储器引用的失效操作,处理器会使用推测恢复来回退计算,并利用失效地址的存储器引用重新开始。

如果处理器对存储器请求进行重新排序后,新执行顺序的结果不同于在顺序连贯性下看到的结果,处理器将会撤消此次执行。使用这一方法的关键在于:处理器只需要确保其结果与所有访问完全循序完成时是一样的,通过检测两种结果可能在什么时候出现不同,就可以做到这一点。由于很少会触发推测重启,所以这种方法很有吸引力。只有当非同步访问实际导致竞赛时,才会触发推测重启。Hill [1998]提倡将顺序连贯性或处理器连贯性与推测执行结合起来,作为一种连贯性模型。他的观点包括三个部分。第一,积极实现顺序连贯性或处理器连贯性,可以获得更宽松模型的大多数好处。第二,这种实施方式仅对推测处理器增加了非常少的实施成本。第三,这种方法允许程序员考虑使用顺序连贯性或处理器连贯性的更简单编程模型。

一个尚未解决的问题是,在优化对共享变量的存储器引用时,编译器技术会取得怎样的成功?共享数据通常是通过指针和数组索引进行访问的,这一事实再加上优化技术的现状,已经限制了此种优化技术的使用。如果这一技术进入实用状态,而且能够带来显著的性能优势,编译器编写入员可能会希望使用更宽松的编程模型。

包含性及其实现

所有多处理器都使用多级缓存层级结构来减少对全局互连的要求和缓存缺失延迟。如果缓存还提供了多级包含性(缓存层次结构的每一级都是距处理器更远一层的子集),所以我们可以使用多级结构来减少一致性通信 与处理器通信之间的争用,当监听与处理器缓存访问必须竞争缓存时,就会出现这些争用。许多具有多层缓存的多处理器都具备这种包含性,不过,最近有些多处理器采用较小的L1缓存和不同的块大小,有时会选择不实施这种包含特性。这一限制有时也称为子集特性,因为每个缓存都是它下一级缓存的子集。

乍看起来,保持多级包含特性是件很简单的事情。考虑一个两级示例:L1中的所有缺失要么在L2命中,要么在L2中产生缺失,无论是哪一种情况,缺失块都会进入L1和L2两级缓存。与此类似,任何在L2命中的失效都必然被发送给L1,如果L1中存在这个块,将会使其失效。难以理解的地方在于当L1和L2的块大小不同时会发生什么。选择不同块大小是非常合情合理的,因为L2通常要大得多,其缺失代价中的延迟分量也要长得多,因此希望使用较大的块大小。当块大小不同时,对于包含性的“ 自动”实施有什么影响呢? L2中的一个块对应于L1中的多个块,L2的一次缺失所导致的数据替换对应于多个L1块。例如,如果L2的块大小是L1的4倍,那么L2中的一次缺失将替换相当于4个L1块的内容。下面考虑一个详细示例。

假定L2的块大小为L1块的4倍。说明一次导致L1和L2产生替换的地址缺失将如何违犯包含特性。

假定L1和L2是直接映射的,L1的块大小为b个字节,L2的块大小为4b个字节。假定L1包含两个块,起始地址为x和x+b,且x mod 4b = 0,也就是说,x也是L2中一个块的起始地址;因此,L2中的单个块包含着L1块x、x+b、x+2b和x+3b。假定处理器生成一个对块y的引用,这个块对应于在两个缓存中都包含x的块,从而会产生缺失。由于L2产生缺失,所以它会提取4b个字节,并替换包含x、x+b、x+2b和x+3b的块,而L1取得b个字节,并替换包含x的块。由于L1仍然包含x+b,但L2不再包含,因此不再保持包含特性。

为了在采用多个块大小时仍然保持包含性,在较低级别完成替换时,必须上溯到层次结构的较高级别,以确保较低级别中替换的所有字在较高级别的缓存中都已失效;相联度的不同级别也会产生同类问题。Intel i7为L3应用了包含性,也就是说L3总是包含L2和L1的内容。这样就可以在L3实施一种简单的目录机制,在最大程度上降低因为监听L1和L2而对这些情景造成的干扰,目录中指出L1或L2中含有一个缓存副本。AMD Opteron与之相对,使L2包含L1的内容,但对L3没有这一限制。它们使用了监听协议,但除非存在命中情况,否则仅需要在L2进行监听,在这种情况下,会向L1发送监听。

利用多重处理和多线程的性能增益

融会贯通:多核处理器及其性能

2011年,多核心成为所有新处理器的主旋律。各种实现方式的变化很大,它们对大型多芯片多处理器的支持也同样有很多不同。这一节研究4种不同多核处理器的设计和一些性能特征。表5-10给出了4种为服务器应用设计的多核处理器。Intel Xeon的设计基础与i7相同,但它的核心更多、时钟频率稍慢(功率限制了其时钟频率)、L3缓存较大。AMD Opteron和桌面Phenom共享相同的基础核心,而SUN T2与在第3章遇到的SUN T1相关。Power7 是Power5的扩展,核心更多,缓存更大。

  • 表中包含了这些处理器中核心数最多的版本;其中一些处理器还有核心数较低、时钟频率较高的版本。IBM Power7中的L3可以全部共享,也可以划分为各个核心专用的更快速专用区城。我们仅包含了这些多核心处理器的单芯片实现方式。

图5-18给出了SPECRate CPU基准测试在核心数目增加时的性能变化。随着处理器芯片数及核心数的增加,可以获得近似线性的加速比。


图5-18 当处理器芯片数增大时,三种多核处理器运行SPECRate基准测试的性能。注意,对于这个高度并行的基准测试,得到了近似线性的加速比。这两个曲线都采用对数一对数刻度,所以线性加速比表现为一条直线

图5-19给出了SPECjbb2005 基准测试的类似数据。要在开发更多ILP和仅关注TLP之间实现平衡是很复杂的,它与具体的工作负载高度相关。SPECjbb2005 工作负载能够在增加更多处理器时进行扩展,使运行时间(而非问题规模)保持恒定。在这种情况下,会有足够的并行,可以通过64个核心来实现线性加速比。


图5-19 当处理器芯片数目增加时,三种多核心处理器运行SPE1bb2005基准测试的性能。注意,对于这并行基准测试,得到了近似线性的加速比

Intel Core i7多核的性能与能耗效率

在这一节,我们利用第3章考虑过的两组基准测试来研究i7的性能,即并行Java基准测试和并行PARSEC基准测试。我们首先来看一下在没有使用SMT时多核心性能、扩展能力与单核心的对比。然后将多核心和SMT功能结合起来。

图5-20绘制了在没有使用SMT时Java和PARSEC基准测试的加速比和能量效率曲线。给出能耗效率曲线意味着我们绘制的是两核心或四核心运行消耗能量与单核心运行消耗能量的比值;因此,能耗效率越高越好,取值为1.0 时为其平衡点。在所有情景中,没有使用的核心都处于尝试睡眠模式,基本上相当于将这些核心关闭,使其功耗降至最低。在对比单核心和多核心基准测试的数据时,一定要记住,在单核心(及多核心)情景中,L3缓存和存储器接口的全部能耗成本都是物有所值的。因为这一事实,对于那些能够很好扩展的应用程序,有可能进一步改善其能耗指标。在汇总这些结果时使用了调和均值,其隐含意义见图题。

图5-20 本图给出了未采用SMT时,两核和四核处理器执行并行Java与PARSEC工作负载时的加速比。Turbo Boost功能被关闭。加速比与能耗效率数据使用调和均值汇总,其隐含含义就是在这种工作负载中,运行每个2p基准测试所花费的时间是等价的

如图5-20所示,PARSEC基准测试的加速比要优于Java 基准测试,在四核心处理器上的加速比效率为76%(即实际加速比除以处理器数目),而Java基准测试在四核心处理器上的加速比效率为67%。尽管从数据中可以很清楚地看出这一结果,但要分析存在这种差异的原因要麻烦一些。例如,很有可能是Amdahl定律降低了Java 工作负载的加速比。此外,处理器体系结构与应用程序之间的交互也可能在其中产生影响(它会影响到同步成本或通信成本等问题)。具体来说,并行化程度很高的应用程序(比如PARSEC中的程序)有时可能因为计算与通信之间的有利比值而获益,这种比值可以降低对通信成本的依赖性。

这种加速比的差异性可以转换为能耗效率的差异性。例如,相对于单核心版本,PARSEC基准测试实际上只是稍微提高了能耗效率;这一结果可能受到以下事实的显著影响:L3缓存在多核运行版本中的使用效率要高于单核情景,而两种情景中的能耗成本是相同的。因此,对于PARSEC基准测试,多核方法达到了设计人员从关注ILP的设计转向多核设计的目的,即:其性能的增长速度不低于功率的增长速率,从而使能耗效率保持不变,甚至还有所提高。在Java情景中我们看到,由于Java工作负载的加速比级别较低,所以两核和四核运行版本都没有达到能耗效率的平衡点。四核Java情景中的能耗效率相当高(0.94)。对于PARSEC或Java工作负载,以ILP为中心的处理器很可能需要更多的功率才能实现相似的加速比。因此,在提高这些应用程序的性能方面,以TLP为中心的方法当然也会优于以ILP为中心的方法。

将多核与SMT结合起来

最后,我们通过测量两组基准测试在2~4个处理器、1~2个线程(总共4个数据点、最多8个线程)情况下的结果,来研究多核与多线程的组合方式。图5-21给出了在处理器数目为2或4、使用和未使用SMT时,在Intel i7上获得的加速比和能耗效率,采用调和均值来汇总两组基准测试的结果。显然,如果在多核情景下也有足够的线程级并行,SMT是可以提高性能的。例如,在四核无SMT情景中,Java和PARSEC的加速比效率分别为67%和76%。在采用SMT、四个核心时,这些比值达到了令人惊讶的83%和97%!

图5-21 本图给出了在有、无SMT时,以两核和四核处理器执行并行Java和PARSEC工作负载的加速比。注意,以上结果是在线程数由2变为8时获得的,反映了体系结构的影响和应用程序的特征。汇总结果时采用了调和均值,如图5-20的图题所述能耗效率给出了一幅稍有不同的画面。对于PARSEC,加速比在四核SMT情景中(8个线程)基本上为线性,功率的增长要更慢一些,从而使这种情景中的能耗效率达到1.1。 Java情景要更复杂一些;两核心SMT (四线程)运行时的能耗效率峰值达到0.97,在四核心SMT(8线程)运行时下降到0.89。在部署4个以上的线程时,Java 基准测试非常有可能遭遇Amdahl定律效应。一些架构师已经观察到,多核处理器将提高性能(从而提高能耗效率)的更多责任转嫁给程序员,Java工作负载的结果显然证实了这一点。

量化设计与分析基础

处理器性能的提高从单纯依赖指令集并行(ILP)转向数据级并行(DLP)和线程级并行(TLP)。

并行度与并行体系结构的分类

在所有4个计算机类别中,多种级别的并行度现在已经成为计算机设计的推动力量,而能耗和成本则是主要约束条件。应用程序中主要有以下两种并行。

  1. 数据级并行(DLP),它的出现是因为可以同时操作许多数据项。
  2. 任务级并行(TLP),它的出现是因为创建了一些能够单独处理但大量采用并行方式执行的工作任务。

计算机硬件又以如下4种主要方式来开发这两种类型的应用并行。

  • 指令级并行在编译器的帮助下,利用流水线之类的思想适度开发数据级并行,利用推理执行之类的思想以中等水平开发数据级并行。
  • 向量体系结构和图形处理器(GPU)将单条指令并行应用于一个数据集,以开发数据级并行。
  • 线程级井行在一种紧耦合硬件模型中开发数据级并行或任务级并行,这种模型允许在并行线程之间进行交互。
  • 请求级并行在程序员或操作系统指定的大量去耦合任务之间开发并行。

Michael Flynn[1966]在20世纪60年代研究并行计算工作量时,提出了一种简单的分类方式,他研究了多处理器最受约束组件中的指令流及数据流的并行,并据此将所有计算机分为以下4类。

  • 单指令流、单数据流(SISD)是单处理器。程序员把它看作标准的顺序计算机,但可以利用指令级并行。第3章介绍了采用ILP技术(比如超标量和推理执行)的SISD体系结构。
  • 单指令流、多数据流(SIMD)同一指令由多个使用不同数据流的处理器执行。SIMD计算机开发数据级并行,对多个数据项并行执行相同操作。每个处理器都有自己的数据存储器,但只有一个指令存储器和控制处理器,用来提取和分派指令。
  • 多指令流、单数据流(MISD):目前为止,还没有这种类型的商用多处理器。
  • 多指令流、多数据流(MIMD):每个处理器都提取自己的指令,对自己的数据进行操作。它针对的是任务级并行。

计算机体系结构

指令集体系结构:计算机体系结构的近距离审视

我们在本书中用指令集体系结构(ISA)一词来指代程序员可以看到的实际指令集。ISA的作用相当于区分软件和硬件的界限。在下面对ISA的快速回顾中,将使用80x86、ARM和MIPS的例子从7个方面来介绍ISA。

(1)ISA分类。现今几乎所有的ISA都划分到通用寄存器体系结构中,在这种体系结构中,操作数或者是寄存器,或者是存储器地址。80x86有16个通用寄存器和16个通常存入浮点数据的寄存器,而MIPS则有32个通用寄存器和32个浮点寄存器(见表)。这一类别有两种主流版本,一种是寄存器-存储器ISA,比如80x86,可以在许多指令中访问存储器,另一种是载入-存储ISA,比如ARM和MIPS,它们只能用载入或存储指令来访问存储器。所有最新ISA都采用载入-存储版本。

(2)存储器寻址。几乎所有桌面计算机和服务器计算机(包括80x86、ARM和MIPS)都使用字节寻址来访问存储器操作数。有些体系结构(像ARM和MIPS)要求操作对象必须是对齐的。一个大小为s的对象,其字节地址为A,如果A mod s = 0,则对这个对象的访问是对齐的。如果操作数是对齐的,访向速度通常会快一些。

(3)寻址模式。除了指定寄存器和常量操作数之外,寻址模式还指定了一个存储器对象的地址。MIPS寻址模式为:寄存器、立即数、位移量。立即数寻址用于常数寻址,在位移量寻址模式中,将一个固定偏移量加到寄存器,得出存储器地址。

(4)操作数的类型和大小。和大多数ISA类似,80x86、ARM和MIPS支持的操作数大小为8位(ASCII字符〕、16位(Unicode字符或半个字)、32位(整数或字)、64位(双字或长整型)以及IEEE754浮点数,包括32位(单精度)和位〈双精度)。80x86还支持80位浮点(扩展双精度)。

(5)操作指令。常见的操作类别为:数据传输指令、算术逻辑指令、控制指令(下面进行讨论)和浮点指令。MIPS是一种简单的、易于实现流水化的指令集体系结构,它是2011年采用RISC体系结构的代表。表中总结了MIPS ISA。8cx86的操作指令集要丰富得多。


(6)控制流指令。几乎所有ISA,包括上述三种在内,都支持条件转移、无条件跳转、过程调用和返回。所有这三种都使用相对于PC的寻址方式,其中的分支地址由一个地址字段指定,该地址将被加到PC。这三种ISA之间有一些微小的区别。MIPS条件分支(BE、BNE等)检验寄存器中的内容,而80x86和ARM分支测试条件代码位,这些位是在执行算术/逻辑运算时顺带置位的。ARM和MIPS过程调用将返回地址放在一个寄存器中,而886调用(CALLF)将返回地址放在存储器中的一个栈内。

(7)ISA的编码。有两种基本的编码选择:固定长度和可变长度。所有ARM和MIPS指令的长度都是32位,从而简化了指令译码。图中给出了MIPS指令格式。80x86编码为可变长度,变化范围为1~18个字节。与固定长度的指令相比,可变长度的指令可以占用较少的空间,所以为80x86编译的程序通常要小于为MIPS编译的相同程序。

真正的计算机体系结构:设计满足目标和功能需求的组成和硬件

计算机的实现包括两个方面:组成和硬件。组成一词包含了计算机设计的高阶内容,比如存储器系统,存储器互连,设计内部处理器或CPU(中央处理器一一一算术、逻辑、分支和数据输送功能都在这里实现)。

由于单个微处理器上开始采用多个处理器,所以人们开始使用核心一词来称呼处理器:人们一般不说“多处理器微处理器”,而是使用“多核”。由于现今几乎所有芯片都有多个处理器,所以人们不怎么使用中央处理器(或CPU)一词了。

硬件是指一个计算机的具体实现,包括计算机的详尽逻辑设计和封装技术。同一系列的计算机通常具有相同的指令集体系结构和几乎相同的组成,但在具体硬件实现方面有所不同。

计算机设计的量化原理

充分利用并行

充分利用并行是提高性能的最重要方法之一。这里给出三个简单的例子,在后续各章会给出详细解释。

第一个例子是在系统级别开发并行。为了提高在一个典型服务器基准测试(比如SPECWeb或TPC-C)上的吞吐量性能,可以使用多个处理器和多个磁盘。随后可以在处理器和磁盘之间分散处理请求的工作负载,从而提高吞吐量。扩展内存以及处理器和磁盘数目的能力称为可扩展性。在许多磁盘之间分布数据,以实现并行读写,就可以支持数据级并行。SPECWeb还依靠请求级并行来使用大量处理器,而TPC-C使用线程级并行实现对数据库请求的更快速处理。

在单独的处理器级别,充分利用指令间的并行对于实现高性能非常关键。实现这种并行的最简单方法之一就是通过流水线。流水线背后的基本思想是将指令执行重叠起来,以缩短完成指令序列的总时间。流水线能够实现的关键是认识到并非所有执行都取决于与其直接相邻的前一条指令,所以有可能完全并行或部分并行地执行这些指令。

在具体的数字设计级别也可以开发并行。例如,组相联(Set Associative)缓存使用多组存储器,通常可以对它们进行并行查询,以查找所需项目。现代ALU(算术逻辑单元)使用先行进位,这种方法使用并行来加快求和过程,使计算时间与操作数位数之间的关系由线性关系变为对数关系。数据级并行的例子还有许多。

局域性原理

一个最重要程序特性是局域性原理:程序常常重复使用它们最近用过的数据和指令。一条广泛适用的经验规律是:一个程序90%的执行时间花费在仅10%的代码中。局域性意味着我们可以根据一个程序最近访问的指令和数据,比较准确地预测它近期会使用哪些内容。时间局域性是指最近访问过的内容很可能会在短期内被再次访问。空间局域性是指地址相互临近的项目很可能会在短时间内都被用到。

Amdahl定律

利用Amdahl定律,可以计算出通过改进计算机某一部分而能获得的性能增益。Amdahl定律表明,使用某种快速执行模式获得的性能改进受限于可使用此种快速执行方式的时间比例。

Amdabl定律定义了使用某一特定功能所获得的加速比(speedup〕。加速比的定义为:加速比=整个任务在采用该升级时的性能/整个任务在未采用该升级时的性能。或者:加速比=整个任务在未采用该升级时的执行时间/整个任务在采用该升级时的执行时间。加速比告诉我们,与原计算机相比,在经过升级的计算机上运行一个任务可以加快多少。

Amdahl定律为我们提供了一种快速方法,用来计算某一升级所得到的加速比,加速比取决于下面两个因素。

  • 原计算机计算时间中可升级部分所占的比例。例如,一个程序的总执行时间为秒,如果有20秒的执行时间可进行升级,那这个比例就是20/60。我们将这个值称为升级比例,它总是小于或等于1。
  • 通过升级执行模式得到的改进,也就是说在为整个程序使用这一执行模式时,任务的运行速度会提高多少倍。这个值等于原模式的执行时间除以升级模式的执行时间。如果为程序的某一部分采用升级模式后需要2秒,而在原始模式中需要5秒,则提升值为5/2。我们将这个值称为升级加速比,它总是大于1。

原计算机采用升级模式后的执行时间等于该讨机未升级部分耗用的时间加上使用升级部分耗用的时间:

1
新执行时间 = 原执行时间 × ((1-升级比例) + 升级比例/升级加速比)

总加速比是这两个执行时间之比:

1
总加速比=原执行时间/新执行时间=1/((1-升级比例)+升级比例/升级加速比)

Amdahl定律阐述了回报递减定律:如果仅改进一部分计算的性能,在增加改进时,所获得的加速比增量会逐渐减小。

存储器层次结构设计

引言

由于快速存储器非常昂贵,所以将存储器层次结构分为几个级别——越接近处理器,容量越小、速度越快、每字节的成本也越高。其目标是提供一种存储器系统,每字节的成本几乎与最便宜的存储器级别相同,速度几乎与最快速的级别相同。在大多数(但并非全部)情况下,低层级存储器中的数据是其上一级存储器中数据的超集。这一性质称为包含性质,层次结构的最低级别必须具备这一性质,如果这一最低级别是缓存,则由主存储器组成,如果是虚拟存储器,则由磁盘存储器组成。

随着处理器性能的提高,存储器层次结构的重要性也在不断增加。图2-2是单处理器性能和主存储器访问次数的历史发展过程。处理器曲线显示了平均每秒发出存储器请求数的增加(即两次存储器引用之间延迟值的倒数),而存储器曲线显示每秒DRAM访问数的增加(即DRAM访问延迟的倒数)。在单处理器中,由于峰值存储器访问速度要快于平均速度(也就是图中绘制的速度),所以其实际情况要稍差一些。

近来,高端处理器已经转向多核,与单核相比,进一步提高了带宽需求。事实上,总峰值带宽基本上随核心个数的增大而增大。现代高端处理器每个时钟周期可以由每个核心生成两次数据存储器引用,i7有4个核心,时钟频率为3.2G,除了大约128亿次128位指令引用的峰值指令要求之外,每秒最多还可生成256亿次位数据存储器引用;总峰值带宽为409.6GB/s!

这一难以置信的高带宽是通过以下方法实现的:实现缓存的多端口和流水线;利用多级缓存,为每个核心使用独立的第一级缓存,有时也使用独立的第二级缓存;在第一级使用独立的指令与数据缓存。与其形成鲜明对比的是,DRAM主存储器的峰值带宽只有它的6%(25GB/s)。

如果在缓存中找不到某一个字,就必须从层次结构的一个较低层级(可能是另一个缓存,也可能是主存储器)中去提取这个字,并把它放在缓存中,然后才能继续。出于效率原因,会一次移动多个字,称为块(或行),这样做还有另外一个原因:由于空间局域性原理,很可能马上就会用到这些字。每个缓存块都包括一个标记,指明它与哪个存储器地址相对应。

在设计时需要作出一个非常重要的决策:哪些块(或行)可以放在缓存中。最常见的方案是组相联,其中组是指缓存中的一组块。一个块首先被映射到一个组上,然后可以将这个块放到这个组中的任意位置。要查找一个块,首先将这个块的地址映射到这个组,然后再搜索这个组(通常为并行搜索),以找到这个块。这个组是根据数据地址选择的:(块地址)MOD(缓存中的组数)

如果组中有n个块,则缓存的布局被称为n路组相联。组相联的端点有其自己的名字。直接映射缓存每组中只有一个块(所以块总是放在同一个位置),全相联缓存只有一个组(所以块可以放在任何地方)。

要缓存只读数据是一件很容易的事情,这是因为缓存和存储器中的副本是相同的。缓存写入难一些;比如,缓存和存储器中的副本怎样才能保持一致呢?共有两种主要策略。一种是直写(write-through)缓存,它会更新缓存中的项目,并直接写入主存储器,对其进行更新。另一种是回写(write-back)缓存,仅更新缓存中的副本。在马上要替换这个块时,再将它复制回存储器。这两种写入策略都使用了一种写缓冲区,将数据放入这个缓冲区之后,马上就可以进行缓存操作,不需要等待将数据写入存储器的全部延迟时间。

为了衡量不同缓存组织方式的优劣,可以采用一个名为缺失率的指标。缺失率是指那些未能找到预期目标的缓存访问所占的比例,即未找到目标的访问数目除以总访问数目。为了深刻理解高缺失率的原因,从而更好地设计缓存,一种”3C”模式将所有这些缺失情景分为以下三个简单的类别。

  • 强制(compulsory):对一个数据块的第一次访问,这个块不可能在缓存中,所以必须将这个块调入缓存中。即使拥有无限大的缓存,也会发生强制缺失。
  • 容量(Capacity):如果缓存不能包含程序运行期间所需要的全部块,就会因为有些块被放弃,之后再被调入,从而导致容量缺失(还有强制缺失)。
  • 冲突(conflict):如果块放置策略不是全相联的,如果多个块映射到一个块的组中,对不同块的访问混杂在一起,一个块可能会被放弃,之后再被调入,从而发生冲突缺失(还有强制缺失和容量缺失)。

多线程和多核都增加了缓存的复杂性,都加大了出现容量缺失的可能性,而且还因为缓存刷新增加了第4个C——一致性(coherency)缺失,之所以进行缓存刷新是为保持多处理器中多个缓存的一致性。

要小心,缺失率可能会因为多个原因而产生误导。因此,一些设计人员喜欢测量每条指令的缺失次数,而不是每次存储器引用的缺失次数(缺失率)。这两者之间的关系如下:

1
缺失数/指令 = 缺失率×存储器访问/指令数 = 缺失率x存储器访问/指令

这两种度量指标的问题在于它们都没有考虑缺失成本的因素。一种更好的度量指标是存储器平均访问时间:

1
存储器平均访问时间 = 命中时间 + 缺失率 × 缺失代价

其中,命中时间是指在缓存中命中目标花费的时间,缺失代价是从内存中替代块的时间(即缺失成本)。多线程的使用也允许一个处理器容忍一些缺失,而不会被强制转入空闲状态。

下文给出了6种基本的缓存优化方法

  1. 增大块以降低缺失率。这是降低缺失率的最简单方法,它利用了空间局域性,并增大了块的大小。使用较大的块可以减少强缺失,但也增加了缺失代价。因为较大块减少了标记数目,所以它们可以略微降低静态功率。较大块还可能增大容量缺失或冲突缺失,特别是当缓存较小时尤为如此。选择合适的块大小是一项很复杂的权衡过程,具体取决于缓存的大小和缺失代价。
  2. 增大缓存以降低缺失率。要减少容量缺失,一个显而易见的方法就是增大缓存容量。其缺点包括可能会延长较大缓存存储器的命中时间,增加成本和功率。较大的缓存会同时增大静态功率和动态功率。
  3. 提高相联程度以降低缺失率。显然,提高相联程度可以减少冲突缺失。较大的相联程度是以延长命中时间为代价的。
  4. 采用多级缓存以降低缺失代价。是加快缓存命中速度,以跟上处理器的高速时钟频率,还是加大缓存,以缩小处理器访问和主存储器访问之间的差距,这是一个艰难的决策。在原缓存和存储器之间加入另一级缓存可以降低这一决策的难度。第一级缓存可以非常小,足以跟上快速时钟频率,而第二级(或第三级)缓存可以非大,足收集容纳许多本来要对主存储器进行的访问。为了减少第二级缓存中的缺失,需要采用更大的块、更大的容量和更高的相联程度。与一级总缓存相比,多级缓存的功率效率更高。如果用LI和L2分别指代第一级和第二季缓存,可以将平均存储器访问时间定义为:

    1
    L1命中时间 + L1缺失率 × (L2命中时间 + L2缺失率 × L2缺失代价)
  5. 为读取缺失指定高于写入操作的优先级,以降低缺失率。写缓冲区是实现这一优化的好地方。因为写缓冲区拥有在读取缺失时所需位置的更新值,所以写缓冲区存在隐患,即通过存储器进行先写后读的隐患。一种解决方案就是在读取缺失时检查写缓冲区的内容。如果没有冲突,如果存储器系统可用,则在写入操作之前发送读取请求会降低缺失代价。大多数处理器为读取指定的优先级要高于写入操作。这种选择对功耗几乎没有什么影响。

** 识别结果 1**

访问时间通常会随着缓存大小和相联程度的增大而增加。这些数据假定采用40nm制程、单一存储器组、块大小为64字节。由于对缓存布局所做的假定,以及在互连延迟(通常取决于正在访问的缓存块的大小)和标记检查与多工之间的复杂权衡,会得到一些有时看起来令人惊奇的结果,比如对于两路组相联的64KB缓存,其访问时间会低于直接映射。与此类似,当缓存大小增大时,八路组相联产生的结果也会导致一些不同寻常的行为。

  1. 在缓存索引期间避免地址转换,以缩短命中时间。缓存必须妥善应对从处理器虚拟地址到访问存储器的物理地址之间的转换。一种常见的优化方法是使用页偏移地址(虚拟地址和物理地址中的相同部分)来索引缓存。这种虚拟索引/物理标记方法增加了某些系统复杂度以及(或者)对L1缓存大小与结构的限制,但从关键路径消除转换旁视缓冲区(TLB)访问这一优点抵得过这些缺点。

缓存性能的10种高级优化方法

上面的存储器平均访问时间公式提供了三种缓存优化度量:命中时间缺失率缺失代价。根据最近的发展趋势,我们向这个列表中添加了缓存带宽功耗两个度量。根据这些度量,可以将我们研究的10种高级缓存优化方法分为以下5类。

  1. 缩短命中时间。小而简单的第一级缓存和路预测。这两种技术通常还都能降低功耗。
  2. 增加缓存带宽。流水化缓存、多组缓存和无阻塞缓存。这些技术对功耗具有不确定影响。
  3. 降低缺失代价。关键字优化,合并写缓冲区。这两种优化方法对功率的影响很小。
  4. 降低缺失率。编译器优化。显然,缩短编译时间肯定可以降低功耗。
  5. 通过并行降低缺失代价或缺失率。硬件预取和编译器预取。这些优化方法通常会增加功耗,主要是因为提前取出了未用到的数据。

一般来说,在采用这些技术方法时,硬件复杂度会增加。另外,这些优化技术中有几种需要采用高级编译器技术。对其中比较简单的优化方法仅作简单介绍,而对其他技术将给出更多描述。

第一种优化:小而简单的第一级缓存,用以缩短命中时间、降低功率

提高时钟频率和降低功率的双重压力都推动了对第一级缓存大小的限制。与此类似,使用较低级别的相联度,也可以缩短命中时间、降低功率,不过这种权衡要比限制大小涉及的权衡更复杂一些。

缓存命中过程中的关键计时路径由3个步骤组成:

  • 使用地址中的索引确定标记存储器的地址;
  • 读取的标签值与地址进行比较;
  • 如果缓存为组相联缓存,则设置多路转换器以选择正确的数据项。

直接映射的缓存可以将标记核对与数据传输重叠起来,有效缩短命中时间。此外,在采用低相联度时,由于减少了必须访问的缓存行,所以通常还可以降低功率。尽管在各代新型微处理器中,片上缓存的总数已经大幅增加,但由于大容量L1缓存带来的时钟频率影响,所以L1缓存大小最近的涨幅很小,甚至根本没有增长。选择相联度时的另一个考虑因素是消除地址别名的可能性

在选择缓存大小和相联度时,能耗也是一个因素,在128KB或256KB缓存中,当从直接映射变到两路组相联时,高相联度的能量消耗从大于2到可以忽略。

每次读取操作的能耗随缓存大小、相联度增加而增加。八路组相联缓存的代价之所以很高,是因为并行读取8个标签及相应数据的成本造成的

在最近的设计中,有三种其他因素导致了在第一级缓存中使用较高的相联度。

  • 第一,许多处理器在访问缓存时至少需要两个时钟周期,因此命中时间较长可能不会产生太过严重的影响。
  • 第二,将TLB排除在关键路径之外(TLB带来的延迟可能要大于高相联度导致的延迟),几乎所有L1缓存都应当是变址寻址的。这就将缓存的大小限制为页大小与相联度的乘积,这是因为只有页内的位才能用于变址。在完成地址转换之前对缓存进行变址的问题还有其他一些解决方案,但提高相联度是最具吸引力的一种,它还有其他一些好处。
  • 第三,在引入多线程之后,冲突缺失会增加,从而使提高相联度更具吸引力。

第二种优化:采用路预测以缩短命中时间

这是另外一种可以减少冲突缺失,同时又能保持直接映射缓存命中速度的方法。在路预测技术中,缓存中另外保存了一些位,用于预测下一次缓存访问组中的路或块。这种预测意味着尽早设定多工选择器,以选择所需要的块,在与缓存数据读取并行的时钟周期内,只执行一次标签比较。如果缺失,则会在下一个时钟周期中查看其他块,以找出匹配项。

在一个缓存的每个块中都添加块预测位。根据这些位选定要在下一次缓存访问中优先尝试琊些块。如果顸测正确,则存访问延迟就等于这一快速命中时间。如果预测错误,则尝试其他块,改变路预测器,延迟会增加一个时钟周期。模拟表明,对于一个两路组相联缓存,组预测准确度超过90%;对于四路组相联缓存,超过80%,对I-缓存的准确度优于对D-缓存的准确度。

还有一种扩展形式的路预测,它使用路预测位来判断实际访问的缓存块,可以用来降低功耗(路预测位基本上就是附加地址位〕;这种方法也可称为路选择,当路预测正确时,它可以节省功率,但在路预测错误时则会显著增加时间,这是因为需要重复进行访问,而不仅是重复标记匹配与选择过程。这种优化方法只有在低功率处理器中才可能有意义。

第三种优化:实现缓存访问的流水化,以提高缓存带宽

这种方法就是实现缓存访问的流水化,使第一级缓存命中的实际延迟可以分散到多个时钟周期,从而缩短时钟周期时间、提高带宽,但会减缓命中速度。这一变化增加了流水线的段数,增加了预测错误分支的代价,延长了从发出载入指令到使用数据之间的时钟周期数,但的确更便于采用高相联度的缓存。

第四种优化:采用无阻塞缓存,以提高缓存带宽

对于允许乱序执行的流水化计算机,其处理器不必因为一次数据缓存缺失而停顿。例如,在等待数据缓存返回缺失数据时,处理器可以继续从指令缓存中提取指令。无阻塞缓存(或称为自由查询缓存)允许数据缓存在一次缺失期间继续提供缓存命中,进一步加强了这种方案的潜在优势。这种“缺失时仍然命中”优化方法在缺失期间非常有用,它不会在此期间忽略处理器的请求,从而降低了实际缺失代价。还有一种精巧而复杂的选项:如果能够重叠多个缺失,那缓存就能进一步降低实际缺失代价,这种选项被称为“多次缺失时仍然命中”或者“缺失时缺失”优化方法。只有当存储器系统可以为多次缺失供服务时,第二种选项才有好处,大多数高性能处理器通常都支持这两种优化方法。

对非阻塞缓存进行性能评估时,真正的难度在于一次缓存缺失不一定会使处理器停顿。在这种情况下,很难判断一次缺失造成的影响,因此也就难以计算存储器平均访问时间。实际缺失代价并不等于这些缺失时间之和,而是处理器的非重叠停顿时间。非阻塞缓存的优势评估非常复杂,因为它取决于存在多次缺失时的缺失代价、存储器引用模式以及处理器在有未处理缺失时能够执行多少条指令。

通常,对于一个能够在L2缓存中命中的L1数据缓存缺失,乱序处理器能够隐藏其大部分缺失代价,但不能隐藏更低层次缓存缺失的大部分代价。而在决定支持多少个未处理缺失时,需要考虑多种因素,如下所述。

  • 缺失流中的时间与空间局域性,它决定了一次缺失是否会触发对低级缓存或对存储器的新访问操作。
  • 对访问请求作出回应的存储器或缓存的带宽。
  • 为了允许在最低级别的缓存中出现更多的未处理缺失(在这一级别的缺失时间是最长的),需要在较高级别上支持至少数目相等的缺失,这是因为这些缺失必须在最高级别缓存上启动。
  • 存储器系统的延迟。

第五种优化:采用多种缓存以提高缓

我们可以将缓存划分为几个相互独立、支持同时访问的缓存组,而不是将它们看作一个整体。分组方式最初用于提高主存储器的性能,现在也用于DRAM芯片和缓存中。Arm Cortex-A8在其L2缓存中支持1至4个缓存组;Intel Core i7的L1中有4个组(每个时钟周期内可以支持2次存储器访问),L2有8个组。

显然,当访问请求很自然地分布在缓存组之间时,分组方式的效果最佳,所以将地址映射到缓存组的方式影响着存储器系统的行为。一种简单有效的映射方式是将缓存块地址按顺序分散在这些缓存组中,这种方式称为顺序交错。例如,如果有4个缓存组,0号缓存组中的所有缓存块地址对4求模后余0,1号缓存组中的所有缓存块地址对4求模后余1,以此类推。图中显示了这种交错方式。采用分组方式还可以降低缓存和DRAM中的功耗。

图2使用块寻址的四路交错缓存组。假定每个块64个字节,这些地址需要分别乘以64才能实现字节寻址

第六种优化:关键字优先和提前重启动以降低缺失代价

这一技术的事实基础是人们观测到处理器在某一时刻通常仅需要缓存块的一个字。这一策略显得“不够耐心”:无须等待完成整个块的载入过程,一旦载入被请求字之后,立即将其发出,然后就重启处理器。下面是两个待定策略。

  • 关键字优先:首先从存储器中请求缺失的字,在其到达缓存之后立即发给处理器;使处理器能够在载入块中其他字时继续执行。
  • 提前重启动:以正常顺序提取字,但只要块中的被请求字到达缓存,就立即将其发送给处理器,让处理器继续执行。

通常,只有在采用大型缓存块的设计中,这些技术才有用武之地,如果缓存块很小,它们带来的好处是很低的。注意,在载入某个块中的其余内容时,缓存通常可以继续满足对其他块的访问请求。

不过,根据空间局域性原理,下一次引用很可能就会指向这个块的其余内容。和非阻塞缓存一样,其缺失代价的计算也不是非常容易。在采用关键字优先策略时,如果存在第二次请求,实际缺失代价等于从本次引用开始到第二部分内容到达之前的非重叠时间。关键字优先策略和提前重启动策略的好处取决于块的大小以及在尚未获取某部分内容时又出现另一次访问的机率

第七种优化:合并写缓冲区以降低缺失代价

因为所有存储内容都必须发送到层次结构的下一层级,所以直写缓存依赖于写缓冲区。即使是写回缓存,在替代一个块时也会使用一个简单的缓冲区。如果写缓冲区为空,则数据和整个地址被写到缓冲区中,从处理器的角度来看,写入操作已经完成;在写缓冲区准备将字写到存储器时,处理器继续自己的工作。如果缓冲区中包含其他经过修改的块,则检查它们的地址,看看新数据的地址是否与写缓冲区中有效项目的地址匹配。如果匹配,则将新数据与这个项目合并在一起。这种优化方法称为写合并。如果缓冲区已满,而且没有匹配地址,则缓存(和处理器)必须等待,直到缓冲区中拥有空白项目为止。由于多字写入的速度通常快于每次只写入一个字的写入操作,所以这种优化方法可以更高效地使用存储器。

这种优化方式还会减少因为写缓冲区已满而导致的停顿。图中显示了一个写缓冲区在采用和不采用写合并时的情况。假定这个写缓冲区中有四项,每一项有4个位的字。在采用这一优化方法时,图中的4个字可以完全合并,放在写缓冲区的一个项目中,而在不采用这一优化方法时,对写缓冲区的连续地址执行4次存储操作,将整个缓冲区填满,每个项目中保存一个字。

注意,输入/出设备寄存器经常被映射到物理地址空间。由于IO寄存器是分享的,不能像存储器中的字数组那样操作,所以这些IO地址不能允许写合并。例如,它们可能要求为每个IO寄存器提供一个地址和一个数据字,而不能只提供一个地址进行多字写入。为了现这些副作用,通常由缓存将这些页面进行标记,表明其需要采用非合并直写方式。

图中为了说明写合并过程。上面的写缓冲区未采用该技术,下面的写缓冲区采用了这一技术。在进行合并时,4次写入内容被合并到一个缓冲区项目中;而未进行合并时,4次写入操作就填满了整个缓冲区,每个项目的四分之三被浪费。这个缓冲区有四个项目,每一项保存4个纠位字。每个项目的地址位于左侧,有效位(v)指明这个项目的下面8个连续字节是否被占用(未采用写合并时,图中上半部分右侧的字只会用于同时写多个字的指令。)

第八种优化:采用编译器优化以降低缺失率

前面介绍的技术都需要改变硬件。下面这种技术可以在不做任何硬件改变的情况下降低缺失率。

这种神奇的降低效果来自软件优化。处理器与主存储器之间的性能差距越拉越大,己经促使编译器编写入员深人研究存储器的层次结构,以了解能否通过编译时间优化来提高性能。同样,研究内容包括两个方面:指令缺失性能改进数据缺失性能改进。下面给出的优化技术在很多现代编译器中均有应用。

循环交换

一些程序中存在嵌套循环,它们会以非连续顺序访问存储器中的数据。只要交换一下这些循环的嵌套顺序,就可能使程序代码按照数据的存储顺序来访问它们。如果缓存中无法容纳这些数组这一技术可以通过提高空间局域性来减少缺失;通过重新排序,可以使缓存块中的数据在被替换之前,得到最大限度的充分利用。

例如,设x是一个大小为5000*100的两维数据,其分配方式使得x[i,j]x[i,j+1]相邻(由于这个数组是按行进行排列的,所以我们说这种顺序是以行为主的),以下两段代码说明可以怎样来优化访问过程:

1
2
3
4
5
6
7
8
9
/* 优化之前 */
for(j=0; j< 100; j=j+1)
for(i=0; i<5000; i++)
x[i][j] = 2 * x[i][j];

/* 优化之后 */
for(i=0; i<5000; i++)
for(j=0; j< 100; j=j+1)
x[i][j] = 2 * x[i][j];

原代码以100个字的步幅跳跃式浏览存储器,而修改后的版本在访问了一个缓存块中的所有字之后才进入下一个块。这一优化方法提高了缓存性能,却没有影响到所执行的指令数目。

分块

这一优化方法通过提高时间局域性来减少缺失。这一次还是要处理多个数组,有的数组是按行来访问的,有的是按列来访问的。由于在每个循环迭代中都用到了行与列,所以按行或按列来存储数组并不能解决问题(按行存储称为行主序,按列存储称为列主序)。这种正交访问方式意味着在进行循环交换之类的转换操作之后,仍然有很大的改进空间。

分块算法不是对一个数组的整行或整列进行操作,而是对其子矩阵(或称块)进行操作。其目的是在缓存中载入的数据被替换之前,在最大限度上利用它。下面这段执行矩阵乘法的示例可以帮助理解这一优化方法的动机:

1
2
3
4
5
6
7
8
/* 优化之前 */
for (i = 0; i < N; i ++)
for (j = 0; j < N; j ++) {
r = 0;
for (k = 0; k < N; k ++)
r = r + y[i][k] * z[k][j];
x[i][j] = r;
}

两个内层循环读取Z的所有N×N个元素,重复读取Y中一行的同一组N个元素,再写入X的一行N个元素。图中是访问这三个数组的一个快照。深色阴影区域表示最近的访问,浅色阴影区域表示较早的访问,白色表示还没有进行访问。

容量缺失的数目显然取决于N和缓存的大小。如果它能容纳所有这3个N×N矩阵,只要没有缓存冲突,就一切正常。如果缓存可以容纳一个N×N矩阵和包含N个元素的一行,则至少Y的第i行和数组Z可以停留在缓存中。如果缓存的容量更小,那可能对于X和Z都会发生缺失。在最差情况下,进行N^3次操作可能需要从存储器中读取2N^3 + N^2个字。

三个数组X、Y和Z的快照,其中N=6,i=1。对数组元素访问的前后时间用阴影表示。白色表示还没有被访问过,浅色表示较早的访问,深色表示最近的访问。与下图相对照,为了计算X的新元素,会重复读取Y和Z的元素。在行或列的旁边显示了用于访问这些数组的变量i,j和k。

为了确保正在访问的元素能够放在缓存中,对原代码进行了修改,改为计算一个大小为B×B的子矩阵。两个内层循环现在以大小为B的步长进行计算,而不是以X和Z的完整长度为步长。B被称为分块因子。(假定x被初始化为0。)

1
2
3
4
5
6
7
8
9
10
/* 优化之后 */
for (jj = 0; jj < N; jj += B)
for (kk = 0; kk < N; kk += B)
for (i = 0; i < N; i ++)
for (j = kk; j < min(jj+B, N); j ++) {
r = 0;
for (k = kk; k < min(kk+B, N); k ++)
r = r + y[i][k] * z[k][j];
x[i][j] = r;
}

图展示了使用分块方法对三个数组的访问。仅观察容量缺失,从存储器中访问的总字数为2N^3/B+N^2。这一总数的改善因素大约为B。由于Y获益于空间局域性,Z获益于时间局域性,所以分层方法综合利用了空间局域性和时间局域性。

尽管我们的目标是降低缓存缺失,但分块方法也可用于帮助寄存器分配。通过设定一个较小的分块大小,使这个块能够保存在寄存器中,可以在最大程度上降低程序中的载入与存储数量。

第九种优化:对指令和数据进行硬件预取,以降低缺失代价或缺失率

在处理器请求项目之前,预先提取它们。指令和数据都可以预先提取,既可以直接放在缓存中,也可以放在一个访问速度快于主存储器的外部缓冲区中。指令预取经常在缓存外部的硬件中完成。通常,处理器在一次缺失时提取两个块:被请求块和下一个相邻块。被请求块放在它返回时的指令缓存中,预取块被放在指令流缓冲区中。如果被请求块当前存在于指令流缓冲区中,则原缓存请求被取消,从流缓冲区中读取这个块,并发出下一条预取请求。

Intel core i7支持利用硬件预先提取到L1和L2中,最常见的预取情况是访问下一行。一些较早的Intel处理器使用更主动的硬件预取,但会导致某些应用程序的性能降低,一些高级用户会因此而关闭这一功能。图显示了在启用硬件预取时,部分SPEC2000程序的整体性能改进。注意,这一数字仅包含12个整数程序中的两个,而大多数SPEC浮点程序则都包含在内。

图在intel Pentium4上启用硬件预取之后,12个SPECInt 2000基准测试中的2个测试、14个SPECfp2000基准测试中的9个測试获得的加速比。图中仅给出从预取中获益最多的程序,对于图中未给出的15个SPEC基准测试,顸取加速比低于15%

预取操作需要利用未被充分利用的存储器带宽,但如果它干扰了迫切需要的缺失内容,反而可能会实际降低性能。在编译器的帮助下,可以减少无用预取。当预取操作正常执行时,它对功率的影响可以忽略。如果未用到预取数据或者替换了有用数据,预取操作会对功率产生非常负面的影响。

第十种优化:用编译器控制预取,以降低缺失代价或缺失率

作为硬件预取的替代方法,可以在处理器需要某一数据之前,由编译器插入请求该数据的预取指令。共有以下两种预取。

  • 寄存器预取将数据值载入到一个寄存器中。
  • 缓存预取仅将数据载入到缓存中,而不是寄存器中。

这两种预取都可能是故障性的,也都可能是非故障性的;也就是说,其地址可能会导致虚拟地址错误异常和保护冲突异常,也可能不会导致。利用这一术语,正常的载入指令可能被看作是“故障性寄存器预取指令”。非故障性预取只是在通常导致异常时转换为空操作,而这正是我们想要的结果。

最有效的预取对程序来说是“语义透明的”:它不会改变寄存器和存储器的内容,也不会导致虚拟存储器错误。今天的大多数处理器都提供非故障性缓存预取。本节假定非故障性缓存预取,也称为非绑定预取。

只有当处理器在预取数据时能够继续工作的情况下,预取才有意义;也就是说,缓存在等待返回预取数据时不会停顿,而是继续提供指令和数据。可以想到,这些计算机的数据缓存通常是非阻塞性的。

与硬件控制的预取操作类似,这里的目标也是将执行过程与数据预取过程重叠起来。循环是重要的预取优化目标,因为它们本身很适于进行预取优化。如果缺失代价很小,编译器只需要将循环展开一两次,在执行时调度这些预取操作。如果缺失代价很大,它会使用软件流水线或者将循环展开多次,以预先提取数据,供后续迭代使用。

不过,发出预取指令会导致指令开销,所以编译器必须非常小心地确保这些开销不会超过所得到的好处。如果程序能够将注意力放在那些可能导致缓存缺失的引用上,就可以避免不必要的预取操作,同时明显缩短存储器平均访问时间。

许多处理器支持缓存预取指令,高端处理器还经常在硬件中完成某种类型的自动预取。

缓存优化小结

表中估计了它们对复杂度的影响,其中“+”表明该技术会改进该项因素,“-”表明会损害该项因素。

存储器技术与优化

主存储器在层次结构上位于缓存的下一层级。主存储器满足缓存的需求,并充当接口,既是输入的目的地,又是输出的源头。对主存储器的性能度量同时强调延迟和带宽。传统上,主存储器延迟(它会影响缓存缺失代价)是缓存的主要考虑因素,而主存储器带宽则是微处理器和I/O的主要考虑因素。

由于多级缓存日益普及,而且采用较大的分块,使主存储器带宽对于缓存也非常重要。事实上,缓存设计人员增大块大小就是为了利用更高的存储器带宽。在过去,对主存储器的革新就是改变构成主存储器的众多DRAM芯片(比如多个存储器组)的组织方式。在使用存储器组时,通过拓宽存储器或其总线,或者同时加宽两者,可以提供更大的带宽。

但是,具有讽刺意味的是,随着单片存储器芯片容量的增加,在同样大小的存储器系统中,所需要的芯片越来越少,从而降低了存储器系统容量不变、带宽加大的可能性。为使存储器系统跟上现代处理器的带宽需求,存储器革新开始在DRAM芯片自身内部展开。

存储期延迟主要由两部分组成,访问时间和周期时间。访问时间是发出读取请求到收到所需字之间的时间,周期时间是指对存储器发出两次不相关请求之间的最短时间。

SRAM

SRAM的第一个字母表示静态。DRAM电路的动态本质要求在读取数据之后将其写回,因此在访问时间和周期时间之间存在差异,并需要进行刷新。SRAM不需要刷新,所以存在时间与周期时间非常接近。SRAM通常使用6个晶体管保存1位数据,以防止在读取信息时对其造成干扰。在待机模式中,SRAM只需要很少的功率来维持电荷。

DRAM技术

早期DRAM的容量增大时,由于封装上需要提供所有必要的地址线,所以封装成本较高。解决方案是对地址线进行复用,从而将地址管脚数砍去一半。图中给出基本的DRAM组成结构。先在行选通(RAS)期间发送一半地址。然后在列选通(CAS)期间发送另一半地址。行选通列选通等名字源于芯片的内部结构,这些存储器的内部是一个按行和列寻址的长方形矩阵。

对DRAM的另一要求来自其第一个字母D表示的特性,即动态(dynamic)。为了在每个芯片中容纳更多的位,DRAM仅使用一个晶体管来存储一位数据。信息的读取过程会破坏该信息,所以必须进行恢复。这是DRAM周期时间一般要长于访问时间的原因之一;近来,DRAM已经引入了多个组,从而可以隐藏访问周期中的重写部分,见图。此外,为了防止在没有读写某一个位时丢失信息,必须定期“刷新”该位。幸运的是,只需对一行进行读取就可以同时刷新该行。因此,每个DRAM必须在某一特定时间窗口内访问每一行。

图为DRAM中的内部组成结构。现代DRAM是以“组”为单位进行组织的,DDR3通常有4组。每一组由一系列行构成。发送PRE(precharge)命令会打开或关闭一个组。行地址随Act(activate)命令发送,该命令会将一个行传送到缓冲区中。将行放入缓冲区后,就可以采用两种方式进行传送,一种是根据DRAM的宽度采用连续列地址传送(在DDR3中,这一宽度通常为4位、8位、16位),另一种是指定块传送方式,并给出起始地址。每个命令和块传送过程,都以一个时钟进行同步。

这一要求意味着存储器系统偶尔会有不可用时间,因为它要发出一个信号,告诉每个芯片进行刷新。刷新时间通常是对DRAM中每一行进行完整存储器访同(RAS和CAS)的时间。由于DRAM中的存储器矩阵在概念上是方形的,所以一次刷新中的步骤通常是DRAM容量的平方根。DRAM设计人员尽力将刷新时间保持在总时间的5%以下。

刷新操作是存储器延迟发生变化的另一个原因,从而也会影响缓存缺失代价的变化。

Amdahl提出一条经验规律:要保持系统的平衡,存储器容量应当随处理器的速度线性增长,所以一个运算速度为1000MIPS的处理器应当拥有1000 MB的存储器。处理器设计人员依靠DRAM来满足这一要求。过去,他们可以指望存储器容量每三年翻两番,也就是年增长率为55%。遗憾的是,DRAM现在的性能增长速度非常慢。表2-2给出了行访问时间的性能改变,它与延迟有关,每年大约为5%。CAS或数据传输时间与带宽有关,其增长速度超过上述
速度的两倍。

尽管我们前面讨论的是独立芯片,但DRAM通常是放在被称为双列直插存储模块(DIMM)的小电路板上出售的。DIMM通常包括4-16个DRAM,对于桌面系统和服务器系统,它们通常被组织为8字节宽度(+ECC)。

提高DRAM芯片内部的存储器性能

前面曾经提到,对DRAM的访问分为行访问和列访问两部分。DRAM必须在DRAM内部缓冲一行中的所有位,为列访问做好准备,一行的位数通常等于DRAM大小的平方根,比如,4Mbit DRAM的每行有2Kbit。随着DRAM容量的增大,添加了一些附加结构,也多了几种提高带宽的可能性。

第一,DRAM添加了定时信号,允许复访问行缓冲区,从而节省了行访问时间。这种缓冲区的出现非常自然,因为每个数组会为每次访问操作缓冲1024-4094位信息。最初,在每次传输时都需要发送不同的列地址,从而在每组新的列地址之后都有一定的延迟时间。

DRAM原来有一个与存储器控制器相连的异步接口,所以每次传输都需要一定的开销,以完成与控制器的同步。第二,向DRAM接口添加一个时钟信号,使重复传输不需要承担这一开销。这种优化的名字就是同步DRAM(SDRAM)。SDRAM通常还有一个可编程寄存器,用于保存请求字节数,因此可以在几个周期内为每个请求发送许多字节。通常,将DRAM置于突发模式,不需要发送任何新地址就能进行8次或更多次的16位传输;这种模式支持关键字优先传
输方式,是唯一能够达到表2-3所示峰值带宽的方法。

第三,随着存储器系统密度的增加,为了从存储器获得较宽的比特流,而又不必使存储器系统变得过大,人们拓展了DRAM的宽度。原来提供一种4位传输模式;2010年,DDR2和DDR3 DRAMS已经拥有宽达16位的总线。

第四种提高带宽的DRAM重要创新是在DRAM时钟信号的上升沿和下降沿都传输数据,从而使峰值数据传输率加倍。这种优化方法被称为双倍数据率(DDR)。

为了提供交错(interleaving)的一些优点,并帮助进行电源管理,SDRAM还引入了分组,将一块SDRAM分为2-8个可以独立运行的块(在目前的DDR3 DRAM中为8块)。在一个DRAM中创建多个组可以有效地添加另一个地址段,它现在由组号、行地址和列地址组成。在发送指定一个新组的地址时,这个组必须己经打开,会增加一些延迟。分组与行缓冲区的管理完全由现代存储器控制接口处理,这样,如果后续地址指定了一个开放组的相同行时,只需要发送列地址就能快速完成访问。

在将DDR SDRAM封装为DIMM形式时,会用DIMM峰值带宽进行标记,这种标记方法很容易引起混淆。比如,之所得到PC2100这样一个DIMM名称,是源于133MHz x 2 x 8字节=2100MB/s,为了避免这种混淆,在对芯片本身进行标记时,给出的不是其时钟频率,而是每秒比特数,因此一个133MHz的DDR芯片被称为DDR266。表2-3给出了时钟频率、每芯片每秒钟的传送数目、芯片名称、DIMM带宽和DIMM名称之间的关系。

降低SDRAM中的功耗

动态存储器芯片中的功耗由静态(或待机)功率和读写期间消耗的动态功率构成,这两者取决于工作电压。在大多数高端DDR3 SDRAM中,工作电压已经降到1.35-1.5伏,与DDR2 SDRAM相比,显著降低了功率。分组的增加也降低了功率,这是因为每次仅读取并预充电一个分组中的行。

除了这些变化之外,所有最新SDRAM都支持一种省电模式,通知DRAM忽略时钟即可进入这一模式。省电模式会禁用SDRAM,但内部自动刷新除外(如果没有自动刷新,当进入省电模式时间长于刷新时间后,将会导致存储器内容丢失)。图2-12给出了一个2Gbit DDR3 SDRAM在三种情况下的功耗。从低功率模式返回正常模式所需的确切延迟时间取决于SDRAM,但从自动刷新低功率模式返回正常状态的时间一般为200个时钟周期;在第一个命令之前复位模式寄存器可能需要延长一些时间。

DDR3 SDRAM在3种不同运行条件下的功耗:低功率(关闭)模式、典型系统模式。(在读取操作中,DRAM有30%的时间处于活动状态,在写入操作中有15%的时间处于活动状态)和完全活动模式,在这种模式下,DRAM在非预充电状态下持续读取或写入。读取和写入采用由8次传输组成的突发形式。

闪存

闪存是一种EEPROM(电可擦可编程只读存储器),它通是只读的,但可以檫除。闪存的另一个重要特性是能在没有任何供电的情况下保存其内容

闪存在PMD中用作备份存储,其工作方式与笔记本型计算机或服务器中的磁盘相同。此外,由于大多数PMD的DRAM数量有限,所以闪存在很大程度上还要充当存储器层次结构的一级,闪存使用的体系结构与标准DRAM有很大不同,性质也有所不同。最重要的区别在于以下几方面。

  1. 在改写内存之前,必须对其进行擦除,(在高密度闪存中,称为NAND闪存,大多数计算机都采用这种闪存)它的擦除过程是按块进行的,而不是单独擦除各个字节或各个字。这意味着在需要向闪存中写入数据时,必须对整个块进行处理,或者全是新数据,或者将要写入的数据与块中的其他内容合
    并在一起。
  2. 闪存是静态的(也就是说,即使在没有供电的情况下,它也能保持其内容),在未进行读写时,只消耗非常低的一点功率(在待机模式下会低于一半,在完全非活动状态上可以为零)。
  3. 对任何一个块来说,闪存为其提供有限数目的写入周期,通常至少为1000000个。这样,系统可以确保写入块均匀分布在整个存储器中,从而在最大程度上延长闪存系统的寿命。
  4. 高密度闪存比SDRAM便宜,但比磁盘贵:闪存的价格大约是2美元/GB,SDRAM为20美元到40美GB,磁盘为0.09美元/GB。
  5. 闪存的速度比SDRAM慢得多,但比磁盘快得多。从DDR SDRAM进行类似传输需要的时间大约长四分之一,而从磁盘上传输则大约慢1000倍。对于写入操作,这种区别更是大得多,SDRAM至少比闪存快10倍,也可能达到100倍,具体数值取决于环境。

提高存储器系统的可靠性

缓存和主存储器容量的增大也大幅提高了在制造过程期间和对存储器单元进行动态冲击时(主要来自宇宙射线)出现错误的概率。这些动态错误会改变存储器单元的内容,但不会改变电路,称之为软错误。所有DRAM,闪存和许多SRAM在制造时都留有备用行,这样可以容忍少量的制造缺陷,只需要通过编程方式用备用行替代缺陷行即可。除了必须在配置期间纠正的制造错误之外,还可能在运行时发生硬错误,它可能会永久改变一个或多个存储器单元的运行方式。

动态错误可以使用奇偶校验位检测,可以使用纠错码(ECC)检测和纠正。因为指令缓存是只读的,用奇偶校验位就足够了。在更大型的数据缓存和主存储器中,则使用ECC技术来检测和纠正错误。奇偶校验位只需要占用一个数据位就可以检测一系列数据位中的一个错误。由于无法使用奇偶校验位来检测多位错误,所以必须限制用奇偶校验位提供保护的位数。一个常用比值是每8个数据位使用一个奇偶校验位。如果采用ECC技术,在每64个数据位中,8位的开销成本可以检测两个错误,纠正一个错误。

保护:虚拟存储器和虚拟机

通过虚拟存储器提供保护

页式虚拟存储器(包括缓存页表项目的变换旁视缓冲区)是保护进程免受相互伤害的主要机制。

多道程序设计(multi programming,几个同时运行的程序共享一台计算机的资源)需要在各个程序之间提供保护和共享,从而产生了进程概念。在任意时刻,必须能够从一个进程切换到另一个进程。这种交换被称为进程切换上下文切换。操作系统和体系结构联合起来就能使进程共享硬件而不会相互干扰。为此,在运行一个用户进程时,体系结构限制用户进程能够访问的资源,但要允许操作系统进程访问更多资源。体系结构至少要做到以下几点。

  1. 提供至少两种模式,指出正在运行的进程是用户进程还是操作系统进程。后者有时被称为内核进程或管理进程。
  2. 提供一部分处理器状态信息,用户进程可以使用但不能写入。这种状态信息包括用户/管理模式位、异常启用/禁用位和存储器保护位。之所以禁止用户写入这些状态信息,是因为如果用户可以授予自己管理权限、禁用异常或者改变存储器保护,那操作系统就不能控制用户进程了。
  3. 提供处理器借以从用户模式转为管理模式及反向转换的机制。前一种转换通常通过系统调用完成,使用一种特殊指令将控制传递到管理代码空间的一个专用位置。保存系统调用时刻的程序计数器,处理器转入管理模式。返回用户模式的过程类似于一个全程返回过程,恢复到先前的用户/管理模式。
  4. 提供限制存储器访问的机制,在上下文切换时不需要将一个进程切换到磁盘就能保护该进程的存储器状态。

到目前为止,最流行的机制还是添加对虚拟存储器各个页面的保护性限制。固定大小的页面(通常长4KB或8KB)通过一个页表由虚拟地址空间映射到物理地址空间。这些保护性限制就包含在每个页表项中。保护性限制可以决定一个用户进程能否读取这个页面,一个用户进程能否写这个页面以及能否从这个页面执行代码。此外,如果一个进程没有包含在页表中,那它就既不能读取也不能写入一个页面。由于只有操作系统才能更新页表,所以分页机制提供了全面的访问保护。

分页虚拟存储器意味着每次存储器访问在逻辑上都要花费至少两倍的时间,一次存储器访问用以获取物理地址,第二次访问用于获取数据。这种成本可能太过高昂了。解决方案就是依靠局域性原理,如果这些访问具有局域性,那么访问操作的地址转换也肯定具有局域性。只要将这些地址转换放在一个特殊的缓存中,存储器访问就很少再需要第二次访问操作来转换地址了。这种特殊的地址转换缓存被称为变换旁视缓冲区(TLB)。

TLB项目类似于缓存项目,其中的标记保存虚拟地址部分,数据部分保存物理页地址、保护字段、有效位,通常还有一个使用位和一个更改位(dirty bit)。操作系统在改变这些位时,改变页表中的值,然后使相应的TLB项失效。当这个项目新载入到页表中时,TLB即获得这些位的准确副本。

通过虚拟机提供保护

有一个与虚拟存储器相关而且几乎与它一样古老的概念,那就是虚拟机(VM)。它们最早是在20世纪60年代后期提出的,多年以来一直是大型机计算的重要组成部分。近来得到广泛关注,原因如下:

  • 隔离与安全在现代系统中的重要性提高
  • 标准操作系统的安全性和可靠性出现问题;
  • 许多不相关用户(比如一个数据中心或云中的用户〕共享同一计算机,
  • 处理器原始速度的飞速增大,使虚拟机的开销更容易接受。

最常见的情况是,VM支持的ISA与底层硬件相同;但也有可能支持不同的ISA,在ISA之间迁移时经常采用这种方法,这样,在能够迁移到新ISA之前,使软件能够继续在原ISA上使用。在我们重点关注的虚拟机中,所提供的ISA与其底层硬件相匹配。这种虚拟机称为(操作)系统虚拟机。它们让虚拟机用户感觉到自己拥有整个计算机,包括操作系统的副本在内。一台计算机可以运行多个虚拟机,可以支持多种不同操作系统。在传统平台上,一个操作系统“拥有”所有硬件资源,但在使用虚拟机时,多个操作系统一起共享硬件资源。

为虚拟机提供支持的软件称为虚拟机监视器(VMM)或管理程序,VMM是虚拟机技术的核心。底层硬件平台称为主机,其资源在来宾VM之间共享。VMM决定了如何将虚拟资源映射到物理资源:物理资源可以分时共享、划分,甚至可以在软件内模拟。VMM要比传统操作系统小得多。

一般来说,处理器虚拟化的成本取决于工作负载。用户级别的处理器操作密集型程序的虚拟化开销为零,这是因为很少会调用操作系统,所有程序都以原速运行。与之相对的是L/O操作密集的工作负载,它们通常也会大量涉及操作系统,执行许多系统调用(以满足I/O需求)和可能导致高虚拟化开销的特权指令。如果涉及大量I/O操作的工作负载也是IO密集型的,由于处理器经常要等待IO,所以处理器虚拟化的成本可以完全被较低的处理器利用率所隐藏。

尽管我们这里关心的是VM提供保护的功能,但VM还提供了其他两个具有重要商业价值的优点。

  1. 软件管理。VM提供一种能运行整个软件栈的抽象层。一种典型部署是用一部分VM运行原有OS,大量VM运行当前稳定的OS,一小部分VM用于测试下一代OS。
  2. 硬件管理。需要多个服务器的原因之一是希望每个应用程序都能在独立的计算机上与其兼容的操作系统一起运行,这种分离可以提高系统的可靠性。VM允许这些分享软件栈独立运行,却共享硬件,从而减少了服务器的数量。还有一个例子,一些VMM允许将正在运行的VM迁移到不同计算机上,既可能是为了平衡负载,也可能是为了撤出发生故障的硬件。

对虚拟机监视器的要求

一个VM监视器必须完成哪些任务?它向来宾软件提供一个软件接口,必须使不同来宾的状态相互隔离,还必须保护自己免受客户端软件的破坏(包括来宾操作系统)。定性需求包括:

  • 来宾软件在VM上的运行情况应当与在原始硬件上完全相同,当然,与性能相关的行为或者因为多个VM共享固定资源所造成的局限性除外;
  • 来宾软件应当不能直接修改实际系统资源的分配。

为了实现处理器的“虚拟化”,VMM必须控制几乎所有操作——特权状态的访问、地址转换、异常和中断,即使目前正在运行的来宾VM和操作系统正在临时使用它们,也不应当影响到这些控制。

例如,在计时器中断时,VMM将挂起当前正在运行的来宾VM,保存其状态、处理中断、判断接下来运行哪个来宾VM,然后载入其状态。依赖计时器中断的来宾都会有一个由VMM提供的虚拟计时器和仿真计时器。

为了进行管理,VMM的管理权限必须高于来宾,后者通常运行于用户模式;这样还能确保任何特权指令的执行都由VMM处理。系统虚拟机的基本需求几乎与上述分页虚拟存储器的要求相同。

  • 至少两种处理器模式:系统模式和用户模式。
  • 指令的一些特权子集只能在系统模式下使用,如果在用户模式下执行将会导致陷阱。所有系统资源都只能通过这些指令进行控制。

虚拟机(缺少)的指令集体系结构支持

如果在设计ISA期间已经为VM作了规划,那就可以比较轻松地减少VMM必须执行的指令数、缩短模拟这些指令所需要的时间。如果一种体系结构允许、直接在硬件上运行,则为其冠以可虚拟化的头衔。

由于VMM必须确保客户系统只能与虚拟资源进行交互,所以传统的来宾操作系统是作为一种用户模式程序在VMM的上层运行的。因此,如果一个来宾操作系统试图通过特权指令访问或修改与硬件相关的信息,它会向VM发出陷阱中断。VMM随后可以对相应的实际资源实进行适当修改。

因此,如果任何以用户模式执行的指令试图读写此类敏感信息陷阱,VMM可以截获它,根据来宾操作系统的需要,向其提供敏感信息的一个虚拟版本。如果缺乏此类支持,则必须采取其他措施。

虚拟机对虚拟存储器和IO的影响

由于每个VM中的每个来宾操作系统都管理其自己的页表集,所以虚拟存储器的虚拟化就成为另一项挑战。为了实现这一功能,VMM区分了实际存储器(real memory)和物理存储器,使实际存储器成为虚拟存储器与物理存储器之间的独立、中间级存储器。来宾操作系统通过它的页表将虚拟存储器映射到实际存储器,然页表将来宾的实际存储器映射到物理存储器。虚拟存储器体系结构可以通过页表指定;也可以通过TLB结构指定,许多RISC体系结构属于此类。

VMM没有再为所有存储器访问进行一级间接迂回,而是维护了一个影子页表,直接从来宾虚拟地址空间映射到硬件的物理地址空间。通过检测来宾页表的所有修改,VMM就能保证硬件在转换地址时使用的影子页表项与来宾操作系统环境的页表项一一对应,只是用正确的物理页代替了来宾表中的实际页。因此,只要来宾试图修改它的页表,或者试图访问页表指针,VMM都必须加以捕获。这一功能通常通过以下方法来实现:对来宾页表提供写保护,并捕获来宾操作系统对页表指针的所有访问尝试。前面曾经指出,如果对页表指针的访问属于特权操作,就会很自然地实现捕获。

IBM 370体系结构在20世纪70年代添加了一个由VMM管理的迂回层,解决了页表问题。来宾操作系统和以前一样保存自己的页表,所以就不再需要影子页表。在许多RISC计算机中,为了实现TLB的虚拟化,VMM管理实际TLB,并拥有每个来宾VM的TLB内容副本。为实现这一功能,所有访问TLB的功能都必须被捕获。具有进程ID标记的TLB可以将来自不同VM与VMM的项目混合在一起,所以不需要在切换VM时刷新TLB。与此同时,VMM在后台支持VM的虚拟进程ID与实际进程ID之间的映射。

体系结构中最后一个要虚拟化的部分是I/O。到目前为止,这是系统虚拟化中最困难的一部分,原因在于连接到计算机的I/O设备数目在增加,而且这些IO设备的类型也变得更加多样化。另外一个难题是在多个VM之间共享实际设备,还有一个难题是需要支持不同的设备驱动程序,在同一VM系统上支持不同来宾操作系统时尤为困难。为了仍然能够实现VM,可以为每个VM提供每种I/O设备驱动程序的一个通用版本,然后交由VMM来处理实际IO。

将虚拟设备映射到物理IO设备的方法取决于设备类型。例如,VMM通常会对物理进行分区,为来宾VM创建虚拟磁盘,而VMM会维护虚拟磁道与扇区到物理磁盘与扇区的映射。网络接口通常会在非常短的时间片内在VM之间共享,VMM的任务就是跟踪虚拟网络地址的消息,以确保来宾VM只收到发给自己的消息。

交叉问题:存储器层次结构的设计

保护和指令集体系结构

保护是由体系结构和操作系统协力完成的。但是当虚拟存储器变得更为普及时,体系结构必须修改现有指令集体系结构中一些不便使用的细节。在历史上,IBM大型机硬件和VMM通过以下3个步骤来提高虚拟机的性能:

  • 降低处理器虚拟化的成本
  • 降低由于虚拟化而造成的中断开销
  • 将中断传送给正确的VM,而不调用VMM,以降低中断成本。

Intel和AMD芯片集的最近版本添加了一些指令,用以支持VM中的设备,在较低层级屏蔽来自每个VM的中断,将中断发送到适当的VM。

缓存数据的一致性

数据可以同时出现在存储器和缓存中。只要处理器是唯一修改或读取数据的组件。并且缓存存在于处理器和存储器之间,那处理器看到旧副本或者说过期副本的危险性就很低。后面将会看到,使用多个处理器和IO设备增大了副本不一致及读取错误副本的机会。

处理器出现缓存一致性问题的频率与IO不同。对IO来说出现多个数据副本是非常罕见的情况,而一个在多处理器上运行的程序会希望在几个存中拥有同一数据的多个副本。多处理器程序的性能取决于系统共享数据的性能。IO缓存一致性问题可以表述如下:IO发生在计算机中的什么地方?在I/O设备与缓存之间,还是在IO设备与主存储器之间?如果输入将数据放在缓存中,而且输出从缓存中读取数据,那IO和处理器会看到相同数据。这种方法的难点在于它干扰了处理器。可能会导致处理器因为等待IO而停顿。输入还可能会用某些不会马上用到的新数据来取代存中的某些信息,从而对缓存造成干扰。

在带有缓存的计算机中,IO系统的目标应当是防止出现数据过期问题,同时尽可能减少干扰。因此,许多系統喜欢直接对主有储器进行I/O操作,把主存储器当作一个I/O缓冲区。如果使用直写缓存,存储器中将拥有最新的信息副本,在榆出时不存在过期数据问题(这一好处也是处理器使用直写方式的一个原因。遗憾的是,现在通常只会在第一级数据缓存中使用直写方式,由使用写回方式的L2缓存为其提供后盾。

输入操作还需要另外做点功课。软件解决方案是保证输入缓冲区的所有数据块都没有在缓存中。可以将包含缓冲区的页标记为不可缓存,操作系統总是可以向这样一个页面中输入数据。或者,可以由操作系统在输入之前从缓存刷新缓冲区地址。硬件解决方案则是在输入时检查IO地址,查看它们是否在缓存中。如果在缓存中找到了IO地址的匹配项,则使缓存项失效,以免过期数据。所有这些方法也都能用于带有写回缓存的输出操作。

融会贯通:ARM Cortex-A8和Intel i7中的存储器层次结构

ARM Cortex-A8

Cortex-A8是一种支持ARMv7指令集体系结构的可配置核心。它是作为一种IP(知识产权)核心交付的。在嵌入式、PMD和相关市场上,IP核心是主要的技术交付形式;利用这些IP核心已经生成了数十亿个ARM和MIPS处理器。注意,IP核心不同于Intel i7中的核心和AMD Athlon多核心。一个IP核心(它本身可能是多核心)就是为与其他逻辑集成而设计的,因此,它是一个芯片的核心。这些其他逻辑包括专用处理器(比如视频编解码器)、 I/O接口和存储器接口,从而制造出一种专门针对特定应用进行优化的处理器。

整体来说,IP 核心分为两类。硬核心针对特定半导体厂家进行优化,是一些具有外部接口的黑盒(不过这些接口仍然在片上)。硬核心通常仅允许对核心外面的逻辑进行参数设定,比如L2缓存大小,不能对IP核心本身进行修改。软核心的交付形式通常采用一个标准的逻辑元件库。软核心可以针对不同的半导体厂家进行编译,也可以进行修改,不过由于当今IP核心的复杂度,很难对其进行大幅修改。一般来说,硬核心的性能较高、晶片面积较小,而软核心则允许针对不同厂家进行调整,其修改更容易。

当时钟频率高达1 GHz 时,Cortex-A8每个时钟周期可以发出两条指令。它可以支持一种两级缓存层次结构,第一级是一个缓存对(I和D),分别为16 KB或32 KB,其组织形式为四路组相联缓存,并使用路预测和随机替代。其目的是使缓存的访问延迟只有一个周期,使Cortex-A8将从载入到使用的延迟时间保持在一个周期,简化指令提取,在分支缺失导致预取了错误指令时,降低提取正确指令的代价。第二级缓存是可选的,如果存在这一级缓存,则采用八路组相联,容量可达128 KB ~ 1 MB;它的组织形式分为1-4组,允许同时从存储器进行多次传输。一个64位至128位的外部总线用来处理存储器请求。第一级缓存为虚索引、物理标记,第二级缓存为物理索引与标记;这两级缓存的块大小都是64字节。当D缓存为32 KB且页大小为4KB时,每个物理页可以映射到两个不同的缓存地址;在发生缺失时,通过硬件检测可以避免出现混淆现象。

存储器管理由TLB对处理(I和D),每个TLB与32个项目完全相关,页面大小可变(4KB、16KB、64KB、1 MB和16 MB);TLB中的替换用轮询算法完成。TLB缺失在硬件中处理,它会遍历存储器中的一个页表结构。图2-13显示如何使用32位虚拟地址来索引TLB和缓存,假定主缓存为32 KB,次级缓存为512 KB,页面大小为16 KB。

Cortex-A8存储器层次结构的性能:Cortex-A8的存储器层次结构使用32 KB主缓存和1 MB八路组相联L2缓存来模拟,用整数Minnespec基准测试进行测试。Minnespec 是一个基准测试集,由SPEC2000基准测试组成,但其输入不一样,将运行时间缩短了几个数量级。尽管使用较小规模的输入并不会改变指令混合比例,但它的确会影响缓存行为。例如,根据mcf的测试结果(它是存储器操作最密集的SPEC2000整数基准测试),当缓存为32 KB时,Minnerspec的缺失率只有完整SPEC版本缺失率的65%。当缓存为1 MB时,这种差距为6倍。根据许多其他基准测试,这些比值都与mof的测试结果类似,但绝对缺失率要小得多。由于这一原因,不能将Minniespec基准测试与SPEC2000基准测试进行对比。不过这些数据对于研究L1和L2缺失对整体CPI的相对影响是很有用的。

图中展示了ARM Cortex-A8数据缓存和数据TLB的虚拟地址、物理地址、索引、标记和数据块。由于指令与数据层次结构是对称的,所以这里只给出其中一个。 TLB(指令或数据)与32个项目完全相关联。L1缓存是四路组相联,块大小为64个字节,容量为32 KB。L2缓存是八路组相联,块大小为64个字节,容量为1 MB。本图没有显示缓存和TLB的有效位和保护位,也没有使用可以指示L1缓存预测组的路预测位

即使仅对于L1,这些基准测试(以及作为Minniespec基础的完全SPEC2000版本)的指令缓存缺失率也非常低:大多接近于零,都低于1%。这种低缺失率的原因可能是因为SPEC程序在本质上是计算密集型的,而且四路组相联缓存消除了大多数冲突缺失。图2-14给出了数据缓存结果,这些结果中的L1和L2缺失率非常高。以DDR SDRAM为主存储器时,1 GHz Cortex-A8的L1缺失代价为11个时钟周期,L2缺失代价为60个时钟周期。通过这些缺失代价数据,图2-15给出了每次数据存取的平均代价。

2.6.2 Intel Core i7

i7支持x86-64指令集体系结构,它是80x86体系结构的64位扩展。i7 是包含四个核心的乱序执行处理器。i7中的每个核心采用一种多次发送、动态调度、16 级流水线,每个时钟周期可以执行多达4个80x86指令。i7还使用一种名为“同时多线程”的技术,每个处理器可以支持两个同时线程。2010 年,最快速i7的时钟频率为3.3GHz,指令的峰值执行速度为每秒132亿条指令,四核芯片超过每秒500万条指令。

i7可以支持多达三个存储器通道,每个通道由独立的DIMM组构成,它们能够并行传输数据。i7采用DDR3-1066(DIMM PC8500),峰值存储器带宽超过25 GB/s。i7使用48位虚拟地址和36位物理地址,物理存储器容量最大为36 GB。存储器管理用一个两级TLB处理,表2-4中对此进行了总结。

表2-5总结了i7 的三级缓存层次结构。第一级缓存为虚索引、物理标记,而L2和L3则采用物理索引。图2-16标有对存储器层次结构进行存取的步骤。首先,向揩令缓存发送程序计数器。指令缓存索引为:

1
2^索引 = 缓存大小/(块大小*组相联度) = 32K/(64*4) = 128 = 2^7

也就是7位。指令地址的页帧(36位)被发送给指令TLB。同时,来自虚拟地址的7位索引(再加上来自块偏移量的另外两位,用以选择适当的16字节,指令提取数)被发送给指令缓存。注意,对于四路相联指令缓存,缓存地址需要13位: 7位用于索引缓存,再加上64字节块的6位块偏移量,但页大小为4KB=2^12,这意味着缓存索引的一位必须来自虚拟地址。使用1位虚拟地址意味着对应块实际上可能位于缓存中的两个不同位置,这是因为对应的物理地址在这一位置既可能为0也可能为1。对指令来说,这样不会有什么问题,因为即使一条指令出现在缓存中的两个不同位置,这两个版本也必然是相同的。但如果允许对数据进行此类重复或别名设置,那在改变页映射时就必须对缓存进行检查,这一事件并非经常出现。注意,只要很简单地应用页着色就能消除出现这种混淆的可能性。如果偶数地址的虛拟页被映射到偶数地址的物理页(奇数页也一样),那么因为虚拟页号和物理页中的低阶位都是相同的,所以就不可能发生这种混淆。

为查找地址与有效页表项(PTE)之间的匹配项而访问指令TLB。除了转换地址之外,TLB还会进行检查,以了解这一访问操作所需要的PTE是否会因为非法访问而产生异常。

指令TLB缺失首先进入L2 TLB,它包含512个页大小为4KB的PTE,为四路组相联。从L2 TLB中载入L1 TLB需要两个时钟周期。如果L2 TLB缺失,则使用一种硬件算法遍历页表,并更新TLB项。在最糟糕情况下,这个页不在存储器中,操作系统从磁盘中获取该页。由于在页面错误期间可能执行数百万条指令,所以这时如果有另一进程正在等待,操作系统将转入该进程。否则,如果没有TLB异常,则继续访问指令缓存。

地址的索引字段被发送到指令缓存的所有4个组中。指令缓存标记为36-7位(索引)-6位(块偏移)=23位。将4个标记及有效位与来自指令TLB中的物理页帧进行对比。由于i7希望每个指令获取16个字节,所以使用6位块偏移量中的2位来选择适当的16个字节。因此,在向处理器发送16字节指令时使用了7+2=9位。L1缓存实现了流水化,一次命中的延迟为4个时钟周期。一次缺失将进入第二级缓存。前面曾经提到,指令缓存采用虚寻址、物理标记。因为第二级缓存是物理寻址的,所以来自TLB的物理页地址包含页偏移量,构成一个能够访问L2缓存的地址。L2索引为:

1
2^索引 = 缓存大小/(块大小*组相联度) = 256K/(64*8) = 512 = 2^9

所以长30位的块地址(36位物理地址-6位块偏移)被分为一个21位的标记和一个9位的索引。索引和标记再次被发送给统一L2缓存的所有8个组,同时对它们进行比较。如果有一个匹配且有效,则在开头的10周期延迟之后按顺序返回该块,返回速度为每个时钟周期8个字节。

如果L2缓存缺失,则访问L3缓存。对于一个四核i7(它的L3为8MB),其索引大小为:

1
2^索引 = 缓存大小/(块大小*组相联度) = 8M/(64*16) = 8192 = 2^13

这个长13位的地址被发送给L3的所有16个组。L3 标记的长度为36-(13-6)=17位,将其与来自TLB的物理地址进行对比。如果发生命中,则在初始延迟之后以每个时钟周期16字节的速度返回这个块,并将它放在L1和L3中。如果L3缺失,则启动存储器访问。

如果在L3中没有找到这个指令,片上存储器控制器必须从主存中找到这个块,i7有三个64位存储器通道,它们可以用作一个192位通道,这是因为只有一个存储器控制器,在两个通道上发送的是相同的地址;当两个通道具有相同的DIMM时,就可以进行宽通道传送。每个通道最多支持4个DDR DIMM。由于L3包含在内,所以在数据返回时,会将它们放在L3和L1中。

在发生指令缺失时,由主存储器提供这一指令的总延迟包括用于判断发生了L3缺失的约35个处理器周期,再加上关键指令的DRAM延迟。对于一个单组DDR1600 SDRAM和3.3 GHz CPU来说,在接收到前16个字节之前的DRAM延迟为大约35 ns或100个时钟周期,所以总的缺失代价为135个时钟周期。存储器控制器以每个存储器时钟周期16个字节的速度填充64字节缓存块的剩余部分,这将另外花费15 ns或45个时钟周期。

由于第二级缓存是一个写回缓存,任何缺失都会导致将旧块写回存储器中。i7 有一个10项合并写缓冲区,当缓存的下一级未用于读取时写回脏缓存行。在发生任何缺失时都会查看此写缓冲区,看看该缓存地是否在这个缓冲区中;如果在,则从缓冲区中获取缺失内容。在L1和L2缓存之间使用了一个类似缓冲区。

如果初始指令是一个载入指令,则将数据地址发送给数据缓存和数据TLB,与指令缓存访问非常类似,但有一个非常关键的区别。 第一级数据缓存为八路组相联,也就是说索引是6位(指令缓存为7位),用于访问此缓存的地址与页偏移相同。因此,就不再需要担心数据缓存中的混淆问题。

假定这个指令是存储指令,而不是载入指令。在发出存储指令时,它会像载入指令一样进行数据缓存查询。发生缺失时,会将这个块放到写缓冲区中,这是因为L1缓存在发生写缺失时不会分配该块。在命中时,存储不会立即更新L1(或L2)缓存,而是要等到确认没有疑问时才会进行更新。在此期间,存储指令驻存在一个“载入-存储”队列中,这是处理器乱序控制机制的一个组成部分。i7还支持从层次结构的下一层级为L1和L2进行预取。在大多数情况下,预取行就是缓存中的下一个块。在仅为L1和L2预取时,就可以避免向存储器执行高成本的提取操作。

我们使用SPECCPU2006基准测试中的19个基准测试(12个整型和7个浮点)来评估i7缓存结构的性能。本节的数据由路易斯安那州大学的LuPeng教授和Ying Zhang博士生收集。

我们首先来看L1缓存。这个32 KB、4路组相联指令缓存的指令缺失率非常低,最主要的原因是因为i7的指令预取十分有效。当然,由于i7不会为单个指令单元生成单独的请求,而是预取16字节的指令数据(通常介于4~ 5个指令之间),所以如何评估这一缺失率需要一点技巧。为了简单起见,如果我们就像处理单一指令引用那样研究指令缓存缺失率,那么L1指令缓存缺失率的变化范围为0.1%-1.8%,平均略高于0.4%。这一比率与利用SPECCPU2006基准测试对指令缓存行为进行的其他研究一致,这些研究也显示指令缓存缺失率很低。

L1数据缓存更有趣,对它的评估需要更强的技巧性,原因如下所述。

  • 因为L1数据缓存不进行写入分派,所以写入操作可以命中,从来不会真正缺失,之所以这么说,是因为那些没有命中的写入操作会将其数据放在写缓冲区中,而不会记录为缺失。
  • 因为推测有时可能会错误,所以会有一些对L1数据缓存的引用,它们没有对应最终会完整执行的载入或存储操作。这样的缺失应当怎样处理呢?
  • 最后,L1数据缓存进行自动预取。发生缺失的预取是否应当计算在内?如果要计算在内,如何计算?

为了解决这些问题,在保持数据量合理的情况下,图2-17以两种方式显示了L1的数据缓存缺失: 一种是相对于实际完成的载入指令数(通常称为“已完成”或“中途退出”),另一种是相对于从任意来源执行的L1数据缓存访问数。可以看到,在仅相对于已完成载入指令测试的缺失率要高出1.6倍(平均9.5%对5.9%)。表2-6以表格形式显示了相同数据。

由于L1数据缓存缺失率达到5%~10%,有时还会更高一些,所以L2和L3缓存的重要性应当就非常明显了。图2-18给出了L2和L3缓存相对于L1引用的缺失率。由于对存储器的一次缺失需要超过100个周期的成本,而且L2中的平均数据觖失率达4%,所以L3的重要性就不言而喻了。如果没有L3,并假定一半指令是载入或存储指令,L2缓存缺失会使CPI增加每条指令2个周期!作为对比,L3的平均数据缺失率为1%,仍然非常显眼,但只有L2缺失率的1/4,是L1缺失率的1/6。

指令级并行及其开发

指令级并行

大约1985年之后的所有处理器都使用流水线来重叠指令的执行过程,以提高性能。由于指令可以并行执行,所以指令之间可能实现的这种重叠称为指令级并行(ILP)。在本章中,我们将研究一系 列通过提高指令并行度来扩展基本流水线概念的技术。本章首先研究数据和控制冒险带来的局限性,然后再转而讨论如何提高编译器和处理器对并行的开发能力。

ILP大体有两种不同开发方法:

  1. 依靠硬件来帮助动态发现和开发并行;
  2. 依靠软件技术在编译时静态发现并行;

什么是指令级并行

这一章的所有技术都是开发指令间的并行。基本块(一段顺序执行代码,除入口外没有其他转入分支,除出口外没有其他转出分支)中可以利用的并行数非常有限。对于典型的MIPS程序,平均动态分支频率通常介于15%到25%之间,也就是说在一对分支之间会执行3~6条指令。由于这些指令可能相互依赖,所以在基本块中可以开发的重叠数量可能要少于基本块的平均大小。为了真正地提高性能,我们必须跨越多个基本块开发ILP。提高ILP的最简单、最常见方法是在循环的各次迭代之间开发并行。这种并行经常被称作循环级并行。

下面是一个简单的循环示例,它对两个分别有1000个元素的数组求和,完全可以并行实现:

1
2
for (i=0; i<=999; =i+1)
x[i] = x[i] + y[i];

这个循环的每次迭代都可以与任意其他迭代重叠,当然,在每次循环迭代中进行重叠的机会不大,甚至没有这种机会。我们将研究大量将这种循环级并行转换为指令级并行的技术。这些技术的工作方式基本上都是采用编译器静态展开循环或者利用硬件动态展开循环。开发循环级并行的一种重要替代方法是使用向量处理器和图形处理器中的SIMD。SIMD指令在开发数据级并行时,并行处理少量到中等数量的数据项。而向量指令在开发数据级并行时,则通过使用并行执行单元和深流水线,并行处理许多数据项。例如,上述代码序列的简单形式在每次迭代中需要7条指令(2次载入、1次求和、1次存储、2次地址更新和1次分支),总共7000条指令,而在每条指令可以处理4个数据项的某种SIMD体系结构中,只需要四分之一的指令就能完成任务。在一些向量处理器中,这个序列可能只需要4条指令:2条指令用于从存储器中载入向量x和y, 1条指令用于对两个向量求和,还有1条指令用于将结果向量存回存储器。当然,这些指令可以是流水化的,其延迟相对较长,但可以对这些延迟进行重叠。

数据相关与冒险

要确定一个程序中可以存在多少并行以及如何开发并行,判断指令之间的相互依赖性是至关重要的。具体来说,为了开发指令级并行,我们必须判断哪些指令可以并行执行。如果两条指令是并行的,只要流水线有足够资源(因而也就不存在任何结构性冒险),就可以在一个任意深度的流水线中同时执行它们,不会导致任何停顿。如果两条指令是相关的,它们就不是并行的,尽管它们通常可以部分重叠,但必须按顺序执行。这两种情景的关键在于判断一条指令是否依赖于另一指令。

数据相关

共有3种不同类型的相关:数据相关(也称为真数据相关)、名称相关和控制相关。如果以下任一条件成立,则说指令j数据相关于指令i:

  • 指令i生成的结果可能会被指令j用到;
  • 指令j数据相关于指令k,指令k数据相关于指令i。

第二个条件就是说:如果两条指令之间存在第一类型的相关链,那么这两条指令也是相关的。这种相关链可以很长,贯穿整个程序。注意,单条指令内部的相关性(比如ADDD RI, R1, R1)不认为是相关。例如,考虑以下MIPS代码序列,它用寄存器F2中的一个标量来递增存储器中的一个值向量(从0(R1)开始,最后一个元素是B(R2))。

1
2
3
4
5
Loop:   L.D F0,0(R1)     ;F0=数组元素
ADD.D F4,F0,F2 ;加上F2中的标量
S.D F4,0(R1) ;存储结果
DADDUI R1,R1,#-8 ;使指针递减8个字节
BNE R1,R2 ,LOOP ;R1!=R2 时跳转

这一代码序列中的数据相关涉及两个浮点数据:
1
2
3
Loop:   L.D F0,0(R1)     ;F0=数组元素
ADD.D F4,F0,F2 ;加上F2中的标量
S.D F4,0(R1) ;存储结果

和整型数据:
1
2
3
DADDIU R1,R1,#-8 ;使指针递减 8个字节
;(每个DW)
BNE R1 ,R2 ,Loop ;R1!=R2 时跳转

在以上两个相关序列中,如箭头所示,每条指令都依赖于上一条指令。这段代码及以下示例中给出的箭头表示为了正确执行必须保持的顺序。位于箭头起始处的指令必须位于箭头所指指令之前。如果两条指令是数据相关的,那它们必须按顺序执行,不能同时执行或不能完全重叠执行。这种相关意味着两条指令之间可能存在由一个或多个数据冒险构成的链。同时执行这些指令会导致具有流水线互锁(而且流水线深度大于指令间距离,以周期为单位)的处理器检测冒险和停顿,从而降低或消除重叠。在依靠编译器调度、没有互锁的处理器中,编译器在调度相关指令时不能使它们完全重叠,这样会使程序无法正常执行。指令序列中存在数据相关,反映出生成该指令序列的源代码中存在数据相关。原数据相关的影响一定会保留下来。

相关是程序的一种属性。某种给定相关是否会导致检测到实际冒险,这一冒险又是否会实际导致停顿,这都属于流水线结构的性质。这一区别对于理解如何开发指令级并行至关重要。数据相关传递了三点信息:

  1. 冒险的可能性
  2. 计算结果必须遵循的顺序
  3. 可开发并行度的上限

由于数据相关可能会限制我们能够开发的指令级并行数目,所以本章的一个重点就是如何克服这些局限性。可以采用两种不同方法来克服相关性:

  • 保护相关性但避免冒险;
  • 通过转换代码来消除相关性。

对代码进行调度是在不修改相关性的情况下避免冒险的主要方法,这种调度既可以由编译器完成,也可以由硬件完成。数据值既可以通过寄存器也可以通过存储器位置在指令之间传送。当数据传送在寄存器中发生时,由于指令中的寄存器名字是固定的,所以相关性的检测很简单,当然,如果存在分支干扰以及为了保持正确性而迫使编译器或硬件变得保守,那可能会变得复杂些。当数据在存储器位置之间流动时,由于两个看起来不同的地址可能引用同一位置,所以其相关性更难以检测,比如100(R4)和20(R6)可能是同一个存储器地址。

此外,载入指令或存储指令的实际地址可能会在每次执行时发生变化(所以20(R4)和20(R4)可能是不一样的),这使相关性的检测进一步复杂化。本章研究采用硬件来检测那些涉及存储器位置的数据相关,但我们将会看到,这些技术也有局限性。用于检测这些相关的编译器技术是揭示循环级别并行的关键所在。

名称相关

第二种相关称为名称相关。当两条指令使用相同的寄存器或存储器位置(称为名称),但与该名称相关的指令之间并没有数据流动时,就会发生名称相关。在指令i和指令j(按照程序顺序,指令i位于指令j之前)之间存在两种类型的名称相关。

(1)当指令j对指令i读取的寄存器或存储器位置执行写操作时就会在指令i和指令j之间发生反相关。为了确保i能读取到正确取值,必须保持原来的顺序。在前面的例子中,S.D和DADDIU之间存在关于寄存器R1的反相关。

(2)当指令i和指令j对同一个寄存器或存储器位置执行写操作时,发生输出相关。为了确保最后写入的值与指令j相对应,必须保持指令之间的排序。

由于没有在指令之间传递值,所以反相关和输出相关都是名称相关,与真数据相关相对。因为名称相关不是真正的相关,因此,如果改变这些指令中使用的名称(寄存器号或存储器位置),使这些指令不再冲突,那名称相关中涉及的指令就可以同时执行,或者重新排序。对于寄存器操作数,这一重命名操作更容易实现,这种操作称作寄存器重命名。寄存器重命名既可以由编译器静态完成,也可以由硬件动态完成。在介绍因分支导致的相关性之前,先让我们来看看相关与流水线数据冒险之间的关系。

数据冒险

只要指令间存在名称相关或数据相关,而且它们非常接近,足以使执行期间的重叠改变对相关操作数的访问顺序,那就会存在冒险。由于存在相关,必须保持程序顺序,也就是由原来的源程序决定的指令执行顺序。软、硬件技术的目的都是尽量开发并行方式,仅在程序顺序会影响程序输出时才保持程序顺序。检测和避免冒险可以确保不会打乱必要的程序顺序。根据指令中读、写访问的顺序,可以将数据冒险分为三类。根据惯例,一般按照流水线必须保持的程序顺序为这些冒险命名。考虑两条指令i和j,其中i根据程序顺序排在j的前面。可能出现的数据冒险为:

  • RAW(写后读)——j试图在i写入一个源位置之前读取它,所以j会错误地获得i旧值。这一冒险是最常见的类型,与真数据相关相对应。为了确保j会收到来自i的值,必须保持程序顺序。
  • WAW(写后写)——j试图在i写一个操作数之前写该操作数。这些写操作最终将以错误顺序执行,最后留在目标位置的是由i写入的值,而不是由j写入的值。这种冒险与输出相关相对应。只有允许在多个流水级进行写操作的流水线中,或者在前一指令停顿时允许后一指令继续执行的流水线中,才会存在WAW冒险。
  • WAR(读后写)——j尝试在i读取一个目标位置之前写入该位置,所以i会错误地获取新值。这一冒险源于反相关(或名称相关)。在大多数静态发射流水线中(即使是较深的流水线或者浮点流水线),由于所有读取操作都较早进行,所有写操作都要晚一些,所以不会发生WAR冒险。如果有一些指令在指令流水线中提前写出结果,而其他指令在流水线的后期读取一个源位置,或者在对指令进行重新排序时,就会发生WAR冒险,在本章后面将对此进行讨论。注意,RAR(读后读)情况不是冒险。

控制相关

最后一种相关是控制相关。控制相关决定了指令i相对于分支指令的顺序,使指令i按正确程序顺序执行,而且只会在应当执行时执行。除了程序中第一基本块中的指令之外,其他所有指令都与某组分支存在控制相关,一般来说,为了保持程序顺序,必须保留这些控制相关。控制相关的最简单示例就是分支中if语句的then部分中的语句。例如,在以下代码段中:

1
2
3
4
5
6
if p1 {
S1;
};
if p2 {
S2;
}

S1与p1控制相关,S2与p2控制相关,但与p1没有控制相关。

一般来说,控制相关会施加下述两条约束条件。

  • 如果一条指令与一个分支控制相关,那就不能把这个指令移到这个分支之前,使它的执行不再受控于这个分支。例如,不能把if语句then部分中的一条指令拿出来,移到这个if语句的前面。
  • 如果一条指令与一个分支没有控制相关,那就不能把这个指令移到这个分支之后,使其执行受控于这个分支。例如,不能将if之前的一个语句移到它的then部分。

在不影响程序正确性的情况下,我们可能希望执行一些还不应当执行的指令,从而会违犯控制相关。因此,控制相关并不是一个必须保持的关键特性。有两个特性对程序正确性是至关重要的,即异常行为数据流,通常保持数据相关与控制相关也就保护了这两种特性。保护异步行为意味着对指令执行顺序的任何改变都不能改变程序中激发异常的方式。通常会放松这一约束条件,要求改变指令的执行顺序时不得导致程序中生成任何新异常。下面的简单示例说明维护控制相关和数据相关是如何防止出现这类情景的。考虑以下代码序列:

1
2
3
4
DADDU R2, R3, R4
BEQZ R2,L1
LW R1 ,0(R2)
L1:

在这个例子中,可以很容易地看出如果不维护涉及R2的数据相关,就会改变程序的结果。还有一个事实没有那么明显:如果我们忽略控制相关,将载入指令移到分支之前,这个载入指令可能会导致存储器保护异常。注意,没有数据相关禁止交换BEQZ和LN;这只是控制相关。要允许调整这些指令的顺序(而且仍然保持数据相关),我们可能希望在执行这一分支操作时忽略此异常。

通过维护数据相关和控制相关来保护的第二个特性是数据流。数据流是指数据值在生成结果成和使用结果的指令之间进行的实际流动。分支允许一条给定指令从多个不同地方获取源数据,从而使数据流变为动态的。换种说法,由于一条指令可能会与之前的多条指令存在数据相关性,所以仅保持数据相关是不够的。一条指令的数据值究竟由之前哪条指令提供,是由程序顺序决定的。而程序顺序是通过维护控制相关来保证的。

例如,考虑以下代码段:

1
2
3
4
5
    DADDU R1,R2,R3
BEQZ R4,L
DSUBU R1,R5,R6
L:
OR R7,R1,R8

在这个例子中,OR指令使用的R1值取决于是否进行了分支转移。单靠数据相关不足以保证正确性。OR指令数据相关于DADDU和DSUBU指令,但仅保持这一顺序 并不足以保证能够正确执行。

在执行这些指令时,还必须保持数据流:如果没有进行分支转移,那么由DSUBU计算的R1值应当由OR使用;如果进行了分支转移,由DADDU计算的R1值则应当由OR使用。通过保持分支中OR的控制相关,就能防止非法修改数据流。出于类似原因,DSUBU指令也不能移到分支之前。推测不但可以帮助解决异常问题,还能在仍然保持数据流的同时降低控制相关的影响。

有些情况下,我们可以断定违犯控制相关并不会影响异常行为或数据流。考虑以下代码序列:

1
2
3
4
5
6
    DADDU R1,R2,R3
BEQZ R12,skip
DSUBU R4, R5,R6.
DADDU R5,R4,R9
skip:
OR R7 ,R8,R9

假定我们知道DSUBU指令的目标寄存器(R4)在标有skip的指令之后不再使用。(一个值是否会被后续指令使用,这一特性被称为活性。)如果R4不会再被使用,由于它在skip之后的代码部分变为死亡(不再具备活性),那么就在这个分支之前改变R4的值并不会影响数据流。因此,如果R4已经死亡,而且现有DSUBU指令不会生成异常(处理器会从某些指令处重启同一过程,这些指令除外),那就可以把DSUBU指令移到分支之前,数据流不会受这一改变的影响。

如果进行了分支转移,将会执行DSUBU指令,之后不再有用,但这样仍然不会影响程序结果。由于编译器在对分支结果进行猜测,所以这种类型的代码调度也是一种推测形式,通常称为软件推测;在这个例子中,编译器推测通常不会进行分支转移。

对导致控制停顿的控制冒险进行检测,可以保持控制相关。控制停顿可以通过各种软硬件技术加以消除或减少。

揭示ILP的基本编译器技术

这一节研究一些简单的编译器技术,可以用来提高处理器开发ILP的能力。这些技术对于使用静态发射或静态调度的处理器非常重要。有了这一编译器技术,稍后将会研究那些采用静态发射的处理器的设计与性能。

基本流水线调度和循环展开

为使流水线保持满载,必须找出可以在流水线中重叠的不相关指令序列,充分开发指令并行。为了避免流水线停顿,必须将相关指令与源指令的执行隔开一定的时间周期,这一间隔应当等于源指令的流水线延迟。编译器执行这种调度的能力既依赖于程序中可用ILP的数目,也依赖于流水线中功能单元的延迟。表3-2给出了在本章采用的FP单元延迟,如果偶尔采用不同延迟,会另行明确说明。假定采用一个标准的5级整数流水线,所以分支的延迟为一个时钟周期。假定这些功能单元被完全流水化或复制,所以在每个时钟周期可以发射任何一个类型的指令,不存在结构冒险。

最后一列是为了避免停顿而需要插入的时钟周期数。这些数字与我们在FP单元上看到的平均延迟类似。由于可以旁路载入指令的结果,不会使存储指令停顿,所以浮点载入指令对载入指令的延迟为0。我们还假定整数载入延迟为1,整数ALU操作延迟为0。

在这一小节,我们将研究编译器如何通过转换循环来提高可用ILP的数目。我们的讨论将就以下代码段展开,它将对一个标量和一个向量求和:

1
2
for (i = 999; i >= 0; i = i - 1)
x[1] = x[i] + s;

注意,这个循环的每个迭代体都是相互独立的,从而可以看出这个循环是并行的。首先来看这个循环的性能,说明如何利用并行来提高一个MIPS流水线的性能(采用以上所示的延迟值)。第一步是将以上代码段转换为MIPS汇编语言。在以下代码段中,R1 最初是数组元素的最高地址,F2 包含标量值s。寄存器R2的值预先计算得出,使8(R2)成为最后一个进行运算的元素的地址。

这段简单的MIPS代码应当类似如下所示(未针对流水线进行调度):

1
2
3
4
5
6
Loop:   L.D    F0,0(R1)  ;F0=数组元素
ADD.D F4,F0,F2 ;加上F2中的标量
S. D F4,0(R1) ;存储结果
DADDUI RI,R1,#-8 ;使指针逐减8个字节
;(每个DW)
BNE R1,R2,Loop;R1!=R2时跳转

首先来看看在针对MIPS的简单流水线上调度这个循环时的执行情况。

写出在进行调度与不进行调度的情况下,这个循环在MIPS上的执行过程,包括所有停顿或空闲时钟周期。调度时要考虑浮点运算产生的延迟,但忽略延迟分支。

在不进行任何调度时,循环的执行过程如下,共花费9个周期:

1
2
3
4
5
6
7
8
9
10
                       发射的时钟周期
Loop: L. D F0,0(RI) 1
停顿 2
ADD.D F4,F0,F2 3
停顿
停顿
S,D F4,0(R1) 6
DADDUI R1,R1,#-8 7
停顿 8
BNE R1. ,R2,Loop 9

我们可以调度这个循环,使其只有2次停顿,将花费时间缩短至7个周期:
1
2
3
4
5
6
7
Loop:   L.D      F0,0(R1)
DADDUI R1,R1.#-8
ADD.D F4,F0,F2
停顿
停顿
S.D F4,8(R1)
BNE R1,R2, Loop

ADD.D之后的停顿是供S.D.使用的。

在上面这个例子中,每7个时钟周期完成一次循环迭代,并存回一个数组元素,但对数组元索进行的实际运算仅占用这7个时钟周期中的3个(载入、求和与存储)。其余4个时钟周期包括循环开销(DADDUI和BNE)和2次停顿。为了消除这4个时钟周期,需要使循环体中的运算指令数多于开销指令数。

要提高运算指令相对于分支和开销指令的数目,一种简单的方案是循环展开。展开就是将循环体复制多次,调整循环的终止代码。

循环展开还可用于提高调度效率。由于它消除了分支,因此可以将来自不同迭代的指令放在一起调度。在这个例子中,我们通过在循环体内创建更多的独立指令来消除数据使用停顿。如果在展开循环时只是简单地复制这些指令,最后使用的都是同一组寄存器,所以可能会妨碍对循环的有效调度。因此,我们希望为每次迭代使用不同寄存器,这就需要增加寄存器的数目。

展开以上循环,使其包含循环体的4个副本,假定R1-R2(即数组的大小)最初是32的倍数,也就是说循环迭代的数目是4的倍数。消除任何明显的冗余计算,不要重复使用任何寄存器。

合并DADDUI指令,删除在展开期间重复的非必需BNE运算,得到的结果如下。注意,现在必须对R2进行置位,使32(R2)成为后4个元素的起始地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Loop:   L.D      F0,0(R1)
ADD.D F4,F0,F2
S.D F4,0(R1) ;删除DADDUI和BNE
L.D F6,-8(R1)
ADD.D F8,F6,F2
S.D F8,-8(R1) ;删除DADDUI和BNE
L.D F10,-16(R1)
ADD.D F12,F10,F2
S.D F12,-16(R1) ;删除DADDUI和BNE
L.D F14,-24(R1}
ADD.D F16,F14,F2
S.D F16,-24(R1)
DADDUI R1,R1,#-32
BNE R1,R2, LOOP

我们省掉了三次分支转移和对R1的三次递减。并对载入和存储指令的地址进行了补偿,以允许合并针对R1的DADDUI指令。这一优化看起来似乎微不足道,但实际并非如此;它需要进行符号替换和化简。符号替换和化简将重新整理表达式,以合并其中的常量,比如表达式((i+ 1)+ 1)可以重写为(i+(1 + 1)),然后化简为(i+2)。这种优化方式消除了相关计算。如果没有调度,展开循环中的每个操作后面都会跟有一个相关操作,从而导致停顿。这个循环将运行27个时钟周期(每个LD有1次停顿,每个ADDD 2、DADDUI 1,加上14个指令发射周期),或者说在4个元素的每个元素上平均花费6.75个时钟周期,但通过调度可以显著提高其性能。循环展开通常是在编译过程的早期完成的,所以优化器可以发现并消除冗余运算。

在实际程序中,人们通常不知道循环的上限。假定此上限为n,我们希望展开循环,制作循环体的k个副本。我们生成的是一对连续循环,而不是单个展开后的循环。第一个循环执行(n mod k)次,其主体就是原来的循环。第二个循环是由外层循环包围的展开循环,迭代(n/k)次。当n值较大时,大多数执行时间都花费在未展开的循环体上。在前面的例子中,通过展开消除了开销指令,尽管这样会显著增大代码规模,但却可以提高这一循环的性能。如果针对先前介绍的流水线来调度展开后的循环,它的执行情况又会如何呢?

针对具有表3-2 所示延迟的流水线,调度前面例子中展开后的循环,写出其执行情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Loop:   L.D         F0,0(R1)
L.D F6,-8{R1)
L.D F10,-16(R1}
L.D F14,-24{R1)
ADD.D F4,F0,F2
ADD.D F8,F6,F2
ADD.D F12,F10,F2
ADD.D F16,F14,F2
S.D F4,0(R1)
S.D F8,-8(R1)
DADDUI R1,R1,#-32
S.D F12,16(R1)
S.D F16,8(R1)
BNE R1,R2, Loop

展开后循环的执行时间已经缩减到总共14个时钟周期,或者说每个元素需要3.5个时钟周期,而在未进行任何展开或调度之前为每个元素9个时钟周期,进行调度但未展开时为7个周期。对展开循环进行调度所获得的收益甚至还会大于对原循环进行调度的收益。之所以会这样,是因为展开后的循环暴露了更多可以进行调度的计算,从而可以将停顿时间减至最低;上述代码中就没有任何停顿。要以这种方式调度循环,必须意识到载入指令和存储指令是不相关的,可以交换位置。

循环展开与调度小结

在本章中,我们将会研究各种可以利用指令级并行的硬件与软件技术,以充分发挥处理器中各功能单元的潜力。大多数此类技术的关键在于判断何时能够改变指令顺序以及如何改变。我们的例子中进行了许多此类改变,对于人类来说,可以很容易地判断出是可以进行此类改变的。而在实践中,这一过程必须采用系统方式,由编译器或硬件来完成。为了获得最终展开后的代码,必须进行如下决策和变换。

  • 确认循环迭代不相关(循环维护代码除外),判定展开循环是有用的。
  • 使用不同寄存器,以避免由于不同运算使用相同寄存器而施加的非必要约束(比如,名称相关)。
  • 去除多余的测试和分支指令,并调整循环终止与迭代代码。
  • 观察不同迭代中的载入与存储指令互不相关,判定展开后的循环中的载入和存储指令可以交换位置。这一变换需要分析存储器地址,查明它们没有引用同一地址。
  • 对代码进行调度,保留任何必要相关,以得到与原代码相同的结果。

要进行所有这些变换,最关键的要求就是要理解指令之间的相关依赖关系,而且要知道在这些给定关系下如何改变指令或调整指令的顺序。

有3种不同的效果会限制循环展开带来的好处:

  1. 每次展开操作分摊的开销数目降低,
  2. 代码规模限制,
  3. 编译器限制。

我们首先考虑循环开销问题。如果将循环展开4次,所生成的指令并行足以使循环调度消除所有停顿周期。事实上,在14个时钟周期中,只有2个周期是循环开销:维护索引值的DADDUI和终止循环的BNE。如果将循环展开8次,这一开销将从每次原迭代的1/2周期降低到1/4。对展开的第二个限制是所生成代码规模的增长。对于较大规模的循环,如果代码规模的增长会导致指令缓存缺失率的上升,那就要特别加以关注。还有一个因素通常要比代码大小更为重要,那就是由于大量进行展开和调试而造成寄存器数量不足。因为在大段代码中进行指令调度而产生的这一副作用被称为寄存器紧缺。它的出现是因为在为了提高ILP而调试代码时,导致存活值的数目增加。在大量进行指令调度之后,可能无法将所有存活值都分配到寄存器中。尽管转换后的代码在理论上可以提高运行速度,但由于它会造成寄存器短缺,所以可能会损失部分乃至全部收益。在没有展开循环时,分支就足以限制调度的大量使用,所以寄存器紧缺几乎不会成为问题。但是,循环展开与大量调度的综合应用却可能导致这一问题。 在多发射处理器中需要公开更多可以重叠执行的独立指令序列,上述问题可能变得尤其困难。一般来说, 高级复杂转换的应用已经导致现代编译器的复杂度大幅增加,而在生成具体代码之前,很难衡量这种应用带来的潜在改进。

直行代码段可以进行有效的调度,而循环展开是一种增大直行代码段规模的简单有效方法。这种转换在各种处理器上都非常有用,从我们前面已经研究过的简单流水线,到多发射超标量,再到本章后面要研究的VLIW。

用高级分支预测降低分支成本

由于需要通过分支冒险和停顿来实施控制相关,所以分支会伤害流水线性能。循环展开是降低分支冒险的一种方法,我们还可以通过预测分支的行为方式来降低分支的性能损失。我们将研究一些简单的分支预测器,它们既可能依赖于编译时信息,也可能依赖于在隔离状态下观测到的分支动态行为。随着所用指令数目的增大,更准确的分支预测也变得越来越重要。在本节,我们将研究一些提高动态预测准确度的技术。

相关分支预测

2位预测器方案仅使用单个分支的最近行为来预测该分支的未来行为。如果我们同时还能查看其他分支的最近行为,而不是仅查看要预测的分支,那就有可能提高预测准确度。考虑eqntott基准测试中的一小段代码,这个基准测试是早期SPEC基准测试套件的一个成员,用来显示特别糟糕的分支预测行为:

1
2
3
4
5
if (aa==2)
aa=0;
if (bb==2)
bb=0;
if (aa !=bb){

下面是通常为这一代码段生成的MIPS代码,假定aa和bb分别被赋值给R1和R2:
1
2
3
4
5
6
7
8
        DADDIU    R3,R1,#-2
BNEZ R3,L1 ;branch b1 (aa!=2)
DADD R1,RO,R0 ;aa=0
L1: DADDIU R3,R2,#-2
BNEZ R3,L2 ;branch b2 (bb!=2)
DADD R2,RO,RO ;bb=0
L2: DSUBU R3,R1,R2 ;R3=aa-bb
BEQZ R3,L ;branch b3 (aa==bb)

我们将这些分支标记为b1、b2和b3。从中可以看出很重要的一点:分支b3的行为与分支b1和b2的行为有关。显然,如果分支b1和b2都未执行转移(即其条件均为真,且aa和bb均被赋值为0),aa 和bb明显相等,所以会进行b3分支转移。如果预测器仅利用一个分支的行为来预测分支结果,那显然不会捕获到这一行为。利用其他分支行为来进行预测的分支预测器称为相关预测两级预测器。现有相关预测器增加最近分支的行为信息,来决定如何预测一个给定分支。例如,(1,2)预测器在预测一个特定分支时,利用最近一个分支的行为从一对2位分支预测器中进行选择。在一般情况下,(m,n)预测器利用最近m个分支的行为从2^m个分支预测器中进行选择,其中每个预测器都是单个分支的n位预测器。这种相关分支预测器的吸引力在于它的预测率可以高于2位方案,而需要添加的硬件很少。

硬件的这种简易性源自一个简单的观察事实:最近m个分支的全局历史可以记录在m位移位的寄存器中,其中每一位记录是否执行了该分支转移。将分支地址的低位与m位全局历史串联在一起,就可以对分支预测缓冲区进行寻址。例如,在一个共有64项的(2,2)缓冲区中,分支的低4位地址(字地址)和2个全局位(表示最近执行的两个分支的行为)构成一个6位索引,可用来对64个计数器进行寻址。

与标准的2位方案相比,相关分支预测器的性能可以提高多少呢?为了进行公平对比,进行比较的预测器必须使用相同数目的状态位。一个(m,n)预测器的位数为:

1
2^m * n *由分支地址选中的预测项数目

没有全局历史的2位预测器就是(0,2)预测器。

在具有4K项的(0,2)分支预测器中有多少位?在具有同样位数的(2,2)预测器中有多少项?

具有4K项的预测器拥有:

1
2^0 * 2 * 4K = 8K位

在预测缓冲区中共有8K位的(2,2)预测器中有多少由分支选中的项呢?我们知道:
1
2^2 * 2 * 由分支选中的预测项数=8K

因此,由分支选中的预测项数=1K。

图3-1对比了前面具有4K项的(0,2)预测器与具有1K项的(2,2)预测器的错误预测率。可以看出,这种相关预测器的性能不但优于具有相同状态位数的简单2位预测器,还经常会优于具有无限项数的2位预测器。

2位预测器的对比。第一个是4096位的非相关预测器,然后是具有无限数目的非相关2位预测器,和一个具有2位全局历史和总共1024项的2位预测器。尽管这些数据是从SPEC的较早版本获取的,但最近SPEC基准测试的数据也显示了类似的准确度差异。

竞赛预测器: 局部预测器与全局预测器的自适应联合

采用相关分支预测器主是要因为观察到:仅使用局部信息的标准2位预测器无法预测某些重要分支,而通过增加全局信息就可以提升性能。竞赛预测器更进一步,采用了多个预测器,通常是一个基于全局信息的预测器和一个基于局部信息的预测器,用选择器将它们结合起来。竞赛预测器既可以以中等规模的预测位(8K~32K位)实现更好的预测准确度,还可以更有效地利用超大量预测位。现有竞赛预测器为每个分支使用一个2位饱和预测器,根据哪种预测器(局部、全局,甚至包括两者的组合方式)在最近的预测中最为有效,从两个不同预测器中进行选择。在简单的2位预测器中,饱和计数器要在两次预测错误之后才会改变优选预测器的身份。

竞赛预测器的优势在于它能够为特定分支选择正确的预测器,这一点对于整数基准测试尤为重要。对于SPEC整数基准测试,典型竞赛预测器为其选择全局预测器的概率大约为40%,而对于SPECFP基准测试,则少于15%。除了在竞赛预测器方面走在前列的Alpha处理器之外,最近的AMD处理器,包括Opteron和Phenom,都已经采用了竞赛类型的预测器。图3-2以SPEC89为基准测试,研究三种不同预测器:一个局部2位预测器、一个相关预测器和竞赛预测器,在不同位数时的性能。和我们前面曾经看到的一样,局部预测器的预测性能改进不会超出一定的范围。相关预测器的改进非常突出,竞赛预测器的性能稍好一些。对于SPEC的最新版本,其结果是类似的,但需要采用稍大些的预测器规模才能达到渐趋一致的行为。

图3-2在总位数升高时,3种不同预测器运行SPEC89的错误预测率。这些预测器包括:一个局部2位预测器;一个相关预测器;针对图中每一点的全局和局部信息采用了结构优化;一个竞赛预测器。尽管这些数据是从SPEC的较早版本获取的,但最近SPEC基准测试的数据也显示了类似行为,当预测器规模稍大时,可能趋于一个渐近值

局部预测器包括一个两级预测器。顶级是一个局部历史表,包括1024个10位项;每个10位项对应这一项最近10个分支输出。即,如果这个分支被选中10次或更多次,那么这个局部历史表中的相应项都是1。如果这个分支被交替选中和未选中,那么历史项中则包括交替的0和1。这个10位历史信息最多可以发现和预测10个分支。局部历史表的选定项用来对一个拥有1K项的表进行索引,这个表由3位饱和计数器组成,可以提供局部预测。这种组合共使用29K位,可以提高分支预测的准确度。

Intel Core i7分支预测器

关于Core i7的分支预测器,Intel 只发布了非常有限的信息,这种预测器是以Core Duo芯片中使用的较早预测器为基础。i7 使用了一个两级预测器,它的第一级预测器较小, 设计用来满足周期约束条件:每个时钟周期预测一个分支,还有一个较大的二级预测器作为备份。每个预测器都组合了3个不同预测器:(1)简单的两位预测器;(2)全局历史预测器,类似于我们刚刚看到的预测器;(3)循环退出预测器。对于一个被检测为循环分支的分支,循环退出预测器使用计数器来预测被选中分支的确切数目(也就是循环迭代的数目)。对于每个分支,通过跟踪每种预测的准确度从3个预测器中选择最佳预测,就像一个竞赛预测器。除了这一多级主预测器之外,还有一个独立单元为间接分支预测目标地址,还使用了栈来预测返回地址。

和在其他情景中一样,推测在评估预测器方面也导致了一些难题,这是因为对一个分支的错误预测很容易导致提取和错误预测另一个分支。为了使事情变得简单一些,我们看一下错误预测数占成功完成分支数(这些分支不是推测错误导致的结果)的百分比。图3-3显示了19个SPECCPU 2006基准测试的数据。这些基准测试明显大于SPEC89或SPEC2000,其结果是:即使更精心地组合应用预测器,其错误预测率也只是稍大于图3-2中所示数据。由于分支预测错误会导致推测效率低下,所以它会导致一些无效工作,在本章后面将会了解到这一点。


图3-3 19 个SPECCPU2006基准测试错误预测数占成功完成分支数的比例,整数基准测试的平均值稍高于FP(4%对3%)。更重要的是,某些基准测试的错误率高出很多

用动态调度克服数据冒险

除非是流水线中的已有指令与要提取的指令之间存在数据相关,而且无法通过旁路或转发来隐藏这一数据相关,否则,简单的静态调度流水线就会提取一条指令并发射出去。(转发逻辑可以减少实际流水线延迟,所以某些特定的相关不会导致冒险)如果存在不能隐藏的数据相关,那些冒险检测软件会从使用该结果的指令开始,将流水线置于停顿状态。在清除这一相关之前,不会提取和发射新的指令。

本节将研究动态调度,在这种调度方式中,硬件会重新安排指令的执行顺序以减少停顿,并同时保持数据流和异常行为。动态调度有几个优点。

  • 第一,它允许针对一种流水线编译的代码在不同流水线上高效执行,不需要在使用不同微体系结构时重新进行编译,并拥有多个二进制文件。
  • 第二,在某些情况下,在编译代码时还不能知道相关性,利用动态调度可以处理某些此类情况;比如,这些相关可能涉及存储器引用或者与数据有关的分支,或者,它们可能源自使用动态链接或动态分发的现代编程环境。
  • 第三,也可能是最重要的一个优点,它允许处理器容忍些预料之外的延迟,比如缓存缺失,它可以在等待解决缺失问题时执行其他代码。

在3.6节,我们将研究以动态调度为基础的硬件推测,这一技术还有更多性能方面的优势。我们将会看到,动态调度的好处是以硬件复杂度的显著提高为代价的。尽管动态调度的处理器不能改变数据流,但它会在存在相关性时尽力避免停顿。相反,由编译器调度的静态流水线也会尽力将停顿时间降至最低,具体方法是隔离相关指令,使它们不会导致冒险。当然,对于那些本来准备在采用动态调度流水线的处理器上运行的代码,也可以使用编译器流水线调度。

动态调度: 思想

简单流水线技术的一个主要限制是它们使用循序指令发射与执行:指令按程序顺序发射,如果一条指令停顿在流水线中,后续指令都不能继续进行。因此,如果流水线中两条相距很近的指令存在相关性,就会导致冒险和停顿。如果存在多个功能单元,这些单元也可能处于空闲状态。如果指令j依赖于长时间运行的指令i(当前正在流水线中执行),那么j之后的所有指令都必须停顿,直到i完成、j可以执行为止。例如,考虑以下代码:

1
2
3
DIV.D     F0, F2,F4
ADD.D F10,F0,F8
SUB.D F12,F8,F14

由于ADD.D对DIV.D的相关性会导致流水线停顿,所以SUB.D指令不能执行;但是,SUB.D与流水线中的任何指令都没有数据相关性。这一冒险会对性能造成限制,如果不需要以程序顺序来执行指令,就可以消除这一限制。在经典的五级流水线中,会在指令译码(ID)期间检查结构冒险和数据冒险:当一个指令可以无冒险执行时,知道所有数据冒险都已经解决,从ID将其发射出去。

为了能够开始执行上面例子中的SUB.D,必须将发射过程分为两个部分:检查所有结构冒险和等待数据冒险的消失。因此,我们仍然使用循序指令发射(即,按程序顺序发射指令),但我们希望一条指令能够在其数据操作数可用时立即开始执行。这样一种流水线实际是乱序执行,也就意味着乱序完成。

乱序执行就可能导致WAR和WAW冒险,在这个五级整数流水线及其循序浮点流水线的逻辑扩展中不存在这些冒险。考虑以下MIPS浮点代码序列:

1
2
3
4
DIV.D    F0,F2,F4
ADD.D F6,F0,F8
SUB.D F8,F10,F14
MUL.D F6,F10,F8

在ADD.D和SUB.D之间存在反相关,如果流水线在ADD.D之前执行SUB.D(ADD.D在等待DIV.D),将会违犯反相关,产生WAR冒险。与此类似,为了避免违犯输出相关,比如由MUL.D写入F6,就必须处理WAW冒险。后面将会看到,利用寄存器重命名可以避免这些冒险。

乱序完成还会使异常处理变得复杂。采用乱序完成的动态调度必须保持异常行为,使那些在严格按照程序顺序执行程序时会发生的异常仍然会实际发生,也不会发生其他异常。动态调度处理器会推迟发布相关异常的发布,一直等到处理器知道该指令就是接下来要完成的指令为止,通过这一方式来保持异常行为。

尽管异常行为必须保持,但动态调度处理器可能生成一些非精确异常。如果在发生异常时,处理器的状态与严格按照程序顺序执行指令时的状态不完全一致,那就说这一异常是非精确的。非精确异常可以因为以下两种可能性而发生。

  1. 流水线在执行导致异常的指令时,可能已经完成了按照程序顺序排在这一指令之后的指令。
  2. 流水线在执行导致异常的指令时,可能还没有完成按照程序顺序排在这一指令之前的指令。

非精确异常增大了在异常之后重新开始执行的难度。我们在这一节不会解决这些问题,而是讨论一种解决方案,能够在具有推测功能的处理器环境中提供精确异常。对于浮点异常,已经采用了其他解决方案。

为了能够进行乱序执行,我们将五级简单流水线的ID流水级大体分为以下两个阶段。

  1. 发射:译码指令,检查结构性冒险。
  2. 读操作数:直等到没有数据冒险,然后读取操作数。

指令提取阶段位于发射阶段之前,既可以把指令放到指令寄存器中,也可能放到一个待完成指令队列中;然后从寄存器或队列发射这些指令。执行阶段跟在读操作数阶段之后,这一点和五级流水线中一样。执行过程可能需要多个周期,具体数目取决于所执行的操作。

我们区分一个指令开始执行和完成执行的时刻,在这两个时刻之间,指令处于执行过程中。我们的流水线允许同时执行多条指令,没有这一功能,就会失去动态调度的主要优势。要同时执行多条执行,需要有多个功能单元、流水化功能单元,或者同时需要这两者。由于这两种功能(流水化功能单元和多个功能单元)在流水线控制方面大体相当,所以我们假定处理器拥有多个功能单元。

在动态调度流水线中,所有指令都循序经历发射阶段(循序发射);但是,它们可能在第二阶段(读操作数阶段)停顿或者相互旁路,从而进行乱序执行状态。记分板技术允许在有足够资源和没有数据相关时乱序执行指令。这里重点介绍一种名为Tomasulo算法的更高级技术。它们之间的主要区别于Tomasulo算法通过对寄存器进行有效动态重命名来处理反相关和输出相关。

此外,还可以对Tomasulo算法进行扩展,用来处理推测,这种技术通过预测一个分支的输出、执行预测目标地址的指令、当预测错误时采取纠正措施,从而降低控制相关的影响。使用记分板可能足以支持诸如ARM A8之类的简单两发射超标量处理器,而诸如四发射Intel i7之类更具主动性的处理器则受益于乱序执行的使用。

使用Tomasulo算法进行动态调度

IBM 360/91浮点单元使用一种支持乱序执行的高级方案。这一方案由Robert Tomasulo发明,它会跟踪指令的操作数何时可用,将RAW冒险降至最低,并在硬件中引入寄存器重命名功能,将WAW和WAR冒险降至最低。在现代处理器中存在这一方案的许多变体,但跟踪指令相关以允许在操作数可用时立即执行指令、重命名寄存器以避免WAR和WAW冒险,这些核心概念仍然是它们的共同特征。

IBM的目标是从指令集出发、从为整个350计算机系列设计的编译器出发来实现高浮点性能,而不是通过采用专门为高端处理器设计的编译器来实现。360体系结构只有4个双精度浮点寄存器,它限制了编译器调度的有效性;这一事实是开发Tomasulo方法的另一个动机。此外,IBM 360/91的内存访问时间和浮点延迟都很长,Tomasulo算法就是设计用来克服这些问题的。在本节的最后,我们将会看到Tomasulo算法还支持重叠执行一个循环的多个迭代。

我们将在MIPS指令集上下文中解释这一算法,重点放在浮点单元和载入-存储单元。MIPS与360之间的主要区别是后者的体系结构中存在寄存器存储器指令。由于Tomasulo算法使用一个载入功能单元,所以添加寄存器-存储器寻址模式并不需要进行大量修改。IBM 360/91还有一点不同,它拥有的是流水化功能单元,而不是多个功能单元,但我们在描述该算法时仍然假定它有多个功能单元。它只是对功能单元进行流水化的概念扩展。

我们将会看到,如果仅在操作数可用时才执行指令,就可以避免RAW冒险,而这正是一些简单记分板方法提供的功能。WAR和WAW冒险(源于名称相关)可以通过寄存器重命名来消除。对所有目标寄存器(包括较早指令正在进行读取或写入的寄存器)进行重命名,使乱序写入不会影响到任何依赖某一操作数较早值的指令,从而消除WAR和WAW冒险。为了更好地理解寄存器重命名如何消除WAR和WAW冒险,考虑以下可能出现WAR和WAW冒险的代码序列示例:

1
2
3
4
5
DIV.D     F0,F2,F4
ADD.D F6,F0,F8
S.D F6,0(R1)
SUB.D F8,F10,F14
MUL.D F6,F10,F8

以上代码共有两处反相关:ADD.D与SUB.D之间,S.D和MUL.D之间。在ADD.D和MUL.D之间还有一处输出相关,从而一共可能存在3处冒险:ADD.D使用F8和SUB.D使用F6时的WAR冒险,以及因为ADD.D可能在MUL.D之后完成所造成的WAW冒险。还有3个真正的数据相关:DIV.D和ADD.D之间、SUB.D和MUL.D之间、ADD.D和S.D之间。

这3个名称相关都可以通过寄存器重命名来消除。为简便起见,假定存在两个临时寄存器:S和T。利用S和T,可以对这一序列进行改写,使其没有任何相关,如下所示:

1
2
3
4
5
DIV.D    F0,F2,F4
ADD.D S,FO,F8
S.D S, 0(R1)
SUB.D T,F10,F14
MUL.D F6,F10,T

此外,对F8的任何后续使用都必须用寄存器T来代替。在这个代码段中,可以由编译器静态完成这一重命名过程。要在后续代码中找出所有使用F8的地方,需要采用高级编译器分析或硬件支持,这是因为上述代码段与后面使用F8的位置之间可能存在插入分支。我们将会看到,Tomasulo算法可以处理跨越分支的重命名问题。

在Tomasulo方案中,寄存器重命名功能由保留站提供,保留站会为等待发射的指令缓冲操作数。其基本思想是:保留站在一个操作数可用时马上提取并缓冲它,这样就不再需要从寄存器中获取该操作数。此外,等待执行的指令会指定保留站,为自己提供输入。最后,在对寄存器连续进行写入操作并且重叠执行时,只会实际使用最后一个操作更新寄存器。在发射指令时,会将待用操作数的寄存器说明符更名,改为保留站的名字,这就实现了寄存器重命名功能。

由于保留站的数目可能多于实际寄存器,所以这一技术甚至可以消除因为名称相关而导致的冒险,这类冒险是编译器所无法消除的。使用保留站,而不使用集中式寄存器堆,可以导致另外两个重要特性。

  • 冒险检测和执行控制是分布式的:每个功能单元保留站中保存的信息决定了一条指令什么时候可以开始在该单元中执行。
  • 结果将直接从缓冲它们的保留站中传递给功能单元,而不需要经过寄存器。这一旁路是使用公共结果总线完成的,它允许同时载入所有等待一个操作数的单元。在具有多个执行单元并且每个时钟周期发射多条指令的流水线中,将需要不止一条总线。

图3-4给出了基于Tomasulo算法的处理器的基本结构,其中包括浮点单元和载入存储单元;所有执行控制表均未显示。每个保留站保存一条已经被发射、正在功能单元等待执行的指令,如果已经计算出这一指令的操作数值,则保留这些操作数值,如果还没有计算出,则保留提供这些操作数值的保留站名称。

使用Tomasulo箅法的MIPS浮点单元的基本结构。指令由指令单元发送给指令队列,再按先入先出(FIFO)顺序从指令队列中发射出去。保留站包括运算和实际操作数,还有用于检测和解决冒险的信息。载入缓冲区有3项功能:

  1. 保存有效地址的各个部分,直到计算完成;
  2. 跟踪正在等待存储器的未完成载入过程;
  3. 保存正在等待总线的已完成载入过程的结果。

与此类似,存储器缓冲区也有3项功能:

  1. 保存有效地址的各个部分,直到计算完成;
  2. 对于尚未完成、正在等待待存储数据值的存储过程,存储其目标存储器地址;
  3. 保存要存储的地址和数据值,直到存储器单元可用为止。

来自FP单元或载入单元的所有结果都被放在CDB中,它会通向FP寄存器堆以及保留站和存储器缓冲区。FP加法器实现加法和减法,FP乘法器完成乘法和除法。

载入缓冲区和存储缓冲区保存来自和进入存储器的数据或地址,其行为方式基本与保留站相同,所以我们仅在必要时才区分它们。浮点寄存器通过一对总线连接到功能单元,由一根总线连接到存储缓冲区。来自功能单元和来自存储器的所有结果都通过公共数据总线发送,它会通向除载入缓冲区之外的所有地方。所有保留站都有标记字段,供流水线控制使用。

在详细描述保留站和此算法之前,让我们看看一条指令所经历的步骤。尽管每条指令现在可能需要任意数目的时钟周期,但一共只有以下3个步骤。

  • 发射
    • 从指令队列的头部获取下一条指令,指令队列按FIFO顺序维护,以确保能够保持数据流的正确性。如果有一个匹配保留站为空,则将这条指令发送到这个站中,如果操作数值当前已经存在于寄存器,也一并发送到站中。如果没有空保留站,则存在结构性冒险,该指令会停顿,直到有保留站或缓冲区被释放为止。如果操作数不在寄存器中,则一直跟踪将生成这些操作数的功能单元。这一步骤将对寄存器进行重命名,消除WAR和WAW冒险。(在动态调度处理器中,这一阶段有时被称为分派。)
  • 执行
    • 如果还有一个或多个操作数不可用,则在等待计算的同时监视公共数据总线。当一个操作数变为可用时,就将它放到任何一个正在等待它的保留站中。当所有操作数都可用时,则可以在相应功能单元中执行运算。通过延迟指令执行,直到操作数可用为止,可以避免RAW冒险。(一些动态调度处理器将这一步骤称为“发射”,但我们使用“执行”一词。)
    • 注意,在同一时钟周期,同一功能单元可能会有几条指令同时变为就绪状态。尽管独立功能单元可以在同一时钟周期执行不同指令,如果单个功能单元有多条指令准备就绪,那这个单元就必须从这些指令中进行选择。对于浮点保留站,可以任意作出这一选择;但是载入和存储指令可能要更复杂一些。载入和存储指令的执行过程需要两个步骤。第一步是在基址寄存器可用时计算有效地址,然后将有效地址放在载入缓冲区或存储缓冲区中。载入缓冲区中的载入指令在存储器单元可用时立即执行。存储缓冲区中的存储指令等待要存储的值,然后将其发送给存储器单元。邇过有效地址的计算,载入和存储指令保持程序顺序,稍后将会看到,这样有助于通过存储器来避免冒险。
    • 为了保持异常行为,对于任何一条指令,必须要等到根据程序顺序排在这条指令之前的所有分支全部完成之后,才能执行该指令。这一限制保证了在执行期间导致异常的指令实际上已经执行。在使用分支预测的处理器中(就和所有动态调度处理器一样),这意味着处理器在允许分支之后的指令开始执行之前,必须知道分支预测是正确的。如果处理器记录了异常的发生,但没有实际触发,则可以开始执行一条指令,在进入写结果阶段之前没有停顿。后面可以看到,推测提供了一种更灵活、更完整的异常处理方法,所以我会将推后进行这一改进,并说明推测是如何解决这一问题的。
  • 写结果
    • 在计算出结果之后,将其写到CDB上,再从CDB传送给寄存器和任意等待这一结果的保留站(包括存储缓冲区)。存储指令一直缓存在存储缓冲区中,直到待存储值和存储地址可用为止,然后在有空闲存储器单元时,立即写入结果。

保留站、寄存器堆和载入存储缓冲区都采用了可以检测和消除冒险的数据结构,根据对象的不同,这些数据结构中的信息也稍有不同。这些标签实际上就是用于重命名的虛拟寄存器扩展集的名字。在这里的例子中,标签字段包含4个数位,用来表示5个保留站之一或5个载入缓冲区之一。后面将会看到,这相当于设定了10个可以指定为结果寄存器的寄存器。在拥有更多真正寄存器的处理器中,我们可能希望重命名能够提供更多的虚拟寄存器。标签字段指出哪个保留站中包含的指令将会生成作为源操作数的结果。

在指令被发射出去并开始等待源操作数之后,将使用一个保留站编号来引用该操作数,这个保留站中保存着将对寄存器进行写操作的指令。如果使用一个未用作保留站编号的值来引用该操作数(比如0),则表明该操作数已经在寄存器中准备就绪。由于保留站的数目多于实际寄存器数目,所以使用保留站编号对结果进行重命名,就可以避免WAW和WAR冒险。在Tomasulo方案中,保留站被用作扩展虚拟寄存器,而其他方法可能使用拥有更多寄存器的寄存器集,也可能使用诸如重排序缓冲区这样的结构。

在Tomasulo方案以及后面将会介绍的支持推测的方法中,结果都是在受保留站监视的总线(CDB)上广播。采用公用结果总线,再由保留站从总线中提取结果,共同实现了静态调度流水线中使用的转发和旁路机制。但在这一做法中,动态调度方案会在源与结果之间引入一个时钟周期的延迟,这是因为要等到“写结果”阶段才能让结果与其应用匹配起来。因此,在动态调度流水线中,在生成结果的指令与使用结果的指令之间至少要比生成该结果的功能单元的延迟长一个时钟周期

一定别忘了,Tomasulo方案中的标签引用的是将会生成结果的缓冲区或单元;当一条指令发射到保留站之后,寄存器名称将会丢弃。(这是Tomasulo方案与记分板之间的一个关键区别:在记分板中,操作数保存在寄存器中,只有生成结果的指令已经完成、使用结果的指令做好执行准备之后才会读取操作数。)每个保留站有以下7个字段。

  • Op——对源操作数S1和S2执行的运算。
  • Qj、Qk——将生成相应源操作数的保留站;当取值为0时,表明已经可以在Vj或Vk中获得源操作数,或者不需要源操作数。
  • Vj、Vk——源操作数的值。注意,对于每个操作数,V字段和Q字段中只有一个是有效的。对于载入指令,Vk字段用于保存偏移量字段。
  • A——用于保存为载入或存储指令计算存储器地址的信息。在开始时,指令的立即数字段存储在这里;在计算地址之后,有效地址存储在这里。
  • Busy——指明这个保留站及其相关功能单元正被占用。

寄存器堆有一个字段Qi。

  • Qi——一个运算的结果应当存储在这个寄存器中,则Qi是包含此运算的保留站的编号。如果Qi的值为空(或0),则当前没有活动指令正在计算以此寄存器为目的地的结果,也就是说这个值就是寄存器的内容。

载入缓冲区和存储缓冲区各有一个字段A,一旦完成了第一个执行步骤,这个字段中就包含了有效地址的结果。在下一节,我们将首先看一些示例,说明这些机制是如何工作的,然后再详细研究具体算法。

动态调度:示例和算法

在详细研究Tomasulo算法之前,让我们看几个示例,这些示例有助于说明这种算法的工作方式。

对于以下代码序列,写出在仅完成了第一条载入指令并已将其结果写到CDB总线时的信息表:

1
2
3
4
5
6
L.D     F6,32 (R2)
L.D F2,44(R3)
MUL.D F0,F2,F4
SUB.D F8,F2,F6
DIV.D F10,F0,F6
ADD.D F6,F8, F2

表3-3用3个表显示了其结果。Add、 Mult和Load之后附加的数字表示保留站的标签——Add1是第一加法单元计算结果的标签。此外,我们还给出了一个指令状态表。之所以列出这个表是为了帮助读者理解这一算法;它不是硬件的实际组成部分,而是由保留站来保存每个已发射运算的状态。


表3-3当所有指令都已经被发射, 但只有第一-条载入指令已经完成而且已将其结果写到CDB时的保留站与寄存器标签。

与先前的较简单方案相比,Tomasulo方案有两点优势:

  1. 冒险检测逻辑的分布;
  2. 消除了可能产生WAW和WAR冒险的停顿。

第一个优势源于分布式保留站和CDB的使用。如果多条指令正在等待同一个结果,而每条指令的其他操作数均已准备就绪,那么在CDB上广播这一结果就可以同时释放这些指令。如果使用集中式的寄存器堆,这些单元必须在寄存器总线可用时从寄存器中读取自己的结果。

第二个优势(消除WAW和WAR冒险)的实现是利用保留站来重命名寄存器,并在操作数可用时立即将其存储在保留站中。

例如,尽管存在涉及F6的WAR冒险,但表3-3中的代码序列发射了DIV.D和 ADD.D。这一冒险通过两种方法之一消除。第一种方法:如果为DIV. D提供操作数的指令已经完成,则Vk中会存储这个结果,使DIV.D不需要ADD.D就能执行(表中所示的就是这种情况)。另一方面,如果L.D还没有完成,则Qk将指向Load1保留站,DIV.D指令不再依赖于ADD.D。因此,在任一情况下,ADD.D都可以发射并开始执行。在用到DIV.D的结果时,都会指向保留站,使ADD.D能够完成,并将其值存储在寄存器中,不会影响到DIV.D。

稍后将会看到一个消除WAW冒险的例子。但先来看看前面的示例是如何继续执行的。在这个例子以及本章后面的例子中,假定有如下延迟值:载入指令为1个时钟周期,相加指令为2个时钟周期,乘法指令为6个时钟周期,除法指令为12个时钟周期。

对于上例中的同一代码段,给出当MUL.D做好写出结果准备时的状态表。

其结果如表3-4中的3个表格所示。注意,在复制DIV.D的操作数时ADD.D已经完成,所以克服了WAR冒险问题。注意,既然F6的载入操作被延迟,在执行对F6的加法操作时也不会触发WAW冒险。

Tomasulo 算法:细节

表3-5给出了每条指令都必须经历的检查和步骤。前面曾经提到,载入指令和存储指令在进入独立载入或存储缓冲区之前,要经过一个进行有效地址计算的功能单元。载入指令会进入第二执行步骤,以访问存储器,然后进如“写结果”阶段,将来自存储器的值写入寄存器堆以及(或者)任何正在等待的保留站。存储指令在写结果阶段完成其执行,将结果写到存储器中。注意,无论目标是寄存器还是存储器,所有写入操作都在“写结果”阶段发生。这一限制简化了Tomasulo算法,是其扩展到支持推测功能的关键。

对于发射指令,rd是目的地、rs和rt是源寄存器编号、imm是符号扩展立即数字段,r是为指令指定的保留站或缓冲区。RS 是保留站数据结构。FP 单元或载入单元返回的值称为result。RegisterStat是寄存器状态数据结构(不是寄存器堆,寄存器堆应当是Regs[])。当发射指令,目标寄存器的Qi字段被设置为向其发射该指令的缓冲区或保留站编号。如果操作数巳经存在于寄存器中,就将它们存储在V字段中。否则,设置Q字段,指出将生成源操作数值的保留站。指令将一直在保留站中等待,直到它的两个操作数都可用为止,当Q字段中的取值为0时即表示这一状态。当指令已被发射,或者当这一指令所依赖的指令已经完成并写回结果时,这些Q字段被设置为0。当一条指令执行完毕,并且CDB可用时,它就可以进行写回操作。任何一个缓冲区、寄存器和保留站,只要其Qj或Qk值与完成该指令的保留站相同,都会由CDB更新其取值,并标记Q字段,表明已经接收到这些值。因此,CDB可以在一个时钟周期中向许多目标广播其结果,如果正在等待这一结果的指令已经有了其他操作数,那就都可以在下一个时钟周期开始执行了。载入指令要经历两个执行步骤,存储指令在写结果阶段稍有不同,它们必须在这一阶段等待要存储的值。记住,为了保持异常行为,如果按照排在程序顺序前面的分支还没有完成,就不应允许执行后面的指令。由于在发射阶段之后不再保持任何有关程序顺序的概念,因此,为了实施这一限制,在流水线中还有未完成的分支时,通常不允许任何指令离开发射步骤。

Tomasulo算法: 基于循环的示例

为了理解通过寄存器的动态重命名来消除WAW和WAR胃险的强大威力,我们需要看一个循环。考虑下面的简单序列,将一个数组的元素乘以F2中的标量:

1
2
3
4
5
Loop:   L.D      F0,0(R1)
MUL. D F4,F0,F2
S.D F4,0(R1)
DADDIU R1,R1,-8
BNE R1, R2, Loop; branches if R1 | R2

如果我们预测会执行这些分支转移,那使用保留站可以同时执行这个循环的多条指令。不需要修改代码就能实现这一好处——实际上,这个循环是由硬件使用保留站动态展开的,这些保留站经过重命名获得,充当附加寄存器。

假定已经发射了该循环两个连续迭代中的所有指令,但一个浮点载入存储指令或运算也没有完成。表3-6显示了在此时刻的保留站、寄存器状态表和载入缓冲区与存储缓冲区。(整数ALU运算被忽略,假定预测选中该分支。)一旦系统达到这一状态,如果乘法运算可以在4个时钟周期内完成,则流水线中可以保持该循环的两个副本,CPI接近1.0。在达到稳定状态之前,还需要处理其他一些迭代,延迟为6个时钟周期。这需要有更多的保留站来保存正在运行的指令。在本章后面将会看到,在采用多指令发射对Tomasulo方法进行扩展时,它可以保持每个时钟周期处理一条以上指令的速度。


表3-6 还没有指令完成时,循环的两个活动迭代

乘法器保留站中的项目指出尚未完成的载入指令是操作数来源。存储保留站指出乘法运算的目标位置是待存储值的来源。

只要载入指令和存储指令访问的是不同地址,就可以放心地乱序执行它们。如果载入指令和存储指令访问相同地址,则会出现以下两种情况之一:

  • 根据程序顺序,载入指令位于存储指令之前,交换它们会导致WAR冒险;
  • 根据程序顺序,存储指令位于载入指令之前,交换它们会导致RAW冒险。

依此类似,交换两个访问同一地址的存储指令会导致WAW冒险。

因此,为了判断在给定时刻是否可以执行一条载入指令,处理器可以检查:根据程序顺序排在该载入指令之前的任何未完成存储指令是否与该载入指令共享相同数据存储器地址。对于存储指令也是如此,如果按照程序顺序排在它前面的载入指令或存储指令与它访问的存储器地址相同,那它必须等到所有这些指令都执行完毕之后才能开始执行。

为了检测此类冒险,处理器必须计算出与任何先前存储器运算有关的数据存储器地址。为了保证处理器拥有所有此类地址,一种简单但不一定最优的方法是按照程序顺序来执行有效地址计算。(实际只需要保持存储及其他存储器引用之间的相对顺序,也就是说,可以随意调整载入指令的顺序。)

首先来考虑载入指令的情况。如果按程序顺序执行有效地址计算,那么当一条载入指令完成有效地址计算时,就可以通过查看所有活动存储缓冲区的A字段来确定是否存在地址冲突。如果载入地址与存储缓冲区中任何活动项目的地址匹配,则在发生冲突的存储指令完成之前,不要将载入指令发送到载入缓冲区。(有些实施方式将要存储的值直接传送给载入指令,减少了因为这一RAW冒险造成的延迟。)

存储指令的工作方式类似,只是因为发生冲突的存储指令不能调整相对于载入或存储指令的顺序,所以处理器必须在载入与存储两个缓冲区中检查是否存在冲突。

如果能够准确预测分支(这是在上一节解决的问题),动态调度流水线可以提供非常高的性能。这种方法的主要缺点在于Tomasulo方案的复杂性,它需要大量硬件。具体来说,每个保留站都必须包含一个必须高速运转的相关缓冲区,还有复杂的控制逻辑。它的性能还可能受到单个CDB的限制。尽管可以增加更多CDB,但每个CDB都必须与每个保留站进行交互,必须在每个保留站为每个CDB配备相关标签匹配硬件。

在Tomasulo方案中,可以组合使用两种不同技术:对体系结构寄存器重命名,提供更大的寄存器集合;缓冲来自寄存器堆的源操作数。源操作数缓冲消除了当操作数在寄存器中可用时出现的WAR冒险。后面将会看到,通过对寄存器重命名,再结合对结果的缓存,直到对寄存器早期数据的引用全部结束,这样也有可能消除WAR冒险。在我们讨论硬件推测时将会用到这一方法。

从20世纪90年代开始在多发射处理器中采用,原因有如下几个。

  1. 尽管Tomasulo算法是在缓存出现之前设计的,但缓存的出现以及其固有的不可预测的延迟,已经成为使用动态调度的主要动力之一。乱序执行可以让处理器在等待解决缓存缺失的同时继续执行指令,从而消除了全部或部分缓存缺失代价。
  2. 随着处理器的发射功能变得越来越强大,设计人员越来越关注难以调度的代码(比如,大多数非数值代码)的性能,所以诸如寄存器重命名、动态调度和推测等技术变得越来越重要。
  3. 无需编译器针对特定流水线结构来编译代码,Tomasulo 算法就能实现高性能。

基于硬件的推测

当我们尝试开发更多指令级并行时,控制相关的维护就会成为一项不断加重的负担。分支预测减少了由于分支导致的直接停顿,但对于每个时钟周期要执行多条指令的处理器来说,仅靠正确地预测分支可能不足以生成期望数量的指令级并行。宽发射处理器可能需要每个时钟周期执行一个分支才能维持最高性能。因此,要开发更多并行,需要我们克服控制相关的局限性。

通过预测分支的输出,然后在假定猜测正确的前提下执行程序,可以克服控制相关问题。这种机制对采用动态调度的分支预测进行了一种虽细微但很重要的扩展。具体来说,通过推测,我们提取、发射和执行指令,就好像分支预测总是正确的;而动态调度只是提取和发射这些指令。当然,我们需要一些机制来处理推测错误的情景。

基于硬件的推测结合了3种关键思想:

  1. 用动态分支预测选择要执行哪些指令;
  2. 利用推测,可以在解决控制相关问题之前执行指令(能够撤消错误推测序列的影响);
  3. 进行动态调度,以应对基本模块不同组合方式的调度。(与之相对,没有推测的动态调度需要先解析分支才能实际执行后期基本模块的操作,因为只能部分重叠基本模块。)

基于硬件的推测根据预测的数据值流来选择何时执行指令。这种执行程序的方法实际上是一种数据流执行:操作数一旦可用就立即执行运算。

为了扩展Tomasulo算法,使其支持推测,我们必须将指令结果的旁路(以推测方式执行指令时需要这一操作)从一条指令的实际完成操作中分离出来。进行这种分离之后,就可以允许执行一条指令,并将其结果旁路给其他指令,但不允许这条指令执行任何不能撤消的更新操作,直到确认这条指令不再具有不确定性为止。

使用旁路值类似于执行一次推测寄存器读取操作,因为在提供源寄存器值的指令不再具有不确定性之前,我们无法知道它是否提供了正确值。当一个指令不再具有不确定性时,允许它更新寄存器堆或存储器;我们将指令执行序列中的这个附加步骤称为指令提交。实现推测之后的关键思想在于允许指令乱序执行,但强制它们循序提交,以防止在指令提交之前采取任何不可挽回的动作(比如更新状态或激发异常)。因此,当我们添加推测时,需要将完成执行的过程与指令提交区分开来,这是因为指令执行完毕的时间可能远远早于它们做好提交准备的时间。在指令执行序列中添加这一提交阶段需要增加一组硬件缓冲区,用来保存已经完成执行但还没有提交的指令结果。这一硬件缓冲区称为重排序缓冲区,也可用于在可被推测的指令之间传送结果。

重排序缓冲区(ROB)像Tomasulo算法通过保留站扩展寄存器集一样,提供了附加寄存器。ROB会在一定时间内保存指令的结果,这段时间从完成该指令的相关运算算起,到该指令提交完毕为止。因此,ROB是指令的操作数来源,就像Tomasulo算法中的保留站提供操作数一样。

两者之间的关键区别在于:在Tomasulo算法中,一旦一条指令写出其结果之后,任何后续发射的指令都会在寄存器堆中找到该结果。而在采用推测时,寄存器堆要等到指令提交之后才会更新(我们非常确定该指令会被执行);因此,ROB是在指令执行完毕到指令提交这段时间内提供操作数。ROB类似于Tomasulo 算法中的存储器缓冲区,为简单起见,我们将存储器缓冲区的功能集成到ROB中。

ROB中的每个项目都包含4个字段:指令类型、目的地字段、值字段和就绪字段。指令类型字段指定这个指令是分支(没有目的地结果)、存储指令(含有存储器地址目的地),还是寄存器操作(ALU运算或载入指令,它含有寄存器目的地)。目的地字段提供了应当向其中写入指令结果的寄存器编号(对于载入指令和ALU运算)或存储器地址(对于存储指令)。值字段用于在提交指令之前保存指令结果值。我们稍后将会看到ROB项目的一个例子。最后一个就绪字段指出指令已经完成执行,结果值准备就绪。

图3-5给出包含ROB的处理器的硬件结构。ROB包含存储缓冲区。存储指令仍然分两步执行,但第二步是由指令提交来执行的。尽管保留站的重命名功能由ROB代替,但在发射运算之后仍然需要一个空间来缓冲它们(以及操作数),直到它们开始执行为止。这一功能仍然由保留站提供。由于每条指令在提交之前都在ROB拥有一个位置,所以我们使用ROB项目编号而不是保留站编号来标记结果。这种标记方式要求必须在保留站中跟踪为一条指令分配的 ROB。


图3-5 使用 Tomasulo箅法的FP单元的基本结构,为处理推测而进行了扩展。将此图与实施Tomasulo算法的图3-4对比,主要变化是添加了ROB,去除了存储器缓冲区,后者的功能被集成到ROB中。如果拓宽CDB,以允许每个时钟周期完成多条指令,则可以将这一机制扩展为支持多发射方案

在指令执行时涉及以下4个步骤。

  1. 发射——从指令队列获得一条指令。如果存在空保留站而且ROB中有空插槽,则发射该指令;如果寄存器或ROB中已经含有这些操作数,则将其发送到保留站。更新控制项,指明这些缓冲区正在使用中。为该结果分配的ROB项目编号也被发送到保留站,以便在将结果放在CDB上时,可以使用这个编号来标记结果。如果所有保留站都被占满或者ROB被占满,则指令发射过程停顿,直到这两者都有可用项目为止。
  2. 执行——如果还有一个或多个操作数不可用,则在等待计算寄存器的同时监视CDB。这一步骤检查RAW冒险。当保留站中拥有这两个操作数时,执行该运算。指令在这一阶段可能占用多个时钟周期,载入操作在这一阶段仍然需要两个步骤。此时执行存储指令只是为了计算有效地址,所以在这一阶段只需要有基址寄存器可用即可。
  3. 写结果——当结果可用时,将它写在CDB上(还有在发射指令时发送的ROB标签),并从CDB写到ROB并写到任何等待这一结果的保留站。将保留站标记为可用。对于存储指令需要执行一些特殊操作。如果要存储的值已经准备就绪,则将它写到ROB项目的Value字段,以备存储。如果要存储的值还不可用,CDB必须进行监视,直到该数值被广播时再更新该存储指令ROB项目的Value字段。为简单起见,我们假定这一过程在存储操作的写结果阶段进行;
  4. 提交——这是完成指令的最后一个阶段,在此之后将仅留下它的结果。(一些处理器将这一提交阶段称为“完成”或“毕业”。)根据要提交的指令是预测错误的分支、存储指令,或是任意其他指令(正常提交),在提交时共有3种不同的操作序列。当一个指令到达ROB的头部而且其结果出现在缓冲区中,则进行正常提交;此时,处理器用结果更新其寄存器,并从ROB清除该指令。提交存储指令与正常提交类似,但更新的是存储器而不是结果寄存器。当预测错误的分支到达ROB的头部时,它指出推测是错误的。ROB被刷新,执行过程从该分支的后续正常指令处重新开始。如果对该分支的预测正确,则该分支完成提交。

指令一旦提交完毕,它在ROB的相应项将被收回,寄存器或存储器目的地被更新,不再需要ROB项。如果ROB填满,那么只需要停止发射指令,直到有空闲项目为止。下面,我们研究一下这一机制如何处理前面为Tomasulo算法所举的示例。

假定浮点功能单元的延迟与前面示例中相同:加法为2个时钟周期、乘法为6个时钟周期、除法为12个时钟周期。使用下面的代码段(也就是前面用于生成表3-4的代码段),写出当MUL.D做好提交准备时的状态表。

1
2
3
4
5
6
L.D        F6,32(R2)
L.D F2,44(R3)
MUL.D F0,F2,F4
SUB.D F8,F2,F6
DIV.D F10,F0,F6
ADD.D F6,F8,F2

表3-7用3个表给出了结果。注意,尽管SUB.D指令已经完成执行,但它不会在MUL.D提交之前提交。保留站和寄存器状态字段中的基本信息与Tomasulo算法中相同(见3.5节中关于这些字段的描述)。区别在于,Qj和Qk字段以及寄存器状态字段中的保留站编号被ROB项目编号代替,我们已经将Dest字段加到保留站中。Dest字段指定一个ROB项目,也就是这个保留站项目所生成结果的目的地。

MUL.D位于ROB的头部,此处两个L.D指令只是为了便于理解。尽管SUB.D和ADD.D指令的结果已经可用,而且可以用作其他指令的数据源,但它们在MUL.D指令提交之前不会提交。DIV.D正在执行过程中,但由于它的延迟要比MUL.D长,所有不会独自究成。“值”列表示所保存的值;#X格式表示ROB项目X的值字段。重排序缓冲区1和2实际上已经完成,但为了提供更多信息,也一并列在表中。

上面的例子说明了采用推测的处理器与采用动态调度的处理器之间的关键区别。对比表3-7与表3-4中的内容,后者显示的是同一代码序列在采用Tomasulo算法的处理器上的执行情况。关键区别在于:在上面的例子中,MUL.D是排在最前面的未完成指令,它之后的所有指令都不允许完成。而在表3-4中,SUB.D和ADD.D指令也已经完成。

这一区别意味着具有ROB的处理器可以在维持精确中断模式的同时动态执行代码。例如,如果MUL.D指令导致一个中断,我们只需要等待它到达ROB的头部并生成该中断,刷新ROB中的任意其他未完成指令。由于指令提交是按顺序进行的,所以这样会生成一个精确异常。

而在使用Tomasulo算法的例子中,SUB.D和ADD.D指令都可以在MUL.D激发异常之前完成。结果就是寄存器F8和F6(SUB.D和ADD.D指令的目的地)可能被改写,中断可能不准确。

一些用户和架构师认为不准确的浮点异常在高性能处理器中是可接受的,因为程序可能会终止。而其他类型的异常,比如页面错误,由于程序必须在处理此类异常之后透明地恢复执行,所以很难容忍这些异常出现不准确情况。

在循序提交指令时使用ROB,除了支持推测执行之外,还可以提供准确的异常,如下例所示。

考虑前面Tomasulo算法使用的示例,表3-6显示了其执行情况:

1
2
3
4
5
6
Loop:
L.D F0,0(R1)
MUL.D F4,F0,F2
S.D F4,0(R1)
DADDIU R1,R1,#-8.
BNE R1,R2,Loop ;branches if R1|R2

假定这个循环中所有指令已经发射了两次。还假定第一次迭代的L.D和MUL.D指令已经提交,并且所有其他指令都已经完成执行。正常情况下,存储指令将在ROB中等待有效地址操作数(本例中为R1)和值(本例中为F4)。由于我们只考虑浮点流水线,所以假定存储指令的有效地址在发射该指令时计算。

表3-8用两个表给出了结果。

由于在提交指令之前,寄存器值和存贮器值都没有实际写入,所以在发射分支预测错误时,处理器可以很轻松地撤销其推测操作。假定在表3-8中第一次没有选中分支BNE。当该分支之前的指令到达ROB的头部之后,直接提交即可;当分支到达缓冲区的头部时,将会清除缓冲区,处理器开始从其他路径提取指令。

在实践中,进行推测的处理器会在错误预测一个分支后尽早恢复。将预测错误的分支之后的所有ROB项目清空,使该分支之前的ROB项目继续执行,并在后续的正确分支处重新开始提取指令,从而完成恢复操作。在推测处理器中,由于错误预测的影响更大一些,所以性能对分支预测也更敏感。因此,分支处理的各个方面(预测准确度、预测错误的检测延迟、预测错误的恢复时间)都变得更为重要。

在处理异常时,要等到做好提交准备时才会识别异常。如果推测的指令产生异常,则将异常记录在ROB中。如果出现分支预测错误,而且指令还没有执行,则在清除ROB时将异常连同指令一直刷新。如果指令到达ROB的头部,我们就知道它不再具有不确定性,应当激发该异常。我们还可以在异常出现之后、所有先前指令都已处理完毕的情况下立即处理异常,但异常要比分支预测错误的处理更难一些,而且由于异常的发生概率要更低一些,所以其重要性也要低一些。

表3-9给出了一条指令的执行步骤,以及为了继续执行这些步骤和要采取的动作而必须满足的条件。我们给出了到提交时才解决预测错误分支时的情景。尽管推测似乎只是对动态调度添加了非常简单的一点儿内容,但通过对比表3-9和表3-5中Tomasulo算法的相应内容,可能看出推测大大增加了控制复杂度。此外,还要记住分支预测也要更复杂一些。

在推测处理器处理存储指令时与Tomasulo算法中有一点非常重要的不同。在Tomasulo算法中,一条存储指令可以在到达“写结果”阶段(确保已经计算出有效地址)且待存储值可用时更新存储器。在推测处理器中,只有当存储指令到达ROB的头部时才能更新存储器。这一区别可以保证当指令不再具有不确定性时才会更新存储器。

表3-9大幅简化了存储指令,在实践中不需要这一简化。表3-9需要存储指令在写结果阶段等待寄存器源操作数,它的值就是要存储的内容;随后将这个值从该存储指令的保留站的Vk字段移到该存储指令ROB项目的“值”字段。但在现实中,待存储值只需要在提交存储指令之前到达即可,可以直接将源指令放到存储指令的ROB项目中。其实现方法为:用硬件来跟踪要存储的源值什么时候在该存储指令的ROB项目中准备就绪,并在每次完成指令时搜索ROB,查看相关存储指令。

对于发射的指令,rd为目的地、rs和rt为源、r为分配的保留站、b是分配的ROB项目、h是ROB的头项目。RS是保留站数据结构。保留站返回的值被称为result。RegisterStat 是寄存器数据结构,Regs表示实际寄存器,ROB是重排序缓冲区数据结构。

这一补充并不复杂,但添加之后有两个效果:需要向ROB中添加一个字段,表3-9尽管已经采用了小字体,但仍然会变得更长!尽管表3-9进行了简化,但在本示例中,我们将允许该存储指令跳过写结果阶段,只需要在准备提交前得到要保存的值即可。和Tomasulo算法一样,我们必须避免存储器冒险。用推测可以消除存储器中的WAW和WAR冒险,这是因为存储器更新是循序进行的,当存储指令位于ROB头部时,先前不可能再有尚未完成的载入或存储指令。通过以下两点限制来解决存储器中的RAW冒险。

  • 如果一条存储指令占用的活动ROB项目的“目的地”字段与一条载入指令的A字段取值匹配,则不允许该载入指令开始执行第二步骤。
  • 在计算一条载入指令的有效地址时,保持相对于所有先前存储指令的程序顺序。

这两条限制条件共同保证了:对于任何一条载入指令,如果它要访问由先前存储指令写入的存储器位置,在这条存储指令写入该数据之前,该载入指令不能执行存储器访问。在发生此类RAW冒险时,一些推测处理器会直接将来自存储指令的值旁路给载入指令。另一种方法是采用值预测方式预测可能出现的冲突;我们将在3.9节考虑这一方法。尽管这里对推测执行的解释主要是针对浮点运算的,但这些技术可以很容易地扩展到整数寄存器和功能单元。事实上,推测在整数程序中可能更有用一些,因为这些程序中的代码可能更难预测一些。此外,只要允许在每个周期内发射和提交多条指令,就可以将这些技术扩展到能够在多发射处理器中工作。事实上,在这些处理器中,一些实用技术可能会在编译器的支持下在基本模块中开发足够的指令级并行,所以对这些处理器来说,推测技术可能是最有意义的。

以多发射和静态调度来开发ILP

前面几节介绍的技术可以用来消除数据与控制停顿,使用CPI到达理想值1。为了进一步提高性能,我们希望将CPI降低至小于1,但如果每个时钟周期仅发射一条指令,那CPI是不可能降低到小于1的。

多发射处理器的目标就是允许在一个时钟周期中发射多条指令。多发射处理器主要有以下3类。

  1. 静态调度超标量处理器。
  2. VLIW(超长指令字)处理器。
  3. 动态调度超标量处理器。

两种超标量处理器每个时钟发射不同数目的指令,如果它们采用静态调度则采用循序执行,如果采用动态调度则采用乱序执行。

与之相对,VLIW处理器每个时钟周期发射固定数目的指令,这些指令可以设置为两种格式之一:一种格式是一个长指令;另一种是一个固定的指令包,指令之间具有一定的并行度,由指令显式表示出来。VLIW处理器由编译器进行静态调度。Intel和HP在创建IA-64体系结构时,它们还将这种体系结构命名为EPIC(显式并行指令计算机)。尽管静态调度超标量处理器在每个周期内发射的指令数是可变的,而不是固定的,但它们在概念上实际与VLIW更接近一些,这是因为这两种方法都依靠编译器为处理器调度代码。

由于静态调度超标量的收益会随着发射宽度的增长而逐渐减少,所以静态调度超标量主要用于发射宽度较窄的情况,通常仅有两条指令。超过这一宽度之后,大多数设计人员选择实现VLIW或动态调度超标量。由于两者的硬件要求和所需要的编译器技术是类似的,所以这一节将主要介绍VLIW。深入理解这一节的内容之后,可以很轻松地将相关道理扩展到静态调度超标量。

表3-10总结了多发射的基本方法和它们的突出特征,并给出了使用每一方法的处理器。

基本VLIW方法

VLIW使用多个独立功能单元。VLIW没有尝试向这些单元发射多条独立指令,而是将多个操作包装在一个非常长的指令中,或者要求发射包中的指令满足同样的约束条件。由于这两种方法之间没有本质性的区别,所以假定将多个操作放在一条指令中,原始VLIW方法即是如此。

由于VLIW的收益会随着最大发射率的增长而增长,所以我们主要关注宽发射处理器。实际上,对于简单的两发射处理器,超标量的开销可能是最低的。许多设计人员可能会说:四发射处理器的开销是可控的,但在本章后面将会看到,开销的增长是限制宽发射处理器的主要因素。

本章主要讨论硬件操作密集的技术,它们都采用某种超标量形式。EPIC方法扩展了早期VLIW方法的主要概念,将静态与动态方法结合在一起。

我们考虑一个VLIW处理器,在上面运行包含5种运算的指令,这5种运算是:一个整数运算(也可以是一个分支)两个浮点运算和两个存储器引用。这些指令可能拥有与每个功能单元相对应的一组字段,每个单元可能为16~24位,得到的指令长度介于80~120位之间。作为对比,Intel Itanium 1和2的每个指令包中包含6个运算(也就是说,它们允许同时发射两个3指令包)。

为使功能单元保持繁忙状态,代码序列必须具有足够的并行度,以填充可用操作插槽。这种并行是通过展开循环和调度单个更大型循环体中的代码而展现的。如果展开过程会生成直行代码,则可以使用局部调度技术,它可以对单个基本模块进行操作。如果并行的发现与开发需要在分支之间调度代码,那就必须使用可能更为复杂的全局调度算法。全局调度算法不仅在结构上更为复杂,而且由于在分支之间移动代码的成本很高,所以它们还必须进行非常复杂的优化权衡。

而现在,我们将依靠循环展开来生成一个长的直行代码序列,所以可以使用局部调度来构建VLIW指令,并集中研究这些处理器的运行情况。

假定有一个VLIW,它可以在每个时钟周期中发射两个存储器引用、两个浮点运算和整数运算或分支。写出针对这样一个处理器展开循环x[i] = x[i] + S的版本。可进行任意次展开,以消除所有停顿。忽略延迟分支。

表3-11给出了这一代码。该循环被展开后,形成循环体的7个副本,消除了所有停顿(即,全空发射周期),运行9个时钟周期。这一代码的运行速度为9个周期生成7个结果,也就是每个结果需要1.29个周期,与3.2节使用非展开调度代码的两发射超标量相比,速度差不多是它的两倍。

在假定没有分支延迟的情况下,这一代码需要9个周期;通常,分支延迟也需要进行调度。其发射速率为9个时钟周期发射23个运算,或者说每个周期2.5个运算。其效率为大约60%(也就是包含操作的可用插槽比例)。为了实现这一发射速率,需要更多寄存器,远远超过MIPS通常在处理这一循环时使用的寄存器数目。上面的VLIW代码序列需要至少8个浮点寄存器,而在基本MIPS处理器上,同一代码序列可以仅使用2个浮点寄存器,在使用未展开调度时,也只需要使用5个。

原始VLIW模块中既存在一些技术问题,也存在一些逻辑问题,从而降低了其效率。技术问题包括代码大小的增大和锁步(clockstep)操作的局限性。有两个不同因素共同造成VLIW代码大小的增大。

  • 第一,要在直行代码段中生成足够操作,需要大量展开循环(如前面的示例所示),从而增大了代码大小。
  • 第二,只要指令未被填满,那些没有用到的功能单元就会在指令编码时变为多余的位。为了应对这种代码大小的增长,有时会使用智能编码。比如,一条指令中可能只有一个很大的立即数字段供所有功能单元使用。另一种技术是在主存储器中压缩指令,然后在到达缓存或进行译码时再展开它们。

早期的VLIW是锁步工作的,根本就没有冒险检测硬件。在这种结构中,由于所有功能单元都必须保持同步,所以任意功能单元流水线中的停顿都必然导致整个处理器停顿。尽管一个编译器也许能够调度起决定作用的功能单元,以防止停顿,但要想预测哪些数据访问会遭遇缓存停顿,并对它们进行调度,那是非常困难的。因此,应当对缓存进行分块,并能导致所有功能单元停顿。由于发射速度和内存引用数目都变得很大,所以这一同步限制变得不可接受。在最近的处理器中,这些功能单元以更独立的方式工作,在发射时利用编译器来避免冒险,而在发射指令之后,可以通过硬件检测来进行非同步执行。

二进制代码兼容性也是VLIW的主要逻辑问题。在严格的VLIW方法中,代码序列既利用指令集定义,又要利用具体的流水线结构,包括功能单元及其延迟。因此,当功能单元数目和单元延迟不同时,就需要不同的代码版本。与超标量设计相比,由于这一要求而更难以在先后实施版本之间或者具有不同发射带宽的实施方式之间移植代码。当然,要想通过新的超标量设计而提高性能,可能需要重新编译。不过,能够运行旧版本二进制文件是超标量方法的一个实际优势。EPIC方法解决了早期VLIW设计中遇到的许多问题,包括扩展到更积极主动的软件推测与方法,在保证二进制兼容性的前提下克服硬件依赖的局限性。

所有多发射处理器都要面对的重要挑战是尝试开发大量ILP。这种并行是通过展开浮点程序中的简单循环而实现的,而原来的循环很可能可以在向量处理器上高效地运行。对于这类应用程序,目前还不清楚多发射处理器是否优于向量处理器;其成本是类似的,向量处理器的速度可能与多发射处理器相同,或者还会更快一些。多发射处理器相对于向量处理器的潜在优势在于它们能够从结构化程度较低的代码中提取某些并行,以及能够很轻松地缓存所有形式的数据。因为这些原因,多发射方法已经成为利用指令级并行的主要方法,而向量主要作为这些处理器的扩展。

以动态调度、多发射和推测来开发ILP

到目前为止,我们已经看到了动态调度、多发射和推测等各种机制是如何单独工作的。本节,我们将这三种技术结合在一起, 得到一种非常类似于现代微处理器的微体系结构。为简单起见,我们只考虑每个时钟周期发射两条指令的发射速率,但其概念与每个时钟周期发射三条或更多条指令的现代处理器没有什么不同。

假定我们希望扩展Tomasulo算法,以支持具有分离整数、载入存储和浮点单元(包括浮点乘和浮点)加的多发射超标量流水线,每个单元都可以在每个时钟周期启动一个操作。我们不希望向保留站乱序发射指令,这样可能会违犯程序语义。为了获得动态调度的全部好处,允许流水线在一个时钟周期内发射两条指令的任意组合,通过调度硬件向整数和浮点单元实际分配运算。由于整数指令和浮点指令的交互非常关键,所以还会扩展Tomasulo方案,以处理整数和浮点功能单元与寄存器,还能整合推测执行功能。如图3-6,其基本组织结构类似于每个时钟周期发射一条指令、具有推测功能的处理器的组织结构,不过必须改进其发射和完成逻辑,以允许每个时钟周期处理多条指令。

在动态调度处理器中(无论有无推测功能),每个时钟周期发射多条指令都非常复杂,原因很简单,这些指令之间可能存在相关性。因此,必须为这些并行指令更新控制表;否则,这些控制表中可能会出现错误,或者会丢失相关性。

在动态调度处理器中,已经采用两种方法在每个时钟周期内发射多条指令,这两种方法都基于这样一个事实:要在每个时钟周期中发射多条指令,其关键在于保留站的分配和流水线控制表的更新。一种方法是在一个时钟周期的一半时间内运行这一步骤,从而可以在一个时钟周期内运行2条指令;遗憾的是,很难将这一方法扩展为每个时钟周期处理4条指令。

第二种方法是构建必要的逻辑,一次处理两条或更多条指令,包括指令之间可能存在的相关性。可以在每个时钟周期发射四条或更多条指令的现代超标量处理器可能采用这两种方法:都采用流水线方式并拓宽了发射逻辑。一个重要的事实是:仅靠流水线无法解决这一问题。使指令发射占用多个时钟周期时,由于每个时钟周期都会发射新指令,所以必须能够分配保留站,并更新流水线表,使下一个时钟周期进行的相关指令发射能够利用更新后的信息。

在动态调度超标量中,这一发射步骤是最基本的瓶颈之一。为了说明这一过程的复杂性,表3-12给出了一种情景下的发射逻辑:在发射载入命令之后执行一个相关浮点运算。这个逻辑的基础是表3-9,但它仅代表一种情景。在现代超标量中,对于所有可以在同一时钟周期内发射的相关指令的可能组合,都必须加以考虑。这种组合数与一个周期内可发射指令数的平方成正比,所以在尝试突破每时钟周期执行4条指令的速度时,发射步骤可能会成为一个瓶颈。


对于发射指令,rd1和rd2是目的地,rs1、rs2和rt2是源(载入指令仅有一个源),r1和r2是分配的保留站,b1和b2是指定的ROB项目。RS是保留站数据结构。RegisterStat是寄存器数据结构,Regs表示实际寄存器,ROB是重排序缓冲区数据结构。注意,这一逻辑的正常运行需要指定重排序缓冲区项目,还有,别忘了所有这些更新是在单个时钟周期内并行完成的,不是顺序执行!

我们可以推广表3-12的细节,以描述在动态调度超标量中更新发射逻辑和保留表的基本策略,可以在一个时钟周期内发射多达n条指令,如下所示。

  1. 为可能在下一个发射包中发射的每条指令指定保留站和重排序缓冲区。这一指定过程可以在知道指令类型之前完成,只需要使用n个可用重排序缓冲区项依次为发射包中的指令预先分配重排序缓冲区项目,并确保有足够的保留站可用于发射整个包(无论包中包含多少指令)即可。通过限制一个给定类别的指令数目(比如,一个浮点运算、一个整数运算、一个载入指令、一个存储指令),就可以预告分配必要的保留站。如果没有足够的保留站可用,比如,当程序中接下来的几条指令都是同一种指令类型时),则将这个包分解,仅根据原始程序顺序,发射其中一部分指令。包中的其余指令可以放在下一个发射包中。
  2. 分析发射包中指令之间的所有相关。
  3. 如果包中的一条指令依赖于包中的先前指令,则使用指定的重排序缓冲区编号来更新相关指令的保留表。否则,使用已有保留表和重排序缓冲区信息更新所发射指令的保留表项。当然,由于所有这些都要在一个时钟周期中并行完成,所以使上述操作变得非常复杂。

在流水线的后端,必须能够在一个时钟周期内完成和提交多条指令。由于可以在同一时钟周期中实际提交的多条指令必须已经解决了相关性问题,所以这些步骤要比发射问题稍容易一些。从性能的角度来看,我们可以用一个示例来说明这些概念是如何结合在一起的。

考虑以下循环在两发射处理器上的执行情况,它会使整数数组的所有元素递增,一次没有推测,一次进行推测:

1
2
3
4
5
Loop:   LD      R2,0(R1)      ; R2=array element
DADDIU R2,R2,#1 ; increment R2
SD R2,0(R1) ; store result
DADDIU R1,R1,#8 ; increment poi nter
BNE R2,R3, LOOP ;branch if not last element

假定有独立的整数功能单元用于有效地址计算、ALU运算和分支条件求值。给出这个循环在两种处理器上前3次迭代的控制表。假定可以在每个时钟周期内提交2条任意类型的指令。

表3-13和表3-14给出了一个两发射动态调度处理器在有、无推测情况下的性能。在本例中,分支是一个关键的性能限制因素,推测会很有帮助。推测处理器中的第三分支在时间周期13中执行,而在非推测流水线中是在时钟周期19中执行。由于非推测流水线上的完成速率很快就会落在发射速率的后面,所以在再发射几个迭代之后,非推测流水线将会停顿。如果允许载入指令在决定分支之前完成有效地址计算,就可以提高非推测处理器的性能,但除非允许推测存储器访问,否则这一改进只会在每次迭代中获得一个时钟周期。

注意,跟在BNE后面的LD不能提前开始执行,因为它必须等待分支结果的判断。这种类型的程序(带有不能提前解决的数据相关分支)展示了推测的威力。将用于地址计算、ALU运算和分支条件求值的功能单元分离开来,就可以在同一周期中执行多条指令。表3-14显示的是这个例子带有推测功能
的版本。

这个例子清楚地表明,推测方法在存在数据相关分支时可以带来一些好处,而在没有这种分支时会限制性能。但是,这种好处依赖于分支预测的准确性。错误预测不会提高性能,事实上,它通常会有损于性能,而且后面将会看到,它会极大地降低能耗效率。

用于指令传送和推测的高级技术

在高性能流水线中,特别是在多发射流水线中,仅仅很好地预测分支还不够;实际上还得能够提高带宽的指令流。在最近的多发射处理器中,所谓高带宽发射流意味着每个时钟周期要提交4~8条指令。我们首先研究提高指令提交带宽的方法,然后再转而研究在实现高级推测技术中的一组关键问题,包括寄存器重命名的应用与重排序缓冲区、推测的积极性和一种称为值预测的技术,它尝试预测计算的结果,可以进一步增强ILP。

提高指令提取带宽

多发射处理器需要每个时钟周期提取的平均指令数目至少等于平均吞吐量。当然,提取这些指令需要有足够宽的路径能够连向指令缓存,但最重要的部分还是分支的处理。在本节,我们将研究两种处理分支的方法,然后讨论现在处理器如何将指令预测和预取功能结合在一起。

分支目标缓冲区

为了减少这个简单的五级流水线以及更深流水线的分支代价,必须知道尚未译码的指令是不是分支,如果是分支,则需要知道下一个程序计数器(PC)应当是什么。如果这条指令是一个分支,而且知道下一个PC应当是什么,那就可以将分支代价降为零。分支预测缓存中存储着一条分支之后下一条指令的预测地址,这一缓存被称为分支目标缓冲区或分支目标缓存。图3-7给出了一个分支目标缓冲区。

图3-7分支目标缓冲区。将所提取指令的PC与第一列中存储的一组指令地址进行匹配,这组地址代表的是已知分支的地址。如果PC与其中一项匹配,则所提取的指令为被选中的分支,第二个字段——预测的PC包含了对该分支之后下一个PC的预测值。会立即在该地址处开始提取指令。第三个字段是可选字段,可用于附加的预测状态位

由于分支目标缓冲区中预测下一条指令地址,并在对该指令译码之前把它发送出去,所以必须知道所提取的指令是否被预测为一条选中分支指令。如果所提供指令的PC与预测缓冲区中的一个地址匹配,则将相应的预测PC用作下一个PC。这种分支目标缓冲区的硬件基本上与缓存硬件相同。

如果在分支目标缓冲区中找到一个匹配项,则立即在所预测的PC处开始提取指令。注意,与分支预测目标不同的是,由于在知道一条指令是否为分支之前就要将预测到的PC发送出去,所以预测项必须与这一指令匹配。如果处理器没有查看这一项是否与这个PC匹配,那么就会为不是分支的指令发送错误的PC,导致性能恶化。我们只需要在分支目标缓冲区中存储预测选中的分支,这是因为未被选中的分支应当直接提取下一条顺序指令,就好像它不是分支指令一样。

图3-8显示了在为简单的五级流水线使用分支目标缓冲区时的步骤。从这个图中可以看出,如果在缓冲区中找到了分支预测项,而且预测正确,那就没有分支延迟。否则,至少存在两个时钟周期的代价。我们在重写缓冲区项目时通常会暂停指令提取,所以要处理错误预测与缺失是一个不小的难题。因此,我们希望快速完成这一过程,将代价降至最低。为了评估一个分支目标缓冲区的工作情况,必须首先判断所有可能情景中的代价。表3-15给出了一个简单五级流水线的相关信息。


如果一切都预测正确,而且在目标缓存中找到该分支,那就没有分支代价。如果分支预测错误,那代价就等于使用正常信息更新缓冲区的一个时钟周期(在此期间不能提取指令),在需要时,还有一个时钟周期用于重新开始为该分支提取下一个正确指令。如果这个分支没有找到,或未被选中,那代价就是两个周期,在此期间会更新缓冲区。

假定各个错误预测的代价周期如表3-15所示,判断一个分支目标缓冲区的总体分支代价。关于预测准确率和命中率作以下假设:

  • 预测准确率为90%(对于缓冲区中的指令);
  • 缓冲区中的命中率为90%(对于预测选中的分支)。

通过研究两个事件的概率来计算代价,一个事件是预测分支将被选中但最后未被选中,另一个事件是分支被选中,但未在缓冲区中找到。这两个事件的代价都是两个周期。

概率(分支在缓冲区中,但未被选中)= 缓冲区命中率 x 错误预测比例 = 90% x 10% = 0.09

概率(分支不在缓冲区中,但被实际选中)= 10%

分支代价=(0.09 + 0.10) x 2.

分支代价=0.38

这一代价略低于延迟分支的分支代价,大约为每个分支0.5个时钟周期。记住,当流水线长度增加从而导致分支延迟增加时,通过动态分支预测得到的性能改善也会随之增加;此外,使用更准确的预测器也会获得更大的性能优势。现代高性能处理器的分支错误预测代价大约为15个时钟周期量级,显然,准确预测是非常关键的!

分支目标缓冲区的一种变体是存储一个或多个目标指令,用于作为预测目标地址的补充或替代。这一变体有两个潜在好处。第一,它允许分支目标缓冲区访问花费的时间长于连续两次指令提取之间的时间,从而可能允许采用更大型的分支目标缓冲区。第二,通过缓存实际目标指令可以让我们执行一种称为分支折合(branch folding)的优化方法。分支折合可用于实现0时钟周期的无条件分支,有时可以实现0时钟周期的条件分支。

考虑一个分支目标缓冲区,它缓冲来自预测路径的指令,可以用无条件分支的地址来访问。无条件分支的唯一作用就是改变PC。因此,当分支目标缓冲区发出命中信号并指出该分支是无条件分支时,流水线只需要将分支目标缓冲区中的指令代替从缓存中的返回的指令(它是一个无条件分支)。如果处理器在每个周期发射多条指令,那么缓冲区需要提供多条指令,以获得最大好处。在一些情况下,有可能消除条件分支的成本。

返回地址预测器

当我们试图提高推测的机会和准确性时,就面临着预测间接跳转的挑战,也就是说跳转到那些在运行时变化的目标地址。尽管高级语言程序会为间接过程调用、选择或case语句、FORTRAN计算的goto语句等生成此类跳转,但大多数间接跳转都源于过程的返回操作。对于诸如C++和Java之类的面向对象的语言,过程返回操作甚至还要频繁。因此,将重点放在过程返回操作上似乎是恰当的。

尽管过程返回操作可以用分支目标缓冲区预测,但如果从多个地方调用这个进程,而且来自一个地方的多个调用在时间方面比较分散,那这种预测方法的准确性会很低。例如,在SPECCPU9S中,一个主动分支预测器对于此类返回分支所能达到的准确率不足60%。为了解决这一问题,一些设计使用了一个小型的返回地址缓冲区,它的工作方式相当于一个栈。这种结构缓存最近的返回地址:在调用时将返回地址压入栈中,在返回时弹出一个地址。如果缓存足够大(也就是与最大调用深度相等),它就能准确地预测过程返回操作。图3-9给出了这样的返回缓冲区在进行许多SPEC CPU95基准测试时的性能,缓冲区的元素数目为0~16个。Intel Core处理器和AMD Phenom处理器都有返回地址
预测器。

作为栈运行的返回地址缓冲区在进行大量SPEC CPU95基准测试时的预测准确率。这一准确率是正确预测返回地址所占的比例。若缓冲区中有0项,意味着使用标准分支预测。由于调用深度通常不是很大(当然也有一些例外),所以中等大小的缓冲区就可以取得很好的效果。使用一种改进机制来防止缓存返回地址出现错误

集成指令提取单元

为了满足多发射处理器的要求,近来的许多设计人员选择实现一个集成指令提取单元,作为独立的自主单元,为流水线的其余部分提供指令。实际上,这是因为他们意识到:由于多发射的复杂性,不能再将取指过程视为简单的单一流水级。最近的设计已经开始使用集成了多种功能的集成指令提取单元,包括以下这些功能。

  1. 集成分支预测——分支预测器变为指令提取单元的组成部分,它持续预测分支,以驱动提取流水线。
  2. 指令预取——为了在每个时钟周期内提交多条指令,指令提取单元可能需要提前提取指令。这一单元自主管理指令的预取,把它与分支预测结合在一起。
  3. 指令存储器访问与缓存——在每个时钟周期提取多条指令时会遇到不同的复杂性,包括:提取多条指令可能需要访问多个缓存行,这是一个难题。指令提取单元封装了这一复杂性,尝试使用预取来隐藏跨缓存模块的成本。指令提取单元还可以提供缓存功能,大体充当一个按需单元,用于根据需要向发射级提供相应数量的指令。

几乎所有高端处理器现在都使用了一个独立的指令提取单元,通过一个包含未完成指令的缓冲区与流水线的其余部分连接在一起。

推测:实现问题与扩展

本节,我们将研究涉及推测设计权衡的4个问题,首先从寄存器重命名开始,这一方法经常被用来替代重排序缓冲区。然后讨论控制流推测的一-个重要扩展: 一种被称为值推测的思想。

推测支持:寄存器重命名与重排序缓冲区

ROB(重排序缓冲区)的一种替代方法是明确使用更大的物理寄存器集,并与寄存器重命名方法结合。这一方法以Tomasulo算法中使用的重命名为基础,并对其进行了扩展。在Tomasulo算法中,在执行过程的任意时刻,体系结构可见的寄存器(R0,…,R31和F0,…, F31)都包含在寄存器集和保留站的某一组合中。在添加了推测功能之后,寄存器值还会临时保存在ROB中。在任一情况下,如果处理器在一段时间内没有发射新指令,所有现有指令都会提交,寄存器值将出现在寄存器堆中,寄存器堆直接与在体系结构中可见的寄存器相对应。

在寄存器重命名方法中,使用物理寄存器的一个扩展集来保存体系结构可见寄存器和临时值。因此,扩展后的寄存器取代了ROB和保留站的大多数功能;只需要一个队列来确保循序完成指令。在指令发射期间,一种重命名过程会将体系结构寄存器的名称映射到扩展寄存器集中的物理寄存器编号,为目的地分配一个新的未使用寄存器。WAR和WAR冒险通过目标寄存器的重命名来避免,在指令提交之前,保存指令目的地的物理寄存器不会成为体系结构寄存器,所以也解决了推测恢复问题。重命名映射是一种简单的数据结构,它提供当前与某个指定体系结构寄存器相对应的寄存器的物理寄存器编号,在Tomasulo算法中,这一功能由寄存器状态表完成。在提交指令时,重命名表被永久更新,用于指示一个物理寄存器与实际体系结构寄存器相对应,从而有效地完成对处理器状态的更新。尽管在采用寄存器重命名时并不需要ROB,但硬件仍然必须在一个类似于队列的结构中跟踪信息,并严格按照顺序来更新重命名表。

与ROB方法相比,重命名方法的一个优点是简化了指令提交过程,它只需要两个简单操作:

  • 记录体系结构寄存器编号与物理寄存器编号之间的映射不再是推测结果;
  • 释放所有用于保存体系结构寄存器“旧”值的物理寄存器。在采用保留站的设计中,当使用一个保留站的指令完成执行后,该保留站会被释放,与一个ROB项目对应的指令提交之后,该ROB项目也被释放。

在采用寄存器重命名时,撤消寄存器分配的工作要更复杂一些,这是因为在释放物理寄存器之前,必须知道它不再与体系结构寄存器相对应,而且对该物理寄存器的所有使用都已完成。物理寄存器与体系结构寄存器相对应,直到该体系结构寄存器被改写为止,此时将使重命名表指向其他位置。也就是说,如果没有重命名项指向一个特定的物理寄存器,那它就不再对应于体系结构寄存器。但是,对该物理寄存器的使用可能仍未结束。处理器可以通过查看功能单元队列中所有指令的源寄存器说明符。如果一个给定物理寄存器没有显示为源寄存器,而且它也没有被指定为体系结构寄存器,那就可以收回该寄存器,重新进行分配。

或者,处理器也可以一直等待,直到对同一体系结构寄存器执行写入操作的另一指令提交为止。此时,对旧值的使用可能都已经完成了。尽管这种方法对物理寄存器的绑定时间可能要稍长于必要时间,但它的实现非常容易,在最近的超标量中得到了应用。

读者可能会问到的一个问题是: 如果寄存器一直都在变化, 那要如何知道哪些寄存器是体系结构寄存器呢?在程序运行的大多数时间内,这是无所谓的。当然,在某些情况下,另一个进程(比如操作系统)必须知道特定的体系寄存器的内容到底在什么位置。为了理解如何提供这一功能,假定处理器在一段时间内没有发射指令。流水线中的所有指令最终都会提交,体系结构可见寄存器与物理寄存器之间的映射变得稳定。这时,物理寄存器的子集包含体系结构可见寄存器,任何未与体系结构寄存器关联的物理寄存器的都不再需要。从而可以很轻松地将体系结构寄存器移到物理寄存器的一个固定子集中,从而可以将这些值发送给另一进程。

寄存器重命名和重排序缓冲区都继续在高端处理器中使用,这些高端处理器现在能够同时运行40或50条指令(包括在缓存中等待的载入指令和存储指令)。无论是使用重命名还是重排序缓冲区,动态调度超标量的关键复杂性瓶颈仍然在于所发射的指令包中包含相关性的情景。具体来说,在发射一个发射包中的相关指令时,必须使用它们所依赖指令的指定虚拟寄存器。在采用寄存器重命名发射指令时,所部署的策略可以类似于采用重排序缓冲区进行多发射时使用的策略,如下所示。

  1. 发射逻辑预先为整个发射包保留足够的物理寄存器(比如,当每个指令最多有一个寄存器结果时,为四指令包预留4个寄存器)。
  2. 发射逻辑判断包中存在什么样的相关。如果包中不存在相关,则使用寄存器重命名结构来判断哪个物理寄存器保存着(或将会保存)指令所依赖的结果。如果包中指令都不依赖于先前发射包中的结果,寄存器重命名表中将拥有正确的寄存器编号。
  3. 如果一条指令依赖于在该发射包中排在前列的某条指令,那么将使用在其中存放结果的预留物理寄存器来为发射指令更新信息。

注意,就像在重排序缓冲区中一样,发射逻辑必须在一个周期内判断包内相关性,并更新重命名表,而且和前面一样,当每个时钟处理大量指令时,这种做法的复杂度就会成为发射宽度中的一个主要限制。

推测的代价

推测的重要优势之一是能够尽早发现那些本来会使流水线停顿的事件,比如缓存缺失。但是,这种潜在优势也带来一个潜在的重大不利条件。推测不是免费的,它需要时间和能量,错误预测的恢复过程还会进一步降低性能。此外,为了从推测中获益,需要支持更高的指令执行速率,为此,处理器必须拥有更多的资源,而这些资源又会占用硅面积、消耗功率。最后,如果推测导致异常事件的发生,比如缓存缺失或转变旁视缓冲区(TLB)缺失,而没有推测时本来不会发生这种事件,那推测造成重大性能损失的可能性就会增大。

为了在最大程度上保持优势、减少不利因素,大多数具有推测的流水线都仅允许以推测模式处理低成本的异常事件(比如,第一级缓存缺失)。如果发生成本高昂的异常事件,比如第二级缓存缺失或TLB缺失,处理器将会一直等待,等引发该事件的指令不再具有推测性时再来处理这一事件。尽管这样会使一些程序的性能稍有降低,但对于其他一些程序,尤其是会频繁出现此类事件且分支预测效果不佳的程序,可以避免性能大幅降低。

在20世纪90年代,推测的潜在不利因素还不是特别明显。随着处理器的发展,推测的实际成本变得越来越明显,宽发射和推测的局限性也变得更为突出。

多分支预测

在本章已经研究过的示例中,在必须推测一个分支之前, 已经有可能解决另一个分支。有三种情景可以通过同时推测多个分支获益:

  • 分支出现频率非常高;
  • 分支高度汇集;
  • 功能单元中的延迟很长。

在前两种情况下,要实现高性能可能意味着对多个分支进行推测,每个时钟周期甚至可能处理一条以上的指令。数据库程序和其他结构化程度较低的整数计算经常呈现这些特性,使多个分支的预测变得非常重要。同样,功能单元中的延迟很长时,也会增加对多个分支进行推测的重要性,用于避免因为流水线延迟过长而造成的停顿。

对多个分支进行推测会使推测恢复过程变得稍微复杂,但在其他方面比较简单。到2011年,还没有处理器能够将推测与每个时钟周期内处理多条分支完全结合在一起, 从性能与复杂性、功率的对比来看,这样做的成本可能有些过高了。

推测与能耗效率的挑战

推测对能耗效率有什么影响呢?乍看起来,有人可能会说推测的使用总是降低能耗效率,因为只要推测错误,就会以下面两种方式消耗更多的性能。

  1. 对某些指令进行了推测,但却不需要它们的结果,这些指令会为处理器生成多余工作,浪费能量。
  2. 撤销推测,恢复处理器的状态,以便在适当的地址处继续执行,这些操作都会多消耗一部分能量,在没有推测时是不需要消耗这部分能量的。

当然,推测的确会增大功率消耗,如果我们能够控制推测,那就有可能对成本进行测量(至少可以测量动态功率成本)。但是,如果推测过程缩短的执行时间多于它增加的平均功耗,那消耗的总能量仍然可能减少。

因此,为了了解推测对能耗效率的影响,我们需要研究推测生成非必要任务的频繁程度。如果会执行非常大量的非必要指令,那推测就不大可能大幅缩短运行时间!图3-10给出了由于错误预测而执行的指令比例。可以看到,在科技代码中,这一比例很小,而在整数代码中则很高(平均为大约30%)。因此,对于整数应用程序来说,推测的能耗效率不会很高。设计人员可以避免推测,尝试减少错误预测,或者考虑采用新方法,比如仅对那些已经知道可预测性很强的分支进行推测。

值预测

一种提高程序中可用ILP数目的技术是值预测。值预测尝试预测一条指令可能生成的值。显然,由于大多数指令在每次执行时都生成一个不同值(至少是一组取值中的一个不同值),所以值预测的成功率可能非常有限。但是,也有一些特定的指令,可以很轻松地预测它们的结果值,比如从常数池中进行载入的载入指令,或者载入一个不经常变化的取值。此外,如果一条指令只是从一组很少的取值中选择一个,那就有可能结合其他程序行为来预测结果值。

如果值预测能够显著提高可用ILP的数量,那它就是有用的。当某个值被用作一串相关运算的源数据时(比如一个载入操作),这种可能性就很大。因为值预测是用来提高推测能力的,而且错误预测会有不利的性能影响,所以预测的准确性非常关键。

有一种比较简单的与值预测相关的较早思想已经得到了应用,那就是地址别名预测。地址别名预测是一种非常简单的技术,用来预测两个存储指令或者一个载入指令与一个存储指令是否引用同一存储器地址。如果这样两条指令没有引用同一地址,那就可以放心地交换它们的顺序。否则,就必须等待,直到知道这些指令访问的存储器地址为止。因为我们不需要实际预测地址值,只需要知道这些值是否冲突即可,所以这种预测更稳定,也更简单。

ILP 局限性的研究

在20世纪60年代出现第一批流水化处理时,就开始通过开发ILP来提高性能。这一节的数据还为我们提供了一种方法,用来验证本章所讨论思想的价值,这些思想包括存储器消歧、寄存器重命名和推测。

硬件模型

为了了解ILP可能有哪些局限性,我们首先需要定义一种理想处理器。在理想处理器中,去除了对ILP的所有约束条件。在这样一个处理器中, 对ILP仅有的一些限制是由通过寄存器或存储器的实际数据流带来的。对理想或完美处理器作出以下假设。

  1. 无限寄存器重命名——有无限个虚拟寄存器可供使用,因此避免了所有WAW和WAR冒险,可以有无数条指令同时执行。
  2. 完美分支预测——分支预测非常完美。所有条件分支都被准确预测。
  3. 完美跳转预测——所有跳转(包括用于返回和计算跳转的跳转寄存器)都被完美预测。与完美分支预测相结合,这相当于拥有了一种处理器,它可以进行完美预测,并拥有一个极大的可执行指令缓冲区。
  4. 完美存储器地址别名分析——所有存储器地址都已确切知道,如果载入指令与存储指令的地址不同,可以将载入指令移到存储指令之前。注意,这就实现了完美的地址别名分析。
  5. 完美缓存——所有存储器访问都占用一个时钟周期。在实践中,超标量处理器通常会使用大量ILP,隐藏了缓存缺失,使这些结果变得非常乐观。

假设(2)与假设(3)消除了所有控制相关。而假设(1)和假设(4)消除了除真数据相关之外的所有数据相关。这4条假设结合在起来,就意味着:对于程序执行中的任何一条指令,在它所依赖的先前指令执行完毕之后,可以将该指令调度到紧随其后的时钟周期上。根据这些假设,甚至有可能将程序中最后一条动态执行的指令调度到最前面的时钟周期上!因此,这一组假设同时包含了控制推测与地址推测,在实现时把它们当作是完美的。

我们首先研究一个可以同时发射无数条指令的处理器,能够提前看到运算过程中的任意指令。在我们研究的所有处理器型号中,对于一个时钟周期能够执行哪些类型的指令没有限制。在可以发射无限条指令时,这意味着在一个时钟周期中可能有无数条载入指令或存储指令。此外,所有功能单元的延迟都假定为一个时钟周期,所以任何相关指令序列都可以在连续周期上发射。如果延迟长于一个周期,尽管不会降低任意时刻正在执行的指令数目,但可能会降低每个周期发射的指令数目。(任意时刻正在执行的指令通常被称为是在in flight。)

为了测量可用并行,可以使用标准MIPS优化编译器来编译和优化一组程序。对这些程序交付运行,生成一个指令与数据引用踪迹。然后尽早调度踪迹中的所有指令,仅受数据相关的限制。由于使用了踪迹,所以很容易实现完美分支预测和完美别名分析。利用这些机制,可以大大提前对这些指令的调度,在没有数据相关的大量指令之间进行移动,由于可以完美预测分支,所以这些指令也包括分支指令在内。

图3-11显示了6个SPEC92基准测试的平均可用并行度数目。本节中,始终以平均指令发射率为指标来测试并行。注意,所有指令的延迟为一个时钟周期;延迟较长时会减少每个时钟的平均指令数。这些基准测试中有3个(fppp、doduc和tomcatv)是浮点操作密集的基准测试,另外3个为整数程序。浮点基准测试中有两个(fppp和tomcatv)有大量并行,可供向量计算机或多处理器开发(不过,由于已经对代码进行了一些人工转换,所以fpppp 中的结构十分杂乱)。doduc程序拥有大量并行,但这些并行不会在简单的并行循环中出现,这一点与fpppp和omcatv中不同。程序li是-个拥有许多短相关的LISP解释程序。

图3-11 一个完美处理器中运行6个SPEC92基准测试时的可用ILP。前3个程序是整数程序,后3个是浮点程序。浮点程序中循环数量很多,有大量循环级并行

可实现处理器上ILP的局限性

本节中,我们将研究一些处理器的性能,具体来说,我们将假定以下固定属性。

  1. 每个时钟周期最多发射64条指令、没有发射限制,或者是2011年最宽处理器总发射宽度的10倍以上。后面将会讨论,超大发射宽度对时钟频率、逻辑复杂度和功率产生的实际影响可能才是对ILP开发的最重要限制。
  2. 一个竞赛预测器,拥有1000项和一个16项返回预测器。这个预测器可以与2011年的最佳预测器相媲美;这个预测器不是主要瓶颈。
  3. 对于动态完成的存储器引用能够完全消除歧义——这项要求是比较高的,但当窗口较小时(因此,发射速度和载入存储缓冲区也较小),或者通过地址别名预测,还是有可能做到的。
  4. 具有64个附加整数寄存器和64个浮点寄存器的寄存器重命名,这一数目要略小于2011年的最强劲处理器。

图3-12给出了这一配置在窗口大小变化时的结果。这一配置比任何已有实现方式都要复杂和昂贵,特别是在指令发射数方面,它要比2011年任意处理器上的最大可用发射数大10倍以上。不过,它对未来实施方式所能生成的内容给出了一个非常有用的范围。由于另一个原因,这些图形中的数据可能是非常乐观的。在64条指令中没有发射限制:它们可能都是存储器引用。在不远的将来,甚至没有人会在处理器中设计这一功能。遗憾的是,用合理的发射限制来界定处理器的性能是十分困难的,这不仅是因为存在各种各样的可能性,而且发射限制的存在需要用准确的指令调度器来评估并行,在研究具有大量发射指令的处理器时,其成本会非常昂贵。

另外还请记住,在解读这些结果时,没有考虑缓存缺失和长于1个时钟周期的延迟,这两个因素都可能产生严重影响!

图3-12:对于各种整数与浮点程序,每个时钟周期发射64个任意指令时,可用并行数随窗口大小的变化情况。尽管重命名寄存器的数目少于窗口大小,但所有操作的延迟为一个时钟周期 、重命名寄存器的数目等于发射宽度,这一事实使处理器能够在整个窗口中开发并行。在实际实现中,必须平衡窗口大小和重命名寄存器的数目,以防止这些因素中的某一个过度限制发射速率

图3-12中最令人吃惊的观测结果是:考虑到以下所列的实际处理器约束条件,窗口大小对整数程序的影响不像对浮点程序那么严重。这一结果指向了这两种程序之间的关键区别。两个浮点程序中能够利用循环级并行,这意味着可以开发的ILP数目较高,而对于整数程序,其他因素(比如分支预测、寄存器重命名、并行较少,等等)都是重要的限制情况。从万维网和云计算于20世纪90年代中期开始爆发式发展以来,人们增加了对整数性能的重视,所以这一观察结果非常关键。事实上,在过去10年里,大多数市场增长(事务处理、Web 服务器等)都依赖于整数性能,而不是浮点性能。

由于在实际硬件设计中,提高指令速率具有一定的难度,所以设计人员面临着一项挑战:决定如何更好地利用集成电路上有限的可用资源。是采用具有更大缓存和更高时钟速率的较简单处理器,还是将重点放在具有较慢时钟和较小缓存的指今级并行上,这是最重要的设计权衡之一。下面的例子演示了这些挑战。

考虑以下3种假设的非典型处理器,我们将在上面运行SPEC gcc基准测试。

  1. 一个简单的MIPS双发射静态流水线,其时钟速率为4 GHz,所实现的流水线CPI为0.8。这一-处理器的缓存系统每条指令发生0.005次缺失。
  2. 一个双发射MIPS处理器的深度流水线版本,缓存稍小一些,时钟速率为5 GHz。处理器的流水线CPI为1.0,缓存较小,平均每条指令生成0.0055次缺失。
  3. 一个推测超标量处理器,具有一个64项窗口。它能实现的发射率为这一窗口大小理想发射率的一半。这一处理器的缓存最小,每条指令产生0.01次缺失,但通过动态调度可以隐藏每次缺失25%的缺失代价。这个处理器的时钟为2.5 GHz。

假定主存储器时间(这一时间决定了缺失代价)为50ns。判断这3种处理器的相对性能。

首先,我们使用缺失代价和缺失率信息计算每一种配置中缓存缺失对CPI的影响。计算公式如下:

缓存CPI=每条指令的缺失数x缺失代价

我们需要为每个系统计算缺失代价:

缺失代价=存储器访问时间/时钟周期

这3种处理器的时钟周期时间分别为250 ps、200 ps和400 ps。因此,缺失代价是:

缺失代价1 = 50ns / 250ps = 200周期

缺失代价2 = 50ns / 250ps = 250周期

缺失代价3 = 0.75x50ns / 400ps = 94周期

为每一缓存应用此公式:

缓存CPI1=0.005 x 200=1.0

缓存CPI2=0.0055 x 250=1.4

缓存CPI3=0.01 x 94=0.94

除了处理器3之外,我们知道了其他处理器的流水线CPI影响;它的流水线CPI给出如下:

流水线CPI3 = 1 / 发射速率 = 1 / (9x0.5) = 1 / 4.5 = 0.22

现在可以通过添加流水线和缓存CPI因素来求出每个处理器的CPI:

  • CPI1=0.8+1.0=1.8
  • CPI2=1.0+1.4=2.4
  • CPI3=0.22+0.94=1.16

由于这是同一体系结构,所以可以对比指令执行速度以确定相对性能:

指令执行速度=CR/CPI

指令执行速度1=4000MHz/1.8=2222 MIPS

指令执行速度2=5000MHz/2.4=2083 MIPS

指令执行速度3=2500MHz/1.16=2155 MIPS

在这个例子中,简单的双发射静态超标量看起来是最好的。在实践中,性能取决于CPI和时钟频率两项假设。

超越本研究的局限

和所有极限研究一样,即使在完美推测处理器中也会存在的局限性;在一或多种现实模型中存在的局限性。当然,第一类中的所有局限性也适用于第二类。适用于完美模型的最重要局限性包括以下几个。

(1)访问存储器的WAW和WAR冒险——这一研究通过寄存器重命名消除了WAW和WAR冒险,但却没有消除存储器使用中的冒险。尽管乍看起来,此类情况很少会出现(特别是WAW冒险),但它们的确会因为栈帧分配而出现。某种被调用过程重复利用栈中上一过程使用的存储器位置,这样可能会导致WAW和WAR冒险,造成不必要的限制。

(2)不必要的相关——在有无数个寄存器时,就可以消除真寄存器数据相关之外的所有其他数据相关。但是,由于递归或代码生成约定而造成的相关仍然会引入一些不必要的真数据相关。其中一个例子就是在一个简单的 for 循环中对于控制变量的相关。由于控制变量在每次循环迭代时都会递增,所以循环中包含至少一个相关。Wall 的研究中包含了数量有限的一些此类优化,但更积极地应用这些优化方式有可能增加ILP的数目。此外,特定的代码生成约定也会引入一些不必要的相关,特别是在使用返回地址窗口和栈指针寄存器时(它会在调用/返回序列中递增和递减)。Wall消除了返回地址寄存器的影响,但在链接约定中使用规模指针可能会导致“不必要的”相关。

(3)克服数据流限制——如果值预测的精度很高,那就可能克服数据流限制。显然,完美的数据值预测可以得到高效的无限并行,因为每个指令的每个值都可能提前预测得出。

对于不够完美的处理器,已经提出了几种可以发现更多ILP的思想。其中一个例子是沿多条路径进行推测。通过在多条路径上进行推测,可以降低错误恢复的成本、发现更多的并行。由于所需硬件资源呈指数增长,所以只有对有限个分支评估这一方案才有意义。

交叉问题:ILP方法与存储器问题

硬件推测 与软件推测

下面列出这些硬件密集的推测方法的一些权衡与局限性。

  • 为了能够大范围进行推测,必须能够消除存储器引用的歧义。对于包含指针的整数程序,很难在编译时实现这一功能。在基于硬件的方案中,存储器地址的动态运行时消歧是使用前面介绍的Tomasulo算法的技术完成的。这一消歧功能可以在运行时把载入指令移到存储指令之后。对推测存储器引用的支持可以帮助克服编译器的保守性,但如果使用这些方法时不够仔细,那恢复机制的开销可能会大于它们所能带来的收益。
  • 当控制流不可预测时,当基于硬件的分支预测优于在编译时完成的软件分支预测时,基于硬件的推测效果较佳。这些特性对于许多整数程序都是成立的。例如,一个好的静态预测器,对4个主要整数SPEC92程序的错误预测率大约为16%,而硬件预测器的错误预测率低于10%。因为当预测不正确时,推测错误的指令可能会拖慢计算速度,所以这一差别非常明显。因为这一差别而导致了一个结果:即使是静态调度的处理器中通常也会包含动态分支预测器。
  • 即使对于被推测的指令,基于硬件的推测也能保持完全精确的异常模型。最近的基于软件的方法也添加了这一特殊支持,同样可以做到这一点。
  • 基于硬件的推测不需要补充或记录代码,而那些雄心勃勃的软件推测机制则需要这一条件。
  • 基于编译器的方法能够深人了解代码序列,从中获益,从而在代码调度方面要优于纯硬件驱动的方法。
  • 对于一种体系结构的不同实现方式,采用动态调度的硬件推测不需要采用不同代码序列就能实现好的性能。尽管这一收益很难量化,但从长期来看,这一收益可能是最重要的。有意思的是,它曾经是设计IBM 360/91的动机之一。另一方面,最近的显式并行体系结构(比如IA-64)已经增加一定的灵活性,可以减少代码序列中固有的硬件相关。

在硬件中支持推测的主要缺点是需要更多、更复杂的硬件资源。必须针对两个方面对这一硬件成本进行评估,一是与软件方法中编译器的复杂性相对比,一是与依赖此类编译器的处理器简化程度相对比。

一些设计人员已经尝试将动态方法和基于编译器的方法结合起来,以期达到两种方法的最佳效果。这种组合方式可以产生一些有趣但不够明确的交互。例如,如果将条件移动与寄存器重命名结合起来,就会出现一种微妙的副作用。由于之前已经在指令流水线中更改了目标寄存器的名称,所以一个被撤消的条件移动操作仍然会向目标寄存器中复制一个值。这种微妙的交互使设计与验证过程都变得非常复杂,还可能会降低性能。

迄今为止,在采用软件方法支持ILP和推测的计算机中,Intel Itanium处理器是最强大的。它没有像设计人员所希望的那样提供硬件支持,对于通用、非科学代码尤为如此。意识到3.10节讨论的困难之后,设计人员开发ILP的热情已经减退,因此,在大多数体系结构最终采用的硬件方案中,发射速率为每个时钟周期发射3~4条指令。

推测执行与存储器系统

在一个支持推测执行或条件指令的处理器中,自然可能生成一些在没有 推测执行时就不会用到的无效地址。如果激发了保护异常,那这不仅是一种错误行为,而且推测执行的收益还可能会被错误异常的开销抵消。因此,存储器系统必须识别推测执行的指令和条件执行的指令,并抑制相应的异常。

此类指令可能会导致缓存因缺失而停顿,这些不必要的停顿仍然可能超过推测带来的收益,出于和前面类似的原因,我们不能允许这种现象的发生。因此,这些处理器必须与非阻塞缓存相匹配。

在实际中,由于L2缺失的代价非常大,所以编译器通常都仅对L1缺失进行推测。

多线程:开发线程级并行提高单处理器吞吐量

多线程是一个真正的交叉主题,它同流水线与超标量相关、与图形处理器相关,还与多处理器有关。利用多线程来隐藏流水线和存储器延迟,从而提高单处理器的吞吐量。

尽管使用ILP来提高性能具有很大的优势:它对编程人员是适度透明的,但我们已经看到,在某些应用程序中,ILP可能受到很大的限制或者难以开发。具体来说,当指令发射率处于合理范围时,那些到达存储器或片外缓存的缓存缺失不太可能通过可用ILP来隐藏。当然,当处理器停顿下来等待缓存缺失时,功能单元的利用率会激剧下降。由于人们在试图通过更多的ILP来应对很长的存储器停顿时,效果非常有限。

多线程技术支持多个线程以重叠方式共享单个处理器的功能单元。而与之相对的是,开发线程级并行(TLP)的更一般方法是使用多处理器,它同时并行运行多个独立线程。但是,多线程不会像多处理器那样复制整个处理器。而是在一组线程之间共享处理器核心的大多数功能,仅复制私有状态,比如寄存器和程序计数器。

要复制一个处理器核心中每个线程的状态,就要为每个线程创建独立的寄存器堆、独立的PC和独立的页表。存储器本身可以通过虚拟存储器机制共享,这些机制已经支持多重编程了。此外,硬件必须支持对不同线程进行较快速的修改;具体来说,线程切换的效率应当远远高于进程切换,后者通常需要数百个到数千个处理器周期。当然,为使多线程硬件实现性能改进,一个程序中必须包含能够以并发形式执行的多个线程(有时说这种应用程序是多线程的)。这些线程既可由编译器识别(通常是来自具有并行结构的语言),也可能由程序员识别。实现多线程的硬件方法主要有3种。细粒度多线程每个时钟周期在线程之间进行一次切换,使多个线程的指令执行过程交织在一起。这种交织通常是以轮询方式完成的,当时发生停顿的所有进程都会被跳过。在细粒度多线程情况下,当一个线程停顿时,哪怕这停顿只有几个周期,也可以执行其他线程中的指令,所以这种多线程的一个重要好处是它能够隐藏因为长、短停顿而导致的吞吐量损失。细粒度多线程的一个主要不足是它会减缓个体线程的执行速度,因为一个做好执行准备、没有停顿的线程可能会被其他线程的执行延迟。它用单个进程的性能损失(这里所说的性能是以延迟来衡量的)来换取多线程吞吐量的一致性。

粗粒度多线程的设计目的就是用来作为细粒度的替代选项。粗粒度多线程仅在发生成本较高的停顿时才切换线程,比如第2级或第3级缓存缺失时。通过这一变化, 只有当一个线程遇到成本高昂的停顿时才会发射其他线程的指令,这样就不再严格要求线程切换操作必须是无成本的,同时也大大降低了减缓任一线程执行速度的可能性。不过,粗粒度多线程也有一个严重不足:克服吞吐量损失的能力非常有限,特别是由于较短停顿导致的损失。这一局限性源于粗粒度多线程的流水线启动成本。由于采用粗粒度多线程的CPU仅发现来自单个线程的指令,所以当流水线发生停顿时,在新线程开始执行之前会出现“气泡”。由于这一启动开销,粗粒度多线程更多是用来应对那些成本超高的停顿,当发生此类停顿时,重新填充流水线的时间与停顿时间相比可以忽略不计。已经有几个研究项目对粗粒度多线程进行了探索,但现在的主流处理器中都还没有使用这一技术。

最常见的多线程实施方式称为同时多线程(SMT)。同时多线程是细粒度多线程的一种变体,它是在多发射、动态调度处理器的顶层实现细粒度多线程时自然出现的。和其他形式的多线程一样,SMT利用线程级并行来隐藏处理器中的长延迟事件,从而提高功能单元的利用率。SMT的关键在于认识到通过寄存器重命名和动态调度可以执行来自独立线程的多个指令,而不用考虑这些指令之间的相关性;这些相关性留给动态调度功能来处理。

图3-13从概念上给出了处理器在以下不同配置中开发超标量源的能力差异:

  • 不支持多线程的超标量;
  • 支持粗粒度多线程的超标量;
  • 支持细粒度多线程的超标量;
  • 支持同时多线程的超标量。


图3-13 4种不同方法在应用一个超标量处理器功能单元执行槽的表现状况。水平维度表示每个时钟周期中的指令执行能力。垂直维度代表时间周期序列。空白框(白色)表示该时钟周期的相应执行槽未被使用。灰色和黑色的阴影框对应多线程处理器中的4个不同线程。黑色框表示在不支持多线程的超标量中被占用的发射槽。SunT1和T2(akaNiagara)处理器是细粒度多线程处理器,而Intel Core i7 和IBM Power7 处理器使用SMT。T2有8个线程、Power7有4个、Intel i7有2个。在所有现有SMT中,每次只发射一个线程中的指令。SMT的区别在于:在后面决定执行哪条指令时不需要考虑相互之间的影响,可以在同-一个时钟周期执行来自几个不同指令的操作

在不支持多线程的超标量中,由于缺乏ILP(包括用于隐藏存储器延迟的ILP),所以发射槽的使用非常有限。由于L2和L3缓存缺失的长度原因,处理器在大多数时间内可能保持空闲。

在粗粒度多线程超标量中,通过切换到另一个利用处理器资源的线程来部分隐藏长时间的停顿。这一切换减少了完全空闲的时钟周期数。但是,在粗粒度多线程处理器中,仅当存在停顿时才会进行线程切换。由于新线程有一个启动时间,所以仍然可能存在一些完全空闲的周期。

在细粒度情况中,线程的交织可以消除全空槽。此外,由于每个时钟周期都会改变发射线程,所以可以隐藏较长延迟的操作。由于指令发射和执行联系在一起,所以线程所能发射的指令数目仅限于准备就绪的指令。当发射宽度较窄时,没有什么问题(某个时钟周期要么被占用,要么不被占用),这就是细粒度多线程在单发射处理器中能够完美运行的原因,SMT没有什么意义。事实上,在SunT2中,每个时钟周期会有两次发射,但它们来自不同线程。这样就不再需要实施复杂的动态调度方法,而是依靠更多线程来隐藏延迟。

如果在多发射动态调度处理器的顶层实现细粒度线程,所得到的结果就是SMT。在所有现有SMT实现方式中,尽管来自不同线程的指令可以在同一时钟周期内开始执行,但所有发射都来自一个线程,使用动态调度硬件来决定哪些指令已经准备就绪。尽管图3-13极大地简化了这些处理器的实际操作,但它仍然能够说明在发射宽度较宽的动态调度处理器中,一般多线程和SMT的潜在性能优势。

同时多线程的出现是因为深刻地认识到:动态调度处理器已经拥有支持这一方案所需要的大量硬件方案,包括大量虚拟寄存器。通过为每个线程添加专用的重命名表、保持独立的PC、支持提交来自多个不同线程的指令,也可以在乱序处理器上实现多线程。

细粒度多线程在Sun T1上的效果

在这一节,我们利用Sun T1处理器来研究多线程隐藏延迟的能力。T1 是Sun公司在2005年发布的-种细粒度多线程多核微处理器。人们之所以特别关注T1,是因为它几乎将全部重心都放在开发线程级并行(TLP)上,而不是用于开发指令级并行(ILP)。T1 不再强调对ILP的调度(就在此前不久,刚刚发布了更积极的ILP处理器),回归一种简单的流水线策略,着力开发TLP,使用多核和多线程技术来提高吞吐量。

每个T1处理器包含8个处理器核心,每个核心支持4个线程。每个处理器核心包括一个简单的六级、单发射流水线。T1使用细粒度多线程(但不是SMT),每个时钟周期切换到一个新的线程,在调度过程中,会跳过那些因为流水线延迟或缓存缺失而处于等待状态的空闲线程。只有在所有4个线程全部空闲或停顿时,处理器才会空闲。载入指令和分支指令都会导致一个长度为3个时钟周期的延迟,这一延迟只能由其他线程来隐藏。由于浮点性能不是T1的重点,所以它只提供了一组浮点功能单元,由所有8个核心共享。表3-16总结了T1处理器的各个特性。

T1多线程单核性能

T1把自己的重点放在TLP上,既在各个核心上实施了多线程,又在单个晶片上使用了多个简单核心。在这一节,我们将研究T1通过细粒度多线程提升单核心性能的效果。为了研究T1的性能,我们使用3种面向服务器的基准测试: TPC-C、SPECJBB (SPEC Java业务基准测试)和SPECWeb99。由于多个线程会增大单个处理器对存储器的需求,所以可能会使存储器系统过载,从而降低多线程的潜在收益。图3-14是TPC-C基准测试中,在每个核心执行1个线程和每个核心执行4个线程时缺失率及所观测到的缺失延迟的相对增长情况。由于存储器系统的争用增加,缺失率和缺失延迟都会增大。缺失延迟的增幅较小,表示存储器系统仍然有未用空间。

图3-14在TPC-C基准测试中,每个核心执行1个线程和每个核心执行4个线程时缺失率与缺失延迟的相对增长情况。这些延迟是指在一次缺失之后返回所请求数据的实际时间。在4线程情况中,其他线程的执行可能会隐藏这一延迟的大部分时间通过研究一个一般线程的行为,可以了解线程之间进行交互以及它们使核心保持繁忙状态的能力。图3-15给出了3种时钟周期所占的百分比:一种是有线程正在执行,一种是有线程准备就绪,但尚未执行,还有一种是线程没有准备就绪。注意,没有准备就绪并不意昧着拥有该线程的核心处于停顿状态,只有当所有4个线程都未准备就绪时,该核心才会停顿。

囹3-15 一般线程的状态细分。“正在执行”是指该线程在该时钟周期内发射了一条指令。“就绪但未被选中”是指它可以发射指令,但另一线程已被选中;“未就绪”是指该线程正在等待一个事件(例如,一次流水线延迟或缓存缺失)的完成线程未准备就绪可能是因为缓存缺失、流水线延迟(由于一些延迟较长的指令导致,比如分支、载入、浮点或整数乘/除)以及各种影响较小的因素。图3-16显示了这些不同原因的发生频率。在50% ~ 70%的时间里,线程未准备就绪是由缓存原因造成的,L1 指令缺失、L1数据缺失和L2缺失造成的影响大致相同。在SPECJBB中,由于流水线造成的潜在延迟(称为“流水
线延迟”)是最严重的,可能是由于它的分支频率较高所致。

图3-16 线程的未就绪原因细分。“其他”类别的原因是变化的。在TPC-C 中,存储器缓冲区满是最主要的因素;在SPEC-JBB中,原子指令是最主要的因素;而在SPECWeb99中,这两个因素都有影响

表3-17显示了每个线程的CPI和每个核心的CPI。由于T1是一种细粒度多线程处理器,每个核心有4个线程,当有足够的并行时,每个线程的理想有效CPI为4,这意味着每个线程占用每4个时钟周期中的1个周期。每个核心的理想CPI为1。2005年,在积极采用ILP的核心上运行这些基准测试的IPC就已经可以达到T1核心上观测到的类似数据。但是,与2005年更积极的ILP核心相比,T1核心的大小非常合适,这就是为什么T1拥有8个核心,而同一时期的其他处理器只提供了2~4个核心。因此,在2005年发布SunT1处理器时,它在处理TLP密集、存储器性能需求较高的的整数应用程序和事务处理工作负载时,具有最佳性能。

同时多线程在超标量处理器上的效果

一个关键问题是:通过实施SMT可以使性能提高多少?在2000 ~ 2001年研究这一问题时,研究人员认为动态超标量会在接下来的5年里大幅改进,每个时钟周期可以支持6~8次发射,处理器会支持推测动态调度、许多并行载入和存储操作、大容量主缓存、4~8种上下文,可以同时发射和完成来自不同上下文的指令。到目前为止,还没有处理器能够接近这一水平。因此,那些认为多编程工作负载可以使性能提高2 ~ 3倍的模拟研究结果是不现实的。实际中,现有的SMT实现只能提供2~4个带有取值的上下文,只能发射来自一个上下文的指令,每个时钟周期最多发射4条指令。其结果就是:由SMT获得的收益是非常有限的。

例如,在Pentium 4 Extreme中(在HP-Compaq服务器中实现)使用SMT时,运行SPECintRate基准测试时可以使性能提高1.01,运行SPECfpRate基准测试时可以使性能提高1.07。

我们可以使用一组多线程应用程序来研究在单个i7核心中使用SMT时获得的性能与能耗收益。我们使用的基准测试包括一组并行科学应用程序和一组来自DaCapo和SPEC Java 套件的多线程Java程序,在表3-18中对其进行了总结。Intel i7支持拥有两个线程的SMT.图3-17给出了SMT分别为关闭与开启状态时,在i7的-个核心上运行这些基准测试的性能比与能耗效率比。(我们绘制了能耗效率比曲线,能耗效率是能耗的倒数,所以和加速比一样,这个比值越大越好。)

尽管这两个Java基准测试的性能增益较小,但其加速比的调和均值为1.28。在采用多线程时,两个基准测试(pjbb2005和tradebeans)的并行非常有限。之所以包含这些基准测试是因为它们是典型的多线程基准测试,可以在一个指望提高性能的SMT处理器上运行,它们提升效果非常有限。PARSEC 基准测试的加速比要比全套Java基准测试好一些(调和均值为1.31)。如果省略tradebeans和pjbb2005, Java 工作负载的实际加速比(1.39)要比PARSEC基准测试好得多。

融会贯通: Intel Core i7和ARM Cortex-A8

本节,我们研究两种多发射处理器的设计:一种是ARM Cortex- A8核心,它是iPad中AppleA9处理器、Motorola Droid和iPhone 3GS、4中处理器的基础,另一种是Intel Core i7,一种高端、动态调度、推测处理器,主要为高端桌面应用程序和服务器应用程序设计。我们首先从较简单的处理器开始。

ARM Cortex-A8

A8是一种双发射、静态调度超标量处理器,具有动态发射检测功能,允许处理器在每个时钟周期内发射一条或两条指令。图3-18显示了13级流水线的基本流水线结构。A8使用一种动态分支预测器,具有一个512项2路组相联分支目标缓冲区和一个4K项全局历史缓冲区,由分支历史和当前PC进行索引。当出现分支目标缓冲区缺失时,则在全局历史缓冲区中进行预测,然后用预测值计算分支地址。此外,还维护一个8项返回栈,用于跟踪返回地址。一次错误预测会导致13个时钟周期的代价,用于刷新流水线。图3-19显示了指令译码流水线。利用循序发射机制,每个时钟周期最多可以发射两条指令。可以使用一种简单的记分板结构来跟踪何时能够发射一条指令。通过发射逻辑可以处理一对相关指令,当然,除非它们的发射方式能使转发路径消除两者之间的相关,否则会在记分板上对它们进行序列化。

图3-19 A8的5级指令译码。在第一级中,使用取指单元生成的PC(或者来自分支目标缓冲区,或者来自PC递增器)从缓存中提取大小为8字节的块。最多对两条指令进行译码,并将它们放在译码队列中;如果两条指令都不是分支,则将PC递增,为下一次取指做准备。一旦进入译码队列,则由记分板逻辑决定何时可以发射这些指令。在发射时,读取寄存器操作数;回想在简单的记分板中,操作数总是来自寄存器。寄存器操作数和操作码被发送到流水线的指令执行部分

图3-20显示了A8处理器的执行流水线。指令1或指令2可以进入这个载入存储流水线。在这些流水线之间支持完全旁路。ARM Cortex-A8流水线使用简单的双发射靜态调度超标量,可以在较低功率下实现相当高的时钟频率。与之相对,i7 使用一种相当积极的4发射动态调度推理流水线结构。

A8流水线的性能

由于A8采用双发射结构,所以它的理想CPI为0.5。可能会因为以下3种来源而产生流水线停顿。

(1)功能冒险,如果被选择同时发射的两个相邻指令使用同一功能流水线,就会出现功能冒险。由于A8是静态调度的,所以避免此类冲突是编译器的任务。如果不能避免此类冲突,A8在这个时钟周期内最多只能发射一条指令。

(2)数据冒险,在流水线的早期进行侦测,可能使两条指令停顿(如果第一条指令不能发射,第二条总是会被停顿),也可能是一对指令中的第二条指令停顿。编译器负责尽可能防止此类停顿。

(3)控制冒险,仅在分支预测错误时发生。

除了流水线停顿之外,L1和L2缺失都会导致停顿。

图3-21是影响Minnespec基准测试实际CPI的各项因素的估计值,我们在第2章曾经见过这些基准测试。可以看到,这一CPI的主要影响因素是流水线延迟,而不是存储器停顿。出现这一结果的部分原因是Minnespec的缓存印记要小于全套SPEC或其他大型程序。流水线停顿会造成性能大幅下降,深刻认识到这一点,可能对于决定将ARM Cortex-A9设计为动态调度超标量处理器起到了重要作用。A9和A8相似,每个时钟最多发射两条指令,但它使用了动态调度和推测。在一个时钟周期内可以开发执行多达4条未完成指令(2个ALU、1个载入存储或浮点/多媒体指令、1个分支指令)。A9使用一种功能更为强劲的分支预测器、指令缓存预取和非阻塞L1数据缓存。图3-22表明,在使用相同时钟频率和几乎相同的缓存配置时,A9的平均性能是A8的1.28倍。


图3-21对 ARM A8.上CPI各组成分量的估计值表明:流水线停顿是增大基本CPI的主要因素。eon值得专门一提,它完成基于整数的图形计算(光线跟踪),而且缓存缺失很少。由于大量使用乘法,计算非常密集,所以单个乘法流水线可能会成为主要瓶颈。这一估计值是利用L1和L2缺失率与代价来计算每个指令中因为L1和L2生成的停顿而获得的。从具体模拟器测得的CPI中减去这些估计值即可获得流水线停顿。流水线停顿包含所有这3种冒险再加上一些次要影响,比如路预测错误等


图3-22在时钟频率均为 1 GHz、L1 与L2缓存大小相同时,A9与A8的性能比表明: A9大约快1.28倍。两者都使用32KB主缓存和1MB次级缓存,A8采用8路组相关,A9使用16路组相联。A8处理器缓存中的块大小为64字节,A9为32字节。在图3-21的图题中曾经提及, eon 大量使用了整数乘法,动态调度与快速乘法流水线的组合使用显著提高了A9的性能。由于A9的L1块较小,所以twoif的缓存表现不佳,可能是由于这一因素, twoif 的速度略有减缓

Intel Core i7

i7采用非常积极的乱序推测微体系结构,具有较深的流水线,目的是通过综合应用多发射与高时钟速率来提高指令吞吐量。图3-23显示了i7流水线的整体结构。我们在研究流水线时,按照如下步骤,首先从指令提取开始,接下来是指令提交。


图3-23 Intel Core i7流水线结构,一同给出了存储器系统组件。总流水线深度为14级,分支错误预测成本为17个时钟周期。共有48个载入缓冲区和32个存储缓冲区。6个独立功能单元可以在同一时钟周期分别开始执行准备就绪的微操作

(1)指令提取——处理器使用一个多级分支目标缓冲区,在速度与预测准确度之间达到一种平衡。还有一个返回地址栈,用于加速函数返回。错误预测会损失大约15个时钟周期。利用预测地址,指令提取单元从指令缓存中提取16个字节。

(2)16个字节被放在预译码指令缓冲区中——在这一步,会执行一个名为微指令融合的进程。微指令融合接收指令组合(比如先对比后分支),然后将它们融合为一个操作。这个预译码过程还将16个字节分解为单独的x86指令。由于x86指令的长度可能是1 ~ 17字节中的任何一种长度,所以这一预译码非常重要,预译码器必须查看许多字节才能知道指令长度。然后将单独的x86指令(包括一些整合指令)放到包含18项的指令队列中。

(3)微指令译码——各个x86指令被转换为微指令。微指令是一些类似于MIPS的简单指令,可以直接由流水线执行;这种方法将x86指令集转换为更容易实现流水化的简单操作,1997 年在Pentium Pro中引入,一直使用至今。3个译码器处理可以直接转换为一个微指令的x86指令。对于那些语义更为复杂的x86指令,有-一个微码引擎可供用于生成微指令序列;它可以在每个时钟周期中生成多达4条微指令,并一直持续下去,直到生成必要的微指令序列为止。按照x86指令的顺序,将这些微指令放在一个包含28项的微指令缓冲区中。

(4)微指令缓冲区执行循环流检测和微融合——如果存在一个包含循环的小指令序列(长度少于28条指令,或不足256个字节),循环流检测器会找到这个循环,直接从缓冲区中发射微指令,不再需要启动指令提取与指令译码级。微整合则合并指令对,比如载入ALU运算和ALU运算/存储,并将它们发射到单个保留站(在保留站仍然可以独立发射这些指令),从而提高了缓冲区的利用率。在对Intel Core体系结构的一项研究中(这种结构也合并了微整合和宏融合),Bird等人[2007]发现微整合几乎对性能没有什么影响,而宏融合则对整数性能有一定的下面影响,对浮点性能也几乎没有什么影响。

(5)执行基本指令发射——在寄存器表中查看寄存器位置,对寄存器重命名、分配重排序缓冲区项,从寄存器或重排序缓冲区中提取任意结果,然后向保留站发送微指令。

(6)i7使用一个包括36项的集中保留站,供6个功能单元共享。每个时钟周期最多可以向这些功能单元分发6个微指令。

(7)微指令由各个功能单元执行,然后将结果发送给任何正在等待的保留站以及寄存器退回单元,一旦知道指令不再具有推测之后,将在这里更新寄存器状态。重排序缓冲区中与该指令相对应的数目被标记为完成。

(8)当重排序缓冲区头部的一条或多条指令被标记为完成之后,则执行寄存器退回单元中的未完成写入操作,并将这些指令从重排序缓冲区中删除。

i7的性能

在前面几节中,我们研究了i7分支预测器的性能和SMT的性能。在这本节,我们将研究单线程流水线性能。由于积极推测与非阻塞缓存的存在,所以很难准确描述理想性能与实际性能之间的差距。我们将会看到,因为指令不能发射而导致的停顿很少。例如,只有大约3%的载入指令是因为没有可用保留站而导致的。大多数损失不是来自分支错误预测就是来自缓存缺失。分支预测错误的成本为15个时钟周期,而L1缺失的成本大约为10个时钟周期; L2 缺失的成本比L1缺失的3倍略多一些,而L3缺失的成本大约是L1缺失成本的13倍(130~135个时钟周期)!尽管在发生L3缺失和某些L2缺失时,处理器会尝试寻找一些替代指令来执行,但有些缓冲区会在缺失完成之前填满,从而导致处理器停止发射指令。

为了研究错误预测和错误推测的成本,图3-24给出了未退回工作(即它们的结果未被取消)相对于所有微指令分发指令所占的比例(根据分发到流水线中的微指令数目来测量)。比如,对sjeng来说,由于在所分发的微指令中,有25%从来未被退回,所以浪费了25%的工作。注意,在某些情况下,被浪费的工作与图3-3所示的分支错误预测率吻合,而在几种实例中,比如mcf中,被浪费的工作似乎要高于错误预测率。从存储器行为的角度也许能够解释这些情况。当数据缓存缺失率非常高时,只要有足够的保留站可供停顿存储器引用使用,mcf就将在错误的推测期间分发许多指令。在检测到分支预测错误时,与这些指令相对应的微指令将被刷新,但当推测存储器引用试图完成时,可能会产生缓存争用。对处理器来说,在启动缓存请求之后,没有一种简单方法可以使其停止。


图3-24通过计算所有已分发微指令中未退回微指令所占的比值,绘制了“被浪费工作”的数量。例如,sjeng的比值为25%,也就是说在已分发、执行的微指令中有25%被抛弃。

图3-25显示了19 个SPECCPU2006基准测试的总CPI。整数基准测试的CPI为1.06,方差很大(标准偏差为0.67)。MCF和OMNETPP是两个主要例外,它们的CPI都大于2.0,而其他基准测试都接近或小于1.0(gcc是第二高,为1.23)。这种偏差是由于分支预测准确度和缓存缺失率方面的差别造成的。对于整数基准测试,L2缺失率是CPI的最佳预测值,L3缺失率(非常小)几乎没有什么影响。


图3-25 19个SPECCPU2006的CPI表明,尽管行为表现有很大不同,但浮点与整数基准测试的平均CPI都是0.83。整数基准测试的CPI值变化范围为0.44 ~ 2.66,标准偏差为0.77,而浮点基准测试的变化范围为0.62 ~ 1.38,标准偏差为0.25。

浮点基准测试的性能较高:平均CPI较低(0.89)标准偏差较低(0.25)。对于浮点基准测试来说,L1和L2对于确定CPI是同等重要的,而L3则扮演着小而重要的角色。尽管i7的动态调度和非阻塞功能可以隐藏一些缺失延迟,但缓存存储器表现仍然是重要因素。这进一步强化了多线程作为另一种方法来隐藏存储器延迟的作用。

谬论与易犯错误

这里介绍的几点谬论主要集中在根据单一测量值(比如时钟频率或CPI)来预测性能、能耗效率以及进行推断的难度。我们还将表明:对于不同基准测试,不同体系结构方法可能会有截然不同的表现。

谬论:CPI较低的处理器总是要更快一些。

谬论:时钟频率较快的处理器总是要更快一些。

要点在于:性能是由CPI与时钟频率的乘积决定的。在通过实现CPU的深度流水化获得高时钟频率后,还必须保持较低的CPI, 才能全面体现快速时钟频率的优势。同理,一个时钟频率很高、CPI 很低的简单处理器也可能更慢一些 。

在前面讨论的谬论中已经看到,在为不同环境设计的处理器中,即使它们采用相同的ISA,也可能在性能与能耗效率方面有很大不同。事实上,即使是同一公司为高端应用程序设计的同一处理器系列,在性能方面也会有很大差异。表3-20显示的是Intel公司对x86体系结构两种不同实现方式的整数与浮点性能,还有一个是Itanium体系结构,也是Intel出品。


图3-26 一组单线程基准测试的相对性能与功耗效率表明,i7 920比Atom 230快10倍以上,而功率系数平均只有它的二分之一。柱形条中显示的性能是i7与Atom的比值,即执行时间(i7)/执行时间(Atom)。能耗以曲线显示,为能耗(Atom)/能耗(i7)。i7在能耗效率方法从来都没有打羸Atom,不过在4个基准测试方面的性能基本相当,其中有3个是浮点。SPEC基准测试是使用标准Intel编译器在打开优化的情况下编译的,Java 基准测试使用Sun (Oracle) Hostpot Java VM。i7. 上只有一个核心是活动的,其余核心处于深度节能模式。i7上使用了Turbo Boost,这一功能可以提高其性能优势,但相对的能耗效率会略有降低

Pentium 4是Intel公司生成的最积极的流水线处理器。它使用深度超过20级的流水线,有7个功能单元,还有缓存微指令,而不是x86指令。在这种积极的实施方式中,它的性能相对较差一些,这清楚地表明它在开发更多ILP方面的努力失败了(很容易就会同时有50条指令正在执行)。Pentium 的功耗与i7相似,不过它的晶体管数较少,主存储器大约是i7的一半,包括仅有2 MB的次级缓存,没有第三级缓存。

Intel Itanium是一种 VLIW风格的体系结构,尽管调度超标量相比,它的复杂度可能增加,但它的时钟频率从来都不能与主流x86处理器相提并论(尽管它的总CPI与i7类似)。在研究这些结果时,读者应当明白它们不同的实现技术,对于同等流水线的处理器来说,i7 在晶体管速度上具有优势,从而在时间频率方面也占据上风。不过,性能方面的巨大变化(Pentium和i7之间相差3倍以上)还是令人吃惊的。

谬论:有时越大、越被动就越好。

在2000年前期,人们大多把注意力都放在构建更积极的处理器,用于开发ILP,其中就包括Pentium 4体系结构(它在一个微处理器中使用了当时最深的流水线)和Intel Itanium (它每个时钟周期的峰值发射率是当时最高的)。后来人们很快发现在开发ILP时,主要限制因素是存储器系统造成的。尽管推测乱序流水线可以很好地隐藏第一级缺失中10~ 15个时钟周期的大部分缺失代价,但它们在隐藏第二级缺失代价方面几乎是无能为力的,由于涉及主存储器访问,所以第二级缺失代价可能达到50 ~ 100个时钟周期。

结果就是,尽管使用了数目庞大的晶体管和极为高级、聪明的技术,但这些设计从来未能接近峰值指令吞吐量。下一 节将讨论这一两难选择,并从更积极的ILP方案转向多核技术,但过去还出现了另外一个变化,放大了这一缺陷。设计人员不再尝试用ILP来隐藏更多的存储器延迟,而是直接利用晶体管来创建更大的缓存。Itanium 2和i7使用三级缓存,而Pentium 4使用了两级缓存,三级缓存为9MB和8MB,而Pentium 4的二级缓存为2 MB.不用说,构建更多的缓存要比设计20多级的Pentium 4流水线容易得多。

结语: 前路何方

在2000年初,人们对开发指令级并行的关注达到顶峰。Intel 当时要发布Itanium,它是一种高发射率的静态调度处理器,依靠一种类似于VLIW的方法,支持强劲的编译器。采用动态调度推测执行的MIPS、Alpha和IBM处理器正处于其第二代,已经变得更宽、更快。那一年还发布了Pentium 4,它采用推测调度,具有7个功能单元和1个深度超过20级的流水线,然而它的发展前景浮现出一些乌云。

诸如3.10节介绍的研究表明,要想进一步推动ILP是极为困难的,大约三到五年前的第一代推测处理器已经实现了峰值指令吞吐量,而持续指令执行速度的增长要慢得多。接下来的五年真相大白。人们发现Itanium是一个很好的浮点处理器,但在整数处理方面表现泛泛。Intel 仍在生成这一产品,但它的用户不是很多,时钟频率要落后于主流Intel处理器,微软不再支持其指令集。Intel Pentium 4实现了很好的性能,但在性能/瓦特(也就是能量利用)方面的效率很低,这种处理器的复杂程度也使它很难通过提高发射率来进一步提高性能。 这条通过开发ILP来进一步提高处理 器性能的20年之路已经走到尽头。人们普遍认为Pentium 4已经超越了回报递减点,积极、复杂的Netburst微体系结构被放弃。

到2005年, Intel和所有其他主要处理器制造商都调整了自己的方法,将重点放在多核心上。往往通过线程级并行而不是指令级并行来实现更高的性能,高效运用处理器的责任从硬件转移到软件和程序员身上。从流水线和指令级并行的早期发展以来(大约是25年之前),这是处理器体系结构的最重大变化。在同一时间,设计人员开始探索利用更多数据级并行来作为提高性能的另一方法。 SIMD扩展使桌面和服务器微处理器能够适当地提高图形功能及类似功能的性能。更重要的是,GPU追求更积极地使用SIMD,用大量数据级并行来实现应用程序的极大性能优势。对于科学应用程序,这些方法可以有效地替代在多核心中开发的更具一般性但效率较低的线程级并行。

许多研究人员预测ILP的应用会大幅减少,预计未来会是双发射超标量和更多核心的天下。但是,略高的发射率以及使用推测动态调度来处理意外事件(比如一级缓存缺失)的优势,使适度ILP成为多核心设计的主要构造模块。SMT的添加及其有效性(无论是在性能方面还是在能耗效率方面)都进一步巩固了适度发射、乱序、推测方法的地位。事实上,即使是在嵌入市场领域,最新的处理器(例如ARM Cortex-A9)已经引入了动态调度、推测和更宽的发射速率。

未来处理器几乎不会尝试大幅提高发射宽度。因为从硅利用率和功率效率的角度来看,它的效率太低了。在过去10年里, Power处理器对ILP的支持已经有了一定的改进,但所增加的大部分晶体管(从Power 4到Power7增加了差不多7倍)用来提高每个晶片的缓存和核心数目。甚至对SMT支持扩展的重视也多于ILP吞吐量的增加:从Power4到Power7的ILP结构由5发射变为6发射,从8个功能单元变为12 个(但最初的2个载入存储单元没有变化),而SMT支持从零变为4个线程/处理器。显然,即使是2011年最高级的ILP处理器(Power7),其重点也超越了指令级并行。

第一章:STL概论和版本简介

STL六大组件

  1. 容器:各种数据结构,如:vector、list、deque、set、map、主要用来存放数据。
  2. 算法:各种常见算法,如:sort、search、copy、erase
  3. 迭代器:扮演算法和容器中的中介。迭代器是一种将operator*operator->operator++operator--等指针相关操作予以重载的class template。所有的容器均有自己独特的迭代器,实现对容器内数据的访问
  4. 仿函数:行为类似函数,可作为算法的某种策略。仿函数是一种重载了operator()的class或class template。一般指针函数可视为狭义的仿函数。
  5. 配接器(adapters): 修饰容器、仿函数、迭代器接口。例如STL提供的queue和stack,虽然看似容器,但是只能算一种容器配接器,因为它们的底部完全借助deque,所有操作都由底层的deque供应。
  6. 配置器(allocators):负责空间配置和管理,配置器是一个实现了动态空间配置、空间管理、空间释放的class template.

各组件间的关系

由于STL已成为C++标准程序库的大脉系,因此:目前所有的C++编译器一定支持有一份STL。STL并非以二进制代码面貌出现,而是以源代码面貌供应。某些STL版本同时存在具扩展名和无扩展名的两份文件,例如Visual C++的版本同时具备<vectorr.h><vector>。某些STL版本只存在具扩展名的头文件,例如C++ Builder的RaugeWave版本只有<vector.h>。某些STL版本不仅有一线装配,还有二线装配,例如GNU C++的SGI版本不但有一线的<vector.h><vector>,还有二线的<stl_vector.h>

Container通过Allocator取得数据储存空间,Algorithm通过Iterator存取Container内容,Functor可以协助Algorithm完成不同的策略变化,Adapter可以修饰或套接Functor。

SGI STL头文件分布

  • C++标准规范下的C头文件:cstdio,csyflib,cstring,…
  • C++标准程序库中不属于STL范畴者:stream,string,…
  • STL标准头文件(无扩展名):vector,deque,list,map,…
  • C++标准定案前,HP所规范的STL头文件:vector.h,deque.h,list.h,…
  • SGI STL内部文件(STL真正实现与此):stl_vector.h,stl_deque.h,stl_algo.h,…

不同的编译器对C++语言的支持程度不尽相同。作为一个希望具备广泛移植能力的程序库,SGI STL准备了一个环境组态文件<stl_config.h>,其中定义了许多常量,标示某些组态的成立与否,所有STL头文件都会直接或间接包含这个组态文件,并以条件式写法,让预处理器根据各个常量决定取舍哪一段程序代码,例如:

<stl_config.h>文件起始处有一份常量定义说明,针对各家不同的编译器及可能的版本给予常量设定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
// Filename:    stl_config.h  
/*
* Copyright (c) 1996-1997
* Silicon Graphics Computer Systems, Inc.
*
* Permission to use, copy, modify, distribute and sell this software
* and its documentation for any purpose is hereby granted without fee,
* provided that the above copyright notice appear in all copies and
* that both that copyright notice and this permission notice appear
* in supporting documentation. Silicon Graphics makes no
* representations about the suitability of this software for any
* purpose. It is provided "as is" without express or implied warranty.
*/

/* NOTE: This is an internal header file, included by other STL headers.
* You should not attempt to use it directly.
*/

#ifndef __STL_CONFIG_H
#define __STL_CONFIG_H

// 本配置文件功能表:
// (1) 如果不编译器没有定义bool, true, false则定义
// (2) 如果编译器不支持drand48()函数则定义__STL_NO_DRAND48
// 注: drand48产生双精度的伪随机数, 因为采用了48bit计算, 故名drand48
// (3) 如果编译器不支持static members of template classes(模板类静态成员),
// 则定义__STL_STATIC_TEMPLATE_MEMBER_BUG
// (4) 如果编译器不支持'typename'关键字, 则将'typename'定义为空(null macro)
// (5) 如果编译器支持partial specialization of class templates(模板类偏特化),
// 则定义__STL_CLASS_PARTIAL_SPECIALIZATION
// 参考文献: http://msdn.microsoft.com/en-us/library/9w7t3kf1(v=VS.71).aspx
// (6) 如果编译器支持partial ordering of function templates(模板函数特化优先级),
// 则定义__STL_FUNCTION_TMPL_PARTIAL_ORDER
// 参考资料: http://msdn.microsoft.com/zh-cn/library/zaycz069.aspx
// (7) 如果编译器支持calling a function template by providing its template
// arguments explicitly(显式指定调用模板函数的模板参数)
// 则定义__STL_EXPLICIT_FUNCTION_TMPL_ARGS
// (8) 如果编译器支持template members of classes(类模板成员),
// 则定义__STL_MEMBER_TEMPLATES
// (9) 如果编译器不支持'explicit'关键字, 则将'explicit'定义为空(null macro)
// (10) 如果编译器不能根据前一个模板参数设定后面的默认模板参数,
// 则定义__STL_LIMITED_DEFAULT_TEMPLATES
// (11) 如果编译器处理模板函数的non-type模板参数类型推断有困难,
// 则定义__STL_NON_TYPE_TMPL_PARAM_BUG
// (12) 如果编译器不支持迭代器使用'->'操作符,
// 则定义__SGI_STL_NO_ARROW_OPERATOR
// (13) 如果编译器(在当前编译模式下)支持异常,
// 则定义__STL_USE_EXCEPTIONS
// (14) 如果我们将STL放进命名空间中,
// 则定义__STL_USE_NAMESPACES
// (15) 如果本STL在SGI的编译器上编译, 并且用户没有选择pthreads或者no threads,
// 则默认使用__STL_SGI_THREADS
// 注: POSIX thread 简称为pthread, Posix线程是一个POSIX标准线程.
// (16) 如果本STL在Win32平台的编译器上使用多线程模式编译,
// 则定义__STL_WIN32THREADS
// (17) 适当的定义命名空间相关的宏(__STD, __STL_BEGIN_NAMESPACE, 等)
// (18) 适当的定义异常相关的宏(__STL_TRY, __STL_UNWIND, 等)
// (19) 根据是否定义__STL_ASSERTIONS, 将__stl_assert定义为断言或者空(null macro)

#ifdef _PTHREADS
# define __STL_PTHREADS
#endif

// 如果编译器不提供本STL需要的一些功能,则定义__STL_NEED_XXX
# if defined(__sgi) && !defined(__GNUC__)
# if !defined(_BOOL)
# define __STL_NEED_BOOL
# endif
# if !defined(_TYPENAME_IS_KEYWORD)
# define __STL_NEED_TYPENAME
# endif
# ifdef _PARTIAL_SPECIALIZATION_OF_CLASS_TEMPLATES
# define __STL_CLASS_PARTIAL_SPECIALIZATION
# endif
# ifdef _MEMBER_TEMPLATES
# define __STL_MEMBER_TEMPLATES
# endif
# if !defined(_EXPLICIT_IS_KEYWORD)
# define __STL_NEED_EXPLICIT
# endif
# ifdef __EXCEPTIONS
# define __STL_USE_EXCEPTIONS
# endif
# if (_COMPILER_VERSION >= 721) && defined(_NAMESPACES)
# define __STL_USE_NAMESPACES
# endif
# if !defined(_NOTHREADS) && !defined(__STL_PTHREADS)
# define __STL_SGI_THREADS
# endif
# endif

# ifdef __GNUC__
# include <_G_config.h>
# if __GNUC__ < 2 || (__GNUC__ == 2 && __GNUC_MINOR__ < 8)
# define __STL_STATIC_TEMPLATE_MEMBER_BUG
# define __STL_NEED_TYPENAME
# define __STL_NEED_EXPLICIT
# else
# define __STL_CLASS_PARTIAL_SPECIALIZATION
# define __STL_FUNCTION_TMPL_PARTIAL_ORDER
# define __STL_EXPLICIT_FUNCTION_TMPL_ARGS
# define __STL_MEMBER_TEMPLATES
# endif
/* glibc pre 2.0 is very buggy. We have to disable thread for it.
It should be upgraded to glibc 2.0 or later. */
# if !defined(_NOTHREADS) && __GLIBC__ >= 2 && defined(_G_USING_THUNKS)
# define __STL_PTHREADS
# endif
# ifdef __EXCEPTIONS
# define __STL_USE_EXCEPTIONS
# endif
# endif

// Sun C++ compiler
# if defined(__SUNPRO_CC)
# define __STL_NEED_BOOL
# define __STL_NEED_TYPENAME
# define __STL_NEED_EXPLICIT
# define __STL_USE_EXCEPTIONS
# endif

# if defined(__COMO__)
# define __STL_MEMBER_TEMPLATES
# define __STL_CLASS_PARTIAL_SPECIALIZATION
# define __STL_USE_EXCEPTIONS
# define __STL_USE_NAMESPACES
# endif

// _MSC_VER 定义微软编译器的版本
// MS VC++ 10.0 _MSC_VER = 1600
// MS VC++ 9.0 _MSC_VER = 1500
// MS VC++ 8.0 _MSC_VER = 1400
// MS VC++ 7.1 _MSC_VER = 1310
// MS VC++ 7.0 _MSC_VER = 1300
// MS VC++ 6.0 _MSC_VER = 1200
// MS VC++ 5.0 _MSC_VER = 1100
# if defined(_MSC_VER)
# if _MSC_VER > 1000
# include <yvals.h>
# else
# define __STL_NEED_BOOL
# endif
# define __STL_NO_DRAND48
# define __STL_NEED_TYPENAME
# if _MSC_VER < 1100
# define __STL_NEED_EXPLICIT
# endif
# define __STL_NON_TYPE_TMPL_PARAM_BUG
# define __SGI_STL_NO_ARROW_OPERATOR
# ifdef _CPPUNWIND
# define __STL_USE_EXCEPTIONS
# endif
# ifdef _MT
# define __STL_WIN32THREADS
# endif
# endif

# if defined(__BORLANDC__)
# define __STL_NO_DRAND48
# define __STL_NEED_TYPENAME
# define __STL_LIMITED_DEFAULT_TEMPLATES
# define __SGI_STL_NO_ARROW_OPERATOR
# define __STL_NON_TYPE_TMPL_PARAM_BUG
# ifdef _CPPUNWIND
# define __STL_USE_EXCEPTIONS
# endif
# ifdef __MT__
# define __STL_WIN32THREADS
# endif
# endif


# if defined(__STL_NEED_BOOL)
typedef int bool;
# define true 1
# define false 0
# endif

# ifdef __STL_NEED_TYPENAME
# define typename
# endif

# ifdef __STL_NEED_EXPLICIT
# define explicit
# endif

# ifdef __STL_EXPLICIT_FUNCTION_TMPL_ARGS
# define __STL_NULL_TMPL_ARGS <>
# else
# define __STL_NULL_TMPL_ARGS
# endif

# ifdef __STL_CLASS_PARTIAL_SPECIALIZATION
# define __STL_TEMPLATE_NULL template<>
# else
# define __STL_TEMPLATE_NULL
# endif

// __STL_NO_NAMESPACES is a hook so that users can disable namespaces
// without having to edit library headers.
# if defined(__STL_USE_NAMESPACES) && !defined(__STL_NO_NAMESPACES)
# define __STD std
# define __STL_BEGIN_NAMESPACE namespace std {
# define __STL_END_NAMESPACE }
# define __STL_USE_NAMESPACE_FOR_RELOPS
# define __STL_BEGIN_RELOPS_NAMESPACE namespace std {
# define __STL_END_RELOPS_NAMESPACE }
# define __STD_RELOPS std
# else
# define __STD
# define __STL_BEGIN_NAMESPACE
# define __STL_END_NAMESPACE
# undef __STL_USE_NAMESPACE_FOR_RELOPS
# define __STL_BEGIN_RELOPS_NAMESPACE
# define __STL_END_RELOPS_NAMESPACE
# define __STD_RELOPS
# endif

# ifdef __STL_USE_EXCEPTIONS
# define __STL_TRY try
# define __STL_CATCH_ALL catch(...)
# define __STL_RETHROW throw
# define __STL_NOTHROW throw()
# define __STL_UNWIND(action) catch(...) { action; throw; }
# else
# define __STL_TRY
# define __STL_CATCH_ALL if (false)
# define __STL_RETHROW
# define __STL_NOTHROW
# define __STL_UNWIND(action)
# endif

#ifdef __STL_ASSERTIONS
# include <stdio.h>
# define __stl_assert(expr) \
if (!(expr)) { fprintf(stderr, "%s:%d STL assertion failure: %s\n", \
__FILE__, __LINE__, # expr); abort(); }
#else
# define __stl_assert(expr)
#endif

#endif /* __STL_CONFIG_H */

// Local Variables:
// mode:C++
// End:

可能困惑的C++语法

stl_config.h中的各种组态(configuration)

组态3__STL_STATIC_TEMPLATE_MEMBER_BUG。如果编译器无法处理static member of template classes(模板类静态成员)就定义
。即对于模板类中,模板类型不同时的静态变量不同。

1
2
3
4
5
6
7
8
template <typename T>
class test{
public:
static int _data;
}

int test<int>::_data=1;
int test<char>::_data=2;

组态5__STL_CLASS_PARTIAL_SPECIALIZATION。如果编译器支持 partial specialization of class templates(模板类偏特化)就定义。在模板类一般化设计之外(全特化),针对某些template做特殊设计。“所谓的partial specialization的另一个意思是提供另一份template定义式,而其本身仍是templatized”。全特化就是所有的模板都为具体的类。T*特化允许用指针类型匹配的模式(也只能匹配指针类型)。const T*特化允许使用指向const的指针类型匹配(也只能匹配指向const的指针)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//一般化设计,非特化情况均使用这个
template <class I,class O>
struct test{
test() { cout << "I, O" <<endl; }
};

//特殊化设计1(偏特化1)
template <class T>
struct test <T* ,T*> {
test() { cout << "T* ,T*" << endl; }
};

//特殊化设计2(偏特化2)
template <class T>
struct test <const T* ,T*> {
test() { cout << "const T* ,T*" << endl; }
};
//测试
int main() {
test<int, char> obj1; //I, O
test<int*, int*> obj2; //T*, T*
test<const int*, int*> obj3; //const T*, T*
}

组态6__STL_FUNCTION_TMPL_PARTIAL_ORDER。如果编译器支持partial ordering of function templates或者说partial specialization of function templates就定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T,class Alloc=alloc>
class vec {
public:
void swap(vec<T, Alloc>&) { cout << "swap1()" << endl; }
};

#ifdef __STL_FUNCTION_TMPL_PARTIAL_ORDER
template <class T, class Alloc = alloc>
inline void swap(vec<T, Alloc>& a, vec<T, Alloc>& b) { a.swap(b); }
#endif

int main() {
vec<int> a, b;
swap(a, b);
}

组态8__STL_MEMBER_TEMPLATES。如果编译器支持template members of classes(模板类内嵌套模板) 就定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class vec {
public:
typedef T value_type;
typedef value_type* iterator;

template<class I>
void insert(iterator position, I first, I last) {
cout << "insert()" << endl;
}
};

int main() {
int ia[5] = { 0,1,2,3,4 };
vec<int> a;
vec<int>::iterator ite;
a.insert(ite, ia, ia + 5);
}

组态10__STL_LIMITED_DEFAULT_TEMPLATES。如果编译器支持一个template参数可以根据前一个template的参数设置就定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class T,class Alloc=alloc,size_t BufSiz=0>
class deque {
public:
deque() { cout << deque() << endl; }
};

template <class T,class Sequence=deque<T>>
class stack {
public:
stack() { cout << "Stack" << endl; }
private:
Sequence c;
};

int main() {
stack<int> x;
}

组态11__STL_NON_TYPE_TMPL_PARAM_BUG。测试类模板是否使用非类型模板参数(non-type template parameters) 。当以类型(type)作为模板参数的时候,代码中未决定的是类型;

当以一般的数字(non-type)作为模板参数的时候,代码中待定的内容便是某些数值。使用者这种模板必须要显示指定数值,模板才能实例化。通常它们只能是常数整数(constant integral values )包括枚举,或者是指向外部链接的指针。不能把float,class-type类型的对象,内部链接(internal linkage )对象,作为非类型模板参数。

1
2
3
4
5
template <class T,class Alloc=alloc,size_t BufSiz=0>  //BufSiz即为非类型模板。
class deque {
public:
deque() { cout << deque() << endl; }
};

__STL_NULL_TMPL_ARGS。直接理解为若允许bound friend template(约束模板友元)则定义为 <> ,否则为空。

1
friend bool ooperator== __STL_NULL_TMPL_ARGS(const stack&,const stack&);

展开后变成
1
friend bool ooperator== <>(const stack&,const stack&);

bound friend template(约束模板友元)即友元类型取决于类被初始化时的类型,但程序必须在类外为友元提供模板定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <class T,class Sequence=deque<T>>
class stack {
//最标准的写法
friend bool operator== <T>(const stack<T>&, const stack<T>&);
friend bool operator< <T>(const stack<T>&, const stack<T>&);

//参数列表中的<T>实际上可以忽略
friend bool operator== <T>(const stack&, const stack&);
friend bool operator< <T>(const stack&, const stack&);

//当可以从参数中推断出模板类型时,可以改用<>
friend bool operator== <>(const stack&, const stack&);
friend bool operator< <>(const stack&, const stack&);

//下面用法是错误的!
//friend bool operator== (const stack&, const stack&);
//friend bool operator< (const stack&, const stack&);
public:
stack() { cout << "Stack" << endl; }
private:
Sequence c;
};
//定义部分懒得写了,但必须要写!

__STL_TEMPLATE_NULLtemplate <>显示的模板特化 。

1
2
3
4
5
#ifdef __STL_CLASS_PARTIAL_SPECIALIZATION
# define __STL_TEMPLATE_NULL template<>
#else
# define __STL_TEMPLATE_NULL
#endif

模板特化(class template explicit specialization)即指定一种或多种模板形参的实际值或实际类型,作为特殊情况。(与模板类型偏特化不同!)

1
2
3
4
5
6
template<class type> struct __type_traits{ ...};//非特化情况均使用这个
__STL_TEMPLATE_NULL struct __type_traits<char> { ... };//特化char情况

template<class Key> struct hash { };//非特化情况均使用这个
__STL_TEMPLATE_NULL struct hash<char> { ... };//特化char情况
__STL_TEMPLATE_NULL struct hash<unsgned char> { ... };//特化unsigned char情况

经展开后:

1
2
3
4
5
6
template<class type> struct __type_traits{ ...};//非特化情况均使用这个
template<> struct __type_traits<char> { ... };//特化char情况

template<class Key> struct hash { };//非特化情况均使用这个
template<> struct hash<char> { ... };//特化char情况
template<> struct hash<unsgned char> { ... };//特化unsigned char情况

临时对象的产生与应用

刻意制造一些临时对象,在类型名之后直接加一对(),并指定初值,使用时相当于调用该类的临时对象的()操作。常用于仿函数与算法的搭配上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T>
class print {
public:
void operator()(const T& elem) {
cout << elem << " ";
}
};
template<typename T>
class plus{
public:
T operator()(const T& x, const T& y)const { return x + y; }
};

int main() {
vector<int> ai({ 1,2,3,4,5 });
for_each(ai.begin(), ai.end(), print<int>());
int a = 5, b = 3;
print<int>()(plus<int>()(a, b));
}

最后一行便是产生“function template具现体”print<int>的一个临时对象。这个对象将被传入进for_each()中。

静态常量整数成员在class内部直接初始化

如果class内含const static integral data member,那么根据C++规格,我们可以在class之内直接给予初值。所谓integral泛指所有的整数型别(包括浮点数),不单只是指int,下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<iostream>
using namespace std;

template <typename T>
class testClass
{
public:
static const double _datai=1.2;
static const long _datal=3L;
static const char _datac='c';
};

int main()
{
cout<<testClass<int>::_datai<<endl;
cout<<testClass<int>::_datal<<endl;
cout<<testClass<int>::_datac<<endl;
}

一般,非const的static数据成员是不能在类的内部初始化,但是,我们可以为静态成员提供const整数类型的类内初始值。

例如,下面的情况会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<iostream>
using namespace std;

template <typename T>
class testClass
{
public:
static double _datai=1.2;
static const long _datal=3L;
static const char _datac='c';
};

int main()
{
cout<<testClass<int>::_datai<<endl;
cout<<testClass<int>::_datal<<endl;
cout<<testClass<int>::_datac<<endl;
}

如果加了const 或者constexpr之后,就可以在类内进行初始化了。

对于static成员,如果在类的内部提供了一个初值,则成员在类外的定义不能再指定一个初始值了。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<iostream>
using namespace std;

template <typename T>
class testClass
{
public:
static const double _datai=1.2;
static const long _datal=3L;
static const char _datac='c';
};


template <typename T>
const double testClass<T>::_datai=8.8;

int main()
{
cout<<testClass<int>::_datai<<endl;
cout<<testClass<int>::_datal<<endl;
cout<<testClass<int>::_datac<<endl;
}

下面的情况是允许的,直接在定义的时候提供初始值或者在类内提供初始值之后只在类外定义但不提供初始值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<iostream>
using namespace std;

template <typename T>
class testClass
{
public:
static const double _datai;
static const long _datal=3L;
static const char _datac='c';
};


template <typename T>
const double testClass<T>::_datai=8.8;

int main()
{
cout<<testClass<int>::_datai<<endl;
cout<<testClass<int>::_datal<<endl;
cout<<testClass<int>::_datac<<endl;
}

或者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include<iostream>
using namespace std;

template <typename T>
class testClass
{
public:
static const double _datai=1.2;
static const long _datal=3L;
static const char _datac='c';
};

template <typename T>
const double testClass<T>::_datai;

int main()
{
cout<<testClass<int>::_datai<<endl;
cout<<testClass<int>::_datal<<endl;
cout<<testClass<int>::_datac<<endl;
}

increment/decrement/dereference操作符

increment/dereference操作符在迭代器的实现上占有非常重要的地位,因为任何一个迭代器都必须实现出前进(increment,operator++)和取值(dereference,operator*)功能,前者还分为前置式(prefix)和后置式(Postfix)两种。有写迭代器具备双向移动功能,那么就必须再提供decrement操作符(也分前置式和后置式),下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include<iostream>
using namespace std;

class INT
{
friend ostream& operator<<(ostream& os,const INT& i);
public:
INT(int i):m_i(i){}
INT& operator++()
{
++(this->m_i);
return *this;
}

const INT operator++(int)
{
INT temp=*this;
++(*this);
return temp;
}

INT& operator--()
{
--(this->m_i);
return *this;
}

const INT operator--(int)
{
INT temp=*this;
--(*this);
return temp;
}

int& operator*() const
{
return (int&)m_i;
}
private:
int m_i;

};

ostream& operator<<(ostream& os,const INT &i)
{
os<<'['<<i.m_i<<']';
return os;
}

int main()
{
INT I(5);
cout<<I++;
cout<<++I;
cout<<I--;
cout<<--I;
cout<<*I;
}

前闭后开区间表示法

任何一个STL算法,都需要获得由一对迭代器(泛型指针)所标示的区间,用以表示操作范围,这一对迭代器所标示的是个所谓的前闭后开区间,以[first,last)表示,也就是说,整个实际范围从first开始,直到last-1.迭代器last所指的是“最后一个元素的下一位置”。这种off by one(偏移一格,或说pass the end)的标示法,带来了很多方便,例如下面两个STL算法的循环设计,就显得干净利落:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class InputIterator,class T>
InputIterator find(InputIterator first,InputIterator last,const T&value)
{
while(first!=last&&*first!=value) ++first;
return first; //返回迭代器
}

template <class InputIterator,class Function>
Function for_each(InputIterator first,InputIterator last,Function f)
{
for(;first!=last;++first)
f(*first);
return f;
}

function call 操作符

函数调用操作(C++语法中的左右小括号)也可以被重载。

许多STL算法都提供了两个版本,一个用于一般情况(例如排序时以递增方式排列),一个用于特殊情况(例如排序时由使用者指定以何种特殊关系进行排列),像这种情况,需要用户指定某个条件或某个策略,而条件或策略的背后由一整组操作构成,便需要某种特殊的东西来代表这“一整组操作”。

代表“一整组操作“的,当然是函数,过去C语言时代,欲将函数当做参数传递,唯有通过函数指针才能达成,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include<iostream>
#include<cstdlib>
using namespace std;

int fcmp(const void* elem1,const void* elem2);

int main()
{
int ia[10]={32,92,67,58,10,4,25,52,59,54};
for(int i=0;i<10;i++)
cout<<ia[i]<<" ";
cout<<endl;
qsort(ia,sizeof(ia)/sizeof(int),sizeof(int),fcmp);

for(int i=0;i<10;i++)
cout<<ia[i]<<" ";
cout<<endl;
}

int fcmp(const void* elem1,const void* elem2)
{
const int *i1=(const int*)elem1;
const int *i2=(const int*)elem2;
if(*i1<*i2)
return -1;
else if(*i1==*i2)
return 0;
else if(*i1>*i2)
return 1;
}

但是函数指针有缺点,最重要的是它无法持有自己的状态(所谓局部状态,local states),也无法达到组件技术中的可适配性(adaptability)——也就是无法再将某些修饰条件加诸于其上面而改变其状态。

为此,STL算法的特殊版本所接受的所谓“条件”或“策略”或“一整组操作”,都以仿函数形式呈现。所谓仿函数(functor)就是使用起来像函数一样的东西。如果你针对么某个class进行operator()重载,它就是一个仿函数,至于要成为一个可配接的仿函数,还需要做一些额外的努力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include<iostream>
//注意,不能使用using namespace std 不然plus和minus会有歧义
using std::cout;
using std::endl;

template <class T>
struct plus
{
T operator()(const T& x,const T& y) const {return x+y;}
};

template <class T>
struct minus
{
T operator()(const T& x,const T& y)const {return x-y;}
};

int main()
{
plus<int> plusObj;
minus<int> minusObj;
cout<<plusObj(3,5)<<endl;
cout<<minusObj(3,5)<<endl;
//注意下面的调用,不要忘记调用默认构造函数的小括号以及函数对象调用参数的小括号
  //以下直接产生仿函数的临时对象(第一对小括号),并调用之(第二对小括号)
cout<<plus<int>()(43,50)<<endl;
cout<<minus<int>()(43,50)<<endl;
}

空间配置器

以STL的运用角度而言,空间配置器是最不需要介绍的东西,它总是隐藏在一切组件(更具体地说是指容器,container)的背后,默默工作,默默付出。但若以STL的实现角度而言,第一个需要介绍的就是空间配置器,因为整个STL的操作对象(所有的数据)都存放在容器之内,而容器一定需要配置空间以置放资料。

为什么不说allocator是内存配置器而说它是空间配置器呢?因为空间不一定是内存,空间也可以是磁盘或其它辅助存储介质。是的,你可以一个allocator,直接向硬盘取空间,以下介绍的是SGI STL提供的配置器,配置的对象是内存。

空间配置器的标准接口

根据STL的规范,以下是allocator的必要接口:

  • allocator::value_type
  • allocator::pointer
  • allocator::const_pointer
  • allocator::reference
  • allocator::const_reference
  • allocator::size_type
  • allocator::difference_type
  • allocator::rebind:一个嵌套的class template,class rebind<U>拥有唯一的成员other,那是一个typedef,代表allocator<U>
  • allocator::allocator():default constuctor
  • allocator::allocator(const allocator&):copy constructor
  • template<class U>allocator::allocator(const allocator<U>&):泛化的copy constructor
  • allocator::~allocator():destructor
  • pointer allocator::address(reference x)const:返回某个对象的地址,算式a.address(x)等同于&x
  • const_pointer allocator::address(const_reference x)const:返回某个const对象的地址,算式a.address(x)等同于&x
  • pointer allocator::allocate(size_type n,const void* =0):配置空间,足以存储n个T对象,第二参数是个提示,实际上可能会利用它来增进区域性,或完全忽略之
  • void allocator::deallocate(pointer p,size_type n):归还先前配置的空间
  • size_type allocator::max_size() const:返回可成功分配的最大量
  • void allocator::construct(pointer p,const T& x):等同于new((void*)p) T(x)
  • void allocator::destroy(pointer p):等同于p->~T()

设计一个简单的空间配置器, JJ::allocator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#include "stdafx.h"
#include <new>
#include <cstddef>
#include <cstdlib>
#include <climits>
#include <iostream>
#include <vector>
using namespace std;

namespace JJ
{
template <typename T>
inline T* _allocate(ptrdiff_t size, T*)
{
set_new_handler(0);
T* tmp = (T*)(::operator new((size_t)(size *sizeof(T))));
if (tmp == 0)
{
cerr << "out of memory!" << endl;
exit(1);
}
return tmp;
}


template <typename T>
inline void _deallocate(T* buffer)
{
::operator delete(buffer);
}


template <typename T1,typename T2>
inline void _construct(T1* p,const T2& value)
{
new(p) T1(value);
}


template <typename T>
inline void _destroy(T* ptr)
{
ptr->T();
}


template <typename T>
class allocator
{
public:
typedef T value_type;
typedef T* pointer;
typedef const T* const_pointer;
typedef T& reference;
typedef const T& const_reference;
typedef size_t size_type;
typedef ptrdiff_t difference_type;


template<typename U>
struct rebind
{
typedef allocator<U> other;
};


pointer allocate(size_type n, const void *hint=0)
{
return _allocate((difference_type)n, (pointer)0);
}


void deallocate(pointer p, size_type n)
{
_deallocate(p);
}


void construct(pointer p, const T& value)
{
_construct(p,value);
}


void destroy(pointer p)
{
_destroy(p);
}


pointer address(reference x)
{
return (pointer)&x;
}


const_pointer const_address(const_reference x)
{
return (const_pointer)&x;
}


size_type max_size()const
{
return size_type(UINT_MAX/sizeof(T));
}
};
}


int main()
{
int ia[5] = {0,1,2,3,4};
unsigned int i;
vector<int,JJ::allocator<int>> ivv(ia,ia+5);
vector<int,std::allocator<int> > iv(ia,ia+5);
for(i = 0; i < iv.size(); ++i)
std::cout << iv[i] << " ";
cout << endl;

system("pause");
return 0;
}

具备次配置力的 SGI 空间配置器

SGI STL的配置器与众不同,它与标准规范不同,其名称是alloc而非allocator。如果要在程序中明白采用SGI配置器,那么应该这样写:

1
vector<int, std::alloc> iv; //gcc编译器

配置器名字为alloc,不接受任何参数。标准配置器的名字是allocator,而且可以接受参数。比如VC中写法:

1
vector<int, std::allocator<int> > iv; //VC编译器

SGI STL的每一个容器都已经指定了缺省配置其alloc。我们很少需要自己去指定空间配置器。比如vector容器的声明:

1
2
3
4
template <class T, class Alloc = alloc>
class vector {
//...
}

SGI标准的空间配置器allocator

其实SGI也定义了一个符合部分标准,名为allocator的配置器,但是它自己不使用,也不建议我们使用,主要原因是效率不佳。它只是把C++的操作符::operator new::operator delete做了一层简单的封装而已。下面仅仅贴出代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#ifndef DEFALLOC_H
#define DEFALLOC_H

#include <new.h>
#include <stddef.h>
#include <stdlib.h>
#include <limits.h>
#include <iostream.h>
#include <algobase.h>

//仅仅是简单的封装了operator new
template <class T>
inline T* allocate(ptrdiff_t size, T*) {
set_new_handler(0);
T* tmp = (T*)(::operator new((size_t)(size * sizeof(T))));
if (tmp == 0) {
cerr << "out of memory" << endl;
exit(1);
}
return tmp;
}

//仅仅是简单的封装了operator::delete
template <class T>
inline void deallocate(T* buffer) {
::operator delete(buffer);
}

template <class T>
class allocator {
public:
typedef T value_type;
typedef T* pointer;
typedef const T* const_pointer;
typedef T& reference;
typedef const T& const_reference;
typedef size_t size_type;
typedef ptrdiff_t difference_type;

pointer allocate(size_type n) {
return ::allocate((difference_type)n, (pointer)0);
}
void deallocate(pointer p) { ::deallocate(p); }
pointer address(reference x) { return (pointer)&x; }
const_pointer const_address(const_reference x) {
return (const_pointer)&x;
}
size_type init_page_size() {
return max(size_type(1), size_type(4096/sizeof(T)));
}
size_type max_size() const {
return max(size_type(1), size_type(UINT_MAX/sizeof(T)));
}
};

// 特化版本
class allocator<void> {
public:
typedef void* pointer;
};

#endif

SGI特殊的空间配置器std::alloc

一般而言,我们所习惯的C++内存配置器操作和释放操作时这样的:

1
2
3
class FOO { ...};
FOO* pf=new FOO; //配置内存,然后构造对象
delete pf; //将对象析构,然后释放内存

这其中的new算式内含两个阶段操作:

  1. 调用::operator new配置内存
  2. 调用FOO::FOO()构造对象内容

delete算式也内含两个阶段操作:

  1. 调用FOO::~FOO()对对象析构
  2. 调用::operator delete释放内存

为了精密分工,SGI allocator将两个阶段分开:

内存配置操作由alloc:allocate负责,内存释放由alloc:deallocate负责;对象构造操作由::contructor()负责,对象析构由::destroy()负责。

STL标准告诉我们,配置器定义在头文件<memory>中,它里面又包括两个文件:

1
2
#include <stl_alloc.h>		// 负责内存空间的配置和器释放
#include <stl_construct.h> // 负责对象的构造和析构

内存空间的配置/释放与对象内容的构造/析构,分别落在这两个文件身上。其中<stl_construct.h>定义了两个基本函数:构造用的construct()和析构用的destroy()

下图显示了其结构:

构造函数析构的基本工具:construct()和destroy()

下面是<stl_constuct.h>的部分内容:

函数construct()使用了定位new操作符,其源代码:

1
2
3
4
template <class T1, class T2>
inline void construct(T1* p, const T2& value) {
new (p) T1(value); // 定为new操作符placement new; 在指针p所指处构造对象
}

函数destroy则有两个版本。

第一个版本较简单,接受一个指针作为参数,直接调用对象的析构函数即可,其源代码:

1
2
3
4
template <class T>
inline void destroy(T* pointer) {
pointer->~T(); // 调用析构函数
}

第二个版本,其参数接受两个迭代器,将两个迭代器所指范围内的所有对象析构掉。而且,它采用了一种特别的技术:依据元素的型别,判断其是否有trivial destructor(无用的析构函数)进行不同的处理。这也是为了效率考虑。因为如果每个对象的析构函数都是trivial的,那么调用这些毫无作用的析构函数会对效率造成影响。

下面看其源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 以下是 destroy() 第二版本,接受两个迭代器。它会设法找出元素的数值型別,
// 进而利用 __type_traits<> 求取最适当措施。
template <class ForwardIterator>
inline void destroy(ForwardIterator first, ForwardIterator last) {
__destroy(first, last, value_type(first));
}


// 判断元素的数值型別(value type)是否有 trivial destructor,分别调用上面的函数进行不同的处理
template <class ForwardIterator, class T>
inline void __destroy(ForwardIterator first, ForwardIterator last, T*) {
typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor;
__destroy_aux(first, last, trivial_destructor());
}

// 如果元素的数值型別(value type)有 trivial destructor…
template <class ForwardIterator>
inline void __destroy_aux(ForwardIterator, ForwardIterator, __true_type) {}//不调用析构函数

// 如果元素的数值型別(value type)有 non-trivial destructor…
template <class ForwardIterator>
inline void
__destroy_aux(ForwardIterator first, ForwardIterator last, __false_type) {
for ( ; first < last; ++first)
destroy(&*first);//调用析构函数
}

第二版本还针对迭代器为char*wchar_t*定义了特化版本:
1
2
inline void destroy(char*, char*) {}
inline void destroy(wchar_t*, wchar_t*) {}

图二显示了这两个函数的结构和功能。他们被包含在头文件stl_construct.h中。

这两个作为构造、析构之用的函数被设计为全局函数,符合STL的规范。此外,STL还规定配置器必须拥有名为construct()destroy()的两个成员函数。

上述construct()接收一个指针p和一个初值value,该函数的用途就是将初值设定到指针所指的空间上。C++的placement new运算可用来完成这一任务。

destroy()有两个版本,第一版本接受一个指针,准备将该指针所指之物析构掉。这很简单,直接调用该对象的析构函数即可。第二版本接受first和last迭代器,准备将[first,last)范围内的所以对象析构掉。我们不知道这个范围有多大,万一很大,而每个对象的析构函数都无关痛痒(所谓的trivial destructor),那么一次次调用这些无关痛痒的析构函数,对效率是一种伤害。因此,这里先利用value_type()获得迭代器所指对象的型别,再利用_type_traits<T>判断该型别的析构函数是否无关痛痒。若是(_true_type),则什么也不做就结束;若否,(_false_type),这才以循环方式巡访整个范围,并在循环中每经历一个对象就调用一个版本的destroy()

空间的配置和释放,std::alloc

对象构造前的空间配置和对象析构后的空间释放,由<stl_alloc.h>负责,SGI对此的设计哲学如下:

  • 向system heap要求空间
  • 考虑多线程状态
  • 考虑内存不足时的应变措施
  • 考虑过多“小型区块”可能造成的内存碎片问题

C++的内存配置基本操作是::operator new(),内存释放基本操作是::operator delete()。这两个全局函数相当于C的malloc()free()函数。是的,正是如此,SGI正是以malloc()free()完成内存的配置和释放。

考虑到小型区块所可能造成的内存碎片问题。SGI设计了双层配置器,第一级配置器直接使用malloc()free(),第二级配置器则视情况采用不同的策略;当配置区块超过128bytes时,视之为“足够大”,便调用第一级配置器;当配置区块小于128bytes时,视之为“过小”,为了降低额外负担,便采用复杂的memory pool整理方式,而不再求助于第一级配置器。整个设计究竟是开放第一级配置器或是同时开放第二级配置器,取决于_USE_MALLOC是否被定义:

1
2
3
4
5
6
7
8
9
# ifdef __USE_MALLOC 
...
typedef __malloc_alloc_template<0> malloc_alloc;//令 alloc为第一级配置器
typedef malloc_alloc alloc;
# else
...
//令 alloc 为第二级配置器
typedef __default_alloc_template<__NODE_ALLOCATOR_THREADS, 0> alloc;
#endif /* ! __USE_MALLOC */

其中__malloc_alloc_template就是第一级配置器,__default_alloc_template就是第二级配置器。

无论alloc被定义为第一级或者是第二级配置器,SGI还为它包装一个接口如下,使配置器的接口能够符合STL规格:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T, class Alloc>
class simple_alloc {

public:
static T *allocate(size_t n)
{ return 0 == n? 0 : (T*) Alloc::allocate(n * sizeof (T)); }
static T *allocate(void)
{ return (T*) Alloc::allocate(sizeof (T)); }
static void deallocate(T *p, size_t n)
{ if (0 != n) Alloc::deallocate(p, n * sizeof (T)); }
static void deallocate(T *p)
{ Alloc::deallocate(p, sizeof (T)); }
};

其内部四个成员函数其实都是弹单纯的转调用,调用传递给配置器(可能是第一级也可能是第二级)的成员函数。这个接口使配置器的配置单位从bytes转为个别元素的大小(sizeof(T))。SGI STL容器全都用这个simple_alloc接口。
1
2
3
4
5
6
7
8
9
10
template <class T, class Alloc = alloc> // 缺省使用alloc为配置器
class vector {
protected:
typedef simple_alloc<value_type, Alloc> data_allocator;

void deallocate() {
if (...)
data_allocator::deallocate(start, end_of_storage - start);
}
};

一、二级配置器的关系如下:

第一级和第二级配置器的包装接口和运用方式如下:

第一级配置器__malloc_alloc_template剖析

首先我们观察第一级配置器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#if 0 
# include <new>
# define __THROW_BAD_ALLOC throw bad_alloc
#elif !defined(__THROW_BAD_ALLOC)
# include <iostream.h>
# define __THROW_BAD_ALLOC cerr << "out of memory" << endl; exit(1)
#endif

// malloc-based allocator. 通常比稍后介绍的 default alloc 速度慢,
//一般而言是 thread-safe,并且对于空间的运用比较高效(efficient)。
//以下是第一级配置器。
//注意,无「template 型别参数」。至于「非型别参数」inst,完全没派上用场。
template <int inst>
class __malloc_alloc_template {

private:
//以下都是函式指标,所代表的函式将用来处理内存不足的情况。
// oom : out of memory.
static void *oom_malloc(size_t);
static void *oom_realloc(void *, size_t);
static void (* __malloc_alloc_oom_handler)();

public:

static void * allocate(size_t n)
{
void *result =malloc(n);//第一级配置器直接使用 malloc()
// 以下,无法满足需求时,改用 oom_malloc()
if (0 == result) result = oom_malloc(n);
return result;
}
static void deallocate(void *p, size_t /* n */)
{
free(p); //第一级配置器直接使用 free()
}

static void * reallocate(void *p, size_t /* old_sz */, size_t new_sz)
{
void * result =realloc(p, new_sz);//第一级配置器直接使用 rea
// 以下,无法满足需求时,改用 oom_realloc()
if (0 == result) result = oom_realloc(p, new_sz);
return result;
}

//以下模拟 C++的 set_new_handler(). 换句话说,你可以透过它,
//指定你自己的 out-of-memory handler
static void (* set_malloc_handler(void (*f)()))()
{
void (* old)() = __malloc_alloc_oom_handler;
__malloc_alloc_oom_handler = f;
return(old);
}
};

// malloc_alloc out-of-memory handling
//初值为 0。有待客端设定。
template <int inst>
void (* __malloc_alloc_template<inst>::__malloc_alloc_oom_handler)() = 0;

template <int inst>
void * __malloc_alloc_template<inst>::oom_malloc(size_t n)
{
void (* my_malloc_handler)();
void *result;

for (;;) {
//不断尝试释放、配置、再释放、再配置…
my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == my_malloc_handler) { __THROW_BAD_ALLOC; }
(*my_malloc_handler)();//呼叫处理例程,企图释放内存。
result = malloc(n); //再次尝试配置内存。
if (result) return(result);
}
}

template <int inst>
void * __malloc_alloc_template<inst>::oom_realloc(void *p, size_t n)
{
void (* my_malloc_handler)();
void *result;

for (;;) { //不断尝试释放、配置、再释放、再配置…
my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == my_malloc_handler) { __THROW_BAD_ALLOC; }
(*my_malloc_handler)();//呼叫处理例程,企图释放内存。
result = realloc(p, n);//再次尝试配置内存。
if (result) return(result);
}
}

//注意,以下直接将参数 inst指定为 0。
typedef __malloc_alloc_template<0> malloc_alloc;

第一级配置器直接使用malloc()free()realloc()等C函数执行实际的内存配置、释放、重配置操作,并实现出类似C++ new handler机制。它有独特的out-of-memory内存处理机制:在抛出std::bad_alloc异常之前,调用内存不足处理例程尝试释放空间,如果用户没有定义相应的内存不足处理例程,那么还是会抛出异常。

所谓C++ new handler机制是,你可以要求系统在内存配置要求无法被满足时,调用一个你所指定的函数。换句话说,一旦::operator new无法完成任务,在丢出std::bad_alloc异常状态之前,会先调用由客户端指定的处理例程,该处理例程通常即被称为new-handler。new-handler解决内存不足的做法有特定的模式。

请注意,SGI第一级配置器的allocate()realloc()都是在调用malloc()realloc()不成功后,改调用oom_malloc()oom_realloc()。后两者都有内循环,不断调用“内存不足处理例程”,期望在某次调用之后,获得足够的内存而圆满完成任务。但如果“内存不足处理例程”并未被客户端设定,oom_malloc()oom_realloc()便老实不客气地调用__THROW_BAD_ALLOC,丢出bad_alloc异常信息,或利用exit(1)硬生生中止程序。

记住,设计“内存不足处理例程”是客端的责任,设定“内存不足处理例程”也是客端的责任。

第二级配置器__default_alloc_template剖析

相比第一级配置器,第二级配置器多了一些机制,避免小额区块造成内存的碎片。不仅仅是碎片的问题,配置时的额外负担也是一个大问题。因为区块越小,额外负担所占的比例就越大。

额外负担是指动态分配内存块的时候,位于其头部的额外信息,包括记录内存块大小的信息以及内存保护区(判断是否越界)。要想了解详细信息,请参考MSVC或者其他malloc实现。

SGI STL第二级配置器具体实现思想如下:

  • 如果要分配的区块大于128bytes,则移交给第一级配置器处理。
  • 如果要分配的区块小于128bytes,则以内存池管理(memory pool),又称之次层配置(sub-allocation):每次配置一大块内存,并维护对应的自由链表(free-list)。下次若有相同大小的内存需求,则直接从free-list中取。如果有小额区块被释放,则由配置器回收到free-list中——是的,别忘了,配置器除了负责配置,也负责回收。

在第二级配置器中,小额区块内存需求大小都被上调至8的倍数,比如需要分配的大小是30bytes,就自动调整为32bytes。系统中总共维护16个free-lists,各自管理大小为8,16,…,128bytes的小额区块。

为了维护链表,需要额外的指针,为了避免造成另外一种额外的负担,这里采用了一种技术:用union表示链表节点结构:

1
2
3
4
union obj {
union obj * free_list_link;//指向下一个节点
char client_data[1]; /* The client sees this. */
};

union能够实现一物二用的效果,当节点所指的内存块是空闲块时,obj被视为一个指针,指向另一个节点。当节点已被分配时,被视为一个指针,指向实际区块。

下面是第二级配置器的部分实现内容:

1
2
3
enum {__ALIGN=8}; //小型区块的上调上界
enum {__MAX_BYTES=128}; //小型区块的上限
enum {__NFREELISTS=__MAX_BYRES/__ALIGN}; //free-lists个数

以下是第二级配置器总体实现代码概览:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
template <bool threads, int inst>
class __default_alloc_template {

private:
// 實際上我們應該使用 static const int x = N
// 來取代 enum { x = N }, 但目前支援該性質的編譯器還不多。
# ifndef __SUNPRO_CC
enum {__ALIGN = 8};
enum {__MAX_BYTES = 128};
enum {__NFREELISTS = __MAX_BYTES/__ALIGN};
# endif
static size_t ROUND_UP(size_t bytes) {
return (((bytes) + __ALIGN-1) & ~(__ALIGN - 1));
}
__PRIVATE:
union obj {
union obj * free_list_link;
char client_data[1]; /* The client sees this. */
};
private:
# ifdef __SUNPRO_CC
static obj * __VOLATILE free_list[];
// Specifying a size results in duplicate def for 4.1
# else
static obj * __VOLATILE free_list[__NFREELISTS];
# endif
static size_t FREELIST_INDEX(size_t bytes) {
return (((bytes) + __ALIGN-1)/__ALIGN - 1);
}

// Returns an object of size n, and optionally adds to size n free list.
static void *refill(size_t n);
// Allocates a chunk for nobjs of size "size". nobjs may be reduced
// if it is inconvenient to allocate the requested number.
static char *chunk_alloc(size_t size, int &nobjs);

// Chunk allocation state.
static char *start_free;
static char *end_free;
static size_t heap_size;

/* n must be > 0 */
static void * allocate(size_t n){...}

/* p may not be 0 */
static void deallocate(void *p, size_t n){...}
static void * reallocate(void *p, size_t old_sz, size_t new_sz);

template <bool threads, int inst>
char *__default_alloc_template<threads, inst>::start_free = 0;//内存池起始位置

template <bool threads, int inst>
char *__default_alloc_template<threads, inst>::end_free = 0;//内存池结束位置

template <bool threads, int inst>
size_t __default_alloc_template<threads, inst>::heap_size = 0;
template <bool threads, int inst>
__default_alloc_template<threads, inst>::obj * __VOLATILE
__default_alloc_template<threads, inst> ::free_list[
# ifdef __SUNPRO_CC
__NFREELISTS
# else
__default_alloc_template<threads, inst>::__NFREELISTS
# endif
] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, };

空间配置函数allocate()

__default_alloc_template拥有配置器的标准接口函数allocate(),此函数首先判断区块大小,要分配的区块小于128bytes,调用第一级配置器。否则,向对应的free-list寻求帮助。对应的free list有可用的区块,直接拿过来用。如果没有可用的区块,调用函数refill()为`free list重新填充空间。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 /* n must be > 0      */
static void * allocate(size_t n)
{
obj * __VOLATILE * my_free_list;
obj * __RESTRICT result;

if (n > (size_t) __MAX_BYTES) {
return(malloc_alloc::allocate(n));
}
my_free_list = free_list + FREELIST_INDEX(n);
// Acquire the lock here with a constructor call.
// This ensures that it is released in exit or during stack
// unwinding.
# ifndef _NOTHREADS
/*REFERENCED*/
lock lock_instance;
# endif
result = *my_free_list;
if (result == 0) {
void *r = refill(ROUND_UP(n));
return r;
}
*my_free_list = result -> free_list_link;
return (result);
};

这里需要注意的是,每次都是从对应的free list的头部取出可用的内存块。图示如下:

空间释放函数

身为一个配置器,__default_alloc_template拥有配置器的标准接口函数deallocate(),此函数首先判断区块大小,大于128bytes调用第一级配置器。否则,找出对应的free list,将区块回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// p 不可以为0
static void deallocate(void* p, size_t n)
{
obj *q = (obj *)p;
obj * volatile * my_free_list;

// 大于128就调用一级配置器
if (n > (size_t) __MAX_BYTES) {
malloc_alloc::deallocate(p, n);
return;
}
// 寻找对应的free list
my_free_list = free_list + FREELIST_INDEX(n);
// 调整free list,收回区块
q->free_list_link = *my_free_list;
*my_free_list = q;
}

为free list填充空间

当发现对应的free list没有可用的空闲区块时,就需要调用refill()函数重新填充空间。新的空间将取自于内存池(将经由chunk_alloc()完成)。缺省取得20个新节点(新区块),但万一内存池空间不足,获得的节点数(区块数)可能小于20,内存池的管理后面会讲到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* Returns an object of size n, and optionally adds to size n free list.*/
/* We assume that n is properly aligned. */
/* We hold the allocation lock. */
template <bool threads, int inst>
void* __default_alloc_template<threads, inst>::refill(size_t n)
{
int nobjs = 20;
//调用chunk_alloc(),尝试取得nobjs个区块作为free list的新节点,注意参数nobjs是pass by reference
char * chunk = chunk_alloc(n, nobjs);
obj * __VOLATILE * my_free_list;
obj * result;
obj * current_obj, * next_obj;
int i;

if (1 == nobjs) return(chunk);
my_free_list = free_list + FREELIST_INDEX(n);

/* Build free list in chunk */
result = (obj *)chunk;
*my_free_list = next_obj = (obj *)(chunk + n);
for (i = 1; ; i++) {//将各节点串接起来(注意,索引为0的返回给客端使用)
current_obj = next_obj;
next_obj = (obj *)((char *)next_obj + n);
if (nobjs - 1 == i) {
current_obj -> free_list_link = 0;
break;
} else {
current_obj -> free_list_link = next_obj;
}
}
return(result);
}

内存池

从内存池中取空间供free list使用,是chunk_alloc()的工作。具体实现思想如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

/* We allocate memory in large chunks in order to avoid fragmenting */
/* the malloc heap too much. */
/* We assume that size is properly aligned. */
/* We hold the allocation lock. */
template <bool threads, int inst>
char*
__default_alloc_template<threads, inst>::chunk_alloc(size_t size, int& nobjs)
{
char * result;
size_t total_bytes = size * nobjs;
size_t bytes_left = end_free - start_free;

if (bytes_left >= total_bytes) {
result = start_free;
start_free += total_bytes;
return(result);
} else if (bytes_left >= size) {
nobjs = bytes_left/size;
total_bytes = size * nobjs;
result = start_free;
start_free += total_bytes;
return(result);
} else {
size_t bytes_to_get = 2 * total_bytes + ROUND_UP(heap_size >> 4);//注意此处申请的空间的大小
// Try to make use of the left-over piece.
if (bytes_left > 0) {
obj * __VOLATILE * my_free_list =
free_list + FREELIST_INDEX(bytes_left);

((obj *)start_free) -> free_list_link = *my_free_list;
*my_free_list = (obj *)start_free;
}
start_free = (char *)malloc(bytes_to_get);
if (0 == start_free) {
int i;
obj * __VOLATILE * my_free_list, *p;
// Try to make do with what we have. That can't
// hurt. We do not try smaller requests, since that tends
// to result in disaster on multi-process machines.
for (i = size; i <= __MAX_BYTES; i += __ALIGN) {
my_free_list = free_list + FREELIST_INDEX(i);
p = *my_free_list;
if (0 != p) {
*my_free_list = p -> free_list_link;
start_free = (char *)p;
end_free = start_free + i;
return(chunk_alloc(size, nobjs));
// Any leftover piece will eventually make it to the
// right free list.
}
}
end_free = 0; // In case of exception.
start_free = (char *)malloc_alloc::allocate(bytes_to_get);
// This should either throw an
// exception or remedy the situation. Thus we assume it
// succeeded.
}
heap_size += bytes_to_get;
end_free = start_free + bytes_to_get;
return(chunk_alloc(size, nobjs));
}
}

chunk_alloc()函数以end_free - start_free来判断内存池的数量:

  • 内存池剩余空间完全满足20个区块的需求量,则直接取出对应大小的空间。
  • 内存池剩余空间不能完全满足20个区块的需求量,但是足够供应一个及一个以上的区块,则取出能够满足条件的区块个数的空间。
  • 内存池剩余空间不能满足一个区块的大小,则需要利用malloc()从heap中配置内存,为内存池注入活水。

举个例子,见图2-7,假设程序一开始,客端就调用chunk_alloc(32,2O),于是malloc()配置40个32 bytes区块,其中第1个交出,另19个交给free_list[3]维护,余20个留给内存池。接下来客端调用chunk_alloc(64,20),此时free_1ist[7]空空如也,必须向内存池要求支持,内存池只够供应(32*20)/M = 10个64 bytes区块,就把这10个区块返回,第1个交给客端、余9个由free_list[7]维护。此时内存池全空,接下来再调用chunk_alloc(96, 20),此时free_list[11]空空如也,必须向内存池要求支持,而内存池此时也是空的,于是以malloc()配置40+n(附加量)个96 bytes区块, 其中第1个交出,另19个交给free_list[11]维护,余20+n(附加量)个区块留给内存池。

万一山穷水尽,整个system heap空问都不够了(以至无法为内存池注入活水源头),malloc()行动失败,chunk_alloc()就四处寻找有无“尚有未用区块,且区块够大”之free lists。找到了就挖一块交出,找不到就调用第一级配置器,第一级配置器其实也是使用malloc()来配置内存,但它有out-of-memory处理机制(类似new-handler机制),或许有机会释放其它的内存拿来此处使用。如果可以,就成功,否则发出bad_alloc异常。

以上便是整个第二级空间配置器的设计。

回想一些那个提供配置器标准接口的simple_alloc:

1
2
3
4
template<class T, class Alloc>
class simple_alloc{
...
};

SGI容器通常以这种方式来使用配置器:

1
2
3
4
5
6
7
8
9
10
template<class T,class Alloc=alloc> //缺省使用alloc配置器
class vector{
public:
typedef T value_type;
...
protected:
//专属之空间配置器,每次配置一个元素大小
typedef simple_alloc<value_type,Alloc> data_allocator;
...
};

其中第二个template参数所使用的缺省参数alloc,可以是第一级配置器也可以是第二级配置器。不过,SGI STL已经把它设为第二级配置器。

deallocate()

如果需要回收的区块大于128bytes,则调用第一级配置器。如果需要回收的区块小于128bytes,找到对应的free-list,将区块回收。注意是将区块放入free -list的头部。SGI STL源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 /* p may not be 0 */
static void deallocate(void *p, size_t n)
{
obj *q = (obj *)p;
obj * __VOLATILE * my_free_list;

if (n > (size_t) __MAX_BYTES) {
malloc_alloc::deallocate(p, n);
return;
}
my_free_list = free_list + FREELIST_INDEX(n);
// acquire lock
# ifndef _NOTHREADS
/*REFERENCED*/
lock lock_instance;
# endif /* _NOTHREADS */
q -> free_list_link = *my_free_list;
*my_free_list = q;
// lock is released here
}

内存基本处理工具

STL定义有五个全局函数,作用于未初始化空间上,这样的功能对于容器的实现很有帮助。前两个函数是用于构造的construct()和用于析构的destroy(),另三个函数是uninitialized_copy()uninitialized_fill()uninitialized_fill_n,分别对应于高层次函数copy()fill()fill_n()——这些都是STL算法。

uninitialized_copy

1
2
3
template <class InputIterator,class ForwardIterator>

ForwardIterator uninitialized_copy(InputIterator first,InputIterator last,ForwardIterator result);

uninitialized_copy()使我们能够将内存的配置和对象的构造行为分离开来,如果作为输出目的地的[result,result+(last-first))范围内的每一个迭代器都指向为初始化区域,则uninitialized_copy()会使用copy constructor,给身为输入来源之[first,last)范围内的每一个对象产生一份复制品,放进输出范围中。换句话说,针对输入范围内的每一个迭代器i,该函数会调用construct(&*(result+(i-first)),*i),产生*i的复制品,放置于输出范围的相对位置上。

如果你需要实现一个容器,uninitialized_copy()这样的函数会为你带来很大的帮助,因为容器的全区间构造函数通常以两个步骤完成:

  • 配置内存块,足以包含范围内的所有元素
  • 使用uninitialized_copy(),在该内存区块上构造元素。

C++标志规格书要求uninitialized_copy()具有“commit or rollback”语意,意思是要么“构造出所有必要的元素”,要么(当有任何一个copy constructor失败时)“不构造任何东西。

uninitialized_fill

1
2
3
template <class ForwardIterator,class T>

ForwardIterator uninitialized_fill(ForwardIterator first,ForwardIterator last,const T& x);

uninitialized_fill()也能够使我们将内存配置与对象的构造行为分离开来。如果[first,last)范围内的每个迭代器都指向未初始化的内存,那么uninitialized_fill()会在该范围内产生x(上式第三个参数)的复制品。换句话说,uninitialized_fill()会针对操作范围内的每个迭代器i,调用construct(&*i,x),在i所指之处产生x的复制品。

uninitialized_copy()一样,uninitialized_fill()必须具备“commit or rollback”语意,换句话说,它要么产生出所有必要元素,要么不产生任何元素,如果有任何一个copy constructor丢出异常(exception),uninitialized_fill(),必须能够将已产生的所有元素析构掉。

uninitialized_fill_n

1
2
3
template <class ForwardIterator,class Size,class T>

ForwardIterator uninitialized_fill_n(ForwardIterator first,Size n,const T& x);

uninitialized_fill_n()能使我们将内存配置与对象构造行为分离开来,它会为指定范围内的所有元素设定相同的初值。

如果[first,first+n)范围内的每一个迭代器都指向未初始化的内存,那么uninitialized_fill_n()会调用 copy constructor,在该范围内产生x(上式第三个参数——的复制品。也就是说,面对[first,first+n)范围内的每个迭代器iuninitialized_fill_n()会调用construct(&*i,x),在对应位置产生x的复制品。

uninitialized_fill_n()也具有“commit or rollback”语意:要么产生所有必要的元素,否则就不产生任何元素。如果任何一个copy constructor丢出异常(exception),uninitialized_fill_n()必须析构已产生的所有元素。

以下分别介绍这三个函数的实现法,其中所呈现的iterators(迭代器)、value_type()_type_traits_true_type_false_typeis_POD_type等实现技术,都在后面介绍。

uninitialized_fill_n()

本函数接受三个参数:

  • 迭代器first指向欲初始化空间的起始处;n表示欲初始化空间的大小;x表示初值。
1
2
3
4
template <class ForwardIterator,class Size,class T>
inline ForwardInterator uninitialized_fill_n(ForwardIterator first, Size n, const T& x) {
return __uninitialized_fill_n(first, n, x, value_type(first));
}

这个函数的逻辑是,首先萃取出迭代器first的value_type,然后判断是否是POD型别:

1
2
3
4
5
template <class ForwardIterator, class Size, class T, class T1>
inline ForwardInterator __uninitialized_fill_n(ForwardIterator first, Size n, const T& x, T1*) {
typedef typename __type_traits<T1>::is_POD_type is_POD;
return __uninitialized_fill_n_aux(first, n, x, is_POD());
}

POD意为Plain Old Data,也就是标量型别,或传统的C struct型别,可以用最有效率的初值填写手法,而对non_POD型别采取最保险的做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 如果copy construction等同于assignment,而且destructor是trivial,以下就有效
// 如果是POD型别,执行流程就会转进到以下函数,这借由function template的参数推导机制而得

template <class ForwardIterator,class Size,class T>
inline ForwardInterator __uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __true_type) {
return fill_n(first, n, x);
}

// 如果不是POD型别,就会转进到以下函数,这借由function template的参数推导机制而得
template <class ForwardIterator,class Size,class T>
inline ForwardInterator __uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __false_type) {
ForwardIterator cur = first;
for (; n > 0; --n, ++ cur)
construct(&*cur, x);
return cur;
}

uninitialized_copy()

uninitialized_copy()接受三个函数:

  • 迭代器first指向输入端的起始位置
  • 迭代器last指向输入端的结束位置
  • 迭代器rsult指向输出端(欲初始化空间)的起始处
1
2
3
4
template <class InputIterator, class ForwardIterator>
inline ForwardInterator uninitialized_copy(InputIterator first, InputIterator last, ForwardIterator result) {
return __uninitialized_copy(first, last, result, value_type(result));
}

这个函数的逻辑是,首先萃取出迭代器result的value_type,然后判断是否是POD型别:

1
2
3
4
5
template <class InputIterator, class ForwardIterator, class T>
inline ForwardInterator __uninitialized_copy(InputIterator first, InputIterator last, ForwardIterator result, T*) {
typedef typename __type_traits<T>::is_POD_type is_POD;
return __uninitialized_copy_aux(first, last, result, is_POD());
}

POD可以用最有效率的初值填写手法,而对non_POD型别采取最保险的做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 如果copy construction等同于assignment,而且destructor是trivial,以下就有效
// 如果是POD型别,执行流程就会转进到以下函数,这借由function template的参数推导机制而得

template <class InputIterator, class ForwardIterator>
inline ForwardInterator __uninitialized_copy_aux(InputIterator first, InputIterator last, ForwardIterator result, __true_type) {
return copy(first, last, result);
}

// 如果不是POD型别,就会转进到以下函数,这借由function template的参数推导机制而得
template <class InputIterator, class ForwardIterator>
ForwardInterator __uninitialized_copy_aux(InputIterator first, InputIterator last, ForwardIterator result, __false_type) {
ForwardIterator cur = first;
for (; first != last; ++ first, ++ cur)
construct(&*cur, *first);
return cur;
}

针对char*wchar_t*两种型别,可以采用最具效率的做法memmove执行复制行为:

1
2
3
4
5
6
7
8
9
inline char* uninitialized_copy(const char* first, const char* last, char* result) {
memmove(rseult, first, last-first);
return result + (last - first);
}

inline wchar_t* uninitialized_copy(const wchar_t* first, const wchar_t* last, wchar_t* result) {
memmove(rseult, first, sizeof(wchar_t) * (last-first));
return result + (last - first);
}

uninitialized_fill_n()

本函数接受三个参数:

  • 迭代器first指向欲初始化空间的起始处;
  • 迭代器last指向输出端的结束处;
  • x表示初值。
1
2
3
4
template <class ForwardIterator, class T>
inline void uninitialized_fill(ForwardIterator first, ForwardIterator last, const T& x) {
__uninitialized_fill(first, last, x, value_type(first));
}

这个函数的逻辑是,首先萃取出迭代器first的value_type,然后判断是否是POD型别:

1
2
3
4
5
template <class ForwardIterator, class T, class T1>
inline void __uninitialized_fill(ForwardIterator first, ForwardIterator last, const T& x, T1*) {
typedef typename __type_traits<T1>::is_POD_type is_POD;
return __uninitialized_fill_aux(first, last, x, is_POD());
}

POD意为Plain Old Data,也就是标量型别,或传统的C struct型别,可以用最有效率的初值填写手法,而对non_POD型别采取最保险的做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 如果copy construction等同于assignment,而且destructor是trivial,以下就有效
// 如果是POD型别,执行流程就会转进到以下函数,这借由function template的参数推导机制而得

template <class ForwardIterator, class T>
inline void __uninitialized_fill_aux(ForwardIterator first, ForwardIterator last, const T& x, __true_type) {
fill(first, last, x);
}

// 如果不是POD型别,就会转进到以下函数,这借由function template的参数推导机制而得
template <class ForwardIterator,class Size,class T>
inline void __uninitialized_fill_aux(ForwardIterator first, ForwardIterator last, const T& x, __false_type) {
ForwardIterator cur = first;
for (; cur != last; ++ cur)
construct(&*cur, x);
}

迭代器概念与traits编程技法

迭代器是一种抽象的设计概念,现实程序语言中并没有直接对应于这个概念的实物。

迭代器设计思维——STL关键所在

不论是泛型思维或STL的实际运用,迭代器都扮演这重要的角色。STL的中心思想在于:将数据容器和算法分开,彼此独立设计,最后再以一贴胶着剂将它们撮合在一起。容器和算法的泛型化,从技术的角度来看是并不困难,C++的class template和function templates可分别达成目标。

以下是容器、算法、迭代器的合作展示,以算法find()为例,它接受两个迭代器和一个“搜索目标”:

1
2
3
4
5
6
7
template <class InputIterator,class T>
InputIterator find(InputIterator first,InputIterator last,const T& value)
{
while(first=!last&&*first!=value)
++first;
return first;
}

只要给出不同的迭代器,find()便能够对不同的容器进行直接操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include<vector>
#include<list>
#include<deque>
#include<algorithm>
#include<iostream>
using namespace std;

int main()
{
const int arraySize=7;
int ia[arraySize]={0,1,2,3,4,5,6};
vector<int> ivect(ia,ia+arraySize);
list<int> ilist(ia,ia+arraySize);
deque<int> ideque(ia,ia+arraySize);
//注意算法和成员方法的区别
vector<int>::iterator it1=find(ivect.begin(),ivect.end(),4);
if(it1!=ivect.end())
cout<<"4 found. "<<*it1<<endl;
else
cout<<"4 not found."<<endl;
list<int>::iterator it2=find(ilist.begin(),ilist.end(),6);
if(it2==ilist.end())
cout<<"6 not found. "<<endl;
else
cout<<"6 found. "<<*it2<<endl;

deque<int>::iterator it3=find(ideque.begin(),ideque.end(),8);
if(it3==ideque.end())
cout<<"8 not found."<<endl;
else
cout<<"8 found. "<<*it3<<endl;
}

从上面的例子看来,迭代器似乎依附于在容器之下,是吗?有没有独立而泛用的迭代器?我们又该如何自行设计特殊的迭代器?

迭代器是一种smart pointer

迭代器是一种行为类似指针的对象,而指针的各种行为中最常见也最重要的便是内容提领(dereference)和成员访问(member access),因此,迭代器最重要的编程工作就是对operator*operator->进行重载工作。关于这一点,C++标准库有一个auto_ptr可供我们参考。这是一个用来包含原生指针的对象,声名狼藉的内存泄露问题可借此获得解决。auto_ptr用法如下,和原生指针一模一样:

1
2
3
4
5
6
7
void func()
{
auto_ptr<string> ps(new string("jjhou"));
cout<<*ps<<endl;
cout<<ps->size()<<endl;
//离开前不需要delete,auto_ptr会自动释放内存
}

函数第一行的意思是,以new动态配置一个初值为”jjhou”的string对象,并将所得的结果(一个原生指针)作为auto_ptr<string>对象的初值。注意,auto_ptr尖括号内放的是”原生指针所指对象“的型别,而不是原生指针的型别。

auto_ptr的源代码在头文件<memory>中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//file:autoptr.cpp

template<class T>
class auto_ptr{
public:
explicit auto_ptr(T *p=0):pointee(p) {}
template<class U>
auto_ptr(auto_ptr<U>& rhs):pointee(rhs.release()) {}
~auto_ptr() {delete pointee;}

template<class U>
auto_ptr<T>& operator=(auto_ptr<U> &rhs)
{
if(this!=rhs) reset(ths.release());
return *this;
}

T& operator*() const { return *pointee;}
T* operator->() const { return pointee;}
T* get() const {return pointee;}
//...
private:
T *pointee;
};

有了模仿对象,现在我们来为list(链表)设计一个迭代器,假设list及其节点的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#include <iostream>
using namespace std;
template<class T>//节点类
class ListItem
{
public:
ListItem(T value):_value(value), _next(NULL){}
T value() {
return _value;
}
void setNext(ListItem<T> *newNode) {
_next = newNode;
}
ListItem* getNext() {
return _next;
}
private:
T _value;
ListItem* _next;
};
//单链表类
template<class T>
class List
{
public:
//...
List():_size(0) {
_front = _end = NULL;
}
void insert_front(T value) {
ListItem<T> *newNode = new ListItem<T>(value);
if(_size == 0) {
_end = _front = newNode;
}
else {
newNode -> setNext(_front);
_front = newNode;
}
_size++;
}
void insert_end(T value) {
ListItem<T> *newNode = new ListItem<T>(value);
if(_size == 0) {
_end = _front = newNode;
}
else {
_end -> setNext(newNode);
_end = _end -> getNext();
}
_size++;
}

void display() {
ListItem<T>* temp = _front;
while(temp != _end -> getNext()) {
printf("%d ", temp -> value());
temp = temp -> getNext();
}
printf("\n");
}
void getSize() {
printf("%d\n", _size);
}
ListItem<T>*front() {
return _front;
}
ListItem<T>*back() {
return _end;
}
private:
ListItem<T>* _end;
ListItem<T>* _front;
long _size;
};
//迭代器类
template<class Item>
struct ListIter
{
Item* ptr;

ListIter(Item* p = 0):ptr(p) {}

Item& operator* () const {return *ptr;}
Item* operator -> () const {return ptr;}

ListIter& operator++() {
ptr = ptr -> getNext();
return *this;
}

ListIter operator++(int) {
ListIter tmp = *this;
++*this;
return tmp;
}

bool operator==(const ListIter& i)const {
return ptr == i.ptr;
}
bool operator!=(const ListIter& i)const {
return ptr != i.ptr;
}
};

ListIter<ListItem<int> > find(ListIter<ListItem<int> > &begin, ListIter<ListItem<int> > &end, int value)
{
ListIter<ListItem<int> > first = begin;
ListIter<ListItem<int> > last = end;
while( first != last -> getNext())
{
if(first -> value() != value)
{
first++;
}
else
{
return first;
}

}
return end -> getNext();
}

并且加上测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int main()
{
List<int> m_ListItor;
for(int i = 0; i < 6; i++)
{
m_ListItor.insert_front(i);
m_ListItor.insert_end(i + 2);
}
m_ListItor.display(); //5 4 3 2 1 0 2 3 4 5 6 7

ListIter<ListItem<int> > begin(m_ListItor.front());
ListIter<ListItem<int> > end(m_ListItor.back());
ListIter<ListItem<int> > iter;

iter = find(begin, end, 3);
if(iter == end -> getNext())
{
printf("%s", "not found");
}
else
{
printf("%d\n", iter -> value());
}


iter = find(begin, end, 8);
if(iter == end -> getNext())
{
printf("%s", "not found");
}
else
{
printf("%d", iter -> value());
}
return 0;
}

以上可以看出,为了完成一个针对List而设计的迭代器,我们必须暴露太多有关于List实现细节,在main函数中制作begin()end()两个迭代器,我们暴露了ListItem,在ListIter class中为了达成operator++,我们暴露了ListItem的操作函数getNext(),如果不是为了迭代器,ListItem是要完全隐藏起来不曝光的。换句话说只有对ListItem的实现细节特别了解,才能设计出迭代器,既然这无法避免,干脆把迭代器的设计工作交给 List 的设计者,如此一来,所有实现细节反而不被使用者发现,这也是为什么 STL 的每一种容器都有自己专属的迭代器的原因。

迭代器相应型别

在算法运用迭代器的时候,很可能用到起相应型别(即迭代器所指之物的型别),但C++支持sizeof ,并无typeof。可以利用function template的参数推导机制

  1. 函数参数的情况
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #include<iostream>
    using namespace std;

    template <class I,class T>
    void func_impl(I iter,T t){
    T tmp;//这里解决了问题,T就是迭代器所指之物的类型
      //…………
    }

    template <class I>
    void func(I iter){
    func_impl(iter,*iter);
    }

    int main()
    {
    int i;
    func(&i);
    }

我们以func()为对外接口,却把实际操作全部置于func_impl()之中。由于func_impl()是一个function template,一旦被调用,编译器会自动进行template参数推导,于是导出型别,顺利解决了问题。迭代器相应型别(associated types)不只是“迭代器所指对象的型别”一种而已。根据经验,最常用的相应型别有五种,然而并非任何情况下任何一种都可利用上述的template参数推导机制来取得,我们需要更全面的解法。

Traits编程技法——STL源代码门钥

迭代器所指对象的型别,称为该迭代器的value type,上述的参数型别推导技巧虽然可用于value,却非全面可用:万一value type必须用于函数的传回值,就束手无策了,毕竟函数的”template参数推导机制”推而导之的只是参数,无法推导函数的返回值类型。

声明内嵌类型是个好主意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<iostream>
using namespace std;

template <class T>
class MyIter{
public:
typedef T value_type;
T* ptr;
MyIter(T* p=0):ptr(p){}
T& operator*()const{
return *ptr;
}
};

template <class I>
typename I::value_type//I::value_type编译器不知道代表一个型别或是一个成员函数或是一个数据成员,关键词typename用以告诉编译器这是一个型别
func(I ite){
return *ite;
}

int main(){
MyIter<int> ite(new int(8));
cout<<func(ite);
}

注意,func()的返回类型必须加上关键词,因为是一个template参数,在它被编译器具现化之前,编译器对此一无所悉,换句话说,编译器此时并不知道MyIter<T>::value_type代表的是一个型别或是一个member function或是一个data member。关键词的用意在于告诉编译器这是一个型别,如此才能顺利通过编译。但是并不是所有迭代器都是class,原生指针就不是,如果不是就无法为它定义内嵌型别,但STL(以及整个泛型思维)绝对必须接受原生指针作为一种迭代器,所以上面这样还不够。template partial speciahzation可以做到。

Partial Specialization(偏特化)的意义

如果class template 拥有一个以上的template参数,我们可以针对其中某个(或数个,但非全部)template参数进行特化工作。换句话说,我们可以在泛化设计中提供一个特化版本(也就是将泛化版本中的某些template参数赋予明确的指定)。例如,面对以下这么一个class template:

1
2
template<typename T>
class C { ... }; // 这个泛化版本允许接受T为任何类型

我们便很容易接受它有一个形式如下的partial specialization

1
2
template<typename T>
class C<T* { ... }; // 这个泛化版本允许接受T为原生指针的情况

有了这项利器,我们便可以解决前述“内嵌型别”未能解决的问题。先前的问题是,原生指针并非class,因此无法为它们定义内嵌型别。现在,我们可以针对“迭代器之template参数为指针”者,设计特别版的迭代器。

下面这个class template专门用来“萃取”迭代器的特性,而value type正是迭代器的特性之一:

1
2
3
4
5
6
7
#include<iostream>
using namespace std;
template<class T>
struct iterator_traits//traits 意为“特性”
{
typedef typename I::vlue_type value_type;
};

这个所谓的traits,其意义是,如果I定义自己的value type,那么通过这个traits的作用,萃取出来的value_type就是I::value_type。换句话说,如果I定义有自己的value type ,那个func()可以改写成这样:

1
2
3
4
5
6
template <class T>
typename iteraotr_traits<T>::value_type//这一整行是函数返回值
func(T ite)
{
return *ite;
}

但这除了多了一层间接性,好处是traits可以拥有特化版本。现在,我们令iterator_traites拥有一个partial specializations如下:
1
2
3
4
5
template<class>
struct iterator_traits<T*>
{
typedef T value_type;
};

于是,原生指针int*虽然不是一种class type ,亦可通过traits取其value type。这就解决了先前的问题。但是注意针对“指向常数对象的指针(pointer-to-const)”,下面这个式子得到什么结果:

1
iterator_traits<const int*>::value_type

获得的是const int而非int。我们希望利用这种机制来声明一个暂时变量,使其型别与迭代器的value type相同,而现在,声明一个无法复制的暂时变量,没什么用!因此,如果迭代器是一个pointer-to-const,我们应该设法令其value type为一个non-const型别。只需要另外设计一个特化版本就可以解决问题:
1
2
3
4
template<class T>
struct iterator_traits<const T*>{//偏特化版—当迭代器是一个pointer-to-const
typedef T value_type;//萃取出来的型别应该是T,而非const T
};

现在,不论面对的是迭代器MyIter,或是原生指针*intconst int*,都可以通过traits取出正确的(我们所期望的)value type。

下图说明了traits所扮演的“特性萃取机”角色,萃取各个迭代器的特性。这里所谓的迭代器特性,指的是迭代器的相应型别。当然,若要这个“特性萃取机”traits嫩够有效运作,每一个迭代器必须遵守约定,自行以内嵌型别定义的方式定义出相应型别。这是一个约定,谁不遵守约定,谁就不能兼容STL这个大家庭。

根据经验,最常用到的迭代器相应型别有五种:value typedifference typepointerreference,iterator categoly。如果你希望你所开发的容器能与STL水乳交融,一定要为你的容器的迭代器定义这五种相应型别。“特性萃取机”traits会很忠实地将其原汁原味榨取出来:

1
2
3
4
5
6
7
temp1ate <class I> 
struct itarator_traits {
typedef typename I::iterator_category iterator_category;
typedef typename I::value_type value_type;
typedef typename I::difference_type difference_type;
typedef typename I::pointer pointer;
typedef typename I::reference reference;
iterator_traits`必须针对传入的型别为pointer及pointer-to-const者,设计特化版本,稍后数节为你展示如何进行。

迭代器相应型别之一:value type

所谓value type,是指迭代器所指对象的型别。任何一个打算与STL算法有完美搭配的class,都应该定义自己的value type内嵌型别,例如STL中的vector定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <class T,class Alloc = alloc>
class vector
{
public:
// nested type 定义
typedef T value_type;
typedef value_type* pointer;
typedef const value_type* const_pointer;
typedef value_type* iterator;
typedef const value_type* const_iterator;
typedef value_type& reference;
typedef const value_type& const_reference;
typedef size_t s ize_type;
typedef ptrdiff_t difference_type;
...
};

迭代器相应型别之二:difference type

difference type用来表示两个迭代器之间的距离,因此它也可以用来表示一个容器的最大容量,因为对于连续空间的容器而言,头尾之间的距离就是其最大容量。如果一个泛型算法提供计数功能,例如STL的count(),其传回值就必须使用迭代器的diference type:

1
2
3
4
5
6
7
8
9
template <class I,class T>
typename iterator_traits<I>::difference_type
count (I first, I last, const T& value){
typename iterator_traits<I>::difference_type n=0;
for(;first!=last;++first)
if(*first == value)
++n;
return n;
}

针对相应型别difference type,traits的如下两个特化版本,以C++内建的ptrdiff_L作为原生指针的difference type:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <class I>
struct iterator_traits {
...
typedef typename I::difference_type difference_type;
};

// 针对原生指针偏特化版
template <class T>
struct iterator_traits<T*> {
...
typedef ptrdiff_L difference_type;
};

// 针对原生的pointer-to-const设计的偏特化版
template <class T>
struct iterator_traits<const T*> {
...
typedef ptrdiff_t difference_type;
};

现在,任何时候我们需要任何迭代器I的difference type,可以:
1
typename iterator_traits<I>::difference_type;

迭代器相应型别之三:reference type

从“迭代器所指之物的内容是否允许改变”的角度观之,迭代器分为两种:不允许改变“所指对象之内容”者,称为constant iterators,例如const int* pic允许改变“所指对象之内容”者,称为 mutable iterators,例如int* pi。 当我们对一个 mutable iterators做解引用时,获得的应该是个左值(lvalue) ,可以被赋值。

1
2
3
4
int* pi = new int(5);
const int* pci = new int(9);
*pi = 7; // 对mutable iterator及逆行操作,获得的是左值,允许赋值
*pci = 1; // 这个操作不被允许,pci是constant iterator,提领pci所得结果是个右值,不能赋值。

在 C++中,函数如果要返回左值,都是以by reference的方式进行,所以当p是个mutable iterators时,如果其value type是T,那么*p的型别不应该是T,应该是T&。将此道理扩充,如果p是一个 constant iterators,其value type是 T,那么*p的型别不应该是const T,而应该是const T&*p的型别,即所谓的reference type。

迭代器相应型别之四:pointer type

pointers和 references 在C++中有非常密切的关连。 如果“传回一个左值,令它代表p所指之物”是可能的,那么“传回一个左值,令它代表p所指之物的位址”也一定可以。 我们能够传回一个 pointer,指向迭代器所指之物。

这些相应型别已在先前的ListIter class中出现过:

1
2
Item& operator*() const { return *ptr; }
Item* operator->() const { return ptr; }

Item&便是ListIter的reference type而Item*便是其pointer type。

现在把reference type和pointer type这两个相应型别加入traits内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class I>
struct iterator_traits {
typedef typename I::pointer pointer;
typedef typename I::reference reference;
}

// 针对原生指针偏特化版
template <class T>
struct iterator_traits<T*> {
...
typedef T* pointer;
typedef T& reference;
};

// 针对原生的pointer-to-const设计的偏特化版
template <class T>
struct iterator_traits<const T*> {
...
typedef const T* pointer;
typedef const T& reference;
};

迭代器相应型别之五:iterator_category

最后一个(第五个)迭代器的相应型别会引发较大规模的写代码工程。在那之前,我必须先讨论迭代器的分类。

根据移动特性与施行操作,迭代器被分为五类:

  • Input lterator:这种迭代器所指的对象,不允许外界改变。只读(read only)。
  • Output terator:唯写(write only)。
  • Forward lterator:允许“写入型”算法(例如replace())在此种迭代器所形成的区间上进行读写操作。
  • Bidirectiona lterator:可双向移动。某些算法需要逆向走访某个迭代器区间(例如逆向拷贝某范围内的元素),可以使用Biairectional lterators。
  • Random Access lterator:前四种迭代器都只供应一部分指针算术能力(前三种支持operator++,第四种再加上operator--),第五种则涵盖所有指针算术能力,包括p+np-np[n]p1-p2p1<p2

迭代器的分类与从属关系如下图所示:

设计算法时,如果可能,我们尽量针对上图中某种迭代器提供一个明确定义,并针对更强化的某种迭代器提供另一定义,这样才能在不同情况下提供最大效率。假设有个算法接受 Forward Iterator,你以 Random Access Iterator 喂给它,也可用,但是可用不一定最佳。

下面以advanced()函数为例,介绍各类迭代器的性能差异。该函数有两个参数,迭代器p和数值n,函数内部将p累进n次,下面有三个定义,一个针对Input iterator,一个针对Bidirectional iterator,另一个针对Random Access iterator。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class InputIterator,class Distance>
void advanced_II(InputIterator& i, Distance n)
{
//单向 逐一前进
while(n--) ++i;
}

template<class BidirectionalIterator, class Distance>
void advanced_BI(BidirectionalIterator& i, Distance n)
{
//双向 逐一前进
if(n>=0)
while(n--) ++i;
else
while(n++) --i;
}

template<class RandomAccessIterator, class Distance>
void advanced_RAI(RandomAccessIterator& i,Distance n)
{
//双向 跳跃前进
i += n;
}

当程序调用advance()时,应该调用哪一份函数定义呢?通常会将三者合一,下面是一种做法:

1
2
3
4
5
6
7
8
9
10
template <class InputIterator, class Distance>
void advanced(InputIterator& i, Distance n)
{
if(is_random_access_iterator(i))//有待设计
advanced_RAI(i,n);
else if(is_bidirectional_iterator(i))//有待设计
advanced_BI(i,n);
else
advanced_II(i,n);
}

但是上述处理方式,会在程序执行期间才能决定使用哪个处理函数,影响程序效率。最好能够在编译期就选择正确的版本,重载函数机制可以实现该目标。我们可以给advanced()添加第三个参数,即“迭代器类型”这个参数,然后利用traits萃取出迭代器的种类。下面五个classes,即代表五种迭代器类型:
1
2
3
4
5
struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : public input_iterator_tag {};
struct bidirectional_iterator_tag : public forward_iterator_tag {};
struct random_access_iterator_tag : public bidirectional_iterator_tag {};

这些classes只作为标记用,所以不需要任何成员。重新设计__advance(),然后利用第三参数重新定义上面的advance()函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template<class InputIterator, class Distance>
inline void __advanced(InputIterator& i, Distance n, input_iterator_tag)
{
//单向 逐一前进
while(n--) ++i;
}

//这是一个单纯的传递调用参数(triv forwarding function) 稍后讨论如何免除之
template<class ForwardIterator,class Distance>
inline void __advanced(ForwardIterator& i, Distance n, forward_iterator_tag)
{
//单纯的进行传递调用
advance(i,n,input_iterator_tag);
}
template<class BidirectionalIterator, class Distance>
inline void __advanced(BidirectionalIterator& i, Distance n, bidirectional_iterator_tag)
{
//双向 逐一前进
if(n>=0)
while(n--) ++i;
else
while(n++) --i;
}

template<class RandomAccessIterator, class Distance>
inline void __advanced(RandomAccessIterator& i, Distance n, random_access_iterator_tag)
{
//双向 跳跃前进
i += n;
}

对外开放的上层接口,调用上述各个重载的__advance()。这一上层接口只需两个参数,当它准备将工作转给上述的__advance()时,才自行加上第三参数:迭代器类型。因此,这个上层函数必须有能力从它所获得的迭代器中推导出其类型——这份工作自然交给traits机制:
1
2
3
4
5
template<class InputIterator, class Distance>
inline void advanced(InputIterator& i, Distance n)
{
__advance(i,n,iterator_traits<InputIterator>::iterator_categoty());
}

iterator_traits<InputIterator>::iterator_categoty()将产生一个临时对象,其类别应该为前述5个迭代器类型之一。根据这个类别编译器决定调用哪个__advance()重载函数。

任何一个迭代器,其类型永远应该落在“该迭代器所隶属之各种类型中”,最强化的那个。同时,STL算法命名规则:以算法所能接受之最低阶迭代器类型,来为其迭代器型别参数命名,因此advance()中template参数名称为InputIterator

消除“单纯传递调用的函数”

由于各个迭代器之间存在着继承关系,“传递调用”的行为模式自然存在,即如果不重载Forward Iterators或BidirectionalIterator时,统统都会传递调用InputIterator版的函数。

std::iterator的保证

任何迭代器都应该提供五个内嵌相应类别,以利于traits萃取。STL提供了一个iteratots class如下,如果每个新设计的迭代器都继承自它,则可以保证符合STL规范(即需要提供五个迭代器相应的类型)。

1
2
3
4
5
6
7
8
9
template <class Category, class T, class Distance = ptrdiff_t,
class Pointer = T*, class Reference = T&>
struct iterator {
typedef Category iterator_category;
typedef T value_type;
typedef Distance difference_type;
typedef Pointer pointer;
typedef Reference reference;
};

iterator class 不含成员,纯粹只是类型定义,所以继承它不会造成任何负担。由于后三个参数都有默认值,新的迭代器只需提供前两个参数即可。

SGI STL的私房菜:__type_traits

traits编程技法很棒,适度弥补了 C++ 语言本身的不足。 STL只对迭代器加以规范,制定出iterator_traits这样的东西。 SGI 把这种技法进一步扩大到迭代器以外的世界,于是有了所谓的__type_traits

iterator_traits负责萃取迭代器的特性, __type_traits则负责萃取型别(type)的特性。 型别特性是指:这个型别是否具备non-trivial defalt ctor ?是否具备 non-trivial copy ctor?是否具备 non-trivial assignment operator?是否具备 non-trivial dtor?如果答案是否定的,我们在对这个型别进行建构、解构、拷贝、赋值等动作时,就可以采用最有效率的措施,而采用内存直接处理动作如malloc()memcpy()等等,获得最高效率。这对于大规模而动作频繁的容器,有着显著的效率提升!

type_traits提供了一种机制,允许针对不同的型别属性,在编译时期完成函数派送决定,如果我们事先知道是否有trivial copy constructor,便能够帮助我们确定是否可以使用memcpy()memmove()

根据iterator_traits得来的经验,我们希望程序中可以这样运用__type_traits<T>T代表任意型别:

1
2
3
4
5
__type_traits<T>::has_trivial_default_constructor
__type_traits<T>::has_trivial_copy_constructor
__type_traits<T>::has_trivial_assignment_operator
__type_traits<T>::has_trivial_destructor
__type_traits<T>::is_POD_type

上述式子应该传回:
1
2
struct __true_type{};
struct __false_type{};

利用其响应结果进行类型推断。

为了达成上述五个式子,__type_traits应该定义一些typedefs,其值不是_true_type就是_false_type

1
2
3
4
5
6
7
8
9
10
template <class type>
struct __type_traits {
typedef __true_type this_dummy_member_must_be_first;

typedef __false_type has_trivial_default_constructor;
typedef __false_type has_trivial_copy_constructor;
typedef __false_type has_trivial_assignment_operator;
typedef __false_type has_trivial_destructor;
typedef __false_type is_POD_type;
};

SGI把所有内嵌型别都定义为_false_type为了定义出最保守的值,然后再针对每一个标量型别(scalar types)设计适当的_type_traits特化版本,这样就解决了问题。上述_type_traits可以接受任何型别的参数,五个typedefs将经由以下管道获得实值:

  • 一般具现体(gerera1 instantiation),内含对所有型别都必定有效的保守值。上述各个has_trivial_xxx型别都被定义为_false_type,就是对所有型别都必定有效的保守值。
  • 经过声明的特化版本,例如<type_traits.h>内对所有C++标量型别(scalar types)提供了对应的特化声明。稍后展示
  • 某些编译器会自动为所有型别提供适当的特化版本

以下是<type_traits.h>对所有C++标量类型所定义的__type_traits特化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
__STL_TEMPLATE_NULL struct __type_traits<char> {
typedef __true_type has_trivial_default_constructor;
typedef __true_type has_trivial_copy_constructor;
typedef __true_type has_trivial_assignment_operator;
typedef __true_type has_trivial_destructor;
typedef __true_type is_POD_type;
};


__STL_TEMPLATE_NULL struct __type_traits<int> {
typedef __true_type has_trivial_default_constructor;
typedef __true_type has_trivial_copy_constructor;
typedef __true_type has_trivial_assignment_operator;
typedef __true_type has_trivial_destructor;
typedef __true_type is_POD_type;
};

// 上述还有针对其他数据类型的定义
// 以下针对原生指针设计__type_traits偏特化版本
template <class T>
struct __type_traits<T*> {
typedef __true_type has_trivial_default_constructor;
typedef __true_type has_trivial_copy_constructor;
typedef __true_type has_trivial_assignment_operator;
typedef __true_type has_trivial_destructor;
typedef __true_type is_POD_type;
};

前面第二章提到过的uninitialized_fill_n等函数就在实现中使用了__type_traits机制。

1
2
3
4
5
template <class ForwardIterator, class Size, class T>
inline ForwardIterator __uninitialized_fill_n(ForwardIterator first, Size n,
const T& x) {
return __uninitialized_fill_n(first, n, x, vaule_type(first));
}

该函数以x为蓝本,自迭代器first开始构造n个元素,首先以value_type()萃取出迭代器first的value_type,再利用__type_traits判断该类型是否为POD类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template <class ForwardIterator, class Size, class T, class T1>
inline ForwardIterator __uninitialized_fill_n(ForwardIterator first, Size n,
const T& x, T1*) {
typedef typename __type_traits<T1>::is_POD_type is_POD;
return __uninitialized_fill_n_aux(first, n, x, is_POD());

}

// 如果不是POD型别 就会派送(dispatch)到这里
template <class ForwardIterator, class Size, class T>
ForwardIterator
__uninitialized_fill_n_aux(ForwardIterator first, Size n,
const T& x, __false_type) {
ForwardIterator cur = first;
__STL_TRY {
for ( ; n > 0; --n, ++cur)
construct(&*cur, x);//需要逐个进行构造
return cur;
}

//如果是POD型别 就会派送到这里 下两行是源文件所附注解
//如果copy construction 等同于 assignment 而且有 trivival destructor
//以下就有效
template <class ForwardIterator, class Size, class T>
inline ForwardIterator
__uninitialized_fill_n_aux(ForwardIterator first, Size n,
const T& x, __true_type) {
return fill_n(first, n, x); //交由高阶函数执行
}

//以下定义于<stl_algobase.h>中的fill_n()
template <class OutputIter, class _Size, class _Tp>
OutputIter fill_n(OutputIter first, Size n, const Tp& value) {
for ( ; n > 0; --n, ++first)
*first = value;
return first;
}

第二个例子是copy()全局函数(泛型算法之一〕,这个函数有非常多的特化〔specialization)与强化(refinement)版本。最基本的想法是这样:

1
2
3
4
5
6
7
8
9
10
// 拷贝一个数组,其元素为任意型别,视情况采用最有效率的拷贝手段 
template <c1ass T> inline void copy(T* source, T* destination, int n) {
copy(source, destination, n, typename __type_traits<T>:::has_trivial_copy_constructor());
}

// 拷贝一个数组,其元素型别拥有non-trivial copy constructors
template <class T> void copy(T* source, T* destination, int n, __false_type) { ... }
// 拷贝一个数组,其元素型别拥有trivial copy constructors
// 可借助memcpy()完成工作
template <class T> void copy(T* source, T* destination, int n, __true_type) { ... }

以上只是针对“函数参数为原生指针”的情况而做的设计。

如果你是SGI STL的用户,你可以在自己的程序中充分运用这个__type_traits,假设我自行定义了一个shape c1ass,__type_traits会对它产生什么效应呢?如果编译器够厉害,__type_traits针对shape萃取出来的每一个特性,其结果将取决于我的Shape是否有trivial default ctor,或triviai copy ctor,或trivial assignment operator, 或trivial dtor而定。但对大部分缺乏这种特异功能的编译器而言,type_traits针对Shape萃取出来的每一个特性都是`false_type,即使shape是个POD型别。这样的结果当然过于保守、但是别无选择,除非我针对shape,自行设计一个__type_traits`特化版本,明白地告诉编译器以下事实(举例):

1
2
3
4
5
6
7
8
template <class type>
struct __type_traits<Shape> {
typedef __true_type has_trivial_default_constructor;
typedef __false_type has_trivial_copy_constructor;
typedef __false_type has_trivial_assignment_operator;
typedef __false_type has_trivial_destructor;
typedef __false_type is_POD_type;
};

一个简单的判断标准是,如果class内含指针成员,并且对它进行动态内存配置,那么这个class就要实现出自己的non-trival-xxx。

序列式容器

容器的概观与分类

容器,置物之所也。研究数据的特定排列方式,以利于搜寻或排序或其他特殊目的,这一专门学科称为数据结构。几乎可以说,任何特定的数据结构都是为了实现某种特定的算法。

SGI STL的各个容器(本图以内缩方式来表达基层与衍生层的关系)。

这里所谓的衍生,并非派生关系,而是内含关系。例如,heap内含一个vector,priority-queue内含一个heap,stack和queue都内含一个deque,set/map/multiset/multimap都内含一个RB-tree,hash_set/hash_map/hash_multiset/hash_multimap都内含一个hashtabe。

vector概述

vector的数据安排以及操作方式,与array非常相似。两者的唯一差别在于空间的运用的灵活性。array是静态空间,一旦配置了就不能改变;vector的动态空间 ,随着元素的加入,它的内部机制会自行扩充空间以容纳新元素。vector的实现技术,关键在于对其大小的控制以及重新配置时的数据移动效率。

vector的内部定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
template <class T, class Alloc = alloc>
class vector {
public:
typedef T value_type;
typedef value_type* pointer;
typedef value_type* iterator;
typedef value_type& reference;
typedef size_t size_type;
typedef ptrdiff_t difference_type;

protected:
typedef simple_alloc<value_type, Alloc> data_allocator; // SGI STL 空间配置器接口
iterator start; // 表示目前使用空间的头
iterator finish; // 表示目前使用空间的尾
iterator end_of_storage; // 表示目前可用空间的尾
void insert_aux(iterator position, const T& x);
void deallocate() { // 释放空间
if (start) data_allocator::deallocate(start, end_of_storage - start);
}

void fill_initialize(size_type n, const T& value) {
start = allocate_and_fill(n, value);
finish = start + n;
end_of_storage = finish;
}
public:
// 各种迭代器
iterator begin() { return start; }
const_iterator begin() const { return start; }
iterator end() { return finish; }
const_iterator end() const { return finish; }
reverse_iterator rbegin() { return reverse_iterator(end()); }
const_reverse_iterator rbegin() const { return const_reverse_iterator(end()); }
reverse_iterator rend() { return reverse_iterator(begin()); }
const_reverse_iterator rend() const { return const_reverse_iterator(begin()); }

// size、max_size、capacity、empty
size_type size() const { return size_type(end() - begin()); }
size_type max_size() const { return size_type(-1) / sizeof(T); }
size_type capacity() const { return size_type(end_of_storage - begin()); }
bool empty() const { return begin() == end(); }
// 重载 []
reference operator[](size_type n) { return *(begin() + n); }
const_reference operator[](size_type n) const { return *(begin() + n); }

// 构造函数,大都调用 fill_initialize
vector() : start(0), finish(0), end_of_storage(0) {}
vector(size_type n, const T& value) { fill_initialize(n, value); }
vector(int n, const T& value) { fill_initialize(n, value); }
vector(long n, const T& value) { fill_initialize(n, value); }
explicit vector(size_type n) { fill_initialize(n, T()); }

// 析构函数
~vector() {
destroy(start, finish);
deallocate();
}

// 首尾元素
reference front() { return *begin(); }
const_reference front() const { return *begin(); }
reference back() { return *(end() - 1); }
const_reference back() const { return *(end() - 1); }

void push_back(const T& x) {
if (finish != end_of_storage) {
construct(finish, x);
++finish;
}
else
insert_aux(end(), x);
}

// 插入操作
iterator insert(iterator position, const T& x) {
size_type n = position - begin();
if (finish != end_of_storage && position == end()) {
construct(finish, x);
++finish;
}
else
insert_aux(position, x);
return begin() + n;
}

// 删除最尾端元素
void pop_back() {
--finish;
destroy(finish);
}

//清除某位置上的元素
iterator erase(iterator position) {
if (position + 1 != end())
copy(position + 1, finish, position); // 后续元素往前移动
--finish;
destroy(finish);
return position;
}

// 清除迭代器所指定的区间的元素
iterator erase(iterator first, iterator last) {
iterator i = copy(last, finish, first);
destroy(i, finish);
finish = finish - (last - first);
return first;
}

// 重新设置 vector 大小,若设置值 new_size 大于当前 size,在尾端插入 x
void resize(size_type new_size, const T& x) {
if (new_size < size())
erase(begin() + new_size, end());
else
insert(end(), new_size - size(), x);
}
void resize(size_type new_size) { resize(new_size, T()); }
void clear() { erase(begin(), end()); }

protected:
// 配置空间并填满内容,其中__STL_TRY、__STL_UNWIND 为异常相关的宏,在 stl_config.h 中定义
iterator allocate_and_fill(size_type n, const T& x) {
iterator result = data_allocator::allocate(n);
__STL_TRY {
uninitialized_fill_n(result, n, x);
return result;
}
__STL_UNWIND(data_allocator::deallocate(result, n));
}
};

vector 的迭代器

vector 维护的是一个连续的线性空间,所以不论其元素型别如何,普通指针都可以作为 vector 的迭代器而满足所有必要条件,因为 vector 迭代器所需要的操作行为,如operator*operator->operator++operator–operator+operator-operator+=operator-=,普通指针天生就具备。vector 支持随机存取,而普通指针正有这样的能力。所以,vector 提供的是 Random Access Iterators。

1
2
3
4
5
6
7
8
template <class T, class Alloc = alloc>
class vector
{
public:
typedef T value_type;
typedef value_type* iterator;
...
}

根据定义,如果客户端写出这样的代码:
1
2
vector<int>::iterator ivite;
vector<Shape>::iterator svite;

ivite 型别就是int*,svite 的型别就是Shape*

vector数据结构

vector采用线性连续空间的数据结构。它以两个迭代器start和finish分别指向配置的来的连续空间中目前已被使用的范围,并以迭代器end_of_storage指向整块连续空间(含备用空间)的尾端:

1
2
3
4
5
6
7
8
template<class T,class Alloc = alloc>  
class vector{
...
protected :
iterator start ; //表示目前使用空间的头
iterator finish ; // 表示目前使用空间的尾
iterator end_of_storage ; //表示目前可用空间的尾
};

为了降低空间配置时的速度成本,vector 实际配置的大小可能比客户端需求量更大一些,以备将来可能的扩充。这便是容量(capacity)的概念。添加新元素时,如果超出当时的容量,则容量会扩充至两倍,如果两倍容量仍不足,就扩充至足够大的容量。上述容量的扩张必须经历“重新配置、元素移动、释放空间”等过程。vector数据插入过程的示意图如下:

vector构造与内存管理

vector缺省使用alloc作为空间配置器,并据此另外定义了一个data_allocator,为的是更方便以元素大小为配置单位:

1
2
3
4
5
6
template<class T, class Alloc = alloc>  
class vector{
protected:
typedef simple_alloc<value_type,Alloc> data_allocator;
...
}

于是,data_allocator::allocate(n)表示配置n个元素空间。

vector提供许多constructors,其中一个允许我们指定空间大小及初值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector(size_type n, const T& value) {
fill_initialize(n, value);
}

void fill_initialize(n, value) {
start = allocate_and_fill(n, value);
finish = start + n;
end_of_storage = finish;
}

// 配置而后填充
iterator allocate_and_fill(size_type n, const T& x) {
iterator result = data_allocator::allocate(n);
uninitialized_fill_n(result, n, x);
return result;
}

uninitialized_fill_n()会根据第一参数的类型决定使用算法fill_n或反复调用construct()完成任务。

当我们以push_back()将新元素插入vector尾端时,该函数先检查是否还有备用空间,如果有就直接在备用空间上构造元素,并调整迭代器finish,使vector变大。如果没有备用空间,就扩充空间(重新配置、移动数据、释放原空间):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void push_back(const T& x) {
if (finish != end_of_storage) {
construct(finish, x);
++finish;
}
else
insert_aux(end(), x);
}

template<class T, class Alloc>
void vector<T, Alloc>::insert_aux(iterator position, const T&x){
if (finish != end_of_storage){//还有备用空间
construct(finish, *(finish - 1)); //在备用空间起始处构造一个元素,以vector最后一个元素值为其初值
++finish; //调整finish迭代器
T x_copy = x;
copy_backward(position, finish - 2, finish - 1);
*position = x_copy;
}
else{//没有备用空间
const size_type old_size = size();
const size_type new_size = old_size != 0 ? 2 * old_size : 1;
iterator new_start = data_allocator::allocate(new_size);
iterator new_finish = new_start;
try{
new_finish = uninitialized_copy(start, position, new_start);//将原vector的内容拷贝到新vector
construct(new_finish, x);
++new_finish;
new_finish = uninitialzed_copy(position, finish, new_finish);//将安插点的原内容也拷贝过来
}
catch (excetion e){
destroy(new_start, new_finish);//如果发生异常,析构移动的元素,释放新空间
data_allocator::deallocate(new_start, new_size);
throw;
}//析构并释放原空间
destroy(begin(), end());
deallocator();
start = new_start; //调整迭代器
finish = new_finish;
end_of_storage = new_start + new_size;//调整迭代器
}
}

补充:
1
2
3
4
5
6
7
8
9
10
11
template<class BidirectionalIterator1, class BidirectionalIterator2>
BidirectionalIterator2 copy_backward ( BidirectionalIterator1 first,
BidirectionalIterator1 last,
BidirectionalIterator2 result)
参数:
first, last
指出被复制的元素的区间范围[first,last).
result
指出复制到目标区间的具体位置[result-(last-first),result)
返回值:
返回一个迭代器,指出已被复制元素区间的起始位置

所谓动态增加大小,并不是在原空间之后接续空间(因为无法包装原空间之后尚有可配置的空间),而是以原大小的两倍另外配置一块较大的空间,然后将原来内容拷贝过来,然后才开始在原内容之后构造新元素,并释放原空间。因此对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了。

vector的元素操作

pop_back()实现:

1
2
3
4
void pop_back(){
--finish; //将尾端标记往前移一格,表示放弃尾端元素
destory(finish); //finish原来指向容器尾部[strat,finish),--后指向最后一个元素,然后析构
}

erase()实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//清除[first,last]中的所有元素
iterator erase(iterator first,iterator last){
iterator i=copy(last,finish,first);
destroy(i,finish);
finish=finish-(last-first);
}
//清除某个位置上的元素
iterator erase(iterator position){
if(position+1!=end())
copoy(position+1,finish,position);
--finish;
destory(finish);
return position;
}
//清除所有元素
void clear() {erase(begin(),end());}

copy()函数具体实现:

1
2
3
4
5
6
7
8
//InputIterator 版本
template<class InputIterator, class OutputIterator>
inline OutputIterator __copy(InputIterator first, InputIterator last, OutputIterator result, input_iterator_tag){
//如果只是 InputIterator 的话,以迭代器赞同与否,决定循环是否继续、速度慢
for( ; first != last; ++result, ++first)
*result = *first;
return result;
}


1
2
for( ; last != finish; ++first, ++last)
*first = *last; //即将last开始的元素接到first后面

insert()实现,根据备用空间和插入元素的多少分为以下三种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
template <class T, class Alloc>
void vector<T, Alloc>::insert(iterator position, size_type n, const T& x) {
// 当 n != 0 才进行以下所有操作
if (n != 0) {
// 备用空间大于等于新增元素个数
if (size_type(end_of_storage - finish) >= n) {
T x_copy = x;
const size_type elems_after = finish - position;
iterator old_finish = finish;
// 针对插入点后现有元素与新增元素个数的数量采取不同的操作
// 插入点后现有元素个数大于新增元素个数
if (elems_after > n) {
uninitialized_copy(finish - n, finish, finish);
finish += n;
copy_backward(position, old_finish - n, old_finish);
fill(position, position + n, x_copy);
}
// 插入点后现有元素个数小于等于新增元素个数
else {
uninitialized_fill_n(finish, n - elems_after, x_copy);
finish += n - elems_after;
uninitialized_copy(position, old_finish, finish);
finish += elems_after;
fill(position, old_finish, x_copy);
}
}
else {
// 备用空间小于新增元素个数(必须配置额外的内存)
// 首先决定新长度:旧长度的2倍,或者旧长度+新增元素个数
const size_type old_size = size();
const size_type len = old_size + max(old_size, n);
// 配置新的 vector 空间
iterator new_start = data_allocator::allocate(len);
iterator new_finish = new_start;
__STL_TRY {
new_finish = uninitialized_copy(start, position, new_start);
new_finish = uninitialized_fill_n(new_finish, n, x);
new_finish = uninitialized_copy(position, finish, new_finish);
}
# ifdef __STL_USE_EXCEPTIONS
catch(...) {
// 如有异常发生,实现 commit or rollback 语义
destroy(new_start, new_finish);
data_allocator::deallocate(new_start, len);
throw;
}
# endif /* __STL_USE_EXCEPTIONS */
// 清除并释放旧的 vector
destroy(start, finish);
deallocate();
// 调整迭代器
start = new_start;
finish = new_finish;
end_of_storage = new_start + len;
}
}
}



list

list概述

相比于vector的连续线性空间,list显得更为复杂;但list每次插入或删除一个元素时,就将配置或释放一个元素。因此,list对于空间的运用有绝对的精准,一点也不浪费。对于任何位置的插入或元素删除,list永远是常数时间。

list的节点

下面是STL list的节点结构,显然是一个双向链表。

1
2
3
4
5
6
7
template <class T>
struct __list_node {
typedef void* void_pointer;
void_pointer prev; //型别为void*,其实可设为__list_node<T>*
void_pointer next;
T data;
};

list的迭代器

list中的元素由于都是节点,不保证在存储空间中连续存在。list迭代器必须有能力指向list的节点,并有能力正确递增递减取值存取等操作。其迭代器递增时取用的是下一个节点,递减时取用上一个节点,取值时取的是节点的数据值,成员存取时取用的是节点的成员。

由于list是双向链表,迭代器必须具备前移、后移的能力,因此,list提供的是Bidirectional Iterators;list的插入和接合操作都不会导致原有迭代器失效,但vector的插入可能造成存储空间重新分配,导致原有的迭代器全部失效。甚至list的删除操作也只有指向被删除元素的那个迭代器失效,其他迭代器不受影响。

以下是list迭代器的设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
template<class T, class Ref, class Ptr>
struct __list_iterator {
typedef __list_iterator<T, P&, T*> iterator;
typedef __list_iterator<T, Ref, Ptr> self;

typedef bidirectionla_iterator_tag iterator_category;
typedef T value_type;
typedef Ptr pointer;
typedef Ref reference;
typedef __list_node<T>* link_type;
typedef size_t size_type;
typedef ptrdiff_t difference_type;

link_type node;

// constrcutor
__list_iterator(link_type x) : node(x) {}
__list_iterator() {}
__list_iterator(const iterator& x) : node(x.node) {}

bool operator==(const self& x) const { return node == x.node; }
bool operator!=(const self& x) const { return node != x.node; }
reference operator*() const { return (*node).data; }

pointer operator->() const { return &(operator*()); }

self& operator++() { //运算符前置++的重载
node = (link_type)((*node).next);
return *this;
}
self operator++(int) { //运算符后置++的重载
self tmp = *this;
++*this;
return tmp;
}
self& operator--() { //运算符前置--的重载
node = (link_type)((*node).prev);
return *this;
}
self operator--(int) { //运算符后置--的重载
self tmp = *this;
--*this;
return tmp;
}
};

list的数据结构

SGI list是一个双向链表,而且是一个环状双向链表:

1
2
3
4
5
6
7
8
9
template<class T,class Alloc = alloc> //缺省使用alloc为配置器:w
class list{
protected :
typedef __list_node<T> list_node ;
public :
typedef list_node* link_type ;
protected :
link_type node ; //只要一个指针,便可以表示整个环状双向链表
};

如果让指针 node 指向刻意置于尾端的一个空白节点, node 便能符合 STL 对于“前闭后开”区间的要求,成为 last 迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
//取首元素,node是尾端的一个空节点
iterator begin() { return (link_type) ((*node).next); }
//取尾元素的下一个,即node
iterator end() { return node; }
//为空,说明只有node
bool empty() const { return node->next == node; }
size_type size() const {
size_type result = 0;
distance(begin(), end(), result);
return result;
}
reference front() { return *begin(); }
reference back() { return *(--end()); }

list的构造与内存管理

list采用list_node_allocator来配置节点空间,以下四个函数分别用来配置、释放、构造、销毁一个节点。

1
2
3
4
5
6
7
8
template<class T,class Alloc = alloc> //缺省使用alloc为配置器:w
class list{
protected :
typedef __list_node<T> list_node ;
//专属之空间配置器,每次配置一个节点大小
typedef simple_alloc<list_node,Alloc> list_node_allocator;
...
};

list_node_allocator(n)表示配置n个节点空间,配置、释放、构造、销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//配置一个节点
link_type get_node() { return list_node_allocator::allocate(); }
//释放一个节点
void put_node(link_type p) { list_node_deallocator::deallocate(p); }
//产生一个节点,带有元素值
link_type create_node(const T& x) {
link_type p = get_node();
construct(&p->data, x);
return p;
}
//销毁一个节点
void destroy_node(link_type p) {
destroy(&p->data);
put_node(p);
}

list提供了默认的构造函数,使得可以创建一个空list:

1
2
3
4
5
6
7
8
public:
list() { empty_initialize(); } //默认构造函数
protected:
void empty_initialize() {
node = get_node(); //配置一个节点空间
node->next = node;
node->prev = node;
}

当我们以push_back()将新元素插入list尾端时,此函数内部调用insert()insert()是一个重载函数,最简单的一种如下:

1
2
3
4
5
6
7
8
iterator insert(iterator position, const T& x){//在迭代器position所指位置插入一个节点,内容为x
link_type tmp = create_node(x);
tmp->next = position.node;
tmp->prev = position.node->prev;
(link_type(position.node->prev))->next = tmp;
position.node->prev =tmp;
return tmp;
}

当连续插入5个节点之后,list的状态如图,如果希望在list内部的某处插入新节点,首先必须确定插入位置,例如希望在数据为3的节点处插入一个数据值为99的节点,可以:

1
2
3
ilite = find(li.begin, li.end(), 3);
if (ilite != 0)
il.insert(ilite, 99);

list的元素操作

push_front()函数:将新元素插入于list头端,内部调用insert()函数。

1
void push_front(const T&x)  { insert(begin(),x); }

push_back()函数:将新元素插入于list尾端,内部调用insert()函数。

1
void push_back(const T& x)   {  insert(end(),x); }

erase()函数:移除迭代器position所指节点。

1
2
3
4
5
6
7
8
iterator erase(iterator position){
link_type next_node=link_type(position.node->next);
link_type prev_node=link_type(position.node->prev);
prev_node->next=next_node;
next_node->prev=prev_node;
destroy_node(position.node);
return iterator(next_node);
}

pop_front()函数:移除头结点,内部调用erase()函数。

1
void pop_front()  {  erase(begin());  }

pop_back()函数:移除尾结点,内部调用erase()函数。

1
2
3
4
void pop_back(){
iterator i = end();
erase(--i);
}

clear()函数:清除所有节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class T, class Alloc>  
void list<T, Alloc>::clear()
{
link_type cur = (link_type) node->next;//node原来指向list的end,node->next为begin
while (cur != node)
{
link_type tmp = cur;
cur = (link_type) cur->next;
destroy_node(tmp);
}
// 恢复node原始状态
node->next = node;
node->prev = node;
}

remove():将数值为value的所有元素移除

1
2
3
4
5
6
7
8
9
10
11
12
template <class T, class Alloc>
void list<T, Alloc>::remove(const T& value) {
iterator first = begin();
iterator last = end();
while(first != end) {
iterator next = first;
++ next;
if (*first == value)
erase(first);
first = next;
}
}

transfer()迁移函数:将[ frirst , last ) 内所有元素移动到position之前。

1
2
3
4
5
6
7
8
9
10
11
void transfer(iterator position, iterator first, iterator last) {
if (position != last) {
(*(link_type((*last.node).prev))).next = position.node; //(1)
(*(link_type((*first.node).prev))).next = last.node; //(2)
(*(link_type((*position.node).prev))).next = first.node;//(3)
link_type tmp = link_type((*position.node).prev); //(4)
(*position.node).prev = (*last.node).prev; //(5)
(*last.node).prev = (*first.node).prev; //(6)
(*first.node).prev = tmp; //(7)
}
}

list公开提供的是所谓的接合操作splice,splice结合操作将连续范围的元素从一个list移动到另一个list的某个定点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int iv[5] = { 5,6,7,8,9 };
list<int> ilist2(iv,iv+5);

//目前,ilist的内容为 0 2 99 3 4
ite = find(ilist.begin(),ilist.end(),99);
ilist.splice(ite,ilist2); // 0 2 5 6 7 8 9 99 3 4
ilist.reverse(); // 4 3 99 9 8 7 6 5 2 0
ilist.sort(); // 0 2 3 4 5 6 7 8 9 99

// 将链表x移动到position所指位置之前
void splice(iterator position, list& x)
{
if (!x.empty())
transfer(position, x.begin(), x.end());
}

// 将链表中i指向的内容移动到position之前
void splice(iterator position, list&, iterator i)
{
iterator j = i;
++j;
if (position == i || position == j) return;
transfer(position, i, j);
}

以下是merge()reverse()sort()的源代码,有了transfer()在手,这些操作都不难完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<class T, class Alloc>
void list<T, Alloc>::merge(list<T, Alloc>& x) {
iterator first1 = begin();
iterator last1 = end();
iterator first2 = x.begin();
iterator last2 = x.begin();

while(first1 != last1 && first2 != last2)
if (*first2 < *first1) {
iterator next = first2;
transfer(first1, first2, ++next);
first2 = next;
}
else
++first1;
if (first2 != last2)
transfer)last1, first2, last2);
}

reverse()*this的内容逆向重置

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T, class Alloc>
void list<T, Alloc>::reverse() {
// 以下判断,如果是空链表,或仅有一个元素,就不操作
if (node->next == node || size() == 1)
return;
iteratro first = begin();
++first;
while(first != end()) {
iterator old = first;
++ first;
transfer(begin(), old, first);
}
}

list不能使用STL算法sort(),必须使用自己的sort():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template <class T, class Alloc>
void list<T, Alloc>::sort() {
// 以下判断,如果是空链表,或仅有一个元素,就不操作
if (node->next == node || size() == 1)
return;

list<T, Alloc> carry;
list<T, Alloc> counter[64];
int fill = 0;
while(!empty()) {
carry.splice(carry.begin(), *this, begin());
int i = 0;
while(i < fill && !counter[i].empty()) {
counter[i].merge(carry);
carry.swap(counter[i++]);
}
carry.swap(counter[i]);
if (i == fill)
++ fill;
}

for (int i = 1; i < fill; i ++)
counter[i].merge(counter[i-1]);
swap(counter[fill-1]);
}

deque

deque(double-ended queue,双端队列)是一种具有队列和栈的性质的数据结构。相比于vector单向开口的连续线性空间而言,deque则是一种双向开口的连续线性空间,可以在头尾两端分别做元素的插入和删除操作。虽然vector从技术层面也可以对头部操作,但是效率极低。

deque与vector的最大差异在于:

  1. deque可以在常数时间内完成对头部元素的插入或删除操作;
  2. deque没有容量的概念,它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接起来。deque没有必要提供所谓的空间保留reserve功能。

虽然deque也提供Random Access Iterator,但它的迭代器并不是普通指针,其复杂度和vector不同。除非必要我们应该选择vector而不是deque。对deque进行排序操作,为了得到最高效率,可先将deque复制一个vector,将vector排序后再复制回deque。

deque的中控器

deque由一段一段的定量连续空间构成。一旦有必要在dequer前端或尾端增加新空间,便配置一段定量连续空间,串接在整个deque的头端或尾端。deque的最大任务是在这些分段的定量连续空间上,维护其整体连续的假象,并提供随机存取的接口。避开了“重新配置、复制、释放”的轮回,代价则是复杂的迭代器架构。

deque采用一块所谓的map作为主控。这里所谓map是一小块连续空间,其中每个元素(此处称为一个节点,node)都是指针,指向另一段(较大的)连续线性空间,称为缓冲区。缓冲区才是deque的储存空间主体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<class T, class Alloc = alloc, size_t BufSiz = 0>
class deque{
public :
typedef T value_type ;
typedef value_type* pointer ;
...
protected :
//元素的指针的指针(pointer of pointer of T)
typedef pointer* map_pointer ; //其实就是T**

protected :
map_pointer map ; //指向map,map是块连续空间,其内的每个元素
//都是一个指针(称为节点),指向一块缓冲区
size_type map_size ;//map内可容纳多少指针
...
};

map其实是一个T**,所指之物是另一个指针,指向类型为T的一块空间。

deque的迭代器

deque是分段连续空间,维持“整体连续”假象的任务,落在迭代器的operator++operator--两个运算子上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<class T, class Ref, class Ptr, size_t BufSiz>
struct __deque_iterator{ //未继承std::iterator
typedef __deque_iterator<T,T&,T*,BufSize> iterator ;
typedef __deque_iterator<T,const T&,const T*,BufSize> const_iterator ;
static size_t buffer_size() {return __deque_buf_size(BufSize,sizeof(T)) ;}

//未继承std::iterator,所以必须自行撰写五个必要的迭代器相应型别
typedef random_access_iterator_tag iterator_category ;
typedef T value_type ;
typedef Ptr pointer ;
typedef Ref reference ;
typedef size_t size_type ;
typedef ptrdiff_t difference_type ;
typedef T** map_pointer ;

typedef __deque_iterator self ;

//保持与容器的联结
T *cut ; //此迭代器所指之缓冲区中的现行(current)元素
T *first ; //此迭代器所指之缓冲区的头
T *last ; //此迭代器所指之缓冲区的尾(含备用空间)
map_pointer node ; //指向管控中心
...
};

其中用来决定缓冲区大小的函数buffer_size()调用__deque_buf_size(),后者是一个全局函数:

1
2
3
4
5
6
7
8
// 如果n不为0,传回n,表示buffer size由用户定义
// 如果n为0,表示buffer size使用默认值,那么
// 如果sz(元素大小,sizeof(value_type))小于512,传回512/sz
// 如果sz不小于512,传回1
inline size_t __deque_buf_size(size_t n, size_t sz)
{
return n != 0 ? n : (sz < 512 ? size_t(512/sz) : size_t(1));
}

假设现在我们产生1个deque<int>,并令其缓冲区大小为32,于是每个缓冲区可容纳32/sizeof(int)=4个元素:经过某些操作之后,deque拥有20个元素,那么其begin()end()所传回的两个迭代器应该如图4-12所示。这两个迭代器事实上一直保持在deque内,名为start和finish,稍后在deque数据结构中便可看到。

20个元素需要20/8=3个缓冲区,所以map之内运用了三个节点。迭代器start内的cur指针当然指向缓冲区的第一个兀素,迭代器finish内的指针当然指向缓冲区的最后元素(的下一位置)。注意,最后1个缓冲区尚有备用空间。稍后如果有新元素要插入于尾端,可直接拿此备用空间来使用。

下面是deque迭代器的几个关键行为.由于迭代器内对各种指针运算都进行了重载操作,所以各种指针运算如加、减、前进、后退都不能直观视之。其中最关键的就是:一旦行进时遇到缓冲区边缘,要特别当心,视前进或后退而定,可能需要调用set_node()跳一个缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
void set_node(map_pointer new_node) {
node = new_node;
first = *new_node;
last = first + difference_type(buffer_size());
}

//以下各个重载运算子是__deque_iterator<>成功运作的关踺
reference operator*() const { return *cur; }
pointer operator->() const { return &(operator*(); }

difference_type operator-(const self& x) const {
return difference_type(buffer_size()) * (node - x.node - 1) + (cur - first) + (x.last - x.cur);
}

self& operator++() {
++ cur; //切换至下一个元素
if (cur == last) { // 如果已达所在缓冲区的尾端
set_node(node + 1); //就切换至下一节点(亦即缓冲区)
cur = first; // 的第一个元素
}
return *this;
}

self operator++(int) {
// 后置式标准写法
self tmp = *this;
++*this;
return tmp;
}

self& operaeor--() {
if (cur == first) { // 如果已达所在缓冲区的头端,
set_node(node - 1); // 就切涣至前一节点(亦即缓冲区)
cur = last; // 的最后一个元素
}
-- cur; //切换至前一个元素
return *this;
}

self operator--(int) {
// 后置标准写法
self tmp = *this;
-- *this;
return tmp;
}

//以下实现随机存取.迭代器可以直接跳跃n个距离
self& operator+=(difference_type n) {
difference_type offset = n + (cur - first);
if (offset >= 0 && offset < difference_type(buffer_size()))
// 目标位置在同一缓冲区内
cur += n;
else {
// 标的位不在同一缓冲区内
difference_type node_offset = offset > 0 ? offset/difference_type(buffer_size()) : -difference_type((-offset-1)/buffer_size()) - 1;
// 切换至正确的节点〈亦即缓冲区)
set_node(node + node_offset);
// 切换至正确的元素
cur = first + (offset - node_offset * difference_type(buffer_size()));
}
return *this;
}

self operator+(difference_type n) const {
self tmp = *this;
return tmp += n; // 调用operator+=
}

self& operator-=(difference_type n) { return *this += -n; }
// 以上利用operator+= 来完成operator-=

self operator-(difference_type n) const {
slef tmp = *this;
return tmp -= n; // 调用operator-=
}

// 以下实现随机存取。迭代器可以直接跳跃n个距离
reference operator[] (difference_type n) const { return *(*this + n); }
// 以上调用operator*, operator+

bool operator== (const self& x) const { return cur == x.cur; }
bool operator!= (const self& x) const { return !(*this == x); }
bool operator< (const self& x) const {
return (node == x.node) ? (cur < x.cur) : (node < x.node);
}

deque的数据结构

deque除了维护一个指向map的指针外,也维护start,finish两个迭代器,分别指向第一缓冲区的第一个元素和最后缓冲区的最后一个元素(的下一个位置)。此外,也必须记住目前的map大小,因为一旦map提供的节点不足,就必须重新配置更大的一块map。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<class T, class Alloc = alloc, size_t BufSiz = 0>
class deque{
public :
typedef T value_type ;
typedef value_type* pointer ;
typedef size_t size_type ;
public :
typedef __deque_iterator<T,T&,T*,BufSiz> iterator ;
protected :
//元素的指针的指针(pointer of pointer of T)
typedef pointer* map_pointer ;
protected:
iterator start ; //表现第一节点
iterator finish ; //表现最后一个节点
map_pointer map ; //指向map,map是块连续空间,其每个元素都是个指针,指向一个节点(缓冲区)
size_type map_size ; //map内有多少指针
...
} ;

deque的构造与内存管理

以程序实现来初步了解deque的构造和内存管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <deque>
#include <iostream>
#include <algorithm>
using namespace std;

class alloc {
};

int main() {
deque<int, allocator<int>> ideq(20, 9);
//deque<int,alloc,8> ideq(20, 9);//在linux下不支持alloc
cout << "size=" << ideq.size() << endl;

for (int i = 0; i < ideq.size(); ++i)
cout << ideq[i] << ' ';
cout << endl;

for (int i = 0; i < 3; ++i)
ideq.push_back(i);

for (int i = 0; i < ideq.size(); ++i)
cout << ideq[i] << ' ';
cout << endl;
cout << "size=" << ideq.size() << endl;

ideq.push_back(3);
for (int i = 0; i < ideq.size(); ++i)
cout << ideq[i] << ' ';
cout << endl;
cout << "size=" << ideq.size() << endl;

ideq.push_front(99);
for (int i = 0; i < ideq.size(); ++i)
cout << ideq[i] << ' ';
cout << endl;
cout << "size=" << ideq.size() << endl;

ideq.push_front(98);
ideq.push_front(97);
for (int i = 0; i < ideq.size(); ++i)
cout << ideq[i] << ' ';
cout << endl;
cout << "size=" << ideq.size() << endl;

deque<int, allocator<int>>::iterator itr;
itr = find(ideq.begin(), ideq.end(), 99);
cout << *itr << endl;
cout << *(itr._M_cur) << endl;
}

————————————————————————————————————————————————————————————————————————————————
[root@192 4_STL_sequence_container]# ./4_4_5_deque-test
size=20
9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9
9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 0 1 2
size=23
9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 0 1 2 3
size=24
99 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 0 1 2 3
size=25
97 98 99 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 0 1 2 3
size=27
99
99

一开始声明一个deque:

1
deque<int, alloc, 32> ideq(20, 9);

其缓冲区为32bytes,并令其保留20个元素空间,每个元素初值为9。现在,deque的情况如图4-12。

deque自行定义了2个专属的空间配置器:

1
2
3
4
5
protected:
// 每次配置一个元素大小
typedef simple_alloc<value_type, Alloc> data_allocator;
// 每次配置一个指针大小
typedef simple_alloc<pointer, Alloc> map_allocator;

并提供一个constructor:

1
2
3
4
deque(int n,const value_type& value):start(),finish(),map(0),map_size(0)
{
fill_initialize(n, value);
}

其内调用的fill_initialize()负责产生并安排好deque的结构,并将元素的初值设定妥当。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::fill_initialize(size_type n, const value_type& value) {
//负责产生并安排好deque的结构,并将元素的初值设定好
create_map_and_nodes(n); //把deque的结构都安排好
map_pointer cur;
__STL_TRY {
//已经获得空间,为每个节点缓冲区设定初值
for(cur = start.node; cur < finish.node; ++cur) {
uninitialized_fill(*cur, *cur+buffer_size(), value);
}
//最后一个节点的设定稍有不同(尾端可能有备用空间,不必设初值)
uninitialized_fill(finish.first, finish.cur, value);
} catch( ... ) {
...
}
}

其中create_map_and_nodes()负责产生并安排好deque的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template<class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::creat_map_and_nodes(size_type num_elements)
{ //产生并安排好deque的结构
size_type num_nodes=num_elements/buffer_size()+1;
//一个map要管理几个节点,最少8个,最多是“所需节点数+2”,前后各预留一个,扩充时用
map_size=max(initial_map_size(),num_nodes+2);
map=map_allocator::allocate(map_size);

// 以下令nstart和finish指向map所拥有的全部节点的最中央区段
// 保持在最中央,可使头尾两端的扩充能量一样大,每个节点对应一个缓冲区
map_pointer nstart=map+(map_size-num_nodes)/2;
map_pointer nfinish=nstart+num_nodes-1;

map_pointer cur;
__STL_TRY {
// 为map的每个节点配置缓冲区,所有缓冲区加起来就是deque的可用空间
for(cur=nstart; cur <= nfinish; cur++)
{
*cur=allocate_node();
}
} catch(...) {
...
}

// 为deque内的两个迭代器start和end设置正确内容
start.set_node(nstart);
finish.set_node(nfinish);
start.cur=start.first;
finish.cur=finish.first+num_elements%buffer_size();
}

接下来范例程序为每个元素重新设置值。在尾端插入三个元素

deque的元素操作

1
2
3
4
5
6
7
8
9
10
11
12
void push_back(const value_type &t)
{
if (finish.cur != finish.last - 1)
{
construct(finish.cur, t);
++finish.cur;
}
else //需要配置新的缓冲区
{
push_back_aux(t);
}
}

如果尾端只剩一个元素备用空间,push_back()调用push_back_aux(),先配置一块新的缓冲区,再设妥新元素内容,然后更改迭代器finish状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::push_back_aux(const value_type &t) //只剩最后一个缓冲区的最后一个备用缓冲区
{ //先配置一块新的缓冲区,再设新元素内容,更改迭代器finish的状态
value_type t_copy = t;
reserve_map_at_back(); //若符合某种条件,则必须重换一个map
*(finish.node + 1) = allocate_node(); //配置一新缓冲区
__STL_TRY {
construct(finish.cur, t_copy);
finish.set_node(finish.node + 1);
finish.cur = finish.first;
}
__STL_UNWIND(deallocate_node(*(finish.node + 1)));
}

现在deque的状态如图:

接下来范例程序在deque的前端插入一个新元素:

1
ideq.push_front(99);

push_front()的操作如下:

1
2
3
4
5
6
7
8
9
10
void push_front(const value_type &t)
{
if (start.cur != start.first) { // 第一缓冲区尚有空间
construct(start.cur - 1, t); // 直接在备用空间上构造元素
-- start.cur; // 调整第一缓冲区的使用状态
}
else {
push_front_aux(t);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::push_front_aux(const value_type &t)
{
value_type t_copy = t;
reserve_map_at_front();
*(start.node - 1) = allocate_node();
__STL_TRY {
start.set_node(start.node - 1);
start.cur = start.last - 1;
construct(start.cur, t_copy);
}
catch(...) {
start.set_node(start.node + 1);
start.cur = start.first;
deallocate_node(*(start.node - 1));
throw;
}
}

一开始调用reserve_map_at_front(),判断是否需要扩充map,如果有则付诸行动。后续流程配置了一块新缓冲区,并直接将节点安置在现有的map中,设定新元素,改变迭代器的状态:

接下来插入两个新的:

1
2
ideq.push_front(98);
ideq.push_front(97);

这一次,由于第一缓冲区有备用空间,push_front()可以在备用空间上构造新元素:

reserve_map_at_back()reserve_map_at_front()决定map是否需要整治:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void reserve_map_at_back(size_type nodes_to_add = 1)
{
if (nodes_to_add + 1 > map_size - (finish.node - map)) //map尾端的节点备用空间不足
{
//换一个map(配置更大的,拷贝原来的,释放原来的)
reallocate_map(nodes_to_add, false);
}
}

void reserve_map_at_front(size_type nodes_to_add = 1)
{
if (nodes_to_add > start.node - map)
{
reallocate_map(nodes_to_add, true);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
template<class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::reallocate_map(size_type node_to_add, bool add_at_front) {
size_type old_num_nodes = finish.node - start.node + 1;
size_type new_num_nodes = old_num_nodes + nodes_to_add;

map_pointer new_nstart;
if (map_size > 2 * new_num_nodes) {
new_nstart = map + (map_size - new_num_nodes) / 2 + (add_at_front ? nodes_to_add : 0);
if (new_nstart < start.node)
copy(start.node, finish.node+1, new_nstart);
else
copy_backward(start.node, finish.node+1, new_nstart+old_num_nodes);
}
else {
size_type new_map_size = map + max(map_size, nodes_to_add) + 2;
//配置一块空间,准备给新map使用
map_pointer new_map = allocator::allocate(new_map_size);
new_nstart = new_map + (new_map_size - new_num_nodes) / 2 + (add_at_front ? nodes_to_add : 0);
//把原nap内容拷贝过来
copy(start.node, finish.node+1, new_nstart);
// 释放原map
map_allocator::deallocate(map, map_size);
// 设定新的起始地址与大小
map = new_map;
map_size = new_map_size;
}
// 新设定迭代器start和finish
start.set_node(new_nstart);
finish.set_node(new_nstart + old_num_nodes - 1);

pop是将元素拿掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void pop_back()
{
if (finish.cur != finish.first)
{
--finish.cur;
destroy(finish.cur); //将最后元素析构,左开右闭
}
else
{
//最后缓冲区没有任何元素
pop_back_aux(); //这里将进行缓冲区的释放工作
}
}

void pop_back_aux()
{ //finish.cur==finish.first 释放该缓冲区
deallocate_node(finish.first); //释放最后一个缓冲区
finish.set_node(finish.node - 1);
finish.cur = finish.last - 1;
destroy(finish.cur); //析构
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void pop_front()
{
if (start.cur != start.last - 1)
{
destroy(start.cur);
++start.cur;
}
else
pop_front_aux();
}

void pop_front_aux()
{
destory(start.cur);
deallocate_node(start.last);
start.set_node(start.node + 1);
start.cur = start.first;
}

clear用来清除整个deque。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void clear()
{
for (map_pointer node = start.node + 1; node < finish.node; ++node)
{
destory(*node, *node + buffer_size()); //析构元素
data_allocator::deallocate(*node, buffer_size()); //释放内存
}

if (start.node != finish.node) //至少有两个缓冲区
{
destroy(start.cur, start.last);
destroy(finish.first, finish.cur);
//保留头缓冲区
data_allocator::deallocate(finish.first, buffer_size());
}
else
{
destroy(start.cur, finish.cur);
}
finish = start;
}

下面这个例子是clear(),用来清除某个元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 清除pos所指的元素.pos为清除点
iterator erase(iterator pos) {
iterator next = pos;
++next;
difference_type index = pos - start; // 清除点之前的元素个数
if (index < (size() >> 1)) { // 如果清除点之前的元素比较少,
copy_backward(start, pos, next); // 就移动清除点之前的元素
pop_front(); // 移动完毕,最前一个元素冗余,去除之
}
else { // 清除点之后的元素比较少,
copy(next, finish, pos); // 就移动清除点之后的元素
pop_back(); // 移动完毕,最后一个元素冗余,去除之
}
return start + index;
}

下面这个例子是erase(),用来清除[first, last]区间内的所有元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template<Class T, class Alloc, size_t BufSize>
deque<T, Alloc, BufSize>::eraee(iterator first, iterator last) {
if (first == start && last == finish) {
clear();
return finish;
}
else {
difference_type n = last - first;
difference_type elems_before = first - start;
if (elems_before < (size() - n) / 2) {
copy_backward(start, first, last);
iterator new_start = start + n;
destroy(start, new_start);
// 以下将冗余的缓冲区释放
for(map_pointer cur = start.node; cur < new_start.node; ++cur)
data_allocator::deallocate(*cur, buffer_size());
start = new_start; //设定deque的新起点
}
else {
copy(last, finish, first);
iterator new_finish = finish - n;
destroy(new_finish, finish);
// 以下将冗余的缓冲区释放
for(map_pointer cur = new_finish.node + 1; cur <= finish.node; ++cur)
data_allocator::deallocate(*cur, buffer_size());
finish = new_finish; //设定deque的新尾点
}
return start + elems_before;
}
}

最后一个例子是insert。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
iterator insert(iterator position, const value& x) {
if (position.cur == start.cur) {
push_front(x);
return start;
}
else if (position.cur == finish.cur) {
push_back(x);
iterator tmp = finish;
-- tmp;
return tmp;
}
else {
return insert_aux(position, x);
}
}

template <class T, class Alloc, size_t BufSize>
typename deque<T, Alloc, BufSize>::iterator
deque<T, Alloc, BufSize>::insert_aux(iterator pos, const value_type& x) {
difference_type index = pos - start;
value_type x_copy = x;
if (index < size() / 2) {
push_front(front());
iterator front1 = start;
++ front1;
iterator front2 = front1;
++ front2;
pos = start + index;
iterator pos1 = pos;
++ pos1;
copy(front2, pos1, front1);
}
else {
push_back(back());
iterator back1 = finish;
-- back1;
iterator back2 = back1;
-- back2;
pos = start + index;
copy_backward(pos, back2, back1);
}
*pos = x_copy;
return pos;
}

stack

stack概述

stack是一种先进后出(First In Last Out,FILO)的数据结构,它只有一个出口。stack允许新增元素,移除元素、取得最顶端元素,但不允许有遍历行为。若以deque为底部结构并封闭其头端开口,便轻而易举地形成了一个stack。同时,也可以使用list作为底层实现,它也是具有双向开口的数据结构。由于stack系以底部容器完成其所有工作,而具有这种“修改某物接口,形成另一种风貌”之性质者,称为adapter(配接器)。因此,STL stack往往不被称为container,而被归类为container adapter。

因为stack的所有元素的进出都必须符合“先进后出”的条件,即只有stack顶端的元素,才会被外界取用,所以stack不提供走访功能,也不提供迭代器。

stack 完整定义

SGI STL以deque作为缺省情况下的stack底部结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
template<class T, class Sequence = deque<T> >
class stack{
// __STL_NULL_TMPL_ARGS展开为<>
friend bool operator== __STL_NULL_TMPL_ARGS(const stack& , const stack&) ;
friend bool operator< __STL_NULL_TMPL_ARGS(const stack& , const stack&) ;
public :
typedef typename Sequence::value_type value_type ;
typedef typename Sequence::size_type size_type ;
typedef typename Sequence::reference reference ;
typedef typename Sequence::const_reference const_reference ;
protected:
Sequence e ; //底层容器
public :
//以下完全利用Sequence c 的操作,完成stack的操作
bool empty() const {return c.empty() ;}
size_type size() {return c.size();}
reference top() {return c.back();}
const_reference top() const {return c.back();}
//deque是两头可进出,stack是末端进,末端出。
void push(const value_type& x) {c.push_back(x);}
void pop() {c.pop_back();}
};

template <class T, class Sequence>
bool operator==(const stack<T, Sequence>& x, const stack<T, Sequence>& y)
{
return x.c == y.c;
}

template <class T, class Sequence>
bool operator<(const stack<T, Sequence>& x, const stack<T, Sequence>& y)
{
return x.c < y.c;
}

以list作为stack的底部容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stack>
#include <list>
#include <iostream>
#include <algorithm>

using namespace std;

int main() {
stack<int, list<int>> istack;
//stack<int> istack; //缺省时使用deque
istack.push(1);
istack.push(3);
istack.push(5);
istack.push(7);

cout << istack.size() << endl; //4
cout << istack.top() << endl; //7

istack.pop();
cout << istack.top() << endl; //5
istack.pop();
cout << istack.top() << endl; //3
istack.pop();
cout << istack.top() << endl; //1
cout << istack.size() << endl; //1

return 0;
}

queue

queue概述

queue是一种先进先出(First In First Out,FIFO)的数据结构,它有两个出口。queue允许新增元素、移除元素、从最底端加入元素、取得最顶端元素,但不允许遍历行为,也不提供迭代器。若以deque为底部结构并封闭其头端入口和尾部出口,便轻而易举地形成了一个queue。同时,也可以使用list作为底层实现,它也是具有双向开口的数据结构。因为queue的所有元素的进出都必须符合“先进先出”的条件,queue不提供走访功能,也不提供迭代器。

由于queue系以底部容器完成其所有工作,而具有这种“修改某物接口,形成另一种风貌”之性质者,称为adapter(配接器)。因此,STL queue往往不被称为container,而被归类为container adapter。

queue 完整定义

SGI STL以deque作为缺省情况下的queue底部结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
template<class T, class Sequence = deque<T> >
class queue{
// __STL_NULL_TMPL_ARGS展开为<>
friend bool operator== __STL_NULL_TMPL_ARGS(const queue& , const queue&) ;
friend bool operator< __STL_NULL_TMPL_ARGS(const queue& , const queue&) ;
public :
typedef typename Sequence::value_type value_type ;
typedef typename Sequence::size_type size_type ;
typedef typename Sequence::reference reference ;
typedef typename Sequence::const_reference const_reference ;
protected :
Sequence c ; //底层容器
public :
//以下完全利用Sequence c的操作,完成queue的操作
bool empty() const {return c.empty();}
size_type size() const {return c.size();}
reference front() const {return c.front();}
const_reference front() const {return c.front();}
reference back() {return c.back();}
const_reference back() const {return c.back();}
//deque是两头可进出,queue是末端进,前端出。
void push(const value_type &x) {c.push_back(x) ;}
void pop() {c.pop_front();}
};

template <class T, class Sequence>
bool operator==(const queue<T, Sequence>& x, const queue<T, Sequence>& y)
{
return x.c == y.c;
}

template <class T, class Sequence>
bool operator<(const queue<T, Sequence>& x, const queue<T, Sequence>& y)
{
return x.c < y.c;
}

以list作为queue的底部容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <queue>
#include <list>
#include <iostream>
#include <algorithm>

using namespace std;

int main() {
queue<int, list<int>> iqueue;
//queue<int> iqueue; //缺省时使用deque
iqueue.push(1);
iqueue.push(3);
iqueue.push(5);
iqueue.push(7);

cout << iqueue.size() << endl; //4
cout << iqueue.front() << endl; //1

iqueue.pop();
cout << iqueue.front() << endl; //3
iqueue.pop();
cout << iqueue.front() << endl; //5
iqueue.pop();
cout << iqueue.front() << endl; //7
cout << iqueue.size() << endl; //1

return 0;
}

heap概述

heap并不归属于STL容器组件,扮演priority queue的助手,binary max heap适合作为priority queue的底层机制,priority queue允许用户以任意次序将元素推入容器,但是取出是一定是优先权最高(数值最高)的元素先出来。binary heap是一种complete binary tree(完全二叉树),整棵binary tree除了最底层的叶子节点外是填满的,而最底层的叶子节点由左至右不得有空隙。

complete binary tree整棵树内没有任何节点漏洞,这就可以利用array来存储completebinary tree的所有节点,将array的#0元素保留,那么当complete binary tree的某个节点位于array的i处时,其左子节点必位于array的2i处,右子节点必位于array的2i+1处,父节点必位于i/2处。我们需要的是一个array和一组heap算法,array的缺点是无法动态改变大小,以vector代替array是更好的选择。

根据元素排列方式,heap分为max-heap和min-heap两种,前者每个节点的键值都大于或等于其子节点的值,后者每个节点的键值都小于或等于其子节点的值。max-heap中最大值在根节点,min-heap最小值在根节点。底层存储结构为vector或者array。STL 供应的是max-heap。heap的所有元素都必须遵循特别的排列规则,所以heap不提供遍历功能,也不提供迭代器。

heap算法

push_heap算法

push_heap算法:将新加入的元素放在最下层的叶节点,即vector的end()处,还需满足max-heap条件,执行所谓的percolate up(上溯)过程,即不断比较新节点和其父节点,如果键值比父节点大,就父子节点对换位置,最终将其放到合适的位置。举例如下:

下面是push_heap的实现细节,该函数接受两个迭代器,用来表达一个heap底部容器的头尾,并且新元素已经插入到底部容器的最尾端,如果不符合这两个条件,push_heap的执行结果不可预期。代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class RandomAccessIterator>
inline void push_heap(RandomAccessIterator first,RandomAccessIterator last){
//这个函数被调用时,新元素已经放在底部容器的最尾端
_push_heap_aux(first,last,distance_type(first),value_type(first));
}

template<class RandomAccessIterator,class Distance,class T>
inline void _push_heap_aux(RandomAccessIterator first,RandomAccessIterator last,Distance*,T*)
{_push_heap(first ,Distance((last-first)-1),Distance(0),T(*(last-1)));}

// 一下这组push_back()不允许指定大小比较标准
template<class RandomAccessIterator,class Distance,class T>
void _push_heap(RandomAccessIterator first,Distance holeIndex,Distance topIndex,T value)
{
Distance parent=(holeIndex-1)/2;//找出父节点
while(holeIndex>topIndex&&*(first+parent)<value){
//未到达顶端,父节点小于新值,使用<,所以STL heap是个max_heap。
*(first+holeIndex)=*(first +parent)//父值下移
holeIndex=parent;//调整位置,向上提升至父节点
parent=(holeIndex-1)/2;//找到新的父节点。
}//持续到顶端,或者满足heap的特性就停止了。
*(first+holeIndex)=value;//找到它应该处于的位置,插入操作结束。
}

pop_heap

如图所示的是pop heap算法的实际操演情况。既然身为max-heap,最大值必然在根节点。pop操作取走根节点(其实是移至底部容器vector的最后一个元素)之后,为了满足complete binary tree的条件,必须将最下一层最右边的叶节点拿掉,现在我们的任务是为这个被拿掉的节点找一个适当的位置。

为满足max-heap的条件(每个节点的键值都大于或等于其子节点键值), 我们执行一个所谓的percolate down(下溯)程序:将根节点(最大值被取走后,形成一个“洞”)填人上述那个失去生存空间的叶节点值,再将它拿来和其两个子节点比较键值(key),并与较大子节点对调位置、如此一直下放,直到这个“洞” 的键值大于左右两个子节点,或直到下放至叶节点为止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
template <class RandomAccessIterator>//提供首尾两个迭代器,否则结果不可预知。
inline void pop_heap(RandomAccessIterator first, RandomAccessIterator last) {
__pop_heap_aux(first, last, value_type(first));
}

template <class RandomAccessIterator, class T>
inline void __pop_heap_aux(RandomAccessIterator first,
RandomAccessIterator last, T*) {
__pop_heap(first, last - 1, last - 1, T(*(last - 1)), distance_type(first));
// pop动作的結果为底层容器的第一個元素。因此,首先设定欲调整值为尾值,然后將首值調至
// 尾节点(所以以上將迭代器result设为last-1)。然后重整 [first, last-1),
// 使之重新成一個合格的 heap。
}

template <class RandomAccessIterator, class T, class Distance>
inline void __pop_heap(RandomAccessIterator first, RandomAccessIterator last,
RandomAccessIterator result, T value, Distance*) {
*result = *first; // 設定尾值为首值,于是尾值即是結果,
// 可由调用底层容器之 pop_back() 取出尾值。
__adjust_heap(first, Distance(0), Distance(last - first), value);
// 以上欲重新調整 heap,洞号为 0,欲調整值为value。
}

template <class RandomAccessIterator, class Distance, class T>
void __adjust_heap(RandomAccessIterator first, Distance holeIndex,
Distance len, T value) {
Distance topIndex = holeIndex;
Distance secondChild = 2 * holeIndex + 2; // 洞节点之右子节点
while (secondChild < len) {
// 比较洞节点之左右兩个子值,然后以 secondChild 代表较大子节点。
if (*(first + secondChild) < *(first + (secondChild - 1)))
secondChild--;
// Percolate down:令较大大子值为洞值,再令洞号下移至较大子节点处。
*(first + holeIndex) = *(first + secondChild);
holeIndex = secondChild;
// 找出新洞节点的右子节点
secondChild = 2 * (secondChild + 1);
}

if (secondChild == len) { // 沒有右子节点,只有左子节点
// Percolate down:令左子值为洞值,再令洞号下移至左子节点处。
*(first + holeIndex) = *(first + (secondChild - 1));
holeIndex = secondChild - 1;
}

// 將欲调整值填入目前的洞号內。注意,此時肯定滿足次序特性。
// 依侯捷之见,下面直接改為 *(first + holeIndex) = value; 应该可以。
__push_heap(first, holeIndex, topIndex, value);
}
//此时的最大元素只是被放置在了底部容器的尾端,并未被取走,所以要取值,可以使用底部容器提供的back()操作函数,如果要移除,使用pop_back().

注意:pop_heap后,最大元素只是被置于底层容器的最尾部,尚未被取走。如果取值,可用back函数;如果移除,可用pop_back函数。

sort_heap

既然每次pop_heap都将最大值放到vector的末尾,那么如果每次都缩小pop_heap的参数范围(从后向前缩减一个与元素),那么最终得到的vector将是一个递增序列。

1
2
3
4
5
6
7
8
9
// 以下这个sort_heap()不允许指定「大小比较标准」
template <class RandomAccessIterator>
void sort_heap(RandomAccessIterator first, RandomAccessIterator last) {
// 以下,每執行一次 pop_heap(),極值(在STL heap中為極大值)即被放在尾端。
// 扣除尾端再執行一次 pop_heap(),次極值又被放在新尾端。一直下去,最後即得
// 排序結果。
while (last - first > 1)
pop_heap(first, last--); // 每執行 pop_heap() 一次,操作範圍即退縮一格。
}

make_heap

make_heap将一段现有的数据转化成一个heap,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 將 [first,last)排列为一个heap。
template <class RandomAccessIterator>
inline void make_heap(RandomAccessIterator first, RandomAccessIterator last) {
__make_heap(first, last, value_type(first), distance_type(first));
}

// 以下這組 make_heap() 不允許指定「大小比較標準」。
template <class RandomAccessIterator, class T, class Distance>
void __make_heap(RandomAccessIterator first, RandomAccessIterator last, T*,
Distance*) {
if (last - first < 2) return; // 如果長度為 0 或 1,不必重新排列。
Distance len = last - first;
// 找出第一個需要重排的子樹頭部,以 parent 標示出。由於任何葉節點都不需執行
// perlocate down,所以有以下計算。parent 命名不佳,名為 holeIndex 更好。
Distance parent = (len - 2) / 2;//找出第一个有子节点的节点

while (true) {
// 重排以 parent 為首的子樹。len 是為了讓 __adjust_heap() 判斷操作範圍
__adjust_heap(first, parent, len, T(*(first + parent)));//下溯程序
if (parent == 0) return; // 排序到根節點,程序就結束。
parent--; // (重排之子樹的)頭部向前一個節點,迭代过程,排序完一个就接着排序前一个。
}
}

heap测试实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <vector>
#include <iostream>
#include <algorithm>

using namespace std;

int main() {
{
// test heap (以vector完成)
int ia[9] = {0, 1, 2, 3, 4, 8, 9, 3, 5};
vector<int> ivec(ia, ia + 9);

make_heap(ivec.begin(), ivec.end());
for (int i = 0; i < ivec.size(); ++i) {
cout << ivec[i] << " "; // 9 5 8 3 4 0 2 3 1
}
cout << endl;

ivec.push_back(7);
push_heap(ivec.begin(), ivec.end());
for (int i = 0; i < ivec.size(); ++i) {
cout << ivec[i] << " "; // 9 5 8 3 4 0 2 3 1 4
}
cout << endl;

pop_heap(ivec.begin(), ivec.end());
cout << ivec.back() << endl; // 9
ivec.pop_back();

for (int i = 0; i < ivec.size(); ++i) {
cout << ivec[i] << " "; // 8 7 4 3 5 0 2 3 1
}
cout << endl;

sort_heap(ivec.begin(), ivec.end());
for (int i = 0; i < ivec.size(); ++i) {
cout << ivec[i] << " "; // 0 1 2 3 3 4 5 7 8
}
cout << endl;
}

{
// test heap (以array完成)
int ia[9] = {0, 1, 2, 3, 4, 8, 9, 3, 5};

make_heap(ia, ia+9);
// array无法动态改变大小,因此不可以对满载的 array做 push_heap()动作。
//因为那得先在 array尾端增加㆒个元素。

sort_heap(ia, ia+9);
for (int i = 0; i < 9; ++i) {
cout << ia[i] << " "; // 0 1 2 3 3 4 5 8 9
}
cout << endl;

make_heap(ia, ia+9);
pop_heap(ia, ia+9);
cout << ia[8] << endl; // 9
}

{
// test heap (底层以 array完成)
int ia[6] = {4,1,7,6,2,5};
make_heap(ia, ia+6);
for(int i=0; i<6; ++i)
cout << ia[i] << ' '; // 7 6 5 1 2 4
cout << endl;
}
}

priority_queue概述

priority_queue是一个拥有权值观念的queue,它允许加入新元素,移除旧元素,审视元素值等功能.只允许在尾部加入元素,并从头部取出元素,除此之外别无其他存取元素的途径。priority_queue缺省情况下是以vector为底层容器,再加上max-heap处理规则,STL priority_queue往往不被归类为Container(容器),而被归类为container adapter。priority_queue的所有元素,进出都有一定规则,只有queue顶端的元素,才有机会被外界取用。它不提供遍历功能,也不提供迭代器。

priority_queue 完整定义

priority_queue完全以底部容器为根据,再加上heap处理规则,所以实现很简单,缺省情况下是以vector为底部容器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
template <class T, class Sequence = vector<T>,
class Compare = less<typename Sequence::value_type> >
class priority_queue {
public:
typedef typename Sequence::value_type value_type;
typedef typename Sequence::size_type size_type;
typedef typename Sequence::reference reference;
typedef typename Sequence::const_reference const_reference;

protected:
Sequence c; //底层容器
Compare comp; //元素大小比较标准
public
priority_queue() : c() {}
explicit priority_queue(const Compare& x) : c() comp(x) {}

//...
//用到heap泛型算法作为其实现
//定义一个priority_queue实则是一个建堆的过程
template <class InputIterator>
priority_queue( InputIterator first, InputIterator last, const Compare& x)
: c (first, last), comp(x) {
make_heap(c.begin(), c.end(), comp);
}
priority_queue( InputIterator first, InputIterator last)
: c (first, last) {
make_heap(c.begin(), c.end(), comp);
}

bool empty() const {return c.empty(); }
size_type size() const { return c.size(); }
const_reference top() const { return c.front(); }
//....
void push(const value_type& x) {
__STL_TRY {
//先利用底层容器的 push_back将新元素推入末端,再重排heap
c.push_back(x);
push_heap(c.begin(), c.end(), comp);
}
__STL_UNWIND(c.clear());
}
void pop() {
__STL_TRY {
//先从heap内取出一个元素,并不是简单的弹出,而是重排heap,然后在以底层容器的pop_back取得被弹出的元素
pop_heap(c.begin(), c.end(), comp);
c.pop_back();
}
__STL_UNWIND(c.clear());
}
};

priority_queue测试实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <queue>
#include <iostream>
#include <algorithm>

using namespace std;

int main() {
// test priority queue
int ia[9] = {0, 1, 2, 3, 4, 8, 9, 3, 5};
priority_queue<int> ipq(ia, ia + 9);
cout << "size=" << ipq.size() << endl;

for (int i = 0; i < ipq.size(); ++i)
cout << ipq.top() << ' ';
cout << endl;

while(!ipq.empty()) {
cout << ipq.top() << ' ';
ipq.pop();
}
cout << endl;
}

————————————————————————————————————————————————————————————————————
[root@192 4_STL_sequence_container]# ./4_8_4_pqueue-test
size=9
9 9 9 9 9 9 9 9 9
9 8 5 4 3 3 2 1 0

slist概述

SGI STL另提供一个单向链表slist。slist和list的主要差别在于,前者的迭代器属于单向的Forward Iterator,后者的迭代器属于双向的BidirectionalIterator。slist的功能自然也受到一些限制,不过单向链表所耗用的空间更小,某些操作更快,不失为一种选择。slist和list共同的特点是,插入删除等操作不会造成原有的迭代器失效。

根据STL的习惯,插入操作会将新元素插入于指定位置之前。作为单向链表,slist没有任何方便的方法可以回头定出前一个位置,因此它必须从头找起。为此,slist特别提供了insert_aftererase_after函数供灵活调用。

slist的节点

slist节点和其迭代器的设计,运用了继承关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct __slist_node_base {
__slist_node_base *next;
};

template <class T>
struct __slist_node : public __slist_node_base
{
T data;
};

inline __slist_node_base* __slist_make_link(__slist_node_base* prev_node, __slist_node_base* new_node) {
// 令new节点的下一节点为prev节点的下一节点
new_node->next = prev_node->next;
prev_node->next = new_node;
return new_node;
}

inline size_t __slist_size(__slist_node_base* node) {
size_t result = 0;
for (; node != 0; node = node->next)
++ result;
return result;
}

slist的迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 单向链表的迭代器基本结构
struct __slist_iterator_base
{
typedef size_t size_type;
typedef ptrdiff_t difference_type;
typedef forward_iterator_tag iterator_category;

__slist_node_base* node;
__slist_iterator_base(__slist_node_base*x) : node(x) {}

void incr() {node = node->next;}

bool operator == (const __slist_iterator_base& x) const {
return node == x.node;
}

bool operator != (const __slist_iterator_base& x) const {
return node != x.node;
}
};

// 单向链表的迭代器结构
template<class T, class Ref, class Ptr>
struct __slist_iterator : public __slist_iterator_base
{
typedef __slist_iteratror<T, T&, T*> iterator;
typedef __slist_iteratror<T, const T&, const T*> const_iterator;
typedef __slist_iteratror<T, Ref, Ptr> self;

typedef T value_type;
typedef Ptr pointer;
typedef Ref reference;
typedef __slist_node<T> list_node;

__slist_iterator(list_node* x) : __slist_iterator_base(x) {}
// 调用slist<T>::end()时会造成__slist_iterator(0),于是调用上述函数
__slist_iterator() : __slist_iterator_base(0) {}
__slist_iterator(const iterator& x) : __slist_iterator_base(x.node) {}

reference operator*() const { return ((list_node*) node)->data; }
pointer operator->() const { return &(operator*()); }

self& operator++()
{
incr();
return *this;
}

self operator++(int)
{
self tmp = *this;
incr();
return tmp;
}
}

slist的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
template<class T, class Alloc = alloc>
class slist
{
public :
typedef T value_type ;
typedef value_type* pointer ;
typedef const value_type* const_pointer ;
typedef value_type& reference ;
typedef const value_type& const_reference ;
typedef size_t size_type ;
typedef ptrdiff_t difference_type ;


typedef __slist_iterator<T,T&,T*> iterator ;
typedef __slist_iterator<T,const T&,const T*> const_iterator ;

private :
typedef __slist_node<T> list_node ;
typedef __slist_node_base list_node_base ;
typedef __slist_iterator_base iterator_base ;
typedef simple_alloc<list_node,Alloc> list_node_allocator ;

static list_node* create_node(const value_type& x)
{
list_node* node = list_node_allocator:;allocate() ; //配置空间
__STL_TRY{
construct(&node->data,x) ;
node->next = 0 ;
}
__STL_UNWIND(list_node_allocator:;deallocate(node)) ;
return node ;
}

static void destroy_node(list_node* node)
{
destroy(&node->data) ; //将元素析构
list_node_allocator::deallocate(node) ; //释放空间
}

private :
list_node_base head ; //头部。注意,它不是指针,是实物


public:
slist() {head.next = 0 ;}
~slist(){clear() ;}

public :
iterator begin() {return iterator((list_node*)head.next) ;}
iterator end() {return iteator(0) ;}
iterator size() {const __slist_size(head.next) ;}
bool empty() const {return head.next == 0 ;}

//两个slist互换:只要将head交换互指即可
void swap(slist &L)
{
list_node_base* tmp = head.next;
head.next = L.head.next ;
L.head.next = tmp ;
}

public :
//取头部元素
reference front() {return ((list_node*)head.next)->data ;}

//从头部插入元素(新元素成为slist的第一个元素)
void push_front(const value_type& x)
{
__slist_make_link(&head,create_node(x)) ;
}

//注意,没有push_back()

//从头部取走元素(删除之)。修改head
void pop_front()
{
list_node* node = (list_node*)head.next ;
head.next = node->next ;
destroy_node(node);
}
.....
} ;

slist的测试实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// file: 4slist-test.cpp

// mingw64没有这个库
//#include <slist>
#include <iostream>
#include <algorithm>

using namespace std;

int main() {
int i;
slist<int> islist;
cout << "size=" << islist.size() << endl;
islist.push_front(9);
islist.push_front(1);
islist.push_front(2);
islist.push_front(3);
islist.push_front(4);

cout << "size=" << islist.size() << endl;

slist<int>::iterator ite =islist.begin();
slist<int>::iterator ite2=islist.end();
for(; ite != ite2; ++ite)
cout << *ite << ' '; // 4 3 2 1 9
cout << endl;

ite = find(islist.begin(), islist.end(), 1); //使用STL的find函数,可以找到1之前的那个迭代器
if (ite!=0)
islist.insert(ite, 99);
cout << "size=" << islist.size() << endl; // size=6
cout << *ite << endl; // 1

ite =islist.begin();
ite2=islist.end();
for(; ite != ite2; ++ite)
cout << *ite << ' '; // 4 3 2 99 1 9
cout << endl;

ite = find(islist.begin(), islist.end(), 3);
if (ite!=0)
cout << *(islist.erase(ite)) << endl; // 2

ite =islist.begin();
ite2=islist.end();
for(; ite != ite2; ++ite)
cout << *ite << ' '; // 4 2 99 1 9
cout << endl;
}


insert

insert函数的实现如下,__slist_previous函数可以根据头节点_M_head和位置节点__pos找到__pos之前的那个节点,然后调用_M_insert_after函数,实际调用__slist_make_link,在__pos-1节点后创建以__x为值的节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
islist.insert(ite, 99); 

iterator insert(iterator __pos, const value_type& __x) {
return iterator(_M_insert_after(__slist_previous(&this->_M_head,
__pos._M_node),
__x));
}

inline _Slist_node_base* __slist_previous(_Slist_node_base* __head,
const _Slist_node_base* __node)
{
while (__head && __head->_M_next != __node)
__head = __head->_M_next;
return __head;
}

_Node* _M_insert_after(_Node_base* __pos, const value_type& __x) {
return (_Node*) (__slist_make_link(__pos, _M_create_node(__x)));

关联式容器

根据“数据在容器中的排列”特性,容器可概分为序列式(sequence)和关联式(associative)两种。标准的STL关联式容器分为set(集合〕和map(映射表)两大类:以及这两大类的衍生体multi-set(多键集合)和multimap(多键映射表)。这些容器的底层机制均以RB-tree(红黑树)完成。RB-tree也是一个独立容器,但并不开放给外界使用。

RB-tree概述

首先介绍一下基本概念,二叉树:任何节点最多只有两个子节点,这两个子节点分别称为左子节点和右子节点。二叉搜索树:任何节点的键值一定大于其左子树中的每一个节点的键值,小于其右子树中的每一个节点的键值。所谓的RB-tree不仅是二叉搜索树,而且必须满足以下规则:

  • 每个节点不是红色就是黑色。
  • 根节点为黑色。
  • 如果节点为红色,其子节点必须为黑色。
  • 任意一个节点到到NULL(树尾端)的任何路径,所含之黑色节点数必须相同。

根据规则4,新增节点必须为红色;根据规则3,新增节点之父节点必须为黑色。当新增节点根据二叉搜索树的规则到达其插入点时,却未能符合上述条件时,就必须调整颜色并旋转树形,如下图:

插入节点,会导致不满足RB-tree的规则条件,经历左旋和右旋等操作,使得重新满足规则。

RB-tree节点设计

RB-tree的节点和迭代器都是双层结构,RB-tree迭代器的前进和后退操作,都是调用基础迭代器的increment和decrement实现的。RB-tree的极值通过minimum和maximum可以方便地查找到,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
typedef bool __rb_tree_color_type;
const __rb_tree_color_type __rb_tree_red = false; // 红色为0
const __rb_tree_color_type __rb_tree_black = true; // 黑色为1

struct __rb_tree_node_base
{
typedef __rb_tree_color_type color_type;
typedef __rb_tree_node_base* base_ptr;

color_type color; // 节点颜色,红色或黑色
base_ptr parent; // 该指针指向其父节点
base_ptr left; // 指向左节点
base_ptr right; // 指向右节点

static base_ptr minimum(base_ptr x)
{
while (x->left != 0) x = x->left; //一直向左走,找到最小值
return x;
}

static base_ptr maximum(base_ptr x)
{
while (x->right != 0) x = x->right; //一直向右走,找到最大值
return x;
}
};

template <class Value>
struct __rb_tree_node : public __rb_tree_node_base
{
typedef __rb_tree_node<Value>* link_type;
Value value_field; //节点值
};

RB-tree的迭代器

为了更大的弹性,RB-tree迭代器实现为两层,下图即为双层节点结构和双层迭代器结构之间的关系,__rb_tree_node继承自__rb_tree_node_base__rb_tree_iterator继承自__rb_tree_base_iterator

RB-tree迭代器属于双向迭代器,但不具备随机定位能力,其提领操作和成员访问操作与list十分近似,较为特殊的是其前进和后退操作:注意,RB-tree迭代器的前进操作operator()++调用了基层迭代器的increment(),RB-tree迭代器的后退操作operator--()则调用了基层迭代器的decrement()。前进或后退的举止行为完全依据二叉搜索树的节点排列法则,再加上实现上的某些特殊技巧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 基层迭代器
struct __rb_tree_base_iterator
{
typedef __rb_tree_node_base::base_ptr base_ptr;
typedef bidirectional_iterator_tag iterator_category;
typedef ptrdiff_t difference_type;

base_ptr node; //与容器之间生成一个连结关系

void increment()
{
if (node->right != 0) {
node = node->right;
while(node->left != 0)
node = node->left;
}
// 如果有右子节点。状况(1),就向右走。然后一直往左子树走到底,即是解答
else {
// 没有右子节点。状况(2),找出父节点。如果现行节点本身是个右子节点,就一直上溯,直到“不为右子节点”止
base_ptr y = node->parent;
while(node == y->right) {
node = y;
y = y->parent;
}
if (node->right != y)
node = y;
// 若此时的右子节点不等于此时的父节点,状况(3)此时的父节点即为解答。否则此时的node为解答。状况(4)
}
}

// 以下其实可实现于operator--内,因为再无他处会调用此函数了
void decrement()
{
if (node->color == _rb_tree_red && node->parent->parent == node)
node = node->right;
// 如果是红节点、且父节点的父节点等于自己,状况(1)右子节点即为解答
// 以上情况发生于node为header时(亦即node为end()时)
// 注意,header之右子节点即mostright,指向整棵树的max节点
else if (node->left != 0) {
base_ptr y = node->left;
while(y->right != 0)
y = y->right;
node = y;
}
// 如果有左子节点。状况(2)令y指向左子节点。当y有右子节点时,一直往右子节点走到底,最后即为答案
else {
base_ptr y = node->parent;
while (node == y->left) {
node = y;
y = y->parent;
}
node = y;
// 既非根节点,亦无左子节点,状况(3)找出父节点。当现行节点身为左子节点,一直交替往上走,直到现行节点不为左子节点
// 此时之父节点即为答案
}
}
};

template <class Value, class Ref, class Ptr>
struct __rb_tree_iterator : public __rb_tree_base_iterator
{
typedef Value value_type;
typedef Ref reference;
typedef Ptr pointer;
typedef __rb_tree_iterator<Value, Value&, Value*> iterator;
typedef __rb_tree_iterator<Value, const value&, const value*> const_iterator;
typedef __rb_tree_iterator<Value, Ref, Ptr> self;
typedef __rb_tree_node<Value>* link_type;

__rb_tree_iterator(){}
__rb_tree_iterator(link_type x) { node = x; }
__rb_tree_iterator (const iterator& it) { node = it.node; }

reference operator*() const { return link_type(node)->value_field; }
#ifndef __SGI_STL_NO_ARROW_OPERATOR
pointer operator->() const { return &(operator*()); }
#endif /*_SGI_STL_NO_ARROW_OPERATOR*/

selt& operator++(){ increment(); return *this; }
self operator++(int) {
self tmp = *this;
increment();
return tmp;
}

self& operator--() {decrement(); return *this; }
self operator--(int) {
self tmp = *this;
decrement();
return *this;
}
};

RB-tree数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//stl_tree.h
#ifndef __SGI_STL_INTERNAL_TREE_H
#define __SGI_STL_INTERNAL_TREE_H

/*
Red-black tree(红黑树)class,用来当做SLT关联容器的底层机制(如set,multiset,map,
multimap)。里面所用的insertion和deletion方法以Cormen, Leiserson 和 Riveset所著的
《算法导论》一书为基础,但是有以下两点不同:
(1)header不仅指向root,也指向红黑树的最左节点,以便用常数时间实现begin(),并且也指向红黑树的最右边节点,以便
set相关泛型算法(如set_union等等)可以有线性时间实现。
(2)当一个即将被删除的节点有两个孩子节点时,它的successor(后继)node is relinked into its place, ranther than copied,
如此一来唯一失效的(invalidated)的迭代器就只是那些referring to the deleted node.
*/
#include <stl_algobase.h>
#include <stl_alloc.h>
#include <stl_construct.h>
#include <stl_function.h>


template <class Key, class Value, class KeyOfValue, class Compare,
class Alloc = alloc>
class rb_tree {
protected:
typedef void* void_pointer;
typedef __rb_tree_node_base* base_ptr;
typedef __rb_tree_node<Value> rb_tree_node;
typedef simple_alloc<rb_tree_node, Alloc> rb_tree_node_allocator;
typedef __rb_tree_color_type color_type;
public:
//这里没有定义iterator,在后面定义
typedef Key key_type;
typedef Value value_type;
typedef value_type* pointer;
typedef const value_type* const_pointer;
typedef value_type& reference;
typedef const value_type& const_reference;
typedef rb_tree_node* link_type;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
protected:
link_type get_node() { return rb_tree_node_allocator::allocate(); }
void put_node(link_type p) { rb_tree_node_allocator::deallocate(p); }

link_type create_node(const value_type& x) {
link_type tmp = get_node(); // 配置空间
__STL_TRY{
construct(&tmp->value_field, x); // 构建内容
}
__STL_UNWIND(put_node(tmp));
return tmp;
}

link_type clone_node(link_type x) { // 复制一个节点(值和颜色)
link_type tmp = create_node(x->value_field);
tmp->color = x->color;
tmp->left = 0;
tmp->right = 0;
return tmp;
}

void destroy_node(link_type p) {
destroy(&p->value_field); // 析构内容
put_node(p); // 释放内存
}

protected:
// RB-tree 只以三个资料表现
size_type node_count; // 追踪记录树的大小(节点总数)
link_type header; //这个是实现上的一个技巧
Compare key_compare; // 节点的键值比较判断准则。是个函数 function object。

//以下三个函数用来方便取得header的成员
link_type& root() const { return (link_type&)header->parent; }
link_type& leftmost() const { return (link_type&)header->left; }
link_type& rightmost() const { return (link_type&)header->right; }

//以下六个函数用来方便取得节点x的成员。x为函数参数
static link_type& left(link_type x) { return (link_type&)(x->left); }
static link_type& right(link_type x) { return (link_type&)(x->right); }
static link_type& parent(link_type x) { return (link_type&)(x->parent); }
static reference value(link_type x) { return x->value_field; }
static const Key& key(link_type x) { return KeyOfValue()(value(x)); }
static color_type& color(link_type x) { return (color_type&)(x->color); }

//和上面六个作用相同,注意x参数类型不同。一个是基类指针,一个是派生类指针
static link_type& left(base_ptr x) { return (link_type&)(x->left); }
static link_type& right(base_ptr x) { return (link_type&)(x->right); }
static link_type& parent(base_ptr x) { return (link_type&)(x->parent); }
static reference value(base_ptr x) { return ((link_type)x)->value_field; }
static const Key& key(base_ptr x) { return KeyOfValue()(value(link_type(x))); }
static color_type& color(base_ptr x) { return (color_type&)(link_type(x)->color); }

//找最大值和最小值。node class 有这个功能函数
static link_type minimum(link_type x) {
return (link_type)__rb_tree_node_base::minimum(x);
}
static link_type maximum(link_type x) {
return (link_type)__rb_tree_node_base::maximum(x);
}

public:
typedef __rb_tree_iterator<value_type, reference, pointer> iterator;
typedef __rb_tree_iterator<value_type, const_reference, const_pointer>
const_iterator;

#ifdef __STL_CLASS_PARTIAL_SPECIALIZATION
typedef reverse_iterator<const_iterator> const_reverse_iterator;
typedef reverse_iterator<iterator> reverse_iterator;
#else /* __STL_CLASS_PARTIAL_SPECIALIZATION */
typedef reverse_bidirectional_iterator<iterator, value_type, reference,
difference_type>
reverse_iterator;
typedef reverse_bidirectional_iterator<const_iterator, value_type,
const_reference, difference_type>
const_reverse_iterator;
#endif /* __STL_CLASS_PARTIAL_SPECIALIZATION */
private:
iterator __insert(base_ptr x, base_ptr y, const value_type& v);
link_type __copy(link_type x, link_type p);
void __erase(link_type x);
void init() {
header = get_node(); // 产生一个节点空间,令header指向它
color(header) = __rb_tree_red; // 令 header 尾红色,用來区 header
// 和 root(在 iterator.operator++ 中)
root() = 0;
leftmost() = header; // 令 header 的左孩子为自己。
rightmost() = header; // 令 header 的右孩子为自己。
}
public:
//默认构造函数 // allocation/deallocation
rb_tree(const Compare& comp = Compare())
: node_count(0), key_compare(comp) {
init();
}

// 以另一个 rb_tree x 初始化
rb_tree(const rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& x)
: node_count(0), key_compare(x.key_compare)
{
header = get_node();
color(header) = __rb_tree_red;
if (x.root() == 0) { // 如果 x 空树
root() = 0;
leftmost() = header;
rightmost() = header;
}
else { // x 不是空树
__STL_TRY{
root() = __copy(x.root(), header); // 拷贝红黑树x
}
__STL_UNWIND(put_node(header));
leftmost() = minimum(root()); // 令 header 的左孩子为最小节点
rightmost() = maximum(root()); // 令 header 的右孩子为最大节点
}
node_count = x.node_count;
}
~rb_tree() {
clear();
put_node(header);
}
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>&
operator=(const rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& x);

public:
// accessors:
Compare key_comp() const { return key_compare; }
iterator begin() { return leftmost(); } // RB 树的起始为最左(最小节点)
const_iterator begin() const { return leftmost(); }
iterator end() { return header; } // RB 树的终节点为header所指处
const_iterator end() const { return header; }
reverse_iterator rbegin() { return reverse_iterator(end()); }
const_reverse_iterator rbegin() const {
return const_reverse_iterator(end());
}
reverse_iterator rend() { return reverse_iterator(begin()); }
const_reverse_iterator rend() const {
return const_reverse_iterator(begin());
}
bool empty() const { return node_count == 0; }
size_type size() const { return node_count; }
size_type max_size() const { return size_type(-1); }

void swap(rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& t) {

//RB-tree只有三个资料表现成员,所以两颗RB-tree互换时,只需互换3个成员
__STD::swap(header, t.header);
__STD::swap(node_count, t.node_count);
__STD::swap(key_compare, t.key_compare);
}

public:
// insert/erase
// 将 x 安插到 RB-tree 中(保持节点值独一无二)。
pair<iterator, bool> insert_unique(const value_type& x);
// 将 x 安插到 RB-tree 中(允许重复节点)
iterator insert_equal(const value_type& x);

iterator insert_unique(iterator position, const value_type& x);
iterator insert_equal(iterator position, const value_type& x);

#ifdef __STL_MEMBER_TEMPLATES
template <class InputIterator>
void insert_unique(InputIterator first, InputIterator last);
template <class InputIterator>
void insert_equal(InputIterator first, InputIterator last);
#else /* __STL_MEMBER_TEMPLATES */
void insert_unique(const_iterator first, const_iterator last);
void insert_unique(const value_type* first, const value_type* last);
void insert_equal(const_iterator first, const_iterator last);
void insert_equal(const value_type* first, const value_type* last);
#endif /* __STL_MEMBER_TEMPLATES */

void erase(iterator position);
size_type erase(const key_type& x);
void erase(iterator first, iterator last);
void erase(const key_type* first, const key_type* last);
void clear() {
if (node_count != 0) {
__erase(root());
leftmost() = header;
root() = 0;
rightmost() = header;
node_count = 0;
}
}

public:
// 集合(set)的各种操作行为
iterator find(const key_type& x);
const_iterator find(const key_type& x) const;
size_type count(const key_type& x) const;
iterator lower_bound(const key_type& x);
const_iterator lower_bound(const key_type& x) const;
iterator upper_bound(const key_type& x);
const_iterator upper_bound(const key_type& x) const;
pair<iterator, iterator> equal_range(const key_type& x);
pair<const_iterator, const_iterator> equal_range(const key_type& x) const;

public:
// Debugging.
bool __rb_verify() const;
};

template <class Key, class Value, class KeyOfValue, class Compare, class Alloc>
inline bool operator==(const rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& x,
const rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& y) {
return x.size() == y.size() && equal(x.begin(), x.end(), y.begin());
}

// 重载 < 运算符,使用的是STL泛型算法lexicographical_compare
template <class Key, class Value, class KeyOfValue, class Compare, class Alloc> inline bool operator < (const rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& x, const rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& y) {
return lexicographical_compare(x.begin(), x.end(), y.begin(), y.end());
}
#ifdef __STL_FUNCTION_TMPL_PARTIAL_ORDER
template <class Key, class Value, class KeyOfValue, class Compare, class Alloc>
inline void swap(rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& x,
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& y) {
x.swap(y);
}
#endif /* __STL_FUNCTION_TMPL_PARTIAL_ORDER */
//重载赋值运算符=
template <class Key, class Value, class KeyOfValue, class Compare, class Alloc>
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>&
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::
operator=(const rb_tree<Key, Value, KeyOfValue, Compare, Alloc>& x) {
if (this != &x) {//防止自身赋值
// Note that Key may be a constant type.
clear();//先清除
node_count = 0;
key_compare = x.key_compare;
if (x.root() == 0) {
root() = 0;
leftmost() = header;
rightmost() = header;
}
else {
root() = __copy(x.root(), header);
leftmost() = minimum(root());
rightmost() = maximum(root());
node_count = x.node_count;
}
}
return *this;
}

#ifdef __STL_MEMBER_TEMPLATES

template <class K, class V, class KoV, class Cmp, class Al> template<class II>
void rb_tree<K, V, KoV, Cmp, Al>::insert_equal(II first, II last) {
for (; first != last; ++first)
insert_equal(*first);
}

template <class K, class V, class KoV, class Cmp, class Al> template<class II>
void rb_tree<K, V, KoV, Cmp, Al>::insert_unique(II first, II last) {
for (; first != last; ++first)
insert_unique(*first);
}

#else /* __STL_MEMBER_TEMPLATES */

template <class K, class V, class KoV, class Cmp, class Al>
void
rb_tree<K, V, KoV, Cmp, Al>::insert_equal(const V* first, const V* last) {
for (; first != last; ++first)
insert_equal(*first);
}

template <class K, class V, class KoV, class Cmp, class Al>
void
rb_tree<K, V, KoV, Cmp, Al>::insert_equal(const_iterator first,
const_iterator last) {
for (; first != last; ++first)
insert_equal(*first);
}

template <class K, class V, class KoV, class Cmp, class A>
void
rb_tree<K, V, KoV, Cmp, A>::insert_unique(const V* first, const V* last) {
for (; first != last; ++first)
insert_unique(*first);
}

template <class K, class V, class KoV, class Cmp, class A>
void
rb_tree<K, V, KoV, Cmp, A>::insert_unique(const_iterator first,
const_iterator last) {
for (; first != last; ++first)
insert_unique(*first);
}

#endif /* __STL_MEMBER_TEMPLATES */

RB-tree的构造与内存管理

下面是RB-tree所定义的专属空间配置器rb_tree_node_allocator,每次可恰恰配置一个节点:

1
2
3
4
5
template <class Key, class Value, class KeyOfValue, class Compare, class Alloc = alloc>
class rb_tree {
protected:
typedef __rb_tree_node<Value> rb_tree_node;
typedef simple_alloc<rb_tree_node, Alloc> rb_tree_node_allocator;

rb-tree的构造方式有两种,一种是以现有的rb-tree构造一个新的rb-tree,另一种是构造一个空空如也的新树。

1
rb_tree<int, int, identity<int>, less<int> > itree;

然后调用构造函数。
1
rb_tree(const Compare& comp = Compare()) : node_count(0), key_compare(comp) { init(); }

其中的init()是一个关键点
1
2
3
4
5
6
7
8
void init() {
header = get_node(); // 产生一个节点空间,令header指向它
color(header) = __rb_tree_red; // 令 header 尾红色,用來区 header
// 和 root(在 iterator.operator++ 中)
root() = 0;
leftmost() = header; // 令 header 的左孩子为自己。
rightmost() = header; // 令 header 的右孩子为自己。
}

STL为根节点再设计了一个父节点:

RB-tree的元素操作

RB-tree提供两种插入操作:insert_unique()insert_equal(),前者标识被插入节点的键值(key)在整棵树中必须独一无二(因此,如果整棵树中已存在相同的键值,插入操作就不会真正进行),后者标识被插入节点的键值在整棵树中可以重复,因此,无论如何插入都会成功(除非空间不足导致配置失败)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109

// 安插新值;允许键值重复。返回新插入节点的迭代器
template <class Key, class Value, class KeyOfValue, class Compare, class Alloc>
typename rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::iterator
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::insert_equal(const Value& v)
{
link_type y = header;
link_type x = root();
while (x != 0) { // 从根节点开始,向下寻找适当安插位置
y = x;
x = key_compare(KeyOfValue()(v), key(x)) ? left(x) : right(x);
}
return __insert(x, y, v);
}

/*
不允许键值重复,否则安插无效。
返回值是个pair,第一个元素是个RB-tree迭代器,指向新增节点。
第二个元素表示安插是否成功。
*/
template <class Key, class Value, class KeyOfValue, class Compare, class Alloc>
pair<typename rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::iterator, bool>
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::insert_unique(const Value& v)
{
link_type y = header;
link_type x = root(); //从根节点开始
bool comp = true;
while (x != 0) { // 从根节点开始向下寻找适当安插位置
y = x;
comp = key_compare(KeyOfValue()(v), key(x)); // v 键值小于目前节点的键值?
x = comp ? left(x) : right(x); // 遇「大」往左,遇「小于或等于」往右
}
//离开while循环之后,y所指即为安插点的父节点,x必为叶子节点

iterator j = iterator(y); // 令迭代器j指向安插点之父节点 y
if (comp) //如果离开while循环时comp为真,表示 父节点键值>v ,将安插在左孩子处
if (j == begin()) // 如果j是最左节点
return pair<iterator, bool>(__insert(x, y, v), true);
// 以上,x 为安插点,y 为安插点之父节点,v 为新值。
else // 否则(安插点之父节点不是最左节点)
--j; // 调整 j,回头准备测试...
if (key_compare(key(j.node), KeyOfValue()(v)))
// 小于新值(表示遇「小」,将安插于右侧)
return pair<iterator, bool>(__insert(x, y, v), true);

//若运行到这里,表示键值有重复,不应该插入
return pair<iterator, bool>(j, false);
}


template <class Key, class Val, class KeyOfValue, class Compare, class Alloc>
typename rb_tree<Key, Val, KeyOfValue, Compare, Alloc>::iterator
rb_tree<Key, Val, KeyOfValue, Compare, Alloc>::insert_unique(iterator position,
const Val& v) {
if (position.node == header->left) // begin()
if (size() > 0 && key_compare(KeyOfValue()(v), key(position.node)))
return __insert(position.node, position.node, v);
// first argument just needs to be non-null
else
return insert_unique(v).first;
else if (position.node == header) // end()
if (key_compare(key(rightmost()), KeyOfValue()(v)))
return __insert(0, rightmost(), v);
else
return insert_unique(v).first;
else {
iterator before = position;
--before;
if (key_compare(key(before.node), KeyOfValue()(v))
&& key_compare(KeyOfValue()(v), key(position.node)))
if (right(before.node) == 0)
return __insert(0, before.node, v);
else
return __insert(position.node, position.node, v);
// first argument just needs to be non-null
else
return insert_unique(v).first;
}
}

template <class Key, class Val, class KeyOfValue, class Compare, class Alloc>
typename rb_tree<Key, Val, KeyOfValue, Compare, Alloc>::iterator
rb_tree<Key, Val, KeyOfValue, Compare, Alloc>::insert_equal(iterator position,
const Val& v) {
if (position.node == header->left) // begin()
if (size() > 0 && key_compare(KeyOfValue()(v), key(position.node)))
return __insert(position.node, position.node, v);
// first argument just needs to be non-null
else
return insert_equal(v);
else if (position.node == header) // end()
if (!key_compare(KeyOfValue()(v), key(rightmost())))
return __insert(0, rightmost(), v);
else
return insert_equal(v);
else {
iterator before = position;
--before;
if (!key_compare(KeyOfValue()(v), key(before.node))
&& !key_compare(key(position.node), KeyOfValue()(v)))
if (right(before.node) == 0)
return __insert(0, before.node, v);
else
return __insert(position.node, position.node, v);
// first argument just needs to be non-null
else
return insert_equal(v);
}
}

真正的插入程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
template <class Key, class Value, class KeyOfValue, class Compare, class Alloc>
typename rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::iterator
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::
__insert(base_ptr x_, base_ptr y_, const Value& v) {
//参数x_为新值安插点,参数y_为安插点之父节点,参数v 为新值
link_type x = (link_type)x_;
link_type y = (link_type)y_;
link_type z;
//key_compare是键值得比较准则,是个函数或函数指针
if (y == header || x != 0 || key_compare(KeyOfValue()(v), key(y))) {
z = create_node(v); // 产生一个新节点
left(y) = z; // 这使得当y为header时,leftmost()=z
if (y == header) {
root() = z;
rightmost() = z;
}
else if (y == leftmost()) // 如果y为最左节点
leftmost() = z; // 维护leftmost(),使它永远指向最左节点
}
else {
z = create_node(v);
right(y) = z; // 令新节点成为安插点之父节点y的右孩子
if (y == rightmost())
rightmost() = z; // 维护rightmost(),使它永远指向最右节点
}
parent(z) = y; // 设定新节点的父节点
left(z) = 0; // 设定新孩子节点的左孩子
right(z) = 0; // 设定新孩子节点的右孩子
// 新节点的颜色将在 __rb_tree_rebalance() 设定并调整
__rb_tree_rebalance(z, header->parent); // 参数一为新增节点,参数二为root
++node_count; // 节点数增加
return iterator(z); // 返回迭代器,指向新增节点
}

set

set的特性是,所有元素都会根据元素的键值自动排序,set的元素不像map那样可以同时拥有实值和键值,set元素的键值就是实值,实值就是键值,且不允许两个元素有相同的键值。set具有以下特点:

  • 不能通过set的迭代器改变set的元素,set iterators被定义为底层RB-tree的const_iterators,杜绝写入操作。
  • 客户端对set进行元素新增或者删除操作时,操作之前的所有迭代器在操作后都依然有效,被删除的元素的迭代器例外。

STL特别提供了一组set/multiset相关算法,包括交集、联集、差集、对称差集。STL set以RB-tree为底层机制,set的操作几乎都是转调用RB-tree的函数而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
template <class Key, class Compare = less<Key>, class Alloc = alloc>
class set {
public:
// typedefs:

typedef Key key_type;
typedef Key value_type;
typedef Compare key_compare;
typedef Compare value_compare;
...
private:

/*
template <class T>
struct identity : public unary_function<T, T> {
const T& operator()(const T& x) const { return x; }
};
*/
typedef rb_tree<key_type, value_type,
identity<value_type>, key_compare, Alloc> rep_type;
rep_type t; // red-black tree representing set
...
public:
typedef typename rep_type::const_pointer pointer;
typedef typename rep_type::const_pointer const_pointer;
typedef typename rep_type::const_reference reference;
typedef typename rep_type::const_reference const_reference;
typedef typename rep_type::const_iterator iterator;

typedef typename rep_type::const_iterator const_iterator;
typedef typename rep_type::const_reverse_iterator reverse_iterator;
typedef typename rep_type::const_reverse_iterator const_reverse_iterator;
typedef typename rep_type::size_type size_type;
typedef typename rep_type::difference_type difference_type;

// allocation/deallocation
// 注意,set一定使用RB-tree的insert_unique(),而非insert_equal()
// multiset才使用RB-tree的insert_equal()
// 因为set不许相同键值存在
set() : t(Compare()) {}
explicit set(const Compare& comp) : t(comp) {}

template<class InputIterator>
set(InputIterator first, InputIterator last) : t(Compare()) {t.insert_unique(first, last);}

template<class InputIterator>
set(InputIterator first, InputIterator last, const Compare& comp) : t(comp) {t.insert_unique(first, last);}

set(const set<Key, Compare, Alloc>& x) : t(x.t) {}
set<Key, Compare, Alloc>& operator=(const set<Key, Compare, Alloc>& x) {
t = x.t;
return *this;
}

// accessors:
//转调用RB-tree的操作行为
key_compare key_comp() const { return t.key_comp(); }
value_compare value_comp() const { return value_compare(t.key_comp()); }
iterator begin() { return t.begin(); }
iterator end() { return t.end(); }
reverse_iterator rbegin() { return t.rbegin(); }
reverse_iterator rend() { return t.rend(); }
bool empty() const { return t.empty(); }
size_type size() const { return t.size(); }
size_type max_size() const { return t.max_size(); }
void swap(set<Key, Compare, Alloc>& x) {t.swap(x.t);}

// insert/erase
typedef pair<iterator, bool> pair_iterator_bool;
pair<iterator, bool> insert(const value_type& x) {
pair<typename rep_type::iterator, bool> p = t.insert_unique(x);
return pair<iterator, bool>((p.first, p.second);
}
iterator insert(iterator position, const value_type& x) {
typedef typename rep_type::iterator rep_iterator;
return t.insert_unique((rep_iterator&)position, x);
}
template <class InputIterator>
void insert(InputIterator first, InputIterator last) {
t.insert_unique(first, last);
}
void erase(iterator position) {
typedef typename rep_type::iterator rep_iterator;
t.erase((rep_iterator&)position);
}
size_type erase(const key_type& x) { return t.erase(x);}

void erase(iterator first, iterator last) {
typedef typename rep_type::iterator rep_iterator;
t.erase((rep_iterators&)first, (rep_iterator&)last);
}
void clear() ( t.clear(); }

// set operations:
iterator find(const key_type& x) const { return t.find(x); }
size_type count(const key_type& x) const { return t.count(x); }
iterator lowerbound(const key_type& x) const {
return t.lower_bound(x);
}
iterator upper_bound(const key_type& x) const { return t.upppr_bonnd(x); }
pair<iterator, iterator> equal_range(const key_type& x) const {
t.equal_range(x);
}
// 以下的 STL_NULL_TMPL_ARCS 被定义为<>
friend bool operator== __STL_NULL_TMPL_ARGS (const set&, const set&);
friend bool operator< __STL_NULL_TMPL_ARGS (const set&, const set&);

template <class Key, class Compare, class Alloc>
inline bool operator==(const set<Key, Compare, Alloc>& x, const set<Key, Compare, Alloc>& y) {
return x.t == y.t;
}
template <class Key, class Compare, class Alloc>
inline bool operator<(const set<Key, Compare, Alloc>& x, const set<Key, Compare, Alloc>& y) {
return x.t < y.t;
}

};

测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// file: 5set-test.cpp

#include <set>
#include <iostream>
#include <algorithm>

using namespace std;

int main() {
int i;
int ia[5] = {0, 1, 2, 3, 4};
set<int> iset{ia, ia + 5};

cout << "size=" << iset.size() << endl; //size=5
cout << "3 count =" << iset.count(3) << endl; //3 count =1
iset.insert(3);
cout << "size=" << iset.size() << endl; //size=5
cout << "3 count =" << iset.count(3) << endl; //3 count =1

iset.insert(5);
cout << "size=" << iset.size() << endl; //size=6
cout << "5 count =" << iset.count(5) << endl; //5 count =1

iset.erase(1);
cout << "size=" << iset.size() << endl; //size=5
cout << "3 count =" << iset.count(3) << endl; //3 count =1
cout << "1 count =" << iset.count(1) << endl; //1 count =0

set<int>::iterator ite1 = iset.begin();
set<int>::iterator ite2 = iset.end();
for (; ite1 != ite2; ++ite1) {
cout << *ite1; //02345
}
cout << endl;

// 使用STL算法find可以搜索元素,但不推荐
ite1 = find(iset.begin(), iset.end(), 3);
if (ite1 != iset.end())
cout << "3 found" << endl; //3 found

ite1 = find(iset.begin(), iset.end(), 1);
if (ite1 == iset.end())
cout << "1 not found" << endl; //1 not found

// 关联式容器应使用专用的find函数搜索更有效率
ite1 = iset.find(3);
if (ite1 != iset.end())
cout << "3 found" << endl; //3 found

ite1 = iset.find(1);
if (ite1 == iset.end())
cout << "1 not found" << endl; //1 not found

// *ite1 = 9; // 修改失败
}

multiset

multiset的特性及用法和set完全相同,唯一的差别在于它允许键值重复,因为它的插入操作采用的是RB-tree的insert_equal()。测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include<set>
#include<iostream>
using namespace std;

int main(){
int ia[] = { 5, 3, 4, 1, 6, 2 };
multiset<int> iset(begin(ia), end(ia));

cout << "size=" << iset.size() << endl; //size=6
cout << "3 count=" << iset.count(3) << endl;//3 count=1

iset.insert(3); //和set区别的地方
cout << "size=" << iset.size() << endl;//size=7
cout << "3 count=" << iset.count(3) << endl;//3 count=2

iset.insert(7);
cout << "size=" << iset.size() << endl;//size=8
cout << "3 count=" << iset.count(3) << endl;//3 count=2

iset.erase(1);
cout << "size=" << iset.size() << endl;//size=7
cout << "1 count=" << iset.count(1) << endl;//1 count=0

set<int>::iterator it;
for (it = iset.begin(); it != iset.end(); ++it)
cout << *it << " "; //2 3 3 4 5 6 7
cout << endl;

it = iset.find(3);
if (it != iset.end())
cout << "3 found" << endl;//3 found

it = iset.find(1);
if (it == iset.end())
cout << "1 not found" << endl;//1 not found

return 0;
}

map概述

map的特性是,所有元素都会根据元素的键值自动排序,map的所有元素都是pair,pair的第一元素是键值,第二元素是实值。map具有以下特点:

  • 不能通过map的迭代器改变map的键值,但通过map的迭代器能改变map的实值。因此map的iterators既不是一种const iterators,也不是一种mutable iterators。
  • 客户端对map进行元素新增或者删除操作时,操作之前的所有迭代器在操作后都依然有效,被删除的元素的迭代器例外。
  • map不允许两个元素拥有相同的键值。

下面是pair的定义

1
2
3
4
5
6
7
8
9
10
template <class T1, class T2>
struct pair {
typedef T1 first_type;
typedef T2 second_type;

T1 first;
T2 second;
pair() : first(T1()), second(T2()) {}
pair(const T1& a, const T2& b) : first(a), second(b) {}
};

从上可以看出,pair 包含两个类型(可以相同,也可以不相同)的公共元素。
map同set一样,都是关联式容器,内部元素的实值都会根据其键值来进行排序(set 的实值就是键值),所以都不能任意改变元素的键值,但是map可以任意改变元素的实值,我们所有操作的前提以及是否被允许,要看是否会影响到map元素的排序规则。

同样map和multimap 也是以RB-tree 为底层机制,几乎所有的map操作行为,都只是转调用RB-tree的操作行为而已。

set 和 map的内部结构即元素的存储都是RB-tree,set 中,RB-tree的节点内容是单一元素,而map中,节点内容则是一个pair <key,value>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
class map {
public:

// typedefs:

typedef Key key_type; //键值型别
typedef T data_type; //实值型别
typedef pair<const Key, T> value_type; //元素性别(键值/实值)
typedef Compare key_compare; //键值比较函数

//定义一个函数,其作用就是调用“元素比较函数”
class value_compare
: public binary_function<value_type, value_type, bool> {
friend class map<Key, T, Compare, Alloc>;
protected:
Compare comp;
value_compare(Compare c) : comp(c) {}
public:
//重载
bool operator()(const value_type& x, const value_type& y) const {
return comp(x.first, y.first);
}
};

private:
typedef rb_tree<key_type, value_type,
select1st<value_type>, key_compare, Alloc> rep_type;
rep_type t; // 以RB-tree为底层实现,所有元素(pair)存放在RB-tree节点中
public:
//两种类型的都有,因为map允许用户通过迭代器修改元素的实值
typedef rep_type::pointer pointer;
typedef rep_type::reference reference;
typedef rep_type::const_reference const_reference;
typedef rep_type::iterator iterator;
typedef rep_type::const_iterator const_iterator;
typedef rep_type::reverse_iterator reverse_iterator;
typedef rep_type::const_reverse_iterator const_reverse_iterator;
typedef rep_type::size_type size_type;
typedef rep_type::difference_type difference_type;

// allocation/deallocation
//构造函数
map() : t(Compare()) {}
explicit map(const Compare& comp) : t(comp) {}

/*map一定使用底层RB-tree 的insert_unique()*/

map(const map<Key, T, Compare, Alloc>& x) : t(x.t) {}
map<Key, T, Compare, Alloc>& operator=(const map<Key, T, Compare, Alloc>& x)
{
t = x.t;
return *this;
}

// accessors:
//转调用RB-tree的操作行为
key_compare key_comp() const { return t.key_comp(); }
value_compare value_comp() const { return value_compare(t.key_comp()); }
iterator begin() { return t.begin(); }
const_iterator begin() const { return t.begin(); }
iterator end() { return t.end(); }
const_iterator end() const { return t.end(); }
reverse_iterator rbegin() { return t.rbegin(); }
const_reverse_iterator rbegin() const { return t.rbegin(); }
reverse_iterator rend() { return t.rend(); }
const_reverse_iterator rend() const { return t.rend(); }
bool empty() const { return t.empty(); }
size_type size() const { return t.size(); }
size_type max_size() const { return t.max_size(); }
/*下面这个下标操作符重载函数是的map支持元素的直接存取,索引值是元素的key*/
T& operator[](const key_type& k)
{
return (*((insert(value_type(k, T()))).first)).second;
}

void swap(map<Key, T, Compare, Alloc>& x) { t.swap(x.t); }

// insert/erase
//都是调用底层RB-tree的操作行为
pair<iterator, bool> insert(const value_type& x) { return t.insert_unique(x); }
iterator insert(iterator position, const value_type& x) {
return t.insert_unique(position, x);
}

void erase(iterator position) { t.erase(position); }
size_type erase(const key_type& x) { return t.erase(x); }
void erase(iterator first, iterator last) { t.erase(first, last); }
void clear() { t.clear(); }

// map operations:
/*返回一个迭代器指向键值为key的元素,如果没找到就返回end()*/
iterator find(const key_type& x) { return t.find(x); }
const_iterator find(const key_type& x) const { return t.find(x); }
/*返回键值等于key的元素的个数*/
size_type count(const key_type& x) const { return t.count(x); }
/*返回一个迭代器,指向键值>=key的第一个元素*/
iterator lower_bound(const key_type& x) { return t.lower_bound(x); }
const_iterator lower_bound(const key_type& x) const {
return t.lower_bound(x);
}
/*返回一个迭代器,指向键值>key的第一个元素*/
iterator upper_bound(const key_type& x) { return t.upper_bound(x); }
const_iterator upper_bound(const key_type& x) const {
return t.upper_bound(x);
}
/*返回键值为key的元素的第一个可安插位置和最后一个可安插位置,也就是“键值==key”的元素区间*/
pair<iterator, iterator> equal_range(const key_type& x) {
return t.equal_range(x);
}
pair<const_iterator, const_iterator> equal_range(const key_type& x) const {
return t.equal_range(x);
}
friend bool operator==(const map&, const map&);
friend bool operator<(const map&, const map&);
};

/*运算符重载,几乎有所有的操作行为都是调用RB-tree的操作行为
事实上,包括set在内的关联式容器内部都是以RB-tree方式存放的*/
template <class Key, class T, class Compare, class Alloc>
inline bool operator==(const map<Key, T, Compare, Alloc>& x,
const map<Key, T, Compare, Alloc>& y) {
return x.t == y.t;
}

template <class Key, class T, class Compare, class Alloc>
inline bool operator<(const map<Key, T, Compare, Alloc>& x,
const map<Key, T, Compare, Alloc>& y) {
return x.t < y.t;
}

通常,关联式容器并不提供元素的直接存取,你必须依靠迭代器,不过 map 内部的下标操作符重载函数使得其支持元素的直接存取,看看它是怎么实现的
这是map作为关联式容器特殊的地方(multimap没有哦)

map测试程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <map>
#include <iostream>
#include <string>

using namespace std;

int main() {
map<int, string> stuMap;

cout<<"————————————————————插入操作—————————————————"<<endl;
stuMap[1001]="Jason";
stuMap.insert(pair<int, string>(1002,"Helen"));
stuMap.insert(make_pair(1003,"Steve"));

map<int, string>::const_iterator iter = stuMap.begin();
for (; iter != stuMap.end(); ++iter)
{
cout <<"id:"<< iter->first <<" name:"<< iter->second << endl;
}
cout<<"————————————————————取值操作—————————————————"<<endl;

cout <<"stuMap[1004]:"<<stuMap[1004]<<endl;

//使用at会进行关键字检查,因此下面语句会报错
//stuMap.at(1005) = "Bob";
cout<<"————————————————————查找操作—————————————————"<<endl;
iter = stuMap.find(1001);
if (iter!=stuMap.end())
{
cout <<"1001 found name:"<<iter->second<<endl;
}

iter = stuMap.find(1005);
if ( iter==stuMap.end())
{
cout <<"1005 not found"<<endl;
}

cout<<"————————————————————容量查询—————————————————"<<endl;

cout<<"stuMap empty state is "<<boolalpha<<stuMap.empty()<<endl;

cout<<"stuMap size is "<<boolalpha<<stuMap.size()<<endl;

cout<<"stuMap.count(1008) is "<<boolalpha<<stuMap.count(1008)<<endl;

cout<<"————————————————————删除操作—————————————————"<<endl;
cout<<"before delete"<<endl;
iter = stuMap.begin();
for (; iter != stuMap.end(); ++iter)
{
cout <<"id:"<< iter->first <<" name:"<< iter->second << endl;
}

stuMap.erase(1004);

iter = stuMap.begin();
for (; iter != stuMap.end(); ++iter)
{
if(iter->second=="Helen")
{
stuMap.erase(iter);
break;
}
}

cout<<"after delete"<<endl;
iter = stuMap.begin();
for (; iter != stuMap.end(); ++iter)
{
cout <<"id:"<< iter->first <<" name:"<< iter->second << endl;
}

return 0;
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[root@192 5_STL_associated_container]# ./5_4_map-test
————————————————————插入操作—————————————————
id:1001 name:Jason
id:1002 name:Helen
id:1003 name:Steve
————————————————————取值操作—————————————————
stuMap[1004]:
————————————————————查找操作—————————————————
1001 found name:Jason
1005 not found
————————————————————容量查询—————————————————
stuMap empty state is false
stuMap size is 4
stuMap.count(1008) is 0
————————————————————删除操作—————————————————
before delete
id:1001 name:Jason
id:1002 name:Helen
id:1003 name:Steve
id:1004 name:
after delete
id:1001 name:Jason
id:1003 name:Steve

hashtable

hashtable在插入,删除,搜寻等操作上具有"常数平均时间"的表现,而且这种表现以统计为基础,不依赖输入元素的随机性。(二叉搜索树具有对数平均时间的表现,是建立在输入数据有足够随机性的基础上。)

hash table 可提供对任何有名项的存取操作和删除操作。由于操作对象是有名项,所以hashtable可被视为一种字典结构(dictionary)。举个例子,如果所有元素时16-bits且不带有正负号的整数,范围为0-65535,则可以通过配置一个array拥有65536个元素,对应位置记录元素出现个数,添加A[i]++,删除A[i]—,查找A[i]==0判断。但是进一步,如果所有元素是32-bits,那大小必须是2^32=4GB,那直接分配这么大的空间就不太实际。再进一步,如果元素时字符串,每个字符用7-bits数值(ASCII)表示,这种方法就更不可取了。

为了解决上述问题,引入了hash funtion的概念。hashfuntion可以将某一元素映射为一个“大小可接受之索引”,即大数映射成小数。hashtable通过hashfunction将元素映射到不同的位置,但当不同的元素通过hash function映射到相同位置时,便产生了"碰撞"问题.解决碰撞问题的方法主要有线性探测,二次探测,开链法等.

  • 线性探测:当hash function计算出某个元素的插入位置,而该位置的空间已不可用时,循序往下寻找下一个可用位置(到达尾端时绕到头部继续寻找),会产生primary clustering(一次聚集)问题。
  • 二次探测:当hash function计算出某个元素的插入位置为H,而该位置的空间已经被占用,就尝试用H+1²、H+2²…,会产生secondary clustering(二次聚集)问题。
  • 开链:在每一个表格元素中维护一个list:hash function为我们分配某个list,在那个list上进行元素的插入,删除,搜寻等操作.SGI STL解决碰撞问题的方法就是此方法。

下面以开链法完成hash table的图形表述,hash table 表格内的元素为桶子(bucket),每个bucket都维护一个链表,来解决哈希碰撞,如下所示:

下面看一下 hashtable 的定义:

1
2
3
4
5
6
template <class Value>
struct __hastabl_node
{
__hastable_node* next;
Value val;
};

以下是hash table的迭代器的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey, class Alloc>
struct __hashtable_iterator {
typedef hashtable<Value, Key, HashFcn, ExtractKey, EqualKey, Alloc> hashtable;
typedef __hashtable_iterator<Value, Key, HashFcn, ExtractKey, EqualKey, Alloc> iterator;
typedef __hashtable_const_iterator<Value, Key, HashFcn, ExtractKey, EqualKey, Alloc> const_iterator;
typedef __hashtable_node<Value> node;

typedef forward_iterator_tag iterator_category;
typedef ptrdiff_t difference_type;
typedef size_t size_type;
typedef Value& reference;
typedef Value* pointer;

node* cur; //迭代器目前所指之节点
hashtable* ht; //保持容器的连结关系
...
__hashtable_iterator(node* n, hashtable* tab) : cur(n), ht(tab) {}
__hashtable_iterator() {}
reference operator*() const { return cur->val; }
pointer operator->() const { return &(operator*()); }
iterator& operator++();
iterator operator++(int);
bool operator==(const iterator& it) const { return cur == it.cur; }
bool operator!=(const iterator& it) const { return cur != it.cur; }
};

注意hashtable迭代器必须永远维系着与整个buckets vector的关系,并记录目前所指的节点。其前进操作是首先尝试从目前所指的节点出发,前进一个位置〔节点),由于节点被安置于st内,所以利用节点的next指针即可轻易达成前进操作,如果目前节点正巧是list的尾端,就跳至下一个bucket身上,那正是指向下一个bucket的头部节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <class V, class K, class HF, Class ExK, class EqK, class A>
__hashtable_iteratorc<V, K, HF, ExK, EqK, A>&
__hashtable_iteratorc<V, K, HF, ExK, EqK, A>::operator++()
{
const node* old = cur;
cur = cur->next; //如果存在,就是它。否则进入以下流程
if(!cur) {
//根据元素值,定位出下一个bucket,其起头处就是我们的目的地
size_type bucket = ht->bkt_num(old->val);
while (!cur && ++bucket < ht->buckets.size())
cur = ht->buckets[bucket];
}
return *this;
}

template <class V, class K, class HF, Class ExK, class EqK, class A>
inline __hashtable_iteratorc<V, K, HF, ExK, EqK, A>
__hashtable_iteratorc<V, K, HF, ExK, EqK, A>::operator++(int)
{
iterator tmp = *this;
++*this;
return tmp;
}

hashtable没有逆向操作(operator—())。

hashtable的数据结构

下图是hashtable的定义摘要,其中可见bucket聚合体以vector完成,以利动态扩充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<class Value,class Key, class HashFcn, class ExtractKey, class EqualKey, class Alloc = alloc>
class hashtable;

template< class Value, class Key, class HashFcn, class ExtractKey, class EqualKey, class Alloc>
class hashtable {
public:
typedef HashFcn hasher;
typedef EqualKey key_equal;
typedef size_t size_type;

private:
hasher hash;
key_equal equals;
ExtractKey get_key;

typedef __hashtable_node<Value> node;
typedef simple_alloc<node, Alloc> node_allocator;

vector<node*, Alloc> buckets;
size_type num_elements;

public:
size_type bucket_count() const {return buckets.size(); }
};

虽然开链法不要求表格大小必须为质数,但是SGI STL仍然以质数来设计表格大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

//表格大小必须为质数,从下述28个质数中取最接近的
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473ul, 4294967291ul
};

/* 获取桶的数量 */
inline unsigned long __stl_next_prime(unsigned long n)
{
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n); //>=
return pos == last ? *(last - 1) : *pos;
}

// 总共可以有多少个buckets
size_type max_bucket_count() const
{
return __stl_prime_list(__stl_num_promes - 1);
}

hashtable的构造与内存管理

节点配置函数和节点释放函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
node* new_node(const value_type& obj) {
node* n = node_allocator::allocate();
n->next = 0;
__STL_TRY {
construct(&n->val, obj);
return n;
}
__STL_UNWIND(node_allocator::deallocate(n));
}

void delete_node(node* n)
{
destroy(&n->val);
node_allocator::deallocate(n);
}

当我们初始构造一个拥有50个节点的hashtable如下:

1
2
3
4
5
// <value,key,hash-func,extract-key,equal-key,allocator>
// 注意:hashtable没有提供default constructor
hashtable<int, int, hash<int>, identity<int>, equal_to<int>, alloc> iht(50, hash<int>(), equal_to<int>());
cout<<iht.size()<<endl;
cout<<iht.bucket_count()<<endl;

上述定义调用:

1
2
3
4
5
6
7
hashtable(size_type n,
const HashFcn& hf,
const EqualKey& eql)
: hash(hf), equals(eql), get_key(ExtractKey()), num_elements(0)
{
initialize_buckets(n);
}

首先初始化三个仿函数,然后调用 initialize_buckets 来初始化hashtable。initialize_buckets 函数定义如下:
1
2
3
4
5
6
7
void initialize_buckets(size_type n)
{
cost size_type n_buckets = next_size(n);
buckets.reserve(n_buckets);
buckets.insert(buckets.end(), n_buckets, (node*) 0);
num_elements = 0;
}

首先确定 bucket 的数量,然后通过 reserve 初始化,然后再通过 insert 将所有的 bucket 初始化为 NULL。最后将元素个数填为0(其中的 buckets 是一个 vector)。

hashtable 的插入 跟 RB-tree 的插入类似,有两种插入方法 insert_unique 和 insert_equal ,意思也是一样的,insert_unique 不允许有重复值,而 insert_equal 允许有重复值。因为都会用到是否需要重建表格的判断,我们先来整理这一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
template<class V, class K, class HF, class Ex, class Eq, class A>
void hashtable<V, K, HF, Ex, Eq, A>::resize(size_type num_elements_hint)
{
//判断 “表格重建与否” 是拿元素个数和 bucket vector 的大小来比,如果前者大于后者,就重建表格
//所以 每个 bucket list 的最大容量和 bucket vector 的大小相同
const size_type old_n = buckets.size();

if( num_elements_hint > old_n )
{
const size_type n = next_size(num_elements_hint); //next_size 底层调用 __stl_next_prime()
if( n > old_n)
{
vector<node*,A> tmp(n, (node*) 0); //设立新的 buckets

//以下是处理每一个旧的 bucket
for( size_type bucket = 0; bucket < old_n; ++bucket )
{
node* first = buckets[bucket]; //指向节点所对应之串行的起始节点
while( first ) //串行还没结束
{
//找出当前节点应该放在 新buckets 的哪一个位置
size_type new_bucket = bkt_num(first->val, n);

//以下就是对新旧表格的处理,同时还要维护好 first 指针
buckets[bucket] = first->next; // 令旧bucket指向其所对应串行的下一个节点
first->next = tmp[new_bucket]; // 将当前节点插入到新bucket中,成为其对应串行的第一个节点
tmp[new_bucket] = first;
first = buckets[bucket]; // 回到旧bucket所指的待处理串行,准备处理下一个节点
}
buckets.swap( tmp ); //vector::swap 函数,新旧两个buckets对调,对调之后释放tmp内存
}
}
}
}

现在来看一下 insert_unique 函数,需要注意的是插入时,新节点直接插入到链表的头节点,代码如下:

1
2
3
4
5
pair<iterator, bool> insert_unique(const value_type& obj)
{
resize(num_elements + 1);
return insert_unique_noresize(obj);
}

在不需要重建表格的情况插入新节点,键值不允许重复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pair<iterator, bool> insert_unique_noresize(const value_type& obj)
{
const size_type n = bkt_num(obj); //决定 obj 应位于 buckets 的那一个链表中
node* first = buckets[n];

//遍历当前链表,如果发现有相同的键值,就不插入,立刻返回
for( node* cur = first; cur; cur = cur->next)
{
if( equals(get_key(cur->val), get_key(obj)) )
return pair<iterator, bool>(iterator(cur, this), false);
}

//离开以上循环(或根本未进入循环)时,first指向 bucket 所指链表的头节点
node* tmp = new_node(obj); //产生新节点
tmp->next = first;
buckets[n] = tmp; //令新节点为链表的第一个节点
++num_elements; //节点个数累加1
return pair<iterator, bool>( iterator(tmp,this), true);
}

resize()如果有必要就得做表格重建工作:

1
2
3
4
5
6
7
//(1)令旧bucket指向其所对应之链表的下一个节点(以便迭代处理)
buckets[bucket】 = first->next;
// (2)(3) 将当前节点插人到新bucket内,成为其对应链表的第一个节点
first->next = tmp[new_bucket];
tmp[new_bucket] = first;
// (4) 回到旧bucket所指的待处理链表,准备处理下一个节点
first = buckets[bucket];

允许重复插入的 insert_equal,需要注意的是插入时,重复节点插入到相同节点的后面,新节点还是插入到链表的头节点,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
iterator insert_equal(const value_type& obj)
{
resize( num_elements + 1 ); //判断是否 需要重建表格,如需要就扩充
return insert_equal_noresize(obj);
}

template<class V, class K, class HF, class Ex, class Eq, class A>
typename hashtable<V, K, HF, Ex, Eq, A>::iterator
hashtable<V, K, HF, Ex, Eq, A>::insert_equal_noresize(const value_type& obj)
{
const size_type n = bkt_num(obj); //决定 obj 应位于 buckets 的那一个链表中
node* first = buckets[n];

//遍历当前链表,如果发现有相同的键值,就马上插入,立刻返回
for( node* cur = first; cur; cur = cur->next)
{
if( equals(get_key(cur->val), get_key(obj)) )
{
node* tmp = new_node(obj);
tmp->next = cur->next; //新节点插入当前节点位置之后
cur->next = tmp;
++num_elements;
return iterator(tmp, this);
}
}

//运行到这里,表示没有发现重复的键值
node* tmp = new_node(obj); //产生新节点
tmp->next = first;
buckets[n] = tmp; //令新节点为链表的第一个节点
++num_elements; //节点个数累加1
return iterator(tmp, this);
}

删除元素

首先找到指定的bucket,然后从第二个元素开始遍历,如果节点的 key 等于指定的 key,将将其删除。最后再检查第一个元素的 key,如果等于指定的 key,那么就将其删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
template <class V, class K, class HF, class Ex, class Eq, class A>
typename hashtable<V, K, HF, Ex, Eq, A>::size_type
hashtable<V, K, HF, Ex, Eq, A>::erase(const key_type& key)
{
const size_type n = bkt_num_key(key);
node* first = buckets[n];
size_type erased = 0;

if (first) {
node* cur = first;
node* next = cur->next;
while (next) {
if (equals(get_key(next->val), key)) {
cur->next = next->next;
delete_node(next);
next = cur->next;
++erased;
--num_elements;
}
else {
cur = next;
next = cur->next;
}
}
if (equals(get_key(first->val), key)) {
buckets[n] = first->next;
delete_node(first);
++erased;
--num_elements;
}
}
return erased;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <class V, class K, class HF, class Ex, class Eq, class A>
void hashtable<V, K, HF, Ex, Eq, A>::clear()
{
for( size_type i = 0; i<buckets.size(); ++i)
{
node* cur = buckets[i];

//将 bucket list 中的每一个节点删除掉
while( cur != 0 )
{
node* next = cur->next;
delete_node(cur);
cur = next;
}
buckets[i] = 0; //令bucket内容为null指针
}

num_elements = 0; //令总节点个数为 0
//注意:buckets vector 并未释放掉空间,扔保留原来大小
}

复制操作 copy_from,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template <class V, class K, class HF, class Ex, class Eq, class A>
void hashtable<V, K, HF, Ex, Eq, A>::copy_from(const hashtable& ht)
{
//先清除己方的 buckets vector
buckets.clear();
buckets.reserve(ht.buckets.size());

//从己方的 buckets vector 尾端开始,插入 n 个元素,其值为 null 指针
buckets.insert(buckets.end(), ht.buckets.size(), (node*)0);

//真正的执行复制操作
for(size_type i = 0; i < ht.buckets.size(); ++i)
{
if( const node* cur = ht.bucktes[i] )
{
node* copy = new_node(cur->val);
buckets[i] = copy;
for(node* next = cur->next; next; cur = next, next = cur->next)
{
copy->next = new_node(next->val);
copy = copy->next;
}
}
}
}

hash_set

虽然STL只规范复杂度与接口,并不规范实现方法,但STL set多半以RB-tree为底层机制。SGI则是在STL标准规格之外另又提供了一个所谓的hash_set,以hashtable为底层机制。由于hash_set所供应的操作接口, hashtable都提供了,所以几乎所有的hash_set操作行为,都只是转调用hashtable的操作行为而己。

运用set,为的是能够快速搜寻元素。这一点,不论其底层是RB-tree或是hash table、都可以达成任务。但是请注意,RB-tree有自动排序功能而hashtable没有,反应出来的结果就是,set的元素有自动排序功能而hash_set没有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
 template<class Value, class HashFcn = hash<Value>,
class EqualKey = equal_to<Value>, class Alloc=alloc>
class hash_set
{
private:
typedef hashtable<Value, Value, HashFcn, identity<Value>,
EqualKey, Alloc> ht;
ht rep;

public:
typedef typename ht::key_type key_type;
typedef typename ht::value_type value_type;
typedef typename ht::hasher hasher; //hashtable中: typedef HashFcn hasher
typedef typename ht::key_equel key_equel;

typedef typename ht::size_type size_type;
typedef typename ht::difference_type difference_type;
typedef typename ht::const_pointer pointer;
typedef typename ht::const_pointer const_pointer;
typedef typename ht::const_reference reference;
typedef typename ht::const_reference const_reference;

typedef typename ht::const_iterator iterator;
typedef typename ht::const_iterator const_iterator;

hasher hash_funct() const {return rep.hash_funct(); }
key_equel key_eq() const { return rep.key_eq(); }

public:

//各种构造函数 ,不给定大小的话默认值为100,实际上找到的质数为193
hash_set() : rep(100,hasher(), key_equel()){}
explicit hash_set(size_type n) : rep(n, hasher(), key_equel()) {}
hash_set(size_type n, const hasher& hf) : rep(n, hf, key_equel()) {}
hash_set(size_type n, const hasher& hf, const key_equel& eql)
: rep(n, hf, eql) {}

template< class InputIterator>
hash_set(InputIterator f, InputIterator l)
: rep(100, hasher(), key_equel()) { rep.insert_unique(f, l); }

template< class InputIterator>
hash_set(InputIterator f, InputIterator l, size_type n)
: rep(n, hasher(), key_equel()) { rep.insert_unique(f, l); }

template< class InputIterator>
hash_set(InputIterator f, InputIterator l, size_type n, const hasher& hf)
: rep(n, hf, key_equel()) { rep.insert_unique(f, l); }

template< class InputIterator>
hash_set(InputIterator f, InputIterator l, size_type n, const hasher& hf
const key_equel& eql)
: rep(n, hf, eql) { rep.insert_unique(f, l); }

public:
size_type size() const {return rep.size();}
size_type max_size() const { return rep.max_size(); }
bool empty() const {return rep.empty(); }
void swap(hash_set& hs) { rep.swap(hs.rep); }
friend bool operator== __STL_NULL_TMPL_ARGS (const hash_set&, const hash_set&);
iterator begin() const { return rep.begin(); }
iterator end() const { return rep.end(); }
public:
pair<iterator, bool> insert(const value_type& obj)
{
pair<typename ht::iterator, bool> p =rep.insert_unique(obj);
return pair<iterator, bool>(p.first, p.second);
}

template<class InputIterator>
void insert(InputIterator f, InputIterator l) { rep.insert_unique(f,l); }
pair<iterator, bool> insert_noresize(const value_type& obj)
{
pair<typename ht::iterator, bool> p = rep.insert_unique_noresize(obj);
return pair<iterator, bool>(p.first, p.second);
}

iterator find(const key_type& key) const { return rep.find(key); }

size_type count(const key_type& key) const {return rep.count(key); }

//相等的key的位置(是一个左闭右开的区间),由迭代器给出
pair<iterator, iterator> equal_range(const key_type& key) const
{ return rep.equal_range(key); }

size_type erase(const key_type& key) { return rep.erase(key); }
void erase(iterator it) { rep.erase(it); }
void erase(iterator f, iterator l) { rep.erase(f, l); }
void clear() { rep.clear(); }

public:
void resize(size_type hint) { rep.resize(hint); }
size_type bucket_count() const { return rep.bucket_count(); }
size_type elems_in_bucket(size_type n) const
{ return rep.elems_in_bucket(n); }

//class
template<class Value, class HashFcn, class EqualKey, class Alloc>
inline bool operator==(const hash_set<Value, HashFcn, EqualKey, Alloc>& hs1,
const hash_set<Value, HashFcn, EqualKey, Alloc>& hs2)
{
return has1.rep == has2.rep;
}
};
}

hash_map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
//class
template<class Key, class T, class HashFcn = hash<Value>,
class EqualKey = equal_to<Value>, class Alloc=alloc>
class hash_map
{
private:
typedef hashtable<pair<const Key, T>, Key, HashFcn,
select1st<pair<const Key, T>, EqualKey, Alloc> ht;

ht rep;

public:
typedef typename ht::key_type key_type;
typedef T data_type;
typedef T mapped_type;
typedef typename ht::value_type value_type;
typedef typename ht::hasher hasher; //hashtable中: typedef HashFcn hasher
typedef typename ht::key_equel key_equel;

typedef typename ht::size_type size_type;
typedef typename ht::difference_type difference_type;
typedef typename ht::pointer pointer;
typedef typename ht::const_pointer const_pointer;
typedef typename ht::reference reference;
typedef typename ht::const_reference const_reference;

typedef typename ht::iterator iterator;
typedef typename ht::const_iterator const_iterator;

hasher hash_funct() const {return rep.hash_funct(); }
key_equel key_eq() const { return rep.key_eq(); }

public:

//各种构造函数 ,不给定大小的话默认值为100,实际上找到的质数为193
hash_map() : rep(100, hasher(), key_equel()){}
explicit hash_map(size_type n) : rep(n, hasher(), key_equel()) {}
hash_map(size_type n, const hasher& hf) : rep(n, hf, key_equel()) {}
hash_map(size_type n, const hasher& hf, const key_equel& eql)
: rep(n, hf, eql) {}

template< class InputIterator>
hash_map(InputIterator f, InputIterator l)
: rep(100, hasher(), key_equel()) { rep.insert_unique(f, l); }

template< class InputIterator>
hash_map(InputIterator f, InputIterator l, size_type n)
: rep(n, hasher(), key_equel()) { rep.insert_unique(f, l); }

template< class InputIterator>
hash_map(InputIterator f, InputIterator l, size_type n, const hasher& hf)
: rep(n, hf, key_equel()) { rep.insert_unique(f, l); }

template< class InputIterator>
hash_map(InputIterator f, InputIterator l, size_type n, const hasher& hf
const key_equel& eql)
: rep(n, hf, eql) { rep.insert_unique(f, l); }
public:

size_type size() const {return rep.size();}
size_type max_size() const { return rep.max_size(); }
bool empty() const {return rep.empty(); }
void swap(hash_map& hs) { rep.swap(hs.rep); }
friend bool operator== __STL_NULL_TMPL_ARGS (const hash_map&, const hash_map&);

iterator begin() const { return rep.begin(); }
iterator end() const { return rep.end(); }
public:
pair<iterator, bool> insert(const value_type& obj)
{
//之前在set中就想过 为什么不直接返回 在map中看到时直接返回了,
//不是像set中一样还要先申请一个临时变量 再返回临时变量
//经过一番努力,发现:set的iterator是const_iterator,因为set不能更改值嘛
//所以需要进行转化,所以set那里会复杂一些
return rep.insert_unique(obj);

}

template<class InputIterator>
void insert(InputIterator f, InputIterator l) { rep.insert_unique(f,l); }
pair<iterator, bool> insert_noresize(const value_type& obj)
{
return rep.insert_unique_noresize(obj);

}

iterator find(const key_type& key) const { return rep.find(key); }
const_iterator find(const key_type& key) const { return rep.find(key);}

T& operator[](const key_type& key)
{
return rep.find_or_insert(value_type(key, T())).second;
}

size_type count(const key_type& key) const {return rep.count(key); }

//相等的key的位置(是一个左闭右开的区间),由迭代器给出
pair<iterator, iterator> equal_range(const key_type& key) const
{ return rep.equal_range(key); }

size_type erase(const key_type& key) { return rep.erase(key); }
void erase(iterator it) { rep.erase(it); }
void erase(iterator f, iterator l) { rep.erase(f, l); }
void clear() { rep.clear(); }

public:
void resize(size_type hint) { rep.resize(hint); }
size_type bucket_count() const { return rep.bucket_count(); }
size_type elems_in_bucket(size_type n) const
{ return rep.elems_in_bucket(n); }

//class
template<class Value, class HashFcn, class EqualKey, class Alloc>
inline bool operator==(const hash_map<Value, HashFcn, EqualKey, Alloc>& hm1,
const hash_map<Value, HashFcn, EqualKey, Alloc>& hm2)
{
return has1.rep == has2.rep;
}
};

hash_multiset

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
template<class Value, class HashFcn = hash<Value>,
class EqualKey = equal_to<Value>, class Alloc=alloc>
class hash_multiset
{
private:
typedef hash_multiset<Value, Value, HashFcn, identity<Value>,
EqualKey, Alloc> ht;

ht rep;

public:
typedef typename ht::key_type key_type;
typedef typename ht::value_type value_type;
typedef typename ht::hasher hasher; //hashtable中: typedef HashFcn hasher
typedef typename ht::key_equel key_equel;

typedef typename ht::size_type size_type;
typedef typename ht::difference_type difference_type;
typedef typename ht::const_pointer pointer;
typedef typename ht::const_pointer const_pointer;
typedef typename ht::const_reference reference;
typedef typename ht::const_reference const_reference;

typedef typename ht::const_iterator iterator;
typedef typename ht::const_iterator const_iterator;

hasher hash_funct() const {return rep.hash_funct(); }
key_equel key_eq() const { return rep.key_eq(); }

public:

//各种构造函数 ,不给定大小的话默认值为100,实际上找到的质数为193
hash_multiset() : rep(100,hasher(), key_equel()){}
explicit hash_multiset(size_type n) : rep(n, hasher(), key_equel()) {}
hash_multiset(size_type n, const hasher& hf) : rep(n, hf, key_equel()) {}
hash_multiset(size_type n, const hasher& hf, const key_equel& eql)
: rep(n, hf, eql) {}

template< class InputIterator>
hash_multiset(InputIterator f, InputIterator l)
: rep(100, hasher(), key_equel()) { rep.insert_equal(f, l); }

template< class InputIterator>
hash_multiset(InputIterator f, InputIterator l, size_type n)
: rep(n, hasher(), key_equel()) { rep.insert_equal(f, l); }

template< class InputIterator>
hash_multiset(InputIterator f, InputIterator l, size_type n, const hasher& hf)
: rep(n, hf, key_equel()) { rep.insert_equal(f, l); }

template< class InputIterator>
hash_multiset(InputIterator f, InputIterator l, size_type n, const hasher& hf
const key_equel& eql)
: rep(n, hf, eql) { rep.insert_equal(f, l); }
public:

size_type size() const {return rep.size();}
size_type max_size() const { return rep.max_size(); }
bool empty() const {return rep.empty(); }
void swap(hash_multiset& hs) { rep.swap(hs.rep); }
friend bool operator== __STL_NULL_TMPL_ARGS (const hash_multiset&, const hash_multiset&);
iterator begin() const { return rep.begin(); }
iterator end() const { return rep.end(); }
public:
iterator insert(const value_type& obj)
{
return rep.insert_equal(obj);
}

template<class InputIterator>
void insert(InputIterator f, InputIterator l) { rep.insert_equal(f,l); }
iterator insert_noresize(const value_type& obj)
{
return rep.insert_equal_noresize(obj);
}

iterator find(const key_type& key) const { return rep.find(key); }

size_type count(const key_type& key) const {return rep.count(key); }

//相等的key的位置(是一个左闭右开的区间),由迭代器给出
pair<iterator, iterator> equal_range(const key_type& key) const
{ return rep.equal_range(key); }

size_type erase(const key_type& key) { return rep.erase(key); }
void erase(iterator it) { rep.erase(it); }
void erase(iterator f, iterator l) { rep.erase(f, l); }
void clear() { rep.clear(); }

public:
void resize(size_type hint) { rep.resize(hint); }
size_type bucket_count() const { return rep.bucket_count(); }
size_type elems_in_bucket(size_type n) const
{ return rep.elems_in_bucket(n); }

//class
template<class Value, class HashFcn, class EqualKey, class Alloc>
inline bool operator==(const hash_multiset<Value, HashFcn, EqualKey, Alloc>& hs1,
const hash_multiset<Value, HashFcn, EqualKey, Alloc>& hs2)
{
return has1.rep == has2.rep;
}
};

hash_multimap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
template<class Key, class T, class HashFcn = hash<Value>,
class EqualKey = equal_to<Value>, class Alloc=alloc>
class hash_multimap
{
private:
typedef hashtable<pair<const Key, T>, Key, HashFcn,
select1st<pair<const Key, T>, EqualKey, Alloc> ht;

ht rep;

public:
typedef typename ht::key_type key_type;
typedef T data_type;
typedef T mapped_type;
typedef typename ht::value_type value_type;
typedef typename ht::hasher hasher; //hashtable中: typedef HashFcn hasher
typedef typename ht::key_equel key_equel;

typedef typename ht::size_type size_type;
typedef typename ht::difference_type difference_type;
typedef typename ht::pointer pointer;
typedef typename ht::const_pointer const_pointer;
typedef typename ht::reference reference;
typedef typename ht::const_reference const_reference;

typedef typename ht::iterator iterator;
typedef typename ht::const_iterator const_iterator;

hasher hash_funct() const {return rep.hash_funct(); }
key_equel key_eq() const { return rep.key_eq(); }

public:

//各种构造函数 ,不给定大小的话默认值为100,实际上找到的质数为193
hash_multimap() : rep(100, hasher(), key_equel()){}
explicit hash_multimap(size_type n) : rep(n, hasher(), key_equel()) {}
hash_multimap(size_type n, const hasher& hf) : rep(n, hf, key_equel()) {}
hash_multimap(size_type n, const hasher& hf, const key_equel& eql)
: rep(n, hf, eql) {}

template< class InputIterator>
hash_multimap(InputIterator f, InputIterator l)
: rep(100, hasher(), key_equel()) { rep.insert_equal(f, l); }

template< class InputIterator>
hash_multimap(InputIterator f, InputIterator l, size_type n)
: rep(n, hasher(), key_equel()) { rep.insert_equal(f, l); }

template< class InputIterator>
hash_multimap(InputIterator f, InputIterator l, size_type n, const hasher& hf)
: rep(n, hf, key_equel()) { rep.insert_equal(f, l); }

template< class InputIterator>
hash_multimap(InputIterator f, InputIterator l, size_type n, const hasher& hf
const key_equel& eql)
: rep(n, hf, eql) { rep.insert_equal(f, l); }
public:

size_type size() const {return rep.size();}
size_type max_size() const { return rep.max_size(); }
bool empty() const {return rep.empty(); }
void swap(hash_multimap& hs) { rep.swap(hs.rep); }
friend bool operator== __STL_NULL_TMPL_ARGS (const hash_multimap&, const hash_multimap&);

iterator begin() const { return rep.begin(); }
iterator end() const { return rep.end(); }
public:
iterator insert(const value_type& obj)
{
return rep.insert_equal(obj);
}

template<class InputIterator>
void insert(InputIterator f, InputIterator l) { rep.insert_equal(f,l); }
iterator insert_noresize(const value_type& obj)
{
return rep.insert_equal_noresize(obj);
}

iterator find(const key_type& key) const { return rep.find(key); }
const_iterator find(const key_type& key) const { return rep.find(key);}

size_type count(const key_type& key) const {return rep.count(key); }

//相等的key的位置(是一个左闭右开的区间),由迭代器给出
pair<iterator, iterator> equal_range(const key_type& key) const
{ return rep.equal_range(key); }

size_type erase(const key_type& key) { return rep.erase(key); }
void erase(iterator it) { rep.erase(it); }
void erase(iterator f, iterator l) { rep.erase(f, l); }
void clear() { rep.clear(); }

public:
void resize(size_type hint) { rep.resize(hint); }
size_type bucket_count() const { return rep.bucket_count(); }
size_type elems_in_bucket(size_type n) const
{ return rep.elems_in_bucket(n); }

//class
template<class Value, class HashFcn, class EqualKey, class Alloc>
inline bool operator==(const hash_multimap<Value, HashFcn, EqualKey, Alloc>& hm1,
const hash_multimap<Value, HashFcn, EqualKey, Alloc>& hm2)
{
return has1.rep == has2.rep;
}
};

算法

算法概观

算法,问题之解法也。以有限的步骤,解决逻辑或数学上的问题,这一专门科目称为算法。STL 正是将极具复用价值的算法进行封装,包含sort,find,copy等函数。

STL算法总览

表格中凡是不在STL标准规格之列的SGI专属算法,都以*加以标识。



所有的STL算法都作用在迭代器 [first,last) 所标出来的区间上。根据是否改变操作对象的值,可以分为 质变算法(mutating algorithms)和 非质变算法 (nomutating algorithms)。

质变算法,是指运算过程中会更改区间内元素的内容的算法。比如,拷贝(copy),互换(swap),替换(replace),填写(fill),删除(remove),排列组合(permutation),分割(partition),随机重排(random shuffling),排序(sort)等。

非质变算法,是指运算过程中不会更改区间内元素的内容的算法。比如,查找(find),匹配(search),计数(count),巡访(for_each),比较(equal,mismatch),寻找极值(max,min)等。但是在for_each算法上应用一个会改变元素内容的仿函数,所在元素必然会改变:

算法的泛化过程

如何将算法独立于其所处理的数据结构之外,不受数据结构的约束?关键在于,要把操作对象的型别加以抽象化,把操作对象的标示法和区间目标的移动行为抽象化,整个算法也就在一个抽象层面上工作了。整个过程称为算法的泛型化(generalized),简称泛化。

以简单的循序查找为例,编写find()函数,在array中寻找特定值。面对整数array,写出如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
int* find(int* arrayHead, int arraySize, int value)
{
int i=0;
for (; i<arraySize; ++i)
{
if (arrayHead[i] == value)
{
break;
}
}

return &(arrayHead[i]);
}

上述find()函数写法暴露了太多的实现细节(例如arraySize),为了让find()适用于所有类型的容器,其操作应该更抽象化些。让find()接受两个指针作为参数,标示一个操作区间:
1
2
3
4
5
6
7
int* find(int* begin, int*end, int value)
{
while(begin !=end && *begin != value)
++begin;

return begin;
}

由于find()函数之内并无任何操作是针对特定整数array而发的,所以我们可以把它改成一个template:
1
2
3
4
5
6
7
8
9
10
template<typename T>
T* find(T* begin, T* end, const T& value)
{
// 注意,以下用到了operator!=, operator*, operator++
while (begin != end && *begin != value)
++begin;

// 注意,以下返回操作用会引发copy行为
return begin;
}

在上述代码中,传入的指针必须支持以下四种操作行为:

  • inequality 判断不相等
  • dereferencelm 提领
  • prefix increment 前置式递增
  • copy 复制

上述操作符可以被重载(overload),find()函数就可以从原生(native)指针的思想框框中跳脱出来。我们可以设计一个class,拥有原生指针的行为,这就是迭代器(iterator):

1
2
3
4
5
6
7
8
template<typename Iterator, typename T>
Iterator find(Iterator begin, Iterator end, const T& value)
{
while(begin != end && *begin != value)
++begin;

return begin;
}

至此,便是完全泛型化的find()函数。

数值算法

C++ STL 的数值算法(Numeric algorithms)是一组对容器元素进行数值计算的模板函数,包括容器元素求和 accumulate 、两序列元素的内积 inner_product 、容器元素的一系列部分元素和 partial_sum 、容器每对相邻元素的差adjacent_difference。其头文件为<numeric>,测试实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// file: 6numeric.cpp

#include <numeric>
#include <vector>
#include <functional> // minus<int>()
#include <iostream> // ostream_iterator
#include <iterator>

using namespace std;

int main() {
int ia[5] = {1, 2, 3, 4, 5};
vector<int> iv(ia, ia + 5);
// 0+1+2+3...
cout << accumulate(iv.begin(), iv.end(), 0) << endl;
//0是初值,T accumulate(InputIterator first, InputIterator last, T init)

// 0-1-2-3
cout << accumulate(iv.begin(), iv.end(), 0, minus<int>()) << endl;

// 10 + 1*1 + 2*2 + ...
cout << inner_product(iv.begin(), iv.end(), iv.begin(), 10) << endl;

// 10 - 1+1 - 2+2 - ...
cout << inner_product(iv.begin(), iv.end(), iv.begin(), 10,
minus<int>(), plus<int>()) << endl;

// 将迭代器绑定到cout,作为输出用
ostream_iterator<int> oite(cout, " ");

// 1 3 6 10 15 累计和
partial_sum(iv.begin(), iv.end(), oite);
cout << endl;

// 1 -1 -4 -8 -13 累计差
partial_sum(iv.begin(), iv.end(), oite, minus<int>());
cout << endl;

// 1 1 1 1 1 new #n = #n - #n-1
adjacent_difference(iv.begin(), iv.end(), oite);
cout << endl;

// 1 3 5 7 9 new #n = op(#n, #n-1)
adjacent_difference(iv.begin(), iv.end(), oite, plus<int>());
cout << endl;

// mingw c++ 中stl没有power实现
// cout << power(10, 3) << endl;
// cout << power(10, 3, plus<int>()) << endl;

int n = 3;
iota(iv.begin(), iv.end(), n); // 填入n, n+1, n+2
for (int i = 0; i < iv.size(); ++i)
cout << iv[i] << ' ';

return 0;
}

运行结果:
1
2
3
4
5
6
7
8
9
10
[root@192 6_STL_algorithms] ./6_3_1_numeric
15
-15
65
-20
1 3 6 10 15
1 -1 -4 -8 -13
1 1 1 1 1
1 3 5 7 9
3 4 5 6 7 [

accumlate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* 函数名:accumulate
* 功能: 将指定区间内的元素累加
*/
// 版本1,算法缺省行为
template <class InputIterator,class T>
T accumulate(InputIterator first, InputIterator last, T init)
{
for (; first != last; first++)
{
init = init + *first;
}
return init;
}

// 版本2,接收外界传入一个仿函数
template <class InputIterator,class T,class BinaryOperation>
T accumulate(InputIterator first, InputIterator last, T init, BinaryOperation binary_op)
{
for (; first != last; first++)
{
init = binary_op(init, *first);
}
return init;
}

adjacent_differencee

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/*
* 函数名: adjacent_differencee
* 功能: 计算[first,last)中相邻元素的差额,首元素内容不变
* 说明: 与partial_sum互为逆运算
*/
// 版本1,算法缺省行为
template <class InputIterator, class OutputIterator>
OutputIterator adjacent_difference(InputIterator first, InputIterator last, OutputIterator result)
{
if (first == last)
{
return result; // 区间内容为空直接返回result
}
*result = *first; // 首先记录第一个元素(即原容器中第一个元素内容不变)
iterator_traits<InputIterator>::value_type value = *first;
while (++first != last) // 之后的元素为本位置-前一个位置的值
{
T tmp = *first;
*++first = tmp - value;
value = tmp;
}
return ++result;
}

// 版本2,接收外界传入一个仿函数
template <class InputIterator, class OutputIterator, class BinaryOperation>
OutputIterator adjacent_difference(InputIterator first, InputIterator last,
OutputIterator result, BinaryOperation binary_op)
{
if (first == last)
{
return result; // 区间内容为空直接返回result
}
*result = *first; // 首先记录第一个元素(即原容器中第一个元素内容不变)
iterator_traits<InputIterator>::value_type value = *first;
while (++first != last)
{
T tmp = *first;
*++first = binary_op(tmp, value);
value = tmp;
}
return ++result;
}

partial_sum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/*
* 函数名: partial_sum
* 功能: 计算[first,last)中相邻元素的差额,首元素内容不变
* 说明: 与adjacent_difference互为逆运算
*/
// 版本1,算法缺省行为
template <class InputIterator, class OutputIterator>
OutputIterator partial_sum(InputIterator first, InputIterator last, OutputIterator result)
{
if (first == last)
{
return result; // 区间内容为空直接返回result
}
*result = *first; // 首先记录第一个元素(即原容器中第一个元素内容不变)
iterator_traits<InputIterator>::value_type value = *first;
while (++first != last) // 之后的元素为本位置+前一个位置的值
{
value = value + *first;
*++result = value;
}
return ++result;
}

// 版本2,接收外界传入一个仿函数
template <class InputIterator, class OutputIterator, class BinaryOperation>
OutputIterator partial_sum(InputIterator first, InputIterator last,
OutputIterator result, BinaryOperation binary_op)
{
if (first == last)
{
return result; // 区间内容为空直接返回result
}
*result = *first; // 首先记录第一个元素(即原容器中第一个元素内容不变)
iterator_traits<InputIterator>::value_type value = *first;
while (++first != last)
{
value = binary_op(value, *first);
*++result = value;
}
return ++result;
}

power

考虑x^23,可以先从x ->x^2 -> x^4 -> x^8 -> x^16 取result1 = x^16,然后23-16=7。
我们只要计算x^7再与result1相乘就可以得到x^23。对于x^7也可以采用这种方法

取result2 = x^4,然后7-4=3,只要计算x^3再与result2相乘就可以得到x^7。由此可以将x^23写成x^16 x^4 x^2 x,即23=16+4+2+1,而23 = 10111(二进制),所以只要将n化为二进制并由低位到高位依次判断如果第i位为1,则result =x^(2^i)。

此函数可以在相乘O(logN)次内计算x的n次幂,且避免了重复计算。但还可以作进一步的优化,如像48=110000(二进制)这种低位有很多0的数,可以先过滤掉低位的0再进行计算,这样也会提高一些效率。程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*
* 函数名: power
* 功能: 对自己进行某种运算n次,缺省值是乘方
*/
// 版本1,算法缺省行为
template <class T, class Integer>
inline T power(T x, Integer n)
{
return power(x,n,multiplies<T>()); // multiplies<T>()是一个仿函数的临时对象,意为相乘
}

// 版本2,如果指定为乘方运算,则当n >= 0时返回x^n
// MonoidOperation必须满足结合律,可不满足交换律
template <class T, class Integer, class MonoidOperation op>
T power(T x, Integer n, MonoidOperation op)
{
if (n == 0) // 直接返回1也行
{
return identity_element(op); // 取出证同元素
}
else // 过滤低位的0
{
while ((n & 1) == 0)
{
n >>= 1; // n右移一位
x = op(x, x); // x = x op x;
}

}
T result = x;
n >>= 1;
while (n != 0)
{
x = op(x, x);
if ((n & 1) != 0)
{
result = op(result, x);
}
n >>= 1;
}
return result;
}

inner_product

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*
* 函数名: inner_product
* 功能: 计算[first1,last1)和[first2,first2+(last1 - first1))的一般内积
*/
// 版本1,算法缺省行为
template <class InputIterator1, class InputIterator2, class T>
T inner_product(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, T init)
{
// 以第一序列为依据,将两个序列都走一遍
for (; first1 != last1; ++first1, ++first2)
{
init = init + (*first1 * *first2); //执行两个序列的一般内积
}
return init;
}

template <class InputIterator1, class InputIterator2,
class BinaryOperation1, class BinaryOperation2, class T>
T inner_product(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, T init,
BinaryOperation1 binary_op1, BinaryOperation2 binary_op2)
{
// 以第一序列为依据,将两个序列都走一遍
for (; first1 != last1; ++first1, ++first2)
{
init = binary_op1(init, binary_op2(*first1, *first2)); //执行两个序列的一般内积
}
return init;
}

iota

1
2
3
4
5
6
7
8
9
10
11
12
/*
* 函数名: iota
* 功能: 在区间[first,last)填入value,value+1,value+2,value+3
*/
template <class ForwardIterator, class T>
void iota(ForwardIterator first, ForwardIterator last, T value)
{
while (first != last)
{
*first = value++;
}
}

基本算法

STL标准中没有区分基本算法或复杂算法,单SGI把常用的一些算法(equal,fill,fill_n,iter_swap,lexicographical_compare,max,min,mismatch,swap,copy,copy_backward,copy_n)定义在<stl_algobase.h>只中,其他算法定义在<stl_algo.h>中。

equal作用:判断[first,last)区间两个元素是否相同,第二个迭代器多出来的元素不予考虑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class _InputIter1, class _InputIter2>//版本1
inline bool equal(_InputIter1 __first1, _InputIter1 __last1,
_InputIter2 __first2) {
for ( ; __first1 != __last1; ++__first1, ++__first2)//遍历区间[first,last)元素
if (*__first1 != *__first2)//只有有一个不相等返回false
return false;
return true;
}

template <class _InputIter1, class _InputIter2, class _BinaryPredicate>//版本2
inline bool equal(_InputIter1 __first1, _InputIter1 __last1,
_InputIter2 __first2, _BinaryPredicate __binary_pred) {
for ( ; __first1 != __last1; ++__first1, ++__first2)
if (!__binary_pred(*__first1, *__first2))//两个元素执行二元操作符
return false;
return true;
}

fill作用:将指定区间元素改为新值。

1
2
3
4
5
template <class _ForwardIter, class _Tp>
void fill(_ForwardIter __first, _ForwardIter __last, const _Tp& __value) {
for ( ; __first != __last; ++__first)//遍历整个区间
*__first = __value;//指定新值
}

fill_n作用:将指定区间前n个元素改为新值。

1
2
3
4
5
6
template <class _OutputIter, class _Size, class _Tp>
_OutputIter fill_n(_OutputIter __first, _Size __n, const _Tp& __value) {
for ( ; __n > 0; --__n, ++__first)
*__first = __value;
return __first;
}

iter_swap作用:将两个迭代器所指对象调换。

1
2
3
4
5
6
7
8
9
10
11
template <class _ForwardIter1, class _ForwardIter2, class _Tp>
inline void __iter_swap(_ForwardIter1 __a, _ForwardIter2 __b, _Tp*) {
_Tp __tmp = *__a;
*__a = *__b;
*__b = __tmp;
}

template <class _ForwardIter1, class _ForwardIter2>
inline void iter_swap(_ForwardIter1 __a, _ForwardIter2 __b) {
__iter_swap(__a, __b, __VALUE_TYPE(__a));
}

iter_swap()是“迭代器之value type派上用场的一个好例子。是的,该函数必须知道迭代器的value type,才能够据此声明一个对象,用来暂时存放迭代器所指对象。为此,上述源代码特别设计了一个双层构造,第一层调用第二层:并多出一个额外的参数value_type(a)。这么一来,第二层就有value type可以用了。乍见之下你可能会对这个额外参数在调用端和接受端的型别感到讶异,调用端是value_type(a),接受端却是T*。只要找出value_type()的定义瞧瞧,就一点也不奇怪了:
1
2
3
4
template<class Iterator>
inline typename iterator_traits<Iterator>::value_type* value_type(const Iterator&){
return static_cast<typename iterator_traits<Iterator>::value_type*>(0);
}

这种双层构造在SGI STL源代码中十分普遍。其实这并非必要,直接这么写就行:

1
2
3
4
5
6
template <class ForwardIterator1, class ForwardIterator2> 
inline void iter_swap(ForwardIterator1 a, ForwardIterator2 b) {
typename iterator_traits<ForwardIterator1>::value_type tmp = *a;
*a = *b;
*b = tmp;
}

lexicographical_compare作用:以“字典排列方式”对两个序列[first1, last1)和[first2, last2)进行比较。比较操作针对两序列中的对应位置上的元素进行,并持续直到:

  • 某组对应元素彼此不相等;
  • 同时到达last1和last2(当两序列的大小相同);
  • 到达last1或last2(当两序列的大小不同)

当这个函数在对应位置上发现第一组不相等的元素时,有下列几种可能:

  • 如果第一序列的元素较小,返回true,否则返回false;
  • 如果到达last1而尚未到达last2,返回true;
  • 如果到达llast2而尚未到达last1,返回false;
  • 如果同时到达last1和last2(换句话说所有元素都匹配),返回false。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 字典序比较, 非常类似字符串的比较
// 具体比较方式参见STL文档, 另外strcmp()也可以参考
template <class InputIterator1, class InputIterator2>
bool lexicographical_compare(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2)
{
for ( ; first1 != last1 && first2 != last2; ++first1, ++first2) {
if (*first1 < *first2)
return true;
if (*first2 < *first1)
return false;
}
return first1 == last1 && first2 != last2;
}

// 二元判别式自己指定, 其余同上
template <class InputIterator1, class InputIterator2, class Compare>
bool lexicographical_compare(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
Compare comp) {
for ( ; first1 != last1 && first2 != last2; ++first1, ++first2)
{
if (comp(*first1, *first2))
return true;
if (comp(*first2, *first1))
return false;
}
return first1 == last1 && first2 != last2;
}

// 针对字符串的特化, 针对原生指针const unsigned char*,效率至上
inline bool
lexicographical_compare(const unsigned char* first1,
const unsigned char* last1,
const unsigned char* first2,
const unsigned char* last2)
{
const size_t len1 = last1 - first1;
const size_t len2 = last2 - first2;
//memcmp标准C函数
const int result = memcmp(first1, first2, min(len1, len2));
return result != 0 ? result < 0 : len1 < len2;
}

// 针对字符串的特化, 针对原生指针const char*,效率至上
inline bool lexicographical_compare(const char* first1, const char* last1,
const char* first2, const char* last2)
{
#if CHAR_MAX == SCHAR_MAX
return lexicographical_compare((const signed char*) first1,
(const signed char*) last1,
(const signed char*) first2,
(const signed char*) last2);
#else
return lexicographical_compare((const unsigned char*) first1,
(const unsigned char*) last1,
(const unsigned char*) first2,
(const unsigned char*) last2);
#endif
}

// 一句话概括, 这个是strcmp()的泛化版本
template <class InputIterator1, class InputIterator2>
int lexicographical_compare_3way(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2)
{
while (first1 != last1 && first2 != last2) {
if (*first1 < *first2) return -1;
if (*first2 < *first1) return 1;
++first1; ++first2;
}
if (first2 == last2) {
return !(first1 == last1);
} else {
return -1;
}
}

// 特换版本, 效率决定一切
inline int
lexicographical_compare_3way(const unsigned char* first1,
const unsigned char* last1,
const unsigned char* first2,
const unsigned char* last2)
{
const ptrdiff_t len1 = last1 - first1;
const ptrdiff_t len2 = last2 - first2;
const int result = memcmp(first1, first2, min(len1, len2));
return result != 0 ? result : (len1 == len2 ? 0 : (len1 < len2 ? -1 : 1));
}

inline int lexicographical_compare_3way(const char* first1, const char* last1,
const char* first2, const char* last2)
{
#if CHAR_MAX == SCHAR_MAX
return lexicographical_compare_3way(
(const signed char*) first1,
(const signed char*) last1,
(const signed char*) first2,
(const signed char*) last2);
#else
return lexicographical_compare_3way((const unsigned char*) first1,
(const unsigned char*) last1,
(const unsigned char*) first2,
(const unsigned char*) last2);
#endif
}

max

去两个对象中的较大值,有两个版本,版本一使用对象类型T所提供的greater-than判断大小,版本二使用仿函数comp判断大小。

1
2
3
4
5
6
7
8
9
10
11
template <class T>
inline const T& max(const T& a, const T& b)
{
return a < b ? b : a;
}

template <class T, class Compare>
inline const T& max(const T& a, const T& b, Compare comp)
{
return comp(a, b) ? b : a;
}

min

max和min非常简单了, 由于返回的是引用, 因此可以嵌套使用

1
2
3
4
5
6
7
8
9
10
11
template <class T>
inline const T& min(const T& a, const T& b)
{
return b < a ? b : a;
}

template <class T, class Compare>
inline const T& min(const T& a, const T& b, Compare comp)
{
return comp(b, a) ? b : a;
}

mismatch

用来平行比较两个序列,指出两者之间的第一个不匹配点:返回一对迭代器,分别指向两序列中的不匹配点,如下图,如果两序列的所有对应元素都匹配,返回的便是两序列各自的iast迭代器。缺省情况下是以equality操作符来比较元素。但第二版本允许用户指定比较操作。如果第二序列的元素个数比第一序列多,多出 来的元素忽略不计。如果第几序列的元素个数比第一序列少,会发生未可预期的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template <class InputIterator1, class InputIterator2>
pair<InputIterator1, InputIterator2> mismatch(InputIterator1 first1,
InputIterator1 last1,
InputIterator2 first2)
{
// 遍历区间, 寻找失配点
while (first1 != last1 && *first1 == *first2) {
++first1;
++first2;
}
return pair<InputIterator1, InputIterator2>(first1, first2);
}

// 提供用户自定义的二元判别式, 其余同上
template <class InputIterator1, class InputIterator2, class BinaryPredicate>
pair<InputIterator1, InputIterator2> mismatch(InputIterator1 first1,
InputIterator1 last1,
InputIterator2 first2,
BinaryPredicate binary_pred)
{
while (first1 != last1 && binary_pred(*first1, *first2)) {
++first1;
++first2;
}
return pair<InputIterator1, InputIterator2>(first1, first2);
}

swap

交换对调两个对象内容

1
2
3
4
5
6
7
8
9
//交换a和b的值
//这里采用引用传参
template <class T>
inline void swap(T& a, T& b)
{
T tmp = a;
a = b;
b = tmp;
}

copy

copy()是一个调用频率非常高的函数,所以SGI STL的copy算法用尽各种办法,包括函数重载(function overloading)、型别特性(type traits)、偏特化(partial specialization)编程技巧,无所不用其极地加强效率。下图是整个copy()操作的脉络。

copy算法将输入区间[first, last)内的元素复制到result指向的输出区间内,赋值操作是向前推进的。如果输入区间和输出区间重叠,复制顺序需要多加讨论。当result位于[first, last)之内时,也就是说,如果输出区间的首部与输入区间重叠,copy的结果可能不正确,建议选用copy_backward;如果输出区间的尾部如输入区间重叠,copy_backward的结果可能不正确,建议选用copy。当然,如果两区间完全不重叠,copy和copy_backward都可以选用。

copy算法根据输出迭代器的特性决定是否调用memmove()来执行任务,memmove()会先将整个输入区间的内容复制下来,然后再复制到输入区间。这种情况下,即使输入区间和输出区间有重叠时,copy的结果也是正确的。这也回答了上文中提调的,为什么result位于[first, last)之内时,copy的结果只是“可能不正确”。

copy为输出区间内的元素赋予新值,而不是产生新元素,它不能改变输出区间的长度。换句话说,copy不能用来直接将元素插入到空容器中。如果你想要将元素插入序列之中,要么使用序列容器的insert成员函数,要么使用copy算法并搭配insert_iterator。

下面是copy算法唯三的对外接口,包括一个完全泛化版本和两个重载函数,重载函数针对原生指针const char*const wchar_t*进行内存直接拷贝操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class InputIterator, class OutputIterator> 
inline OutputIterator
copy(InputIterator first, InputIterator last, OutputIterator result) {
return __copy_dispatch<InputIterator, OutputIterator>()(first, last, result);
}

inline char* copy(const char* first, const char* last, char* result) {
memmove(result, first, last - first);
return result + (last - first);
}

inline wchar_t* copy(const wchar_t* first, const wchar_t* last, wchar_t* result) {
memmove(result, first, last - first);
return result + (last - first);
}

copy()函数的泛化版本中调用了一个__copy_dispatch()的仿函数,此仿函数有一个完全泛化版本和两个偏特化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class InputIterator, class OutputIterator>  
struct __copy_dispatch {
OutputIterator operator(){InputIterator first, InputIterator last, OutputIterator result) {
return __copy(first, last, result, iterator_category(first);
} ;

template <class T>
struct __copy_dispatch<T*, T*> {
T* operator()(T* first, T* last, T* result) {
typedef typename __type_traits<T>::has_trivial_assignment_operator t;
return __copy_t(first, last, result, t());
};
template <class T>
struct __copy_dispatch<const T*, T*> {
T* operator()(T* first, T* last, T* result) {
typedef typename __type_traits<T>::has_trivial_asssignment_operator t;
return __copy_t(first, last, result, t());
};

__copy_dispatch()的完全泛化版本根据迭代器种类的不同,调用不同的__copy(),为的是不同的迭代器使用的循环条件不同,有快慢之别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <class InputIterator, class OutputIterator> 
inline OutputIterator __copy(InputIterator first, InputIterator last,
OutputIterator result, input_iterator_tag) {
for( ; first != last; ++first, ++result)
*result = *first;
return result;
}

template <class RandomAccessIterator, class OutputIterator>
inline OutputIterator __copy(RandomAccessIterator first, RandomAccessIterator last,
OutputIterator result, random_access_iterator_tag) {
__return __copy_d(first, last, result, distance_type(first));
}

template <class RandomAccessIterator, class OutputIterator, class Distance>
inline OutputIterator __copy_d(RandomAccessIterator first, RandomAccessIterator last,
OutputIterator result, Distance*) {
// 以n决定循环次数,速度快
for(Distance n = last - first; n > 0; --n, ++result, ++first) {
*result = *first;
return result;
}

这两个偏特化版本是在“参数为原生指针形式”的前提下,利用__type_traits<>编程技巧来探测指针所指向之物是否有trivial assignment operator. 如果指针所指对象拥有trivial assignment operator,则可以通过memmove()进行复制,速度要比利用赋值操作赋快许多。

1
2
3
4
5
6
7
8
9
template <class T> 
inline T* __copy_t(const T* first, const T* last, T* result, __true_type) {
memmove(result, first, sizeof(T) *(last - first);
return result + (last - first);
}
template <class T>
inline T* __copy_t(const T* first, const T* last, T* result, __false_type) {
return __copy_d(first, last, result, (ptrdiff_t *)0);
}

copy_backward

copy_backward()的实现与copy()极为相似,不同是它将[first, last)区间内的每一个元素,以逆行的方向复制到,以result-1为起点,方向同样为逆行的区间上。

1
2
3
4
5
6
7
8
9

template <class BidirectionalIterator1, class BidirectionalIterator2>
inline BidirectionalIterator2 __copy_backward(BidirectionalIterator1 first,
BidirectionalIterator1 last,
BidirectionalIterator2 result)
{
while (first != last) *--result = *--last;
return result;
}

这个算法的考虑以及实现的技巧与copy十分类似,其操作示意如图,将[first, last)区间内的每一个元素,以逆行的方向复制到,以result-1为起点,方向同样为逆行的区间上。返回一个迭代器result-(last-first)copy_backward所接受的迭代器必须是BidirectionIterators才能够“倒行逆施”。

set相关算法

STL提供了4个set相关的算法,分别是并集(union)、交集(intersection)、差集(difference)和对称差集(symmetric difference),这4个算法接受的set必须是有序区间,都至少接受4个参数,分别表示两个set区间。一般而言,set算法前4个参数分别表示两个区间,第五个参数表示存放结果的区间的起始位置。

set_union

求两个集合的并集,能够造出S1 U S2,此集合内含S1或S2内的每一个元素。如果某个值在S1出现n次,在S2出现m次,那么该值在输出区间中会出现max(m, n)次。返回值为一个迭代器,指向输出区间的尾端。是一种稳定操作,输入区间内的每个元素相对顺序都不会改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//并集,求存在于[first1, last1)或存在于[first2, last2)内的所有元素  
//注意:输入区间必须是已排序
//版本一,默认是operator<操作的排序方式
template <class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator set_union(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result) {
//两个区间都尚未到达区间尾端,执行以下操作
while (first1 != last1 && first2 != last2) {
/*在两区间内分别移动迭代器,首先将元素较小者(假设为A区)记录在目标区result,
移动A区迭代器使其前进;同时另一个区的迭代器不变。然后进行一次新的比较,
记录较小值,移动迭代器...直到两区间中有一个到达尾端。
若元素相等, 默认取第一区间元素到目标区result,同时移动两个迭代器.*/
if (*first1 < *first2) {
*result = *first1;
++first1;
}
else if (*first2 < *first1) {
*result = *first2;
++first2;
}
else {
*result = *first1;
++first1;
++first2;
}
++result;
}
/*只要两区间之中有一个区间到达尾端,就结束上面的while循环
以下将尚未到达尾端的区间剩余的元素拷贝到目标区
此刻,[first1, last1)和[first2, last2)至少有一个是空区间*/
return copy(first2, last2, copy(first1, last1, result));
}
//版本二,用户根据仿函数comp指定排序规则
template <class InputIterator1, class InputIterator2, class OutputIterator,class Compare>
OutputIterator set_union(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result, Compare comp) {
while (first1 != last1 && first2 != last2) {
if (comp(*first1, *first2)) {
*result = *first1;
++first1;
}
else if (comp(*first2, *first1)) {
*result = *first2;
++first2;
}
else {
*result = *first1;
++first1;
++first2;
}
++result;
}
return copy(first2, last2, copy(first1, last1, result));
}

图解如下:

set_intersection

求两个集合的交集,此集合内含同时出现于S1和S2内的每一个元素。如果某个值在S1出现n次,在S2出现m次,那么该值在输出区间中会出现min(m, n)次,并且全部来自S1。

返回值为一个迭代器,指向输出区间的尾端。

是一种稳定操作,输入区间内的每个元素相对顺序都不会改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//交集,求存在于[first1, last1)且存在于[first2, last2)内的所有元素  
//注意:输入区间必须是已排序
//版本一,默认是operator<操作的排序方式
template <class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator set_intersection(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result) {
//若两个区间都尚未到达尾端,则执行以下操作
while (first1 != last1 && first2 != last2)
//在两个区间分别移动迭代器,直到遇到相等元素,记录到目标区
//继续移动迭代器...直到两区间之中有一区到达尾端
if (*first1 < *first2)
++first1;
else if (*first2 < *first1)
++first2;
else {
*result = *first1;
++first1;
++first2;
++result;
}
return result;
}

图解如下:

set_difference

求两个集合的差集,此集合内含出现于S1但不出现于S2内的元素。如果某个值在S1出现n次,在S2出现m次,那么该值在输出区间中会出现max(n-m, 0)次,并且全部来自S1。

返回值为一个迭代器,指向输出区间的尾端。

是一种稳定操作,输入区间内的每个元素相对顺序都不会改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//差集,求存在于[first1, last1)且不存在于[first2, last2)内的所有元素  
//注意:输入区间必须是已排序
//版本一,默认是operator<操作的排序方式
template <class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator set_difference(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result) {
//若两个区间都尚未到达尾端,则执行以下操作
while (first1 != last1 && first2 != last2)
/*在两个区间分别移动迭代器,当第一区间元素等于第二区
间元素时,表示两区间共同存在该元素,则同时移动迭代器;
当第一区间元素大于第二区间元素时,就让第二区间迭代器前进;
第一区间元素小于第二区间元素时,把第一区间元素记录到目标区
继续移动迭代器...直到两区间之中有到达尾端*/
if (*first1 < *first2) {
*result = *first1;
++first1;
++result;
}
else if (*first2 < *first1)
++first2;
else {
++first1;
++first2;
}
//把第一区间剩余的元素(若有剩余)复制到目标区
return copy(first1, last1, result);
}

图解如下:

set_symmetric_difference

求两个集合的对称差集(s1-s2)∪(s2-s1),此集合内含出现于S1但不出现于S2内的元素,以及出现于S2但不出现于S1内的每一个元素。如果某个值在S1出现n次,在S2出现m次,那么该值在输出区间中会出现|n-m|次。

返回值为一个迭代器,指向输出区间的尾端。

是一种稳定操作,输入区间内的每个元素相对顺序都不会改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//对称差集,求存在于[first1, last1)但不存在于[first2, 
//last2)内的所有元素以及出现在[first2, last2)但不出现在
//[first1, last1)的所有元素
//注意:输入区间必须是已排序
//版本一,默认是operator<操作的排序方式
template <class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator set_symmetric_difference(InputIterator1 first1,
InputIterator1 last1,
InputIterator2 first2,
InputIterator2 last2,
OutputIterator result) {
//若两个区间都尚未到达尾端,则执行下面的操作
while (first1 != last1 && first2 != last2)
/*在两区间内分别移动迭代器。当两区间内的元素相等,就让两区同时前进;
当两区间内的元素不等,就记录较小值于目标区,并令较小值所在区间前进*/
if (*first1 < *first2) {
*result = *first1;
++first1;
++result;
}
else if (*first2 < *first1) {
*result = *first2;
++first2;
++result;
}
else {
++first1;
++first2;
}
return copy(first2, last2, copy(first1, last1, result));
}

图解如下:

应用实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include<set>  
#include<iterator>
#include<iostream>
using namespace std;

template<typename T>
struct display{
void operator()(const T& x) const{
cout << x << " ";
}
};

int main(){
int ia[] = { 1, 3, 5, 7, 9, 11 };
int ib[] = { 1, 1, 2, 3, 5, 8, 13 };
multiset<int> s1(begin(ia), end(ia));
multiset<int> s2(begin(ib), end(ib));

for_each(s1.begin(), s1.end(), display<int>());
cout << endl;
for_each(s2.begin(), s2.end(), display<int>());
cout << endl;

multiset<int>::iterator first1 = s1.begin();
multiset<int>::iterator last1 = s1.end();
multiset<int>::iterator first2 = s2.begin();
multiset<int>::iterator last2 = s2.end();

cout << "union of s1 and s2:";
set_union(first1, last1, first2, last2, ostream_iterator<int>(cout, " "));
cout << endl;

cout << "intersection of s1 and s2:";
set_intersection(first1, last1, first2, last2, ostream_iterator<int>(cout, " "));
cout << endl;

cout << "difference of s1 and s2:";
set_difference(first1, last1, first2, last2, ostream_iterator<int>(cout, " "));
cout << endl;

cout << "symmetric differenceof s1 and s2:";
set_symmetric_difference(first1, last1, first2, last2, ostream_iterator<int>(cout, " "));
cout << endl;
return 0;
}

其他算法

单纯的数据处理

测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#include <algorithm>
#include <vector>
#include <functional>
#include <iostream>

using namespace std;

// 仿函数
template <class T>
struct display {
void operator() (const T& x) const {
cout << x << ' ';
}
};

// 仿函数,一元谓词
struct even {
bool operator() (int x) const {
return x % 2 ? false : true;
}
};

// 还是一个仿函数
class even_by_two {
public:
int operator() () const {
return _x += 2;
}

private:
static int _x;
};

int even_by_two::_x = 0; // 类内声明类外初始化

int main() {
int ia[] = {0, 1, 2, 3, 4, 5, 6, 6, 6, 7, 8};
vector<int> iv(ia, ia + sizeof(ia) / sizeof(int));

// 找出相邻元素值相等的第一个元素
cout << *adjacent_find(iv.begin(), iv.end()) << endl;
// 找出相邻元素值相等的第一个元素
cout << *adjacent_find(iv.begin(), iv.end(), equal_to<int>()) << endl;

// 找出元素值为6的元素个数
cout << count(iv.begin(), iv.end(), 6) << endl;
// 找出元素值小于7的元素个数
cout << count_if(iv.begin(), iv.end(), bind2nd(less<int>(), 7)) << endl;

// 找出元素值为4的元素所在位置
cout << *find(iv.begin(), iv.end(), 4) << endl;
// 找出元素值大于2的第一个元素所在位置
cout << *find_if(iv.begin(), iv.end(), bind2nd(greater<int>(), 2)) << endl;

// 找出子序列所出现的最后一个位置,加3
vector<int> iv2(ia + 6, ia + 8);
cout << *(find_end(iv.begin(), iv.end(), iv2.begin(), iv2.end()) + 3) << endl;

// 找出子序列所出现的第一个位置,加3
cout << *(find_first_of(iv.begin(), iv.end(), iv2.begin(), iv2.end()) + 3) << endl;

// 迭代iv 每个元素进行display
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;

// 以下写法错误,generate的第三个参数是仿函数,本身不得有任何参数
// generate(iv.begin(), iv.end(), bind2nd(plus<int>(), 3)); // error

// 迭代iv2,对每个元素进行even_by_two
generate(iv2.begin(), iv2.end(), even_by_two());
for_each(iv2.begin(), iv2.end(), display<int>());
cout << endl;
// 迭代指定区间,对每个元素进行even_by_two
generate_n(iv2.begin(), 3, even_by_two());
for_each(iv2.begin(), iv2.end(), display<int>());
cout << endl;

// 删除(但不删除)元素6,尾端可能有残余数据(可以使用erase)
remove(iv.begin(), iv.end(), 6);
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;

// 删除(但不删除)元素6,结果置于另一区间
vector<int> iv3(12);
remove_copy(iv.begin(), iv.end(), iv3.begin(), 6);
for_each(iv3.begin(), iv3.end(), display<int>());
cout << endl;

// 删除(但不删除)元素6,尾端可能有残余数据
remove_if(iv.begin(), iv.end(), bind2nd(less<int>(), 6));
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;

// 删除(但不删除)小于7的元素,结果置于另一区间
remove_copy_if(iv.begin(), iv.end(), iv3.begin(), bind2nd(less<int>(), 7));
for_each(iv3.begin(), iv3.end(), display<int>());
cout << endl;

// 将所有的元素值6,改为元素值3
replace(iv.begin(), iv.end(), 6, 3);
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;

// 将所有的元素值3,改为元素值5,结果置于另一区间
replace_copy(iv.begin(), iv.end(), iv3.begin(), 3, 5);
for_each(iv3.begin(), iv3.end(), display<int>());
cout << endl;

// 将所有小于5的元素值,改为元素值2
replace_if(iv.begin(), iv.end(), bind2nd(less<int>(), 5), 2);
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;

// 将所有等于8的元素值,改为元素值9,结果置于另一区间
replace_copy_if(iv.begin(), iv.end(), iv3.begin(), bind2nd(equal_to<int>(), 8), 9);
for_each(iv3.begin(), iv3.end(), display<int>());
cout << endl;

// 逆向重排每一个元素
reverse(iv.begin(), iv.end());
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;
// 逆向重排每一个元素,结果置于另一区间
reverse_copy(iv.begin(), iv.end(), iv3.begin());
for_each(iv3.begin(), iv3.end(), display<int>());
cout << endl;

// 旋转(互换元素)[first, middle]和[middle, last]
rotate(iv.begin(), iv.begin() + 4, iv.end());
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;
// 旋转(互换元素)[first, middle]和[middle, last]
// 结果置于另一区间
rotate_copy(iv.begin(), iv.begin() + 5, iv.end(), iv3.begin());
for_each(iv3.begin(), iv3.end(), display<int>());
cout << endl;

// 查找某个子序列的第一次出现地点
int ia2[3] = {2, 8};
vector<int> iv4(ia2, ia2 + 2);
cout << *search(iv.begin(), iv.end(), iv4.begin(), iv4.end()) << endl;
// 查找连续出现2个8的子序列起点
cout << *search_n(iv.begin(), iv.end(), 2, 8) << endl;
// 查找连续出现3个小于8的子序列起点
cout << *search_n(iv.begin(), iv.end(), 3, 8, less<int>()) << endl;

// 将区间元素互换,第二区间个数不应小于第一区间个数
swap_ranges(iv4.begin(), iv4.end(), iv.begin());
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;
for_each(iv4.begin(), iv4.end(), display<int>());
cout << endl;

// 改变区间的值,全部减2,原地搬运
transform(iv.begin(), iv.end(), iv.begin(), bind2nd(minus<int>(), 2));
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;

// 改变区间的值,令 第二区间的元素值加到第一区间上
transform(iv.begin(), iv.end(), iv.begin(), iv.begin(), plus<int>());
for_each(iv.begin(), iv.end(), display<int>());
cout << endl;

// *******************************************

vector<int> iv5(ia, ia + sizeof(ia) / sizeof(int));
vector<int> iv6(ia + 4, ia + 8);
vector<int> iv7(15);
for_each(iv5.begin(), iv5.end(), display<int>());
cout << endl;
for_each(iv6.begin(), iv6.end(), display<int>());
cout << endl;

cout << *max_element(iv5.begin(), iv5.end()) << endl;
cout << *min_element(iv5.begin(), iv5.end()) << endl;

// 判断是否iv6内的所有元素都出现于iv5中
cout << includes(iv5.begin(), iv5.end(), iv6.begin(), iv6.end()) << endl;

// 将两个序列合并,必须有序
merge(iv5.begin(), iv5.end(), iv6.begin(), iv6.end(), iv7.begin());
for_each(iv7.begin(), iv7.end(), display<int>());
cout << endl;

// 符合条件的元素放在容器前端,其他放后端
partition(iv7.begin(), iv7.end(), even());
for_each(iv7.begin(), iv7.end(), display<int>());
cout << endl;

// 去除连续重复元素
unique(iv5.begin(), iv5.end());
for_each(iv5.begin(), iv5.end(), display<int>());
cout << endl;
// 去除连续重复元素,结果置于另一区间
unique_copy(iv5.begin(), iv5.end(), iv7.begin());
for_each(iv7.begin(), iv7.end(), display<int>());
cout << endl;
}

adjacent_find

找出第一组满足条件的相邻元素。

1
2
3
4
5
6
7
8
9
10
template <class ForwardIterator>
ForwardIterator adjacent_find(ForwardIterator first, ForwardIterator last) {
if (first == last) return last;
ForwardIterator next = first;
while(++next != last) {
if (*first == *next) return first;
first = next;
}
return last;
}

count

查找某个元素出现的数目。

1
2
3
4
5
6
7
template <class InputIterator, class T, class Size>
void count(InputIterator first, InputIterator last, const T& value,
Size& n) {
for ( ; first != last; ++first)
if (*first == value)
++n;
}

count_if

返回仿函数计算结果为true的元素的个数

1
2
3
4
5
6
7
template <class InputIterator, class Predicate, class Size>
void count_if(InputIterator first, InputIterator last, Predicate pred,
Size& n) {
for ( ; first != last; ++first)
if (pred(*first))
++n;
}

find

查找第一个匹配的元素

1
2
3
4
5
template <class InputIterator, class T>
InputIterator find(InputIterator first, InputIterator last, const T& value) {
while (first != last && *first != value) ++first;
return first;
}

find_if

查找第一个使仿函数为true的元素。

1
2
3
4
5
6
template <class InputIterator, class Predicate>
InputIterator find_if(InputIterator first, InputIterator last,
Predicate pred) {
while (first != last && !pred(*first)) ++first;
return first;
}

find_end

在序列一的区间内查找序列二的最后一次出现点。要求完全匹配的序列,即连续出现的序列2。可以利用正向查找,每次向后查找,记录上次找的的位置,最后没有找到了,那么上次找到的位置就是最后一次。也可以利用逆向迭代器从后向前找到第一次出现的位置。上层函数为dispatch function。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template <class ForwardIterator1, class ForwardIterator2>
inline ForwardIterator1
find_end(ForwardIterator1 first1, ForwardIterator1 last1,
ForwardIterator2 first2, ForwardIterator2 last2)
{
#ifdef __STL_CLASS_PARTIAL_SPECIALIZATION
typedef typename iterator_traits<ForwardIterator1>::iterator_category
category1;
typedef typename iterator_traits<ForwardIterator2>::iterator_category
category2;
return __find_end(first1, last1, first2, last2, category1(), category2());
#else /* __STL_CLASS_PARTIAL_SPECIALIZATION */
return __find_end(first1, last1, first2, last2,
forward_iterator_tag(), forward_iterator_tag());
#endif /* __STL_CLASS_PARTIAL_SPECIALIZATION */
}

template <class ForwardIterator1, class ForwardIterator2,
class BinaryPredicate>
inline ForwardIterator1
find_end(ForwardIterator1 first1, ForwardIterator1 last1,
ForwardIterator2 first2, ForwardIterator2 last2,
BinaryPredicate comp)
{
#ifdef __STL_CLASS_PARTIAL_SPECIALIZATION
typedef typename iterator_traits<ForwardIterator1>::iterator_category
category1;
typedef typename iterator_traits<ForwardIterator2>::iterator_category
category2;
return __find_end(first1, last1, first2, last2, category1(), category2(),
comp);
#else /* __STL_CLASS_PARTIAL_SPECIALIZATION */
return __find_end(first1, last1, first2, last2,
forward_iterator_tag(), forward_iterator_tag(),
comp);
#endif /* __STL_CLASS_PARTIAL_SPECIALIZATION */
}

这是一种常见的技巧,令函数传递过程中产生迭代器类型的临时对象,再利用编译器的参数推导机制自动调用某个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
template <class ForwardIterator1, class ForwardIterator2>
ForwardIterator1 __find_end(ForwardIterator1 first1, ForwardIterator1 last1,
ForwardIterator2 first2, ForwardIterator2 last2,
forward_iterator_tag, forward_iterator_tag)
{
if (first2 == last2)
return last1;
else {
ForwardIterator1 result = last1;
while (1) {
ForwardIterator1 new_result = search(first1, last1, first2, last2);
if (new_result == last1)
return result;
else {
result = new_result;
first1 = new_result;
++first1;
}
}
}
}

template <class ForwardIterator1, class ForwardIterator2,
class BinaryPredicate>
ForwardIterator1 __find_end(ForwardIterator1 first1, ForwardIterator1 last1,
ForwardIterator2 first2, ForwardIterator2 last2,
forward_iterator_tag, forward_iterator_tag,
BinaryPredicate comp)
{
if (first2 == last2)
return last1;
else {
ForwardIterator1 result = last1;
while (1) {
ForwardIterator1 new_result = search(first1, last1, first2, last2, comp);
if (new_result == last1)
return result;
else {
result = new_result;
first1 = new_result;
++first1;
}
}
}
}

// find_end for bidirectional iterators. Requires partial specialization.
#ifdef __STL_CLASS_PARTIAL_SPECIALIZATION

template <class BidirectionalIterator1, class BidirectionalIterator2>
BidirectionalIterator1
__find_end(BidirectionalIterator1 first1, BidirectionalIterator1 last1,
BidirectionalIterator2 first2, BidirectionalIterator2 last2,
bidirectional_iterator_tag, bidirectional_iterator_tag)
{
typedef reverse_iterator<BidirectionalIterator1> reviter1;
typedef reverse_iterator<BidirectionalIterator2> reviter2;

reviter1 rlast1(first1);
reviter2 rlast2(first2);
reviter1 rresult = search(reviter1(last1), rlast1, reviter2(last2), rlast2);

if (rresult == rlast1)
return last1;
else {
BidirectionalIterator1 result = rresult.base();
advance(result, -distance(first2, last2));
return result;
}
}

template <class BidirectionalIterator1, class BidirectionalIterator2,
class BinaryPredicate>
BidirectionalIterator1
__find_end(BidirectionalIterator1 first1, BidirectionalIterator1 last1,
BidirectionalIterator2 first2, BidirectionalIterator2 last2,
bidirectional_iterator_tag, bidirectional_iterator_tag,
BinaryPredicate comp)
{
typedef reverse_iterator<BidirectionalIterator1> reviter1;
typedef reverse_iterator<BidirectionalIterator2> reviter2;

reviter1 rlast1(first1);
reviter2 rlast2(first2);
reviter1 rresult = search(reviter1(last1), rlast1, reviter2(last2), rlast2,
comp);

if (rresult == rlast1)
return last1;
else {
BidirectionalIterator1 result = rresult.base();
advance(result, -distance(first2, last2));
return result;
}
}

find_first of

找到序列2中的任何一个元素在序列一中出现的位置。不需要完全配匹配序列2,任何一个元素出现都可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <class InputIterator, class ForwardIterator>
InputIterator find_first_of(InputIterator first1, InputIterator last1,
ForwardIterator first2, ForwardIterator last2)
{
for ( ; first1 != last1; ++first1)
for (ForwardIterator iter = first2; iter != last2; ++iter)
if (*first1 == *iter)
return first1;
return last1;
}

template <class InputIterator, class ForwardIterator, class BinaryPredicate>
InputIterator find_first_of(InputIterator first1, InputIterator last1,
ForwardIterator first2, ForwardIterator last2,
BinaryPredicate comp)
{
for ( ; first1 != last1; ++first1)
for (ForwardIterator iter = first2; iter != last2; ++iter)
if (comp(*first1, *iter))
return first1;
return last1;
}

for_each

将仿函数施加于区间内的每个元素之上。不能够改变元素内容,返回值被忽略。可以用于打印元素的值等。

1
2
3
4
5
6
template <class InputIterator, class Function>
Function for_each(InputIterator first, InputIterator last, Function f) {
for ( ; first != last; ++first)
f(*first);
return f;
}

generate

将仿函数的运算结果赋值给区间内的每一个元素

1
2
3
4
5
template <class ForwardIterator, class Generator>
void generate(ForwardIterator first, ForwardIterator last, Generator gen) {
for ( ; first != last; ++first)
*first = gen();
}

generate_n

将仿函数的运算结果赋值给迭代器first开始的n个元素上

1
2
3
4
5
6
template <class OutputIterator, class Size, class Generator>
OutputIterator generate_n(OutputIterator first, Size n, Generator gen) {
for ( ; n > 0; --n, ++first)
*first = gen();
return first;
}

includes

判断序列二S2是否“涵盖于”序列一S1。S1和S2都必须是有序集合:其中的元素都可重复(不必唯一)。所谓涵盖,意思是“S2的每一个元素都出现于S1”。由于判断两个元素是否相等,必须以less或greater运算为依据(当S1元素不小于S2元素且S2元素不小于S1元素,两者即相等;或说当S1元素不大于S2元素且S2元素不大于S1元素,两者即相等),因此配合着两个序列S1和S2的排序方式(递增或递减),includes算法可供用户选择采用less或greater进行两元素的大小比较(comparison)。

换句话说,如果S1和S2是递增排序(以operator<执行比较操作),includes 算法应该这么使用:

1
includes(S1.begin(), S1.end(), S2.begin(), S2.end());

这和下一行完全相同:
1
includes(S1.begin(), S1.end(), S2.begin(), S2.end(), less<int>());

然而如果S1和S2是递减排序(以operator>执行比较操作),includes算法应该这么使用:
1
includes(S1.begin(), S1.end(), S2.begin(), S2.end(), greater<int>());

注意,S1或S2内的元素都可以重复,这种情况下所谓, S1内含一个S2子集合”的定义是:假设某元素在S2出现n次,在S1出现m次。那么如果m < n, 此算法会返回false。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
template <class InputIterator1, class InputIterator2>
bool includes(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2) {
while (first1 != last1 && first2 != last2)
if (*first2 < *first1)
return false;
else if(*first1 < *first2)
++first1;
else
++first1, ++first2;

return first2 == last2;
}

template <class InputIterator1, class InputIterator2, class Compare>
bool includes(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2, Compare comp) {
while (first1 != last1 && first2 != last2)
if (comp(*first2, *first1))
return false;
else if(comp(*first1, *first2))
++first1;
else
++first1, ++first2;

return first2 == last2;
}

max_element

返回区间内最大的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class ForwardIterator>
ForwardIterator max_element(ForwardIterator first, ForwardIterator last) {
if (first == last) return first;
ForwardIterator result = first;
while (++first != last)
if (*result < *first) result = first;
return result;
}

template <class ForwardIterator, class Compare>
ForwardIterator max_element(ForwardIterator first, ForwardIterator last,
Compare comp) {
if (first == last) return first;
ForwardIterator result = first;
while (++first != last)
if (comp(*result, *first)) result = first;
return result;
}

merge

将两个有序序列融合成一个序列,三个序列都是有序的。返回指向结果序列的尾后元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

template <class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator merge(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result) {
while (first1 != last1 && first2 != last2) {
if (*first2 < *first1) {
*result = *first2;
++first2;
}
else {
*result = *first1;
++first1;
}
++result;
}
return copy(first2, last2, copy(first1, last1, result));
}

template <class InputIterator1, class InputIterator2, class OutputIterator,
class Compare>
OutputIterator merge(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result, Compare comp) {
while (first1 != last1 && first2 != last2) {
if (comp(*first2, *first1)) {
*result = *first2;
++first2;
}
else {
*result = *first1;
++first1;
}
++result;
}
return copy(first2, last2, copy(first1, last1, result));
}

min_element

返回序列中数值最小的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

template <class ForwardIterator>
ForwardIterator min_element(ForwardIterator first, ForwardIterator last) {
if (first == last) return first;
ForwardIterator result = first;
while (++first != last)
if (*first < *result) result = first;
return result;
}

template <class ForwardIterator, class Compare>
ForwardIterator min_element(ForwardIterator first, ForwardIterator last,
Compare comp) {
if (first == last) return first;
ForwardIterator result = first;
while (++first != last)
if (comp(*first, *result)) result = first;
return result;
}

partition

对区间进行重新排列,所有被仿函数判定为true的元素被放倒区间的前端,被判定为false的元素被放到区间的后端。这个算法并不保留元素的原始相对位置。需要保留原始相对位置,应使用stable_partition。算法实现类似于快排,先从前向后找到一个false,再从后向前找到一个true,然后交换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <class BidirectionalIterator, class Predicate>
BidirectionalIterator partition(BidirectionalIterator first,
BidirectionalIterator last, Predicate pred) {
while (true) {
while (true)
if (first == last)
return first;
else if (pred(*first))
++first;
else
break;
--last;
while (true)
if (first == last)
return first;
else if (!pred(*last))
--last;
else
break;
iter_swap(first, last);
++first;
}
}

remove

移除区间内与value相等的元素。并不是真正移除,容器大小并未改变。只是将不相等的元素重新赋值到原区间上,所以会在原来有多余的元素。返回在重新整理后的元素的下一位置

1
2
3
4
5
6
7
template <class ForwardIterator, class T>
ForwardIterator remove(ForwardIterator first, ForwardIterator last,
const T& value) {
first = find(first, last, value);
ForwardIterator next = first;
return first == last ? first : remove_copy(++next, last, first, value);
}

remove_copy

移除区间内所有与value相等的元素;它并不真正从容器中删除那些元素(换句话说,原容器没有任何改变),而是将结果复制到一个以result标示起始位置的容器身上,新容器可以和原容器重叠,但如果对新容器实际给值时、 超越了旧容器的大小,会产生无法预期的结果。返回值OuputIterator指出被复制的最后元素的下一位置。

1
2
3
4
5
6
7
8
9
10
template <class InputIterator, class OutputIterator, class T>
OutputIterator remove_copy(InputIterator first, InputIterator last,
OutputIterator result, const T& value) {
for ( ; first != last; ++first)
if (*first != value) {
*result = *first;
++result;
}
return result;
}

remove_if

移除[first, last)区间内所有被仿函数pred核定为true的元素。它并不真正从容器中删除那些元素(换句话说,容器大小并未改变),每一个不符合pred条件的元素都会被轮番赋值给first之后的空间。返回值ForwardIterator标示出重新整理后的最后元素的下一位置,此算法会留有一些残余数据,如果要删除那些残余数据,可将返回的迭代器交给区间所在之容器的erase() member function。

1
2
3
4
5
6
7
template <class ForwardIterator, class Predicate>
ForwardIterator remove_if(ForwardIterator first, ForwardIterator last,
Predicate pred) {
first = find_if(first, last, pred);
ForwardIterator next = first;
return first == last ? first : remove_copy_if(++next, last, first, pred);
}

remove_copy_if

移除区间内所有与value相等的元素;它并不真正从容器中删除那些元素(换句话说,原容器没有任何改变),而是将结果复制到一个以result标示起始位置的容器身上,新容器可以和原容器重叠,但如果对新容器实际给值时、 超越了旧容器的大小,会产生无法预期的结果。

1
2
3
4
5
6
7
8
9
10
template <class InputIterator, class OutputIterator, class Predicate>
OutputIterator remove_copy_if(InputIterator first, InputIterator last,
OutputIterator result, Predicate pred) {
for ( ; first != last; ++first)
if (!pred(*first)) {
*result = *first;
++result;
}
return result;
}

replace

将区间内的所有元素用新元素取代

1
2
3
4
5
6
template <class ForwardIterator, class T>
void replace(ForwardIterator first, ForwardIterator last, const T& old_value,
const T& new_value) {
for ( ; first != last; ++first)
if (*first == old_value) *first = new_value;
}

replace_copy

与replace类似,只不过复制到其他容器上。新容器可以与原容器重叠

1
2
3
4
5
6
7
8
template <class InputIterator, class OutputIterator, class T>
OutputIterator replace_copy(InputIterator first, InputIterator last,
OutputIterator result, const T& old_value,
const T& new_value) {
for ( ; first != last; ++first, ++result)
*result = *first == old_value ? new_value : *first;
return result;
}

replace_if

移除区间内被仿函数判定为true的元素。原理与replace类似。

1
2
3
4
5
6
template <class ForwardIterator, class Predicate, class T>
void replace_if(ForwardIterator first, ForwardIterator last, Predicate pred,
const T& new_value) {
for ( ; first != last; ++first)
if (pred(*first)) *first = new_value;
}

replace_copy_if

与replace_if类似,但是新序列复制到result所指的区间内。

1
2
3
4
5
6
7
8
template <class Iterator, class OutputIterator, class Predicate, class T>
OutputIterator replace_copy_if(Iterator first, Iterator last,
OutputIterator result, Predicate pred,
const T& new_value) {
for ( ; first != last; ++first, ++result)
*result = pred(*first) ? new_value : *first;
return result;
}

reverse

将序列的元素在原容器中颠倒重排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <class BidirectionalIterator>
void __reverse(BidirectionalIterator first, BidirectionalIterator last,
bidirectional_iterator_tag) {
while (true)
if (first == last || first == --last)
return;
else
iter_swap(first++, last);
}

template <class RandomAccessIterator>
void __reverse(RandomAccessIterator first, RandomAccessIterator last,
random_access_iterator_tag) {
while (first < last) iter_swap(first++, --last);
}

template <class BidirectionalIterator>
inline void reverse(BidirectionalIterator first, BidirectionalIterator last) {
__reverse(first, last, iterator_category(first));
}

reverse_copy

将序列颠倒重排,将结果置于另一序列

1
2
3
4
5
6
7
8
9
10
11
template <class BidirectionalIterator, class OutputIterator>
OutputIterator reverse_copy(BidirectionalIterator first,
BidirectionalIterator last,
OutputIterator result) {
while (first != last) {
--last;
*result = *last;
++result;
}
return result;
}

rotate

以middle为中心将序列旋转,middle所指的元素将会变成第一个元素。rotate()可以交换两个长度不同的区间,swap_range()只能交换长度相同的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
template <class ForwardIterator>
inline void rotate(ForwardIterator first, ForwardIterator middle,
ForwardIterator last) {
if (first == middle || middle == last) return;
__rotate(first, middle, last, distance_type(first),
iterator_category(first));
}

template <class ForwardIterator, class Distance>
void __rotate(ForwardIterator first, ForwardIterator middle,
ForwardIterator last, Distance*, forward_iterator_tag) {
for (ForwardIterator i = middle; ;) {
iter_swap(first, i);
++first;
++i;
if (first == middle) {
if (i == last) return;
middle = i;
}
else if (i == last)
i = middle;
}
}

template <class BidirectionalIterator, class Distance>
void __rotate(BidirectionalIterator first, BidirectionalIterator middle,
BidirectionalIterator last, Distance*,
bidirectional_iterator_tag) {
reverse(first, middle);
reverse(middle, last);
reverse(first, last);
}

template <class RandomAccessIterator, class Distance>
void __rotate(RandomAccessIterator first, RandomAccessIterator middle,
RandomAccessIterator last, Distance*,
random_access_iterator_tag) {
Distance n = __gcd(last - first, middle - first);
while (n--)
__rotate_cycle(first, last, first + n, middle - first,
value_type(first));
}

template <class EuclideanRingElement>
EuclideanRingElement __gcd(EuclideanRingElement m, EuclideanRingElement n)
{
while (n != 0) {
EuclideanRingElement t = m % n;
m = n;
n = t;
}
return m;
}

template <class RandomAccessIterator, class Distance, class T>
void __rotate_cycle(RandomAccessIterator first, RandomAccessIterator last,
RandomAccessIterator initial, Distance shift, T*) {
T value = *initial;
RandomAccessIterator ptr1 = initial;
RandomAccessIterator ptr2 = ptr1 + shift;
while (ptr2 != initial) {
*ptr1 = *ptr2;
ptr1 = ptr2;
if (last - ptr2 > shift)
ptr2 += shift;
else
ptr2 = first + (shift - (last - ptr2));
}
*ptr1 = value;
}

rotate_copy

和rotate类似,将结果置于另一序列

1
2
3
4
5
template <class ForwardIterator, class OutputIterator>
OutputIterator rotate_copy(ForwardIterator first, ForwardIterator middle,
ForwardIterator last, OutputIterator result) {
return copy(first, middle, copy(middle, last, result));
}

在序列1中查找序列2的首次出现点,序列1中要求序列2完全匹配,不能间隔。不存在就返回last1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
template <class ForwardIterator1, class ForwardIterator2>
inline ForwardIterator1 search(ForwardIterator1 first1, ForwardIterator1 last1,
ForwardIterator2 first2, ForwardIterator2 last2)
{
return __search(first1, last1, first2, last2, distance_type(first1),
distance_type(first2));
}

template <class ForwardIterator1, class ForwardIterator2,
class BinaryPredicate, class Distance1, class Distance2>
ForwardIterator1 __search(ForwardIterator1 first1, ForwardIterator1 last1,
ForwardIterator2 first2, ForwardIterator2 last2,
BinaryPredicate binary_pred, Distance1*, Distance2*) {
Distance1 d1 = 0;
distance(first1, last1, d1);
Distance2 d2 = 0;
distance(first2, last2, d2);

if (d1 < d2) return last1;

ForwardIterator1 current1 = first1;
ForwardIterator2 current2 = first2;

while (current2 != last2)
if (binary_pred(*current1, *current2)) {
++current1;
++current2;
}
else {
if (d1 == d2)
return last1;
else {
current1 = ++first1;
current2 = first2;
--d1;
}
}
return first1;
}

search_n

和search类似,查找连续n个符合条件的元素形成的子序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
template <class ForwardIterator, class Integer, class T>
ForwardIterator search_n(ForwardIterator first, ForwardIterator last,
Integer count, const T& value) {
if (count <= 0)
return first;
else {
first = find(first, last, value);
while (first != last) {
Integer n = count - 1;
ForwardIterator i = first;
++i;
while (i != last && n != 0 && *i == value) {
++i;
--n;
}
if (n == 0)
return first;
else
first = find(i, last, value);
}
return last;
}
}

template <class ForwardIterator, class Integer, class T, class BinaryPredicate>
ForwardIterator search_n(ForwardIterator first, ForwardIterator last,
Integer count, const T& value,
BinaryPredicate binary_pred) {
if (count <= 0)
return first;
else {
while (first != last) {
if (binary_pred(*first, value)) break;
++first;
}
while (first != last) {
Integer n = count - 1;
ForwardIterator i = first;
++i;
while (i != last && n != 0 && binary_pred(*i, value)) {
++i;
--n;
}
if (n == 0)
return first;
else {
while (i != last) {
if (binary_pred(*i, value)) break;
++i;
}
first = i;
}
}
return last;
}
}

swap_range

将两个长度相同的序列交换。两个序列可以在同一容器,也可在不同容器。如果第一个序列长度小于第二个或者两个序列有重叠,结果不可预期。
返回第二个序列最后一个交换元素的下一位置

1
2
3
4
5
6
7
template <class ForwardIterator1, class ForwardIterator2>
ForwardIterator2 swap_ranges(ForwardIterator1 first1, ForwardIterator1 last1,
ForwardIterator2 first2) {
for ( ; first1 != last1; ++first1, ++first2)
iter_swap(first1, first2);
return first2;
}

transform

将仿函数作用于每一个元素身上,并以其结果产生出一个新序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class InputIterator, class OutputIterator, class UnaryOperation>
OutputIterator transform(InputIterator first, InputIterator last,
OutputIterator result, UnaryOperation op) {
for ( ; first != last; ++first, ++result)
*result = op(*first);
return result;
}

template <class InputIterator1, class InputIterator2, class OutputIterator,
class BinaryOperation>
OutputIterator transform(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, OutputIterator result,
BinaryOperation binary_op) {
for ( ; first1 != last1; ++first1, ++first2, ++result)
*result = binary_op(*first1, *first2);
return result;
}

unique

移除相邻的重复元素。类似于remove,并不是真正移除,而是将不重复的元素重新赋值于区间上。因为区间大小并未改变,所以尾部会有残余数据。算法是稳定的,所有你保留下来的元素其相对位置不变。

1
2
3
4
5
template <class ForwardIterator>
ForwardIterator unique(ForwardIterator first, ForwardIterator last) {
first = adjacent_find(first, last);
return unique_copy(first, last, first);
}

unique_copy

与unique类似,将结果复制到另一区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
template <class InputIterator, class OutputIterator>
inline OutputIterator unique_copy(InputIterator first, InputIterator last,
OutputIterator result) {
if (first == last) return result;
return __unique_copy(first, last, result, iterator_category(result));
}

template <class InputIterator, class ForwardIterator>
ForwardIterator __unique_copy(InputIterator first, InputIterator last,
ForwardIterator result, forward_iterator_tag) {
*result = *first;
while (++first != last)
if (*result != *first) *++result = *first;
return ++result;
}

template <class InputIterator, class OutputIterator>
inline OutputIterator __unique_copy(InputIterator first, InputIterator last,
OutputIterator result,
output_iterator_tag) {
return __unique_copy(first, last, result, value_type(first));
}


template <class InputIterator, class OutputIterator, class T>
OutputIterator __unique_copy(InputIterator first, InputIterator last,
OutputIterator result, T*) {
T value = *first;
*result = value;
while (++first != last)
if (value != *first) {
value = *first;
*++result = value;
}
return ++result;
}

lower_bound

lower_bound 二分查找的一个版本,在已排序区间中查找value。如果区间中有该元素,则返回迭代器,指向第一个该元素。如果没有改元素,则返回一个不小于value的元素。返回值为:在不破坏排序的情况下,可插入value的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
template <class ForwardIterator, class T>
inline ForwardIterator lower_bound(ForwardIterator first, ForwardIterator last,
const T& value) {
return __lower_bound(first, last, value, distance_type(first),
iterator_category(first));
}
template <class ForwardIterator, class T, class Compare>
inline ForwardIterator lower_bound(ForwardIterator first, ForwardIterator last,
const T& value, Compare comp) {
return __lower_bound(first, last, value, comp, distance_type(first),
iterator_category(first));
}

template <class ForwardIterator, class T, class Compare, class Distance>
ForwardIterator __lower_bound(ForwardIterator first, ForwardIterator last,
const T& value, Compare comp, Distance*,
forward_iterator_tag)
{
Distance len = 0;
distance(first, last, len);
Distance half;
ForwardIterator middle;
while(len > 0)
{
half = len >> 1;
middle = first; //对于前向迭代器才需要用这种方式寻找位置,对于随机访问迭代器可以直接+
first = advance(middle, half);
if(*middle < value)
{
first = middle + 1;
++first;
len = len - half - 1;
}
else //即使是相等,还需要迭代,因为要找出相等的元素中最前一个的位置
{
len = half;
}
}
return first;
}

template <class RandomAccessIterator, class T, class Compare, class Distance>
RandomAccessIterator __lower_bound(RandomAccessIterator first,
RandomAccessIterator last,
const T& value, Compare comp, Distance*,
random_access_iterator_tag) {
Distance len = last - first;
Distance half;
RandomAccessIterator middle;

while (len > 0) {
half = len >> 1;
middle = first + half;
if (comp(*middle, value)) {
first = middle + 1;
len = len - half - 1;
}
else
len = half;
}
return first;
}

upper_bound

upper_bound 和上述函数类似,寻找的是符合条件的位置的上限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
template <class ForwardIterator, class T>
inline ForwardIterator upper_bound(ForwardIterator first, ForwardIterator last,
const T& value) {
return __upper_bound(first, last, value, distance_type(first),
iterator_category(first));
}

template <class ForwardIterator, class T, class Compare>
inline ForwardIterator upper_bound(ForwardIterator first, ForwardIterator last,
const T& value, Compare comp) {
return __upper_bound(first, last, value, comp, distance_type(first),
iterator_category(first));
}

template <class ForwardIterator, class T, class Distance>
ForwardIterator __upper_bound(ForwardIterator first, ForwardIterator last,
const T& value, Distance*,
forward_iterator_tag) {
Distance len = 0;
distance(first, last, len);
Distance half;
ForwardIterator middle;

while (len > 0) {
half = len >> 1;
middle = first;
advance(middle, half);
if (value < *middle)
len = half;
else {
first = middle;
++first;
len = len - half - 1;
}
}
return first;
}

template <class RandomAccessIterator, class T, class Distance>
RandomAccessIterator __upper_bound(RandomAccessIterator first,
RandomAccessIterator last, const T& value,
Distance*, random_access_iterator_tag) {
Distance len = last - first;
Distance half;
RandomAccessIterator middle;

while (len > 0) {
half = len >> 1;
middle = first + half;
if (value < *middle)
len = half;
else {
first = middle + 1;
len = len - half - 1;
}
}
return first;
}
template <class ForwardIterator, class T, class Compare, class Distance>
ForwardIterator __upper_bound(ForwardIterator first, ForwardIterator last,
const T& value, Compare comp, Distance*,
forward_iterator_tag) {
Distance len = 0;
distance(first, last, len);
Distance half;
ForwardIterator middle;

while (len > 0) {
half = len >> 1;
middle = first;
advance(middle, half);
if (comp(value, *middle))
len = half;
else {
first = middle;
++first;
len = len - half - 1;
}
}
return first;
}

template <class RandomAccessIterator, class T, class Compare, class Distance>
RandomAccessIterator __upper_bound(RandomAccessIterator first,
RandomAccessIterator last,
const T& value, Compare comp, Distance*,
random_access_iterator_tag) {
Distance len = last - first;
Distance half;
RandomAccessIterator middle;

while (len > 0) {
half = len >> 1;
middle = first + half;
if (comp(value, *middle))
len = half;
else {
first = middle + 1;
len = len - half - 1;
}
}
return first;
}

算法binary_search是一种二分查找法,试图在已排序的[first, last)中寻找元素value。如果[first, last)内有等同于value的元素,便返回true, 否则返回false。

返回单纯的bool或许不能满足你,前面所介绍的lower_bound和upper_bound能够提供额外的信息。事实上binary_search便是利用lower_bound先找出“假设value存在的话,应该出现的位置”。然后再对比该位置上的值是 否为我们所要查找的目标,并返回对比结果。

正式地说,当且仅当(if and only if) [first, last)中存在一个迭代器i使*i < vlauevalue < *i皆不为真,返回true。

函数实现原理如下:在当前序列中,从尾端往前寻找两个相邻元素,前一个记为i,后一个记为ii,并且满足i < ii。然后再从尾端寻找另一个元素j,如果满足i < *j,即将第i个元素与第j个元素对调,并将第ii个元素之后(包括ii)的所有元素颠倒排序,即求出下一个序列了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class ForwardIterator, class T>
bool binary_search(ForwardIterator first, ForwardIterator last,
const T& value) {
ForwardIterator i = lower_bound(first, last, value);
return i != last && !(value < *i);
}

template <class ForwardIterator, class T, class Compare>
bool binary_search(ForwardIterator first, ForwardIterator last, const T& value,
Compare comp) {
ForwardIterator i = lower_bound(first, last, value, comp);
return i != last && !comp(value, *i);
}

next_permutation/prev_permutation

STL中提供了2个计算排列组合关系的算法。分别是next_permucation和prev_permutaion。

next_permutation是用来计算下一个(next)字典序排列的组合,而prev_permutation用来计算上一个(prev)字典序的排列组合。

字典排序是指排列组合中,按照大小由小到大的排序,例如123的排列组着,字典排序为123,132,213,231,312,321。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
template <class BidirectionalIterator>
bool next_permutation(BidirectionalIterator first,
BidirectionalIterator last) {
if (first == last) return false;
BidirectionalIterator i = first;
++i;
if (i == last) return false;
i = last;
--i;

for(;;) {
BidirectionalIterator ii = i;
--i;
if (*i < *ii) {
BidirectionalIterator j = last;
while (!(*i < *--j));
iter_swap(i, j);
reverse(ii, last);
return true;
}
if (i == first) {
reverse(first, last);
return false;
}
}
}

template <class BidirectionalIterator, class Compare>
bool next_permutation(BidirectionalIterator first, BidirectionalIterator last,
Compare comp) {
if (first == last) return false;
BidirectionalIterator i = first;
++i;
if (i == last) return false;
i = last;
--i;

for(;;) {
BidirectionalIterator ii = i;
--i;
if (comp(*i, *ii)) {
BidirectionalIterator j = last;
while (!comp(*i, *--j));
iter_swap(i, j);
reverse(ii, last);
return true;
}
if (i == first) {
reverse(first, last);
return false;
}
}
}

template <class BidirectionalIterator>
bool prev_permutation(BidirectionalIterator first,
BidirectionalIterator last) {
if (first == last) return false;
BidirectionalIterator i = first;
++i;
if (i == last) return false;
i = last;
--i;

for(;;) {
BidirectionalIterator ii = i;
--i;
if (*ii < *i) {
BidirectionalIterator j = last;
while (!(*--j < *i));
iter_swap(i, j);
reverse(ii, last);
return true;
}
if (i == first) {
reverse(first, last);
return false;
}
}
}

template <class BidirectionalIterator, class Compare>
bool prev_permutation(BidirectionalIterator first, BidirectionalIterator last,
Compare comp) {
if (first == last) return false;
BidirectionalIterator i = first;
++i;
if (i == last) return false;
i = last;
--i;

for(;;) {
BidirectionalIterator ii = i;
--i;
if (comp(*ii, *i)) {
BidirectionalIterator j = last;
while (!comp(*--j, *i));
iter_swap(i, j);
reverse(ii, last);
return true;
}
if (i == first) {
reverse(first, last);
return false;
}
}
}

random_shuffle

将区间内的元素随机重排,获得N!种全排列中的任意一种

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template <class RandomAccessIterator, class Distance>
void __random_shuffle(RandomAccessIterator first, RandomAccessIterator last,
Distance*) {
if (first == last) return;
for (RandomAccessIterator i = first + 1; i != last; ++i)
#ifdef __STL_NO_DRAND48
iter_swap(i, first + Distance(rand() % ((i - first) + 1)));
#else
iter_swap(i, first + Distance(lrand48() % ((i - first) + 1)));
#endif
}

template <class RandomAccessIterator>
inline void random_shuffle(RandomAccessIterator first,
RandomAccessIterator last) {
__random_shuffle(first, last, distance_type(first));
}

template <class RandomAccessIterator, class RandomNumberGenerator>
void random_shuffle(RandomAccessIterator first, RandomAccessIterator last,
RandomNumberGenerator& rand) {
if (first == last) return;
for (RandomAccessIterator i = first + 1; i != last; ++i)
iter_swap(i, first + rand((i - first) + 1));
}

partial_sort/partial_sort_copy

本算法接受一个middle迭代器(位于序列[first, last)之内),然后重新安排[first, last),使序列中的middle-first个最小元素以递增顺序排序, 置于[first, middle)内、其余last-middle个元素安置于[middle, last)中, 不保证有任何特定顺序。

使用sort算法,同样能保证较小的N个元素以递增顺序置于[first, first+N)中,

partial_sort的任务是找出middle-first个最小元素,因此,首先界定出区间[first, middle),并利用make_heap()将它组织成一个 max-heap,然后就可以将[middle, last)中的每一个元素拿来与max-heap的最大值比较(max-heap的最大值就在第一个元素身上,轻松可以获得):如果小于该最大值,则互换位置并保持max-heap状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 template <class RandomAccessIterator, class T>
void __partial_sort(RandomAccessIterator first, RandomAccessIterator middle,
RandomAccessIterator last, T*) {
make_heap(first, middle);
for (RandomAccessIterator i = middle; i < last; ++i)
if (*i < *first)
__pop_heap(first, middle, i, T(*i), distance_type(first));
sort_heap(first, middle);
}

template <class RandomAccessIterator>
inline void partial_sort(RandomAccessIterator first,
RandomAccessIterator middle,
RandomAccessIterator last) {
__partial_sort(first, middle, last, value_type(first));
}

template <class RandomAccessIterator, class T, class Compare>
void __partial_sort(RandomAccessIterator first, RandomAccessIterator middle,
RandomAccessIterator last, T*, Compare comp) {
make_heap(first, middle, comp);
for (RandomAccessIterator i = middle; i < last; ++i)
if (comp(*i, *first))
__pop_heap(first, middle, i, T(*i), comp, distance_type(first));
sort_heap(first, middle, comp);
}

template <class RandomAccessIterator, class Compare>
inline void partial_sort(RandomAccessIterator first,
RandomAccessIterator middle,
RandomAccessIterator last, Compare comp) {
__partial_sort(first, middle, last, value_type(first), comp);
}

partial_sort有一个姊妹,就是partial_sort_copy,它将(last-first)个最小元素排序后的结果置于(rsult_first, result_last)中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
template <class InputIterator, class RandomAccessIterator, class Distance,
class T>
RandomAccessIterator __partial_sort_copy(InputIterator first,
InputIterator last,
RandomAccessIterator result_first,
RandomAccessIterator result_last,
Distance*, T*) {
if (result_first == result_last) return result_last;
RandomAccessIterator result_real_last = result_first;
while(first != last && result_real_last != result_last) {
*result_real_last = *first;
++result_real_last;
++first;
}
make_heap(result_first, result_real_last);
while (first != last) {
if (*first < *result_first)
__adjust_heap(result_first, Distance(0),
Distance(result_real_last - result_first), T(*first));
++first;
}
sort_heap(result_first, result_real_last);
return result_real_last;
}

template <class InputIterator, class RandomAccessIterator>
inline RandomAccessIterator
partial_sort_copy(InputIterator first, InputIterator last,
RandomAccessIterator result_first,
RandomAccessIterator result_last) {
return __partial_sort_copy(first, last, result_first, result_last,
distance_type(result_first), value_type(first));
}

template <class InputIterator, class RandomAccessIterator, class Compare,
class Distance, class T>
RandomAccessIterator __partial_sort_copy(InputIterator first,
InputIterator last,
RandomAccessIterator result_first,
RandomAccessIterator result_last,
Compare comp, Distance*, T*) {
if (result_first == result_last) return result_last;
RandomAccessIterator result_real_last = result_first;
while(first != last && result_real_last != result_last) {
*result_real_last = *first;
++result_real_last;
++first;
}
make_heap(result_first, result_real_last, comp);
while (first != last) {
if (comp(*first, *result_first))
__adjust_heap(result_first, Distance(0),
Distance(result_real_last - result_first), T(*first),
comp);
++first;
}
sort_heap(result_first, result_real_last, comp);
return result_real_last;
}

template <class InputIterator, class RandomAccessIterator, class Compare>
inline RandomAccessIterator
partial_sort_copy(InputIterator first, InputIterator last,
RandomAccessIterator result_first,
RandomAccessIterator result_last, Compare comp) {
return __partial_sort_copy(first, last, result_first, result_last, comp,
distance_type(result_first), value_type(first));
}

sort

STL所提供的各式各样算法中,sort()是最复杂最庞大的一个,这个算法接受两个RandomAccessIterator(随机存取迭代器),然后将区间内的所有元素以渐增方式由小到大重新排列,第二个版本则允许用户指定一个仿函数(functor), 作为排序标准。STL的所有关系型容器(associative containers)都拥有自动排序功能(底层结构采用RB - tree),所以不需要用到这个sort算法。至于序列式容器(sequence containers)中的stacks、queue和priority-queue都有特别的出入口,不允许用户对元素排序。剩下vector、 deque和list,前两者的迭代器属于RandormAccessIterators,适合使用sort算法,list的迭代器则属于BidirectionalIterators,不在STL标准之列的slist,其迭代器属于ForwardIterators,都不适合使用sort算法。如果要对list或slist排序,应该使用它们自己提供的member functions sort()。

STL的sort算法,数据量大时采用Quick分段递归排序,一旦分段后的数据量小于某个门槛,为了避免QuickSort的递归调用带来过大的额外负荷,就改用InsertionSort.如果递归层次过深,还会改用HeapSort,以下分别介绍OuickSort和lnsertionSort,然后再整合起来介绍STL sort。

InsertionSort

Insertionsort以双层循环的形式进行,外循环遍历整个序列,每次迭代决定出一个子区间;内循环遍历子区间,将子区间内的每一个“逆转对(inversion)”倒转过来。“逆转对”是指任何两个迭代器i、j,i < j*i > *j。一旦不存在“逆转对”,序列即排序完毕。这个算法的复杂度为O(N2),说起来并不理想,但是当数据量很少时,却有不错的效果,原因是实现上有一些技巧,而且不像其它较为复杂的排序算法有着诸如递归调用等操作带来的额外负荷。

SGISTL的lnsertionsort两个版本,版本一使用以渐增力式排序,也就是说,以operator<为两元素比较的函数,版本二允许用户指定一个仿函数,作为两元素比较的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
template <class RandomAccessIterator>
void __insertion_sort(RandomAccessIterator first, RandomAccessIterator last) {
if (first == last) return;
for (RandomAccessIterator i = first + 1; i != last; ++i)
__linear_insert(first, i, value_type(first));
}

template <class RandomAccessIterator, class Compare>
void __insertion_sort(RandomAccessIterator first,
RandomAccessIterator last, Compare comp) {
if (first == last) return;
for (RandomAccessIterator i = first + 1; i != last; ++i)
__linear_insert(first, i, value_type(first), comp);
}

template <class RandomAccessIterator, class T>
inline void __linear_insert(RandomAccessIterator first,
RandomAccessIterator last, T*) {
T value = *last;
if (value < *first) {
copy_backward(first, last, last + 1);
*first = value;
}
else
__unguarded_linear_insert(last, value);
}

template <class RandomAccessIterator, class T, class Compare>
inline void __linear_insert(RandomAccessIterator first,
RandomAccessIterator last, T*, Compare comp) {
T value = *last;
if (comp(value, *first)) {
copy_backward(first, last, last + 1);
*first = value;
}
else
__unguarded_linear_insert(last, value, comp);
}

template <class RandomAccessIterator, class T>
void __unguarded_linear_insert(RandomAccessIterator last, T value) {
RandomAccessIterator next = last;
--next;
while (value < *next) {
*last = *next;
last = next;
--next;
}
*last = value;
}

template <class RandomAccessIterator, class T, class Compare>
void __unguarded_linear_insert(RandomAccessIterator last, T value,
Compare comp) {
RandomAccessIterator next = last;
--next;
while (comp(value , *next)) {
*last = *next;
last = next;
--next;
}
*last = value;
}

上述函数之所以命名为unguarded_x是因为,一般的Insertion Sort在内循环原本需要做两次判断,判断是否相邻两元素是“逆转对”;同时也判断循环的行进是否超过边界。但由于上述所示的源代码会导致最小值必然在内循环子区间的最边缘,所以两个判断可合为一个判断,所以称为unguarded。省下一个判断操作,乍见之下无足轻重,但是在大数据量的情况下,影响还是可观的,毕竟这是一 个非常根本的算法核心,在大数据量的情况,执行次数非常惊人。

Quick Sort

如果我们拿Insertion Sort来处理大量数据,其O(N2)的复杂度就令人摇头了。大数据量的情形下有许多更好的排序算法可供选择。正如其名称所昭示,Quick Sort 是目前已知最快的排序法,平均复杂度为O(N logN),最坏情况下将达O(N2),不过IntroSort(极类似median-of-three QuickSort的一种排序算法〕可将最坏情况(分割时产生一个空的子区间)推进到O(N logN)。

快排的步骤:

  1. 如果s的元素是0或者1,结束
  2. 取s 中任何一个元素,当做枢纽v
  3. 将s 分割为l r两部分,使l内的元素都小于等于v,r内的元素都大于v
  4. 对l,r递归执行快排

media-of-three partitioning 取头尾中央三个位置的值的中值作为v

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class T>
inline const T& __median(const T& a, const T& b, const T& c) {
if (a < b)
if (b < c)
return b;
else if (a < c)
return c;
else
return a;
else if (a < c)
return a;
else if (b < c)
return c;
else
return b;
}

Partitioining(分割)方法不只一种,以下叙述既简单又有良好成效的做法。令头端迭代器first向尾部移动,尾端迭代器last向头部移动。当*first大于或等于枢轴时 就停下来,当*last小于或等于枢轴时也停下来,然后检验两个迭代器是否交错。如果first仍然在左而last仍然在右,就将两者元素互换,然后各自调整一个位置(向中央逼近),再继续进行相同的行为。如果发现两个迭代器交错了,表示整个序列已经调整完毕,以此时的first为轴,将序列分为左右两半,左半部所有元素值都小于或等于枢轴,右半部所有元素值都大于或等于枢轴。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template <class RandomAccessIterator, class T>
RandomAccessIterator __unguarded_partition(RandomAccessIterator first,
RandomAccessIterator last,
T pivot) {
while (true) {
while (*first < pivot) ++first;
--last;
while (pivot < *last) --last;
if (!(first < last)) return first;
iter_swap(first, last);
++first;
}
}

template <class RandomAccessIterator, class T, class Compare>
RandomAccessIterator __unguarded_partition(RandomAccessIterator first,
RandomAccessIterator last,
T pivot, Compare comp) {
while (1) {
while (comp(*first, pivot)) ++first;
--last;
while (comp(pivot, *last)) --last;
if (!(first < last)) return first;
iter_swap(first, last);
++first;
}
}

面对一个只有十来个元素的小型序列,使用像Quick Sort这样复杂而需要大量运算的排序法不划算,在小数据量的情况下,甚至简单如Insertion Sort者也可能快过Quick Sort―因为Quick Sort会为了极小的子序 列而产生许多的函数递归调用。鉴于这种情况,适度评估序列的大小,然后决定采用Quick Sort或Insertion Sort是值得采纳的一种优化措施。然而究竟多小的序列才应该断然改用Insertion Sort呢?并无定论,5-2O都可能导致差不多的结果。实际的最佳值因设备而异。

final insertion sort 优化措施永不嫌多,只要我们不是贸然行事。如果我们令某个大小以下的序列滞留在“几近排序但尚未完成”的状态、最后再以一次Insertion Sort将所有这些“几近排序但尚未竞全功”的子序列做一次完整的排序,其效率一般认为会比“将所有子序列彻底排序”更好。这是因为Insertion Sort在面对“几近排序”的序列时、有很好的表现。

introsort:不当的枢轴选择导致Quick Sort恶化为O(N2),Introspective Sorting(内省排序)当分割行为有恶化为二次行为时,能够自我侦测,转而改用Heap Sort。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class RandomAccessIterator>
inline void sort(RandomAccessIterator first, RandomAccessIterator last) {
if (first != last) {
__introsort_loop(first, last, value_type(first), __lg(last - first) * 2);
__final_insertion_sort(first, last);
}
}

template <class RandomAccessIterator, class Compare>
inline void sort(RandomAccessIterator first, RandomAccessIterator last,
Compare comp) {
if (first != last) {
__introsort_loop(first, last, value_type(first), __lg(last - first) * 2,
comp);
__final_insertion_sort(first, last, comp);
}
}

其中的__lg()用于控制分割恶化的情况。

1
2
3
4
5
6
template <class Size>
inline Size __lg(Size n) {
Size k;
for (k = 0; n > 1; n >>= 1) ++k;
return k;
}

元素为40个时,__introsort_loop()的最后一个参数为5*2,意思是最多允许10层分割。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
template <class RandomAccessIterator, class T, class Size>
void __introsort_loop(RandomAccessIterator first,
RandomAccessIterator last, T*,
Size depth_limit) {
while (last - first > __stl_threshold) {
if (depth_limit == 0) {
partial_sort(first, last, last);
return;
}
--depth_limit;
RandomAccessIterator cut = __unguarded_partition
(first, last, T(__median(*first, *(first + (last - first)/2),
*(last - 1))));
__introsort_loop(cut, last, value_type(first), depth_limit);
last = cut;
}
}

template <class RandomAccessIterator, class T, class Size, class Compare>
void __introsort_loop(RandomAccessIterator first,
RandomAccessIterator last, T*,
Size depth_limit, Compare comp) {
while (last - first > __stl_threshold) {
if (depth_limit == 0) {
partial_sort(first, last, last, comp);
return;
}
--depth_limit;
RandomAccessIterator cut = __unguarded_partition
(first, last, T(__median(*first, *(first + (last - first)/2),
*(last - 1), comp)), comp);
__introsort_loop(cut, last, value_type(first), depth_limit, comp);
last = cut;
}
}

函数一开始就判断序列大小,__stl_threshold是个全局整型常数,定义如下:

1
const int __stl_threshold - 16;

通过元素个数检验之后,再检查分割层次,如果分割层次超过指定值,就改用partition_sort()。都通过了这些检验之后,便进入与Quick Sort完全相同的程序:以median-of-3方法确定枢轴位置,然后调用__unguarded_partition()找出分割点,然后针对左右段落递归进行IntroSort

__introsort_loop()结束,[first, last)内有多个“元素个数少于16”的子序列,每个子序列都有相当程度的排序,但尚未完全排序(因为元素个数一旦小于__stl_threshold,就被中止进一步的排序操作。回到母函数sort(),再进入final_insertion_sort()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
template <class RandomAccessIterator>
void __final_insertion_sort(RandomAccessIterator first,
RandomAccessIterator last) {
if (last - first > __stl_threshold) {
__insertion_sort(first, first + __stl_threshold);
__unguarded_insertion_sort(first + __stl_threshold, last);
}
else
__insertion_sort(first, last);
}

template <class RandomAccessIterator, class Compare>
void __final_insertion_sort(RandomAccessIterator first,
RandomAccessIterator last, Compare comp) {
if (last - first > __stl_threshold) {
__insertion_sort(first, first + __stl_threshold, comp);
__unguarded_insertion_sort(first + __stl_threshold, last, comp);
}
else
__insertion_sort(first, last, comp);
}

template <class RandomAccessIterator, class T>
void __unguarded_insertion_sort_aux(RandomAccessIterator first,
RandomAccessIterator last, T*) {
for (RandomAccessIterator i = first; i != last; ++i)
__unguarded_linear_insert(i, T(*i));
}

template <class RandomAccessIterator>
inline void __unguarded_insertion_sort(RandomAccessIterator first,
RandomAccessIterator last) {
__unguarded_insertion_sort_aux(first, last, value_type(first));
}

template <class RandomAccessIterator, class T, class Compare>
void __unguarded_insertion_sort_aux(RandomAccessIterator first,
RandomAccessIterator last,
T*, Compare comp) {
for (RandomAccessIterator i = first; i != last; ++i)
__unguarded_linear_insert(i, T(*i), comp);
}

template <class RandomAccessIterator, class Compare>
inline void __unguarded_insertion_sort(RandomAccessIterator first,
RandomAccessIterator last,
Compare comp) {
__unguarded_insertion_sort_aux(first, last, value_type(first), comp);
}

equal_range

是二分查找法的一个版本,试图在已排序的[first, last)中寻找value,它返回一对迭代器i和j,其中i是在不破坏次序的前提下,value可插人的第一个位置(亦即lower_bound),j则是在不破坏次序的前提下,value可插人的最后一个位置(亦即upper_bound)。因此,[i ,j)内的每个元素都等同于value,而且[i, j)是[fisrt, last)之中符合此一性质的最大子区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
template <class ForwardIterator, class T, class Distance>
pair<ForwardIterator, ForwardIterator>
__equal_range(ForwardIterator first, ForwardIterator last, const T& value,
Distance*, forward_iterator_tag) {
Distance len = 0;
distance(first, last, len);
Distance half;
ForwardIterator middle, left, right;

while (len > 0) {
half = len >> 1;
middle = first;
advance(middle, half);
if (*middle < value) {
first = middle;
++first;
len = len - half - 1;
}
else if (value < *middle)
len = half;
else {
left = lower_bound(first, middle, value);
advance(first, len);
right = upper_bound(++middle, first, value);
return pair<ForwardIterator, ForwardIterator>(left, right);
}
}
return pair<ForwardIterator, ForwardIterator>(first, first);
}

template <class RandomAccessIterator, class T, class Distance>
pair<RandomAccessIterator, RandomAccessIterator>
__equal_range(RandomAccessIterator first, RandomAccessIterator last,
const T& value, Distance*, random_access_iterator_tag) {
Distance len = last - first;
Distance half;
RandomAccessIterator middle, left, right;

while (len > 0) {
half = len >> 1;
middle = first + half;
if (*middle < value) {
first = middle + 1;
len = len - half - 1;
}
else if (value < *middle)
len = half;
else {
left = lower_bound(first, middle, value);
right = upper_bound(++middle, first + len, value);
return pair<RandomAccessIterator, RandomAccessIterator>(left,
right);
}
}
return pair<RandomAccessIterator, RandomAccessIterator>(first, first);
}

template <class ForwardIterator, class T>
inline pair<ForwardIterator, ForwardIterator>
equal_range(ForwardIterator first, ForwardIterator last, const T& value) {
return __equal_range(first, last, value, distance_type(first),
iterator_category(first));
}

template <class ForwardIterator, class T, class Compare, class Distance>
pair<ForwardIterator, ForwardIterator>
__equal_range(ForwardIterator first, ForwardIterator last, const T& value,
Compare comp, Distance*, forward_iterator_tag) {
Distance len = 0;
distance(first, last, len);
Distance half;
ForwardIterator middle, left, right;

while (len > 0) {
half = len >> 1;
middle = first;
advance(middle, half);
if (comp(*middle, value)) {
first = middle;
++first;
len = len - half - 1;
}
else if (comp(value, *middle))
len = half;
else {
left = lower_bound(first, middle, value, comp);
advance(first, len);
right = upper_bound(++middle, first, value, comp);
return pair<ForwardIterator, ForwardIterator>(left, right);
}
}
return pair<ForwardIterator, ForwardIterator>(first, first);
}

template <class RandomAccessIterator, class T, class Compare, class Distance>
pair<RandomAccessIterator, RandomAccessIterator>
__equal_range(RandomAccessIterator first, RandomAccessIterator last,
const T& value, Compare comp, Distance*,
random_access_iterator_tag) {
Distance len = last - first;
Distance half;
RandomAccessIterator middle, left, right;

while (len > 0) {
half = len >> 1;
middle = first + half;
if (comp(*middle, value)) {
first = middle + 1;
len = len - half - 1;
}
else if (comp(value, *middle))
len = half;
else {
left = lower_bound(first, middle, value, comp);
right = upper_bound(++middle, first + len, value, comp);
return pair<RandomAccessIterator, RandomAccessIterator>(left,
right);
}
}
return pair<RandomAccessIterator, RandomAccessIterator>(first, first);
}

template <class ForwardIterator, class T, class Compare>
inline pair<ForwardIterator, ForwardIterator>
equal_range(ForwardIterator first, ForwardIterator last, const T& value,
Compare comp) {
return __equal_range(first, last, value, comp, distance_type(first),
iterator_category(first));
}

inplace_merge

应用于有序区间,把两个连接在一起且各自有序的序列合并成一个序列,仍保持有序。稳定操作,保持相对次序不变,如果有相同元素,第一个序列的排在前面。内部实现时根据有无缓冲不同处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
template <class BidirectionalIterator>
inline void inplace_merge(BidirectionalIterator first,
BidirectionalIterator middle,
BidirectionalIterator last) {
if (first == middle || middle == last) return;
__inplace_merge_aux(first, middle, last, value_type(first),
distance_type(first));
}

template <class BidirectionalIterator, class Compare>
inline void inplace_merge(BidirectionalIterator first,
BidirectionalIterator middle,
BidirectionalIterator last, Compare comp) {
if (first == middle || middle == last) return;
__inplace_merge_aux(first, middle, last, value_type(first),
distance_type(first), comp);
}

template <class BidirectionalIterator, class T, class Distance>
inline void __inplace_merge_aux(BidirectionalIterator first,
BidirectionalIterator middle,
BidirectionalIterator last, T*, Distance*) {
Distance len1 = 0;
distance(first, middle, len1);
Distance len2 = 0;
distance(middle, last, len2);

temporary_buffer<BidirectionalIterator, T> buf(first, last);
if (buf.begin() == 0)
__merge_without_buffer(first, middle, last, len1, len2);
else
__merge_adaptive(first, middle, last, len1, len2,
buf.begin(), Distance(buf.size()));
}

template <class BidirectionalIterator, class T, class Distance, class Compare>
inline void __inplace_merge_aux(BidirectionalIterator first,
BidirectionalIterator middle,
BidirectionalIterator last, T*, Distance*,
Compare comp) {
Distance len1 = 0;
distance(first, middle, len1);
Distance len2 = 0;
distance(middle, last, len2);

temporary_buffer<BidirectionalIterator, T> buf(first, last);
if (buf.begin() == 0)
__merge_without_buffer(first, middle, last, len1, len2, comp);
else
__merge_adaptive(first, middle, last, len1, len2,
buf.begin(), Distance(buf.size()),
comp);
}

如果有缓冲区的话效率会好很多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
template <class BidirectionalIterator, class Distance, class Pointer>
void __merge_adaptive(BidirectionalIterator first,
BidirectionalIterator middle,
BidirectionalIterator last, Distance len1, Distance len2,
Pointer buffer, Distance buffer_size) {
if (len1 <= len2 && len1 <= buffer_size) {
Pointer end_buffer = copy(first, middle, buffer);
merge(buffer, end_buffer, middle, last, first);
}
else if (len2 <= buffer_size) {
Pointer end_buffer = copy(middle, last, buffer);
__merge_backward(first, middle, buffer, end_buffer, last);
}
else {
BidirectionalIterator first_cut = first;
BidirectionalIterator second_cut = middle;
Distance len11 = 0;
Distance len22 = 0;
if (len1 > len2) {
len11 = len1 / 2;
advance(first_cut, len11);
second_cut = lower_bound(middle, last, *first_cut);
distance(middle, second_cut, len22);
}
else {
len22 = len2 / 2;
advance(second_cut, len22);
first_cut = upper_bound(first, middle, *second_cut);
distance(first, first_cut, len11);
}
BidirectionalIterator new_middle =
__rotate_adaptive(first_cut, middle, second_cut, len1 - len11,
len22, buffer, buffer_size);
__merge_adaptive(first, first_cut, new_middle, len11, len22, buffer,
buffer_size);
__merge_adaptive(new_middle, second_cut, last, len1 - len11,
len2 - len22, buffer, buffer_size);
}
}

template <class BidirectionalIterator, class Distance, class Pointer,
class Compare>
void __merge_adaptive(BidirectionalIterator first,
BidirectionalIterator middle,
BidirectionalIterator last, Distance len1, Distance len2,
Pointer buffer, Distance buffer_size, Compare comp) {
if (len1 <= len2 && len1 <= buffer_size) {
Pointer end_buffer = copy(first, middle, buffer);
merge(buffer, end_buffer, middle, last, first, comp);
}
else if (len2 <= buffer_size) {
Pointer end_buffer = copy(middle, last, buffer);
__merge_backward(first, middle, buffer, end_buffer, last, comp);
}
else {
BidirectionalIterator first_cut = first;
BidirectionalIterator second_cut = middle;
Distance len11 = 0;
Distance len22 = 0;
if (len1 > len2) {
len11 = len1 / 2;
advance(first_cut, len11);
second_cut = lower_bound(middle, last, *first_cut, comp);
distance(middle, second_cut, len22);
}
else {
len22 = len2 / 2;
advance(second_cut, len22);
first_cut = upper_bound(first, middle, *second_cut, comp);
distance(first, first_cut, len11);
}
BidirectionalIterator new_middle =
__rotate_adaptive(first_cut, middle, second_cut, len1 - len11,
len22, buffer, buffer_size);
__merge_adaptive(first, first_cut, new_middle, len11, len22, buffer,
buffer_size, comp);
__merge_adaptive(new_middle, second_cut, last, len1 - len11,
len2 - len22, buffer, buffer_size, comp);
}
}


缓冲区不足以容纳一个序列时,以递归分割的方式,让处理长度减半,看能否容纳于缓冲区中。

然后执行旋转操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <class BidirectionalIterator1, class BidirectionalIterator2,
class Distance>
BidirectionalIterator1 __rotate_adaptive(BidirectionalIterator1 first,
BidirectionalIterator1 middle,
BidirectionalIterator1 last,
Distance len1, Distance len2,
BidirectionalIterator2 buffer,
Distance buffer_size) {
BidirectionalIterator2 buffer_end;
if (len1 > len2 && len2 <= buffer_size) {
buffer_end = copy(middle, last, buffer);
copy_backward(first, middle, last);
return copy(buffer, buffer_end, first);
} else if (len1 <= buffer_size) {
buffer_end = copy(first, middle, buffer);
copy(middle, last, first);
return copy_backward(buffer, buffer_end, last);
} else {
rotate(first, middle, last);
advance(first, len2);
return first;
}
}

nth_element

重新排列区间,使迭代器nth 所指向的元素与整个区间内排序后,同一位置的元素同值。保证nth-last 中没有任何一个元素不大于 first-nth 中的元素

不断用首尾中央三点中值为枢纽之分割法,将序列分割成更小的子序列,如果nth 落入左子序列, 就继续分割左子序列,如果落在右子序列就分割右子序列,最后对小于3的序列进行排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
template <class RandomAccessIterator, class T>
void __nth_element(RandomAccessIterator first, RandomAccessIterator nth,
RandomAccessIterator last, T*) {
while (last - first > 3) {
RandomAccessIterator cut = __unguarded_partition
(first, last, T(__median(*first, *(first + (last - first)/2),
*(last - 1))));
if (cut <= nth)
first = cut;
else
last = cut;
}
__insertion_sort(first, last);
}

template <class RandomAccessIterator>
inline void nth_element(RandomAccessIterator first, RandomAccessIterator nth,
RandomAccessIterator last) {
__nth_element(first, nth, last, value_type(first));
}

template <class RandomAccessIterator, class T, class Compare>
void __nth_element(RandomAccessIterator first, RandomAccessIterator nth,
RandomAccessIterator last, T*, Compare comp) {
while (last - first > 3) {
RandomAccessIterator cut = __unguarded_partition
(first, last, T(__median(*first, *(first + (last - first)/2),
*(last - 1), comp)), comp);
if (cut <= nth)
first = cut;
else
last = cut;
}
__insertion_sort(first, last, comp);
}

template <class RandomAccessIterator, class Compare>
inline void nth_element(RandomAccessIterator first, RandomAccessIterator nth,
RandomAccessIterator last, Compare comp) {
__nth_element(first, nth, last, value_type(first), comp);
}

merge_sort

归并排序,需要额外的内存,内存之间的拷贝需要时间,但是实现简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template <class RandomAccessIterator1, class RandomAccessIterator2,
class Distance>
void __merge_sort_loop(RandomAccessIterator1 first,
RandomAccessIterator1 last,
RandomAccessIterator2 result, Distance step_size) {
Distance two_step = 2 * step_size;

while (last - first >= two_step) {
result = merge(first, first + step_size,
first + step_size, first + two_step, result);
first += two_step;
}

step_size = min(Distance(last - first), step_size);
merge(first, first + step_size, first + step_size, last, result);
}

template <class RandomAccessIterator1, class RandomAccessIterator2,
class Distance, class Compare>
void __merge_sort_loop(RandomAccessIterator1 first,
RandomAccessIterator1 last,
RandomAccessIterator2 result, Distance step_size,
Compare comp) {
Distance two_step = 2 * step_size;

while (last - first >= two_step) {
result = merge(first, first + step_size,
first + step_size, first + two_step, result, comp);
first += two_step;
}
step_size = min(Distance(last - first), step_size);

merge(first, first + step_size, first + step_size, last, result, comp);
}

仿函数

仿函数概述

仿函数也叫作函数对象,是一种具有函数特质的对象,调用者可以像函数一样地调用这些对象,被调用者则以对象所定义的function call operator扮演函数的实质角色。就实现观点而言,仿函数其实上就是一个“行为类似函数”的对象。为了“行为类似函数”,其类别定义中必须自定义function call 运算子(operator())。STL中仿函数代替函数指针的原因在于函数指针不能满足STL对抽象性的要求,也不能满足软件积木的要求,函数指针无法与STL其他组件搭配。

STL仿函数的分类,若以操作数的个数划分,可以分为一元和二元仿函数;若以功能划分,可以分为算术运算(Arithmetic)、关系运算(Rational)、逻辑运算(Logical)三大类。其头文件为<functional>

可配接的关键

STL仿函数应该有能力被函数适配器修饰,就像积木一样串接,然而,为了拥有配接能力,每个仿函数都必须定义自己的associative types(主要用来表示函数参数类型和返回值类型),就想迭代器如果要融入整个STL大家庭,也必须按照规定定义自己的5个相应的类型一样,这些assocaiative type是为了让配接器可以取得仿函数的某些信息,当然,这些associative type都只是一些typedef,所有必要操作在编译器就全部完成了,对程序的执行效率没有任何影响,不会带来额外的负担。

仿函数的相应类型主要用来表现函数参数类型和传回值类型,为了方便起见<stl_functional.h>定义了两个类classes,分别代表一元仿函数和二元仿函数。其中没有任何data members或member functions,唯有一些类型定义,任何仿函数只要依个人需求选择继承其中一个class,便自动拥有了那些相应类型,也自动拥有了配接能力。

unary_function

unary_function用来呈现一元函数的参数类型和返回值类型。

1
2
3
4
5
6
//STL规定,每一个Adaptable Unary Function都应继承此类别
template <class Arg, class Result>
struct unary_function {
typedef Arg argument_type;
typedef Result result_type;
};

一旦某个仿函数继承了unary_function,其用户便可以这样取得仿函数的参数类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class T>
struct negate : public unary_function<T, T> {
T operator()(const T& x) const { return -x; }
};

// 以下配接器用来表示某个仿函数的逻辑负值
template <class Predicate>
class unary_negate
...
public:
bool operator()(const typename Predicate::argument_type& x) const {
...
}
};

binary_function

binary_function用来呈现二元函数的第一参数型别、第二参数型别及返回值型别。

1
2
3
4
5
6
7
//STL规定,每一个Adaptable Binary Function都应继承此类别
template <class Arg1, class Arg2, class Result>
struct binary_function {
typedef Arg1 first_argument_type;
typedef Arg2 second_argument_type;
typedef Result result_type;
};

一旦某个仿函数继承了binary_function,其用户便可以这样取得仿函数的参数类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class T>
struct plus : public binary_function<T, T, T> {
T operator()(const T& x, const T& y) const { return x+y; }
};

// 以下配接器用来表示某个仿函数的逻辑负值
template <class Operation>
class binder1st
...
protected:
Operation op;
typename Operation::first_argument_type value;
public:
typename Operation::result_type operator()(const typename Operation::second_argument_type& x) const {
...
}
};

算术类仿函数

STL内建算术类仿函数:加法:plus<T>,减法:minus<T>,乘法:multiplies<T>,除法:divides<T>,取模:modulus<T>,取反:negate<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <class T>
struct plus : public binary_function<T,T,T> {
T operator()(const T& x, const T& y) const { return x + y; }
};

template <class T>
struct minus : public binary_function<T,T,T> {
T operator()(const T& x, const T& y) const { return x - y; }
};

template <class T>
struct multiplies : public binary_function<T,T,T> {
T operator()(const T& x, const T& y) const { return x * y; }
};

template <class T>
struct divides : public binary_function<T,T,T> {
T operator()(const T& x, const T& y) const { return x / y; }
};

template <class T>
struct modulus : public binary_function<T,T,T>
{
T operator()(const T& x, const T& y) const { return x % y; }
};

template <class T>
struct negate : public unary_function<T,T>
{
T operator()(const T& x) const { return -x; }
};

测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <functional>

using namespace std;

int main() {
// 产生仿函数实体
plus<int> plusobj;
minus<int> minusobj;
multiplies<int> multipliesobj;
divides<int> dividesobj;
modulus<int> modulusobj;
negate<int> negateobj;

// 使用对象完成函数功能
cout << plusobj(3, 5) << endl; //8
cout << minusobj(3, 5) << endl; //-2
cout << multipliesobj(3, 5) << endl; //15
cout << dividesobj(3, 5) << endl; //0
cout << modulusobj(3, 5) << endl; //3
cout << negateobj(3) << endl; //-3

// 直接使用仿函数的临时对象完成函数功能
cout << plus<int>()(3, 5) << endl;
cout << minus<int>()(3, 5) << endl;
cout << multiplies<int>()(3, 5) << endl;
cout << divides<int>()(3, 5) << endl;
cout << modulus<int>()(3, 5) << endl;
cout << negate<int>()(3) << endl;

return 0;
}

不会这么单纯的使用仿函数,主要用途是搭配STL算法,以下式子表示要以1为基本元素,对vector iv中的每个元素进行乘法操作:

1
accumulate(iv.begin(), iv.end(), 1, multiplies<int>());

证同元素(identity element)

所谓“运算op的证同元素(identity element)”,意思是数值A若与该元素做op运算会得到A自己。加法的证同元素为0,因为任何元素加上0仍为自己。乘法的证同元素为1,因为任何元素乘以1仍为自己。

请注意,这些函数并非STL标准规格中的一员。但许多STL实现都有它们:

1
2
3
4
5
template <class T> 
inline T identity_element(plus<T>) { return T(0);} //SGI STL并未实际运用这个函数

template <class T>
inline T identity_element(muitiplies<T>) { return T(1);}

关系运算类仿函数

STL内建关系类仿函数:等于:equal_to<T>,不等于:not_equal_to<T>,大于:greater<T>,大于等于:greater_equal<T>,小于:less<T>,小于等于:less_equal<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template <class T>
struct equal_to : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x == y; }
};

template <class T>
struct not_equal_to : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x != y; }
};

template <class T>
struct greater : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x > y; }
};

template <class T>
struct less : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x < y; }
};

template <class T>
struct greater_equal : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x >= y; }
};

template <class T>
struct less_equal : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x <= y; }
};

测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <functional>

using namespace std;

int main() {
// 产生仿函数实体
equal_to<int> equal_to_obj;
not_equal_to<int> not_equal_to_obj;
greater<int> greater_obj;
greater_equal<int> greater_equal_obj;
less<int> less_obj;
less_equal<int> less_equal_obj;

// 使用对象完成函数功能
cout << equal_to_obj(3, 5) << endl; //0
cout << not_equal_to_obj(3, 5) << endl; //1
cout << greater_obj(3, 5) << endl; //0
cout << greater_equal_obj(3, 5) << endl; //0
cout << less_obj(3, 5) << endl; //1
cout << less_equal_obj(3, 5) << endl; //1

// 直接使用仿函数的临时对象完成函数功能
cout << equal_to<int>()(3, 5) << endl;
cout << not_equal_to<int>()(3, 5) << endl;
cout << greater<int>()(3, 5) << endl;
cout << greater_equal<int>()(3, 5) << endl;
cout << less<int>()(3, 5) << endl;
cout << less_equal<int>()(3, 5) << endl;

return 0;
}

不会这么单纯的使用仿函数,主要用途是搭配STL算法,以下式子表示要以递增次序对vector iv排序:

1
sort(iv.begin(), iv.end(), greater<int>());

逻辑运算类仿函数

STL内建逻辑类仿函数:逻辑运算And:logical_and<T>,逻辑运算Or:logical_or<T>,逻辑运算Not:logical_not<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class T>
struct logical_and : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x && y; }
};

template <class T>
struct logical_or : public binary_function<T,T,bool>
{
bool operator()(const T& x, const T& y) const { return x || y; }
};

template <class T>
struct logical_not : public unary_function<T,bool>
{
bool operator()(const T& x) const { return !x; }
};

测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <functional>

using namespace std;

int main() {
// 产生仿函数实体
logical_and<int> and_obj;
logical_or<int> or_obj;
logical_not<int> not_obj;

// 使用对象完成函数功能
cout << and_obj(true, true) << endl; //1
cout << or_obj(true, false) << endl; //1
cout << not_obj(true) << endl; //0

// 直接使用仿函数的临时对象完成函数功能
cout << logical_and<int>()(true, true) << endl;
cout << logical_or<int>()(true, false) << endl;
cout << logical_not<int>()(true) << endl;

return 0;
}

不会这么单纯的使用仿函数,主要用途是搭配STL算法。

证同、选择、投射

以下介绍的仿函数,都只是将其参数原封不动地传回。其中某些仿函数对传回的参数有刻意的选择,或者刻意的忽略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 证同函数,任何函数经过后不会改变,用于set rb_tree keyOfValue op
// identity is an extensions: it is not part of the standard.
template <class _Tp>
struct _Identity : public unary_function<_Tp,_Tp> {
const _Tp& operator()(const _Tp& __x) const { return __x; }
};

template <class _Tp> struct identity : public _Identity<_Tp> {};

// 选择函数,返回pair第一元素,用于map rb_tree keyOfValue op
// select1st and select2nd are extensions: they are not part of the standard.
template <class _Pair>
struct _Select1st : public unary_function<_Pair, typename _Pair::first_type> {
const typename _Pair::first_type& operator()(const _Pair& __x) const {
return __x.first;
}
};

// 选择函数,返回pair第二元素
template <class _Pair>
struct _Select2nd : public unary_function<_Pair, typename _Pair::second_type>
{
const typename _Pair::second_type& operator()(const _Pair& __x) const {
return __x.second;
}
};

template <class _Pair> struct select1st : public _Select1st<_Pair> {};
template <class _Pair> struct select2nd : public _Select2nd<_Pair> {};

// 传回一个忽略另一个
// project1st and project2nd are extensions: they are not part of the standard
template <class _Arg1, class _Arg2>
struct _Project1st : public binary_function<_Arg1, _Arg2, _Arg1> {
_Arg1 operator()(const _Arg1& __x, const _Arg2&) const { return __x; }
};

template <class _Arg1, class _Arg2>
struct _Project2nd : public binary_function<_Arg1, _Arg2, _Arg2> {
_Arg2 operator()(const _Arg1&, const _Arg2& __y) const { return __y; }
};

template <class _Arg1, class _Arg2>
struct project1st : public _Project1st<_Arg1, _Arg2> {};

template <class _Arg1, class _Arg2>
struct project2nd : public _Project2nd<_Arg1, _Arg2> {};

配接器

配接器(adapters)在 STL 组件的灵活组合运用功能上,扮演着轴承、转换器的角色。Adapter 这个概念,事实上是一种设计模式。《Design Patterns》一书提到 23 个最普及的设计模式,其中对 adapter 样式的定义如下:将一个 class 的接口转换为另一个 class 的接口,使原本因接口不兼容而不能合作的 classes,可以一起运作。

配接器之概观与分类

STL 所提供的各种配接器中,改变容器(containers)接口者,我们称为 container adapter,改变迭代器(iterators)接口者,我们称之为 iterator adapter,改变仿函数(functors)接口者,我们称为 function adapter。

容器配接器

STL 提供的两个容器 queue 和 stack,其实都只不过是一种配接器,底层由deque构成。stack和queue是两个容器配接器,底层默认由deque构成。stack封住了所有的deque对外接口,只开放符合stack原则的几个函数;queue封住了所有的deque对外接口,只开放符合queue原则的几个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
template <class T ,class Sequence = deque<T>>
class stack {
protected:
Sequence c ; //底层容器
...
};

template <class T ,class Sequence = deque<T>>
class queue{
protected:
Sequence c ; //底层容器
...
};

迭代器配接器

STL 提供了许多应用于迭代器身上的配接器,包括 insert iterators,reverse iterators,iostream iterators。

insert iterators

insert iterators包括尾端插入的back_insert_iterator,头端插入的front_insert_iterator和可任意位置插入的insert_iterator。主要观念是,每个insert iterators内部都维护有一个容器;容器有自己的迭代器,当客户端对insert iterators做赋值操作时,就在insert iterators中转为对该容器的迭代器做插入操作,其他的迭代器功能则被关闭(例如operator++、operator—、operator*)。

迭代器源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
// 迭代器配接器,用来将迭代器的赋值操作替换为插入操作
// 从容器尾端插入
template <class _Container>
class back_insert_iterator {
protected:
_Container* container; // 底层容器
public:
typedef _Container container_type;
typedef output_iterator_tag iterator_category; // 注意类型,只写
typedef void value_type;
typedef void difference_type;
typedef void pointer;
typedef void reference;

// 与容器绑定起来
explicit back_insert_iterator(_Container& __x) : container(&__x) {}
back_insert_iterator<_Container>&
operator=(const typename _Container::value_type& __value) {
container->push_back(__value); // 这里是替换成插入操作
return *this;
}
// 以下不起作用,关闭功能
back_insert_iterator<_Container>& operator*() { return *this; }
back_insert_iterator<_Container>& operator++() { return *this; }
back_insert_iterator<_Container>& operator++(int) { return *this; }
};

#ifndef __STL_CLASS_PARTIAL_SPECIALIZATION

template <class _Container>
inline output_iterator_tag
iterator_category(const back_insert_iterator<_Container>&)
{
return output_iterator_tag();
}

#endif /* __STL_CLASS_PARTIAL_SPECIALIZATION */

// 辅助函数
template <class _Container>
inline back_insert_iterator<_Container> back_inserter(_Container& __x) {
return back_insert_iterator<_Container>(__x);
}

// 迭代器配接器,用来将迭代器的赋值操作替换为插入操作
// 从容器头端插入
// 不适用于vector,因为vector没有push_front
template <class _Container>
class front_insert_iterator {
protected:
_Container* container; // 底层容器
public:
typedef _Container container_type;
typedef output_iterator_tag iterator_category; // 注意类型,只写
typedef void value_type;
typedef void difference_type;
typedef void pointer;
typedef void reference;

explicit front_insert_iterator(_Container& __x) : container(&__x) {}
front_insert_iterator<_Container>&
operator=(const typename _Container::value_type& __value) {
container->push_front(__value); // 替换成插入操作
return *this;
}
// 关闭以下功能
front_insert_iterator<_Container>& operator*() { return *this; }
front_insert_iterator<_Container>& operator++() { return *this; }
front_insert_iterator<_Container>& operator++(int) { return *this; }
};

#ifndef __STL_CLASS_PARTIAL_SPECIALIZATION

template <class _Container>
inline output_iterator_tag
iterator_category(const front_insert_iterator<_Container>&)
{
return output_iterator_tag();
}

#endif /* __STL_CLASS_PARTIAL_SPECIALIZATION */

// 辅助函数
template <class _Container>
inline front_insert_iterator<_Container> front_inserter(_Container& __x) {
return front_insert_iterator<_Container>(__x);
}

// 迭代器配接器,用来将迭代器的赋值操作替换为插入操作
// 从容器随机插入
template <class _Container>
class insert_iterator {
protected:
_Container* container; // 底层容器
typename _Container::iterator iter;
public:
typedef _Container container_type;
typedef output_iterator_tag iterator_category; // 类型只写
typedef void value_type;
typedef void difference_type;
typedef void pointer;
typedef void reference;

insert_iterator(_Container& __x, typename _Container::iterator __i)
: container(&__x), iter(__i) {}
insert_iterator<_Container>&
operator=(const typename _Container::value_type& __value) {
iter = container->insert(iter, __value); // 调用insert
++iter; // 使insert iterator永远随其目标贴身移动
return *this;
}
// 关闭功能
insert_iterator<_Container>& operator*() { return *this; }
insert_iterator<_Container>& operator++() { return *this; }
insert_iterator<_Container>& operator++(int) { return *this; }
};

#ifndef __STL_CLASS_PARTIAL_SPECIALIZATION

template <class _Container>
inline output_iterator_tag
iterator_category(const insert_iterator<_Container>&)
{
return output_iterator_tag();
}

#endif /* __STL_CLASS_PARTIAL_SPECIALIZATION */

// 辅助函数
template <class _Container, class _Iterator>
inline
insert_iterator<_Container> inserter(_Container& __x, _Iterator __i)
{
typedef typename _Container::iterator __iter;
return insert_iterator<_Container>(__x, __iter(__i));
}

reverse iterators

reverse iterators将迭代器的移动行为倒转。 当迭代器被逆转,虽然实体位置不变,但逻辑位置必须改变,主要是为了配合迭代器区间的“前闭后开“习惯。

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
template <class _Iterator>
class reverse_iterator
{
protected:
_Iterator current;
public:
typedef typename iterator_traits<_Iterator>::iterator_category
iterator_category;
typedef typename iterator_traits<_Iterator>::value_type
value_type;
typedef typename iterator_traits<_Iterator>::difference_type
difference_type;
typedef typename iterator_traits<_Iterator>::pointer
pointer;
typedef typename iterator_traits<_Iterator>::reference
reference;

typedef _Iterator iterator_type;
typedef reverse_iterator<_Iterator> _Self;

public:
reverse_iterator() {}
explicit reverse_iterator(iterator_type __x) : current(__x) {}

reverse_iterator(const _Self& __x) : current(__x.current) {}
#ifdef __STL_MEMBER_TEMPLATES
template <class _Iter>
reverse_iterator(const reverse_iterator<_Iter>& __x)
: current(__x.base()) {}
#endif /* __STL_MEMBER_TEMPLATES */

iterator_type base() const { return current; }
reference operator*() const {
_Iterator __tmp = current;
return *--__tmp;
}
#ifndef __SGI_STL_NO_ARROW_OPERATOR
pointer operator->() const { return &(operator*()); }
#endif /* __SGI_STL_NO_ARROW_OPERATOR */

_Self& operator++() {
--current;
return *this;
}
_Self operator++(int) {
_Self __tmp = *this;
--current;
return __tmp;
}
_Self& operator--() {
++current;
return *this;
}
_Self operator--(int) {
_Self __tmp = *this;
++current;
return __tmp;
}

_Self operator+(difference_type __n) const {
return _Self(current - __n);
}
_Self& operator+=(difference_type __n) {
current -= __n;
return *this;
}
_Self operator-(difference_type __n) const {
return _Self(current + __n);
}
_Self& operator-=(difference_type __n) {
current += __n;
return *this;
}
reference operator[](difference_type __n) const { return *(*this + __n); }
};

stream iterators

stream iterators可以将迭代器绑定到某个stream对象身上。绑定一个istream object,其实就是在istream iterator内部维护一个istream member,客户端对这个迭代器做的operator++操作,会被导引调用内部所含的那个istream member的输入操作。绑定一个ostream object,就是在ostream iterator内部维护一个ostream member,客户端对这个迭代器做的operator=操作,会被导引调用内部所含的那个ostream member的输出操作。

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
template <class _Tp, 
class _CharT = char, class _Traits = char_traits<_CharT>,
class _Dist = ptrdiff_t>
class istream_iterator {
public:
typedef _CharT char_type;
typedef _Traits traits_type;
typedef basic_istream<_CharT, _Traits> istream_type;

typedef input_iterator_tag iterator_category;
typedef _Tp value_type;
typedef _Dist difference_type;
typedef const _Tp* pointer;
typedef const _Tp& reference;

istream_iterator() : _M_stream(0), _M_ok(false) {}
istream_iterator(istream_type& __s) : _M_stream(&__s) { _M_read(); }

reference operator*() const { return _M_value; }
pointer operator->() const { return &(operator*()); }

istream_iterator& operator++() {
_M_read();
return *this;
}
istream_iterator operator++(int) {
istream_iterator __tmp = *this;
_M_read();
return __tmp;
}

bool _M_equal(const istream_iterator& __x) const
{ return (_M_ok == __x._M_ok) && (!_M_ok || _M_stream == __x._M_stream); }

private:
istream_type* _M_stream;
_Tp _M_value;
bool _M_ok;

void _M_read() {
_M_ok = (_M_stream && *_M_stream) ? true : false;
if (_M_ok) {
*_M_stream >> _M_value;
_M_ok = *_M_stream ? true : false;
}
}
};


template <class _Tp,
class _CharT = char, class _Traits = char_traits<_CharT> >
class ostream_iterator {
public:
typedef _CharT char_type;
typedef _Traits traits_type;
typedef basic_ostream<_CharT, _Traits> ostream_type;

typedef output_iterator_tag iterator_category;
typedef void value_type;
typedef void difference_type;
typedef void pointer;
typedef void reference;

ostream_iterator(ostream_type& __s) : _M_stream(&__s), _M_string(0) {}
ostream_iterator(ostream_type& __s, const _CharT* __c)
: _M_stream(&__s), _M_string(__c) {}
ostream_iterator<_Tp>& operator=(const _Tp& __value) {
*_M_stream << __value;
if (_M_string) *_M_stream << _M_string;
return *this;
}
ostream_iterator<_Tp>& operator*() { return *this; }
ostream_iterator<_Tp>& operator++() { return *this; }
ostream_iterator<_Tp>& operator++(int) { return *this; }
private:
ostream_type* _M_stream;
const _CharT* _M_string;
};

测试程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iterator> // for iterator adapters
#include <deque>
#include <algorithm> // for copy()
#include <iostream>

using namespace std;

int main() {
// 将outite绑定到cout,每次对outite指派一个元素,就后接一个“ ”
ostream_iterator<int> outite(cout, " ");

int ia[] = {0, 1, 2, 3, 4, 5};
deque<int> id(ia, ia + 6);

// 将所有元素拷贝到outite,即cout
copy(id.begin(), id.end(), outite);
cout << endl;

// 将ia[]的部分元素拷贝到id内,使用front_insert_iterator
// front_insert_iterator会将assign操作给push_front操作
// vector不支持push_front操作,所以不以vector做示范对象
copy(ia + 1, ia + 2, front_inserter(id));
copy(id.begin(), id.end(), outite);
cout << endl;

// 将ia[]的部分元素拷贝到id内,使用back_insert_iterator
copy(ia + 1, ia + 2, back_inserter(id));
copy(id.begin(), id.end(), outite);
cout << endl;

// 搜寻元素5所在位置
deque<int>::iterator ite = find(id.begin(), id.end(), 5);
// 将ia[]的部分元素拷贝到id内,使用insert_iterator
copy(ia + 1, ia + 2, inserter(id, ite));
copy(id.begin(), id.end(), outite);
cout << endl;

// 将所有元素逆向拷贝到outite
// rbegin()和rend()与reverse_iterator有关
copy(id.rbegin(), id.rend(), outite);
cout << endl;

// 将inite绑定到cin,将元素拷贝到inite,知道eos出现
istream_iterator<int> inite(cin), eos; // eos: end-of-stream
copy(inite, eos, inserter(id, id.begin()));
// 输入数字,停止时可以输入任意字符

copy(id.begin(), id.end(), outite);

return 0;
}

执行结果:

1
2
3
4
5
6
7
8
[root@192 8_STL_adapter]# ./8_1_2_iterator-adapter
0 1 2 3 4 5
1 0 1 2 3 4 5
1 0 1 2 3 4 5 1
1 0 1 2 3 4 1 5 1
1 5 1 4 3 2 1 0 1
1 2 3 e // 输入数字,停止时可以输入任意字符
1 2 3 1 0 1 2 3 4 1 5 1

function adapter

仿函数配接操作包括绑定(bind)、否定(negate)、组合(compose)、以及对一般函数或成员函数的修饰。仿函数配接器的价值在于,通过它们之间的绑定、组合、修饰能力,几乎可以创造出各种可能的表达式,配合STL算法。例如,我们可能希望找出某个序列中所有不小于12的元素个数。虽然“不小于” 就是“大于或等于”,我们因此可以选择STL内建的仿函数greater_equal。但如果希望完全遵循题目语意(在某些更复杂的情况下,这可能是必要的),坚持找出“不小于”12的元素个数,可以这么做:

1
not1(bind2nd(less<int>(), 12))

这个式子将less<int>()的第二参数系结(绑定)为12,再加上否定操作,便形成了“不小于12”的语意,整个凑和成为一个表达式(expression),可与任何 “可接受表达式为参数”之算法搭配。

再举一个例子,假设我们希望对序列中的每一个元素都做某个特殊运算,这个运算的数学表达式为:

1
f(g(elem)) 

其中f和g都是数学函数,那么可以这么写:
1
compose(f(x), g(y));

例如我们希望将容器内的每一个元素v进行(v+2)*3的操作,我们可以令f(x)=x*3, g(y)=y+2,并写下这样的式子:
1
compose1(bind2nd(multiplies<int>(), 3), bind2nd(plus<int>(), 2));

由于仿函数就是“将function call操作符重载”的一种class,而任何算法接受一个仿函数时,总是在其演算过程中调用该仿函数的operator(),这使得不具备仿函数之形、却有真函数之实的飞一般函数”和“成员函数(member functions)”感到为难。STL又提供了为数众多的配接器,使“一般函数”和“成员函数”得以无缝隙地与其它配接器或算法结合起。

请注意,所有期望获得配接能力的组件,本身都必须是可配接的。换句话说,一元仿函数必须继承自unary_function,二元仿函数必须继承自binary_function,成员函数必须以mem_fun处理过,一般函数必须以ptr_fun处理过。

每一个仿函数配接器内藏了一个member object,其型别等同于它所要配接的对象。

使用场景:

  • 对返回值进行逻辑否定:not1,not2
  • 对参数进行绑定:bind1st,bind2nd
  • 用于函数合成:compose1,compose2
  • 用于函数指针:ptr_fun
  • 用于成员函数指针:mem_fun,mem_fun_ref

count_if的实例测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include <algorithm>
#include <functional>
#include <vector>
#include <iostream>
#include <iterator>

using namespace std;

// compose1在mingw中没有,所以把定义搬了过来
template <class _Operation1, class _Operation2>
class unary_compose
: public unary_function<typename _Operation2::argument_type,
typename _Operation1::result_type>
{
protected:
_Operation1 _M_fn1;
_Operation2 _M_fn2;
public:
unary_compose(const _Operation1& __x, const _Operation2& __y)
: _M_fn1(__x), _M_fn2(__y) {}
typename _Operation1::result_type
operator()(const typename _Operation2::argument_type& __x) const {
return _M_fn1(_M_fn2(__x));
}
};

template <class _Operation1, class _Operation2>
inline unary_compose<_Operation1,_Operation2>
compose1(const _Operation1& __fn1, const _Operation2& __fn2)
{
return unary_compose<_Operation1,_Operation2>(__fn1, __fn2);
}


void print(int i) {
cout << i << " ";
}

class Int {
public:
// explicit 只能修改只有一个参数的类构造函数,
// 或是除了第一个参数外其他参数都有默认值的情况
// 表明该构造函数是显示的而非隐式的
// 作用是禁止类构造函数的隐式自动转换
// implicit 表示隐式,类构造函数默认声明为隐式
// google c++规范与effective c++都推荐使用explicit声明
explicit Int(int i) : m_i(i) {}

void print1() const {
cout << "[" << m_i << "]";
}

private:
int m_i;
};

int main() {
// 将outite绑定到cout,每次对outite指派一个元素,就后接一个“ ”
ostream_iterator<int> outite(cout, " ");

int ia[6] = {2, 21, 12, 7, 19, 23};
vector<int> iv(ia, ia + 6);

// 找出不小于12的元素个数
cout << count_if(iv.begin(), iv.end(),
not1(bind2nd(less<int>(), 12)));
cout << endl;

// 令每个元素v执行(v+2)*3然后输往outite
transform(iv.begin(), iv.end(), outite,
compose1(bind2nd(multiplies<int>(), 3),
bind2nd(plus<int>(), 2)));
cout << endl;

// 将所有元素拷贝到outite
copy(iv.begin(), iv.end(), outite);
cout << endl;
// 1. 使用函数指针搭配stl算法
for_each(iv.begin(), iv.end(), print);
cout << endl;

// 2. 以修饰过的一般函数搭配stl算法
for_each(iv.begin(), iv.end(), ptr_fun(print));
cout << endl;

Int t1(3), t2(7), t3(20), t4(14), t5(68);
vector<Int> Iv;
Iv.push_back(t1);
Iv.push_back(t2);
Iv.push_back(t3);
Iv.push_back(t4);
Iv.push_back(t5);
// 3. 以修饰过的成员函数搭配stl算法
for_each(Iv.begin(), Iv.end(), mem_fun_ref(&Int::print1));

return 0;
}

执行结果:
1
2
3
4
5
6
7
[root@192 8_STL_adapter]# ./8_1_3_functor-adapter
4
12 69 42 27 63 75
2 21 12 7 19 23
2 21 12 7 19 23
2 21 12 7 19 23
[3][7][20][14][68][

第0章 一篇有用的介绍对象内存布局的文章

什么是多态?

多态可以分为编译时多态和运行时多态。

编译时多态:基于模板和函数重载方式,在编译时就已经确定对象的行为,也称为静态绑定。

运行时多态:面向对象的一大特色,通过继承方式使得程序在运行时才会确定相应调用的方法,也称为动态绑定,它的实现主要是依赖于传说中的虚函数表。

如何查看对象的布局?

在gcc中可以使用如下命令查看对象布局:

1
g++ -fdump-class-hierarchy model.cc

在clang中可以使用如下命令:

1
2
3
4
clang -Xclang -fdump-record-layouts -stdlib=libc++ -c model.cc
// 查看对象布局
clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c model.cc
// 查看虚函数表布局

上面两种方式其实足够了,也可以使用gdb来查看内存布局,这里可以看文末相关参考资料。本文都是使用clang来查看的对象布局。

接下来让我们一起来探秘下各种继承条件下类对象的布局情况吧~

普通类对象的布局

如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Base {
Base() = default;
~Base() = default;

void Func() {}

int a;
int b;
};

int main() {
Base a;
return 0;
}

// 使用clang -Xclang -fdump-record-layouts -stdlib=libc++ -c model.cc查看

输出如下:

1
2
3
4
5
6
7
8
*** Dumping AST Record Layout
0 | struct Base
0 | int a
4 | int b
| [sizeof=8, dsize=8, align=4,
| nvsize=8, nvalign=4]

*** Dumping IRgen Record Layout

从结果中可以看见,这个普通结构体Base的大小为8字节,a占4个字节,b占4个字节。

带虚函数的类对象布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Base {
Base() = default;
virtual ~Base() = default;

void FuncA() {}

virtual void FuncB() {
printf("FuncB\n");
}

int a;
int b;
};

int main() {
Base a;
return 0;
}

// 这里可以查看对象的布局和相应虚函数表的布局
clang -Xclang -fdump-record-layouts -stdlib=libc++ -c model.cc
clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c model.cc

对象布局如下:

1
2
3
4
5
6
7
8
9
*** Dumping AST Record Layout
0 | struct Base
0 | (Base vtable pointer)
8 | int a
12 | int b
| [sizeof=16, dsize=16, align=8,
| nvsize=16, nvalign=8]

*** Dumping IRgen Record Layout

这个含有虚函数的结构体大小为16,在对象的头部,前8个字节是虚函数表的指针,指向虚函数的相应函数指针地址,a占4个字节,b占4个字节,总大小为16。

虚函数表布局:

1
2
3
4
5
6
7
Vtable for 'Base' (5 entries).
0 | offset_to_top (0)
1 | Base RTTI
-- (Base, 0) vtable address --
2 | Base::~Base() [complete]
3 | Base::~Base() [deleting]
4 | void Base::FuncB()

我们来探秘下传说中的虚函数表:

offset_to_top(0):表示当前这个虚函数表地址距离对象顶部地址的偏移量,因为对象的头部就是虚函数表的指针,所以偏移量为0。

RTTI指针:指向存储运行时类型信息(type_info)的地址,用于运行时类型识别,用于typeid和dynamic_cast。

RTTI下面就是虚函数表指针真正指向的地址啦,存储了类里面所有的虚函数,至于这里为什么会有两个析构函数,大家可以先关注对象的布局,最下面会介绍。

单继承下不含有覆盖函数的类对象的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Base {
Base() = default;
virtual ~Base() = default;

void FuncA() {}

virtual void FuncB() {
printf("Base FuncB\n");
}

int a;
int b;
};

struct Derive : public Base{
};

int main() {
Base a;
Derive d;
return 0;
}

子类对象布局:

1
2
3
4
5
6
7
8
9
10
*** Dumping AST Record Layout
0 | struct Derive
0 | struct Base (primary base)
0 | (Base vtable pointer)
8 | int a
12 | int b
| [sizeof=16, dsize=16, align=8,
| nvsize=16, nvalign=8]

*** Dumping IRgen Record Layout

和上面相同,这个含有虚函数的结构体大小为16,在对象的头部,前8个字节是虚函数表的指针,指向虚函数的相应函数指针地址,a占4个字节,b占4个字节,总大小为16。

子类虚函数表布局:

1
2
3
4
5
6
7
8
Vtable for 'Derive' (5 entries).
0 | offset_to_top (0)
1 | Derive RTTI
-- (Base, 0) vtable address --
-- (Derive, 0) vtable address --
2 | Derive::~Derive() [complete]
3 | Derive::~Derive() [deleting]
4 | void Base::FuncB()

这个和上面也是相同的,注意下虚函数表这里的FuncB函数,还是Base类中的FuncB,因为在子类中没有重写这个函数,那么如果子类重写这个函数后对象布局是什么样的,请继续往下看哈。

单继承下含有覆盖函数的类对象的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Base {
Base() = default;
virtual ~Base() = default;

void FuncA() {}

virtual void FuncB() {
printf("Base FuncB\n");
}

int a;
int b;
};

struct Derive : public Base{
void FuncB() override {
printf("Derive FuncB \n");
}
};

int main() {
Base a;
Derive d;
return 0;
}

子类对象布局:

1
2
3
4
5
6
7
8
9
10
*** Dumping AST Record Layout
0 | struct Derive
0 | struct Base (primary base)
0 | (Base vtable pointer)
8 | int a
12 | int b
| [sizeof=16, dsize=16, align=8,
| nvsize=16, nvalign=8]

*** Dumping IRgen Record Layout

依旧和上面相同,这个含有虚函数的结构体大小为16,在对象的头部,前8个字节是虚函数表的指针,指向虚函数的相应函数指针地址,a占4个字节,b占4个字节,总大小为16。

子类虚函数表布局:

1
2
3
4
5
6
7
8
Vtable for 'Derive' (5 entries).
0 | offset_to_top (0)
1 | Derive RTTI
-- (Base, 0) vtable address --
-- (Derive, 0) vtable address --
2 | Derive::~Derive() [complete]
3 | Derive::~Derive() [deleting]
4 | void Derive::FuncB()

注意这里虚函数表中的FuncB函数已经是Derive中的FuncB啦,因为在子类中重写了父类的这个函数。

再注意这里的RTTI中有了两项,表示Base和Derive的虚表地址是相同的,Base类里的虚函数和Derive类里的虚函数都在这个链条下,这里可以继续关注下面多继承的情况,看看有何不同。

多继承下不含有覆盖函数的类对象的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct BaseA {
BaseA() = default;
virtual ~BaseA() = default;

void FuncA() {}

virtual void FuncB() {
printf("BaseA FuncB\n");
}

int a;
int b;
};

struct BaseB {
BaseB() = default;
virtual ~BaseB() = default;

void FuncA() {}

virtual void FuncC() {
printf("BaseB FuncC\n");
}

int a;
int b;
};

struct Derive : public BaseA, public BaseB{
};

int main() {
BaseA a;
Derive d;
return 0;
}

类对象布局:

1
2
3
4
5
6
7
8
9
10
11
12
*** Dumping AST Record Layout
0 | struct Derive
0 | struct BaseA (primary base)
0 | (BaseA vtable pointer)
8 | int a
12 | int b
16 | struct BaseB (base)
16 | (BaseB vtable pointer)
24 | int a
28 | int b
| [sizeof=32, dsize=32, align=8,
| nvsize=32, nvalign=8]

Derive大小为32,注意这里有了两个虚表指针,因为Derive是多继承,一般情况下继承了几个带有虚函数的类,对象布局中就有几个虚表指针,并且子类也会继承基类的数据,一般来说,不考虑内存对齐的话,子类(继承父类)的大小=子类(不继承父类)的大小+所有父类的大小。

虚函数表布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Vtable for 'Derive' (10 entries).
0 | offset_to_top (0)
1 | Derive RTTI
-- (BaseA, 0) vtable address --
-- (Derive, 0) vtable address --
2 | Derive::~Derive() [complete]
3 | Derive::~Derive() [deleting]
4 | void BaseA::FuncB()
5 | offset_to_top (-16)
6 | Derive RTTI
-- (BaseB, 16) vtable address --
7 | Derive::~Derive() [complete]
[this adjustment: -16 non-virtual]
8 | Derive::~Derive() [deleting]
[this adjustment: -16 non-virtual]
9 | void BaseB::FuncC()

可画出对象布局图如下:

offset_to_top(0):表示当前这个虚函数表(BaseA,Derive)地址距离对象顶部地址的偏移量,因为对象的头部就是虚函数表的指针,所以偏移量为0。

再注意这里的RTTI中有了两项,表示BaseA和Derive的虚表地址是相同的,BaseA类里的虚函数和Derive类里的虚函数都在这个链条下,截至到offset_to_top(-16)之前都是BaseA和Derive的虚函数表。

offset_to_top(-16):表示当前这个虚函数表(BaseB)地址距离对象顶部地址的偏移量,因为对象的头部就是虚函数表的指针,所以偏移量为-16,这里用于this指针偏移,下一小节会介绍。

注意下后面的这个RTTI:只有一项,表示BaseB的虚函数表,后面也有两个虚析构函数,为什么有四个Derive类的析构函数呢,又是怎么调用呢,请继续往下看~

多继承下含有覆盖函数的类对象的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct BaseA {
BaseA() = default;
virtual ~BaseA() = default;

void FuncA() {}

virtual void FuncB() {
printf("BaseA FuncB\n");
}

int a;
int b;
};

struct BaseB {
BaseB() = default;
virtual ~BaseB() = default;

void FuncA() {}

virtual void FuncC() {
printf("BaseB FuncC\n");
}

int a;
int b;
};

struct Derive : public BaseA, public BaseB{
void FuncB() override {
printf("Derive FuncB \n");
}

void FuncC() override {
printf("Derive FuncC \n");
}
};

int main() {
BaseA a;
Derive d;
return 0;
}

对象布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
*** Dumping AST Record Layout
0 | struct Derive
0 | struct BaseA (primary base)
0 | (BaseA vtable pointer)
8 | int a
12 | int b
16 | struct BaseB (base)
16 | (BaseB vtable pointer)
24 | int a
28 | int b
| [sizeof=32, dsize=32, align=8,
| nvsize=32, nvalign=8]

*** Dumping IRgen Record Layout

类大小仍然是32,和上面一样。

虚函数表布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Vtable for 'Derive' (11 entries).
0 | offset_to_top (0)
1 | Derive RTTI
-- (BaseA, 0) vtable address --
-- (Derive, 0) vtable address --
2 | Derive::~Derive() [complete]
3 | Derive::~Derive() [deleting]
4 | void Derive::FuncB()
5 | void Derive::FuncC()
6 | offset_to_top (-16)
7 | Derive RTTI
-- (BaseB, 16) vtable address --
8 | Derive::~Derive() [complete]
[this adjustment: -16 non-virtual]
9 | Derive::~Derive() [deleting]
[this adjustment: -16 non-virtual]
10 | void Derive::FuncC()
[this adjustment: -16 non-virtual]

offset_to_top(0):表示当前这个虚函数表(BaseA,Derive)地址距离对象顶部地址的偏移量,因为对象的头部就是虚函数表的指针,所以偏移量为0。

再注意这里的RTTI中有了两项,表示BaseA和Derive的虚表地址是相同的,BaseA类里的虚函数和Derive类里的虚函数都在这个链条下,截至到offset_to_top(-16)之前都是BaseA和Derive的虚函数表。

offset_to_top(-16):表示当前这个虚函数表(BaseB)地址距离对象顶部地址的偏移量,因为对象的头部就是虚函数表的指针,所以偏移量为-16。当基类BaseB的引用或指针base实际接受的是Derive类型的对象,执行base->FuncC()时候,由于FuncC()已经被重写,而此时的this指针指向的是BaseB类型的对象,需要对this指针进行调整,就是offset_to_top(-16),所以this指针向上调整了16字节,之后调用FuncC(),就调用到了被重写后Derive虚函数表中的FuncC()函数。这些带adjustment标记的函数都是需要进行指针调整的。至于上面所说的这里虚函数是怎么调用的,估计您也明白了吧~

多重继承不同的继承顺序导致的类对象的布局相同吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct BaseA {
BaseA() = default;
virtual ~BaseA() = default;

void FuncA() {}

virtual void FuncB() {
printf("BaseA FuncB\n");
}

int a;
int b;
};

struct BaseB {
BaseB() = default;
virtual ~BaseB() = default;

void FuncA() {}

virtual void FuncC() {
printf("BaseB FuncC\n");
}

int a;
int b;
};

struct Derive : public BaseB, public BaseA{
void FuncB() override {
printf("Derive FuncB \n");
}

void FuncC() override {
printf("Derive FuncC \n");
}
};

int main() {
BaseA a;
Derive d;
return 0;
}

对象布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
*** Dumping AST Record Layout
0 | struct Derive
0 | struct BaseB (primary base)
0 | (BaseB vtable pointer)
8 | int a
12 | int b
16 | struct BaseA (base)
16 | (BaseA vtable pointer)
24 | int a
28 | int b
| [sizeof=32, dsize=32, align=8,
| nvsize=32, nvalign=8]

*** Dumping IRgen Record Layout

这里可见,对象布局和上面的不相同啦,BaseB的虚函数表指针和数据在上面,BaseA的虚函数表指针和数据在下面,以A,B的顺序继承,对象的布局就是A在上B在下,以B,A的顺序继承,对象的布局就是B在上A在下。

虚函数表布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Vtable for 'Derive' (11 entries).
0 | offset_to_top (0)
1 | Derive RTTI
-- (BaseB, 0) vtable address --
-- (Derive, 0) vtable address --
2 | Derive::~Derive() [complete]
3 | Derive::~Derive() [deleting]
4 | void Derive::FuncC()
5 | void Derive::FuncB()
6 | offset_to_top (-16)
7 | Derive RTTI
-- (BaseA, 16) vtable address --
8 | Derive::~Derive() [complete]
[this adjustment: -16 non-virtual]
9 | Derive::~Derive() [deleting]
[this adjustment: -16 non-virtual]
10 | void Derive::FuncB()
[this adjustment: -16 non-virtual]

虚函数表的布局也有所不同,BaseB和Derive共用一个虚表地址,在整个虚表布局的上方,而布局的下半部分是BaseA的虚表,可见继承顺序不同,子类的虚表布局也有所不同。

虚继承的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Base {
Base() = default;
virtual ~Base() = default;

void FuncA() {}

virtual void FuncB() {
printf("BaseA FuncB\n");
}

int a;
int b;
};

struct Derive : virtual public Base{
void FuncB() override {
printf("Derive FuncB \n");
}
};

int main() {
Base a;
Derive d;
return 0;
}

对象布局:

1
2
3
4
5
6
7
8
9
10
11
*** Dumping AST Record Layout
0 | struct Derive
0 | (Derive vtable pointer)
8 | struct Base (virtual base)
8 | (Base vtable pointer)
16 | int a
20 | int b
| [sizeof=24, dsize=24, align=8,
| nvsize=8, nvalign=8]

*** Dumping IRgen Record Layout

虚继承下,这里的对象布局和普通单继承有所不同,普通单继承下子类和基类共用一个虚表地址,而在虚继承下,子类和虚基类分别有一个虚表地址的指针,两个指针大小总和为16,再加上a和b的大小8,为24。

虚函数表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Vtable for 'Derive' (13 entries).
0 | vbase_offset (8)
1 | offset_to_top (0)
2 | Derive RTTI
-- (Derive, 0) vtable address --
3 | void Derive::FuncB()
4 | Derive::~Derive() [complete]
5 | Derive::~Derive() [deleting]
6 | vcall_offset (-8)
7 | vcall_offset (-8)
8 | offset_to_top (-8)
9 | Derive RTTI
-- (Base, 8) vtable address --
10 | Derive::~Derive() [complete]
[this adjustment: 0 non-virtual, -24 vcall offset offset]
11 | Derive::~Derive() [deleting]
[this adjustment: 0 non-virtual, -24 vcall offset offset]
12 | void Derive::FuncB()
[this adjustment: 0 non-virtual, -32 vcall offset offset]

对象布局图如下:

vbase_offset(8):对象在对象布局中与指向虚基类虚函数表的指针地址的偏移量

vcall_offset(-8):当虚基类Base的引用或指针base实际接受的是Derive类型的对象,执行base->FuncB()时候,由于FuncB()已经被重写,而此时的this指针指向的是Base类型的对象,需要对this指针进行调整,就是vcall_offset(-8),所以this指针向上调整了8字节,之后调用FuncB(),就调用到了被重写后的FuncB()函数。

虚继承带未覆盖函数的对象布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct Base {
Base() = default;
virtual ~Base() = default;

void FuncA() {}

virtual void FuncB() {
printf("Base FuncB\n");
}

virtual void FuncC() {
printf("Base FuncC\n");
}

int a;
int b;
};

struct Derive : virtual public Base{
void FuncB() override {
printf("Derive FuncB \n");
}
};

int main() {
Base a;
Derive d;
return 0;
}

对象布局:

1
2
3
4
5
6
7
8
9
10
11
*** Dumping AST Record Layout
0 | struct Derive
0 | (Derive vtable pointer)
8 | struct Base (virtual base)
8 | (Base vtable pointer)
16 | int a
20 | int b
| [sizeof=24, dsize=24, align=8,
| nvsize=8, nvalign=8]

*** Dumping IRgen Record Layout

和上面虚继承情况下相同,普通单继承下子类和基类共用一个虚表地址,而在虚继承下,子类和虚基类分别有一个虚表地址的指针,两个指针大小总和为16,再加上a和b的大小8,为24。

虚函数表布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Vtable for 'Derive' (15 entries).
0 | vbase_offset (8)
1 | offset_to_top (0)
2 | Derive RTTI
-- (Derive, 0) vtable address --
3 | void Derive::FuncB()
4 | Derive::~Derive() [complete]
5 | Derive::~Derive() [deleting]
6 | vcall_offset (0)
7 | vcall_offset (-8)
8 | vcall_offset (-8)
9 | offset_to_top (-8)
10 | Derive RTTI
-- (Base, 8) vtable address --
11 | Derive::~Derive() [complete]
[this adjustment: 0 non-virtual, -24 vcall offset offset]
12 | Derive::~Derive() [deleting]
[this adjustment: 0 non-virtual, -24 vcall offset offset]
13 | void Derive::FuncB()
[this adjustment: 0 non-virtual, -32 vcall offset offset]
14 | void Base::FuncC()

vbase_offset(8):对象在对象布局中与指向虚基类虚函数表的指针地址的偏移量

vcall_offset(-8):当虚基类Base的引用或指针base实际接受的是Derive类型的对象,执行base->FuncB()时候,由于FuncB()已经被重写,而此时的this指针指向的是Base类型的对象,需要对this指针进行调整,就是vcall_offset(-8),所以this指针向上调整了8字节,之后调用FuncB(),就调用到了被重写后的FuncB()函数。

vcall_offset(0):当Base的引用或指针base实际接受的是Derive类型的对象,执行base->FuncC()时候,由于FuncC()没有被重写,所以不需要对this指针进行调整,就是vcall_offset(0),之后调用FuncC()。

菱形继承下类对象的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
struct Base {
Base() = default;
virtual ~Base() = default;

void FuncA() {}

virtual void FuncB() {
printf("BaseA FuncB\n");
}

int a;
int b;
};

struct BaseA : virtual public Base {
BaseA() = default;
virtual ~BaseA() = default;

void FuncA() {}

virtual void FuncB() {
printf("BaseA FuncB\n");
}

int a;
int b;
};

struct BaseB : virtual public Base {
BaseB() = default;
virtual ~BaseB() = default;

void FuncA() {}

virtual void FuncC() {
printf("BaseB FuncC\n");
}

int a;
int b;
};

struct Derive : public BaseB, public BaseA{
void FuncB() override {
printf("Derive FuncB \n");
}

void FuncC() override {
printf("Derive FuncC \n");
}
};

int main() {
BaseA a;
Derive d;
return 0;
}

类对象布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
*** Dumping AST Record Layout
0 | struct Derive
0 | struct BaseB (primary base)
0 | (BaseB vtable pointer)
8 | int a
12 | int b
16 | struct BaseA (base)
16 | (BaseA vtable pointer)
24 | int a
28 | int b
32 | struct Base (virtual base)
32 | (Base vtable pointer)
40 | int a
44 | int b
| [sizeof=48, dsize=48, align=8,
| nvsize=32, nvalign=8]

*** Dumping IRgen Record Layout

大小为48,这里不用做过多介绍啦,相信您已经知道了吧。

虚函数表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Vtable for 'Derive' (20 entries).
0 | vbase_offset (32)
1 | offset_to_top (0)
2 | Derive RTTI
-- (BaseB, 0) vtable address --
-- (Derive, 0) vtable address --
3 | Derive::~Derive() [complete]
4 | Derive::~Derive() [deleting]
5 | void Derive::FuncC()
6 | void Derive::FuncB()
7 | vbase_offset (16)
8 | offset_to_top (-16)
9 | Derive RTTI
-- (BaseA, 16) vtable address --
10 | Derive::~Derive() [complete]
[this adjustment: -16 non-virtual]
11 | Derive::~Derive() [deleting]
[this adjustment: -16 non-virtual]
12 | void Derive::FuncB()
[this adjustment: -16 non-virtual]
13 | vcall_offset (-32)
14 | vcall_offset (-32)
15 | offset_to_top (-32)
16 | Derive RTTI
-- (Base, 32) vtable address --
17 | Derive::~Derive() [complete]
[this adjustment: 0 non-virtual, -24 vcall offset offset]
18 | Derive::~Derive() [deleting]
[this adjustment: 0 non-virtual, -24 vcall offset offset]
19 | void Derive::FuncB()
[this adjustment: 0 non-virtual, -32 vcall offset offset]

对象布局图如下:

vbase_offset (32)

vbase_offset (16):对象在对象布局中与指向虚基类虚函数表的指针地址的偏移量

offset_to_top (0)

offset_to_top (-16)

offset_to_top (-32):指向虚函数表的地址与对象顶部地址的偏移量。

vcall_offset(-32):当虚基类Base的引用或指针base实际接受的是Derive类型的对象,执行base->FuncB()时候,由于FuncB()已经被重写,而此时的this指针指向的是Base类型的对象,需要对this指针进行调整,就是vcall_offset(-32),所以this指针向上调整了32字节,之后调用FuncB(),就调用到了被重写后的FuncB()函数。

为什么要虚继承?

非虚继承时,显然D会继承两次A,内部就会存储两份A的数据浪费空间,而且还有二义性,D调用A的方法时,由于有两个A,究竟时调用哪个A的方法呢,编译器也不知道,就会报错,所以有了虚继承,解决了空间浪费以及二义性问题。在虚拟继承下,只有一个共享的基类子对象被继承,而无论该基类在派生层次中出现多少次。共享的基类子对象被称为虚基类。在虚继承下,基类子对象的复制及由此而引起的二义性都被消除了。

为什么虚函数表中有两个析构函数?

前面的代码输出中我们可以看到虚函数表中有两个析构函数,一个标志为deleting,一个标志为complete,因为对象有两种构造方式,栈构造和堆构造,所以对应的实现上,对象也有两种析构方式,其中堆上对象的析构和栈上对象的析构不同之处在于,栈内存的析构不需要执行 delete 函数,会自动被回收。

为什么构造函数不能是虚函数?

构造函数就是为了在编译阶段确定对象的类型以及为对象分配空间,如果类中有虚函数,那就会在构造函数中初始化虚函数表,虚函数的执行却需要依赖虚函数表。如果构造函数是虚函数,那它就需要依赖虚函数表才可执行,而只有在构造函数中才会初始化虚函数表,鸡生蛋蛋生鸡的问题,很矛盾,所以构造函数不能是虚函数。

为什么基类析构函数要是虚函数?

一般基类的析构函数都要设置成虚函数,因为如果不设置成虚函数,在析构的过程中只会调用到基类的析构函数而不会调用到子类的析构函数,可能会产生内存泄漏。

小总结

offset_to_top:对象在对象布局中与对象顶部地址的偏移量。

RTTI指针:指向存储运行时类型信息(type_info)的地址,用于运行时类型识别,用于typeid和dynamic_cast。

vbase_offset:对象在对象布局中与指向虚基类虚函数表的指针地址的偏移量。

vcall_offset:父类引用或指针指向子类对象,调用被子类重写的方法时,用于对虚函数执行指针地址调整,方便成功调用被重写的方法。

thunk: 表示上面虚函数表中带有adjustment字段的函数调用需要先进行this指针调整,才可以调用到被子类重写的函数。

最后通过两张图总结一下对象在Linux中的布局:

1
A *a = new Derive(); // A为Derive的基类

如图:

a作为对象指针存储在栈中,指向在堆中的类A的实例内存,其中实例内存布局中有虚函数表指针,指针指向的虚函数表存放在数据段中,虚函数表中的各个函数指针指向的函数在代码段中。

第1章 关于对象

C中数据和处理数据的函数是分开定义的,语言本身并没有支持“数据和函数”之间的关联性。C++与C不同,用独立的抽象数据结构来实现,或是通过一个双层或三层的继承体系实现。更进一步,他们都能够被参数化。例如一个点类型Point

1
2
3
4
5
6
7
8
9
10
11
12
template <class type>
class Point3d {
public:
Point3d(type x = 0.0, type y = 0.0, type z = 0.0)
: _x(x), _y(y), _z(z) {}
type x() {return _x;}
void setx(type xval) {x = xval;}
private:
type _x;
type _y;
type _z;
}

也可以坐标类型和坐标数目都参数化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <class type, int dim>
class Point {
public:
Point();
Point(type coords[dim]) {
for (int index = 0; index < dim; index ++)
_coords[index] = coords[index];
}
type& operator[](int index) {
assert(index < dim && index >= 0);
return _coords[index];
}

private:
type _coords[dim];
}

加入封装后的布局成本

答案是并没有增加布局成本。就像C struct一样,data members直接在每一个object中,但是memeber functions虽然含在class的声明之内,却不出现在object中。每一个non-inline member function只会诞生一个函数实体。至于每一个拥有零个或一个定义的inline function则会在其每一个使用者(模块)身上产生一个函数实体。

C++在布局以及存取时间上主要的额外负担是由virtual引起的,包括:

  • virtual funciton机制,用以支持一个有效率的执行期绑定(runtime binding)
  • virtual base class,用以实现多次出现在继承体系中的base class,有一个单一而被共享的实体

C++ 对象模式(The C++ Object Model)

在C++中,有两种class data members:staticnonstatic,以及三种class member functions:staticnonstaticvirtual。已知下面这个class Point声明:

1
2
3
4
5
6
7
8
9
10
11
12
class Point{
public:
Point(float xval);
virtual ~Point();
float x() const;
static int PointCount();

protected:
virtual ostream& print(ostream &os) const;
float _x;
static int _point_count;
};

简单对象模型:一个object是一系列的slots,每一个slot指向一个members。Members按其声明的顺序被指定一个slot。每一个data member或function member都有自己的slot。

在这个简单模型中,members本身并不放在object之中。只有“指向member的指针”被放在object内。这么做可以避免“members有不同的类型,因而需要不同的存储空间”所招致的问题。Object中的members是以slot的索引值来寻址:本例之中_x的索引是6,_point_count的索引是7。一个class object的大小很容易计算出来:“指针大小,乘以class中所声明的members数目”便是。(类似指针数组,一个object就是一个指针数组。)

表格驱动对象模型:把所有与members相关的信息抽取出来,放在一个data member table和一个member function table之中,class object本身则含有指向这两个表格的指针。member function table是一系列的slot,每一个slot指出一个member function,data member table则直接含有数据本身:

C++对象模型:nonstatic data members被配置于每一个class object之内,static data members则被存放在所有的class object之外。static和nonstatic function members也被放在所有的class object之外。

虚函数则以两个步骤支持之:

  1. 每个class产生出一堆指向虚函数的指针,放在表格之中。这个表格被称为virtual table(vtbl)
  2. 每一个class object被安插一个指针,指向相关的virtual table。通常这个指针被称为vptr。vptr的设定和重置都由每一个class的constructor、destructor和copy assignment运算符自动完成。每一个class所关联的type_info object(用以支持runtime type identification, RTTI)也经由virtual table被指出来,通常放在表格的第一个slot处。

故上面的声明所对应的对象模型如下:

上图说明了C++对象模型如何应用于Point Class身上,这个模型的主要优点在于它的空间和存取时间的效率。主要缺点是:如果应用程序代码未曾改变,但所用到的class objects的nonstatic data members有所修改(有可能是增加、移除或更改),那么应用程序代码同样得重新编译。

加上继承:C++支持单一/多重继承。

1
2
3
4
class iostream:
public istream,
public ostream
{...};

继承关系可以指定为虚拟(virtual,也就是共享的意思):
1
2
class istream : virtual public ios {...};
class ostream : virtual public ios {...};

在虚拟继承的情况下,base class不管在继承链中被派生(derived)多少次,永远只会存在一个实例(称为subobject),例如iostream中只有virtual ios base class的一个实体。

每一个base class可以被derived class object内的一个slot指出,该slot内含base class subobject的地址。这个体制因为间接性导致了空间和存取时间上的额外负担,优点则是class object大小不会因为base class的改变而受到影响。

另一种所谓的base table模型。这里所说的base class table被产生出来时,表格中的每一个slot内含一个相关的base class地址,这很像virtual table内含每一个virtual function的地址一样。每一个class object内含一个bptr,它会被初始化,指向其base class table。这种策略的主要缺点是由于间接性而导致的空间和存取时间上的额外负担,优点则是在每一个class object中对于继承都有一致的表现方式:每一个class object都应该在某个固定位置上安放一个base table指针,与base classes的大小或数目无关。第二个优点是,不需要改变class objects本身,就可以放大、缩小、或更改base class table。

不管上述哪一种体制,“间接性”的级数都将因为继承的深度而增加。如果在derived class内复制一个指针,指向继承串链中的每一个base class,倒是可以获得一个永远不变的存取时间。当然这必须付出代价,因为需要额外的空间来放置额外的指针。

C++最初采用的继承模型并不运用任何间接性:base class subobject的data members被直接放置于derived class object中。这提供了对base class members最紧凑而且最有效率的存取。缺点是base class members的任何改变,包括增加、移除或改变类型等等,都使得所有用到此base class或其derived class的objects必须重新编译。

自c++ 2.0起才新导入的virtual base class,需要一些间接的base class表现方法。Virtual base class的原始模型是在class object中为每一个有关联的virtual base class加上一个指针。其它演化出来的模型则若不是导入一个virtual base class table,就是扩充原已存在的virtual table,以便维护每一个virtual base class的位置。

对象模型如何影响程序:不同的对象模型会导致“现有的程序代码必须修改”和“必须加入新的代码”两个结果。

关键词带来的差异

下面一行其实是pf的一个函数调用而不是声明:

1
2
// 直到看到1024才决定是声明还是调用
int (*pf)(1024)

而在下边的这个声明中,上边那样的向前预览甚至不起作用。
1
int (*pf) ();

当语言无法区分是一个声明还是一个表达式时,需要一个超越语言范围的规则,该规则将上述式子判断为一个“声明”。

关键词struct本身并不一定要象征其后随之声明的任何东西。我们可以使用struct代替class,但仍然声明public、protected、private等等存取区段,及一个完全public的接口,以及virtual functions,以及单一继承、多重继承、虚拟继承等等。

真正的问题并不在于所有“使用者自定义类型”的声明是否必须使用相同的关键词,问题在于使用class或struct关键词是否可以给予“类型的内部声明”以某种承诺。也就是说,如果struct关键词的使用实现了C的数据萃取观念,而class关键词实现的是C++的ADT (Abstract Data Type)观念,那么当然“不一致性”是一种错误的语言用法。就好像下面这种错误,一个object被矛盾地声明为static和extern:

1
2
3
4
5
//不合法吗?是的 
//以下两个声明造成矛盾的存储空间
static int foo;
...
extern int foo;

这组声明对于foo的存储空间造成矛盾。然而,如你所见,struct和class这两个关键词并不会造成这样的矛盾。class的真正特性是由声明的本身(declaration body)来决定的。“一致性的用法”只不过是一种风格上的问题而已。

对象的差异

C++程序设计模型支持三种programming paradigms典范:

  1. 程序模型(procedural model),就像C一样,C++当然也支持它,字符串的处理就是一个例子,我们可以使用字符数组以及str*函数集(定义在标准的C函数库中):

    1
    2
    3
    4
    5
    6
    7
    8
    char boy[] = "Danny";
    char *p_son;
    ……
    p_son = new char[ strlen (boy ) + 1 ];
    strcpy( p_son, boy );
    ……
    if ( !strcmp( p_son, boy ) )
    take_to_disneyland( boy );
  2. 抽象数据类型模型(abstract data type model, ADT)。该模型所谓的“抽象”是和一组表达式(public 接口)一起提供,而其运算定义仍然隐而未明。例如下面的String class:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    String girl = "Anna";
    String daughter;
    ……
    // String::operator=();
    daughter = girl;
    ……
    // String::operator==();
    if ( girl == daughter )
    take_to_disneyland( girl );
  3. 面向对象模型(object-oriented model)。在此模型中有一些彼此相关的类型,通过一个抽象的 base class (用以提供共通接口)被封装起来。Library_materials class 就是一个例子,真正的 subtypes 例如 Book、Video、Compact_Disc、Puppet、Laptop 等等都可以从那里派生而来:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void check_in( Library_materials *pmat )
    {
    if ( pmat->late() )
    pmat->fine();
    pmat->check_in();

    if ( Lender *plend = pmat->reserved() )
    pmat->notify( plend );
    }

纯粹以一种 paradigm 写程序,有助于整体行为的良好稳固。

在 OO paradigm 中,程序员需要处理一个未知的实体,虽然类型有所界限,但有无穷的可能,被指定的 object 的真实类型在某个特定执行点之前,是无法解析的。只用通过 pointers 和 references 的操作才能够完成。相反,在 ADT paradigm 中,程序员处理的则是一个固定而单一的实体,在编译时已经定义完成。

1
2
3
4
5
6
// 描述objects:不确定类型
Library_materials *px = retrieve_some_material();
Library_materials &rx = *px;

// 描述已知物:不可能有令人惊讶的结果产生
Librar materials dx= *px;

你绝对没有办法确定地说出px或rx的类型,只能说要不是Library_materials object,要不是它的子类型。不过,我们倒是可以确定,dx只能是Libraty materials class的一个object。

对于object的多态操作要求此object必须可以经由一个pointer或reference来存取,然而C++中的pointer或reference的处理却不是多态的必要结果:

1
2
3
4
5
6
7
8
int *pi;
// 没有多态,操作对象不是class object

void *pvi;
// 没有语言所支持的多态,操作对象不是class object

x *px;
// class x视为一个base class

在C++,多态只存在于一个个的public class体系中。举个例子,Px可能指向自我类型的一个object,或指向以public派生而来的一个类型〔请不要把不良的转型操作考虑在内)。Nonpublic的派生行为以及类型为void*的指针可以说是多态,但它们并没有被语言明白地支持,也就是说它们必须由程序员通过明白的转型操作来管理〔你或许可以说它们并不是多态对象的一线选手).

C++ 用下列方法支持多态:

  1. 经由一组隐含的转化操作。如:把一个 derived class 类型的指针转化为一个指向 base type 的指针:
    1
    shape *ps = new circle();
  2. 经由 virtual function 机制。

    1
    ps->rotate();
  3. 经由 dynamic_cast 和 typeid 运算符:

    1
    if (circle *pc = dynamic_cast<circle*>(ps))...

多态的主要用途是经由一个共同的接口来影响类型的封装,这个接口通常被定义在一个抽象的base class中。例如Library materials class就为Book、Video、Puppet等subtype定义了一个接口。这个共享接口是以virtual function机制引发的,它可以在执行期根据object的真正类型解析出到底是哪一个函数实体被调用。经由这样的操作:Library_material->check_out();,我们的代码可以避免由于“借助某一特定library的materials”而导致变动无常。这不只使得“当类型有所增加、修改、或删减时,我们的程序代码不需改变”,而且也使一个新的Library_materials subtype的供应者不需要重新写出“对继承体系中的所有类型都共通”的行为和操作。

需要多少内存才能表现一个 class object?一般而言:

  • 其 nonstatic data members 的总和大小;
  • 加上任何由于 alignment 的需求而填补(padding)上去空间;
  • 加上为了支持 virtual 而由内部产生的任何额外负担(overhead)。

指针的类型(The Type of a Pointer):“指向不同类型的各个指针”间的差异,不在于指针表示法不同,也不在其内容(地址)不同,而是在其所寻址出来的 object 类型的不同。也就是说,“指针类型”会教导编译器如何解释某个特定地址中的内存内容及大小。

  1. 指向地址1000的整数指针,在32位机器上将涵盖地址空间1000-1003
  2. 如果string是传统的8-byte,包含一个4-byte的字符指针和一个用来表示字符串长度的证书,那么一个Zoo Animal指针将横跨1000-1015:
  3. 一个指向地址1000的void*指针的地址空间呢?不知道!

加上多态之后(Adding Polymorphism):
定义以下类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class ZooAnimal {
public:
ZooAnimal();
virtual ~ZooAnimal();
// ...
virtual void rotate();

protected:
int loc;
String name;
};

class Bear : public ZooAnimal {
public:
Bear();
~Bear();
// ...
void rotate();
virtual void dance();
// ...
protected:
enum Dances {... };
Dances dances_known;
int cell_block;
};

Bear b("Yogi");
Bear* pb = &b;
Bear& rb = *pb;

不管 pointer 还是 reference 都只需要一个 word 的空间(32 位机器上为 4-bytes)。Bear object 需要 24 bytes,也就是 ZooAnimal 的 16 bytes 加上 Bear 所带来的 8 bytes。

有如下指针:

1
2
3
Bear b;
ZooAnimal *pz = &b;
Bear *pb = &b;

它们每个都指向 Bear object 的第一个 bytes。差别是:

  • pb 所涵盖整个 Bear object,
  • pz 值只涵盖 Bear object 中的 ZooAnimal subobject。

不能用pz处理Bear的任何member,唯一例外是通过virtual:

1
2
3
4
5
6
7
8
9
// 不合法
pz->cell_block;

// ok:经过一个downcast没问题
((Bear*)pz)->cell_block;

// 下边这样更好但是是一个runtime operation
if (Bear* pb2 = dynamic_cast<Bear*>(pz))
pb2->cell_block;

当我们写下pz->protate()时,pz 的类型将在编译时期决定以下两点:

  • 固定可用的接口。pz 只能调用 ZooAnimal 的 public 接口。
  • 该接口的 access level (例如 rotate() 是 ZooAnimal 的一个 public member)。

在每一个执行点,pz所指的类型可以决定rotate()所调用的实体。类型信息的封装不是维护于pz中,而是维护于link之中,link存在于object的vptr和vptr所指的virtual table之间。

编译器必须确保如果一个object含有一个或一个以上的vptrs,那些vptrs的内容不会被base class object初始化或改变。OO 程序设计不支持对 object 的直接处理,考虑如下例子:

1
2
3
4
5
6
7
ZooAnimal za;
ZooAnimal *pza;

Bear b;
Panda *pp = new Panda;

pza = &b;

其内存布局可能如下:

将 za 或 b 的地址,或 pp 所含内容(也是地址)指定给 pza,显然没问题。一个 pointer 或一个 reference 之所以支持多态,是因为它们并不引发内存中任何“与类型有关的内存委托操作”,改变的只是他们所指向的内存的“大小和内容解释方式”。

任何企图改变 object za 大小的行为,都会违反其定义中的“资源需求量”,如:把整个 Bear object 指定给 za,那么就会溢出它所配置得到的内存。当一个 base class object 被指定为一个 derived class object 时,derived object 就会被切割,以塞入较小的 base type 内存中。derived type 将不会留下任何痕迹。

C++ 也支持 object-based(OB)风格(非 OO),区别是对象构建不需要 virtual 机制,编译时即可决定类型。例如String class,一种非多态的数据结构,String class可以展示封装的非多态形式,它提供一个public接口和一个private实作品,包括数据和算法,但是不支持类型的扩充,一个OB设计可能比一个对等的OO涉及速度更快而且空间更紧凑,速度快是因为所有函数引发操作都在编译期决定,对象构建起来不需要virtual机制,空间紧凑是因为每一个class object不需要负担传统上为了支持virtual机制而需要的额外负荷,不过OB设计没有弹性。

第二章 构造函数语意学

iostream 函数库的建筑师:Jerry Schwarz 早期意图支持一个 iostream class object 的纯测试量(scalar test):

1
if (cin) ...

为了让 cin 可以求得真假值,Jerry 定义了一个 conversion 运算符:operator int()(把 cin 转换成 int 类型)。正确使用的话确实可行,但如下情况:
1
2
// oops: meant cout, not cin
cin << intVal;

这里程序员犯了个粗心的错误,本应使用 cout 而不是 cin,Class 的 “type-safe”本应可以捕捉这种运算符的错误运用,但是,编译器比较喜欢找到一个正确的诠释,而不是仅仅抛出错误,此例中,编译器首先会认出<<是一个左移运算符,而左移运算符只有在“cin 可以改变为和一个整数值同义”才可用,然后编译器就去找 conversion 运算符,于是找到了operator int()。那么:
1
2
int temp = cin.operator int();
temp << intVal;

现在合法了,这种错误被戏称为“Schwarz Error”。设计师以operator void*()取代operator int()

关键词explict之所以被导入,就是为了提供一种方法,使他们能够制止单一参数的constructor被当做一个conversion运算符

Default Construtor 的建构操作

default constructors 在需要的时候会被编译器产生出来,被谁需要?有如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Foo {
public:
int val;
Foo *pnext;
};

void foo_bar() {
// Oops: program needs bar's members zeroed out
Foo bar;
if (bar.val || bar.pnext)
// ... do something
// ...
}

正确的程序语意是要求 default constructor,可以将两个 members 初始化为 0,但编译器并不会为之合成出一个 default constructor,因为上述所说的需要,是指编译器需要的时候,而不是程序员需要的时候,这里编译器并不需要这个 default constructor。所以正确的表述应该是:如果没有任何 user-declared constructor,那么就会有一个 default constructor 被声明,但其是一个 trivial constructor(没啥用的 constructor)。那么,编译器什么时候会生成一个 nontrivial default constructor 呢?

“带有 Default Constructor”的 Member Class Object

简单来说:如果一个 class 没有任何 constructor,但其内含一个 member object,而这个 member object 有 default constructor,那么编译器就会合成出一个“nontrivial default constructor”。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Foo {
public:
Foo(), Foo(int)...
};

class Bar {
public:
Foo foo;
char *str;
};

void foo_bar() {
Bar bar; // Bar::foo must be initialized here

if (str) {
}
...
}

这个程序当中,编译器会为 class Bar 合成一个 default constructor,因为在 foo_bar 中,声明了一个 Bar 对象,这时候就需要初始化其中的 member,其中 Bar::foo 就需要调用 Foo 的 default constructor 才能初始化,这里初始化 foo 就是编译器的责任,但是 Bar::str 的初始化,则还是程序员的责任。合成出的 default constructor 可能如下:
1
2
3
4
5
6
// possible synthesis of Bar default constructor
// invoke Foo default constructor for member foo
inline Bar::Bar() {
// Pseudo C++ Code
foo.Foo::Foo();
}

假如程序员定义了一个 default constructor,提供了 str 的初始化操作,但没有提供 foo 的初始化操作:

1
2
3
Bar::Bar() {
str = 0;
}

现在程序的需求满足,但编译器的需求没有满足,还需要初始化 foo,但 default constructor 已经被程序员定义了,没法再合成一个了,那么编译器会按如下准则行动:“如果 class A 内含一个或一个以上的 member class objects,那么,class A 的每个 constructor 必须调用每一个 member class 的default constructor”。所以,编译器可能会将代码扩展成:

1
2
3
4
5
6
// Augmented default constructor
// Pseudo C++ Code
Bar::Bar() {
foo.Foo::Foo(); // augmented compiler code
str = 0; // explicit user code
}

如果有多个 class member object 都需要进行初始化操作,那么编译器会按 member object 在 class 中的声明次序,一个个调用其 default constructors。这些代码都将被安插在 explicit user code(生成的代码是 implicit 的)之前。

“带有 Default Constructor”的 Base Class

如果一个没有任何 constructor 的 class 派生自一个“带有 default constructor”(包括自动生成的)的 base class,那么编译器就会为其生成一个 nontrivial default constructor,在其中调用 base class 的 default constructor。

如果程序员写了好几个 constructor,但就是没写 default constructor 呢?那么编译器就会扩张现有的每一个 constructor,将所需要调用的 base calss 的 default constructor 一个个加上去,但并不会为其合成新的 default constructor(因为程序员已经提供了 constructor,所以不会再合成了)。注意,如果还有上一小节说的 member class object,那么这些 object 的 default constructor 也会被安插进去,位置在 base class constructor 之后。

“带有一个 Virtual Function”的 Class

在下面两种情况下,也需合成 default constructor:

  1. class 声明(或继承)一个 virtual function。
  2. class 派生自一个继承串链,其中有一个或多个 virtual base class。

不管哪一种情况,由于缺乏由user声明的constructor,编译器会详细记录合成一个default constructor的必要信息。有如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:
virtual void flip() = 0;
// ...
};
void flip(const Widget& widget) {
widget.flip();
}
// presuming Bell and Whistle are derived from Widget
void foo() {
Bell b;
Whistle w;
flip(b);
flip(w);
}

其中,Bell 和 Wistle 都继承自 Widget。那么下面两个扩张操作会在编译期间发生:

  1. 编译器会产生一个 virtual function table(vtbl),其中存放 class 的 virtual function 的地址。
  2. 在每个 class object 中,会合成一个额外的 pointer member(vptr),存放 vtbl 的地址。

并且,widget.flip()的虚拟引发操作会被重新改写,以使用widget的vptr和vtbl中的flip()条目:

1
2
// simplified transformation of virtual invocation: widget.flip()
(*widget.vptr[1])(&widget)

其中:

  • 1表示flip()在 virtual table 中的索引;
  • &widgetthis指针(每个成员函数都有一个隐含的 this 指针哦)。

编译器会为每个 Widget object 的 vptr 设定初值,所以对于 class 所定义的每个 constructor,编译器都会安插一些代码来做这样的事。对于没有任何 constructor 的 class,编译器则合成一个 default constructor 来做此事。

“带有一个 Virtual Base Class”的 Class

必须使virtual base class在其每一个derived class object中的位置,能够于执行期准备妥当,例如:

1
2
3
4
5
6
7
8
9
10
11
12
class X { public: int i;};
class A : public virtual X {public: int j;};
class B : public virtual X {public: double d;};
class A : public A, public B {public: int k;};

// 无法在编译期间决定pa->X::i的位置
void foo(const A* pa) {pa-> = 1024;}

main (){
foo(new A);
foo(new C);
}

编译器无法固定住foo()之中“经由pa而存取的X::i”的实际偏移位置,因为pa的真正类型可以改变。编译器必须改变执行存取操作的那些码,使X::i可以延迟到执行的时候决定。所有经由reference或pointer来存取一个virtual base class的操作都可以通过相关指针完成,foo()可以被改写为:

1
void foo(const A* pa) { pa->__vbcX->i = 1024;}

其中__vbcX表示编译器所产生的指针。

因为 virtual base class 在内存中的位置也是由一个指针指示出的,所以编译器也会对每个 constructor 安插一些代码,用来支持 virtual base class,如果没有声明任何 constructor,那么编译器就会合成一个 default constructor。

小结

以上四种情况,编译器都会给未声明 constructor 的 class 合成一个 default constructor。C++ Standard 把这些合成物称为 implicit nontrivial default constructor。至于没有存在这四种情况下且没有声明 constructor 的 class,它们拥有的是 implicit trivial default constructor,且实际上并不会被合成出来。

在合成的 default constructor 中,只有 base class subobject 和 member class object 会被初始化,其他的 nonstatic data member 都不会被初始化,因为编译器不需要。

C++ 新手(我)一般有两个误解:

  1. 任何 class 如果没有定义 default constructor,就会被合成出一个来。
  2. 编译器合成出来的 default constructor 会明确设定 “class 内每一个 data member 的默认值”。

以上两点都是错的!

Copy Constructor 的建构操作

有三种情况,会以一个 object 的内容作为另一个 class object 的初值:

  • 对一个 object 做明确的初始化操作。
  • 当 object 被当作参数交给某个函数时。
  • 当函数传回一个 class object 时。

当程序员定义了 copy constructor 时,以上情况下,当一个class object以另一个同类实体作为初值时,都会调用这个 copy constructor,这可能会导致一个暂时性class object的产生或程序代码的蜕变。

Default Memberwise Initialization

若程序员没有定义 copy constructor,那么当 class object 以相同 class 的另一个 object 作为初值时,其内部是以 default memberwise initialization 手法完成的,把每一个内建的或派生的data member的值,从一个object拷贝到另一个object身上,不过它并不会拷贝其中的member class object,而是以递归的方式实行member wise initialization。比如下列程序:

1
2
3
4
5
6
7
8
9
10
class String {
public:
// ... no explicit copy constructor
private:
char *str;
int len;
};

String noun("book");
String verb = noun;

其完成方式就像设定每一个member一样:
1
2
3
// semantic equivalent of memberwise initialization
verb.str = noun.str;
verb.len = noun.len;

如果一个 String object 被声明为另一个 class 的 member:
1
2
3
4
5
6
7
class Word {
public:
// ...no explicit copy constructor
private:
int _occurs;
String _word;
};

那么一个 Word object 的 default memberwise initialization 会拷贝其内建的member _occurs,然后再于_word身上递归的进行 memberwise initialization。

从概念上对于一个class X,这个操作是被一个copy constructor实现出来。

一个良好的编译器可以为大部分class object产生bitwise copies,因为它们有bitwise copy semantics。

应该是,default constructor和copy constructor在需要的时候才由编译器产生。这个句子的“必要”指当class不展现bitwise copy semantics时。

一个 class object 可以从两种方式复制得到,一种是被初始化(也就是我们这里所说的),另一种是被指定(assignment)。这两个操作分别以 copy constructor 和 copy assignment operator 完成。

就像 default constructor 一样,如果 class 没有声明 copy constructor,那么只有 nontrivial 的情况出现时,编译器才会在必要的时候合成一个 copy constructor,而在 trivial 的情况下,则会使用 bitwise copy semantics 。

Bitwise Copy Semantics(位逐次拷贝)

有如下程序:

1
2
3
4
5
6
7
#include "Word.h"

Word noun("block");
void foo() {
Word verb = noun;
// ...
}

很明显 verb 是根据 nonun 来初始化。如果 class Word 定义了一个 copy constructor,则 verb 的初始化操作会调用它,但如果没有,则编译器会先看看 Word 这个 class 是否展现了 “bitwise copy semantics”,然后再决定要不要合成一个 copy constructor。若 class Word 声明如下:
1
2
3
4
5
6
7
8
9
10
11
12
// declaration exhibits bitwise copy semantics
class Word {
public:
Word(const char*);
~Word() {
delete[] str;
}
// ...
private:
int cnt;
char* str;
}

那么这时候并不会合成一个 default copy constructor,因为上述声明展现了“default copy semantics”(但上述程序是有问题的,Word 的析构函数可能会重复 delete str,因为 str 被浅拷贝了)。

如果 class Word 这样声明:

1
2
3
4
5
6
7
8
9
10
// declaration does not exhibits bitwise copy semantics
class Word {
public:
Word(const String&);
~Word();
// ...
private:
int cnt;
String str;
};

其中,String 有自己的 copy constructor,这样的情况,编译器则必须合成一个 copy constructor 用来调用 String 的 copy constructor:
1
2
3
4
5
6
// A synthesized copy constructor
// Pseudo C++ Code
inline Word::Word(const Word& wd) {
str.String::String(wd.str);
cnt = wd.cnt;
}

注意:在合成的 copy constructor 中,不只 String 被复制,普通的成员如数组、指针等等 nonclass member 也会被复制。

不要 Bitwise Copy Semantics!

以下四种情况 class 不展现出“bitwise copy semantics”:

  • 当 class 内含一个 member object,而这个 member object 有一个 copy constructor(包括程序员定义的和编译器合成的)。
  • 当 class 继承自一个 base class,而这个 base class 有一个 copy constructor(同样,包括程序员定义的和编译器合成的)。
  • 当 class 声明了 virtual function 时。
  • 当 class 派生自一个继承串链,其中有 virtual base class 时。

前两个情况很好理解,编译器必须将member或base class的copy constructors调用操作安插到被合成的copy constructors中,下面讨论后两种情况。

重新设定 Virtual Table 的指针

在 class 声明了 virtual function 后,编译期间会有两个程序扩张操作:

  • 增加一个 virtual function table(vtbl),内含每个 virtual function 的地址。
  • 将一个指向 virtual function table 的指针(vptr),安插在每一个 class object 内。

很显然,在 copy 的时候需要为 vptr 正确的设定初值才行,而不是简单的拷贝。这时候,class 就不再展现 bitwise semantics 了,编译器需要合成一个copy constructor,讲vptr适当地初始化。有如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ZooAnimal {
public:
ZooAnimal();
virtual ~ZooAnimal();
virtual void animate();
virtual void draw();
// ...
private:
// data necessary for ZooAnimal's
// version of animate() and draw()
};
class Bear : public ZooAnimal {
public:
Bear();
void animate();
void draw();
virtual void dance();
// ...
private:
// data necessary for Bear's version
// of animate(), draw(), and dance()
};
Bear yogi;
Bear winnie = yogi;

ZooAnimal class object以另一个ZooAnimal class object作为初值可以直接靠bitwise copy semantics完成。

yogi 会被 default Bear constructor 初始化。且在 constructor 中,yogi 的 vptr 被设定指向了 Bear class 的 virtual table(靠编译器完成的)。此时,把 yogi 的 vptr 的值拷贝给 winnie 是安全的。yogi 和 winnie 的关系如下图所示:

当一个 base class object 用一个 derived class 的 object 初始化时,其 vptr 的复制也必须保证安全:

1
ZooAnimal franny = yogi;	// 译注:这会发生切割(sliced)行为

franny 的 vptr 显然不可以指向 Bear class 的 virtual table(如果 yogi 使用“bitwise copy”则会直接拷贝 vptr)。不然如下程序就会出错:
1
2
3
4
5
6
7
8
9
10
11
void draw(const ZooAnimal& zoey) {
zoey.draw();
}

void foo() {
// franny's vptr must address the ZooAnimal virtual table
// not the Bear virtual table yogi's vptr addresses
ZooAnimal franny = yogi;
draw(yogi); // invoke Bear::draw()
draw(franny); // invoke ZooAnimal::draw()
}

如果直接复制 vptr 的话,第 10 行的 draw 就会调用 Bear 的 draw 而不是其基类 ZooAnimal 的 draw。franny 和 yogi 正确的关系如下图所示:

也就是说,合成出的 ZooAnimal copy constructor 会明确设定 object 的 vptr 指向 ZooAnimal class 的 virtual table,而非单纯的拷贝。

处理 Virtual Base Class Subobject

如果一个 class object 以另一个 object 作为初值,且后者有一个 virtual base class subobject,那么“bitwise copy semantics”就会失效。

每一个编译器都必须让 derived class object 中的 virtual base class subobject 的位置在执行期就准备妥当。“Bitwise copy semantics”就可能会破坏这个位置。所以需要合成一个 copy constructor 来做这件事。举个例子:

1
2
3
4
5
6
7
8
9
10
class Raccoon : public virtual ZooAnimal {
public:
Raccoon() { /* private data initialization */
}
Raccoon(int val) { /* private data initialization */
}
// ...
private:
// all necessary data
};

编译器首先会为 Raccoon 的两个 constructor 生成一些代码来初始化 vptr。注意:与上节所说的 vptr 的情况一样,一个 class object 和另一个同类型的 object 之间的 memberwise 初始化并不会出现任何问题,只有在一个 class object 用其 derived class object 作为初值时,才会出问题。如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class RedPanda : public Raccoon {
public:
RedPanda() { /* private data initialization */
}
RedPanda(int val) { /* private data initialization */
}
// ...
private:
// all necessary data
};

// simple bitwise copy is sufficient
Raccoon rocky;
Raccoon little_critter = rocky;

上面的程序用 rocky 初始化 little_critter,因为他们都是 Raccoon 类型,所以“bitwise copy”就可以了。但如果这样:
1
2
3
4
5
// simple bitwise copy is not sufficient
// compiler must explicitly initialize little_critter's
// virtual base class pointer/offset
RedPanda little_red;
Raccoon little_critter = little_red;

为了正确的 little_critter 初值设定,则必须合成一个 copy constructor,在其中会生成一些代码来设定 virtual base class pointer/offset 的初值(或只是简单的确定它没有被消除),对于其它 member 则执行必要的 memberwise 初始化操作。下图展示了 little_red 和 little_critter 的关系:

在上面所说的四种情况下,class 将不再保持 “bitwise copy semantics”,这时候,如果 default copy constructor 没有声明,则会合成出一个 copy constructor。

程序转化语意学

有如下程序片段:

1
2
3
4
5
6
7
#include "X.h"

X foo() {
X xx;
// ...
return xx;
}

我们可能会做出如下假设:

  1. 每次 foo() 被调用,就传回 xx 的值。
  2. 如果 class X 定义了一个 copy constructor,那么当 foo() 被调用时,保证该 copy constructor 也会被调用。

这两个假设都得视编译器所提供的进取性优化程度(degree of aggressive optimization)而定。在高品质的 C++ 编译器中,上述两点对于 class X 的 nontrivial definitions 都不正确。

明确的初始化操作(Explicit Initialization)

定义X x0;,有如下程序,每一个都明显地以x0来初始化其class object:

1
2
3
4
5
6
void foo_bar() {
X x1(x0);
X x2 = x0;
X x3 = X(x0);
// ...
}

会有如下两个转化阶段:

  1. 重写每一个定义,其中的初始化操作会被删除。
  2. class 的 copy constructor 调用操作会被安插进去。

在明确的双阶段转化后,foo_bar()转化后可能的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Possible program transformation
// Pseudo C++ Code
void foo_bar() {
X x1;
X x2;
X x3;
// compiler inserted invocations
// of copy constructor for X
x1.X::X(x0);
x2.X::X(x0);
x3.X::X(x0);
// ...
}

其中的x1.X::X(x0)表现出对以下的copy constructor的调用:

1
X::X(const X& xx);

参数的初始化(Argument Initialization)

有如下函数定义:

1
void foo(X x0);

以下调用方式:
1
2
3
X xx;
// ...
foo(xx);

将会要求局部实体(local instance)x0以 memberwise 的方式将 xx 当作初值。编译器的一种策略如下,导入暂时性的 object,并调用 copy constructor 将其初始化
1
2
3
4
5
6
7
// Pseudo C++ code
// compiler generated temporary
X __temp0;
// compiler invocation of copy constructor
__temp0.X::X(xx);
// rewrite function call to take temporary
foo(__temp0);

暂时性object先以class X的copy constructor正确设定了初值,然后以bitwise方式拷贝到x0这个局部实体中。这样的话,还要将 foo 函数的声明改写才行:
1
void foo(X& x0);

需要改为引用传参。在 foo() 函数完成之后,将会调用 class X 的 destructor 将其析构。

另一种策略是以拷贝建构(copy construct)的方式把实际参数直接建构在其应该的位置上(堆栈中)。同样,在函数返回之前,其 destructor(如果有)会被执行。

返回值的初始化(Return Value Initialization)

有如下函数定义:

1
2
3
4
5
X bar() {
X xx;
// 处理 xx ...
return xx;
}

编译器可能会做如下的双阶段转化:

  1. 首先加上一个额外的参数,类型是 class object 的一个引用。这个参数将用来放置被“拷贝建构(copy constructed)”而得的返回值。
  2. 在 return 指令之前安插一个 copy constructor 调用操作,以便将欲传回的 object 的内容当作上述新参数的初值。

而真正的返回值则没有了,return 将不返回任何东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// function transformation to reflect
// application of copy constructor
// Pseudo C++ Code
void bar(X& __result) { // 这里多了一个参数哦
X xx;
// compiler generated invocation
// of default constructor
xx.X::X();
// ... process xx
// compiler generated invocation
// of copy constructor
__result.X::X(xx);
return;
}

现在编译器则会将如下调用操作:
1
X xx = bar();

转化为:

1
2
3
// note: no default constructor applied
X xx;
bar( xx );

而:

1
bar().memfunc();	// 执行 bar 函数返回的 object 的成员函数

则可能转化为:
1
2
X __temp0;
(bar(__temp0), __temp0).memfunc();

函数指针的类型也会被转换:
1
2
X (*pf) ();
pf = bar;

转化为:
1
2
void (*pf) (X&);
pf = bar;

在使用者层面做优化(Optimization at the User Level)

对于如下函数,xx 会被拷贝到编译器所产生的__result之中:

1
2
3
4
5
X bar(const T &y, const T &z) {
X xx;
// ... process xx using y and z
return xx;
}

程序员可以换种形式编写,可以在 X 当中另外定义一个 constructor,接收 y 和 z 类型的值,直接计算xx,改写函数为:
1
2
3
X bar(const T &y, const T &z) {
return X(y, z);
}

于是经过编译器转换后:
1
2
3
4
5
// Pseudo C++ Code
void bar(X &__result, const T &y, const T &z) {
__result.X::X(y, z);
return;
}

__result直接被计算出来,而非经过 copy constructor 拷贝而得(本来应该是在 bar 中构造出 xx,然后用 copy constructor 把__result初始化为 xx 的值)。这种方法的优劣有待探讨。

在编译器层面做优化(Optimization at the Compiler Level)

有如下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
X bar() {
X xx;
// ... process xx
return xx;
}
所有的return指令传回相同的具名数值(named value),因此编译器可能会做优化,以`__result`参数代替 named return value:
```C++
void bar(X &__result) {
// default constructor invocation
// Pseudo C++ Code
__result.X::X();
// ... process in __result directly
return;
}

这种优化被称为 Named Retrun Value(NRV)优化。有如下测试代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class test {
friend test foo(double);

public:
test() {
memset(array, 0, 100 * sizeof(double));
}

private:
double array[100];
};

test foo(double val) {
test local;
local.array[0] = val;
local.array[99] = val;
return local;
}

int main() {
for (int cnt = 0; cnt < 10000000; cnt++) {
test t = foo(double(cnt));
}
return 0;
}

上面的代码中,没有 copy constructor,所以在foo()中不会实施 NRV 优化。增加 copy constructor 后:
1
2
3
inline test::test( const test &t ) {
memcpy( this, &t, sizeof( test ));
}

激活了编译器的 NRV 优化。下面是原书测试时间表:

注意,只有当所有的 named return 指令在函数的 top level 时,优化才施行,比如在 if 语句里也有个 return 的话,优化就会关闭。

如下三个初始化操作在语义上相等:

1
2
3
X xx0(1024);
X xx1 = X(1024);
X xx2 = (X) 1024;

但是 2、3 两行有两个步骤的初始化操作:

  1. 将一个暂时性的 object 设初值为 1024;
  2. 将暂时性的 object 以拷贝建构的方式作为 explicit object 的初值。

xx0是被单一的constructor操作设定初值:

1
xx0.X::X(1024)

而xx1或xx2却调用两个constructor,产生一个暂时性object,并针对该暂时性object调用class X的destructor:
1
2
3
4
X __temp0;
__temp0.X::X(1024);
xx1.X::X(__temp0);
__temp0.X::~X();

Copy Constructor:要还是不要?

如果一个 class 没有任何 member(或 base)class object 带有 copy constructor,也没有任何 virtual base class 或 virtual function,那么这个 class 会以“bitwise” copy,这样效率高,且安全,不会有 memory leak,也不会产生 address aliasing。这时候程序员没理由,也不需要提供一个 copy constructor。但如果这个 class 需要 大量的 memberwise 初始化操作,例如上面的测试,以传值的方式传回 object,那么就可以提供一个 copy constructor 来让编译器进行 NRV 优化。

例如Point3d支持下边的函数:

1
2
3
Point3d operator+(const Point3d&, const Point3d&);
Point3d operator-(const Point3d&, const Point3d&);
Point3d operator*(const Point3d&, int);

所有那些函数都能够良好地符合NRV template:

1
2
3
Point3d result;
// 计算result
return result;

实现copy constructor的最简单方法像这样:

1
2
3
4
5
Point3d::Point3d(const Point3d& rhs) {
_x = rhs._x;
_y = rhs._y;
_z = rhs._z;
}

但是用memcpy()会更简单:

1
2
3
Point3d::Point3d(const Point3d& rhs) {
memcpy(this, &rhs, sizeof(Point3d));
}

有一点需要注意,在使用 memcpy 进行初始化的时候,要注意有没有 virtual function 或者 virtual base class:
1
2
3
4
5
6
7
8
9
class Shape {
public:
// oops: this will overwrite internal vptr!
Shape() {
memset(this, 0, sizeof(Shape));
}
virtual ~Shape();
// ...
};

上面这个 Shape 类有 virtual function,那么编译器会在 constructor 当中安插一些代码以正确设置 vptr:
1
2
3
4
5
6
7
8
// Expansion of constructor
// Pseudo C++ Code
Shape::Shape() {
// vptr must be set before user code executes
__vptr__Shape = __vtbl__Shape;
// oops: memset zeros out value of vptr
memset(this, 0, sizeof(Shape));
};

如代码所示,memset会将__vptr__Shape变成0,memcpy也类似,会将__vptr__Shape设为错误的值。

小结
copy constructor 会使编译器对代码做出优化,尤其是当函数以传值的方式传回一个 class object 时,编译器会将 copy constructor 的调用操作优化,通过在参数表中额外安插一个参数,用来取代 NRV。

成员们的初始化队伍(Member Initialization List)

初始化 class members,要么通过 member initialization list,要么就在 constructor 函数体内初始化。以下四种情况则必须使用 member initialization list:

  • 当初始化一个 reference member 时;
  • 当初始化一个 const member 时;
  • 当调用一个 base class 的 constructor,而它拥有一组参数时;
  • 当调用一个 member class 的 constructor,而它拥有一组参数时。

如下情况中,如果在函数体内初始化,会影响效率:

1
2
3
4
5
6
7
8
9
10
11
class Word {
String _name;
int _cnt;

public:
// not wrong, just naive ...
Word() {
_name = 0;
_cnt = 0;
}
};

这时候,编译器会做出如下扩张:
1
2
3
4
5
6
7
8
9
10
11
12
// Pseudo C++ Code
Word::Word(/* this pointer goes here */) {
// invoke default String constructor
_name.String::String();
// generate temporary
String temp = String(0);
// memberwise copy _name
_name.String::operator=(temp);
// destroy temporary
temp.String::~String();
_cnt = 0;
}

可以看到,Word constructor 会先产生一个暂时的 String object,然后将它初始化,最后用赋值运算符将其指定给_name,再摧毁那个暂时性object。

如果这样写则效率更佳:

1
2
3
4
// preferred implementation
Word::Word : _name(0) {
_cnt = 0;
}

它会被扩张为:
1
2
3
4
5
6
// Pseudo C++ Code
Word::Word(/* this pointer goes here */) {
// invoke String( int ) constructor
_name.String::String(0);
_cnt = 0;
}

陷阱最有可能发生在这种形式的template code中:

1
2
3
4
template <class type>
foo<type>::foo(type t) {
_t = t;
}

这种优化会导致一些程序员坚持所有的 member 初始化操作必须在 member initialization list 中完成,即使是行为良好的 member 如 _cnt。

1
Word::Word() : _cnt(0), _name(0) {}

事实上,编译器会一个个操作 initialization list,以声明的次序,将代码安插在 constructor 内,并且是安插在 explicit user code 之前。下面这个初始化操作就会出错:

1
2
3
4
5
6
7
8
class X {
int i;
int j;

public:
// oops! do you see the problem?
X(int val) : j(val), i(j){}...
};

程序员的本意是想把 j 用 val 先初始化,然后再用 j 把 i 初始化,而事实上,初始化的顺序是按照 member 的声明次序来的,所以会先用 j 初始化 i,而 i 目前是个随机值。建议把一个member的初始化操作和另一个放在一起,放在constructor中:
1
2
3
X::X(int val) :j(val) {
i = j;
}

另外,可以调用一个 member function 来设定一个 member 的初值。但这时候应该在 constructor 体内调用 member function 做初始化,而不是在 member initialization list 中,因为这时候,和此 object 相关的 this 指针已经准备好了,可以通过 this 指针调用 member function 了。

最后,用一个 derived class member function 的调用结果来初始化 base class constructor 会如何:

1
2
3
4
5
6
7
8
9
10
11
12
// is the invocation of FooBar::fval() ok?
class FooBar : public X { // FooBar 继承自 X
int _fval;

public:
int fval() {
return _fval;
}
// 用成员函数 fval 的调用结果作为 base class constructor 的参数
FooBar(int val) : _fval(val), X(fval()){}
...
};

编译器可能会将其扩张为:
1
2
3
4
5
6
// Pseudo C++ Code
FooBar::FooBar( /* this pointer goes here */ ) {
// Oops: definitely not a good idea
X::X( this, this->fval() );
_fval = val;
};

很显然,调用fval()回传的_fval还是个随机值。可能是由于 base class 必须在 initialization list 里面初始化,而之前那种情况可以在 constructor 函数体内初始化,这时候就可以将所需要的 member 先初始化好,再调用成员函数。

简略的说,编译器会对initialization list一一处理并可能重新排序,以反映出members的声明次序,它会安插一些代码到constructor体内,并置于任何explicit user code之前。

Data 语意学

有如下代码:

1
2
3
4
class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y, public Z {};

继承关系如下:

按理说每个 class 的大小都为 0,书中的结果为:

  • sizeof X 结果为 1
  • sizeof Y 结果为 8
  • sizeof Z 结果为 8
  • sizeof A 结果为 12
  • (在我的 gcc 7.4.0 上分别为:1,8,8,16,在侯捷巨佬的 vc 5.0 上为 1,4,4,8)

一个空的 class 如上述 X,事实并非为空,它有一个隐晦的1 byte,是被编译器安插进去的char,使得这个 class 的两个 objects 可以在内存中分配独一无二的地址

而 Y 和 Z 的大小和机器还有编译器有关,其受三个因素影响:

  1. 语言本身所造成的额外负担(overhead):当语言支持 virtual base class 时,在 derived class 中,会有一个指针,指向 virtual base class subobject 或者是一个相关表格,表格中存放的是 virtual base class subobject 的地址或者是其偏移量(offset)。书中所用机器上,指针是 4 bytes(我的机器上是 8 bytes)。
  2. 编译器对于特殊情况所提供的优化处理:Virtual base class X subobject 的 1 bytes 大小也出现在 class Y 和 Z 身上。传统上它被放在 derived class 的尾端。某些编译器会对 empty virtual base class 提供特殊支持(看来我用的 gcc 7.4.0 有提供支持)。
  3. Alignment 的限制:class Y 和 Z 的大小截至目前为 5 bytes。为了更有效率的在内存中存取,会有 alignment 机制。在书中所用机器上,alignment 是 4 bytes(我的机器上为 8 bytes),所以 class Y 和 Z 必须填补 3 bytes,最终结果为 8 bytes。下图表现了 X,Y,Z 对象布局:

有的编译器会将一个 empty virtual base class 视为最开头的部分,这样就不需要任何额外的空间了(比如我用的 gcc 7.4.0 上,Y 和 Z 的大小仅为一个指针的大小,无需额外空间),省下了上述第二点的 1 bytes,也就不再需要第三点所说的3bbytes的填补,只剩下第一点所说的额外负担,在此模型下Y和Z的大小都是4而不是8。侯捷所用的 vc++ 就是这样,其 X,Y,Z 的对象布局如下:

编译器之间的差异正说明了 C++ 对象模型的演化,这是一个例子,第二章的 NRV 优化也是一个例子。

Y 和 Z 的大小都是 8,而 A 的大小却是 12,分许一下即可,首先一个 virtual base class subobject 只会在 derived class 中存一份实体,所以:

  • 被共享的一个 class X 实体,大小 1 bytes。
  • Base class Y 的大小本来还有个 virtual base class,现在减去 virtual base class 的大小,就是 4 bytes,Z 也是一样,这样加起来就是 8 bytes。
  • class A 大小 0 byte。
  • A 的 alignment 大小(如果有)。上述三项总和:9 bytes。然后 class A 必须对齐 4 bytes,所以填补 3 bytes,最后是 12 bytes。

如果编译器对empty virtual base class有所处理,那么 class X 的 1 bytes 就没有了,于是额外 3 bytes 的对齐也不需要了,所以只需 8 bytes 即可(侯捷的就是这样,我的也是这样,只不过我的一个指针大小 8 bytes,所以需要 16 bytes)。

在这一章中,class的data members以及class hierarchy是中心议题。一个 class的data members,一般而言,可以表现这个class在程序执行时的某种状态。Non-static data members放置的是个别的class object感兴趣的数据,static data members则放置的是整个class感兴趣的数据。

C++对象模型尽量以空间优化和存取速度优化的考虑来表现nonstatic data members,并且保持和C语言struct数据配置的兼容性。它把数据直接存放在每一个class object之中。对于继承而来的nonstatic data members(不管是virtual或 nonvirtual base class)也是如此。不过并没有强制定义其间的排列顺序。static data members则被放置在程序的一个global data segment中,不会影响个别的class object的大小。在程序之中,不管该class被产生出多少个objects(经由直接产生或间接派生),static data members永远只存在一份实体(译注:甚至即使该class没有任何object实体,其static data members也已存在)。但是一个template class的static data mnembers的行为稍有不同,7.1节有详细的讨论。

综上,一个 class object 的大小可能会受以下两个因素的影响:

  • 由编译器自动加上的额外 data members,用来支持某些语言特性(如 virtual 特性)。
  • alignment 的需要。

Data Member 的绑定(The Binding of a Data Member)

有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// A third party foo.h header file
// pulled in from somewhere
extern float x;
// the programmer's Point3d.h file
class Point3d {
public:
Point3d(float, float, float);
// question: which x is returned and set?
float X() const {
return x;
}
void X(float new_x) const {
x = new_x;
}
// ...
private:
float x, y, z;
};

Point3d::X()很显然会传回 class 内部的 x,而非外部(extern)那个 x,但并不总是这样!以前的编译器是会返回 global x 的。所以导致了两种防御性程序设计风格:

  1. 把所有的 data members 放在 class 声明的起头处,以确保正确的绑定。
  2. 把所有的 inline functions,不管大小都放在 class 声明之外。

这种风格现在依然存在。但它们的必要性从 C++ 2.0 之后就没了。后来的这种语言规则被称为member rewriting rule,大意是一个 inline 函数实体,在整个 class 未被完全看见之前,是不会被评估求值(evaluated)的。C++ Stantard 以member scope resolution rules来精炼这个rewriting rule:如果一个 inline 函数在 class 声明之后立刻被定义,那么就还是对其评估求值(evaluate)。

对member functions本身的分析会直到整个class的声明都出现了之后才开始。因此在一个inline member function躯体之内的一个data member绑定操作,会在整个 class 声明完成之后才发生。

然而,对于 member function 的 argument list 并不是这样,Argument list 中的名词还是会在第一次遇到时就被决议(resolved)完成。所以对于 nested type(typedef)的声明,还是应该放在 class 的起始处。例如在下边的程序中,length的类型在两个member function signatures中都决议为global typedef,也就是int,当后续再有length的nested typedef声明出现时,C++就把稍早的绑定标识为非法。

1
2
3
4
5
6
7
8
9
10
typedef int length;
class Point3d {
public:
void mumble(length val) {_val = val;}
length mumble() {return _val;}
private:
// length必须在本class对它的第一个参考操作之前被看到,这样的声明将使之前的参考操作不合法。
typedef float length;
length _val;
}

请始终把nested type声明放在class的起始处。

Data Member 的布局(Data Member Layout)

下面一组data member:

1
2
3
4
5
6
7
8
9
10
class Point3d {
public:
// ...
private:
float x;
static List<Point3d*> *freeList;
float y;
static const int chunkSize = 250;
float z;
};

Nonstatic data member 在 class object 中的排列顺序和其被声明的顺序一样,任何中间插入的 static data member 都不会放进对象布局中。static data member 放在程序的 data segment 中。

同一个 access section 中,member 的排列只需符合较晚出现的 member 在 class object 中有较高的地址即可,而 member 并不一定要连续排列(alignment 可能就需要安插在当中)。

编译器可能合成一些内部使用的 data member,比如 vptr,vptr 传统上放在所有明确声明的 member 之后,不过也有一些编译器把 vptr 放在 class object 的最前端(放在中间都是可以的)。

各个 access section 中的 data member 也可自由排列,不必在乎顺序,但目前各家编译器都是把一个以上 access sections 按照声明的次序放在一起的。section 的数量不会有额外的负担。

可用以下函数模板来查看 data member 的相对位置,它接受两个data members,然后判断出谁先出现在class object之中,如果两个members都是不同access sections中的第一个被声明者,此函数即可以判断哪一个section先出现:

1
2
3
4
5
6
7
8
9
10
template <class class_type, class data_type1, class data_type2>
char* access_order(data_type1 class_type::*mem1,
data_type2 class_type::*mem2) {
assert(mem1 != mem2);
return mem1 < mem2 ?
"member 1 occurs first" : "member 2 occurs first";
}

// 这样调用:
access_order(&Point3d::z, &Point3d::y)

于是class_type会被绑定为Point3d,而data_type1和data_type2会被绑定为float。

Data Member 的存取

考虑如下问题:

1
2
3
4
Point3d origin, *pt = &origin;

origin.x = 0.0;
pt->x = 0.0;

通过 origin 存取和通过 pt 存取,有什么重大差异吗?

Static Data Members

Static data member 被编译器提出于 class 之外,并被视为 global 变量(但只在 class 的范围内可见),其存取效率不会受 class object 的影响,不会有任何空间或时间上的额外负担。

每个 static data member 只有一个实体,放在程序的 data segment 之中。每次对 static member 取用,都会做出如下转换:

1
2
3
4
// origin.chunkSize = 250;
Point3d::chunkSize = 250;
// pt->chunkSize = 250;
Point3d::chunkSize = 250;

通过 member selection operaor(也就是 . 运算符)只不过是语法上的方便而已,member 并不在 class object 中。对于从复杂继承关系中继承而来的 static data member,也是一样,程序之中对于static members仍然只有一个唯一的实体,其存取路径仍然是那么直接。

若取一个 static data member 的地址,会得到一个指向其数据类型的指针,而不是一个指向其 class member 的指针,应为 static member 并不在 class object 中:

1
&Point3d::chunkSize;

会得到类型如下的内存地址:
1
const int*

如果有两个 class,声明了一个相同名字的 static member。那么编译器会给每个 static data member 编码(所谓的 name-mangling),以获得独一无二的程序识别代码,以免放在 data segment 中时导致名称冲突。

Nonstatic Data Members

Nonstatic data member 直接放在每个 class object 中,除非有一个 class object,不然无法直接存取。再 member function 中直接取一个 nonstatic data member 时,会有如下转换:

1
2
3
4
5
Point3d Point3d::translate( const Point3d &pt ) {
x += pt.x;
y += pt.y;
z += pt.z;
}

对于 x,y,z 的存取,实际上是由implicit class object(this 指针)完成的:
1
2
3
4
5
6
// internal augmentation of member function
Point3d Point3d::translate( Point3d *const this, const Point3d &pt ) {
this->x += pt.x;
this->y += pt.y;
this->z += pt.z;
}

要想对 nonstatic data member 进行存取,编译器需要把 class object 的起始地址加上一个 data member 的偏移量(offset):
1
origin._y = 0.0;

那么地址&origin._y就等于:
1
&origin + (&Point3d::_y - 1);

注意 -1 操作。指向 data member 的指针,其 offset 值总是被加上 1,这样就可以使编译器区分出一个指向 data member 的指针,用以指出class 的第一个 member和一个指向 data member 的指针没有指出任何 member两种情况。

每一个 nonstatic data member 的偏移量(offset)在编译时期即可获得,即使 member 数以 base class,所以,存取 nonstatic data member 的效率和存取一个 C struct member 是一样的

若有虚拟继承,则存取虚拟继承的 base class 当中的 member 时,会有一层间接性:

1
2
Point3d *pt3d;
pt3d->_x = 0.0;

如果_x是一个 virtual base class 的 member,存取速度则会变慢。

现在考虑本小节开始的问题,从 origin 存取和从 pt 存取有什么差异?答案是:当 Point3d 是一个 derived class ,并且继承结构中有一个 virtual base class,并且被存取的member是一个从该virtual base class继承而来的member时,就会有差异。这时候我们不知道 pt 到底指向哪一种类型(是 base class 类型还是 derived class 类型?),所以也就不知道 member 真正的 offset 位置,所以必须延迟至执行期才行,且需要一层间接引导。但是用origin就不会有这种问题,其类型无疑是Point3d class,而即使它继承自virtual base class,members的offset位置也在编译期间固定了。

继承与Data Member

C++ 继承模型里,一个 derived class object 是其自己的 member 加上其 base class member 的总和,至于 derived class member 和 base class member 的排列次序则无所谓。但大部分都是 base class member 先出现,有 virtual base class 的除外。

有如下两个抽象数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// supporting abstract data types
class Point2d {
public:
// constructor(s)
// operations
// access functions
private:
float x, y;
};

class Point3d {
public:
// constructor(s)
// operations
// access functions
private:
float x, y, z;
};

下图就是 Point2d 和 Point3d 的对象布局,在没有 virtual function 的情况下,它们和 C struct 完全一样:

下面讨论 Point 的单一继承且不含 virtual function单一继承含 virtual function多重继承虚拟继承等四种情况。

只要继承不要多态(Inheritance without Polymorphism)

我们可以使用具体继承(concrete inheritance,相对于虚拟继承 virtual inheritance),就是从Point2d派生出一个Point3d,具体继承不会增加空间或存取时间上的额外负担,且可以共享数据本身数据的处理方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Point2d {
public:
Point2d(float x = 0.0, float y = 0.0) : _x(x), _y(y){};
float x() {
return _x;
}
float y() {
return _y;
}
void x(float newX) {
_x = newX;
}
void y(float newY) {
_y = newY;
}
void operator+=(const Point2d& rhs) {
_x += rhs.x();
_y += rhs.y();
}
// ... more members
protected:
float _x, _y;
};

// inheritance from concrete class
class Point3d : public Point2d {
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point2d(x, y), _z(z){};
float z() {
return _z;
}
void z(float newZ) {
_z = newZ;
}
void operator+=(const Point3d& rhs) {
Point2d::operator+=(rhs);
_z += rhs.z();
}
// ... more members
protected:
float _z;
};

Point2d 和 Point3d 的继承关系如下图所示:

这样设计的好处是可以把管理x和y的代码局部化,此外这个设计可以明显地表现出两个抽象类的紧密关系。当这两个class独立的时候,Point2d object和 Point3d object的声明和使用都不会改变,所以这两个抽象类的使用者不需要知道object是不是独立的classes类型,或是彼此之间有继承关系。下图显示了Point2d 和 Point3d 继承关系的实物布局:

对于这样的继承,经验不足的人可能会重复设计一些相同的操作,如这个例子种的 constructor 和 operator+=,它们没有被做成 inline 函数(我记得现在是定义在 class 中的函数默认是 inline 的)。

还有个容易犯的错误是把一个 class 分解为两次或更多层,这样可能会导致所需空间的膨胀。C++ 语言保证出现在 derived class 中的 base class subobject 有其完整原样性,结合以下代码理解。

1
2
3
4
5
6
7
8
9
class Concrete {
public:
// ...
private:
int val;
char c1;
char c2;
char c3;
};

其内存布局如下,32位机器中的concrete object共占用 8 bytes:

  1. val占用4bytes
  2. c1、c2、c3各占用1bytes
  3. alignment需要1bytes

现在,concrete 分裂成三层结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Concrete1 {
public:
// ...
protected:
int val;
char bit1;
};

class Concrete2 : public Concrete1 {
public:
// ...
protected:
char bit2;
};

class Concrete3 : public Concrete2 {
public:
// ...
protected:
char bit3;
};

现在,Concrete3 object 的大小是 16 bytes!下面是内存布局图:

这就是base class subobject 在 derived 中的原样性,你可能以为在 Concrete1 中,val 和 bit1 占用 5 bytes,然后 padding 了 3 bytes,所以对于 Concrete2,只增加了一个 bit2,应该把 bit2 放在原来填补空间的地方,于是 Concrete2 还是 8 bytes,其中 padding 了 2 bytes。然而Concrete2 object 的 bit2 是放在填补空间所用的 3 bytes 之后的,于是其大小变为了12 bytes,这样,总共有 6 bytes 浪费在了空间填补上面。同理可得,Concrete3 浪费了 9 bytes 用于空间填补。

为什么要这样,让我们声明以下一组指针:

1
2
Concrete2 *pc2;
Concrete1 *pc1_1, *pc1_2;

其中pc1_1pc1_2两者都可以指向前三种classes objects。如下赋值操作:
1
*pc1_2 = *pc1_1;

应该执行 memberwise 复制操作,对象是被指的object的concrete1那一部分。如果把pc1_1指向一个 Concrete2 object,则上述操作会将 Concrete2 的内容复制给 Concrete1 subobject。

如果C++把derived class members和concrete1 subobject捆绑在一起,去除填补空间,上述那些语意就无法保留了,那么下边的指定操作:

1
2
pc1_1 = pc2;
*pc1_2 = *pc1_1;

就会将被捆绑在一起、继承而得的members内容覆盖掉。

所以必须保持base class subobject 在 derived 中的原样性。

加上多态(Adding Polymorphism)

如果要处理一个坐标点,不论其是一个 Point2d 还是 Point3d 实例,那么,就需要提供 virtual function 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Point2d {
public:
Point2d(float x = 0.0, float y = 0.0) : _x(x), _y(y){};
// access functions for x & y same as above
// invariant across type: not made virtual

// add placeholders for z — do nothing ...
virtual float z(){return 0.0};
virtual void z(float) {}
// turn type explicit operations virtual
virtual void operator+=(const Point2d& rhs) {
_x += rhs.x();
_y += rhs.y();
}
// ... more members
protected:
float _x, _y;
};

可以用多态方式处理 2d 或 3d 坐标点,这样在设计中导入一个virtual接口才合理:
1
2
3
4
5
void foo( Point2d &p1, Point2d &p2 ) {
// ...
p1 += p2;
// ...
}

foo 接收的指针可能指向 2d 也可能指向 3d。这样的弹性带来了以下负担:

  1. 导入一个和 Point2d 有关的 virtual table,这个 table 的元素数目一般而言是 virtual function 的数目在加上 1 或 2 个 slots(用来支持 runtime time identification)。
  2. 在每个 class object 中导入 vptr,提供执行期的链接。
  3. 加强 constructor,使它能够为vptr设定初值,指向class所对应的virtual table。这可能意味着derived class和每一个base class的constructor中,重新设定vptr的值。
  4. 加强 destructor, 用来消除 vptr。vptr可能已经在derived class destructor中被设定为derived class的virtual table地址。

vptr 所放位置是编译器领域里的一个讨论题目,在 cfront 编译器中,它被放在 class object 的尾端,这样,当 base class 是 struct 时,就可以保留 base class C struct 的对象布局。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct no_virts {
int d1, d2;
};

class has_virts : public no_virts {
public:
virtual void foo();
// ...
private:
int d3;
};

no_virts *p = new has_virts;

也有一些编译器把 vptr 放在 class object 的起头处,这样在多重继承下会有点好处。这种布局如下图所示:

把vptr放在class object的前端,对于在多重继承之下,通过指向class members的指针调用virtual function,会带来一些帮助。否则,不仅从class object起始点开始量起的offset必须在执行期备妥,甚至与class vptr之间的offset也必须备妥。当然,vptr放在前端,代价就是丧失了C 语言兼容性.这种丧失有多少意义?有多少程序会从一个C struct派生出一个具多态性质的class呢?当前我手上并没有什么统计数据可以告诉我这一点。

下图显示Point2d和Point3d加上了virtual function之后的继承布局。注意此图是把vptr放在base class的尾端。

多重继承(Multiple Inheritance)

单一继承提供了一种自然多态(natural polymorphism)形式,是关于 class 体系中的 base type 和 derived type 之间的转换,它们的 base class 和 derived class 的 objects 都是从相同的地址开始,差异只在于,derived object 比较大,用来容纳它自己的 nonstatic data member。如以下操作:

1
2
Point3d p3d;
Point2d *p = &p3d;

把一个 derived class object 指定给 base class 的指针或 reference,并不需要编译器去修改地址(因为它们的起始地址是相同的,指针的值无需改变,只是解释指针的方式改变了),提供了最佳执行效率。

如果把 vptr 放在 class object 的起始处,这时候,如果 base class 没有 virtual function 而 derived class 有,那么这种自然多态就会被打破,因为将 derived object 转换为 base 类型,需要编译器介入,用来调整地址(把 vptr 排除掉)。

多重继承更为复杂,它没有了这种自然多态,而是derived class和其上一个base class乃至上上一个base class之间的非自然关系,考虑如下面这个多重继承所获得的class Vertex3d:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Point2d {
public:
// 拥有virtual接口,所以Point2d对象之中会有vptr
protected:
float _x, _y;
};

class Point3d : public Point2d {
public:
// ...
protected:
float _z;
};

class Vertex {
public:
// 拥有virtual接口,所以Vertex对象之中会有vptr
protected:
Vertex *next;
};

class Vertex3d : public Point3d, public Vertex {
public:
// ...
protected:
float mumble;
}

所示的继承体系:

如图所示,多重继承的问题主要发生于 derived class object 和其第二或后继的 base class object 之间的转换,对一个多重派生对象,将其地址指定给最左端(也就是第一个)base class 的指针,情况和单一继承一样,因为它们有相同的地址。而第二或后继的 base class 起始的地址,则与 derived class 不同(可以在上图中看出,Vertex 在 Point3d 后面)。所以如下操作:

1
2
3
4
Vertex3d v3d;
Vertex *pv;
Point3d *p2d;
Point3d *p3d;

如下指定操作:

1
pv = &v3d;

会被转换为:
1
2
// 伪码
pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));

而下面的指定操作:
1
2
p2d = &v3d;
p3d = &v3d;

只需见到拷贝其地址即可。

而如下操作:

1
2
3
4
Vertex3d *pv3d;
Vertex *pv;

pv = pv3d;

不可以简单做如下转换:
1
2
// 伪码
pv = (Vertex*)((char*)pv3d) + sizeof(Point3d);

因为 p3d 可能是空指针为0,pv将获得sizeof(Point3d)的值,这是错误的,所以,正确写法:
1
2
// 伪码
pv = pv3d ? (Vertex*)((char*)pv3d) + sizeof(Point3d) : 0;

如上多重继承,如果要存取第二个或后继 base class 的 data member,也不需要付出额外的成本,因为 member 的位置是编译时就固定的,只需一个 offset 运算即可。

虚拟继承(Virtual Inheritance)

多重继承的语意上的副作用是必须支持shared subobject继承,典型例子是iostream library:

1
2
3
4
class ios { ... };
class istream : public ios { ... };
class ostream : public ios { ... };
class iostream : public istream, public ostream { ... };

istream或ostream中都含有一个ios subobject,然而在iostream的对象中只需要一份单一的ios subobject,语言层面的办法是导入虚拟继承

1
2
3
4
class ios { ... };
class istream : public virtual ios { ... };
class ostream : public virtual ios { ... };
class iostream : public istream, public ostream { ... };

难度在于把istream或ostream各自维护的一个ios subobject折叠成一个由iostream维护的单一ios subobject,并且还可以保存base class和derived class的指针之间的多态指定操作

Class 如果含有一个或多个 virtual base class subobjects,将会被分割为两个部分:一个不变的局部和一个共享局部。不变局部不管后继如何演化,总是拥有固定的 offset,这一部分可以直接存取。而共享局部(就是 virtual base class subobject 的部分),这一部分会因为每次的派生操作而发生变化,所以会被间接存取。这时候各家编译器的实现就有差别了,下面是三种主流策略。

有如下继承体系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Point2d {
public:
...
protected:
float _x, _y;
};

class Vertex : public virtual Point2d {
public:
...
protected:
Vertex *next;
};

class Point3d : public virtual Point2d {
public:
...
protected:
float _z;
};

class Vertex3d : public Vertex, public Point2d {
public:
...
protected:
float mumble;
};

一般的布局策略是先安排好 derived class 的不变部分,再建立其共享部分。cfront 编译器会在每一个 derived class object 中安插一些指针,每个指针指向一个 virtual base class,要存取继承来的 virtual base class member,可以用相关指针间接完成。

这样的模型有两个主要缺点:

  1. 每一个对象必须针对其每一个 virtual base class 背负一个额外的指针,然而我们希望 class object 有固定的负担,不会因为 virtual base class 的数目而有所变化。
  2. 由于虚拟机串链的加成,导致间接存取层次的增加,比如,如果有三层虚拟继承,我就需要三次间接存取(经过三个 virtual base class 指针),然而我们希望有固定的存取时间,而不会因为继承深度改变而改变。

第二个问题可以通过将所用的 virtual base class 指针拷贝到 derived class object 中来解决,这就付出了空间上的代价。下图为该方式的布局:

至于第一个问题,有两个解决办法。Microsoft 编译器引入所谓的 virtual base class table。每一个 class object 如果有一个或多个 virtual base class,则编译器会安插一个指针,指向 virtual base class table,真正的 virtual base class 指针则放在这个 table 中。第二个解决方法是在 virtual function table 中放置 virtual base class 的 offset(不是地址哦),下图显示了这种布局:

经由一个非多态的 class object 来存取一个继承而来的 virtual base class 的 member:

1
2
3
Point3d origin;
...
origin._x;

可以被优化为一个直接存取操作,就好像一个经由对象调用的 virtual function 调用操作,可以在编译时期被决议(resolved)完成一样。这次存取以及下一次存取之间对象的类型不可改变。所以virtual base class subobjects的位置会变化的问题不再存在。

一般而言,virtual base class 最有效的运用形式是:一个抽象的 virtual base class,没有任何 data members。

指向 Data Members 的指针(Pointer to Data Members)

指向 data member 的指针可以用来调查 class member 的底层布局,比如 vptr 的位置。考虑下面的 Point3d 声明:

1
2
3
4
5
6
7
8
class Point3d {
public:
virtual ~Point3d();
// ...
protected:
static Point3d origin;
float x, y, z;
};

每个 Point3d class 有三个坐标值:x,y,z,以及一个 vptr,而 static data member origin 则被放在 class object 之外。唯一可能因编译器不同而不同的是 vptr 的位置。C++ Standard 对 vptr 的位置没有限制,但实际上不是在对象头部就是在对象尾部。

那么,取某个坐标成员的地址:

1
&Point3d::z;

实际上得到的是 z 坐标在 class object 中的偏移量(offset)。其最小值是 x 和 y 的大小总和,因为 C++ 要求同一个 access level 中的 member 的排列次序应该和其声明次序相同。

然而vptr的位置没有限制,实际上vptr不是放在对象的头部就是尾部,在一部32位的机器上,每一个float是4 bytes,所以应该期望刚才获得的值不是8就是12。如果 vptr 在对象的尾端,则三个坐标值的 offset 分别是 0,4,8。如果 vptr 在对象起头,则三个坐标值的 offset 分别是 4,8,12。然而若去取 data member 的地址,传回值总是多 1,也就是 1,6,9 或 5,9,13。这是为了区分一个没有指向任何 data member的指针和一个指向第一个 data member 的指针(gcc 7.4.0 将没有指向任何 data member的指针设为了 0xffffffffffffffff)。考虑如下例子:

1
2
3
4
5
6
7
float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
// oops: how to distinguish?
if (p1 == p2) {
cout << " p1 & p2 contain the same value — ";
cout << " they must address the same member!" << endl;
}

为了区分 p1 和 p2,每一个真正的 member offset 都被加 1。

现在,可以很容易知道下面两者的区别:

1
2
&Point3d::z;
&origin.z;

&origin.z的值减去 z 的偏移值再加 1(gcc 7.4.0 并不需要加 1 了),就是 origin 的起始地址。上面代码第 2 行返回值的类型是:float*,而第一行的返回值类型是:float Point3d::*

在多重继承的情况下,若要将第二个(或后继)base class 的指针和一个与 derived class object 绑定之 member 接合起来,那么会因为需要加入 offset 值而变得很复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Base1 { int val1; };
struct Base2 { int val2; };
struct Derived : Base1, Base2 { ... };

void func1(int Derived::*dmp, Derived* pd) {
// expects a derived pointer to member
// what if we pass it a base pointer?
pd->*dmp;
}

void func2(Derived* pd) {
// assigns bmp 1
int Base2::*bmp = &Base2::val2;
// oops: bmp == 1,
// but in Derived, val2 == 5
func1(bmp, pd)
}

bmp作为func1()的第一个参数时,它的值就必须调整(因为 Base2 和 Derived 之间还有个 Base1),否则func1()中的操作,将存取到Base1::val1,而不是我们想要的Base2::val2。所以编译器会做出如下转换:
1
func1(bmp + sizeof(Base1), pd);

注意,我们不能保证 bmp 不是 0,所以应该改进为如下:
1
func1(bmp ? bmp + sizeof(Base1) : 0, pd);

指向 Members 的指针的效率问题

由于被继承的data members是被放在class object中的,所以继承的引入不影响这些部分的效率。继承妨碍了优化的有效性,每一层虚拟继承都导入一个额外层次的间接性。每次存取Point::x(pB 是一个虚基类):

1
pB.*bx

会被转化为(这里的虚拟继承采用了前面说的第一种策略:直接安插一个指针指示 base class):
1
&pB->__vbcPoint + (bx - 1);

而不是最直接的(单一继承):
1
&pB + (bx - 1);

Function 语意学

假设 Point3d 有如下成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
Point3d Point3d::normalize() const {
register float mag = magnitude();
Point3d normal;
normal._x = _x/mag;
normal._y = _y/mag;
normal._z = _z/mag;
return normal;
}

float Point3d::magnitude() const {
return sqrt(_x * _x + _y * _y + _z * _z);
}

通过以下两种方式调用:
1
2
3
4
5
Point3d obj;
Point3d *ptr = &obj;

obj.normalize();
ptr->normalize();

会发生什么?答案是不知道!C++ 的三种类型的 member function:static、nonstatic 和 virtual,每种类型被调用的方式都不同。虽然不能确定normalize()magnitude()两个函数是否为virtual或nonvirtual,但可以确定不是static的。因为它直接存取static数据,且被声明为const。

Member 的各种调用方式

Nonstatic Member Functions(非静态成员函数)

C++ 的设计准则之一:nonstatic member function 至少和一般的 nonmember function 有相同的效率。也就是说对于下面两个函数:

1
2
float magnitude3d(const Point3d *_this) { ... }
float Point3d::magnitude() const { ... }

选择第 2 行的 member function 不应带来额外负担。实际上,编译器已经将第 2 行的 member 函数实体转换成了第 1 行 nonmember 函数实体了。下面是转换步骤:

  1. 改写函数的 signature(函数原型)以安插一个额外的参数到 member function 中,用以提供一个存取管道,使class object得以调用函数。该额外参数就是 this 指针。

    1
    2
    // non-const nonstatic member augmentation
    Point3d Point3d::magnitude( Point3d *const this )

    如果 member function 是 const 的,则变为:

    1
    2
    // const nonstatic member augmentation
    Point3d Point3d::magnitude( const Point3d *const this )
  2. 将每一个对 nonstatic data member 的存取操作改为经由 this 指针来存取:

    1
    2
    3
    {
    return sqrt(this->_x * this->_x + this->_y * this->_y + this->_z * this->_z );
    }
  3. 将 member function 重新写成一个外部函数,并对函数名进行mangling处理,使其名称独一无二:

    1
    2
    extern magnitude__7Point3dFv(
    register Point3d *const this );

于是现在调用obj.magnitude()将变为magnitude__7Point3dFv(&obj)。而ptr->magnitude()则变为了magnitude__7Point3dFv(ptr)

名称的特殊处理(Name Mangling)

一般而言,member 的名称前面会加上 class 的名称,这样在继承体系中基类和父类拥有相同变量名的情况下也可以区分两者了。

1
class Bar { public: int ival; ...}

其中的ival有可能变成:
1
2
// member经过name-mangling之后的可能结果之一
ival__3Bar

不管要处理哪一个ival,通过name-mangling,都可以清楚地指出来,由于member functions可以被重载,所以需要更广泛的mangling手法。如果把:

1
2
3
4
5
class Point {
public:
void x(float newX);
float x();
}

1
2
3
4
5
class Point {
public:
void x__5Point(float newX);
float x__5Point();
}

在 member function 的名字后面加上参数链表,再把参数类型也编码进去,这样就可以区分重载的函数了。如果声明extern "C",就会压抑nonmember functions的mangling效果。

把参数和函数名称编码在一起,编译器于是在不同的编译模块之间达成了一种有限形式的类型检验。

Virtual Member Functions(虚拟成员函数)

如果normalize()是一个 virtual member function,那么以下的调用:

1
ptr->normalize();

会被转化为:
1
(*ptr->vptr[1])(ptr);

其中:

  • vptr表示由编译器产生的指针,指向virtual table。它被安插在每一个声明有(或继承自)一个或多个 virtual function的 class object 中。事实上其名字也会被mangled,因为在复杂的 class 派生体系中,可能存在多个 vptr。
  • 1是 virtual table slot 的索引值,关联到normalize()函数。
  • 第二个 ptr 表示 this 指针。

同样,如果magnitude()也是一个virtual function,那么其在normalize()中的调用将被转换为:

1
register float mag = (*this->vptr[2])(this);

但此时,由于Point3d::magnitude()是在Point3d::normalize()中被调用的,而后者已经由虚拟机制确定了实体,所以这里明确调用 Point3d 实体会更有效率:
1
register float msg = Point3d::magnitude();

如果magnitude()声明为inline会更有效率。使用 class scope operator 明确的调用一个 virtual function,就和调用 nonstatic member function 的效果是一样的。通过一个 class object 调用一个 virtual function,也和调用 nonstatic member function 的效果一样:
1
2
// Point3d obj;
obj.normalize();

可以转换为:
1
(*obj.vptr[1])(&obj);

但没必要,因为 obj 已经确定了,不需要 virtual 机制了

Static Member Function(静态成员函数)

如果normalize()是一个 static member function,以下两个调用:

1
2
obj.normalize();
ptr->nomalize();

将被转化为一般的 nonmember 函数调用:
1
2
3
4
// obj.normalize();
normalize__7Point3dSFv();
// ptr->nomalize();
normalize__7Point3dSFv();

在引入 static member function 之前,C++ 语言要求所有的 member function 必须由该 class 的 object 来调用,所以就有了下面奇特的写法:
1
((Point3d*)0)->object_count();

其中object_count()只是简单的回传_object_count这个 static data member。

实际上,只有当一个或多个nonstatic data members在member function中被直接存取时,才需要class object。 Class obect提供了this指针给这种形式的函数调用使用。这个this指针把在 member function中存取的nonstatic class members绑定于object内对应的 members之上。如果没有任何一个members被直接存取,事实上就不需要this指针,因此也就没有必要通过一个class object来调用一个member function。不过C++语言到当前为止并不能够识别这种情况。

这么一来就在存取static data members时产生了一些不规则性。如果class 的设计者把static data member声明为nonpublic(这一直被视为是一种好的习惯),那么他就必须提供一个或多个;member functions来存取该member。因此虽然你可以不靠class object来存取一个static member,但其存取函数却得绑定于一个class object之上。

独立于class object之外的存取操作,在某个时候特别重要,当class设计者希望支持没有class object存在的情况,程序方法上的解决之道:

1
object_count((Point3d*)0);

通过将 0 强转为 class 的指针,从而为 member function 提供一个 this 指针,这样在函数内部就可以通过这个指针来取类中的 nonstatic member 了,然而这个函数并不需要这个 this 指针。所以 static member function 应运而生。

Static member function 主要特性就是它没有 this 指针,其次,它还有以下几个次要特性(都是源于主要特性):

  • 它不能直接存取 class 中的 nonstatic member。
  • 它不能被声明为 const、volatile 或 virtual。
  • 它不需要经由 class object 才被调用。

通过member selection语法来使用 static member function 仍会被转化为直接调用操作。

1
if (Point3d::object_count() > 1) ...

但是如果是通过某个表达式而获得的 class object:
1
if (foo().object_count() > 1) ...

那么,这个表达式仍然会被求出来,上述代码将转化为:
1
2
(void) foo();
if (Point3d::object_count() > 1) ...

一个 static member function 当然也会被提出于 class 声明之外,并经给一个经过mangled的名称:
1
2
3
4
// SFv 表示其为 static member function,拥有 void(空白)的参数列表
unsigned int object_count__5Point3dSFv() {
return _object_count_5Point3d; // 由 _object_count 转换而来
}

如果取一个 static member function 的地址,获得的将是其在内存中的位置,也就是其地址,而不是偏移量 offset,并且其指针类型为 nonmember 函数指针,而不是指向 class member function 的指针:

1
&Point3d::object_count();

会得到一个类型为:unsigned int (*) ();类型的指针,而不是unsigned int (Point3d::*) ();类型。

Static member function 由于缺乏this指针,因此差不多等同于nonmember function。它提供了一个意想不到的好处,成为一个callback函数。

Virtual Member Function(虚拟成员函数)

我们已经看过了virtual function的一般实现模型:每一个class有一个virtual table,内含该class之中有作用的virtual function的地址,然后每个object有一个vptr,指向virtual table的所在。在这一节中,我要走访一组可能的设计然后根据单一继承、多重继承和虚拟继承等各种情况,从细部上探究这个模型。

为了支持virtual function机制,必须首先能够对于多态对象有某种形式的执行期类型判断法(runtime type resolution)。也就是说,以下的调用操作将需要ptr在执行期的某些相关信息:

1
ptr->z();

如此一来才能够找到并调用z()的适当实体。

或许最直接了当但是成本最高的解决方法就是把必要的信息加在ptr身上。在这样的策略之下,一个指针(或是一个reference)含有两项信息:

  • 它所参考到的对象的地址(也就是当前它所含有的东西);
  • 对象类型的某种编码,或是某个结构(内含某些信息,用以正确决议出z()函数实例)的地址。

这个方法带来两个问题,第一,它明显增加了空间负担,即使程序并不使用多态(polymorphism);第二,它打断了与C程序间的链接兼容性。如果这份额外信息不能够和指针放在一起,下一个可以考虑的地方就是把它放在对象本身,但是哪一个对象真正需要这些信息呢?我们应该把这些信息放在必须支持某种形式的执行期多态的时候。

C++ 中,多态(polymorphism)表示以一个 public base class 的指针(或 reference),寻址出一个 derived class object的意思。多态机制主要扮演一个输送机制经由它我们可以在程序的任何地方采用一组public derived类型,这种多态形式被称为是消极的。可以在编译时期完成。

当被指出的对象真正被使用时,多态也就变成积极的(active)了。下面对于virtual function的调用,就是一例:

1
2
//“积极多态(active poiymorphism)”的常见例子
ptr->z();

在runtime type identification (RTTT)性质被引入C++语言之前,c++对“积极多态(active polymorphism)”的唯一支持,就是对于virtual function call的决议(resolution)操作。有了RTTI,就能够在执行期查询一个多态的pointer 或多态的reference了。
1
2
3
//“积极多态(active polymorphism)”的第二个例子
if(Point3d *p3d = dynamic_cast< Point3d*>(ptr))
return p3d->_z;

识别一个 class 是否支持多态,唯一适当的方法就是看看它是否有任何 virtual function,只要 class 拥有一个 virtual function,它就需要这份额外的执行期信息。那么,什么样的额外信息是我们需要存起来的?也就是说,这样的调用:ptr->z();。其中z()是一个 virtual function,那么我们需要以下信息才可以在执行期调用正确的z()实体:

  • ptr所指对象的真实类型,这可使我们选择正确的z()实体;
  • z()实体的位置,以便我们能够调用它。

在现实中,可以在每一个多态的 class object 身上增加两个 member:

  1. 一个字符串或数字,表示 class 的类型;
  2. 一个指针,指向某表格,表格中带有程序的 virtual function 的执行期地址。

如何构建这个表格?virtual function 的地址可以在编译时期获知,并且这些地址是固定不变的,执行期不会新增或替换,表格的大小和内容的不会改变,所以这个表格在编译期被构建出来,无需执行期的介入。如何找到函数地址?两个步骤:

  1. 为了找到表格,每个 class object 被安插一个由编译器内部产生的指针,指向表格。
  2. 为了找到函数地址,每个 virtual function 被指派一个表格索引值。

这些工作依然由编译器完成,执行期要做的仅仅是去表格中取用 virtual function。

一个 class 只有一个 virtual table,每个 table 内含对应的 class object 中的active virtual function 的地址,包括:

  • 这个 class 所定义的函数实体,会改写一个可能存在的base class virtual function函数实体。
  • 继承自 base class 的函数实体。这是在derived class决定不改写virtual function时才会出现的情况。
  • 一个pure_virual_called()函数实体。

在单一继承的情况下,virtual table 的布局如下图所示:

当一个 class 派生自 Point 时,会发生什么?例如上图中的 class Point2d。三种可能:

  1. 继承 base class 所声明的 virtual function 的函数实体,将该函数实体的地址拷贝到 derived class 的 virtual table 相对应的 slot 之中。
  2. 使用自己的函数实体,这表示它自己的函数实体地址必须放在对应的 slot 之中。
  3. 加入一个新的 virtual function,这时候 virtual table 的尺寸会增大一个 slot,新的函数实体的地址会放进该 slot 之中。

现在,如果有这样的调用ptr->z();,那么,如何有足够的信息来调用在编译时期设定的 virtual function 呢:

  • 一般而言,并不知道 ptr 所指对象的真正类型,然而,可以知道的是经由 ptr 可以存取到该对象的 virtual table。
  • 虽然不知道哪一个z()函数实体会被调用,但可以知道的是每一个z()函数地址都放在 slot 4 中。所以该调用会被转化为:(*ptr->vptr[4])(ptr);

在这个转换中vptr表示编译器的指针,指向virtual table;4表示z()被赋值的slot编号(关联到Point体系的virtual table)。唯一一个在执行期才能知道的东西是:slot 4所指的到底是哪一个z()函数实体?

在一个单一继承体系中,virtual function机制的行为十分良好,不但有效率而且很容易塑造出模型来。但是在多重继承和虚拟继承之中,对virtual functions的支持就没有那么美好了。

多重继承下的 Virtual Functions

在多重继承中支持 virtual function,难点在于第二个及后继的 base class 身上,以及必须在执行期调整 this 指针这一点。以如下 class 体系为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// hierarchy to illustrate MI complications
// of virtual function support
class Base1 {
public:
Base1();
virtual ~Base1();
virtual void speakClearly();
virtual Base1 *clone() const;

protected:
float data_Base1;
};

class Base2 {
public:
Base2();
virtual ~Base2();
virtual void mumble();
virtual Base2 *clone() const;

protected:
float data_Base2;
};

class Derived : public Base1, public Base2 {
public:
Derived();
virtual ~Derived();
virtual Derived *clone() const;

protected:
float data_Derived;
};

Derived 支持 virtual function的难度,统统落在了 Base2 subobject 身上,需解决三个问题,对本例而言是:

  • virtual destructor。
  • 被继承下来的 Base2::mumble() 。
  • 一组 clone() 函数实体。

首先,把一个从 heap 中配置得到的 Derived 对象的地址,指定给一个 Base2 指针:

1
Base2 *pbase2 = new Derived;

新的 Derived 对象的地址必须调整,以指向 Base2 subobject:
1
2
Derived *temp = new Derived;
Base2 *pbase2 = temp ? tmp + sizeof(Base1) : 0;

从而像这样非多态的调用:
1
pbase2->data_Base2;

也可以正确执行。

当程序员要删除 pbase2 所指对象时:

1
2
3
4
// 首先调用正确的 virtual destructor 函数实体
// 然后施行 delete 运算符
// pbase2 可能需要调整,以指出完整对象的起始点
delete pbase2;

指针必须再调整一次,以指向 Derived 对象的起始处。然而,这些调整操作的 offset 并不能在编译时设定,因为 pbase2 所指的真正对象只有在执行期才能确定。

一般规则是,经由指向第二或后继的 base class的指针(或 reference)来调用 derived class virtual function。

该调用操作所连带的必要的 this 指针调整操作,必须在执行期完成,一开始实施于 cfront 编译器中的方法是将 virtual table 加大,使它容纳此处所需的 this 指针,调整相关事务,每一个 virtual table slot,都被加上了一个可能的 offset,于是以下 virtual function 的调用操作:

1
(*pbase2->vptr[1])(pbase2);

被改为:
1
(*pbase2->vptr[1].faddr)(pbase2 + pbase2->vptr[1].offset);

其中,faddr为 virtual function 的地址,offset为 this 指针的调整值。这个做法的缺点就是对每个 virtual function 的调用操作都有影响,即使不需要 offset 的情况也是如此。比较有效率的方法是利用所谓的 thunk。所谓thunk是一小段assembly码,用来:

  • 以适当的offset值调整this指针
  • 跳到virtual function里

例如,经由一个Base2指针调用Derived destructor,其相关的thunk可能看起来是这个样子:

1
2
3
4
//虚拟c++码
pbase2_dtor_thunk:
this+=sizeof(base1);
Derived::~Derived(this)

Thunk技术允许virtual table slot继续内含一个简单的指针,因此多重继承不需要任何空间上的额外负担。Slots中的地址可以直接指向virtual function,也可以指向一个相关的thunk(如果需要调整this指针的话)。于是,对于那些不需要调整this指针的virtual function而言,也就不需承载效率上的额外负担。

调整 this 指针还存在第二个负担,考虑如下调用:

1
2
3
4
5
Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;

delete pbase1;
delete pbase2;

虽然两个 delete 会调用相同的 Derived destructor,但他们用的是不同的 virtual table slot:

  • pbase1不需调整this指针,其指向的就是 Derived 对象的起始处,所以其 virtual table slot 放置的是真正的 destructor 地址。
  • pbase2需要调整this指针,其 virtual table slot 需要相关的 thunk 地址。

所以,在多重继承下,一个 derived class 内含 n - 1 个额外的 virtual table,n 表示上一层 base class 的数目(如果是单一继承,只需要一个 virtual table 即可)。对本例而言,有两个 virtual table:

  1. 一个主要实体,与 Base1(最左端 base class)共享。
  2. 一个次要实体,与 Base2(第二个 base class)有关。

对于每个 virtual table,都有一个对应的 vptr,vptr 会在 constructor 中被设定初值。例如本例中,可能会有这样两个虚函数表:

1
2
vtbl__Derived;					// 主要实体
vtbl__Base2__Derived; // 次要实体

当你将一个Derived对象地址指定给一个Base1指针或Derived指针时,被处理的virtual table是主要表格vtbl_Derived。当你将一个Derived对象地址指定给一个Base2指针时,被处理的virtual table是次要表格vtbl_Base2_Derived。其布局如下图所示:

第二或后继的base class会影响对virtual functions的支持。第一种情况是通过一个指向第二个base class的指针,嗲用derived class virtual function。例如:

1
2
3
4
Base2 *ptr = new Derived;
// 调用Derived::~Derived
// ptr必须被向后调整sizeof(Base1)个bytes
delete ptr;

这个操作的重点是:ptr指向Derived对象中的Base2 subobject,为了能够正确执行,ptr必须调整指向Derived对象的起始处。

现在看第二个需要解决的问题,通过一个指向 derived class的指针,调用第二个 base class 中一个继承而来的 virtual function。这种情况下,derived class 指针必须再次调整,以指向第二个 base subobject:

1
2
3
Derived *pder = new Derived;
// pder 必须向前调整 sizeof(Base1) 个 bytes
pder->mumble();

第三个问题发生于一个语言扩充性质之下:允许一个 virtual function 的返回值类型有所变化,比如本例中的clone()函数,clone函数的Derived版本传回一个Derived class指针,默默改写了两个base class函数实体,当我们通过指向第二个base class的指针来调用clone()时,this指针的offset问题诞生。

1
2
3
4
Base2 *pb1 = new Derived;
// 调用 Derived* Derived::clone()
// 返回值必须调整,以指向 Base2 subobject
Base2 *pb2 = pb1->clone();

第 1 行调用时,pb1会被调整以指向 Derived 对象的起始地址,从而clone()的 Derived 版会被调用,它会传回一个指向 Derived 对象的指针,在这个指针值被指定给 pb2 之前,必须先经过调整,以指向 Base2 subobject。

当函数被认为足够小的时候,Sun 编译器会提供split function技术:用相同的算法产生两个函数,其中第二个在返回之前,为指针加上必要的 offset。这样,不论是通过 Base1 指针或 Derived 指针调用函数,都不需要调整返回值,而通过 Base2 指针调用的实际上是另一个函数。

虚拟继承下的 Virtual Functions

虚拟继承下 virtual table 的布局如下图所示:

当一个 virtual base class 从另一个 virtual base class 派生而来,并且两者都支持 virtual function 和 nonstatic data member 时,情况过于复杂,不在此讨论,书中的建议是:不要在一个 virtual base class 中声明 nonstatic data member

函数的效能

下面用 nonmember friend function、member function、virtual member function 的形式测试以下函数:

1
2
3
4
5
6
void cross_product( const pt3d &pA, const pt3d &pB ) {
Point3d pC;
pC.x = pA.y * pB.z - pA.z * pB.y;
pC.y = pA.z * pB.x - pA.x * pB.z;
pC.z = pA.x * pB.y - pA.y * pB.x;
}

其中,virtual member function 又分为单一、虚拟、多重继承三种情况,main()函数:
1
2
3
4
5
6
7
main() {
Point3d pA( 1.725, 0.875, 0.478 );
Point3d pB( 0.315, 0.317, 0.838 );
for (int iters = 0; iters < 10000000; iters++)
cross_product( pA, pB ); // 不同类型函数调用方式当然不一样
return 0;
}

下图为测试结果:

可以看到,inline 的表现惊人,这是因为编译器将被视为不变的表达式提到循环之外,因此只计算了一次。

CC 和 NCC 都使用了 delta-offset(偏移差值)模型来支持 virtual function,在该模型中,需要一个 offset 来调整 this 指针,如下调用形式:

1
ptr->virt_func();

都会被转化为:
1
2
(*ptr->__vptr[index].addr)(ptr + ptr->__vptr[index].delta);
// 将this指针的调整值传过去

即使大部分调用操作中,offset 都是 0。这种实现技术下,不论是单一继承或多重继承,虚拟调用都会消耗相同的成本,但上面表中的结果却显示多重继承会有额外负担。这是因为cross_product()中出现的局部性 Point3d class object pC。于是 default Point3d constructor 就被调用了一千万次。而增加继承深度,就多增加执行成本。这也能解释为什么多重继承有额外的负担了。

导入 virtual function 之后,class constructor 将获得参数以设定 virtual table 指针。所以每多一层继承,就会多增加一个额外的 vptr 设定。

在导入new和delete运算符之前,承担class内存管理的唯一方法就是在constructor中指定this指针。刚才的if判断也支持该做法.对于cfront,“this的指定操作”的语意回溯兼容性一直到4.0版才获得保证。现代的编译器把new运算符的调用操作分离开来,就像把一个运算从constructor的调用中分离出来一样。“this 指定操作”的语意不再由语言来支持。

在这些编译器中,每一个额外的base class或额外的单一继承层次,其constructor内会被加入另一个对this指针的测试。若执行这些Constructor一千万次,效率就会因此下降至可以测试的程度。这种效率表现明显反应出一个编译器的反常,而不是对象模型的不正常。

在任何情况下,我想看看是否construction调用操作的额外损失会被视为额外花费的效率时间。我以两种不同的风格重写这个函数,都不使用局部对象:

  1. 在函数参数中加上一个对象,用来存放加法的结果。
  2. 直接在 this 对象中计算结果。

两种情况,其未优化的执行平均时间未 6.90 秒(和单一继承的 virtual function 效率相同)。

指向 Member Function 的指针(Pointer-to-Member Functions)

取一个 nonstatic data member 的地址,得到的是该 member 在 class 布局中的 bytes 位置(再加 1)。需要被绑定于某个 class object 的地址上,才能够被存取。

取一个 nonstatic member function 的地址,且该函数不是虚函数,则得到的结果是它在内存中真正的地址(我在 gcc 7.4.0 上得到所有的成员函数的地址都是一个相同的值,包括不同 class 的成员函数,不知道为什么)。但这个地址也需要被绑定到 class objet 才能使用,因为所有的 nonstatic member functions 都需要对象的地址才能使用 。

一个指向 member function 的指针,其声明语法如下:

1
2
3
4
double      // return type
(Point::* // class the function is member
pmf) // name of pointer to member
(); // argument list

然后这样使用:
1
double (Point::*coord)() = &Point::x;

也可以这样指定:
1
coord = &Point::y;

可以这样调用(origin是对象):
1
(origin.*coord)();

也可以这样调用(origin是指针):
1
(origin->*coord)();

调用操作会被转化为:
1
(coord)(&origin));

或:
1
(coord)(ptr);

使用“member function 指针”,如果不用于 virtual function、多重继承、virtual base class 等情况的话,并不会比使用一个“nonmember function 指针”的成本更高。

支持“指向 Virtual Members Functions”之指针

有如下程序片段:

1
2
float (Point::*pmf)() = &Point::z;
Point *ptr = new Point3d;

pmf一个指向member function的指针,被设置为Point::z()的地址,ptr则被指定以一个Point3d对象。如果直接经由ptr调用z()

1
ptr->z();

则被调用的是ptr->z(),但如果我们通过(ptr->*pmf)();调用,
1
(ptr->*pmf)();

虚拟机制依然可以起作用。

对一个 non-virtual member function 取地址,获得的是该函数在内存中的地址;对一个 virtual member function 取地址,所获得的是一个索引值,例如:

1
2
3
4
5
6
7
8
class Point {
public:
virtual ~Point();
float x();
float y();
virtual float z();
// ...
};

取 destructor 的地址&Point::~Point;得到的结果是1(我在 gcc 7.4.0 上不允许取析构函数的地址)。取xy的地址,
1
2
&Point::x();
&Point::y();

得到的则是函数在内存中的地址,因为它们不是virtual。

z()的地址&Point::z(),得到的结果是2。通过pmf调用z(),会被内部转化为:

1
(*ptr->vptr[(int)pmf])(ptr);

pmf 的内部定义是:float (Point::*pmf)();,这个指针必须同时能够寻址出nonvirtual x()virtual z()两个 member function,而这两个函数有着相同的原型:
1
2
3
// 二者都可以被指定给 pmf
float Point::x() { return _x; }
float Point::z() { return 0; }

但其中一个是内存地址,另一个是 virtual table 中的索引值。因此,编译器必须定义 pmf 使它能够:

  • 含有两种数值
  • 其数值可以被区别代表内存地址还是索引值

在 cfront 2.0 非正式版中,这两个值被内含在一个普通的指针内,并使用以下技巧识别该值是内存地址还是 virtual table 索引:

1
2
(((int)pmf)) & ~127) ? 
(*pmf)(ptr) : (*ptr->vptr[(int)pmf](ptr));

这种实现技巧必须假设继承体系中最多只有 128 个 virtual functions(不太理解这个技巧)。然而,多重继承下,就需要更一般化的实现方式,并趁机除去对 virtual function 的数目限制。

在多重继承之下,指向 Member Functions 的指针

为了支持多重继承,设计了下面一个结构体:

1
2
3
4
5
6
7
8
struct __mptr {
int delta;
int index;
union {
ptrtofunc faddr;
int v_offset;
};
};

其中,index 和 faddr 分别(不同时)带有 virtual table 索引和 nonvirtual member function 地址。为了方便,当 index 不指向 virtual table 时,会被设为 -1。于是,这样的调用操作:(ptr->*pmf)();,会被转变为:
1
2
(pmf.index < 0) ?
(*pmf.addr)(ptr) : (*ptr->vptr[pmf.index](ptr));

这种方法会让每个调用操作都得付出上述成本。Microsoft 把这项检查拿掉,导入一个 vcall thunk,在此策略下,faddr 要不就是真正的 member function 地址(如果函数是 nonvirtual),要不就是 vcall thunk 的地址(如果函数是 virtual)。于是virtual或nonvirtual函数的调用操作透明化,vcall thunk会选出并调用相关virtual table中的适当slot。

这个结构体的另一个副作用就是,传递一个指向 member function 的指针给函数时,会产生一个临时的__mptr对象。

1
2
3
4
5
extern Point3d foo(const Point3d&, Point3d (Point3d::*)());
void bar(const Point3d& p) {
Point3d pt = foo(p, &Point3d::normal);
// ...
}

其中&Point3d::normal的值类似这样{0, -1, 10727417}将需要产生一个临时性对象,有明确的初值:
1
2
__mptr temp = {0, -1, 10727417}
foo(p, temp);

继续看__mptr这个结构体,delta字段表示 this 指针的 offset 值,而 v_offset 字段放的是一个 virtual base class 或多重继承中的第二或后继的 base class 的 vptr 位置。如果 vptr 放在 class 对象的起始处,那么这个字段就不需要了,代价则是 C 对象的兼容性降低。这些字段(delta 和 v_offset)只有在多重继承或虚拟继承的情况下才需要。

许多编译器对不同的 class 的特性提供多种指向 member function 的指针形式,如 Microsoft 提供了:

  • 一个单一继承实例(其中带有 vcall thunk 地址或函数地址);
  • 一个多重继承实例(其中带有 faddr 和 delta 两个member);
  • 一个虚拟继承实例(其中带有四个 member)。

“指向 Member Function 之指针”的效率

下面的测试中,cross_product()函数经由以下方式调用:

  • 一个指向 nonmember function 的指针;
  • 一个指向 class member function 的指针;
  • 一个指向 virtual member function 的指针;
  • 多重继承下的 nonvirtual 及 virtual member function call;
  • 虚拟继承下的 nonvirtual 及 virtual member function call;

结果如下:

Inline Functions

关键词 inline(或 class declaration 中的 member function 或 friend function 的定义)只是一项请求,如果这项请求被接受,编译器就必须认为它可以用一个表达式(expression)合理地将这个函数扩展开。

cfront 有一套复杂的测试法,通常是用来计算 assignment、function calls、virtual function calls 等操作的次数,每个表达式种类都有一个权值,而 inline 函数的复杂度就是以这些操作的总和来决定。当在某个层次上,编译器觉得扩展一个 inline 函数比一般的函数调用及返回机制所带来的负荷低时,inline 才会生效。

一般而言,处理 inline 函数有两个阶段:

  1. 分析函数定义,以决定函数的“intrinsic inline ability”。如果函数因为复杂度或建构原因,被判定为不可成为 inline,就会被转为一个 static 函数,并在“被编译模块”内产生对应的函数定义。在一个支持“模块个别编译”的环境中,编译器儿乎没有什么权宜之计。理想情况下,链接器会将被产生出来的重复东西清理掉,然而一般来说,目前市面上的链接器并不会将“随该调用而被产生出来的重复调试信息”清理掉。UNIX环境中的strip命令可以达到这个目的。
  2. 真正的inline函数扩展操作是在调用的那一点上。这会带来参数的求值操作(evaluation)以及临时性对象的管理。同样是在扩展点上,编译器将决定这个调用是否“不可为inline”。在cfront中,inline函数如果只有一个表达式(expression),则其第二或后继的调用操作:
    1
    new_pt.x(lhs.x()+rhs.x());
    就不会被扩展开来。这是因为在。front中它被变成:
    1
    2
    // 虚拟C++码,建议的inline扩展形式
    new_pt.x = lhs._x + x__5PointFV(&rhs);
    这就完全没有带来效率的改善!对此,我们唯一能够做的就是重写其内容:
    1
    new_pt.x(lhs._x + rhs._x);

形式参数(Formal Arguments)

在 inline 扩展期间,每一个形参都会被对应的实参取代。所以不可以单独地一一封塞每一个形式参数,因为这将导致对于实际参数的多次求值操作。一般而言对会带来副作用的实际参数都需要引入临时性对象,如果实际参数是一个常量表达式,我们可以在替换之前先完成求值操作。后续的inline替换就可以把常量直接加上。假设有以下的简单 inline 函数:

1
2
3
inline int min(int i, int j) {
return i < j ? i : j;
}

下面是三个调用操作:
1
2
3
4
5
6
7
8
9
10
11
inline int bar () {
int minval;
int val1 = 1024;
int val2 = 2048;

/*(1)*/minval = min(val1, val2);
/*(2)*/minval = min(1024, 2048);
/*(3)*/minval = min(foo(), bar()+1);

return minval;
}

其中的 inline 调用会被扩展为:
1
2
3
4
5
6
7
8
9
10
//(1) 参数直接代换
minval = val1 < val2 ? val1 : val2;
//(2) 代换之后,直接使用常量
minval = 1024;
//(3) 有副作用,所以导入临时对象
int t1;
int t2;
minval =
(t1 = foo()), (t2 = bar() + 1),
t1 < t2 ? t1 : t2;

可以看到,如果实参是一个常量表达式,则在替换之前先完成求值操作,然后直接把常量绑定上去;如果实参有副作用(在函数调用里面可能会有其他操作?),就会引入临时对象;其它情况则直接替换。

局部变量(Local Variables)

如果在 inline 定义中加入一个局部变量:

1
2
3
4
inline int min(int i, int j) {
int minval = i < j ? i : j;
return minval;
}

如果有以下调用操作:
1
2
3
4
5
6
{
int local_var;
int minval;
// ...
minval = min(val1, val2);
}

inline 被扩展开后,为了维护其局部变量,可能会变为(理论上这里可以用 NRV 优化):
1
2
3
4
5
6
7
8
9
{
int local_val;
int minval;
// 将 inline 函数的局部变量处以“mangling”操作
int __min_lv_minval;
minval =
(__min_lv_minval = val1 < val2 ? val1 : val2),
__min_lv_minval;
}

一般而言,inline函数中的每一个局部变量都必须被放在函数调用的一个封闭区间中,拥有一个独一无二的名称。如果inline函数以单一表达式扩展多次,每次扩展都需要自己的一组局部变量。如果inline函数以分离的多个式子被扩展多次,那么只需要一组变量就能重复使用。

inline 函数中的局部变量,再加上有副作用的参数,可能会导致大量临时性对象的产生,特别是如果它在单一表达式中被扩展多次的情况:

1
minval = min(val1, val2) + min(foo(), foo() + 1);

可能被扩展为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 为局部对象产生临时对象
int __min_lv_minval_00;
int __min_lv_minval_01;
// 为放置副作用值而产生临时变量
int t1;
int t2;

minval =
((__min_lv_minval__00 =
val1 < val2 ? val1 : val2),
__min_lv_minval__00) +
((__min_lv_minval__01 =
(t1 = foo()), (t2 = foo() + 1), t1 < t2 ? t1 : t2),
__min_lv_minval__01)

inline函数对于封装提供了一种必要的支持,可以有效存取封装于class中的nonpublic数据。它同时也是C程序中大量使用的#define(前置处理宏)的一个安全代替品―特别是如果宏中的参数有副作用的话。然而一个inline函数如果被调用太多次的话,会产生大量的扩展码,使程序的大小暴涨。

参数带有副作用,或是以一个单一表达式做多重调用,或是在inline函数中有多个局部变量,都会产生临时性对象,编译器也许(或也 许不)能够把它们移除。此外,inline中再有inline,可能会使一个表面上看起来 平凡的inline却因其连锁复杂度而没办法扩展开来.这种情况可能发生于复杂 C lass体系下的constructors,或是object体系中一些表面上并不正确的inline调 用所组成的串链―它们每一个都会执行一小组运算,然后对另一个对象发出请求。对于既要安全又要效率的程序,inline函数提供了一个强而有力的工具。然 而,与non-inline函数比起来,它们需要更加小心地处理.

构造、解构、拷贝语义学(Semantics of Construction, Destruction, and Copy)

有如下抽象基类的声明:

1
2
3
4
5
6
7
8
Class Abstract_base {
public:
virtual ~Abstract_base() = 0;
virtual void interface() const = 0;
virtual const char* mumble() const { return _mumble; }
protected:
char* _mumble;
}

上述抽象基类的问题在于,虽然它是一个抽象类,但仍需要一个明确的构造函数来初始化_mumble。如果没有这个初始化操作,其 derived class 的就无法确定_mumble的初值。

如果 Abstract_base 的设计者试图让每一个 derived class 提供_mumble的初值,那么 derived class 的唯一要求就是 Abstract_base 必须提供一个带有唯一参数的 protected constructor:

1
Abstract_base::Abstract_base(char* mumble_value = 0) : _mumble(mumble_value) {}

一般而言,class 的 data member 应该在其 constructor 或 member functions 中设定初值,否则会破坏封装性质。

纯虚函数的存在(Presence of a Pure Virtual Function)

C++ 新手(我)常常很惊讶地发现,竟然可以定义和调用一个pure virtual function。但它只能被静态的调用,不能经由虚拟机制调用:

1
2
3
4
5
6
7
8
9
10
11
12
// ok:定义 pure virtual function
inline void
Abstract_base::interface() const {
// ...
}

inline void
Concrete_derived::interface() {
// ok: 静态调用纯虚函数
Abstract_base::interface();
// ...
}

要不要定义纯虚函数完全由 class 设计者决定,但有一个例外:class 的设计者必须定义 pure virtual destructor。因为每一个 derived class destructor 都会被编译器扩展,以静态调用的方式调用每一个基类的 destructor,因此,只要缺乏任何一个 base class destructor 的定义,就会导致链接失败。

你可能会争辩说,难道对一个pure virtual destructor的调用操作不应该在“编译器扩展derived class的destructor”时压抑下来吗?不!class 设计者可能 已经真的定义了一个pure virtual destructor,这样的设计是以C++语言的一个保证为前提:继承体系中每一个class object的destructors都会被调用。所以编译器不能够压抑这个调用操作。

编译器的确没有足够知识“合成”一个pure virtual destructor的函数定义,因为编译器对一个可执行文件采取“分离编译模型”。开发环境可以提供一个设备,在链接时找出pure virtual destructor不存在的事实体,然后重新激活编译器,赋予一个特殊指令(directive),以合成一个必要的函数实体。一个比较好的替代方案就是,不要把virtual destructor声明为pure

虚拟规格的存在(Presence of a Virtual Specification)

如果把Abstract_base::mumble()设计为一个 virtual function,将会是非常糟糕的选择,因为其函数定义内容与类型无关,因而几乎不会被后继的 derived class 改写,而且还会带来效率上的负担。

一般而言把所有的成员函数都声明为virtual function再靠编译器的优化操作把非必要的virtual invocation去除并不是好的设计观念。

虚拟规格中 const 的存在

决定一个 virtual function 是否需要 const,看似是无所谓的事,但真正面对一个 abstract base class 时,却不容易做决定,作者的想法很简单,不再用 const 就是了。

重新考虑 class 的声明

由前面的讨论可知,重新定义 Abstract_base 如下,才比较合适:

1
2
3
4
5
6
7
8
9
class Abstract_base {
public:
virtual ~Abstract_base(); // 不是 pure 了
virtual void interface() = 0; // 不是 const 了
const char* mumble () const { return _mumble; } // 不是 virtual 了
protected:
Abstract_base( char *pc = 0 ) // 新增一个带有唯一参数的 constructor(是保护成员哦)
char *_mumble;
};

“无继承”情况下的对象构造

考虑下面的程序片段:

1
2
3
4
5
6
7
8
9
10
11
Point global;

Point foobar()
{
Point local;
Point *heap = new Point;
*heap = local;
// ...stuff...
delete heap;
return local;
}

L1,L5,L6 表现出三种不同的对象产生方式:global 内存配置、local 内存配置和 heap 内存配置。

local object 的生命从 L5 的定义开始,到 L10 为止。global object 的生命和整个程序的生命相同。heap object 的生命从它被 new 运算符配置出来开始,到它被 delete 运算符损毁为止。

下面的 Point 的声明,是一种所谓的 Plain Ol’ Data 声明形式:

1
2
3
typedef struct {
float x, y, z;
} Point;

如果以C++来编译这段码,观念上编译器会为Point声明一个trivial default constructor、一个trivial destructor、一个trivial copy constructor,以及一个trivial copy assignment operator,但实际上编译器会分析这个声明,并为其贴上 Plain Ol’ Data 卷标。

当编译器遇到这样的定义:Point global;,程序的行为会和在 C 语言中表现一样(没有构造函数等东西),只有一点不一样:在 C 中,global 被视为临时性的定义,因为它没有明确的初始化操作,这种“临时性的定义”会被链接器折叠,只留下单独一个实体,放在程序 data segment 中一个特别保留给未初始化之 global objects 使用的空间,这块空间被称为BSS,是 Block Started by Symbol 的缩写。

而 C++ 并不支持“临时性的定义”,global 在 C++ 中被视为完全定义。C 和 C++ 的一个差异就在于,BSS data segment 在 C++ 中相对不重要。C++ 的所有全局对象都被当作“初始化过的数据”来对待。

foobar()函数中 L5 声明了一个Point object local,它既没有被构造也没有被解构,这会是一个潜在的 bug,比如 L7 的赋值操作。至于 heap object 在 L6 的初始化操作,会被转化为对 new 运算符的调用:

1
Point *heap = __new(sizeof(Point));

再强调一次,并没有 default constructor 施行于 new 运算符所传回的 Point object 上。L7 对此 object 有一个赋值操作,如果 local 被适当的初始化过,那么一切就没问题。观念上,这样的赋值操作会触发 copy assignment operator 进行拷贝搬运操作。然而这个 object 是一个 Plain Ol’ Data,所以赋值操作只是像 C 那样的纯粹位搬移操作。L9 执行的 delete 操作会被转化为对 delete 运算符的调用:
1
__delete(heap);

观念上,这会触发Point的trivial destructor,但destructor 和 constructor 一样,不是没产生出来,就是没被调用。最后的 return 语句传值也是一样,只是简单的位拷贝操作。

抽象数据类型(Abstract Data Type)

以下是 Point 的另一种声明,在public接口下多了private数据,提供了完整的封装性,但没有 virtual function:

1
2
3
4
5
6
7
8
9
10
class Point {
public:
Point( float x = 0.0, float y = 0.0, float z = 0.0 )
: _x( x ), _y( y ), _z( z ) {}
// no copy constructor, copy operator
// or destructor defined ...
// ...
private:
float _x, _y, _z;
};

其大小并没有改变,还是三个连续的 float,不论private还是public或是member function的声明都不会占用额外的对象空间。我们也没有定义 copy constructor 或 copy operator,因为默认的位语义(default bitwise semantics)已经足够。也无需 destructor,默认的内存管理方法也够了。现在对于一个 global 实体,就会有 default constructor 作用其上了。

如果要对 class 中的所有成员都设定常量初值,那么使用explicit initialization list会比较高效,比如:

1
2
3
4
5
6
7
8
9
10
void mumble()
{
Point local1 = { 1.0, 1.0, 1.0 };
Point local2;
// equivalent to an inline expansion
// the explicit initialization is slightly faster
local2._x = 1.0;
local2._y = 1.0;
local2._z = 1.0;
}

local1 的初始化操作会比 local2 的高效。这是因为函数的 activation record 被放进程序堆栈时,上述 initialization list 中的常量就可以被放进 local1 内存中了。

Explicit initialization list 带来三项缺点:

  • 只有当 class members 都是 public 时,此法才奏效。
  • 只能指定常量,因为它们再编译时期就恶意被评估求值。
  • 由于编译器并没有自动施行之,所以初始化行为的失败可能性会比较高一些。

在编译器层面,会有一个优化机制用来识别inline constructors,后者简单地 提供一个member-by-member的常量指定操作。然后编译器会抽取出那些值,并且对待它们就好像是explicit initialization list所供应的一样,而不会把constructor扩展成为一系列的assignment指令。于是,local Point object的定义:

1
2
3
{
Point local;
}

现在被附加上default Potni constructor的inline expansion:

1
2
3
4
5
//inline expansion of default constructor
Point local;
local._x = 0.0;
local._y = 0.0;
local._z = 0.0;

L6配置出一个heap Poini object:

1
Point *heap = new Point;

现在则被附加一个“对default Point constructor的有条件调用操作”:
1
2
3
4
//c++伪码
Point *heap = __new(sizeof(Point));
if(heap != 0)
heap->Point::Point();

然后才又被编译器进行inline expansion操作,至于把heap指针指向local object;
1
*heap = local;

则保持着简单的位拷贝操作。以传值方式传回local object,情况也是一样:

1
(10) return local; 

L9删除heap所指之对象:

1
(9) delete heap; 

该操作并不会导致destructor被调用,因为我们并没有明确地提供一个destructor函数实体。

观念上,我们的Point class有一个相关的default copy constructor、copy operator和destructor,然而它们都是无关痛痒的(trivial),而且编译器实际上根本没有产生它们。

为继承做准备

下面时第三个 Point 声明,将为“继承性质”以及动态决议做准备:

1
2
3
4
5
6
7
8
9
10
11
class Point
public:
Point( float x = 0.0, float y = 0.0 )
: _x( x ), _y( y ) {}
// no destructor, copy constructor, or
// copy operator defined ...
virtual float z();
// ...
protected:
float _x, _y;
};

再一次强调,这里并没有定义 copy constructor、copy operator、destructor。因为程序在默认语义之下表现良好。

virtual function 的引入促使每一个 Point object 拥有一个 virtual table pointer。除了多了一个 vptr 外,编译器还对 Point class 进行了扩张:

  • constructor 被附加了一些代码,以便将 vptr 初始化。这些代码放在所有 base class constructor 之后,程序员的代码之前。
  • 合成一个 copy constructor 和一个 copy assignment operator,而且其操作不再是 trivial(如果用 bitwise 拷贝可能会给 vptr 带来非法设定)。

继承体系下的对象构造

当我们定义一个 object:T object;时,会发生什么?很显然,其 constructor 会被调用,不显然的是,constructor 可能会带有大量编译器为其扩展的代码:

  1. 记录在 member initialization list 中的 data members 初始化操作会被放进 constructor 的函数本身,并以 member 的声明顺序放置。
  2. 如果有 member 没有出现在 member initialization list 中,但它有一个 default constructor,那么该 default constructor 必须被调用。
  3. 在那之前,如果 class object 有 virtual table pointer,它们必须被设定初值,指向适当的 virtual table。
  4. 在那之前,所有上一层的 base class constructor 必须被调用,以 base class 的声明次序为顺序:
    1. 如果 base class 被列于 member initialization list 中,那么任何明确指定的参数都应该传递过去。
    2. 如果 base class 没有被列于 member initialization list 中,而它有 default constructor,那么就调用之。
    3. 如果 base class 是多重继承下的第二或后继的 base class,那么 this 指针必须有所调整。
  5. 在那之前,所有 virtual base class constructors 必须被调用,从左到右,从最深到最浅:
    1. 如果 class 被列于 member initialization list 中,那么如果有任何明确指定的参数,都应该传递过去。若没有列于 list 中,而 class 有 default constructor,那么调用之。
    2. 此外,class 中的每一个 virtual base class subobject 的偏移量(offset)必须在执行期可被存取。
    3. 如果 class object 是最底层(most-derived)的class,其 constructor 可能被调用;某些用以支持这个行为的机制必须被放进来。

这一节中,再次以 Point 为例,并增加 copy constructor、copy operator、virtual destructor:

1
2
3
4
5
6
7
8
9
10
11
class Point {
public:
Point( float x = 0.0, float y = 0.0 );
Point( const Point& );
Point& operator=( const Point& );
virtual ~Point();
virtual float z(){ return 0.0; }
// ...
protected:
float _x, _y;
};

然后再定义一个 Line class,它由_begin_end两个点构成:
1
2
3
4
5
6
7
8
class Line {
Point _begin, _end;
public:
Line( float=0.0, float=0.0, float=0.0, float=0.0 );
Line( const Point&, const Point& );
draw();
// ...
};

每一个explicit constructor都会被扩充以调用其两个member class objects的constructors。如果定义constructor如下:

1
Line::Line(const Point &begin, const Point &end) : _end(end), _begin(begin) {}

它会被扩充为:

1
2
3
4
5
Line* Line::Line(Line *this, const Point &begin, const Point &end) {
this->_begin.Point::Point(begin);
this->_end.Point::Point(end);
return this;
}

当程序员写下:

1
Line a;

时,implicit Line destructor会被合成出来(如果Line派生自Point,那么合成出来的destructor将会是virtual。然而由于Line只是内带Point objects而非继承自Point,所以被合成出来的destructor只是nontrivial而已)。在其中,它的member class objects的destructors会被调用(以其构造的相反顺序)
1
2
3
4
//C++伪码:合成出来的Line destructor 
inline void Line::~Line(Line *this) {
this->end.Point::~Point();
this->begin.Point::~Point();

当然,如果Point destructor是inline函数,那么每一个调用操作会在调用地点被扩展开来。请注意,虽然Point destructor是virtual,但其调用操作(在containing class destructor之中)会被静态地决议出来(resolved statically)。类似的道理,当一个程序员写下:

1
Line b = a;

时,implicit Line copy constructor会被合成出来,成为一个inline public member。

最后,当程序员写下:

1
a = b;

时,implicit copy assignment operator会被合成出来,成为一个inline public member。

虚拟继承(Virtual Inheritance)

考虑如下的虚拟继承情况:

1
2
3
4
5
6
7
8
9
10
class Point3d : public virtual Point {
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point(x, y), _z(z) {}
Point3d(const Point3d& rhs) : Point(rhs), _z(rhs._z) {}
~Point3d();
Point3d& operator=(const Point3d&);
virtual float z() {return z;}
protected:
float _z;
}

对于 Point3d 和 Vertex,传统的 constructor 扩充并没有用,这是因为 virtual base class 的“共享性”之故,如果正常的去扩展 constructor,那么 Point3d 和 Vertex 都会调用 Point 的 constructor,这显然不行。所以应该在最底层的 class 中将 Point 初始化,在上图中,应该由 PVertex 去初始化共享的 Point。下面就是 Point3d 的 constructor 扩充内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Psuedo C++ Code:
// Constructor Augmentation with Virtual Base class
Point3d*
Point3d::Point3d( Point3d *this, bool __most_derived, // 多了一个 __most_derived 参数
float x, float y, float z ) {
if ( __most_derived != false )
this->Point::Point( x, y);

this->__vptr__Point3d = __vtbl__Point3d;
this->__vptr__Point3d__Point =
__vtbl__Point3d__Point;
this->_z = rhs._z;
return this;
}

在更深层的继承情况下,例如 Vertex3d,当调用 Point3d 和 Vertex 的 constructor 时,总是会把__most_derived参数设为 false,于是就压制了两个 constructor 中对 Point constructor 的调用操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Psuedo C++ Code:
// Constructor Augmentation with Virtual Base class
Vertex3d*
Vertex3d::Vertex3d( Vertex3d *this, bool __most_derived,
float x, float y, float z ) {
if ( __most_derived != false )
this->Point::Point( x, y);

// invoke immediate base classes,
// setting __most_derived to false
this->Point3d::Point3d( false, x, y, z );
this->Vertex::Vertex( false, x, y );
// set vptrs
// insert user code
return this;
}

所以,“virtual base class constructor 的被调用”有着明确的定义:只有当一个完整的 class object 被定义出来时,它才会被调用;如果 object 只是某个完整 object 的 subobject,它就不会被调用

以这一点为杠杆,我们可以产生更有效率的constructors。某些新近的编译器把每一个constructor分裂为二,一个针对完整的object,另一个针对subobject。“完整object”版无条件地调用virtual base constructors,设定所有的vptrs等等。 “subobject”版则不调用virtual base constructors,也可能不设定vptrs等等。将在下一节讨论对vptr的设定。constructor的分裂可带来程序速度的提升。

vptr 初始化语义学(The Semantics of the vptr Initialization)

当我们定义一个 PVertex object 时,constructors 的调用顺序是:

1
2
3
4
5
Point(x, y);
Point3d(x, y, z);
Vertex(x, y, z);
Vertex3d(x, y, z);
PVertex(x, y, z);

假设这个继承体系中的每一个 class 都定义了一个 virtual function:size(),该函数负责传回 class 的大小。如果我们写:
1
2
3
4
PVertex pv;
Point3d p3d;

Point *pt = &pv;

那么这个调用操作:

1
pt->size();

将传回PVertex的大小,而:

1
2
pt = &p3d;
pt->size();

将传回Point3d的大小。

假设在继承体系中的每一个 constructor 内都带一个调用操作,比如:

1
2
3
4
5
6
Point3d::Point3d( float x, float y, float z )
: _x( x ), _y( y ), _z( z ) {
if ( spyOn )
cerr << "within Point3d::Point3d()"
<< " size: " << size() << endl;
}

那么,每次对size()的调用都会被决议为PVertex::size()吗(毕竟我们正在构造的是 PVertex)?

事实是,在 Point3d constructor 中调用的size()函数,必须被决议为Point3d::size()。更一般地,在一个 class (Point3d)的 constructor 中,经由构造中的对象(PVertex)来调用一个 virtual function,其函数实体应该是在此 class 中有作用的那个(Point3d)

Constructors的调用顺序是:由根源而末端(bottom up),由内而外(inside out)。 当base class constructor执行时,derived实体还没有被构造出来。在PVertex constructor执行完毕之前,PVertex并不是一个完整的对象:Point3d constructor执行之后,只有Point3d subobject构造完毕。

这意味着,当每一个PVertex base class constructors被调用时,编译系统必须保证有适当的size()函数实体被调用。怎样才能办到这一点呢?如果调用操作限制必须在constructor(或destructor)中直接调用,那么答案十分明显:将每一个调用操作以静态方式决议之,千万不要用到虚拟机制。如果是在Point3d constructor中,就明确调用Point3d::size()

然而如果size()之中又调用一个virtual function情况下,这个调用也必须决议为Point3d的函数实体。其他情况下,这个调用是纯正的virtual,必须经由虚拟机制来决定其归向。也就是说,虚拟机制本身必须知道是否这个调用源自于一个constructor之中。

什么是决定一个class的virtual function名单的关键?答案是virtual table。virtual table通过vptr被处理。所以为了控制一个class中有所作用的函数,编译系统只要简单地控制住vptr的初始化和设定操作即可。

vptr初始化操作应该如何处理?视vptr在constructor之中“应该在何时被初始化”而定,我们有三种选择:

  1. 在任何操作之前。
  2. 在base class constructors调用操作之后,但是在程序员代码或是“member initialization list中所列的members初始化操作”之前。
  3. 在每一件事情发生之后。

答案是2,策略2解决了“在class中限制一组virtual functions名单”的问题。如果每一个constructor都一直等待到其base class constructors执行完毕之后才设定其对象的vptt,那么每次它都能够调用正确的virtual function实体。

令每一个base class constructor设定其对象的vptr,使它指向相关的virtual table之后,构造中的对象就可以严格而正确橄变成“构造过程中所幻化出来的每一个class的对象。一个PVertex对象会先形成一个Point对象、 一个Point3d对象、一个Vertex对象、一个Vertex3d对象,然后才成为一个PVertex对象。在每一个base class constructor中,对象可以与constructor’s class 的完整对象作比较。对于对象而言,“个体发生学”概括了“系统发生学”。constructor 的执行算法通常如下:

  1. 在derived class constructor中,“所有virtual base classes”及“上层base class”的constructors会被调用;
  2. 上述完成之后,对象的vptr(s)被初始化。指向相关的virtual table(s)
  3. 如果有member initialization list的话,将在constructor体内扩展开来。这必须在vptr被设定之后才进行,以免有一个virtual member function被调用。
  4. 执行程序员的代码

例如,已知下面这个由程序员定义的PVertex constructor:

1
2
3
4
PVertex::PVertex(float x, float y, float z) : _next(0), Vertex3d(x, y, z), Point(x, y) {
if(spyOn)
cerr << "Within PVertex::PVertex()" << "size:" << size() << endl;
}

它很可能被扩展为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//C++伪码:
//PVertex constructor的扩展结果
PVertex* PVertex::PVertex(PVertex *this, bool __most__derived, float x, float y, float z) {
if (__most__derived != false)
this->Point::Point(x, y);

// 无条件调用上一层base
this->Vertex3d::Vertex3d(x, y, z);

// 将相关的Vptr初始化
this->__vptr__PVertex = __vtbl_PVertex;
this->__vptr__Point__PVertex = __vtbl_Point_PVertex;

// 程序员所写的码
if(spyOn)
cerr << "Within PVertex::PVertex()" << "size:" << (*this->__vptr__PVertex[3].faddr)(this) << endl;
return this;

下面是vptr必须被设定的两种情况:

  1. 当一个完整的对象被构造起来时。如果我们声明1个Point对象,Point constructor必须设定其vptr;
  2. 当一个subobject constructor调用了一个virtual function(不论是直接调用或间接调用)时.

如果我们声明一个PVertex对象,然后由于我们对其base class constructors的最新定义,其vptr将不再需要在每一个base class constructor中被设定。解决之道是把constructor分裂为一个完整的object实体和一个subobject实体。在subobject实体中,vptr的设定可以省略。

在class的constructor 的 member initialization list中调用该class的一个虚拟函数,安全吗?就实际而将该函数运行于其classs data member的初始化行动中,总是安全的。这是,正如我们所见,vptr保证能够在member initialization list被扩展之前,由编译器正确设定好。但是在语意上这可能是不安全的,因为函数本身可能还得依赖未被设立初值的members。所以我并不推荐这种做法。然而,从vptr的整体来看是安全的。

对象复制语义学(Object Copy Semantics)

当设计一个 class,并以一个 class object 指定给另一个 class object 时,有三种选择:

  • 什么都不做,从而实施默认行为。
  • 提供一个 explicit copy assignment operator。
  • 明确地拒绝把一个 class object 指定给另一个 class object。

如果选择第 3 点,只需将 copy assignment operator 声明为 private,并不提供定义即可。把它设置为private,我们就不再允许在任何地点(除了在member function以及此class的friends之中)进行赋值操作。不提供其函数定义,则一旦某个member function或friend企图影响一份拷贝,程序在链接时就会失败。

这一节,继续用 Point class 来帮助讨论:

1
2
3
4
5
6
7
class Point {
public:
Point( float x = 0.0, y = 0.0 );
//...(没有 virtual function)
protected:
float _x, _y;
};

似乎没有理由禁止拷贝一个 Point object,那么问题在于:默认行为是否足够?如果只是简单的拷贝操作,那么默认行为足够并且很有效率,没有理由再提供一个 copy assignment operator。

只有默认行为不够安全或不正确时,才需要设计一个 copy assignment operator。由于坐标都内带数值,所以不会发生“别名化”或“内存泄漏”。

那么如果程序员不对 Point 提供一个 copy assignment operator,只是依靠 memberwise copy,编译器是否会产生一个实体?实际和 copy constructor 一样,不会。因为这里以及有了 bitwise copy 语义,所以 implicit copy assignment operator 被视为毫无用处,所以不会被合成出来。

在以下情况下,不会表现出 bitwise copy 语义:

  • 当 class 内带一个 member object,而其 class 有一个 copy assignment operator 时。
  • 当一个 class 的 base class 有一个 copy assignment operator 时。
  • 当一个 class 声明了任何 virtual function(不能直接拷贝 vptr 的值,因为右边的 object 可能是一个 derived class object)。
  • 当 class 继承自一个 virtual base class (无论其有没有 copy operator)时。

于是,对于 Point class 的赋值操作:

1
2
3
Poitn a, b;
...
a = b;

由 bitwise copy 完成,期间并没有 copy assignment operator 被调用。注意,我们可以提供一个 copy constructor,这样可以打开 NRV 优化,但这并不意味着也需要提供一个 copy assignment operator。

现在,导入一个 copy assignment operator,来说明其在继承下的行为:

1
2
3
4
5
inline Point& Point::operator=(const Point& p) {
_x = p._x;
_y = p._y;
return *this;
}

然后派生一个 Point3d class(虚拟继承):
1
2
3
4
5
6
7
class Point3dvirtual public Point {
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0);
// ...
protected:
float _z;
}

现在,如果 Point3d 没有定义 copy assignment operator,编译器就必须合成一个。类似于这样:
1
2
3
4
5
6
7
8
9
// Pseudo C++ Code: synthesized copy assignment operator
inline Point3d&
Point3d::operator=( Point3d *const this, const Point3d &p ) {
// invoke the base class instance
this->Point::operator=( p );
// memberwise copy the derived class members
_z = p._z;
return *this;
}

copy assignment operator 有一个不够理想、严谨的地方,那就是它缺乏一个 member assignment list(也就是类似于 member initialization list 的东西)。必须写成以下两种形式,才能调用 base class 的 copy assignment operator:
1
2
3
Point::operator=(p3d);
// 或
(*(Point*)this) = p3d;

因为缺少了 copy assignment list,所以编译器就没法压抑上一层 base class 的 copy operator 被调用。例如,下面是 Vertex(虚拟继承自 Point) 的 copy operator:
1
2
3
4
5
6
// class Vertex : virtual public Point
inline Vertex& Vertex::operator=( const Vertex &v ) {
this->Point::operator=( v );
_next = v._next;
return *this;
}

现在从 Point3d 和 Vertex 中派生出 Vertex3d,下面为其 copy assignment operator:
1
2
3
4
5
6
7
inline Vertex3d&
Vertex3d::operator=( const Vertex3d &v ) {
this->Point::operator=( v );
this->Point3d::operator=( v );
this->Vertex::operator=( v );
...
}

编译器怎样才能在 Point3d 和 Vertex 的 copy assignment operator 中压抑 Point 的 copy assignment operator ?编译器不能使用 constructor 的解决方案(附上额外的参数)。因为和 constructor、destructor 不同,取 copy assignment operator 的地址是合法的,下边的代码是合法的:
1
2
3
4
typedef Point3d& (Point3d::*pmfPoint3d) (const Point3d&) ;

pmfPoint3d pmf = &Point3d::operator=;
(x.*pmf)(x);

然而我们无法支持它,仍然需要根据继承体系安插任何可能数目的参数给copy assignment operator。

另一个方法是,编译器为 copy assignment operator 产生分化函数(split functions),用来支持这个 class 成为 most-derived class 或成为中间的 base class。(这里没有说具体如何做,不是很懂这个方法)

事实上,copy assignment operator 在虚拟继承情况下行为不佳,需要小心设计和说明。许多编译器甚至并不尝试取得正确的语义,导致 virtual base class copy assignment operator 多次被调用。

有一种方法可以保证 most-derived class 会完成 virtual base class subobject 的 copy 行为,就是在 derived class 的 copy assignment operator 函数实体的最后,明确调用那个 operator:

1
2
3
4
5
6
7
8
9
inline Vertex3d&
Vertex3d::operator=( const Vertex3d &v ) {
this->Point3d::operator=( v );
this->Vertex::operator=( v );
// must place this last if your compiler does
// not suppress intermediate class invocations
this->Point::operator=( v );
...
}

这不能省略 subobject 的多重拷贝,但可以保证语义正确。另一个解决方案要求把 virtual subobject 拷贝到一个分离的函数中,并根据 call path 条件化的调用它。

作者的建议是:不要允许 virtual base class 的拷贝操作,甚至不要在任何 virtual base class 中声明数据。

对象的功能(Object Efficiency)

在下面的效率测试中,对象构造和拷贝所需的成本是以 Point3d class 声明为基准,从简单到复杂,依次声明为:Plain Ol’ Data、抽象数据类型(ADT)、单一继承、多重继承、虚拟继承。以下为测试主角:

1
2
3
4
5
6
Point3d lots_of_copies( Point3d a, Point3d b ) {
Point3d pC = a;
pC = b; // 1
b = a; // 2
return pC;
}

其中有四个 memberwise 初始化操作,包括两个参数,一个返回值以及一个局部对象 pC,还带有两个 memberwise 拷贝操作,在第 3、第 4 行。main 函数:
1
2
3
4
5
6
7
8
main() {
Point3d pA( 1.725, 0.875, 0.478 );
Point3d pB( 0.315, 0.317, 0.838 );
Point3d pC;
for ( int iters = 0; iters < 10000000; iters++ )
pC = lots_of_copies( pA, pB );
return 0;
}

在最初的两个程序中,数据类型是一个 struct 和一个拥有 public 数据的 class:
1
2
struct Point3d { float x, y, z; };
class Point3d { public: float x, y, z; };

对 pA 和 pB 的初始化操作通过 explicit initialization list 来完成:
1
2
Point3d pA = { 1.725, 0.875, 0.478 };
Point3d pB = { 0.315, 0.317, 0.838 };

结果如下:

下个测试,唯一的改变是数据的封装(public 变 private)以及 inline 函数的使用,以及一个 inline constructor,用以初始化每个 object,class 仍然展现 bitwise copy 语义,常识告诉我们效率应该相同,然而是有一些距离的:

所有测试都表现了 bitwise copy 语义,所以效率相似。然而一旦导入虚拟继承:

1
2
3
class Point1d { ... };
class Point2d : public virtual Point1d { ... };
class Point3d : public Point2d { ... };

则不再拥有 bitwise copy 语义,编译器将会合成 inline copy constructor 和 copy assignment operator。结果如下:

然后是有着封装和 virtual function 的 class,这种情况也是不允许 bitwise copy 语义的,inline copy constructor 和 copy assignment operator 被合成并调用。结果如下:

下面的测试是采用其他有着 bitwise copy 语义的表现方式,取代合成的 inline copy constructor 和 copy assignment operator。结果如下:

解构语义学(Semantics of Destruction)

如果 class 没有定义 destructor,那么只有在 class 内带的 member object (或是 class 自己的 base class)拥有 destructor 的情况下,编译器才会自动合成出一个。否则,destructor 被视为不需要,所以不会合成。

当我们从Point派生出一个Point3d时,如果没有声明一个destructor,编译器就没有必要合成一个destructor。

如果一个 class 确实不需要 destructor,还为其定义 destructor 是不符合效率的,应该拒绝那种“对称策略”的奇怪想法:“已经定义了一个 constructor,那么当然需要提供一个 destructor”。

为了决定 class 是否需要 destructor(或 constructor),应该想想一个 class object 的声明在哪里结束(或开始),需要什么操作才能保证对象的完整?例如:

1
2
3
4
5
6
7
{
Point pt;
Point *p = new Point3d;
foo(&pt, p);
...
delete p;
}

可以看到ptp作为foo()函数的参数前,必须先初始化其坐标,这时候就需要一个 constructor。

当明确 delete 一个 p 时会如何?是否有必要这么做:

1
2
p->x(0);
p->y(0);

当然没必要,没有理由在 delete 前将对象内容清除干净,也无需归还任何资源,所以完全不需要一个 destructor。在结束pt和p的生命之前没有任何class使用者层面的程序操作是绝对必要的。因此也就不一定需要一个destructor。

当我们从Point3d和Vertex派生出Vertex3d时,如果我们不供应一个explicit Vertex3d destructor,那么我们还是希望Vertex destructor被调用,以结束一个Vertex3d object。因此,编译器必须合成一个Vertex3d destructor,其唯一任务就是调用Vertex destructor。如果我们提供一个Vertex3d destructor,编译器会扩展它,使它调用Vertex destructor(在我们所供应的程序代码之后)。一个由程序员定义的 destructor 被扩展的方式类似 constructor 被扩展的方式,但顺序相反(这里的destructor扩展形式似乎应为2,3,1,4,5):

  1. 如果 object 内带有一个 vptr,那么首先重设相关的 virtual table。
  2. destructor 的函数本身现在被执行,也就是说 vptr 会在程序员的代码执行前被重设。
  3. 如果 class 拥有 member class objects,而后者拥有 destructor,那么它们会以其声明顺序相反的顺序被调用。
  4. 如果有任何直接的(上一层)nonvirtual base class 拥有 destructor,它们会以其声明顺序相反的顺序被调用。
  5. 如果有任何 virtual base class 拥有 destructor,而当前讨论的这个 class 是最尾端(most-derived)的 class,那么它们会以原来的构造顺序的相反顺序被调用。

如 constructor 一样,目前对于 destructor 的一种最佳实现策略就是维护两个 destructor 实体:

  1. 一个 complete 实体,总是设定好 vptr,并调用 virtual base class destructor。
  2. 一个 base class subobject 实体;除非在 destructor 函数中调用一个 virtual function,否则它绝不会调用 virtual base class destructor 并设定 vptr。

一个object的生命结束于其destructor开始执行之时。由于每一个base class destructor都轮番被调用,所以derived object实际变成了一个完整的object。例如一个PVertex对象归还其内存空间之前,会依次变成一个Vertex3d对象、一个Vertex对象、一个Point3d对象,最后成为一个Point对象。当我们在destructor中调用member functions时,对象的蜕变会因为vptr的重新设定(在每一个destructor中,在程序员所供应的码执行之前)而受到影响。在程序中施行destructors的真正语意将在第6章详述。

第六章 执行期语义学(Runtime Semantics)

想象一下我们有下面这个简单的式子:

1
if (yy == xx.getValue())

其中xx和yy定义为:
1
X xx; y yy; 

class Y定义为:

1
2
3
4
5
6
7
class Y {
public:
Y();
~Y();
bool operator== (const Y&) const;
// ...
};

class X定义为:
1
2
3
4
5
6
7
class X {
public: x();
~X();
operator Y() const; //译注:conversion运算符
X getValue();
//...
};

让我们看看本章一开始的那个表达式该如何处理。

首先,让我们决定equality〔等号)运算符所参考到的真正实体。在这个例子中,它将被决议(resolves)为“被overloaded的Y成员实体”。下面是该式子的第一次转换:

1
2
//resolution of intended operator
if (yy.operator==(xx.getValue()))

Y的equality〔等号〕运算符需要一个类型为Y的参数,然而getValue()传回的却是一个类型为X的object。若非有什么方法可以把一个X object转换为一个Y object,那么这个式子就算错。

本例中x提供一个conversion运算符,把一个X object转换为一个Y object。它必须施行于getValue()的返回值身上。下面是该式子的第二次转换:

1
2
//conversion of getValue()'s return value
if(yy.operator==(xx.getValue().operator Y()))

到目前为止所发生的一切都是编译器根据class的隐含语意,对我们的程序代码所做的“增胖”操作.如果我们需要,我们也可以明确地写出那样的式子。

接下来我们必须产生一个临时对象,用来放置函数调用所传回的值:

  • 产生一个临时的class X object,放置getValue()的返回值:X temp1 = xx.getValue()
  • 产生一个临时的class Y object,放置operator Y()的返回值:Y temp2 = temp1.operator Y()
  • 产生一个临时的int object,放置等号运算符的返回值:int temp3 = yy.operator==(temp2)
  • 最后适当的destructor将被施行于每一个临时性的class object身上,这导致式子最后被转换为以下形式:
1
2
3
4
5
6
7
8
9
以下是条件句if (yy == xx.getValue()) 的转换
X temp1 = xx.getValue()
Y temp2 = temp1.operator Y()
int temp3 = yy.operator==(temp2)

if (temp3) ...

temp2.Y::~Y();
temp1.X::~X();

对象的构造和解构(Object Construction and Destruction)

如果一个区段(以 {} 括起来的区域)或函数有一个以上的离开点,情况会复杂一些,destructor必须放在每一个离开点之前,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Point point; 
// constructor 在这里行动
switch( int( point.x() ) ) {
case -1 :
// mumble;
// destructor 在这里行动
return;
case 0 :
// mumble;
// destructor 在这里行动
return;
case 1 :
// mumble;
// destructor 在这里行动
return;
default :
// mumble;
// destructor 在这里行动
return;
}
// destructor 在这里行动

在上述例子中,destructor 的调用操作必须放在switch指令四个出口的return之前,另外也很有可能在这个区段的结束符号之前被生成出来,即使程序分析的结果发现绝不会到那里。

goto指令也可能需要多个destructor调用操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
if ( cache )
// check cache; if match, return 1
return 1;

Point xx;
// constructor goes here

while ( cvs.iter( xx ))
if ( xx == value )
goto found;
// destructor goes here
return 0;

found:
// cache item
// destructor goes here
return 1;
}

在上述例子中,destructor 的调用操作必须放在最后两个 return 之前,但是却不必放在最初的 return 之前,因为 object 还没有被定义。所以,在程序当中,我们应该把object 尽可能放置在它的那个程序区段附近,这样可以节省不必要的对象产生操作和摧毁操作。如果在检查cache之前就定义了Point object,那就不够理想。

全局对象(Global Objects)

如果有以下程序片段:

1
2
3
4
5
6
7
8
Matrix identity;

int main() {
// identity 必须在此处被初始化
Matrix m1 = identity;
...
return 0;
}

C++ 必须保证main()函数中第一次用到identity之前必须把identity构造出来。并在main()函数结束之前把identity摧毁掉。像identity这样的 global object,如果有 constructor 和 destructor,必须要静态的初始化和释放操作。

C++ 程序中所有的 global object 都放在 data segment 中,如果不给初值,那么所配置的内存内容为 0。因此在下面这段码中:

1
2
int v1 = 1024;
int v2;

v1和v2都被配置于程序的data segment,vi值为1024,v2值为0(这和C略有不同,C并不自动设定初值)。在C语言中一个global object只能够被 一个常量表达式(可在编译时期求其值的那种)设定初值。当然,constructor并不是常量表达式。虽然class object在编译时期可以被放置于data segment中并且内容为0,但constructor一直要到程序激活(startup)时才会实施。必须对一个“放置于program data segment中的object的初始化表达式”做评估 (evaluate),这正是为什么一个object需要静态初始化的原因。

局部静态对象(Local Static Objects)

有如下程序片段:

1
2
3
4
5
const Matrix& identity {
static Matrix mat_identity;
// ...
return mat_identity;
}

Local static class object 保证以下语义:

  • mat_identity的constructor必须只能执行一次,虽然上述函数可能会被调用多次。
  • mat_identity的destructor必须只能执行一次,虽然上述函数可能会被调用多次。

编译器的策略之一事,无条件地在程序起始时构造出对象来。然而这会导致所有的local static class object都在程序起始时被初始化,即使它们所在的那个函数从不曾被调用。因此,只在identity()被调用时才把mat_identity构造起来是比较好的做法。

cfront 的做法是引入一个临时性对象以保护mat_identity的初始化操作,第一次处理identity()时,这个临时对象被评估为 false,于是 constructor 会被调用,然后临时对象被改为 true。而在相反的那一端,destructor也需要有条件地施行于mat_identity身上,但只有在mat_identity已经被构造起来时才算数。要判断mat_identity是否被构造起来,很简单。如果那个临时对象为true,就表示构造好了。困难的是,由于cfront产生C码,mat_identity对函数而言仍然是local,因此我没办法在静态的内存释放函数(static deallocation function)中存取它。解决的方法有点诡异:取出local object的地址。下面是cfront的输出(经过轻微的修润):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 被产生出来的临时对象,作为戒护用
static struct Matrix *__0__F3 = 0;

// identity() 的名称会被 mangled
struct Matrix* identity_Fv() {
static struct Matrix __1mat_identity;
// 如果临时性的保护对象已被设立,那就什么也别做,否则:
// (a) 调用constructor:__ct__6MatrixFv
// (b) 设定保护对象,使它指向目标对象

__0__F3
? 0
:(__ct__1MatrixFv ( & __1mat_identity ),
(__0__F3 = (&__1mat_identity)));
...
}

最后,destructor必须在与text program file有关联的静态内存释放函数中被有条件调用:

1
2
3
4
5
6
char __std__stat_0_c () {
__0__F3
? __dt__6MatrixFv( __0__F3, 2)
: 0 ;
...
}

对象数组(Array of Object)

假设有这样的数组定义:Point knots[10];

如果 Point 没有定义 constructor 和 destructor,那么只需配置足够的内存即可,不需要做其他事。

如果 Point 明确定义了 default constructor,那么 这个 constructor 必须轮流施行于每个元素之上。在 cfront 中,使用了一个名为vec_new()的函数产生出以 class object 构造而成的数组。函数类型通常如下:

1
2
3
4
5
6
7
void* vec_new {
void *array, // address of start of array
size_t elem_size, // size of each class object
int elem_count, // number of elements in array
void (*constructor)( void* ),
void (*destructor)( void*, char )
}

其中 constructor 和 destructor 参数是这个 class 的 default constructor 和 default destructor 的函数指针。参数array带有的若不是具名数组的地址,就是0。若 array 的地址为 0,那么数组将经由应用程序的 new 运算符动态的配置于 heap 中。elem_size 表示数组中的元素数目。在vec_new()中,constructor施行于elem_count个元素之上。对于支持exception handling的编译器而言,destructor的提供是必要的。下面是编译器可能针对我们的10个Point元素所做的vec_new()调用操作:
1
2
Point knots[10];
vec_new(&knots, sizeof(Point), 10, &Point::Point, 0);

如果 Point 也定义了 destructor,那么当 knots 的生命结束时,destructor 会施行于那 10 个 Point 元素身上。这会经由类似的一个vec_delete()来完成,其函数类型通常如下:

1
2
3
4
5
6
7
void*
vec_delete {
void *array, // address of start of array
size_t elem_size, // size of each class object
int elem_count, // number of elements in array
void (*destructor)( void*, char )
}

若程序员提供一个或多个初值给这个数组:

1
2
3
4
5
Point knots[10] = {
Point(),
Point(1.0, 1.0, 0.5),
-1.0
};

那么对于明显获得初值的元素,vec_new()就不需要了,但对于尚未被初始化的元素,vec_new()的施行方式就像面对“由class elements组成的数组,而该数组没有explicit initialization list”一样,定义可能会被转换为:
1
2
3
4
5
6
7
8
Point knots[ 10 ];
// Pseudo C++ Code
// initialize the first 3 with explicit invocations
Point::Point( &knots[0]);
Point::Point( &knots[1], 1.0, 1.0, 0.5 );
Point::Point( &knots[2], -1.0, 0.0, 0.0 );
// initialize last 7 with vec_new ...
vec_new( &knots+3, sizeof( Point ), 7, &Point::Point, 0 );

Default Constructors和数组

如果你想要在程序中取出一个constructor的地址,这是不可以的。当然啦, 这是编译器在支持vec_new()时该做的事清然而,经由一个指针来激活constructor,将无法(不被允许)存取default argument values。

举个例子,在cfront 2.0之前,声明一个由class objects所组成的数组,意味着这个class必须没有声明constructors或一个default constructor(没有参数那种)。一个constructor不可以取一个或一个以上的默认参数值。这是违反直觉的,会导致以下的大错。下面是在cfront1.0中对于复数函数库(complex library)的声明,你能够看出其中的错误吗?

1
2
class complex 
Complex (double = 0.0, double = 0.0);

在当时的语言规则下,此复数函数库的使用者没办法声明一个由complex class objects组成的数组.显然我们在语言的一个陷阱上被绊倒了.在1.1版我们修改的是class library;然而在2.0版,我们修改了语言本身。

再一次地,让我们花点时间想想,如何支持以下句子:

1
Complex::complex (double=0.0, double=0.0); 

当程序员写出:
1
complex c_array[10];

时,而编译器最终需要调用:
1
vec_new(&c_array, sizeof(complex), 10, &complex::complex, 0);

默认的参数如何能够对vec_new()而言有用? 很明显,有多种可能的实现方法。cfront所采用的方法是产生一个内部的stub constructor,没有参数。在其函数内调用由程序员提供的constructor,并将default参数值明确地指定过去(由于constructor的地址已被取得,所以它不能够成为一个inline):

1
2
3
4
5
// 内部产生的stub constructor 
// 用以支持数组的构造
complex::complex() {
complex(0.0, 0.0)
}

编译器自己又一次违反了一个明显的语言规则:class如今支持了两个没有带参数的constructors。当然,只有当class object:数组真正被产生出来时,stub实体才会被产生以及被使用。

new 和 delete 运算符

运算符 new 是由以下两步完成的:

  1. 通过适当的 new 运算符函数实体,配置所需内存:

    1
    2
    // 调用函数库中的 new 运算符
    int *pi = __new(sizeof(int));
  2. 给配置得来的对象设立初值:

    1
    *pi = 5;

其中,初始化操作应该在内存配置成功后才执行:

1
2
3
4
5
// new 运算符的两个分离步骤
// given: int *pi = new int(5);
int *pi;
if (pi = __new(sizeof(int)))
*pi = 5;

delete 的情况类似,如果 pi 的值是 0,c++ 要求 delete 不要有操作:

1
2
if (pi != 0)
__delete(pi);

pi并不会自动被清除为0,因此像这样的后继行为:

1
if (pi && *pi == 5)

虽然没有良好定义,但是可能(也可能不)被评估为真。这是因为对于pi所指向的内存的变更或再使用没有肯定的答案。

pi所指对象之生命会因delete而结束・所以后继任何对pi的参考操作就不再保证有良好的行为,并因此被视为是一种不好的程序风格。然而,把pi继续当做一个指针来用,仍然是可以的(虽然其使用受到限制),例如:

1
2
3
// ok: p1仍然指向合法空间 
// 甚至即使储存于其中的object已经不再合法
if(p1==sentinel)

在这里,使用指针pi和使用pi所指的对象、其差别在于哪一个的生命已经结束了。虽然该地址上的对象不再合法,但地址本身却仍然代表一个合法的程序空间。因此pi能够继续被使用,但只能在受限制的情况下,很像一个void*指针的情况。

以constructor来配置一个class object,情况类似。例如:

1
Point3d *origin = new Point3d 

被转换为:
1
2
3
4
Point3d *origin;
// C++ 伪码
if (origin = __new(sizeof(Point3d)))
origin = Point3d::Point3d(origin);

如果实现出exception handling,那么转换结果会更复杂:

1
2
3
4
5
6
7
8
9
10
11
12
// C++ 伪码
if (origin = __new(sizeof(Point3d)))
try {
origin = Point3d::Point3d(origin);
}
catch( ... ) {
// 调用delete library function以释放new配置的内存
__delete(origin);

// 将原来的exception上传
throw;
}

如果以new配置object,而其constructor丢出一个exception,配置得来的内存就会被释放掉,然后exception再被丢出去。

destructor得应用极为类似:

1
delete origin;

会变成:

1
2
3
4
if (origin != 0) {
Point3d::~Point3d(origin);
__delete(origin);
}

一般的 library 对于 new 运算符的实现如下(略去了 exception handling 的版本):
1
2
3
4
5
6
7
8
9
10
11
12
13
extern void *operator new(size_t size) {
if (size == 0)
size = 1;

void *last_alloc;
while (!(last_alloc = malloc(size))) {
if (_new_handler)
(*_new_handler)();
else
return 0;
}
return last_alloc;
}

虽然new T[0];是合法的,但语言要求每一次对 new 的调用都必须传回一个独一无二的指针,所以程序中会有一个默认的 size 被设为 1。并且这个实现还允许使用者提供一个属于自己的_new_handler()函数。

newdelete运算符实际上都是由标准的 C malloc()free()完成的。

1
2
3
4
extern void operator delete(void* ptr) {
if (ptr)
free( (char*)ptr);
}

针对数组的 new 语义

当我们这么写:int *p_array = new int[5];时,vec_new()不会被调用,因为vec_new()的主要功能是把 default constructor 施行于 class object 所组成的数组的每个元素上。被调用的是 new 运算符:

1
int *p_array = (int*)__new(5 * sizeof(int));

对于没有定义 default constructor 的 class 也是一样。只有在 class 定义了一个 default constructor 时,某些版本的vec_new()才会被调用。例如:

1
Point3d *p_array = new Point3d[10];

会被编译为:

1
2
Point3d *p_array;
p_array = vec_new(0, sizeof(Point3d), 10, &Point3d::Point3d, &Point3d::~Point3d);

还记得吗,在个别的数组元素构造过程中,如果发生exception,destructor就会被传递给vec_new()。只有已经构造妥当的元素才需要destructor的施行,因为它们的内存已经被配置出来了,vec_new()有责任在exception发生的时候把那些内存释放掉。

在 C++ 2.0 之前,程序员需要将数组的真正大小提供给 delete 运算符。所以删除一个数组应该这样:

1
delete [array_size] p_array;

在 2.1 版中,程序员无需提供数组元素数目了,所以可以这样写:
1
delete [] p_array;

只有在中括号出现时,编译器才会寻找数组的维度,否则就认为只有单独一个 object 要被删除

各家编译器存在一个有趣的差异,那就是元素数目如果被明显指定,是否会被拿去使用。在某个版本中,优先采用使用者(程序员)明确指定的值。下面是程序代码的虚拟版本(pseudo-version),附带注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 首先检查是否最后一个被配置的项目(__cache_key)
// 是当前要被delete的项目
// 如果是,就不需要做搜寻操作了
// 如果不是,就寻找元素数目

int elem_count = __cache_key == pointer
? ((_cache_key = 0), __cache_cout)
: // 取出元素数目

// num_elem:元素数,将传递给vec_new()
// 对于配置于heap中的数组,只有针对以下形式,才会设定一个值:
// delete [10] ptr;
// 否则cfront会传-1以表示取出
if (num_elem == -1)
// prefer explicit user size if choice !
num_elem = ans;

然而儿乎晚近所有的C++编译器都不考虑程序员的明确指定(如果有的话):

1
2
3
"x.C", line 3: warning(467) 
delete array size expression ignored(anachronism)
foo() {delete [12] pi; }

为什么这里优先采用程序员所指定的值,而新近的编译器却不这么做呢?因为这个性质刚被导入的时候,没有仟何程序代码会不“明确指定数组大小”。时代演化到cfront 4.0的今天,我们已经给这个习惯贴上“落伍”的标记,并且产生一个类似的警告消息。

应该如何记录元素数目?一个明显的方法就是为vec_new()所传回的每一个内存区块配置一个额外的word,然后把元素数目包藏在那个word之中。通常这种被包藏的数值称为所谓的cookie(小甜饼)。然而,某些编译器决定维护一个“联合数组(associative array)”,放置指针及大小,也把destructor的地址维护于此数组之中。

cookie策略有一个普遍引起忧虑的话题,那就是如果一个坏指针应该被交给delete_vec(),取出来的cookie自然是不合法的。一个不合法的元素数目和一个坏的起始地址,会导致destructor以非预期的次数被施行于一段非预期的区域,然而在联合数组的政策下,坏指针的可能结果只是取出错误的元素数目而已。

在原始编译器中,有两个主要函数用来存储和取出所谓的cookie:

1
2
3
4
5
6
7
8
9
10
// array_key是新数组的地址
// mustn't either be 0 or already entered
// elem_count is the count; it may be 0

typedef void *PV;
extern int __insert_new_array(PV array_key, int elem_count);

// 从表格中取出并去除array_key
// 若不是传回elem_count,就是传回-1
extern int __remove_old_array(PV array_key);

对于 delete 操作,vec_delete()行为并不一定符合程序员的预期,比如对于这样一个 class :

1
2
3
4
5
6
7
8
9
10
11
12
class Point {
public:
Point();
virtual ~Point();
// ...
};
class Point3d : public Point {
public:
Point3d();
~Point3d();
// ...
}

如果我们这样配置一个数组:
1
Point *ptr = new Point3d[10];

我们预期 Point 和 Point3d 的constructor 会各被调用 10 次。当我们 delete 这个 ptr 所指向的 10 个 Point3d 元素时,很显然需要虚拟机制的帮助,以获得 Point 和 Point3d 的 destructor 的各 10 次调用。
1
delete [] ptr;

然而,施行于数组上的 destructor 是根据vec_delete()函数之“被删除的指针类型的 destructor”——本例中为 Point destructor。所以这个程序就会出错。不仅仅是因为执行了错误的 destructor,而且从第一个元素开始往后,destructor 会被施行于不正确的内存区块中(因为元素大小不对)。

所以,我们应该避免以一个 base class 指针指向一个 derived class object class 所组成的数组。如果一定这样写程序,解决之道在于程序员层面,而非语言层面:

1
2
3
4
for (int ix = 0; ix < elem_count; ++ix) {
Point3d *p = & ((Point 3d*)ptr)[ix];
delete p;
}

基本上,程序员必须迭代走过整个数组,把delete达算符实施于每一个元素身上.以此方式,调用操作将是virtual,因此,Point3d和Point的destructor都会施行于数组中的每一个objects身上。

Placement Operator new 的语义

有一个预先定义好的重载的 new 运算符,称为 placement operator new。它需要第二个参数,类型为void*,调用方式如下:

1
Point2w *ptw = new (arena) Point2w;

其中 arena 指向内存中的一个区块,用来放置新产生出来的 Point2w object。其实现方式出乎意料的平凡,它只要将获得的指针所指的地址传回即可:
1
2
3
void* operator new(size_t, void* p) {
return p;
}

看起来没啥用,只是传回第二个参数,但其实还有另一半操作,placement new operator 所扩充的另一半是将 Point2w constructor 自动实施于 arena 所指的地址上
1
2
3
// Pseudo C++ code
Point2w ptw = (Point2w *)arena;
if (ptw != 0) ptw->Point2w::Point2w();

这份代码决定了objects被放置于哪里:编译系统保证 object 的 constructor 会施行于其上。但却有一个轻微的不良行为:
1
2
3
4
5
6
7
// let arena be globally defined
void fooBar() {
Point2w *p2w = new (arena) Point2w;
// ... do it ...
// ... now manipulate a new object ...
p2w = new (arena) Point2w;
};

如果 placement new 在原已存在的一个 object 上构造新的 object,而该 object 有一个 destructor,则这个 destructor 是不会被调用的。调用该 destructor 的方法之一是将那个指针 delete 掉,不过在此例中这样做是错误的,因为 delete 还会释放 p2w 所指内存,而我们马上还需要用这块内存。因此,我们应该显式调用 destructor(现在有一个 placement operator delete,无需手动调用 destructor 了):
1
2
p2w->~Point2w;
p2w = new (arena) Point2w;

还有一个问题是:如何知道 arena 所指的内存区块是否需要先解构?这在语言层面上没有解答,合理的习俗是令执行 new 的这一端也要负责执行 destructor。

另一个问题是 arena 所表现的真正指针类型,它必须指向相同类型的 class,或者是一块“新鲜”的内存,足够容纳该类型的 object。注意,derived class 很明显不在被支持之列。对于一个derived class,或是其他没有关联的类型,其行为虽然并非不合法,却也未经定义。

新鲜额存储空间可以这样配置而来:

1
char *arena = new char [ sizeof(Point2w) ];

相同类型的object则可以这样获得:

1
Point2w *arena = new Point2w;

不论哪一种情况,新的Point2w的储存空间的确是覆盖了arena的位置,而此行为已在良好控制之下。然而,一般而言,placement new operator并不支持多态(polymorphism)。被交给new的指针,应该适当地指向一块预先配置好的内存。如果derived class比其base class大,例如:

1
Point2w *p2w = new (arena) Point3w;

Poit3w的constructor将会导致严重的破坏。

Placement new perator被引入C++ 2.0时,最晦涩隐暗的问题就是下面这个:

1
2
3
4
5
6
7
8
9
10
strcut Base { int j; virtual void f(); };
struct Derived : Base { void f(); }

void fooBar() {
Base b;
b.f(); // Base::f()被调用
b.~Base();
new (&b) Derived; // 1
b.f(); // 哪一个f()被调用
}

上述两个classes有相同的大小,故把derived object放在为base class配置的内存中是安全的,然而,要支持这一点,或许必须放弃对于“经由object静态调用所有virtual function”通常都会有的优化处理。结果,placement new operator的这种使用方式在C++中未能获得支持。于是上述程序的行为没有定义,大部分编译器调用的是Base::f()。

一般而言,placement new operator 并不支持多态。被交给 new 的指针应该是预先配置好的。

临时性对象(Temporary Objects)

如果找们有一个函数,形式如下:

1
T operator+(const T&, const T&);

以及两个T objects,a和b,那么:
1
a + b

可能会导致一个临时性对象,以放置传回的对象。是否会导致一个临时性对象,视编译器的进取性(aggressiveness)以及上述操作发生时的程序上下关系(program context)而定,例如下面这个片段:
1
2
T a, b;
T c = a + b;

编译器会产生一个临时性对象,放置a + b的结果,然后再使用T的copy constructor,把该临时性对象当做c的初始值。然而比较更可能的转换是直接以拷贝构造的方式,将a + b的值放到c中(2.3节对于加法运算符的转换曾有讨论),于是就不需要临时性对象,以及对其constructor和destructor的调用了。

此外,视operator+()的定义而定,named return value (NRV)优化(请看2.3 节)也可能实施起来。这将导致直接在上述c对象中求表达式结果,避免执行copy constructor和具名对象(named object)的destructor。

三种方式所获得的c对象,结果都一样。其间的差异在于初始化的成本。一个编译器可能给我们任何保证吗?严格地说没有。C++ Standard允许编译器对于临时性对象的产生有完全的自由度。由于市场竞争,几乎保证任何表达式如果有这种形式:

1
T c = a + b;

而其中的加法运算符被定义为:

1
T operator+(const T&, const T&);


1
T T::operator+(const T&);

那么实现时根本不产生一个临时性对象。

然而请你注意,意义相当的assignment叙述句(statement):

1
c = a + b;

不能够忽略临时性对象。相反,它会导致下面的结果:
1
2
3
4
5
6
7
8
//C++伪码
// T temp = a + b;
T temp;
temp.operator+(a, b); // (1)

// c = temp
c.operator=(temp); //(2)
temp.T::~T();

标示为(1)的那一行,未构造的临时对象被赋值给operator+()。这意思是要不是“表达式的结果被copy constructed至临时对象中”,就是“以临时对象取代NRV”:在后者中,原本要施行于NRV的constructor,现在将施行于该临时对象。

不管哪一种情况,直接传递c(上例赋值操作的目标对象)到运算符函数中是有问题的。由于运算符函数并不为其外加参数调用一个destructor(它期望一块 “新鲜的”内存),所以必须在此调用之前先调用destructor。然而,“转换”语意将被用来将下面的assignment操作:

1
c = a + b; // c.operator=( a + b );

取代为其copy assignment运算符的隐含调用操作,以及一系列的destructor和copy construction:

1
2
3
// C++伪码
c.T::~T()
c.T::T(a + b);

copy constructor、destructor以及copy assignment operator都可以由使用者供应,所以不能够保证上述两个操作会导致相同的语意.因此,以一连串的destruction和copy construction来取代assignment,一般而言是不安全的,而且会产生临时对象。所以这样的初始化操作:

1
T c = a + b;

总是比下面的操作更有效率地被编译器转换:
1
c = a + b;

第三种运算形式是没有出现目标对象:

1
a + b;

这时候有必要产生一个临时对象放置运算后的结果。例如如果:

1
String s("hello"), t("world"), u("!");

那么不论:
1
2
String v;
v = s + t + u;


1
printf("%s\n", s + t);

都会导致产生一个临时对象,与s + t相关联。

“临时对象的生命期”论题颇值得深入探讨。在Standard C++之前,临时对象的生命(也就是说它的destructor何时实施)并没有明确指定,而是由编译厂商自行决定。换句话说,上述的printf并不保证安全,因为它的正确性与s + t何时被摧毁有关。本例的一个可能性是,String class定义了一个conversion运算符如下:

1
String::operator const char*(){return _str; }

其中,_str是一个private member addressing storage,在String object构造时配置,在其destructor中被释放。

因此,如果果临时对象在调用printf之前就被解构了,经由convertion运算符交给它的地址就是不合法的.真正的结果视底部的delete运算符在释放内存时的进取性而定。某些编译器可能会把这块内存标示为free,不以任何方式改变其内容。在这块内存被其它地方宣称主权之前,只要它还没有被deleted掉,它就可以被使用。像这样在内存被释放之后又再被使用,并非罕见。事实上malloc()的许多编译器会提供一个特殊的调用操作:

1
malloc(0);

下面是对于该算式的一个可能的pre-Standard转化。虽然在pre-Standard语言定义中是合法的,但可能造成重大灾难。

1
2
3
4
5
6
7
8
9
10
// C++伪码:pre-Standard的合法转换
// 临时性对象被摧毁得太快(太早)了
String temp1 = operator+(s, t);
const char *temp2 = temp1.operator const char*();

// 合法但是有欠考虑,太过轻率
temp1.~String();

// 这时候并未定义temp2指向何方
printf("%s\n", temp2);

另一种(比较被喜欢的)转换方式是在调用printf()之后实施String destructor。在C++ Standard之下,这正是该表达式的必须转换方式。标准规格上这么说:

临时性对象的被摧毁,应该是对完整表达式〔full-expression)求值过程中的最后一个步骤。该完整表达式造成临时对象的产生。

什么是一个完整表达式(full-expression)?非正式地说,它是被涵括的表达式中最外围的那个。下面这个式子;

1
(( objA > 1024) && (objB > 1024 )) ? objA + objB : foo(objA, objB);

一共有五个子算式(subexpressions),内带在一个“?:完整表达式”中。任何一个子表达式所产生的任何一个临时对象,都应该在完整表达式被求值完成后,才可以毁去

当临时性对象是根据程序的执行期语意有条件地被产生出来时,临时性对象的生命规则就显得有些复杂了。举个例子,像这样的表达式:if (s + t || u + v),其中的u + v子算式只有在s + t被评估为false时,才会开始被评估。与第二个子算式有关的临时性对象必须被摧毁。但是,很明显地,不可以被无条件地摧毁。也就是说,我们希望只有在临时性对象被产生出来的情况下才去摧毁它。

在讨论临时对象的生命规则之前,标准编译器将临时对象的构造和解构附着于第二个子算式的评估程序中。例如,对于以下的class声明:

1
2
3
4
5
6
7
8
9
class X {
public:
x();
~X();
operator int();
X foo();
private:
int val;
};

以及对于class X的两个objects的条件测试:
1
2
3
4
5
6
7
main() {
X xx;
X yy;
if( xx.foo() || yy.foo() )
;
return;
}

cfront对于main()产生出以下的转换结果(已经过轻微的修润和注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int main (void) 
{
struct X __lxx;
struct X __lyy;
int __0_result;

// name_mangled default constructor:
// X:X( X *this)
__ct__1xFv( &__lxx);
__ct__1xFv( &__lyy);

{
// 被产生出来的临时性对象
struct X __0__Q1;
struct X __0__Q2;
int __0__Q3;
/* 每一端变成一个附逗点的表达式
* 有着以下的次序:
*
* tempQ1 = xx.foo();
* tempQ3 = tempQ1.operator int();
* tempQ1.X::-X();
* tempQ3;
*/
// __opi__1xFv ==> X::operator int()
if ((((
__0__Q3 = __opi__1xFv(((
__0__Q1 = foo__1xFv( &__1xx )), ( &__0__Q1 )))),
__dt__1xFv( &__0__Q1, 2 )), __0__Q3)
|| (((
__0__Q3 = __opi__1xFv(((
__0__Q2 = foo__1xFv( &__1yy )), ( &__0__Q2)))),
__dt__1xFv( &__0__Q2, 2 )), __0__Q3 ))
{
__0_result = 0;
__dt_1xFv( &__lyy, 2 );
__dt_1xFv( &__lxx, 2 );
}
return __0_result;
}
}

把临时性对象的destructor放在每一个子算式的求值过程中,可以免除“努力追踪第二个子算式是否真的需要被评估”。然而在C++ Standard的临时对象生命规则中,这样的策略不再被允许。临时性对象在完整表达式尚未评估完全之前,不得被摧毁。也就是说,某些形式的条件测试现在必须被安插进来,以决定是否要摧毁和第二算式有关的临时对象。

临时性对象的生命规则有两个例外。第一个例外发生在表达式被用来初始化一个object时。例如:

1
2
3
bool verbose;
...
String progNameVersion = !verbose ? 0 : progName + progVersion;

其中progName和progVersion都是String objects。这时候会生出一个临时对象,放置加法运算符的运算结果:

1
String operator+(const String&, const String&);

临时对象必须根据对verbose的测试结果有条件地解构。在临时对象的生命规则之下,它应该在完整的“? : 表达式”结束评估之后尽快被摧毁。然而,如果progNameVersion的初始化需要调用一个copy constructor:

1
2
// C++伪码
progNameVersion.String::String(temp);

那么临时性对象的解构(在“?:完整表达式”之后)当然就不是我们所期望的。

C++ Standard要求说:

凡含有表达式执行结果的临时性对象,应该存留到object的初始化操作完成为止。

甚至即使每一个人都坚守C++ Standard中的临时对象生命规则,程序员还是有可能让一个临时对象在他们的控制中被摧毁。其间的主要差异在于这时候的行为有明确的定义。例如,在新的临时对象生命规则中,下面这个初始化操作保证失败:

1
2
// 不是个好主意 
const char *progNameversion = progName + progVersion;

其中progName和progVersion都是String objects。产生出来的程序代码看起来像这样:
1
2
3
4
5
// C++ pseudo Code 
String temp;
operator+(temp, progName, progVersion);
progNameVersion = temp.String::operator char*();
temp.String::~String();

此刻progNameVersion指向未定义的heap内存!

临时性对象的生命规则的第二个例外是“当一个临时性对象被一个reference绑定”时,例如:

1
const String &space = ;

产生出这样的程序代码:
1
2
3
4
//C++ pseudo Code 
String temp;
temp.String::String(" ");
const String &space = temp;

很明显,如果临时性对象现在被摧毁,那个reference也就差不多没什么用了。所以规则上说:

如果一个临时性对象被绑定于一个reference,对象将残留,直到被初始化之reference的生命结束,或直到临时对象的生命范畴(scope)结束―视哪一种情况先到达而定。

临时性对象的迷思(神话、传说)

有一种说法是,由于当前的C++编译器会产生临时性对象,导致程序的执行比较没有效率。更有人认为,这种效率上的不彰足以掩盖C++在“抽象化”上的贡献。

第七章 站在对象模型的尖端(On the Cusp of the Object Model)

template

下面是有关 template 的三个主要讨论方向:

  1. template 的声明。基本上来说就是当你声明一个 template class、template class member function 等等时,会发生什么事情。
  2. 如何“具现(instantiates)”出 class object 以及 inline nonmember,以及 member template functions,这些是“每一个编译单位都会拥有一份实体”的东西。
  3. 如何“具现(instantiates)”出 nonmember 以及 member template functions,以及 static template class members,这些都是“每一个可执行文件中只需要一份实体”的东西。这也就是一般而言 template 所带来的问题。

“具现(instantiation)”表示“将真正的类型和表达式绑定到 template 相关形式参数(formal parameters)上头”的操作。下面是一个template function:

1
2
3
template <class Type> 
Type
min (const Type &t1, const Type &t2) { ... }

用法如下:
1
min(1.0, 2.0);

进程把Type绑定为double并产生min()的一个程序实体,其中t1t2的类型都是double

Template 的“具现”行为(Template Instantiation)

有如下 template Point class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class Type>
class Point {
public:
enum Status { unallocated, normalized };
Point(Type x = 0.0, Type y = 0.0, Type z = 0.0);
~Point();
void *operator new(size_t);
void operator delete(void *, size_t);
// ...
private:
static Point<Type> *freeList;
static int chunkSize;
Type _x, _y, _z;
};

当编译器看得到template class声明时,什么也不会做,其 static data members 也并不可用,nested enum 也一样。

虽然 enum Status 的真正类型在所有的 Point instantiation 中都一样,其 enumerators 也是,但它们依然只能通过 template Point class 的某个实体来存取或操作:

1
2
3
4
// ok:
Point<float>::Status s;
// error:
Point::Status s;

对于 freeList 和 chunkSize 也是一样的道理:
1
2
3
4
// ok:
Point<float>::freeList;
// error:
Point::freeList;

像这样使用 static member,会产生一份类型为 float 的 Point class 实体,如果写下:Point<double>::freeList;,则会出现第二个实体。

如果定义一个指针,指向特定的实体: Point<float>* ptr = 0;,则什么也不会发生,因为 class object 的指针,本身并不是一个 class object,编译器无需知道与该 class 有关的任何 members 的数据或 object 布局数据,所以没有具现的必要。

如果不是 pointer 而是 reference:

1
const Point<float>& ref = 0;

则真的会具现出一个“Point 的 float 实体”来。这个定义会被扩展为:
1
2
Point<float> temporary(float(0));
const Point<float>& ref = temporary;

因为reference并不是无物的代名词,0被视作整数,必须要转换为以下类型的一个对象:

1
Point<float>

所以,一个 class object 的定义(隐含或明确的)就会导致 template class 的具现。也就是说,像上面的语句,会让 Type 绑定为 float,所以 temporary 会配置出能够容纳三个 float 成员的空间。

对于 member functions, 只有在使用它们的时候才会被具现。当前的编译器并不精确遵循这项要求。之所以由使用者来主导“具现”
(instantiarition)规则,有两个主要原因:

  1. 空间和时间效率的考虑。如果ctass中有100个member functions,但你的程序只针对某个类型使用其中两个,针对另一个类型使用其中五个,那么将其它193个函数都“具现”将会花费大量的时间和空间。
  2. 尚未实现的机能。并不是一个template具现出来的所有类型就一定能够完整支持一组member functions所需要的所有运算符。如果只“具现”那些真正用到的member functions,template就能够支持那些原本可能会造成编译时期错误的类型(types)。

举个例子,origin的定义需要调用Point的default constructor和destructor,那么只有这两个函数需要被“具现”。类似的道理,当程序员写:

1
Point<float> *p = new Point<float>;

时,只有(1)Point template的float实例,(2) new运算符,(3) default constructor 需要被“具现”化。有趣的是,虽然new运算符是这个class的一个implicitly static member,以至于它不能够直接处理其任何一个nonstatic members,但它还是依赖真正的template参数类型,因为它的第一参数size_t代表class的大小。

这些函数在什么时候“具现”出来呢?当前流行两种策略:

  • 在编译时候。那么函数将“具现”于origin和p存在的那个文件中;
  • 在链接时候。那么编译器会被辅助工具重新激活。template函数实体可能被放在这个文件中、别的文件中,或一个分离的储存位置上,

在“int和long一致”(或“double和long double一致”)的结构之中,两个类型具现操作:

1
2
Point<int> pi;
Point<long> pl;

应该产生一个还是两个实体呢?目前我知道的所有编译器都产生两个实体(可能有两组完整的member functions)。C++ Standard并未对此有什么强制规定。

Template 的错误报告(Error Reporting within a Template)

在 template class 中,所有与类型有关的检验,如果牵涉到 template 参数,都必须延迟到真正的具现操作(instantiation)发生,才可以进行。

在编译器处理 template 声明时,cfront 对 template 的处理是完全解析(parse)但不做类型检验,只有在每一个具现操作(instantiation)发生时才做类型检验。所以,对于语汇(lexing)错误和解析(parsing)错误都会在处理 template 声明的过程中被标示出来。

还有一种普遍的做法是,template 的声明被收集成为一系列的“lexical tokens”,而 parsing 操作延迟,直到真正有具现操作(instantiation)发生时才开始,在这之前,很少有错误会被指出。每当看到一个instantiation发生,这组token就被推往parser,然后调用类型检验等等。

目前的编译器,面对一个 template 声明,在被一组实际参数具现之前,只能施行有限的错误检查,template 中与语法无关的错误,编译器都会通过,只有在特定实体被定义之后,才会报错。

Template 中的名称决议方式(Name Resolution within a Template)

要区分出以下两种意义,一种是“scope of the template definition”,也就是定义出 template 的程序,另一种是“scope of the template instantiation”,也就是具现出 template 的程序。例如第一种情况:

1
2
3
4
5
6
7
8
9
10
11
12
// scope of the template definition
extern double foo(double);
template <class type>
class ScopeRules {
public:
void invariant() { _member = foo(_val); }
type type_dependent() { return foo(_member); }
// ...
private:
int _val;
type _member;
};

第二种情况:
1
2
3
4
// scope of the template instantiation
extern int foo(int);
// ...
ScopeRules<int> sr0

在 ScopeRules template 中有两个foo()调用操作。在“scope of template definition”中,只有一个foo()函数声明位于 scope 内,而“scope of template instantiation”中,有两个foo()函数声明位于 scope 内。如果我们有一个函数调用操作,
1
sr0.invariant();

那么,在invariant()中调用的是哪一个foo()函数实体呢?在调用操作的那一点上,程序中的两个函数实体是:

1
2
extern double foo(double);
extern int foo(int);

_val的类型是int,被选中的是:

1
extern double foo(double)

实际上,在 template 之中,对于一个 nonmember name 的决议结果,是根据这个 name 的使用是否与 template 的类型参数有关而决定的。如果互不相关,就以“scope of the template declaration”来决定 name。否则,以“scope of the template instantiation”来决定 name。

那么对于以下两个调用操作:

1
2
sr0.invariant();
sr0.type_dependent();

第一行的调用,invariant()中的foo()与用以具现 ScopeRules 的参数类型无关,_val的类型是int,是不会变的,此外,函数的决议结果只和函数的原型(signature)有关,与函数的返回值无关,所以_member的类型不影响哪个foo()实体被选中,foo()的调用与 template 参数毫无关联,所以必须根据“scope of the template declaration”来决议。

对于第二行的调用,很明显与 template 参数有关,template 参数将决定_member的真正类型。所以这一次foo()必须在“scope of the template instantiation”中决议,本例中,这个 scope 有两个foo(),所以如果_member的类型为 int,就会调用 int 版的foo(),如果是 double 则调用 double 版本的foo(),如果是 long,那么就会产生歧义了。

所以编译器必须保持两个 scope contexts:

  1. “scope of the template declaration”,用以专注于一般的 template class。
  2. “scope of the template instantiation”,用以专注于特定的实体。

Member Function 的具现行为(Member Function Instantiation)

下面是编译器设计者必须回答的三个主要问题:

  1. 编译器如何找出函数的定义?答案之一是包含template program text file,就好像它是个header文件一样。Borland编译器就是遵循这个策略。另一种方法是要求一个文件命名规则,例如,我们可以要求,在Point.h文件中发现的函数声明,其template program text一定要放置于文件Point.CPoint.cpp中,依此类推。cfront就是遵循这个策略。Edison Design Group编译器对此两种策略都支持。
  2. 编译器如何能够只具现出程序中用到的member functions?解决办法之一就是,根本忽略这项要求,把一个已经具现出来的class的所有member functions都产生出来。Borland就是这么做的,虽然它也提供#pragmas让你压制特定实体。另一种策略是仿真链接操作,检测看看那一个函数真正需要,然后只为它们产生实体。cfront就是这么做的。
  3. 编译器如何阻止 member definition 在多个 .o 文件中都被具现呢?解决办法之一就是产生多个实体,然后从链接器中提供支持,只留下其中一个实体,其余都忽略。另一个办法就是由使用者来导引“仿真链接阶段”的具现策略,决定哪些实体(instances)才是所需求的。

目前,不论编译时期或链接时期的具现(instantiation)策略,其弱点就是,当template实体被产生出来时,有时候会大量增加编译时间。很显然,这将是template functions第一次具现时的必要条件。然而当那些函数被非必要地再次具现,或是当“决定那些函数是否需要再具现”所花的代价太大时,编译器的表现令人失望!

C++支持template的原始意图可以想见是一个由使用者导引的自动具现机制(use-directed automatic instantiation mechanism),既不需要使用者的介入,也不需要相同文件有多次的具现行为。但是这已被证明是非常难以达成的任务。

Edison Design Group 开发出一套第二代的 directed-instantiation 机制,主要过程如下:

  1. 一个程序代码被编译时,最初不会产生任何“template具现体”。然而,相关信息已经产生于 object files 之中。
  2. 当 object file 被链接在一块时,会执行一个 prelinker 程序,它会检查 object files,寻找 template 实体的相互参考以及对应的定义。
  3. 对于每个“参考到 template 实体”而“该实体却没有定义”的情况,prelinker 将该文件视为与另一个文件(在其中,实体已经具现)同类。以这种方法,就可以将必要的程序具现操作指定给特定的文件。这些都会注册到 prelinker 所产生的 .ii 文件中。
  4. prelinker 重新执行编译器,重新编译每个“.ii 文件被改变过”的文件。这个过程不断重复,直到所有必要的具现操作都已完成。
  5. 所有的 object files 被链接成一个可执行文件。

异常处理(Exception Handling)

欲支持exception handling,编译器的主要工作就是找出catch子句,以处理被丢出来的exception。这多少需要追踪程序堆栈中的每一个函数的当前作用区域(包括追踪函数中的local class objects当时的情况)。同时,编译器必须提供某种查询exception objects的方法,以知道其实际类型(这直接导致某种形式的执行期类型识别,也就是RTTI)。最后,还需要某种机制用以管理被丢出的 object, 包括它的产生、储存、可能的解构(如果有相关的destructor)、清理(clean up)以及一般存取。也可能有一个以上的objects同时起作用。一般而言,exception handling机制需要与编译器所产生的数据结构以及执行期的一个exception library紧密合作。在程序大小和执行速度之间,编译器必须有所抉择:

  1. 为了维持执行速度,编译器可以在编译时期建立起用于支持的数据结构。这会使程序的大小膨胀,但编译器可以几乎忽略这些结构,直到有个exception被丢出来。
  2. 为了维护程序大小,编译器可以在执行期建立起用于支持的数据结构。这会影响程序的执行速度,但意味着编译器只有在必要的时候才建立那些数据结构(并目可以抛弃之)

Exception Handling 快速检阅

C++ 的 exception 由三个主要的语汇组件构成:

  1. 一个 throw 子句。它在程序某处发出一个 exception。被丢出的 exception 可以是内建类型,也可以是使用者自定类型。
  2. 一个或多个 catch 子句。每一个 catch 子句都是一个 exception handler。它用来表示说,这个子句准备处理某种类型的 exception,并且在封闭的大括号区段中提供实际的处理程序。
  3. 一个 try 区段。它被围绕以一系列的 statements,这些语句可能会引发 catch 子句起作用。

当一个 exception 被丢出时,控制权会从函数调用中被释放,并寻找吻合的 catch 子句。如果都没有吻合者,那么默认的处理例程terminate()会被调用。当控制权被放弃后,堆栈中的每个函数调用就被推离。这个程序称为unwinding the stack。在每个函数被推离堆栈之前,函数的 local class objects 的 destructor 会被调用。

在程序员层面,exception handling 也改变了函数在资源管理上的语义。例如下列程序,在 exception handling 下并不能保证正确运行:

1
2
3
4
5
6
7
8
void mumble(void *arena) {
Point *p = new Point;
smLock(arena); // function call
// 如果有一个 exception 在此发生,问题就来了
// ...;
smUnLock(arena); // function call
delete p;
}

本例中,exception handling 机制把整个函数视为单一区域,不需要操心“将函数从程序堆栈中unwinding”的事情。然而从语义上来说,在函数推出堆栈前,需要 unlock 共享内存,并delete p。所以应该像这样安插一个default cache:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void mumble(void *arena) {
Point *p;
p = new Point;
try {
smLock(arena);
// ...
} catch (...) {
smUnLock(arena);
delete p;
throw;
}
smUnLock(arena);
delete p;
}

注意,new 运算符的调用并非在 try 区段内。因为 new 运算符丢出一个 exception,那么就不需要配置 heap 中的内存,Point constructor 也不需要被调用。所以没有理由调用 delete 运算符。然而如果在 Point constructor 中发生 exception,此时内存已配置完成,那么 Point 之中任何构造好的合成物或子对象都将自动解构,然后 heap 内存也会被释放。不论哪种情况,都无需调用 delete 运算符。类似的,如果一个exception是在new运算符执行过程中被丢出,arena所指向的内存就绝不会被locked。因此也没有必要unlock。

对于这些资源管理问题,有一个办法就是将资源需求封装于一个 class object 体内,并由 destructor 来释放资源。

1
2
3
4
5
6
7
8
9
10
11
void mumble( void *arena) {
auto_ptr<Point> ph ( new Point);
SMLock sm(arena};

// 如果这里丢出一个exception,现在就没有问题了
// ...
// 不需要明确地unlock和delete
// local destructors在这里被调用
// sm.SMLock::~SMLock();
// ph.auto_ptr<Point>::~auto_ptr<Point>();
}

从exception handling的角度看,这个函数现在有三个区段:

  • 第一区段是auto_ptr被定义之处。
  • 第二区段是SMLock被定义之处。
  • 上述两个定义之后的整个函数。

如果exception是在auto_ptr中被丢出的,那么就没有active local objects需要被EH机制摧毁。然而如果SMLock constructor中丢出一个exception,则auto_ptr object必须在“unwinding”之前先被摧毁。至于在第三个区段中,两个local objects当然必须被摧毁。

支持 EH,会使那些拥有 member class subobject 或 base class subobject 的 class 的 constructor 更复杂。一个 class 如果被部分构造,则其 destructor 必须只施行于那些已被构造的 subobject 和 member object 身上。这些事情都是编译器的责任。例如,假设class X有member objects A、B和C,都各有一对constructor和destructor,如果A的constructor丢出一个exception,不论A或B或C都不需要调用其destructor,如果B的constructor丢出一个exception,则A的destructor必须被调用,但C不用。

同样的道理,如果程序员写下:

1
Point3d *cvs = new Point3d[512];

会发生两件事:

  1. 从 heap 中配置足以给 512 个 Point3d objects 所用的内存。
  2. 如果成功,先是 Point2d constructor,然后是 Point3d constructor,会施行于每个元素身上。

如果 # 27 元素的 Point3d constructor 丢出一个 exception,对于 #27 元素,只有 Point2d destructor 需要调用。前 26 个元素 Point2d 和 Point3d 的destructor 都需要调用,然后释放内存。

对 Exception Handling 的支持

当一个 exception 发生时,编译系统必须完成以下事情:

  1. 检验发生 throw 操作的函数;
  2. 决定 throw 操作是否发生在 try 区段中;
  3. 若是,编译系统必须把 exception type 拿来和每一个 catch 子句比较;
  4. 如果比较吻合,流程控制应该交到 catch 子句手中;
  5. 如果 throw 的发生并不在 try 区段中,或没有一个 catch 子句吻合,那么系统必须 (a) 摧毁所有 active local objects,(b) 从堆栈中将当前的函数“unwind”掉,(c) 进行到程序堆栈中的下一个函数中去,然后重复上述步骤 2~5。

一个函数可以被想象成是好几个区域:

  • try区段以外的区域,而且没有active local objects
  • try区段以外的区域,但有一个(以上)的active local objects需要解构
  • try区段以内的区域。

编译器必须标示出以上各区域,并使它们对执行期的exception handling系统有所作用。一个很棒的策略就是构造出program counter-range表格。

program counter(译注:在Intel CPU中为EIP缓存器)内含下一个即将执行的程序指令。为了在一个内含try区段的函数中标示出某个区域,可以把program counter的起始值和结束值(或是起始值和范围)储存在一个表格中。

当throw操作发生时,当前的program counter值被拿来与对应的“范围表格”进行比较,以决定当前作用中的区域是否在一个try区段中。如果是,就需要找出相关的catch子句(稍后我们再来看这一部分)。如果这个exception无法被处理(或者它被再次丢出),当前的这个函数会从程序堆栈中被推出(popped),而program counter会被设定为调用端地址,然后这样的循环再重新开始。

将exception的类型和每一个catch子句的类型做比较

对于每一个被丢出来的exception,编译器必须产生一个类型描述器,对exception的类型进行编码。如果那是一个derived type,则编码内容必须包括其所有base class的类型信息。只编进public base class的类型是不够的,因为这个exception可能被一个member function捕捉,而在一个member function的范围(scope)之中,在derived class和nonpublic base class之间可以转换。

类型描述器(type descriptor)是必要的,因为真正的exception是在执行期 被处理,其object必须有自己的类型信息。RTTI正是因为支持EFI而获得的副产品。

编译器还必须为每一个catch子句产生一个类型描述器。执行期的exception handler会对“被丢出之object的类型描述器”和“每一个cause子句的类型描述器”进行比较,直至找到吻合的一个,或是直到堆栈已经被”unwound”而terminate()已被调用。

每一个函数会产生出一个exception表格,它描述与函数相关的各区域、任何必要的善后码(cleanup code、被local class object destructors调用),以及catch子句的位置(如果某个区域是在try区段之中)。

当一个实际对象在程序执行时被丢出,会发生什么事

当一个exception被丢出时,exception object会被产生出来并通常放置在相同形式的exception数据堆栈中。从throw端传给catch子句的是exception object的地址、类型描述器(或是一个函数指针,该函数会传回与该exception type 有关的类型描述器对象),以及可能会有的exception object描述器(如果有人定义它的话)。

考虑如下 catch 子句:

1
2
3
4
catch(exPoint p) {
// do something
throw;
}

以及一个 exception object,类型为 exVertex,派生自 exPoint。这两种类型都吻合,于是 catch 子句会作用起来:

  • p 将以 exception object 作为初值,就像是一个函数参数一样。这意味着 copy constructor 和 destructor(如果有)都会实施于local copy 身上。
  • 由于 p 是一个 object 而非 reference,所以拷贝时,non-exPoint 部分会被切掉(sliced off)。此外,如果有 virtual function,那么 p 的 vptr 会被设为 exPoint 的 virtual table,exception object 的 vptr 不会被拷贝。

当这个 exception 再一次被丢出时,p 将是一个 local object,在 catch 子句的末端被摧毁,且丧失了原来 exception 的 exVertex 部分。任何对 p 的修改都会被抛弃。

如下 catch 子句:

1
2
3
4
catch(exPoint& rp) {
// do something
throw;
}

则是参考到真正的 exception object,且任何虚拟调用都会发生作用。任何对此 object 的改变都会被繁殖到下一个 catch 子句中。

最后,如果 throw 出一个 object:throw errVer;,是一个复制品被构造出来,全局的 errVer 并没有被繁殖。catch 语句中对于 exception object 的任何改变都是局部性的,不会影响 errVer

执行期类型识别(Runtime Type Identification,RTTI)

在cfront中,用以表现出一个程序的所谓“内部类型体系”,看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
// 程序层次结构的根类(root class)
class node { ... };
// root of the'type'subtree: basic types
// 'derived'types: pointers, arrays,
// functions, classes,enums

class type: public node { ... };

//two representations for functions
class fct : public type { ... };
class gen : public type { ... };

其中gen是generic的简写,用来表现一个overloaded function。

于是只要你有一个变量,或是类型为type*的成员(并知道它代表一个函数),你就必须决定其特定的derived type是否为fct或是gen。在2.0之前,除了destructor之外唯一不能够被overloaded的函数就是conversion运算符,例如:

1
2
3
4
5
class String{
public:
operator char*();
// ...
};

在2.0入const member functions之前,conversion运算符不能够被overloaded,因为它们不使用参数。直到引进了const member functions后,情况才有所变化。现在,像下面这样的声明就有可能了:

1
2
3
4
5
6
class String{
public:
//ok with Release 2.0
operator char*();
operator char*()const; //
};

也就是说,在2.0版之前,以一个explicit cast来存取derived object总是安全(而且比较快速)的,像下面这样:

1
2
3
4
5
6
7
8
9
typedef type *ptype;
typedef fct *pfct;

simplify_conv_op(ptype Pt)
{
//ok: conversion operators can only be fcts
pfct pf = pfct(pt);
//
};

在const member functicns引入之前,这份码是正确的。请注意其中甚至有一个批注,说明这样的转型的安全。但是在const member functions引进之后,不论程序批注或程序代码都不对了。程序代码之所以失败,非常不幸是因为String class声明的改变.因为char * cnversion运算符现在被内部视为一个gen而不是一个fct。

下面这徉的转型形式:

1
pfct pf = pfct(pt);

被称为downcast(向下转型),因为它有效地把一个base class转换至继承结构的末端,变成其derived classes中的某一个。downcast有潜在性的危险,因为它遏制了类型系统的作用,不正确的使用可能会带来错误的解释(如果它是一个read操作)或腐蚀掉程序内存(如果它是一个write操作)。在我们的例子中,一个指向gen object的指针被不正确地转型为一个指向fct object的指针pf。有后续对pf的使用都是不正确的(除非只是检查它是否为0,或只是把它拿来和其它指针作比较)。

Type-Safe Downcast(保证安全的向下转型操作)

C++ 缺乏一个保证安全的 downcast(向下转型操作)。只有在“类型真的可以被适当转型”的情况下,才能执行 downcast。一个 type-safe downcast 必须在执行期对指针有所查询,看看它是否指向它所展现之 object 的真正类型,于是在 object 空间和执行时间上都需要一些额外负担:

  • 需要额外的空间以存储类型信息(type information),通常是一个指针,指向某个类型信息节点。
  • 需要额外的时间以决定执行期的类型(runtime type),因为,正如其名所示,这需要在执行期才能决定。

这样的机制面对下而这样平常的C结构,会如何影响其大小、效率、以及 链接兼容性呢?

1
char *winnie_tbl[] = {"rumbly in my tummy", "oh, bother"};

很明显,它所导致的空间和效率上的不良报应甚为可观。

冲突发生在两组使用者之间;

  1. 程序员大量使用多态(polymorphism),并因而需要正统而合法的大量downcast操作。
  2. 程序员使用内建数据类型以及非多态设备,因而不受各种额外负担所带来的报应。

理想的解决方案是,为两派使用者提供正统而合法的需要―虽然或许得牺牲一些设计上的纯度与优雅性。你知道要怎么做吗?

C++ 的 RTTI 机制提供一个安全的 downcast 设备,但只对那些展现“多态”的类型有效。如何分辨一个 class 是否展现多态? RTTI 所采用的策略是经由声明一个或多个 virtual function 来区别 class 声明,优点是透明化地将旧有程序转换过来,只要重新编译就好,缺点则是可能会将一个其实并非必要的virtual function强迫导入继承体系的base class身上。在 C++ 中,一个具备多态性质的 class 正是内含继承而来(或直接声明)的 virtual functions。

从编译器角度看,这个策略还有其他优点,就是大量降低额外负担。所有多态类的 object 都维护了一个指针,指向 virtual function table。只要把与该 class 相关的 RTTI object 地址放进 virtual table 中(通常放在第一个 slot),那么额外负担就降低为:每个 class object 只多花费一个指针。这个指针只需要被设定一次,他是被编译器静态设定而不是在执行期由class constructor设定。

Type-Safe Dynamic Cast(保证安全的动态转型)

dynamic_cast 运算符可以在执行期决定真正的类型。如果 downcast 是安全的(也就是 base type pointer 确实指向一个 derived class object),这个运算符会传回被适当转型过的指针。如果 downcast 不安全,则会传回 0。

type_info是 C++ 所定义的类型描述器的 class 名称,该 class 中放置着待索求的类型信息。virtual table 的第一个 slot 内含 type_info object 的地址。此type_info object与pt所指之class type有关。这两个类型描述器被交给一个runtime library函数,比较之后告诉我们是否吻合。很显然这比static cast昂贵得多,但却安全得多(如果我们把一个fct类型 “downcast”为一个gen类型的话)。

最初对runtime cast的支持提议中,并未引进任何关键词或额外的语法。下面这样的转型操作:

1
2
// 最初对runtime cast的提议语法
pfct pf = pfct(pt);

究竟是static还是dynamic,必须视pt是否指向一个多态class object而定。

Reference 并不是 Pointers

对指针类型实施dynamic_cast运算符,会获得 true 或 false:

  • 如果传回真正的地址,表示这个 object 的动态类型被确认了,一些与类型有关的操作现在可以施行于其上。
  • 如果传回 0,表示没有指向任何 object,意味着应该以另一种逻辑施行于这个动态类型未确定的 object 身上。

dynamic_cast运算符也适用于reference身上。然而对于一个non-type-safe-cast,其结果不会与施行于指针的情况相同.为什么?一个reference不可以像指针那样“把自己设为0便代表了”no object”;若将一个reference设为0,会引起一个临时性对象(拥有被参考到的类型)被产生出来,该临时对象的初值为 0,这个reference然后被设定成为该临时对象的一个别名(alias)。但如果对 reference 实施 dynamic_cast 运算符:

  • 如果 reference 真正参考到适当的 derived class,downcast 会被执行而程序可以继续进行。
  • 如果 reference 并不真正是某一种 derived class,那么,由于不能够传回 0,遂丢出一个 bad_cast exception。

Typeid 运算符

使用typeid运算符,就可能以一个 reference 达到相同的执行期替代路线:

1
2
3
4
5
6
7
8
simplify_conv_op(const type &rt) {
if (typeid(rt) == typeid(fct)) {
fct &rf = static_cast<fct &>(rt);
// ...
} else {
...
}
}

typeid运算符传回一个 const reference,类型为type_info。在先前测试的equality于是暖夫其实是一个被overloaded的函数。
1
bool type_info::operator == ( const type_info &) const;

如果两个 type_info objects 相等,这个 equality 运算符就传回 true。

type_info object由什么组成?C++ Standard (Section 18.5.1)中对type_info的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class type_info {
public:
virtual ~type_info();
bool operator==(const type_info&) const;
bool operator!=(const type_info&) const;
bool before(const type_info&) const;
const char* name() const; // 译注:传回class原始名称
private:
//prevent memberwise init and copy
type_info( const type_info&);
type_info& operator=(const type info&);
//data members
};

编译器必须提供的最小量信息是class的真实名称、以及在type_info objects 之间的某些排序算法(这就是before()函数的目的)、以及某些形式的描述器,用来表现explicit class type和这个class的任何subtypes。在描述exception handling 字符串的原始文章中,曾建议实现出一种描述器:编码后的字符串。

虽然RTTI提供的type_info对于exception handling的支持来说是必要的,但对于exception handling的完整支持而言,还不够。如果再加上额外的一些 type_info derived classes,就可以在exception发生时提供有关于指针、函数及类等等的更详细信息。例如MetaWare就定义了以下的额外类:

1
2
3
4
5
6
class Pointer_type_info : public type_info { ... };
class Member_pointer_info : public type_info { ... };
class Modified_type_info : public type_info { ... };
class Array_type_info : public type_info { ... };
class Func_type_info : public type_info { ... };
class Class_type_info : public type info { ... };

并允许使用者取用它们。不幸的是,那些derived classes的大小以及命名习惯都没有标准。

type_info objects 也适用于内建类型,以及非多态的使用者自定类型,这对于exception handling的支持有必要,例如:

1
2
3
int ex_errno;
...
throw ex_errno;

其中int类型也有其自己的type_info object:
1
2
3
4
int *ptr;
...
if (typeid(ptr) == typeid(int*))
...

在程序中使用typeid(expression)
1
2
3
int ival;
...
typeid(ival) ...;

或是使用typeid(type)
1
typeid(double) ...;

这会传回一个const type_info&,这时候的 type_info object 是静态取得,而非执行期取得。一般的实现策略是在需要时才产生 type_info object,而非程序一开头就产生。

效率有了,弹性呢

动态共享库(Dynamic Shared Libraries)

理想中,一个动态链接的 shared library 应该会透明化的取用新的 library 版本。新的 library 问世不应该对旧的应用程序产生侵略性,应用程序不应该需要为此重新 build 一次。然而目前 C++ 对象模型中,class 的大小及其每个直接(或继承而来)的 members 的偏移量(offset)都在编译时期固定(虚拟继承的 members 除外)。这虽然带来效率,却在二进制层面影响了弹性。如果 object 布局改变,就得重新编译。

共享内存(Shared Memory)

当一个 shared library 被加载,它在内存中的位置由 runtime linker 决定,一般而言与执行中的行程(process)无关。然而在 C++ 对象模型中,当一个动态的 shared library 支持一个 class object,其中含有 virtual function(被放在 shared memory 中),上述说法便不正确。

问题在于“想要经由这个 shared object 附着并调用一个 virtual function”的第二个或更后继的行程(我感觉这里可能想表达的是“进程”而非“行程”)。除非 dynamic shared library 被放置于完全相同的内存位置上,就像当初加载这个 shared object 的行程一样,否则 virtual function 会死的很难看,可能的错误包含 segment fault 或 bus error。

病灶出在每个 virtual function 在 virtual table 中的位置已经被写死了。目前的解决办法属于程序层面,程序员必须保证让跨越行程的 shared libraries 有相同的坐落地址(在 SGI 中,使用者可以指定每个 shared library 的精确位置)。

Ext2和Ext3文件系统

Ext2的一般特征

以下特点有助于Ext2的效率:

  • 当创建Ext2文件系统时,系统管理员可根据预期的文件平均长度选择最佳块大小(1024B ~ 4096B)。
  • 当创建Ext2文件系统时,系统管理员可根据在给定大小的分区上预计存放的文件数来选择给该分区分配多少个索引节点
  • 文件系统把磁盘块分为组。
  • 磁盘数据块被实际使用前,文件系统就把这些块预分配给普通文件。文件大小增加时,物理上相邻的几个块已经被保留,这就减少了文件的碎片。
  • 支持快速符号链接。如果符号连接表示一个短路径名,则存放在索引节点中,不用通过读数据块转换

另外,Ext2还包含了一些使它既健壮又灵活的特点:

  • 文件更新策略的谨慎实现将系统崩溃的影响减到最少。
  • 在启动时支持对文件系统的状态进行自动的一致性检查。
  • 支持不可变的文件和仅追加的文件。
  • 既与UnixSystem V Release 4(SVR 4)兼容,也与新文件的用户组 ID 的 BSD 语义兼容。

Ext2需要引入以下几个特点:

  • 块片:通过把几个文件存放在同一个块的不同片上解决大块上存放小文件的问题。
  • 透明地处理压缩和加密文件:允许用户透明地在磁盘上存放压缩和加密的文件版本。
  • 逻辑删除:一个undelete选项将允许用户在必要时很容易恢复以前删除的文件
  • 日志:避免文件系统在被突然卸载时对其自动进行的耗时检查。

Ext2磁盘数据结构

任何Ext2分区中的第一个块从不受Ext2文件系统的管理,因为这一块是为分区的引导扇区所保留。Ext2的其余部分分成块组,每个块组的分布如图18-1 所示。

在Ext2文件系统中的所有块组大小相同并被顺序存放,因此,内核可以从块组的正数索引很容易地得到磁盘中一个块组的位置

由于内核尽可能地把属于一个文件的数据块存放在同一块组中,所以块组减少了文件碎片。块组中的每个块包含下列信息之一:

  • 文件系统的超级块的一个拷贝。
  • 一组块组描述符的拷贝。
  • 一个数据块位图。
  • 一个索引节点位图。
  • 一个索引节点表。
  • 属于文件的一大块数据,即数据块。

如果一个块中不包含任何有意义的信息,就说这个块是空闲的。

从图18-1 可看出,超级块与组描述符被复制到每个块组中。只有块组0中所包含的超级块和组描述符才由内核使用,而其余的超级块和组描述符保持不变。
当 e2fsck 程序对Ext2文件系统的状态执行一致性检查时,就引用存放在块组0中的超级块和组描述符,然后把它们拷贝到其它所有的块组中。

如果出现数据损坏,并且块组0中的主超级块和主组描述符变为无效,则系统管理员就可以命令 e2fsck 引用存放在某个块组(除了第一个块组)中的超级块和组描述符的旧拷贝

块组的数量主要限制于块位图,因为块位图必须存放在一个单独的块中。块位图用来标识一个组中块的占用和空闲状况。所以,每组中至多有 8b 个块,b 是以字节为单位的块大小。

超级块

Ext2在磁盘上的超级块存放在一个ext2_super_block结构中,__u8__u16__u32分别表示长度为8、16、32的无符号数,__s8__s16__s32分别表示长度为8、16、32的符号数。内核又使用了__le16__le32__be16__be32分别表示字或双字的小尾或大尾排序方式。

s_indoes_count字段存索引节点的个数。s_blocks_count字段存放Ext2文件系统的块的个数。

s_log_block_size字段以 2 的幂次方表示块的大小,用1024 字节作为单位。s_blocks_per_groups_frags_per_groups_inodes_per_group字段分别存放每个块组中的块数、片数及索引节点数。s_mnt_counts_max_mnt_counts_lastchecks_checkinterval字段使系统启动时自动地检查Ext2文件系统。

组描述符和位图

每个块组都由自己的组描述符,它是一个ext2_group_desc结构。

当分配新索引节点和数据块时,会用到bg_free_blocks_countbg_free_inodes_countbg_used_dirs_count字段。这些字段确定在最合适的块中给每个数据结构进行分配。

位图是位的序列,其中值0表示对应的索引节点块或数据块是空闲的,1 表示占用。一个单独的位图描述 8192、16384 或 32768 个块的状态。

索引节点表

索引节点表由一连串连续的块组成,其中每一块包含索引节点的一个预定义号。索引节点表第一个块的块号存放在组描述符的bg_inode_table字段中。

所有索引节点的大小相同,即128 字节。一个1024 字节的块可以包含 8 个索引节点,一个 4096 字节的块可以包含 32 个索引节点。为了计算出索引节点表占用了多少块,用一个组中的索引节点总数(超级块的s_inodes_per_group字段)除以每块中的索引节点数。

每个Ext2索引节点为ext2_innode结构。

i_size字段存放以字节为单位的文件的有效长度。i_blocks字段存放已分配给文件的数据块数(以 512 字节为单位)。

i_sizei_blocks的值没有必然的联系。因为一个文件总是存放在整数块中,一个非空文件至少接受一个数据块且i_size可能小于512 ∗ i_blocks。如果一个文件中包含空洞,i_size可能大于512 * i_blocks

i_blocks字段是具有EXT2_N_BLOCKS(通常是15)个指针元素的一个数组,每个元素指向分配给文件的数据块。

留给i_size字段的 32 位把文件的大小限制到 4GB。又因为i_size字段的最高位有使用,因此,文件的最大长度限制为 2GB。

i_dir_acl字段(普通文件没有使用)表示i_size字段的 32 位扩展。因此,文件的大小作为 64 位整数存放在索引节点中。在 32 位体系结构上访问大文件时,需以O_LARGEFILE标志打开文件。

索引节点的增强属性

引入增强属性的原因:如果要给索引节点的128 个字符空间中充满了信息,增加新字段时,将索引节点的长度增加到 256 有些浪费。增强属性存放在索引节点之外的磁盘块中。索引节点的i_file_acl字段指向一个存放增强属性的块。具有同样增强属性的不同索引节点可共享同一个块。

每个增强属性有一个名称和值。两者都编码位变长字符数组,并由ext2_xattr_entry描述符确定。每个属性分成两部分:在块首部的是ext2_xattr_entry描述符与属性名,而属性值则在块尾部。块前面的表项按照属性名称排序,而值的位置是固定的,因为它们是由属性的分配次序决定的。

有很多系统调用用来设置、取得、列表和删除一个文件的增强属性。系统调用setxattr()lsetxattr()fsetxattr()设置文件的增强属性,它们在符号链接的处理与文件限定的方式(或者传递路径名或者是文件描述符)上根本不同。类似地,系统调用getxattr()lgxattr()fgetxattr()返回增强属性的值。系统调用listxattr()llistxattr()flistxattr()则列出一个文件的所有增强属性。最后,系统调用removexattr()lremovexattr()fremovexattr()从文件删除亠个增强属性。

访问控制列表

访问控制列表(access control list, ACL)可以与每个文件关联。有了这种列表,用户可以为他的文件线段可以访问的用户(或用户组)名称及相应的权限。

Linux2.6 通过索引节点的增强属性完整实现 ACL。增强属性主要是为了支持ACL才引入的。

各种文件类型如何使用磁盘块

Ext2所认可的文件类型(普通文件、管道文件等)以不同的方式使用数据块。

普通文件

普通文件是最常见的情况,但只有在开始有数据时才需要数据块。普通文件在刚创建时是空的;也可以用truncate()open()清空它。

目录

Ext2以一种特殊的文件实现了目录,这种文件的数据块把文件名和相应的索引节点号存放在一起。这样的数据块包含了类型为ext2_dir_entry_2的数据结构。

该结构最后一个name字段是最大为EXT2_NAME_LEN(通常是 255)个字符的变长数组,因此该结构的长度是可变的。此外,因为效率的原因,目录项的长度总是 4 的倍数,必要时以NULL字符(\0)填充。name_len字段存放实际的文件名长度。

file_type字段存放指定文件类型的值。rec_len字段可被解释为指向下一个有效目录的指针:它是偏移量,与目录项的起始地址相加就得到下一个有效的目录项的起始地址。为了删除一个目录项,把它的inode字段置为0并适当地增加前一个有效目录项rec_len字段的值即可。下图的rec_len被置为12+16,因为oldfile已被删除。

符号链接

如果符号链接的路径名小于等于 60 个字符,就把它存放在索引节点的i_blocks字段,该字段是由15 个 4 字节整数组成的数组,因此无需数据块。但是,如果路径名大于 60 个字符,就需要一个单独的数据块。

设备文件、管道和套接字

这些类型的文件不需要数据块。所有必要的信息都存放在索引节点中。

Ext2的内存数据结构

为提高效率,安装Ext2文件系统时,存放在Ext2分区的磁盘数据结构中的大部分信息被拷贝到RAM中,从而使内核避免了后来的很多读操作。

  • 当一个新文件被创建时,必须减少Ext2超级块中s_free_inodes_count字段的值和相应的组描述符中bg_free_inodes_count字段的值。
  • 如果内核给一个现有的文件追加一些数据,以使分配给它的数据块数因此也增加,那么就必须修改Ext2超级块中s_free_blocks_count字段的值和组描述符中bg_free_blocks_count字段的值。
  • 即使仅仅重写一个现有文件的部分内容,也要对Ext2超级块的s_wtime字段进行更新。

因为所有的Ext2磁盘数据结构都存放在Ext2分区的块中,因此,内核利用页高速缓存来保持它们最新

下表说明了在磁盘上用来表示数据的数据结构、在内核中内核所使用的数据结构以及决定使用多大容量高速缓存的经验方法。总是缓存的数据总在RAM,这样就不必从磁盘读数据了。还有一种动态模式,只要相应的对象还在使用,就保存在高速缓存中,而当文件关闭或数据块删除之后,页框回收算法会从高速缓存中删除有关数据。

索引节点与块位图并不永久保存在内存里,而是需要时从磁盘读。

Ext2的超级块对象

VFS 超级块的s_fs_info字段指向一个包含文件系统信息的数据结构。对于Ext2,该字段指向ext2_sb_info类型的结构,它包含如下信息:

  • 磁盘超级块中的大部分字段。
  • s_sbh指针,指向包含磁盘超级块的缓冲区的缓冲区首部。
  • s_es指针,指向磁盘超级块所在的缓冲区。
  • 组描述符的个数s_desc_per_block,可以放在一个块中。
  • s_group_desc指针,指向一个缓冲区(包含组描述符的缓冲区)首部数组。
  • 其它与安装状态、安装选项等有关的数据。

当内核安装Ext2文件系统时,它调用ext2_fill_super()为数据结构分配空间,并写入从磁盘读取的数据。这里只强调缓冲区与描述符的内存分配。

  1. 分配一个ext2_sb_info描述符,将其地址当作参数传递并存放在超级块的s_fs_info字段。
  2. 调用__bread()在缓冲区页中分配一个缓冲区和缓冲区首部。然后从磁盘读入超级块存放在缓冲区中。如果一个块在页高速缓存的缓冲区页而且是最新的,那么无需再分配。将缓冲区首部地址存放在Ext2超级块对象的s_sbh字段。
  3. 分配一个字节数组,每组一个字节,把它的地址存放在ext2_sb_info描述符的s_debts字段。
  4. 分配一个数组用于存放缓冲区首部指针,每个组描述符一个,把该数组地址存放在ext2_sb_infos_group_desc字段。
  5. 重复调用__bread()分配缓冲区,从磁盘读入包含Ext2组描述符的块。把缓冲区首部地址存放在上一步得到的s_group_desc数组中。
  6. 为根目录分配一个索引节点和目录项对象,为超级块建立相应的字段,从而能够从磁盘读入根索引节点对象。

ext_fill_super()返回后,分配的所有数据结构都保存在内存里,只有当Ext2文件系统卸载时才会被释放。当内核必须修改Ext2超级块的字段时,它只要把新值写入相应缓冲区内的相应位置然后将该缓冲区标记为脏即可。

Ext2的索引节点对象

对于不在目录项高速缓存内的路径名元素,会创建一个新的目录项对象和索引节点对象。当VFS访问一个Ext2磁盘索引节点时,它会创建一个ext2_inode_info类型的索引节点描述符。该描述符包含以下信息:

  • 存放在vfs_inode字段的整个VFS索引节点对象。
  • 磁盘索引节点对象结构中的大部分字段(不保存在VFS索引节点中)。
  • 索引节点对应的i_block_group块组索引。
  • i_next_alloc_blocki_next_alloc_goal字段,分别存放着最近为文件分配的磁盘块的逻辑块号和物理块号。
  • i_prealloc_blocki_prealloc_count字段,用于数据块预分配。
  • xattr_sem字段,一个读写信号量,允许增强属性与文件数据同时读入。
  • i_acli_default_acl字段,指向文件的ACL。

当处理Ext2文件时,alloc_inode超级块方法是由ext2_alloc_inode()实现的。它首先从ext2_inode_cachepslab分配器高速缓存得到一个ext2_inode_info描述符,然后返回在这个ext2_inode_info描述符中的索引节点对象的地址。

创建Ext2文件系统

在磁盘上创建一个文件系统通常有两个阶段。第一步格式化磁盘,以使磁盘驱动程序可以读和写磁盘上的块。Linux上可使用superformatfdformat等使用程序对软盘格式化。第二步才涉及创建文件系统

Ext2文件系统是由实际程序mke2fs创建的。mke2fs采用下列缺省选项,用户可以用命令行的标志修改这些选项:

  • 块大小:1024字节。
  • 片大小:块的大小。
  • 所分配的索引节点个数:每8192字节的组分配一个索引节点。
  • 保留块的百分比:5%

`mke2fs 程序执行下列操作:

  1. 初始化超级块和组描述符。
  2. 作为选择,检查分区释放包含有缺陷的块;如果有,就创建一个有缺陷块的链表。
  3. 对于每个块组,保留存放超级块、组描述符、索引节点表及两个位图所需的所有磁盘块。
  4. 把索引节点位图和每个块组的数据映射位图都初始化为0。
  5. 初始化每个块组的索引节点表。
  6. 创建 /root 目录。
  7. 创建lost+found目录,由e2fsck使用该目录把丢失和找到的缺陷块连接起来。
  8. 在前两个已经创建的目录所在的块组中,更新块组中的索引节点位图和数据块位图。
  9. 把有缺陷的块(如果存在)组织起来放在lost+found目录中。

表18-7 总结了按缺省选项如何在软盘上建立Ext2文件系统。

Ext2的方法

Ext2超级块的操作

超级块方法的地址存放在ext2_sops指针数组中。

Ext2索引节点的操作

一些VFS索引节点的操作在Ext2中都由具体的实现,这取决于索引节点所指的文件类型。普通文件与目录的Ext2方法的地址分别存放在ext2_file_inode_operationsext2_dir_inode_operations表中。

Ext2的符号链接的索引节点见表:

有两种符号链接:快速符号链接(路径名全部存放在索引节点内)与普通符号链接(较长的路径名)。因此,有两套索引节点操作,分别存放在ext2_fast_symlink_inode_operationsext2_symlink_inode_operations表中。

如果索引节点指的是一个字符设备文件、块设备文件或命名管道,那么这种索引节点的操作不依赖于文件系统,其操作分别位于chrdev_inode_operationsblkdev_inode_operationsfifo_inode_operations表中。

Ext2的文件操作

一些VFS方式是由很多文件系统共用的通用函数实现的,这些方法的地址存放在ext2_file_operations表中。

Ext2的readwrite方法分别通过generic_file_read()generic_file_write()实现。

管理Ext2磁盘空间

文件在磁盘的存储不同于程序员所看到的文件,表现在两方面:

  • 块可以分散在磁盘上;
  • 程序员看到的文件似乎比实际的文件大,因为文件中可包含空洞。

在分配和释放索引节点和数据块方面有两个主要的问题必须考虑:

  • 空间管理必须尽力避免文件碎片。避免文件在物理上存放于几个小的、不相邻的盘块上。
  • 空间管理必须考虑效率,即内核应该能从文件的偏移量快速导出Ext2分区上相应的逻辑块号。

创建索引节点

ext2_new_inode()创建Ext2磁盘的索引节点,返回相应的索引节点对象的地址。该函数谨慎地选择存放在该新索引节点的块组;它将无联系的目录散放在不同的组,而且同时把文件存放在父目录的同一组。为了平衡普通文件数与块组中的目录数,Ext2为每一个块组引入参数。

该函数的两个参数:

  • dir,一个目录对应的索引节点对象的地址,新创建的索引节点必须插入到该目录中。
  • mode,要创建的索引节点的类型。还包含一个MS_SYNCHRONOUS标志,该标志请求当前进程一直挂起,直到索引节点被分配。

该函数执行如下操作

  1. 调用new_inode()分配一个新的VFS索引节点对象,并把它的i_sb字段初始化为存放在dir->i_sb中的超级块地址。然后把它追加到正在用的索引节点链表与超级块链表中。
  2. 如果新的索引节点是一个目录,函数就调用find_group_orlov()为目录找到一个合适的块组。该函数执行如下试探:
    1. 以文件系统根root为父目录的目录应该分散在各个组。这样,函数在这些块组中查找一个组,它的空闲索引节点数和空闲块数比平均值高。如果没有这样的组则跳到第 2C步。
    2. 如果满足下列条件,嵌套目录(父目录不是文件系统根root)就应该存放到父目录组:
      1. 该组没有包含太多的目录。
      2. 该组有足够多的空闲索引节点。
      3. 该组有一点小“债”
      4. 如果父目录组不满足这些条件,则选择第一个满足条件的组。如果没有满足条件的组,则跳到第 2C步。
    3. 这是一个“退一步”原则,当找不到合适的组时使用。函数从包含父目录的块组开始选择第一个满足条件的块组,该条件为:它的空闲索引节点数比每块组空闲索引节点数的平均值大
  3. 如果新索引节点不是个目录,则调用find_group_other(),在有空闲索引节点的块组中给它分配一个。该函数从包含父目录的组开始往下找,具体如下:
    1. 从包含父目录dir的块组开始,执行快速的对数查找。这种算法要查找log(n)个块组,这里n是块组总数。该算法一直向前查找直到找到一个可用的块组,具体如下:如果我们把开始的块组称为i,那么,该算法要查找的块组为i mod(n)i+1 mod(n)i+1+2 mod(n)i+1+2+4 mod(n),等等 。
    2. 如果该算法没有找到含有空闲索引节点的块组,就从包含父目录dir的块组开始执行彻底的线性查找。
  4. 调用read_inode_bitmap()得到所选块组的索引节点位图,并从中寻找第一个空位,这样就得到了第一个空闲磁盘索引节点号。
  5. 分配磁盘索引节点:把索引节点位图中的相应置位,并把含有这个位图的缓冲区标记为脏。此外,如果文件系统安装时指定了MS_SYNCHRONOUS标志,则调用sync_dirty_buffer()开始I/O写操作并等待,直到写操作终止。
  6. 减少组描述符的bg_free_inodes_count字段。如果新的索引节点是一个目录,则增加bg_used_dirs_count字段,并把含有这个组描述符的缓冲区标记为脏。
  7. 依据索引节点指向的是普通文件或目录,相应增减超级块内s_debts数组中的组计数器。
  8. 减少ext2_sb_info数据结构中的s_freeinodes_counter字段;而且如果新索引节点是目录,则增大ext2_sb_info数据结构的s_dirs_counter字段。
  9. 将超级块的s_dirt标志置1,并把包含它的缓冲区标记为脏。
  10. 把VFS超级块对象的s_dirt字段置1。
  11. 初始化这个索引节点对象的字段。特别是,设置索引节点号i_no,并把xtime.tv_sec值拷贝到i_atimei_mtimei_ctime。把这个块组的索引赋给ext2_inode_info结构的i_block_group字段。
  12. 初始化该索引节点对象的访问控制列表(ACL)。
  13. 将新索引节点对象插入散列表inode_hashtable,调用mark_inode_dirty()把该索引节点对象移进超级块脏索引节点链表。
  14. 调用ext2_preread_inode()从磁盘读入包含该索引节点的块,将它存入页高速缓存。进行这种预读是因为最近创建的索引节点可能会被很快写入。
  15. 返回新索引节点对象的地址。

总结:分配VFS索引节点对象;根据新索引节点是目录还是普通文件找到一个合适的块组;得到索引节点位图;从位图中找到空位,分配磁盘索引节点;更新相关计数器;初始化索引节点对象;将新索引节点插入散列表、存入页高速缓存;返回新索引对象地址。

删除索引节点

ext2_free_inode()删除一个磁盘索引节点,把磁盘索引节点表示为索引节点对象,其地址作为参数来传递。内核在进行一系列的清除操作后调用该函数。具体来说,它在下列操作完成后才执行:索引节点对象已经从散列表中删除,执行该索引节点的最后一个硬链接已经从适当的目录中删除,文件的长度截为0以回收它的所有数据块。函数执行下列操作:

  1. 调用clear_inode(),它依次执行如下步骤:
    1. 删除与索引节点关联的“间接”脏缓冲区。它们都存放在一个链表中,该链表的首部在address_space对象inode->i_dataprivate_list字段。
    2. 如果索引节点的I_LOCK标志置位,则说明索引节点中的某些缓冲区正处于I/O数据传送中;于是,函数挂起当前进程,直到这些I/O数据传送结束。
    3. 调用超级块对象的clear_inode方法,但Ext2没有定义该方法。
    4. 如果索引节点指向一个设备文件,则从设备的索引节点链表中删除索引节点对象,该链表要么在cdev字符设备描述符的cdev字段,要么在block_device块设备描述符的bd_inodes字段。
    5. 把索引节点的状态置为I_CLEAR(索引节点对象的内容不再有意义)。
  2. 从每个块组的索引节点号和索引节点数计算包含这个磁盘索引节点的块组的索引。
  3. 调用read_inode_bitmap()得到索引节点位图。
  4. 增加组描述符的bg_free_inodes_count字段。如果删除的索引节点是一个目录,那么也要减小bg_used_dirs_count字段。把这个组描述符所在的缓冲区标记为脏。
  5. 如果删除的索引节点是一个目录,就减小ext2_sb_info结构的s_dirs_counter字段,把超级块的s_dirt标志置1,并把它所在的缓冲区标记为脏。
  6. 清除索引节点位图中这个磁盘索引节点对应的位,并把包含这个位图的缓冲区标记为脏。此外,如果文件系统以MS_SYNCHRONIZE标志安装,则调用sync_dirty_buffer()并等待,直到在位图缓冲区上的写操作终止。

总结:删除索引节点缓冲区;获取块组索引;获取索引节点位图;更新相关计数器、状态;清除索引节点位图中相应;写回。

数据块寻址

每个非空的普通文件都由一组数据块组成。这些块或者由文件内的相对位置(它们的文件块号)标识,或者由磁盘分区内的位置(它们的逻辑块号)来标识。

从文件内的偏移量f导出相应数据块的逻辑块号需要两个步骤:

  1. 从偏移量f导出文件的块号,即在偏移量f处的字符所在的块索引。
  2. 把文件的块号转化为相应的逻辑块号。

因为Unix文件不包含任何控制字符,因此,导出文件的第f个字符所在的文件块号的方式为,用f除以文件系统块的大小,并取整即可。

但是,由于Ext2文件的数据块在磁盘上不必是相邻的,因此不能直接把文件的块号转化为相应的逻辑块号。因此,Ext2文件系统在索引节点内部实现了一种映射,可以在磁盘上建立每个文件块号与相应逻辑块号之间的关系。这种映射也涉及一些包含额外指针的专用块,这些块用来处理大型文件的索引节点的扩展。

磁盘索引节点的i_block字段是一个有EXT2_N_BLOCKS个元素且包含逻辑块号的数组。如图18-5所示,假定EXT2_N_BLOCKS =15,数组中的元素有4种不同的类型。

  • 最初的12个元素产生的逻辑块号与文件最初的12个块对应,即对应的文件块号为0~11。
  • 下标12中的元素包含一个块的逻辑块号(叫做间接块),这个块表示逻辑块号的一个二级数组。该数组的元素对应的文件块号从12 ~ b/4 +11,这里b是文件系统的块大小(每个逻辑块号占4个字节)。因此,内核为了查找指向一个块的指针必须先访问该元素,然后,在这个块中找到另一个指向最终块(包含文件内容)的指针。
  • 下标13中的元素包含一个间接块的逻辑块号,而这个包含逻辑块号的一个二级数组,这个二级数组的数组项依次指向三级数组,这个三级数组存放的才是
    文件块号对应的逻辑块号,范围从b/4 +12 ~ (b/4)^2 + (b/4) +11
  • 最后,下标14中的元素使用三级间接索引,第四级数组中存放的采释文件块号对应的逻辑块号,范围从(b/4)^2 + (b/4) +12 ~ (b/4)^3 + (b/4)^2 + (b/4)+11

如果文件需要的数据块小于12,则两次磁盘访问就可以检索到任何数据:一次是读磁盘索引节点i_block数组的一个元素,另一次是读所需要的数据块。
对于打文件,可能需要三四次的磁盘访问才能找到需要的块。实际上,因为目录项、索引节点、页高速缓存都有助于极大减少实际访问磁盘的次数。

还要注意文件系统的块大小是如何影响寻址机制的,因为大的块允许Ext2把更多的逻辑块号存放在一个单独的块中。表18-11显示了对每种块大小和每种寻址方式所存放文件大小的上限。例如,如果块的大小是1024字节,并且文件包含的数据最多为268KB,那么,通过直接映射可以访问文件最初的12KB数据,通过简单的间接映射可以访问剩余的13-268KB的数据。大于2GB的大型文件通过指定O_LARGEFILE打开标志必须在32位体系结构上进行打开。

文件的洞

文件的洞是普通文件的一部分,它是一些空字符但没有存放在磁盘的任何数据块中。因为文件的洞是为了避免磁盘空间的浪费。

文件洞在Ext2中的实现是基于动态数据块的分配的:只有当进程需要向一个块写数据时,才真正把这个块分配给文件。每个索引节点的i_size字段定义程序所看到的文件大小,包括洞,而i_blocks字段存放分配给文件有效的数据块数(以512字节为单位)。

分配数据块

当内核要分配一个数据块来保存Ext2普通文件的数据时,就调用ext2_get_block()。如果块不存在,该函数就自动为文件分配块。每当内核在Ext2普通文件上执行读或写操作时就调用该函数。该函数只有在页高速缓存内没有相应的块时才被调用。

ext2_get_bloc()在必要时调用ext2_alloc_block()在Ext2分区真正搜索一个空闲块。如果需要,还为间接寻址分配相应的块。

为了减少文件的碎片,Ext2文件系统尽力在已分配给文件的最后一个块附近找到一个新块分配给该文件。如果失败,Ext2文件系统又在包含这个文件索引节点的块组中搜寻一个新的块。作为最后一个办法,可以从其它一个块组中获得空闲块。

Ext2文件系统使用数据块的预分配策略。文件并不仅仅获得所需的块,而是获得一组多达8个邻接的块。ext2_inode_info结构的i_prealloc_count字段存放预分配给某一个文件但还没有使用的数据块数,而i_prealloc_block字段存放下一次要使用的预分配块的逻辑块号。

下列情况发生时,释放预分配而一直没有使用的块:当文件被关闭时当文件被缩短时,或者当一个写操作相对于引发预分配的写操作不是顺序时

ext2_alloc_block()参数为指向索引节点对象的指针、目标和存放错误码的变量地址。目标是一个逻辑块号,表示新块的首选位置。ext2_getblk()根据下列的试探法设置目标参数:

  1. 如果正被分配的块与前面已分配的块有连续的文件块号,则目标就是前一块的逻辑块号加1。
  2. 如果第一条规则不适用,并且至少给文件已分配了一个块,那么目标就是这些块的逻辑块号的一个。更确切的说,目标是已分配的逻辑块号,位于文件中待分配块之前。
  3. 如果前面的规则都不适用,则目标就是文件索引节点所在的块组中第一个块的逻辑块号。

ext2_alloc_block()检查目标是否指向文件的预分配块中的一块。如果是,就分配相应的块并返回它的逻辑块号;否则,丢弃所有剩余的预分配块并调用ext2_new_block()

ext2_new_block()用下列策略在Ext2分区内搜寻一个空闲块:

  1. 如果传递给ext2_alloc_block()的首选块(目标块)是空闲的,就分配它。
  2. 如果目标为忙,就检查首选块后的其余块之中是否有空闲的块。
  3. 如果在首先块附近没有找到空闲块,就从包含目标的块组开始,查找所有的块组,对每个块组:
    1. 寻址至少有 8 个相邻空闲块的一个块组。
    2. 如果没有找到这样的一组块,就寻找一个单独的空闲块。

只要找到一个空闲块,搜索就结束。在结束前,ext2_new_block()还尽力在找到的空闲块附近的块中找8个空闲块进行预分配,并把磁盘索引节点的i_prealloc_blocki_prealloc_count字段设置为适当的块位置及块数。

释放数据块

当进程删除一个文件或把它的长度截为0时,ext2_truncate()将其所有数据块回收。该函数扫描磁盘索引节点的i_block数组,以确定所有数据块的位置和间接寻址用的块的位置。然后反复调用ext2_free_blocks()释放这些块。

ext2_free_blocks()释放一组含有一个或多个相邻块的数据块。除ext2_truncate()调用它外,当丢弃文件的预分配块时也主要调用它。参数:

  • inode,文件的索引节点对象的地址。
  • block,要释放的第一个块的逻辑块号。
  • count,要释放的相邻块数。

该函数对每个要释放的块执行下列操作:

  1. 获得要释放块所在块组的块位图。
  2. 把块位图中要释放的块的对应位清0,并把位图所在的缓冲区标记为脏。
  3. 增加块组描述符的bg_free_blocks_count字段,并把相应的缓冲区标记为脏。
  4. 增加磁盘超级块的s_free_blocks_count字段,并把相应的缓冲区标记为脏,把超级块对象的s_dirt标记置位。
  5. 如果Ext2文件系统安装时设置了MS_SYNCHRONOUS标志,则调用sync_dirty_buffer()并等待,直到对这个位图缓冲区的写操作终止。

Ext3文件系统

Ext3文件夹系统设计时秉持两个简单的概念:

  • 成为一个日志文件系统。
  • 尽可能与原来的Ext2文件系统兼容。

日志文件系统

日志文件系统的目标是避免对整个文件系统进行耗时的一致性检查,这是通过查看一个特殊的磁盘区达到的,因为这种磁盘区包含日志的最新磁盘写操作。系统出现故障后,安装日志文件系统只需要几秒钟。

Ext3日志文件系统

Ext3日志所隐含的思想就是对文件系统进行的任何高级修改都分两步进行。首先,把待写块的一个副本存放在日志中;其次,当发往日志的I/O数据传送完成时,块就被写入文件系统。当发往文件系统的I/O数据传送终止时,日志中的块副本就被丢弃。

当从系统故障中恢复时,e2fsck程序区分下列两种情况:

  • 提交到日志之前系统故障发生。 与高级修改相关的块副本或者从日志中丢失,或者是不完整的;这两种情况下,e2fsck都忽略它们。
  • 提交到日志之后的系统故障发生。 块的副本是有效的,且e2fsck把它们写入文件系统。

  • 第一种情况下,对文件系统的高级修改被丢失,但文件系统的状态还是一致的。

  • 第二种情况下,e2fsck应用于整个高级修改,因此,修正由于把未完成的I/O数据传送到文件系统而造成的任何不一致。

日志系统通常不把所有的块都拷贝到日志中。事实上,每个文件系统都由两种块组成:包含元数据的块包含普通数据的块。在Ext2和Ext3中,有六种元数据:超级块块组描述符索引节点用于间接寻址的块(间接块)数据位图块索引节点位图块

很多日志文件系统都限定自己把影响元数据的操作写入日志。事实上,元数据的日志记录足以恢复磁盘文件系统数据结构的一致性。然而,因为文件的数据块不记入日志,因此就无法防止系统故障造成的文件内容的损坏。

不过,可以把Ext3文件系统配置为把影响文件系统元数据的操作和影响文件数据块的操作都记入日志。因为把每种些操作都记入日志会导致极大的性能损失,因此,Ext3让系统管理员决定应当把什么记入日志;具体来说,它提供三种不同的日志模式:

  • 日志,文件系统所有数据和元数据的改变都被记入日志。
  • 预定,只有对文件系统元数据的改变才被记入日志。然而,Ext3文件系统把元数据和相关的数据块进行分组,以便在元数据之前把数据块写入磁盘,这样减少文件内数据损坏的机会。是Ext3缺省的日志模式。
  • 写回,只有对文件系统元数据的改变才被记入日志;这是在其它日志文件系统中发现的方法,也是最快的模式。

日志块设备层

Ext3日志通常存放在名为.journal的隐藏文件中,该文件位于文件系统的根目录。

Ext3文件系统本身不处理日志,而是利用所谓日志块设备(Journaling Block Device, JBD)的通用内核层。现在,只有Ext3使用JDB层,而其它文件系统可能在将来才使用它。

JDB层是相当复杂的软件部分。Ext3文件系统调用JDB例程,以确保在系统万一出现故障时它的后续操作不会损坏磁盘数据结构。然后,JDB典型地使用同一磁盘来把Ext3文件系统所做的改变记入日志,因此,它与Ext3一样易受系统故障的影响。换言之,JDB也必须保护自己免受任何系统故障引起的日志损坏。

因此,Ext3与JDB之间的交互本质上基于三个基本单元:

  • 日志记录,描述日志文件系统一个磁盘块的一次更新。
  • 原子操作处理,包括文件系统的一次高级修改对应的日志记录;一般来说,修改文件系统的每个系统调用都引起一次单独的原子操作处理。
  • 事务,包括几个原子操作处理,同时,原子操作处理的日志记录对e2fsck标记为有效。

日志记录

日志记录本质上是文件系统将要发出的一个低级操作的描述。在某些日志文件系统中,日志记录只包括操作所修改的字节范围及字节在文件系统中的起始位置。然而,JDB层使用的日志记录由低级操作所修改的整个缓冲区组成。这种方式可能浪费很多日志空间,但它还是相当快的,因为JBD层直接对缓冲区和缓冲区首部进行操作。

因此,日志记录在日志内部表示为普通的数据块(或元数据)。但是,每个这样的块都是与类型为journal_block_tag_t的小标签相关联的,这种小标签存放在文件系统中的逻辑块和几个状态标志。

随后,只要一个缓冲区得到JBD的关注,或者因为它属于日志记录,或者因为它是一个数据块,该数据块应当在相应的元数据之前刷新到磁盘,那么,内核把journal_head数据结构加入到缓冲区首部。这种情况下,缓冲区首部的b_private字段存放journal_head数据结构的地址,并把BH_JBD标志置位。

原子操作处理

修改文件文件系统的任一系统调用通常都被划分为操纵磁盘数据结构的一系列低级操作。

为防止数据损坏,Ext3文件系统必须确保每个系统调用以原子的方式进行处理。原子操作处理是对磁盘数据结构的一组低级操作,这组低级操作对应一个单独的高级操作。当系统故障恢复时,文件系统确保要么整个高级操作起作用,要么没有一个低级操作起作用。

任何原子操作处理都用类型为handle_t的描述符表示。为了开始一个原子操作,Ext3文件系统调用journal_start()JBD函数,该函数在必要时分配一个新的原子操作处理并把它插入到当前事务中。因为对磁盘的任何低级操作都可能挂起进程,因此,活动原子操作处理的地址存放在进程描述符的journal_info字段。为了通知原子操作已经完成,Ext3文件系统调用journal_stop()

事务

出于效率的原因,JBD层对日志的处理采用分组的方法,即把属于几个原子操作处理的日志记录分组放在一个单独的事务中。此外,与一个处理相关的所有日志记录都必须包含在同一个事务中。

一个事务的所有日志记录存放在日志的连续块中。JBD层把每个事务作为整体来处理。

事务一旦被创建,它就能接受新处理的日志记录。当下列情况之一发生时,事务就停止接受新处理:

  • 固定的时间已经过去,典型情况为 5s。
  • 日志中没有空闲块留给新处理。

事务是由类型为transaction_t的描述符来表示。其最重要的字段为t_state,该字段描述事务的当前状态。

从本质上上,事务可以是:

  • 完成的。包含在事务中的所有日志记录都已经从物理上写入日志。当从系统故障恢复时,e2fsck考虑日志中每个完成的事务,并把相应的块写入文件系统。在这种情况下,t_state字段存放值T_FINISHED
  • 未完成的。包含在事务中的日志记录至少还有一个没有从物理上写入日志,或者新的日志记录还在追加到事务中。在系统故障的情况下,存放在日志中的事务映像可能不是最新的。因此,当从系统故障中恢复时,e2fsck不信任日志中未完成的事务,并跳过它们。这种情况下,i_state存放下列值之一:
    • T_RUNNING,还在接受新的原子操作处理。
    • T_LOCKED,不接受新的原子操作,但其中的一些还没有完成。
    • T_FLUSH,所有的原子操作处理都完成,但一些日志记录还正在写入日志。
    • T_COMMIT,原子操作处理的所有日志记录都已经写入磁盘,但在日志中,事务仍然被标记为完成。

在任何时刻,日志可能包含多个事务,但其中只有一个处于T_RUNNNIG状态,即它是活动事务。所谓活动事务就是正在接受由Ext3文件系统发出的新原子操作处理的请求

日志中的几个事务可能是未完成的,因为包含相关日志记录的缓冲区还没有写入日志。

如果事务完成,说明所有日志记录已被写入日志,但是一部分相应的缓冲区还没有写入文件系统。只有当JBD层确认日志记录描述的所有缓冲区都已成功写入Ext3文件系统时,一个完成的事务才能从日志中删除。

日志如何工作

  1. write()系统调用服务例程触发与Ext3普通文件相关的文件对象的write方法。对于Ext3来说,该方法由generic_file_write()实现。
  2. generic_file_write()几次调用address_space对象的prepare_write方法,写方法涉及的每个数据页都调用一次。对Ext3来说,该方法由ext3_prepare_write()实现的。
  3. ext3_prepare_write()调用journal_start()JBD函数开始一个新的原子操作。该原子操作处理被加到活动事务中。实际上,原子操作处理是第一次调用journal_start()创建的。后续的调用确认进程描述符的journal_info字段已经被置位,并使用这个处理。
  4. ext3_prepare_write()调用block_prepare_write(),参数为ext3_get_block()的地址。block_prepare_write()负责准备文件页的缓冲区和缓冲区首部。
  5. 当内核必须确定Ext3文件系统的逻辑块号时,就执行ext3_get_block()。该函数实际上类似于ext2_get_block(),但有一个差异在于Ext3文件系统调用JDB层的函数确保低级操作记入日志:
    1. 在对Ext3文件系统的元数据块发出低级写操作之前,该函数调用journal_get_write_access()。后一个函数主要把元数据缓冲区加入到活动事务链表中。但是,它也必须检查元数据是否包含在日志的一个较老的未完成的事务中;这种情况下,它把缓冲区复制一份以确保老的事务以老的内容提交。
    2. 在更新元数据块所在的缓冲区后,Ext3文件系统调用journal_dirty_metadata()把元数据缓冲区移到活动事务的适当脏链表中,并在日志中记录这一操作。
    3. 注意,由JDB层处理的元数据缓冲区通常并不包含在索引节点的缓冲区的脏链表中,因此,这些缓冲区并不由正常磁盘高速缓存的刷新机制写入磁盘。
  6. 如果Ext3文件系统已经以“日志”模式安装,则ext3_prepare_write()在写操作触及的每个缓冲区上也调用journal_get_write_access()
  7. 控制权回到generic_file_write(),该函数用存放在用户态地址空间的数据更新页,并调用address_space对象的commit_write方法。对于Ext3,函数如何实现该方法取决于Ext3文件系统的安装方式:
    1. 如果Ext3文件系统已经以“日志”模式安装,那么commit_write方法是由ext3_journalled_commit_write()实现的,它对页中的每个数据缓冲区调用journal_dirty_metdata()。这样,缓冲区就包含在活动事务的适当脏链表中,但不包含在拥有者索引节点的脏链表中;此外,相应的日志记录写入日志。最后,ext3_journalled_commit_write()调用journal_stop通知JBD层原子操作处理已关闭。
    2. 如果Ext3文件系统已经以“预定”模式安装,那么commit_write方法是由ext3_ordered_commit_write()实现,它对页中的每个数据缓冲区调用journal_dirty_data()以把缓冲区插入到活动事务的适当链表中。JDB层确保在事务中的元数据缓冲区写入之前这个链表中的所有缓冲区写入磁盘。没有日志记录写入日志。然后,ext3_ordered_commit_write()执行generic_commit_write(),将数据缓冲区插入拥有者索引节点的脏缓冲区链表中。然后,ext3_writeback_commit_write()调用journal_stop()通知JBD层原子操作处理已关闭。
    3. 如果Ext3文件系统以“写回”模式安装,那么commit_write方法由ext3_writeback_commit_write()实现,它执行generic_commit_write()把数据缓冲区插入拥有者索引节点的脏缓冲区链表中。然后,ext3_writeback_commit_write()调用journal_stop()通知JBD层原子操作已关闭。
  8. write()的服务例程到此结束。但是,JDB层还没有完成它的工作,当事务的所有日志记录都物理地写入日志时,我们的事务才完成。然后,执行journal_commit_transaction()
  9. 如果Ext3文件系统以“预定”模式安装,则journal_commit_transaction()为事务链表包含的所有数据缓冲区激活I/O数据传送,并等待直到数据传送终止。
  10. journal_commit_transaction()为包含在事务中的所有元数据缓冲区激活I/O数据传送。
  11. 内核周期性地为日志中每个完成的事务激活检查活动。检查点主要验证由journal_commit_transaction()触发的I/O数据传送是否已经成功结束。
    如果是,则从日志中删除事务。

总结:write()开始;开始一个新的原子操作;确定逻辑块号,将元数据缓冲区加入到活动事务链表;commit_write:把缓冲区写入磁盘,原子操作关闭;write()结束;JDB事务中的元数据缓冲区激活I/O数据传送;周期性为每个完成事务激活检查活动。

只有当系统发生故障时,e2fsck使用程序才扫描存放在文件系统中的日志,并重新安排完成的事务中的日志记录所描述的所有写操作。

进程通信

Unix系统提供的进程间通信的基本机制:

  • 管道和FIFO(命名管道)。最适合在进程之间实现生产者/消费者的交互。有些进程向管道中写入数据,另外一些进程则从管道中读出数据。
  • 信号量。
  • 消息。允许进程在预定义的消息队列中读和写消息来交换消息。Linux内核提供两种不同的消息版本:System V IPC消息和POSIX消息。
  • 共享内存区。允许进程通过共享内存块来交换消息。在必须共享大量数据的应用中,可能是最高效的进程通信形式。
  • 套接字。允许不同计算机上的进程通过网络交换数据。还可用作相同主机上的进程之间的通信工具。

管道

管道是所有Unix都愿意提供的一种进程间通信机制。管道是进程之间的一个单向数据流:一个进程写入管道的所有数据都由内核定向到另一个进程,另一个进程由此可以从管道中读取数据。

在Unix的命令shell中,可以使用“|”操作符创建管道。

使用管道

管道被看作是打开的文件,但在已安装的文件系统中没有相应的映像。可以使用pipe()创建一个新管道,该系统调用返回一对文件描述符;然后进程通过fork()把这两个描述符传递给它的子进程,由此与子进程共享管道。进程可以在read()中使用第一个文件描述符从管道中读取数据,同样也可以在write()中使用第二个文件描述符向管道中写入数据。

POSIX只定义了半双工的管道,因此即使pipe()返回了两个描述符,每个进程在使用一个文件描述符之前仍得把另一个文件描述符关闭。如果所需要的是双向数据流,那么进程必须通过两次调用pipe()来使用两个不同的管道。

有些Unix系统,如System V Release 4,实现了全双工的管道。Linux采用另外一种解决方法:每个管道的文件描述符仍然都是单向的,但是在使用一个描述符前不必把另一个描述符关闭

shell命令对ls | more语句进行解释时,实际上执行以下操作:

  • 调用pipe();假设pipe()返回文件描述符3(管道的读通道)和4(管道的写通道)。
  • 两次调用fork()
  • 两次调用close()释放文件描述符3和4。

第一个子进程必须执行ls程序,它执行以下操作:

  1. 调用dup2(4, 1)把文件描述符4拷贝到文件描述符1。从现在开始,文件描述符1就代表该管道的写通道。
  2. 两次调用close()释放文件描述符3和4。
  3. 调用execve()执行ls程序。缺省情况下,该程序要把自己的输出写到文件描述符为1的那个文件(标准输出)中,也就是说,写入管道中。

第二个子程序必须执行more程序;因此,该进程执行以下操作:

  1. 调用dup2(3,0)把文件描述符3拷贝到文件描述符0。从现在开始,文件描述符0就代表管道的读通道。
  2. 两次调用close()释放文件描述符3和4。
  3. 调用execve()执行more程序。缺省情况下,该程序要从文件描述符为0的那个文件(标准输入)中读取输入,即,从管道中读取输入。

如果多个进程对同一管道进行读写,必须使用文件加锁机制或IPC信号量机制对自己的访问进行显式同步。

popen()可创建一个管道,然后使用包含在C函数库中的高级I/O函数对该管道进行操作。

Linux中,popen()pclose()都包含在C库函数中。popen()参数为:可执行文件的路径名filename和定义数据传输方向的字符串type。返回一个指向FILE数据结构的指针。popen()执行以下操作:

  1. 使用pipe()创建一个新管道。
  2. 创建一个新进程,该进程执行以下操作:
    1. 如果typer,就把与管道的写通道相关的文件描述符拷贝到文件描述符1(标准输出);否则,如果typew,就把管道的读通道相关的文件描述符拷贝到文件描述符0(标准输入)。
    2. 关闭pipe()返回的文件描述符。
    3. 调用execve()执行filename所指定的程序。
  3. 如果typer,就关闭与管道的写通道相关的文件描述符;否则,如果typew,就关闭与管道的读通道相关的文件描述符。
  4. 返回FILE文件指针所指向的地址,该指针指向仍然打开的管道所涉及的任一文件描述符。

popen()被调用后,父进程和子进程就可以通过管道交换信息:父进程可以使用该函数返回的FILE指针来读(如果typer)写(如果typew)数据。子进程所指向的程序分别把数据写入标准输出或从标准输入中读取数据。

pclose()参数为popen()所返回的文件指针,它会简单地调用wait4()并等待popen()所创建的进程结束。

管道数据结构

只要管道一被创建,进程就可以使用read()write()这两个VFS系统调用来访问管道。因此,对于每个管道来说,内核都要创建一个索引节点对象和两个文件对象,一个文件对象用于读,另一个对象用于写。当进程希望从管道中读取数据或向管道中写入数据时,必须使用适当的文件描述符。

当索引节点指的是管道时,其i_pipe字段指向一个pipe_inode_info结构。

除了一个索引节点对象和两个文件对象外,每个管道都还有自己的管道缓冲区。实际上,它是一个单独页,其中包含了已经写入管道等待读出的数据。Linux2.6.11中,每个管道可以使用16个管道缓冲区。该改变大大增强了向管道写大量数据的用户态应用的性能。

pipe_inode_infobufs字段存放一个具有16个pipe_buffer对象的数组,每个对象代表一个管道缓冲区。

ops字段指向管道缓冲区方法表anon_pipe_buf_ops,其类型为pipe_buf_operations,有三个方法:

  • map,在访问缓冲区数据之前调用。它只在管道缓冲区在高端内存时对管道缓冲区页框调用kmap()
  • unmap,不再访问缓冲区数据时调用。它对管道缓冲区页框调用kunmap()
  • release,当释放管道缓冲区时调用。该方法实现了一个单页内存高速缓存:释放的不是存放缓冲区的那个页框,而是由pipe_inode_infotmp_page字段指向的高速缓存页框。存放缓冲区的页框变成新的高速缓存页框。

16个缓冲区可以被看作一个整体环形缓冲区:写进程不断向这个大缓冲区追加数据,而读进程则不断移出数据。所有管道缓冲区中当前写入而等待读出的字节数就是管道大小。为提高效率,仍然要读的数据可以分散在几个未填充满的管道缓冲区内:事实上,在上一个管道缓冲区没有足够空间存放新数据时,每个写操作都可能把数据拷贝到一个新的空管道缓冲区。因此,内核必须记录:

  • 下一个待读字节所在的管道缓冲区、页框中的对应偏移量。该管道缓冲区的索引存放在pipe_inode_infocurbuf字段,而偏移量在相应pipe_buffer对象的offset字段。
  • 第一个空管道缓冲区。它可以通过增加当前管道缓冲区的索引得到(模为16),并存放在pipe_inode_infocurbuf字段,而存放有效数据的管道缓冲区号存放在nrbufs字段。

pipefs 特殊文件系统

管道是作为一组VFS对象来实现的,因此没有对应的磁盘映像。在 Linux2.6 中,把这些VFS对象组织为pipefs特殊文件系统以加速它们的处理。因为这种文件系统在系统目录树中没有安装点,因此用户看不到它。但是,有了pipefs,管道完全被整合到VFS层,内核就可以命名管道或FIFO的方式处理它们,FIFO是以终端用户认可的文件而存在的。

init_pipe_fs()注册并安装pipefs文件系统。

1
2
3
4
5
6
struct file_system_type pipe_fs_type;
pipe_fs_type.name = "pipefs";
pipe_fs_type.get_sb = pipefs_get_sb;
pipe_fs.kill_sb = kill_anon_super;
register_filesystem(&pipe_fs_type);
pipe_mnt = do_kern_mount("pipefs",0, "pipefs",NULL);

表示pipefs根目录的已安装文件系统对象存放在pipe_mnt变量中。为避免对管道的竞争条件,内核使用包含在索引节点对象中的i_sem信号量。

创建和撤销管道

pipe()sys_pipe()处理,后者又会调用do_pipe()。为了创建一个新的管道,do_pipe()执行以下操作:

  1. 调用get_pipe_inode(),该函数为pipefs文件系统中的管道分配一个索引节点对象并对其进行初始化。具体执行以下操作:
    1. pipefs文件系统中分配一个新的索引节点。
    2. 分配pipe_inode_info,并把它的地址存放在索引节点的i_pipe字段。
    3. 设置pipe_inode_infocurbufnrbufs字段为0,并将bufs数组中的管道缓冲区对象的所有字段都清0。
    4. pipe_inode_infor_counterw_counter字段初始化为1。
    5. pipe_inode_inforeaderswriters字段初始化为1。
  2. 为管道的读通道分配一个文件对象和一个文件描述符,并把该文件对象的f_flag字段设置为O_RDONLY,把f_op字段初始化为read_pipe_fops表的地址。
  3. 为管道的写通道分配一个文件对象和一个文件描述符,并把该文件对象的f_flag字段设置为O_WRONLY,把f_op字段初始化为write_pipe_fops表的地址。
  4. 分配一个目录项对象,并使用它把两个文件对象和索引节点对象连接在一起;然后,把新的索引节点插入pipefs特殊文件系统中。
  5. 把两个文件描述符返回给用户态进程。

发出一个pipe()的进程是最初唯一一个可以读写访问新管道的进程。为了表示该管道实际上既有一个读进程,又有一个写进程,就要把pipe_inode_inforeaderswriters字段初始化为1。通常,只要相应管道的文件对象仍然由某个进程打开,这两个字段中的每个字段应该都被设置成1;如果相应的文件对象已经被释放,那么这个字段就被设置成0,因为不会由任何进程访问该管道。

创建一个新进程并不增加readerswriters字段的值,因此这两个值从不超过1。但是,父进程仍然使用的所有文件对象的引用计数器的值都会增加。因此,即使父进程死亡时该对象都不会被释放,管道仍会一直打开供子进程使用

只要进程对与管道相关的一个文件描述符调用close(),内核就对相应的文件对象执行fput(),这会减少它的引用计数器的值。如果这个计数器变成0,那么该函数就调用该文件操作的release方法。

根据文件是与读通道还是写通道关联,release方法或者由pipe_read_release()或者由pipe_write_release()实现。这两个函数都调用pipe_release(),后者把pipe_inode_inforeaders字段或writers字段设置成0。`

pipe_release()还检查readerswriters是否都等于0。如果是,就调用所有管道缓冲区的release方法,向伙伴系统释放所有管道缓冲区页框;此外,函数还释放由tmp_page字段执行的高速缓存页框。否则,readers或者writers字段不为0,函数唤醒在管道的等待队列上睡眠的任一进程,以使它们可以识别管道状态的变化。

从管道中读取数据

在管道的情况下,read方法在read_pipe_fops表中的表项指向pipe_read(),从一个管道大小为p的管道中读取n个字节。

可能以两种方式阻塞当前进程:

  • 当系统调用开始时管道缓冲区为空。
  • 管道缓冲区没有包含所请求的字节,写进程在等待缓冲区的空间时曾被设置为睡眠。

读操作可以是非阻塞的,只要所有可用的字节(即使为0)一旦被拷贝到用户地址空间中,读操作就完成。只有在管道为空且当前没有进程正在使用与管道的写通道相关的文件对象时,read()才会返回0

pipe_read()执行下列操作:

  1. 获取索引节点的i_sem信号量。
  2. 确定存放在pipe_inode_infonrbufs字段中的管道大小是否为0。如果是,说明所有管道缓冲区为空。这时还要确定函数必须返回还是进程在等待时必须被阻塞,直到其它进程向管道中写入一些数据。I/O操作的类型(阻塞或非阻塞)是通过文件对象的f_flags字段的O_NONBLOCK标志来表示的。如果当前必须被阻塞,则函数执行下列操作:
    1. 调用prepare_to_wait()current加到管道的等待队列(pipe_inode_infowait字段)。
    2. 释放索引节点的信号量。
    3. 调用schedule()
    4. 一旦current被唤醒,就调用finish_wait()把它从等待队列中删除,再次获得i_sem索引节点信号量,然后跳回第2步。
  3. pipe_inode_infocurbuf字段得到当前管道缓冲区索引。
  4. 执行管道缓冲区的map方法。
  5. 从管道缓冲区拷贝请求的字节数(如果较小,就是管道缓冲区可用字节数)到用户地址空间。
  6. 执行管道缓冲区的unmap方法。
  7. 更新相应pipe_buffer对象的offsetlen字段。
  8. 如果管道缓冲区已空(pipe_buffer对象的len字段现在等于0),则调用管道缓冲区的release方法释放对应的页框,把pipe_buffer对象的ops字段设置为NULL,增加在pipe_inode_infocurbuf字段中存放的当前管道缓冲区索引,并减小nrbufs字段中非空管道缓冲区计数器的值。
  9. 如果所有请求字节拷贝完毕,则跳至第12步。
  10. 目前,还没有把所有请求字节拷贝到用户态地址空间。如果管道大小大于0(pipe_inode_infonrbufs字段不为NULL),则跳到第3步。
  11. 管道缓冲区内没有剩余字节。如果至少有一个写进程正在睡眠(即pipe_inode_infowaiting_writers字段大于0),且读操作是阻塞的,那么调用wake_up_interruptible_sync()唤醒在管道等待队列中所有睡眠的进程,然后跳到第2步。
  12. 释放索引节点的i_sem信号量。
  13. 调用wake_up_interruptible_sync()唤醒在管道的等待队列中所有睡眠的写进程。
  14. 返回拷贝到用户地址空间的字节数。

向管道中写入数据

write_pipe_fops表中相应的项指向pipe_write(),向管道中写入数据。下表概述了write()的行为,把n个字节写入管道中,而该管道在它的缓冲区中有u个未用的字节。该标准要求涉及少量字节数的写操作必须原子地执行。如果两个或多个进程并发地写在一个管道,那么任何少于4096字节的写操作都必须单独完成。

如果管道没有读进程(管道的索引节点对象的readers字段值是0),那么任何对管道执行的写操作都会失败。在这种情况下,内核会向写进程发送一个SIGPIPE信号,并停止write(),使其返回一个-EPIPE码,含义为“Broken pipe(损坏的管道)”。

pipe_write()执行以下操作:

  1. 获取索引节点的i_sem信号量。
  2. 检查管道是否至少有一个读进程。如果不是,就向当前进程发送一个SIGPIPE信号,释放索引节点信号量并返回-EPIPE值。
  3. pipe_inode_infocurbufnrbufs字段相加并减一得到最后写入的管道缓冲区索引。如果该管道缓冲区有足够空间存放待写字节,就拷入这些数据:
    1. 执行管道缓冲区的map方法。
    2. 把所有字节拷贝到管道缓冲区。
    3. 执行管道缓冲区的unmap方法。
    4. 更新相应pipe_buffer对象的len字段。
    5. 跳到第11步。
  4. 如果pipe_inode_infonrbufs字段等于16,就表明没有空闲管道缓冲区来存放待写字节,这种情况下:
    1. 如果写操作是非阻塞的,跳到第11步,结束并返回错误码-EAGAIN。
    2. 如果写操作是阻塞的,将pipe_inode_infowaiting_writers字段加1,调用prepare_to_wait()将当前操作加入管道等待队列(pipe_inode_infowait字段),释放索引节点信号量,调用schedule()。一旦唤醒,就调用finish_wait()从等待队列中移出当前操作,重新获得索引节点信号量,递减waiting_writers字段,然后跳回第4步。
  5. 现在至少有一个空缓冲区,将pipe_inode_infocurbufnrbufs字段相加得到第一个空管道缓冲区索引。
  6. 除非pipe_inode_infotmp_page字段不是NULL,否则从伙伴系统中分配一个新页框。
  7. 从用户态地址空间拷贝多达4096个字节到页框(如果必要,在内核态线性地址空间作临时映射)。
  8. 更新与管道缓冲区关联的pipe_buffer对象的字段:将page字段设为页框描述符的地址,ops字段设为anon_pipe_buf_ops表的地址,offset字段设为0,len字段设为写入的字节数。
  9. 增加非空管道缓冲区计数器的值,该缓冲区计数器存放在pipe_inode_infnr_bufs字段。
  10. 如果所有请求的字节还没写完,则跳到第4步。
  11. 释放索引节点信号量。
  12. 唤醒在管道等待队列上睡眠的所有读进程。
  13. 返回写入管道缓冲区的字节数(如果无法写入,返回错误码)。

FIFO

管道的优点:简单、灵活、有效。管道的缺点:无法打开已经存在的管道。使得任意的两个进程不能共享同一个管道,除非管道由一个共同的祖先进程创建。

Unix引入了命名管道,或者FIFO的特殊文件类型。FIFO与管道的共同点:在文件系统中不拥有磁盘块,打开的FIFO总是与一个内核缓冲区关联,这一缓冲区中临时存放两个或多个进程之间交换的数据

然而,有了磁盘索引节点,任何进程都可以访问FIFO,因为FIFO文件名包含在系统的目录树中。服务器在启动时创建一个FIFO,由客户端用来发出自己的请求。每个客户端程序在建立连接前都另外创建一个FIFO,并在自己对服务器发出的最初请求中包含该FIFO的名字,服务器程序就可以把查询结果写入该FIFO。

FIFO的readwrite操作是由pipe_read()pipe_write()实现的。FIFO与管道只有两点主要的差别:

  • FIFO索引节点出现在系统目录树上而不是pipefs特殊文件系统中。
  • FIFO是一种双向通信管道,即可能以读/写模式打开一个FIFO。

创建并打开FIFO

进程通过执行mknod()创建一个FIFO“设备文件”,参数为新FIFO的路径名以及S_IFIFO(0x10000)与该新文件的权限位掩码进行逻辑或的结果。
POSIX引入了一个名为mkfifo()的系统调用专门创建FIFO。该系统调用在Linux及 System V Release 4 中是作为调用mknod()的C库函数实现的。

FIFO一旦被创建,就可以使用普通的open()read()write()close()访问FIFO。但是VFS对FIFO的处理方法比较特殊,因为FIFO的索引节点及文件操作都是专用的,并且不依赖于FIFO所在的文件系统。

POSIX标准定义了open()对FIFO的操作;这种操作本质上与所请求的访问类型、I/O操作的种类(阻塞或非阻塞)以及其它正在访问FIFO的进程的存在状况有关。

进程可以为读、写操作或者读写操作打开一个FIFO。根据这三种情况,把与相应文件对象相关的文件操作设置程特定的方法。

当进程打开一个FIFO时,VFS就执行一些与设备文件所指向的操作相同的操作。与打开的FIFO相关的索引节点对象是由依赖于文件系统的read_inode超级块对象方法进行初始化的。该方法总要检查磁盘上的索引节点是否表示一个特殊文件,并在必要时调用init_special_inode()。该函数又把索引节点对象的i_fop字段设置为def_fifo_fops表的地址。随后,内核把文件对象的文件操表设置为def_fifo_fops,并执行它的open方法,该方法由FIFO_open()实现。



fifo_open()初始化专用于FIFO的数据结构,执行下列操作:

  1. 获取i_sem索引节点信号量。
  2. 检查索引节点对象i_pipe字段;如果为NULL,则分配并初始化一个新的pipe_inode_info结构。
  3. 根据open()的参数中指定的访问模式,用合适的文件操作表的地址初始化文件对象的f_op字段。
  4. 如果访问模式为只读或者读/写,则把1加到pipe_inode_inforeaders字段和r_counter字段。此外,如果访问模式是只读的,且没有其它的读进程,则唤醒等待队列上的任何写进程。
  5. 如果访问模式为只写或者读/写,则把1加到pipe_inode_infowriters字段和w_counter字段。此外,如果访问模式是只写的,且没有其它的写进程,则唤醒等待队列上的任何读进程。
  6. 如果没有读进程或者写进程,则确定函数是应当阻塞还是返回一个错误码而终止。
  7. 释放索引节点信号量,并终止,返回0(成功)。

FIFO的三个专用文件操作表的主要区别是readwrite方法的实现不同。如果访问类型允许读操作,那么read方法是使用pipe_read()实现的;否则,read方法就是使用bad_pipe_r()实现的。write方法同理。

System V IPC

IPC(进程间通信)通常指允许用户态进程执行下列操作的一组机制:

  • 通过信号量与其它进程进行同步。
  • 向其它进程发送消息或者从其它进程接收消息。
  • 和其它进程共享一段内存区。

IPC数据结构是在进程请求IPC资源(信号量、消息队列或者共享内存区)时动态创建的。每个IPC资源都是持久的:除非被进程显示地释放,否则永远驻留在内存中(直到系统关闭)。IPC资源可以由任一进程使用使用,包括那些不共享祖先进程所创建的资源的进程。

由于一个进程可能需要同类型的多个IPC资源,因此每个新资源都是使用一个32位IPC关键字表示,这个系统的目录树中的文件路径名类似。每个IPC资源都有一个32位IPC标识符,这与和打开文件相关的文件描述符类似。IPC标识符由内核分配给IPC资源,在系统内部是唯一的,而IPC关键字可以由程序自由地选择。

当两个或更多的进程要通过一个IPC资源进行通信时,这些进程都要引用该资源的IPC标识符。

使用IPC资源

根据新资源是信号量、消息队列还是共享内存区,分别调用semget()msgget()或者shmget()创建IPC资源。

这三个函数的主要目的都是从IPC关键字(第一个参数)中导出相应的IPC标识符,进程以后就可以使用该标识符对资源进程访问。如果还没有IPC资源和IPC关键字相关联,就创建一个新的资源。如果一切都顺利,则函数就返回一个正的IPC标识符;否则,就返回一个错误码。

假设两个独立的进程想共享一个公共的IPC资源。这可以使用两种方法达到:

  • 这两个进程统一使用固定的、预定义的IPC关键字。这是最简单的情况,对于由很多进程实现的任一复杂的应用程序也有效。然而,另外一个无关的程序也可能使用了相同的IPC关键字。这种情况下,IPC可能被成功调用,但返回错误资源的IPC标识符。
  • 一个进程通过指定IPC_PRIVATE作为自己的IPC关键字调用semget()msgget()shmget()。一个新的IPC资源因此被分配,这个进程或者可以与应用程序中的另一个进程共享自己的IPC标识符,或者自己创建另一个进程。这种方法确保IPC资源不会偶然地被其它应用程序使用

semget()msgget()shmget()的最后一个参数可包括三个标志。

  • IPC_CREAT说明如果IPC资源不存在,就必须创建它;
  • IPC_EXCL说明如果资源已经存在且设置了IPC_CREAT标志,则函数必定失败;
  • IPC_NOWAIT说明访问IPC资源时进程从不阻塞。

即使进程使用了IPC_CREATIPC_EXCL标志,也没有办法保证对一个IPC资源进行排它访问,因为其它进程也可能用自己的IPC标识符引用该资源。

为了把不正确地引用错误资源的风险降到最小,内核不会在IPC标识符一空闲就再利用它。相反,分配给资源的IPC标识符总是大于给同类型的前一个资源所分配的标识符(溢出例外)。每个IPC标识符都是通过结合使用与资源类型相关的位置使用序号s、已分配资源的任一位置索引i以及内核中可分配资源所选定的最大值M而计算出。0 <= i < M,则每个IPC资源的 ID 可按如下公式计算:IPC标识符 = s * M + i

Linux2.6中,M = 32768IPCMIN宏)。s =0,每次分配资源时增加1,到达阈值时,重新从0开始。

IPC资源的每种类型(信号量、消息队列和共享内存区)都拥有IPC_ids数据结构。

ipc_id_ary有两个字段:psizep是指向一个kern_ipc_perm数据结构的指针数组,每个结构对应一个可分配资源。size是这个数组的大小。最初,数组为共享内存区、消息队列与信号量分别存放1、16或128个指针。当太小时,内核动态地增大数组。但每种资源都有上限。系统管理员可修改/proc/sys/kernel/sem/proc/kernel/msgmni/proc/sys/kernel/shmmni这三个文件以改变这些上限。

每个kern_ipc_perm与一个IPC资源相关联。uidgidcuidcgid分别存放资源的创建者的用户标识符和组标识符以及当前资源数组的用户标识符和组标识符。mode位掩码包括六个标志,分别存放资源的属主、组以及其它用户的读、写访问权限。

kern_ipc_perm也包括一个key字段和一个seq字段,前者指的是相应资源的IPC关键字,后者存放的是用来计算该资源的IPC标识符所使用的位置使用序号。

semctl()msgctl()shmctl()都可以用来处理IPC资源。IPC_SET子命令允许进程改变属主的用户标识符和组标识符以及IPC_perm中的许可权位掩码。IPC_STATIPC_INFO子命令取得的和资源有关的信息。最后,IPC_RMID子命令释放IPC资源。根据IPC资源的种类不同,还可以使用其它专用的子命令。

一旦IPC资源被创建,进程就可以通过一些专用函数对该资源进行操作。进程可以执行semop()获得或释放一个IPC信号量。当进程希望发送或接收一个IPC消息时,就分别使用msgsnd()msgrcv()。最后,进程可以分别使用shmat()shmdt()把一个共享内存区附加到自己的地址空间中或者取消这种附加关系。

ipc()系统调用

实际上,在80x86体系结构中,只有一个名为IPC()的IPC系统调用。当进程调用一个IPC函数时,如msgget(),实际上调用C库中的一个封装函数,该函数又通过传递msgget()的所有参数加上一个适当的子命令代码来调用IPC()系统调用。sys_ipc()服务例程检查子命令代码,并调用内核函数实现所请求的服务。

ipc()“多路复用”系统调用实际上是从早期的Linux版本中继承而来,早期Linux版本把IPC代码包含在动态模块中。在system_call表中为可能未实现的内核部件保留几个系统调用入口并没有什么意义,因此内核设计者就采用了多路复用的方法。

现在,System V IPC不再作为动态模板被编译,因此也就没有理由使用单个IPC系统调用。

IPC信号量

IPC信号量与内核信号量类似:两者都是计数器,用来为多个进程共享的数据结构提供受控访问。

如果受保护的资源是可用的,则信号量的值就是正数;如果受包含的资源不可用,则信号量的值就是0。要访问资源的进程试图把信号量的值减1,但是,内核阻塞该进程,直到该信号量上的操作产生一个正值。当进程释放受保护的资源时,就把信号量的值增加1;在该处理过程中,其它所有正在等待该信号量的进程都被唤醒。

IPC信号量比内核信号量的处理更复杂是由于两个主要的原因:

  • 每个IPC信号量都是一个或者多个信号量值的集合,而不像内核信号量一样只有一个值。这意味着同一个IPC资源可以保护多个独立、共享的数据结构。
  • System V IPC信号量提供了一种失效安全机制,这是用于进程不能取消以前对信号量执行的操作就死亡的情况的。当进程死亡时,所有IPC信号量都可以恢复原值,就好像从来都没有开始它的操作。

当进程访问IPC信号量所包含的一个或者多个资源时所执行的典型步骤:

  1. 调用semget()获得IPC信号量标识符,通过参数指定对共享资源进行保护的IPC信号量的IPC关键字。如果进程希望创建一个新的IPC信号量,则还要指定IPC_CREATE或者IPC_PRIVATE标志以及所需要的原始信号量。
  2. 调用semop()测试并递减所有原始信号量所涉及的值。如果所有的测试全部成功,就执行递减操作,结束函数并允许该进程访问受保护的资源。如果有些信号量正在使用,则进程通常都会被挂起,直到某个其它进程释放这个资源为止。函数接收的参数为IPC信号量标识符、用来指定对原始信号量所进行的原子操作的一组整数以及这种操作的个数。作为选项,进程也可以指定SEM_UNDO标志,该标志通知内核:如果进程没有释放原始信号量就退出,那么撤销那些操作。
  3. 当放弃受保护的资源时,就再次调用semop()来原子地增加所有有关的原始信号量。
  4. 作为选择,调用semctl(),在参数中指定IPC_RMID命令把该IPC信号量从系统中删除。


图19-1中的sem_ids变量存放IPC信号量资源类型IPC_ids;对应的IPC_id_ary包含一个指针数组,它指向sem_array,每个元素对应一个IPC信号量资源。

从形式上,该数组存放指向kern_ipc_perm的指针,每个结构是sem_array的第一个字段。

sem_array中的sembase字段是指向sem的数组,每个元素对应一个IPC原始信号量。sem只包括两个字段:

  • semval,信号量的计数器的值。
  • sempid,最后一个访问信号量的进程的PID。进程可以使用semctl()查询该值。

可取消的信号量操作

如果一个进程突然放弃执行,则它就不能取消已经开始执行的操作;因此通过把这些操作定义程可取消的,进程就可以让内核把信号量返回到一致状态并允许其它进程继续执行。进程可以在semop()中指定SEM_UNDO标志请求可取消的操作。

为了有助于内核撤销给定进程对给定的IPC信号量资源所执行的可撤销操作,有关的信息存放在sem_undo中。该结构实际上包含信号量的IPC标识符及一个整数数组,该数组表示由进程执行的所有可能取消操作对原始信号量值引起的修改。

一个简单的例子说明如果使用该种sem_undo元素。一个进程使用具有4个原始信号量的一个IPC信号量资源,并假设该进程调用semop()把第一个计数器加1并把第二个计数器减2。如果函数指定了SEM_UNDO标志,sem_undo中的第一个数组元素中的整数值就被减少1,而第二个元素就被增加2,其它两个整数都保持不变。同一进程对该IPC信号量执行的更多的可取消操作将相应地改变存放在sem_undo中的整数值。当进程退出时,该数组中的任何非零值就表示对相应原始信号量的一个或者多个错乱的操作;内核只简单地给相应的原始信号量计数器增加该非零值来取消该操作。换言之,把异常终端的进程所做的修改退回,而其它进程所做的修改仍然能反映信号量的状态。

对于每个进程,内核都要记录可以取消操作处理的所有信号量资源,这样如果进程意外退出,就可以回滚这些操作。内核还必须对每个信号量都记录它所有的sem_undo结构,这样只要进程使用semctl()来强行给一个原始信号量的计数器赋给一个明确的值或者撤销一个IPC信号量资源时,内核就可以快速访问这些结构。

正是由于两个链表(称之为每个进程的链表和每个信号量的链表),使得内核可以有效地处理这些任务。第一个链表记录给定进程可以取消操作处理的所有信号量。第二个链表记录可取消操作对给定信号量进行操作的所有进程。更确切地说:

  • 每个进程链表包含所有的sem_undo数据结构,该机构对应于进程执行了可取消操作的IPC信号量。进程描述符的sysvsem.undo_list字段指向一个sem_undo_list类型的数据结构,而该结构又包含了指向该链表的第一个元素的指针。- 每个sem_undoproc_next字段指向链表的下一个元素。
  • 每个信号量链表包含的所有sem_undo数据结构对应于在该信号量上执行可取消操作的进程。sem_arrayundo字段执行链表的第一个元素,而每个sem_undoid_next字段指向链表的下一个元素。

当进程结束时,每个进程的链表才被使用。exit_sem()do_exit()调用,后者会遍历该链表,并为进程所涉及的每个IPC信号量平息错乱操作产生的影响。与此对照,当进程调用semctl()强行给一个原始信号量赋一个明确的值时,每个信号量的链表才被使用。内核把指向IPC信号量资源的所有sem_undo中的数组的相应元素都设置为0,因为撤销原始信号量的一个可取消操作不再有任何意义。

此外,在IPC信号量被清除时,每个信号量链表也被使用。通过把semid字段设置成-1而使所有有关的sem_undo数据结构变为无效。

挂起请求的队列

内核给每个IPC信号量否分配了一个挂起请求队列,用来标识正在等待数组中的一个(或多个)信号量的进程。该队列是一个sem_queue数据结构的双向链表。

队列中的第一个和最后一个挂起请求分别由sem_array中的sem_pendingsem_pending_last字段指向。最后一个字段允许把链表作为一个FIFO进行简单的处理。新的挂起请求都被追加到链表的末尾,这样就可以稍后得到服务。挂起请求最重要的字段是nsopssops,前者存放挂起操作所涉及的原始信号量的个数,后者指向描述符每个信号量操作的整型数组。sleeper字段存放发出请求操作的睡眠进程的描述符地址。

IPC消息

进程彼此之间可通过IPC消息进行通信。进程产生的每条消息都被发送到一个IPC消息队列中,该消息存放在队列中直到另一个进程将其读走为止。

消息是由固定大小的首部和可变长度的正文组成,可以使用一个整数值(消息类型)标识消息,这就允许进程有选择地从消息队列中获取消息。只要进程从IPC消息队列中读出一条消息,内核就把该消息删除;因此,只有一个进程接收一条给定的消息。

为了发送一条消息,进程要调用msgsnd(),传递给它以下参数:

  • 目标消息队列的IPC标识符。
  • 消息正文的大小。
  • 用户态缓冲区的地址,缓冲区中包含消息类型,之后紧跟消息正文。

进程要获得一条消息就要调用msgcv(),传递给它如下参数:

  • 消息队列资源的IPC标识符。
  • 指向用户态缓冲区的指针,消息类型和消息正文应该被拷贝到这个缓冲区。
  • 缓冲区的大小。
  • 一个值t,指定应该获得什么消息。
    • 如果t的值为0,就返回队列中的第一条消息。
    • 如果t为正数,就返回队列中类型等于t的第一条消息。
    • 如果t为负数,就返回消息类型小于等于t绝对值的最小的第一条消息。

为了避免资源耗尽,IPC消息队列资源在这几个方面是有限制的:IPC消息队列数(缺省为16),每个消息的大小(缺省为8192字节)及队列中全部消息的大小(缺省为16384字节)。系统管理员可分别修改/proc/sys/kernel/msgmni/proc/sys/kernel/msgmnb/proc/sys/kernel/msgmax调整这些值。


msg_ids变量存放IPC消息队列资源类型的IPC_ids数据结构;相应的IPC_id_ary数据结构包含一个指向shmid_kernel数据结构的指针数组。每个IPC消息资源对应一个元素。从形式上看,数组中存放指向kern_ipc_perm数据结构的指针,每个这样的结构是msg_queue数据结构的第一个字段。

msg_queue数据结构的字段如图19-12所示。

msg_queue中最重要的字段是q_messages,它表示包含队列中当前所有消息的双向循环链表的首部。

每条消息分开存放在一个或多个动态分配的页中。第一页的起始部分存放消息头,消息头是一个msg_msg类型的数据结构。

m_list字段指向队列中前一条和后一条消息。消息的正文正好从msg_msg描述符之后开始;如果消息(页的大小减去msg_msg描述符的大小)大于4072字节,就继续放在另一页,它的地址存放在msg_msg描述符的next字段中。第二个页框以msg_msgseg类型的描述符开始,该描述符只包含一个next指针,该指针存放可选的第三个页,以此类推。

当消息队列满时(或者达到了最大消息数,或者达到了队列最大字节数),则试图让新消息入队的进程可能被阻塞。msg_queueq_senders字段是所有阻塞的发送进程的描述符形成的链表的头。

当消息队列为空时(或者当进程指定的一条消息类型不在队列中时),则接收进程也会被阻塞。msg_queueq_receivers字段是msg_receiver链表的头,每个阻塞的接收进程对应其中一个元素。每个结构本质上都包含一个指向进程描述符的指针、一个指向消息的msg_msg的指针和所请求的消息类型。

IPC共享内存

共享内存允许两个或多个进程通过把公共数据结构放入一个共享内存区来访问它们。如果进程要访问这种存放在共享内存区的数据结构,就必须在自己的地址空间中增加一个新内存区,它将映射与该共享内存区相关的页框。这样的页框可以很容易地由内核通过请求调页处理。

与信号量与消息队列一样,调页shmget()来获得一个共享内存区的IPC标识符,如果该共享内存区不存在,就创建它。

调用shmat()把一个共享内存区“附加”到一个进程上。该函数的参数为IPC共享内存资源的标识符,并试图把一个共享内存区加入到调用进程的地址空间中。调用进程可获得该内存区域的起始线性地址,但该地址通常并不重要,访问该共享内存区域的每个进程都可以使用自己地址空间中的不同地址。shmat()不修改进程的页表。

调用shmdt()来“分离”由IPC标识符所指定的共享内存区域,也就是把相应的共享内存区域从进程地址空间中删除。IPC共享内存资源是持久的;即使现在没有进程使用它,相应的页也不能丢弃,但可以被换出


图19-3显示与IPC共享内存区相关的数据结构。shm_ids变量存放IPC共享内存资源类型的IPC_ids的数据结构;相应的IPC_id_ary数据结构包含一个指向shmid_kernel数据结构的指针数组,每个IPC共享内存资源对应一个数组元素。该数组存放指向kern_ipc_perm的指针,每个这样的结构是msg_queue的第一个字段。

shhmid_kernel中最重要的字段是shm_file,该字段存放文件对象的地址。每个IPC共享内存区与属于shm特殊文件系统的一个普通文件关联。

因为shm文件夹系统在目录树中没有安装点,因此,用户不能通过普通的VFS系统调用打开并访问它的文件。但是,只要进程“附加”一个内存段,内核就调用do_mmap(),并在进程的地址空间创建文件的一个新的共享内存映射。因此,属于shm特殊文件系统的文件只有一个文件对象方法mmap,该方法由shm_mmap()实现。

与IPC共享内存区对应的内存区是用vm_area_struct描述的。它的vm_file字段指向特殊文件的文件对象,而特殊文件又依次引用目录项对象和索引节点对象。存放在索引节点i_ino字段的索引节点号实际上是IPC共享内存区的位置索引,因此,索引节点对象间接引用shmid_kernel描述符。

同样,对于任何共享内存映射,通过address_space对象把页框包含在页高速缓存中,而address_space对象包含在索引节点中且被索引节点的i_mapping字段引用。万一页框属于IPC共享内存区,address_space对象的方法就存放在全局变量shem_aops中。

换出IPC共享内存区的页

因为IPC共享内存区映射的是在磁盘上没有映像的特殊索引节点,因此其页是可交换的(而不是可同步的)。因此,为了回收IPC共享内存区的页,内核必须把它写入交换区。因为IPC共享内存区是持久的,也就是说即使内存段不附加到进程,也必须保留这些页。因此,即使这些页没有被进程使用,内核也不能简单地删除它们。

PFRA回收IPC共享内存区页框:一直到shrink_list()处理页之前,都与“内存紧缺回收”一样。因为该函数并不为IPC共享内存区域作任何检查,因此它会调用try_to_unmap()从用户态地址空间删除队页框的每个引用,并删除相应的页表项。

然后,shrink_list()检查页的PG_dirty标志。pageout()在IPC共享内存区域的页框分配时被标记为脏,并调用所映射文件的address_space对象的writepage方法。

shmem_writepage()实现了IPC共享内存区页的writepage方法。它实际上给交换区域分配一个新页槽,然后将它从页高速缓存移到交换高速缓存(改变页所有者的address_space对象)。该函数还在shmem_indoe_info中存放换出页页标识符,该结构包含了IPC共享内存区的索引节点对象,它再次设置页的PG_dirty标志。shrink_list()检查PG_dirty标志,并通过把页留在非活动链表而中断回收过程。

当PFRA再处理该页框时,shrink_list()又一次调用pageout()尝试将页刷新到磁盘。但这一次,页已在交换高速缓存内,因而它的所有者是交换子系统的address_space对象,即swapper_space。相应的writepage方法swap_writepage()开始有效地向交换区进行写入操作。一旦pageout()结束,shrink_list()确认该页已干净,于是从交换高速缓存删除页并释放给伙伴系统。

IPC共享内存区的请求调页

通过shmat()加入进程的页都是哑元页;该函数把一个新内存区加入一个进程的地址空间中,但是它不修改该进程的页表。此外,IPC共享内存区的页可以被换出。因此,可以通过请求调页机制处理这些页。

当进程试图访问IPC共享内存区的一个单元,而其基本的页框还没有分配时则发生缺页异常。相应的异常处理程序确定引起缺页的地址是在进程地址空间内,且相应的页表项为空;因此,调用do_no_page()。该函数又调用nopage方法,并把页表设置成所返回的地址。

IPC共享内存所使用的内存区通常都定义了nopage方法。这是通过shmem_nopage()实现的,该函数执行以下操作:

  1. 遍历VFS对象的指针链表,并导出IPC共享内存资源的索引节点对象的地址。
  2. 从内存区域描述符的vm_start字段和请求的地址计算共享段内的逻辑页号。
  3. 检查页是否已经在交换高速缓存中,如果是,则结束并返回该描述符的地址。
  4. 检查页是否在交换高速缓存内且是否是最新,如果是,则结束并返回该描述符的地址。
  5. 检查内嵌在索引节点对象的shmem_inode_info是否存放着逻辑页号对应的换出页标识符。如果是,就调用read_swap_cache_async()执行换入操作,并一直等到数据传送完成,然后结束并返回页描述符的地址。
  6. 否则,页不在交换区中;从伙伴系统分配一个新页框,把它插入页高速缓存,并返回它的地址。

do_no_page()对引起缺页的地址在进程的页表中所对应的页表项进行设置,以使该函数指向nopage方法所返回的页框。

POSIX消息队列

POSIX消息队列比老的队列具有许多优点:

  • 更简单的基于文件的应用接口。
  • 完全支持消息优先级(优先级最终决定队列中消息的位置)。
  • 完全支持消息到达的异步通知,这通过信号或线程创建实现。
  • 用于阻塞发送与结束操作的超时机制。

POSIX消息队列通过一套库实现:

首先,调用mq_open()打开一个POSIX消息队列。第一个参数是一个指定队列名字的字符串,与文件名类似,且必须以“/”开始。该函数接收一个open()的标志子集:O_RDONLYO_WRONLYO_RDWRO_CREATO_EXCLO_NONBLOCK。应用可以通过指定一个O_CREAT标志创建一个新的POSIX消息队列。mq_open()返回一个队列描述符,与open()返回的文件描述符类似。

一旦POSXI消息队列打开,应用可以通过mq_send()mq_receive()来发送与接收消息,参数为mq_open()返回的队列描述符。应用也可以通过mq_timedsend()mq_timedreceive()指定应用程序等待发送与接收操作完成所需的最长时间。

应用除了在mq_receive()上阻塞,或者如果O_NONBLOCK标志置位则继续在消息队列上轮询外,还可以通过执行mq_notify()建立异步通知机制。
实际上,当一个消息插入空队列时,应用可以要求:要么给指定进程发出信号,要么创建一个新线程。

最后,当应用使用完消息队列,调用mq_close()函数,参数为队列描述符。调用mq_unlink()删除队列。

Linux2.6中,POSIX消息队列通过引入mqeueu的特殊文件系统实现,每个现存队列在其中都有一个相应的索引节点。内核提供了几个系统调用:mq_open()mq_unlink()mq_timesend()mq_timedreceive()mq_notify()mq_getsetattr()。当这些系统调用透明地对mqueue文件系统的文件进行操作时,大部分工作交由VFS层处理。如mq_close()close()实现。

mqueue特殊文件系统不能安装在系统目录树中。但是如果安装了,用户可以通过使用文件系统根目录中的文件来创建POSIX消息队列,也可以读入相应文件来得到队列的有关信息。最后,应用可以使用select()poll()获得队列状态变化的通知。

每个队列有一个mqueue_inode_info描述符,它包含有inode对象,该对象与mqueue特殊文件系统的一个文件相对应。当POSIX消息队列系统调用的参数为一个队列描述符时,它就调用VFS的fget()函数计算出对应文件对象的地址。然后,系统调用得到mqueue文件系统中文件的索引节点对象。最后,就可以得到该索引节点对象所对应的mqueue_inode_info描述符地址。

队列中挂起的消息被收集到mqueue_inode_info描述符中的一个单向链表。每个消息由一个msg_msg类型的描述符表示,与System V IPC中使用的消息描述符完全一样。

程序的执行

尽管把一组指令装入内存并让 CPU 执行看起来不是大问题,但内核还必须灵活处理以下几方面的问题:

  • 不同的可执行文件格式。Linux可在 64 位版本的机器上执行 32 位可执行代码。
  • 共享库。很多可执行文件并不包含执行程序所需的所有代码,而是期望内核在运行时从共享库中加载函数。
  • 执行上下文的其它信息。这包括命令行参数与环境变量。

程序是以可执行文件的形式存放在磁盘上的,可执行文件既包括被执行函数的目标代码,也包括这些函数所使用的数据。程序中的很多函数是可使用的服务例程,它们的目标代码包含在所谓“库”的特殊文件中。实际上,一个库函数的代码或被静态地拷贝到可执行文件中(静态库),或在运行时被连接到进程(共享库,因为它们的代码由很多独立的进程共享)。

当装入并运行一个程序时,用户可以提供影响程序执行的方式的两种信息:命令行参数环境变量

可执行文件

进程被定义为执行上下文,意味着特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容。可执行文件是一个普通文件,它描述了如何初始化一个新的执行上下文,即如何开始一个新的计算。进程开始执行一个新程序时,其执行上下文变化较大,因为进程的前一个计算执行期间所获得的大部分资源会被抛弃。但进程的 PID 不改变,并且新的计算从前一个计算继承所有打开的文件描述符。

进程的信任状和权能

信任状把进程与一个特定的用户或用户在绑定到一起。信任状在多用户系统上尤为重要,因为信任状可以决定每个进程能做什么,不能做什么,这样既保证了每个用户的个入数据的完整性,也保证了系统整体上的稳定性。

信任状的使用既需要在进程的数据结构方面给予支持,也需要在被包含的资源方面给与支持。文件就是一种资源。因此在Ext2文件系统中,每个文件都属于一个特定的用户。

进程的信任状存放在进程描述符的几个字段中。

值为0的UID指定给root超级用户,值为0的GID指定给root超级组。只要有关进程的信任状存放了一个零值,则内核将放弃权限检查,始终运行该进程做任何事情

当一个进程被创建时,总是继承父进程的信任状。但这些信任状以后可以被修改。通常情况下,进程的uideuidfsuidsuid字段具有相同的值。然而,当进程执行setuid程序时,即可执行文件的setuid标志被设置时,euidfsuid字段被设置为该文件拥有者的标识符。几乎所有的检查都涉及这两个字段中的一个:fsuid用于与文件相关的操作,euid用于其它所有的操作。这也同样适用于组描述符的gidegidfsgidsgid字段。

Linux让进程只有在必要时才获得setuid特权,并在不需要时取消它们。进程描述符包含一个suid字段,在setuid程序执行以后在该字段中正好存放有效标识符(euidfsuid)的值。进程可以通过setuid()setresuid()setfsuid()setreuid()以改变有效标识符。

setuid()调用的效果取决于调用者进程的euid字段是否被置为0(即进程有超级用户特权)或被设置为一个正常的UID。如果euid字段为0,setuid()就把调用进程的所有信任状字段(uideuidfsuidsuid)置为参数e的值。超级用户进程因此就可以删除自己的特权而变为普通用户拥有的一个进程。

如果euid字段不为0,则setuid()只修改存放在euidfsuid中的值,让其它两个字段保持不变。当运行setuid程序来提高和降低进程有效权限时(这些权限存放在euidfsuid字段),setuid()非常有用。

进程的权能

一种权能仅仅是一个标志,它表明是否允许进程执行一个特定的操作或一组特定的操作

权能的主要优点是,任何时候每个进程只需要有限种权能。因此,即使有恶意的用户发现一种利用有潜在错误的程序的方法,也只能非法地执行有限个操作类型。

VFS和Ext2文件系统目前都不支持权能模型,所以,当进程执行一个可执行文件时,无法将该文件与本该强加的一组权能联系起来。然而,进程可分别用capget()capset()显式地获得和降低它的权能。

在Linux内核定义了一个名为CAP_SYS_NICE的权能,可检测调用进程描述符的euid字段是否为0。内核通过调用capable()并把CAP_SYS_NICE值传给该函数来检查该标志的值。

每当一个进程把euidfsuid字段设置为0时,内核就设置进程的所有权能,以便所有的检查成功。当进程把euidfsuid字段重新设置为进程拥有者的实际UID时,内核检查进程描述符种的keep_capabilities标志,并在该标志设置时删除进程的所有权能。进程可调用Linux专有的prctl()来设置和重新设置keep_capabilities标志。

Linux安全模块框架

在 Linux2.6 中,权能是与Linux安全模块(LSM)框架紧密结合在一起的。LSM 框架允许定义几种可选择的内核安全模型。

每个安全模型是由一组安全钩实现的。安全钩是由内核调用的一个函数,用于执行与安全有关的重要操作钩函数决定一个操作是否可以执行

钩函数存放在security_operations类型的表中。当前使用的安全模型钩表地址存放在security_ops变量中。内核默认使用dummy_security_ops表实现最小安全模型。表中的每个钩函数实际上检查相应的权能是否允许,否则无条件返回0(允许操作)。

命令行参数和shell环境

当用户键入一个命令时,为满足该请求而装入的程序可从shell接收一些命令行参数。如当用户键入命令,以获得在/usr/bin目录下的全部文件列表时,shell进程创建一个新进程执行该命令。该新进程装入/bin/ls可执行文件。该过程中,从shell继承的大多数执行上下文被丢弃,但三个单独的参数ls-l/usr/bin依然保持。一般情况下,新进程可接收任意多个参数。

传递命令行参数的约定依赖于所用的高级语言。在C语言中,程序的main()把传递给程序的参数个数和指向字符串指针数组的地址作为参数。下列原型形式化地表示了该标准格式:

1
int main(int argc, char *argv[])

/bin/ls被调用时,argc的值为3,argv[0]指向ls字符串,argv[1]指向-l字符串,argv[2]指向/usr/bin字符串。argv数组的末尾处总以空格来标记,因此,argv[3]为NULL。

在C语言中,传递给main()的第三个可选参数是包含环境变量的参数。环境变量用来定制进程的执行上下文,由此为用户或其它进程提供通用的信息,或者允许进程在执行execve()的过程中保持一些信息。为了使用环境变量,main()可声明如下:

1
int main(int argc, char *argv[], char *envp[])

envp参数指向环境串的指针数组,形式如下:
1
VAR_NAME = something

VAR_NAME表示一个环境变量的名字,“=”后面的子串表示赋给变量的实际值。envp数组的结尾用一个空指针标记,就像argv数组。envp数组的地址存放在C库的environ全局变量中。

命令行参数和环境串都存放在用户态堆栈中,正好位于返回地址之前。环境变量位于栈底附近正好在一个0长整数之后。

每个高级语言的源码文件都是经过几个步骤才转化为目标文件的,目标文件中包含的是汇编语言指令的机器代码,它们和相应的高级语言指令对应。目标文件并不能被执行,因为它不包含源代码文件所用的全局外部符号名的线性地址。这些地址的分配或解析是由链接程序完成的,链接程序把程序所有的目标文件收集起来并构造可执行文件。链接程序还分析程序所用的库函数,并把它们粘合成可执行文件。

大多数程序,甚至是最小的程序都会利用C库。如下列C程序:

1
void main(void){}

需要做很多工作来建立执行环境,并在程序终止时杀死该进程,尤其当main()终止时,C编译程序把exit_group()函数插入到目标代码中。

程序通常通过C库中的封装例程调用系统调用。C编译器亦如此。任何可执行文件除了包括对程序的语句进行编译所直接产生的代码外,还包括一些“粘合”代码来处理用户态进程与内核之间的交互。这样的粘合代码有一部分存放在C库中。

除了C库,Unix系统中还包含很多其它的库函数。这就意味着链接程序所产生的可执行文件不仅包括源程序的代码,还包括程序所引用的库函数的代码。静态库的一大缺点是:它们占用大量的磁盘空间。的确,每个静态链接可执行文件都复制库代码的某些部分

现代Unix系统利用共享库。可执行文件不再包含库的目标代码,而仅仅指向库名。当程序被装入内存执行时,一个名为动态链接器的程序就专注于分析可执行文件中的库名,确定所需库在系统目录树中的位置,并使执行进程可使用所请求的代码。进程也可以调用dlopen()库函数在运行时装入额外的共享库

共享库对提供文件内映射的系统尤为方便,因为它们减少了执行一个程序所需的主内存量。当动态链接程序必须把某一共享库链接到进程时,并不拷贝目标代码,而仅仅仔细一个内存映射,把库文件的相关部分映射到进程的地址空间中。这就允许共享库机器代码所在的页框被使用同一代码的所有进程共享。显然,如果程序是静态链接的,那么共享是不可能的。

共享库也有一些缺点。动态链接的程序启动时通常比静态链接的程序长。此外,动态链接的程序的可移植性也不如静态链接的好,因为当系统中所包含的库版本发生变化时,动态链接的程序运行时就可能出现问题。

用户可以始终请求一个程序被静态地链接。

程序段和进程的线性区

从逻辑上说,Unix程序的线性地址传统上被划分为几个叫做的区间:

  • 正文段,包含程序的可执行代码。
  • 已初始化数据段,包含已初始化的数据,也就是初值存放在可执行文件中的所有静态变量和全局变量。
  • 未初始化数据段bss段),包含未初始化的数据,也就是初值没有存放在任何可执行文件中的所有全局变量。
  • 堆栈段,包含程序的堆栈,堆栈中有返回地址、参数和倍执行函数的局部变量。

每个mm_struct内存描述符都包含一些字段来标识相应进程特定线性区的作用:

  • start_code,end_code,程序的源代码所作的线性区的起始和终止线性地址,即可执行文件中的代码。
  • start_data,end_data,程序初始化数据所在的线性区的起始和终止线性地址,正如在可执行文件中所指定的那样。这两个字段指定的线性区大体上与数据段对应。
  • start_brk,brk,存放线性区的起始和终止线性地址,该线性区包含动态分配给进程的内存区。有时把这部分线性区叫做堆。
  • start_stack,正好位于main()的返回地址之上的地址。
  • arg_start,arg_end,命令行参数所在的堆栈部分的起始地址和终止地址。
  • env_start,env_end,环境串所在的堆栈部分的起始地址和终止地址。

灵活线性区布局

每个进程按照用户态堆栈预期的增长量来进行内存布局。但当内核无法限制用户态堆栈的大小时,仍然可以使用老的经典布局。80x86中默认的用户态地址空间最大可以到3GB。

灵活布局中,文件内存映射与匿名映射的线性区是紧接着用户态堆栈尾的。新的区域往更低线性地址追加,因此,这些区域往堆的方向发展。

当内核能通过RLIMIT_STACK资源限制来限定用户态堆栈的大小时,通常使用灵活布局。该限制确定了为堆栈保留的线性地址空间大小。该空间不能小于128MB或大于2.5GB。

另外,如果RLIMIT_STACK资源限制设为无限,或系统管理员将sysctl_legacy_va_layout变量设为1(通过修改/proc/sys/vm/legacy_va_layout文件或调用相应的sysctl()实现),内核无法确定用户态堆栈的上下,就仍然使用经典线性布局。

引入灵活布局的主要优点是可以允许进程更好地使用用户态线性地址空间。在经典布局中,堆的限制是小于1GB,而其它线性区可以使用到约2GB(减去堆栈大小)。在灵活布局中,堆和其它线性区可以自由扩展,可以使用除了用户态堆栈和程序固定大小的段以外的所有线性地址空间

执行跟踪

执行跟踪是一个程序监视另一个程序执行的一种技术。被跟踪的程序一步一步地执行,直到接收到一个信号或调用一个系统调用。执行跟踪由调试程序广泛使用。

在Linux中,通过ptrace()进行执行跟踪,能处理如表中所示的命令。

设置了CAP_SYS_PTRACE权能的进程可以跟踪系统中的任何进程。相反,没有CAP_SYS_PTRACE权能的进程P只能跟踪与P有相同属主的进程。此外,两个进程不能跟踪同一进程。

ptrace()修改被跟踪进程描述符的parent字段以使它指向跟踪进程,因此,跟踪进程变为被跟踪进程的有效父进程。当执行跟踪终止时,即当以PTRACE_DETACH命令调用ptrace()时,该系统调用把p_pptr设置为real_parent的值,恢复被跟踪进程原来的父进程。

与被跟踪程序相关的几个监控事件:

  • 一条单独汇编指令执行的结束。
  • 进入系统调用。
  • 退出系统调用。
  • 接收到一个信号。

当一个监控的事件发生时,被跟踪的程序停止,并且将SIGCHID信号发生给它的进程。当父进程希望恢复子进程的执行时,就使用PTRACE_CONTPTRACE_SINGLESTEPPTRACE_SYSCALL命令中的一条命令,这取决于父进程要监控哪种事件。

PTRACE_CONT命令只继续执行,子进程将一直执行到收到另一个信号。这种跟踪是通过进程描述符的ptrace字段的PF_PTRACED标志实现的,该标志的检查是由do_signal()进行。

PTRACE_SINGLESTEP命令强迫子进程执行下一条汇编语言指令,然后又停止它。这种跟踪基于80x86机器的eflags寄存器的TF陷阱标志而实现。当该标志为1时,在任一条汇编指令之后产生一个“Debug”异常。相应的异常处理程序只是清掉该标志,强迫当前进程停止,并发送SIGCHLD信号给父进程。设置TF标志并不是特权操作,因此用户态进程即使在没有ptrace()的情况下,也能强迫单步执行。内核检查进程描述符的PT_DTRACE标志,以跟踪子进程是否通过ptrace()进行单步执行。

PTRACE_SYSCALL命令使被跟踪的进程重新恢复执行,直到一个系统调用被调用。进程停止两次,第一次是在系统调用开始时,第二次是在系统调用终止时。这种跟踪是利用进程描述符中的TIF_SYSCALL_TRACE标志实现的。该标志是在进程thread_infoflags字段中,并在system_call()汇编语言函数中检查。

可执行格式

Linux标志的可执行格式是ELF(executable and Linking Format)。有几种可执行格式与平台无关,如bash脚本。类型为linux_binfmt的对象所描述的可执行格式实质上提供以下三种方法:

  • load_binary,通过读存放可执行文件中的信息为当前进程建立一个新的执行环境。
  • load_shlib,用于动态地把一个共享库捆绑到一个已经在运行的进程,由uselib()激活。
  • core_dump,在名为core的文件中存放当前进程的执行上下文。该文件通常在进程接收到一个缺省操作为“dump”的信号时被创建,其格式取决于被执行程序的可执行类型。

所有的linux_binfmt对象都处于一个单向链表中,第一个元素的地址存放在formats变量中。可通过调用register_binfmt()unregister_binfmt()在链表中插入和删除元素。在系统启动期间,为每个编译进程可执行的模块都执行register_binfmt(),当实现了一个新的可执行格式的模块正被装载时,也执行该函数,当模块被卸载时,执行unregister_binfmt()

formats链表中的最后一个元素总是对解释脚本的可执行格式进行描述的一个对象。这种格式只定义了load_binary方法。相应的load_script()函数检查这种可执行文件是否以#!字符开始。如果是,该函数就把第一行的其余部分解释为另一个可执行文件的路径名,并把普通脚本文件名作为参数传递以执行它。

Linux允许用户注册自己定义的可执行格式。对这种格式的失败或者通过文件前128字节的魔数,或者通过表示文件类型的扩展名。如MS-DOS的扩展名由“.”把三个字符从文件名中分离出来:.exe标识扩展名标识可执行文件,而.bat扩展名标识shell脚本。

当内核确定可执行文件是自定义格式时,它就启动相应的解释程序。解释程序运行在用户态,读入可执行文件的路径名作为参数,并执行计算。这种机制与脚本格式类似,但功能更强大,因为它对自定义格式不加任何限制。要注册一个新格式,就必须在binfmt_misc特殊文件系统(通常/proc/sys/fs/binfmt_misc)的注册文件中写入一个字符串,格式如下:

1
:name:type:offset:string?interpreter:flags

每个字段含义如下:

  • name,新格式的标识符。
  • type,识别类型(M表示魔数,E表示扩展)。
  • offset,魔数在文件中的起始偏移量。
  • string,以魔数或者以扩展名匹配的字节序列。
  • mask,用来屏蔽掉string中的一些位的字符串。
  • interpreter,解释程序的完整路径名。
  • flags,可选标志,控制必须怎样调用解释程序。

例如,超级用户执行的下列命令将使内核识别出 Microsoft Windows 的可执行格式:

1
$echo ‘DOSWin:M:0:MZ:0xff:/usr/bin/win:’ > /proc/sys/fs/binfmt_mis/register

Winows 可执行文件的前两个字节是魔数MZ,由解释程序/usr/bin/wine执行该可执行文件。

执行域

Linux的一个巧妙的特点是能执行其它操作系统所编译的程序。但是,只有内核运行的平台与可执行文件包含的机器代码对应的平台相同时才可能。
对于“外来”程序提供两种支持:

  • 模拟执行:程序中包含的系统调用与POSIX不兼容时才有必要执行这种程序。
  • 原样执行:只有程序所包含的系统调用完全与POSIX兼容时才有效。

Microsoft MS-DOS和Windows程序是被模拟执行的,因为它们包含的API不能被Linux识别,因此不能原样执行。像DOSemu或Wine这样的模拟程序被调用来把API调用转换位一个模拟的封装调用,而封装函数调用又使用现有的Linux系统调用。

另一方面,不用太费力就可以执行其它操作系统编译的与POSIX兼容的程序,因为与POSIX兼容的操作系统都提供了类似API。内核必须消除的细微差别通常涉及如何调用系统调用或如何给各种信号编号。这种类型存放在类型为exec_domain的执行域描述符中。

进程可以指定它的执行域,这是通过设置进程描述符的personality字段,以及把相应exec_domain的地址存放到thread_infoexec_domain字段来实现。进程可通过发布personality()来改变它的个性。

exec函数

以前缀exec开始的函数能用可执行文件所描述的新上下文代替进程的上下文。

每个函数的第一个参数表示被执行文件的路径名。如果路径名不包含/字符,execlp()execvp()就在PATH环境变量所指定的所有目录中搜索该可执行文件。

除了第一个参数,execl()execlp()execle()包含的其它参数格式都是可变的。每个参数指向一个字符串,该字符串是对新程序命令行参数的描述,正如函数名中“l”字符所隐含的那样,这些参数组织成一个列表(最后一个值为NULL)。通常,第一个命令行参数复制可指向文件名。相反,execv()execvp()execve()指定单个参数的命令行参数,正如函数名中“v”字符所隐含的那样,该单个参数是指向命令行参数串的指针向量地址。
数组的最后一个元素就必须存放NULL值。

execle()execve()的最后一个参数是指向环境串的指针数组的地址;数组的最后一个元素照样必须是NULL。其它函数对新程序环境参数的访问是通过C库定义的外部全局变量environ进行的。

所有的exec函数(除execve()外)都是C库定义的封装函数例程,并利用了execve(),这是Linux所提供的处理程序执行的唯一系统调用。

sys_execve()服务例程的参数:

  • 可执行文件路径名的地址(在用户态地址空间)。
  • 以NULL结束的字符串指针数组的地址(在用户态地址空间)。每个字符串表示一个命令行参数。
  • 以NULL结束的字符串指针数组的地址(在用户态地址空间)。每个字符串以NAME = value形式表示一个环境变量。

sys_execve()把可执行文件路径名拷贝到一个新分配的页框。然后调用do_execve()函数,参数为指向该页框的指针、指针数组的指针及把用户态寄存器内容保存到内核态堆栈的位置。do_execve()依次执行下列操作:

  1. 动态分配一个linux_binprm数据结构,并用新的可执行文件的数据填充该结构。
  2. 调用path_lookup()dentry_open()path_release(),以获得与可执行文件相关的目录项对象、文件对象和索引节点对象。如果失败,则返回相应的错误码。
  3. 检查是否可以由当前进程执行该文件,再检查索引节点的i_writecount字段,以确定可执行文件没被写入;把-1存放在该字段以禁止进一步的写访问。
  4. 在多处理器系统中,调用sched_exec()确定最小负载CPU以执行新程序,并把当前进程转移过去。
  5. 调用init_new_context()检查当前进程是否使用自定义局部描述符表。如果是,函数为新程序分配和准备一个新的LDT。
  6. 调用prepare_binprm()函数填充linux_binprm数据结构,该函数又依次执行下列操作:
    1. 再一次检查文件是否可执行(只是设置一个执行访问权限)。如果不可执行,则返回错误码。
    2. 初始化linux_binprm结构的e_uide_gid字段,考虑可执行文件的setuidsetgid标志的值。这些字段分别表示有效的用户ID和组ID。也要检查进程的权能。
    3. 用可执行文件的前128字节填充linux_binprm结构的buf字段。这些字节包含的是适合于可执行文件格式的一个魔数和其它信息。
  7. 把文件名、命令行参数及环境串拷贝到一个或多个新分配的页框中(最终,它们会被分配给用户态地址空间的)。
  8. 调用search_binary_handler()formats链表进行扫描,并尽力应用每个元素的load_binary方法,把linux_binprm传递给该函数。只要load_binary方法成功应答了文件的可执行格式,对formats的扫描就终止。
  9. 如果可执行文件格式不在formats链表中,就释放所分配的所有页框并返回错误码-ENOEXEC,表示Linux不认识该可执行文件格式。
  10. 否则,函数释放linux_binprm数据结构,返回从该文件可执行格式的load_binary方法中所获得的代码。

可执行文件格式对应的load_binary方法执行下列操作(假定该可执行文件所在的文件系统允许文件进行内存映射并需要一个或多个共享库):

  1. 检查存放在文件前128字节中的一些魔数以确认可执行格式。如果魔数不匹配,则返回错误码-ENOEXEC。
  2. 读可执行文件的首部。该首部描述程序的段和所需的共享库。
  3. 从可执行文件获得动态链接程序的路径名,并用它来确定共享库的位置并把它们映射到内存。
  4. 获得动态链接程序的目录项对象(也就获得了索引节点对象和文件对象)。
  5. 检查动态链接程序的执行许可权。
  6. 把动态链接程序的前128字节拷贝到缓冲区。
  7. 对动态链接程序类型执行一些一致性检查。
  8. 调用flush_old_exec()释放前一个计算所占用的几乎所有资源。该函数又依次执行下列操作:
    1. 如果信号处理程序的表为其它进程所共享,那么就分配一个新表并把旧表的引用计数器减1;而且它将进程从旧的线程组脱离。这通过调用de_thread()完成。
    2. 如果与其它进程共享,就调用unshare_files()拷贝一份包含进程已打开文件的files_struct结构。
    3. 调用exec_mmap()释放分配给进程的内存描述符、所有线性区地址及所有页框,并清除进程的页表。
    4. 将可执行文件路径名赋给进程描述符的comm字段。
    5. 调用flush_thread()清除浮点寄存器的值和TSS段保存的调试寄存器的值。
    6. 调用flush_signal_handlers(),用于将每个信号恢复为默认操作,从而更新信号处理程序的表。
    7. 调用flush_old_files()关闭所有打开的文件,这些打开的文件在进程描述符的files->close_on_exec字段设置了相应的标志。现在,已经不能返回了:如果真出了差错,该函数不能恢复前一个计算。
  9. 清除进程描述符的PF_FORKNOEXEC标志。该标志用于在进程创建时设置进程记账,在执行一个新程序时清除进程记账。
  10. 建立进程新的个性,即设置进程描述符的personlity字段。
  11. 调用arch_pick_mmap_layout(),以选择进程线性区的布局。
  12. 调用setup_arg_pages()为进程的用户态堆栈分配一个新的线性区描述符,并把该线性区插入到进程的地址空间。setup_arg_pages()还把命令行参数和环境变量串所在的页框分配给新的线性区。
  13. 调用do_mmap()创建一个新线性区来对可执行文件正文段(即代码)进行映射。该线性区的起始地址依赖于可执行文件的格式,因为程序的可执行代码通常是不可重定位的。因此,该函数假定从某一特定逻辑地址的偏移量开始(因此就从某一特定的线性地址开始)装入正文段。ELF程序被装入的起始线性地址为0x0804800。
  14. 调用do_mmap()创建一个新线性区来对可执行文件的数据进行映射。该线性区的起始线性地址也依赖于可执行文件的格式,因为可执行代码希望在特定的偏移量(即特定的线性地址)处找到自己的变量。在ELF程序中,数据段正好装在正文段之后。
  15. 为可执行文件的其它专用段分配另外的线性区,通常是无。
  16. 调用一个装入动态链接程序的函数。如果动态链接程序是ELF可执行的,该函数就叫做load_elf_interp()。一般情况下,该函数执行第12~14步的操作,不过要用动态链接程序代替被执行的文件。动态链接程序的正文段和数据段在线性区的起始地址是由动态链接程序本身指定的;但它们处于高地址区(通常高于0x40000000),这是为了避免与被执行文件的正文段和数据段所映射的线性区发生冲突。
  17. 把可执行格式的linux_binfmt对象的地址存放在进程描述符的binfmt字段中。
  18. 确定进程的新权能。
  19. 创建特定的动态链接程序表示并把它们存放在用户态堆栈,这些表处于命令行参数和指向环境串的指针数组之间。
  20. 设置进程的内存描述符的start_codeend_codestart_dataend_datastart_brkbrksstart_stack字段。
  21. 调用do_brk()创建一个新的匿名线性区来映射程序的bss段(当进程写入一个变量时,就触发请求调页,进而分配一个页框)。该线性区的大小是在可执行程序被链接时就计算出来的。因为程序的可执行代码通常是不可重新定位的,因此,必须指定该线性区的起始线性地址。在ELF程序中,bss段正好装在数据段之后。
  22. 调用start_thread()宏修改保存在内核态堆栈但属于用户态寄存器的eipesp的值,以使它们分别指向动态链接程序的入口点和新的用态堆栈的栈顶。
  23. 如果进程正被跟踪,就通知调试程序execve()已完成。
  24. 返回0值(成功)。

execve()终止且调用进程重新恢复它在用户态的执行时,执行上下文被大幅度改变,调用系统调用的代码不复存在。从这个意义上看,execve()从未成功返回。取而代之的是,要执行的新程序已被映射到进程的地址空间

但是,新程序还不能执行,因为动态链接程序还必须考虑共享库的装载。

动态链接程序运行在用户态,其运作方式:

  • 第一个工作是从内核保存在用户态堆栈的信息(处于环境串指针数组和arg_start之间)开始,为自己建立一个基本的执行上下文
  • 然后,动态链接程序必须检查被执行的程序,以识别哪个共享库必须装入及在每个共享库中哪个函数被有效地请求。
  • 接下来,解释器发出几个mmap()创建线性区,以对将存放程序实际使用的库函数(正文和数据)的页进行映射。
  • 然后,解释器根据库的线性区的线性地址更新对共享库符号的所有引用
  • 最后,动态链接程序通过跳转到被执行程序的主入口点而终止它的执行。
  • 从现在开始,进程将执行可执行文件的代码和共享库的代码

附录一:系统启动

现代:start_kernel()函数

start_kernel()函数完成Linux内核的初始化工作。几乎每天内核部件都是由这个函数进行初始化的,我们只提及其中的少部分:

  • 调用sched_init()函数来初始化调度程序
  • 调用build_all_zonelists()函数来初始化内存管理区
  • 调用page_alloc_init()函数来初始化伙伴系统分配程序
  • 调用trap_init()函数和init_IRQ()函数以完成IDT初始化
  • 调用softirq_init()函数初始化TASKLET_SOFTIRQHI_SOFTIRQ
  • 调用time_init()函数来初始化系统日期和时间
  • 调用kmem_cache_init()函数来初始化slab分配器
  • 调用calibrate_delay()函数以确定CPU时钟的速度
  • 调用kernel_thread()函数为进程1创建内核线程。这个内核线程又会创建其他的内核线程并执行/sbin/init程序

附录二:模块

当系统程序员希望给Linux内核增加新功能时,倾向于把新代码作为一个模块来实现。因为模块可以根据需要进行链接,这样内核就不会因为装载那些数以百计的很少使用的程序而变得非常庞大。几乎Linux内核的每个高层组件都可以作为模块进行编译。

然而,有些Linux代码必须被静态链接,也就是说相应组件或者被包含在内核中,或者根本不被编译。典型情况下,这发生在组件需要对内核中静态链接的某个数据结构或函数进行修改时。例如,假设某个组件必须在进程描述符中引入新字段。链接一个模块并不能修改诸如task_struct之类已经定义的数据结构,因为即使这个模块使用其数据结构的修改版,所有静态链接的代码看到的仍是原来的版本,这样就很容易发生数据崩溃。对此问题的一种局部解决方法就是静态地把新字段加到进程描述符,从而让这个内核组件可以使用这些字段,而不用考虑组件究竟是如何被链接的。然而,如果该内核组件从未被使
用,那么,在每个进程描述符中都复制这些额外的字段就是对内存的浪费。如果新内核组件对进程描述符的大小有很大的增加,那么,只有新内核组件被静态地链接到内核,才可能通过在这个数据结构中增加需要的字段获得较好的系统性能。

再例如,考虑一个内核组件,它要替换静态链接的代码。显然,这样的组件不能作为一个模块来编译,因为在链接模块时内核不能修改已经在RAM中的机器码。例如,系统不可能链接一个改变页框分配方法的模块,因为伙伴系统函数总是被静态地链接到内核。内核有两个主要的任务来进行模块的管理。第一个任务是确保内核的其他部分可以访问该模块的全局符号,例如指向模块主函数的入口。模块还必须知道这些符号在内核及其他模块中的地址。因此,在链接模块时,一定要解决模块间的引用关系。第二个任务是记录模块的使用情况,以便在其他模块或者内核的其他部分正在使用这个模块时,不能卸载这个模块。系统使用了一个简单的引用计数器来记录每个模块的引用次数。

一般的,使用MODULE_LICENSE宏,每个模块开发者在模块源代码中标出许可证类型,如果不是GPL兼容的,模块就不能使用许多核心数据结构和函数。

模块的实现

模块作为ELF对象文件存放在文件系统,并通过insmod程序链接到内核中。对每个模块,系统分配一个包含以下数据的内存区:

  • 一个module对象
  • 表示模块名的一个以NULL结尾的字符串
  • 实现模块功能的代码

module对象描述一个模块,一个双向循环列表存放所有module对象,链表头部存放在modules变量中,而指向相邻单元的指针存放在每个module对象的list字段中。


state字段记录模块内部状态,它可以是:MODULE_STATE_LIVE(模块为活动的)、MODULE_STATE_COMING(模块正在初始化)和MODULE_STATE_GOING(模块正在卸载)。每个模块都有自己的异常表。该表包括(如果有)模块的修正代码的地址。在链接模块时,该表被拷贝到RAM中,其开始地址保存在module对象的extable字段中。

模块使用计数器

每个模块都有一组使用计数器,每个CPU一个,存放在相应module对象的ref字段中。在模块功能所涉及的操作开始执行时递增这个计数器,在操作结束时递减这个计数器。只有所有使用计数器的和为0时,模块才可以被取消链接。

例如,假设MS-DOS文件系统层作为模块被编译,而且这个模块已经在运行时被链接。最开始时,该模块的引用计数器是0。如果用户装载一张MS-DOS软盘,那么模块引用计数器其中的一个就被递增1。反之,当用户卸载这张软盘时,计数器其中之一就被减1(甚至不是刚才递增的那一个)。模块的总的引用计数器就是所有CPU计数器的总和。

导出符号

当链接一个模块时,必须用合适的地址替换在模块对象代码中引用的所有全局内核符号(变量和函数)。这个操作与在用户态编译程序时链接程序所执行的操作非常类似,这是委托给insmod外部程序完成的。内核使用一些专门的内核符号表,用于保存模块访问的符号和相应的地址。它们在内核代码段中分三个节:__kstrtab节(保存符号名)、__ksymtab节(所有模块可使用的符号地址)和__ksymtab_gpl节(GPL兼容许可证下发布的模块可以使用的符号地址)。当用于静态链接内核代码内时,EXPORT_SYMBOLEXPORT_SYMBOL_GPL宏让C编译器分别往__ksymtab__symtab_gpl部分相应地加入一个专用符号。

只有某一现有的模块实际使用的内核符号才会保存在这个表中。如果系统程序员在某些模块中需要访问一个尚未导出的内核符号,那么他只要在Linux源代码中增加相应的EXPORT_SYMBOL_GPL宏就可以了。当然,如果许可证不是GPL兼容的,他就不能为模块合法导出一个新符号。

已链接的模块也可以导出自己的符号,这样其他模块就可以访问这些符号。模块符号部分表(module symbol table)保存在模块代码段的__ksymcab__ksymtab_gpl__kstrtab部分中。要从模块中导出符号的一个子集,程序员可以使用上面描述的EXPORT_SYMBOLEXPORT_SYMBOL_GPL宏。当模块链接时,模块的导出符号被拷贝到两个内存数组中,而其地址保存在module对象的symsgpl_syms字段中。

模块依赖

一个模块(B)可以引用由另一个模块(A)所导出的符号;在这种情况下,我们就说B装载在A的上面,或者说A被B使用。为了链接模块B,必须首先链接模块A;否则对于模块A所导出的那些符号的引用就不能适当地链接到B中。简而言之,两个模块存在着依赖(dependency)。

A模块对象的modules_which_use_me字段是一个依赖链表的头部,该链表保存使用A的所有模块。链表中的每个元素是一个小型module_use描述符,该描述符保存指向链表中相邻元素的指针及一个指向相应模块对象的指针。在本例中,指向B模块对象的module_use描述符将出现在A的modules_which_use_me链表中。只要有模块装载在A上,modules_which_use_me链表就必须动态更新。如果A的依赖链表非空,模块A就不能卸载。

当然,除A和B之外,还会有其他模块(C)装载到B上,依此类推。模块的堆叠是对内核源代码进行模块化的一种有效方法,目的是为了加速内核的开发。

模块的链接和取消

用户可以通过执行insmod外部程序把一个模块链接到正在运行的内核中。该程序执行以下操作:

  1. 从命令行中读取要链接的模块名。
  2. 确定模块对象代码所在的文件在系统目录树中的位置。对应的文件通常都是在/lib/modules的某个子目录中。
  3. 从磁盘读入存有模块目标代码的文件。
  4. 调用init_module()系统调用,传入参数:存有模块目标代码的用户态缓冲区地址、目标代码长度和存有insmod程序所需参数的用户态内存区。
  5. 结束。

sys_init_module()服务例程是实际执行者,主要操作步骤如下:

  1. 检查是否允许用户链接模块(当前进程必须具有CAP_SYS_MODULE权能)。只要给内核增加功能,而它可以访问系统中的所有数据和进程,安全就是至关重要的。
  2. 为模块目标代码分配一个临时内存区,然后拷入作为系统调用第一个参数的用户态缓冲区数据。
  3. 验证内存区中的数据是否有效表示模块的ELF对象,如果不能,则返回错误码。
  4. 为传给insmod程序的参数分配一个内存区,并存入用户态缓冲区的数据,该缓冲区地址是系统调用传入的第三个参数。
  5. 查找modules链表,以验证模块未被链接。通过比较模块名(module对象的name字段)进行这一检查。
  6. 为模块核心可执行代码分配一个内存区,并存入模块相应节的内容。
  7. 为模块初始化代码分配一个内存区,并存入模块相应节的内容。
  8. 为新模块确定模块对象地址,对象映像保存在模块ELF文件的正文段gnu.linkonce.this_module一节,而模块对象保存在第6步中的内存区。
  9. 将第6和7步中分配的内存区地址存入模块对象的module_codemodule_init字段。
  10. 初始化模块对象的modules_which_use_me链表。当前执行CPU的计数器设为1, 而其余所有的模块引用计数器设为0。
  11. 根据模块对象许可证类型设定模块对象的license_gplok标志。
  12. 使用内核符号表与模块符号表,重置模块目标码。这意味着用相应的逻辑地址偏移量替换所有外部与全局符号的实例值。
  13. 初始化模块对象的symsgpl_syms字段,使其指向模块导出的内存中符号表。
  14. 模块异常表保存在模块ELF文件的__ex_table一节,因此它在第6步中已拷入内存区,将其地址存入模块对象的extable字段。
  15. 解析insmod程序的参数,并相应地设定模块变量的值。
  16. 注册模块对象rnkobj字段中的kobject对象,这样在sysfs特殊文件系统的module目录中就有一个新的子目录。
  17. 释放第2步中分配的临时内存区。
  18. 将模块对象追加到modules链表。
  19. 将模块状态设为MODULE_STATE_COMING
  20. 如果模块对象的init方法已定义,执行它。
  21. 将模块状态设为MODULE_STATE_LIVE
  22. 结束并返回0(成功)。

为了取消模块的链接,用户需要调用rmmod外部程序,该程序执行以下操作:

  1. 从命令行中读取要取消的模块的名字。
  2. 打开/proc/modules文件,其中列出了所有链接到内核的模块,检查待取消模块是否有效链接。
  3. 调用delete_module()系统调用,向其传递要卸载的模块名。
  4. 结束。

相应的sys_delete_module()服务例程执行以下操作:

  1. 检查是否允许用户取消模块链接(当前进程必须具有CAP_SYS_MODULE权能)。
  2. 将模块名存入内核缓冲区。
  3. modules链表查找模块的module对象。
  4. 检查模块的modules_which_use_me依赖链表,如果非空就返回一个错误码。
  5. 检查模块状态,如果不是MODULE_STATE_LIVE,就返回错误码。
  6. 如果模块有自定义init方法,函数就要检查是否有自定义exit方法。如果没有自定义exit方法,模块就不能卸载,那么返回一个退出码。
  7. 为了避免竞争条件,除运行sys_delete_module()服务例程的CPU外,暂停系统中所有CPU的运行。
  8. 把模块状态设为MODULE_STATE_GOING
  9. 如果所有模块引用计数器的累加值大于0,就返回错误码。
  10. 如果已定义模块的exit方法,执行它。
  11. modules链表删除模块对象,并且从sysfs特殊文件系统注销该模块。
  12. 从刚才使用的模块依赖链表中删除模块对象。
  13. 释放相应内存区,其中存有模块可执行代码、module对象及有关符号和异常表。
  14. 返回0(成功)。

根据需要链接模块

模块可以在系统需要其所提供的功能时自动进行链接,之后也可以自动删除。

例如,假设MS-DOS文件系统既没有被静态链接,也没有被动态链接。如果用户试图装载MS-DOS文件系统,那么mount()系统调用通常就会失败,返回一个错误码,因为MS-DOS没有被包含在已注册文件系统的file_systems链表中。然而,如果内核已配置为支持模块的动态链接,那么Linux就试图链接MS-DOS模块,然后再扫描已经注册过的文件系统的列表。如果该模块成功地被链接,那么mount()系统调用就可以继续执行,就好像MS-DOS文件系统从一开始就存在一样。

modprobe程序

为了自动链接模块,内核要创建一个内核线程来执行modprobe外部程序,该程序要考虑由干模块依赖所引起的所有可能因素。模块依赖在前面已介绍过:一个模块可能需要一个或者多个其他模块,这些模块又可能需要其他模块。对模块依赖进行解析以及对模块进行查找的操作最好都在用户态中实现,因为这需要查找和访问文件系统中的模块对象文件。

modprobe外部程序和insmod类似,因为它链接在命令行中指定的一个模块。然而,modprobe还可以递归地链接命令行中模块所使用的所有模块。实际上,modprobe只是检查模块依赖关系,每个模块的实际的链接工作是通过创建一个进程并执行insmod命令来实现的。

modprobe又是如何知道模块间的依赖关系的呢?另外一个称为depmod的外部命令在系统启动时被执行。该程序查找为正在运行的内核而编译的所有模块,这些模块通常存放在/lib/nodules目录下。然后它就把所有的模块间依赖关系写入一个名为modules.dep的文件。这样,modprobe就可以对该文件中存放的信息和/proc/modules文件产生的链接模块链表进行比较。

request_module()函数

调用request_module()函数自动链接一个模块。再次考虑用户试图装载MS-DOS文件系统的情况。如果get_fs_type()函数发现这个文件系统还没有注册,就调用request_module()函数,希望MS-DOS已经被编译为一个模块。

如果request_module()成功地链接所请求的模块,get_fs_type()就可以继续执行,仿佛这个模块一直都存在一样。request_module()函数接收要链接的模块名作为参数。该函数调用kernel_thread()来创建一个新的内核线程并等待,直到这个内核线程结束为止。而此内核线程又接收待链接的模块名作为参数,并调用execve()系统调用以执行modprobe外部程序,向其传递模块名。然后,modeprobe程序真正地链接所请求的模块以及这个模块所依赖的任何模块。

本书是对Boost C++库的介绍,Boost 库通过加入一些在实践中非常有用的函数对C++标准进行了补充。由于Boost C++库是基于C++标准的,所以它们是使用最先进的C++来实现的。它们是平台独立的,并由于有一个大型的开发人员社区,它可以被包括Windows和Linux在内的许多操作系统所支持。

Boost C++库可以提升你作为一个C++开发人员的生产力。例如,你可以从智能指针中受益,帮助你写出更可靠的代码,或者使用某个库来开发平台独立的网络应用。因为多数Boost C++库正被收录进下一个版本的C++标准,所以你可以从今天就开始作好准备。

第 1 章 简介

1.1.C++与Boost

Boost C++库 是一组基于C++标准的现代库。其源码按Boost Software License 来发布,允许任何人自由地使用、修改和分发。这些库是平台独立的,且支持大多数知名和不那么知名的编译器。

Boost 社区负责开发和发布Boost C++库。社区由一个很大的C++开发人员群组组成,这些开发人员来自于全球,他们通过网站 www.boost.org 以及几个邮件列表相互协调。社区的使命是开发和收集高质量的库,作为C++标准的补充。那些被证实有价值且对于C++应用开发非常重要的库,将会有很大机会在某天被纳入C++标准中。

Boost 社区在1998年左右出现,当时刚刚发布了C++标准的第一个版本。从那时起,社区就不断地扩大,现在已成为C++标准化工作中的一个重要角色。虽然Boost 社区与标准化委员会之间没有直接的关系,但有部分开发者同时活跃于两方。下一个版本的C++标准很大可能在2011年通过,其中将扩展一批库,这些库均起源于Boost 社区。

要增强C++项目的生产力,除了C++标准以外,Boost C++库是一个不错的选择。由于当前版本的C++标准在2003年修订之后,C++又有了新的发展,所以Boost C++库提供了许多新的特性。由于有了Boost C++库,我们无需等待下一个版本的C++标准,就可以立即享用C++演化中取得的最新进展。

Boost C++库具有良好的声誉,这基于它们的使用已被证实是非常有价值的。在面试中询问关于Boost C++库的知识是不常见的,因为知道这些库的开发人员通常也清楚C++的最新创新,并且能够编写和理解现代的C++代码。

1.2. 开发过程

正是因为大量的独立开发者和组织的支持和参与,才使用Boost C++库的开发成为可能。由于Boost 只接受满足以下条件的库:解决了真实存在的问题、表现出令人信服的设计、使用现代C++来开发且以可理解的方式提供文档,所以每一个Boost C++库的背后都有大量的工作。

C++ 开发者都可以加入到Boost 社区中,并提出自己的新库。但是,要将一个想法变成一个Boost C++库,需要投入大量的时间和努力。其中最重要的是在Boost 邮件列表中与其他开发者及潜在用户讨论需求和可能的解决方案。

除了这些好象不知从何处冒出来的新库以外,也可以提名一些已有的C++库进入Boost。不过,由于对这些库的要求是与专门为Boost 开发的库一样的,所以可能需要对它们进行大量的修改。

一个库是否被接纳入Boost,取决于评审过程的结果。库的开发者可以申请评审,这通常需要10天的时间。在这段时间内,其他开发者被邀请对这个库进行评分。基于正面和负面评价的数量,评审经理将决定该库是否被接纳进入Boost。由于有些开发者是在评审阶段才首次公开库的代码,所以在评审期间被要求对库进行修改并不罕见。

如果一个库是因为技术原因被拒绝,那么它还有可能在修改之后对更新后的版本申请新的评审。但是,如果一个库是因为不能解决实际问题或未能提供令人信服的解决方案而被拒绝,那么再一次评审也很可能会被拒绝。

因为可能随时接纳新的库,所以Boost C++库会每三个月发布一次新版本。本书所涉及的库均基于2010年2月发布的 1.42.0 版本。

请注意,另外还有一些库已被接纳,但尚未成为Boost C++库发布版的一部分。在被包含进发布版之前,它们必须手工安装。

1.3. 安装

Boost C++库均带有源代码。其中大多数库只包含头文件,可以直接使用,但也有一些库需要编译。为了尽可能容易安装,可以使用Boost Jam 进行自动安装。无需逐个库进行检查和编译,Boost Jam 自动安装整个库集。它支持许多操作系统和编译器,并且知道如何基于适当的配置文件来编译单个库。

为了在Boost Jam 的帮助下自动安装,要使用一个名为 bjam 的应用程序,它也带有源代码。对于某些操作系统,包括Windows和 Linux,也有预编译好的 bjam 二进制文件。

为了编译 bjam 本身,要执行一个名为 build 的简单脚本,它也为不同的操作系统提供了源代码。对于 Windows,它是批处理文件 build.bat。对于 Linux,文件名为 build.sh。

如果执行 build 时不带任何命令行选项,则该脚本尝试找到一个合适的编译器来生成 bjam。通过使用命令行开关,称为 toolset,可以选择特定的编译器。对于 Windows,build 支持 toolsets vc7,vc8 和 vc9,可以选择不同版本的 MicrosoftC++编译器。要从 Visual Studio 2008 的C++编译器编译 bjam,需要指定命令 build vc9。对于 Linux,支持 toolsets gcc 和 intel-linux,分别选定 GCC 和 Intel 的C++编译器。

应用程序 bjam 必须复制到本地的Boost 目录 - 不论它是编译出来的还是下载的预编译二进制文件。然后就可以不带任何命令行选项地执行 bjam,编译并安装Boost C++库。由于缺省选项 - 在这种情况下所使用的 - 并不一定是最好的选择,所以以下列出最重要的几个选项供参考:

声明stageinstall可以指定Boost C++库是安装在一个名为stage的子目录下,还是在系统范围内安装。”系统范围”的意义取决于操作系统。在Windows中,目标目录是C:\Boost;而在Linux中则是 /usr/local。目标目录也可以用—prefix`选项明确指出。

如果不带任何命令行选项执行bjam,则它会自己搜索一个合适的C++编译器。可以通过--toolset选项来指定一个特定的编译器。要在Windows中指定 Visual Studio 2008 的 MicrosoftC++编译器,bjam 执行时要带上--toolset=msvc-9.0选项。要在Linux中指定 GCC 编译器,则要给出--toolset=gcc选项。

命令行选项--build-type决定了创建何种方式的库。缺省情况下,该选项设为minimal,即只创建发布版。对于那些想用 Visual Studio 或 GCC 构建他们项目的调试版的开发者来说,可能是一个问题。因为这些编译器会自动尝试链接调试版的Boost C++库,这样就会给出一个错误信息。在这种情况下,应将--build-type选项设为complete,以同时生成Boost C++库的调试版和发布版,当然,所需时间也会更长一些。

要用 Visual Studio 2008 的C++编译器同时生成Boost C++库的调试版和发布版,并将它们安装在目录D:\Boost中,应执行的命令是bjam --toolset=msvc-9.0 --build-type=complete --prefix=D:\Boost install. 要在Linux中使用缺省目录创建它们,则要执行的命令是bjam --toolset=gcc --build-type=complete install.

其它多个命令行选项可用于指定如何编译Boost C++库的一些细节设定。我通常在Windows下使用以下命令:bjam --toolset=msvc-9.0 debug release link=static runtime-link=shared install. debug 和 release 使得调试版和发布版均被生成。link=static则只创建静态库。runtime-link=shared则是指定C++运行时库是动态链接的,这是在 Visual Studio 2008 中对C++项目的缺省设置。

1.4. 概述
Boost C++库的 1.42.0 版本包含了超过90个库,本书只详细讨论了以下各库:

Boost C++库 C++ 标准 简要说明
Boost.Any Boost.Any 提供了一个名为 boost::any 的数据类型,可以存放任意的类型。例如,一个类型为 boost::any 的变量可以先存放一个 int 类型的值,然后替换为一个std::string类型的字符串。
Boost.Array TR1 Boost.Array 可以把C++数组视同C++标准的容器。
Boost.Asio TR2 Boost.Asio可用于开发异步处理数据的应用,如网络应用。
Boost.Bimap Boost.Bimap 提供了一个名为 boost::bimap 的类,它类似于 std::map. 主要的差别在于 boost::bimap 可以同时从键和值进行搜索。
Boost.Bind TR1 Boost.Bind 是一种适配器,可以将函数作为模板参数,即使该函数的签名与模板参数不兼容。
Boost.Conversion Boost.Conversion 提供了三个转型操作符,分别执行向下转型、交叉转型,以及不同数字类型间的值转换。
Boost.DateTime Boost.DateTime可用于以灵活的格式处理、读入和写出日期及时间值。
Boost.Exception Boost.Exception 可以在抛出的异常中加入额外的数据,以便在 catch 处理中提供更多的信息。这有助于更容易地调试,以及对异常情况更好地作出反应。
Boost.Filesystem TR2 Boost.Filesystem提供了一个类来处理路径信息,还包含了几个访问文件和目录的函数。
Boost.Format Boost.Format 以一个类型安全且可扩展的 boost::format 类替代了 std::printf() 函数。
Boost.Function TR1 Boost.Function 简化了函数指针的定义。
Boost.Interprocess Boost.Interprocess 允许多个应用通过共享内存以快速、高效的方式进行通信。
Boost.Lambda Boost.Lambda 可以定义匿名的函数。代码被内联地声明和执行,避免了单独的函数调用。
Boost.Multiindex Boost.Multiindex 定义了一些新的容器,它们可以同时支持多个接口,如 std::vector 和 std::map 的接口。
Boost.NumericConversion Boost.NumericConversion 提供了一个转型操作符,可以安全地在不同的数字类型间进行值转换,不会生成上溢出或下溢出的条件。
Boost.PointerContainer Boost.PointerContainer 提供了专门为动态分配对象进行优化的容器。
Boost.Ref TR1 Boost.Ref 的适配器可以将不可复制对象的引用传给需要复制的函数。
Boost.Regex TR1 Boost.Regex 提供了通过正则表达式进行文本搜索的函数。
Boost.Serialization 通过Boost.Serialization,对象可以被序列化,如保存在文件中,并在以后重新导入。
Boost.Signals Boost.Signal 是一个事件处理的框架,基于所谓的 signal/slot 概念。函数与信号相关联并在信号被触发时自动被调用。
Boost.SmartPoiners TR1 Boost.SmartPoiners 提供了多个智能指针,简化了动态分配对象的管理。
Boost.Spirit Boost.Spirit 可以用类似于EBNF(扩展巴科斯范式)的语法生成词法分析器。
Boost.StringAlgorithms Boost.StringAlgorithms 提供了多个独立的函数,以方便处理字符串。
Boost.System TR2 Boost.System 提供了一个处理系统相关或应用相关错误代码的框架。
Boost.Thread C++0x Boost.Thread 可用于开发多线程应用。
Boost.Tokenizer Boost.Tokenizer 可以对一个字符串的各个组件进行迭代。
Boost.Tuple TR1 Boost.Tuple 提供了泛化版的 std::pair,可以将任意数量的数据组在一起。
Boost.Unordered TR1 Boost.Unordered 扩展了C++标准的容器,增加了boost::unordered_set 和 boost::unordered_map.
Boost.Variant Boost.Variant 可以定义多个数据类型,类似于 union,将多个数据类型组在一起。Boost.Variant 比 union 优胜的地方在于它可以使用类。

Technical Report 1 是在2003年发布的,有关 C++0x 标准和 Technical Report 2 的一些细节才能反映当前的状态。由于无论是下一个版本的C++标准,还是 Technical Report 2 都尚未被批准,所以在往后的时间里,它们仍然可能会有改变。

第 2 章 智能指针

2.1. 概述

1998年修订的第一版C++标准只提供了一种智能指针:std::auto_ptr。它基本上就像是个普通的指针: 通过地址来访问一个动态分配的对象。std::auto_ptr之所以被看作是智能指针,是因为它会在析构的时候调用delete操作符来自动释放所包含的对象。当然这要求在初始化的时候,传给它一个由new操作符返回的对象的地址。既然std::auto_ptr的析构函数会调用delete操作符,它所包含的对象的内存会确保释放掉。这是智能指针的一个优点。

当和异常联系起来时这就更加重要了:没有std::auto_ptr这样的智能指针,每一个动态分配内存的函数都需要捕捉所有可能的异常,以确保在异常传递给函数的调用者之前将内存释放掉。Boost C++库 Smart Pointers 提供了许多可以用在各种场合的智能指针。

2.2. RAII

智能指针的原理基于一个常见的习语叫做 RAII :资源申请即初始化。智能指针只是这个习语的其中一例——当然是相当重要的一例。智能指针确保在任何情况下,动态分配的内存都能得到正确释放,从而将开发人员从这项任务中解放了出来。这包括程序因为异常而中断,原本用于释放内存的代码被跳过的场景。用一个动态分配的对象的地址来初始化智能指针,在析构的时候释放内存,就确保了这一点。因为析构函数总是会被执行的,这样所包含的内存也将总是会被释放。

无论何时,一定得有第二条指令来释放之前另一条指令所分配的资源时,RAII 都是适用的。许多的C++应用程序都需要动态管理内存,因而智能指针是一种很重要的 RAII 类型。不过 RAII 本身是适用于许多其它场景的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <windows.h> 

class windows_handle
{
public:
windows_handle(HANDLE h)
: handle_(h)
{
}

~windows_handle()
{
CloseHandle(handle_);
}

HANDLE handle() const
{
return handle_;
}

private:
HANDLE handle_;
};

int main()
{
windows_handle h(OpenProcess(PROCESS_SET_INFORMATION, FALSE, GetCurrentProcessId()));
SetPriorityClass(h.handle(), HIGH_PRIORITY_CLASS);
}

上面的例子中定义了一个名为windows_handle的类,它的析构函数调用了CloseHandle()函数。这是一个WindowsAPI 函数,因而这个程序只能在Windows上运行。在Windows上,许多资源在使用之前都要求打开。这暗示着一旦资源不再使用之后就应该关闭。windows_handle类的机制能确保这一点。

windows_handle类的实例以一个句柄来初始化。Windows 使用句柄来唯一的标识资源。比如说,OpenProcess()函数返回一个HANDLE类型的句柄,通过该句柄可以访问当前系统中的进程。在示例代码中,访问的是进程自己——换句话说就是应用程序本身。

我们通过这个返回的句柄提升了进程的优先级,这样它就能从调度器那里获得更多的 CPU 时间。这里只是用于演示目的,并没什么实际的效应。重要的一点是:通过OpenProcess()打开的资源不需要显式的调用CloseHandle()来关闭。当然,应用程序终止时资源也会随之关闭。然而,在更加复杂的应用程序里,windows_handle类确保当一个资源不再使用时就能正确的关闭。某个资源一旦离开了它的作用域——上例中h的作用域在main()函数的末尾——它的析构函数会被自动的调用,相应的资源也就释放掉了。

2.3. 作用域指针

一个作用域指针独占一个动态分配的对象。对应的类名为boost::scoped_ptr,它的定义在boost/scoped_ptr.hpp中。不像std::auto_ptr,一个作用域指针不能传递它所包含的对象的所有权到另一个作用域指针。一旦用一个地址来初始化,这个动态分配的对象将在析构阶段释放。

因为一个作用域指针只是简单保存和独占一个内存地址,所以boost::scoped_ptr的实现就要比std::auto_ptr简单。在不需要所有权传递的时候应该优先使用boost::scoped_ptr。在这些情况下,比起std::auto_ptr它是一个更好的选择,因为可以避免不经意间的所有权传递。

1
2
3
4
5
6
7
8
9
#include <boost/scoped_ptr.hpp> 

int main()
{
boost::scoped_ptr<int> i(new int);
*i = 1;
*i.get() = 2;
i.reset(new int);
}

一经初始化,智能指针boost::scoped_ptr所包含的对象,可以通过类似于普通指针的接口来访问。这是因为重载了相关的操作符operator*()operator->()operator bool()。此外,还有get()reset()方法。前者返回所含对象的地址,后者用一个新的对象来重新初始化智能指针。在这种情况下,新创建的对象赋值之前会先自动释放所包含的对象。

boost::scoped_ptr的析构函数中使用delete操作符来释放所包含的对象。这对boost::scoped_ptr所包含的类型加上了一条重要的限制。boost::scoped_ptr不能用动态分配的数组来做初始化,因为这需要调用delete[]来释放。在这种情况下,可以使用下面将要介绍的boost:scoped_array类。

2.4. 作用域数组

作用域数组的使用方式与作用域指针相似。关键不同在于,作用域数组的析构函数使用delete[]操作符来释放所包含的对象。因为该操作符只能用于数组对象,所以作用域数组必须通过动态分配的数组来初始化。

对应的作用域数组类名为boost::scoped_array,它的定义在boost/scoped_array.hpp里。

1
2
3
4
5
6
7
8
9
#include <boost/scoped_array.hpp> 

int main()
{
boost::scoped_array<int> i(new int[2]);
*i.get() = 1;
i[1] = 2;
i.reset(new int[3]);
}

boost:scoped_array类重载了操作符operator[]()operator bool()。可以通过operator[]()操作符访问数组中特定的元素,于是boost::scoped_array类型对象的行为就酷似它所含的数组。

正如boost::scoped_ptr那样,boost:scoped_array也提供了get()reset()方法,用来返回和重新初始化所含对象的地址。

2.5. 共享指针

这是使用率最高的智能指针,但是C++标准的第一版中缺少这种指针。它已经作为技术报告1(TR 1)的一部分被添加到标准里了。如果开发环境支持的话,可以使用memory中定义的std::shared_ptr。在Boost C++库里,这个智能指针命名为boost::shared_ptr,定义在boost/shared_ptr.hpp里。

智能指针boost::shared_ptr基本上类似于boost::scoped_ptr。关键不同之处在于boost::shared_ptr不一定要独占一个对象。它可以和其他boost::shared_ptr类型的智能指针共享所有权。在这种情况下,当引用对象的最后一个智能指针销毁后,对象才会被释放。

因为所有权可以在boost::shared_ptr之间共享,任何一个共享指针都可以被复制,这跟boost::scoped_ptr是不同的。这样就可以在标准容器里存储智能指针了——你不能在标准容器中存储 std::auto_ptr,因为它们在拷贝的时候传递了所有权。

1
2
3
4
5
6
7
8
9
#include <boost/shared_ptr.hpp> 
#include <vector>

int main()
{
std::vector<boost::shared_ptr<int> > v;
v.push_back(boost::shared_ptr<int>(new int(1)));
v.push_back(boost::shared_ptr<int>(new int(2)));
}

多亏了有boost::shared_ptr,我们才能像上例中展示的那样,在标准容器中安全的使用动态分配的对象。因为boost::shared_ptr能够共享它所含对象的所有权,所以保存在容器中的拷贝(包括容器在需要时额外创建的拷贝)都是和原件相同的。如前所述,std::auto_ptr做不到这一点,所以绝对不应该在容器中保存它们。

类似于boost::scoped_ptrboost::shared_ptr类重载了以下这些操作符:operator*()operator->()operatorbool()。另外还有get()reset()函数来获取和重新初始化所包含的对象的地址。

1
2
3
4
5
6
7
8
#include <boost/shared_ptr.hpp> 

int main()
{
boost::shared_ptr<int> i1(new int(1));
boost::shared_ptr<int> i2(i1);
i1.reset(new int(2));
}

本例中定义了2个共享指针i1i2,它们都引用到同一个 int 类型的对象。i1通过new操作符返回的地址显示的初始化,i2通过i1拷贝构造而来。i1接着调用reset(),它所包含的整数的地址被重新初始化。不过它之前所包含的对象并没有被释放,因为i2仍然引用着它。智能指针boost::shared_ptr记录了有多少个共享指针在引用同一个对象,只有在最后一个共享指针销毁时才会释放这个对象。

默认情况下,boost::shared_ptr使用delete操作符来销毁所含的对象。然而,具体通过什么方法来销毁,是可以指定的,就像下面的例子里所展示的:

1
2
3
4
5
6
7
8
#include <boost/shared_ptr.hpp> 
#include <windows.h>

int main()
{
boost::shared_ptr<void> h(OpenProcess(PROCESS_SET_INFORMATION, FALSE, GetCurrentProcessId()), CloseHandle);
SetPriorityClass(h.get(), HIGH_PRIORITY_CLASS);
}

boost::shared_ptr的构造函数的第二个参数是一个普通函数或者函数对象,该参数用来销毁所含的对象。在本例中,这个参数是WindowsAPI 函数CloseHandle()。当变量h超出它的作用域时,调用的是这个函数而不是delete操作符来销毁所含的对象。为了避免编译错误,该函数只能带一个HANDLE类型的参数,CloseHandle()正好符合要求。

该例和本章稍早讲述 RAII 习语时所用的例子的运行是一样的。然而,本例没有单独定义一个windows_handle类,而是利用了boost::shared_ptr的特性,给它的构造函数传递一个方法,这个方法会在共享指针超出它的作用域时自动调用。

2.6. 共享数组

共享数组的行为类似于共享指针。关键不同在于共享数组在析构时,默认使用delete[]操作符来释放所含的对象。因为这个操作符只能用于数组对象,共享数组必须通过动态分配的数组的地址来初始化。

共享数组对应的类型是boost::shared_array,它的定义在boost/shared_array.hpp里。

1
2
3
4
5
6
7
8
9
10
#include <boost/shared_array.hpp> 
#include <iostream>

int main()
{
boost::shared_array<int> i1(new int[2]);
boost::shared_array<int> i2(i1);
i1[0] = 1;
std::cout << i2[0] << std::endl;
}

就像共享指针那样,所含对象的所有权可以跟其他共享数组来共享。这个例子中定义了2个变量i1i2,它们引用到同一个动态分配的数组。i1通过operator[]()操作符保存了一个整数1——这个整数可以被i2引用,比如打印到标准输出。

和本章中所有的智能指针一样,boost::shared_array也同样提供了get()reset()方法。另外还重载了operatorbool()

2.7. 弱指针

到目前为止介绍的各种智能指针都能在不同的场合下独立使用。相反,弱指针只有在配合共享指针一起使用时才有意义。弱指针boost::weak_ptr的定义在boost/weak_ptr.hpp里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <windows.h> 
#include <boost/shared_ptr.hpp>
#include <boost/weak_ptr.hpp>
#include <iostream>

DWORD WINAPI reset(LPVOID p)
{
boost::shared_ptr<int> *sh = static_cast<boost::shared_ptr<int>*>(p);
sh->reset();
return 0;
}

DWORD WINAPI print(LPVOID p)
{
boost::weak_ptr<int> *w = static_cast<boost::weak_ptr<int>*>(p);
boost::shared_ptr<int> sh = w->lock();
if (sh)
std::cout << *sh << std::endl;
return 0;
}

int main()
{
boost::shared_ptr<int> sh(new int(99));
boost::weak_ptr<int> w(sh);
HANDLE threads[2];
threads[0] = CreateThread(0, 0, reset, &sh, 0, 0);
threads[1] = CreateThread(0, 0, print, &w, 0, 0);
WaitForMultipleObjects(2, threads, TRUE, INFINITE);
}

boost::weak_ptr必定总是通过boost::shared_ptr来初始化的。一旦初始化之后,它基本上只提供一个有用的方法:lock()。此方法返回的boost::shared_ptr与用来初始化弱指针的共享指针共享所有权。如果这个共享指针不含有任何对象,返回的共享指针也将是空的。

当函数需要一个由共享指针所管理的对象,而这个对象的生存期又不依赖于这个函数时,就可以使用弱指针。只要程序中还有一个共享指针掌管着这个对象,函数就可以使用该对象。如果共享指针复位了,就算函数里能得到一个共享指针,对象也不存在了。

上例的main()函数中,通过WindowsAPI 创建了2个线程。于是乎,该例只能在Windows平台上编译运行。

第一个线程函数reset()的参数是一个共享指针的地址。第二个线程函数print()的参数是一个弱指针的地址。这个弱指针是之前通过共享指针初始化的。

一旦程序启动之后,reset()print()就都开始执行了。不过执行顺序是不确定的。这就导致了一个潜在的问题:reset()线程在销毁对象的时候print()线程可能正在访问它。

通过调用弱指针的lock()函数可以解决这个问题:如果对象存在,那么lock()函数返回的共享指针指向这个合法的对象。否则,返回的共享指针被设置为0,这等价于标准的null指针。

弱指针本身对于对象的生存期没有任何影响。lock()返回一个共享指针,print()函数就可以安全的访问对象了。这就保证了——即使另一个线程要释放对象——由于我们有返回的共享指针,对象依然存在。

2.8. 介入式指针

大体上,介入式指针的工作方式和共享指针完全一样。boost::shared_ptr在内部记录着引用到某个对象的共享指针的数量,可是对介入式指针来说,程序员就得自己来做记录。对于框架对象来说这就特别有用,因为它们记录着自身被引用的次数。

介入式指针boost::intrusive_ptr定义在boost/intrusive_ptr.hpp里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <boost/intrusive_ptr.hpp> 
#include <atlbase.h>
#include <iostream>

void intrusive_ptr_add_ref(IDispatch *p)
{
p->AddRef();
}

void intrusive_ptr_release(IDispatch *p)
{
p->Release();
}

void check_windows_folder()
{
CLSID clsid;
CLSIDFromProgID(CComBSTR("Scripting.FileSystemObject"), &clsid);
void *p;
CoCreateInstance(clsid, 0, CLSCTX_INPROC_SERVER, __uuidof(IDispatch), &p);
boost::intrusive_ptr<IDispatch> disp(static_cast<IDispatch*>(p));
CComDispatchDriver dd(disp.get());
CComVariant arg("C:\\Windows");
CComVariant ret(false);
dd.Invoke1(CComBSTR("FolderExists"), &arg, &ret);
std::cout << (ret.boolVal != 0) << std::endl;
}

void main()
{
CoInitialize(0);
check_windows_folder();
CoUninitialize();
}

上面的例子中使用了 COM(组件对象模型)提供的函数,于是乎只能在Windows平台上编译运行。COM 对象是使用boost::intrusive_ptr的绝佳范例,因为 COM 对象需要记录当前有多少指针引用着它。通过调用AddRef()Release()函数,内部的引用计数分别增 1 或者减 1。当引用计数为 0 时,COM 对象自动销毁。

intrusive_ptr_add_ref()intrusive_ptr_release()内部调用AddRef()Release()这两个函数,来增加或减少相应 COM 对象的引用计数。这个例子中用到的 COM 对象名为'FileSystemObject',在Windows上它是默认可用的。通过这个对象可以访问底层的文件系统,比如检查一个给定的目录是否存在。在上例中,我们检查C:\Windows目录是否存在。具体它在内部是怎么实现的,跟boost::intrusive_ptr的功能无关,完全取决于 COM。关键点在于一旦介入式指针 disp 离开了它的作用域——check_windows_folder()函数的末尾,函数intrusive_ptr_release()将会被自动调用。这将减少 COM 对象'FileSystemObject'的内部引用计数到0,于是该对象就销毁了。

2.9. 指针容器

在你见过Boost C++库的各种智能指针之后,应该能够编写安全的代码,来使用动态分配的对象和数组。多数时候,这些对象要存储在容器里——如上所述——使用boost::shared_ptrboost::shared_array这就相当简单了。

1
2
3
4
5
6
7
8
9
#include <boost/shared_ptr.hpp> 
#include <vector>

int main()
{
std::vector<boost::shared_ptr<int> > v;
v.push_back(boost::shared_ptr<int>(new int(1)));
v.push_back(boost::shared_ptr<int>(new int(2)));
}

上面例子中的代码当然是正确的,智能指针确实可以这样用,然而因为某些原因,实际情况中并不这么用。第一,反复声明boost::shared_ptr需要更多的输入。其次,将boost::shared_ptr拷进,拷出,或者在容器内部做拷贝,需要频繁的增加或者减少内部引用计数,这肯定效率不高。由于这些原因,Boost C++库提供了指针容器专门用来管理动态分配的对象。
1
2
3
4
5
6
7
8
#include <boost/ptr_container/ptr_vector.hpp> 

int main()
{
boost::ptr_vector<int> v;
v.push_back(new int(1));
v.push_back(new int(2));
}

boost::ptr_vector类的定义在boost/ptr_container/ptr_vector.hpp里,它跟前一个例子中用boost::shared_ptr模板参数来初始化的容器具有相同的工作方式。boost::ptr_vector专门用于动态分配的对象,它使用起来更容易也更高效。boost::ptr_vector独占它所包含的对象,因而容器之外的共享指针不能共享所有权,这跟std::vector<boost::shared_ptr<int>>相反。

除了boost::ptr_vector之外,专门用于管理动态分配对象的容器还包括:boost::ptr_deque,boost::ptr_listboost::ptr_setboost::ptr_mapboost::ptr_unordered_setboost::ptr_unordered_map。这些容器等价于C++标准里提供的那些。最后两个容器对应于std::unordered_setstd::unordered_map,它们作为技术报告1的一部分加入C++标准。如果所使用的C++标准实现不支持技术报告1的话,还可以使用Boost C++库里实现的boost::unordered_setboost::unordered_map

第 3 数对象

3.1. 概述

本章介绍的是函数对象,可能称为’高阶函数’更为适合。它实际上是指那些可以被传入到其它函数或是从其它函数返回的一类函数。在C++中高阶函数是被实现为函数对象的,所以这个标题还是有意义的。

在这整一章中,将会介绍几个用于处理函数对象的Boost C++库。其中,Boost.Bind可替换来自C++标准的著名的std::bind1st()std::bind2nd()函数,而Boost.Function则提供了一个用于封装函数指针的类。最后,Boost.Lambda则引入了一种创建匿名函数的方法。

3.2.Boost.Bind

Boost.Bind是这样的一个库,它简化了由C++标准中的std::bind1st()std::bind2nd()模板函数所提供的一个机制:将这些函数与几乎不限数量的参数一起使用,就可以得到指定签名的函数。这种情形的一个最好的例子就是在C++标准中定义的多个不同算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream> 
#include <vector>
#include <algorithm>

void print(int i)
{
std::cout << i << std::endl;
}

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::for_each(v.begin(), v.end(), print);
}

算法std::for_each()要求它的第三个参数是一个仅接受正好一个参数的函数或函数对象。如果std::for_each()被执行,指定容器中的所有元素 - 在上例中,这些元素的类型为int- 将按顺序被传入至print()函数。但是,如果要使用一个具有不同签名的函数的话,事情就复杂了。例如,如果要传入的是以下函数add(),它要将一个常数值加至容器中的每个元素上,并显示结果。
1
2
3
4
void add(int i, int j) 
{
std::cout << i + j << std::endl;
}

由于std::for_each()要求的是仅接受一个参数的函数,所以不能直接传入add()函数。源代码必须要修改。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream> 
#include <vector>
#include <algorithm>
#include <functional>

class add
: public std::binary_function<int, int, void>
{
public:
void operator()(int i, int j) const
{
std::cout << i + j << std::endl;
}
};

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::for_each(v.begin(), v.end(), std::bind1st(add(), 10));
}

以上程序将值10加至容器v的每个元素之上,并使用标准输出流显示结果。源代码必须作出大幅的修改,以实现此功能:add()函数已被转换为一个派生自std::binary_function的函数对象。

Boost.Bind简化了不同函数之间的绑定。它只包含一个boost::bind()模板函数,定义于boost/bind.hpp中。使用这个函数,可以如下实现以上例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <boost/bind.hpp> 
#include <iostream>
#include <vector>
#include <algorithm>

void add(int i, int j)
{
std::cout << i + j << std::endl;
}

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::for_each(v.begin(), v.end(), boost::bind(add, 10, _1));
}

add()这样的函数不再需要为了要用于std::for_each()而转换为函数对象。使用boost::bind(),这个函数可以忽略其第一个参数而使用。

因为add()函数要求两个参数,两个参数都必须传递给boost::bind()。第一个参数是常数值10,而第二个参数则是一个怪异的_1

_1被称为占位符(placeholder),定义于Boost.Bind。除了_1Boost.Bind还定义了_2_3。通过使用这些占位符,boost::bind()可以变为一元、二元或三元的函数。对于_1boost::bind()变成了一个一元函数 - 即只要求一个参数的函数。这是必需的,因为std::for_each()正是要求一个一元函数作为其第三个参数。

当这个程序执行时,std::for_each()对容器v中的第一个元素调用该一元函数。元素的值通过占位符_1传入到一元函数中。这个占位符和常数值被进一步传递到add()函数。通过使用这种机制,std::for_each()只看到了由boost::bind()所定义的一元函数。而boost::bind()本身则只是调用了另一个函数,并将常数值或占位符作为参数传入给它。

下面这个例子通过boost::bind()定义了一个二元函数,用于std::sort()算法,该算法要求一个二元函数作为其第三个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <boost/bind.hpp> 
#include <vector>
#include <algorithm>

bool compare(int i, int j)
{
return i > j;
}

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::sort(v.begin(), v.end(), boost::bind(compare, _1, _2));
}

因为使用了两个占位符_1_2,所以boost::bind()定义了一个二元函数。std::sort()算法以容器v的两个元素来调用该函数,并根据返回值来对容器进行排序。基于compare()函数的定义,容器将被按降序排列。

但是,由于compare()本身就是一个二元函数,所以使用boost::bind()确是多余的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <boost/bind.hpp> 
#include <vector>
#include <algorithm>

bool compare(int i, int j)
{
return i > j;
}

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::sort(v.begin(), v.end(), compare);
}

不过使用boost::bind()还是有意义的。例如,如果容器要按升序排列而又不能修改compare()函数的定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <boost/bind.hpp> 
#include <vector>
#include <algorithm>

bool compare(int i, int j)
{
return i > j;
}

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::sort(v.begin(), v.end(), boost::bind(compare, _2, _1));
}

该例子仅改变了占位符的顺序:_2被作为第一参数传递,而_1则被作为第二参数传递至compare(),这样即可改变排序的顺序。

3.3.Boost.Ref

本库Boost.Ref通常与Boost.Bind一起使用,所以我把它们挨着写。它提供了两个函数 -boost::ref()boost::cref()- 定义于boost/ref.hpp

当要用于boost::bind()的函数带有至少一个引用参数时,Boost.Ref就很重要了。由于boost::bind()会复制它的参数,所以引用必须特别处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <boost/bind.hpp> 
#include <iostream>
#include <vector>
#include <algorithm>

void add(int i, int j, std::ostream &os)
{
os << i + j << std::endl;
}

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::for_each(v.begin(), v.end(), boost::bind(add, 10, _1, boost::ref(std::cout)));
}

以上例子使用了上一节中的add()函数。不过这一次该函数需要一个流对象的引用来打印信息。因为传给boost::bind()的参数是以值方式传递的,所以std::cout不能直接使用,否则该函数会试图创建它的一份拷贝。

通过使用模板函数boost::ref(),象std::cout这样的流就可以被以引用方式传递,也就可以成功编译上面这个例子了。

要以引用方式传递常量对象,可以使用模板函数boost::cref()

3.4.Boost.Function

为了封装函数指针,Boost.Function提供了一个名为boost::function的类。它定义于boost/function.hpp,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/function.hpp> 
#include <iostream>
#include <cstdlib>
#include <cstring>

int main()
{
boost::function<int (const char*)> f = std::atoi;
std::cout << f("1609") << std::endl;
f = std::strlen;
std::cout << f("1609") << std::endl;
}

boost::function可以定义一个指针,指向具有特定签名的函数。以上例子定义了一个指针f,它可以指向某个接受一个类型为const char*的参数且返回一个类型为int的值的函数。定义完成后,匹配此签名的函数均可赋值给这个指针。这个例程就是先将std::atoi()赋值给f,然后再将它重赋值为std::strlen()

注意,给定的数据类型并不需要精确匹配:虽然std::strlen()是以std::size_t作为返回类型的,但是它也可以被赋值给f

因为f是一个函数指针,所以被赋值的函数可以通过重载的operator()()操作符来调用。取决于当前被赋值的是哪一个函数,在以上例子中将调用std::atoi()std::strlen()

如果f未赋予一个函数而被调用,则会抛出一个boost::bad_function_call异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/function.hpp> 
#include <iostream>

int main()
{
try
{
boost::function<int (const char*)> f;
f("");
}
catch (boost::bad_function_call &ex)
{
std::cout << ex.what() << std::endl;
}
}

注意,将值0赋给一个boost::function类型的函数指针,将会释放当前所赋的函数。释放之后再调用它也会导致boost::bad_function_call异常被抛出。要检查一个函数指针是否被赋值某个函数,可以使用empty()函数或operator bool()操作符。

通过使用Boost.Function,类成员函数也可以被赋值给类型为boost::function的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <boost/function.hpp> 
#include <iostream>

struct world
{
void hello(std::ostream &os)
{
os << "Hello, world!" << std::endl;
}
};

int main()
{
boost::function<void (world*, std::ostream&)> f = &world::hello;
world w;
f(&w, boost::ref(std::cout));
}

在调用这样的一个函数时,传入的第一个参数表示了该函数被调用的那个特定对象。因此,在模板定义中的左括号后的第一个参数必须是该特定类的指针。接下来的参数才是表示相应的成员函数的签名。

这个程序还使用了来自Boost.Ref库的boost::ref(),它提供了一个方便的机制向Boost.Function传递引用。

3.5.Boost.Lambda

匿名函数——又称为lambda函数 - 已经在多种编程语言中存在,但C++除外。不过在Boost.Lambda库的帮助下,现在在C++应用中也可以使用它们了。

lambda 函数的目标是令源代码更为紧凑,从而也更容易理解。以本章第一节中的代码例子为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream> 
#include <vector>
#include <algorithm>

void print(int i)
{
std::cout << i << std::endl;
}

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::for_each(v.begin(), v.end(), print);
}

这段程序接受容器v中的元素并使用print()函数将它们写出到标准输出流。由于print()只是写出一个简单的int,所以该函数的实现相当简单。严格来说,它是如此地简单,以致于如果可以在std::for_each()算法里面直接定义它的话,会更为方便;从而省去增加一个函数的需要。另外一个好处是代码更为紧凑,使得算法与负责数据输出的函数不是局部性分离的。Boost.Lambda正好使之成为现实。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/lambda/lambda.hpp> 
#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::for_each(v.begin(), v.end(), std::cout << boost::lambda::_1 << "\n");
}

Boost.Lambda提供了几个结构来定义匿名函数。代码就被置于执行的地方,从而省去将它包装为一个函数再进行相应的函数调用的这些开销。与原来的例子一样,这个程序将容器v的所有元素写出至标准输出流。

Boost.Bind相类似,Boost.Lambda也定义了三个占位符,名为_1,_2_3。但与Boost.Bind不同的是,这些占位符是定义在单独的名字空间的。因此,该例中的第一个占位符是通过boost::lambda::_1来引用的。为了满足编译器的要求,必须包含相应的头文件boost/lambda/lambda.hpp

虽然代码的位置位于std::for_each()的第三个参数处,看起来很怪异,但Boost.Lambda可以写出正常的C++代码。通过使用占位符,容器v的元素可以通过<<传给std::cout以将它们写出到标准输出流。

虽然Boost.Lambda非常强大,但也有一些缺点。要在以上例子中插入换行的话,必须用"\n"来替代std::endl才能成功编译。因为一元std::endl模板函数所要求的类型不同于lambda函数std::cout << boost::lambda::_1的函数,所以在此不能使用它。

下一个版本的C++标准很可能会将 lambda 函数作为C++语言本身的组成部分加入,从而消除对单独的库的需要。但是在下一个版本到来并被不同的编译器厂商所采用可能还需要好几年。在此之前,Boost.Lambda 被证明是一个完美的替代品,从以下例子可以看出,这个例子只将大于1的元素写出到标准输出流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <boost/lambda/lambda.hpp> 
#include <boost/lambda/if.hpp>
#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(3);
v.push_back(2);

std::for_each(v.begin(), v.end(),
boost::lambda::if_then(boost::lambda::_1 > 1,
std::cout << boost::lambda::_1 << "\n"));
}
头文件boost/lambda/if.hpp定义了几个结构,允许在lambda函数内部使用if语句。最基本的结构是boost::lambda::if_then()`模板函数,它要求两个参数:第一个参数对条件求值 - 如果为真,则执行第二个参数。如例中所示,每个参数本身都可以是 lambda 函数。

除了boost::lambda::if_then()Boost.Lambda还提供了boost::lambda::if_then_else()boost::lambda::if_then_else_return()模板函数 - 它们都要求三个参数。另外还提供了用于实现循环、转型操作符,甚至是 throw - 允许 lambda 函数抛出异常 - 的模板函数。

虽然可以用这些模板函数在C++中构造出复杂的 lambda 函数,但是你必须要考虑其它方面,如可读性和可维护性。因为别人需要学习并理解额外的函数,如用boost::lambda::if_then()来替代已知的C++关键字ifelselambda函数的好处通常随着它的复杂性而降低。多数情况下,更为合理的方法是用熟悉的C++结构定义一个单独的函数。

第 4 事件处理

4.1. 概述

很多开发者在听到术语’事件处理’时就会想到GUI:点击一下某个按钮,相关联的功能就会被执行。点击本身就是事件,而功能就是相对应的事件处理器。

这一模式的使用当然不仅限于GUI。一般情况下,任意对象都可以调用基于特定事件的专门函数。本章所介绍的Boost.Signals库提供了一个简单的方法在C++中应用这一模式。

严格来说,Boost.Function库也可以用于事件处理。不过,Boost.FunctionBoost.Signals之间的一个主要区别在于,Boost.Signals能够将一个以上的事件处理器关联至单个事件。因此,Boost.Signals可以更好地支持事件驱动的开发,当需要进行事件处理时,应作为第一选择。

4.2. 信号 Signals

虽然这个库的名字乍一看好象有点误导,但实际上并非如此。Boost.Signals所实现的模式被命名为信号至插槽(signal to slot),它基于以下概念:当对应的信号被发出时,相关联的插槽即被执行。原则上,你可以把单词信号插槽分别替换为事件事件处理器。不过,由于信号可以在任意给定的时间发出,所以这一概念放弃了 ‘事件’ 的名字。

因此,Boost.Signals没有提供任何类似于 ‘事件’ 的类。相反,它提供了一个名为boost::signal的类,定义于boost/signal.hpp.实际上,这个头文件是唯一一个需要知道的,因为它会自动包含其它相关的头文件。

Boost.Signals定义了其它一些类,位于boost::signals名字空间中。由于boost::signal是最常被用到的类,所以它是位于名字空间boost中的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/signal.hpp> 
#include <iostream>

void func()
{
std::cout << "Hello, world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
s.connect(func);
s();
}

boost::signal实际上被实现为一个模板函数,具有被用作为事件处理器的函数的签名,该签名也是它的模板参数。在这个例子中,只有签名为void ()的函数可以被成功关联至信号s

函数func()被通过connect()方法关联至信号s。由于func()符合所要求的void()签名,所以该关联成功建立。因此当信号s被触发时,func()将被调用。

信号是通过调用s来触发的,就象普通的函数调用那样。这个函数的签名对应于作为模板参数传入的签名:因为void()不要求任何参数,所以括号内是空的。

调用s会引发一个触发器,进而执行相应的func()函数 - 之前用connect()关联了的。

同一例子也可以用Boost.Function来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/function.hpp> 
#include <iostream>

void func()
{
std::cout << "Hello, world!" << std::endl;
}

int main()
{
boost::function<void ()> f;
f = func;
f();
}

和前一个例子相类似,func()被关联至f。当f被调用时,就会相应地执行func()Boost.Function仅限于这种情形下适用,而Boost.Signals则提供了多得多的方式,如关联多个函数至单个特定信号,示例如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <boost/signal.hpp> 
#include <iostream>

void func1()
{
std::cout << "Hello" << std::flush;
}

void func2()
{
std::cout << ", world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
s.connect(func1);
s.connect(func2);
s();
}

boost::signal可以通过反复调用connect()方法来把多个函数赋值给单个特定信号。当该信号被触发时,这些函数被按照之前用connect()进行关联时的顺序来执行。

另外,执行的顺序也可通过connect()方法的另一个重载版本来明确指定,该重载版本要求以一个int类型的值作为额外的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <boost/signal.hpp> 
#include <iostream>

void func1()
{
std::cout << "Hello" << std::flush;
}

void func2()
{
std::cout << ", world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
s.connect(1, func2);
s.connect(0, func1);
s();
}

和前一个例子一样,func1()func2()之前执行。

要释放某个函数与给定信号的关联,可以用disconnect()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <boost/signal.hpp> 
#include <iostream>

void func1()
{
std::cout << "Hello" << std::endl;
}

void func2()
{
std::cout << ", world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
s.connect(func1);
s.connect(func2);
s.disconnect(func2);
s();
}

这个例子仅输出Hello,因为与func2()的关联在触发信号之前已经被释放。

除了connect()disconnect()以外,boost::signal还提供了几个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <boost/signal.hpp> 
#include <iostream>

void func1()
{
std::cout << "Hello" << std::flush;
}

void func2()
{
std::cout << ", world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
s.connect(func1);
s.connect(func2);
std::cout << s.num_slots() << std::endl;
if (!s.empty())
s();
s.disconnect_all_slots();
}

num_slots()返回已关联函数的数量。如果没有函数被关联,则num_slots()返回0。在这种特定情况下,可以用empty()方法来替代。disconnect_all_slots()方法所做的实际上正是它的名字所表达的:释放所有已有的关联。

看完了函数如何被关联至信号,以及弄明白了信号被触发时会发生什么事之后,还有一个问题:这些函数的返回值去了哪里?以下例子回答了这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <boost/signal.hpp> 
#include <iostream>

int func1()
{
return 1;
}

int func2()
{
return 2;
}

int main()
{
boost::signal<int ()> s;
s.connect(func1);
s.connect(func2);
std::cout << s() << std::endl;
}

func1()func2()都具有int类型的返回值。s将处理两个返回值,并将它们都写出至标准输出流。那么,到底会发生什么呢?

以上例子实际上会把 2 写出至标准输出流。两个返回值都被s正确接收,但除了最后一个值,其它值都会被忽略。缺省情况下,所有被关联函数中,实际上只有最后一个返回值被返回。

你可以定制一个信号,令每个返回值都被相应地处理。为此,要把一个称为合成器(combiner)的东西作为第二个参数传递给boost::signal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <boost/signal.hpp> 
#include <iostream>
#include <algorithm>

int func1()
{
return 1;
}

int func2()
{
return 2;
}

template <typename T>
struct min_element
{
typedef T result_type;

template <typename InputIterator>
T operator()(InputIterator first, InputIterator last) const
{
return *std::min_element(first, last);
}
};

int main()
{
boost::signal<int (), min_element<int> > s;
s.connect(func1);
s.connect(func2);
std::cout << s() << std::endl;
}

合成器是一个重载了operator()()操作符的类。这个操作符会被自动调用,传入两个迭代器,指向某个特定信号的所有返回值。以上例子使用了标准C++算法std::min_element()来确定并返回最小的值。

不幸的是,我们不可能把象std::min_element()这样的一个算法直接传给boost::signal作为一个模板参数。boost::signal要求这个合成器定义一个名为result_type的类型,用于说明operator()()操作符返回值的类型。由于在标准C++算法中缺少这个类型,所以在编译时会产生一个相应的错误。

除了对返回值进行分析以外,合成器也可以保存它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <boost/signal.hpp> 
#include <iostream>
#include <vector>
#include <algorithm>

int func1()
{
return 1;
}

int func2()
{
return 2;
}

template <typename T>
struct min_element
{
typedef T result_type;

template <typename InputIterator>
T operator()(InputIterator first, InputIterator last) const
{
return T(first, last);
}
};

int main()
{
boost::signal<int (), min_element<std::vector<int> > > s;
s.connect(func1);
s.connect(func2);
std::vector<int> v = s();
std::cout << *std::min_element(v.begin(), v.end()) << std::endl;
}

这个例子把所有返回值保存在一个vector中,再由s()返回。

4.3. 连接 Connections

函数可以通过由boost::signal所提供的connect()disconnect()方法的帮助来进行管理。由于connect()会返回一个类型为boost::signals::connection的值,它们可以通过其它方法来管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/signal.hpp> 
#include <iostream>

void func()
{
std::cout << "Hello, world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
boost::signals::connection c = s.connect(func);
s();
c.disconnect();
}

boost::signaldisconnect()方法需要传入一个函数指针,而直接调用boost::signals::connection对象上的disconnect()方法则略去该参数。

除了disconnect()方法之外,boost::signals::connection还提供了其它方法,如block()unblock()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <boost/signal.hpp> 
#include <iostream>

void func()
{
std::cout << "Hello, world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
boost::signals::connection c = s.connect(func);
c.block();
s();
c.unblock();
s();
}

以上程序只会执行一次func()。虽然信号s被触发了两次,但是在第一次触发时func()不会被调用,因为连接c实际上已经被block()调用所阻塞。由于在第二次触发之前调用了unblock(),所以之后func()被正确地执行。

除了boost::signals::connection以外,还有一个名为boost::signals::scoped_connection的类,它会在析构时自动释放连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/signal.hpp> 
#include <iostream>

void func()
{
std::cout << "Hello, world!" << std::endl;
}

int main()
{
boost::signal<void ()> s;
{
boost::signals::scoped_connection c = s.connect(func);
}
s();
}

因为连接对象c在信号触发之前被销毁,所以func()不会被调用。

boost::signals::scoped_connection实际上是派生自boost::signals::connection的,所以它提供了相同的方法。它们之间的区别仅在于,在析构boost::signals::scoped_connection时,连接会自动释放。

虽然boost::signals::scoped_connection的确令自动释放连接更为容易,但是该类型的对象仍需要管理。如果在其它情形下连接也可以被自动释放,而且不需要管理这些对象的话,就更好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <boost/signal.hpp> 
#include <boost/bind.hpp>
#include <iostream>
#include <memory>

class world
{
public:
void hello() const
{
std::cout << "Hello, world!" << std::endl;
}
};

int main()
{
boost::signal<void ()> s;
{
std::auto_ptr<world> w(new world());
s.connect(boost::bind(&world::hello, w.get()));
}
std::cout << s.num_slots() << std::endl;
s();
}

以上程序使用Boost.Bind将一个对象的方法关联至一个信号。在信号触发之前,这个对象就被销毁了,这会产生问题。我们不传递实际的对象w,而只传递一个指针给boost::bind()。在s()`被实际调用的时候,该指针所引向的对象已不再存在。

可以如下修改这个程序,使得一旦对象w被销毁,连接就会自动释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <boost/signal.hpp> 
#include <boost/bind.hpp>
#include <iostream>
#include <memory>

class world :
public boost::signals::trackable
{
public:
void hello() const
{
std::cout << "Hello, world!" << std::endl;
}
};

int main()
{
boost::signal<void ()> s;
{
std::auto_ptr<world> w(new world());
s.connect(boost::bind(&world::hello, w.get()));
}
std::cout << s.num_slots() << std::endl;
s();
}

如果现在再执行,num_slots()会返回0以确保不会试图调用已销毁对象之上的方法。仅需的修改是让world类继承自boost::signals::trackable。当使用对象的指针而不是对象的副本来关联函数至信号时,boost::signals::trackable可以显著简化连接的管理。

第 5 章 字符串处理

5.1. 前言

在标准C++中,用于处理字符串的是std::string类,它提供很多字符串操作,包括查找指定字符或子串的函数。尽管std::string囊括了百余函数,是标准C++中最为臃肿的类之一,然而却并不能满足很多开发者在日常工作中的需要。

5.2. 区域设置

区域设置在标准C++中封装了文化习俗相关的内容,包括货币符号,日期时间格式,分隔整数部分与分数部分的符号(基数符)以及多于三个数字时的分隔符(千位符)。

在字符串处理方面,区域设置和特定文化中对字符次序以及特殊字符的描述有关。例如,字母表中是否含有变异元音字母以及其在字母表中的位置都由语言文化决定。

如果一个函数用于将字符串转换为大写形式,那么其实施步骤取决于具体的区域设置。在德语中,字母 ‘ä’ 显然要转换为 ‘Ä’,然而在其他语言中并不一定。

使用类std::string时区域设置可以忽略,因为它的函数均不依赖于特定语言。然而在本章中为了使用Boost C++库,区域设置的知识是必不可少的。

C++ 标准中在locale文件中定义了类std::locale。每个C++程序自动拥有一个此类的实例,即不能直接访问的全局区域设置。如果要访问它,需要使用默认构造函数构造类std::locale的对象,并使用与全局区域设置相同的属性初始化。

1
2
3
4
5
6
7
8
#include <locale> 
#include <iostream>

int main()
{
std::locale loc;
std::cout << loc.name() << std::endl;
}

以上程序在标准输出流输出 C ,这是基本区域设置的名称,它包括了 C 语言编写的程序中默认使用的描述。

这也是每个C++应用的默认全局区域设置,它包括了美式文化中使用的描述。例如,货币符号使用美元符号,基字符为英文句号,日期中的月份用英语书写。

全局区域设置可以使用类std::locale中的静态函数global()改变。

1
2
3
4
5
6
7
8
9
#include <locale> 
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
std::locale loc;
std::cout << loc.name() << std::endl;
}

静态函数global()接受一个类型为std::locale的对象作为其唯一的参数。此类的另一个版本的构造函数接受类型为const char*的字符串,可以为一个特别的文化创建区域设置对象。然而,除了 C 区域设置相应地命名为 “C” 之外,其他区域设置的名字并没有标准化,所以这依赖于接受区域设置名字的C++标准库。在使用 Visual Studio 2008 的情况下,语言字符串文档指出,可以使用语言字符串 “German” 选择定义为德国文化。

执行程序,会输出German_Germany.1252。指定语言字符串为 “German” 等于选择了德国文化作为主要语言和子语言,这里选择了字符映射 1252。

如果想指定与德国文化不同的子语言设置,例如瑞士语,需要使用不同的语言字符串。

1
2
3
4
5
6
7
8
9
#include <locale> 
#include <iostream>

int main()
{
std::locale::global(std::locale("German_Switzerland"));
std::locale loc;
std::cout << loc.name() << std::endl;
}

现在程序会输出German_Switzerland.1252

在初步理解了区域设置以及如何更改全局设置后,下面的例子说明了区域设置如何影响字符串操作。

1
2
3
4
5
6
7
8
9
10
#include <locale> 
#include <iostream>
#include <cstring>

int main()
{
std::cout << std::strcoll("ä", "z") << std::endl;
std::locale::global(std::locale("German"));
std::cout << std::strcoll("ä", "z") << std::endl;
}

本例使用了定义在文件cstring中的函数std::strcoll(),这个函数用于按照字典顺序比较第一个字符串是否小于第二个。换言之,它会判断这两个字符串中哪一个在字典中靠前。

执行程序,得到结果为1-1。虽然函数的参数是一样的,却得到了不同的结果。原因很简单,在第一次调用函数std::strcoll()时,使用了全局 C 区域设置;而在第二次调用时,全局区域设置更改为德国文化。从输出中可以看出,在这两种区域设置中,字符 ‘ä’ 和 ‘z’ 的次序是不同的。

很多 C 函数以及C++流都与区域设置有关。尽管类std::string中的函数是与区域设置独立工作的,但是以下各节中提到的函数并不是这样。所以,在本章中还会多次提到区域设置的相关内容。

5.3. 字符串算法库Boost.StringAlgorithms

Boost C++字符串算法库Boost.StringAlgorithms提供了很多字符串操作函数。字符串的类型可以是std::stringstd::wstring或任何其他模板类std::basic_string的实例。

这些函数分类别在不同的头文件定义。例如,大小写转换函数定义在文件boost/algorithm/string/case_conv.hpp中。因为Boost.StringAlgorithms类中包括超过20个类别和相同数目的头文件,为了方便起见,头文件boost/algorithm/string.hpp包括了所有其他的头文件。后面所有例子都会使用这个头文件。

正如上节提到的那样,Boost.StringAlgorithms库中许多函数都可以接受一个类型为std::locale的对象作为附加参数。而此参数是可选的,如果不设置将使用默认全局区域设置。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>
#include <clocale>

int main()
{
std::setlocale(LC_ALL, "German");
`std::string`s = "Boris Schäling";
std::cout << boost::algorithm::to_upper_copy(s) << std::endl;
std::cout << boost::algorithm::to_upper_copy(s, std::locale("German")) << std::endl;
}

函数boost::algorithm::to_upper_copy()用于 转换一个字符串为大写形式,自然也有提供相反功能的函数 ——boost::algorithm::to_lower_copy()把字符串转换为小写形式。这两个函数都返回转换过的字符串作为结果。如果作为参数传入的字符串自身需要被转换为大(小)写形式,可以使用函数boost::algorithm::to_upper()boost::algorithm::to_lower()。

上面的例子使用函数boost::algorithm::to_upper_copy()把字符串"Boris Schäling"转换为大写形式。第一次调用时使用的是默认全局区域设置,第二次调用时则明确将区域设置为德国文化。

显然后者的转换是正确的,因为小写字母 ‘ä’ 对应的大写形式 ‘Ä’ 是存在的。而在 C 区域设置中,’ä’ 是一个未知字符所以不能转换。为了能得到正确结果,必须明确传递正确的区域设置参数或者在调用boost::algorithm::to_upper_copy()之前改变全局区域设置。

可以注意到,程序使用了定义在头文件clocale中的函数std::setlocale()为 C 函数进行区域设置,因为std::cout使用 C 函数在屏幕上显示信息。在设置了正确的区域后,才可以正确显示 ‘ä’ 和 ‘Ä’ 等元音字母。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
std::cout << boost::algorithm::to_upper_copy(s) << std::endl;
std::cout << boost::algorithm::to_upper_copy(s, std::locale("German")) << std::endl;
}

上述程序将全局区域设置设为德国文化,这使得对函数boost::algorithm::to_upper_copy()的调用 可以将 ‘ä’ 转换为 ‘Ä’ 。

注意到本例并没有调用函数std::setlocale()。使用函数std::locale::global()设置全局区域设置后,也自动进行了 C 区域设置。实际上,C++ 程序几乎总是使用函数std::locale::global()进行全局区域设置,而不是像前面的例子那样使用函数std::setlocale()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
std::cout << boost::algorithm::erase_first_copy(s, "i") << std::endl;
std::cout << boost::algorithm::erase_nth_copy(s, "i", 0) << std::endl;
std::cout << boost::algorithm::erase_last_copy(s, "i") << std::endl;
std::cout << boost::algorithm::erase_all_copy(s, "i") << std::endl;
std::cout << boost::algorithm::erase_head_copy(s, 5) << std::endl;
std::cout << boost::algorithm::erase_tail_copy(s, 8) << std::endl;
}

Boost.StringAlgorithms库提供了几个从字符串中删除单独字母的函数,可以明确指定在哪里删除,如何删除。例如,可以使用函数boost::algorithm::erase_all_copy()从整个字符串中删除特定的某个字符。如果只在此字符首次出现时删除,可以使用函数boost::algorithm::erase_first_copy()。如果要在字符串头部或尾部删除若干字符,可以使用函数boost::algorithm::erase_head_copy()boost::algorithm::erase_tail_copy()
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
boost::iterator_range<std::string::iterator> r = boost::algorithm::find_first(s, "Boris");
std::cout << r << std::endl;
r = boost::algorithm::find_first(s, "xyz");
std::cout << r << std::endl;
}

以下各个不同函数boost::algorithm::find_first()boost::algorithm::find_last()boost::algorithm::find_nth()boost::algorithm::find_head()以及boost::algorithm::find_tail()可以用于在字符串中查找子串。

所有这些函数的共同点是均返回类型为boost::iterator_range类的一对迭代器。此类起源于Boost C++的Boost.Range库,它在迭代器的概念上定义了“范围”。因为操作符<<boost::iterator_range类重载而来,单个搜索算法的结果可以直接写入标准输出流。以上程序将 Boris 作为第一个结果输出而第二个结果为空字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>
#include <vector>

int main()
{
std::locale::global(std::locale("German"));
std::vector<std::string> v;
v.push_back("Boris");
v.push_back("Schäling");
std::cout << boost::algorithm::join(v, " ") << std::endl;
}

函数boost::algorithm::join()接受一个字符串的容器作为第一个参数,根据第二个参数将这些字符串连接起来。相应地这个例子会输出 Boris Schäling 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
std::cout << boost::algorithm::replace_first_copy(s, "B", "D") << std::endl;
std::cout << boost::algorithm::replace_nth_copy(s, "B", 0, "D") << std::endl;
std::cout << boost::algorithm::replace_last_copy(s, "B", "D") << std::endl;
std::cout << boost::algorithm::replace_all_copy(s, "B", "D") << std::endl;
std::cout << boost::algorithm::replace_head_copy(s, 5, "Doris") << std::endl;
std::cout << boost::algorithm::replace_tail_copy(s, 8, "Becker") << std::endl;
}

Boost.StringAlgorithms库不但提供了查找子串或删除字母的函数,而且提供了使用字符串替代子串的函数,包括boost::algorithm::replace_first_copy()boost::algorithm::replace_nth_copy()boost::algorithm::replace_last_copy()boost::algorithm::replace_all_copy()boost::algorithm::replace_head_copy()以及boost::algorithm::replace_tail_copy()等等。它们的使用方法同查找和删除函数是差不多一样的,所不同的是还需要 一个替代字符串作为附加参数。
1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "\t Boris Schäling \t";
std::cout << "." << boost::algorithm::trim_left_copy(s) << "." << std::endl;
std::cout << "." <<boost::algorithm::trim_right_copy(s) << "." << std::endl;
std::cout << "." <<boost::algorithm::trim_copy(s) << "." << std::endl;
}

可以使用修剪函数boost::algorithm::trim_left_copy()boost::algorithm::trim_right_copy()以及boost::algorithm::trim_copy()等自动去除字符串中的空格或者字符串的结束符。什么字符是空格取决于全局区域设置。

Boost.StringAlgorithms库的函数可以接受一个附加的谓词参数,以决定函数作用于字符串的哪些字符。谓词版本的修剪函数相应地被命名为boost::algorithm::trim_left_copy_if()boost::algorithm::trim_right_copy_if()boost::algorithm::trim_copy_if()

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "--Boris Schäling--";
std::cout << "." << boost::algorithm::trim_left_copy_if(s, boost::algorithm::is_any_of("-")) << "." << std::endl;
std::cout << "." <<boost::algorithm::trim_right_copy_if(s, boost::algorithm::is_any_of("-")) << "." << std::endl;
std::cout << "." <<boost::algorithm::trim_copy_if(s, boost::algorithm::is_any_of("-")) << "." << std::endl;
}

以上程序调用了一个辅助函数boost::algorithm::is_any_of(),它用于生成谓词以验证作为参数传入的字符是否在给定的字符串中存在。使用函数boost::algorithm::is_any_of后,正如例子中做的那样,修剪字符串的字符被指定为连字符。

Boost.StringAlgorithms类也提供了众多返回通用谓词的辅助函数。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "123456789Boris Schäling123456789";
std::cout << "." << boost::algorithm::trim_left_copy_if(s, boost::algorithm::is_digit()) << "." << std::endl;
std::cout << "." <<boost::algorithm::trim_right_copy_if(s, boost::algorithm::is_digit()) << "." << std::endl;
std::cout << "." <<boost::algorithm::trim_copy_if(s, boost::algorithm::is_digit()) << "." << std::endl;
}

函数boost::algorithm::is_digit()返回的谓词在字符为数字时返回布尔值true。检查字符是否为大写或小写的辅助函数分别是boost::algorithm::is_upper()boost::algorithm::is_lower()。所有这些函数都默认使用全局区域设置,除非在参数中指定其他区域设置。

除了检验单独字符的谓词之外,Boost.StringAlgorithms库还提供了处理字符串的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
std::cout << boost::algorithm::starts_with(s, "Boris") << std::endl;
std::cout << boost::algorithm::ends_with(s, "Schäling") << std::endl;
std::cout << boost::algorithm::contains(s, "is") << std::endl;
std::cout << boost::algorithm::lexicographical_compare(s, "Boris") << std::endl;
}

函数boost::algorithm::starts_with()boost::algorithm::ends_with()boost::algorithm::contains()boost::algorithm::lexicographical_compare()均可以比较两个字符串。

以下介绍一个字符串切割函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/algorithm/string.hpp> 
#include <locale>
#include <iostream>
#include <vector>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
std::vector<std::string> v;
boost::algorithm::split(v, s, boost::algorithm::is_space());
std::cout << v.size() << std::endl;
}

在给定分界符后,使用函数boost::algorithm::split()可以将一个字符串拆分为一个字符串容器。它需要给定一个谓词作为第三个参数以判断应该在字符串的哪个位置分割。这个例子使用了辅助函数boost::algorithm::is_space()创建一个谓词,在每个空格字符处分割字符串。

本节中许多函数都有忽略字符串大小写的版本,这些版本一般都有与原函数相似的名称,所相差的只是以 ‘i’.开头。例如,与函数boost::algorithm::erase_all_copy()相对应的是函数boost::algorithm::ierase_all_copy()

最后,值得注意的是类Boost.StringAlgorithms中许多函数都支持正则表达式。以下程序使用函数boost::algorithm::find_regex()搜索正则表达式。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/algorithm/string.hpp> 
#include <boost/algorithm/string/regex.hpp>
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
boost::iterator_range<std::string::iterator> r = boost::algorithm::find_regex(s, boost::regex("\\w\\s\\w"));
std::cout << r << std::endl;
}

为了使用正则表达式,此程序使用了Boost C++库中的boost::regex,这将在下一节介绍。

5.4. 正则表达式库Boost.Regex

Boost C++的正则表达式库Boost.Regex可以应用正则表达式于C++。正则表达式大大减轻了搜索特定模式字符串的负担,在很多语言中都是强大的功能。虽然现在C++仍然需要以Boost C++库的形式提供这一功能,但是在将来正则表达式将进入C++标准库。Boost.Regex库有望包括在下一版的C++标准中。

Boost.Regex库中两个最重要的类是boost::regexboost::smatch,它们都在boost/regex.hpp文件中定义。前者用于定义一个正则表达式,而后者可以保存搜索结果。

以下将要介绍Boost.Regex库中提供的三个搜索正则表达式的函数。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/regex.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
boost::regex expr("\\w+\\s\\w+");
std::cout << boost::regex_match(s, expr) << std::endl;
}

函数boost::regex_match()用于字符串与正则表达式的比较。在整个字符串匹配正则表达式时其返回值为true

函数boost::regex_search()可用于在字符串中搜索正则表达式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/regex.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
boost::regex expr("(\\w+)\\s(\\w+)");
boost::smatch what;
if (boost::regex_search(s, what, expr))
{
std::cout << what[0] << std::endl;
std::cout << what[1] << " " << what[2] << std::endl;
}
}

函数boost::regex_search()可以接受一个类型为boost::smatch的引用的参数用于储存结果。函数boost::regex_search()只用于分类的搜索,本例实际上返回了两个结果,它们是基于正则表达式的分组。

存储结果的类boost::smatch事实上是持有类型为boost::sub_match的元素的容器,可以通过与类std::vector相似的界面访问。例如,元素可以通过操作符operator[]()访问。

另一方面,类boost::sub_match将迭代器保存在对应于正则表达式分组的位置。因为它继承自类std::pair,迭代器引用的子串可以使用firstsecond访问。如果像上面的例子那样,只把子串写入标准输出流,那么通过重载操作符<<就可以直接做到这一点,那么并不需要访问迭代器。

请注意结果保存在迭代器中而boost::sub_match类并不复制它们,这说明它们只是在被迭代器引用的相关字符串存在时才可以访问。

另外,还需要注意容器boost::smatch的第一个元素存储的引用是指向匹配正则表达式的整个字符串的,匹配第一组的第一个子串由索引1访问。

Boost.Regex提供的第三个函数是boost::regex_replace()

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/regex.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = " Boris Schäling ";
boost::regex expr("\\s");
`std::string`fmt("_");
std::cout << boost::regex_replace(s, expr, fmt) << std::endl;
}

除了待搜索的字符串和正则表达式之外,boost::regex_replace()函数还需要一个格式参数,它决定了子串、匹配正则表达式的分组如何被替换。如果正则表达式不包含任何分组,相关子串将被用给定的格式一个个地被替换。这样上面程序输出的结果为_Boris_Schäling_

boost::regex_replace()函数总是在整个字符串中搜索正则表达式,所以这个程序实际上将三处空格都替换为下划线。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/regex.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
boost::regex expr("(\\w+)\\s(\\w+)");
`std::string`fmt("\\2 \\1");
std::cout << boost::regex_replace(s, expr, fmt) << std::endl;
}

格式参数可以访问由正则表达式分组的子串,这个例子正是使用了这项技术,交换了姓、名的位置,于是结果显示为Schäling Boris

需要注意的是,对于正则表达式和格式有不同的标准。这三个函数都可以接受一个额外的参数,用于选择具体的标准。也可以指定是否以某一具体格式解释特殊字符或者替代匹配正则表达式的整个字符串。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/regex.hpp> 
#include <locale>
#include <iostream>

int main()
{
std::locale::global(std::locale("German"));
`std::string`s = "Boris Schäling";
boost::regex expr("(\\w+)\\s(\\w+)");
`std::string`fmt("\\2 \\1");
std::cout << boost::regex_replace(s, expr, fmt, boost::regex_constants::format_literal) << std::endl;
}

此程序将boost::regex_constants::format_literal标志作为第四参数传递给函数boost::regex_replace(),从而抑制了格式参数中对特殊字符的处理。因为整个字符串匹配正则表达式,所以本例中经格式参数替换的到达的输出结果为\2 \1

正如上一节末指出的那样,正则表达式可以和Boost.StringAlgorithms库结合使用。它通过Boost.Regex库提供函数如boost::algorithm::find_regex()boost::algorithm::replace_regex()boost::algorithm::erase_regex()以及boost::algorithm::split_regex()等等。由于Boost.Regex库很有可能成为即将到来的下一版C++标准的一部分,脱离Boost.StringAlgorithms库,熟练地使用正则表达式是个明智的选择。

5.5. 词汇分割器库Boost.Tokenizer

Boost.Tokenizer库可以在指定某个字符为分隔符后,遍历字符串的部分表达式。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/tokenizer.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tokenizer<boost::char_separator<char> > tokenizer;
`std::string`s = "Boost C++libraries";
tokenizer tok(s);
for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it)
std::cout << *it << std::endl;
}

Boost.Tokenizer库在boost/tokenizer.hpp文件中定义了模板类boost::tokenizer,其模板参数为支持相关表达式的类。上面的例子中就使用了boost::char_separator类作为模板参数,它将空格和标点符号视为分隔符。

词汇分割器必须由类型为std::string的字符串初始化。通过使用begin()end()方法,词汇分割器可以像容器一样访问。通过使用迭代器,可以得到前述字符串的部分表达式。模板参数的类型决定了如何达到部分表达式。

因为boost::char_separator类默认将空格和标点符号视为分隔符,所以本例显示的结果为Boost 、 C 、 + 、 + 和 libraries。为了识别这些分隔符,boost::char_separator函数调用了std::isspace()函数和std::ispunct函数。Boost.Tokenizer库会区分要隐藏的分隔符和要显示的分隔符。在默认的情况下,空格会隐藏而标点符号会显示出来,所以这个例子里显示了两个加号。

如果不需要将标点符号作为分隔符,可以在传递给词汇分割器之前相应地初始化boost::char_separator对象。以下例子正式这样做的。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/tokenizer.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tokenizer<boost::char_separator<char> > tokenizer;
`std::string`s = "Boost C++libraries";
boost::char_separator<char> sep(" ");
tokenizer tok(s, sep);
for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it)
std::cout << *it << std::endl;
}

boost::char_separator的构造函数可以接受三个参数,只有第一个是必须的,它描述了需要隐藏的分隔符。在本例中,空格仍然被视为分隔符。

第二个参数指定了需要显示的分隔符。在不提供此参数的情况下,将不显示任何分隔符。执行程序,会显示Boost 、C++和 libraries 。

如果将加号作为第二个参数,此例的结果将和上一个例子相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/tokenizer.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tokenizer<boost::char_separator<char> > tokenizer;
`std::string`s = "Boost C++libraries";
boost::char_separator<char> sep(" ", "+");
tokenizer tok(s, sep);
for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it)
std::cout << *it << std::endl;
}

第三个参数决定了是否显示空的部分表达式。如果连续找到两个分隔符,他们之间的部分表达式将为空。在默认情况下,这些空表达式是不会显示的。第三个参数可以改变默认的行为。
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/tokenizer.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tokenizer<boost::char_separator<char> > tokenizer;
`std::string`s = "Boost C++libraries";
boost::char_separator<char> sep(" ", "+", boost::keep_empty_tokens);
tokenizer tok(s, sep);
for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it)
std::cout << *it << std::endl;
}

执行以上程序,会显示另外两个的空表达式。其中第一个是在两个加号中间的而第二个是加号和之后的空格之间的。

词汇分割器也可用于不同的字符串类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/tokenizer.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tokenizer<boost::char_separator<wchar_t>, std::wstring::const_iterator, std::wstring> tokenizer;
std::wstring s = L"Boost C++libraries";
boost::char_separator<wchar_t> sep(L" ");
tokenizer tok(s, sep);
for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it)
std::wcout << *it << std::endl;
}

这个例子遍历了一个类型为std::wstring的字符串。为了使用这个类型的字符串,必须使用另外的模板参数初始化词汇分割器,对boost::char_separator类也是如此,他们都需要参数wchar_t初始化。

除了boost::char_separator类之外,Boost.Tokenizer还提供了另外两个类以识别部分表达式。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/tokenizer.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tokenizer<boost::escaped_list_separator<char> > tokenizer;
`std::string`s = "Boost,\"C++ libraries\"";
tokenizer tok(s);
for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it)
std::cout << *it << std::endl;
}

boost::escaped_list_separator类用于读取由逗号分隔的多个值,这个格式的文件通常称为 CSV (comma separated values,逗号分隔文件),它甚至还可以处理双引号以及转义序列。所以本例的输出为Boost 和C++libraries 。

另一个是boost::offset_separator类,必须用实例说明。这个类的对象必须作为第二个参数传递给boost::tokenizer类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/tokenizer.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tokenizer<boost::offset_separator> tokenizer;
`std::string`s = "Boost C++libraries";
int offsets[] = { 5, 5, 9 };
boost::offset_separator sep(offsets, offsets + 3);
tokenizer tok(s, sep);
for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it)
std::cout << *it << std::endl;
}

boost::offset_separator指定了部分表达式应当在字符串中的哪个位置结束。以上程序制定第一个部分表达式在 5 个字符后结束,第二个字符串在另 5 个字符后结束,第三个也就是最后一个字符串应当在之后的 9 个字符后结束。输出的结果为Boost 、C++和 libraries 。

5.6. 格式化输出库Boost.Format

Boost.Format库可以作为定义在文件cstdio中的函数std::printf()的替代。std::printf()函数最初出现在 C 标准中,提供格式化数据输出功能,但是它既不是类型安全的有不能扩展。因此在C++应用中,Boost.Format库通常是数据格式化输出的上佳之选。

Boost.Format库在文件boost/format.hpp中定义了类boost::format。与函数std::printf相似的是,传递给boost::format的构造函数的参数也是一个字符串,它由控制格式的特殊字符组成。实际数据通过操作符%相连,在输出中替代特殊字符,如下例所示。

1
2
3
4
5
6
7
#include <boost/format.hpp> 
#include <iostream>

int main()
{
std::cout << boost::format("%1%.%2%.%3%") % 16 % 9 % 2008 << std::endl;
}

Boost.Format类使用置于两个百分号之间的数字作为占位符,占位符稍后通过%操作符与实际数据连接。以上程序使用数字16、9 和 2009 组成一个日期字符串,以 16.9.2008的格式输出。如果要月份出现在日期之前,即美式表示,只需交换占位符即可。
1
2
3
4
5
6
7
#include <boost/format.hpp> 
#include <iostream>

int main()
{
std::cout << boost::format("%2%/%1%/%3%") % 16 % 9 % 2008 << std::endl;
}

现在程序显示的结果变成 9/16/2008 。

如果要使用C++ 操作器格式化数据,Boost.Format库提供了函数boost::io::group()

1
2
3
4
5
6
7
#include <boost/format.hpp> 
#include <iostream>

int main()
{
std::cout << boost::format("%1% %2% %1%") % boost::io::group(std::showpos, 99) % 100 << std::endl;
}

本例的结果显示为 +99 100 +99 。因为操作器std::showpos()通过boost::io::group()与数字 99 连接,所以只要显示 99 ,在它前面就会自动加上加号。

如果需要加号仅在 99 第一次输出时显示,则需要改造格式化占位符。

1
2
3
4
5
6
7
#include <boost/format.hpp> 
#include <iostream>

int main()
{
std::cout << boost::format("%|1$+| %2% %1%") % 99 % 100 << std::endl;
}

为了将输出格式改为 +99 100 99 ,不但需要将数据的引用符号由1$变为1%,还需要在其两侧各添加一个附加的管道符号,即将占位符%1%替换为%|1$+|

请注意,虽然一般对数据的引用不是必须的,但是所有占位符一定要同时设置为指定货非指定。以下例子在执行时会出现错误,因为它给第二个和第三个占位符设置了引用但是却忽略了第一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/format.hpp> 
#include <iostream>

int main()
{
try
{
std::cout << boost::format("%|+| %2% %1%") % 99 % 100 << std::endl;
}
catch (boost::io::format_error &ex)
{
std::cout << ex.what() << std::endl;
}
}

此程序抛出了类型为boost::io::format_error的异常。严格地说,Boost.Format库抛出的异常为boost::io::bad_format_string。但是由于所有的异常类都继承自boost::io::format_error类,捕捉此类型的异常会轻松一些。

以下例子演示了不引用数据的方法。

1
2
3
4
5
6
7
#include <boost/format.hpp> 
#include <iostream>

int main()
{
std::cout << boost::format("%|+| %|| %||") % 99 % 100 % 99 << std::endl;
}

第二、第三个占位符的管道符号可以被安全地省略,因为在这种情况下,他们并不指定格式。这样的语法看起来很像std::printf()的那种。
1
2
3
4
5
6
7
#include <boost/format.hpp> 
#include <iostream>

int main()
{
std::cout << boost::format("%+d %d %d") % 99 % 100 % 99 << std::endl;
}

虽然这看起来就像std::printf(),但是Boost.Format库有类型安全的优点。格式化字符串中字母 ‘d’ 的使用并不表示输出数字,而是表示boost::format类所使用的内部流对象上的std::dec()操作器,它可以使用某些对std::printf()函数无意义的格式字符串,如果使用std::printf()会导致程序在运行时崩溃。
1
2
3
4
5
6
7
#include <boost/format.hpp> 
#include <iostream>

int main()
{
std::cout << boost::format("%+s %s %s") % 99 % 100 % 99 << std::endl;
}

尽管在std::printf()函数中,字母's'只用于表示类型为const char*的字符串,然而以上程序却能正常运行。因为在Boost.Format库中,这并不代表强制为字符串,它会结合适当的操作器,调整内部流的操作模式。所以即使在这种情况下,在内部流中加入数字也是没问题的。

第 6 章 多线程

6.1. 概述

线程就是,在同一程序同一时间内允许执行不同函数的离散处理队列。这使得一个长时间去进行某种特殊运算的函数在执行时不阻碍其他的函数变得十分重要。线程实际上允许同时执行两种函数,而这两个函数不必相互等待。

一旦一个应用程序启动,它仅包含一个默认线程。此线程执行main()函数。在main()中被调用的函数则按这个线程的上下文顺序地执行。这样的程序称为单线程程序。

反之,那些创建新的线程的程序就是多线程程序。他们不仅可以在同一时间执行多个函数,而且这在如今多核盛行的时代显得尤为重要。既然多核允许同时执行多个函数,这就使得对开发人员相应地使用这种处理能力提出了要求。然而线程一直被用来当并发地执行多个函数,开发人员现在不得不仔细地构建应用来支持这种并发。多线程编程知识也因此在多核系统时代变得越来越重要。

本章将介绍C++ Boost库Boost.Thread,它可以开发独立于平台的多线程应用程序。

6.2. 线程管理

在这个库最重要的一个类就是boost::thread,它是在boost/thread.hpp里定义的,用来创建一个新线程。下面的示例来说明如何运用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <boost/thread.hpp> 
#include <iostream>

void wait(int seconds)
{
boost::this_thread::sleep(boost::posix_time::seconds(seconds));
}

void thread()
{
for (int i = 0; i < 5; ++i)
{
wait(1);
std::cout << i << std::endl;
}
}

int main()
{
boost::thread t(thread);
t.join();
}

新建线程里执行的那个函数的名称被传递到boost::thread的构造函数。一旦上述示例中的变量t被创建,该thread()函数就在其所在线程中被立即执行。同时在main()里也并发地执行该thread()

为了防止程序终止,就需要对新建线程调用join()方法。join()方法是一个阻塞调用:它可以暂停当前线程,直到调用join()的线程运行结束。这就使得main()函数一直会等待到thread()运行结束。

正如在上面的例子中看到,一个特定的线程可以通过诸如t的变量访问,通过这个变量等待着它的使用join()方法终止。但是,即使t越界或者析构了,该线程也将继续执行。一个线程总是在一开始就绑定到一个类型为boost::thread的变量,但是一旦创建,就不在取决于它。甚至还存在着一个叫detach()的方法,允许类型为boost::thread的变量从它对应的线程里分离。当然了,像join()的方法之后也就不能被调用,因为这个变量不再是一个有效的线程。

任何一个函数内可以做的事情也可以在一个线程内完成。归根结底,一个线程只不过是一个函数,除了它是同时执行的。在上述例子中,使用一个循环把5个数字写入标准输出流。为了减缓输出,每一个循环中调用wait()函数让执行延迟了一秒。wait()可以调用一个名为sleep()的函数,这个函数也来自于Boost.Thread,位于boost::this_thread命名空间内。

sleep()要么在预计的一段时间或一个特定的时间点后时才让线程继续执行。通过传递一个类型为boost::posix_time::seconds的对象,在这个例子里我们指定了一段时间。boost::posix_time::seconds来自于Boost.DateTime库,它被Boost.Thread用来管理和处理时间的数据。

虽然前面的例子说明了如何等待一个不同的线程,但下面的例子演示了如何通过所谓的中断点让一个线程中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <boost/thread.hpp> 
#include <iostream>

void wait(int seconds)
{
boost::this_thread::sleep(boost::posix_time::seconds(seconds));
}

void thread()
{
try
{
for (int i = 0; i < 5; ++i)
{
wait(1);
std::cout << i << std::endl;
}
}
catch (boost::thread_interrupted&)
{
}
}

int main()
{
boost::thread t(thread);
wait(3);
t.interrupt();
t.join();
}

在一个线程对象上调用interrupt()会中断相应的线程。在这方面,中断意味着一个类型为boost::thread_interrupted的异常,它会在这个线程中抛出。然后这只有在线程达到中断点时才会发生。

如果给定的线程不包含任何中断点,简单调用interrupt()就不会起作用。每当一个线程中断点,它就会检查interrupt()是否被调用过。只有被调用过了,boost::thread_interrupted异常才会相应地抛出。

Boost.Thread定义了一系列的中断点,例如sleep()函数。由于sleep()在这个例子里被调用了五次,该线程就检查了五次它是否应该被中断。然而sleep()之间的调用,却不能使线程中断。

一旦该程序被执行,它只会打印三个数字到标准输出流。这是由于在main里3秒后调用interrupt()方法。因此,相应的线程被中断,并抛出一个boost::thread_interrupted异常。这个异常在线程内也被正确地捕获,catch处理虽然是空的。由于thread()函数在处理程序后返回,线程也被终止。这反过来也将终止整个程序,因为main()等待该线程使用join()终止该线程。

Boost.Thread定义包括上述sleep()函数十个中断。有了这些中断点,线程可以很容易及时中断。然而,他们并不总是最佳的选择,因为中断点必须事前读入以检查boost::thread_interrupted异常。

为了提供一个对 Boost.Thread 里提供的多种函数的整体概述,下面的例子将会再介绍两个。

1
2
3
4
5
6
7
8
#include <boost/thread.hpp> 
#include <iostream>

int main()
{
std::cout << boost::this_thread::get_id() << std::endl;
std::cout << boost::thread::hardware_concurrency() << std::endl;
}

使用boost::this_thread命名空间,能提供独立的函数应用于当前线程,比如前面出现的sleep()。另一个是get_id():它会返回一个当前线程的ID号。它也是由boost::thread提供的。

boost::thread类提供了一个静态方法hardware_concurrency(),它能够返回基于CPU数目或者CPU内核数目的刻在同时在物理机器上运行的线程数。在常用的双核机器上调用这个方法,返回值为2。这样的话就可以确定在一个多核程序可以同时运行的理论最大线程数。

6.3. 同步

虽然多线程的使用可以提高应用程序的性能,但也增加了复杂性。如果使用线程在同一时间执行几个函数,访问共享资源时必须相应地同步。一旦应用达到了一定规模,这涉及相当一些工作。本段介绍了Boost.Thread提供同步线程的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <boost/thread.hpp> 
#include <iostream>

void wait(int seconds)
{
boost::this_thread::sleep(boost::posix_time::seconds(seconds));
}

boost::mutex mutex;

void thread()
{
for (int i = 0; i < 5; ++i)
{
wait(1);
mutex.lock();
std::cout << "Thread " << boost::this_thread::get_id() << ": " << i << std::endl;
mutex.unlock();
}
}

int main()
{
boost::thread t1(thread);
boost::thread t2(thread);
t1.join();
t2.join();
}

多线程程序使用所谓的互斥对象来同步。Boost.Thread提供多个的互斥类,boost::mutex是最简单的一个。互斥的基本原则是当一个特定的线程拥有资源的时候防止其他线程夺取其所有权。一旦释放,其他的线程可以取得所有权。这将导致线程等待至另一个线程完成处理一些操作,从而相应地释放互斥对象的所有权。

上面的示例使用一个类型为boost::mutexmutex全局互斥对象。thread()函数获取此对象的所有权才在for循环内使用lock()方法写入到标准输出流的。一旦信息被写入,使用unlock()方法释放所有权。

main()创建两个线程,同时执行thread()函数。利用for循环,每个线程数到5,用一个迭代器写一条消息到标准输出流。不幸的是,标准输出流是一个全局性的被所有线程共享的对象。该标准不提供任何保证std::cout可以安全地从多个线程访问。因此,访问标准输出流必须同步:在任何时候,只有一个线程可以访问 std::cout。

由于两个线程试图在写入标准输出流前获得互斥体,实际上只能保证一次只有一个线程访问std::cout。不管哪个线程成功调用lock()方法,其他所有线程必须等待,直到unlock()被调用。

获取和释放互斥体是一个典型的模式,是由Boost.Thread通过不同的数据类型支持。例如,不直接地调用lock()unlock(),使用boost::lock_guard类也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <boost/thread.hpp> 
#include <iostream>

void wait(int seconds)
{
boost::this_thread::sleep(boost::posix_time::seconds(seconds));
}

boost::mutex mutex;

void thread()
{
for (int i = 0; i < 5; ++i)
{
wait(1);
boost::lock_guard<boost::mutex> lock(mutex);
std::cout << "Thread " << boost::this_thread::get_id() << ": " << i << std::endl;
}
}

int main()
{
boost::thread t1(thread);
boost::thread t2(thread);
t1.join();
t2.join();
}

boost::lock_guard在其内部构造和析构函数分别自动调用lock()unlock()。访问共享资源是需要同步的,因为它显示地被两个方法调用。boost::lock_guard类是另一个出现在第2章智能指针的RAII用语。

除了boost::mutexboost::lock_guard之外,Boost.Thread也提供其他的类支持各种同步。其中一个重要的就是boost::unique_lock,相比较boost::lock_guard而言,它提供许多有用的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <boost/thread.hpp> 
#include <iostream>

void wait(int seconds)
{
boost::this_thread::sleep(boost::posix_time::seconds(seconds));
}

boost::timed_mutex mutex;

void thread()
{
for (int i = 0; i < 5; ++i)
{
wait(1);
boost::unique_lock<boost::timed_mutex> lock(mutex, boost::try_to_lock);
if (!lock.owns_lock())
lock.timed_lock(boost::get_system_time() + boost::posix_time::seconds(1));
std::cout << "Thread " << boost::this_thread::get_id() << ": " << i << std::endl;
boost::timed_mutex *m = lock.release();
m->unlock();
}
}

int main()
{
boost::thread t1(thread);
boost::thread t2(thread);
t1.join();
t2.join();
}

上面的例子用不同的方法来演示boost::unique_lock的功能。当然了,这些功能的用法对给定的情景不一定适用;boost::lock_guard在上个例子的用法还是挺合理的。这个例子就是为了演示boost::unique_lock提供的功能。

boost::unique_lock通过多个构造函数来提供不同的方式获得互斥体。这个期望获得互斥体的函数简单地调用了lock()方法,一直等到获得这个互斥体。所以它的行为跟boost::lock_guard的那个是一样的。

如果第二个参数传入一个boost::try_to_lock类型的值,对应的构造函数就会调用try_lock()方法。这个方法返回bool型的值:如果能够获得互斥体则返回true,否则返回false。相比lock()函数,try_lock()会立即返回,而且在获得互斥体之前不会被阻塞。

上面的程序向boost::unique_lock的构造函数的第二个参数传入boost::try_to_lock。然后通过owns_lock()可以检查是否可获得互斥体。如果不能,owns_lock()返回false。这也用到boost::unique_lock提供的另外一个函数:timed_lock()等待一定的时间以获得互斥体。给定的程序等待长达1秒,应较足够的时间来获取更多的互斥。

其实这个例子显示了三个方法获取一个互斥体:lock()会一直等待,直到获得一个互斥体。try_lock()则不会等待,但如果它只会在互斥体可用的时候才能获得,否则返回 false 。最后,timed_lock()试图获得在一定的时间内获取互斥体。和try_lock()一样,返回bool 类型的值意味着成功是否。

虽然boost::mutex提供了lock()try_lock()两个方法,但是boost::timed_mutex只支持timed_lock(),这就是上面示例那么使用的原因。如果不用timed_lock()的话,也可以像以前的例子那样用boost::mutex

就像boost::lock_guard一样,boost::unique_lock的析构函数也会相应地释放互斥量。此外,可以手动地用unlock()释放互斥量。也可以像上面的例子那样,通过调用release()解除boost::unique_lock和互斥量之间的关联。然而在这种情况下,必须显式地调用unlock()方法来释放互斥量,因为boost::unique_lock的析构函数不再做这件事情。

boost::unique_lock这个所谓的独占锁意味着一个互斥量同时只能被一个线程获取。其他线程必须等待,直到互斥体再次被释放。除了独占锁,还有非独占锁。Boost.Thread里有个boost::shared_lock的类提供了非独占锁。正如下面的例子,这个类必须和boost::shared_mutex型的互斥量一起使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <boost/thread.hpp> 
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>

void wait(int seconds)
{
boost::this_thread::sleep(boost::posix_time::seconds(seconds));
}

boost::shared_mutex mutex;
std::vector<int> random_numbers;

void fill()
{
std::srand(static_cast<unsigned int>(std::time(0)));
for (int i = 0; i < 3; ++i)
{
boost::unique_lock<boost::shared_mutex> lock(mutex);
random_numbers.push_back(std::rand());
lock.unlock();
wait(1);
}
}

void print()
{
for (int i = 0; i < 3; ++i)
{
wait(1);
boost::shared_lock<boost::shared_mutex> lock(mutex);
std::cout << random_numbers.back() << std::endl;
}
}

int sum = 0;

void count()
{
for (int i = 0; i < 3; ++i)
{
wait(1);
boost::shared_lock<boost::shared_mutex> lock(mutex);
sum += random_numbers.back();
}
}

int main()
{
boost::thread t1(fill);
boost::thread t2(print);
boost::thread t3(count);
t1.join();
t2.join();
t3.join();
std::cout << "Sum: " << sum << std::endl;
}

boost::shared_lock类型的非独占锁可以在线程只对某个资源读访问的情况下使用。一个线程修改的资源需要写访问,因此需要一个独占锁。这样做也很明显:只需要读访问的线程不需要知道同一时间其他线程是否访问。因此非独占锁可以共享一个互斥体。

在给定的例子,print()count()都可以只读访问random_numbers。虽然print()函数把random_numbers里的最后一个数写到标准输出,count()函数把它统计到sum变量。由于没有函数修改random_numbers,所有的都可以在同一时间用boost::shared_lock类型的非独占锁访问它。

fill()函数里,需要用一个boost::unique_lock类型的非独占锁,因为它插入了一个新的随机数到random_numbers。在unlock()显式地调用unlock()来释放互斥量之后,fill()等待了一秒。相比于之前的那个样子,在for循环的尾部调用wait()以保证容器里至少存在一个随机数,可以被print()或者count()访问。对应地,这两个函数在for循环的开始调用了wait()

考虑到在不同的地方每个单独地调用wait(),一个潜在的问题变得很明显:函数调用的顺序直接受CPU执行每个独立进程的顺序决定。利用所谓的条件变量,可以同步哪些独立的线程,使数组的每个元素都被不同的线程立即添加到random_numbers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <boost/thread.hpp> 
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>

boost::mutex mutex;
boost::condition_variable_any cond;
std::vector<int> random_numbers;

void fill()
{
std::srand(static_cast<unsigned int>(std::time(0)));
for (int i = 0; i < 3; ++i)
{
boost::unique_lock<boost::mutex> lock(mutex);
random_numbers.push_back(std::rand());
cond.notify_all();
cond.wait(mutex);
}
}

void print()
{
std::size_t next_size = 1;
for (int i = 0; i < 3; ++i)
{
boost::unique_lock<boost::mutex> lock(mutex);
while (random_numbers.size() != next_size)
cond.wait(mutex);
std::cout << random_numbers.back() << std::endl;
++next_size;
cond.notify_all();
}
}

int main()
{
boost::thread t1(fill);
boost::thread t2(print);
t1.join();
t2.join();
}

这个例子的程序删除了wait()count()。线程不用在每个循环迭代中等待一秒,而是尽可能快地执行。此外,没有计算总额;数字完全写入标准输出流。

为确保正确地处理随机数,需要一个允许检查多个线程之间特定条件的条件变量来同步不每个独立的线程。

正如上面所说,fill()函数用在每个迭代产生一个随机数,然后放在random_numbers容器中。为了防止其他线程同时访问这个容器,就要相应得使用一个排它锁。不是等待一秒,实际上这个例子却用了一个条件变量。调用notify_all()会唤醒每个哪些正在分别通过调用wait()等待此通知的线程。

通过查看print()函数里的for循环,可以看到相同的条件变量被wait()函数调用了。如果这个线程被notify_all()唤醒,它就会试图这个互斥量,但只有在fill()函数完全释放之后才能成功。

这里的窍门就是调用wait()会释放相应的被参数传入的互斥量。在调用notify_all()后,fill()函数会通过wait()相应地释放线程。然后它会阻止和等待其他的线程调用notify_all(),一旦随机数已写入标准输出流,这就会在print()里发生。

注意到在print()函数里调用wait()事实上发生在一个单独while循环里。这样做的目的是为了处理在print()函数里第一次调用wait()函数之前随机数已经放到容器里。通过比较random_numbers里元素的数目与预期值,发现这成功地处理了把随机数写入到标准输出流。

6.4. 线程本地存储

线程本地存储(TLS)是一个只能由一个线程访问的专门的存储区域。TLS的变量可以被看作是一个只对某个特定线程而非整个程序可见的全局变量。下面的例子显示了这些变量的好处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <boost/thread.hpp> 
#include <iostream>
#include <cstdlib>
#include <ctime>

void init_number_generator()
{
static bool done = false;
if (!done)
{
done = true;
std::srand(static_cast<unsigned int>(std::time(0)));
}
}

boost::mutex mutex;

void random_number_generator()
{
init_number_generator();
int i = std::rand();
boost::lock_guard<boost::mutex> lock(mutex);
std::cout << i << std::endl;
}

int main()
{
boost::thread t[3];

for (int i = 0; i < 3; ++i)
t[i] = boost::thread(random_number_generator);

for (int i = 0; i < 3; ++i)
t[i].join();
}

该示例创建三个线程,每个线程写一个随机数到标准输出流。random_number_generator()函数将会利用在C++标准里定义的std::rand()函数创建一个随机数。但是用于std::rand()的随机数产生器必须先用std::srand()正确地初始化。如果没做,程序始终打印同一个随机数。

随机数产生器,通过std::time()返回当前时间,在init_number_generator()函数里完成初始化。由于这个值每次都不同,可以保证产生器总是用不同的值初始化,从而产生不同的随机数。因为产生器只要初始化一次,init_number_generator()用了一个静态变量done作为条件量。

如果程序运行了多次,写入的三分之二的随机数显然就会相同。事实上这个程序有个缺陷:std::rand()所用的产生器必须被各个线程初始化。因此init_number_generator()的实现实际上是不对的,因为它只调用了一次std::srand()。使用TLS,这一缺陷可以得到纠正。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <boost/thread.hpp> 
#include <iostream>
#include <cstdlib>
#include <ctime>

void init_number_generator()
{
static boost::thread_specific_ptr<bool> tls;
if (!tls.get())
tls.reset(new bool(false));
if (!*tls)
{
*tls = true;
std::srand(static_cast<unsigned int>(std::time(0)));
}
}

boost::mutex mutex;

void random_number_generator()
{
init_number_generator();
int i = std::rand();
boost::lock_guard<boost::mutex> lock(mutex);
std::cout << i << std::endl;
}

int main()
{
boost::thread t[3];

for (int i = 0; i < 3; ++i)
t[i] = boost::thread(random_number_generator);

for (int i = 0; i < 3; ++i)
t[i].join();
}

用一个TLS变量tls代替静态变量done,是基于用bool类型实例化的boost::thread_specific_ptr。原则上,tls工作起来就像done:它可以作为一个条件指明随机数发生器是否被初始化。但是关键的区别,就是tls存储的值只对相应的线程可见和可用。

一旦一个boost::thread_specific_ptr型的变量被创建,它可以相应地设置。不过,它期望得到一个bool型变量的地址,而非它本身。使用reset()方法,可以把它的地址保存到tls里面。在给出的例子中,会动态地分配一个bool型的变量,由new返回它的地址,并保存到tls里。为了避免每次调用init_number_generator()都设置tls,它会通过get()函数检查是否已经保存了一个地址。

由于boost::thread_specific_ptr保存了一个地址,它的行为就像一个普通的指针。因此,operator*()operator->()都被被重载以方便使用。这个例子用*tls检查这个条件当前是true还是false。再根据当前的条件,随机数生成器决定是否初始化。

正如所见,boost::thread_specific_ptr允许为当前进程保存一个对象的地址,然后只允许当前进程获得这个地址。然而,当一个线程已经成功保存这个地址,其他的线程就会可能就失败。

如果程序正在执行时,它可能会令人感到奇怪:尽管有了TLS的变量,生成的随机数仍然相等。这是因为,三个线程在同一时间被创建,从而造成随机数生成器在同一时间初始化。如果该程序执行了几次,随机数就会改变,这就表明生成器初始化正确了。

第 7 章 异步输入输出

7.1. 概述

本章介绍了Boost C++库Asio,它是异步输入输出的核心。名字本身就说明了一切:Asio意即异步输入/输出。该库可以让C++异步地处理数据,且平台独立。异步数据处理就是指,任务触发后不需要等待它们完成。相反,Boost.Asio会在任务完成时触发一个应用。异步任务的主要优点在于,在等待任务完成时不需要阻塞应用程序,可以去执行其它任务。

异步任务的典型例子是网络应用。如果数据被发送出去了,比如发送至 Internet,通常需要知道数据是否发送成功。如果没有一个象Boost.Asio这样的库,就必须对函数的返回值进行求值。但是,这样就要求待至所有数据发送完毕,并得到一个确认或是错误代码。而使用Boost.Asio,这个过程被分为两个单独的步骤:第一步是作为一个异步任务开始数据传输。一旦传输完成,不论成功或是错误,应用程序都会在第二步中得到关于相应的结果通知。主要的区别在于,应用程序无需阻塞至传输完成,而可以在这段时间里执行其它操作。

7.2. I/O服务与I/O对象

使用Boost.Asio进行异步数据处理的应用程序基于两个概念:I/O服务I/O对象。I/O服务抽象了操作系统的接口,允许第一时间进行异步数据处理,而I/O对象则用于初始化特定的操作。鉴于Boost.Asio只提供了一个名为boost::asio::io_service的类作为I/O服务,它针对所支持的每一个操作系统都分别实现了优化的类,另外库中还包含了针对不同I/O对象的几个类。其中,类boost::asio::ip::tcp::socket用于通过网络发送和接收数据,而类boost::asio::deadline_timer则提供了一个计时器,用于测量某个固定时间点到来或是一段指定的时长过去了。以下第一个例子中就使用了计时器,因为与 Asio所提供的其它I/O对象相比较而言,它不需要任何有关于网络编程的知识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/asio.hpp> 
#include <iostream>

void handler(const boost::system::error_code &ec)
{
std::cout << "5 s." << std::endl;
}

int main()
{
boost::asio::io_service io_service;
boost::asio::deadline_timer timer(io_service, boost::posix_time::seconds(5));
timer.async_wait(handler);
io_service.run();
}

函数main()首先定义了一个I/O服务io_service,用于初始化I/O对象timer。就象boost::asio::deadline_timer那样,所有I/O对象通常都需要一个I/O服务作为它们的构造函数的第一个参数。由于timer的作用类似于一个闹钟,所以boost::asio::deadline_timer的构造函数可以传入第二个参数,用于表示在某个时间点或是在某段时长之后闹钟停止。以上例子指定了五秒的时长,该闹钟在timer被定义之后立即开始计时。

虽然我们可以调用一个在五秒后返回的函数,但是通过调用方法async_wait()并传入handler()函数的名字作为唯一参数,可以让Asio启动一个异步操作。请留意,我们只是传入了handler()函数的名字,而该函数本身并没有被调用。

async_wait()的好处是,该函数调用会立即返回,而不是等待五秒钟。一旦闹钟时间到,作为参数所提供的函数就会被相应调用。因此,应用程序可以在调用了async_wait()之后执行其它操作,而不是阻塞在这里。

async_wait()这样的方法被称为是非阻塞式的。I/O对象通常还提供了阻塞式的方法,可以让执行流在特定操作完成之前保持阻塞。例如,可以调用阻塞式的wait()方法,取代boost::asio::deadline_timer的调用。由于它会阻塞调用,所以它不需要传入一个函数名,而是在指定时间点或指定时长之后返回。

再看看上面的源代码,可以留意到在调用async_wait()之后,又在I/O服务之上调用了一个名为run()的方法。这是必须的,因为控制权必须被操作系统接管,才能在五秒之后调用handler()函数。

async_wait()会启动一个异步操作并立即返回,而run()则是阻塞的。因此调用run()后程序执行会停止。具有讽刺意味的是,许多操作系统只是通过阻塞函数来支持异步操作。以下例子显示了为什么这个限制通常不会成为问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <boost/asio.hpp> 
#include <iostream>

void handler1(const boost::system::error_code &ec)
{
std::cout << "5 s." << std::endl;
}

void handler2(const boost::system::error_code &ec)
{
std::cout << "10 s." << std::endl;
}

int main()
{
boost::asio::io_service io_service;
boost::asio::deadline_timer timer1(io_service, boost::posix_time::seconds(5));
timer1.async_wait(handler1);
boost::asio::deadline_timer timer2(io_service, boost::posix_time::seconds(10));
timer2.async_wait(handler2);
io_service.run();
}

上面的程序用了两个boost::asio::deadline_timer类型的I/O对象。第一个I/O对象表示一个五秒后触发的闹钟,而第二个则表示一个十秒后触发的闹钟。每一段指定时长过去后,都会相应地调用函数handler1()handler2()

main()的最后,再次在唯一的I/O服务之上调用了run()方法。如前所述,这个函数将阻塞执行,把控制权交给操作系统以接管异步处理。在操作系统的帮助下,handler1()函数会在五秒后被调用,而handler2()函数则在十秒后被调用。

乍一看,你可能会觉得有些奇怪,为什么异步处理还要调用阻塞式的run()方法。然而,由于应用程序必须防止被中止执行,所以这样做实际上不会有任何问题。如果run()不是阻塞的,main()就会结束从而中止该应用程序。如果应用程序不应被阻塞,那么就应该在一个新的线程内部调用run(),它自然就会仅仅阻塞那个线程。

一旦特定的I/O服务的所有异步操作都完成了,控制权就会返回给run()方法,然后它就会返回。以上两个例子中,应用程序都会在闹钟到时间后马上结束。

7.3. 可扩展性与多线程

Boost.Asio这样的库来开发应用程序,与一般的C++风格不同。那些可能需要较长时间才返回的函数不再是以顺序的方式来调用。不再是调用阻塞式的函数,Boost.Asio是启动一个异步操作。而那些需要在操作结束后调用的函数则实现为相应的句柄。这种方法的缺点是,本来顺序执行的功能变得在物理上分割开来了,从而令相应的代码更难理解。

Boost.Asio这样的库通常是为了令应用程序具有更高的效率。应用程序不需要等待特定的函数执行完成,而可以在期间执行其它任务,如开始另一个需要较长时间的操作

可扩展性是指,一个应用程序从新增资源有效地获得好处的能力。如果那些执行时间较长的操作不应该阻塞其它操作的话,那么建议使用Boost.Asio。由于现今的PC机通常都具有多核处理器,所以线程的应用可以进一步提高一个基于Boost.Asio的应用程序的可扩展性。

如果在某个boost::asio::io_service类型的对象之上调用run()方法,则相关联的句柄也会在同一个线程内被执行。通过使用多线程,应用程序可以同时调用多个run()方法。一旦某个异步操作结束,相应的I/O服务就将在这些线程中的某一个之中执行句柄。如果第二个操作在第一个操作之后很快也结束了,则I/O服务可以在另一个线程中执行句柄,而无需等待第一个句柄终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <boost/asio.hpp> 
#include <boost/thread.hpp>
#include <iostream>

void handler1(const boost::system::error_code &ec)
{
std::cout << "5 s." << std::endl;
}

void handler2(const boost::system::error_code &ec)
{
std::cout << "5 s." << std::endl;
}

boost::asio::io_service io_service;

void run()
{
io_service.run();
}

int main()
{
boost::asio::deadline_timer timer1(io_service, boost::posix_time::seconds(5));
timer1.async_wait(handler1);
boost::asio::deadline_timer timer2(io_service, boost::posix_time::seconds(5));
timer2.async_wait(handler2);
boost::thread thread1(run);
boost::thread thread2(run);
thread1.join();
thread2.join();
}

上一节中的例子现在变成了一个多线程的应用。通过使用在boost/thread.hpp中定义的boost::thread类,它来自于Boost C++库Thread,我们在main()中创建了两个线程。这两个线程均针对同一个I/O服务调用了run()方法。这样当异步操作完成时,这个I/O服务就可以使用两个线程去执行句柄函数。

这个例子中的两个计时数均被设为在五秒后触发。由于有两个线程,所以handler1()handler2()可以同时执行。如果第二个计时器触发时第一个仍在执行,则第二个句柄就会在第二个线程中执行。如果第一个计时器的句柄已经终止,则I/O服务可以自由选择任一线程。

线程可以提高应用程序的性能。因为线程是在处理器内核上执行的,所以创建比内核数更多的线程是没有意义的。这样可以确保每个线程在其自己的内核上执行,而没有同一内核上的其它线程与之竞争。

要注意,使用线程并不总是值得的。以上例子的运行会导致不同信息在标准输出流上混合输出,因为这两个句柄可能会并行运行,访问同一个共享资源:标准输出流std::cout。这种访问必须被同步,以保证每一条信息在另一个线程可以向标准输出流写出另一条信息之前被完全写出。在这种情形下使用线程并不能提供多少好处,如果各个独立句柄不能独立地并行运行。

多次调用同一个I/O服务的run()方法,是为基于Boost.Asio的应用程序增加可扩展性的推荐方法。另外还有一个不同的方法:不要绑定多个线程到单个I/O服务,而是创建多个I/O服务。然后每一个I/O服务使用一个线程。如果I/O服务的数量与系统的处理器内核数量相匹配,则异步操作都可以在各自的内核上执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <boost/asio.hpp> 
#include <boost/thread.hpp>
#include <iostream>

void handler1(const boost::system::error_code &ec)
{
std::cout << "5 s." << std::endl;
}

void handler2(const boost::system::error_code &ec)
{
std::cout << "5 s." << std::endl;
}

boost::asio::io_service io_service1;
boost::asio::io_service io_service2;

void run1()
{
io_service1.run();
}

void run2()
{
io_service2.run();
}

int main()
{
boost::asio::deadline_timer timer1(io_service1, boost::posix_time::seconds(5));
timer1.async_wait(handler1);
boost::asio::deadline_timer timer2(io_service2, boost::posix_time::seconds(5));
timer2.async_wait(handler2);
boost::thread thread1(run1);
boost::thread thread2(run2);
thread1.join();
thread2.join();
}

前面的那个使用两个计时器的例子被重写为使用两个I/O服务。这个应用程序仍然基于两个线程;但是现在每个线程被绑定至不同的I/O服务。此外,两个I/O对象timer1timer2现在也被绑定至不同的I/O服务。

这个应用程序的功能与前一个相同。在一定条件下使用多个I/O服务是有好处的,每个I/O服务有自己的线程,最好是运行在各自的处理器内核上,这样每一个异步操作连同它们的句柄就可以局部化执行。如果没有远端的数据或函数需要访问,那么每一个I/O服务就象一个小的自主应用。这里的局部和远端是指象高速缓存、内存页这样的资源。由于在确定优化策略之前需要对底层硬件、操作系统、编译器以及潜在的瓶颈有专门的了解,所以应该仅在清楚这些好处的情况下使用多个I/O服务。

7.4. 网络编程

虽然Boost.Asio是一个可以异步处理任何种类数据的库,但是它主要被用于网络编程。这是由于,事实上Boost.Asio在加入其它I/O对象之前很久就已经支持网络功能了。网络功能是异步处理的一个很好的例子,因为通过网络进行数据传输可能会需要较长时间,从而不能直接获得确认或错误条件。

Boost.Asio提供了多个I/O对象以开发网络应用。以下例子使用了boost::asio::ip::tcp::socket类来建立与中另一台PC的连接,并下载'Highscore'主页;就象一个浏览器在指向www.highscore.de时所要做的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <boost/asio.hpp> 
#include <boost/array.hpp>
#include <iostream>
#include <string>

boost::asio::io_service io_service;
boost::asio::ip::tcp::resolver resolver(io_service);
boost::asio::ip::tcp::socket sock(io_service);
boost::array<char, 4096> buffer;

void read_handler(const boost::system::error_code &ec, std::size_t bytes_transferred)
{
if (!ec)
{
std::cout << std::string(buffer.data(), bytes_transferred) << std::endl;
sock.async_read_some(boost::asio::buffer(buffer), read_handler);
}
}

void connect_handler(const boost::system::error_code &ec)
{
if (!ec)
{
boost::asio::write(sock, boost::asio::buffer("GET / HTTP 1.1\r\nHost: highscore.de\r\n\r\n"));
sock.async_read_some(boost::asio::buffer(buffer), read_handler);
}
}

void resolve_handler(const boost::system::error_code &ec, boost::asio::ip::tcp::resolver::iterator it)
{
if (!ec)
{
sock.async_connect(*it, connect_handler);
}
}

int main()
{
boost::asio::ip::tcp::resolver::query query("www.highscore.de", "80");
resolver.async_resolve(query, resolve_handler);
io_service.run();
}

这个程序最明显的部分是三个句柄的使用:connect_handler()read_handler()函数会分别在连接被建立后以及接收到数据后被调用。那么为什么需要resolve_handler()函数呢?

互联网使用了所谓的IP地址来标识每台PC。IP地址实际上只是一长串数字,难以记住。而记住象 www.highscore.de 这样的名字就容易得多。为了在互联网上使用类似的名字,需要通过一个叫作域名解析的过程将它们翻译成相应的IP地址。这个过程由所谓的域名解析器来完成,对应的I/O对象是:boost::asio::ip::tcp::resolver

域名解析也是一个需要连接到互联网的过程。有些专门的PC,被称为DNS服务器,其作用就象是电话本,它知晓哪个IP地址被赋给了哪台PC。由于这个过程本身的透明的,只要明白其背后的概念以及为何需要boost::asio::ip::tcp::resolverI/O对象就可以了。由于域名解析不是发生在本地的,所以它也被实现为一个异步操作。一旦域名解析成功或被某个错误中断,resolve_handler()函数就会被调用。

因为接收数据需要一个成功的连接,进而需要一次成功的域名解析,所以这三个不同的异步操作要以三个不同的句柄来启动。resolve_handler()访问I/O对象sock,用由迭代器it所提供的解析后地址创建一个连接。而sock也在connect_handler()的内部被使用,发送 HTTP 请求并启动数据的接收。因为所有这些操作都是异步的,各个句柄的名字被作为参数传递。取决于各个句柄,需要相应的其它参数,如指向解析后地址的迭代器it或用于保存接收到的数据的缓冲区buffer

开始执行后,该应用将创建一个类型为boost::asio::ip::tcp::resolver::query的对象query,表示一个查询,其中含有名字 www.highscore.de 以及互联网常用的端口80。这个查询被传递给async_resolve()方法以解析该名字。最后,main()只要调用I/O服务的run()方法,将控制交给操作系统进行异步操作即可。

当域名解析的过程完成后,resolve_handler()被调用,检查域名是否能被解析。如果解析成功,则存有错误条件的对象ec被设为0。只有在这种情况下,才会相应地访问socket以创建连接。服务器的地址是通过类型为boost::asio::ip::tcp::resolver::iterator的第二个参数来提供的。

调用了async_connect()方法之后,connect_handler()会被自动调用。在该句柄的内部,会访问ec对象以检查连接是否已建立。如果连接是有效的,则对相应的socket调用async_read_some()方法,启动读数据操作。为了保存接收到的数据,要提供一个缓冲区作为第一个参数。在以上例子中,缓冲区的类型是boost::array,它来自Boost C++库 Array,定义于boost/array.hpp.

每当有一个或多个字节被接收并保存至缓冲区时,read_handler()函数就会被调用。准确的字节数通过std::size_t类型的参数bytes_transferred给出。同样的规则,该句柄应该首先看看参数ec以检查有没有接收错误。如果是成功接收,则将数据写出至标准输出流。

请留意,read_handler()在将数据写出至std::cout之后,会再次调用async_read_some()方法。这是必需的,因为无法保证仅在一次异步操作中就可以接收到整个网页。async_read_some()read_handler()的交替调用只有当连接被破坏时才中止,如当web服务器已经传送完整个网页时。这种情况下,在read_handler()内部将报告一个错误,以防止进一步将数据输出至标准输出流,以及进一步对该socket调用async_read()方法。这时该例程将停止,因为没有更多的异步操作了。

上个例子是用来取出 www.highscore.de 的网页的,而下一个例子则示范了一个简单的 web 服务器。其主要差别在于,这个应用不会连接至其它PC,而是等待连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <boost/asio.hpp> 
#include <string>

boost::asio::io_service io_service;
boost::asio::ip::tcp::endpoint endpoint(boost::asio::ip::tcp::v4(), 80);
boost::asio::ip::tcp::acceptor acceptor(io_service, endpoint);
boost::asio::ip::tcp::socket sock(io_service);
std::string data = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world!";

void write_handler(const boost::system::error_code &ec, std::size_t bytes_transferred)
{
}

void accept_handler(const boost::system::error_code &ec)
{
if (!ec)
{
boost::asio::async_write(sock, boost::asio::buffer(data), write_handler);
}
}

int main()
{
acceptor.listen();
acceptor.async_accept(sock, accept_handler);
io_service.run();
}

类型为boost::asio::ip::tcp::acceptor的I/O对象acceptor- 被初始化为指定的协议和端口号 - 用于等待从其它PC传入的连接。初始化工作是通过 endpoint 对象完成的,该对象的类型为boost::asio::ip::tcp::endpoint,将本例子中的接收器配置为使用端口80来等待 IP v4 的传入连接,这是 WWW 通常所使用的端口和协议。

接收器初始化完成后,main()首先调用listen()方法将接收器置于接收状态,然后再用async_accept()方法等待初始连接。用于发送和接收数据的socket`被作为第一个参数传递。

当一个PC试图建立一个连接时,accept_handler()被自动调用。如果该连接请求成功,就执行自由函数boost::asio::async_write()来通过socket发送保存在data中的信息。boost::asio::ip::tcp::socket还有一个名为async_write_some()的方法也可以发送数据;不过它会在发送了至少一个字节之后调用相关联的句柄。该句柄需要计算还剩余多少字节,并反复调用async_write_some()直至所有字节发送完毕。而使用boost::asio::async_write()可以避免这些,因为这个异步操作仅在缓冲区的所有字节都被发送后才结束。

在这个例子中,当所有数据发送完毕,空函数write_handler()将被调用。由于所有异步操作都已完成,所以应用程序终止。与其它PC的连接也被相应关闭。

7.5. 开发Boost.Asio扩展

虽然Boost.Asio主要是支持网络功能的,但是加入其它I/O对象以执行其它的异步操作也非常容易。本节将介绍Boost.Asio扩展的一个总体布局。虽然这不是必须的,但它为其它扩展提供了一个可行的框架作为起点。

要向Boost.Asio中增加新的异步操作,需要实现以下三个类:

一个派生自boost::asio::basic_io_object的类,以表示新的I/O对象。使用这个新的Boost.Asio扩展的开发者将只会看到这个I/O对象。

一个派生自boost::asio::io_service::service的类,表示一个服务,它被注册为I/O服务,可以从I/O对象访问它。服务与I/O对象之间的区别是很重要的,因为在任意给定的时间点,每个I/O服务只能有一个服务实例,而一个服务可以被多个I/O对象访问。

一个不派生自任何其它类的类,表示该服务的具体实现。由于在任意给定的时间点每个I/O服务只能有一个服务实例,所以服务会为每个I/O对象创建一个其具体实现的实例。该实例管理与相应I/O对象有关的内部数据。

本节中开发的Boost.Asio扩展并不仅仅提供一个框架,而是模拟一个可用的boost::asio::deadline_timer对象。它与原来的boost::asio::deadline_timer的区别在于,计时器的时长是作为参数传递给wait()async_wait()方法的,而不是传给构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <boost/asio.hpp> 
#include <cstddef>

template <typename Service>
class basic_timer
: public boost::asio::basic_io_object<Service>
{
public:
explicit basic_timer(boost::asio::io_service &io_service)
: boost::asio::basic_io_object<Service>(io_service)
{
}

void wait(std::size_t seconds)
{
return this->service.wait(this->implementation, seconds);
}

template <typename Handler>
void async_wait(std::size_t seconds, Handler handler)
{
this->service.async_wait(this->implementation, seconds, handler);
}
};

每个I/O对象通常被实现为一个模板类,要求以一个服务来实例化 - 通常就是那个特定为此I/O对象开发的服务。当一个I/O对象被实例化时,该服务会通过父类boost::asio::basic_io_object自动注册为I/O服务,除非它之前已经注册。这样可确保任何I/O对象所使用的服务只会每个I/O服务只注册一次。

在I/O对象的内部,可以通过service引用来访问相应的服务,通常的访问就是将方法调用前转至该服务。由于服务需要为每一个I/O对象保存数据,所以要为每一个使用该服务的I/O对象自动创建一个实例。这还是在父类boost::asio::basic_io_object的帮助下实现的。实际的服务实现被作为一个参数传递给任一方法调用,使得服务可以知道是哪个I/O对象启动了这次调用。服务的具体实现是通过implementation属性来访问的。

一般一上谕,I/O对象是相对简单的:服务的安装以及服务实现的创建都是由父类boost::asio::basic_io_object来完成的,方法调用则只是前转至相应的服务;以I/O对象的实际服务实现作为参数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <boost/asio.hpp> 
#include <boost/thread.hpp>
#include <boost/bind.hpp>
#include <boost/scoped_ptr.hpp>
#include <boost/shared_ptr.hpp>
#include <boost/weak_ptr.hpp>
#include <boost/system/error_code.hpp>

template <typename TimerImplementation = timer_impl>
class basic_timer_service
: public boost::asio::io_service::service
{
public:
static boost::asio::io_service::id id;

explicit basic_timer_service(boost::asio::io_service &io_service)
: boost::asio::io_service::service(io_service),
async_work_(new boost::asio::io_service::work(async_io_service_)),
async_thread_(boost::bind(&boost::asio::io_service::run, &async_io_service_))
{
}

~basic_timer_service()
{
async_work_.reset();
async_io_service_.stop();
async_thread_.join();
}

typedef boost::shared_ptr<TimerImplementation> implementation_type;

void construct(implementation_type &impl)
{
impl.reset(new TimerImplementation());
}

void destroy(implementation_type &impl)
{
impl->destroy();
impl.reset();
}

void wait(implementation_type &impl, std::size_t seconds)
{
boost::system::error_code ec;
impl->wait(seconds, ec);
boost::asio::detail::throw_error(ec);
}

template <typename Handler>
class wait_operation
{
public:
wait_operation(implementation_type &impl, boost::asio::io_service &io_service, std::size_t seconds, Handler handler)
: impl_(impl),
io_service_(io_service),
work_(io_service),
seconds_(seconds),
handler_(handler)
{
}

void operator()() const
{
implementation_type impl = impl_.lock();
if (impl)
{
boost::system::error_code ec;
impl->wait(seconds_, ec);
this->io_service_.post(boost::asio::detail::bind_handler(handler_, ec));
}
else
{
this->io_service_.post(boost::asio::detail::bind_handler(handler_, boost::asio::error::operation_aborted));
}
}

private:
boost::weak_ptr<TimerImplementation> impl_;
boost::asio::io_service &io_service_;
boost::asio::io_service::work work_;
std::size_t seconds_;
Handler handler_;
};

template <typename Handler>
void async_wait(implementation_type &impl, std::size_t seconds, Handler handler)
{
this->async_io_service_.post(wait_operation<Handler>(impl, this->get_io_service(), seconds, handler));
}

private:
void shutdown_service()
{
}

boost::asio::io_service async_io_service_;
boost::scoped_ptr<boost::asio::io_service::work> async_work_;
boost::thread async_thread_;
};

template <typename TimerImplementation>
boost::asio::io_service::id basic_timer_service<TimerImplementation>::id;

为了与Boost.Asio集成,一个服务必须符合几个要求:

它必须派生自boost::asio::io_service::service。构造函数必须接受一个指向I/O服务的引用,该I/O服务会被相应地传给boost::asio::io_service::service的构造函数。

任何服务都必须包含一个类型为boost::asio::io_service::id的静态公有属性id。在I/O服务的内部是用该属性来识别服务的。

必须定义两个名为construct()destruct()的公有方法,均要求一个类型为implementation_type的参数。implementation_type通常是该服务的具体实现的类型定义。正如上面例子所示,在construct()中可以很容易地使用一个boost::shared_ptr对象来初始化一个服务实现,以及在destruct()中相应地析构它。由于这两个方法都会在一个I/O对象被创建或销毁时自动被调用,所以一个服务可以分别使用construct()destruct()为每个I/O对象创建和销毁服务实现。

必须定义一个名为shutdown_service()的方法;不过它可以是私有的。对于一般的Boost.Asio扩展来说,它通常是一个空方法。只有与Boost.Asio集成得非常紧密的服务才会使用它。但是这个方法必须要有,这样扩展才能编译成功。

为了将方法调用前转至相应的服务,必须为相应的I/O对象定义要前转的方法。这些方法通常具有与I/O对象中的方法相似的名字,如上例中的wait()async_wait()。同步方法,如wait(),只是访问该服务的具体实现去调用一个阻塞式的方法,而异步方法,如async_wait(),则是在一个线程中调用这个阻塞式方法。

在线程的协助下使用异步操作,通常是通过访问一个新的I/O服务来完成的。上述例子中包含了一个名为async_io_service_的属性,其类型为boost::asio::io_service。这个I/O服务的run()方法是在它自己的线程中启动的,而它的线程是在该服务的构造函数内部由类型为boost::threadasync_thread_创建的。第三个属性async_work_的类型为boost::scoped_ptr<boost::asio::io_service::work>,用于避免run()方法立即返回。否则,这可能会发生,因为已没有其它的异步操作在创建。创建一个类型为boost::asio::io_service::work的对象并将它绑定至该I/O服务,这个动作也是发生在该服务的构造函数中,可以防止run()方法立即返回。

一个服务也可以无需访问它自身的I/O服务来实现 - 单线程就足够的。为新增的线程使用一个新的I/O服务的原因是,这样更简单: 线程间可以用I/O服务来非常容易地相互通信。在这个例子中,async_wait()创建了一个类型为wait_operation的函数对象,并通过post()方法将它传递给内部的I/O服务。然后,在用于执行这个内部I/O服务的run()方法的线程内,调用该函数对象的重载operator()()post()提供了一个简单的方法,在另一个线程中执行一个函数对象。

wait_operation的重载operator()()操作符基本上就是执行了和wait()方法相同的工作:调用服务实现中的阻塞式wait()方法。但是,有可能这个I/O对象以及它的服务实现在这个线程执行operator()()操作符期间被销毁。如果服务实现是在destruct()中销毁的,则operator()()操作符将不能再访问它。这种情形是通过使用一个弱指针来防止的,从第一章中我们知道:如果在调用lock()时服务实现仍然存在,则弱指针impl_返回它的一个共享指针,否则它将返回0。在这种情况下,operator()()不会访问这个服务实现,而是以一个boost::asio::error::operation_aborted错误来调用句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <boost/system/error_code.hpp> 
#include <cstddef>
#include <windows.h>

class timer_impl
{
public:
timer_impl()
: handle_(CreateEvent(NULL, FALSE, FALSE, NULL))
{
}

~timer_impl()
{
CloseHandle(handle_);
}

void destroy()
{
SetEvent(handle_);
}

void wait(std::size_t seconds, boost::system::error_code &ec)
{
DWORD res = WaitForSingleObject(handle_, seconds * 1000);
if (res == WAIT_OBJECT_0)
ec = boost::asio::error::operation_aborted;
else
ec = boost::system::error_code();
}

private:
HANDLE handle_;
};

服务实现timer_impl使用了WindowsAPI 函数,只能在Windows中编译和使用。这个例子的目的只是为了说明一种潜在的实现。

timer_impl提供两个基本方法:wait()用于等待数秒。destroy()则用于取消一个等待操作,这是必须要有的,因为对于异步操作来说,wait()方法是在其自身的线程中调用的。如果I/O对象及其服务实现被销毁,那么阻塞式的wait()方法就要尽使用destroy()`来取消。

这个Boost.Asio扩展可以如下使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <boost/asio.hpp> 
#include <iostream>
#include "basic_timer.hpp"
#include "timer_impl.hpp"
#include "basic_timer_service.hpp"

void wait_handler(const boost::system::error_code &ec)
{
std::cout << "5 s." << std::endl;
}

typedef basic_timer<basic_timer_service<> > timer;

int main()
{
boost::asio::io_service io_service;
timer t(io_service);
t.async_wait(5, wait_handler);
io_service.run();
}

与本章开始的例子相比,这个Boost.Asio扩展的用法类似于boost::asio::deadline_timer。在实践上,应该优先使用boost::asio::deadline_timer,因为它已经集成在Boost.Asio中了。这个扩展的唯一目的就是示范一下Boost.Asio是如何扩展新的异步操作的。

目录监视器(Directory Monitor) 是现实中的一个Boost.Asio扩展,它提供了一个可以监视目录的I/O对象。如果被监视目录中的某个文件被创建、修改或是删除,就会相应地调用一个句柄。当前的版本支持Windows和Linux(内核版本 2.6.13 或以上)。

第 8 章 进程间通讯

8.1. 概述

进程间通讯描述的是同一台计算机的不同应用程序之间的数据交换机制。但不包括网络通讯方式。本章展示了Boost.Interprocess库,它包括众多的类,这些类提供了操作系统相关的进程间通讯接口的抽象层。虽然不同操作系统的进程间通讯概念非常相近,但接口的变化却很大。Boost.Interprocess库使通过C++使用这些功能成为可能。

虽然Boost.Asio也可以用来在同一台计算机的应用程序间交换数据,但是使用Boost.Interprocess库通常性能更好。Boost.Interprocess库实际上是使用操作系统的功能优化了同一台计算机的应用程序之间数据交换,所以它应该是任何不需要网络时应用程序间数据交换的首选。

8.2. 共享内存

共享内存通常是进程间通讯最快的形式。它提供一块在应用程序间共享的内存区域。一个应用能够在另一个应用读取数据时写数据。

这样一块内存区用Boost.Interprocessboost::interprocess::shared_memory_object类表示。为使用这个类,需要包含boost/interprocess/shared_memory_object.hpp头文件。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/interprocess/shared_memory_object.hpp> 
#include <iostream>

int main()
{
`boost::interprocess::shared_memory_object`shdmem(boost::interprocess::open_or_create, "Highscore", boost::interprocess::read_write);
shdmem.truncate(1024);
std::cout << shdmem.get_name() << std::endl;
boost::interprocess::offset_t size;
if (shdmem.get_size(size))
std::cout << size << std::endl;
}

boost::interprocess::shared_memory_object的构造函数需要三个参数。第一个参数指定共享内存是要创建或打开。上面的例子实际上是指定了两种方式:用boost::interprocess::open_or_create作为参数,共享内存如果存在就将其打开,否则创建之。

假设之前已经创建了共享内存,现打开前面已经创建的共享内存。为了唯一标识一块共享内存,需要为其指定一个名称,传递给boost::interprocess::shared_memory_object构造函数的第二个参数指定了这个名称。

第三个,也就是最后一个参数指示应用程序如何访问共享内存。例子应用程序能够读写共享内存,这是因为最后的一个参数是boost::interprocess::read_write

在创建一个boost::interprocess::shared_memory_object类型的对象后,相应的共享内存就在操作系统中建立了。可是此共享内存区域的大小被初始化为0.为了使用这块区域,需要调用truncate()函数,以字节为单位传递请求的共享内存的大小。对于上面的例子,共享内存提供了1,024字节的空间。

请注意,truncate()函数只能在共享内存以boost::interprocess::read_write方式打开时调用。如果不是以此方式打开,将抛出boost::interprocess::interprocess_exception异常。

为了调整共享内存的大小,truncate()函数可以被重复调用。

在创建共享内存后,get_name()get_size()函数可以分别用来查询共享内存的名称和大小。

由于共享内存被用于应用程序之间交换数据,所以每个应用程序需要映射共享内存到自己的地址空间上,这是通过boost::interprocess::mapped_region类实现的。

你也许有些奇怪,为了访问共享内存,要使用两个类。是的,boost::interprocess::mapped_region还能映射不同的对象到具体应用的地址空间。如Boost.Interprocess提供boost::interprocess::file_mapping类实际上代表特定文件的共享内存。所以boost::interprocess::file_mapping类型的对象对应一个文件。向这个对象写入的数据将自动保存关联的物理文件上。由于boost::interprocess::file_mapping不必加载整个文件,但却可以使用boost::interprocess::mapped_region将任意部分映射到地址空间,所以就能处理几个GB的文件,而这个文件在32位系统上是不能全部加载到内存上的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/interprocess/shared_memory_object.hpp> 
#include <boost/interprocess/mapped_region.hpp>
#include <iostream>

int main()
{
`boost::interprocess::shared_memory_object`shdmem(boost::interprocess::open_or_create, "Highscore", boost::interprocess::read_write);
shdmem.truncate(1024);
boost::interprocess::mapped_region region(shdmem, boost::interprocess::read_write);
std::cout << std::hex << "0x" << region.get_address() << std::endl;
std::cout << std::dec << region.get_size() << std::endl;
boost::interprocess::mapped_region region2(shdmem, boost::interprocess::read_only);
std::cout << std::hex << "0x" << region2.get_address() << std::endl;
std::cout << std::dec << region2.get_size() << std::endl;
}

为了使用boost::interprocess::mapped_region类,需要包含boost/interprocess/mapped_region.hpp头文件。boost::interprocess::mapped_region的构造函数的第一个参数必须是boost::interprocess::shared_memory_object类型的对象。第二个参数指示此内存区域对应用程序来说,是只读或是可写的。

上面的例子创建了两个boost::interprocess::mapped_region类型的对象。名为”Highscore”的共享内存,被映射到进程的地址空间两次。通过get_address()get_size()这两个函数获得共享内存的地址和大小写到标准标准输出流中。在这两种情况下,get_size()的返回值都是1024,而get_address()的返回值是不同的。

下面的例子使用共享内存写入并读取一个数字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/interprocess/shared_memory_object.hpp> 
#include <boost/interprocess/mapped_region.hpp>
#include <iostream>

int main()
{
`boost::interprocess::shared_memory_object`shdmem(boost::interprocess::open_or_create, "Highscore", boost::interprocess::read_write);
shdmem.truncate(1024);
boost::interprocess::mapped_region region(shdmem, boost::interprocess::read_write);
int *i1 = static_cast<int*>(region.get_address());
*i1 = 99;
boost::interprocess::mapped_region region2(shdmem, boost::interprocess::read_only);
int *i2 = static_cast<int*>(region2.get_address());
std::cout << *i2 << std::endl;
}

通过变量region,数值 99 被写到共享内存的开始处。然后变量region2访问共享内存的同一个位置,并将数值写入到标准输出流中。正如前面例子的get_address()函数的返回值所见,虽然变量regionregion2表示的是该进程内不同的内存区域,但由于两个内存区域底层实际访问的是同一块共享内存,所以程序打印出99。

通常,不会在同一个应用程序内使用多个boost::interprocess::mapped_region访问同一块共享内存。实际上在同一个应用程序内将同一个共享内存映射到不同的内存区域上没有多大的意义,上面的例子只用于说明的目的。

为了删除指定的共享内存,boost::interprocess::shared_memory_object对象提供了静态的remove()函数,此函数带有一个要被删除的共享内存名称的参数。

Boost.Interprocess类的RAII概念支持,明显来自关于智能指针的章节,并使用了另外的一个类名称boost::interprocess::remove_shared_memory_on_destroy。它的构造函数需要一个已经存在的共享内存的名称。如果这个类的对象被销毁了,那么在析构函数中会自动删除共享内存的容器。

请注意构造函数并不创建或打开共享内存,所以,这个类并不是典型RAII概念的代表。

1
2
3
4
5
6
7
8
#include <boost/interprocess/shared_memory_object.hpp> 
#include <iostream>

int main()
{
bool removed = boost::interprocess::shared_memory_object::remove("Highscore");
std::cout << removed << std::endl;
}

如果remove()没有被调用, 那么,即使进程终止,共享内存还会一直存在,而不论共享内存的删除是否依赖底层操作系统。多数Unix操作系统,包括Linux,一旦系统重新启动,都会自动删除共享内存,在Windows或 Mac OS X上,remove()必须调用,这两种系统实际上将共享内存存储在持久化的文件上,此文件在系统重启后还是存在的。

Windows 提供了一种特别的共享内存,它可以在最后一个使用它的应用程序终止后自动删除。为了使用它,提供了boost::interprocess::windows_shared_memory类,定义在boost/interprocess/windows_shared_memory.hpp文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/interprocess/windows_shared_memory.hpp> 
#include <boost/interprocess/mapped_region.hpp>
#include <iostream>

int main()
{
boost::interprocess::windows_shared_memory shdmem(boost::interprocess::open_or_create, "Highscore", boost::interprocess::read_write, 1024);
boost::interprocess::mapped_region region(shdmem, boost::interprocess::read_write);
int *i1 = static_cast<int*>(region.get_address());
*i1 = 99;
boost::interprocess::mapped_region region2(shdmem, boost::interprocess::read_only);
int *i2 = static_cast<int*>(region2.get_address());
std::cout << *i2 << std::endl;
}

请注意,boost::interprocess::windows_shared_memory类没有提供truncate()函数,而是在构造函数的第四个参数传递共享内存的大小。

即使boost::interprocess::windows_shared_memory类是不可移植的,且只能用于Windows系统,但使用这种特别的共享内存在不同应用之间交换数据,它还是非常有用的。

8.3. 托管共享内存

上一节介绍了用来创建和管理共享的boost::interprocess::shared_memory_object类。实际上,由于这个类需要按单个字节的方式读写共享内存,所以这个类几乎不用。概念上来讲,C++改善了类对象的创建并隐藏了它们存储在内存中哪里,是怎们存储的这些细节。

Boost.Interprocess提供了一个名为托管共享内存的概念,通过定义在boost/interprocess/managed_shared_memory.hpp文件中的boost::interprocess::managed_shared_memory类提供。这个类的目的是,对于需要分配到共享内存上的对象,它能够以内存申请的方式初始化,并使其自动为使用同一个共享内存的其他应用程序可用。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <iostream>

int main()
{
boost::interprocess::shared_memory_object::remove("Highscore");
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "Highscore", 1024);
int *i = managed_shm.construct<int>("Integer")(99);
std::cout << *i << std::endl;
std::pair<int*, std::size_t> p = managed_shm.find<int>("Integer");
if (p.first)
std::cout << *p.first << std::endl;
}

上面的例子打开名为 “Highscore” 大小为1,024 字节的共享内存,如果它不存在,它会被自动地创建。

在常规的共享内存中,为了读写数据,单个字节被直接访问,托管共享内存使用诸如construct()函数,此函数要求一个数据类型作为模板参数,此例中声明的是int类型,函数缺省要求一个名称来表示在共享内存中创建的对象。此例中使用的名称是 “Integer”。

由于construct()函数返回一个代理对象,为了初始化创建的对象,可以传递参数给此函数。语法看上去像调用一个构造函数。这就确保了对象不仅能在共享内存上创建,还能够按需要的方式初始化它。

为了访问托管共享内存上的一个特定对象,用find()函数。通过传递要查找对象的名称,返回或者是一个指向这个特定对象的指针,或者是0表示给定名称的对象没有找到。

正如前面例子中所见,find()实际返回的是std::pair类型的对象,first属性提供的是指向对象的指针,那么second属性提供的是什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <iostream>

int main()
{
boost::interprocess::shared_memory_object::remove("Highscore");
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "Highscore", 1024);
int *i = managed_shm.construct<int>("Integer")[10](99);
std::cout << *i << std::endl;
std::pair<int*, std::size_t> p = managed_shm.find<int>("Integer");
if (p.first)
{
std::cout << *p.first << std::endl;
std::cout << p.second << std::endl;
}
}

这次,通过在construct()函数后面给以用方括号括住的数字10,创建了一个10个元素的int类型的数组。将second属性写到标准输出流,同样是这个数字10。使用这个属性,find()函数返回的对象能够区分是单个对象还是数组对象。对于前者,second的值是1,而对于后者,它的值是数组元素的个数。

请注意数组中的所有元素被初始化为数值99。不可能每个元素初始化为不同的值。

如果给定名称的对象已经在托管的共享内存中存在,那么construct()将会失败。在这种情况下,construct()返回值是0。如果存在的对象即使存在也可以被重用,find_or_construct()函数可以调用,此函数返回一个指向它找到的对象的指针。此时没有初始化动作发生。

其他可以导致construct()失败的情况如下例所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <iostream>

int main()
{
try
{
boost::interprocess::shared_memory_object::remove("Highscore");
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "Highscore", 1024);
int *i = managed_shm.construct<int>("Integer")[4096](99);
}
catch (boost::interprocess::bad_alloc &ex)
{
std::cerr << ex.what() << std::endl;
}
}

应用程序尝试创建一个int类型的,包含4,096个元素的数组。然而,共享内存只有1,024 字节,于是由于共享内存不能提供请求的内存,而抛出boost::interprocess::bad_alloc类型的异常。

一旦对象已经在共享内存中创建,它们可以用destroy()函数删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <iostream>

int main()
{
boost::interprocess::shared_memory_object::remove("Highscore");
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "Highscore", 1024);
int *i = managed_shm.find_or_construct<int>("Integer")(99);
std::cout << *i << std::endl;
managed_shm.destroy<int>("Integer");
std::pair<int*, std::size_t> p = managed_shm.find<int>("Integer");
std::cout << p.first << std::endl;
}

由于它是一个参数的,要被删除对象的名称传递给destroy()函数。如果需要,可以检查此函数的 bool 类型的返回值,以确定是否给定的对象被找到并成功删除。由于对象如果被找到总是被删除,所以返回值false表示给定名称的对象没有找到。

除了destroy()函数,还提供了另外一个函数destroy_ptr(),它能够传递一个托管共享内存中的对象的指针,它也能用来删除数组。

由于托管内存很容易用来存储在不同应用程序之间共享的对象,那么很自然就会使用到来自C++标准模板库的容器了。这些容器需要用new这种方式来分配各自需要的内存。为了在托管共享内存上使用这些容器,这就需要更加仔细地在共享内存上分配内存。

可惜的是,许多C++标准模板库的实现并不太灵活,不能够提供Boost.Interprocess使用std::stringstd::list的容器。移植到 Microsoft Visual Studio 2008 的标准模板库实现就是一个例子。

为了允许开发人员可以使用这些有名的来自C++标准的容器,Boost.Interprocess在命名空间boost::interprocess下,提供了它们的更灵活的实现方式。如,boost::interprocess::string的行为实际上对应的是std::string,优点是它的对象能够安全地存储在托管共享内存上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <boost/interprocess/allocators/allocator.hpp>
#include <boost/interprocess/containers/string.hpp>
#include <iostream>

int main()
{
boost::interprocess::shared_memory_object::remove("Highscore");
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "Highscore", 1024);
typedef boost::interprocess::allocator<char, boost::interprocess::managed_shared_memory::segment_manager> CharAllocator;
typedef boost::interprocess::basic_string<char, std::char_traits<char>, CharAllocator> string;
string *s = managed_shm.find_or_construct<string>("String")("Hello!", managed_shm.get_segment_manager());
s->insert(5, ", world");
std::cout << *s << std::endl;
}

为了创建在托管共享内存上申请内存的字符串,必须为Boost.Interprocess提供的另外一个分配器定义对应的数据类型,而不是使用C++标准提供的缺省分配器。

为了这个目的,Boost.Interprocessboost/interprocess/allocators/allocator.hpp文件中提供了boost::interprocess::allocator类的定义。使用这个类,可以创建一个分配器,此分配器的内部使用的是“托管共享内存段管理器”。段管理器负责管理位于托管共享内存之内的内存。使用新建的分配器,与string相应的数据类型被定义了。如上面所示,它使用boost::interprocess::basic_string而不是std::basic_string。上面例子中的新数据类型简单地命名为string,它是基于boost::interprocess::basic_string并经过分配器访问段管理器。为了让通过find_or_construct()创建的string特定实例,知道哪个段管理器应该被访问,相应的段管理器的指针传递给构造函数的第二个参数。

boost::interprocess::string一起,Boost.Interprocess还提供了许多其他C++标准中已知的容器。如,boost::interprocess::vectorboost::interprocess::map,分别定义在boost/interprocess/containers/vector.hppboost/interprocess/containers/map.hpp文件中。

无论何时同一个托管共享内存被不同的应用程序访问,诸如创建,查找和销毁对象的操作是自动同步的。如果两个应用程序尝试在托管共享内存上创建不同名称的对象,访问相应地被串行化了。为了立刻执行多个操作而不被其他应用程序的操作打断,可以使用atomic_func()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <boost/bind.hpp>
#include <iostream>

void construct_objects(boost::interprocess::managed_shared_memory &managed_shm)
{
managed_shm.construct<int>("Integer")(99);
managed_shm.construct<float>("Float")(3.14);
}

int main()
{
boost::interprocess::shared_memory_object::remove("Highscore");
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "Highscore", 1024);
managed_shm.atomic_func(boost::bind(construct_objects, boost::ref(managed_shm)));
std::cout << *managed_shm.find<int>("Integer").first << std::endl;
std::cout << *managed_shm.find<float>("Float").first << std::endl;
}

atomic_func()需要一个无参数,无返回值的函数作为它的参数。被传递的函数将以以一种确保排他访问托管共享内存的方式被调用,但仅限于对象的创建,查找和销毁操作。如果另一个应用程序有一个指向托管内存中对象的指针,它还是可以使用这个指针修改该对象的。

Boost.Interprocess也可以用来同步对象的访问。由于Boost.Interprocess不知道在任意一个时间点谁可以访问某个对象,所以同步需要明确的状态标志,下一节介绍这些类提供的同步方式。

8.4. 同步

Boost.Interprocess允许多个应用程序并发使用共享内存。由于共享内存被定义为在应用程序之间“共享”,所以Boost.Interprocess需要支持一些同步方式。

当考虑到同步的时候,Boost.Thread当然浮现在脑海里。正如在第6章多线程所见,Boost.Thread确实提供了各种概念,如互斥对象和条件变量来同步线程。可惜的是,这些类只能用来同步同一个应用程序内的线程,它们不支持同步不同的应用程序。由于二者面临的问题相同,所以在概念上没有什么差别。

当诸如互斥对象和条件变量等同步对象位于一个多线程的应用程序的同一地址空间内时,当然它们对于所有线程都是可以访问的,而在共享内存方面的问题是不同的应用程序需要在彼此之间正确地共享这些对象。例如,如果一个应用程序创建一个互斥对象,它有时候需要从另外一个应用程序访问此对象。

Boost.Interprocess提供了两种同步对象,匿名对象被直接存储在共享内存上,这使得他们自动对所有应用程序可用。命名对象由操作系统管理,所以它们不存储在共享内存上,它们可以被应用程序通过名称访问。

接下来的例子通过boost::interprocess::named_mutex创建并使用一个命名互斥对象,此类定义在boost/interprocess/sync/named_mutex.hpp文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <boost/interprocess/sync/named_mutex.hpp>
#include <iostream>

int main()
{
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "shm", 1024);
int *i = managed_shm.find_or_construct<int>("Integer")();
boost::interprocess::named_mutex named_mtx(boost::interprocess::open_or_create, "mtx");
named_mtx.lock();
++(*i);
std::cout << *i << std::endl;
named_mtx.unlock();
}

除了一个参数用来指定互斥对象是被创建或者打开之外,boost::interprocess::named_mutex的构造函数还需要一个名称参数。每个知道此名称的应用程序能够访问这同一个对象。为了获得对位于共享内存中数据的访问,应用程序需要通过调用lock()函数获得互斥对象的拥有关系。由于互斥对象在任意时刻只能被一个应用程序拥有,其他应用程序需要等待,直到互斥对象被第一个应用程序使用lock()函数释放。一旦应用程序获得互斥对象的所有权,它可以获得互斥对象保护的资源的排他访问。在上面例子中,资源是int类的变量被递增并写到标准输出流中。

如果应用程序被启动多次,每个实例都会打印出和前一个值比较递增1的值。感谢互斥对象,访问共享内存和变量本身在多个应用程序之间是同步的。

接下来的应用程序使用了定义在boost/interprocess/sync/interprocess_mutex.hpp文件中的boost::interprocess::interprocess_mutex类的匿名对象。为了可以被所有应用程序访问,它存储在共享内存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <boost/interprocess/sync/interprocess_mutex.hpp>
#include <iostream>

int main()
{
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "shm", 1024);
int *i = managed_shm.find_or_construct<int>("Integer")();
boost::interprocess::interprocess_mutex *mtx = managed_shm.find_or_construct<boost::interprocess::interprocess_mutex>("mtx")();
mtx->lock();
++(*i);
std::cout << *i << std::endl;
mtx->unlock();
}

这个应用程序的行为确实和前一个有点像。唯一的不同是这次互斥对象通过用boost::interprocess::managed_shared_memory类的construct()find_or_construct()函数被直接存储在共享内存中。

除了lock()函数,boost::interprocess::named_mutexboost::interprocess::interprocess_mutex还提供了try_lock()timed_lock()函数。它们的行为和Boost.Thread提供的互斥对象相对应。

在需要递归互斥对象的时候,Boost.Interprocess提供boost::interprocess::named_recursive_mutexboost::interprocess::interprocess_mutex两个对象可供使用。

在互斥对象保证共享资源的排他访问的时候,条件变量控制了在什么时候,谁必须具有排他访问权。一般来讲,Boost.InterprocessBoost.Thread提供的条件变量工作方式相同。它们有非常相似的接口,使熟悉Boost.Thread的用户在使用Boost.Interprocess的这些条件变量时立刻有一种自在的感觉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <boost/interprocess/sync/named_mutex.hpp>
#include <boost/interprocess/sync/named_condition.hpp>
#include <boost/interprocess/sync/scoped_lock.hpp>
#include <iostream>

int main()
{
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "shm", 1024);
int *i = managed_shm.find_or_construct<int>("Integer")(0);
boost::interprocess::named_mutex named_mtx(boost::interprocess::open_or_create, "mtx");
boost::interprocess::named_condition named_cnd(boost::interprocess::open_or_create, "cnd");
boost::interprocess::scoped_lock<boost::interprocess::named_mutex> lock(named_mtx);
while (*i < 10)
{
if (*i % 2 == 0)
{
++(*i);
named_cnd.notify_all();
named_cnd.wait(lock);
}
else
{
std::cout << *i << std::endl;
++(*i);
named_cnd.notify_all();
named_cnd.wait(lock);
}
}
named_cnd.notify_all();
boost::interprocess::shared_memory_object::remove("shm");
boost::interprocess::named_mutex::remove("mtx");
boost::interprocess::named_condition::remove("cnd");
}

例子中使用的条件变量的类型boost::interprocess::named_condition,定义在boost/interprocess/sync/named_condition.hpp文件中。由于它是命名变量,所以它不需要存储在共享内存。

应用程序使用while循环递增一个存储在共享内存中的int类型变量而变量是在每个循环内重复递增,而它只在每两个循环时写出到标准输出中:写出的只能是奇数。

每次,在变量递增1之后,条件变量named_cndwait()函数被调用。一个称作锁,在此例中是变量lock被传递给此函数。这个锁和Boost.Thread中的锁含义相同:基于RAII概念的在构造函数中获得互斥对象的所有权,并在析构函数中释放它。

while之前创建的锁因而在整个应用程序执行期间拥有互斥对象的所有权。可是,如果作为一个参数传递给wait()函数,它会被自动释放。

条件变量常常用来等待一个信号,此信号会指示等待现在到了。同步是通过wait()notify_all()函数控制的。如果一个应用程序调用wait()函数,一直到对应的条件变量的notify_all()函数被调用,相应的互斥对象的所有权才会被被释放。

如果启动此程序,它看上去什么也没做:而只是变量在while循环内从0递增到1,然后应用程序使用wait()等待信号。为了提供这个信号,应用程序需要再启动第二个实例。

应用程序的第二个实例将会在进入while循环之前,尝试获得同一个互斥对象的所有权。这肯定是成功的,由于应用程序的第一个实例通过调用wait()释放了互斥对象的所有权。因为变量已经递增了一次,第二个实例现在会执行if表达式的else分支,这使得在递增1之前将当前值写到标准输出流。

现在,第二个实例也调用了wait()函数,可是,在调用之前,它调用了notify_all()函数,这对于两个实例正确协作是非常重要的顺序。第一个实例被通知并再次尝试获得互斥对象的所有权,虽然现在它还被第二个实例所拥有。由于第二个实例在调用notify_all()之后调用了wait(),这自动释放了所有权,第一个实例此时能够获得所有权。

两个实例交替地递增共享内存中的变量。仅有一个实例将变量值写到标准输出流。只要变量值到达10,while循环结束。为了让其他实例不必永远等待信号,notify_all()函数在循环之后又被调用了一次。在终止之前,共享内存,互斥对象和条件变量都被销毁。

就像有两种互斥对象,即必须存储在共享内存中匿名类型和命名类型,也存在两种类型的条件变量。现在用匿名条件变量重写上面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <boost/interprocess/managed_shared_memory.hpp> 
#include <boost/interprocess/sync/interprocess_mutex.hpp>
#include <boost/interprocess/sync/interprocess_condition.hpp>
#include <boost/interprocess/sync/scoped_lock.hpp>
#include <iostream>

int main()
{
try
{
boost::interprocess::managed_shared_memory managed_shm(boost::interprocess::open_or_create, "shm", 1024);
int *i = managed_shm.find_or_construct<int>("Integer")(0);
boost::interprocess::interprocess_mutex *mtx = managed_shm.find_or_construct<boost::interprocess::interprocess_mutex>("mtx")();
boost::interprocess::interprocess_condition *cnd = managed_shm.find_or_construct<boost::interprocess::interprocess_condition>("cnd")();
boost::interprocess::scoped_lock<boost::interprocess::interprocess_mutex> lock(*mtx);
while (*i < 10)
{
if (*i % 2 == 0)
{
++(*i);
cnd->notify_all();
cnd->wait(lock);
}
else
{
std::cout << *i << std::endl;
++(*i);
cnd->notify_all();
cnd->wait(lock);
}
}
cnd->notify_all();
}
catch (...)
{
}
boost::interprocess::shared_memory_object::remove("shm");
}

这个应用程序的工作完全和前一个例子一样,为了递增变量10次,因而也需要启动两个实例。两个例子之间的差别很小。与是否使用匿名或命名条件变量根本没有什么关系。

处理互斥对象和条件变量,Boost.Interprocess还提供了叫做信号量和文件锁。信号量的行为和条件变量相似,除了它不能区别两种状态,但它确是基于计数器的。文件锁有些像互斥对象,虽然它们不是关于内存的对象,但它们确是文件系统上关于文件的对象。

就像Boost.Thread能够区分不同的互斥对象和锁,Boost.Interprocess也提供了几个互斥对象和锁。例如,互斥对象不仅能被排他地拥有,也可以不排他地所有。这在多个应用程序需要同时读而排他写的时候非常有用。对于不同的互斥对象,可以使用不同的具有RAII概念的锁类。

请注意如果不使用匿名同步对象,那么名称应该是唯一的。虽然互斥对象和条件变量是基于不同类的对象,但也不必总是认为操作系统独立的接口是由Boost.Interprocess区别对待的。在Windows系统上,互斥对象和条件变量使用同样的系统函数。如果这两种对象使用相同的名称,那么应用程序在Windows上将不会正确地址执行。

第 9 章 文件系统

9.1. 概述

Boost.Filesystem简化了处理文件和目录的工作。它提供了一个名为boost::filesystem::path的类,可以对路径进行处理。另外,还有多个函数用于创建目录或验证某个给定文件的有效性。

9.2. 路径

boost::filesystem::pathBoost.Filesystem中的核心类,它表示路径的信息,并提供了处理路径的方法。

实际上,boost::filesystem::pathboost::filesystem::basic_path<std::string>的一个typedef。此外还有一个boost::filesystem::wpathboost::filesystem::basic_path<std::wstring>typedef

所有定义均位于boost::filesystem名字空间,定义于boost/filesystem.hpp中。

可以通过传入一个字符串至boost::filesystem::path类来构建一个路径。

1
2
3
4
5
6
7
8
#include <boost/filesystem.hpp> 

int main()
{
boost::filesystem::path p1("C:\\");
boost::filesystem::path p2("C:\\Windows");
boost::filesystem::path p3("C:\\Program Files");
}

没有一个boost::filesystem::path的构造函数会实际验证所提供路径的有效性,或检查给定的文件或目录是否存在。因此,boost::filesystem::path甚至可以用无意义的路径来初始化。
1
2
3
4
5
6
7
8
#include <boost/filesystem.hpp> 

int main()
{
boost::filesystem::path p1("...");
boost::filesystem::path p2("\\");
boost::filesystem::path p3("@:");
}

以上程序可以执行的原因是,路径其实只是字符串而已。boost::filesystem::path只是处理字符串罢了;文件系统没有被访问到。

boost::filesystem::path特别提供了一些方法来以字符串方式获取一个路径。有趣的是,有三种不同的方法。

1
2
3
4
5
6
7
8
9
10
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\Windows\\System");
std::cout << p.string() << std::endl;
std::cout << p.file_string() << std::endl;
std::cout << p.directory_string() << std::endl;
}

string()方法返回一个所谓的可移植路径。换句话说,就是Boost.Filesystem用它自己预定义的规则来正规化给定的字符串。在以上例子中,string()返回 C:/Windows/System。如你所见,Boost.Filesystem 内部使用斜杠符/作为文件名与目录名的分隔符。

可移植路径的目的是在不同的平台,如Windows或Linux之间,唯一地标识文件和目录。因此就不再需要使用预处理器宏来根据底层的操作系统进行路径的编码。构建可移植路径的规则大多符合POSIX标准,在Boost.Filesystem参考手册 给出。

请注意,boost::filesystem::path的构造函数同时支持可移植路径和平台相关路径。在上面例子中所使用的路径C:\\Windows\\System就不是可移植路径,而是Windows专用的。它可以被Boost.Filesystem正确识别,但仅当该程序是在Windows操作系统下运行的时候! 当程序运行于一个 POSIX 兼容的操作系统,如Linux时,string()将返回C:\Windows\System。因为在Linux中,反斜杠符\并不被用作分隔符,无论是可移植格式或原生格式,Boost.Filesystem都不会认为它是文件和目录的分隔符。

很多时候,都不能避免使用平台相关路径作为字符串。一个例子就是,使用操作系统函数时必须要用平台相关的编码。方法file_string()directory_string()正是为此目的而提供的。

在上例中,这两个方法都会返回C:\Windows\System- 与底层操作系统无关。在Windows上这个字符串是有效路径,而在一个Linux系统上则既不是可移植路径也不是平台相关路径,会象前面所说那样被解析。

以下例子使用一个可移植路径来初始化boost::filesystem::path

1
2
3
4
5
6
7
8
9
10
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("/");
std::cout << p.string() << std::endl;
std::cout << p.file_string() << std::endl;
std::cout << p.directory_string() << std::endl;
}

由于string()返回的是一个可移植路径,所以它与用于初始化boost::filesystem::path的字符串相同:/。但是file_string()directory_string()方法则会因底层平台而返回不同的结果。在Windows中,它们都返回\,而在Linux中则都返回/

你可能会奇怪为什么会有两个不同的方法用来返回平台相关路径。到目前为止,在所看到的例子中,file_string()directory_string()都是返回相同的值。但是,有些操作系统可能会返回不同的结果。因为Boost.Filesystem的目标是支持尽可能多的操作系统,所以它提供了两个方法来适应这种情况。即使你可能更为熟悉Windows或 POSIX 系统如 Linux,但还是建议使用file_string()来取出文件的路径信息,且使用directory_string()取出目录的路径信息。这无疑会增加代码的可移植性。

boost::filesystem::path提供了几个方法来访问一个路径中的特定组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\Windows\\System");
std::cout << p.root_name() << std::endl;
std::cout << p.root_directory() << std::endl;
std::cout << p.root_path() << std::endl;
std::cout << p.relative_path() << std::endl;
std::cout << p.parent_path() << std::endl;
std::cout << p.filename() << std::endl;
}

如果在是一个Windows操作系统上执行,则字符串C:\\Windows\\System被解释为一个平台相关的路径信息。因此,root_name()返回C:root_directory()返回/root_path()返回C:/relative_path()返回Windows/Systemparent_path()返回C:/Windows,而filename()返回System

如你所见,没有平台相关的路径信息被返回。没有一个返回值包含反斜杠\,只有斜杠/。如果需要平台相关信息,则要使用file_string()directory_string()。为了使用这些路径中的单独组件,必须创建一个类型为boost::filesystem::path的新对象并相应的进行初始化。

如果以上程序在Linux操作系统中执行,则返回值有所不同。多数方法会返回一个空字符串,除了relative_path()filename()会返回C:\Windows\System。字符串C:\\Windows\\System在Linux中被解释为一个文件名,这个字符串既不是某个路径的可移植编码,也不是一个被Linux支持的平台相关编码。因此,Boost.Filesystem没有其它选择,只能将整个字符串解释为一个文件名。

Boost.Filesystem还提供了其它方法来检查一个路径中是否包含某个特定子串。这些方法是:has_root_name()has_root_directory()has_root_path()has_relative_path()has_parent_path()has_filename()。各个方法都是返回一个bool类型的值。

还有两个方法用于将一个文件名拆分为各个组件。它们应当仅在has_filename()返回true时使用。否则只会返回一个空字符串,因为如果没有文件名就没什么可拆分了。

1
2
3
4
5
6
7
8
9
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("photo.jpg");
std::cout << p.stem() << std::endl;
std::cout << p.extension() << std::endl;
}

这个程序分别返回photostem(),以及.jpgextension()

除了使用各个方法调用来访问路径的各个组件以外,你还可以对组件本身进行迭代。

1
2
3
4
5
6
7
8
9
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\Windows\\System");
for (boost::filesystem::path::iterator it = p.begin(); it != p.end(); ++it)
std::cout << *it << std::endl;
}

如果是在Windows上执行,则该程序将相继输出C:,/,WindowsSystem。在其它的操作系统如Linux上,输出结果则是C:\Windows\System

前面的例子示范了不同的方法来访问路径中的各个组件,以下例子则示范了修改路径信息的方法。

1
2
3
4
5
6
7
8
9
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\");
p /= "Windows\\System";
std::cout << p.string() << std::endl;
}

通过使用重载的operator/=()操作符,这个例子将一个路径添加到另一个之上。在Windows中,该程序将输出C:\Windows\System。在Linux中,输出将会是C:\/Windows\System,因为斜杠符/是文件与目录的分隔符。这也是重载operator/=()操作符的原因:毕竟,斜杠是这个方法名的一个部分。

除了operator/=()Boost.Filesystem只提供了remove_filename()replace_extension()方法来修改路径信息。

9.3. 文件与目录

boost::filesystem::path的各个方法内部其实只是对字符串进行处理。它们可以用来访问一个路径的各个组件、相互添加路径等等。

为了处理硬盘上的物理文件和目录,提供了几个独立的函数。这些函数需要一个或多个boost::filesystem::path类型的参数,并且在其内部会调用操作系统功能来处理这些文件或目录。

在介绍各个函数之前,很重要的一点是要弄明白出现错误时会发生什么。所有要在内部访问操作系统功能的函数都有可能失败。在失败的情况下,将抛出一个类型为boost::filesystem::filesystem_error的异常。这个类是派生自boost::system::system_error的,因此适用于Boost.System框架。

除了继承自父类boost::system::system_errorwhat()code()方法以外,还有另外两个方法:path1()path2()。它们均返回一个类型为boost::filesystem::path的对象,因此在发生错误时可以很容易地确定路径信息 - 即使是对那些需要两个boost::filesystem::path参数的函数。

多数函数存在两个变体:在失败时,一个会抛出类型为boost::filesystem::filesystem_error的异常,而另一个则返回类型为boost::system::error_code的对象。对于后者,需要对返回值进行明确的检查以确定是否出错。

以下例子介绍了一个函数,它可以查询一个文件或目录的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\");
try
{
boost::filesystem::file_status s = boost::filesystem::status(p);
std::cout << boost::filesystem::is_directory(s) << std::endl;
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

boost::filesystem::status()返回一个boost::filesystem::file_status类型的对象,该对象可以被传递给其它辅助函数来评估。例如,如果查询的是一个目录的状态,则boost::filesystem::is_directory()将返回true。除了boost::filesystem::is_directory(),还有其它函数,如boost::filesystem::is_regular_file()boost::filesystem::is_symlink()boost::filesystem::exists(),它们都会返回一个bool类型的值。

除了boost::filesystem::status(),另一个名为boost::filesystem::symlink_status()的函数可用于查询一个符号链接的状态。在此情况下,实际上查询的是符号链接所指向的文件的状态。在Windows中,符号链接以lnk文件扩展名识别。

另有一组函数可用于查询文件和目录的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\Windows\\win.ini");
try
{
std::cout << boost::filesystem::file_size(p) << std::endl;
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

函数boost::filesystem::file_size()以字节数返回一个文件的大小。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <boost/filesystem.hpp> 
#include <iostream>
#include <ctime>

int main()
{
boost::filesystem::path p("C:\\Windows\\win.ini");
try
{
std::time_t t = boost::filesystem::last_write_time(p);
std::cout << std::ctime(&t) << std::endl;
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

要获得一个文件最后被修改的时间,可使用boost::filesystem::last_write_time()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\");
try
{
boost::filesystem::space_info s = boost::filesystem::space(p);
std::cout << s.capacity << std::endl;
std::cout << s.free << std::endl;
std::cout << s.available << std::endl;
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

boost::filesystem::space()用于取回磁盘的总空间和剩余空间。它返回一个boost::filesystem::space_info类型的对象,其中定义了三个公有属性:capacityfreeavailable。这三个属性的类型均为boost::uintmax_t,该类型定义于Boost.Integer库,通常是unsigned long longtypedef。磁盘空间是以字节数来计算的。

目前所看到的函数都不会触及文件和目录本身,不过有另外几个函数可以用于创建、改名或删除文件和目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("C:\\Test");
try
{
if (boost::filesystem::create_directory(p))
{
boost::filesystem::rename(p, "C:\\Test2");
boost::filesystem::remove("C:\\Test2");
}
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

以上例子应该是自解释的。仔细察看,可以看到传递给各个函数的不一定是boost::filesystem::path类型的对象,也可以是一个简单的字符串。这是可以的,因为boost::filesystem::path提供了一个非显式的构造函数,可以从简单的字符串转换为boost::filesystem::path类型的对象。这实际上简化了Boost.Filesystem的使用,因为可以无须显式创建一个对象。

还有其它的函数,如create_symlink()用于创建符号链接,以及copy_file()用于复制文件或目录。

以下例子中介绍了一个函数,基于一个文件名或一小节路径来创建一个绝对路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
try
{
std::cout << boost::filesystem::complete("photo.jpg") << std::endl;
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

输出哪个路径是由该程序运行时所处的路径决定的。例如,如果该例子从C:\运行,输出将是C:/photo.jpg

请再次留意斜杠符/!如果想得到一个平台相关的路径,则需要初始化一个boost::filesystem::path类型的对象,且必须调用file_string()

要取出一个相对于其它目录的绝对路径,可将第二个参数传递给boost::filesystem::complete()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/filesystem.hpp> 
#include <iostream>

int main()
{
try
{
std::cout << boost::filesystem::complete("photo.jpg", "D:\\") << std::endl;
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

现在,该程序显示的是D:/photo.jpg

最后,还有一个辅助函数用于取出当前工作目录,如下例所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <windows.h> 
#include <boost/filesystem.hpp>
#include <iostream>

int main()
{
try
{
std::cout << boost::filesystem::current_path() << std::endl;
SetCurrentDirectory("C:\\");
std::cout << boost::filesystem::current_path() << std::endl;
}
catch (boost::filesystem::filesystem_error &e)
{
std::cerr << e.what() << std::endl;
}
}

以上程序只能在Windows中执行,这是SetCurrentDirectory()函数的原因。这个函数更换了当前工作目录,因此对boost::filesystem::current_path()的两次调用将返回不同的结果。

函数boost::filesystem::initial_path()用于返回应用程序开始执行时所处的目录。但是,这个函数取决于操作系统的支持,因此如果需要可移植性,建议不要使用。在这种情况下,Boost.Filesystem文档中建议的方法是,可以在程序开始时保存boost::filesystem::current_path()的返回值,以备后用。

9.4. 文件流

C++标准在fstream头文件中定义了几个文件流。这些流不能接受boost::filesystem::path类型的参数。由于Boost.Filesystem库很有可能被包含在C++标准的 Technical Report 2 中,所以这些文件流将通过相应的构造函数来进行扩展。为了当前可以让文件流与类型为boost::filesystem::path的路径信息一起工作,可以使用头文件boost/filesystem/fstream.hpp。它提供了对文件流所需的扩展,这些都是基于Technical Report 2 即将加入C++标准中的。

1
2
3
4
5
6
7
8
9
#include <boost/filesystem/fstream.hpp> 
#include <iostream>

int main()
{
boost::filesystem::path p("test.txt");
boost::filesystem::ofstream ofs(p);
ofs << "Hello, world!" << std::endl;
}

不仅是构造函数,还有open()方法也需要重载,以接受类型为boost::filesystem::path的参数。

第 10 章 日期与时间

10.1. 概述

Boost.DateTime可用于处理时间数据,如历法日期和时间。另外,Boost.DateTime还提供了扩展来处理时区的问题,且支持历法日期和时间的格式化输入与输出。本章将覆盖Boost.DateTime的各个部分。

10.2. 历法日期

Boost.DateTime只支持基于格里历的历法日期,这通常不成问题,因为这是最广泛使用的历法。如果你与其它国家的某人有个会议,时间在2010年1月5日,你可以期望无需与对方确认这个日期是否基于格里历。

格里历是教皇 Gregory XIII 在1582年颁发的。严格来说,Boost.DateTime支持由1400年至9999年的历法日期,这意味着它支持1582年以前的日期。因此,Boost.DateTime可用于任一历法日期,只要该日期在转换为格里历后是在1400年之后。如果需要更早的年份,就必须使用其它库来代替。

用于处理历法日期的类和函数位于名字空间boost::gregorian中,定义于boost/date_time/gregorian/gregorian.hpp。要创建一个日期,请使用boost::gregorian::date类。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d(2010, 1, 30);
std::cout << d.year() << std::endl;
std::cout << d.month() << std::endl;
std::cout << d.day() << std::endl;
std::cout << d.day_of_week() << std::endl;
std::cout << d.end_of_month() << std::endl;
}

boost::gregorian::date提供了多个构造函数来进行日期的创建。最基本的构造函数接受一个年份、一个月份和一个日期作为参数。如果给定的是一个无效值,则将分别抛出boost::gregorian::bad_yearboost::gregorian::bad_monthboost::gregorian::bad_day_of_month类型的异常,这些异常均派生自std::out_of_range

正如在这个例子中所示的,有多个方法用于访问一个日期。象year()month()day()这些方法访问用于初始化的初始值,象day_of_week()end_of_month()这些方法则访问计算得到的值。

boost::gregorian::date的构造函数则接受年份、月份和日期的值来设定一个日期,调用month()方法实际上会显示Jan,而调用day_of_week()则显示Sat。它们不是普通的数字值,而分别是boost::gregorian::date::month_typeboost::gregorian::date::day_of_week_type类型的值。不过,Boost.DateTime为格式化的输入输出提供了全面的支持,可以将以上输出从Jan调整为1

请留意,boost::gregorian::date的缺省构造函数会创建一个无效的日期。这样的无效日期也可以通过将boost::date_time::not_a_date_time作为单一参数传递给构造函数来显式地创建。

除了直接调用构造函数,也可以通过自由函数或其它对象的方法来创建一个boost::gregorian::date类型的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d = boost::gregorian::day_clock::universal_day();
std::cout << d.year() << std::endl;
std::cout << d.month() << std::endl;
std::cout << d.day() << std::endl;

d = boost::gregorian::date_from_iso_string("20100131");
std::cout << d.year() << std::endl;
std::cout << d.month() << std::endl;
std::cout << d.day() << std::endl;
}

这个例子使用了boost::gregorian::day_clock类,它是一个返回当前日期的时钟类。方法universal_day()返回一个与时区及夏时制无关的 UTC 日期。UTC 是世界时(universal time)的国际缩写。boost::gregorian::day_clock还提供了另一个方法local_day(),它接受本地设置。要取出本地时区的当前日期,必须使用local_day()

名字空间boost::gregorian中包含了许多其它自由函数,将保存在字符串中的日期转换为boost::gregorian::date类型的对象。这个例子实际上是通过boost::gregorian::date_from_iso_string()函数对一个以 ISO 8601 格式给出的日期进行转换。还有其它相类似的函数,如boost::gregorian::from_simple_string()boost::gregorian::from_us_string()

boost::gregorian::date表示的是一个特定的时间点,而boost::gregorian::date_duration则表示了一段时间。

1
2
3
4
5
6
7
8
9
10
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d1(2008, 1, 31);
boost::gregorian::date d2(2008, 8, 31);
boost::gregorian::date_duration dd = d2 - d1;
std::cout << dd.days() << std::endl;
}

由于boost::gregorian::date重载了operator-()操作符,所以两个时间点可以如上所示那样相减。返回值的类型为boost::gregorian::date_duration,表示了两个日期之间的时间长度。

boost::gregorian::date_duration所提供的最重要的方法是days(),它返回一段时间内所包含的天数。

我们也可以通过传递一个天数作为构造函数的唯一参数,来显式创建boost::gregorian::date_duration类型的对象。要创建涉及星期数、月份数或年数的时间段,可以相应使用boost::gregorian::weeksboost::gregorian::monthsboost::gregorian::years

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date_duration dd(4);
std::cout << dd.days() << std::endl;
boost::gregorian::weeks ws(4);
std::cout << ws.days() << std::endl;
boost::gregorian::months ms(4);
std::cout << ms.number_of_months() << std::endl;
boost::gregorian::years ys(4);
std::cout << ys.number_of_years() << std::endl;
}

boost::gregorian::monthsboost::gregorian::years都无法确定其天数,因为某月或某年所含天数是可长的。不过,这些类的用法还是可以从以下例子中看出。
1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d(2009, 1, 31);
boost::gregorian::months ms(1);
boost::gregorian::date d2 = d + ms;
std::cout << d2 << std::endl;
boost::gregorian::date d3 = d2 - ms;
std::cout << d3 << std::endl;
}

该程序将一个月加到给定的日期January 31, 2009上,得到d2,其为February 28, 2009。接着,再减回一个月得到d3,又重新变回January 31, 2009。如上所示,时间点和时间长度可用于计算。不过,需要考虑具体的情况。例如,从某月的最后一天开始计算,boost::gregorian::months总是会到达另一个月的最后一天,如果反复前后跳,就可能得到令人惊讶的结果。
1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d(2009, 1, 30);
boost::gregorian::months ms(1);
boost::gregorian::date d2 = d + ms;
std::cout << d2 << std::endl;
boost::gregorian::date d3 = d2 - ms;
std::cout << d3 << std::endl;
}

这个例子与前一个例子的不同之处在于,初始的日期是January 30, 2009。虽然这不是January的最后一天,但是向前跳一个月后得到的d2还是February 28, 2009,因为没有February 30这一天。不过,当我们再往回跳一个月,这次得到的d3就变成January 31, 2009!因为February 28, 2009是当月的最后一天,往回跳实际上是返回到January的最后一天。

如果你觉得这种行为过于混乱,可以通过取消BOOST_DATE_TIME_OPTIONAL_GREGORIAN_TYPES宏的定义来改变这种行为。取消该宏后,boost::gregorian::weeksboost::gregorian::monthsboost::gregorian::years类都不再可用。唯一剩下的类是boost::gregorian::date_duration,只能指定前向或后向的跳过的天数,这样就不会再有意外的结果了。

boost::gregorian::date_duration表示的是时间长度,而boost::gregorian::date_period则提供了对两个日期之间区间的支持。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d1(2009, 1, 30);
boost::gregorian::date d2(2009, 10, 31);
boost::gregorian::date_period dp(d1, d2);
boost::gregorian::date_duration dd = dp.length();
std::cout << dd.days() << std::endl;
}

两个类型为boost::gregorian::date的参数指定了开始和结束的日期,它们被传递给boost::gregorian::date_period的构造函数。此外,也可以指定一个开始日期和一个类型为boost::gregorian::date_duration的时间长度。请注意,结束日期的前一天才是这个时间区间的最后一天,这对于理解以下例子的输出非常重要。
1
2
3
4
5
6
7
8
9
10
11
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d1(2009, 1, 30);
boost::gregorian::date d2(2009, 10, 31);
boost::gregorian::date_period dp(d1, d2);
std::cout << dp.contains(d1) << std::endl;
std::cout << dp.contains(d2) << std::endl;
}

这个程序用contains()方法来检查某个给定的日期是否包含在时间区间内。虽然d1d2都是被传递给boost::gregorian::date_period的构造函数的,但是contains()仅对第一个返回true。因为结束日期不是区间的一部分,所以以d2调用contains()会返回false

boost::gregorian::date_period还提供了其它方法,如移动一个区间,或计算两个重叠区间的交集。

除了日期类、时间长度类和时间区间类,Boost.DateTime还提供了迭代器和其它有用的自由函数,如下例所示。

1
2
3
4
5
6
7
8
9
10
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>

int main()
{
boost::gregorian::date d(2009, 1, 5);
boost::gregorian::day_iterator it(d);
std::cout << *++it << std::endl;
std::cout << boost::date_time::next_weekday(*it, boost::gregorian::greg_weekday(boost::date_time::Friday)) << std::endl;
}

为了从一个指定日期向前或向后一天一天地跳,可以使用迭代器boost::gregorian::day_iterator。还有boost::gregorian::week_iteratorboost::gregorian::month_iteratorboost::gregorian::year_iterator分别提供了按周、按月或是按年跳的迭代器。

这个例子还使用了boost::date_time::next_weekday(),它基于一个给定的日期返回下一个星期几的日期。以下程序将显示2009-Jan-09,因为它是January 6, 2009之的第一个Friday

10.3. 位置无关的时间

boost::gregorian::date用于创建日期,boost::posix_time::ptime则用于定义一个位置无关的时间。boost::posix_time::ptime会存取boost::gregorian::date且额外保存一个时间。

为了使用boost::posix_time::ptime,必须包含头文件boost/date_time/posix_time/posix_time.hpp

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/date_time/posix_time/posix_time.hpp> 
#include <boost/date_time/gregorian/gregorian.hpp>
#include <iostream>

int main()
{
boost::posix_time::ptime pt(boost::gregorian::date(2009, 1, 5), boost::posix_time::time_duration(12, 0, 0));
boost::gregorian::date d = pt.date();
std::cout << d << std::endl;
boost::posix_time::time_duration td = pt.time_of_day();
std::cout << td << std::endl;
}

要初始化一个boost::posix_time::ptime类型的对象,要把一个类型为boost::gregorian::date的日期和一个类型为boost::posix_time::time_duration的时间长度作为第一和第二参数传递给构造函数。传给boost::posix_time::time_duration构造函数的三个参数决定了时间点。以上程序指定的时间点是January 5, 2009的 12 PM。

要查询日期和时间,可以使用date()time_of_day()方法。

boost::gregorian::date的缺省构造函数会创建一个无效日期一样,如果使用缺省构造函数,boost::posix_time::ptime类型的对象也是无效的。也可以通过传递一个boost::date_time::not_a_date_time给构造函数来显式创建一个无效时间。

和使用自由函数或其它对象的方法来创建boost::gregorian::date类型的历法日期一样,Boost.DateTime也提供了相应的自由函数和对象来创建时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/date_time/posix_time/posix_time.hpp> 
#include <boost/date_time/gregorian/gregorian.hpp>
#include <iostream>

int main()
{
boost::posix_time::ptime pt = boost::posix_time::second_clock::universal_time();
std::cout << pt.date() << std::endl;
std::cout << pt.time_of_day() << std::endl;

pt = boost::posix_time::from_iso_string("20090105T120000");
std::cout << pt.date() << std::endl;
std::cout << pt.time_of_day() << std::endl;
}

boost::posix_time::second_clock表示一个返回当前时间的时钟。universal_time()方法返回 UTC 时间,如上例所示。如果需要本地时间,则必须使用local_time()

Boost.DateTime还提供了一个名为boost::posix_time::microsec_clock的类,它返回包含微秒在内的当前时间,供需要更高精度时使用。

要将一个保存在字符串中的时间点转换为类型为boost::posix_time::ptime的对象,可以用boost::posix_time::from_iso_string()这样的自由函数,它要求传入的时间点以 ISO 8601 格式提供。

除了boost::posix_time::ptimeBoost.DateTime也提供了boost::posix_time::time_duration类,用于指定一个时间长度。这个类前面已经提到过,因为boost::posix_time::ptime的构造函数实际上需要一个boost::posix_time::time_duration类型的对象作为其第二个参数。当然,boost::posix_time::time_duration也可以单独使用。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/date_time/posix_time/posix_time.hpp> 
#include <iostream>

int main()
{
boost::posix_time::time_duration td(16, 30, 0);
std::cout << td.hours() << std::endl;
std::cout << td.minutes() << std::endl;
std::cout << td.seconds() << std::endl;
std::cout << td.total_seconds() << std::endl;
}

hours()minutes()seconds()均返回传给构造函数的各个值,而象total_seconds()这样的方法则返回总的秒数,以简单的方式为你提供额外的信息。

可以传递任意值给boost::posix_time::time_duration,因为没有象24小时这样的上限存在。

和历法日期一样,时间点与时间长度也可以执行运算。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/date_time/posix_time/posix_time.hpp> 
#include <iostream>

int main()
{
boost::posix_time::ptime pt1(boost::gregorian::date(2009, 1, 05), boost::posix_time::time_duration(12, 0, 0));
boost::posix_time::ptime pt2(boost::gregorian::date(2009, 1, 05), boost::posix_time::time_duration(18, 30, 0));
boost::posix_time::time_duration td = pt2 - pt1;
std::cout << td.hours() << std::endl;
std::cout << td.minutes() << std::endl;
std::cout << td.seconds() << std::endl;
}

如果两个boost::posix_time::ptime类型的时间点相减,结果将是一个boost::posix_time::time_duration类型的对象,给出两个时间点之间的时间长度。
1
2
3
4
5
6
7
8
9
10
#include <boost/date_time/posix_time/posix_time.hpp> 
#include <iostream>

int main()
{
boost::posix_time::ptime pt1(boost::gregorian::date(2009, 1, 05), boost::posix_time::time_duration(12, 0, 0));
boost::posix_time::time_duration td(6, 30, 0);
boost::posix_time::ptime pt2 = pt1 + td;
std::cout << pt2.time_of_day() << std::endl;
}

正如这个例子所示,时间长度可以被增加至一个时间点上,以得到一个新的时间点。以上程序将打印18:30:00到标准输出流。

你可能已经留意到,Boost.DateTime对于历法日期和时间使用了相同的概念。就象有时间类和时间长度类一样,也有一个时间区间的类。对于历法日期,这个类是boost::gregorian::date_period;对于时间则是boost::posix_time::time_period。这两个类均要求传入两个参数给构造函数:boost::gregorian::date_period要求两个历法日期,而boost::posix_time::time_period则要求两个时间。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/date_time/posix_time/posix_time.hpp> 
#include <iostream>

int main()
{
boost::posix_time::ptime pt1(boost::gregorian::date(2009, 1, 05), boost::posix_time::time_duration(12, 0, 0));
boost::posix_time::ptime pt2(boost::gregorian::date(2009, 1, 05), boost::posix_time::time_duration(18, 30, 0));
boost::posix_time::time_period tp(pt1, pt2);
std::cout << tp.contains(pt1) << std::endl;
std::cout << tp.contains(pt2) << std::endl;
}

大致上说,boost::posix_time::time_period非常象boost::gregorian::date_period。它提供了一个名为contains()的方法,对于位于该时间区间内的每一个时间点,它返回true。由于传给boost::posix_time::time_period的构造函数的结束时间不是时间区间的一部分,所以上例中第二个contains()调用将返回false

boost::posix_time::time_period还提供了其它方法,如intersection()merge()分别用于计算两个重叠时间区间的交集,以及合并两个相交区间。

最后,迭代器boost::posix_time::time_iterator用于对时间点进行迭代。

1
2
3
4
5
6
7
8
9
10
#include <boost/date_time/local_time/local_time.hpp> 
#include <iostream>

int main()
{
boost::posix_time::ptime pt(boost::gregorian::date(2009, 1, 05), boost::posix_time::time_duration(12, 0, 0));
boost::posix_time::time_iterator it(pt, boost::posix_time::time_duration(6, 30, 0));
std::cout << *++it << std::endl;
std::cout << *++it << std::endl;
}

以上程序使用了迭代器it从时间点pt开始向前跳6.5个小时。由于迭代器被递增两次,所以相应的输出分别为2009-Jan-05 18:30:002009-Jan-06 01:00:00

10.4. 位置相关的时间

和前一节所介绍的位置无关时间不一样,位置相关时间是要考虑时区的。为此,Boost.DateTime提供了boost::local_time::local_date_time类,它定义于boost/date_time/local_time/local_time.hpp,并使用boost::local_time::posix_time_zone来保存时区信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/date_time/local_time/local_time.hpp> 
#include <iostream>

int main()
{
boost::local_time::time_zone_ptr tz(new boost::local_time::posix_time_zone("CET+1"));
boost::posix_time::ptime pt(boost::gregorian::date(2009, 1, 5), boost::posix_time::time_duration(12, 0, 0));
boost::local_time::local_date_time dt(pt, tz);
std::cout << dt.utc_time() << std::endl;
std::cout << dt << std::endl;
std::cout << dt.local_time() << std::endl;
std::cout << dt.zone_name() << std::endl;
}

boost::local_time::local_date_time的构造函数要求一个boost::posix_time::ptime类型的对象作为其第一个参数,以及一个boost::local_time::time_zone_ptr类型的对象作为第二个参数。后者只不过是boost::shared_ptr<boost::local_time::posix_time_zone>的类型定义。换句话说,并不是传递一个boost::local_time::posix_time_zone对象,而是传递一个指向该对象的智能指针。这样,多个boost::local_time::local_date_time类型的对象就可以共享时区信息。只有当最后一个对象被销毁时,相应的表示时区的对象才会被自动释放。

要创建一个boost::local_time::posix_time_zone类型的对象,就要将一个描述该时区的字符串作为单一参数传递给构造函数。以上例子指定了欧洲中部时区:CET 是欧洲中部时间(Central European Time)的缩写。由于 CET 比 UTC 早一个小时,所以时差以 +1 表示。Boost.DateTime并不能解释时区的缩写,也就不知道 CET 的意思。所以,必须以小时数给出时差;传入 +0 表示没有时差。

在执行时,该程序将打印2009-Jan-05 12:00:002009-Jan-05 13:00:00 CET2009-Jan-05 13:00:00和 CET 到标准输出流。用以初始化boost::posix_time::ptimeboost::local_time::local_date_time类型的值缺省总是与 UTC 时区相关的。只有当一个boost::local_time::local_date_time类型的对象被写出至标准输出流时,或者调用local_time()方法时,才使用时差来计算本地时间。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/date_time/local_time/local_time.hpp> 
#include <iostream>

int main()
{
boost::local_time::time_zone_ptr tz(new boost::local_time::posix_time_zone("CET+1"));
boost::posix_time::ptime pt(boost::gregorian::date(2009, 1, 5), boost::posix_time::time_duration(12, 0, 0));
boost::local_time::local_date_time dt(pt, tz);
std::cout << dt.local_time() << std::endl;
boost::local_time::time_zone_ptr tz2(new boost::local_time::posix_time_zone("EET+2"));
std::cout << dt.local_time_in(tz2).local_time() << std::endl;
}

通过使用local_time()方法,时区的偏差才被考虑进来。为了计算 CET 时间,需要往保存在dt中的 UTC 时间 12 PM 上加一个小时,因为 CET 比 UTC 早一个小时。local_time()会相应地输出2009-Jan-05 13:00:00到标准输出流。

相比之下,local_time_in()方法是在所传入参数的时区内解释保存在dt中的时间。这意味着 12 PM UTC 相当于 2 PM EET,即东部欧洲时间,它比 UTC 早两个小时。

最后,Boost.DateTime通过boost::local_time::local_time_period类提供了位置相关的时间区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/date_time/local_time/local_time.hpp> 
#include <iostream>

int main()
{
boost::local_time::time_zone_ptr tz(new boost::local_time::posix_time_zone("CET+0"));
boost::posix_time::ptime pt1(boost::gregorian::date(2009, 1, 5), boost::posix_time::time_duration(12, 0, 0));
boost::local_time::local_date_time dt1(pt1, tz);
boost::posix_time::ptime pt2(boost::gregorian::date(2009, 1, 5), boost::posix_time::time_duration(18, 0, 0));
boost::local_time::local_date_time dt2(pt2, tz);
boost::local_time::local_time_period tp(dt1, dt2);
std::cout << tp.contains(dt1) << std::endl;
std::cout << tp.contains(dt2) << std::endl;
}

boost::local_time::local_time_period的构造函数要求两个类型为boost::local_time::local_date_time的参数。和其它类型的时间区间一样,第二个参数所表示的结束时间并不包含在区间之内。通过如contains()intersection()merge()以及其它的方法,时间区间可以与其它boost::local_time::local_time_period相互操作。

10.5. 格式化输入输出

本章中的所有例子在执行后都提供形如2009-Jan-07这样的输出结果。有的人可能更喜欢用其它格式来显示结果。Boost.DateTime允许boost::date_time::date_facetboost::date_time::time_facet类来格式化历法日期和时间。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>
#include <locale>

int main()
{
boost::gregorian::date d(2009, 1, 7);
boost::gregorian::date_facet *df = new boost::gregorian::date_facet("%A, %d %B %Y");
std::cout.imbue(std::locale(std::cout.getloc(), df));
std::cout << d << std::endl;
}

Boost.DateTime使用了locales的概念,它来自于C++标准,在第5章字符串处理中有概括的介绍。要格式化一个历法日期,必须创建一个boost::date_time::date_facet类型的对象并安装在一个locale内。一个描述新格式的字符串被传递给boost::date_time::date_facet的构造函数。上面的例子传递的是%A, %d %B %Y,指定格式为:星期几后跟日月年全名:Wednesday, 07 January 2009

Boost.DateTime提供了多个格式化标志,标志由一个百分号后跟一个字符组成。Boost.DateTime的文档中对于所支持的所有标志有一个完整的介绍。例如,%A 表示星期几的全名。

如果应用程序的基本用户是位于德国或德语国家,最好可以用德语而不是英语来显示星期几和月份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <boost/date_time/gregorian/gregorian.hpp> 
#include <iostream>
#include <locale>
#include <string>
#include <vector>

int main()
{
std::locale::global(std::locale("German"));
std::string months[12] = { "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" };
std::string weekdays[7] = { "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" };
boost::gregorian::date d(2009, 1, 7);
boost::gregorian::date_facet *df = new boost::gregorian::date_facet("%A, %d. %B %Y");
df->long_month_names(std::vector<std::string>(months, months + 12));
df->long_weekday_names(std::vector<std::string>(weekdays, weekdays + 7));
std::cout.imbue(std::locale(std::cout.getloc(), df));
std::cout << d << std::endl;
}

星期几和月份的名字可以通过分别传入两个数组给boost::date_time::date_facet类的long_month_names()long_weekday_names()方法来修改,这两个数组分别包含了相应的名字。以上例子现在将打印Mittwoch, 07. Januar 2009到标准输出流。

Boost.DateTime在格式化输入输出方面是非常灵活的。除了输出类boost::date_time::date_facetboost::date_time::time_facet以外,类boost::date_time::date_input_facetboost::date_time::time_input_facet可用于格式化输入。所有这四个类都提供了许多方法,来为Boost.DateTime所提供的各种不同对象配置输入和输出的方式。例如,可以指定boost::gregorian::date_period类型的时间长度如何输入和输出。要弄清楚各种格式化输入输出的可能性,请参考Boost.DateTime的文档。

第 11 章 序列化

11.1. 概述

Boost C++ 的序列化库允许将C++应用程序中的对象转换为一个字节序列,此序列可以被保存,并可在将来恢复对象的时候再次加载。各种不同的数据格式,包括 XML,只要具有一定规则的数据格式,在序列化后都产生一个字节序列。所有Boost.Serialization支持的格式,在某些方面来说都是专有的。比如 XML 格式不同用来和不是用 C++ Boost.Serialization库开发的应用程序交换数据。所有以 XML 格式存储的数据适合于从之前存储的数据上恢复同一个C++对象。XML 格式的唯一优点是序列化的C++对象容易理解,这是很有用的,比如说在调试的时候。

11.2. 归档

Boost.Serialization的主要概念是归档。归档的文件是相当于序列化的C++对象的一个字节流。对象可以通过序列化添加到归档文件,相应地也可从归档文件中加载。为了恢复和之前存储相同的C++对象,需假定数据类型是相同的。

下面看一个简单的例子。

1
2
3
4
5
6
7
8
9
10
#include <boost/archive/text_oarchive.hpp> 
#include <iostream>

int main()
{
boost::archive::text_oarchive oa(std::cout);
int i = 1;
oa << i;
}


Boost.Serialization提供了多个归档类,如boost::archive::text_oarchive类,它定义在boost/archive/text_oarchive.hpp文件中。boost::archive::text_oarchive,可将对象序列化为文本流。上面的应用程序将22 serialization::archive 5 1写出到标准输出流。

可见,boost::archive::text_oarchive类型的对象oa可以用来像流 (stream) 一样通过<<来序列化对象。尽管如此,归档也不能被认为是可以存储任何数据的常规的流。为了以后恢复数据,必须以相同的顺序使用和先前存储时用的一样的数据类型。下面的例子序列化和恢复了int类型的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <fstream>

void save()
{
std::ofstream file("archiv.txt");
boost::archive::text_oarchive oa(file);
int i = 1;
oa << i;
}

void load()
{
std::ifstream file("archiv.txt");
boost::archive::text_iarchive ia(file);
int i = 0;
ia >> i;
std::cout << i << std::endl;
}

int main()
{
save();
load();
}


boost::archive::text_oarchive被用来把数据序列化为文本流,boost::archive::text_iarchive就用来从文本流恢复数据。为了使用这些类,必须包含boost/archive/text_iarchive.hpp头文件。

归档的构造函数需要一个输入或者输出流作为参数。流分别用来序列化或恢复数据。虽然上面的应用程序使用了一个文件流,其他流,如stringstream流也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

void save()
{
boost::archive::text_oarchive oa(ss);
int i = 1;
oa << i;
}

void load()
{
boost::archive::text_iarchive ia(ss);
int i = 0;
ia >> i;
std::cout << i << std::endl;
}

int main()
{
save();
load();
}


这个应用程序也向标准输出流写了1。然而,与前面的例子相比,数据却是用stringstream流序列化的。

到目前为止,原始的数据类型已经被序列化了。接下来的例子演示如何序列化用户定义类型的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}

int age_;
};

void save()
{
boost::archive::text_oarchive oa(ss);
person p(31);
oa << p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
person p;
ia >> p;
std::cout << p.age() << std::endl;
}

int main()
{
save();
load();
}


为了序列化用户定义类型的对话,serialize()函数必须定义,它在对象序列化或从字节流中恢复是被调用。由于serialize()函数既用来序列化又用来恢复数据,Boost.Serialization除了<<>>之外还提供了&操作符。如果使用这个操作符,就不再需要在serialize()函数中区分是序列化和恢复了。

serialize()在对象序列化或恢复时自动调用。它应从来不被明确地调用,所以应生命为私有的。这样的话,boost::serialization::access类必须被声明为友元,以允许Boost.Serialization能够访问到这个函数。

有些情况下需要添加serialize()函数却不能修改现有的类。比如,对于来自C++标准库或其他库的类就是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
friend void serialize(Archive &ar, person &p, const unsigned int version);

int age_;
};

template <typename Archive>
void serialize(Archive &ar, person &p, const unsigned int version)
{
ar & p.age_;
}

void save()
{
boost::archive::text_oarchive oa(ss);
person p(31);
oa << p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
person p;
ia >> p;
std::cout << p.age() << std::endl;
}

int main()
{
save();
load();
}


为了序列化那些不能被修改的数据类型,要定义一个单独的函数serialize(),如上面的例子所示。这个函数需要相应的数据类型的引用作为它的第二个参数。

如果要被序列化的数据类型中含有不能经由公有函数访问的私有属性,事情就变得复杂了。在这种情况下,该数据列席就需要修改。在上面应用程序中的serialize()函数如果不声明为friend,就不能访问age_属性。

不过还好,Boost.Serialization为许多C++标准库的类提供了serialize()函数。为了序列化基于C++标准库的类,需要包含额外的头文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/serialization/string.hpp>
#include <iostream>
#include <sstream>
#include <string>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age, const std::string &name)
: age_(age), name_(name)
{
}

int age() const
{
return age_;
}

std::string name() const
{
return name_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
friend void serialize(Archive &ar, person &p, const unsigned int version);

int age_;
std::string name_;
};

template <typename Archive>
void serialize(Archive &ar, person &p, const unsigned int version)
{
ar & p.age_;
ar & p.name_;
}

void save()
{
boost::archive::text_oarchive oa(ss);
person p(31, "Boris");
oa << p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
person p;
ia >> p;
std::cout << p.age() << std::endl;
std::cout << p.name() << std::endl;
}

int main()
{
save();
load();
}


这个例子扩展了person类,增加了std::string类型的名称变量,为了序列化这个属性property,boost/serialization/string.hpp头文件必须包含,它提供了合适的单独的serialize()函数。

正如前面所提到的,Boost.Serialization为许多C++标准库类定义了serialize()函数。这些都定义在和C++标准库头文件名称相对应的头文件中。为了序列化std::string类型的对象,必须包含boost/serialization/string.hpp头文件。为了序列化std::vector类型的对象,必须包含boost/serialization/vector.hpp头文件。于是在给定的场合中应该包含哪个头文件就显而易见了。

还有一个serialize()函数的参数,到目前为止我们一直忽略没谈到,那就是version。如果归档需要向前兼容,以支持给定应用程序的未来版本,那么这个参数就是有意义的。接下来的例子考虑到person类的归档需要向前兼容。由于person的原始版本没有包含任何名称,新版本的person应该能够处理不带名称的旧的归档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/serialization/string.hpp>
#include <iostream>
#include <sstream>
#include <string>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age, const std::string &name)
: age_(age), name_(name)
{
}

int age() const
{
return age_;
}

std::string name() const
{
return name_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
friend void serialize(Archive &ar, person &p, const unsigned int version);

int age_;
std::string name_;
};

template <typename Archive>
void serialize(Archive &ar, person &p, const unsigned int version)
{
ar & p.age_;
if (version > 0)
ar & p.name_;
}

BOOST_CLASS_VERSION(person, 1)

void save()
{
boost::archive::text_oarchive oa(ss);
person p(31, "Boris");
oa << p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
person p;
ia >> p;
std::cout << p.age() << std::endl;
std::cout << p.name() << std::endl;
}

int main()
{
save();
load();
}


BOOST_CLASS_VERSION宏用来指定类的版本号。上面例子中person类的版本号设置为1。如果没有使用BOOST_CLASS_VERSION,版本号缺省是0。

版本号存储在归档文件中,因此也就是归档的一部份。当一个特定类的版本号通过BOOST_CLASS_VERSION宏,在序列化时给定时,serialize()函数的version参数被设为给定值存储在归档中。如果新版本的person访问一个包含旧版本序列化对象的归档时,name_由于旧版本不含有这个属性而不能恢复。通过这种机制,Boost.Serialization提供了向前兼容归档的支持。

11.3. 指针和引用

Boost.Serialization 还能序列化指针和引用。由于指针存储对象的地址,序列化对象的地址没有什么意义,而是在序列化指针和引用时,对象的引用被自动地序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}

int age_;
};

void save()
{
boost::archive::text_oarchive oa(ss);
person *p = new person(31);
oa << p;
std::cout << std::hex << p << std::endl;
delete p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
person *p;
ia >> p;
std::cout << std::hex << p << std::endl;
std::cout << p->age() << std::endl;
delete p;
}

int main()
{
save();
load();
}

上面的应用程序创建了一个新的person类型的对象,使用new创建并赋值给指针p。是指针 - 而不是*p- 被序列化了。Boost.Serialization自动地通过p`的引用序列化对象本身而不是对象的地址。

如果归档被恢复,p不必指向相同的地址。而是创建新对象并将它的地址赋值给pBoost.Serialization只保证对象和之前序列化的对象相同,而不是地址相同。

由于新式的C++在动态分配内存有关的地方使用智能指针(smart pointers) ,Boost.Serialization对此也提供了相应的支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/serialization/scoped_ptr.hpp>
#include <boost/scoped_ptr.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}

int age_;
};

void save()
{
boost::archive::text_oarchive oa(ss);
boost::scoped_ptr<person> p(new person(31));
oa << p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
boost::scoped_ptr<person> p;
ia >> p;
std::cout << p->age() << std::endl;
}

int main()
{
save();
load();
}


例子中使用了智能指针boost::scoped_ptr来管理动态分配的person类型的对象。为了序列化这样的指针,必须包含boost/serialization/scoped_ptr.hpp头文件。

在使用boost::shared_ptr类型的智能指针的时候需要序列化,那么必须包含boost/serialization/shared_ptr.hpp头文件。

下面的应用程序使用引用替代了指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}

int age_;
};

void save()
{
boost::archive::text_oarchive oa(ss);
person p(31);
person &pp = p;
oa << pp;
}

void load()
{
boost::archive::text_iarchive ia(ss);
person p;
person &pp = p;
ia >> pp;
std::cout << pp.age() << std::endl;
}

int main()
{
save();
load();
}


可见,Boost.Serialization还能没有任何问题地序列化引用。就像指针一样,引用对象被自动地序列化。

11.4. 对象类层次结构的序列化

为了序列化基于类层次结构的对象,子类必须在serialize()函数中访问boost::serialization::base_object()。此函数确保继承自基类的属性也能正确地序列化。下面的例子演示了一个名为developer类,它继承自类person

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/serialization/string.hpp>
#include <iostream>
#include <sstream>
#include <string>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}

int age_;
};

class developer
: public person
{
public:
developer()
{
}

developer(int age, const std::string &language)
: person(age), language_(language)
{
}

std::string language() const
{
return language_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & boost::serialization::base_object<person>(*this);
ar & language_;
}

std::string language_;
};

void save()
{
boost::archive::text_oarchive oa(ss);
developer d(31, "C++");
oa << d;
}

void load()
{
boost::archive::text_iarchive ia(ss);
developer d;
ia >> d;
std::cout << d.age() << std::endl;
std::cout << d.language() << std::endl;
}

int main()
{
save();
load();
}


persondeveloper这两个类都包含有一个私有的serialize()函数,它使得基于其他类的对象能被序列化。由于developer类继承自person类,所以它的serialize()函数必须确保继承自person属性也能被序列化。

继承自基类的属性被序列化是通过在子类的serialize()函数中用boost::serialization::base_object()函数访问基类实现的。在例子中强制要求使用这个函数而不是static_cast是因为只有boost::serialization::base_object()才能保证正确地序列化。

动态创建对象的地址可以被赋值给对应的基类类型的指针。下面的例子演示了Boost.Serialization还能够正确地序列化它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/serialization/string.hpp>
#include <boost/serialization/export.hpp>
#include <iostream>
#include <sstream>
#include <string>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

virtual int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}

int age_;
};

class developer
: public person
{
public:
developer()
{
}

developer(int age, const std::string &language)
: person(age), language_(language)
{
}

std::string language() const
{
return language_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & boost::serialization::base_object<person>(*this);
ar & language_;
}

std::string language_;
};

BOOST_CLASS_EXPORT(developer)

void save()
{
boost::archive::text_oarchive oa(ss);
person *p = new developer(31, "C++");
oa << p;
delete p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
person *p;
ia >> p;
std::cout << p->age() << std::endl;
delete p;
}

int main()
{
save();
load();
}


应用程序在save()函数创建了developer类型的对象并赋值给person*类型的指针,接下来通过<<序列化。

正如在前面章节中提到的,引用对象被自动地序列化。为了让Boost.Serialization识别将要序列化的developer类型的对象,即使指针是person*类型的对象。developer类需要相应的声明。这是通过这个BOOST_CLASS_EXPORT宏实现的,它定义在boost/serialization/export.hpp文件中。因为developer这个数据类型没有指针形式的定义,所以Boost.Serialization没有这个宏就不能正确地序列化developer

如果子类对象需要通过基类的指针序列化,那么BOOST_CLASS_EXPORT宏必须要用。

由于静态注册的原因,BOOST_CLASS_EXPORT的一个缺点是可能有些注册的类最后是不需要序列化的。Boost.Serialization为这种情况提供一种解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/serialization/string.hpp>
#include <boost/serialization/export.hpp>
#include <iostream>
#include <sstream>
#include <string>

std::stringstream ss;

class person
{
public:
person()
{
}

person(int age)
: age_(age)
{
}

virtual int age() const
{
return age_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}

int age_;
};

class developer
: public person
{
public:
developer()
{
}

developer(int age, const std::string &language)
: person(age), language_(language)
{
}

std::string language() const
{
return language_;
}

private:
friend class boost::serialization::access;

template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & boost::serialization::base_object<person>(*this);
ar & language_;
}

std::string language_;
};

void save()
{
boost::archive::text_oarchive oa(ss);
oa.register_type<developer>();
person *p = new developer(31, "C++");
oa << p;
delete p;
}

void load()
{
boost::archive::text_iarchive ia(ss);
ia.register_type<developer>();
person *p;
ia >> p;
std::cout << p->age() << std::endl;
delete p;
}

int main()
{
save();
load();
}


上面的应用程序没有使用BOOST_CLASS_EXPORT宏,而是调用了register_type()`模板函数。需要注册的类型作为模板参数传入。

请注意register_type()必须在save()load()都要调用。

register_type()的优点是只有需要序列化的类才注册。比如在开发一个库时,你不知道开发人员将来要序列化哪些类。当然BOOST_CLASS_EXPORT宏用起来简单,可它却可能注册那些不需要序列化的类型。

11.5. 优化用封装函数

在理解了如何序列化对象之后,本节介绍用来优化序列化过程的封装函数。通过这个函数,对象被打上标记允许Boost.Serialization使用一些优化技术。

下面例子使用不带封装函数的Boost.Serialization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/array.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

void save()
{
boost::archive::text_oarchive oa(ss);
boost::array<int, 3> a = { 0, 1, 2 };
oa << a;
}

void load()
{
boost::archive::text_iarchive ia(ss);
boost::array<int, 3> a;
ia >> a;
std::cout << a[0] << ", " << a[1] << ", " << a[2] << std::endl;
}

int main()
{
save();
load();
}


上面的应用程序创建一个文本流22 serialization::archive 5 0 0 3 0 1 2并将其写到标准输出流中。使用封装函数boost::serialization::make_array(),输出可以缩短到22 serialization::archive 5 0 1 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <boost/archive/text_oarchive.hpp> 
#include <boost/archive/text_iarchive.hpp>
#include <boost/serialization/array.hpp>
#include <boost/array.hpp>
#include <iostream>
#include <sstream>

std::stringstream ss;

void save()
{
boost::archive::text_oarchive oa(ss);
boost::array<int, 3> a = { 0, 1, 2 };
oa << boost::serialization::make_array(a.data(), a.size());
}

void load()
{
boost::archive::text_iarchive ia(ss);
boost::array<int, 3> a;
ia >> boost::serialization::make_array(a.data(), a.size());
std::cout << a[0] << ", " << a[1] << ", " << a[2] << std::endl;
}

int main()
{
save();
load();
}


boost::serialization::make_array()函数需要地址和数组的长度。由于长度是硬编码的,所以它不需要作为boost::array类型的一部分序列化。任何时候,如果boost::arraystd::vector包含一个可以直接序列化的数组,都可以使用这个函数。其他一般需要序列化的属性不能被序列化。

另一个Boost.Serialization提供的封装函数是boost::serialization::make_binary_object()。与boost::serialization::make_array()类似,它也需要地址和长度。boost::serialization::make_binary_object()函数只是为了用来序列化没有底层结构的二进制数据,而boost::serialization::make_array()是用来序列化数组的。

第 12 章 词法分析器

12.1. 概述

词法分析器用于读取各种格式的数据,这些数据可以具有灵活但可能非常复杂的结构。关于”格式”的一个最好的例子就是C++代码。编译器的词法分析器必须理解C++的各种可能的语言结构组合,以将它们翻译为某种二进制形式。

开发词法分析器的主要问题是所分析的数据的组成结构具有大量的规则。例如,C++支持很多的语言结构,开发一个相应的词法分析器可能需要无数个 if 表达式来识别任意所能想象到的C++代码是否有效。

本章所介绍的Boost.Spirit库将词法分析器的开发放到了桌面上来。无需将明确的规则转换为代码并使用无数的if表达式来验证代码,Boost.Spirit可以使用一种被称为扩展BNF范式的东西来表示规则。通过使用这些规则,Boost.Spirit就可以对一个C++源代码进行分析。

Boost.Spirit的基本思想类似于正则表达式。即不用if表达式来查找指定模式的文本,而是将模式以正则表达式的方式指定出来。然后就可以使用象Boost.Regex这样的库来执行相应的查找国,因此开发者无需关心其中的细节。

本章将示范如何用Boost.Spirit来读入正则表达式不再适用的复杂格式。由于Boost.Spirit是一个功能非常全的库,引入了多个不同的概念,所以在本章我们将开发一个JSON格式 的简单的词法分析器。JSON是被如 Ajax 一类的应用程序用于在程序之间交换数据的格式,类似于 XML,可以在不同平台上运行。

虽然Boost.Spirit简化了词法分析器的开发,但是还没有人能够成功地基于这个库写出一个C++词法分析器。这类词法分析器的开发仍然是Boost.Spirit的一个长期目标,不过,由于C++语言的复杂性,目前还未能实现。Boost.Spirit目前还不能很好地适用于这种复杂性或二进制格式。

12.2. 扩展BNF范式

Backus-Naur范式,简称BNF,是一种精确描述规则的语言,被应用于多种技术规范。例如,众多互联网协议的许多技术规范,称为 RFC,除了文字说明以外,都包含了以BNF编写的规则。

Boost.Spirit支持扩展BNF范式(EBNF),可以用比BNF更简短的方式来指定规则。EBNF的主要优点就是简短,从而写法更简单。

请注意,EBNF有几种不同的变体,它们的语法可能有些差异。本章以及Boost.Spirit所使用的EBNF语法类似于正则表达式。

要使用 Boost.Spirit,你必须懂得EBNF。多数情况下,开发者已经知道EBNF,因此才会选择Boost.Spirit来重用以前用EBNF表示的规则。以下是对EBNF的一个简短介绍;如果需要对本章当中以及Boost.Spirit所使用的语法有一个快速的参考,请查看 W3C XML 规范,其中包含了一个 短摘要。

1
digit	=	"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"

严格地讲,EBNF以生成规则来表示规则。可以将任意数量的生成规则组合起来,描述一个特定的格式。以上格式只包含一个生成规则。它定义了一个digit是由0至9之间的任一数字组成。

digit这样的定义被称为非终结符号。以上定义中的数字 0 到 9 则被称为终结符号。这些符号不具有任意特定意义,而且很容易识别出来,因为它们是用双引号引起来的。

所有数字值是用竖直符相连的,其意义与C++中的||操作符一样:多选一。

一句话,这个生成规则指明了0至9之间的任一数字都是一个digit

1
integer	=	("+" | "-")? digit+

这个新的非终结符integer包含至少一个digit,而且可选地以一个加号或减号开头。

integer的定义用到了多个新的操作符。圆括号用于创建一个子表达式,就象它在数学中的作用。其它操作符可应用于这些子表达式。问号表示这个子表达式只能出现一次或不出现。

digit之后的加号表示相应的表达式必须出现至少一次。

这个新的生成规则定义了一个任意的正或负的整数。一个digit正好是一个数字,而一个integer则可以由多个数字组成,且可以被标记为无符号的或有符号的。因此5即是一个digit也是一个integer,而+5则只是一个integer。同样地,169-8也只是integer

通过定义和组合各种非终结符,可以创建越来越复杂的生成规则。

1
real	=	integer "." digit*

integer的定义表示的是整数,而real的定义则表示了浮点数。这个规则基于前面已定义的非终结符integerdigit,以一个句点号分隔。digit之后的星类表示点号之后的数字是可选的:可以有任意多个数字或没有数字。

浮点数如 1.2, -16.99 甚至 3. 都符合real的定义。但是,当前的定义不允许浮点数不带前导的零,如 .9。

正如本章开始的时候所提到的,接下来我们要用Boost.Spirit开发一个JSON格式的词法分析器。为此,需要用EBNF来给出JSON格式的规则。

1
2
3
4
5
6
7
object	=	"{" member ("," member)* "}"
member = string ":" value
string = '"' character* '"'
value = string | number | object | array | "true" | "false" | "null"
number = integer | real
array = "[" value ("," value)* "]"
character = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"

JSON格式基于一些包含了键值和值的成对的对象,它们被花括号括起来。其中键值是普通的字符串,而值可以是字符串、数字值、数组、其它对象或是字面值 true,false 或 null。字符串是由双引号引起来的连续字符。数字值可以是整数或浮点数。数组包含以逗号分隔的值,并以方括号括起来。

请注意,以上定义并不完整。一方面,character 的定义缺少了大写字母以及其它字符;另一方面,JSON还特别支持 Unicode 或控制字符。这些现在都可以先忽略掉,因为Boost.Spirit定义了常用的非终结符号,如字母数字字符,以减少你打字的次数。另外,稍后在代码中,字符串被定义为除双引号以外的任意字符的连续串。由于双引号用于结束一个字符串,所以其它所有字符都在字符串中使用。上述EBNF并不如此表示,因为EBNF要求定义一个包含除单个字符外的所有字符的非终结符号,应该定义一个例外来排除。

以下是使用了上述定义的JSON格式的一个例子。

1
2
3
4
5
6
7
8
{
"Boris Schäling" :
{
"Male": true,
"Programming Languages": [ "C++", "Java", "C#" ],
"Age": 31
}
}

整个对象由最外层的花括号给出,它包含了一个键-值对。键值是 “Boris Schäling”,值则是一个新的对象,包含多个键-值对。其中所有键值均为字符串,而值则分别为字面值true,一个包含几个字符串的数组,以及一个数字值。

以上所定义的EBNF规则现在就可用于通过Boost.Spirit开发一个可以读取以上JSON格式的词法分析器。

12.3. 语法

继上一节中以EBNF为JSON格式定义了相关规则后,现在要将这些规则与Boost.Spirit一起使用。Boost.Spirit实际上允许以C++代码来定义EBNF规则,方法是重载各个由EBNF使用的不同操作符。

请注意,EBNF规则需要稍作修改,才能创建出合法的C++代码。在EBNF中各个符号是以空格相连的,在C++中需要用某个操作符来连接。此外,象星号、问号和加号这些操作符,在EBNF中是置于对应的符号后面的,在C++中必须置于符号的前面,才能作为单参操作符来使用。

以下是在Boost.Spirit中为表示JSON格式,用C++代码写的EBNF规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <boost/spirit.hpp> 

structJSON_grammar
: public boost::spirit::grammar<json_grammar>
{
template <typename Scanner>
struct definition
{
boost::spirit::rule<Scanner> object, member, string, value, number, array;

definition(constJSON_grammar &self)
{
using namespace boost::spirit;
object = "{" >> member >> *("," >> member) >> "}";
member = string >> ":" >> value;
string = "\"" >> *~ch_p("\"") >> "\"";
value = string | number | object | array | "true" | "false" | "null";
number = real_p;
array = "[" >> value >> *("," >> value) >> "]";
}

const boost::spirit::rule<Scanner> &start()
{
return object;
}
};
};

int main()
{
}

为了使用Boost.Spirit中的各个类,需要包含头文件boost/spirit.hpp。所有类均位于名字空间boost::spirit内。

为了用Boost.Spirit创建一个词法分析器,除了那些定义了数据是如何构成的规则以外,还必须创建一个所谓的语法。在上例中,就创建一个JSON_grammar类,它派生自模板类boost::spirit::grammar,并以该类的名字来实例化。json_grammar定义了理解JSON格式所需的完整语法。

语法的一个最重要的组成部分就是正确读入结构化数据的规则。这些规则在一个名为definition的内层类中定义 - 这个名字是强制性的。这个类是带有一个模板参数的模板类,由Boost.Spirit以一个所谓的扫描器来进行实例化。扫描器是Boost.Spirit内部使用的一个概念。虽然强制要求definition必须是以一个扫描器类型作为其模板参数的模板类,但是对于Boost.Spirit的日常使用来说,这些扫描器是什么以及为什么要定义它们,并不重要。

definition必须定义一个名为start()的方法,它会被Boost.Spirit调用,以获得该语法的完整规则和标准。这个方法的返回值是boost::spirit::rule的一个常量引用,它也是一个以扫描器类型实例化的模板类。

boost::spirit::rule类用于定义规则。非终结符号就以这个类来定义。前面所定义的非终结符号objectmemberstringvaluenumberarray的类型均为boost::spirit::rule

所有这些对象都被定义为definition类的属性,这并非强制性的,但简化了定义,尤其是当各个规则之间有递归引用时。正如在上一节中所看到的EBNF例子那样,递归引用并不是一个问题。

乍一看,在definition的构造函数内的规则定义非常类似于在上一节中看到的EBNF生成规则。这并不奇怪,因为这正是Boost.Spirit的目标:重用在EBNF中定义的生成规则。

由于是用C++代码来组成EBNF中建立的规则,为了写出合法的C++,其实是有一点点差异的。例如,所有符号间的连接是通过>>操作符完成的。EBNF中的一些操作符,如星号,被置于相应符号的前面而非后面。尽管有这样一些语法上的修改,Boost.Spirit还是尽量在将EBNF规则转换至C++代码时不进行太多的修改。

definition的构造函数使用了由Boost.Spirit提供的两个类:boost::spirit::ch_pboost::spirit::real_p。这些以分析器形式提供的常用规则可以很方便地重用。一个例子就是boost::spirit::real_p,它可以用于保存正或负的整数或浮点数,无需定义象digitreal这样的非终结符号。

boost::spirit::ch_p可用于创建一个针对单个字符的分析器,相当于将字符置于双引号中。在上例中,boost::spirit::ch_p的使用是强制性的,因为波浪号和星号是要应用于双引号之上的。没有这个类,代码将变为*~"\"",这会被编译器拒绝为非法代码。

波浪号实际上是实现了前一节中提到的一个技巧:在双引号之前加上波浪号,可以接受除双引号以外的所有其它字符。

定义完了识别JSON格式的规则后,以下例子示范了如何使用这些规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <boost/spirit.hpp> 
#include <fstream>
#include <sstream>
#include <iostream>

structJSON_grammar
: public boost::spirit::grammar<json_grammar>
{
template <typename Scanner>
struct definition
{
boost::spirit::rule<Scanner> object, member, string, value, number, array;

definition(constJSON_grammar &self)
{
using namespace boost::spirit;
object = "{" >> member >> *("," >> member) >> "}";
member = string >> ":" >> value;
string = "\"" >> *~ch_p("\"") >> "\"";
value = string | number | object | array | "true" | "false" | "null";
number = real_p;
array = "[" >> value >> *("," >> value) >> "]";
}

const boost::spirit::rule<Scanner> &start()
{
return object;
}
};
};

int main(int argc, char *argv[])
{
std::ifstream fs(argv[1]);
std::ostringstream ss;
ss << fs.rdbuf();
std::string data = ss.str();

JSON_grammar g;
boost::spirit::parse_info<> pi = boost::spirit::parse(data.c_str(), g, boost::spirit::space_p);
if (pi.hit)
{
if (pi.full)
std::cout << "parsing all data successfully" << std::endl;
else
std::cout << "parsing data partially" << std::endl;
std::cout << pi.length << " characters parsed" << std::endl;
}
else
std::cout << "parsing failed; stopped at '" << pi.stop << "'" << std::endl;
}

Boost.Spirit提供了一个名为boost::spirit::parse()的自由函数。通过创建一个语法的实例,就会相应地创建一个词法分析器,该分析器被作为第二个参数传递给boost::spirit::parse()。第一个参数表示要进行分析的文本,而第三个参数则是一个表明在给定文本中哪些字符将被跳过的词法分析器。为了跳过空格,要将一个类型为boost::spirit::space_p的对象作为第三个参数传入。这只是表示在被捕获的数据之间 - 换句话说,就是规则中使用了>>操作符的地方 - 可以有任意数量的空格。这其中包含了制表符和换行符,令数据的格式可以更为灵活。

boost::spirit::parse()返回一个类型为boost::spirit::parse_info的对象,该对象提供了四个属性来表示文本是否被成功分析。如果文本被成功分析,则属性hit被设置为true。如果文本中的所有字符都被分析完了,最后没有剩余空格,则full也被设置为true。仅当hittrue时,length是有效的,其中保存了被成功分析的字符数量。

如果文本未能分析成功,则属性length不能被访问。此时,可以访问属性stop来获得停止分析的文本位置。如果文本被成功分析,stop也是可访问的,只不过没什么意义,因为此时它肯定是指向被分析文本之后。

12.4. 动作

到目前为止,你已经知道了如何定义一个语法,以得到一个新的词法分析器,用于识别一个给定的文本是否具有该语法的规则所规定的结构。但是此刻,数据的格式仍未被解释,因为从结构化格式如JSON中所读取的数据并没有被进一步处理。

要对由分析器识别出来的符合某个特定规则的数据进行处理,可以使用动作(action)。动作是一些与规则相关联的函数。如果词法分析器识别出某些数据符合某个特定的规则,则相关联的动作会被执行,并把识别得到的数据传入进行处理,如下例所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <boost/spirit.hpp> 
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

structJSON_grammar
: public boost::spirit::grammar<json_grammar>
{
struct print
{
void operator()(const char *begin, const char *end) const
{
std::cout << std::string(begin, end) << std::endl;
}
};

template <typename Scanner>
struct definition
{
boost::spirit::rule<Scanner> object, member, string, value, number, array;

definition(constJSON_grammar &self)
{
using namespace boost::spirit;
object = "{" >> member >> *("," >> member) >> "}";
member = string[print()] >> ":" >> value;
string = "\"" >> *~ch_p("\"") >> "\"";
value = string | number | object | array | "true" | "false" | "null";
number = real_p;
array = "[" >> value >> *("," >> value) >> "]";
}

const boost::spirit::rule<Scanner> &start()
{
return object;
}
};
};

int main(int argc, char *argv[])
{
std::ifstream fs(argv[1]);
std::ostringstream ss;
ss << fs.rdbuf();
std::string data = ss.str();

JSON_grammar g;
boost::spirit::parse_info<> pi = boost::spirit::parse(data.c_str(), g, boost::spirit::space_p);
if (pi.hit)
{
if (pi.full)
std::cout << "parsing all data successfully" << std::endl;
else
std::cout << "parsing data partially" << std::endl;
std::cout << pi.length << " characters parsed" << std::endl;
}
else
std::cout << "parsing failed; stopped at '" << pi.stop << "'" << std::endl;
}

动作被实现为函数或函数对象。如果动作需要被初始化或是要在多次执行之间维护某些状态信息,则后者更好一些。以上例子中将动作实现为函数对象。

print是一个函数对象,它将数据写出至标准输出流。当其被调用时,重载的operator()()操作符将接受一对指向数据起始点和结束点的指针,所指范围即为被执行该动作的规则所识别出来的数据。

这个例子将这个动作关联至在member之后作为第一个符号出现的非终结符号string。一个类型为print的实例被放在方括号内传递给非终结符号string。由于string表示的是JSON对象的键-值对中的键,所以每次找到一个键时,类print中的重载operator()()操作符将被调用,将该键写出到标准输出流。

我们可以定义任意数量的动作,或将它们关联至任意数量的符号。要把一个动作关联至一个字面值,必须明确给出一个词法分析器。这与在非终结符号string的定义中指定boost::spirit::ch_p类没什么不同。以下例子使用了boost::spirit::str_p类来将一个print类型的对象关联至字面值true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <boost/spirit.hpp> 
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

structJSON_grammar
: public boost::spirit::grammar<json_grammar>
{
struct print
{
void operator()(const char *begin, const char *end) const
{
std::cout << std::string(begin, end) << std::endl;
}

void operator()(const double d) const
{
std::cout << d << std::endl;
}
};

template <typename Scanner>
struct definition
{
boost::spirit::rule<Scanner> object, member, string, value, number, array;

definition(constJSON_grammar &self)
{
using namespace boost::spirit;
object = "{" >> member >> *("," >> member) >> "}";
member = string[print()] >> ":" >> value;
string = "\"" >> *~ch_p("\"") >> "\"";
value = string | number | object | array | str_p("true")[print()] | "false" | "null";
number = real_p[print()];
array = "[" >> value >> *("," >> value) >> "]";
}

const boost::spirit::rule<Scanner> &start()
{
return object;
}
};
};

int main(int argc, char *argv[])
{
std::ifstream fs(argv[1]);
std::ostringstream ss;
ss << fs.rdbuf();
std::string data = ss.str();

JSON_grammar g;
boost::spirit::parse_info<> pi = boost::spirit::parse(data.c_str(), g, boost::spirit::space_p);
if (pi.hit)
{
if (pi.full)
std::cout << "parsing all data successfully" << std::endl;
else
std::cout << "parsing data partially" << std::endl;
std::cout << pi.length << " characters parsed" << std::endl;
}
else
std::cout << "parsing failed; stopped at '" << pi.stop << "'" << std::endl;
}

另外,这个例子还将一个动作关联至boost::spirit::real_p。大多数分析器会传递一对指向被识别数据起始点和结束点的指针,而boost::spirit::real_p则将所找到的数字作为double来传递。这样可以使对数字的处理更为方便,因为这些数字不再需要被显式转换。为了传递一个double类型的值给这个动作,我们相应地增加了一个重载的operator()()操作符给print

除了在本章中介绍过的分析器,如boost::spirit::str_pboost::spirit::real_p以外,Boost.Spirit还提供了很多其它的分析器。例如,如果要使用正则表达式,我们有boost::spirit::regex_p可用。此外,还有用于验证条件或执行循环的分析器。它们有助于创建动态的词法分析器,根据条件来对数据进行不同的处理。要对Boost.Spirit提供的这些工具有一个大概的了解,你应该看一下这个库的文档。

第 13 章 容器

13.1. 概述

这一章将会介绍许多我们在C++标准中已经很熟悉的容器的 Boost 版本。在这一章里,我们会对Boost.Unordered的用法有一定的了解 (这个容器已经在 TR1 里被加入到了C++标准);我们将会学习如何定义一个Boost.MultiIndex;我们还会了解何时应该使用MuitiIndex的一个特殊的扩展 —— Boost.Bimap。接下来,我们会向你介绍第一个容器 ——Boost.Array,通过使用它,你可以把C++标准里普通的数组以容器的形式实现。

13.2. Boost.Array

Boost.Arrayboost/array.hpp中定义了一个模板类boost::array。通过使用这个类,你可以创建一个跟C++里传统的数组有着相同属性的容器。而且,boost::array还满足了C++中容器的一切需求,因此,你可以像操作容器一样方便的操作这个array。基本上,你可以把boost::array当成std::vector来使用,只不过boost::array是定长的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <boost/array.hpp> 
#include <iostream>
#include <string>
#include <algorithm>

int main()
{
typedef boost::array<std::string, 3> array;
array a;

a[0] = "Boris";
a.at(1) = "Anton";
*a.rbegin() = "Caesar";

std::sort(a.begin(), a.end());

for (array::const_iterator it = a.begin(); it != a.end(); ++it)
std::cout << *it << std::endl;

std::cout << a.size() << std::endl;
std::cout << a.max_size() << std::endl;
}

就像我们在上面的例子看到的那样,boost::array简直就是简单的不需要任何多余的解释,因为所有操作都跟std::vector是一样的。

在下面的例子里,我们会见识到Boost.Array的一个特性。

1
2
3
4
5
6
7
8
#include <boost/array.hpp> 
#include <string>

int main()
{
typedef boost::array<std::string, 3> array;
array a = { "Boris", "Anton", "Caesar" };
}

一个boost::array类型的数组可以使用传统C++数组的初始化方式来初始化。

既然这个容器已经在 TR1 中加入到了C++标准,它同样可以通过std::array来访问到。他被定义在头文件array中,使用它的前提是你正在使用一个支持 TR1 的C++标准的库。

13.3. Boost.Unordered

Boost.Unordered在C++标准容器std::setstd::multisetstd::mapstd::multimap的基础上多实现了四个容器:boost::unordered_setboost::unordered_multisetboost::unordered_mapboost::unordered_multimap。那些名字很相似的容器之间并没有什么不同,甚至还提供了相同的接口。在很多情况下,替换这两种容器 (stdboost) 对你的应用不会造成任何影响。

Boost.Unordered和C++标准里的容器的不同之处在于——Boost.Unordered不要求其中的元素是可排序的,因为它不会做出排序操作。在排序操作无足轻重时(或是根本不需要),Boost.Unordered就很合适了。

为了能够快速的查找元素,我们需要使用Hash值。Hash值是一些可以唯一标识容器中元素的数字,它在比较时比起类似String的数据类型会更加有效率。为了计算Hash值,容器中的所有元素都必须支持对他们自己唯一ID的计算。比如std::set要求其中的元素都要是可比较的,而boost::unordered_set要求其中的元素都要可计算Hash值。尽管如此,在对排序没有需求时,你还是应该倾向使用Boost.Unordered

下面的例子展示了boost::unordered_set的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <boost/unordered_set.hpp> 
#include <iostream>
#include <string>

int main()
{
typedef boost::unordered_set<std::string> unordered_set;
unordered_set set;

set.insert("Boris");
set.insert("Anton");
set.insert("Caesar");

for (unordered_set::iterator it = set.begin(); it != set.end(); ++it)
std::cout << *it << std::endl;

std::cout << set.size() << std::endl;
std::cout << set.max_size() << std::endl;

std::cout << (set.find("David") != set.end()) << std::endl;
std::cout << set.count("Boris") << std::endl;
}

boost::unordered_set提供了与std::set相似的函数。当然,这个例子不需要多大改进就可以用std::set来实现。

下面的例子展示了如何用boost::unordered_map来存储每一个的personnameage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <boost/unordered_map.hpp> 
#include <iostream>
#include <string>

int main()
{
typedef boost::unordered_map<std::string, int> unordered_map;
unordered_map map;

map.insert(unordered_map::value_type("Boris", 31));
map.insert(unordered_map::value_type("Anton", 35));
map.insert(unordered_map::value_type("Caesar", 25));

for (unordered_map::iterator it = map.begin(); it != map.end(); ++it)
std::cout << it->first << ", " << it->second << std::endl;

std::cout << map.size() << std::endl;
std::cout << map.max_size() << std::endl;

std::cout << (map.find("David") != map.end()) << std::endl;
std::cout << map.count("Boris") << std::endl;
}

就像我们看到的,boost::unordered_mapstd::map之间并没多大区别。同样地,你可以很方便的用std::map来重新实现这个例子。

就像上面提到过的,Boost.Unordered需要其中的元素可计算Hash值。一些类似于std::string的数据类型“天生”就支持Hash值的计算。对于那些自定义的类型,你需要手动的定义Hash函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <boost/unordered_set.hpp> 
#include <string>

struct person
{
std::string name;
int age;

person(const std::string &n, int a)
: name(n), age(a)
{
}

bool operator==(const person &p) const
{
return name == p.name && age == p.age;
}
};

std::size_t hash_value(person const &p)
{
std::size_t seed = 0;
boost::hash_combine(seed, p.name);
boost::hash_combine(seed, p.age);
return seed;
}

int main()
{
typedef boost::unordered_set<person> unordered_set;
unordered_set set;

set.insert(person("Boris", 31));
set.insert(person("Anton", 35));
set.insert(person("Caesar", 25));
}

在代码中,person类型的元素被存到了boost::unordered_set中。因为boost::unordered_set中的Hash函数不能识别person类型,Hash 值就变得无法计算了。若果没有定义另一个Hash函数,你的代码将不会通过编译。

Hash函数的签名必须是:hash_value()。它接受唯一的一个参数来指明需要计算Hash值的对象的类型。因为Hash值是单纯的数字,所以函数的返回值为:std::size_t

每当一个对象需要计算它的Hash值时,hash_value()都会自动被调用。BoostC++库已经为一些数据类型定义好了Hash函数,比如:std::string。但对于像person这样的自定义类型,你就需要自己手工定义了。

hash_value()的实现往往都很简单:你只需要按顺序对其中的每个属性都调用Boostboost/functional/hash.hpp中提供的boost::hash_combine()函数就行了。当你使用Boost.Unordered时,这个头文件已经自动被包含了。

除了自定义hash_value()函数,自定义的类型还需要支持通过==运算符的比较操作。因此,person就重载了相应的operator==()操作符。

13.4. Boost.MultiIndex

Boost.MultiIndex比我们之前介绍的任何库都要复杂。不像Boost.ArrayBoost.Unordered为我们提供了可以直接使用的容器,Boost.MultiIndex让我们可以自定义新的容器。跟C++标准中的容器不同的是:一个用户自定义的容器可以对其中的数据提供多组访问接口。举例来说,你可以定义一个类似于std::map的容器,但它可以通过value值来查询。如果不用Boost.MultiIndex,你就需要自己整合两个std::map类型的容器,还要自己处理一些同步操作来确保数据的完整性。

下面这个例子就用Boost.MultiIndex定义了一个新容器来存储每个人的nameage,不像std::map,这个容器可以分别通过nameage来查询(std::map只能用一个值)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <boost/multi_index_container.hpp> 
#include <boost/multi_index/hashed_index.hpp>
#include <boost/multi_index/member.hpp>
#include <iostream>
#include <string>

struct person
{
std::string name;
int age;

person(const std::string &n, int a)
: name(n), age(a)
{
}
};

typedef boost::multi_index::multi_index_container<
person,
boost::multi_index::indexed_by<
boost::multi_index::hashed_non_unique<
boost::multi_index::member<
person, std::string, &person::name
>
>,
boost::multi_index::hashed_non_unique<
boost::multi_index::member<
person, int, &person::age
>
>
>
> person_multi;

int main()
{
person_multi persons;

persons.insert(person("Boris", 31));
persons.insert(person("Anton", 35));
persons.insert(person("Caesar", 25));

std::cout << persons.count("Boris") << std::endl;

const person_multi::nth_index<1>::type &age_index = persons.get<1>();
std::cout << age_index.count(25) << std::endl;
}

就像上面提到的,Boost.MultiIndex并没有提供任何特定的容器而是一些类来方便我们定义新的容器。典型的做法是: 你需要用到typedef来为你的新容器提供对Boost.Unordered中类的方便的访问。

每个容器定义都需要的类boost::multi_index::multi_index_container被定义在了boost/multi_index_container.hpp里。因为他是一个模板类,你需要为它传递两个模板参数。第一个参数是容器中储存的元素类型,在例子中是person;而第二个参数指明了容器所提供的所有索引类型。

基于Boost.Unordered的容器最大的优势在于:他对一组同样的数据提供了多组访问接口。访问接口的具体细节都可以在定义容器时被指定。因为例子中的personagename都提供了查询功能,我们必须要定义两组接口。

接口的定义必须借由模板类boost::multi_index::indexed_by来实现。每一个接口都作为参数传递给它。例子中定义了两个boost::multi_index::hashed_non_unique类型的接口,(定义在头文件boost/multi_index/hashed_index.hpp中)如果你希望容器像Boost.Unordered一样存储一些可以计算Hash值的元素,你就可以使用这个接口。

boost::multi_index::hashed_non_unique是一个模板类,他需要一个可计算Hash值的类型作为它的参数。因为接口需要访问person中的nameage,所以nameage都要是可计算Hash值的。

Boost.MultiIndex提供了一个辅助模板类:boost::multi_index::member(定义在boost/multi_index/member.hpp中)来访问类中的属性。就像我们在例子中所看到的,我们指定了好几个参数来让boost::multi_index::member明白可以访问person中的哪些属性以及这些属性的类型。

不得不说person_multi的定义第一眼看起来相当复杂,但这个类本身跟Boost.Unordered中的boost::unordered_map并没有什么不同,他也可以分别通过其中的两个属性nameage来查询容器。

为了访问MultiIndex容器,你必须要定义至少一个接口。如果用insert()或者count()来直接访问persons对象,第一个接口会被隐式的调用 —— 在例子中是name属性的Hash容器。如果你想用其他接口,你必须要显示的指定它。

接口都是用从0开始的索引值来编号的。想要访问第二个接口,你需要调用get()函数并且传入想要访问的接口的索引值。

函数get()的返回值看起来也有点复杂:他是一个用来访问MultiIndex容器的类nth_index,同样的,你也需要指定需要访问的接口的索引值。当然,这个值肯定跟get()函数指定的模板参数是一样的。最后一步:用::来得到nth_indextype,也就是接口的真正的type。

虽然我们并不知道细节就用nth_indextype得到了接口,我们还是需要明白这到底是什么接口。通过传给get()nth_index的索引值,我们就可以很容易得知所使用的哪一个接口了。例子中的age_index就是一个通过age来访问的Hash容器。

既然MultiIndex容器中的namekey作为了接口访问的键值,他们都不能再被更改了。比如一个personage在通过name搜索以后被改变了,使用age作为键值的接口却意识不到这种更改,因此,你需要重新计算Hash值才行。

就像std::map一样,MultiIndex容器中的值也不允许被修改。严格的说,所有存储在MultiIndex中的元素都该是常量。为了避免删除或修改其中元素真正的值,Boost.MultiIndex提供了一些常用函数来操作其中的元素。使用这些函数来操作MultiIndex容器中的值并不会引起那些元素所指向的真正的对象改变,所以更新动作是安全的。而且所有接口都会被通知这种改变,然后去重新计算新的Hash值等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <boost/multi_index_container.hpp> 
#include <boost/multi_index/hashed_index.hpp>
#include <boost/multi_index/member.hpp>
#include <iostream>
#include <string>

struct person
{
std::string name;
int age;

person(const std::string &n, int a)
: name(n), age(a)
{
}
};

typedef boost::multi_index::multi_index_container<
person,
boost::multi_index::indexed_by<
boost::multi_index::hashed_non_unique<
boost::multi_index::member<
person, std::string, &person::name
>
>,
boost::multi_index::hashed_non_unique<
boost::multi_index::member<
person, int, &person::age
>
>
>
> person_multi;

void set_age(person &p)
{
p.age = 32;
}

int main()
{
person_multi persons;

persons.insert(person("Boris", 31));
persons.insert(person("Anton", 35));
persons.insert(person("Caesar", 25));

person_multi::iterator it = persons.find("Boris");
persons.modify(it, set_age);

const person_multi::nth_index<1>::type &age_index = persons.get<1>();
std::cout << age_index.count(32) << std::endl;
}

每个Boost.Unordered中的接口都支持modify()函数来提供直接对容器本身的操作。它的第一个参数是一个需要更改对象的迭代器;第二参数则是一个对该对象进行操作的函数。在例子中,对应的两个参数则是:personset_age()

至此,我们都还只介绍了一个接口:boost::multi_index::hashed_non_unique,他会计算其中元素的Hash值,但并不要求是唯一的。为了确保容器中存储的值是唯一的,你可以使用boost::multi_index::hashed_unique接口。请注意:所有要被存入容器中的值都必须满足它的接口的限定。只要一个接口限定了容器中的值必须是唯一的,那其他接口都不会对该限定造成影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <boost/multi_index_container.hpp> 
#include <boost/multi_index/hashed_index.hpp>
#include <boost/multi_index/member.hpp>
#include <iostream>
#include <string>

struct person
{
std::string name;
int age;

person(const std::string &n, int a)
: name(n), age(a)
{
}
};

typedef boost::multi_index::multi_index_container<
person,
boost::multi_index::indexed_by<
boost::multi_index::hashed_non_unique<
boost::multi_index::member<
person, std::string, &person::name
>
>,
boost::multi_index::hashed_unique<
boost::multi_index::member<
person, int, &person::age
>
>
>
> person_multi;

int main()
{
person_multi persons;

persons.insert(person("Boris", 31));
persons.insert(person("Anton", 31));
persons.insert(person("Caesar", 25));

const person_multi::nth_index<1>::type &age_index = persons.get<1>();
std::cout << age_index.count(31) << std::endl;
}

上例中的容器现在使用了boost::multi_index::hashed_unique来作为他的第二个接口,因此他不允许其中有两个同ageperson存在。

上面的代码尝试存储一个与Boris同age的Anton,因为这个动作违反了容器第二个接口的限定,它(Anton)将不会被存入到容器中。因此,程序将会输出:1而不是2。

接下来的例子向我们展示了Boost.Unordered中剩下的三个接口:boost::multi_index::sequencedboost::multi_index::ordered_non_uniqueboost::multi_index::random_access

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <boost/multi_index_container.hpp> 
#include <boost/multi_index/sequenced_index.hpp>
#include <boost/multi_index/ordered_index.hpp>
#include <boost/multi_index/random_access_index.hpp>
#include <boost/multi_index/member.hpp>
#include <iostream>
#include <string>

struct person
{
std::string name;
int age;

person(const std::string &n, int a)
: name(n), age(a)
{
}
};

typedef boost::multi_index::multi_index_container<
person,
boost::multi_index::indexed_by<
boost::multi_index::sequenced<>,
boost::multi_index::ordered_non_unique<
boost::multi_index::member<
person, int, &person::age
>
>,
boost::multi_index::random_access<>
>
> person_multi;

int main()
{
person_multi persons;

persons.push_back(person("Boris", 31));
persons.push_back(person("Anton", 31));
persons.push_back(person("Caesar", 25));

const person_multi::nth_index<1>::type &ordered_index = persons.get<1>();
person_multi::nth_index<1>::type::iterator lower = ordered_index.lower_bound(30);
person_multi::nth_index<1>::type::iterator upper = ordered_index.upper_bound(40);
for (; lower != upper; ++lower)
std::cout << lower->name << std::endl;

const person_multi::nth_index<2>::type &random_access_index = persons.get<2>();
std::cout << random_access_index[2].name << std::endl;
}

boost::multi_index::sequenced接口让我们可以像使用std::list一样的使用MultiIndex。这个接口定义起来十分容易:你不用为它传递任何模板参数。person类型的对象在容器中就是像list一样按照加入的顺序来排列的。

而通过使用boost::multi_index::ordered_non_unique接口,容器中的对象会自动的排序。你在定义容器时就必须指定接口的排序规则。示例中的对象person就是以age来排序的,它借助了辅助类boost::multi_index::member来实现这一功能。

boost::multi_index::ordered_non_unique为我们提供了一些特别的函数来查找特定范围的数据。通过使用lower_bound()upper_bound(),示例实现了对所有30岁至40岁的person的查询。要注意因为容器中的数据是有序的,所以才提供了这些函数,其他接口中并不提供这些函数。

最后一个接口是:boost::multi_index::random_access,他让我们可以像使用std::vector一样使用MultiIndex容器。你又可以使用你熟悉的operator[]()at()操作了。

请注意boost::multi_index::random_access已经被完整的包含在了boost::multi_index::sequenced接口中。所以当你使用boost::multi_index::random_access的时候,你也可以使用boost::multi_index::sequenced接口中的所有函数。

在介绍完Boost.Unordered剩下的4个接口后,本章剩下的部分将向你介绍所谓的“键值提取器”(key extractors)。目前为止,我们已经见过一个在boost/multi_index/member.hpp定义的键值提取器了——boost::multi_index::member。这个辅助函数的得名源自它可以显示的声明类中的哪些属性会作为接口中的键值使用。

接下来的例子介绍了另外两个键值提取器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <boost/multi_index_container.hpp> 
#include <boost/multi_index/ordered_index.hpp>
#include <boost/multi_index/hashed_index.hpp>
#include <boost/multi_index/identity.hpp>
#include <boost/multi_index/mem_fun.hpp>
#include <iostream>
#include <string>

class person
{
public:
person(const std::string &n, int a)
: name(n), age(a)
{
}

bool operator<(const person &p) const
{
return age < p.age;
}

std::string get_name() const
{
return name;
}

private:
std::string name;
int age;
};

typedef boost::multi_index::multi_index_container<
person,
boost::multi_index::indexed_by<
boost::multi_index::ordered_unique<
boost::multi_index::identity<person>
>,
boost::multi_index::hashed_unique<
boost::multi_index::const_mem_fun<
person, std::string, &person::get_name
>
>
>
> person_multi;

int main()
{
person_multi persons;

persons.insert(person("Boris", 31));
persons.insert(person("Anton", 31));
persons.insert(person("Caesar", 25));

std::cout << persons.begin()->get_name() << std::endl;

const person_multi::nth_index<1>::type &hashed_index = persons.get<1>();
std::cout << hashed_index.count("Boris") << std::endl;
}

键值提取器boost::multi_index::identity(定义在boost/multi_index/identity.hpp中)可以使用容器中的数据类型作为键值。示例中,就需要 person 类是可排序的,因为它已经作为了接口boost::multi_index::ordered_unique的键值。在示例里,它是通过重载operator<()操作符来实现的。

头文件boost/multi_index/mem_fun.hpp定义了两个可以把函数返回值作为键值的键值提取器:boost::multi_index::const_mem_funboost::multi_index::mem_fun。在示例程序中,就是用到了get_name()的返回值作为键值。显而易见的,boost::multi_index::const_mem_fun适用于返回常量的函数,而boost::multi_index::mem_fun适用于返回非常量的函数。

Boost.MultiIndex还提供了两个键值提取器:boost::multi_index::global_funboost::multi_index::composite_key。前一个适用于独立的函数或者静态函数,后一个允许你将几个键值提取器组合成一个新的的键值提取器。

13.5. Boost.Bimap

Boost.Bimap库提供了一个建立在Boost.Unordered之上但不需要预先定义就可以使用的容器。这个容器十分类似于std::map,但他不仅可以通过key搜索,还可以用value来搜索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/bimap.hpp> 
#include <iostream>
#include <string>

int main()
{
typedef boost::bimap<std::string, int> bimap;
bimap persons;

persons.insert(bimap::value_type("Boris", 31));
persons.insert(bimap::value_type("Anton", 31));
persons.insert(bimap::value_type("Caesar", 25));

std::cout << persons.left.count("Boris") << std::endl;
std::cout << persons.right.count(31) << std::endl;
}

boost/bimap.hpp中定义的boost::bimap为我们提供了两个属性:leftright来访问在boost::bimap统一的两个std::map类型的容器。在例子中,leftstd::string类型的key来访问容器,而right用到了int类型的key

除了支持用leftright对容器中的记录进行单独的访问,boost::bimap还允许像下面的例子一样展示记录间的关联关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/bimap.hpp> 
#include <iostream>
#include <string>

int main()
{
typedef boost::bimap<std::string, int> bimap;
bimap persons;

persons.insert(bimap::value_type("Boris", 31));
persons.insert(bimap::value_type("Anton", 31));
persons.insert(bimap::value_type("Caesar", 25));

for (bimap::iterator it = persons.begin(); it != persons.end(); ++it)
std::cout << it->left << " is " << it->right << " years old." << std::endl;
}

对一个记录访问时,leftright并不是必须的。你也可以使用迭代器来访问每个记录中的leftright容器。

std::mapstd::multimap组合让你觉得似乎可以存储多个具有相同key值的记录,但boost::bimap并没有这样做。但这并不代表在boost::bimap存储两个具有相同key值的记录是不可能的。严格来说,那两个模板参数并不会对leftright的容器类型做出具体的规定。如果像例子中那样并没有指定容器类型时,boost::bimaps::set_of类型会缺省的使用。跟std::map一样,它要求记录有唯一的key值。

第一个boost::bimap例子也可以像下面这样写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/bimap.hpp> 
#include <iostream>
#include <string>

int main()
{
typedef boost::bimap<boost::bimaps::set_of<std::string>, boost::bimaps::set_of<int>> bimap;
bimap persons;

persons.insert(bimap::value_type("Boris", 31));
persons.insert(bimap::value_type("Anton", 31));
persons.insert(bimap::value_type("Caesar", 25));

std::cout << persons.left.count("Boris") << std::endl;
std::cout << persons.right.count(31) << std::endl;
}

除了boost::bimaps::set_of,你还可以用一些其他的容器类型来定制你的boost::bimap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <boost/bimap.hpp> 
#include <boost/bimap/multiset_of.hpp>
#include <iostream>
#include <string>

int main()
{
typedef boost::bimap<boost::bimaps::set_of<std::string>, boost::bimaps::multiset_of<int>> bimap;
bimap persons;

persons.insert(bimap::value_type("Boris", 31));
persons.insert(bimap::value_type("Anton", 31));
persons.insert(bimap::value_type("Caesar", 25));

std::cout << persons.left.count("Boris") << std::endl;
std::cout << persons.right.count(31) << std::endl;
}

代码中的容器使用了定义在boost/bimap/multiset_of.hpp中的boost::bimaps::multiset_of。这个容器的操作和boost::bimaps::set_of差不了多少,只是它不再要求key值是唯一的。因此,上面的例子将会在计算age为31的person数时输出:2。

既然boost::bimaps::set_of会在定义boost::bimap被缺省的使用,你没必要再显示的包含头文件:boost/bimap/set_of.hpp。但在使用其它类型的容器时,你就必须要显示的包含一些相应的头文件了。

Boost.Bimap还提供了类:boost::bimaps::unordered_set_ofboost::bimaps::unordered_multiset_ofboost::bimaps::list_ofboost::bimaps::vector_ofboost::bimaps::unconstrainted_set_of以供使用。除了boost::bimaps::unconstrainted_set_of,剩下的所有容器类型的使用方法都和他们在C++标准里的版本一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <boost/bimap.hpp> 
#include <boost/bimap/unconstrained_set_of.hpp>
#include <boost/bimap/support/lambda.hpp>
#include <iostream>
#include <string>

int main()
{
typedef boost::bimap<std::string, boost::bimaps::unconstrained_set_of<int>> bimap;
bimap persons;

persons.insert(bimap::value_type("Boris", 31));
persons.insert(bimap::value_type("Anton", 31));
persons.insert(bimap::value_type("Caesar", 25));

bimap::left_map::iterator it = persons.left.find("Boris");
persons.left.modify_key(it, boost::bimaps::_key = "Doris");

std::cout << it->first << std::endl;
}

boost::bimaps::unconstrainted_set_of可以使boost::bimapright(也就是age)值无法用来查找person。在这种特定的情况下,boost::bimap可以被视为是一个std::map类型的容器。

虽然如此,例子还是向我们展示了boost::bimap对于std::map的优越性。因为Boost.Bimap是基于Boost.Unordered的,你当然可以使用Boost.Unordered提供的所有函数。例子中就用modify_key()修改了key值,这在std::map中是不可能的。

请注意修改key值的以下细节:key通过boost::bimaps::_key函数赋予了新值,而boost::bimaps::_key是一个定义在boost/bimap/support/lambda.hpp中的lambda函数。有关lambda函数,详见:第3章函数对象。

boost/bimap/support/lambda.hpp还定义了boost::bimaps::_data。函数modify_data()可以用来修改boost::bimap中的value值。

第 14 章 数据结构

14.1. 概述

在BoostC++库中,把一些类型定义为container显得不太合适,所以就并没有放在第13章容器里。而把他们放在本章就比较合适了。举例来说,boost::tuple就扩展了C++的数据类型std::pair用以储存多个而不只是两个值。

除了boost::tuple,这一章还涵盖了类boost::anyboost::variant以储存那些不确定类型的值。其中boost::any类型的变量使用起来就像弱类型语言中的变量一样灵活。另一方面,boost::variant类型的变量可以储存一些预定义的数据类型,就像我们用union时候一样。

14.2. 元组

Boost.Tuple库提供了一个更一般的版本的std::pair——boost::tuple。不过std::pair只能储存两个值而已,boost::tuple则给了我们更多的选择。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_io.hpp>
#include <string>
#include <iostream>

int main()
{
typedef boost::tuple<std::string, std::string> person;
person p("Boris", "Schaeling");
std::cout << p << std::endl;
}

为了使用boost::tuple,你必须要包含头文件:boost/tuple/tuple.hpp。若想要让元组和流一起使用,你还需要包含头文件:boost/tuple/tuple_io.hpp才行。

其实,boost::tuple的用法基本上和std::pair一样。就像我们在上面的例子里看到的那样,两个值类型的std::string通过两个相应的模板参数存储在了元组里。

当然person类型也可以用std::pair来实现。所有boost::tuple类型的对象都可以被写入流里。再次强调,为了使用流操作和各种流操作运算符,你必须要包含头文件:boost/tuple/tuple_io.hpp。显然,我们的例子会输出:(Boris Schaeling)

boost::tuplestd::pair之间最重要的一点不同点:元组可以存储无限多个值!

1
2
3
4
5
6
7
8
9
10
11
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_io.hpp>
#include <string>
#include <iostream>

int main()
{
typedef boost::tuple<std::string, std::string, int> person;
person p("Boris", "Schaeling", 43);
std::cout << p << std::endl;
}

我们修改了实例,现在的元组里不仅储存了一个人的firstname和lastname,还加上了他的鞋子的尺码。现在,我们的例子将会输出:(Boris Schaeling 43)

就像std::pair有辅助函数std::make_pair()一样,一个元组也可以用它的辅助函数boost::make_tuple()来创建。

1
2
3
4
5
6
7
8
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_io.hpp>
#include <iostream>

int main()
{
std::cout << boost::make_tuple("Boris", "Schaeling", 43) << std::endl;
}

就像下面的例子所演示的那样,一个元组也可以存储引用类型的值。
1
2
3
4
5
6
7
8
9
10
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_io.hpp>
#include <string>
#include <iostream>

int main()
{
std::string s = "Boris";
std::cout << boost::make_tuple(boost::ref(s), "Schaeling", 43) << std::endl;
}

因为Schaeling43是按值传递的,所以就直接存储在了元组中。与他们不同的是:person的第一个元素是一个指向s的引用。Boost.Ref中的boost::ref()就是用来创建这样的引用的。相对的,要创建一个常量的引用的时候,你需要使用boost::cref()

在学习了创建元组的方法之后,让我们来了解一下访问元组中元素的方式。std::pair只包含两个元素,故可以使用属性firstsecond来访问其中的元素。但元组可以包含无限多个元素,显然,我们需要用另一种方式来解决访问的问题。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/tuple/tuple.hpp> 
#include <string>
#include <iostream>

int main()
{
typedef boost::tuple<std::string, std::string, int> person;
person p = boost::make_tuple("Boris", "Schaeling", 43);
std::cout << p.get<0>() << std::endl;
std::cout << boost::get<0>(p) << std::endl;
}

我们可以用两种方式来访问元组中的元素:使用成员函数get(),或者将元组传给一个独立的函数boost::get()。使用这两种方式时,元素的索引值都是通过模板参数来指定的。例子中就分别使用了这两种方式来访问p中的第一个元素。因此,Boris会被输出两次。

另外,对于索引值合法性的检查会在编译期执行,故访问非法的索引值会引起编译期错误而不是运行时的错误。

对于元组中元素的修改,你同样可以使用get()boost::get()函数。

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_io.hpp>
#include <string>
#include <iostream>

int main()
{
typedef boost::tuple<std::string, std::string, int> person;
person p = boost::make_tuple("Boris", "Schaeling", 43);
p.get<1>() = "Becker";
std::cout << p << std::endl;
}

get()boost::get()都会返回一个引用值。例子中修改了lastname之后将会输出:(Boris Becker 43)

Boost.Tuple除了重载了流操作运算符以外,还为我们提供了比较运算符。为了使用它们,你必须要包含相应的头文件:boost/tuple/tuple_comparison.hpp

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_comparison.hpp>
#include <string>
#include <iostream>

int main()
{
typedef boost::tuple<std::string, std::string, int> person;
person p1 = boost::make_tuple("Boris", "Schaeling", 43);
person p2 = boost::make_tuple("Boris", "Becker", 43);
std::cout << (p1 != p2) << std::endl;
}

上面的例子将会输出1因为两个元组p1p2是不同的。

同时,头文件boost/tuple/tuple_comparison.hpp还定义了一些其他的比较操作,比如用来做字典序比较的大于操作等。

Boost.Tuple还提供了一种叫做Tier的特殊元组。Tier的特殊之处在于它包含的所有元素都是引用类型的。它可以通过构造函数boost::tie()来创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_io.hpp>
#include <string>
#include <iostream>

int main()
{
typedef boost::tuple<std::string&, std::string&, int&> person;

std::string firstname = "Boris";
std::string surname = "Schaeling";
int shoesize = 43;
person p = boost::tie(firstname, surname, shoesize);
surname = "Becker";
std::cout << p << std::endl;
}

上面的例子创建了一个tier p,他包含了三个分别指向firstnamesurnameshoesize的引用值。在修改变量surname的同时,tier也会跟着改变。

就像下面的例子展示的那样,你当然可以用boost::make_tuple()boost::ref()来代替构造函数boost::tie()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/tuple/tuple.hpp> 
#include <boost/tuple/tuple_io.hpp>
#include <string>
#include <iostream>

int main()
{
typedef boost::tuple<std::string&, std::string&, int&> person;

std::string firstname = "Boris";
std::string surname = "Schaeling";
int shoesize = 43;
person p = boost::make_tuple(boost::ref(firstname), boost::ref(surname), boost::ref(shoesize));
surname = "Becker";
std::cout << p << std::endl;
}

boost::tie()在一定程度上简化了语法,同时,也可以用作“拆箱”元组。在接下来的这个例子里,元组中的各个元素就被很方便的“拆箱”并直接赋给了其他变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <boost/tuple/tuple.hpp> 
#include <string>
#include <iostream>

boost::tuple<std::string, int> func()
{
return boost::make_tuple("Error message", 2009);
}

int main()
{
std::string errmsg;
int errcode;

boost::tie(errmsg, errcode) = func();
std::cout << errmsg << ": " << errcode << std::endl;
}

通过使用boost::tie(),元组中的元素:字符串“Error massage”和错误代码“2009”就很方便地经func()的返回值直接赋给了errmsgerrcode

14.3. Boost.Any

像C++这样的强类型语言要求给每个变量一个确定的类型。而以 JavaScript 为代表的弱类型语言却不这样做,弱类型的每个变量都可以存储数组、布尔值、或者是字符串。

Boost.Any给我们提供了boost::any类,让我们可以在C++中像 JavaScript 一样的使用弱类型的变量。

1
2
3
4
5
6
7
8
#include <boost/any.hpp> 

int main()
{
boost::any a = 1;
a = 3.14;
a = true;
}

为了使用boost::any,你必须要包含头文件:boost/any.hpp。接下来,你就可以定义和使用boost::any的对象了。

需要注明的是:boost::any并不能真的存储任意类型的值;Boost.Any需要一些特定的前提条件才能工作。任何想要存储在boost::any中的值,都必须是可拷贝构造的。因此,想要在boost::any存储一个字符串类型的值,就必须要用到std::string,就像在下面那个例子中做的一样。

1
2
3
4
5
6
7
8
9
10
#include <boost/any.hpp> 
#include <string>

int main()
{
boost::any a = 1;
a = 3.14;
a = true;
a = std::string("Hello, world!");
}

如果你企图把字符串 “Hello, world!” 直接赋给a,你的编译器就会报错,因为由基类型char构成的字符串在C++中并不是可拷贝构造的。

想要访问boost::any中具体的内容,你必须要使用转型操作:boost::any_cast

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/any.hpp> 
#include <iostream>

int main()
{
boost::any a = 1;
std::cout << boost::any_cast<int>(a) << std::endl;
a = 3.14;
std::cout << boost::any_cast<double>(a) << std::endl;
a = true;
std::cout << boost::any_cast<bool>(a) << std::endl;
}

通过由模板参数传入boost::any_cast的值,变量会被转化成相应的类型。一旦你指定了一种非法的类型,该操作会抛出boost::bad_any_cast类型的异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <boost/any.hpp> 
#include <iostream>

int main()
{
try
{
boost::any a = 1;
std::cout << boost::any_cast<float>(a) << std::endl;
}
catch (boost::bad_any_cast &e)
{
std::cerr << e.what() << std::endl;
}
}

上面的例子就抛出了一个异常,因为float并不能匹配原本存储在a中的int类型。记住,在任何情况下都保证boost::any中的类型匹配是很重要的。在没有通过模板参数指定shortlong类型时,同样会有异常抛出。

既然boost::bad_any_cast继承自std::bad_cast,catch当然也可以捕获相应类型的异常。

想要检查boost::any是否为空,你可以使用empty()函数。想要确定其中具体的类型信息,你可以使用type()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/any.hpp> 
#include <typeinfo>
#include <iostream>

int main()
{
boost::any a = 1;
if (!a.empty())
{
const std::type_info &ti = a.type();
std::cout << ti.name() << std::endl;
}
}

上面的例子同时用到了empty()type()函数。empty()将会返回一个布尔值,而type()则会返回一个在typeinfo中定义的std::type_info值。

作为对这一节的总结,最后一个例子会向你展示怎样用boost::any_cast来定义一个指向boost::any中内容的指针。

1
2
3
4
5
6
7
8
9
#include <boost/any.hpp> 
#include <iostream>

int main()
{
boost::any a = 1;
int *i = boost::any_cast<int>(&a);
std::cout << *i << std::endl;
}

你需要做的就是传递一个boost::any类型的指针,作为boost::any_cast的参数;模板参数却没有任何改动。

14.4. Boost.Variant

Boost.VariantBoost.Any之间的不同点在于Boost.Any可以被视为任意的类型,而Boost.Variant只能被视为固定数量的类型。让我们来看下面这个例子。

1
2
3
4
5
6
7
8
#include <boost/variant.hpp> 

int main()
{
boost::variant<double, char> v;
v = 3.14;
v = 'A';
}

Boost.Variant为我们提供了一个定义在boost/variant.hpp中的类:boost::variant。既然boost::variant是一个模板,你必须要指定至少一个参数。Variant所存储的数据类型就由这些参数来指定。上面的例子就给v指定了double类型和char类型。注意,一旦你将一个int值赋给了v,你的代码将不会编译通过。

当然,上面的例子也可以用一个union类型来实现,但是与union不同的是:boost::variant可以储存像std::string这样的class类型的数据。

1
2
3
4
5
6
7
8
9
10
#include <boost/variant.hpp> 
#include <string>

int main()
{
boost::variant<double, char, std::string> v;
v = 3.14;
v = 'A';
v = "Hello, world!";
}

要访问v中的数据,你可以使用独立的boost::get()函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/variant.hpp> 
#include <string>
#include <iostream>

int main()
{
boost::variant<double, char, std::string> v;
v = 3.14;
std::cout << boost::get<double>(v) << std::endl;
v = 'A';
std::cout << boost::get<char>(v) << std::endl;
v = "Hello, world!";
std::cout << boost::get<std::string>(v) << std::endl;
}

boost::get()需要传入一个模板参数来指明你需要返回的数据类型。若是指定了一个非法的类型,你会遇到一个运行时而不是编译期的错误。

所有boost::variant类型的值都可以被直接写入标准输入流这样的流中,这可以在一定程度上让你避开运行时错误的风险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <boost/variant.hpp> 
#include <string>
#include <iostream>

int main()
{
boost::variant<double, char, std::string> v;
v = 3.14;
std::cout << v << std::endl;
v = 'A';
std::cout << v << std::endl;
v = "Hello, world!";
std::cout << v << std::endl;
}

想要分别处理各种不同类型的数据,Boost.Variant为我们提供了一个名为boost::apply_visitor()的函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <boost/variant.hpp> 
#include <boost/any.hpp>
#include <vector>
#include <string>
#include <iostream>

std::vector<boost::any> vector;

struct output :
public boost::static_visitor<>
{
void operator()(double &d) const
{
vector.push_back(d);
}

void operator()(char &c) const
{
vector.push_back(c);
}

void operator()(std::string &s) const
{
vector.push_back(s);
}
};

int main()
{
boost::variant<double, char, std::string> v;
v = 3.14;
boost::apply_visitor(output(), v);
v = 'A';
boost::apply_visitor(output(), v);
v = "Hello, world!";
boost::apply_visitor(output(), v);
}

boost::apply_visitor()第一个参数需要传入一个继承自boost::static_visitor类型的对象。这个类必须要重载operator()()运算符来处理boost::variant每个可能的类型。相应的,例子中的v就重载了三次operator()来处理三种可能的类型:doublecharstd::string

再仔细看代码,不难发现boost::static_visitor是一个模板。那么,当operator()()有返回值的时候,就必须返回一个模板才行。如果operator()像例子那样没有返回值时,你就不需要模板了。

boost::apply_visitor()的第二个参数是一个boost::variant类型的值。

在使用时,boost::apply_visitor()会自动调用跟第二个参数匹配的operator()()。示例程序中的boost::apply_visitor()就自动调用了三个不同的operator第一个是double类型的,第二个是char最后一个是std::string

boost::apply_visitor()的优点不只是“自动调用匹配的函数”这一点。更有用的是,boost::apply_visitor()会确认是否boost::variant中的每个可能值都定义了相应的函数。如果你忘记重载了任何一个函数,代码都不会编译通过。

当然,如果对每种类型的操作都是一样的,你也可以像下面的示例一样使用一个模板来简化你的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <boost/variant.hpp> 
#include <boost/any.hpp>
#include <vector>
#include <string>
#include <iostream>

std::vector<boost::any> vector;

struct output :
public boost::static_visitor<>
{
template <typename T>
void operator()(T &t) const
{
vector.push_back(t);
}
};

int main()
{
boost::variant<double, char, std::string> v;
v = 3.14;
boost::apply_visitor(output(), v);
v = 'A';
boost::apply_visitor(output(), v);
v = "Hello, world!";
boost::apply_visitor(output(), v);
}

既然boost::apply_visitor()可以在编译期确定代码的正确性,你就该更多的使用它而不是boost::get()

第 15 章 错误处理

15.1. 概述

在执行时会有潜在失败可能的每个函数都需要一种合适的方式和它的调用者进行交互。在C++中,这一步是通过返回值或抛出一个异常来完成的。作为常识,返回值经常用在处理非错误的异常中。调用者通过返回值作出相应的反馈。

异常被通常用来标示出未预期的异常情况。一个很好的例子是在错误的使用new时将抛出的一个动态内存分配异常类型std::bad_alloc。由于内存的分配通常不会出现任何问题,如果总是检查返回值将会变得异常累赘。

本章介绍了两种可以帮助开发者利用错误处理的Boost C++库:其中Boost.System可以由特定操作系统平台的错误代码转换出跨平台的错误代码。借助于Boost.System,函数基于某个特定操作系统的返回值类型可以被转换成为跨平台的类型。另外,Boost.Exception允许给任何异常添加额外的信息,以便利用catch相应的处理程序更好的对异常作出反应。

15.2. Boost.System

Boost.System是一个定义了四个类的小型库,用以识别错误。boost::system::error_code是一个最基本的类,用于代表某个特定操作系统的异常。由于操作系统通常枚举异常,boost::system::error_code中以变量的形式保存错误代码int。下面的例子说明了如何通过访问Boost.Asio类来使用这个类。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/system/error_code.hpp> 
#include <boost/asio.hpp>
#include <iostream>
#include <string>

int main()
{
boost::system::error_code ec;
std::string hostname = boost::asio::ip::host_name(ec);
std::cout << ec.value() << std::endl;
}

Boost.Asio提供了独立的函数boost::asio::ip::host_name()可以返回正在执行的应用程序名。

boost::system::error_code类型的一个对象可以作为单独的参数传递给boost::asio::ip::host_name()。如果当前的操作系统函数失败,这个参数包含相关的错误代码。也可以通过调用boost::asio::ip::host_name()而不使用任何参数,以防止错误代码是非相关的。

事实上在Boost 1.36.0中boost::asio::ip::host_name()是有问题的,然而它可以当作一个很好的例子。即使当前操作系统函数成功返回了计算机名,这个函数它也可能返回一个错误代码。由于在Boost 1.37.0中解决了这个问题,现在可以放心使用boost::asio::ip::host_name()了。

由于错误代码仅仅是一个数值,因此可以借助于value()方法得到它。由于错误代码0通常意味着没有错误,其他的值的意义则依赖于操作系统并且需要查看相关手册。

如果使用Boost 1.36.0,并且用Visual Studio 2008在Windows XP环境下编译以上应用程序将不断产生错误代码14(没有足够的存储空间以完成操作)。即使函数boost::asio::ip::host_name()成功决定了计算机名,也会报出错误代码14。事实上这是因为函数boost::asio::ip::host_name()的实现有问题。

除了value()方法之外,类boost::system::error_code提供了方法category()。这个方法可返回一个在Boost.System中定义的二级对象:boost::system::category

错误代码是简单的数值。操作系统开发商,例如微软,可以保证系统错误代码的特异性。对于任何开发商来说,在所有现有应用程序中保持错误代码的独一无二是几乎不可能的。他需要一个包含有所有软件开发者的错误代码中心数据库,以防止在不同的方案下重复使用相同的代码。当然这是不实际的。这是错误分类表存在的缘由。

类型boost::system::error_code的错误代码总是属于可以使用category()方法获取的分类。通过预定义的对象boost::system::system_category来表示操作系统的错误。

通过调用category()方法,可以返回预定义变量boost::system::system_category的一个引用。它允许获取关于分类的特定信息。例如在使用的是system分类的情况下,通过使用name()方法将得到它的名字system

1
2
3
4
5
6
7
8
9
10
11
12
#include <boost/system/error_code.hpp> 
#include <boost/asio.hpp>
#include <iostream>
#include <string>

int main()
{
boost::system::error_code ec;
std::string hostname = boost::asio::ip::host_name(ec);
std::cout << ec.value() << std::endl;
std::cout << ec.category().name() << std::endl;
}

通过错误代码和错误分类识别出的错误是独一无二的。由于仅仅在错误分类中的错误代码是必须唯一的,程序员应当在希望定义某个特定应用程序的错误代码时创建一个新的分类。这使得任何错误代码都不会影响到其他开发者的错误代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <boost/system/error_code.hpp> 
#include <iostream>
#include <string>

class application_category :
public boost::system::error_category
{
public:
const char *name() const { return "application"; }
std::string message(int ev) const { return "error message"; }
};

application_category cat;

int main()
{
boost::system::error_code ec(14, cat);
std::cout << ec.value() << std::endl;
std::cout << ec.category().name() << std::endl;
}

通过创建一个派生于boost::system::error_category的类以及实现作为新分类的所必须的接口的不同方法可以定义一个新的错误分类。由于方法name()message()在类boost::system::error_category中被定义为纯虚拟函数,所以它们是必须提供的。至于额外的方法,在必要的条件下,可以重载相对应的默认行为。

当方法name()返回错误分类名时,可以使用方法message()来获取针对某个错误代码的描述。不像之前的那个例子,参数ev往往被用于返回基于错误代码的描述。

新创建的错误分类的对象可以被用来初始化相应的错误代码。本例中定义了一个用于新分类application_category的错误代码ec。然而错误代码14不再是系统错误;他的意义被开发者指定为新的错误分类。

boost::system::error_code包含了一个叫作default_error_condition()的方法,它可以返回boost::system::error_condition类型的对象。boost::system::error_condition的接口几乎与boost::system::error_code相同。唯一的差别是只有类boost::system::error_code提供了方法default_error_condition()

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <boost/system/error_code.hpp> 
#include <boost/asio.hpp>
#include <iostream>
#include <string>

int main()
{
boost::system::error_code ec;
std::string hostname = boost::asio::ip::host_name(ec);
boost::system::error_condition ecnd = ec.default_error_condition();
std::cout << ecnd.value() << std::endl;
std::cout << ecnd.category().name() << std::endl;
}

boost::system::error_condition的使用方法与boost::system::error_code类似。对象boost::system::error_conditionvalue()category()方法都可以像上面的例子中那样调用。

有或多或少两个相同的类的原因很简单:当类boost::system::error_code被当作当前平台的错误代码时,类boost::system::error_condition可以被用作获取跨平台的错误代码。通过调用default_error_condition()方法,可以把依赖于某个平台的的错误代码转换成boost::system::error_condition类型的跨平台的错误代码。

如果执行以上应用程序,它将显示数字12以及错误分类GENERIC。依赖于平台的错误代码14被转换成了跨平台的错误代码12。借助于boost::system::error_condition,可以总是使用相同的数字表示错误,无视当前操作系统。当Windows报出错误14时,其他操作系统可能会对相同的错误报出错误代码25。使用boost::system::error_condition,总是对这个错误报出错误代码12。

最后Boost.System提供了类boost::system::system_error,它派生于std::runtime_error。它可被用来传送发生在异常里类型为boost::system::error_code的错误代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <boost/asio.hpp> 
#include <boost/system/system_error.hpp>
#include <iostream>

int main()
{
try
{
std::cout << boost::asio::ip::host_name() << std::endl;
}
catch (boost::system::system_error &e)
{
boost::system::error_code ec = e.code();
std::cerr << ec.value() << std::endl;
std::cerr << ec.category().name() << std::endl;
}
}

独立的函数boost::asio::ip::host_name()是以两种方式提供的:一种是需要类型为boost::system::error_code的参数,另一种不需要参数。第二个版本将在错误发生时抛出boost::system::system_error类型的异常。异常传出类型为boost::system::error_code的相应错误代码。

15.3. Boost.Exception

Boost.Exception库提供了一个新的异常类boost::exception允许给一个抛出的异常添加信息。它被定义在文件boost/exception/exception.hpp中。由于Boost.Exception中的类和函数分布在不同的头文件中,下面的例子中将使用boost/exception/all.hpp以避免一个一个添加头文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <boost/exception/all.hpp> 
#include <boost/lexical_cast.hpp>
#include <boost/shared_array.hpp>
#include <exception>
#include <string>
#include <iostream>

typedef boost::error_info<struct tag_errmsg, std::string> errmsg_info;

class allocation_failed :
public boost::exception,
public std::exception
{
public:
allocation_failed(std::size_t size)
: what_("allocation of " + boost::lexical_cast<std::string>(size) + " bytes failed")
{
}

virtual const char *what() const throw()
{
return what_.c_str();
}

private:
std::string what_;
};

boost::shared_array<char> allocate(std::size_t size)
{
if (size > 1000)
throw allocation_failed(size);
return boost::shared_array<char>(new char[size]);
}

void save_configuration_data()
{
try
{
boost::shared_array<char> a = allocate(2000);
// saving configuration data ...
}
catch (boost::exception &e)
{
e << errmsg_info("saving configuration data failed");
throw;
}
}

int main()
{
try
{
save_configuration_data();
}
catch (boost::exception &e)
{
std::cerr << boost::diagnostic_information(e);
}
}

这个例子在main()中调用了一个函数save_configuration_data(),它调回了allocate()allocate()函数动态分配内存,而它检查是否超过某个限度。这个限度在本例中被设定为1,000个字节。

如果allocate()被调用的值大于1,000,将会抛出save_configuration_data()函数里的相应异常。正如注释中所标识的那样,这个函数把配置数据被存储在动态分配的内存中。

事实上,这个例子的目的是通过抛出异常以示范Boost.Exception。这个通过allocate()抛出的异常是allocation_failed类型的,而且它同时继承了boost::exceptionstd::exception

当然,也不是一定要派生于std::exception异常的。为了把它嵌入到现有的框架中,异常allocation_failed可以派生于其他类的层次结构。当通过C++标准来定义以上例子的类层次结构的时候,单独从boost::exception中派生出allocation_failed就足够了。

当抛出allocation_failed类型的异常的时候,分配内存的大小是存储在异常中的,以缓解相应应用程序的调试。如果想通过allocate()分配获取更多的内存空间,那么可以很容易发现导致异常的根本原因。

如果仅仅通过一个函数(例子中的函数save_configuration_data())来调用allocate(),这个信息足以找到问题的所在。然而,在有许多函数调用allocate()以动态分配内存的更加复杂的应用程序中,这个信息不足以高效的调试应用程序。在这些情况下,它最好能有助于找到哪个函数试图分配allocate()所能提供空间之外的内存。向异常中添加更多的信息,在这些情况下,将非常有助于进程的调试。

有挑战性的是,函数allocate()中并没有调用者名等信息,以把它加入到相关的异常中。

Boost.Exception提供了如下的解决方案:对于任何一个可以添加到异常中的信息,可以通过定义一个派生于boost::error_info的数据类型,来随时向这个异常添加信息。

boost::error_info是一个需要两个参数的模板,第一个参数叫做标签(tag),特定用来识别新建的数据类型。通常是一个有特定名字的结构体。第二个参数是与存储于异常中的数据类型信息相关的。

这个应用程序定义了一个新的数据类型errmsg_info,可以通过tag_errmsg结构来特异性的识别,它存储着一个std::string类型的字符串。

save_configuration_data()catch句柄中,通过获取tag_errmsg以创建一个对象,它通过字符串saving configuration data failed进行初始化,以便通过operator<<()操作符向异常boost::exception中加入更多信息。然后这个异常被相应的重新抛出。

现在,这个异常不仅包含有需要动态分配的内存大小,而且对于错误的描述被填入到save_configuration_data()函数中。在调试时,这个描述显然很有帮助,因为可以很容易明白哪个函数试图分配更多的内存。

为了从一个异常中获取所有可用信息,可以像例子中那样在main()catch句柄中使用函数boost::diagnostic_information()。对于每个异常,函数boost::diagnostic_information()不仅调用what()而且获取所有附加信息存储到异常中。返回一个可以在标准输出中写入的std::string字符串。

以上程序通过Visual C++ 2008编译会显示如下的信息:

1
2
3
4
Throw in function (unknown)
Dynamic exception type: class allocation_failed
std::exception::what: allocation of 2000 bytes failed
[struct tag_errmsg *] = saving configuration data failed

正如我们所看见的,数据包含了异常的数据类型,通过what()方法获取到错误信息,以及包括相应结构体名的描述。

boost::diagnostic_information()函数在运行时检查一个给定的异常是否派生于std::exception。只会在派生于std::exception的条件下调用what()方法。

抛出异常类型allocation_failed的函数名会被指定为”unknown”(未知)信息。

Boost.Exception提供了一个用以抛出异常的宏,它包含了函数名,以及如文件名、行数的附加信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <boost/exception/all.hpp> 
#include <boost/lexical_cast.hpp>
#include <boost/shared_array.hpp>
#include <exception>
#include <string>
#include <iostream>

typedef boost::error_info<struct tag_errmsg, std::string> errmsg_info;

class allocation_failed :
public std::exception
{
public:
allocation_failed(std::size_t size)
: what_("allocation of " + boost::lexical_cast<std::string>(size) + " bytes failed")
{
}

virtual const char *what() const throw()
{
return what_.c_str();
}

private:
std::string what_;
};

boost::shared_array<char> allocate(std::size_t size)
{
if (size > 1000)
BOOST_THROW_EXCEPTION(allocation_failed(size));
return boost::shared_array<char>(new char[size]);
}

void save_configuration_data()
{
try
{
boost::shared_array<char> a = allocate(2000);
// saving configuration data ...
}
catch (boost::exception &e)
{
e << errmsg_info("saving configuration data failed");
throw;
}
}

int main()
{
try
{
save_configuration_data();
}
catch (boost::exception &e)
{
std::cerr << boost::diagnostic_information(e);
}
}

通过使用宏BOOST_THROW_EXCEPTION替代throw,如函数名、文件名、行数之类的附加信息将自动被添加到异常中。但这仅仅在编译器支持宏的情况下有效。当通过C++标准定义__FILE____LINE__之类的宏时,没有用于返回当前函数名的标准化的宏。由于许多编译器制造商提供这样的宏,BOOST_THROW_EXCEPTION试图识别当前编译器,从而利用相对应的宏。使用 Visual C++ 2008 编译时,以上应用程序显示以下信息:
1
2
3
4
.\main.cpp(31): Throw in function class boost::shared_array<char> __cdecl allocate(unsigned int)
Dynamic exception type: class boost::exception_detail::clone_impl<struct boost::exception_detail::error_info_injector<class allocation_failed> >
std::exception::what: allocation of 2000 bytes failed
[struct tag_errmsg *] = saving configuration data failed

即使allocation_failed类不再派生于boost::exception代码的编译也不会产生错误。BOOST_THROW_EXCEPTION获取到一个能够动态识别是否派生于boost::exception的函数boost::enable_error_info()。如果不是,他将自动建立一个派生于特定类和boost::exception的新异常类型。这个机制使得以上信息中不仅仅显示内存分配异常allocation_failed

最后,这个部分包含了一个例子,它选择性的获取了添加到异常中的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <boost/exception/all.hpp> 
#include <boost/lexical_cast.hpp>
#include <boost/shared_array.hpp>
#include <exception>
#include <string>
#include <iostream>

typedef boost::error_info<struct tag_errmsg, std::string> errmsg_info;

class allocation_failed :
public std::exception
{
public:
allocation_failed(std::size_t size)
: what_("allocation of " + boost::lexical_cast<std::string>(size) + " bytes failed")
{
}

virtual const char *what() const throw()
{
return what_.c_str();
}

private:
std::string what_;
};

boost::shared_array<char> allocate(std::size_t size)
{
if (size > 1000)
BOOST_THROW_EXCEPTION(allocation_failed(size));
return boost::shared_array<char>(new char[size]);
}

void save_configuration_data()
{
try
{
boost::shared_array<char> a = allocate(2000);
// saving configuration data ...
}
catch (boost::exception &e)
{
e << errmsg_info("saving configuration data failed");
throw;
}
}

int main()
{
try
{
save_configuration_data();
}
catch (boost::exception &e)
{
std::cerr << *boost::get_error_info<errmsg_info>(e);
}
}

这个例子并没有使用函数boost::diagnostic_information()而是使用boost::get_error_info()函数来直接获取错误信息的类型errmsg_info。函数boost::get_error_info()用于返回boost::shared_ptr类型的智能指针。如果传递的参数不是boost::exception类型的,返回的值将是相应的空指针。如果BOOST_THROW_EXCEPTION宏总是被用来抛出异常,派生于boost::exception的异常是可以得到保障的——在这些情况下没有必要去检查返回的智能指针是否为空。

第 16 章 类型转换操作符

16.1. 概述

C++标准定义了四种类型转换操作符:static_castdynamic_castconst_castreinterpret_castBoost.ConversionBoost.NumericConversion这两个库特别为某些类型转换定义了额外的类型转换操作符。

16.2. Boost.Conversion

Boost.Conversion库由两个文件组成。分别在boost/cast.hpp文件中定义了boost::polymorphic_castboost::polymorphic_downcast这两个类型转换操作符,在boost/lexical_cast.hpp文件中定义了boost::lexical_cast

boost::polymorphic_castboost::polymorphic_downcast是为了使原来用dynamic_cast实现的类型转换更加具体。具体细节,如下例所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct father 
{
virtual ~father() { };
};

struct mother
{
virtual ~mother() { };
};

struct child :
public father,
public mother
{
};

void func(father *f)
{
child *c = dynamic_cast<child*>(f);
}

int main()
{
child *c = new child;
func(c);

father *f = new child;
mother *m = dynamic_cast<mother*>(f);
}

本例使用dynamic_cast类型转换操作符两次:在func()函数中,它将指向父类的指针转换为指向子类的指针。在main()中,它将一个指向父类的指针转为指向另一个父类的指针。第一个转换称为向下转换(downcast),第二个转换称为交叉转换(cross cast)。

通过使用Boost.Conversion的类型转换操作符,可以将向下转换和交叉转换区分开来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <boost/cast.hpp> 

struct father
{
virtual ~father() { };
};

struct mother
{
virtual ~mother() { };
};

struct child :
public father,
public mother
{
};

void func(father *f)
{
child *c = boost::polymorphic_downcast<child*>(f);
}

int main()
{
child *c = new child;
func(c);

father *f = new child;
mother *m = boost::polymorphic_cast<mother*>(f);
}

boost::polymorphic_downcast类型转换操作符只能用于向下转换。它内部使用static_cast实现类型转换。由于static_cast并不动态检查类型转换是否合法,所以boost::polymorphic_downcast应该只在类型转换是安全的情况下使用。在调试(debug builds)模式下,boost::polymorphic_downcast实际上在assert()函数中使用dynamic_cast验证类型转换是否合法。请注意这种合法性检测只在定义了NDEBUG宏的情况下执行,这通常是在调试模式下。

向下转换最好使用boost::polymorphic_downcast,那么boost::polymorphic_cast就是交叉转换所需要的了。由于dynamic_cast是唯一能实现交叉转换的类型转换操作符,boost::polymorphic_cast内部使用了它。由于boost::polymorphic_cast能够在错误的时候抛出std::bad_cast类型的异常,所以优先使用这个类型转换操作符还是很有必要的。相反,dynamic_cast在类型转换失败使将返回0。避免手工验证返回值,boost::polymorphic_cast提供了自动化的替代方式。

boost::polymorphic_downcastboost::polymorphic_cast只在指针必须转换的时候使用;否则,必须使用dynamic_cast执行转换。由于boost::polymorphic_downcast是基于static_cast,所以它不能够,比如说,将父类对象转换为子类对象。如果转换的类型不是指针,则使用boost::polymorphic_cast执行类型转换也没有什么意义,而在这种情况下使用dynamic_cast还会抛出一个std::bad_cast异常。

虽然所有的类型转换都可用dynamic_cast实现,可boost::polymorphic_downcastboost::polymorphic_cast也不是真正随意使用的。Boost.Conversion还提供了另外一种在实践中很有用的类型转换操作符。体会一下下面的例子。

1
2
3
4
5
6
7
8
9
10
11
#include <boost/lexical_cast.hpp> 
#include <string>
#include <iostream>

int main()
{
std::string s = boost::lexical_cast<std::string>(169);
std::cout << s << std::endl;
double d = boost::lexical_cast<double>(s);
std::cout << d << std::endl;
}

类型转换操作符boost::lexical_cast可将数字转换为其他类型。例子首先将整数169转换为字符串,然后将字符串转换为浮点数。

boost::lexical_cast内部使用流(streams)执行转换操作。因此,只有那些重载了operator<<()operator>>()这两个操作符的类型可以转换。使用boost::lexical_cast的优点是类型转换出现在一行代码之内,无需手工操作流(streams)。由于流的用法对于类型转换不能立刻理解代码含义,而boost::lexical_cast类型转换操作符还可以使代码更有意义,更加容易理解。

请注意boost::lexical_cast并不总是访问流(streams);它自己也优化了一些数据类型的转换。

如果转换失败,则抛出boost::bad_lexical_cast类型的异常,它继承自std::bad_cast

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/lexical_cast.hpp> 
#include <string>
#include <iostream>

int main()
{
try
{
int i = boost::lexical_cast<int>("abc");
std::cout << i << std::endl;
}
catch (boost::bad_lexical_cast &e)
{
std::cerr << e.what() << std::endl;
}
}

本例由于字符串 “abc” 不能转换为int类型的数字而抛出异常。

16.3. Boost.NumericConversion

Boost.NumericConversion可将一种数值类型转换为不同的数值类型。在C++里,这种转换可以隐式地发生,如下面例所示。

1
2
3
4
5
6
7
8
#include <iostream> 

int main()
{
int i = 0x10000;
short s = i;
std::cout << s << std::endl;
}

由于从intshort的类型转换自动产生,所以本例编译没有错误。虽然本例可以运行,但结果由于依赖具体的编译器实现而结果无法预期。数字0x10000对于变量i来说太大而不能存储在short类型的变量中。依据C++标准,这个操作的结果是实现定义的(“implementation defined”)。用Visual C++ 2008编译,应用程序显示的是0。s的值当然不同于i的值。

为避免这种数值转换错误,可以使用boost::numeric_cast类型转换操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/numeric/conversion/cast.hpp> 
#include <iostream>

int main()
{
try
{
int i = 0x10000;
short s = boost::numeric_cast<short>(i);
std::cout << s << std::endl;
}
catch (boost::numeric::bad_numeric_cast &e)
{
std::cerr << e.what() << std::endl;
}
}

boost::numeric_cast的用法与C++类型转换操作符非常相似。当然需要包含正确的头文件;就是boost/numeric/conversion/cast.hpp

boost::numeric_cast执行与C++相同的隐式转换操作。但是,boost::numeric_cast验证了在不改变数值的情况下转换是否能够发生。前面给的应用例子,转换不能发生,因而由于0x10000太大而不能存储在short类型的变量上,而抛出boost::numeric::bad_numeric_cast异常。

严格来讲,抛出的是boost::numeric::positive_overflow类型的异常,这个类型特指所谓的溢出(overflow) - 在此例中是正数。相应地,还存在着boost::numeric::negative_overflow类型的异常,它特指负数的溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/numeric/conversion/cast.hpp> 
#include <iostream>

int main()
{
try
{
int i = -0x10000;
short s = boost::numeric_cast<short>(i);
std::cout << s << std::endl;
}
catch (boost::numeric::negative_overflow &e)
{
std::cerr << e.what() << std::endl;
}
}

Boost.NumericConversion还定义了其他的异常类型,都继承自boost::numeric::bad_numeric_cast。因为boost::numeric::bad_numeric_cast继承自std::bad_cast,所以catch处理也可以捕获这个类型的异常。

访问文件

访问基于磁盘的文件是一种复杂的活动,既涉及VFS抽象层块设备的处理,也涉及磁盘高速缓存的使用。将磁盘文件系统的普通文件和块设备文件都简单地统称为“文件”。

访问文件的模式有多种:

  • 规范模式:规范模式下文件打开后,标志O_SYNCO_DIRECT清0,且它的内容由read()write()存取。read()阻塞调用进程,直到数据被拷贝进用户态地址空间。但write()在数据被拷贝到页高速缓存(延迟写)后马上结束。
  • 同步模式:同步模式下文件打开后,标志O_SYNC置1或稍后由系统调用fcntl()对其置1。该标志只影响写操作(读操作总是会阻塞),它将阻塞系统调用,直到数据写入磁盘。
  • 内存映射模式:内存映射模式下文件打开后,应用程序发出系统调用mmap()将文件映射到内存中。因此,文件就成为RAM中的一个字节数组,应用程序就可直接访问数组元素,而不需要调用read()、write()lseek()
  • 直接I/O模式:直接I/O模式下文件打开后,标志O_DIRECT置1。任何读写操作都将数据在用户态地址空间与磁盘间直接传送而不通过页高速缓存。
  • 异步模式:异步模式下,文件的访问可以有两种方法,即通过一组POSIX API或Linux特有的系统调用实现。所谓异步模式就是数据传输请求并不阻塞调用进程,而是在后台执行,同时应用程序继续它的正常执行

读写文件

read()write()的服务例程最终会调用文件对象的readwrite方法,这两个方法可能依赖文件系统。对于磁盘文件系统,这些方法能确定被访问的数据所在物理块的位置,并激活块设备驱动程序开始数据传送。

读文件是基于页的,内核总是一次传送几个完整的数据页。如果进程发出read()后,数据不在RAM中,内核就分配一个新页框,并使用文件的适当部分填充该页,把该页加入页高速缓存,最后把请求的字节拷贝到进程地址空间中。对于大部分文件系统,从文件中读取一个数据页等同于在磁盘上查找所请求的数据存放在哪些块上。大多数磁盘文件系统read方法由generic_file_read()通用函数实现。

对基于磁盘的文件,写操作比较复杂,因文件大小可改变,因此内核可能会分配磁盘上的一些物理块。很多磁盘文件系统通过generic_file_write()实现write方法。

从文件中读取数据

generic_file_read()参数:

  • filp,文件对象的地址
  • buf,用户态线性区的线性地址,从文件中读出的数据必须存在这里
  • count,要读取的字符个数
  • ppos,指向一个变量的指针,该变量存放读操作开始处的文件偏移量

  • 第一步,初始化两个描述符。

    • 第一个描述符存放在类型为iovec局部变量local_iov中,它包含用户态缓冲区的地址(buf)和长度(count),缓冲区存放待读文件中的数据。
    • 第二个描述符存放在类型为kiocb的局部变量kiocb中,它用来跟踪正在运行的同步和异步I/O操作的完成状态。

  • generic_file_read()通过执行宏init_sync_kiocb来初始化描述符kiocb,并设置ki_key字段为KIOCB_SYNC_KEY、ki_flip字段为filpki_obj字段为current
  • 然后,调用__generic_file_aio_read()并将刚填完ioveckiocb描述符地址传给它。
  • 最后该函数返回一个值,该值通常就是从文件有效读入的字节数。

__generic_file_aio_read()是所有文件系统实现同步和异步操作所使用的通用例程。参数:kiocb描述符的地址iocbiovec描述符数组的地址iov、数组的长度和存放文件当前指针的一个变量的地址pposiovec描述符数组被函数generic_file_read()调用时只有一个元素,描述待接收数据的用户态缓冲区。

__generic_file_aio_read()执行步骤:

  1. 调用access_ok()检查iovec描述符所描述的用户态缓冲区是否有效。因为起始地址和长度已经从sys_read()服务例程得到,因此在使用前需要对它们进行检查,无效时返回错误代码-EFAULT。
  2. 建立一个读操作描述符,即一个read_descriptor_t类型的数据结构。该结构存放单个用户态缓冲相关的文件读操作的当前状态
  3. 调用do_generic_file_read(),传送给它文件对象指针filp、文件偏移量指针ppos、刚分配的读操作描述符的地址和函数file_read_actor()的地址。
  4. 返回拷贝到用户态缓冲区的字节数,即read_descriptor_twritten字段值。

do_generic_file_read()从磁盘读入所请求的页并把它们拷贝到用户态缓冲区。步骤如下:

  1. 获得要读取的文件对应的address_space对象,它的地址存放在filp->f_mapping
  2. 获得地址空间对象的所有者,即索引节点对象,它将拥有填充了文件数据的页面。它的地址存放在address_space对象的host字段。如果所读文件是块设备文件,那么所有者就不是由filp->f_dentry->d_inode所指向的索引节点对象,而是bdev特殊文件系统中的索引节点对象。
  3. 把文件看作细分的数据页(每页4096字节),并从文件指针*ppos导出一个请求字节所在页的逻辑号,即地址空间中的页索引,并存放在index局部变量中。把第一个请求字节在页内的偏移量存放在offset局部变量中。
  4. 开始一个循环来读入包含请求字节的所有页,要读数据的字节数存放在read_descriptor_t描述符的count字段中。每次循环中,通过下述步骤传送一个数据页:
    1. 如果index*4096+offset超过索引节点对象的i_size字段中的文件大小,则从循环退出,并跳到第5步。
    2. 调用cond_resched()检查当前进程的标志TIF_NEED_RESCHED。如果标志置位,则调用schedule()
    3. 如果有预读的页,则调用page_cache_readahead()读入这些页。
    4. 调用find_get_page(),参数为指向address_space对象的指针及索引值;它将查找页高速缓存已找到包含所请求数据的页描述符。
    5. 如果find_get_page()返回NULL指针,则所请求的页不在页高速缓存中,则执行如下步骤:
      1. 调用handle_ra_miss()来调用预读系统的参数。
      2. 分配一个新页。
      3. 调用add_to_page_cache()将该新页描述符插入到页高速缓存,该函数将新页的PG_locked标志置位。
      4. 调用lru_cache_add()将新页描述符插入到LRU链表。
      5. 跳到第4j步,开始读文件数据。
    6. 如果函数已运行至此,说明页已经位于页高速缓存中。检查标志PG_uptodate,如果置位,则页中的数据是最新的,因此无需从磁盘读数据,跳到第4m步。
    7. 页中的数据是无效的,因此必须从磁盘读取。函数通过调用lock_page()获取对页的互斥访问。如果PG_locked已经置位,则lock_page()阻塞当前进程直到标志被清0。
    8. 现在页已由当前进程锁定。但另一个进程也许会在上一步之前已从页高速缓存中删除该页,那么,它就要检查页描述符的mapping字段是否为NULL。如果是,调用unlock_page()解锁页,并减少它的引用计数(find_get_page()增加计数),并跳到第4a步重读同一页。
    9. 至此,页已经被锁定且在高速缓存中。再次检查标志PG_uptodate,因为另一个内核控制路径可能已经完成第4f步和第4g步的必要读操作。如果标志置位,则调用unlock_page()并跳到第4m来跳过读操作。
    10. 现在真正的I/O操作可以开始了,调用文件的address_space对象的readpage方法。相应的函数会负责激活磁盘到页之间的I/O数据传输。
    11. 如果PG_uptodate还没有置位,则它会等待直到调用lock_page()后页被有效读入。该页在第4g步中锁定,一旦读操作完成就被解锁,因此当前进程在I/O数据传输完成时停止睡眠。
    12. 如果index超出文件包含的页数(通过将inode对象的i_size字段的值除以4096得到),那么它将减少页的引用计数器,并跳出循环到第5步。这种情况发生在这个正被本进程读的文件同时有其他进程正在删减它时。
    13. 将应该拷入用户态缓冲区的页中的字节数存放在局部变量nr中。该值的大小等于页的大小(4096字节),除非offset非0或请求数据不全在该文件中。
    14. 调用mark_page_accessed()将标志PG_referencedPG_active置位,从而表示该页正被访问且不应该被换出。如果同一文件在do_generic_file_read()的后续执行中要读几次,则该步骤只在第一次读时执行。
    15. 把页中的数据拷贝到用户态缓冲区。调用file_read_actor()执行下列步骤:
      1. 调用kmap(),该函数为处于高端内存中的页建立永久的内核映射。
      2. 调用__copy_to_user()把页中的数据拷贝到用户态地址空间。该操作在访问用户态地址空间时如果有缺页异常将会阻塞进程。
      3. 调用kunmap()释放页的任一永久内核映射。
      4. 更新read_descriptor_t描述符的countwrittenbuf字段。
    16. 根据传入用户态缓冲区的有效字节数更新局部变量indexcount。一般,如果页的最后一个字节已拷贝到用户态缓冲区,则index的值加1而offset的值清0;否则,index的值不变而offset的值被设为已拷贝到用户态缓冲区的字节数。
    17. 减少页描述符的引用计数器。
    18. 如果read_descriptor_t描述符的count字段不为0,则文件中还有其他数据要读,跳到第4a步继续循环读文件的下一页数据。
  5. 所请求的或可以读到的数据已读完。函数更新预读数据结构filp->f_ra来标记数据已被顺序从文件读入。
  6. index*4096+offset值赋给*ppos,从而保存以后调用read()write()进行顺序访问的位置。
  7. 调用update_atime()把当前时间存放在文件的索引节点对象i_atime字段,并把它标记为脏后返回。

总结:创建读操作请求;检查是否已读完、重新调度、预读页、在页高速缓存中、页被锁定、页中的数据是否为最新,并进行相应处理;把页中的数据拷贝到用户态缓冲区;更新读操作相关

普通文件的readpage方法

do_generic_file_read()反复使用readpage方法把一个个页从磁盘读到内存。

address_space对象的readpage方法存放的是函数地址,有效激活从物理磁盘到页高速缓存的I/O数据传送。对于普通文件,该字段通常指向mpage_readpage()的封装函数。如Ext3文件系统的readpage方法

1
2
3
4
int exit3_readpage(struct file *file, struct page *page)
{
return mpage_readpage(page, ext3_get_block);
}

mpage_readpage()参数为待填充页的页描述符page及有助于mpage_readpage()找到正确块的函数的地址get_block。该函数把相对于文件开始位置的块号转换为相对于磁盘分区中块位置的逻辑块号。所传递的get_block函数总是用缓冲区首部来存放有关重要信息,如块设备(b_dev字段)、设备上请求数据的位置(b_blocknr字段)和块状态(b_state字段)。

mpage_readpage()在从磁盘读入一页时可选择两种不同的策略:

  • 如果包含请求数据的块在磁盘上是连续的,就用单个bio描述符向通用块城发出读I/O操作。
  • 如果不连续,就对页上的每一块用不同的bio描述符来读。

get_block依赖文件系统,它的一个重要作用是:确定文件中的下一块在磁盘上是否也是下一块

mpage_readpage()执行下列步骤:

  1. 检查页描述符的PG_private字段:如果置位,则该页是缓冲区页,即该页与描述组成该页的块的缓冲区首部链表相关。这意味着该页已从磁盘读入过,且页中的块在磁盘上不是相邻的。跳到第11步,用一次读一块的方式读该页。
  2. 得到块的大小(存放在page->mapping->host->i_blkbits索引节点字段),然后计算出访问该页的所有块所需要的两个值,即页中的块数和页中第一块的文件块号(相对于文件起始位置页中第一块的索引)。
  3. 对于页中的每一块,调用依赖于文件系统的get_block函数,得到逻辑块号,即相对于磁盘或分区开始位置的块索引。页中所有块的逻辑块号存放在一个本地数组中。
  4. 在执行上一步的同时,检查可能发生的异常条件。
    1. 当一些块在磁盘上不相邻时,
    2. 某块落入“文件洞”内时,
    3. 一个块缓冲区已经由get_block函数写入时,
    4. 跳到第11步,用一次读一块的方式读该页。
  5. 至此,说明页中的所有块在磁盘上是相邻的。但它可能是文件中的最后一页,因此,页中的一些块可能在磁盘上没有映像。如果这样,它将页中相应块缓冲区填上0;如果不是,将页描述符的标志PG_mappedtodisk置位。
  6. 调用bio_alloc()分配包含单一段的一个新bio描述符,并分别用块设备描述符地址和页中第一个块的逻辑块号来初始化bi_bdev字段和bi_sector字段。这两个信息已在第3步中得到。
  7. 用页的起始地址、所读数据的首字节偏移量(0)和所读字节总数设置bio段的bio_vec描述符。
  8. bio->bi_end_io的值为mpage_end_io_read()的地址。
  9. 调用submit_bio()将数据传输的方向设定bi_rw标志,更新每CPU变量page_states来跟踪所读扇区数,并在bio描述符上调用generic_make_request()
  10. 返回0(成功)。
  11. 如果函数跳到这里,则页中含有的块在磁盘上不连续。如果页是最新的(PG_uptodate置位),函数就调用unlock_page()对该页解锁;否则调用block_read_full_page()用一次读一块的方式读该页。
  12. 返回0(成功)。

mapge_end_io_read()bio的完成方法,一旦I/O数据传输结束它就开始执行。假定没有I/O错误,将页描述符的标志PG_uptodate置位,调用unlock_page()解锁该页并唤醒相应睡眠的进程,然后调用bio_put()清除bio描述符。

块设备文件的readpage方法

bdev特殊文件系统中,块设备使用address_space对象,该对象存放在对应块设备索引节点的i_data字段。块设备文件的readpage方法总是相同的,由blkdev_readpage()实现,该函数调用block_read_full_page()

1
2
3
4
int blkdev_readpage(struct file *file, struct *page page)
{
return block_read_full_page(page, blkdev_get_block);
}

block_read_full_page()的第二个参数也指向一个函数,该函数把相对于文件开始出的文件块号转换为相对于块设备开始处的逻辑块号。但对于块设备文件来说,这两个数是一致的。blkdev_get_block()执行下列步骤:

  1. 检查页中第一个块的块号是否超过块设备的最后一块的索引值(bdev->bd_inode->i_size/bdev->bd_block_size得到索引值,bdev指向块设备描述符)。如果超过,则对于写操作它将返回-EIO,而对于读操作它将返回0。
  2. 设置缓冲区首部的bdev字段为b_dev
  3. 设置缓冲区首部的b_blocknr字段为文件块号,它将作为参数传递给本函数。
  4. 把缓冲区首部的BH_Mapped标志置位,以表明缓冲区首部的b_devb_blocknr字段是有效的。

block_read_full_page()以一次读一块的方式读一页数据。

  1. 检查页描述符的标志PG_private,如果置位,则该页与描述组成该页的块的缓冲区首部链表相关;否则,调用create_empty_buffers()为该页所含的所有块缓冲区分配缓冲区首部。页中第一个缓冲区的缓冲区首部地址存放在page->private字段中。每个缓冲区首部的b_this_page字段指向该页中下一个缓冲区的缓冲区首部。
  2. 从相对于页的文件偏移量(page->index字段)计算出页中第一块的文件块号。
  3. 对该页中的每个缓冲区的缓冲区首部,执行如下步骤:
    1. 如果标志BH_Uptodate置位,则跳过该缓冲区继续处理该页的下一个缓冲区。
    2. 如果标志BH_Mapped未置位,且该块未超过文件尾,则调用get_block
      1. 对于普通文件,该函数在文件系统的磁盘数据结构中查找,得到相对于磁盘或分区开始处的缓冲区逻辑块号。
      2. 对于块设备文件,该函数把文件块号当作逻辑块号。
      3. 对这两种情形,函数都将逻辑块号存放在相应缓冲区首部的b_blocknr字段中,并将标志BH_Mapped置位。
    3. 再检查标志BH_Uptodate,因为依赖于文件系统的get_block可能已触发块I/O操作而更新了缓冲区。如果BH_Uptodate置位,则继续处理该页的下一个缓冲区。
    4. 将缓冲区首部的地址存放在局部数组arr中,继续该页的下一个缓冲区。
  4. 假如上一步中没有遇到“文件洞”,则将该页的标志PG_mappedtodisk置位。
  5. 现在局部变量arr中存放了一些缓冲区首部的地址,与其对应的缓冲区的内容不是最新的。如果数组为空,那么页中的所有缓冲区都是有效的,因此,该函数设置页描述符的PG_uptodate标志,调用unlock_page()对该页解锁并返回。
  6. 局部数组arr非空。对数组中的每个缓冲区首部,执行下述步骤:
    1. BH_Lock标志置位。该标志一旦置位,就一直等待该缓冲区释放。
    2. 将缓冲区首部的b_end_io字段设置为end_buffer_async_read()的地址,并将缓冲区首部的BH_Async_Read标志置位。
  7. 对局部数组arr中的每个缓冲区首部调用submit_bh(),将操作类型设为READ,该函数触发了相应块的I/O数据传输。
  8. 返回0。

end_buffer_async_read()在对缓冲区的I/O数据传输结束后就执行。假定没有I/O错误,将缓冲区首部的BH_Uptodate标志置位而将BH_Async_Read标志置0。那么,函数就得到包含块缓冲区的缓冲区页描述符,同时检测页中所有块是否是最新的,如果是,将该页的PG_uptodate标志置位并调用unlock_page()

文件的预读

预读在实际请求前读普通文件或块设备文件的几个相邻的数据页。预读能使磁盘控制器处理较少的命令,提高系统的响应能力。但是对随机访问的文件没有用,甚至是有害的,因为浪费了页高速缓存的空间。

文件的预读需要复杂的算法,原因如下:

  • 由于数据是逐页读取的,因此预读算法不必考虑页内偏移量,只要考虑所访问的页在文件内部的位置就可以了。
  • 只要进程持续地顺序访问一个文件,预读就会逐渐增加。
  • 当前的访问与上一次访问不是顺序时,预读就会逐渐减少乃至禁止。
  • 当一个进程重复地访问同一页,或当几乎所有的页都已在页高速缓存时,预读必须停止。
  • 低级I/O设备驱动程序必须在合适的时候激活,这样当将来进程需要时,页已传送完毕。

当访问给定文件时,预读算法使用两个页面集,当前窗预读窗,各自对应文件的一个连续区域。当前窗内的页是进程请求的页和内核预读的页,且位于页高速缓存内(当前窗内的页不必是最新的,因为I/O数据传输仍可能在运行)。当前窗包含进程顺序访问的最后一页,且可能由内核预读但进程未请求的页

预读窗内的页紧接着当前窗内的页,它们是内核正在预读的页。预读窗内的页都不是进程请求的,但内核假定进程迟早会请求。当内核认为是顺序访问且第一页在当前窗内时,它就检测是否建立了预读窗。如果没有,内核就创建一个预读窗并触发相应页的读操作。理想情况下,进程继续从当前窗请求页,同时预读窗的页则正在传送。当进程请求的页在预读窗,则预读窗就成为当前窗。

预读算法使用的主要数据结构是file_ra_state描述符,存放于每个文件对象的f_ra字段。

当一个文件被打开时,在它的file_ra_state描述符中,除了prev_pagera_pages这两个字段,其他的所有字段都置为0。prev_page存放进程上一次读操作中所请求页的最后一页的索引。初值为-1。

ra_pages表示当前窗的最大页数,即对该文件允许的最大预读量。初值在该文件所在块设备的backing_dev_info描述符。可以修改一个打开文件的ra_pages从而调整预读算法,具体实现为调用posix_fadvise(),并传送给POSIX_FADV_NORMAL(最大预读量为缺省值32页),POSIX_FADV_SEQUENTIAL(最大预读量为缺省值的2倍),POSIX_FADV_RAMDOM(最大预读量为0)。

flags字段内有两个重要的字段RA_FLAG_MISSRA_FLAG_INCACHE。如果已被预读的页不在页高速缓存内,则第一个标志置位,这时下一个要创建的预读窗大小将被缩小。当内核确定进程请求的最后256页都在页高速缓存内时(连续高速缓存命中数存放在ra->cache_hit字段中),第二个标志置位,这时内核认为所有的页都已在高速缓存内,关闭预读。

执行预读算法的时机:

  • 当内核用用户态请求读文件数据页时。触发page_cache_readahead()
  • 当内核为文件内存映射分配一页时。
  • 当用户态应用执行readahead()系统调用时,对某个文件描述符显式触发某预读活动。
  • 当用户态应用使用POSIX_FADV_NOREUSEPOSIX_FADV_WILLNEED命令执行posix_fadvise()系统调用时,它会通知内核,某个范围的文件页不久将要被访问。
  • 当用户态应用使用MADV_WILLNEED命令执行madvise()系统调用时,它会通知内核,某个文件内存映射区域中的给定范围的文件页不久将要被访问。

page_cache_readahead()

处理没有被特殊系统调用显式触发的所有预读操作。它填写当前窗和预读窗,根据预读命中数更新当前窗和预读窗的大小,即根据过去对文件访问预读策略的成功程度调整。

当内核必须满足对某个文件一页或多页的读请求时,函数就被调用,参数如下:

  • mapping,描述页所有者的address_space对象指针
  • ra,包含该页的文件file_ra_state描述符指针
  • filp,文件对象地址
  • offset,文件内页的偏移量
  • req_size,完成当前读操作还需读的页数

page_cache_readahead()作用于file_ra_state描述符的字段。

当进程第一次访问一个文件,且其第一个请求页是文件中偏移量为0的页时,函数假定进程要进行顺序访问。那么,从第一页创建一个新的当前窗。初始当前窗的长度与进程第一个读操作所请求的页数有关。请求的页数越大,当前窗就越大,一直到最大值,即ra->ra_pages。反之,当进程第一次访问文件,但第一个请求页在文件中的偏移量不为0时,函数假定进程不是执行顺序读。那么,禁止预读(ra->size=-1)。但当预读暂时被禁止而函数又认为需要顺序读时,将建立一个新的当前窗。

预读窗总是从当前窗的最后一页开始。但它的长度与当前窗的长度相关:如果RA_FLAG_MISS标志置位,则预读窗长度为当前窗长度减2,小于4时设为4;否则,预读窗长度为当前窗长度的4倍或2倍。如果进程继续顺序访问文件,最终预读窗会成为新的当前窗,新的预读窗被创建。

一旦函数认识到对文件的访问相对于上一次不是顺序的,当前窗与预读窗就被清空,预读被暂时禁止。当进程的读操作相对于上一次文件访问为顺序时,预读将重新开始。

每次page_cache_readahead()创建一个新窗,它就开始对所包含页的读操作。为了读一大组页,page_cache_readahead()调用blockable_page_cache_readahead()。为了减少内核开销,blockable_page_cache_readahead()采用下面灵活的方法:

  • 如果服务于块设备的请求队列是读拥塞的,就不进行读操作。
  • 将要读的页与页高速缓存进行比较,如果该页已在页高速缓存内,跳过即可。
  • 在从磁盘读前,读请求所需的全部页框是一次性分配的。如果不能一次性得到全部页框,预读操作就只在可以得到的页上进行。
  • 只要可能,通过使用多段bio描述符向通用块层发出读操作。这通过address_space对象专用的readpages方法实现;如果没有定义,就通过反复调用readpage方法实现。

handle_ra_miss()

当预读策略不是十分有效,内核就必须修正预读参数。

如果进程do_generic_file_read()在第4c步调用page_cache_readahead(),有两种情形:

  • 请求页在当前窗或预读窗表明它已经被预先读入了;
  • 或者还没有,则调用blockable_page_cache_readahead()来读入。

在这两种情形下,do_generic_file_read()在第4d步中就在页高速缓存中找到了该页,如果没有,就表示该页框已经被收回算法从高速缓存中删除。这种情形下,do_generic_file_read()调用handle_ra_miss(),通过将RA_FLAG_MISS标志置位与RA_FLAG_INCACHE标志清0来调整预读算法。

写入文件

write()涉及把数据从进程的用户态地址空间中移动到内核数据结构中,然后再移动到磁盘上。文件对象的write方法允许每种文件类型都定义一个专用的操作。Linux 2.6中,每个磁盘文件系统的write方法都是一个过程,主要表示写操作所涉及的磁盘块,把数据从用户态地址空间拷贝到页高速缓存的某些页中,然后把这些页中的缓冲区标记成脏

许多文件系统通过generic_file_write()来实现文件对象的write方法,参数:

  • file,文件对象指针
  • buf,用户态地址空间中的地址,必须从该地址获取要写入文件的字符
  • count,要写入的字符个数
  • ppos,存放文件偏移量的变量地址,必须从这个偏移量处开始写入

执行以下操作:

  1. 初始化iovec类型的一个局部变量,它包含用户态缓冲区的地址与长度。
  2. 确定所写文件索引节点对象的地址inodefile->f_mapping->host),获得信号量(inode->i_sem)。有了该信号量,一次只能有一个进程对某个文件发出write()系统调用。
  3. 调用init_sync_kiocb初始化kiocb类型的局部变量。将ki_key字段设置为KIOCB_SYNC_KEYki_filp字段设置为filpki_obj字段设置为current
  4. 调用__generic_file_aio_write_nolock()将涉及的页标记为脏,并传递相应的参数:ioveckiocb类型的局部变量地址、用户态缓冲区的段数和ppos
  5. 释放inode->i_sem信号量。
  6. 检查文件的O_SYNC标志、索引节点的S_SYNC标志及超级块的MS_SYNCHRONOUS标志。如果至少一个标志置位,则调用sync_page_range()强制内核将页高速缓存中第4步涉及的所有页刷新,阻塞当前进程直到I/O数据传输结束。sync_page_range()先执行address_space对象的writepage方法或mpage_writepages()开始这些脏页的I/O传输,然后调用generic_osync_inode()将索引节点和相关缓冲区刷新到磁盘,最后调用wait_on_page_bit()挂起当前进程直到全部所刷新页的PG_writeback标志清0。
  7. __generic_file_aio_write_nolock()的返回值返回,通常是写入的有效字节数。

__generic_file_aio_write_nolock()参数:kiocb描述符的地址iocbiovec描述符数组的地址iov、该数组的长度及存放文件当前指针的变量的地址ppos。当被generic_file_write()调用时,iovec描述符数组只有一个元素,该元素描述待写数据的用户态缓冲区。

仅讨论最常见的情形,对有页高速缓存的文件进行write()调用的一般情况。__generic_file_aio_write_nolock()执行如下步骤:

  1. 调用access_ok()确定iovec描述符所描述的用户态缓冲区是否有效。无效时返回错误码-EFAULT。
  2. 确定待写文件(file->f_mapping->host)索引节点对象的地址inode。如果文件是一个块设备文件,这就是一个bdev特殊文件系统的索引节点。
  3. 将文件(file->f_mapping->backing_dev_info)的backing_dev_info描述符的地址设为current->backing_dev_info。实际上,即使相应请求队列是拥塞的,该设置也会允许当前进程写回file->f_mapping拥有的脏页。
  4. 如果file->flagsO_APPEND标志置位且文件是普通文件(非块设备文件),它将*ppos设为文件尾,从而新数据都将追加到文件的后面。
  5. 对文件大小进行几次检查。如,写操作不能把一个普通文件增大到超过每用户的上限或文件系统的上限,每用户上限存放在current->signal->rlim[RLIMIT_FSIZE],文件系统上限存放在inode->i_sb->s_maxbytes。另外,如果文件不是“大型文件”(当file->f_flagsO_LARGEFILE标志清0时),则其大小不能超过2GB。如果没有设定上述限制,它就减少待写字节数。
  6. 如果设定,则将文件的suid标志清0,如果是可执行文件的话就将sgid标志也清0。
  7. 将当前时间存放在inode->mtime字段(文件写操作的最新时间)中,也存放在inode->ctime字段(修改索引节点的最新时间)中,且将索引节点对象标记为脏。
  8. 开始循环以更新写操作中涉及的所有文件页。每次循环期间,执行下列子步骤:
    1. 调用find_lock_page()在页高速缓存中搜索该页。如果找到,则增加引用计数并将PG_locked标志置位。
    2. 如果该页不在页高速缓存中,则分配一个新页框并调用add_to_page_cache()在页高速缓存内插入此页。增加引用计数并将PG_locked标志置位。在内存管理区的非活动链表中插入一页。
    3. 调用索引节点(file->f_mapping)中address_space对象的prepare_write方法。为该页分配和初始化缓冲区首部。
    4. 如果缓冲区在高端内存中,则建立用户态缓冲区的内核映射,然后调用__copy_from_user()把用户态缓冲区中的字符拷贝到页中,并释放内核映射。
    5. 调用索引节点(file->f_mapping)中address_space对象的commit_write方法,把基础缓冲区标记为脏。
    6. 调用unlock_page()PG_locked标志,并唤醒等待该页的任何进程。
    7. 调用mark_page_accessed()为内存回收算法更新页状态。
    8. 减少页引用计数来撤销第8a或8b步中的增加值。
    9. 在这一步,还有一页被标记为脏,检查页高速缓存中脏页比例是否超过一个固定的阈值(通常为系统中页的40%)。如果是,调用writeback_inodes()刷新几十页到磁盘。
    10. 调用cond_resched()检查当前进程的TIF_NEED_RESCHED标志。如果该标志置位,则调用schedule()
  9. 现在,写操作中所涉及的文件的所有页都已处理。更新*ppos的值,让它正好指向最后一个被写入的字符之后的位置。
  10. 设置current->backing_dev_info为NULL。
  11. 返回写入文件的有效字符数后结束。

总结:检查;判断是写入还是追加;如果页不在缓存中则添加到缓存中,并标记为脏;如果脏页过多则刷新到磁盘;返回写入字符数。

普通文件的prepare_write和commit_write方法

address_space对象的prepare_writecommit_write方法专门用于由generic_file_write()实现的通用写操作,适用于普通文件和块设备文件。

每个磁盘文件系统都定义了自己的prepare_write方法。Ext2文件系统:

1
2
3
4
int ext2_prepare_write(struct file *file, struct page *page, unsigned from, unsigned to)
{
return block_prepare_write(page, from, to, ext2_get_block);
}

ext2_get_block()把相对于文件的块号转换为逻辑块号。

block_prepare_write()为文件页的缓冲区和缓冲区首部做准备:

  1. 检查某页是否是一个缓冲区页(如果是则PG_Private标志置位);如果该标志清0,则调用create_empty_buffers()为页中所有的缓冲区分配缓冲区首部。
  2. 对于页中包含的缓冲区对应的每个缓冲区首部,及受写操作影响下每个缓冲区首部,执行下列操作:
    1. 如果BH_New标志置位,则将它清0。
    2. 如果BH_New标志已清0,则执行下列子步骤:
      1. 调用依赖于文件系统的函数,该函数的地址get_block以参数形式传递过来。查看这个文件系统磁盘数据结构并查找缓冲区的逻辑块号(相对于磁盘分区的起始位置)。与文件系统相关的函数把这个数存放在对应缓冲区首部的b_blocknr字段,并设置它的BH_Mapped标志。与文件系统相关的函数可能为文件分配一个新的物理块,这种情况下,设置BH_New标志。
      2. 检查BH_New标志的值;如果被置位,则调用unmap_underlying_metadata()检查页高速缓存内的某个块设备缓冲区是否包含指向磁盘同一块的一个缓冲区。实际上调用__find_get_block()在页高速缓存内查找一个旧块。如果找到一块,将BH_Dirty标志清0并等待直到该缓冲区的I/O数据传输完毕。此外,如果写操作不对整个缓冲区进行重写,则用0填充未写区域,然后考虑页中的下一个缓冲区。
    3. 如果写操作不对整个缓冲区进行重写且它的BH_DelayBH_Uptodate标志未置位(已在磁盘文件系统数据结构中分配了块,但RAM中的缓冲区没有有效的数据映射),函数对该块调用ll_rw_block()从磁盘读取它的内容。
  3. 阻塞当前进程,直到在第2c步触发的所有读操作全部完成。
  4. 返回0。

一旦prepare_write方法返回,generic_file_write()就用存放在用户态地址空间中的数据更新页。接下来,调用address_space对象的commit_write方法。该方法由generic_commit_write()实现,几乎适用于所有非日志型磁盘文件系统。

generic_commit_write()执行下列步骤:

  1. 调用__block_commit_write(),然后依次执行如下步骤:
    1. 考虑页中受写操作影响的所有缓冲区;对于其中的每个缓冲区,将对应缓冲区首部的BH_UptodateBH_Dirty标志置位。
    2. 标记相应索引节点为脏,这需要将索引节点加入超级块脏的索引节点链表。
    3. 如果缓冲区页中的所有缓冲区是最新的,则将PG_uptodate标志置位。
    4. 将页的PG_dirty标志置位,并在基树中将页标记成脏。
  2. 检查写操作是否将文件增大。如果增大,则更新文件索引节点对象的i_size字段。
  3. 返回0。

块设备文件的prepare_write和commit_write方法

写入块设备文件的操作类似于对普通文件的相应操作。块设备文件的address_space对象的prepare_write方法通常由下列函数实现:

1
2
3
4
int blkdev_prepare_write(struct file *file, struct page *page, unsigned from, unsigned to)
{
return block_prepare_write(page, from, to, blkdev_get_block);
}

与之前的block_prepare_write()唯一的差异在第二个参数,它是一个指向函数的指针,该函数必须把相对于文件开始处的文件块号转换为相对与块设备开始处的逻辑块号。对于块设备文件,这两个数是一致的。

块设备文件的commit_write()方法:

1
2
3
4
int blkdev_commit_write(struct file *file, struct page *page, unsigned from, unsigned to)
{
return block_commit_write(page, from, to);
}

用于块设备的commit_write方法与用于普通文件的commit_write方法本质上做同样的事情。唯一的差异是这个方法不检查写操作是否扩大了文件,因为不可能在块设备文件的末尾追加字符。

把脏页写到磁盘

通常I/O数据传输是延迟进行的。

当内核要启动有效I/O数据传输时,就调用文件address_space对象的writepages方法,它在基树中寻找脏页,并把它们刷新到磁盘。如Ext2文件系统:

1
2
3
4
int ext2_writepages(struct address_space *mapping, struct writeback_control *wbc)
{
return mpage_writepages(mapping, wbc, ext2_get_block);
}

对于mpage_writepages(),如果没有定义writepages方法,内核直接调用mpage_writepages()并把NULL传给第三个参数。ext2_get_block()将文件块号转换成逻辑块号。

writeback_control数据结构是一个描述符,它控制writeback写操作如何执行。

mpage_writepages()执行下列步骤:

  1. 如果请求队列写拥塞,但进程不希望阻塞,则不向磁盘写任何页就返回。
  2. 确定文件的首页,如果writeback_control描述符给定一个文件内的初始位置,将它转换成索引。否则,如果writeback_control描述符指定进程无需等待I/O数据传输结束,它将mapping->writeback_index的值设为初始页索引。最后,如果进程必须等待数据传输完毕,则从文件的第一页开始扫描。
  3. 调用find_get_pages_tag()在页高速缓存中查找脏页描述符。
  4. 对上一步得到的每个页描述符,执行下述步骤:
    1. 调用lock_page()锁定该页。
    2. 确认页是有效的并在页高速缓存内。
    3. 检查页的PG_writeback标志。如果置位,表明页已被刷新到磁盘。如果进程必须等待I/O数据传输完毕,则调用wait_on_page_bit()PG_writeback清0前一直阻塞当前进程;函数结束时,以前运行的任何writeback操作都被终止。否则,如果进程无需等待,它将检查PG_dirty标志,如果清0,则正在运行的写回操作将处理该页,将它解锁并跳回第4a步继续下一页。
    4. 如果get_block参数是NULL,它将调用文件address_space对象的mapping->writepage方法将页刷新到磁盘。否则,如果get_block参数不是NULL,就调用mpage_writepage()。详见第8步。
  5. 调用cond_resched()检查当前进程的TIF_NEED_RESCHED标志,如果置位就调用schedule()
  6. 如果函数没有扫描完给定范围内的所有页,或写到磁盘的有效页数小于writeback_control描述符中原先的给定值,则跳回第3步。
  7. 如果writeback_control描述符没有给定文件内的初始位置,它将最后一个扫描页的索引值赋给mapping->writeback_index字段。
  8. 如果在第4d步中调用了mpage_writepage(),且返回了bio描述符地址,则调用mpage_bio_submit()

像Ext2这样的典型文件系统的writepage方法是一个通用的block_write_full_page()的封装函数。并将依赖于文件系统的get_block()传给它.

block_write_full_page()分配页缓冲区首部(如果还不在缓冲区页中),对每页调用submit_bh()指定WRITE操作。对于块设备文件,block_write_full_page()的封装函数为blkdev_writepage(),来实现writepage方法。

许多非日志文件系统依赖于mpage_writepage()而不是自定义的writepage方法。这样能改善性能,因为mpage_writepage()在I/O传输中可将尽可能多的页聚集在一个bio描述符。有利于块设备驱动程序利用硬盘控制器的DMA分散-聚集能力。

mpage_writepage()将检查:待写页包含的块在磁盘上是否不相邻该页是否包含文件洞页上的某块是否没有脏或不是最新的。如果以上至少一条成立,就仍然用依赖于文件系统的writepage方法;否则,将页追加为bio描述符的一段。bio描述符的地址将作为参数被传给函数;如果为NULL,mpage_writepage()将初始化一个新的bio描述符并将地址返回给调用函数,调用函数未来调用mpage_writepage()时再将该地址传回。这样,同一个bio可加载几个页。如果bio中某页与上一个加载页不相邻,mpage_writepage()就调用mpage_bio_submit()开始该bio的I/O数据传输,并为该页分配一个新的bio

mpage_bio_submit()biobi_end_io方法设为mpage_end_io_write()的地址,然后调用submit_bio()开始传输。一旦数据传输成功结束,mpage_end_io_write()就唤醒那些等待传输结束的进程,并消除bio描述符。

内存映射

内核把对线性区中页内某个字节的访问转换成对文件中相应字节的操作的技术为内存映射。两种类型的内存映射:

  • 共享型,在线性区页上的任何写操作都会修改磁盘上的文件;而且,如果进程读共享映射中的一个页进行写,那么这种修改对于其他映射了这同一文件的所有进程来说都是可见的。
  • 私有型,当进程创建的映射只是为读文件,而不是写文件时才会使用此种映射。私有映射的效率比共享映射高。但对私有映射页的任何写操作都会使内核停止映射该文件中的页。因此,写操作既不会改变磁盘上的文件,对访问相同文件的其他进程也不可见。但私有内存映射中还没有被进程改变的页会因为其他进程对文件的更新而更新。

mmap()创建一个新的内存映射,必须指定要给MAP_SHARED标志或MAP_PRIVATE标志作为参数。一旦创建映射,进程就可以从这个新线性区的内存单元读取数据,等价于读取了文件中存放的数据。

munmap()撤销或缩小一个内存映射。

如果一个内存映射是共享的,相应的线性区就设置了VM_SHARED标志;如果一个内存映射是私有的,相应的线性区就清除了VM_SHARED标志。

内存映射的数据结构

内存映射可以用下列数据结构的组合表示:

  • 所映射的文件相关的索引节点对象
  • 所映射文件的address_space对象
  • 不同进程对一个文件进行不同映射所使用的文件对象
  • 对文件进行每一不同映射所使用的vm_area_struct描述符
  • 对文件进行映射的线性区所分配的每个页框所对应的页描述符

图的左边给出了标识文件的索引节点。每个索引节点对象的i_mapping字段指向文件的address_space对象。每个address_space对象的page_tree字段又指向该地址空间的页的基树,而i_mmap字段指向第二棵树,叫做radix优先级搜索树(PST),这种树由地址空间的线性区组成。PST的主要作用是为了执行反向映射,这是为了快速标识共享一页的所有进程。

每个线性区描述符都有一个vm_file字段,与所映射文件的文件对象链接(如果为NULL,则线性区没有用于内存映射)。第一个映射的位置存放线性区描述符的vm_pgoff字段,它表示以页大小为单位的偏移量。所映射的文件那部分的长度就是线性区的大小,可以从vm_startvm_end字段计算出来。

共享内存映射的页通常都包含在页高速缓存中;私有内存映射的页只要还没有被修改,也包含在页高速缓存。当进程试图修改一个私有内存映射的页时,内核就把该页进行复制,并在进程页表中用复制的页替换原来的页框。虽然原来的页框还在页高速缓存,但不再属于这个内存映射。该复制的页框不会被插入页高速缓存,因为其中包含的数据不再是磁盘上表示那个文件的有效数据。

对每个不同的文件系统,内核提供了几个钩子函数来定制其内存映射机制。内存映射实现的核心委托给文件对象的mmap方法。对于大多数磁盘文件系统和块设备文件,该方法由generic_file_mmap()通用函数实现。

文件内存映射依赖于请求调用机制。事实上一个新建立的内存映射就是一个不包含任何页的线性区,当进程引用线性区中的一个地址时,缺页异常发生,缺页异常中断处理程序检查线性区的nopage方法是否被定义,如果没有则说明线性区不映射磁盘上的文件。几乎所有的磁盘文件通过filemap_nopage()实现nopage方法。

创建内存映射

mmap()参数:

  • 文件描述符,标识要映射的文件
  • 文件内的偏移量,指定要映射的文件部分的第一个字符
  • 要映射的文件部分的长度
  • 一组标志,进程必须显式地设置MAP_SHARED标志或MAP_PRIVATE标志来指定所请求的内存映射的种类。
  • 一组权限,指定对线性区进行访问的一种或多种权限:读访问(PROT_READ)、写访问(PROT_WRITE)或执行访问(PROT_EXEC)。
  • 一个可选的的线性地址,内核把该地址作为新线性区应该从哪里开始的一个线索。如果指定了MAP_FIXED标志,且内核不能从指定的线性地址开始分配新线性区,那么这个系统调用失败。

mmap()系统调用返回新线性区中第一个单元位置的线性地址。主要调用do_mmap_pgoff()函数。

  • 检查要映射的文件是否定义了mmap文件操作。如果没有,就返回一个错误码。文件操作表中的mmap值为NULL说明相应的文件不能被映射。
  • get_unmapped_area()调用文件对象的get_unmapped_area方法,如果已定义,就为文件的内存映射分配一个合适的线性地址区间。磁盘文件系统不定义这个方法,需调用内存描述符的get_unmapped_area方法。
  • 除了进行正常的一致性检查外,还要对所请求的内存映射的种类(存放在mmap()的参数flags中)与在打开文件时所指定的标志(存放在file->f_mode字段中)进行比较。根据这两个消息源,执行以下的检查:
    • 如果请求一个共享可写的内存映射,文件应该是为写入而打开的,而不是以追加模式打开的(open()O_APPEND标志)。
    • 如果请求一个共享内存映射,文件上应该没有强制锁。
    • 对于任何种类的内存映射,文件都应该是为读操作而打开的。
      初始化vm_flags时,要根据文件的访问权限和所请求的内存映射的种类设置VM_READVM_MAYWRITEVM_MAYEXECVM_MAYSHARE标志。
  • 用文件对象的地址初始化线性区描述符的vm_file字段,并增加文件的引用计数器。对映射的文件调用mmap方法,将文件对象地址和线性区描述符地址作为参数传给它。大多数文件系统由generic_file_mmap()实现
    • 将当前时间赋给文件索引节点对象的i_atime字段,并将该索引节点标记为脏。
    • generic_file_vm_ops表的地址初始化线性区描述符的vm_ops字段。在这个表中的方法,除了nopagepopulate方法外,其他都为空。nopage方法由filemap_nopage()实现,而populate方法由filemap_poplate()实现。
  • 增加文件索引节点对象i_writecount字段的值,该字段就是写进程的引用计数器。

撤销内存映射

munmap()还可用于减少每种内存区的大小。参数:

  • 要删除的线性地址区间中第一个单元的地址
  • 要删除的线性地址区间的长度

sys_munmap()服务例程实际上调用do_munmap()。不需要将待撤销可写共享内存映射中的页刷新到磁盘。实际上,这些页仍然在页高速缓存内,因此继续起磁盘高速缓存的作用。

内存映射的请求调页

内存映射创建后,页框的分配尽可能推迟。

内核先验证缺页所在地址是否包含在某个进程的线性区内,如果是,内核就检查该地址所对应的页表项,如果表项为空,就调用do_no_page()

do_no_page()执行对请求调页的所有类型都通用的操作,如分配页框和更新页表。它还检查所涉及的线性区是否定义了nopage方法,当定义时,do_no_page()执行的主要操作:

  1. 调用nopage方法,返回包含所请求页的页框的地址。
  2. 如果进程试图对页进行写入,而该内存映射是私有的,则通过把刚读取的页拷贝一份并插入页的非活动链表中来避免进一步的“写时复制”异常。如果私有内存映射区域还没有一个包含新页的被动匿名线性区,它要么追加一个新的被动匿名线性区,要么增大现有的。在下面步骤中,该函数使用新页而不是nopage方法返回的页,所以后者不会被用户态进程修改。
  3. 如果某个其他进程删改或作废了该页(address_space描述符的truncate_count字段就是用于这种检查的),函数将跳回第1步,尝试再次获得该页。
  4. 增加进程内存描述符的rss字段,表示一个新页框已分配给进程。
  5. 用新页框的地址及线性区的vm_page_prot字段中所包含的页访问权来设置缺页所在的地址对应的页表项。
  6. 如果进程试图对该页进行写入,则把页表项的Read/WriteDirty位强制置为1。这种情况下,或者把该页框互斥地分配给进程,或者让页成为共享;这两种情况下,都应该允许对该页进行写入。

请求调页算法的核心在于线性区的nopage方法。一般,该方法必须返回进程所访问页所在的页框地址。其实现依赖于页所在线性区的种类。

在处理对磁盘文件进行映射的线性区时,nopage方法必须首先在页高速缓存中查找所请求的页。如果没有找到相应的页,就必须从磁盘读入。大部分文件系统都是由filemap_nopage实现nopage方法。参数:

  • area,所请求页所在线性区的描述符地址。
  • address,所请求页的线性地址。
  • type,存放函数侦测到的缺页类型(VM_FAULT_MAJORVM_FAULT_MINOR)的变量的指针。

filemap_nopage()`执行以下步骤:

  1. area->vm_file字段得到文件对象地址file;从file->f_mapping得到address_space对象地址;从address_space对象的host字段得到索引节点对象地址。
  2. areavm_startvm_pgoff字段来确定从address开始的页对应的数据在文件中的偏移量。
  3. 检查文件偏移量是否大于文件大小。如果是,就返回NULL,这意味着分配新页失败,除非缺页是由调试程序通过ptrace()跟踪另一个进程引起的。
  4. 如果线性区的VM_RAND_READ标志置位,假定进程以随机方式读内存映射中的页,那么它忽略预读,跳到第10步。
  5. 如果线性区的VM_SEQ_READ标志置位,假定进程以严格顺序读内存映射中的页,则调用page_cache_readahead()从缺页处开始预读。
  6. 调用find_get_page(),在页高速缓存内寻找由address_space对象和文件偏移量标识的页。如果没有找到,跳到第11步。
  7. 此时,说明没有在页高速缓存中找到页,检查内存区的VM_SEQ_READ标志:
    1. 如果标志置位,内核将强行预读线性区中的页,预读算法失败,就调用handle_ra_miss()来调整预读参数,并跳到第10步。
    2. 否则,如果标志未置位,将文件file_ra_state描述符中的mmap_miss计数器加1。如果失败数远大于命中数(存放在mmap_hit计数器内),将忽略预读,跳到第10步。
  8. 如果预读没有永久禁止(file_ra_state描述符的ra_pages字段大于0),将调用do_page_cache_readahead()读入包含请求页的一组页。
  9. 调用find_get_page()检查请求页是否在页高速缓存中,如果在,跳到第11步。
  10. 调用page_cache_read()检查请求页是否在页高速缓存中,如果不在,则分配一个新页框,把它追加到页高速缓存,执行mapping->a_ops->readpage方法,安排一个I/O操作从磁盘读入该页内容。
  11. 调用grab_swap_token(),尽可能为当前进程分配一个交换标记。
  12. 请求页已在页高速缓存中,将文件file_ra_state描述符的mmap_hit计数器加1。
  13. 如果页不是最新的(标志PG_uptodate未置位),就调用lock_page()锁定该页,执行mapping->a_ops->readpage方法触发I/O数据传输,调用wait_on_page_bit()后睡眠,一直等到该页被解锁,即等待数据传输完成。
  14. 调用mark_page_accessed()来标记请求页为访问过。
  15. 如果在页高速缓存内找到该页的最新版,将*type设为VM_FAULT_MINOR,否则设为VM_FAULT_MAJOR
  16. 返回请求页地址。

用户态进程可通过madvise()来调整filemap_nopage()的预读行为。MADV_RANDOM命令将线性区的VM_RAND_READ标志置位,从而指定以随机方式访问线性区的页。MADV_SEQUNTIAL命令将线性区的VM_SEQ_READ标志置位,从而指定以严格顺序方式访问页。MADV_NORMAL命令将复位VM_RAND_READVM_SEQ_READ标志,从而指定以不确定的顺序访问页。

把内存映射的脏页刷新到磁盘

进程可是由msync()把属于共享内存映射的脏页刷新到磁盘。参数:一个线性地址区间的起始地址区间的长度即具有下列含义的一组标志

  • MS_SYNC,挂起进程,直到I/O操作完成。调用进程可假设当系统调用完成时,该内存映射中的所有页都已经被刷新到磁盘。
  • MS_ASYNC(对MS_SYNC的补充),要求系统调用立即返回,而不用挂起调用进程。
  • MS_INVALIDATE,使同一文件的其他内存映射无效。

对线性地址区间中所包含的每个线性区,sys_msync()服务例程都调用msync_interval()执行以下操作:

  1. 如果线性区描述符的vm_file字段为NULL,或者如果VM_SHARED标志清0,就返回0(该线性区不是文件的可写共享内存映射)。
  2. 调用filemap_sync()扫描包含在线性区地址区间所对应的页表项。对于找到的每个页,重设对应页表项的Dirty标志,调用flush_tlb_page()刷新相应的快表(TLB)。然后设置页描述符的PG_dirty标志,把页标记为脏。
  3. 如果MS_ASYNC标志置位,返回。MS_ASYNC标志的实际作用就是将线性区的页标志PG_dirty置位。该系统调用没有实际开始I/O数据传输。
  4. 至此,MS_SYNC标志置位,必须将内存区的页刷新到磁盘,且当前进程必须睡眠直到所有I/O数据传输结束。为此,要得到文件索引节点的信号量i_sem
  5. 调用filemap_fdatawrite(),参数为文件的address_space对象的地址。必须用WB_SYNC_ALL同步模式建立一个writeback_control描述符,且要检查地址空间是否有内置的writepage方法。如果有,则返回;没有,则执行mapge_writepages()将脏页写到磁盘。
  6. 检查文件对象的fsync方式是否定义,如果是,则执行。对于普通文件,该方法仅把文件的索引节点对象刷新到磁盘。然而,对于块设备文件,该方法调用sync_blockdev()激活该设备所有脏缓冲区的I/O数据传输。
  7. 执行filemap_fdatawait()。页高速缓存中的基树标识了所有通过PAGECACHE_TAG_SRITEBACK标记正在往磁盘写的页。函数快速扫描覆盖给定线性地址区间的这一部分基树来寻找PG_writeback标志置位的页。调用wait_on_page_bit()使其中每一页睡眠,直到PG_writeback标志清0,即等到正在进行的该页的I/O数据传输结束。
  8. 释放文件的信号量i_sem并返回。

非线性内存映射

非线性映射中,每一内存页都映射文件数据中的随机页。

为实现非线性映射,内核使用了另外一些数据结构。首先,线性区描述符的VM_NONLINERAR标志用于表示线性区存在一个非线性映射。给定文件的所有非线性映射线性区描述符都存放在一个双向循环链表,该链表位于address_space对象的i_mmap_nonlinear字段。

为创建一个非线性内存映射,用户态进程首先以mmap()系统调用创建一个常规的共享内存映射。然后调用remap_file_pages()来重写映射内存映射中的一些页。该系统调用的sys_remap_file_pages()服务例程参数:

  • start,调用进程共享文件内存映射区域内的线性地址。
  • size,文件重写映射部分的字节数。
  • prot,未用(必须为0)。
  • pgoff,待映射文件初始页的页索引。
  • flags,控制非线性映射的标志。

sys_remap_file_pages()用线性地址start、页索引pgoff和映射尺寸size所确定的文件数据部分进行重写映射。如果线性区非共享或不能容纳要映射的所有页,则失败并返回错误码。实际上,该服务例程把线性区插入文件的i_mmap_nonlinear链表,并调用该线性区的populate方法。

对于所有普通文件,populate方法是由filemap_populate()实现的:

  1. 检查remap_file_pages()flags参数中MAP_NONBOCK标志是否清0。如果是,则调用do_page_cache_readahead()预读待映射文件的页。
  2. 对重写映射的每一页:
    1. 检查页描述符是否已在页高速缓存内,如果不在且MAP_NONBLOCK未置位,则从磁盘读入该页。
    2. 如果页描述符在页高速缓存内,它将更新对应线性地址的页表项来指向该页框,并更新线性区描述符的页引用计数器。
    3. 否则,如果没有在页高速缓存内找到该页描述符,它将文件页的偏移量存放在该线性地址对应的页表项的最高32位,并将页表项的Present位清0、Dirty位置位。

当处理请求调页错误时,handle_pte_fault()检查页表项的PresentDirty位。如果它们的值对应一个非线性内存映射,则handle_pte_falut()调用do_file_page()从页表项的高位中取出所请求文件页的索引,然后,do_file_page()调用线性区的populate方法从磁盘读入页并更新页表项本身。

因为非线性内存映射的内存页是按照相对于文件开始处的页索引存放在页高速缓存中,而不是按照相对于线性区开始处的索引存放的,所以非线性内存映射刷新到磁盘的方式与线性内存映射一样。

直接I/O传送

直接I/O传送绕过了页高速缓存,在每次I/O直接传送中,内核对磁盘控制器进行编程,以便在应用程序的用户态地址空间中自缓存的页与磁盘之间直接传送数据

当应用程序直接访问文件时,它以O_DIRECT标志置位的方式打开文件。调用open()时,dentry_open()检查打开文件的address_space对象是否已实现direct_IO方法,没有则返回错误码。对一个已打开的文件也可由fcntl()F_SETFL命令把O_DIRECT置位。

第一种情况中,应用程序对一个以O_DIRECT标志置位打开的文件调用read()。文件的read方法通常由generic_file_read()实现,它初始化ioveckiocb描述符并调用__genenric_file_aio_read()__genenric_file_aio_read()检查iovec描述符描述的用户态缓冲区是否有效,文件的O_DIRECT标志是否置位。当被read()调用时,等效于:

1
2
3
4
5
6
7
8
9
10
11
if(filp->f_flags & O_DIRECT)
{
if(count ==0 || *ppos > file->f_mapping->host->i_size)
return 0;
retval = generic_file_direct_IO(READ, iocb, iov, **ppos, 1);
if(retval >0)
*ppos += retval;

file_accessed(filp);
return retval;
}

函数检查文件指针的当前值是否大于文件大小,然后调用generic_file_direct_IO(),传给它READ操作类型、iocb描述符、iovec描述符、文件指针的当前值和io_vec中指定的用户态缓冲区号。__generic_file_aio_read()更新文件指针,设置对文件索引节点的访问时间戳,然后返回。

对一个以O_DIRECT标志位打开的文件调用write()时,情况类似。文件的write方法就是调用generic_file_aio_write_nolock()。函数检查O_DIRECT标志是否置位,如果置位,则调用generic_file_direct_IO(),这次限定的是WRITE操作类型。

generic_file_direct_IO()参数:

  • rw,操作类型:READWRITE
  • iocbkiocb描述符指针
  • ioviove描述符数组指针
  • offset,文件偏移量
  • nr_segsiov数组中iovec描述符数

generic_file_direct_IO()步骤:

  1. kiocb描述符的ki_filp字段得到文件对象的地址file,从file->f_mapping字段得到address_space对象的地址mapping
  2. 如果操作类型为WRITE,且一个或多个进程已创建了与文件的某个部分关联的内存映射,则调用unmap_mapping_range()取消文件所有页的内存映射。如果任何取消映射的页所对应的页表项,其Dirty位置位,则确保它在页高速缓存内的相应页被标记为脏。
  3. 如果存于mapping的基树不为空(mapping->nrpages大于0),则调用filemap_fdatawrite()filemap_fdatawait()刷新所有脏页到磁盘,并等待I/O操作结束。
  4. 调用mapping地址空间的direct_IO方法。
  5. 如果操作类型为WRITE,则调用invalidate_inode_pages2()扫描mapping基树中的所有页并释放它们。该函数同时也清空指向这些页的用户态表项。

大多数情况下,direct_IO方法都是__blockdev_direct_IO()的封装函数:

  • 对存放在相应块中要读或写的数据进行拆分,确定数据在磁盘上的位置,并添加一个或多个用于描述要进行的I/O操作的bio描述符。
  • 数据将被直接从iov数组中iovec描述符确定的用户态缓冲区读写。
  • 调用submit_bio()bio描述符提交给通用层。

通常,__blockdev_direct_IO()不立即返回,而是等待所有的直接I/O传送都已完成才返回。因此,一旦read()write()返回,应用程序就可以访问含有文件数据的缓冲区。

异步I/O

当用户态进程调用库函数读写文件时,一旦读写操作进入队列函数就结束。甚至有可能真正的I/O数据传输还没开始。这样调用进程可在数据正在传输时继续自己的运行。

使用异步IO很简单,应用程序通过open()打开文件,然后用描述请求操作的信息填充struct aiocb类型的控制块。struct aiocb最常用的字段:

  • aio_fildes,文件的文件描述符
  • aio_buf,文件数据的用户态缓冲区
  • aio_nbytes,待传输的字节数
  • aio_offset,读写操作在文件中的起始位置

最后,应用程序将控制块地址传给aio_read()aio_write()。一旦请求的I/O数据传输已由系统库或内核送进队列,这两个函数就结束。应用程序可调用aio_error()检查正在运行的I/O操作的状态:如果数据传输仍在进行,则返回EINPROGRESS;完成则返回0;失败则返回一个错误码。

aio_return()返回已完成异步I/O操作的有效读写字节数;失败则返回-1。

Linux2.6中的异步I/O

异步I/O可由系统库实现而不完全需要内核支持,实际上aio_read()aio_write()克隆当前进程,子进程调用同步的read()write(),然后父进程结束aio_read()aio_write()并继续执行。Linux 2.6内核版运用一组系统调用实现异步I/O。

异步I/O环境

如果一个用户态进程调用io_submit()开始异步I/O操作,它必须预先创建一个异步I/O环境。

基本上,一个异步I/O环境(简称AIO环境)就是一组数据结构,该数据结构用于跟踪进程请求的异步I/O操作的运行情况。每个AIO环境与一个kioctx对象关联,kioctx对象存放了与该环境有关的所有信息。一个应用可创建多个AIO环境。一个给定进程的所有kioctx描述符存放在一个单向链表中,该链表位于内存描述符的ioctx_list字段。

AIO是被kioctx对象使用的重要的数据结构。AIO环是用户态进程中地址空间的内存缓冲区,它可以由内核态的所有进程访问。kioctx对象的ring_info.mmap_basering_info.mmap_size字段分别存放AIO环的用户态起始地址和长度。ring_info.ring_pages字段存放一个数组指针,该数组存放含有AIO环的页框的描述符。

AIO环实际上一个环形缓冲区,内核用它来写正运行的异步I/O操作的完成报告AIO环的第一个字节有一个首部(struct aio_ring数据结构),后面的所有字节是io_event数据结构,每个表示一个已完成的异步I/O操作。因为AIO环的页映射到进程的用户态地址空间,应用可以直接检查正运行的异步I/O操作的情况,从而避免使用相对较慢的系统调用。

io_setup()为调用进程创建一个新的AIO环境。参数:正在运行的异步I/O操作的最大数目(确定AIO环的大小)和一个存放环境局部的变量指针(AIO环的基地址)。

sys_io_setup()服务例程实际上调用do_mmap()为进程分配一个存放AIO环的新匿名线性区,然后创建和初始化该AIO环境的kioctx对象。

io_destroy()删除AIO环境和含有对应AIO环的匿名线性区。该系统调用阻塞当前进程直到所有正在运行的异步I/O操作结束。

提交异步I/O操作

io_submit()参数:

  • ctx_id,由io_setup()(标识AIO环境)返回的句柄
  • iocbppiocb类型描述符的指针数组的地址,每项元素描述一个异步I/O操作。
  • nriocbpp指向的数组的长度

iocb数据结构与POSIX aiocb描述符有同样的字段aio_fildesaio_bufaio_nbytesaio_offsetaio_lio_opcode字段存放请求操作的类型(如readwritesync)。

sys_io_submit()服务例程执行下列步骤:

  1. 验证iocb描述符数组的有效性。
  2. 在内存描述符的ioctx_list字段所对应的链表中查找ctx_id句柄对应的kioctx对象。
  3. 对数组中的每个iocb描述符,执行下列步骤:
    1. 获得aio_fildes字段中的文件描述符对应的文件对象地址。
    2. 为该I/O操作分配和初始化一个新的kiocb描述符。
    3. 检查AIO环中是否有空闲位置来存放操作的完成情况。
    4. 根据操作类型设置kiocb描述符的ki_retry方法。
    5. 执行aio_run_iocb(),实际上调用ki_retry方法为相应的异步I/O操作启动数据传输。如果ki_retry方法返回-EIOCBRETRY,则表示异步I/O操作已提交但还没有完全成功:稍后在这个kiocb上,aio_run_iocb()会被再次调用;否则,调用aio_complete()为异步I/O操作在AIO环中追加完成事件。

如果异步I/O操作是一个读请求,那么对应kiocb描述符的ki_retry方法由aio_pread()实现。该函数实际上执行文件对象的aio_read方法,然后按照aio_read方法的返回值更新kiocb描述符的ki_bufki_left字段。最后,aio_pread()返回从文件读入的有效字节数,或者,如果函数确定请求的字节没有传输完,则返回-EIOCBRETRY

对于大部分文件系统,文件对象的aio_read方法就是调用__generic_file_aio_read()。如果文件的O_DIRECT标志置位,函数就调用generic_file_aio_read()。但这种情况下,__blockdev_direct_IO()不阻塞当前进程使之等待I/O数据传输完毕,而是立即返回。因为异步I/O操作仍在运行,aio_run_iocb()会被再次调用,调用者是aio_wq工作队列的aio内核线程。kiocb描述符跟踪I/O数据传输的运行。所有数据传输完毕后,将完成结果追加到AIO环。

如果异步I/O操作是一个写请求,则对应kiocb描述符的ki_retry方法由aio_pwrite()实现。该函数实际上执行文件对象的aio_write方法,然后按照aio_write方法的返回值更新kiocb描述符的ki_bufki_left字段。最后aio_pwrite()返回写入文件的有效字节数,或者,如果函数确定请求的字节没有传输完,则返回-EIOCBRETRY。对于大部分文件系统,文件对象的aio_write方法就是调用generic_file_aio_write_nolock()。如果文件的O_DIRECT标志置位,就调用generic_file_direct_IO()

回收页框

页框回收算法

Linux 内核的页框回收算法(page frame reclaiming algorithm, PFRA)采取从用户态进程和内核高速缓存“窃取”页框的办法补充伙伴系统的空闲块列表

页框回收算法的目标之一就是保存最少的空闲页框池以便内核可以安全地从“内存紧缺”的情形中恢复过来

选择目标页

PFRA 的目标是获得页框并使之空闲。PFRA选取的页框肯定不是空闲的,这些页框原本不在伙伴系统的任何一个free_area中。PFRA 按照页框所含的内容,以不同的方式处理页框:

表中所谓“映射页”是指该页映射了一个文件的某个部分。比如,属于文件内存映射的用户态地址空间中所有页都是映射页,页高速缓存中的任何其他页也是映射页。映射页差不多都是可同步的:为回收页框,内核必须检査页是否为脏,而且必要时将页的内容写到相应的磁盘文件中

相反,所谓的“匿名页”是指它属于一个进程的某匿名线性区。为回收页框,内核必须将页中内容保存到一个专门的磁盘分区或磁盘文件,叫做交换区。因此,所有匿名页都是可交换的。通常,特殊文件系统中的页是不可回收的。

当PFRA必须回收属于某进程用户态地址空间的页框时,它必须考虑页框是否为共享的。共享页框属于多个用户态地址空间,而非共享页框属于单个用户态地址空间。注意,非共享页框可能属干几个轻量级进程,这些进程使用同一个内存描述符。

当进程创建子进程时,就建立了共享页框。正如第九章“写时复制”一节所述,子进程页表都从父进程中复制过来的,父子进程因此共享同一个页框。共享页框的另一个常见情形是:一个或多个进程以共享内存映射的方式访问同一个文件。

PFRA设计

确定回收内存的候选页可能是内核设计中最精巧的问题,需要同时满足系统响应和对内存需求量大的要求。

PFRA 采用的几个总的原则:

  • 首先释放“无害”页。先回收没有被任何进程使用的磁盘与内存高速缓存中的页,不需要修改任何页表项。
  • 将用户态进程的所有页定为可回收页。除了锁定页,PFRA 必须能窃得任何用户态进程页,包括匿名页。这样,睡眠时间较长的进程将逐渐失去所有页框。
  • 同时取消引用一个共享页框的所有页表项的映射,清空引用该页框的所有页表项,就可以回收该共享页框。
  • 只回收“未用”页。使用简化的最近最少使用(LRU)置换算法,PFRA 将页分为在用未用。如果某页很长时间没有被访问,那么它将来被访问的可能性较小,可被看作未用;另一方面,如果某页最近被访问过,那么它将来被访问的可能性较大,被看作在用。PFRA 只回收未用页。

LRU算法的主要思想是用一个计数器来存放RAM中每一页的页年龄,即上次访问该页到现在已经过去的时间,PFRA只回收任何进程的最旧页。但Linux内核没有计数器这样的硬件,而是使用每个页表项中的访问标志位,在页被访问时,该标志位由硬件自动置位;而且,页年龄由页描述符在链表中的位置表示。

页框回收算法是几种启发式方法的混合:

  • 谨慎选择检查高速缓存的顺序。
  • 基于页年龄的变化排序。
  • 区别对待不同状态的页。

反向映射

PFRA的目标之一是能释放共享页框。为此,Linux 2.6要能快速定位指向同一页框的所有页表项,该过程为反向映射

反向映射解决的简单方式是在页描述符中引入附加字段,从而将某页描述符所确定的页框中对应的所有页表项连接起来。为方便更新该链表,Linux 2.6 采用面向对象的反向映射技术。对任何可回收的用户态页,内核保留系统中该页所在所有线性区(对象)的反向链接,每个线性区描述符存放一个指向一个描述符的指针,而该内存描述符又包含一个指向一个页全局目录的指针。因此,这些反向链接使得PFRA能够检索引用某页的所有页表项。因为线性区描述符比页描述符少,所以更新共享页的反向链接就比较省时间。

首先,PFRA 必须要确定待回收页是共享的或非共享的,以及是映射页或匿名页。为此,内核要查看页描述符的两个字段:_mapcountmapping_mapcount字段存放引用页框的页表项数目,初值为-1,表示没有页表项引用该页框;如果为0,表示页是非共享的;如果大于0,表示页是共享的。
page_mapcount函数接收页描述符地址,返回值为_mapcount+1(这样,如果返回值为 1,表明是某个进程的用户态地址控件存放的一个非共享页)。

mapping字段用于确定页是映射的或匿名映射的。

  • 如果mapping字段空,则该页属于交换高速缓存。
  • 如果mapping字段非空,且最低位是1,表示该页是匿名页;同时mapping字段中存放指向anon_vma描述符的指针。
  • 如果mapping字段非空,且最低位是0,表示该页是映射页;同时mapping字段指向对应文件的address_space对象。

Linux的address_space对象在RAM中是对齐的,所以其起始地址是4的倍数。因此其mapping字段的最低位可以用一个标志位来表示该字段的指针是指向address_space对象还是anon_vma描述符。PageAnon()参数为页描述符,如果mapping字段的最低位置位,则函数返回1;否则返回0。

try_to_unmap()参数为页描述符指针,它尝试清空所有引用该页描述符对应页框的页表项。如果从页表项中成功清除所有对该页框的应用,函数返回SWAP_SUCCESS(0);否则返回SWAP_AGAIN(1);出错返回SWAP_FAIL(2)

1
2
3
4
5
6
7
8
9
10
11
int try_to_unmap(struct page *page)
{
int ret;
if(PageAnon(page)) // mapping 字段指向 aon_vma
ret = try_to_unmap_anon(page); // 清空对页框的引用,处理匿名页
else // mapping 字段指向 address_space
ret = try_to_unmap_file(page); // 清空对页框的引用,处理映射页
if(!page_mapped(page))
ret = SWAP_SUCCESS;
return ret;
}

匿名页的反向映射

匿名页经常由几个进程共享。最常见的情形:创建新进程,父进程的所有页框,包括匿名页,同时也分配给子进程。另外(不常见),进程创建线性区时使用两个标志MAP_ANONYMOUSMAP_SHARED,表明这个区域内的也将由该进程后面的子进程共享。

将引用同一页框的所有匿名页链接起来的策略:将该页框所在的匿名线性区存放在一个双向循环链表中。注意:即使一个匿名线性区存有不同的页,也始终只有一个反向映射链表用于该区域中的所有页框。

当为一个匿名线性区分配第一页时,内核创建一个新的anon_vma数据结构,它只有两个字段:

  • lock,竞争条件下保护链表的自旋锁。
  • head,线性区描述符双向循环链表的头部。

然后,内核将匿名线性区的vm_area_struct描述符插入anon_vma链表。vm_area_struct中对应链表的两个字段:

  • anon_vma_node,存放指向链表中前一个和后一个元素的指针。
  • anon_vma,指向anon_vma数据结构。

最后,内核将anon_vma数据结构的地址存放在匿名页描述符的mapping字段。

当已被一个进程引用的页框插入另一个进程的页表项时(如调用fork()时),内核只是将第二个进程的匿名线性区插入anon_vma数据结构的双向循环链表,而第一个进程线性区的anon_vma字段指向该anon_vma数据结构。因此,每个anon_vma链表通常包含不同进程的线性区。

借助anon_vma链表,内核可快速定位引用同一匿名页框的所有页表项。每个区域描述符在vm_mm字段中存放内存描述符地址,而该内存描述符又有一个pgd字段,其中存有进程的页全局目录。这样,页表项就可以从匿名页的起始线性地址得到,该线性地址可以由线性区描述符及页描述符的index字段得到。

try_to_unmap_anon()

当回收匿名页框时,PFRA必须扫描anon_vma链表中的所有线性区,检查是否每个区域都存有一个匿名页,而其对应的页框就是目标页框。该工作通过try_to_unmap_anon()实现,参数为目标页框描述符,步骤:

  1. 获得anon_vma数据结构的自旋锁,页描述符的mapping字段指向该数据结构。
  2. 扫描线性区描述符的anon_vma链表。对该链表中的每个vma线性区描述符调用try_to_unmap_one(),参数为vma和页描述符。如果返回SWAP_FAIL,或如果页描述符的_mapcount字段表明已找到所有引用该页框的页表项,停止扫描。
  3. 释放第1步得到的自旋锁。
  4. 返回最后调用try_to_unmap_one()得到的值:SWAP_AGAIN(部分成功)或SWAP_FAIL(失败)。

try_to_unmap_one()

try_to_unmap_anon()try_to_unmap_file()调用。参数:

  • page,指向目标页描述符的指针。
  • vma,指向线性区描述符的指针。
  1. 计算出待回收页的线性地址。依据参数:线性区的起始线性地址vma->vm_start)、被映射文件的线性区偏移量vm->vm_pgoff)和被映射文件内的页偏移量page->index)。对于匿名页,vma->vm_pgoff字段是0或者vm_start/PAGE_SIZEpage->index字段是区域内的页索引或页的线性地址除以PAGE_SIZE
  2. 如果目标页是匿名页,则检查页的线性地址是否在线性区内。如果不是,则结束并返回SWAP_AGAIN
  3. vma->vm_mm得到内存描述符地址,并获得保护页表的自旋锁vma->vm_mm->page_table_lock
  4. 成功调用pgd_offset()pud_offset()pmd_offset()pte_offset_map()以获得对应目标页线性地址的页表项地址。
  5. 执行一些检查来验证目标页可有效回收。以下检查中,如果任何一项失败,跳到第12步,结束并返回一个有关的错误码:SWAP_AGAINSWAP_FAIL
    1. 检查指向目标页的页表项,失败时返回SWAP_AGAIN,可能失败的情形:
      1. 指向页框的页表项与COW关联,而vma标识的匿名线性地址仍然属于原页框的anon_vma链表。
      2. mremap()可重新映射线性区,并通过直接修改页表项将页移到用户态地址空间。这种特殊情况下,因为页描述符的index字段不能用于确定页的实际线性地址,所以面向对象的反向映射不能使用了。
      3. 文件内存映射是非线性的。
    2. 验证线性区不是锁定(VM_LOCKED)或保留(VM_RESERVED)的。如果有锁定(VM_LOCKED)或保留情况之一出现,就返回SWAP_FAIL
    3. 验证页表项中的访问标志位被清0。如果没有,将它清0,并返回SWAP_FAIL。访问标志置位表示页在用,因此不能被回收。
    4. 检查页是否始于交换高速缓存,此时它正由get_user_pages()处理。在这种情形下,为避免恶性竞争条件,返回SWAP_FAIL
  6. 页可以被回收。如果页表项的Dirty标志置位,则将页的PG_dirty标志置位。
  7. 清空页表项,刷新相应的`TLB。
  8. 如果是匿名页,将换出页标识符插入页表项,以便将来访问时将该页换入。而且,递减存放在内存描述符anon_rss字段中的匿名页计数器。
  9. 递减存放在内存描述符rss字段中的页框计数器。
  10. 递减页描述符的_mapcount字段,因为对用户态页表项中页框的引用已被删除。
  11. 递减存放在页描述符_count字段中的页框使用计数器。如果计数器变为负数,则从活动或非活动链表中删除页描述符,且调用free_hot_page()释放页框。
  12. 调用pte_unmap()释放临时内核映射,因为第4步中的pte_offset_map()可能分配了一个这样的映射。
  13. 释放第3步中获得的自旋锁vma->vm_mm->page_table_lock
  14. 返回相应的错误码(成功时返回SWAP_AGAIN)。

映射页的反向映射

映射页的面向对象对象反向映射所基于的思想:总是可以获得指向一个给定页框的页表项,方式就是访问相应映射页所在的线性区描述符。因此,反向映射的关键是一个精巧的数据结构,该数据结构可存放与给定页框有关的所有线性区描述符。

与匿名页相反,映射页经常是共享的,因为不同的进程常会共享同一个程序代码。因此,Linux 2.6采用优先搜索树的结构快速定义引用同一页框的所有线性区。

每个文件对应一个优先搜索树。它存放在address_space对象的i_mmap字段,该对象包含在文件的索引节点对象中。因为映射页描述符的mapping字段指向address_space对象,所以总能快速检索搜索树的根。

优先搜索树PST

PST用于表示一组相互重叠的区间,也叫做McCreight树。PST的每个区间相当于一个树的节点,由基索引堆索引两个索引标识。基索引表示区间的起始点,堆索引表示终点。PST实际上是一个依赖于基索引的搜索树,并附加一个类堆属性,即一个节点的堆索引不会小于其子节点的堆索引。

Linux中的PST的不同之处:不对称被修改程存放线性区而不是线性区间。每个线性区可被看作是文件页的一个区间,并由在文件中的起始位置(基索引)和终点位置(堆索引)所确定。但是,线性区通常是从同一页开始,为此,PST的每个节点还附带一个大小索引,值为线性区大小(页数)减1。该大小索引使搜索程序能区分同一起始文件位置的不同线性区。

但大小索引会大大增加不同的节点数,会使PST溢出。为此,PST可以包括溢出子树,该子树以PST的叶为根,且包含具有相同基索引的节点。

此外,不同进程拥有的线性区可能是映射了相同文件的相同部分。当必须在PST中插入一个与现存某个节点具有相同索引值的线性区时,内核将该线性区描述符插入一个以原PST节点为根的双向循环列表。

上图的左侧有七个线性区覆盖着一个文件的前六页。每个区间标有基索引、堆索引和大小索引。图的右侧则是对应的PST。子节点的堆索引都不大于相应父节点的堆索引,任意一个节点的左子节点基索引也都不大于右子节点基索引,如果基索引相等则按照大小索引排序。

讨论由prio_tree_node数据结构表示的一个PST节点。该数据结构在每个线性区描述符的shared.prio_tree_node字段中。shared.vm_set数据结构作为shared.prio_tree_node的替代品,可将线性区描述符插入一个PST节点的链表副本。可用vma_prio_tree_insert()vma_prio_tree_remove()分别插入和删除PST节点。两个函数的参数都是线性区描述符地址与PST根地址。对PST的搜索可调用vma_prio_tree_foreach宏实现,该宏循环搜索所有线性区描述符,这些描述符在给定范围的线性地址中包含至少一页。

try_to_unmap_file()

try_to_unmap()调用,指向映射页的反向映射。执行步骤如下:

  1. 获得page->mapping->i_mmap_lock自旋锁。
  2. 对搜索树应用vma_prio_tree_foreach()宏,搜索树的根存放在page->mapping->i_mmap字段。对宏发现的每个vm_area_struct描述符,调用try_to_unmap_one()对该页所在的线性区页表项清0。失败时返回SWAP_FAIL,或者如果页描述符的_mapcount字段表明引用该页框的所有页表项都已找到,则搜索过程结束。
  3. 释放page->mapping->i_mmap_lock自旋锁。
  4. 根据所有的页表项清0与否,返回SWAP_AGAINSWAP_FAIL

如果映射是非线性的,则try_to_unmap_one()可能无法清0某些页表项,因为页描述符的index不再对应线性区中的页位置,try_to_unmap_one()就无法确定页的线性地址,也就无法得到页表项地址。

唯一的解决方法是对文件非线性区的穷尽搜索。双向链表以文件的所有非线性区的描述符所在的page->mapping文件的address_space对象的i_mmap_nonlinear字段为根。对每个这样的线性区,try_to_unmap_file()调用try_to_unmap_cluster()扫描该线性区地址所对应的所有页表项,并尝试将它们清0。

因为搜索可能很费时,所以执行有限扫描,而且通过试探法决定扫描线性区的哪一部分,vma_area_struct描述符的vm_private_data字段存有当前扫描的指针。因此try_to_unmap_fie()在某些清空下可能会找不到待停止映射的页,这时,try_to_umap()发现页仍然是映射的,则返回SWAP_AGAIN,而不是SWAP_SUCCESS

PFRA实现

页框回收算法必须处理多种属于用户态进程、磁盘高速缓存和内存高速缓存的页,且必须遵照几条试探法则,函数较多。

PFRA有几个入口。实际上,页框回收算法的执行有三种基本情形:

  • 内存紧缺回收,内核发现内存紧缺。
  • 睡眠回收,在进入suspend_to_disk状态时,内核必须释放内存。
  • 周期回收,必要时,周期性激活内核线程执行内存回收算法。

内存紧缺回收激活情形:

  • grow_buffers()无法获得新的缓冲区页。
  • alloc_page_buffers()无法获得页临时缓冲区首部。
  • __alloc_pages()无法在给定的内存管理区中分配一组连续页框。

周期回收由两种不同的内核线程激活:

  • kswapd内核线程,它检查某个内存管理区中空闲页框数是否已低于pages_high值。
  • events内核线程,它是预定义工作队列的工作者线程;PFRA周期性地调度预定义工作队列中的一个任务执行,从而回收slab分配器处理的位于内存高速缓存中的所有空闲slab。

最近最少使用(LRU)链表

属于进程用户态地址和空间或页高速缓存的所有页被分成两组:活动链表和非活动链表,它们被统称为LRU链表。活动链表存放最近被访问过的页;非活动链表存放有一段时间没有被访问过的页。显然,页必须从非活动链表窃取。

两个双向链表的头分别存放在每个zone描述符的active_listinactive_list字段,nr_activenr_inactive字段表示存放在两个链表中的页数。lru_lock字段是一个自旋锁,保护两个链表免受SMP系统上的并发访问。

如果页属于LRU链表,则设置页描述符的PG_lru标志。如果页属于活动链表,则设置PG_active标志,如果页属于非活动链表,则清PG_active标志。页描述符的lru字段存放指向LRU链表中下一个元素和前一个元素的指针。

常用辅助函数处理LRU链表:

  • add_page_to_active_list():将页加入管理区的活动链表头部并递增管理区描述符的nr_active字段
  • add_page_to_inactive_list():将页加入管理区的非活动链表头部并递增管理区描述符的nr_inactive字段
  • del_page_from_active_list():从管理区的活动链表中删除页并递减管理区描述符的nr_active字段
  • del_page_from_inactive_list():从管理区的非活动链表中删除页并递减管理区描述符的nr_inactive字段
  • del_page_from_lru():检查页的PG_active标识。根据检查结果,将页从活动或非活动链表中删除,递减管理区描述符的nr_active或者nr_inactive字段,且如有必要,将PG_active清0
  • activate_page():检查PG_active标识,如果没置位,将页移到活动链表中,依次调用del_page_from_inactive_list()add_page_to_active_lsit(),最后将PG_active置位。
  • lru_cache_add() 如果页不在LRU链表中,将PG_lru标志置位,得到管理区的lru_lock自旋锁,调用add_page_to_inactive_list()把页插入管理区的非活动链表
  • lru_cache_add_active():如果页不在LRU链表中,将PG_lruPG_active标志置位,得到管理区的lru_lock自旋锁,调用add_page_to_active_list()把页插入管理区的活动链表

在LRU链表之间移动页

PFRA将最近访问过的页集中放在活动链表上,将很长时间没有访问过的页集中放在非活动链表上。但是,两个状态不足以描述页的所有情况,因为PFRA不能预测用户的行为。

页描述符中的PG_referenced标志用来把一个页从非活动链表移到活动链表所需的访问次数加倍,也把一个页从活动链表移动到非活动链表所需的“丢失访问”次数加倍。例如:如果非活动链表的PG_referenced为0,第一次访问把这个标志置为1,但这一页仍然留在非活动链表;第二次访问这个标志为1时,才移动到活动链表。偶尔一次的访问并不能说明这个页是“活动的”。但如果第一次访问后在给定时间间隔内没有再次访问,则页框回收算法可能重置PG_referenced标志。

\

PFRA使用mark_page_accessed()page_referenced()refill_inactive_zone()函数在LRU链表之间移动页。

mark_page_accessed()

mark_page_accessed()把页标记为访问过。每当内核决定一个页是被用户态进程、文件系统层还是设备驱动程序引用时,该情况就会发生。调用mark_page_accessed()的情况:

  • 当按需装入进程的一个匿名页时。(do_anonymous_page()
  • 当按需装入内存映射文件的一个页时。(filemap_nopage()
  • 当按需装入 IPC 共享内存区的一个页时。(sheme_nopage()
  • 当从文件读取数据页时。(do_generic_file_read()
  • 当换入一个页时。(do_swap_page()
  • 当在页高速缓存中搜索一个缓冲区页时。(__fild_get_block()

mark_page_accessed()执行下列代码:

1
2
3
4
5
6
7
if(!PageActive(page) && PageReferenced(page) && PageLRU(page))
{
activate_page(page);
ClearPageReferenced(page);
}
else if(!PageReferenced(page))
SetPageReferenced(page);

page_referenced()

PFRA扫描一页调用一次page_referenced(),如果PG_referenced标志或页表项中的某些Accessed标志置位,则返回1;否则返回0。

  • 首先检查页描述符的PG_referenced标志,如果该标志置位则清0。
  • 然后使用面向对象的反向映射方法,对引用该页的所有用户态页表项中的Accessed标志位进行检查并清0。

从活动链表到非活动链表移动页不是由page_referenced()实施,而是refile_inactive_zone()

refile_inactive_zone()

shrink_zone()调用,shrink_zone()对页高速缓存和用户态地址空间进行页回收。两个参数:

  • zone:指向内存管理区描述符
  • sc:指向一个scan_control结构

refile_inactive_zone()的工作很重要,因为从活动链表移到非活动链表就意味着页迟早被PFRA捕获。如果该函数过激,就会由过多的页从活动链表移动到非活动链表,PFRA就会回收大量的页框,系统性能就会受到影响。但如果该函数太懒,就没有足够的未用页来补充非活动链表,PFRA就不能回收内存。因此,该函数可调整自己的行为:开始时,对每次调用,扫描非活动链表中少量的页,但当PFRA很难回收内存时,就在每次被调用时增加扫描的活动页数。

还有一个试探法可调整该函数的行为。LRU 链表中有两类页:属于用户态地址空间的页、不属于任何用户态进程且在页高速缓存中的页。PFRA倾向于压缩页高速缓存,而将用户态进程的页留在RAM中。该函数使用交换倾向经验值确定是移动所有的页还是只移动不属于用户态地址空间的页。

1
交换倾向值 = 映射比率 / 2 + 负荷值 + 交换值

映射比率是用户态地址空间所有内存管理区的页(sc->nr_mapped)占有可分配页框数的百分比。映射比率值大时表示动态内存大部分用于用户态进程,小则表示大部分用于页高速缓存。

负荷值用于表示PFRA在管理区中回收页框的效率。依据是前一次PFRA运行时管理区的扫描优先级,该优先级存放在管理区描述符的prev_priority字段。

交换值是一个用户定义常数,通常为60。系统管理员可在/proc/sys/vm/swappiness文件内修改该值,或用相应的sysctl()调整该值。

当管理区交换倾向值大于等于100时,页才从进程地址空间回收。
当系统管理员将交换值设为0时,PFRA就不会从用户态地址空间回收页,除非管理区的前一次优先级为0(不太可能)。如果管理员将交换值设为100,则PFRA每次调用该函数时都会从用户态地址空间回收页。

refill_inactive_zone()

  1. 调用lru_add_drain()把仍留在pagevec中的所有页移入活动与非活动链表。
  2. 获得zone->lru_lock自旋锁。
  3. 首次扫描zone->active_list中的页,从链表的底部开始向上,直到链表为空或sc->nr_to_scan的页扫描完毕。每扫描一页,就将引用计数器加1,从zone->active_list中删除页描述符,把它放在临时局部链表l_hold中。但是如果页框引用计数器是0,则把该页放回活动链表。
    实际上,引用计数器为0的页框一定属于管理区的伙伴系统,但释放页框时,首先递减使用计数器,然后将页框从LRU链表删除并插入伙伴系统链表。因此在一个很小的时间段,PFRA可能会发现LRU链表中的空闲页。
  4. 把已扫描的活动页数追加到zone->pages_scanned
  5. zone->nr_active中减去移入局部链表l_load中的页数。
  6. 释放zone->lru_lock自旋锁。
  7. 计算交换倾向值。
  8. 扫描局部链表l_hold中的页。目的:把其中的页分到两个子链表l_activel_inactive中。属于某个进程用户态地址空间的页(即page->_mapcount为负数的页)被加入l_active的条件是:
    1. 交换倾向值小于100
    2. 匿名页但又没有激活的交换区
    3. 应用于该页的page_referenced()返回正数(该页最近被访问过)
    4. 而在其他情形下,页被加入l_incative链表。
  9. 获得zone->lru_lock自旋锁。
  10. 扫描链表l_inactive中的页。把页移入zone->inactive_list链表,更新zone->nr_inactive字段,同时递减被移页框的使用计数器,从而抵消第3步中增加的值。
  11. 扫描局部链表l_active中的页。把页移入zone->active_list链表,更新zone->nr_active字段,同时递减被移页框的使用计数器,从而抵消第3步中增加的值。
  12. 释放自旋锁zone->lru_lock并返回。

内存紧缺回收

当内存分配失败时激活内存紧缺回收。在分配VFS缓冲区或缓冲区首部时,内核调用free_more_memory();而当从伙伴系统分配一个或多个页框时,调用try_to_free_pages()

free_more_memory()

  1. 调用wakeup_bdflush()唤醒一个pdflush内核线程,并触发页高速缓存中1024个脏页的写操作。写脏页到磁盘的操作将最终包含缓冲区、缓冲区首部和其他VFS数据结构的页框变为可释放的。
  2. 调用sched_yield()的服务例程为pdflush内核线程提供执行机会。
  3. 对系统的所有内存节点,启动一个循环。对每一个节点,调用try_to_free_pages(),参数为一个“紧缺”内存管理区链表。

try_to_free_pages()

参数:

  • zones,要回收的页所在的内存管理区链表。
  • gfp_mask,用于识别的内存分配的一组分配标志。
  • order,没有使用。

函数的目标是通过重复调用shrink_caches()shrink_slab()释放至少32个页框,每次调用后优先级会比前一次提高。有关的辅助函数可获得scan_control类型描述符中的优先级,以及正在进行的扫描操作的其他参数。如果try_to_free_pages()没能在某次调用shrink_caches()shrink_slab()时成功回收至少32个页框,PFRA就无策了。最后一招:out_of_memory()删除一个进程,释放它的所有页框

try_to_free_pages():

  1. 分配和初始化一个scan_control描述符,具体说就是把分配掩码gfp_mask存入gfp_mask字段。
  2. zones链表中的每个管理区,将管理区描述符的temp_priority字段设为初始优先级12,而且计算管理区LRU链表中的总页数。
  3. 从优先级12到0,执行最多13次循环,每次迭代执行如下子步骤:
    1. 更新scan_control描述符的一些字段。nr_mapped为用户态进程的总页数;priority为本次迭代的当前优先级;nr_scannednr_relcaimed字段设为0。
    2. 调用shrink_caches(),参数为zones链表和scan_control描述符,扫描管理区的非活动页。
    3. 调用shrink_slab()从可压缩内核高速缓存中回收页。
    4. 如果current->reclaim_state非空,则将slab分配器高速缓存中回收的页数追加到scan_control描述符的nr_reclaimed字段。在调用try_to_free_pages()前,__alloc_pages()建立current->reclaim_state字段,并在结束后马上清除该字段。
    5. 如果已达到目标,则跳出循环到第4步。
    6. 如果未达目标,但已扫描完成至少49页,则调用wakeup_bdflush()激活pdflush内核线程,并将页高速缓存中的一些脏页写入磁盘。
    7. 如果函数已完成4次迭代而又未达目标,则调用blk_congestion_wait()挂起进程,一直到没有拥塞的WRITE请求队列或100ms超时已到。
  4. 把每个管理区描述符的prev_priority字段设为上一次调用shrink_caches()使用的优先级,该值存放在管理区描述符的temp_priority字段。
  5. 如果成功回收则返回1,否则返回0。

shrink_caches()

try_to_free_pages()调用,参数:内存管理区链表zonesscan_control描述符地址sc

该函数的目的只是对zones链表中的每个管理区调用shrink_zone()。但对给定管理区调用shrink_zone()前,shrink_caches()sc->priority字段的值更新管理区描述符的temp_priority字段,这就是扫描操作的当前优先级。而且如果PFRA的上一次调用优先级高于当前优先级,即该管理区进行页框回收变得更难了,在shrink_caches()把当前优先级拷贝到管理区描述符的prev_priority。最后,如果管理区描述符中的all_unreclaimable标志置位,且当前优先级小于12,则shrink_caches()不调用shrink_zone(),即在try_to_free_pages()的第一次迭代中不调用shrink_caches()。当PFRA确定一个管理区都是不可回收页,扫描该管理区的页纯粹是浪费时间时,则将all_unreclaimable标志置位。

shrink_zone()

两个参数:zonesczone是指向struct_zone描述符的指针;sc是指向scan_control描述符的指针。目标是从管理区非活动链表回收32页。它每次在更大的一段管理区非活动链表上重复调用辅助函数shrink_cache()。而且shrink_zone()重复调用refill_incative_zone()来补充管理区非活动链表。

管理区描述符的nr_scan_activenr_scan_inactive字段在这里起到很重要的作用。为提高效率,函数每批处理32页。因此,如果函数在低优先级运行(对应sc->priority的高值),且某个LRU链表中没有足够的页,函数就跳过对它的扫描。但因此跳过的活动或不活动页数存放在nr_scan_activenr_scan_inactive中,这样函数下次执行时再处理这些跳过的页。

shrink_zone()具体执行步骤如下:

  1. 递增zone->nr_scan_active,增量是活动链表(zone->nr_active)的一小部分。实际增量取决于当前优先级,其范围是:zone->nr_active/2^{12}zone->nr_active/2^{0}(即管理区的总活动页数)。
  2. 递增zone->nr_scan_inactive,增量是非活动链表(zone->nr_inactive)的一小部分。实际增量取决于当前优先级,其范围是:zone->nr_active/2^{12}zone->nr_inactive
  3. 如果zone->nr_scan_active字段大于等于32,函数就把该值赋给局部变量nr_active,并把该字段设为0,否则把nr_active设为0。
  4. 如果zone->nr_scan_inactive字段大于等于32,函数就把该值赋给局部变量nr_inactive,并把该字段设为0,否则把nr_inactive设为0。
  5. 设定scan_control描述符的sc->nr_to_recalim字段为32。
  6. 如果nr_activenr_inactiv都为0,则无事可做,函数结束。这不常见,用户态进程没有被分配到任何页时才可能出现这种情形。
  7. 如果nr_active为正,则补充管理区非活动链表:
    1
    2
    3
    sc->nr_to_scan = min(nr_active, 32)
    nr_active -= sc->nr_to_scan
    refill_inactive_zone(zone, sc)
  8. 如果nr_inactive为正,则尝试从非活动链表回收最多32页:
    1
    2
    3
    sc->nr_to_scan = min(nr_inactive, 32)
    nr_inactive -= sc->nr_to_scan
    shrink_cache(zone, sc)
  9. 如果shrink_zone()成功回收32页,则结束;否则,跳回第6步。

shrink_cache()

shrink_cache()是一个辅助函数,其目的是从管理区非活动链表取出一组页,把它们放入一个临时链表,然后调用shrink_list()对该链表中的每个页进行有效的页框回收操作。参数与shrink_zones()一样,都是zonesc,执行的主要步骤:

  1. 调用lru_add_drain(),把仍然在pagevec数据结构中的页移入活动与非活动链表。
  2. 获得zone->lru_lock自旋锁。
  3. 处理非活动链表的页(最多32页),对于每一页,函数递增使用计数器;检查该页是否不会被释放到伙伴系统;把页从管理区非活动链表移入一个局部链表。
  4. zone->nr_inactive计数器的值减去从非活动链表中删除的页数。
  5. 递增zone->pages_scanned计数器的值,增量为在非活动链表中有效检查的页数。
  6. 释放zone->lru_lock自旋锁。
  7. 调用shrink_list(),参数为第3步中搜集的页(在局部链表中)。
  8. sc->nr_to_reclaim字段的值减去由shrink_list()实际回收的页数。
  9. 再次获取zone->lru_lock自旋锁。
  10. 把局部链表中shrink_list()没有成功释放的页放回非活动或活动来链表。shrink_list()有可能置位PG_active标志,从而将某页标记为活动页。该操作使用pagevec数据结构对一组页进行处理。
  11. 如果函数扫描的页数至少是sc->nr_to_scan,且如果没有成功回收目标页数(`sc->nr_to_reclaim>0),则跳回第3步。
  12. 释放zone->lru_lock自旋锁并结束。

shrink_list()

shrink_list()为页框回收算法的核心部分。从try_to_free_pagesshrink_cache()的目的就是找到一组适合回收的候选页。shrink_list()则从参数page_list链表中尝试回收这些页,第二个参数sc是指向scan_control描述符的指针。当shrink_list()返回时,page_list中剩下的是无法回收的页。

  1. 如果当前进程的need_resched字段置位,则调用schedule()
  2. 执行一个循环,处理page_list中的每一页。对其中的每个元素,从链表中删除页描述符并尝试回收该页框。如果由于某种原因页框不能释放,则把该页描述符插入一个局部链表。
  3. 现在page_list已空,函数在把页描述符从局部链表移回page_list链表。
  4. 递增sc->nr_reclaimed字段,增量为第2步中回收的页数,并返回该数。


上图是代码流程图。

shrink_list()处理的每个页框只能有三种结果:

  • 调用`free_cold_page(),把页释放到管理区伙伴系统,因此被有效回收。
  • 页没有被回收,因此被重新插入page_list链表。但是,shrink_list假设不久还能回收该页。因此函数让页描述符的PG_active标志保持清0,这样页将被放回内存管理区的非活动链表。
  • 页没有被回收,因此被重新插入page_list链表。但是,或是页正被使用,或是shrink_list()假设近期无法回收该页。函数将页描述符的PG_active标志置位,这样页框将被放回内存管理区的活动链表。

shrink_list()不会回收锁定页(PG_locked置位)与写回页(PG_writeback置位)。shrink_list()调用page_referenced()检查该页是否最近被引用过。

要回收匿名页,就必须把它加入交换高速缓存,那么就必须在交换区为它保留一个新页槽(slot)。

如果页在某个进程的用户态地址空间(页描述符的_mapcount字段大于等于0),则shrink_list()调用try_to_unmap()寻找引用该页框的所有页表项。当然,只有当该函数返回SWAP_SUCCESS时,回收才可继续。

如果是脏页,则写回磁盘前不能回收,为此,shrink_list()使用pageout()。只有当pageout()不必进行写操作或写操作不久将结束时,回收才可继续。

如果页包含VFS缓冲区,则shrink_list()调用try_to_release_page()释放关联的缓冲区首部。

最后,如果一切顺利,shrink_list()就检查页的引用计数器。如果等于2,则这两个拥有者就是:页高速缓存(如果是匿名页,则为交换高速缓存)和PFRA自己shrink_cache()第3步会递增引用计数器)。这种情况下,如果页仍然不为脏,则页可以回收。为此,首先根据页描述符的PG_swapcache标志的值,从页高速缓存或交换高速缓存删除该页,然后,执行函数free_cold_page()

pageout()

当一个脏页必须写回磁盘时,shrink_list()调用pageout()

  1. 检查页存放在页高速缓存还是交换高速缓存中。进一步检查该页是否由页高速缓存与PFRA拥有。如果检查失败,则返回PAGE_KEEP
  2. 检查address_space对象的writepage方法是否已定义。如果没有,则返回PAGE_ACTIVATE
  3. 检查当前进程是否可以向块设备(与address_space对象对应)请求队列发出写请求。实际上,kswapdpdflush内核线程总会发出写请求;
    而普通进程只有在请求对象不拥塞时才会发出写请求,除非current->bacing_dev_info字段指向块设备的backing_dev_info数据结构。
  4. 检查是否仍然是脏页。如果不是则返回PAGE_CLEAN
  5. 建立一个writeback_control描述符,调用address_space对象的writepage方法以启动一个写回操作。
  6. 如果writepage方法返回错误码,则函数返回PAGE_ACTIVATE
  7. 返回PAGE_SUCCCESS

回收可压缩磁盘高速缓存的页

内核在页高速缓存之外还使用其他磁盘高速缓存,例如目录项耗时缓存与索引节点高速缓存。当要回收其中的页框时,PFRA就必须检查这些磁盘高速缓存是否可压缩。

PFRA处理的每个磁盘高速缓存在初始化时必须注册一个shrinker函数。shrinker函数有两个参数:待回收页框数和一组GFP分配标志。函数按照要求从磁盘高速缓存回收页,然后返回仍然留在高速缓存内的可回收页数。

set_shrinker()向PFRA注册一个shrinker函数。该函数分配一个shrinker类型的描述符,在该描述符中存放shrinker函数的地址,然后把描述符插入一个全局链表,该链表存放在shrinker_list全局变量中。set_shrinker()还初始化shrinker描述符的seeks字段,通俗地说,该字段表示:在高速缓存中的元素一旦被删除后重建一个所需的代价。

在Linux 2.6中,向PFRA注册的磁盘高速缓存很少。除了目录项高速缓存和索引节点高速缓存外,注册shrinker函数的只有磁盘限额层文件系统源信息块高速缓存XFS日志文件系统

从可压缩磁盘高速缓存回收页的PFRA函数叫做shrink_slab()。它由try_to_free_pages()balance_pgdat()调用。

对于从可压缩磁盘高速缓存回收的代价与从LRU链表回收的代价之间,shrink_slab()试图作出一种权衡。实际上,函数扫描shrinker描述符的链表,调用这些shrinker函数并得到磁盘高速缓存中总的可回收页数。然后,函数再一次扫描shrinker描述符的链表,对于每个可压缩磁盘高速缓存,函数推算出待回收页框数。推算考虑的因素:磁盘高速缓存中总的可回收页数、在磁盘高速缓存中重建一页的相关代价、LRU链表中的页数。然后再调用shrinker函数尝试回收一组页(至少128页)。

从目录项高速缓存回收页框

shrink_dcache_memory()是目录项高速缓存的shrinker函数。它搜索高速缓存中的未用目录项对象,即没有被任何进程引用的目录项对象,然后将它们释放

由于目录项高速缓存对象是通过slab分配器分配的,因此shrink__dcache_memory()可能导致一些slab变成空闲的,这样有些页框就可以被cache_reap()回收。此外,目录项高速缓存索引节点起高速缓存控制器的作用,因此,当一个目录项对象被释放时,存放相应索引节点对象的页就可以变为未用,而最终被释放。

shrink_dcache_memory()接收两个参数:待回收页框数和GFP掩码。一开始,它检查GFP掩码中的__GFP_FS标志是否清0,如果是则返回-1,因为释放目录项可能触发基于磁盘文件系统的操作。通过调用prune_dcache(),就可以有效地进行页框回收。该函数扫描未用目录项链表,一直获得请求数量的释放对象或整个链表扫描完毕。对每个最近未被引用的对象,函数执行如下步骤:

  1. 把目录项对象从目录项散列表、从其父目录中的目录项对象链表、从拥有者索引节点的目录项对象链表中删除。
  2. 调用d_iput目录项方法或者iput()函数减少目录项的索引节点的引用计数器。
  3. 调用目录项对象的d_release方法。
  4. 调用call_rcu()函数以注册一个会删除目录项对象的回调函数,该回调函数又调用kmem_cache_free()把对象释放给slab分配器。
  5. 减少父目录的引用计数器。

最后,依据仍然留在目录项高速缓存中的未用目录项数,shrink_dcache_memory()返回一个值:未用目录项数乘以100除以sysctl_vfs_cache_pressure全局变量的值。该变量的系统默认值是100,因此返回值实际就是未用目录项数。但通过修改文件/proc/sys/vm/vfs_cache_pressure或通过有关的sysctl()系统调用,系统管理员可以改变该值。把值改为小于100,则使shrink_slab()从目录项高速缓存回收的页少于从 LRU 链表中回收的页。反之,如果把值改为大于100,则使shrink_slab()从目录项高速缓存回收的页多于从 LRU 链表中回收的页。

从索引节点高速缓存回收页框

shrink_icache_memory()被调用从索引节点高速缓存中删除未用索引节点对象。“未用”是指索引节点不再有一个控制目录对象。该函数非常类似于shrink_dcache_memory()。它检查gfp_mask参数的__GFP_FS位,然后调用prune_icache(),依据仍然留在索引节点高速缓存中的未用索引节点数和sysctl_vfs_cache_pressure变量的值,返回一个值。

prune_icache()又扫描inode_unsed链表。要释放一个索引节点,函数必须释放与该索引节点管理的任何私有缓冲区,它使页高速缓存内的不再使用的干净页框无效,然后通过调用clear_inode()destroy_inode()删除索引节点对象。

周期回收

PFRA用两种机制进行周期回收:kswapd内核线程和cache_reap函数。前者调用shrink_zone()shrink_slab()从LRU链表中回收页;
后者则被周期性地调用以便从slab分配器中回收未用的slab。

kswapd 内核线程

kswapd内核线程是激活内存回收的另外一种机制。kswapd利用机器空闲的时间保持内存空闲页也对系统性能有良好的影响,进程因此能很快获得自己的页。

每个内存节点对应各自的kswapd内核线程。每个这样的线程通常睡眠在等待队列中,该等待队列以节点描述符的kswapd_wait字段为头部。但是,如果__alloc_pages()发现所有适合内存分配的内存管理区包含的空闲页框数低于“警告”阈值时,那么相应内存节点的kswapd内核线程被激活。从本质上,为了避免更多紧张的“内存紧缺”的情形,内核才开始回收页框。

每个管理区描述符还包括字段pages_minpages_high。前者表示必须保留的最小空闲页框数阈值;后者表示“安全”空闲页框数阈值,即空闲页框数大于该阈值时,应该停止页框回收。

kswapd内核线程执行kswapd()。内核线程被初始化的内容是:

  • 把线程绑定到访问内存节点的CPU。
  • reclaim_state描述符地址存入进程描述符的current->reclaim_state字段。
  • current->flags字段的PF_MEMALLOCPF_KSWAP标志置位,其含义是进程将回收内存,运行时允许使用全部可用空闲内存。

kswapd()执行下列操作:

  1. 调用finish_wait()从节点的kswad_wait等待队列删除内核线程。
  2. 调用balance_pgdat()kswapd的内存节点进行内存回收。
  3. 调用prepare_to_wait()把进程设成TASK_INTERRUPTIBLE状态,并让它在节点的kswapd_wait等待队列中睡眠。
  4. 调用schedule()让CPU处理一些其他可运行进程。

balance_pgdat()执行下面步骤:

  1. 建立scan_control描述符。
  2. 把内存节点的每个管理区描述符中的temp_priority字段设为12(最低优先级)。
  3. 执行一个循环,从12到0最多13次迭代,每次迭代执行下列步骤:
    1. 扫描内存管理区,寻找空闲页框数不足的最高管理区(从ZONE_DMAZONE_HIGHMEM)。由zone_watermark_ok()进行每次的检测。如果所有管理区都有大量空闲页框,则跳到第4步。
    2. 对一部分管理区再一次进行扫描,范围是从ZONE_DMA到第3.1步找到的管理区。对每个管理区,必要时用当前优先级更新管理区描述符的prev_priority字段,且连续调用shrink_zone()以回收管理区中的页。然后,调用shrink_slab()从可压缩磁盘高速缓存回收页。
    3. 如果已有至少32页被回收,则跳出循环至第4步
  4. 用各自temp_priority字段的值更新每个管理区描述符的prev_priority字段。
  5. 如果仍有“内存紧缺”管理区存在,且如果进程的need_resched字段置位,则调用schedule()。当再一次执行时,跳到第1步。
  6. 返回回收的页数。

cache_reap()

PFRA使用cache_reap()回收slab分配器高速缓存的页。cache_reap()周期性(大约每两秒一次)地在预定事件工作队列中被调度。它的地址存放在每CPU变量reap_workfunc字段,该变量为work_struct类型。

`cache_reap() 执行下列步骤:

  1. 尝试获得cache_chain_sem信号量,该信号量保护slab高速缓存描述符链表。如果信号量已取得,就调用schedule_delayed_work()去调度该函数的下一次执行,然后结束。
  2. 否则,扫描存放在cache_chain链表中的kmem_cache_t描述符。对找到的每个高速缓存描述符,执行下列步骤:
    1. 如果高速缓存描述符的SLAB_NO_REAP标志置位,则页框回收被禁止,因此处理链表中的下一个高速缓存。
    2. 清空局部slab高速缓存,则会有新的slab被释放。
    3. 每个高速缓存都有“收割时间”,该值存放在高速缓存描述符中kmem_list3结构的next_reap字段。如果jiffies值仍然小于next_reap,则继续处理链表中的下一个高速缓存。
    4. 把存放在next_reap字段的下一次“收割时间”设为:从现在起的4s。
    5. 在多处理器系统中,函数清空slab共享高速缓存,那么会有新的slab被释放。
    6. 如果有新的slab最近被加入高速缓存,即高速缓存描述符中kmem_list3结构的free_touched标志置位,则跳过该高速缓存,继续处理链表中的下一个高速缓存。
    7. 根据经验公式计算要释放的slab数量。基本上,该数取决于高速缓存中空闲对象数的上限和能装入单个slab的对象数。
    8. 对高速缓存空闲slab链表中的每个slab,重复调用slab_destroy(),直到链表为空或者已回收目标数量的空闲slab。
    9. 调用cond_resched()检查当前进程的TIF_NEED_RESCHED标志,如果该标志置位,则调用schedule()
  3. 释放cache_chain_sem信号量。
  4. 调用schedule_delayed_work()去调度该函数的下一次执行,然后结束。

内存不足删除程序

当所有可用内存耗尽,PFRA使用所谓的内存不足删除程序,该程序选择系统中的一个进程,强行删除它并释放页框。当空闲内存十分紧缺且PFRA又无法成功回收任何页时,__alloc_pages()调用out_of_memory()out_of_memory()调用select_bad_process()在现有进程中选择一个“牺牲品”,然后调用oom_kill_process()删除该进程。

select_bad_process()挑选满足下列条件的进程:

  • 它必须拥有大量页框,从而可以释放出大量内存。
  • 删除它只损失少量工作结果(删除一个工作了几个小时或几天的批处理进程就不是个好主意)。
  • 它应具有较低的静态优先级,用户通常给不太重要的进程赋予较低的优先级。
  • 它不应是有root特权的进程,特权进程的工作通常比较重要。
  • 它不应直接访问硬件设备,因为硬件不能处在一个无法预知的状态。
  • 它不能是swapper(进程0)、init(进程1)和任何其它内核线程。

select_bad_process()扫描系统中的每个进程,根据以上准则用经验公式计算一个值,该值表示选择该进程的有利程度,然后返回最有利的被选进程描述符的地址。out_of_memory()再调用oom_kill_process()并发出死亡信号(通常是SIGKILL),该信号发给该进程的一个子进程,或如果做不到,就发给进程进程本身。oom_kill_process()同时也删除与被选进程共享内存描述符的所有克隆进程。

交换标记

交换失效:内存不足时PFRA全力把页写入磁盘以释放内存并从一些进程窃取相应的页框,而同时这些进程要继续执行也全力访问它们的页。结果就是页无休止地写入磁盘再读出来。

为减少交换失效的发生,Jiang和Zhang将交换标记赋给系统中的单个进程,该标记可避免该进程的页框被回收,所以进程可继续运行,而且即使内存十分稀少,也有希望运行至结束

交换标记的具体形式是swap_token_mm内存描述符指针。当进程拥有交换标记时,swap_token_mm被设为进程内存描述符的地址。

页框回收算法的免除以该简洁的方式实现了。在“最近最少使用(LRU)链表”中可知,只有一页最近没有被引用时,才可从活动链表移入非活动链表。page_referenced()进行这一检查。如果该页属于一个线性区,该区域所在进程拥有交换标记,则认可该交换标记并返回1(被引用)。实际上,交换标记在几种情形下不予考虑:

  • PFRA代表一个拥有交换标记的进程在运行。
  • PFRA达到页框回收的最难优先级(0级)。

grab_swap_token()决定是否将交换标记赋给当前进程。在以下两种情形下,对每个主缺页调用该函数:

  • filemap_nopage()发现请求页不在页高速缓存中时。
  • do_swap_page()从交换区读入一个新页时。

grap_swap_token()在分配交换标记前要进行一些检查,满足以下条件时才分配:

  • 上次调用grab_swap_token()后,至少已过了2s。
  • 在上次调用grab_swap_token()后,当前拥有交换标记的进程没再提出主缺页,或该进程拥有交换标记的时间超出swap_token_default_timeout个节拍。
  • 当进程最近没有获得过交换标记。

交换标记的持有时间最好长一些,甚至以分钟为单位,因为其目标就是允许进程完成其执行。在Linux 2.6中,交换标记的持有时间默认值很小,即一个节拍。但通过编辑/proc/sys/vm/swap_token_defaut_timeout文件或发出相应的sysctl()系统调用,系统管理员可修改swap_token_default_timeout变量的值。

当删除一个进程时,内核调用mmput()检查该进程是否拥有交换标记,如果是则放开它。

交换

交换用来为非映射页在磁盘上提供备份。有三类页必须由交换子系统处理:

  • 属于进程匿名线性区(例如,用户态堆栈和堆)的页。
  • 属于进程私有内存映射的脏页。
  • 属于 IPC 共享内存区的页。

交换对于程序是透明的,不需要在代码中嵌入与交换有关的特别指令。Linux利用页表项中的其他位存放换出页标识符。该标识符用于编码换出页在磁盘上的位置。

交换子系统的主要功能总结如下:

  • 在磁盘上建立交换区,用于存放没有磁盘映像的页。
  • 管理交换区空间。当需求发生时,分配与释放页槽。
  • 提供函数用于从RAM中把页换出到交换区或从交换区换入到RAM中。
  • 利用页表项(现已被换出的换出页页表项)中的换出页标识符跟踪数据在交换区中的位置。

交换是页框回收的一个最高级特性。如果需要确保进程的所有页框都能被PFRA随意回收,而不仅仅是回收有磁盘映像的页,就必须使用交换。可以用swapoff命令关闭交换,但随着磁盘系统负载增加,很快磁盘系统就会瘫痪。

交换可以用来扩展内存地址空间,使之被用户态进程有效地使用,但性能比RAM慢几个数量级。

交换区

从内存中换出的页存放在交换区中,交换区的实现可以使用自己的磁盘分区,也可以使用包含在大型分区中的文件。可定义几种不同的交换区,最大个数由MAX_SWAPFILES宏确定。

如果有多个交换区,就允许系统管理员把大的交换空间分布在几个磁盘上,以使硬件可以并发操作这些交换区;这一处理还允许在系统运行时不用重新启动系统就可以扩大交换空间的大小。

每个交换区都由一组页槽组成,即一组4096字节大小的块组成,每块中包含一个换出的页。交换区的第一个页槽用来永久存放有关交换区的信息,其格式由swap_header联合体描述。magic结构提供了一个字符串,用来把磁盘某部分明确地标记为交换区,它只含有一个字段magic.magic,该字段含有一个10字符的magic字符串。maginc结构从根本上允许内核明确地把一个文件或分区标记成交换区,该字符串的内容就是SWAPSPACE2,通常位于第一个页槽的末尾

info结构包含以下字段:

  • bootbits:交换算法不使用该字段,该字段对应交换区的第一个1024字节,可存放分区数据、磁盘标签等。
  • version:交换算法的版本
  • last_page:可有效使用的最后一个页槽
  • nr_badpages:有缺陷页的页槽的个数
  • padding[125]:填充字节
  • badpages[1]:一共637个数字,用来指定有缺陷页槽的位置

创建与激活交换区

交换区包含很少的控制信息,包括交换区类型和有缺陷页槽的链表,存放在一个单独的4KB页中。

通常,系统管理员在创建Linux系统中的其它分区时都创建一个交换区,然后使用mkswap命令把这个磁盘区设置成一个新的交换区。该命令对刚才介绍的第一个页槽中的字段进行初始化。由于磁盘中可能会有一些坏块,该程序还可以对其它所有的页槽进行检查从而确定有缺陷页槽的位置。但是执行mkswap命令会把交换区设置成非激活的状态。每个交换区都可以在系统启动时在脚本文件中被激活,也可以在系统运行之后动态激活。

每个交换区由一个或多个交换子区组成,每个交换子区由一个swap_extent描述符表示,每个子区对应一组页,它们在磁盘上是物理相邻的。swap_extent描述符由几部分组成:交换区的子区首页索引子区的页数子区的起始磁盘扇区号。当激活交换区自身的同时,组成交换区的有序子区链表也被创建。存放在磁盘分区中的交换区只有一个子区;但是,存放在普通文件中的交换区可能有多个子区,这是因为文件系统有可能没把该文件全部分配在磁盘的一组连续块中。

如何在交换区中分布页

内核尽力把换出的页放在相邻的页槽中,从而减少在访问交换区时磁盘的寻道时间。如果系统使用了多个交换区,快速交换区(也就是存放在快速磁盘中的交换区)可以获得比较高的优先级。当查找一个空闲页槽时,要从优先级最高的交换区中开始搜索。

交换区描述符

每个活动的交换区在内存中都有自己的swap_info_struct描述符。

flags字段包含三个重叠的子字段:

  • SWP_USED:如果交换区是活动的,就是1,否则就是0
  • SWP_WRITEOK:如果交换区是可写的,就是1,否则就是0
  • SWP_ACTIVE:如果前边两个字段置位,那么SWP_ACTIVE置位

swap_map字段指向一个计数器数组,交换区的每个页槽对应一个元素。如果计数器值等于0,则该页槽就是空闲的;如果计数器为正数,那么换出页就填充了这个页槽。实际上,页槽计数器的值就表示共享换出页的进程数。如果计数器的值为SWAP_MAP_MAX(32767),那么存放在该页槽中的页就是“永久”的,并且不能从相应的页槽中删除。如果计数器的值是SWAP_MAP_BAD(32768),那么就认为该页槽是有缺陷的,不可用。

prio字段是一个有符号的整数,表示交换子系统依据该值考虑每个交换区的次序。

sdev_lock字段是一个自旋锁,它防止SMP系统上对交换区数据结构的并发访问。swap_info数组包括MAX_SWAPFILES个交换区描述符。只有那些设置了SWP_USED标志的交换区才被使用,因为它们是活动区域。图说明了swap_info数组、一个交换区和相应的计数器数组的情况。

nr_swapfiles变量存放数组中包含或已包含所使用交换区描述符的最后一个元素的索引。这个变量有些名不符实,它并没有包含活动交换区的个数。

活动交换区描述符也被插入按交换区优先级排序的链表中。该链表是通过交换区描述符的next字段实现的,next字段存放的是swap_info数组中下一个描述符的索引。

swap_list_t类型的swap_list变量包括以下字段:

  • head,第一个链表元素在swap_info数组中的下标。
  • next,为换出页所选中的下一个交换区的描述符在swap_info数组中的下标。该字段用于在具有空闲页槽的最大优先级的交换区之间实现轮询算法。

交换区描述符的max字段存放以页为单位交换区的大小,而pages字段存放可用页槽的数目。这两个数字之所以不同是因为pages字段并没有考虑第一个页槽和有缺陷的页槽。

最后,nr_swap_pages变量包含所有活动交换区中可用的(空闲并且无缺陷)页槽数目,而total_swap_pages变量包含无缺陷页槽的总数。

换出页标识符

通过在swap_info数组中指定交换区的索引和在交换区内指定页槽的索引,可简单而又唯一地标识一个换出页。由于交换区的第一个页(索引为0)留给swap_header联合体,第一个可用页槽的索引就为1。

swp_entry(type, offset)宏负责从交换区索引type和页槽索引offset中构造出页标识符。swp_typeswp_offset宏正好相反,它们分别从换出页标识符中提取出交换区区索引和页索引。

当页被换出时,其标识符就作为页的表项插入页表中,这样在需要时就可以在找到该页。要注意这种标识符的最低位与Present标志对应,通常被清除来说明该页目前不在RAM中。但是,剩余31位中至少一位被置位,因为没有页存放在交换区0的页槽0中。这样就可以从一个页表项中区分三种不同的情况:

  • 空项,该页不属于进程的地址空间,或相应的页框还没有分配给进程(请求调页)。
  • 前31个最高位不全等于0,最后一位等于0,该页被换出。
  • 最低位等于1,该页包含在RAM中。

注意,交换区的最大值由表示页槽的可用位数决定。在80x86体系结构结构上,有24位可用,这就限制了交换区的大小为2^{24}个页槽。

由于一页可以属于几个进程的地址空间,所以它可能从一个进程的地址空间被换出,但仍旧保留在主存中;因此可能把同一个页换出多次。当然,一个页在物理上只被换出并存储一次,但后来每次试图换出该页都会增加swap_map计数器的值。

在试图换出一个已经换出的页时就会调用swap_duplicate()。该函数只是验证以参数传递的换出页标识符是否有效,并增加相应的swap_map计数器的值。执行下列操作:

  1. 使用swp_typeswp_offset宏从参数中提取出交换区号type和页槽索引offset
  2. 检查交换区是否被激活;如果不是,则返回0(无效的标识符)。
  3. 检查页槽是否有效且是否不为空闲(swap_map计数器大于0且小于SWAP_MAP_BAD);如果不是,则返回0(无效的标识符)。
  4. 否则,换出页的标识符确定出一个有效页的位置。如果页槽的swap_map计数器还没有达到SWAP_MAP_MAX,则增加它的值。
  5. 返回1(有效的标识符)。

激活和禁用交换区

一旦交换区被初始化,超级用户(任何具有CAP_SYS_ADMIN权能的用户)就可以分别使用swaponswapoff程序激活和禁用交换区。这两个程序分别使用了swapon()swapoff()系统调用。

sys_swapon()服务例程

sys_swapon()服务例程参数:

  • specialfile,指向设备文件(或分区)的路径名(在用户态地址空间),或指向实现交换区的普通文件的路径名。
  • swap_flags,由一个单独的SWAP_FLAG_PREFER位加上交换区优先级的31位组成(只有在SWAP_FLAG_PREFER位置位时,优先级位才有意义)。

sys_swapon()对创建交换区时放入第一个页槽中的swap_header联合体字段进行检查,执行下列步骤:

  1. 检查当前进程是否具有CAP_SYS_ADMIN权能。
  2. 在交换区描述符swap_info数组的前nr_swapfiles个元素中查找SWP_USED标志为0(即对应的交换区不是活动的)的第一个描述符。如果找到一个不活动交换区,则跳到第4步。
  3. 新交换区数组索引等于nr_swapfiles:它检查保留给交换区索引的位数是否足够用于编码新索引。如果不够,则返回错误代码;如果足够,就将nr_swapfiles的值增加1。
  4. 找到未用交换区索引:它初始化这个描述符的字段,即把flags置为SWP_USED,把lowest_bithighest_bit置为0。
  5. 如果swap_flags参数为新交换区指定了优先级,则设置描述符的prio字段。否则,就把所有活动交换区中最低位的优先级减1后赋给这个字段(这样就假设最后一个被激活的交换区在最慢的块设备上)。如果没有其它交换区是活动的,就把该字段设置为-1。
  6. 从用户态地址空间复制由specialfile参数所指向的字符串。
  7. 调用filp_open()打开由specialfile参数指定的文件。
  8. filp_open()返回的文件对象地址存放在交换区描述符的swap_file字段。
  9. 检查swap_info中其它的活动交换区,以确认该交换区还未被激活。具体为,检查交换区描述符的swap_file->f_mapping字段中存放的address_space对象地址。如果交换区已被激活,则返回错误码。
  10. 如果specialfile参数标识一个块设备文件,则执行下列子步骤:
    1. 调用bd_claim()把交换子系统设置为块设备的占有者。如果块设备已有一个占有者,则返回错误码。
    2. block_device描述符地址存入交换区描述符的bdev字段。
    3. 把设备的当前块大小存放在交换区描述符的old_block_size字段,然后把设备的块大小设成4096字节(即页的大小)。
  11. 如果specialfile参数标识一个普通文件,则执行下列子步骤:
    1. 检查文件索引节点i_flags字段中的S_SWAPFILE字段。如果该标志置位,说明文件已被用作交换区,返回错误码。
    2. 把该文件所在块设备的描述符地址存入交换区描述符的bdev字段。
  12. 调用read_cache_page()读入存放在交换区页槽0的swap_header描述符。参数为:swap_file->f_mapping指向的address_space对象、页索引0、文件readpage方法的地址(存放在swap_file->f_mapping->a_ops->readpage)和指向文件对象swap_file的指针。然后等待直到页被读入内存。
  13. 检查交换区中第一页的最后10个字符中的魔术字符串是否等于SWAPSPACE2。如果不是,返回一个错误码。
  14. 根据存放在swap_header联合体的info.last_page字段中的交换区大小,初始化交换区描述符的lowest_bithightest_bit字段。
  15. 调用vmalloc()创建与新交换区相关的计数器数组,并把它的地址存放在交换区描述符的swap_map字段中。还要根据swap_header联合体的info.bad_pages字段中存放的有缺陷的页槽链表把该数组的元素初始化为0或SWAP_MAP_BAD
  16. 通过访问第一个页槽中的info.last_pageinfo.nr_badpages字段计算可用页槽的个数,并把它存入交换区描述符的pages字段。而且把交换区中的总页数赋给max字段。
  17. 为新交换区建立子区链表extent_list(如果交换区建立在磁盘分区上,则只有一个子区),并相应地设定交换区描述符的nr_extentscurr_swap_extent字段。
  18. 把交换区描述符的flags字段设为SWP_ACTIVE
  19. 更新nr_good_pages、nr_swap_pagestotal_swap_pages三个全局变量。
  20. 把新交换区描述符插入swap_list变量所指向的链表中。
  21. 返回0(成功)。

sys_swapoff()服务例程

sys_swapoff()服务例程使specialfile参数所指定的交换区无效。sys_swapoff()sys_swapon()复杂,因为使之无效的这个分区可能仍然还包含几个进程的页。因此,强制该函数扫描交换区并把所有现有的页都换入。由于每个换入操作都需要一个新的页框,因此如果现在没有空闲页框,该操作就可能失败,函数返回一个错误码。

sys_swapoff()执行下列步骤。

  1. 验证当前进程是否具有CAP_SYS_ADMIN权能。
  2. 拷贝内核空间中specialfile所指向的字符串。
  3. 调用filp_open()打开specialfile参数确定的文件,返回文件对象的地址。
  4. 扫描交换区描述符链表swap_list,比较由filp_open()返回的文件对象地址与活动交换区描述符的swap_file字段中的地址,如果不一致,说明传给函数的是一个无效参数,返回一个错误码。
  5. 调用cap_vm_enough_memory()检查是否由足够的空闲页框把交换区上存放的所有页换入。如果不够,交换区就不能禁用,然后释放文件对象,返回错误码。当执行该项检查时,cap_vm_enough_memory()要考虑由slab高速缓存分配且SLAB_RECLAIM_ACCOUNT标志置位的页框,这样的页(可回收的页)的数量存放在slab_reclaim_pages变量中。
  6. swap_list链表中删除该交换区描述符。
  7. nr_swap_pagestotal_swap_pages的值中减去存放在交换区描述符的pages字段的值。
  8. 把交换区描述符flags字段中的SWAP_WRITEOK标志清0。这可禁止PFRA向交换区换出更多的页。
  9. 调用try_to_unuse()强制把该交换区中剩余的所有页都移到RAM中,并相应地修改使用这些页的进程的页表。当执行该函数时,当前进程的PF_SWAPOFF标志置位。该标志置位只有一个结果:如果页框严重不足,select_bad_process()就会强制选中并删除该进程。
  10. 一直等待交换区所在的块设备驱动器被卸载。这样在交换区被禁用之前,try_to_unuse()发出的读请求会被驱动器处理。
  11. 如果在分配所有请求的页框时try_to_unuse()失败,那么就不能禁用该交换区。因此,sys_swapoff()执行下列步骤:
    1. 把该交换区描述符重新插入swap_listl链表,并把它的flags字段设置为SWP_WRITEOK
    2. 把交换区描述符中pages字段的值加到nr_swap_pagestotal_swap_pages变量以恢复其原址。
    3. 调用filp_close()关闭在第3步中打开的文件,并返回错误码。
  12. 否则,所有已用的页槽都已经被成功传送到RAM中。因此,执行下列步骤:
    1. 释放存有swap_map数组和子区描述符的内存区域。
    2. 如果交换区存放在磁盘分区,则把块大小恢复到原值,该原值存放在交换区描述符的old_block_size字段。而且,调用bd_release()使交换子系统不再占有该块设备。
    3. 如果交换区存放再普通文件中,则把文件索引节点的S_SWAPFILE标志清0。
    4. 调用filp_close()两次,第一次针对swap_file文件对象,第二次针对第3步中filep_open()返回的对象。
    5. 返回0(成功)。

try_to_unuse()

try_to_unuse()使用一个索引参数,该参数标识待清空的交换区。该函数换入页并更新已换出页的进程的所有页表。因此,该函数从init_mm内存描述符开始,访问所有内核线程和进程的地址空间。这是一个比较耗时的函数,通常以开中断运行。因此,与其它进程的同步也是关键。

try_to_unuse()扫描交换区的swap_map数组。当它找到一个“在用”页槽时,首先换入其中的页,然后开始查找引用该页的进程。这两个操作顺序对避免竞争条件至关重要。当I/O数据传送正在进行时,页被加锁,因此没有进程可以访问它。一旦I/O数据传输完成,页又被try_tu_unuse()加锁,以使它不会被另一个内核控制路径再次换出。因为每个进程在开始换入或换出操作前查找页高速缓存,所以这也可以避免竞争条件。最后,由try_to_unuse()所考虑的交换区被标记为不可写(SWP_WRITEOK标志被清0),因此,没有进程可对该交换区的页槽执行换出。

但是,可能强迫try_to_unuse()对交换区引用计数器的swap_map数组扫描几次。这是因为对换出页引用的线性区可能在一次扫描中消失,而在随后又出现在进程链表中。

因此,try_to_unuse()对引用给定页槽的进程进行查找时可能失败,因为相应的线性区暂时没有包含在进程链表中。为了处理该情况,try_to_unuse()一直对swap_map数组进行扫描,直到所有的引用计数器都变为空。引用了换出页的线性区最终会重新出现在进程链表中,因此,try_to_unuse()中将会成功释放所有页槽。

try_to_unuse()的参数为交换区swap_map数组的引用计数器。该函数在引用计数器上执行连续循环,如果当前进程接收到一个信号,则循环中断,函数返回错误码。对于数组中的每个引用计数器,try_to_unuse()执行下列步骤:

  1. 如果计数器等于0(没有页存放在这里)或等于SWAP_MAP_BAD,则对下一个页槽继续处理。
  2. 否则,调用read_swap_cache_async()换入该页。这包括分配一个新页框(如果必要),用存放在页槽中的数据填充新页框并把该页存放在交换高速缓存。
  3. 等待,直到用磁盘中的数据适当地更新了该新页,然后锁住它。
  4. 当正在执行前一步时,进程有可能被挂起。因此,还要检查该页槽的引用计数器是否变为空,如果是,说明该交换页可能被另一个内核控制路径释放,然后继续处理下一个页槽。
  5. 对于以init_mm为头部的双向链表中的每个进程描述符,调用unuse_process()。该耗时的函数扫描拥有内存描述符的进程的所有页表项,并用该新页框的物理地址替换页表中每个出现的换出页标识符。为了反映这种移动,还要把swap_map数组中的页槽计数器减1(触发计数器等于SWAP_MAP_MAX),并增加该页框的引用计数器。
  6. 调用shmem_unuse()检查换出的页是否用于IPC共享内存资源,并适当地处理该种情况。
  7. 检查页的引用计数器。如果它的值等于SWAP_MAP_MAX,则页槽是永久的。为了释放它,则把引用计数器强制置为1。
  8. 交换高速缓存可能页拥有该页(它对应计数器的值起作用)。如果页属于交换高速缓存,就调用swap_writepage()把页的内容刷新到磁盘(如果页为脏),调用delete_from_swap_cache()从交换高速缓存删去页,并把页的引用计数减1。
  9. 设置页描述符的PG_dirty标志,并打开页框的锁,递减它的引用计数器(取消第5步的增量)。
  10. 检查当前进程的need_resched字段;如果它被设置,则调用schedule()放弃CPU。只要该进程再次被调度程序选中,try_to_unuse()就从该步继续执行。
  11. 继续到下一个页槽,从第1步开始。

try_to_unuse()继续执行,直到swap_map数组中的每个引用计数器都为空。即使该函数已经开始检查下一个页槽,但前一个页槽的引用计数器有可能仍然为正。事实上,一个进程可能还在引用该页,典型的原因是某些线性区已经被临时从第5步所扫描的进程链表中删除。try_to_unuse()最终会捕获到每个引用。但是,在此期间,页不再位于交换高速缓存,它的锁被打开,并且页的一个拷贝仍然包含在要禁用的交换区的页槽中。

当禁用交换区时该问题不会发生,因为只有在换出的页属于私有匿名内存映射时,才会受到“幽灵”进程的干扰。在这种情况下,用“写时复制”机制处理页框,所以,把不同的页框分配给引用了该页的进程是完全合法的。但是,try_to_unuse()将页标记为“脏”,否则,shrink_list()可能随后从某个进程的页表中删除该页,而并不把它保存在另一个交换区中。

分配和释放页槽

搜索空闲页槽的第一种方法可以选择下列两种既简单又有些极端的策略之一:

  • 总是从交换区的开头开始。这种方法在换出操作过程中可能会增加平均寻道时间,因为空闲页槽可能已经被弄得凌乱不堪。
  • 总是从最后一个已分配的页槽开始。如果交换区的大部分空间都是空闲的(通常如此),那么这种方法在换入操作过程中会增加平均寻道时间,因为所占用的为数不多的页槽可能是零散存放的。

Linux采用了一种混合的方法。除非发生以下条件,否则Linux总是从最后一个已分配的页槽开始查找。

  • 已经到达交换区的末尾。
  • 上次从交换区的开头重新开始后,已经分配了SWAPFILE_CLUSTER(通常是256)个空闲页槽。

swap_info_struct描述符的cluster_nr字段存放已分配的空闲页槽数。当函数从交换区的开头重新分配时该字段被重置为0。cluster_next字段存放在下一次分配时要检查的第一个页槽的索引。

为加速对空闲页槽的搜索,内核要保证每个交换区描述符的lowest_bithightest_bit字段是最新的。这两个字段定义了第一个和最后一个可能为空的页槽,换言之,所有低于lowest_bit和高于hightest_bit的页槽都被认为已经分配过。

scan_swap_map()

在给定的交换区中查找一个空闲页槽并返回其索引。参数指向交换区描述符。如果交换区不含有任何空闲页槽,就返回0。

scan_swap_map()

  1. 首先试图使用当前的簇。如果交换区描述符的cluster_nr字段是正数,就从cluster_next索引处的元素开始对计数器的swap_map数组进行扫描,查找一个空项。如果找到,就减少cluster_nr字段的值并转到第4步。
  2. 执行到该步,或者cluster_nr字段为空,或者从cluster_next开始搜索后没有在swap_map数组中找到空项。现在开始第二阶段的混合查找。把cluster_nr重新初始化成SWAPFILE_CLUSTER,并从lowest_bit索引处开始重新扫描该数组,以便试图找到有SWAPFILE_CLUSTER个空闲槽的一个组。如果找到,转第4步。
  3. 不存在SWAPFILE_CLUSTER个空闲页槽的组。从lowest_bit索引处开始重新开始扫描该数组,以便试图找到一个单独的空闲页槽。如果没有找到空项,就把lowest_bit字段设置为数组的最大索引,hightest_bit字段设置为0,并返回0(交换区已满)。
  4. 已经找到空项。把1放在空项中,减少nr_swap_pages的值,如果需要就修改lowest_bithighest_bit字段,把inuse_page字段的值加1,并把cluster_next字段设置成刚才分配的页槽的索引加1。
  5. 返回刚才分配的页槽的索引。

get_swap_page()

通过搜索所有活动的交换区来查找一个空闲页槽。返回一个新近分配页槽的换出页标识符,如果所有的交换区都填满,就返回0,该函数要考虑活动交换区的不同优先级。

该函数需要经过两遍扫描,在容易发现页槽时可以节约运行时间。第一遍是部分的,只适用于只有相同优先级的交换区。该函数以轮询的方式在这种交换区中查找一个空闲页槽。如果没有找空闲页槽,就从交换区链表的起始位置开始第二遍扫描。在第二遍扫描中,要对所有的交换区都进行检查。

get_swap_page()

  1. 如果nr_swap_pages为空或者如果没有活动的交换区,就返回0。
  2. 首先考虑swap_list.next所指向的交换区(交换区链表是按优先级排序的)。
  3. 如果交换区是活动的,就调用scan_swap_map()获得一个空闲页槽。如果scan_swap_map()返回一个页槽索引,该函数的任务基本就完成了,但还要准备下一次被调用。因此,如果下一个交换区的优先级和这个交换区的优先级相同(即轮询使用这些交换区),该函数就把swap_list.next修改成指向交换区链表中的下一个交换区。如果下一个交换区的优先级和当前交换区的优先级不同,该函数就把swap_list.next设置成交换区链表中的第一个交换区(下次搜索时从优先级最高的交换区开始)。该函数最终返回刚才分配的页槽所对应的换出页标识符。
  4. 或者交换区是不可写的,或者交换区中没有空闲页槽。如果交换区链表中的下一个交换区的优先级和当前交换区的优先级相同,就把下一个交换区设置成当前交换区并跳到第3步。
  5. 此时,交换区链表的下一个交换区的优先级小于前一个交换区的优先级。下一步操作取决于该函数正在进行哪一遍扫描。
    1. 如果只是第一遍(局部)扫描,就考虑链表中的第一个交换区并跳转到第3步,这样就开始第二遍扫描。
    2. 否则,就检查交换区链表中是否有下一个元素。如果有,就考虑这个元素并跳到第3步。
  6. 此时,第二遍对链表的扫描已经完成,并没有发现空闲页槽,返回0。

swap_free()

当换入页时,调用swap_free()以对相应的swap_map计数器减1。当相应的计数器达到0时,由于页槽的标识符不再包含在任何页表项中,因此页槽变为空闲。交换高速缓存页也记录页槽拥有者的个数。

该函数只作用于一个参数entryentry表示换出页标识符。函数执行下列步骤:

  1. entry参数导出交换区索引和页槽索引offset,并获得交换区描述符的地址。
  2. 检查交换区是否是活动的。如果不是,就立即返回。
  3. 如果正在释放的页槽对应的swap_map计数器小于SWAP_MAP_MAX,就减少该计数器的值。值为SWAP_MAP_MAX的项都被认为是永久的(不可删除的)。
  4. 如果swap_map计数器变为0,就增加nr_swap_pages的值,减少inuse_pages字段的值,如果需要就修改该交换区描述符的lowest_bithightest_bit字段。

交换高速缓存

向交换区来回传送页会引发很多竞争条件,具体说,交换子系统必须仔细处理如下情形:

  • 多重换入。两个进程可能同时换入同一个共享匿名页。
  • 同时换入换出。一个进程可能换入正由PFRA换出的页。

交换高速缓存的引入就是为了解决这类同步问题。关键的原则是,没有检查交换高速缓存是否包括了所涉及的页,就不能进行换入或换出操作。有了交换高速缓存,涉及同一页的并发交换操作总是作用于同一个页框的。因此,内核可以安全地依赖页描述符的PG_locked标志,以避免任何竞争条件。

考虑一下共享同一换出页的两个进程这种情形。当第一个进程试图访问页时,内核开始换入页操作,第一步就是检查页框是否在交换高速缓存中,假定页框不在交换高速缓存中,内核会分配一个新页框并把它插入交换高速缓存,然后开始I/O操作,从交换区读入页的数据;同时,第二个进程访问该匿名页,与上面相同,内核开始换入操作,检查涉及的页框是否在交换高速缓存中。现在页框在交换高速缓存,因此内核只是访问页框描述符,在PG_locked标志清0之前(即I/O数据传输完毕之前),让当前进程睡眠。

当换入换出操作同时出现时,交换高速缓存起着至关重要的作用。shrink_list()要开始换出一个匿名页,就必须当try_to_unmap()从进程(所拥有该页的进程)的用户态页表中成功删除了该页后才可以。但是当换出的写入操作还在执行时,可能有某个进程要访问该页,而产生换入操作。

在写入磁盘前,待换出页由shrink_list()存放在交换高速缓存。考虑页P由两个进程(A和B)共享。最初,两个进程的页表项都引用该页框,该页有2个拥有者,如图a所示。当PFRA选择回收页时,shrink_list()把页框插入交换高速缓存,如图b所示,现在页框有3个拥有者,而交换区中的页槽只被交换高速缓存引用。然后PFRA调用try_to_unmap()从这两个进程的页表项中删除对该页框的引用。一旦该函数结束,该页框就只有交换高速缓存引用它,而引用页槽的为这两个进程和交换高速缓存,如图c所示。假定:当页中的数据写入磁盘时,进程B访问该页,即它要用该页内部的线性地址访问内存单元。那么,缺页异常处理程序发现页框在交换高速缓存,并把物理地址放回B的页表项,如图d所示。

相反,如果缓存操作结束,而没有并发换入操作,shrink_list()则从交换高速缓存删除该页框并把它释放到伙伴系统,如图e所示。

交换高速缓存可被视为一个临时区域,该区域存有正在被换入后缓存的匿名页描述符。当换入或缓存结束时(对于共享匿名页,换入换出操作必须对共享该页的所有进程进行),匿名页描述符就可以从交换高速缓存删除。

交换高速缓存的实现

页高速缓存的核心就是一组基树,借助基树,算法就可以从address_space对象地址(即该页的拥有者)和偏移量值推算出页描述符的地址。在交换高速缓存中页的存放方式是隔页存放,并有下列特征:

  • 页描述符的mapping字段为NULL。
  • 页描述符的PG_swapcache标志置位。
  • private字段存放与该页有关的换出页标识符。

此外,当页被放入交换高速缓存时,页描述符的count字段和页槽引用计数器的值都增加,因为交换高速缓存既使用页框,也使用页槽。

最后,交换高速缓存的所有页只使用一个swapper_space地址空间,因此只有一个基树(有swapper_space.page_tree指向)对交换高速缓存中的页进行寻址。swapper_space地址空间的nrpages字段存放交换高速缓存中的页数。

交换高速缓存的辅助函数

处理交换高速缓存的函数主要有:

  • lookup_swap_cache():通过传递来的参数(换出页标识符)在交换高速缓存中查找页并返回页描述符的地址。如果该页不在交换高速缓存中,就返回0。该函数调用radix_tree_lookup()函数,把指向swapper_space.page_tree的指针(用于交换高速缓存中页的基树)和换出页标识符作为参数传递,以查找所需要的页。
  • add_to_swap_cache():把页插入交换高速缓存中。它本质上调用swap_duplicate()检查作为参数传递来的页槽是否有效,并增加页槽引用计数器;然后调用radix_tree_insert()把页插入高速缓存;最后递增页引用计数器并将PG_swapcachePG_locked标志置位。
  • add_to_swap_cache():与add_to_swap_cache()类似,但是,在把页框插入交换高速缓存前,这个函数不调用swap_duplicate()
  • delete_from_swap_cache():调用radix_tree_delete()从交换高速缓存中删除页,递减swap_map中相应的使用计数器,递减页引用计数器。
  • free_page_and_swap_cache():如果除了当前进程外,没有其它用户态进程正在引用相应的页槽,则从交换高速缓存中删除该页,并递减页使用计数器。
  • free_pages_and_swap_cache():与free_page_and_swap_cache()相似,但它是对一组页操作。
  • free_swap_and_cache():释放一个交换表项,并检查该表项引用的页是否在交换高速缓存。如果没有用户态进程(除了当前进程之外)引用该页,或者超过50%的交换表项在用,则从交换高速缓存中释放该页。

换出页

向交换高速缓存插入页框

换出操作的第一步就是准备交换高速缓存。如果shrink_list()确认某页为匿名页,且交换高速缓存中没有相应的页框(页描述符的PG_swapcache标志清0),内核就调用add_to_swap()

add_to_swap()在交换区中分配一个新页槽,并把一个页框(其页描述符地址作为参数传递)插入交换高速缓存。函数执行下述步骤。

  1. 调用get_swap_page()分配一个新页槽,失败(如没有发现空闲页槽)则返回0。
  2. 调用__add_to_page_cache(),参数为页槽索引、页描述符地址和一些分配标志。
  3. 将页描述符中的PG_uptodatePG_dirty标志置位,从而强制shrink_list()把页写入磁盘。
  4. 返回1(成功)。

更新页表项

一旦add_to_swap()结束,shrink_list()就调用try_to_unmap(),它确定引用匿名页的每个用户态页表项地址,然后将换出页标识符写入其中。

将页写入交换区

为完成换出操作需执行的下一个步骤是将页的数据写入交换区。这一I/O传输由shrink_list()激活,它检查页框的PG_dirty标志是否置位,然后执行pageout()

pageout()建立一个writeback_control描述符,且调用页address_space对象的writepage方法。而swapper_state对象的writepage方法是swap_writepage()实现。

swap_writepage()执行如下步骤。

  1. 检查是否至少有一个用户态进程引用该页。如果没有,则从交换高速缓存删除该页,并返回0。这一检查之所以必须做,是因为一个进程可能会与PFRA发生竞争并在shrink_list()检查后释放一页。
  2. 调用get_swap_bio()分配并初始化一个bio描述符。函数从换出页标识符算出交换区描述符地址,然后搜索交换子区链表,以找到页槽的初始磁盘扇区。bio描述符将包含一个单页数据请求(页槽),其完成方法设为end_swap_bio_write()
  3. 置位页描述符的PG_writeback标志和交换高速缓存基树的writeback标记,并将PG_locked标志清0。
  4. 调用submit_bio(),参数为WRITE命令和bio`描述符地址。
  5. 返回0。

一旦I/O数据传输结束,就执行end_swap_bio_write()。该函数唤醒正等待页PG_writeback标志清0的所有进程,清除PG_writeback标志和基树中的相关标记,并释放用于I/O传输的bio描述符。

从交换高速缓存中删除页框

缓存操作的最后一步还是由shrink_list()执行。如果它验证在I/O数据传输时没有进程试图访问该页框,就调用delete_from_swap_cache()从交换高速缓存中删除该页框。因为交换高速缓存是该页的唯一拥有者,该页框被释放到伙伴系统。

换入页

当进程试图对一个已缓存到磁盘的页进行寻址时,必然会发生页的换入。在以下条件发生时,缺页异常处理程序就会触发一个换入操作:

  • 引起异常的地址所在的页是一个有效的页,即它属于当前进程的一个线性区。
  • 页不在内存中,即页表项中的Present标志被清除。
  • 与页有关的页表项不为空,但Dirty位清0,这意味着页表项包含一个换出页标识符。

如果上面的所有条件都满足,则handle_pte_fault()调用相对建议的do_swap_page()换入所需页。

do_swap_page()

参数:

  • mm,引起缺页异常的进程的内存描述符地址。
  • vmaaddress所在的线性区的线性区描述符地址。
  • address,引起异常的线性地址。
  • page_table,映射address的页表项的地址。
  • pmd,映射address的页中间目录的地址。
  • orig_pte,映射address的页表项的内容。
  • write_access,一个标志,表示试图执行的访问是读操作还是写操作。

与其他函数相反,do_swap_page()不返回0。如果页已经在交换高速缓存中就返回1(次错误),如果页已经从交换区读入就返回2(主错误),如果在进行换入时发生错误就返回-1。函数执行下述步骤:

  1. orig_pte获得换出页标识符。
  2. 调用pte_unmap()释放任何页表的临时内核映射,该页表由handle_mm_fault()建立。访问高端内存页表需要进行内核映射。
  3. 释放内存描述符的page_table_lock自旋锁。
  4. 调用lookup_swap_cache()检查交换高速缓存是否已经含有换出页标识符对应的页;如果页已经在交换高速缓存中,就跳到第6步。
  5. 调用swapin_readhead()从交换区读取至多2n个页的一组页,其中包括所请求的页。值n存放在page_cluster变量中,通常等于3。其中每个页是通过调用read_swap_cache_async()读入的。
  6. 再一次调用read_swap_cache_async()换入由引起缺页异常的进程所访问的页。swapin_readahead()可能在读取请求的页时失败,如,因为page_cluster被置为0,或者该函数试图读取一组含空闲或有缺陷页槽(SWAP_MAP_BAD)的页。另一方面,如果swapin_readahead()成功,这次对read_swap_cache_async()的调用就会很快结束,因为它在交换高速缓存找到了页。
  7. 尽管如此,如果请求的页还是没有被加到交换高速缓存,那么,另一个内核控制路径可能已经代表这个进程的一个子进程换入了所请求的页。这种情况的检查可通过临时获取page_table_lock自旋锁,并把page_table所指向的表项与orig_pte进行比较来实现。如果二者有差异,则说明这一页已经被某个其它的内核控制路径换入,因此,函数返回1(次错误);否则,返回-1(失败)。
  8. 至此,页已经在高速缓存中。如果页已经被换入(主错误),函数就调用grab_swap_token()试图获得一个交换标记。
  9. 调用mark_page_accessed()并对页加锁。
  10. 获取page_table_lock自旋锁。
  11. 检查另一个内核控制路径是否代表这个进程的一个子进程换入了所请求的页。如果是,就释放page_table_lock自旋锁,打开页上的锁,并返回1(次错误)。
  12. 调用swap_free()减少entry对应的页槽的引用计数器。
  13. 检查交换高速缓存是否至少占满50%(nr_swap_pages小于total_swap_pages的一半)。如果是,则检查页是否被引起异常的进程(或其一个子进程)拥有;如果是,则从交换高速缓存中删除该页。
  14. 增加进程的内存描述符的rss字段。
  15. 更新页表项以便进程能找到该页。这一操作的实现是通过把所请求的页的物理地址和在线性区的vm_page_prot字段所找到的保护位写入page_table所指向的页表中完成。此外,如果引起缺页的访问是一个写访问,且造成缺页的进程页的唯一拥有者,则函数还要设置DirtyRead/Write标志以防无用的写时复制错误。
  16. 打开页上的锁。
  17. 调用page_add_anon_rmap()把匿名页插入面向对象的反向映射数据结构。
  18. 如果write_access参数等于1,则函数调用do_wp_page()复制一份页框。
  19. 释放mm->page_table_lock自旋锁,并返回1(次错误)或2(主错误)。

read_swap_cache_async()

换入一个页,就调用这个函数。参数:

  • entry,换出页标识符。
  • vma,指向该页所在线性区的指针。
  • addr,页的线性地址。

在访问交换分区前,该函数必须检查交换高速缓存是否已经包含了所要的页框。该函数本质上执行下列操作:

  1. 调用radix_tree_lookup(),搜索swapper_sapce对象的基树,寻找由换出页标识符entry给出位置的页框。如果找到该页,递增它的引用计数器,返回它的描述符地址。
  2. 页不在交换高速缓存。调用alloc_page()分配一个新的页框。如果没有空闲的页框可用,则返回0(表示系统没有足够的内存)。
  3. 调用add_to_swap_cache()把新页框的页描述符插入交换高速缓存,并对页加锁。
  4. 如果add_to_swap_cache()在交换高速缓存找到页的一个副本,则前一步可能失败。如,进程可能在第2步阻塞,因此允许另一个进程在同一个页槽上开始换入操作。这种情况下,该函数释放在第2步分配的页框,并从第1步重新开始。
  5. 调用lru_cache_add_active()把页插入LRU的活动链表。
  6. 新页框的页描述符现在已在交换高速缓存。调用swap_readpage()从交换区读入该页数据。该函数与swap_writepage()相似,将页描述符的PG_uptodate标志清0,调用get_swap_bio()为I/O传输分配与初始化一个bio描述符,再调用submit_bio()向块设备子系统层发出I/O请求。
  7. 返回页描述符的地址。

IO体系结构和设备驱动程序

IO体系结构

总线担当计算机内部主通信通道的作用。所有计算机都拥有一条系统总线,它连接大部分内部硬件设备。一种典型的系统总线是PCI(Peripheral Component Interconnect)总线。目前使用其他类型的总线也很多,如ISA、EISA、MCA、SCSI和USB。

典型的情况是,一台计算机包括几种不同类型的总线,它们通过被称作的硬件设备连接在一起。两条高速总线用于在内存芯片上来回传送数据:前端总线将CPU连接到RAM控制器上,而后端总线将CPU直接连接到外部硬件的高速缓存上。主机上的桥将系统总线和前端总线连接在一起。

CPU和I/O设备之间的数据通路通常称为I/O总线。80x86微处理器使用16位的地址总线对I/O设备进行寻址,使用8位、16位或32位的数据总线传输数据。每个I/O设备依次连接到I/O总线上,这种连接使用了包含3个元素的硬件组织层次:I/O端口接口设备控制器。下图显示了I/O体系结构的这些成分:

I/O端口

每个连接到I/O总线上的设备都有自己的I/O地址集,通常称为I/O端口(I/O port)。在IBM PC体系结构中,I/O地址空间一共提供了65536个8位的I/O端口。可以把两个连续的8位端口看成一个16位端口,但是这必须从偶数地址开始。同理,也可以把两个连续的16位端口看成一个32位端口,但是这必须是从4的整数倍地址开始

有四条专用的汇编语言指令可以允许CPU对I/O端口进行读写,它们是ininsoutouts。在执行其中的一条指令时,CPU使用地址总线选择所请求的I/O端口,使用数据总线在CPU寄存器和端口之间传送数据。

I/O端口还可以被映射到物理地址空间。因此,处理器和I/O设备之间的通信就可以使用对内存直接进行操作的汇编语言指令(例如,movandor等等)。现代的硬件设备更倾向于映射的I/O,因为这样处理的速度较快,并可以和DMA结合起来。

系统设计者的主要目的是对I/O编程提供统一的方法,但又不牺牲性能。为了达到这个目的,每个设备的I/O端口都被组织成如下图所示的一组专用寄存器。CPU把要发送给设备的命令写入设备控制寄存器,并从设备状态寄存器中读出表示设备内部状态的值。CPU还可以通过读取设备输入寄存器的内容从设备取得数据,也可以通过向设备输出寄存器中写入字节而把数据输出到设备。

inoutinsouts汇编语言指令都可以访问I/O端口。内核中包含了以下辅助函数来简化这种访问:

  • inb()inw()inl():分别从I/O端口读取1、2或4个连续字节。后缀“b”、“w”、“l”,分别代表一个字节(8位)、一个字(16位)以及一个长整型(32位)。
  • inb_p()inw_p()inl_p():分别从I/O端口读取1、2或4个连续字节,然后执行一条“哑元(dummy,即空指令)”指令使CPU暂停。
  • outb()outw()outl():分别向一个I/O端口写入1、2或4个连续字节。
  • outb_p()outw_p()outl_p():分别向一个I/O端口写入1、2或4个连续字节,然后执行一条“哑元”指令使CPU暂停。
  • insb()insw()insl():分别从I/O端口读取以1、2或4个字节为一组的连续字节序列。字节序列的长度由该函数的参数给出。
  • outsb()outsw()outsl():分别向I/O端口写入以1、2或4个字节为一组的连续字节序列。

资源表示某个实体的一部分,这部分被互斥地分配给设备驱动程序。在我们的情况中,一个资源表示I/O端口地址的一个范围。每个资源对应的信息存放在resource数据结构中。所有的同种资源都插入到一个树型数据结构中;例如,表示I/O端口地址范围的所有资源都包含在一个根节点为ioport_resource的树中:

节点的孩子被收集到一个链表中,其第一个元素由child指向。sibling字段指向链表中的下一个节点。

一般来说,树中的每个节点肯定相当于父节点对应范围的一个子范围。I/O端口资源树(ioport_resource)的根节点跨越了整个I/O地址空间(从端口0~65535)。一个典型的PC I/O端口资源分配如下:

  • 0000~000F:DMA控制器1
  • 0020~0021:主中断控制器
  • 0040~0043:系统时钟
  • 0060:键盘控制器控制状态口
  • 0061:系统扬声器
  • 0064:键盘控制器数据口
  • 0070~0071:系统CMOS/实时钟
  • 0080~0083:DMA控制器1
  • 0087~0088:DMA控制器1
  • 0089~008B:DMA控制器1
  • 00A0~00A1:从中断控制器
  • 00C0~00DF:DMA控制器2
  • 00F0~00FF:数值协处理器
  • 0170~0117:标准IDE/ESDI硬盘控制器
  • 01F0~01FF:标准IDE/ESDI硬盘控制器
  • 0200~0207:游戏口
  • 0274~0277:ISA即插即用计数器
  • 0278~027F:并行打印机口
  • 02F8~02FF:串行通信口2(COM2)
  • 0376:第二个IDE硬盘控制器
  • 0378~037F:并行打印口1
  • 03B0~03BB:VGA显示适配器
  • 03C0~03DF:VGA显示适配器
  • 03D0~03DF:彩色显示器适配器
  • 03F2~03F5:软磁盘控制器
  • 03F6:第一个硬盘控制器
  • 03F8~03FF:串行通信口1(COM1)
  • 0400~FFFF没有指明端口,供用户扩展使用

任何设备驱动程序都可以使用下面三个函数,传递给它们的参数为资源树的根节点和要插入的新资源数据结构的地址:

  • request_resource():把一个给定范围分配给一个I/O设备。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int request_resource(struct resource *root, struct resource *new)
{
struct resource *conflict;

write_lock(&resource_lock);
conflict = __request_resource(root, new);
write_unlock(&resource_lock);
return conflict ? -EBUSY : 0;
}
static struct resource * __request_resource(struct resource *root, struct resource *new)
{
resource_size_t start = new->start;
resource_size_t end = new->end;
struct resource *tmp, **p;

if (end < start)
return root;
if (start < root->start)
return root;
if (end > root->end)
return root;
p = &root->child;
for (;;) {
tmp = *p;
if (!tmp || tmp->start > end) {
new->sibling = tmp;
*p = new;
new->parent = root;
return NULL;
}
p = &tmp->sibling;
if (tmp->end < start)
continue;
return tmp;
}
}
  • allocate_resource():在资源树中寻找一个给定大小和排列方式的可用范围;若存在,就将这个范围分配给一个I/O设备(主要由PCI设备驱动程序使用,这种驱动程序可以配置成使用任意的端口号和主板上的内存地址对其进行配置)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
int allocate_resource(struct resource *root, struct resource *new,
resource_size_t size, resource_size_t min,
resource_size_t max, resource_size_t align,
void (*alignf)(void *, struct resource *,
resource_size_t, resource_size_t),
void *alignf_data)
{
int err;

write_lock(&resource_lock);
err = find_resource(root, new, size, min, max, align, alignf, alignf_data);
if (err >= 0 && __request_resource(root, new))
err = -EBUSY;
write_unlock(&resource_lock);
return err;
}

static int find_resource(struct resource *root, struct resource *new,
resource_size_t size, resource_size_t min,
resource_size_t max, resource_size_t align,
void (*alignf)(void *, struct resource *,
resource_size_t, resource_size_t),
void *alignf_data)
{
struct resource *this = root->child;

new->start = root->start;
/*
* Skip past an allocated resource that starts at 0, since the assignment
* of this->start - 1 to new->end below would cause an underflow.
*/
if (this && this->start == 0) {
new->start = this->end + 1;
this = this->sibling;
}
for(;;) {
if (this)
new->end = this->start - 1;
else
new->end = root->end;
if (new->start < min)
new->start = min;
if (new->end > max)
new->end = max;
new->start = ALIGN(new->start, align);
if (alignf)
alignf(alignf_data, new, size, align);
if (new->start < new->end && new->end - new->start >= size - 1) {
new->end = new->start + size - 1;
return 0;
}
if (!this)
break;
new->start = this->end + 1;
this = this->sibling;
}
return -EBUSY;
}
  • release_resource():释放以前分配给I/O设备的给定范围。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int release_resource(struct resource *old)
{
int retval;

write_lock(&resource_lock);
retval = __release_resource(old);
write_unlock(&resource_lock);
return retval;
}
static int __release_resource(struct resource *old)
{
struct resource *tmp, **p;

p = &old->parent->child;
for (;;) {
tmp = *p;
if (!tmp)
break;
if (tmp == old) {
*p = tmp->sibling;
old->parent = NULL;
return 0;
}
p = &tmp->sibling;
}
return -EINVAL;
}

内核也为以上应用于I/O端口的函数定义了一些快捷函数:request_region()分配I/O端口的给定范围,release_region()释放以前分配给I/O端口的范围。当前分配给I/O设备的所有I/O地址的树都可以从/proc/ioports文件中获得。

I/O接口

I/O接口是处于一组I/O端口和对应的设备控制器之间的一种硬件电路。它起翻译器的作用,即把I/O端口中的值转换成设备所需要的命令和数据。在相反的方向上,它检测设备状态的变化,并对起状态寄存器作用的I/O端口进行相应的更新。还可以通过一条IRQ线把这种电路连接到可编程中断控制器上,以使它代表相应的设备发出中断请求。

专用I/O接口

专门用于一个特定的硬件设备。在一些情况下,设备控制器与这种I/O接口处于同一块卡中。连接到专用I/O接口上的设备可以是内部设备,也可以是外部设备。

专用I/O接口的种类很多,因此目前已装在PC上设备的种类也很多,我们无法一一列出,在此只列出一些最通用的接口:

  • 键盘接口:连接到一个键盘控制器上,这个控制器包含一个专用微处理器。这个微处理器对按下的组合键进行译码,产生一个中断并把相应的键盘扫描码写人输入寄存器。
  • 图形接口:和图形卡中对应的控制器封装在一起,图形卡有自己的帧缓冲区,还有一个专用处理器以及存放在只读存储器(ROM)芯片中的一些代码。帧缓冲区是显卡上固化的存储器,其中存放的是当前屏幕内容的图形描述。
  • 磁盘接口:由一条电缆连接到磁盘控制器,通常磁盘控制器与磁盘放在一起。例如,IDE接口由一条40线的带形电缆连接到智能磁盘控制器上,在磁盘本身就可以找到这个控制器。
  • 总线鼠标接口:由一条电缆把接口和控制器连接在一起,控制器就包含在鼠标中。
  • 网络接口:与网卡中的相应控制器封装在一起,用以接收或发送网络报文。虽然广泛采用的网络标准很多,但还是以太网(IEEE 802.3)最为通用。

通用I/O接口

用来连接多个不同的硬件设备。连接到通用I/O接口上的设备通常都是外部设备。现代PC都包含连接很多外部设备的几个通用I/O接口。最常用的接口有:

  • 并口:传统上用于连接打印机,它还可以用来连接可移动磁盘、扫描仪、备份设备、其他计算机等等。数据的传送以每次1字节(8位)为单位进行。
  • 串口:与并口类似,但数据的传送是逐位进行的。串口包括一个通用异步收发器(UART)芯片,它可以把要发送的字节信息拆分成位序列,也可以把接收到的位流重新组装成字节信息。由于串口本质上速度低于并口,因此主要用于连接那些不需要高速操作的外部设备,如调制解调器、鼠标以及打印机。
  • PCMCIA接口:大多数便携式计算机都包含这种接口。在不重新启动系统的情况下,这种形状类似于信用卡的外部设备可以被插入插槽或从插槽中拔走。最常用的PCMCIA设备是硬盘、调制解调器、网卡和扩展RAM。
  • SCSI(小型计算机系统接口)接口:是把PC主总线连接到次总线(称为SCSI总线)的电路。SCSI-2总线允许一共8个PC和外部设备(硬盘、扫描仪、CR-ROM刻录机等等)连接在一起。如果有附加接口,宽带SCSI-2和新的SCSI-3接口可以允许你连接多达16个以上的设备。SCSI标准是通过SCSI总线连接设备的通信协议。
  • 通用串行总线(USB):高速运转的通用I/O接口,可用于连接外部设备,代替传统的并口、串口以及SCSI接口。

设备控制器

复杂的设备可能需要一个设备控制器来驱动。从本质上说,控制器起两个重要作用:

  • 对从I/O接口接收到的高级命令进行解释,并通过向设备发送适当的电信号序列强制设备执行特定的操作。
  • 对从设备接收到的电信号进行转换和适当地解释,并修改(通过I/O接口)状态寄存器的值。

很多硬件设备都有自己的存储器,通常称之为I/O共享存储器。例如,所有比较新的图形卡在帧缓冲区中都有几MB的RAM,用它来存放要在屏幕上显示的屏幕映像。

设备驱动程序模型

系统中所有硬件设备由内核全权负责电源管理。例如,在以电池供电的计算机进入“待机”状态时,内核应立刻强制每个硬件设备处于低功率状态。因此,每个能够响应“待机”状态的设备驱动程序必须包含一个回调函数,它能够使得硬件设备处于低功率状态。而且,硬件设备必须按准确的顺序进入“待机”状态,否则一些设备可能会处于错误的电源状态。例如,内核必须首先将硬盘置于“待机”状态,然后才是它们的磁盘控制器,因为若按照相反的顺序执行,磁盘控制器就不能向硬盘发送命令。Linux 2.6提供了一些数据结构,为系统中的设备提供一个统一的视图,这个框架叫做设备驱动程序模型

sysfs文件系统

虽然设备模型的初衷是为了方便电源管理而提供出的一种设备拓扑结构,但是,为了方便调试,设备模型的开发者决定将设备结构树导出为一个文件系统,这就是sysfs文件系统,它可以帮助用户以一个简单文件系统的方式来观察系统中各种设备的拓扑结构。

sysfs文件系统是一种特殊的文件系统。被安装于sys目录下的/proc文件系统相似。/proc文件系统是首次被设计成允许用户态应用程序访问内核内部数据结构的文件系统。sysfs文件系统展现了设备驱动程序模型组件的层次关系。

  • block:块设备,独立于所连接的总线
  • devices:所有被内核识别的硬件设备,依照连接它们的总线对其进行组织
  • bus:系统中用于连接设备的总线
  • drivers:在内核中注册的设备驱动程序
  • class:系统中设备的类型(声卡、网卡、显卡等等);同一类可能包含由不同总线连接的设备,于是由不同的驱动程序驱动。
  • power:处理一些硬件设备电源状态的文件
  • firmware:处理一些硬件设备的固件的文件

sysfs文件系统中所表示的设备驱动程序模型组件之间的关系就像目录和文件之间符号链接的关系一样。sysfs文件系统中普通文件的主要作用是表示驱动程序和设备的属性。

kobject

设备驱动程序模型的核心数据结构是kobject,每个kobject对应于sysfs文件系统中的一个目录。kobject被嵌入到一个叫做容器的更大对象中,容器描述设备驱动程序模型中的组件,典型的容器例子有总线、设备及驱动程序的描述符。

将一个kobject嵌入容器中允许内核:

  • 为容器保持一个引用计数器
  • 维持容器的层次列表或组
  • 为容器的属性提供一种用户态查看的视图

每个kobjectkobject数据结构描述:

ktype字段指向kobj_type对象,该对象描述了kobject的“类型”。本质上,它描述的是包括kobject的容器的类型。kobj_type包含三个字段:

  • void (*release)(struct kobject *);:当kobject被释放时执行
  • struct sysfs_ops * sysfs_ops;:指向sysfs操作表的sysfs_ops指针
  • struct attribute ** default_attrs;sysfs文件系统的缺省属性链表

字段kref字段是一个k_ref类型结构,仅有一个refcount字段。该字段是kobject的引用计数器。但它也可以作为kobject容器的引用计数器。kobject_get()kobject_put()分别用于增加和减少引用计数器的值,如果该计数器为0,则释放kobject使用的资源并执行release方法,释放容器本身。

kset数据结构可以将kobjects组织成一棵层次树。kset是同类型的kobject的集合体,相关的kobject包含在同类型的容器里。

struct list_head list;是包含在kset中的kobject双向循环链表的首部。ktype是指向ksetkob_type类型描述符的指针,该描述符被kset中所有kobject共享。

字段kobj是嵌入在kset中的kobject,而位于kset中的kobject,其parent字段指向这个内嵌的kobject结构。一个ksetkobject集合体,但是它依赖于层次树中用于引用计数和连接的更高层kobject。这种编码效率很高,灵活性很高。

分别用于增加和减少kset引用计数器值的kset_get()kset_put(),只需简单的调用内嵌kobject结构的kobject_get()kobject_put()即可,因为kset的引用计数器即是其内嵌kobject的引用计数器。而且有了内嵌的kobject结构,kset数据结构可以嵌入到”容器”对象中,非常类似嵌入的kobject数据结构。最后kset可以作为其他kset的一个成员:它足以将内嵌的kobject插入到更高层次的kset中。

subsystemkset的集合,一个subsystem可以包含不同类型的kset,包含两个字段:

  • kset:内嵌的kset结构,用于存放subsystemkset
  • rwsem:读写信号量,保护递归地包含于subsystem中的所有ksetkobject

bus子系统包含一个pci子系统,pci子系统又依次包含驱动程序的一个kset,这个kset包含一个串口kobject

注册kobject、kset和subsystem

一般来讲,如果想让kobjectksetsubsystem出现在sysfs子树中,就必须首先注册它们。与kobject对应的目录总是出现在其父kobject的目录中,例如,位于同一个kset中的kobject的目录就出现在kset本身的目录中(kobject->parent指向其所在kset的内嵌kobject)。因此sysfs子树的结构就描述了各种已注册kobject之间以及各种容器对象之间的层次关系

sysfs文件系统的上层目录肯定是已注册的subsystem。常用的函数有:

  • kobject_register(struct kobject * kobj):用于初始化kobject,并将其相应的目录增加到sysfs文件系统中,在调用该函数之前,调用程序应先设置kobject中的kset字段,使它指向其父kset(如果存在)。
  • kobject_unregister(struct kobject * kobj):将kobject目录从sysfs文件系统中移走
  • kset_register(struct kset * k)
  • kset_unregister(struct kset * k)
  • subsystem_register()
  • subsystem_unregister()
1
2
3
4
int subsystem_register(struct kset *s)
{
return kset_register(s);
}
1
2
3
4
void subsystem_unregister(struct kset *s)
{
kset_unregister(s);
}

许多kobject目录都包括称为attribute的普通文件。sysfs_create_file()函数接收kobject的地址和属性描述符作为参数,并创建特殊文件。sysfs文件系统中所面熟的对象间的其他关系可以通过符号链接的方式建立:sysfd_create_link()为目录中与其他kobject相关联的特定kobject创建一个符号链接。

设备驱动程序模型组件

设备驱动程序模型建立在以下几个基本数据结构之上:

设备

设备驱动程序模型中每个设备对应一个device对象。

device对象全部收集在devices_subsys子系统中,该子系统对应的目录是/sys/devices。设备是按照层次关系组织的:一个设备是某个“孩子”的父亲,其条件为子设备离开父设备无法正常工作。例如在基于PCI总线的计算机上,位于PCI总线和USB总线之间的桥就是连接在USB总线上所有设备的父设备。

每个设备驱动程序都保持一个device对象链表,其中链接了所有可被管理的设备;device对象的driver_list字段存放指向相邻对象的指针,而driver字段指向设备驱动程序的描述符。对于任何总线类型来说都有一个链表存放连接到该类型总线上的所有设备;device对象的bus_list字段存放指向相邻对象的指针,而bus字段指向总线类型描述符。

引用计数器记录device对象的使用情况,它包含在kobject类型的kobj中,通过get_device()put_device()函数分别增加和减少该计数器的值。device_register()函数的功能是往设备驱动程序模型中插入一个新的device对象,并自动地在/sys/devices目录下为其创建一个新目录。device_unregister()的功能是从设备驱动程序模型中移走一个设备。

驱动程序

设备驱动程序模型中的每个驱动程序都可由device_driver对象描述:

device_driver对象包括四个方法,它们用于处理热插拔即插即用电源管理。当总线设备驱动程序发现一个可能由它处理的设备时就会调用probe方法;相应的函数将会探测该硬件,从而对该设备进行更进一步的检查。当移走一个可热插拔的设备时,驱动程序会调用remove方法;而驱动程序本身被卸载时,它所处理的每个设备也都会调用remove()方法。当内核必须改变设备的供电状态时,设备会调用shutdownsuspendresume三个方法。

内嵌在描述符中的kobject类型的kobj所包含的引用计数器用于记录device_driver对象的使用情况,相应函数get_driver()put_driver()分别增加和减少该计数器的值。

dirver_register()函数的功能是往设备驱动程序模型中插入一个新的device_driver对象,并自动地在sysfs文件系统下为其创建一个新的目录。相反的,driver_unregister()用于从设备驱动程序模型中移走一个设备驱动对象。

总线

内核支持的每一种总线类型都是由一个bus_type对象描述

每个bus_type对象都包含一个内嵌的子系统。存放在bus_subsys成员中的子系统把嵌入在bus_type对象中的所有子系统都集合在一起。bus_subsys子系统与目录/sys/bus是对应的,例如,有一个/sys/bus/pci目录与pci总线类型相对应。每种总线的子系统分为2类ksetdriversdevices,分别对应于bus_type对象中的driversdevices字段。

名为driverskset包含描述符device_driver,描述与该总线类型相关的所有设备驱动,名为deviceskset包含描述符device,描述与给定总线类型上连接的所与设备。因为设备的kobject目录已经出现在/sys/devices下的sysfs中,所以每种总线子系统的devices目录存放了指向/sys/devices下目录的符号链接。

函数bus_for_each_drv()bus_for_each_dev()分别用于循环扫描driversdevices链表中所有元素。

当内核检查一个给定设备是否可以由给定的驱动处理时,执行match方法。对于连接设备的总线而言,即使其上每个设备的标识符都拥有一个特定的格式。在设备驱动程序模型中注册某个设备时会执行hotplug方法;实现函数应该通过环境变量把总线的具体信息传递给用户态程序,以通告一个新的可用设备。特定类型总线的设备必须改变供电状态时会执行suspendresume方法。

每个类是由一个class对象描述的。所有的类对象都属于与/sys/class目录相对应的class_subsys的子系统。此外,每个类对象还包括一个内嵌的子系统,因此对于/sys/class/input目录,它就与设备驱动程序模型的input类相对应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*
* device classes
*/
struct class {
const char * name;
struct module * owner;

struct kset subsys;//通过subsys.kobj.kset = &class_subsys把该类注册到class_sybsys中
struct list_head children;
struct list_head devices; /*属于该类对象的class_dev描述符链表,每个描述符描述了一个属于该
类的单独逻辑设备*/
struct list_head interfaces; /*一个硬件设备可能包括几个不同的子设备,每个子设备都需要一个不同
的用户态接口*/
struct kset class_dirs;
struct semaphore sem; /* locks both the children and interfaces lists */

struct class_attribute * class_attrs;
struct class_device_attribute * class_dev_attrs;
struct device_attribute * dev_attrs;

int (*uevent)(struct class_device *dev, struct kobj_uevent_env *env);
int (*dev_uevent)(struct device *dev, struct kobj_uevent_env *env);

void (*release)(struct class_device *dev);
void (*class_release)(struct class *class);
void (*dev_release)(struct device *dev);

int (*suspend)(struct device *, pm_message_t state);
int (*resume)(struct device *);
};

每个类对象包括一个属于该类对象的class_dev描述符链表,每个描述符描述了一个属于该类的单独逻辑设备。在class_device结构中包含一个dev字段,它指向一个设备描述符,因此一个逻辑设备总是对应于设备驱动模型中的一个给定设备,然而,可以存在多个class_device描述符对应同一个设备。

同一类中的设备驱动程序可以对用户态应用程序提供相同的功能;设备驱动程序模型中的类本质上是要提供一个标准的方法,从而为向用户态应用程序导出逻辑设备的接口。每个class_device中内嵌一个kobject,这是一个名为dev的属性(特殊文件)。该属性存放设备文件的主设备号和次设备号,通过它们可以访问相应的逻辑设备。

设备文件

类Unix系统都是基于文件概念的,可以把I/O设备当作设备文件这种特殊文件来处理,这样,与磁盘上的普通文件进行交互所用的同一系统调用可直接用于I/O设备。

根据设备驱动程序的基本特性,设备文件可以分为以下几种:

  1. 块设备的数据可以被随机访问,而且从用户观点看,传送任何数据块所需的时间都是较少且大致相同的。
  2. 字符设备的数据或者不可以随机访问,或者可以被随机访问,但是访问随机数据所需的时间很大程度上依赖于数据在设备内的位置(例如,磁带驱动器)
  3. 网络设备(网卡),网络设备没有对应的设备文件,不直接与设备文件对应。

设备文件是存放在文件系统中的实际文件,然而,它的索引节点并不包含指向磁盘上数据块(文件的数据)的指针,因为它们是空的。相反,索引节点必须包含硬件设备的一个标识符,它对应字符或块设备文件。

传统上,设备标识符由设备文件的类型(字符或块)和一对参数组成。

  • 第一个参数称为主设备号(major number),它标识了设备的类型。通常,具有相同主设备号和类型的所有设备文件共享相同的文件操作集合,因为它们是由同一个设备驱动程序处理的。
  • 第二个参数成为次设备号(minor number),它标识了主设备号相同的设备组中的一个特定设备。

mknod()系统调用用来创建设备文件,其参数有设备文件名、设备类型、主设备号及次设备号,设备文件通常包含在/dev目录中。

设备文件通常与硬件设备(如硬盘/dev/hda),或硬件设备的某一物理或逻辑分区(如磁盘分区/dev/hda2)相对应。在某些情况下,设备文件不会和任何实际的硬件对应,而是表示一个虚拟的逻辑设备,例如/dev/null就是一个和“黑洞”对应的设备文件。

设备文件的用户态处理

传统的Unix系统中(以及Linux的早期版本中),设备文件的主设备号和次设备号都只有8位长,在高端系统中并不够用,例如大型集群系统中需要大量的SCSI盘,每个SCSI盘上有15个分区的情况

真正的问题是设备文件被分配一次且永远保存在/dev目录中:因此,系统中每个逻辑设备都应该有一个与其相对应的、明确定义了设备号的设备文件Documentation/devices.txt文件存放了官方注册的已分配设备号和/dev目录节点;include/linux/major.h文件也可能包含设备的主设备号对应的宏。但由于硬件设备数量惊人,官方注册的设备号不能很好的适用于大规模系统。

为解决上述问题,Linux2.6已增加设备号的编码大小:目前主设备号的编码为12位,次设备号的编码为20位。通常把这两个参数合并成一个32位的dev_t变量。使用的宏有:MAJOR()MINOR()用于分别提取主设备号和次设备号,MKDEV()用于把这两个参数合并成一个32位的dev_t变量。

动态分配设备号

每个设备驱动程序在注册阶段都会指定它将要处理的设备号范围,驱动程序可以只指定设备号的分配范围,无需指定精确值,在这种情况下,内核会分配一个合适的设备号范围给驱动程序。因此,新的硬件设备驱动程序不再需要从官方注册表中分配的一个设备号;它们可以仅仅使用当前系统中空闲的设备号。

然而这种情形下,就不能永久的创建设备文件,它只在设备驱动程序初始化一个主设备号和次设备号时才创建。因此,这就需要一个标准的方法将每个驱动程序所使用的设备号输出到用户态应用程序中,为此,设备驱动程序模型提供了一个非常好的解决办法:把主设备号和次设备号存放在/sys/class子目录下的dev属性中

动态创建设备文件

Linux内核可以动态地创建设备文件。系统中必须安装一组udev工具集的用户态程序。

  • 当系统启动时,/dev目录是清空的,这时udev程序将扫描/sys/class子目录来寻找dev文件。
  • 对每一个这样的文件(主设备号和次设备号的组合表示一个内核所支持的逻辑设备文件),udev程序都会在/dev目录下为它创建一个相应的设备文件。
  • udev程序也会根据配置文件为其分配一个文件名并创建一个符号链接。
  • 最后,/dev目录里只存放了系统中内核所支持的所有设备的设备文件,而没有任何其他的文件。

设备文件的VFS处理

虽然设备文件也在系统的目录树中,但是它们和普通文件及目录文件有根本的不同。当进程访问普通文件时,它会通过文件系统访问磁盘分区中的一些数据块;而在进程访问设备文件时,它只要驱动硬件设备就可以了。

为了做到这点,VFS在设备文件打开时改变其缺省文件操作;因此,可把设备文件的每个系统调用都转换成与设备相关的函数的调用,而不是对主文件系统相应函数的调用。

假定open()一个设备文件,从本质上来说,相应的服务例程解析到设备文件的路径名,并建立相应的索引节点对象、目录项对象和文件对象

  • 通过适当的文件系统函数(通常为ext2_read_inode()ext3_read_inode())读取磁盘上的相应的索引节点来对索引节点对象进行初始化。
  • 当这个函数确定磁盘索引节点与设备文件对应时,则调用init_special_inode(),该函数把索引节点对象的i_rdev字段初始化为设备文件的主设备号和次设备号,而把索引节点对象的i_fop字段设置为def_blk_fops或者def_chr_fops文件操作表的地址。
  • 因此,open()系统调用的服务例程也调用dentry_open()函数,后者分配一个新的文件对象并把其f_op字段设置为i_fop中存放的地址,即再一次指向def_blk_fops或者def_chr_fops的地址。正是这两个表的引入,才使得在设备文件上所发出的任何系统调用都将激活设备驱动程序的函数而不是基本文件系统的函数。

设备驱动程序

设备驱动程序是内核例程的集合,它使得硬件设备响应控制设备的编程接口,而该接口是一组规范的VFS函数集(open、read、lseek、ioctl等等)。这些函数的实际实现由设备驱动程序全权负责。由于每个设备都有一个唯一的I/O控制器,因此就有唯一的命令和唯一的状态信息,所以大部分I/O设备都有自己的驱动程序。

在使用设备驱动程序之前,有几个活动是肯定要发生的。

注册设备驱动程序

在设备文件上发出的每个系统调用都由内核转化为相应设备驱动程序对应函数的调用,为完成这个操作,设备驱动程序必须注册自己,即分配一个device_driver描述符,将其插入到设备驱动程序模型的数据结构中,并把它与对应的设备文件(可能是多个设备文件)连接起来。如果设备文件对应的驱动程序之前没有注册,则对该设备文件的访问会返回错误码-ENODEV。

对PCI设备,其驱动程序必须分配一个pci_driver类型描述符,PCI内核层使用该描述符来处理设备,初始化该描述符的一些字段后,设备驱动程序就会调用pci_register_driver()

事实上,pci_driver描述符包括一个内嵌的device_driver描述符,pci_register_driver()仅仅初始化内嵌的驱动程序描述符的字段,然后调用driver_register()把驱动程序插入设备驱动程序模型的数据结构中。

注册设备驱动程序时,内核会寻找可能由该驱动程序处理但还尚未获得支持的硬件设备。为做到这点,内核主要依靠相关的总线类型描述符bus_typematch方法,以及device_driver对象的probe()方法。如果探测到可被驱动程序处理的硬件设备,然后调用device_register()函数把设备插入到设备驱动程序模型中。

初始化设备驱动程序

为确保资源在需要时能够获得,在获得后不再被请求,设备驱动程序通常采用下列模式:

  1. 引用计数器记录当前访问设备文件的进程数。在设备文件的open方法中计数器被增加,在release方法中被减少(更确切的说,引用计数器记录引用设备文件的文件对象的个数,因为子进程可能共享文件对象)。
  2. open()方法在增加引用计数器的值之前应先检查它,如果计数器为0,则设备驱动必须分配资源并激活硬件设备上的中断和DMA。
  3. release()方法在减少使用计数器的值之后检查它,如果计数器为0,说明已经没有进程使用这个硬件设备。如果是这样,该方法将禁止I/O控制器上的中断和DMA,然后释放所分配的资源。

监控I/O操作

监控I/O操作结束的两种可用技术:轮询模式(polling mode)和中断模式(interrupt mode)。

轮询模式

CPU轮询设备的状态寄存器,直到寄存器的值表明I/O操作已经完成为止。I/O轮询技术比较巧妙,因为驱动程序还必须记住检查可能的超时。记录超时的方法:

  1. counter计数
  2. 在每次循环时读取节拍计数器jiffies的值,并将它与开始等待循环之前读取的原值进行比较
  3. 如果完成I/O操作需要时间相对较多,比如毫秒级,那么上述方式比较低效,因为CPU花费宝贵的机器周期去等待I/O操作的完成。在这种情况下,在每次轮询操作之后,可以把schedule()的调用插入到循环内部来自愿放弃CPU。

中断模式

如果I/O控制器能够通过IRQ线发出I/O操作结束的信号,那么中断模式才能被使用。举例如下:当用户在某字符设备的相应的设备文件上发出read()系统调用时,一条输入命令被发往设备的控制寄存器。在一个不可预知的长时间间隔后,设备把一个字节的数据放进输入寄存器。设备驱动程序然后将这个字节作为read()系统调用的结果返回。

实质上,驱动程序包含两个函数:

  1. 实现文件对象read方法的foo_read()`函数;
  2. 处理中断的foo_interrupt()函数;

只要用户读设备文件,foo_read()函数就被触发:

设备驱动程序依赖类型为foo_dev_t的自定义描述符;它包含信号量sem(保护硬件设备免受并发访问)、等待队列wait、标志intr(当设备发出一个中断时设置)及单个字节缓冲区data(由中断处理程序写入且由read方法读取)。一般而言,所有使用中断的I/O驱动程序都依赖中断处理程序及readwrite方法均访问的数据结构。foo_dev_t描述符的地址通常存放在设备文件的文件对象的private_data字段中或一个全局变量中。

foo_read()函数主要操作如下:

  1. 获取foo_dev->sem信号量,因此确保没有其他进程访问该设备;
  2. intr标志;
  3. 对I/O设备发出读命令;
  4. 执行wait_event_interruptible以挂起进程,直到intr标志变为1.

一定时间后,设备发出中断信号以通知I/O操作已经完成,数据已经放在适当的DEV_FOO_DATA_PORT数据端口。中断处理程序置intr标志并唤醒进程。当调度程序决定重新执行该进程时,foo_read()的第二部分被执行,步骤如下:

  1. 把准备在foo_dev->data变量中的字符拷贝到用户地址空间;
  2. 释放foo_dev->sem信号量

实际设备驱动会使用超时控制,一般来说,超时控制是通过静态或动态定时器实现的;定时器必须设置为启动I/O操作后正确的时间,并在操作结束时删除。

函数foo_interrupt():

1
2
3
4
5
6
void foo_interrupt(int irq, void *dev_id, struct pt_regs *regs) {
foo->data = inb(DEV_FOO_DATA_PORT);
foo->intr = 1;
wake_up_interruptible(&foo->wait);
return 1;
}

注意:三个参数中没有一个被中断处理程序使用,这是相当普遍的情况。

访问I/O共享存储器

根据设备和总线的类型,PC体系结构里的I/O共享存储器可以被映射到不同的物理地址范围。主要有:

  • 对于连接到ISA总线上的大多数设备I/O共享存储器通常被映射到0xa0000~0xfffff的16位物理地址范围;这就在640KB和1MB之间留出了一段空间。即物理内存布局中的“空洞”
  • 对于连接到PCI总线上的设备I/O共享存储器被映射到接近4GB的32位物理地址范围。

设备驱动程序如何访问一个I/O共享存储器单元?

先以简单的PC体系结构开始,不要忘了内核程序作用于线性地址,因此I/O共享存储器单元必须表示成大于PAGE_OFFSET的地址,在后续讨论时,先假设PAGE_OFFSET为0xc0000000,也就是说内核线性地址为第4个GB.

设备驱动程序必须把I/O共享存储器单元的物理地址转换成内核空间的线性地址。在PC体系结构中,可简单的把32位物理地址和0xc0000000常量进行或运算得到。例如内核把物理地址为0x000b0fe4的I/O单元的值存放在t1中,把物理地址为0xfc000000的I/O单元的值存放在t2中。

1
2
t1 = *((unsigned char *)(0xc00b0fe4));
t2 = *((unsigned char *)(0xfc000000));

在初始化阶段,内核已经把可用的RAM物理地址映射到线性地址空间第4个GB的开始部分。因此,分页单元把出现在第一个语句中的线性地址0xc00b0fe4映射回原来的I/O物理地址0x000b0fe4,这正好落在从640KB到1MB的这段”ISA洞中”。这工作的很好。

但对于第二个语句来说,有一个问题,因为其I/O物理地址超过了系统RAM的最大物理地址。因此,线性地址0xfc000000就不需要与物理地址0xfc000000相对应。在这种情况下,为了在内核页表中包括对这个I/O物理地址进行映射的线性地址,必须对页表进行修改。这可以通过调用ioremap()ioremap_nocache()函数来实现。第一个函数与vmalloc()函数类似,都调用get_vm_area()为所请求的I/O共享存储区的大小建立一个新的vm_struct描述符。然后,这两个函数适当地更新常规内核页表中的对应页表项。ioremap_nocache()不同于ioremap(),因为前者在适当地引用再映射的线性地址时还使硬件高速缓存内容失效。

因此,第二个语句的正确形式应该为:

1
2
io_mem = ioremap(0xfb000000, 0x200000);
t2 = *((unsigned char *)(io_mem + 0x100000));

第一条语句建立一个2MB的新的线性地址区间,该区间映射了从0xfb000000开始的物理地址;第二条语句读取地址为0xfc000000的内存单元。设备驱动程序以后要取消这种映射,就必须要使用iounmap()函数。

在其他体系结构上,简单地间接引用物理内存单元的线性地址并不能正确访问I/O共享存储器。因此,Linux定义了下列依赖于体系结构的函数,当访问I/O共享存储器时来使用它们:

  • readb()readw()readl():分别从一个I/O共享存储器单元读取1、2或者4个字节
  • writeb()writew()writel():分别向一个I/O共享存储器单元写入1、2或者4个字节
  • memcpy_fromio()memcpy_toio():把一个数据块从一个I/O共享存储器单元拷贝到动态内存中,另一个函数正好相反
  • memset_io():用一个固定的值填充一个I/O共享存储器区域

因此,对应0xfc000000I/O单元的访问推荐使用如下方法:

1
2
io_mem = ioremap(0xfb000000, 0x200000);
t2 = readb(io_mem + 0x100000);

正是由于这些函数,就可以隐藏不同平台访问I/O共享存储器所用方法的差异。

直接内存访问(DMA)

所有的PC都包含一个辅助的DMA电路用来控制在RAM和I/O设备之间数据的传送。

DMA一旦被CPU激活,就可以自行传递数据;当数据传送完成之后,DMA发出一个中断请求。当CPU和DMA同时访问同一内存单元时,所产生的冲突由一个名为内存仲裁器的硬件电路来解决。

使用DMA最多的是磁盘驱动器和其他需要一次传送大量字节的设备,因为DMA设置时间较长,所以传送少量数据时直接使用CPU效率更高。

同步DMA和异步DMA

设备驱动程序可以采用两种方式使用DMA:同步DMA异步DMA。第一种方式,数据的传送是由进程触发的;第二种方式,数据的传送是由硬件设备触发的。

采用同步DMA的如声卡,用户应用程序将声音数据写入与声卡数字信号处理器DSP对应的设备文件中,声卡驱动把写入的这些样本收集在内核缓冲区。同时,驱动程序命令声卡把这些样本从内核缓冲区拷贝到预先定时的DSP中。当声卡完成数据传送时,会引发一个中断,然后驱动程序会检查内核缓冲区是否还有要播放的样本;如果有,驱动程序就再启动一次DMA数据传送。

采用异步DMA的如网卡,它从一个LAN中接收帧,网卡将接收到的帧存储在自己的I/O共享存储器中,然后引发一个中断。其驱动程序确认该中断后,命令网卡将接收到的帧从I/O共享存储器拷贝到内核缓冲区。当数据传送完成后,网卡会引发新的中断,然后驱动程序将这个新帧通知给上层内核层。

DMA传送的辅助函数

DMA辅助函数有两个子集:老式的子集为PCI设备提供了与体系结构无关的函数新的子集则保证了与总线和体系结构两者都无关。介绍如下:

总线地址:DMA的每次数据传送(至少)需要一个内存缓冲区,它包含硬件设备要读出或写入的数据。一般而言,启动一次数据传送前,设备驱动程序必须确保DMA电路可以直接访问RAM内存单元

现已区分三类存储器地址:逻辑地址线性地址以及物理地址,前两个在CPU内部使用,最后一个是CPU从物理上驱动数据总线所用的存储器地址。但还有第四种存储器地址,称为总线地址(bus address),它是除CPU之外的硬件设备驱动数据总线时所用的存储器地址

当内核开始DMA操作时,必须把所涉及的内存缓冲区总线地址或写入DMA适当的I/O端口,或写入I/O设备适当的I/O端口。

不同的总线具有不同的总线地址大小,ISA的总线地址是24位长,因此在80x86体系结构中,可在物理内存的低16MB中完成DMA传送——这就是为什么DMA使用的内存缓冲区分配在ZONE_DMA内存区中(设置了GFP_DMA标志)。原来的PCI标准定义了32位总线地址;但是,一些PCI硬件设备最初是为ISA总线设计的,因此它们仍然访问不了物理地址0x00ffffff以上的RAM内存单元。新的PCI-X标准采用64位的总线地址并允许DMA电路可以直接寻址更高的内存。

在Linux中,数据类型dma_addr_t代表一个通用的总线地址。在80x86体系结构中,dma_addr_t对应一个32位长的整数,除非内核支持PAE,在这种情况下,dma_addr_t代表一个64位整数。

pci_set_dma_mask()dma_set_mask()辅助函数用于检查总线是否可以接收给定大小的总线地址(mask),如果可以,则通知总线层给定的外围设备将使用该大小的总线地址。

高速缓存的一致性:系统体系结构没有必要在硬件级为硬件高速缓存与DMA电路之间提供一个一致性协议,因此,执行DMA映射操作时,DMA辅助函数必须考虑硬件高速缓存。设备驱动开发人员可采用2种方法来处理DMA缓冲区,即两种DMA映射类型中进行选择:

  • 一致性DMA映射:CPU在RAM内存单元上所执行的每个写操作对硬件设备而言都是立即可见的。反之也一样。
  • 流式DMA映射:这种映射方式,设备驱动程序必须注意小心高速缓存一致性问题,这可以使用适当的同步辅助函数来解决,也称为“异步的”

一般来说,如果CPU和DMA处理器以不可预知的方式去访问一个缓冲区,那么必须强制使用一致性DMA映射方式。其他情形下,流式DMA映射方式更可取,因为在一些体系结构中处理一致性DMA映射是很麻烦的,并可能导致更低的系统性能。

一致性DMA映射的辅助函数

为分配内存缓冲区和建立一致性DMA映射,内核提供了依赖体系结构的pci_alloc_consistent()dma_alloc_coherent()两个函数。它们均返回新缓冲区的线性地址和总线地址。在80x86体系结构中,它们返回新缓冲区的线性地址和物理地址。为了释放映射和缓冲区,内核提供了pci_free_consistent()dma_free_coherent()两个函数。

流式DMA映射的辅助函数

流式DMA映射的内存缓冲区通常在数据传送之前被映射,在传送之后被取消映射。也有可能在几次DMA传送过程中保持相同的映射,但是在这种情况下,设备驱动开发人员必须知道位于内存和外围设备之间的硬件高速缓存。

为了启动一次流式DMA数据传送,驱动程序必须首先利用分区页框分配器或通用内存分配器来动态地分配内存缓冲区。然后驱动程序调用pci_map_single()或者dma_map_single()建立流式DMA映射,这两个函数接收缓冲区的线性地址作为其参数并返回相应的总线地址。为了释放该映射,驱动程序调用相应的pci_unmap_single()dma_unmap_single()函数。

为避免高速缓存一致性问题,驱动程序在开始从RAM到设备的DMA数据传送之前,如果有必要,应该调用pci_dma_sync_single_for_device()dma_sync_single_for_device()刷新与DMA缓冲区对应的高速缓存行。同样的,从设备到RAM的一次DMA数据传送完成之前设备驱动程序是不可以访问内存缓冲区的:相反,如果有必要,在读缓冲区之前,驱动程序应该调用pci_dma_sync_single_for_cpu()dma_sync_single_for_cpu()使相应的硬件高速缓存行无效。在80x86体系结构中,上述函数几乎不做任何事情,因为硬件高速缓存和DMA之间的一致性是由硬件来维护的。

即使是高端内存的缓冲区也可以用于DMA传送;开发人员使用pci_map_page()dma_map_page()函数,给其传递的参数为缓冲区所在页的描述符地址和页中缓冲区的偏移地址。相应地,为了释放高端内存缓冲区的映射,开发人员使用pci_unmap_page()dma_unmap_page()函数。

内核支持的级别

Linux内核并不完全支持所有可能存在的I/O设备,一般来说,有三种可能方式支持硬件设备:

  • 根本不支持:应用程序使用适当的inout汇编语言指令直接与设备的I/O端口进行交互。
  • 最小支持:内核不识别硬件设备,但能识别它的I/O接口。用户程序把I/O接口视为能够读写字符流的顺序设备。
  • 扩展支持:内核识别硬件设备,并处理I/O接口本身。事实上,这种设备可能就没有对应的设备文件。

第一种方式与内核设备驱动程序毫无关系,这种方式效率高,但限制了X服务器使用I/O设备产生的硬件中断。

最小支持方法是用来处理连接到通用I/O接口上的外部硬件设备的。内核通过提供设备文件来处理I/O接口,应用程序通过读写设备文件来处理外部硬件设备。

最小支持优于扩展支持,因为它保持内核尽可能小。但PC中,仅串/并口处理使用了这种方法。最小支持的应用范围是有限的,因为当外设必须频繁地与内核内部数据结构进行交互时不能使用这种方法。这种情况下就必须使用扩展支持。

一般情况下,直接连接到I/O总线上的任何硬件设备(如内置硬盘)都要根据扩展支持方法进行处理:内核必须为每个这样的设备提供一个设备驱动程序。USB、PCMCIA或者SCSI接口,简而言之,除串口和并口之外的所有通用I/O接口之上连接的外部设备都需要扩展支持。

值得注意的是,与标准文件相关的系统调用,如open()read()write(),并不总让应用程序完全控制底层硬件设备。事实上,VFS的“最小公分母”方法没有包含某些设备所需的特殊命令,或不让应用程序检查设备是否处于某一特殊的内部状态。

已引入的ioctl()系统调用可以满足这样的需要。这个系统调用除了设备文件的文件描述符和另一个表示请求的32位参数之外,还可以接收任意多个额外的参数。例如,特殊的ioctl()请求可以用来获得CD-ROM的音量或弹出CD-ROM介质。应用程序可以用这类ioctl()请求提供一个CD播放器的用户接口。

字符设备驱动程序

处理字符设备相对比较容易,因为通常不需要很复杂的缓冲策略。字符设备驱动程序是由一个cdev结构描述的。

list字段是双向循环链表的首部,该链表用于收集相同字符设备驱动程序所对应的字符设备文件的索引节点。可能很多设备文件具有相同的设备号,并对应于相同的字符设备。此外,一个设备驱动程序对应的设备号可以是一个范围,而不仅仅是一个号;设备号位于同一范围内的所有设备文件均由同一个字符设备驱动程序处理。设备号范围的大小存放在count字段中。

cdev_alloc()函数的功能是动态地分配cdev描述符,并初始化内嵌的kobject数据结构,因此在引用计数器的值变为0时会自动释放该描述符。

cdev_add()函数的功能是在设备驱动程序模型中注册一个cdev描述符。它初始化cdev描述符中的devcount字段,然后调用kobj_map()函数。kobj_map()则依次建立设备驱动程序模型的数据结构,把设备号范围复制到设备驱动程序的描述符中。

设备驱动程序模型为字符设备定义了一个kobject映射域,该映射域由一个kobject类型的描述符描述,并由全局变量cdev_map引用。kobj_map描述符包括一个散列表,它有255个表项,并由0-255范围的主设备号进行索引。散列表存放probe类型的对象,每个对象都拥有一个已注册的主设备号和次设备号:

调用kobj_map()函数时,把指定的设备号范围加入到散列表中。相应的probe对象的data字段指向设备驱动程序的cdev描述符。执行getlock方法时把data字段的值传递给它们。在这种情况下,get方法通过一个简捷函数实现,其返回值为cdev描述符中内嵌的kobject数据结构的地址;相反,lock方法本质上用于增加内嵌的kobject数据结构的引用计数器的值。

kobj_lookup()函数接收kobject映射域和设备号作为输入参数;它搜索散列表,如果找到,则返回该设备号所在范围的拥有者的kobject的地址。当这个函数应用到字符设备的映射域时,就返回设备驱动程序描述符中所嵌入的kobject的地址。

分配设备号

为了记录目前已经分配了哪些字符设备号,内核使用散列表chrdevs,表的大小不超过设备号范围。两个不同的设备号范围可能共享同一个主设备号,但是范围不能重叠,因此它们的次设备号应该完全不同chrdevs包含255个表项,由于散列函数屏蔽了主设备号的高四位,因此,主设备号的个数少于255个,它们被散列到不同的表项中。

每个表项指向冲突链表的第一个元素,而该链表是按主、次设备号的递增顺序进行排序的。冲突链表中的每个元素是一个char_device_struct结构:

本质上可以采用两种方法为字符设备驱动程序分配一个范围内的设备号。所有新的设备驱动程序使用第一种方法,该方法使用register_chrdev_region()函数和alloc_chrdev_region()函数为驱动程序分配任意范围内的设备号。例如,为了获得从dev(类型为dev_t)开始的大小为size的一个设备号范围:

1
register_chrdev_region(dev, size, "foo");

上述函数并不执行cdev_add(),因此设备驱动程序在所要求的设备号范围被成功分配时必须执行cdev_add()函数。

第二种方法使用register_chrdev()函数,它分配一个固定的设备号范围,该范围包含唯一一个主设备号以及255的次设备号。在这种情形下,设备驱动程序不必调用cdev_add()函数。

register_chrdev_region()函数和alloc_chrdev_region()函数

register_chrdev_region()函数接收三个参数:初始的设备号(主设备号和次设备号)、请求的设备号范围大小(与次设备号的大小一样)以及这个范围内的设备号对应的设备驱动程序的名称。该函数检查请求的设备号范围是否跨越一些次设备号,如果是,则确定其主设备号以及覆羔整个区间的相应设备号范围;然后,在每个相应设备号范围上调用__register_chrdev_region()函数。

alloc_chrdev_region()函数与register_chrdev_region()相似,可以动态分配一个主设备号;因此,该函数接收的参数为设备号范围内的初始次设备号范围的大小以及设备驱动程序的名称。结束时它也调用__register_chrdev_region()函数。

__register_chrdev_region()函数执行以下步骤:

  1. 分配一个新的char_device_struct结构,并用0填充。
  2. 如果设备号范围内的主设备号为0,那么设备驱动程序请求动态分配一个主设备号。函数从散列表的末尾表项开始继续向后寻找一个与尚未使用的主设备号对应的空冲突链表(NULL指针)。若没有找到空表项,则返回一个错误码。
  3. 初始化char_device_struct中的初始设备号、范围大小和设备驱动程序名称
  4. 执行散列函数计算与主设备号对应的散列表索引。
  5. 遍历冲突链表,为新的char_device_struct结构寻找正确的位置。如果找到与请求的设备号范围重叠的一个范围,则返回错误码。
  6. 将新的char_device_struct描述符插人冲突链表中。
  7. 返回新的char_device_struct描述符的地址。

register_chrdev()函数

驱动程序使用register_chrdev()函数时需要一个老式的设备号范围:一个单独的主设备号和0-255的次设备号范围。该函数接收的参数为:请求的主设备号major(如果是0则动态分配)、设备驱动程序的名称name和一个指针fops(它指向设备号范围内的特定字符设备文件的文件操作表)。该函数执行下列操作:

  1. 调用__register_chrdev_region()函数分配请求的设备号范围。如果返回一个错误码(不能分配该范围),函数将终止运行。
  2. 为设备驱动程序分配一个新的cdev结构。
  3. 初始化cdev结构:
    1. 将内嵌的kobject类型设置为ktype_cdev_dynamic类型的描述符
    2. owner字段设置为fops->owner的内容
    3. ops字段设置为文件操作表的地址fops
    4. 将设备驱动程序名称拷贝到内嵌的kobject结构里的name字段里
  4. 调用cdev_add()函数
  5. __register_chrdev_region()函数在第一步中返回的char_device_struct描述符的cdev字段设置为设备驱动程序的cdev描述符的地址
  6. 返回分配的设备号范围的主设备号

访问字符设备驱动程序

open()系统调用服务例程触发的dentry_open()函数定制字符设备文件的文件对象的f_op字段,以使它指向def_chr_fops表。这个表几乎为空;它仅仅定义了chrdev_open()函数作为设备文件的打开方法。这个方法由dentry_open()直接调用。

chrdev_open()函数接收的参数为索引节点的地址ne、指向所打开文件对象的指针filp。本质上它执行以下操作:

  1. 检查指向设备驱动程序的cdev描述符的指针inode->i_cdevo,如果该字段不为空,则inode结构已经被访问:增加cdev描述符的引用计数器值并跳转到第6步。
  2. 调用kobj_lookup()函数搜索包括该设备号在内的范围。如果该范围不存在,则返回一个错误码;否则,函数计算与该范围相对应的cdev描述符的地址。
  3. inode对象的inode->i_cdev字段设置为cdev描述符的地址。
  4. inode->i_cindex字段设置为设备驱动程序的设备号范围内的设备号的相关索引(设备号范围内的第一个次设备号的索引值为0,第二个为1,依此类推)
  5. inode对象加入到由cdev描述符的list字段所指向的链表中。
  6. filp->f_ops文件操作指针初始化为cdev描述符的ops字段的值。
  7. 如果定义了filp->f_ops->open方法,chrdev_open()就会执行该方法,若设备驱动程序处理一个以上的设备号,则chrdev_open()一般会再次设置file对象的文件操作
  8. 成功返回0

字符设备的缓冲策略

某些设备在一次单独的1/O操作中能郇传送大量的数据,而有些设备则只能传送几个字符。两种不同的技术做到:

  • 使用DMA方式传送数据块。
  • 运用两个或多个元素的循环缓冲区,每个元素具有一个数据块的大小。当一个中断(发送一个信号表明新的数据块已被读入)发生时,中断处理程序把指针移到循环缓冲区的下一个元素,以便将来的数据会存放在一个空元素中。相反,只要驱动程序把数据成功地拷贝到用户地址空间,就释放循环缓冲区中的元素,以便用它来保存从硬件设备传送来的新数据。

循环缓冲区的作用是消除CPU负载的峰值;即使接收数据的用户态应用程序因为其他高优先级任务而慢下来,DMA也要能够继续填充循环缓冲区中的元素,因为中断处理程序代表当前运行的进程执行。

块设备驱动程序

块设备的处理

一个进程在某个磁盘文件上发出一个read()系统调用,内核对进程请求回应的一般步骤:

  1. read()调用一个适当的 VFS 函数,将文件描述符和文件内的偏移量传递给它。虚拟文件系统位于块设备处理体系结构的上层,提供一个通用的文件系统模型,Linux 支持的所有系统均采用该模型。
  2. VFS 函数确定所请求的数据是否已经存在,如有必要,它决定如何执行 read 操作。有时候没有必要访问磁盘上的数据,因为内核将大多数最近从快速设备读出或写入其中的数据保存在 RAM 中。
  3. 假设内核从块设备读数据,那么它就必须确定数据的物理位置。因此,内核依赖映射层执行下面步骤:
    1. 内核确定该文件所在文件系统的块大小,并根据文件块的大小计算所请求数据的长度。本质上,文件被看作拆分成许多块,因此内核确定请求数据所在的块号(文件开始位置的相对索引)。
    2. 映射层调用一个具体文件系统的函数,它访问文件的磁盘节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。因为磁盘也被看作拆分成许多块,所以内核必须确定所请求数据的块对应的号。由于一个文件可能存储子磁盘上的不连续块中,因此存放在磁盘索引节点中的数据结构将每个文件块号映射为一个逻辑块号。
  4. 现在内核可以对块设备发出读请求。内核利用通用块层启动 I/O 操作来传送所请求的数据。一般,每个 I/O 操作只针对磁盘上一组连续操作的块。由于请求的数据不必位于相邻的块中,所以通用层可能启动几次 I/O 操作。每次 I/O 操作是由一个“块 I/O”结构描述符,它收集底层组件所需要的所有信息以满足所发出的请求。通用块层为所有的块设备提供一个抽象视图。
  5. 通用块层下面的“I/O 调度程序”根据预先定义的内核策略将待处理的 I/O 数据传送请求进行归类。
    1. 调度程序的作用是把物理介质上相邻的数据请求聚集在一起。
  6. 最后,块设备驱动程序向磁盘控制器的硬件接口发出适当的命令,从而进行实际的数据传送。

块设备中的数据存储涉及了许多内核组件,每个组件采用不同长度的块管理磁盘数据:

  • 硬件块设备控制器采用称为扇区的固定长度的块传送数据。
  • 虚拟文件系统、映射层和文件系统存放在逻辑单元中,一个块对应文件系统中的一个最小的磁盘存储单元。
  • 块设备驱动程序处理数据,一个段就是一个内存页或内存页的一部分,包含磁盘上相邻的数据块。
  • 硬盘高速高速缓存作用于,每页正好装在一个页框中。
  • 通用块层将所有的上层和下层的组件组合在一起

这个具有4096字节的页,上层内核组件将页看成是由4个1024字节组成的块缓冲区。块设备正在传送页中的后3个块,硬盘控制器将该段看成是由6个512字节的扇区组成。

扇区

块设备的每次数据传输都作用于一组称为扇区的相邻字节。大部分磁盘设备中,扇区大小为 512 字节。不允许传送少于一个扇区的数据。

在Linux中,扇区大小按惯例都设为512字节。对存放在块设备上的一组数据是通过它们在磁盘上的位置来标示,即其首个512字节扇区的下标即其扇区的数目。扇区的下标存放在类型为sector_t的32位或64位的变量中。

块是 VFS 和文件系统传输数据的基本单位。内核访问文件内容时,需要首先从磁盘上读文件的磁盘索引节点,该块对应磁盘上的多个扇区,而VFS将其看作一个单一的单元。。

Linux 中,块大小必须是 2 的幂,且不能超过一个页框。此外,它必须是扇区大小的整数倍,因此每个块必须包含整个扇区

每个块都需要自己的块缓冲区,它是内核用来存放内容的 RAM 内存区。内核从磁盘读出一个块时,就用从硬件设备中获得的值填充相应的块缓冲区。写入时则用块缓冲区的实际值更新硬件设备。

缓冲区的首部是一个与每个缓冲区相关的buffer_head类型的描述符。buffer_head中的某些字段:

  • b_page:块缓冲区所在页框的页描述符地址。
  • 如果页框位于高端内存中,那么b_data字段存放页中块缓冲区的偏移量;否则,存放缓冲区本身的起始线性地址。
  • b_blocknr:存放逻辑块号(如磁盘分区中的块索引)。
  • b_bdev:标识使用缓冲区首部的块设备。

对磁盘的每个 I/O 操作就是在磁盘与一些 RAM 单元间相互传送一些相邻扇区是内容。大多数情况下,磁盘控制器之间采用 DMA 方式进行数据传送。块设备驱动程序只要向磁盘控制器发送一些适当的命令就可以触发一次数据传送,完成后,控制器会发出一个中断通知块设备驱动程序。

DMA传送的是磁盘上相邻扇区的数据,虽然可以传送不相邻的扇区,但是效率很低。新的磁盘控制器支持所谓的分散-聚集DMA传送方式:磁盘可与一些非连续的内容区相互传送数据。

启动一次分散-聚集DMA传送,块设备驱动程序需要向磁盘控制器发送:

  • 要传送的起始磁盘扇区号和总的扇区数
  • 内存区的描述符链表,其中链表的每项包含一个地址和一个长度

磁盘控制器负责整个数据传送。

为了使用分散-聚集 DMA 传送方式,块设备驱动程序必须能处理称为的数据存储单元。一个段就是一个内存页或内存页中的一部分,它们包含一些相邻磁盘扇区中的数据。因此,一次分散-聚集 DMA 操作可能同时传送几个段。

如果不同的段在 RAM 中相应的页框正好是连续的且在磁盘上相应的数据块也是相邻的,那么通用块层可合并它们,产生更大的物理段

通用块层

通用块层是一个内核组件,它处理来自系统中的所有块设备发出的请求。由于该层提供的函数,内核可容易地做到:

  • 将数据缓冲区放在高端内存:仅当 CPU 访问时,才将页框映射为内核中的线性地址空间,并在数据访问后取消映射。
  • 实现零-复制模式,将磁盘数据直接存放在用户态地址空间而不是首先复制到内核内存区;事实上,内核为 I/O 数据传送使用的缓冲区所在的页框就映射在进程的用户态线性地址空间中。
  • 管理逻辑卷,例如由 LVM(逻辑卷管理器)和 RAID(廉价磁盘冗余阵列)使用的逻辑卷:几个磁盘分区,即使位于不同的块设备中,也可被看作一个单一的分区。
  • 发挥大部分新磁盘控制器的高级特性,如大主板磁盘高速缓存、增强的 DMA 性能、I/O 传送请求的相关调度等。

Bio 结构

通用块的核心数据结构bio描述符描述了块设备的 I/O 操作。每个bio结构都包含一个磁盘存储区标识符(存储区中的起始扇区号和扇区数目)和一个或多个描述与与I/O操作相关的内存区的段

bio中的某些字段:

bio中的每个段是由一个bio_vec描述的,各字段如下:

bio_vec数据结构的第一个元素bi_io_vec指向bio_vec中的第一个元素,bi_vcnt存放了bio_vec数组中当前的元素个数。

块 I/O 操作器间bio描述符一直保持更新,例如,如果块设备驱动程序在一次分散-聚集 DMA 操作中不能完成全部的数据传送,则bio中的bi_idx会不断更新来指向待传送的第一个段。为了从索引bi_idx指向当前段开始不断重复bio中的段,设备驱动程序可以执行bio_for_each_segment

当通用块层启动一次新的 I/O 操作时,调用bio_alloc()分配一个新的bio结构。bio结构由slab分配器分配,内存不足时,内核也会使用一个备用的bio小内存池。内核也为bio_vec分配内存池。

bio_put()减少bi_cnt,等于 0 时,释放bio结构及相关的bio_vec结构。

磁盘和磁盘分区表示

磁盘是一个由通用块层处理的逻辑块设备。任何情形中,借助通用块层提供的服务,上层内核组件可以同样的方式工作在所在的磁盘上。磁盘由gendisk对象描述,字段:

flags存放关于磁盘的信息。如果设置GENHD_FL_UP标志,则磁盘将被初始化并可使用。如果为软盘或光盘这样的可移动磁盘,则设置GENHD_FL_REOVABLE标志。gendisk对象的fops字段指向一个表block_device_operations,该表为块设备的主要操作存放了几个指定的方法:

通常硬盘被划分成几个逻辑分区。每个块设备文件要么代表整个磁盘,要么代表磁盘中的某个分区。如果将一个磁盘分成几个分区,则分区表保存在hd_struct结构的数组中。该数组的地址存放在gendisk对象的part字段。通过磁盘内分区的相对索引对该数组进行索引。hd_struct中的字段如下表:

当内核发现系统中一个新的磁盘时(在启动阶段,或将一个可移动介质插入一个驱动器中时,或在运行器附加一个外置式磁盘时),调用alloc_disk()分配并初始化一个新的gendisk对象,如果新磁盘被分成几个分区,还会分配并初始化一个适当的hd_struct类型的数组。然后调用add_disk()将新的gendisk对象插入到通用块层的数据结构中。

提交请求

当向通用块层提交一个 I/O 操作请求时,内核所执行的步骤(假设被请求的数据块在磁盘上相邻,且内核已经知道了它们的物理位置)。

  • 首先,bio_alloc()分配一个新的bio描述符,然后,内核通过设置一些字段初始化bio描述符:
    • bi_sector = 数据的起始扇区号(如果块设备分成了几个分区,那么扇区号是相对于分区的起始位置的)。
    • bi_size = 涵盖整个数据的扇区数目。
    • bi_bdev = 块设备描述符的地址。
    • bi_io_vec = bio_vec 结构数组的起始地址,数组中的每个元素描述了 I/O 操作中的一个段(内存缓存)。
    • bi_vcnt = bio 中总的段数。
    • bi_rw = 被请求操作的标志,READ(0)或 WRITE(1)。
    • bi_end_io = 当 bio 上的 I/O 操作完成时所执行的完成程序的地址。
  • bio描述符被初始化后,内核调用generic_make_request(),它是通用块层的主要入口点,该函数执行下列操作:
    • 如果bio->bi_sector> 块设备的扇区数,bio->bi_flags = BIO_EOF,打印一条内核出错信息,调用bio_endio()并终止。bio_endio()更新bio描述符中的bi_sizebi_sector,然后调用biobi_end_io方法。bi_end_io函数依赖于触发 I/O 数据传送的内核组件。
    • 获取与块设备请求相关的请求队列q,其地址存放在块设备描述符的bd_disk字段,其中的每个元素由bio->bi_bdev指向。
    • 调用block_wait_queue_running()检查当前正在使用的 I/O 调度程序是否可被动态取代;如果可以,则让当前进程睡眠直到启动一个新的 I/O 调度程序。
    • 调用blk_partition_remap()检查块设备是否指的是一个磁盘分区(bio->bi_bdev != bio->bi_dev->bd_contains)。如果是,从bio->bi_bdev获取分区的hd_struct描述符,从而执行下面的子操作:
      • 根据数据传送的方向,更新hd_struct描述符中的read_sectorsreadswrite_sectorswrites`值。
      • 调整bio->bi_sector值,使得把相对于分区的起始扇区号转变为相对于整个磁盘的扇区号。
      • bio->bi_bedv = 整个磁盘的块设备描述符(bio->bd_contains)
      • 从现在开始,通用块层、I/O 调度程序及设备驱动程序将忘记磁盘分区的存在,直接作用于整个磁盘。
    • 调用q->make_request_fn方法将bio请求插入请求队列q中。
    • 返回。

总结:主要是分配并初始化bio描述符,以描述符 I/O 操作请求;获取请求队列,将相对于磁盘分区的 I/O 操作请求转换为相对于整个磁盘的 I/O 操作请求;I/O 操作请求入队列。

I/O 调度程序

只要可能,内核就试图把几个扇区合并在一起,作为一个整体处理,以减少磁头的平均移动时间。

当内核组件要读或写一些磁盘数据时,会创建一个块设备请求。请求描述的是所请求的扇区及要对它执行的操作类型(读或写)。但请求发出后内核不一定会立即满足它,I/O 操作仅仅被调度,执行会向后推迟。当请求传送要给新的数据块时,内核检查能否通过稍微扩展前一个一直处于等待状态的请求而满足新的请求。

延迟请求复杂化了块设备的处理。因为块设备驱动程序本身不会阻塞,否则会阻塞试图访问同一磁盘的任何其他进程。

为防止块设备驱动程序被挂起,每个 I/O 操作都是异步处理的。特别是块设备驱动程序是中断驱动的:

  • 通用块层调用 I/O 调度程序产生一个新的块设备请求,或扩展一个已有的块设备请求,然后终止。
  • 激活的块设备驱动程序会调用一个策略例程来选择一个待处理的请求,并向磁盘控制器发出一条命令以满足该请求。
  • 当 I/O 操作终止时,磁盘控制器就产生一个中断,相应的中断处理程序就又调用策略例程去处理队列中的另一个请求。

每个块设备驱动程序都维持着自己的请求队列,它包含设备待处理的请求链表。如果磁盘控制器正在处理几个磁盘,那么通常每个物理块都有一个请求队列。在每个请求队列上单独执行 I/O 调度,可提供高盘性能。

请求队列描述符

请求队列由一个大的数据结构request_queue表示。

请求队列是一个双向链表,其元素是请求描述符(request数据结构)。queue_head存放链表的头。queuelist把任一请求链接到前一个和后一个元素之间。队列链表中元素的排序方式对每个块设备驱动程序是特定的。IO调度程序提供了几种预先定义的元素排序方式。

backing_dev_info:一个backing_dev_info类型的小对象,存放了关于基本硬件块设备的 I/O 数据流量的信息。

请求描述符

每个块设备的待处理请求都是用一个请求描述符表示的,存放于request数据结构。

每个请求包含一个或多个bio结构。最初,通用层创建一个仅包含一个bio结构的请求。然后,I/O 调度程序要么向初始bio中增加一个新段,要么将另一个bio结构链接到请求,从而扩展该请求。bio字段指向第一个bio结构,biotail指向最后一个bio结构。rq_for_each_bio宏执行一个循环,从而遍历请求中的所有bio结构。

flags:存放很多标志,最重要的一个是REQ_RW,确定数据传送的方向,READ(0)或WRITE(1)。

对请求描述符的分配进行管理

在重负载和磁盘操作频繁时,固定数目的动态内存将成为进程想把新请求加入请求队列q的瓶颈。
为解决该问题,每个request_queue描述符包含一个request_list数据结构,其中包括:

  • 一个指针,指向请求描述符的内存池。
  • 两个计数器,分别记录分配给READWRITE请求的请求描述符。
  • 两个标志,分别标记读或写请求的分配是否失败。
  • 两个等待队列,分别存放了为获得空闲的读和写请求描述符而睡眠的进程。
  • 一个等待队列,存放等待一个请求队列被刷新(清空)的进程。

blk_get_request()从一个特定请求队列的内存池中获得一个空闲的请求描述符;如果内存区不足且内存池已经用完,则挂起当前进程,或返回 NULL(不能阻塞内核控制路径)。如果分配成功,则将请求队列的request_list数据结构的地址存放在请求描述符的rl字段。blk_put_request()释放一个请求描述符;如果该描述符的引用计数器为 0,则将描述符归还回它原来所在的内存池。

避免请求队列拥塞

request_queuenr_requests字段存放每个数据传送方向所允许处理的最大请求数。缺省情况下,一个队列至多 128 个待处理读请求和 128 个待处理写请求。如果待处理的读(写)请求数超过了nr_requests,设置request_queuequeue_flags字段的QUEUE_FLAG_READFULLQUEUE_FLAG_WRITEFULL)标志将该队列标记为已满,试图把请求加入某个传送方向的可阻塞进程被放到request_list结构所对应的等待队列中睡眠。

如果给定传送方向上的待处理请求数超过了requestnr_congestion_on字段中的值(缺省为 113),则内核认为该队列是拥塞的,并试图降低新请求的创建速率blk_congestion_wait()挂起当前进程,直到所请求队列都变为不拥塞或超时已到。

激活块设备驱动程序

延迟激活块设备驱动程序有利于集中相邻块的请求。这种延迟是通过设备插入和设备拔出技术实现的。块设备驱动程序被插入时,该驱动程序不被激活,即使在驱动程序队列中有待处理的请求。

blk_plug_device()插入一个块设备:插入到某个块设备驱动程序的请求队列中。参数为一个请求队列描述符的地址q。设置q->queue_flags字段中的QUEUE_FLAG_PLUGGED位,然后重启q->unplub_timer字段中的内嵌动态定时器。

blk_remove_plug()拔出一个请求队列q:清除QUEUE_FLAG_PLUGGED标志并取消q->unplug_timer动态定时器。当所有可合并的请求都被加入请求队列时,内核就会显式调用该函数。此外,如果请求队列中待处理的请求数超过了请求队列描述符的unplug_thresh字段中存放的值(缺省为 4),I/O 调度程序也会去掉该请求队列。

如果一个设备保持插入的时间间隔为q->unplug_delay(通常为 3ms),则说明blk_plug_device()激活的动态定时器时间已用完,因此会执行blk_unplug_timeout()。因而,唤醒内核线程kblocked所操作的工作队列kblocked_workueuekblocked执行blk_unplug_work(),其地址存放在q->unplug_work中。接着,该函数会调用请求队列中的q->unplug_fn方法,该方法通常由generic_unplug_device()实现。

generic_unplug_device()的功能是拔出块设备:

  • 检查请求队列释放仍然活跃。
  • 调用blk_remove_plug()
  • 执行策略例程reuqest_fn方法开始处理请求队列中的下一个请求。

I/O 调度算法

I/O 调度程序也被称为电梯算法

Linux 2.6 中提供了四种不同类型的 I/O 调度程序或电梯算法,分别为预期算法,最后期限算法,CFQ(完全公平队列)算法,及Noop(No Operation)算法。

对于大多数块设备,内核使用缺省电梯算法可在引导时通过内核参数elevator=<name>进行再设置,其中<name>可取值为:asdeadlinecfgnoop。缺省为预期I/O调度程序。设备驱动程序也可定制自己的 I/O 调度算法。

请求队列中使用的 I/O 调度算法由一个elevator_t类型的elevator对象表示,该对象的地址存放在请求队列描述符的elevator字段。elevator对象包含了几个方法:链接和断开elevator增加和合并队列中的请求从队列中删除请求获得队列中下一个待处理的请求等。

elevator也存放了一个表的地址,表中包含了处理请求队列所需的所有信息。每个请求描述符包含一个elevator_private字段,指向一个由 I/O 调度程序用来处理请求的附加数据结构。

一般,所有的算法都使用一个调度队列,队列中包含的所有请求按照设备驱动程序应当处理的顺序排序。几乎所有的算法都使用另外的队列对请求进行分类和排序。

“Noop”算法

最简单的 I/O 调度算法。没有排序的队列。新的请求被插入到队列的开头或末尾,下一个要处理的总是队列中的第一个请求。

“CFQ”完全公平队列算法

目标是在触发 I/O 请求的所有进程中确保磁盘 I/O 带宽的公平分配。为此,算法使用多个排序队列(缺省为 64)存放不同进程发出的请求。当处理一个请求时,内核调用一个散列函数将当前进程的线程组标识符换为队列的索引值,然后将一个新的请求插入该队列的末尾。

算法采用轮询方式扫描 I/O 输入队列,选择第一个非空队列,然后将该队列中的一组请求移动到调度队列的末尾。

“最后期限”算法

除了调度队列外,还使用了四个队列。其中的两个排序队列分别包含读请求和写请求,请求根据起始扇区数排序。另外两个最后期限队列包含相同的读和写请求,但根据“最后期限”排序。引入这些队列是为了避免请求饿死。最后期限保证了调度程序照顾等待了很久的请求,即使它位于排序队列的末尾。

补充调度队列时,首先确定下一个请求的数据方向。如果同时要调度读和写两请求,算法会选择“读”方向,除非“写”方向已经被放弃很多次了。

检查与被选择方向相关的最后期限队列:如果队列中的第一个请求的最后期限已用完,那么将该请求移到调度队列的末尾;也可从超时的那个请求开始移动来自排序队列的一组请求。如果将要移动的请求在磁盘上物理相邻,则组的长度会变长,否则变短。

如果没有请求超时,算法对来自排序队列的最后一个请求之后的一组请求进行调度。当指针到达排序队列的末尾时,搜索又从头开始(“单方向算法”)。

“预期”算法

是 Linux 提供的最复杂的一种 I/O 调度算法。它是“最后期限”算法的一个演变:两个最后期限队列和两个排序队列I/O 调度程序在读和写请求之间交互扫描排序队列,不过更倾向于读请求。扫描基本上是连续的,除非某个请求超时。读请求的缺省超时时间是 125ms,写请求为 250ms。算法还遵循一些附加的启发式规则:

  • 有些情况下,算法可能在排序队列当前位置之后选择一个请求,从而强制磁头从后搜索。这通常发生在该请求之后的搜索距离小于在排序队列当前位置之后对该请求搜索距离的一半时。
  • 算法统计系统中每个进程触发的 I/O 操作种类。当刚刚调度了由某个进程 p 发出的一个读请求后,立马检查排序队列中下一个请求是否来自同一进程 p。
    • 如果是,立即调度下一请求。
    • 否则,查看关于该进程 p 的统计信息:如果确定 p 可能很快发出另一个读请求,则延迟一小段时间(缺省约 7ms)。
    • 因此,算法预测进程 p 发出的读请求与刚被调度的请求在磁盘上可能是“近邻”。

向 I/O 调度程序发出请求

generic_make_request()调用请求队列描述符的make_request_fn方法向 I/O 调度程序发送一个请求。通常该方法由__make_request()实现,__make_request()参数为request_queue类型的描述符qbio结构的描述符bio。执行下列操作:

  1. 如果需要,调用blk_queue_bounce()建立一个回弹缓冲区。然后,对该缓冲区而不是原先的bio结构进行操作。
  2. 调用I/O调度程序的elv_queue_empty()检查请求队列中是否存在待处理请求。调度队列可能是空的,但I/O调度程序的其他队列可能包含待处理请求。如果没有,调用blk_plug_device()插入请求队列,然后跳到第5步。
  3. 插入的请求队列包含待处理请求。调用I/O调度程序的elv_merge()检查新的bio结构是否可以并入已存在的请求中,将返回三个可能值:
    1. ELEVATOR_NO_MERGE:已经存放在的请求中不能包含bio结构,跳到第5步。
    2. ELEVATOR_BACK_MERGEbio结构可作为末尾的bio而插入到某个请求req中,调用q->back_merge_fn方法检查是否可扩展该请求。如果不行,跳到第5步;否则,将bio描述符插入req链表的末尾并更新req的相应字段值。然后,函数试图将该请求与后面的请求合并。
    3. ELEVATOR_FRONT_MERGEbio结构可作为某个请求req的第一个bio被插入,函数调用q->front_merge_fn方法检查是否可扩展该请求。如果不行跳到第5步;否则,将bio描述符插入req链表的首部并更新req的相应字段值。然后,试图将该请求与前面的请求合并。
  4. bio已经被并入存放在的请求中,跳到第7步终止函数。
  5. bio必须被插入一个新的请求中。分配一个新的请求描述符。如果没有空闲的内存,那么挂起当前进程,直到设置了bio->bi_rw中的BIO_RW_AHEAD标志,表明这个I/O操作是一次预读;这种情形下,函数调用bio_endio()并终止:不执行数据传输。
  6. 初始化请求描述符中的字段,主要有:
    1. 根据bio描述符的内容初始化各个字段,包括扇区数、当前bio及当前段。
    2. 设置flags字段中的REQ_CMD标志。
    3. 如果第一个bio段的页框存放在低端内存,则将buffer字段设置为缓冲区的线性地址。
    4. rq_disk = bio->bi_bdev->bd_disk的地址
    5. bio插入请求链表。
    6. start_time = jiffies 值
  7. 所有操作都完成。终止前,检查是否设置了bio->bi_rw中的BIO_RW_SYNC标志,如果是,对请求队列调用generic_unplug_device()卸载设备驱动程序。
  8. 函数终止。

总结:根据请求队列是否为空,不空时是否与已有请求合并,来确定bio与现有请求合并还是新分配、初始化一个新的bio描述符,并插入请求链表。然后根据需要卸载驱动程序,函数终止。

blk_queue_bounce()

功能是查看q->bounce_gfp中的标志及q->bounce_pfn中的阈值,从而确定回弹缓冲区是否必须。通常当请求中的一些缓冲区位于高端内存,而硬件设备不能访问它们时发生该情况。

当处理老式设备时,块设备驱动程序通常更倾向于直接在ZONE_DMA内存区分配DMA缓冲区。如果硬件设备不能处理高端内存中的缓冲区,则blk_queue_bounce()检查bio中的一些缓冲区是否真的必须是回弹的。如果是,则将bio描述符复制一份,接着创建一个回弹bio;当段中的页框号等于或大于q->bounce_pfn时,执行下列操作:

  • 根据分配的标志,在ZONE_NORMALZNOE_DMA内存区中分配一个页框。
  • 更新回弹bio中段的bv_page字段,使其指向新页框的描述符。
  • 如果bio->bio_rw代表一个写操作,则调用kmap()临时将高端内存页映射到内核地址空间中,然后将高端内存页复制到低端内存页上,最后调用kunmap()释放该映射。

然后blk_queue_bounce()设置回弹bio中的BIO_BOUNCED标志,为其初始化一个特定的bi_end_io方法,最后它将存放在biobi_private字段中,该字段指向初始bio的指针。
当回弹bio上的 I/O 数据传送终止时,bi_end_io方法将数据复制到高端内存区中(仅适合读操作),并释放该回弹bio结构。

块设备驱动程序

块设备驱动程序是 Linux 块子系统中最底层组件。它们从 I/O 调度程序获得请求,然后按要求处理这些请求。每个块设备驱动程序对应一个device_driver类型描述符。

块设备

一个块设备驱动程序可能处理几个块设备。块设备驱动程序必须处理块设备对应的块设备文件上的所有VFS系统调用。每个块设备由一个block_device结构描述符表示。

所有块设备的描述符被插入一个全局链表中,链表首部由变量all_bdevs表示;链表链接所用的指针位于块设备描述符的bd_list字段。

如果块设备描述符对应一个磁盘分区,则bd_contains指向与整个磁盘相关的块设备描述符;bd_part指向hd_struct分区描述符。否则,块设备描述符对应整个磁盘,bd_contains指向块设备描述符本身,bd_part_count记录磁盘上的分区已经被打开了多少次。

bd_holder代表块设备持有者的线性地址。持有者是一个内核组件,典型为安装在该设备上的文件系统。当块设备文件被打开进行互斥访问时,持有者就是对应的文件对象。

bd_claim()bd_holder设置为一个特定的地址;bd_release()将该字段重新设置为 NULL。同一内核组件可多次调用bd_claim(),每次调用都增加bd_holders值;为释放块设备,内核组件必须调用bd_release()函数bd_holders次。

访问块设备

当内核接收一个打开块设备文件的请求时,必须先确定该设备文件是否已经是打开的。如果是,则内核没必要创建并初始化一个新的块设备描述符,而是更新已存在的块设备描述符。然而,真正的复杂性在于具有相同主设备号和次设备号但不同路径名的块设备被 VFS 看作不同的文件。因此,内核无法通过简单地在一个对象的索引节点高速缓存中检查块设备文件的存在就确定相应的块设备已经在使用。

主、次设备号和相应的块设备描述符之间的关系是通过bdev特殊文件系统来维护的。每个块设备描述符都对应一个bdev特殊文件:块设备描述符的bd_inode字段指向相应的bdev索引节点;而该索引节点将为块设备的主、次设备号和相应描述符的地址进行编码。

bdget()参数为块设备的主设备号和次设备号,在bdev文件系统中查询相关的索引节点;如果不存在这样的节点,则分配一个新索引节点和新块设备描述符。返回一个与给定主、次设备号对应的块设备描述符的地址。

找到块设备描述符后,内核通过检查bd_openers字段来确定块设备当前是否在使用:如果为正值,则块设备已经在使用(可能通过不同的设备文件)。同时,内核也维护一个与已打开的块设备文件对应的索引节点对象的链表。该链表存放在块设备描述符的bd_inodes字段;索引节点对象的i_devices字段存放于链接链表中的前后元素的指针。

注册和初始化设备驱动程序

定义驱动程序描述符

首先,设备驱动程序需要一个foo_dev_t类型的自定义描述符foo,它拥有驱动硬件设备所需的数据。该描述符存放每个设备的相关信息,如操作设备使用的I/O端口、设备发出中断的 IRQ 线、设备的内部状态等。同时也包含块 I/O 子系统所需的一些字段:

1
2
3
4
5
6
7
struct foo_dev_t
{
[...]
spinlock_t lock;
struct gendisk *gd;
[...]
};

lock字段是保护foo描述符中字段值的自旋锁,保护对驱动程序而言特定的块IO子系统的数据结构;gd是指向gendisk描述符的指针,该描述符描述由该驱动程序处理的整个块设备

预定主设备号

驱动程序通过register_blkdev()预定一个主设备号,传统上通过register_blkdev()完成。

1
2
3
err = register_blkdev(FOO_MAJOR, "foo");  
if(err)
goto error_major_is_busy;

预定主设备号FOO_MAJOR并将设备名称foo赋给它,预定的主设备号和驱动程序之间的数据结构还没有建立连接,结果为产生一个新条目,该条目位于/proc/devices特殊文件的已注册设备号列表中

初始化自定义描述符

为初始化于块 I/O 子系统相关的字段,设备驱动程序主要执行下列操作:

1
2
3
4
5
spin_lock_init(&foo.lock); 
foo.gd = alloc_disk(16);

if(!foo.gd)
goto error_no_gendisk;

首先初始化自旋锁,然后分配一个磁盘描述符,alloc_disk()也分配一个存放磁盘分区描述符的数组,所需要的参数是数组中hd_struct结构的元素参数。16表示驱动程序可支持16个磁盘,每个磁盘可包含15个分区(0分区不使用)

初始化 gendisk 描述符

接下来,驱动程序初始化gendisk描述符的一些字段:

1
2
3
4
5
6
7
8
9

foo.gd->private_data = &foo;
foo.gd->major = FOO_MAJOR;
foo.gd->first_minor = 0;
foo.gd->minors = 16;

set_capacity(foo.gd, foo_disk_capacity_in_sectors);
strcpy(foo.gd->disk_name, "foo");
foo.gd->fops = &foo_ops;

foo描述符的地址存放在gendiskprivate_data字段,// 因此被块 I/O 子系统当作方法调用的低级驱动程序函数可迅速查找到驱动程序描述符。如果驱动程序可并发地处理多个磁盘,可提高效率,set_capacity()函数将capacity字段初始化为以 512 字节扇区为单位的磁盘大小,该值也可能在探测硬件并询问磁盘参数时确定。

初始化块设备操作表

gendisk描述符的fops字段步初始化为自定义的块设备方法表的地址。类似地,设备驱动程序的foo_ops表中包含设备驱动程序的特有函数。例如,如果硬件设备支持可移动磁盘,通用块将调用media_changed方法检测自从最后一次安装或打开该设备以来,磁盘是否被更换。通常通过硬件控制器发送一些低级命令完成该检查,因此,每个设备驱动程序所实现的media_changed方法都不同。

类似地,仅当通用块层不知道如何处理ioctl命令时才调用ioctl方法。如,当一个ioctl()询问磁盘构造时,即磁盘使用的柱面数、磁道数、扇区数即磁头数时,通常用该方法。因此,每个设备驱动程序所实现的ioctl方法也都不同。

分配和初始化请求队列

可通过如下操作建立请求队列:

1
2
3
4
5
6
7
8
foo.gd->rq = blk_init_queue(foo_strategy, &foo.lock); 
if(!foo.gd->rq)
goto error_no_request_queue;

blk_queue_hardsect_size(foo.gd->rd, foo_hard_sector_size);
blk_queue_max_sectors(foo.gd->rd, foo_max_sectors);
blk_queue_max_hw_segments(foo.gd->rd, foo_max_hw_segments);
blk_queue_max_phys_segments(foo.gd->rd, foo_max_phys_segments);

blk_init_queue()分配一个请求队列描述符,并将其中许多字段初始化为缺省值,参数为设备描述符的自旋锁的地址(foo.gd->rq->queue_lock)和设备驱动程序的策略例程的地址(foo.gd->rq->request_fn),也初始化foo.gd->rq->elevator字段为缺省的 I/O 调度算法。接下来使用几个辅助函数将请求队列描述符的不同字段设为设备驱动程序的特征值。

设置中断处理程序

设备驱动程序为设备注册 IRQ 线:

1
request_irq(foo_irq, foo_interrupt, SA_INTERRUPT | SA_INTERRUPT | SA_SHIRQ, "foo", NULL);

foo_interrupt()是设备的中断处理程序。

注册磁盘

最后一步是“注册”和激活磁盘,可简单地通过执行下面的操作完成:

1
add_disk(foo.gd);

add_disk()的参数为gendisk描述符的地址,执行下面步骤:

  • 设置gd->flagsGENHD_FL_UP标志。
  • 调用kobj_map()建立设备驱动程序和设备的主设备号(连同相关范围内的次设备号)之间的连接。
  • 注册设备驱动程序模型的gendisk描述符的kobject结构,它作为设备驱动程序处理的一个新设备(如/sys/block/foo)。
  • 如果需要,扫描磁盘中的分区表;对于查找到的每个分区,适当地初始化foo.gd->part数组中相应的hd_struct描述符。
  • 同时注册设备驱动程序模型中的分区(如/sys/block/foo/foo1)。
  • 注册设备驱动程序模型的请求队列描述符中内嵌的kobject结构(如/sys/block/foo/queue)。

一旦add_disk()返回,设备驱动程序就可以工作了。进程初始化的函数终止;策略例程和中断处理程序开始处理 I/O 调度程序传送给设备驱动程序的每个请求。

策略例程

策略例程是块设备驱动程序的一个函数或一组函数,它与硬件块设备之间相互作用以满足调度队列中的请求。通过请求队列描述符中的request_fn方法可调用策略例程,如foo_strategy(),I/O 调度程序层将请求队列描述符q的地址传给该函数。

把新的请求插入空的请求队列后,策略例程通常才被启动。只要块设备驱动程序被激活,就应该对队列中的所有请求进行处理,直到队列为空才结束。

块设备驱动程序采用如下策略:

  • 策略例程处理队列中的第一个请求并设置块设备控制器,以便在数据传送完成时产生一个中断。然后策略例程终止。
  • 当磁盘控制器产生中断时,中断控制器重新调度策略例程。
  • 策略例程要么为当前请求再启动一次数据传送,要么当请求的所有数据块已经传送完成时,把该请求从调度队列中删除然后开始处理下一个请求。

请求是由几个bio结构组成的,而每个bio结构又由几个段组成。基本上,块设备驱动程序以以下方式使用 DMA:

  • 驱动程序建立不同的 DMA 传送方式,为请求的每个bio结构的每个段进行服务。
  • 驱动程序建立以一种单独的分散-聚集 DMA 传送方式,为请求的所有bio中的所有段服务。

设备驱动程序策略例程的设计依赖块控制器的特性。如,foo_strategy()策略例程执行下列操作:

  • 通过调用 I/O 调度程序的辅助函数elv_next_request()从调度队列中获取当前的请求。如果调度队列为空,就结束这个策略例程:
1
2
3
req = elv_next_request(q);
if(!req)
return;
  • 执行blk_fs_request宏检测是否设置了请求的REQ_CMD标志,即请求是否包含一个标准的读或写操作:

    1
    2
    if(!blk_fs_request(req))
    goto handle_special_request;
  • 如果块设备控制器支持分散-聚集 DMA,那么对磁盘控制器进行编程,以便为整个请求执行数据传送并再传送完成时产生一个中断。blk_rq_map_sg()辅助函数返回一个可以立即被用来启动数据传送的分散-聚集链表。

  • 否则,设备驱动程序必须一段一段地传送数据。这种情形下,策略例程执行rq_for_each_biobio_for_each_segment两个宏,分别遍历bio链表和每个bio中的链表:
1
2
3
4
5
6
7
8
9
rq_for_each_bio(bio, rq)
bio_for_each_segment(bvec, bio, i)
{
local_irq_save(flags);
addr = kmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
foo_start_dma_transfer(addr+bvec->bv_offset, bvec->bv_len);
kunmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
local_irq_restore(flags);
}
  • 如果要传送的数据位于高端内存,kmap_atomic()kunmap_atomic()是必需的,foo_start_dma_transfer()对硬件设备进行编程,以便启动 DMA 数据传送并在 I/O 操作完成时产生一个中断
  • 返回。

中断处理程序

块设备驱动程序的中断处理程序在 DMA 数据传送结束时被激活。它检查是否已经传送完成请求的所有数据块,如果是,中断处理程序就调用策略例程处理调度队列中的下一个请求;否则,中断处理程序更新请求描述符的相应字段并调用策略例程处理还没有完成的数据传送。

设备驱动程序foo的中断处理程序的一个典型片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
irqreturn_t foo_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
struct foo_dev_t *p = (struct foo_dev_t *)dev_id;
struct request_queue *rq = p->gd->rq;
[...]
if(!end_that_request_first(rq, uptodata, nr_seectors))
{
blkdev_dequeue_request(rq);
end_that_request_last(rq);
}
rq->request_fn(rq);
[...]
return IRQ_HANDLED;
}

end_that_request_first()end_that_request_last()共同承担结束一个请求的任务。

end_that_request_first()接收的参数:

  • 一个请求描述符
  • 一个指示 DMA 数据传送完成的标志
  • DMA 所传送的扇区数

end_that_request_first()扫描请求中的bio结构及每个bio中的段,然后采用如下方式更新请求描述符的字段值:

  • 修改bio字段,使其执行请求中的第一个未完成的bio结构。
  • 修改未完成bio结构的bi_idx字段,使其指向第一个未完成的段。
  • 修改未完成的bv_offsetbv_len字段,使其指定仍需传送的数据。

end_that_request_first()如果已经完成请求中的所有数据块,则返回0,否则返回1,如果返回1则中断处理程序重新调用策略历程,继续处理该请求。否则,中断处理程序把请求从请求队列中删除(主要由blkdev_dequeue_request()完成),然后调用end_that_request_last(),并再次调用策略例程处理调度队列中的下一个请求。

end_that_request_last()功能是更新一些磁盘使用统计数,把请求描述符从 I/O 调度程序rq->elevator的调度队列中删除,唤醒等待请求描述符完成的任一睡眠进程,并释放删除的那个描述符

打开块设备文件

内核打开一个块设备文件的时机:

  • 一个文件系统被映射到磁盘或分区上时
  • 激活一个交换分区时
  • 用户态进程向块设备文件发出一个 open() 系统调用时

在所有情况下,内核本质上执行相同的操作:寻找块设备描述符(如果块设备没有在使用,则分配一个新的描述符),为即将开始的数据传送设置文件操作方法

dentry_open()f_op字段设置为表def_blk_fops的地址:

仅考虑open方法,它由dentry_open()调用。blkdev_open()参数为inodefilp,分别为索引节点和文件对象的地址,本质上执行下列操作:

  1. 执行bd_acquire(inode)从而获得块设备描述符bdev的地址。该函数参数为索引节点对象的地址,执行下列主要步骤:
    1. 如果索引节点对象的inode->i_bdev字段不为NULL,表明块设备文件已经打开,该字段存放了相应块描述符的地址。增加与块设备相关联的bdev特殊文件系统的inode->i_bdev->bd_inode索引节点的引用计数器值,并返回描述符inode->i_bdev的地址。
    2. 否则,块设备文件没有被打开。根据块设备相关联的主设备号和次设备号,执行bdget(inode->i_rdev)获取块设备描述符的地址。如果描述符不存在,bdget()就分配一个。
    3. inode->i_bdev = 块设备描述符的地址,以便加速将来对相同块设备文件的打开操作。
    4. inode->i_mapping设置为bdev索引节点中相应字段的值。inode->i_mapping指向地址空间对象。
    5. 把索引节点插入到bdev->bd_inodes确立的块设备描述符的已打开索引节点链表中。
    6. 返回描述符bdev的地址。
  2. filp->i_mapping设置为inode->i_mapping
  3. 获取与这个块设备相关的gendisk描述符的地址:disk = get_gendisk(bdev->bd_dev, &part);
    1. 如果被打开的块设备是一个分区,则返回的索引值存放在本地变量part中;否则,part为0
    2. get_gendisk()函数在kobject映射域bdev_map上简单地调用kobj_lookup()传递设备的主设备号和次设备号
  4. 如果bdev->bd_openers != 0,说明块设备已经被打开。检查bdev->bd_contains字段:
    1. 如果等于bdev,那么块设备是一个整盘:调用块设备方法bdev->bd_disk->fops->open(如果定义了),然后检查bdev->bd_invalidated的值,需要时调用rescan_partitions()
    2. 如果不等于bdev,那么块设备是一个分区:bdev->bd_contains->bd_part_count++,跳到第 8 步。
  5. 这里的块设备是第一次被访问。初始化bdev->bd_diskgendisk描述符的地址disk
  6. 如果块设备是一个整盘(part == 0),则执行下列子步骤:
    1. 如果定义了disk->fops->open块设备方法,就执行它:该方法由块设备驱动程序定义的定制函数,它执行任何特定的最后一分钟初始化。
    2. disk->queue请求队列的hardsect_size字段中获取扇区大小(字节数),用该值适当地设置bdev->bd_block_sizebdev->bd_inode->i_blkbits。同时从disk->capacity中计算来的磁盘大小设置bdev->bd_inode->i_size字段。
    3. 如果设置了bdev->bd_invalidated标志,则调用rescan_partitions()扫描分区表并更新分区描述符。该标志是由check_disk_change块设备方法设置的,仅适用于可移动设备。
  7. 否则,如果块设备是一个分区,则执行下列子步骤:
    1. 再次调用bdget(),这次是传递disk->first_minor次设备号,获取整盘的块描述符地址whole
    2. 对整盘的块设备描述符重复第 3 步 ~ 第 6 步,如果需要则初始化该描述符。
    3. bdev->bd_contains设置为整盘描述符的地址。
    4. whole->bd_part_count++,从而说明磁盘分区上新的打开操作。
    5. disk->part[part-1]中的值设置bdev->bd_partdisk->part[part-1]是分区描述符hd_struct的地址。同样,执行kobject_get(&bdev->bd_part->kobj)增加分区引用计数器的值。
    6. 与第 6b 步中一样,设置索引节点中表示分区大小和扇区大小的字段。
  8. 增加bdev->bd_openers的值。
  9. 如果块设备文件以独占方式被打开(设置了filp->f_flags中的O_EXCL标志),则调用bd_claim(bdev, filp)设置块设备的持有者。如果块设备已经有一个持有者,则释放该块设备描述符并返回要给错误码 -EBUSY。
  10. 返回 0(成功)终止。

blkdev_open()一旦中止,open()系统调用如往常一样继续执行。对已打开的文件上将来发出的每个系统调用都将触发一个缺省块设备文件操作。

页高速缓存

磁盘高速缓存是一种软件机制,它允许系统把通常存放在磁盘上的一些数据保留在 RAM 中,以便对那些数据的进一步访问不用再访问磁盘而能尽快得到满足。

页高速缓存

几乎所有的文件读写操作都依赖于高速缓存,内核的代码和内核数据结构不必从磁盘读,也不必写入磁盘。因此,页高速缓存中的页可能是如下的类型:

  • 含有普通文件数据的页
  • 含有目录的页
  • 含有直接从块设备文件中读出的页
  • 含有用户态进程数据的页
  • 属于特殊文件系统文件的页

只有在O_DIRECT标志被置位,而进程打开文件的情况下才会出现例外。

内核设计者实现页高速缓存主要满足:

  • 快速定位含有给定所有者相关数据的特定页
  • 记录在读或写页中的数据时应当如何处理高速缓存的每个页

页高速缓存中的信息单位是一个完整的页。一个页包含的磁盘块在物理上不一定相邻,所以不能用设备号和块号标识,而是通过页的所有者和所有者数据中的索引来识别。

address_space 对象

页高速缓存的核心数据结构是address_space对象,它是一个嵌入在页所有者的索引节点对象中的数据结构。高速缓存中的许多页可能属于同一个所有者,从而可能被链接到同一个address_space对象。该对象还在所有者的页和对这些页的操作之间建立起链接关系。

每个页描述符都包含把页链接到页高速缓存的两个字段mappingindexmapping指向拥有页的索引节点的address_space对象;index表示所有者的地址空间中以页大小为单位的偏移量,即页中数据在所有者的磁盘映像中的位置。在页高速缓存中查找页时使用这两个字段。

页高速缓存可包含同一磁盘数据的多个副本。例如可以用下述方式访问普通文件的同一4KB数据块:

  • 读文件,数据包含在普通文件的索引节点所拥有的页中
  • 从文件所在的设备文件读取块,因此数据包含在块设备文件的主索引节点所拥有的页中

因此,两个不同address_space对象所引用的两个不同的页中出现了相同的磁盘数据。address_space的一些字段:

如果页高速缓存中页的所有者是一个文件,address_space对象就嵌入在 VFS 索引节点对象的i_data字段中。索引节点的i_mapping字段总是指向索引节点的数据页所拥有的address_space对象。address_space对象的host字段指向其所有者的索引节点对象。因此如果页属于一个文件,那么页的所有者就是文件的索引节点,而且相应的address_space对象存放在VFS索引节点对象的i_data字段中。索引节点的i_mapping字段指向同一个索引节点的i_data字段,而address_space对象的host字段也指向这个索引节点。

不过,有些时候情况会更复杂。如果页中包含的数据来自块设备文件,即页含有存放着块设备的“原始”数据,那么就把address_space对象嵌入到与该块设备相关的特殊文件系统bdev中文件的“主”索引节点中。因此,块设备文件对应索引节点的i_mapping字段指向主索引节点中的address_space对象。相应地,address_space对象的host字段指向主索引节点。这样,从块设备读取数据的所有页具有相同的address_space对象,即使这些数据位于不同的块设备文件。

backing_dev_info指向backing_dev_info描述符,是对所有者的数据所在块设备进行描述的数据结构。backing_dev_info结构通常嵌入在块设备的请求队列描述符中。

private_list是普通链表的首部,文件系统在实现其特定功能时可随意使用。如,Ext2 文件系统利用该链表收集与索引节点相关的“间接”块的脏缓冲区。当刷新操作把索引节点强行写入磁盘时,内核页同时刷新该链表中的所有缓冲区。

a_ops指向一个类型为address_space_operations的表,表中定义了对所有者的页进行处理的各种方法。

基树

为实现页高速缓存的高效查找,每个address_space对象对应一棵搜索树。

address_spacepage_tree字段是基树的根,包含指向所有者的页描述符的指针。给定的页索引表表示页在所有者磁盘映像中的位置,内核能通过快速搜索操作确定所需要的页是否在页高速缓存中。当查找所需要的页时,内核把页索引转换为基树中的路径,并快速找到页描述符所在的位置。如果找到,内核可从基树获得页描述符,并很快确定所找的页是否为脏页,以及其数据的 I/O 传送是否正在进行。

基树的每个节点可有多达 64 个指针指向其他节点或页描述符。底层节点存放指向页描述符的指针(叶子节点),而上层的节点存放指向其他节点(孩子节点)的指针。每个节点由radix_tree_node数据结构表示,包含三个字段:

  • slots:包含 64 个指针的数组
  • count:记录节点中非空指针数量的计数器
  • tags:二维的标志数组

树根由radix_tree_root数据结构表示,有三个字段:

  • height:树的当前深度(不包括叶子节点的层数)
  • gfp_mask:为新节点请求内存时所用的标志
  • rnode:指向与树中第一层节点相应的数据结构radix_tree_node

基树中,页索引相当于线性地址,但页索引中要考虑的字段的数量依赖于基树的深度。
如果基树的深度为 1,就只能表示从 0 ~ 63 范围的索引,因此页索引的低 6 位被解释为 slots 数组的下标,每个下标对应第一层的一个节点。
如果基树深度为2,就可以表示从 0 ~ 4085 范围的索引,页索引的低 12 位分成两个 6 位的字段,高位的字段表示第一层节点数组的下标,而低位的字段用于表示第二层节点数组的下标。依次类推。

如果基树的最大索引小于应该增加的页的索引,则内核相应地增加树的深度;基数的中间节点依赖于页索引的值。

页高速缓存的处理函数

查找页

find_get_page()参数为指向address_space对象的指针和偏移量。它获取地址空间的自旋锁,并调用radix_tree_lookup()搜索拥有指定偏移量的基树的叶子节点。该函数根据偏移量中的位依次从树根开始向下搜索。如果遇到空指针,返回NULL;否则,返回叶子节点的地址,即所需要的页描述符指针。如果找到所需要的页,增加该页的使用计数器,释放自旋锁,并返回该页的地址;否则,释放自旋锁并返回NULL。

find_get_pages()find_get_page()类似,但它实现在高速缓存中查找一组具有相邻索引的页,参数为指向address_space对象的指针、地址空间中相对于搜索起始位置的偏移量、所检索到页的最大数量、指向由该函数赋值的页描述符数组的指针。find_get_pages()依赖radix_tree_gang_lookup()实现查找操作,radix_tree_gang_lookup()为指针数组赋值并返回找到的页数。

find_lock_page()find_get_page()类似,但它增加返回页的使用计数器,并调用lock_page()设置PG_locked标志,调用者可互斥地访问返回的页。随后,如果页已经被加锁,lock_page()就阻塞当前进程。

最后,它在PG_locked置位时,调用__wait_on_bit_lock()

  • 把当前进程设置为TASK_UNINTERRUPTIBLE状态,把进程描述符存入等待队列,
  • 执行address_space对象的sync_page方法以取消文件所在块设备的请求队列,
    -最后调用schedule()挂起进程,直到PG_locked标志清0。

内核用unlock_page()对页进行解锁,并唤醒等待队列上睡眠的进程。

find_trylock_page()find_lock_page()类似,但不阻塞:如果被请求的页已经上锁,则返回错误码。find_or_create_page()如果找不到所请求的页,就分配一个新页并把它插入页高速缓存。

增加页

add_to_page_cache()把一个新页的描述符插入到页高速缓存。参数:页描述符的地址pageaddress_space对象的地址mapping、表示在地址空间内的页索引的值offset和为基树分配新节点时所用的内存分配标志gfp_mask。函数执行下列操作:

  1. 调用radix_tree_preload()禁用内核抢占,并把一些空的radix_tree_node结构赋给每CPU变量radix_tree_preloadsradix_tree_node结构的分配由slab分配器高速缓存radix_tree_node_cachep完成。如果radix_tree_preload()预分配radix_tree_node结构不成功,则终止并返回错误码-ENOMEM。
  2. 获取mapping->tree_lock自旋锁。
  3. 调用radix_tree_insert()在树中插入新节点,该函数执行如下操作:
    1. 调用radix_tree_maxindex()获得最大索引,该索引可能被插入具有当前深度的基树;如果新页的索引不能用当前深度表示,就调用radix_tree_extend()增加适当数量的节点以增加树的深度。分配新节点是通过执行radix_tree_node_alloc()实现的,该函数试图从slab分配高速缓存获得radix_tree_node结构,如果分配失败,就从radix_tree_preloads中的预分配的结构池中获得radix_tree_node结构。
    2. 根据页索引的偏移量,从根节点(mapping->page_tree)开始遍历树,直到叶子节点。如果需要,调用radix_tree_node_alloc()分配新的中间节点。
    3. 把页描述符地址存放在对基树所遍历的最后节点的适当位置,并返回0。
  4. 增加页描述符的使用计数器page->count
  5. 由于页是新的,所以其内容无效:设置页框的PG_locked标志,以阻止其他的内核路径并发访问该页。
  6. mappingoffset参数初始化page->mappingpage->index
  7. 递增在地址空间所缓存页的计数器(mapping->nrpages)。
  8. 释放地址空间的自旋锁。
  9. 调用radix_tree_preload_end()重新启用内核抢占。
  10. 返回`0(成功)。

删除页

remove_from_page_cache()通过下述步骤从页高速缓存中删除页描述符:

  1. 获取自旋锁page->mapping->tree_lock并关中断。
  2. 调用radix_tree_delete()从树中删除节点。参数为树根的地址page->mapping->page_tree和要删除的页索引。执行下述步骤:
    1. 根据页索引从根节点开始遍历树,直到叶子节点。遍历时,建立radix_tree_path结构的数组,描述从根到要删除的页相应的叶子节点的路径构成。
    2. 从最后一个节点(包含指向页描述符的指针)开始,对路径数组中的节点开始循环操作。对每个节点,把指向下一个节点(或有描述符)位置数组的元素置为NULL,并递减count字段。如果count为0,就从树中删除节点并把radix_tree_node结构释放给slab分配器高速缓存。
    3. 返回已经从树上删除的页描述符指针
  3. page->mapping置为NULL。
  4. 把所缓存页的page->mapping->nrpages计数器的值减1。
  5. 释放自旋锁page->mapping->tree_lock,打开中断,函数终止。

更新页

read_cache_page()确保高速缓存中包括最新版本的指定页。参数为指向address_space对象的指针mapping、表示所请求页的偏移量的值index、指向从磁盘读取页数据的函数的指针filter、传递给filter函数的指针data(通常为NULL)。

  1. 调用find_get_page()检查页是否已经在页高速缓存中。
  2. 如果页不在高速缓存中,则执行下列子步骤:
    1. 调用alloc_pages()分配一个新页框。
    2. 调用add_to_page_cache()在页高速缓存中插入相应的页描述符。
    3. 调用lru_cache_add()把页插入该管理区的非活动LRU链表中。
  3. 此时,所请求的页已经在页高速缓存中了。调用mark_page_accessed()记录页已经被访问过的事实。
  4. 如果页不是最新的(PG_uptodate标志为0),就调用filler函数从磁盘读该页。
  5. 返回页描述符的地址。

基树的标记

页高速缓存不仅允许内核能快速获得含有块设备中指定数据的页,还允许内核从高速缓存中快速获得给定状态的页。如,假设内核必须从高速缓存获得属于指定所有者的所有页和脏页,如果大多数页不是脏页,遍历整个基树的操作就太慢了。

为了能快速搜索脏页,基树中的每个中间节点都包含一个针对每个孩子的节点的脏标记,当且至少一个孩子节点的脏标记被置位时该标记被设置。最底层节点的脏标记通常是页描述符的PG_dirty标志的副本。通过这种方式,当内核遍历基树搜索脏页时,就可以跳过脏标记为0的中间节点的所有子树。PG_writeback标志同理,该标志表示页正在被写回磁盘。

radix_tree_tag_set()设置页高速缓存中页的PG_dirtyPG_writeback标志,它作用于三个参数:基树的根页的索引要设置的标记的类型PAGECACHE_TAG_DIRTYPAGECACHE_TAG_WRITEBACK)。函数从树根开始并向下搜索到与指定索引对应的叶子节点;对于从根通往叶子路径上的每个节点,利用指向路径中下一个节点的指针设置标记。最后,返回页描述符的地址。结果是,从根节点到叶子节点的路径中的所有节点都被加上了标记。

radix_tree_tag_clear()清除页高速缓存中页的PG_dirtyPG_writeback标志,参数与radix_tree_tag_set()相同。函数从树根开始向下到叶子节点,建立描述路径的radix_tree_path结构的数组。然后,从叶子节点到根节点进行操作:清除底层节点的标记,然后检查是否节点数组中所有标记都被清0,如果是,把上层父节点的相应标记清0。最后,返回页描述符的地址。

radix_tree_delete()从基树删除页描述符,并更新从根节点到叶子节点的路径中的相应标记。radix_tree_insert()不更新标记,因为插入基树的所有页描述符的PG_dirtyPG_writeback标志都被认为是清0的。如果需要,内核可随后调用radix_tree_tag_set()

radix_tree_tagged()利用树的所有节点的标志数组测试基树是否至少包括一个指定状态的页,因为可能假设基树所有节点的标记都正确地更新过,所以只需要检查第一层的标记。

1
2
3
4
5
6
for(idx = 0; idx < 2; idx++)
{
if(root->rnode->tags[tag][idx])
return 1;
}
return 0;

find_get_pages_tag()find_get_pages()类似,但前者返回的只是那些用tag参数标记的页。

把块存放在页高速缓存中

VFS(映射层)和各种文件系统以“块”的逻辑单位组织磁盘数据。

Linux 内核旧版本中,主要有两种不同的磁盘高速缓存:

  • 页高速缓存,存放访问磁盘文件内容时生成的磁盘数据页。
  • 缓冲区高速缓存,把通过 VFS 访问的块的内容保留在内存中。

后来,缓冲区高速缓存就不存在了,不再单独分配块缓冲区,而是把它们放在“缓冲区页”中,缓冲区页保存在页高速缓存中。

缓冲区页在形式上是与“缓冲区首部”的附加描述符相关的数据页,主要目的是快速确定页中的一个块在磁盘中的地址。实际上,页高速缓存内的页的多个块的数据在磁盘上的地址不一定相邻。

块缓冲区和缓冲区首部

每个块缓冲区都有buffer_head类型的缓冲区首部描述符,包含内核必须了解的、有关如何处理块的所有信息。

缓冲区首部的两个字段编码表示块的磁盘地址,b_bdev包含块的块设备,通常是磁盘或分区。b_blocknr是逻辑块号,即块在磁盘或分区中的编号。b_data表示块缓冲区在缓冲区页中的位置。如果页在高端内存,则b_data存放的是块缓冲区相对于页的起始位置的偏移量,否则,b_data存放块缓冲区的线性地址。b_state存放几个标志。

管理缓冲区首部

缓冲区首部有自己的 slab 分配器高速缓存,其描述符kmem_cache_s存在变量bh_cachep中。alloc_buffer_head()free_buffer_head()分别获取和释放缓冲区首部。

buffer_headb_count字段是相应的块缓冲区的引用计数器。每次对块缓冲区操作前递增计数器,操作后递减。除了周期性地检查保持在页高速缓存中的块缓冲区外,当空闲内存变得很少时也检查它,当引用计数器为 0 时回收块缓冲区。

缓冲区页

只要内核必须单独地访问一个块,就要涉及存放块缓冲区的缓冲区页,并检查相应的缓冲区首部。

内核创建缓冲区页的两种普通情况:

  • 当读或写的文件页在磁盘块中不相邻时。因为文件系统为文件分配了非连续的块,或文件有“洞”。
  • 当访问一个单独的磁盘块时。如,当读超级块或索引节点块时。

第一种情况下,把缓冲区的描述符插入普通文件的基树;保存好缓冲区首部,因为其中存有重要的信息,即数据在磁盘中位置的块设备和逻辑块号。

第二种情况下,把缓冲区页的描述符插入基树,树根是与块设备相关的特殊bdev文件系统中索引节点的address_space对象。这种缓冲区页必须满足很强的约束条件,即所有的块缓冲区涉及的块必须是在块设备上相邻存放的

接下来重点讨论该种情况,即块设备缓冲区页。

一个缓冲区页内的所有块缓冲区大小必须相同,因此,在 80x86 体系结构上,根据块的大小,一个缓冲页可包括 1 ~ 8 个缓冲区。

如果一个页作为缓冲区页使用,那么与它的块缓冲区相关的所有缓冲区首部都被收集在一个单向循环链表中。缓冲区页描述符的private字段指向页中第一个块的缓冲区首部;每个缓冲区首部存放在b_this_page字段,该字段是指向链表中下一个缓冲区首部的指针。每个缓冲区首部把缓冲区页描述符的地址存放在b_page字段。

分配块设备缓冲区页

当内核发现指定块的缓冲区所在的页不在页高速缓存中时,就分配一个新的块设备缓冲区页。特别是,对块的查找操作因以下原因而失败时:

  • 包含数据块的页不在块设备的基树中:必须把新页的描述符加到基树中。
  • 包含数据块的页在块设备的基树中,但该页不是缓冲区页:必须分配新的缓冲区首部,并将它链接到所属的页,从而把它变成块设备缓冲区页。
  • 包含数据块的缓冲区页在块设备的基树中,但页中块的大小与所请求的块大小不同:必须释放旧的缓冲区首部,分配经过重新复制的缓冲区首部并将它链接到所属的页。

内核调用grow_buffers()把块设备缓冲区页添加到页高速缓存中,参数:

  • block_device描述符的地址bdev
  • 逻辑块号block(块在块设备中的位置)
  • 块大小size

执行下列操作:

  1. 计算数据页在所请求块的块设备中的偏移量index
  2. 如果需要,调用grow_dev_page()创建新的块设备缓冲区页。
    1. 调用find_or_create_page(),参数为块设备的address_space对象(bdev->bd_inode->i_mapping)、页偏移indexGFP_NOFS标志。find_or_create_page()在页高速缓存中搜索需要的页,如果需要,就把新页插入高速缓存。
    2. 此时,所请求的页已经在页高速缓存中,且函数获得了它的描述符地址。检查它的PG_private标志;如果为空,说明页还不是一个缓冲区页,跳到第 2e 步。
    3. 页已经是缓冲区页。从页描述符的private字段获得第一个缓冲区首部的地址bh,并检查块大小bh->size是否等于所请求的块大小;如果大小相等,在页高速缓存中找到的页就是有效的缓冲区页,因此跳到第 2g 步。
    4. 如果页中块的大小有错误,调用try_to_free_buffers()释放缓冲区页的上一个缓冲区首部。
    5. 调用alloc_page_buffers()根据页中所请求的块大小分配缓冲区首部,并把它们插入由b_this_page字段实现的单向循环链表。此外,用页描述符的地址初始化缓冲区首部的b_page字段,用块缓冲区在页内的线性地址或偏移量初始化b_data字段。
    6. private字段存放第一个缓冲区首部的地址,把PG_private字段置位,并增加页的使用计数器。
    7. 调用init_page_buffers()初始化连接到页的缓冲区首部的字段b_bdevb_blocknrb_bstate。因为所有的块在磁盘上都是相邻的,因此逻辑块号是连续的,而且很容易从块得出。
    8. 返回页描述符地址。
  3. 为页解锁(find_or_create_page()曾为页加了锁)。
  4. 递减页的使用计数器(find_or_create_page()曾递增了计数器)。
  5. 返回 1(成功)。

释放块设备缓冲区页

try_to_release_page()释放缓冲区页,参数为页描述符的地址page,执行下述步骤:

  • 如果设置了页的PG_writeback标志,则返回 0(正在把页写回磁盘,不能释放该页)。
  • 如果已经定义了块设备address_space对象的releasepage方法,就调用它。
  • 调用try_to_free_buffers()并返回它的错误码。

try_to_free_buffers()依次扫描链接到缓冲区页的缓冲区首部,本质上执行下列操作:

  • 检查页中所有缓冲区首部的标志。如果有些缓冲区首部的BH_DirtyBH_Locked标志置位,则不能释放这些缓冲区,函数终止并返回0(失败)。
  • 如果缓冲区首部在间接缓冲区的链表中,则从链表中删除它。
  • 请求页描述符的PG_private标记,把private字段设置为NULL,并递减页的使用计数器。
  • 清除页的PG_dirty标记。
  • 反复调用free_buffer_head(),释放页的所有缓冲区首部。
  • 返回1(成功)。

在页高速缓存中搜索块

在页高速缓冲中搜索指定的块缓冲区(由块设备描述符的地址bdev和逻辑块号nr表示):

  1. 获取一个指针,让它指向包含指定块的的块设备的address_space对象(bdev->bd_inode->i_mapping)。
  2. 获得设备的块大小(bdev->bd_block_size),并计算包含指定块的页索引。需要在逻辑块号上进行位移操作。如果块的大小为1024字节,每个缓冲区包含四个块缓冲区,则页的索引为nr/4
  3. 在块设备的基树中搜索缓冲区页。获得页描述符后,内核访问缓冲区首部,它描述了页中块缓冲区的状态。

在实现中,为提高系统性能,内核维持一个小磁盘高速缓存数组bh_lrus(每个CPU对应一个数组元素),即最近最少使用(LRU)块高速缓存。

_find_get_block()

参数:block_device描述符地址bdev、块号block块大小size`。函数返回页高速缓存中的块缓冲区对应的缓冲区首部的地址,不存在时返回 NULL

  1. 检查指向 CPU 的 LRU 块高速缓存数组中是否有一个缓冲区首部,其b_bdevb_blocknrb_size字段分别等于bdevblocksize
  2. 如果缓冲区首部在 LRU 块高速缓存中,就刷新数组中的元素,以便让指针指在第一个位置(索引为 0)中的刚找的缓冲区首部,递增它的b_count字段,并跳到第 8 步。
  3. 如果缓冲区首部不在 LRU 块高速缓存中,根据块号和块大小得到与块设备相关的页的索引:index = block >> (PAGE_SHIFT - bdev->bd_inode->i_blkbits);
  4. 调用find_get_page()确定包含所请求的块缓冲区的缓冲区页的描述符在页高速缓存中的位置。参数:指向块设备的address_space对象的指针(bdev->bd_inode->i_mapping)和页索引。页索引用于确定存有所请求的块缓冲区的缓冲区页的描述符在页高速缓存中的位置,没有时返回NULL。
  5. 此时,已得到缓冲区页描述符的地址,扫描链接到缓冲区页的缓冲区首部链表,查找逻辑块号等于block的块。
  6. 递减页描述符的count字段(find_get_page()曾递增过)。
  7. 把 LRU 块高速缓存中的所有元素向下移动一个位置,并把指向所请求块的缓冲区首部的指针插入到第一个位置。如果一个缓冲区首部已经不在 LRU 块高速缓存中,就递减它的引用计数器b_count
  8. 如果需要,调用mark_page_accessed()把缓冲区页移到适当的 LRU 链表中。
  9. 返回缓冲区首部指针。

__getblk()

参数:block_device描述符的地址bdev、块号block和块大小size。返回与缓冲区对应的缓冲区首部的地址。如果块不存在,分配块设备缓冲区页并返回将要描述块的缓冲区首部的指针。__getblk()返回的块缓冲区不必包含有效数据—缓冲区首部的BH_Uptodate标志可能被清 0。

  1. 调用__find_get_block()检查块是否已经在页高速缓存中,如果找到,返回其缓冲区首部的地址。
  2. 否则,调用grow_buffers()为所请求的页分配一个新的缓冲区页。
  3. 如果上一步分配失败,调用free_more_memory()回收一部分内存。
  4. 跳到第1步。

__bread()

参数:block_device描述符的地址bdev、块号block和块大小size。返回与缓冲区对应的缓冲区首部的地址。如果需要,在返回缓冲区首部前__bread()从磁盘读块。

  1. 调用__getblk()在页高速缓存中查找与所请求的块相关的缓冲区页,并获得指向相应的缓冲区首部的指针。
  2. 如果块已经在页高速缓冲中并包含有效数据(BH_Uptodate标志被置位),就返回缓冲区首部的地址。
  3. 否则,递增缓冲区首部的引用计数器。
  4. b_end_io置为end_buffer_read_sync()的地址。
  5. 调用submit_bh()把缓冲区首部传送给通用块层。
  6. 调用wait_on_buffer()把当前进程插入等待队列,直到 I/O 操作完成,即直到缓冲区首部的BH_Lock标志被清 0。
  7. 返回缓冲区首部的地址。

向通用块层提交缓冲区首部

submit_bh()ll_rw_block()允许内核对缓冲区首部描述的一个或多个缓冲区进行 I/O 数据传送

submit_bh()

向通过块层传递一个缓冲区首部,并由此请求传输一个数据块。参数为数据传输的方向(READWRITE)和指向描述符块缓冲区的缓冲区首部的指针bh

submit_bh()只是一个起连接作用的函数,它根据缓冲区首部的内容创建一个bio请求,随后调用generic_make_request()

  1. 设置缓冲区首部的BH_Req标志表示块至少被访问过一次。如果数据传输方向为WRITE,将BH_Write_EIO标志清0。
  2. 调用bio_alloc()分配一个新的bio描述符。
  3. 根据缓冲区首部的内容初始化bio描述符的字段:
    1. bi_sector置为块中的第一个扇区的号bh->blocknr * bh->b_size / 512
    2. bi_bdev置为块设备描述符的地址bh->b_bdev
    3. bi_size置为块大小bh->b_size
    4. 初始化bi_io_vec数组的第一个元素,使该段对应于块缓冲区:bi_io_vec[0].bv_page置为bh->b_pagebi_io_vec[0].bv_len置为bh_b_sizebi_bio_vec[0].bv_offset置为块缓冲区在页中的偏移量bh->b_data
    5. bi_vnt置为1(只涉及一个bio的段),bi_idx置为0(将要传输的当前段)。
    6. bi_end_io置为end_bio_bh_io_sync()的地址,把缓冲区首部的地址赋给bi_private,数据传输结束时调用该函数。
  4. 递增bio的引用计数器。
  5. 调用submit_bio(),把bi_rw标志设置为数据传输的方向,更新每CPU变量page_states以表示读和写的扇区数,并对bio描述符调用generic_make_request()
  6. 递减bio的使用计数器;因为bio描述符现在已经被插入 I/O 调度程序的队列,所以没有释放bio描述符。
  7. 返回 0(成功)。

对 bio 上的 I/O 传输终止时,内核执行bi_end_io方法,即end_bio_bh_io_sync(),本质上从biobi_private字段获取缓冲区首部的地址,然后调用缓冲区首部的方法b_end_io,最后调用bio_put()释放bio结构。

ll_rw_block

要传输的几个数据块不一定物理上相邻。ll_rw_block参数由数据传输的方向(READWRITE)、要传输的数据块的块号、指向块缓冲区所对应的缓冲区首部的指针数组。该函数在所有缓冲区首部上循环,每次循环执行下列操作:

  1. 检查并设置缓冲区首部的BH_Lock标志;如果缓冲区已经被锁住,说明另一个内核控制路径已经激活了数据传输,则不处理该缓冲区。
  2. 把缓冲区首部的使用计数器b_count加1。
  3. 如果数据传输的方向是WRITE,就让缓冲区首部的方法b_end_io指向end_buffer_write_sync()的地址,否则,指向end_buffer_read_sync()的地址。
  4. 如果数据传输的方向是WRITE,就检查并清除缓冲区首部的BH_Dirty标志。如果该标志没有置位,就不必把块写入磁盘,跳到第 7 步。
  5. 如果数据传输的方向是READREADA(向前读),检查缓冲区首部的BH_Uptodate`标志是否被置位,如果是,就不必从磁盘读块,跳到第 7 步。
  6. 此时必须读或写数据块:调用submit_bh()把缓冲区首部传递到通用块层,然后跳到第 9 步。
  7. 通过清除BH_Lock标志为缓冲区首部解锁,然后唤醒所有等待块解锁的进程。
  8. 递减缓冲区首部的b_count字段。
  9. 如果还有其他缓冲区需要处理,则选择下一个缓冲区首部并跳回第一步。

当块的数据传送结束,内核执行缓冲区首部的b_end_io方法。如果没有I/O错误,end_buffer_write_sync()end_buffer_read_snyc()至少简单地把缓冲区首部的BH_Uptodate字段置位,为缓冲区解锁,并递减它的引用计数器。

把脏页写入磁盘

只要进程修改了数据,相应的页就被标记为脏页,其PG_dirty标志置位。由于延迟写,使得任一物理块设备平均为读请求提供的服务将多于写请求。一个脏页可能直到系统关闭时都逗留在主存中,主要有两个缺点:

  • 如果发生硬件错误,则难以找回对文件的修改
  • 页高速缓存的大小可能很大,至少要与所访问块设备的大小相同

在下列条件下把脏页写入磁盘:

  • 页高速缓存变得太满,但还需要更多的页,或脏页的数据已经太多。
  • 自从页变成脏页以来已经过去太长时间。
  • 进程请求对块设备或特定文件任何带动的变化都进行刷新。通过调用sync()fsync()fdatasync()实现。

与每个缓冲区页相关的缓冲区首部使内核能了解每个独立块缓冲区的状态。如果至少有一个缓冲区首部的BH_Dirty标志被置位,就设置相应缓冲区页的PG_dirty标志。当内核选择要刷新的缓冲区页时,它扫描相应的缓冲区首部,并只把脏块的内容写到磁盘。一旦内核把缓冲区的所有脏页刷新到磁盘,就把页的PG_dirty标记清 0。

pdflush 内核线程

pdflush内核线程作用于两个参数:一个指向线程要执行的函数的指针和一个函数要用的参数。系统中pdflush内核线程的数量是要动态调整的:pdflush线程太少时就创建,太多时就杀死。因为这些内核线程所执行的函数可以阻塞,所以创建多个而不是一个pdflush内核线程可以改善系统性能。

根据下面的原则控制pdflush线程的产生和消亡:

  • 必须有至少两个,最多八个pdflush内核线程。
  • 如果到最近的ls期间没有空闲pdflush,就应该创建新的pdflush
  • 如果最近一次pdflush变为空闲的时间超过了ls,就应该删除一个pdflush

所有的pdflush内核线程都有pdflush_work描述符。空闲pdflush内核线程的描述符都集中在pdflush_list链表中;在多处理器系统中,pdflush_lock自旋锁保护该链表不会被并发访问。_nrpdflush_threads变量存放pdflush内核线程的总数。最后last_empty_jifs变量存放pdflush线程的pdflush_list链表变为空的时间(以jiffies表示)。

所有pdflush内核线程都执行`__pdflush(),本质上循环执行直到内核线程死亡。

假设pdflush内核线程是空闲的,而进程正在TASK_INTERRUPTILE状态睡眠。一旦内核线程被唤醒,__pdflush()就访问其pdflush_work描述符,并执行字段fn的回调函数,将arg0字段中的参数传给该函数。函数结束时,__pdflush()检查last_empty_jifs变量的值:如果不存在空闲pdflush内核线程的时间已超过1s,且pdflush内核线程的数量不到8个,__pdflush()就创建一个内核线程。相反,如果pdflush_list链表最后一项对应的pdflush内核线程空闲时间超过了1s,而系统中有两个以上的pdflush内核线程,__pdflush()就终止:相应的内核线程执行_exit(),并因此被撤销。否则,如果系统中pdflush内核线程不多于两个,__pdflush()就把内核线程的pdflush_work描述符重新插入到pdflush_list链表中,并使内核线程睡眠。

pdflush_operation()激活空闲的pdflush内核线程。参数:一个指针fn,执行必须执行的函数;参数arg0

  1. pdflush_list链表获取pdf指针,它指向空闲pdflush内核线程的pdflush_work描述符。如果链表为空,返回-1。如果链表中仅剩一个元素,就把jiffies的值赋给变量last_empty_jifs
  2. pdf->fn=fnpdf->arg0=arg0
  3. 调用wake_up_process()唤醒空闲的pdflush内核线程,即pdf->who

pdflush内核线程通常执行下面的回调函数之一:

  • background_writeout():系统地扫描页高速缓存以搜索要刷新的脏页。
  • wb_kupdate():检查页高速缓冲中是否有“脏”了很长时间的页。

搜索要刷新的脏页

所有基树都可能有要刷新的脏页,为了得到脏页,需要搜索与在磁盘上有映像的索引节点相应的所有address_space对象。wakeup_bdflush()参数为页高速缓存中应该刷新的脏页数量;0表示高速缓存中的所有脏页都应该写回磁盘。该函数调用pdflush_operation()唤醒pdflush内核线程,并委托它执行回调函数background_writeout()以有效地从页高速缓存获得指定数量的脏页,并把它们写回磁盘。

内存不足或用户显式地请求刷新操作时执行wakeup_bdflush(),特别是以下情况:

  • 用户发出sync()系统调用。
  • grow_buffers()分配一个新缓冲区页时失败。
  • 页框回收算法调用free_more_memory()try_to_free_pages()
  • mempool_alloc()分配一个新的内存池元素时失败。

执行background_writeout()回调函数的pdflush内核线程是被满足以下两个条件的进程唤醒的:

  • 对页高速缓存中页的内容进行了修改。
  • 引起脏页部分增加到超过某个脏阈值。

脏阈值通常设置为系统中所有页的10%,但可通过修改文件/proc/sys/vm/dirty_background_ratio来调整该值。

background_writeout()依赖于作为双向通信设备的writeback_control结构:

  • 一方面,它告诉辅助函数writeback_indoes()要做什么;
  • 另一方面,它保存磁盘的页的数量的统计值。

writeback_control的重要字段:

  • sync_mode:表示同步模式:
    • WB_SYNC_ALL表示如果遇到一个上锁的索引节点,必须等待而不能忽略它;
    • WB_SYNC_HOLD表示把上锁的索引节点放入稍后涉及的链表中;
    • WB_SYNC_NONE表示简单地忽略上锁的索引节点。
  • bid:如果不为空,就指向backing_dev_info结构。此时,只有属于基本块设备的脏页会被刷新。
  • older_than_this:如果不为空,就表示应该忽略比指定值还新的索引节点。
  • nr_to_write:当前执行流中仍然要写的脏页的数量。
  • nonblocking:如果这个标志被置位,就不能阻塞进程。

background_writeout()参数为nr_pages,表示应该刷新到磁盘的最少页数。

  1. 从每 CPU 变量page_state中读当前页高速缓存中页和脏页的数。如果脏页的比例低于给定的阈值,且已经至少有nr_pages页被刷新到磁盘,则终止。该阈值通常为系统中总页数的40%,可通过文件/proc/sys/vm/dirty_ratio调整该值。
  2. 调用writeback_inodes()尝试写1024个脏页。
  3. 检查有效写过的页的数量,并减少需要写的页的个数。
  4. 如果已经写过的页少于1024页,或忽略了一些页,则块设备的请求队列处于拥塞状态:此时,使当前进程在特定的等待队列上睡眠10ms或直到队列不拥塞。
  5. 返回第1步。

writeback_inodes()参数为指针wbc,指向writeback_control描述符。该描述符的nr_to_write字段存有要刷新到磁盘的页数。函数返回时,该字段存有要刷新到磁盘的剩余页数,如果一切顺利,该字段为0。

假设writeback_inodes()被调用的条件为:指针wbc->bdiwbc->older_than_this被置为NULL,WB_SYNC_NONE同步模式和wbc->nonblocking标志置位

writeback_inodes()扫描在super_blocks变量中建立的超级块链表。当遍历完整个链表或刷新的页的数量达到预期数量时,就停止扫描。对每个超级块sb执行下述步骤:

  1. 检查sb->s_dirtysb->s_io链表是否为空:
    1. 第一个链表集中了超级块的脏索引节点
    2. 第二个链表集中了等待被传送到磁盘的索引节点。
    3. 如果两个来链表为空,说明相应文件系统的索引节点没有脏页,因此处理链表中的下一个超级块。
  2. 此时,超级块有脏索引节点。对超级块sb调用sync_sb_inodes(),该函数执行下面的操作:
    1. sb->s_dirty的所有索引节点插入sb->s_io指向的链表,并清空脏索引节点链表。
    2. sb->s_io获得下一个索引节点的指针。如果链表为空,就返回。
    3. 如果sync_sb_inodes()开始执行后,索引节点变为脏节点,就忽略这个索引节点的脏页并返回。
    4. 如果当前进程是pdflush内核线程,sync_sb_inodes()就检查运行在另一个CPU上的pdflush内核线程是否已经试图刷新这个块设备文件的脏页。这是通过一个原子测试和对索引节点的backing_dev_infoBDI_pdflush标志的设置操作完成的。
    5. 把索引节点的引用计数器加1。
    6. 调用__writeback_single_inode()回写与所选择的索引节点相关的脏缓冲区:
      1. 如果索引节点被锁定,就把它移到脏索引节点链表中(inode->i_sb->s_dirty)并返回0。
      2. 使用索引节点地址空间的writepages方法,或在没有该方法的情况下使用mpage_writepages()来写wbc->nr_to_write个脏页。该函数调用find_get_pages_tag()快速获得索引节点地址空间的所有脏页。
      3. 如果索引节点是脏的,就调用超级块的write_inode方法把索引节点写到磁盘。实现该方法的函数通常依靠submit_bh()来传输一个数据块。
      4. 检查索引节点的状态。如果索引节点还有脏页,就把索引节点移回sb->s_dirty链表;如果索引节点引用计数器为0,就把索引节点移到inode_unused链表中;否则就把所以节点移到inode_in_use链表中。
      5. 返回在第2f(2)步所调用的函数的错误代码。
    7. 回到sync_sb_inodes()中。如果当前进程是pdflush内核线程,就把第2d步设置的BDI_pdflush标志清0。
    8. 如果忽略了刚处理的索引节点的一些页,那么该索引节点包括锁定的缓冲区:把sb->s_io链表中的所有剩余索引节点移回到sb->s_dirty链表中,以后将重新处理它们。
    9. 把索引节点的引用计数器减1。
    10. 如果wbc->nr_to_write大于0,则回到第2b步搜索同一个超级块的其他脏索引节点。否则,sync_sb_inodes()终止。
  3. 回到writeback_inodes()中。如果wbc->nr_to_write大于0,就跳到第1步,并继续处理全局链表中的下一个超级块;否则返回。

回写陈旧的脏页

脏页在保留一定时间后,内核就显式地开始进行I/O数据的传输,把脏页的内容写到磁盘。

回写陈旧脏页的工作委托给了被定期唤醒的pdflush内核线程。在内核初始化期间,page_writeback_init()建立wb_timer动态定时器,以便定时器的到期时间发生在dirty_writeback_ccentisecs文件中规定的几百分之一秒后。定时器函数wb_timer_fn()本质上调用pdflush_operation(),传递给它的参数是回调函数wb_kupdate()的地址。

wb_kupdate()遍历页高速缓存搜索陈旧的脏索引节点,它执行下面的步骤:

  1. 调用sync_supers()把脏的超级块写到磁盘。sync_supers()确保了任何超级块脏的时间通常不会超过5s。
  2. 把当前时间减30s所对应的值(用jiffies表示)的指针存放在writeback_control描述符的older_than_this字段中。允许一个页保持脏状态的最长时间为30s。
  3. 根据每CPU变量page_state确定当前在页高速缓存中脏页的大概数量。
  4. 反复调用writeback_inodes(),直到写入磁盘的页数等于上一步所确定的值,或直到把所有保持脏状态时间超过30s的页都写到磁盘。如果在循环的过程中一些请求队列变得拥塞,函数就可能睡眠。
  5. mod_timer()重新启动wb_timer动态定时器:一旦从调用该函数开始经历过文件dirty_writeback_centisecs中规定的几百分之一秒时间后,定时器到期。

sync()、fsync()和fdatasync()系统调用

  • sync():允许进程把所有脏缓冲区刷新到磁盘。
  • fsync():允许进程把属于特定打开文件的所有块刷新到磁盘。
  • fdatasync():与fsync()相似,但不刷新文件的索引节点块。

sync()

sync()的服务例程sys_sync()调用一系列辅助函数:

1
2
3
4
5
6
wakeup_bdflush(0);
sync_inodes(0);
sync_supers();
sync_filesystems(0);
sync_filesystems(1);
sync_inodes(1);

wakeup_bdflush()启动pdflush内核线程,把页高速缓存中的所有脏页刷新到磁盘。

sync_inodes()扫描超级块的链表以搜索要刷新的脏索引节点,作用于参数wait,函数扫描文件系统的超级块,对于每个包含脏索引节点的超级块,首先调用sync_sb_inodes()刷新相应的脏页,然后调用sync_blockdev()显式刷新该超级块所在块设备的脏缓冲页,这一步之所以能完成是因为许多磁盘文件系统的write_inode超级块方法仅仅把磁盘索引节点对应的块缓冲区标记为“脏”,sync_blockdev()确保把sync_sb_inodes()所完成的更新有效地写到磁盘。

sync_supers()把脏超级块写到磁盘,如果需要,也可以使用适当的write_super超级块操作

sync_filesystems()为所有可写的文件系统执行sync_fs超级块方法。

sync_inodes()sync_filesystems()都被调用两次,一次是参数wait等于0时,另一次是等于1。首先,把未上锁的索引节点快速刷新到磁盘;其次,等待所有上锁的索引节点被解锁,然后把它们逐个写到磁盘。

fsync()和fdatasync()

fsync()强制内核把文件描述符参数fd所指定文件的所有脏缓冲区写到磁盘中。相应的服务例程获得文件对象的地址,并随后调用fsync方法。通常,该方法以调用__write_back_single_inode()结束,该函数把与被选中的索引节点相关的脏页和索引节点本身都写回磁盘

fdatasync()fsync()很像,但它只把包含文件数据而不是那些包含索引节点信息的缓冲区写到磁盘

系统调用

POSIX API和系统调用

从编程者的观点看,API和系统调用之间的差别是没有关系的:唯一相关的事情就是函数名、参数类型及返回代码的含义。然而,从内核设计者的观点看,这种差别确实有关系,因为系统调用属于内核,而用户态的库函数不属于内核。

大部分封装返回一个整数,其值的含义依赖于相应的系统调用。返回值-1通常表示内核不能满足进程的请求。每个出错码都定义为一个常量宏。POSIX标准制定了很多出错码的宏名。

系统调用处理程序及服务例程

当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核态函数。在80x86体系结构中,可以用两种不同的方式调用linux的系统调用。两种方式的最终结果都是跳转到所谓系统调用处理程序的汇编语言函数

因为内核实现了很多不同的系统调用,因此进程必须传递一个名为系统调用号的参数来识别所需的系统调用,eax寄存器就用作此目的

所有的系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。在内核中,正数或0表示系统调用成功结束,而负数表示一个出错条件。在后一种情况下,这个值就是存放在error变量中必须返回给应用程序的负出错码。

系统调用处理程序与其他异常处理程序的结构类似,执行下列操作:

  • 在内核态栈保存大多数寄存器的内容
  • 调用名为系统调用服务例程
  • 退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换回到用户态

xyz()系统调用对应的服务例程的名字通常是sys_xyz()。不过也有一些例外。为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表(dispatch table)。这个表存放在sys_call_table数组中,有NR_syscalls个表项:第n个表项包含系统调用号为n的服务例程的地址。

NR_syscalls宏只是对可实现的系统调用最大个数的静态限制,并不表示实际已实现的系统调用个数。实际上,分派表中的任意一个表项也可以包含sys_ni_syscall()函数的地址,这个函数是“未实现”系统调用的服务例程,它仅仅返回出错码-ENOSYS。

进入和退出系统调用

本地应用可以通过两种不同的方式调用系统调用:

  • 执行int $0x80汇编语言执行。在Linux内核的老版本中,这是从用户态切换到内核态的唯一方式。
  • 执行sysenter汇编语言执行。

同样,内核可以通过两种方式从系统调用退出,从而使CPU切换回到用户态:

  • 执行iret汇编语言指令。
  • 执行sysexit汇编语言指令。

但是,支持进入内核的两种不同方式并不像看起来那么简单,因为:

  • 内核必须即支持只使用int $0x80指令的旧函数库,同时支持也可以使用sysenter指令的新函数库。
  • 使用sysenter指令的标准库必须能处理仅支持int $0x80指令的旧内核。
  • 内核和标准库必须既能运行在不包含sysenter指令的旧处理器上,也能运行在包含它的新处理器上。

通过int $0x80指令发出系统调用

向量128(十六进制0x80)对应于内核入口点。在内核初始化期间调用的函数trap_init(),用下面的方式建立对应于向量128的中断描述符表表项:

1
set_system_gate(0x80, &system_call);

该调用把下列值存入这个门描述符的相应字段:

  • Segmet Selector:内核代码段__KERNEL_CS的段选择符。
  • Offset:指向system_call()系统调用处理程序的指针。
  • Type:置为15。表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断。
  • DPL(描述符特权级):置为3。这就允许用户态进程调用这个异常处理程序。

因此,当用户态进程发出int $0x80指令时,CPU切换到内核态并开始从地址system_call处开始执行指令。

system_call函数

system_call函数首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已自动保存的eflags、cs、eip、ss和esp寄存器。“I/O中断处理”讨论的SAVB_ALL宏也在ds和es中装入内核数据段的段选择符:

1
2
3
4
5
system_call:
pushl %eax
SAVE_ALL
movl $0xffffe000, %ebx
andl %esp, %ebx

随后,这个函数在ebx中存放当前进程的thread_info数据结构的地址,通过获得内核栈指针并把它取整到4KB或8KB的倍数完成的。

接下来system_call检査thread_info结构flag字段的TIF_SYSCALL_TRACETIF_SYSCALL_AUDIT标志之一是否被设1,是否有某一调试程序正在跟踪执行程序对系统调用的调用。如果是,system_call()两次调do_syscall_trace()在这个系统调用服务例程执行之前在其之后。这个函数停止current,因此允许调试进程收集关于current的信息。

然后对用户态进程传递来的系统调用号有效性检査,如果大于等于系统调用分派表中的表项数,系统调用处理程序就终止。

1
2
3
4
5
cmpl $NR_syscalls, %rax
jb nobadsys
movl $(-ENOSYS), 24(%esp)
jmp resume_userspace
nobadsys:

如果系统调用号无效,就把-ENOSYS存在栈中曾保存eax的单元(从当前栈顶开始偏移量为24的单元),跳到resume_userspace。当进程恢复它在用户态的执行时,会在eax中发现一个负返回码,最后调用与eax中所包含的系统调用号对应的特定服务例程:

1
call *sys_call_table(0, %eax, 4)

分派表中的每表项占4字节,首先把系统调用号乘以4,再加sys_call_table分派表的起始地址,从这个地址单元获取指向服务例程的指针,内核就找到了要调用的服务例程。

从系统调用退出

当系统调用服务例程结束时,system_call()函数从eax获得它的返回值,并把这个返回值存放在eax寄存器的那个栈单元上:movl %eax, 24(%esp)。因此用户态进程将在eax中找到系统调用的返回码。

然后,system_call()函数关闭本地中断并检查当前进程的thread_info结构中的标志。

1
2
3
4
cli
movl 8(%ebp), %ecx
testw $oxffff, %cx
je restore_all

如果所有的标志都没有被设置,函数就跳转到restore_all标记处的代码恢复保存在内核栈中的寄存器的值,并执行iret汇编指令以重新开始执行用户态进程。

只要有任何一个标志被设置,那么就要在返回用户态之前完成一些工作。如果TIF_SYSCALL_TRACE标志被设置,system_call()函数就第二次调用do_syscall_trace()函数,然后跳转到resume_userspace标记处。否则,如果TIF_SYSCALL_TRACE标志没有被设置,函数就调转到work_pending标记处。在resume_userspacework_pending标记处的代码检查重新调度请求,虚拟8086模式,挂起信号和单步执行,最终调转到restore_all标记处以恢复用户态进程的执行。

通过sysenter指令发出系统调用

在Intel文档中被称为快速系统调用sysenter指令,提供了一种从用户态到内核态的快速切换方法

sysenter指令

汇编语言指令sysenter使用三种特殊的寄存器,它们必须装入下述信息:

  • SYSENTER_CS_MSR:内核代码段的段选择符。
  • SYSENTER_EIP_MSR:内核入口点的线性地址。
  • SYSENTER_ESP_MSR:内核堆栈指针。

执行sysenter指令时,CPU控制单元:

  1. SYSENTER_CS_MSR的内核拷贝到cs。
  2. SYSENTER_EIP_MSR的内容拷贝到eip。
  3. SYSENTER_ESP_MSR的内容拷贝到esp。
  4. SYSENTER_CS_MSR加8的值装入ss。

因此CPU切换到内核态并开始执行内核入口点的第一条指令。

在内核初始化期间,一旦系统中的每个CPU执行函数enable_sep_cpu(),三个特定于模型的寄存器就由该函数初始化了。enable_sep_cpu()函数执行以下步骤:

  • 把内核代码(__KERNEL_CS)的段选择符写入SYSENTER_CS_MSR寄存器。
  • 把下面要说明的函数sysenter_enry()的线性地址写入SYSENTER_CS_EIP寄存器。
  • 计算本地TSS末端的线性地址,并把这个值写入SYSENTER_CS_ESP寄存器。

系统调用开始的时候,内核栈是空的,因此 esp 寄存器应该执行 4KB 或 8KB 内存区域的末端,该内存区域包括内核堆栈和当前进程的描述符。因为用户态的封装例程不知道该内存区域的地址,因此不能正确设置该寄存器。但必须在切换到内核态之前设置该寄存器的值,因此,内核初始化该寄存器,以便为本地 CPU 的任务状态段编址。每次进程切换时,内核把当前进程的内核栈指针保存到本地 TSS 的 esp0 字段。这样,系统调用处理程序读 esp 寄存器,计算本地 TSS 的 esp0 字段的地址,然后把正确的内核堆栈指针装入 esp 寄存器。

vsyscall页

只要 CPU 和 Linux 内核都支持 sysenter 指令,标准库 libc 中的封装函数就可以使用它。

本质上,在初始化阶段,sysenter_setup()建立一个称为vsyscall页的页框,它包括一个小的 EFL 共享对象(一个很小的 EFL 动态链接库)。
当进程发出execve()系统调用而开始执行一个 EFL 程序时,vsyscall页中的代码就会自动链接到进程的地址空间。vsyscall页中的代码使用最有用的指令发出系统调用。

sysenter_setup()vsyscall页分配一个新页框,并将它的物理地址与FIX_VSYSCALL固定映射的线性地址相关联。然后把预先定义好的多个 EFL 共享对象拷贝到该页中:

如果 CPU 不支持sysentersysenter_setup()建立一个包括下列代码的vsyscall页:

1
2
3
__kernel_vsyscall:
int $0x80
ret

否则,如果 CPU 的确支持sysentersysenter_setup()建立一个包括下列代码的vsyscall页:

1
2
3
4
5
6
__kernel_vsyscall:
pushl %ecx
push %edx
push %ebp
movl %esp, %ebp
sysenter

当标准库中的封装例程必须调用系统调用时,都调用__kernel_vsyscall()

如果老版本的Linux内核不支持sysenter指令,内核不建立vsyscall页,而且函数__kernel_vsyscall()不会被链接到用户态进程的地址空间。新的标准库识别出这种状况后,简单地执行int $0x80调用系统调用。

进入系统调用

当用sysenter指令发出系统调用时,依次执行下述步骤:

  • 标准库中的封装例程把系统调用号装入eax寄存器,并调用__kernel_vsyscall()
  • __kernel_vsyscall()ebpedxecx的内容保存到用户态堆栈中,把用户栈指针拷贝到ebp中,然后执行sysenter指令。
  • CPU从用户态切换到内核态,内核态开始执行sysenter_entry()(由SYSENTER_EIP_MSR寄存器指向)。
  • sysenter_entry()汇编指令执行下述步骤:
    • 建立内核堆栈指针:movl -508(%esp), %esp
      • 开始时,esp 寄存器指向本地 TSS 的第一个位置,本地 TSS 的大小为 512 字节。因此,sysenter指令把本地 TSS 中的偏移量为 4 处的字段的内容(即 esp0 字段的内容)装入 esp。
      • esp0 字段总是存放当前进程的内核堆栈指针。
    • 打开本地中断:sti
    • 把用户数据段的段选择符、当前用户栈指针、eflags 寄存器、用户代码段的段选择符及从系统调用退出时要指向的指令的地址保存到内核堆栈:
      • pushl $(__USER_DS)
      • pushl %ebp
      • pushfl
      • pushl $(__USER_CS)
      • pushl $SYSENTER_RETURN
    • 把由封装例程传递的寄存器的值恢复到 ebp 中:movl (%ebp), %ebp
      • 该指令完成恢复的工作,因为__kernel_vsyscall()把ebp的原始值存入用户态堆栈中,并随后把用户堆栈指针的当前值装入 ebp 中。
    • 通过执行一系列指令调用系统调用处理程序,这些指令与system_call标记处开始的指令是一样的。

退出系统调用

当系统调用服务例程结束时,sysenter_entry()函数本质上执行与system_call()函数系统的操作。首先,它从eax获得系统调用服务例程的返回码,并将返回码存入内核栈中保存用户态eax寄存器值的位置。然后,函数禁止本地中断,并检查currentthread_info结构中的标志。

sysexit指令

sysexit是与sysenter配对的汇编语言指令:它允许从内核态快速切换到用户态。执行这条指令时,CPU控制单元执行下述步骤:

  1. SYSENTER_CS_MSR寄存器中的值加16所得到的结果加载到cs寄存器。
  2. edx寄存器的内容拷贝到eip寄存器。
  3. SYSENTER_CS_MSR寄存器中的值加24所得到的结果加载到ss寄存器。
  4. ecx寄存器的内容拷贝到esp寄存器。

因为SYSENTER_CS_MSR寄存器加载的是内核代码的段选择符,cs寄存器加载的是用户代码的段选择符,而ss寄存器加载的是用户数据段的段选择符。结果,CPU从内核态切换到用户态,并开始执行其地址存在edx中的那条指令。

SYSENTER_RETURN的代码

SYSENTER_RETURN标记处的代码存放在vsyscall页中,当通过sysenter进入的系统调用被iretsysexit指令终止时,该页框中的代码被执行。

该代码恢复保存在用户态堆栈中的ebp、edx和ecx寄存器的原始内容,并把控制权返回给标准库中的封装例程:

1
2
3
4
5
SYSENTER_RETURN:
popl %ebp
popl %edx
popl %ecx
ret

参数传递

系统调用的输入/输出参数可能是:

  • 实际的值
  • 用户态进程地址空间的变量
  • 指向用户态函数的指针的数据结构地址

因为system_call()sysenter_entry()是 Linux 中所有系统调用的公共入口点,因此每个系统调用至少有一个参数,即通过eax寄存器传递进来的系统调用号。

普通C函数的参数传递时通过把参数值写入活动的程序栈(用户态栈或内核态栈)实现的。而系统调用是一种横跨用户和内核的特殊函数,所以既不能使用用户态栈也不能使用内核态栈在发出系统调用前,系统调用的参数被写入 CPU 寄存器,然后再调用系统调用服务例程前,内核再把存放在 CPU 中的参数拷贝到内核态堆栈中,因为系统调用服务例程是普通的 C 函数

为什么内核不直接把参数从用户态的栈拷贝到内核态的栈?

  • 同时操作两个栈比较复杂。
  • 寄存器的使用使得系统调用服务处理程序的结构与其他异常处理程序结构类似。
  • 使用寄存器传递参数时,必须满足两个条件:

用寄存器传递参数必须满足:

  • 每个参数的长度不能超过寄存器的长度,即 32 位。
  • 参数的个数不能超过 6 个(除 eax 中传递的系统调用号),因为寄存器数量有限。
  • 当确实存在多于 6 个参数的系统调用时,用一个单独的寄存器指向进程地址空间中这些参数值所在的一个内存区。

用于存放系统调用号和系统调用参数的寄存器是:eax(系统调用号)、ebx、ecx、edx、esi、edi 及 ebpsystem_call()sysenter_entry()使用SAVE_ALL宏将这些寄存器的值保存在内核态堆栈中

因此,当系统调用服务例程转到内核态堆栈时,就会找到system_call()sysenter_entry()的返回地址,紧接着时存放在ebx中的参数(系统调用的第一个参数),存放在ecx中的参数等。这种栈结构与普通函数调用的栈结构完全相同,因此,系统调用服务例程很容易通过使用C语言结构引用它的参数。

有时候,服务例程需要知道在发出系统调用前 CPU 寄存器的内容。类型为pt_regs的参数允许服务例程访问由SAVE_ALL宏保存在内核态堆栈中的值:

1
int sys_fork(struct pt_regs regs)

服务例程的返回值必须写入 eax 寄存器。这在执行return n指令时由 C 编译程序自动完成。

验证参数

有一种检查对所有的系统调用都是通用的。只要有一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间内。检查方式:

  • 验证这个线性地址是否属于进程的地址空间
  • 仅仅验证该线性地址是否小于PAGE_OFFSET(即没有落在留给内核的线性地址区间内)。

这是一种非常错略的检查,真正的检查推迟到分页单元将线性地址转换为物理地址时该粗略的检查确保了进程地址空间和内核地址空间都不被非法访问

对系统调用所传递地址的检测是通过access_ok()宏实现的,它有两个分别为addrsize的参数。该宏检查addraddr+size-1之间的地址区间:

1
2
3
4
5
6
7
int access_ok(const void *addr, unsigned long size)
{
unsigned long a = (unsigned long)addr;
if(a + size < a || a + size > current_thread_info()->addr_limit.seg)
return 0;
return 1;
}

验证addr + size是否大于2^32-1,因为 gcc 编译器用 32 位数表示无符号长整数和类型,所以等价于对溢出条件进行检查,检查addr+size是否超过currentthread_info结构的addr_limit.seg存放的值。普通进程通常存放PAGE_OFFSET,内核线程为0xffffffff。可通过get_fsset_fs宏动态修改addr_limit.seg

访问进程地址空间

get_user()put_user()宏可方便系统调用服务例程读写进程地址空间的数据。

  • get_user()宏从一个地址读取 1、2 或 4 个连续字节。
  • put_user()宏把 1、2 或 4 个连续字节的内容写入一个地址。

参数:

  • 要传送的值x
  • 一个变量ptr,决定还有多少字节要传送

get_user(x, ptr)中,由ptr指向的变量大小使该函数展开为__get_user_1()__get_user_2()__get_user_4()汇编语言函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__get_user_2:
addl $1, %eax
jc bad_get_user
movl $0xffffe000, %edx
andl %esp, %edx
cmpl 24(%edx), %eax
jae bad_get_user

2: movzwl -1(%eax), %edx
xorl %eax, %eax
ret

bad_get_user:
xorl %edx, %edx
movl $-EFAULT, %eax
ret

eax寄存器包含要读取的第一个字节的地址ptr,前 6 个指令所执行的检查与access_ok()宏相同,即确保要读取的两个字节的地址小于 4GB 并小于current进程的addr_limit.seg字段,这个字段位于currentthread_info结构中便宜为24处,出现在cmpl指令的第一个操作数处。

如果地址有效,执行movzwl指令,把要读的数据存到edx寄存器的两个低字节,两个高字节置 0,然后在eax中设置返回码0并终止。如果地址无效,清edx并将eax置为-EFAULT。

put_user(x, ptr)宏类似于get_user,但把值x写入以地址ptr为起始地址的进程地址空间。根据x的大小,使用__put_user_asm()宏,或__put_user_u64()宏,成功写入则在eax寄存器中返回0,否则返回 -EFAULT。

表中列出了内核态下用来访问进程地址空间的另外几个函数或宏。首部没有下划线的函数或宏要用额外的时间对所请求的线性地址区间进行有效检查,而有下划线的则会跳过检查。

动态地址检查:修正代码

access_ok()宏仅对以参数传入的线性地址空间进行粗略检查,保证用户态进程不会试图侵扰内核地址空间。但线性地址仍然可能不属于进程地址空间,这时,内核使用该地址时,会发生缺页异常。

缺页异常处理程序区分在内核态引起缺页异常的四种情况,并进行相应处理:

  • 内核试图访问属于进程地址空间的页,但是,相应的页框可能不存在,或内核试图写一个只读页。此时,处理程序必须分配和初始化一个新的页框(请求调页、写时复制)。
  • 内核试图访问属于内核地址空间的页,但是,相应的页表项还没有初始化(处理非连续内存区访问)。此时,内核必须在当前进程页表中适当建立一些表项。
  • 某一个内核函数包含编程错误,导致函数运行时引起异常;或者,可能由于瞬时的硬件错误引起异常。此时,处理程序必须执行一个内核漏洞(处理地址空间以外的错误地址)。
  • 系统调用服务例程试图读写一个内存区,该内存区的地址以系统调用参数传入,但不属于进程的地址空间。

异常表

只有少数的函数和宏访问进程的地址空间;因此,如果异常是由一个无效的参数引起的,那么引起异常的指令一定包含在其中一个函数或展开的宏中。对用户空间寻址的指令非常少。因此,可把访问进程地址空间的每条内核指令的地址放到一个叫异常表的结构中。

当内核态发生缺页异常时,do_page_fault()处理程序检查异常表:

  • 如果表中包含产生异常的指令地址,则该错误就是由非法的系统调用参数引起的,
  • 否则,就是由某一严重的 bug 引起的。

Linux 定义了几个异常表。主要的异常表在建立内核程序映像时,由 C 编译器自动生成。它存放在内核代码段的__ex_table节,起始地址和终止地址由 C 编译器产生的两个符号__start__ex_table__stop__ex_table标识。

此外,每个动态装载的内核模块都包含自己的局部异常表。该表在建立模块映像时,由 C 编译器自动产生,在把模块插入到运行中的内核时,该表被装入内存。

每个异常表的表项是一个exception_table_entry结构,有两个字段:

  • insn,访问进程地址空间的指令的线性地址。
  • fixup,存放在insn单元中的指令所触发的缺页异常发生时,fixup就是要调用的汇编语言代码地址。

修正代码由几条汇编指令组成,用以解决由缺页异常所引起的问题。修正通常由插入的一个指令序列组成,该指令序列强制服务例程向用户态进程返回一个出错码。这些指令通常在访问进程地址空间的同一函数或宏中定义;由 C 编译器把它们放置在内核代码段的一个叫.fixup的独立部分。

search_exception_tables()在所有异常表中查找一个指定地址:若该地址在某一个表中,则返回指向相应exception_table_entry结构的指针;否则,返回 NULL。因此,缺页处理程序do_page_fault()执行:

1
2
3
4
5
if(fixup = search_exception_tables(regs->eip))
{
regs->eip = fixup->fixup;
return 1;
}

regs->eip字段包含异常发生时保存到内核态栈eip寄存器中的值。如果eip寄存器中的该值在某个异常表中,do_page_fault()regs->eip保存的值替换为search_exception_tables()的返回地址。缺页处理程序终止,被中断的程序恢复运行。

生成异常表和修正代码

GNU 汇编程序伪指令.section允许程序员指定可执行文件的哪部分包含紧接着要执行的代码。可执行文件包含一个代码段,该代码段可能被划分为节。下边的代码在异常表中加入一个表项:

1
2
3
.section __ex_table, "a"
.long faulty_instruction_address, fixup_code_address
.previous

.previous伪指令强制汇编程序把紧接着的代码插入到遇到上一个.section伪指令时激活的节。

前边讨论过的__get_user_1()__get_user_2()__get_user_4()函数,访问进程地址空间的指令用1、2、3标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
__get_user_1:
[...]
1: movzbl (%eax), %edx
[...]

__get_user_2:
[...]
2: movzwl -1(%eax), %edx
[...]

__get_user_4:
[...]
3: movl -3(%eax), %edx
[...]

bad_get_user:
xorl %edx, %edx
movl $-EFAULT, %eax
ret

.section __ex_table, "a"
.long 1b, bad_get_user
.long 2b, bad_get_user
.long 3b, bad_get_user
.previous

每个异常表项由两个标号组成,第一个是标号,前缀 “b” 表示”向后的”,标号出现在程序的前一行。修正代码对这三个函数是公用的,被标记为bad_get_user,如果缺页异常是由标号 1、2 或 3 处的指令产生的,则修正代码就执行。bad_get_user修正代码给发出系统调用的进程只简单地返回一个出错码 -EFAULT。

其他作用于用户态地址空间的内核函数也使用修正代码技术。比如strlen_user(string)宏,返回系统调用中string参数的长度,string以null结尾;出错时返回 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
strlen_user(string):
mvol $0, %eax
movl $0x7fffffff, %ecx
movl %ecx, %ebx
movl string, %edi
0: repne; scabsb
subl %ecx, %ebx
movl %ebx, %eax

1:
.section .fixup, "ax"
2: xorl %eax, %eax
jmp 1b

.previous

; 在 __ex_table 中增加一个表项
; 内容包括 repne; scasb 指令的地址和相应的修正代码的地址
.section __ex_table, "a"
.long 0b, 2b

.previous

ecxebx寄存器的初始值设置为 0x7fffffff,表示用户态地址空间字符串的最大长度。repne; scabsb循环扫描由edi指向的字符串,在eax中查找值为0的字符(字符串的结尾标志 \0)。因为每一次循环scasb都将ecx减1,所以eax中最后存放字符串长度。修正代码被插入到.fixup节。ax属性指定该节必须加载到内存且包含可执行代码。如果缺页异常是由标号为 0 的指令引起,就执行修正代码,它只简单地把eax置为 0,因此强制该宏返回一个出错码 0 而不是字符串长度,然后跳转到标号 1,即宏之后的相应指令。

第二个.section指令在__ex_table中增加一个表项,内容包括repne; scsab指令地址和相应的修正代码地址。

内核封装例程

系统调用也可以被内核线程调用,内核线程不能使用库函数。为了简化相应的封装例程的声明,Linux 定义了 7 个从_syscall0_syscall6的一组宏。

每个宏名字中的数字 0~6 对应着系统调用所用的参数个数(系统调用号除外)。也可以用这些宏来声明没有包含在 libc 标准库中的封装例程。然而,不能用这些宏来为超过 6 个参数(系统调用号除外)的系统调用或返回非标准值的系统调用封装例程。

每个宏严格地需要 2+2*n 个参数,n 是系统调用的参数个数。前两个参数指明返回值类型和名字;后面的每一对附加参数指明参数的类型和名字。以fork()系统调用为例,其封装例程可以通过如下语句产生:

1
_syscall0(int, fork)

write()系统调用的封装例程可通过如下语句产生:
1
_syscall3(int, write, int, fd, const char *, buf, unsigned int, count)

展开如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
int write(int fd, const char *buf, usninged int count)
{
long __res;
adm("int $0x80",
: "0" (__NR_write), "b" ((long)fd),
"c" ((long)buf), "d" ((long)count));
if((unsigned long)__res >= (unsigned long)-129)
{
errno = -__res;
__res = -1;
}
return (int)__res;
}

__NR_write宏来自_syscall3的第二个参数;它可展开成 write() 的系统调用号,当编译前面的函数时,产生如下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
write:
pushl ebx ; 将 ebx 入栈
movl 8(%esp), %ebx ; 第一个参数放入 ebx
movl 12(%esp), %ecx ; 第二个参数放入 ecx
mvol 16(%esp), %edx ; 第三个参数放入 edx
movll $4, %eax ; __NR_write 放入 eax
int $0x80 ; 调用系统调用
cmpl $-125, %eax ; 检测返回码
jbe .L1 ; 如果无错则跳转
negl %eax ; 求 eax 的补码
movl %eax, errno ; 结果放入 errno
movl -1, %eax ; eax 置为 -1
.L1: popl %ebx ; 从堆栈弹出 ebx
ret ; 返回调用程序

如果 eax 中的返回值在 -1~-129 之间,则被解释为出错码,在 errno 中存放 -eax 的值并返回 -1;否则,返回 eax 中的值。

信号

信号的作用

信号是很短的消息,可以被发送到一个进程或一组进程。发送给进程的唯一信息通常是一个数,来标识信号。前缀为SIG的一组宏标识信号。如,当一个进程引用无效的内存时,SIGSEGV宏产生发送给进程的信号标识符。

使用信号的两个目的:

  • 让进程知道已经发生了一个特定的事件。
  • 强迫进程执行自己代码中的信号处理程序。

POSIX 标准还引入了实时信号,编码范围为 32~64。不同于常规信号,它们必须排队,以便发送的多个信号都能被接收到。而同种类型的常规信号并不排队:如果一个常规信号被连续发送多次,则只有其中一个发送到接收进程。Linux 内核不使用实时信号,但通过几个特定的系统调用实现了 POSIX 标准。

许多系统调用允许程序员发送信号并决定他们的进程如何响应接收的信号。

信号的一个重要特点是它们可以随时被发送给状态经常不可预知的进程。发送给非运行进程的信号必须由内核保存,直到进程恢复执行。阻塞一个信号会拖延信号的传递,直到阻塞解除。因此,内核区分信号传递的两个不同阶段:

  • 信号产生。内核更新目标进程的数据结构,以表示一个新信号已经被发送。
  • 信号传递。内核强迫目标进程通过以下方式对信号做出反应:或改变目标进程的执行状态,或开始执行一个特定的信号处理程序,或两者都是。

每个产生的信号之多被传递一次。信号是可消费资源:一旦已经传递出去,进程描述符中有关该信号的所有信息都被取消

已经产生但还没有传递的信号被称为挂起信号。任何时候,一个进程仅保存特定类型的一个挂起信号,同一进程同种类型的其他信号不被排队,只被简单地丢弃。但对于实时信号,同种类型的挂起信号可以有好几个。

一般,信号可以保留不可预知的挂起时间,必须考虑下列因素:

  • 信号通常只被当前正在运行的进程(current)传递
  • 给定类型的信号可以由进程选择性地阻塞。此时,在取消阻塞前进程将不接受该信号。
  • 当进程执行一个信号处理程序的函数时,通常“屏蔽”相应的信号,即自动阻塞该信号直到处理程序结束

因此,所处理的信号另一次出现不能中断信号处理程序,所以信号处理函数不必是可重入的

信号的内核实现比较复杂,内核必须:

  • 记住每个进程阻塞哪些信号。
  • 当从内核态切换到用户态时,对任何一个进程都要检查是否有一个信号已经到达。这几乎在每个定时中断时都发生。
  • 确定是否可忽略该信号。发生在下列条件都满足时:
    • 目标进程没有被另一个进程跟踪(进程描述符中ptrace字段的PT_PTRACED的标志等于 0)。
    • 信号没有被目标进程阻塞。
    • 信号被目标进程忽略。
  • 处理这样的信号,即信号可能在进程运行期的任意时刻请求把进程切换到一个信号处理函数,并在这个函数返回后恢复原来执行的上下文
  • 此外,还需考虑兼容性。

传递信号之前所执行的操作

进程以三种方式对一个信号做出应答:

  • 显式地忽略信号。
  • 执行与信号相关的缺省操作。由内核预定义的缺省操作取决于信号的类型:
    • Terminate,进程被终止
    • Dump,进程被终止,如果可能,创建包含进程执行上下文的核心转储文件,该文件可用于调试。
    • Ignor,信号被忽略。
    • Stop,进程被停止,即把进程设置为TASK_STOPPED状态。
    • Continue,如果进程被停止,就把它设置为TASK_RUNNING状态。
  • 通过调用相应的信号处理函数捕获信号。

对一个信号的阻塞和忽略是不同的:

  • 只要信号被阻塞,就不被传递;只有在信号解除阻塞后才传递。
  • 而一个被忽略的信号总是被传递,只是没有进一步的操作。

SIGKILLSIGSTOP信号不可被显式忽略、捕获或阻塞,因此,通常必须执行它们的缺省操作。因此,SIGKILLSIGSTOP分别允许具有适当特权的用户终止、停止任何进程,不管程序执行时采取怎样的防御措施

如果某个信号的传递导致内核杀死一个进程,那么该信号对进程就是致命的。致命的信号包括:

  • SIGKILL 信号
  • 缺省操作为 Terminate 的每个信号
  • 不被进程捕获的信号对于该进程是致命的

如果一个被进程捕获的信号,对应的信号处理函数终止了该进程,那么该信号就不是致命的,因为进程自己选择了终止,而不是被内核杀死。

POSIX 信号和多线程应用

POSXI 1003.1 标准对多线程应用的信号处理有一些严格的要求:

  • 信号处理程序必须在多线程应用的所有线程之间共享;不过,每个线程必须有自己的挂起信号掩码和阻塞信号掩码
  • POSIX库函数kill()sigqueue()必须向所有的多线程应用而不是某个特殊的线程发送信号。所有由内核产生的信号同样如此。
  • 每个发送给多线程应用的信号仅传送给一个线程,这个线程是由内核在从不阻塞该信号的线程中随意选择出来的。
  • 如果向多线程应用发送了一个致命的信号,那么内核将被杀死该应用的所有线程,而不仅仅是杀死接收信号的那个线程。

为遵循 POSIX 标准,Linux 内核把多线程应用实现为一组属于同一个线程组的轻量级进程

如果一个挂起信号被发送给了某个特定进程,那么该信号是私有的;如果被发送给了整个线程组,它就是共享的。

与信号相关的数据结构

内核使用几个从处理器描述符可存取的数据结构:

与信号处理相关的进程描述符中的字段:

进程描述符中的blocked字段存放进程当前所屏蔽的信号。它是一个sigset_t位数组,每种信号类型对应一个元素:

1
2
3
4
typedef struct
{
unsigned long sig[2];
}sigset_t;

每个无符号长整数由 32 位组成,所以信号最大数是64,信号的编号对应于sigset_t位数组中相应位的下标 + 1。1 ~ 31 之间的编号对应于常规信号,32 ~ 64之间的编号对应于实时信号。

信号描述符和信号处理程序描述符

进程描述符的signal字段指向信号描述符—一个signal_struct类型的结构,用来跟踪共享挂起信号。信号描述符还包括与信号处理关系不密切的一些字段,如

  • rlim,每进程的资源限制数组
  • pgrp,进程的组领头进程 PID
  • session,进程的会话领头进程 PID

信号描述符被属于同一线程组的所有进程共享,即被调用clone()系统调用(设置CLONE_SIGHAND标志)创建的所有进程共享,因此,对属于同一线程组的每个进程而言,信号描述符中的字段必须都是相同的

每个进程还有信号处理程序描述符,是一个sighand_struct类型的结构,用来描述每个信号必须如何被线程组处理。

调用clone()时设置CLONE_SIGHAND标志,信号处理程序描述符就可以被几个进程共享。描述符的count字段表示共享该结构的进程个数。在一个POSIX的多线程应用中,线程组中的所有轻量级进程都应该用相同的信号描述符和信号处理程序描述符。

sigaction

信号的特性存放在k_sigaction结构中,既包含对用户态进程所隐藏的特性,也包含sigaction结构。字段:

  • sa_handler,指定执行操作的类型。它的值可以是指向信号处理程序的一个指针,SIG_EFL,或SIG_IGN
  • sa_flags,标志集,指定必须怎样处理信号。
  • sa_mask,类型为sigset_t的变量,指定当运行信号处理程序时要屏蔽的信号。


挂起信号队列

为了跟踪当前的挂起信号是什么,内核把两个挂起信号队列与每个进程关联:

  • 共享挂起信号队列,位于信号描述符的shared_pending字段,存放这个线程组的挂起信号。
  • 私有挂起信号队列,位于进程描述符的pending字段,存放特定进程的挂起信号。

挂起信号队列由sigpending数据结构组成,定义如下:

1
2
3
4
struct singpengding {
struct list_head list;
sigset_t signal; // 指定挂起信号的位掩码
}

signal字段是指定挂起信号的位掩码,而list字段是包含sigqueue的双向链表的头,sigqueue的字段如表:

siginfo_t是一个128字节的数据结构,存放有关出现特定信号的信息,包含下列字段:

  • si_signo,信号编号
  • si_errno,引起信号产生的指令的出错码,没有错误则为 0
  • si_code,发送信号者的代码

  • _sifields,依赖于信号类型的信息的联合体,相对于SIGKILL信号,siginfo_t在这里记录发送者进程的PID和UID。

在信号数据结构上的操作

下面的set是指向sigset_t类型变量的一个指针,nsig是信号的编号,mask是无符号长整数的位掩码。

  • sigemptyset(set)sigfillset(set):把set中的位分别置为 0 或 1。
  • sigaddset(set, nsig)sigdelset(set, nsig):把nsig信号在set中对应的位分别置为 1 或 0。sigaddset()简化为:set->sig[(nsig-1) / 32] |= 1UL << ((nsig - 1) % 32);sigdelset()简化为:set->sig[(nsig-1) / 32] |= ~(1UL << ((nsig - 1) % 32));
  • sigaddsetmask(set, mask)sigdelsetmask(set, mask):把mask中的位在set中对应的所有位分别设置为 1 或 0。仅用于编号为 1~32 之间的信号,可分别简化为:set->sig[0] |= mask;set->sig[0] |= ~mask;
  • sigismember(set, nsig):返回nsig信号在set中对应的值。可简化为:return 1 & (set->sig[(nsig - 1) / 32] >> ((nsig - 1) % 32));
  • sigmask(nsig):产生nsig信号的位索引。如果内核需要设置、清除或测试一个特定信号在sigset_t类型变量中对应的位,可通过该宏得到合适的位。
  • sigandsets(d, s1, s2)sigoresets(d, s1, s2)signandsets(d, s1, s2):在sigset_t类型的s1和s2变量之间分别执行逻辑“与”、逻辑“或”即逻辑“与非”。结果保存在d指向的sigset_t类型的变量中。
  • sigtestsetmask(set, mask):如果maskset中对应的任意一位被设置,就返回 1;否则返回 0,只用于编号为 1 ~ 31。
  • siginitset(set, mask):把mask中的位初始化为 1 ~ 32 之间的信号在set中对应的低位,并把 33 ~ 63 之间信号的对应位清 0。
  • siginitsetinv(set, mask):用mask中位的补码初始化 1 ~ 32 间的信号在sigset_t类型的变量中对应的低位,并把 33 ~ 63 之间信号的对应位置位。
  • signal_pending(p):如果*p进程描述符所表示的进程有非阻塞的挂起信号,就返回 1,否则返回 0。通过检查进程的TIF_SIGPENDING标志实现。
  • recalc_sigpending_tsk(t)recalc_sigpending():第一个函数检查是*t进程描述符表示的进程有挂起信号(t->pending->signa),还是进程所属的线程组有挂起的信号(t->signal->shared_pending->signal),然后把t->thread_info->flagsTIF_SIGPENDING标志置位。第二个函数等价于recalc_sigpending_tsk(current)
  • rm_from_queue(mask, q):从挂起信号队列q中删除与mask位掩码相对应的挂起信号。
  • flush_sigqueue(q):从挂起信号队列q中删除所有的挂起信号。
  • flush_signals(t):删除发送给*t进程描述符所表示的进程的所有信号。通过清除t->thread_info->flags中的TIF_SIGPENDING标志,并在 t->pendingt->signal->shared_pending队列上两次调用flush_sigqueue()实现。

产生信号

当发送给进程或整个线程组一个信号时,该信号可能来自内核,也可能来自另一个进程。内核通过对表中的某个函数进行调用而产生信号:

表中的所有函数在结束时都会调用specific_send_sig_info()

一个信号被发往整个线程组时,这个信号可来自内核,也可能来自另一个进程,内核对表中的函数进行调用来产生这类信号:

表中的所有函数在结束时都会调用group_send_sig_info()

specific_send_sig_info()

向指定进程发送信号,参数:

  • sig,信号编号。
  • info,或者是siginfo_t表的地址,或者是三个特殊值中的一个:
    • 0:信号由用户态进程发送。
    • 1:信号由内核发送。
    • 2:由内核发送的SIGSTOPSIGKILL信号。
  • t:指向目标进程描述符的指针。

必须在关本地中断和已经获得t->sighand->siglock自旋锁的情况下调用该函数,执行下列步骤:

  • 检查进程是否忽略信号,如果是就返回 0(不产生信号)。以下三个条件都满足时,信号被忽略:
    • 进程没有被跟踪(t->ptrace中的PT_PTRACED标志被清 0)
    • 信号没有被阻塞(sigismember(&t->blocked, sig)返回 0)
    • 或者显示地忽略信号(t->sighand->action[sig-1].sa_handler == SIG_IGN),或者隐含地忽略信号(sa_handler == SIGDFL,且信号是SIGCONTSIGCHLDSIGWINCHSIGURG
  • 如果信号是非实时的(sig < 32),且在进程的私有挂起信号队列上已经有另外一个相同的挂起信号(sigismember(&t->pending.signal, sig)返回 1),什么都不需要做,返回0。
  • 调用send_signal(sig, info, t, &t->pending)把信号添加到进程的挂起信号集合中。
  • 如果send_signal()成功结束,且信号不被阻塞(sigismember(&t->blocked,sig)返回0),signal_wake_up()通知进程有新的挂起信号,随后,该函数执行下述步骤:
  • t->thread_info->flags中的TIF_SIGPENDING标志置位。
  • 如果进程处于TASK_INTERRUPTILETASK_STOPPED状态,且信号是SIGKILLtry_to_wake_up()唤醒进程。
  • 如果try_to_wake_up()返回 0,说明进程已经是可运行的:检查进程是否已经在另外一个 CPU 上运行,如果是就像那个 CPU 发送一个处理器间中断,以强制当前进程的重新调度。
    因为从调度函数返回时,每个进程都检查是否存在挂起信号,因此,处理器间中断保证了目标进程能很快注意到新的挂起信号。
  • 返回 1(成功产生信号)。

send_signal()

在挂起信号队列中插入一个新元素。参数:

  • 信号编号sig
  • siginfo_t数据结构的地址info
  • 目标进程描述符的地址t
  • 挂起信号队列的地址signals

执行下列步骤:

  1. 如果info == 2,该信号就是SIGKILLSIGSTOP,且已经由内核通过force_sig_specific()产生:跳到第 9 步,内核立即强制执行与这些信号相关的操作,因此函数不用把信号添加到挂起信号队列中。
  2. 如果进程拥有者的挂起信号的数量(t->user->sigpending)小于当前进程的资源限制(t->signal->rlim[RLIMT_SIGPENDING].rlim_cur),就为新出现的信号分配sigqueue数据结构:q = kmeme_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
  3. 如果进程拥有者的挂起信号的数量太多,或上一步的内存分配失败,就跳转到到第 9 步。
  4. 递增拥有者挂起信号的数量(t->user->sigpending)和t->user所指向的每用户数据结构的引用计数器。
  5. 在挂起信号队列signals中增加sigqueue数据机构:list_add_tail(&q->list, &signals->list);
  6. sigqueue数据结构中填充表siginfo_t

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    if((unsigned long)info == 0)
    {
    q->info.si_signo = sig;
    q->info.si_errno = 0;
    q->info.si_code = SI_USER;
    q->info._sifields._kill._pid = current->pid;
    q->info._sifields._kill._uid = current->uid;
    }
    else if((unsigned long)info == 1)
    {
    q->info.si_signo = sig;
    q->info.si_errno = 0;
    q->info.si_code = SI_KERNEL;
    q->info._sifields._kill._pid = 0;
    q->info._sifiields._kill._uid = 0;
    }
    else
    copy_siginfo(&q->info, info); // 复制由调用者传递的 siginfo_t 表
  7. 把队列位掩码中与信号相应的位置 1:sigaddset(&signals->signal, sig);

  8. 返回 0:说明信号已经被成功追加到挂起信号队列中。
  9. 此时,不再向信号挂起队列中增加元素,因为已经有太多的挂起信号,或已经没有可以分给sigqueue数据结构的空闲空间,或者信号已经由内核强制立即发送。如果信号是实时的,并已经通过内核函数发送给队列排队,则send_signal()返回错误代码 -EAGIN:

    1
    2
    if(sig >= 32 && info && (unsigned long)info != 1 && info->si_code != SI_USER)
    return -EAGIN;
  10. 设置队列的位掩码中与信号相关的位:sigaddset(&signals->signal, sig);

  11. 返回 0:即使信号没有被追加到队列中,挂起信号掩码中相应的位也被设置。

即使在挂起队列中没有空间存放相应的挂起信号,让目标进程能接收信号也很重要。假设一个进程正在消耗过多内存,内核必须保证即使没有空闲内存,kill()也能成功执行。

group_send_sig_info()

向整个线程组发送信号。参数:

  • 信号编号sig
  • siginfo_t表的地址info
  • 进程描述符的地址p

执行下列步骤:

  1. 检查 sig 是否正确
    1
    2
    if(sig < 0 || sig > 64)
    return -EINVAL;
  2. 如果信号是由用户态进程发送的,则确定是否允许该操作。如果不允许用户态进程发送信号,返回 -EPERM。下列条件至少有有一个成立,信号才可被传递:
    1. 发送进程的拥有者拥有适当的权限(通常意味着通过系统管理员发布信号)。
    2. 信号为SIGCONT且目标进程与发送进程处于同一个注册会话中。
    3. 两个进程属于同一个用户。
  3. 如果参数sig == 0,不产生任何信号,立即返回。因为0是无效的信号编码,说明发送进程没有向目标线程组发送信号的特权。如果目标进程正在被杀死(通过检查它的信号处理程序描述符是否被释放得知),那么函数也返回。
  4. 获取p->sighand->siglock自旋锁并关闭本地中断。
  5. handle_stop_signal()检查信号的某些类型,这些类型可能使目标线程组的其他挂起信号无效。
    1. 如果线程组正在被杀死(信号描述符的flags字段的SIGNAL_GROUP_EXIT标志被设置),则返回。
    2. 如果sigSIGSTOPSIGTSTPSIGTTINSIGTTOU信号,rm_from_queue()从共享挂起信号队列p->signal->shared_pending和线程组所有成员的私有信号队列中删除SIGCONT信号。
    3. 如果sigSIGCONT信号,rm_from_queue()从共享挂起信号队列p->signal->shared_pending中删除所有的SIGSTOPSIGTSTPSIGTTINSIGTTOU信号,然后从属于线程组的进程的私有挂起信号队列中删除上述信号,并唤醒进程:
      1
      2
      3
      4
      5
      6
      7
      8
      rm_from_queue(0x003c0000, &p->signal->shared_pending);
      t = p;
      do
      {
      rm_from_queue(0x003c0000, &t->pending);
      try_to_wake_up(t, TASK_STOPPED, 0);
      t = next_thread(t); // 返回线程组中不同轻量级进程的描述符地址
      }while(t != p);
  6. 检查线程组是否忽略信号,如果是就返回 0 值(成功)。如果前一节“信号的作用”中提到的忽略信号的三个条件都满足,就忽略信号。
  7. 如果信号是非实时的,并且在线程组的共享挂起信号队列中已经有另外一个相同的信号,就什么都不做,返回 0 值(成功)。
    1
    2
    if(sit < 32 && sigismember(&p->signal->shared_pending.signal, sig))
    return 0;
  8. send_signal()把信号添加到共享挂起信号队列中。如果返回非 0 的错误码,终止并返回相同值。
  9. __group_complete_signal()唤醒线程组中的一个轻量级进程。
  10. 释放p->sighand->siglock自旋锁并打开本地中断。
  11. 返回 0(成功)。

__group_complete_signal()扫描线程组中的进程,查找能接收新信号的进程。满足下述所有条件的进程可能被选中:

  • 进程不阻塞信号。
  • 进程的状态不是EXIT_ZOMBIEEXIT_DEADTASK_TRACEDTASK_STOPPED
  • 进程没有正在被杀死,即它的PF_EXITING标志没有置位。
  • 进程或者当前正在 CPU 上运行,或者它的TIF_SIGPENDING标志还没有设置。

线程组可能有很多满足上述条件的进程,函数按照下面的规则选中其中一个进程:

  • 如果 p 标识的进程(group_send_sig_info()的参数传递的描述符地址)满足所有的优先准则,函数就选择该进程。
  • 否则,函数通过扫描线程组的成员搜索一个适当的进程,搜索从接收线程组最后一个信号的进程(p->siganl->curr_target)开始。

如果__group_complete_signal()成功找到一个适当的进程,就开始向被选中的进程传递信号。先检查信号是否是致命的,如果是,通过向线程组中的所有轻量级进程发送SIGKILL信号杀死整个线程组。否则,调用signal_wake_up()通知被选中的进程:有新的挂起信号。

传递信号

如何确保进程的挂起信号得到处理内核所执行的操作。

在运行进程恢复用户态下的执行前,内核会检查进程TIF_SIGPENDING标志的值。每当内核处理完一个中断或异常时,就检查是否存在挂起信号。

为了处理非阻塞的挂起信号,内核调用do_signal()。参数:

  • regs,栈区的地址,当前进程在用户态下寄存器的内容存放在这个栈中。
  • oldset,变量的地址,假设函数把阻塞信号的位掩码数组存放在这个变量中。不需要保存位掩码数组时,置为 NULL。

通常只在 CPU 要返回到用户态时才调用do_signal()。因此,如果中断处理程序调用do_signal(),该函数立即返回。

1
2
if((regs->xcs & 3) != 3)
return 1;

如果oldset参数为NULL,就用current->blocked字段的地址对它初始化:

1
2
if(!oldset)
oldset = &current->blocked;

do_signal()的核心是重复调用dequeue_signal(),直到私有挂起信号队列和共享挂起信号队列中都没有非阻塞的挂起信号为止。

dequeue_signal()的返回码存放在signr局部变量中,值为:

  • 0,所有挂起的信号已全部被处理,且do_signal()可以结束。
  • 非 0,挂起的信号正等待被处理,且do_signal()处理了当前信号后又调用了dequeue_signal()

dequeue_signal()首先考虑私有信号队列中的所有信号,并从最低编号的挂起信号开始。然后考虑共享队列中的信号。它更新数据结构以标识信号不再是挂起的,并返回它的编号。这就涉及清current->pending.signalcurrent->signal->shared_pending.signal中对应的位,并调用recalc_sigpending()更新TIF_SIGPEDING标志的值。

do_signal()处理每个挂起的信号,并将其编号通过dequeue_signal()返回:

  • 首先,检查current接收进程是否正受其他一些进程的监控,如果是,调用do_notify_parent_cldtop()schedule()让监控进程知道进程的信号处理。
  • 然后,把要处理信号的k_sigaction数据结构的地址赋给局部变量kaka = &current->sig->action[signr-1];
  • 根据ka的内容可以执行三种操作:忽略信号执行缺省操作执行信号处理程序
  • 如果显式忽略被传递的信号,do_signal()仅仅继续执行循环,接着考虑另一个挂起信号:if(ka->sa.sa_handler == SIG_IGN) continue;

执行信号的缺省操作

如果ka->sa.sa_handler == SIG_DFLdo_signal()就必须执行信号的缺省操作。但当接收进程是init时,该信号被丢弃:

1
2
if(current->pid == 1)
continue;

如果接收进程是其他进程,对缺省操作是 Ignore 的信号进行简单处理:

1
2
if(signr == SIGCONT || signr == SIGCHLD || signr == SIGWINCH || signr == SIGURG)
continue;

缺省操作是 Stop 的信号可能停止线程组中的所有进程。

因此,do_singal()把进程的状态都设置为TASK_STOPPED,并随后调用schedule()

1
2
3
4
5
6
if(signr == SIGTOP || signr == SIGTSTP || signr == SIGTTIN || signr = SIGTTOU)
{
if(signr != SIGSTOP && is_orphaned_pgrp(current->signal->pgrp))
continue;
do_signal_stop(signr);
}

SIGSTOP与其他信号的差异比较微妙,SIGSTOP总是停止线程组而其他信号只停止不在“孤儿进程组”中的线程组。POSIX 标准规定,只要进程组中有一个进程有父进程,即便父进程处于不同的进程组中,但在同一个会话中,那么该进程组不是孤儿进程组。因此,如果父进程死亡,但启动该进程的用户仍登录在线,那么该进程组就不是一个孤儿进程组。

do_signal_stop()检查current是否是线程组中第一个被停止的进程,如果是,激活“组停止”:本质上,将信号描述符中的group_stop_count字段设为正值,并唤醒线程组中的所有进程。组中的所有进程都检查该字段以确认正在进行“组停止”,然后把进程的状态设置为TASK_STOPPED,并调用schedule(),如果线程组领头进程的父进程没有设置SIGCHLDSA_NOCLDSTOP标志,还需要向它发送SIGCHLD信号。

缺省操作为 Dump 的信号可以在进程的工作目录中创建一个“转储”文件,该关文件列出进程地址空间和 CPU 寄存器的全部内容。do_signal()创建了转储文件后,就杀死该线程组。

剩余 18 个信号的缺省操作为Terminate,仅仅杀死线程组。为了杀死整个线程组,调用do_group_exit()执行彻底的“组退出”过程。

捕获信号

如果信号有一个专门的处理程序,do_signal()就执行它。通过调用handle_signal()进行:

1
2
3
4
handle_signal(signr, &info, &ka, oldset, regs);
if(ka->sa.sa_flags & SA_ONESHOT)
ka->sa.sa_handler = SIG_DFL;
return 1;

如果所接收信号的SA_ONESHOT标志被置位,就必须重新设置它的缺省操作,以便同一信号的再次出现不会再次触发信号处理程序的执行。do_signal()处理了一个单独的信号后,直到下一次调用do_signal()时才考虑其他挂起的信号,确保了实时信号将以适当的顺序得到处理。

执行一个信号处理程序复杂性一:在用户态和内核态之间切换时,需要谨慎地处理栈中的内容

handle_signal()运行在内核态,而信号处理程序运行在用户态,当前进程恢复“正常”执行前,必须首先执行用户态的信号处理程序。此外,当内核打算恢复进程的正常执行时,内核态堆栈不再包含被中断程序的硬件上下文,因为每当从内核态向用户态转换时,内核态堆栈都被清空

执行一个信号处理程序复杂性二:可以调用系统调用。这种情况下,执行了系统调用的服务例程后,控制权必须返回到信号处理程序,而不是被中断程序的正常代码流。

Linux 所采用的解决方法是,把保存在内核态堆栈中的硬件上下文拷贝到当前进程的用户态堆栈中。用户态堆栈也以同样方式修改:即当信号处理程序终止时,自动调用sigreturn()把这个硬件上下文拷贝回内核态堆栈中,并恢复用户态堆栈中原来的内容

图11-2说明了有关捕获一个信号的函数的执行流:

  • 一个非阻塞的信号发送一个进程。
  • 中断或异常发生时,进程切换到内核态。
  • 内核执行do_signal(),该函数依次处理信号(handle_signal())和建立用户态堆栈(setup_frame()setup_rt_frame())。
  • 进程返回到用户态,因为信号处理程序的起始地址被强制放进程序计数器,因此开始执行信号处理程序。
  • 处理程序终止时,setup_frame()setup_rt_frame()放在用户态堆栈中的返回代码被执行。该代码调用sig_return()rt_sigreturn()系统调用,相应的服务例程把正常程序的用户态堆栈硬件上下文拷贝到内核态堆栈,并把用户态堆栈恢复到它原来的样子(restore_sigcontext())。
  • 普通进程恢复执行。

下面详细讨论该种方案。

建立帧

为建立进程的用户态堆栈,handle_signal()调用setup_frame()setup_rt_frame()。为了在这两个函数之间进行选择,内核检查与信号相关的sigactionsa_flags字段的SA_SIGINFO标志。

setup_frame()参数:

  • sig,信号编号
  • ka,与信号相关的k_sigaction表的地址
  • oldset,阻塞信号的位掩码数组的地址
  • regs,用户态寄存器的内容保存在内核态堆栈区的地址

setup_frame()推入用户态堆栈中,该帧含有处理信号所需的信息,并确保正确返回到handle_signal()。一个帧就是包含下列字段的sigframe表:

  • pretcode:信号处理函数的返回地址,它指向__kernel_sigreturn标记处的代码
  • sig:信号编号
  • sc:类型为sigcontext的结构,它包含正好切换到内核态前用户态进程的硬件上下文,还包含进程被阻塞的常规信号的位数组
  • fpstate:类型为_fpstate的结构,用来存放用户态进程的浮点寄存器内容
  • extramask:被阻塞的实时信号的位数组
  • retcode:发出sigreturn()系统调用的8字节代码

setup_frame()执行步骤:

  • 调用get_sigframe()计算帧的第一个内存单元,通常在用户态堆栈中:
    • 因为栈朝低地址方向延伸,所以通过把当前栈顶的地址减去它的大小,使其结果与 8 的倍数对齐,就获得了帧的起始地址
1
(rets->esp - sizeof(struct sigframe)) & 0xfffffff8
  • access_ok宏对返回地址进行验证。
    • 如果地址有效,setup_frame()反复调用__put_user()填充帧的所有字段。
    • 帧的pretcode初始化为&__kernel_sigreturn,一些粘合代码的地址存放在vsyscall页中。
    • 修改内核态堆栈的regs区,保证了当current恢复在用户态的执行时,控制权将传递给信号处理程序。
1
2
3
4
5
6
7
regs->esp = (unsigned long)frame;  // 而 esp 指向已推进用户态堆栈顶的帧的第一个内存单元
regs->eip = (unsigned long)ka->sa.sa_handler; // eip 寄存器执行信号处理程序的第一条指令
regs->eax = (unsigned long)sig;
regs->edx = regs->ecx = 0;

regs->xds = regs->xes = regs->xss = __USER_DS;
regs->xcs = __USER_CS;

setup_frame()把保存在内核态堆栈的段寄存器内容重新设置成它们的缺省值,现在,信号处理程序所需的信息就在用户态堆栈的顶部。

setup_rt_frame()setup_frame()非常相似,但它把用户态堆栈存放在一个扩展帧中(rt_sigframe数据结构中),该帧包含了与信号相关的siginfo_t表的内容。此外,该函数设置pretcode字段以使它执行vsyscall页中的__kernel_rt_sigreturn代码。

检查信号标志

建立用户态堆栈后,handle_signal()检查与信号相关的标志值。如果信号没有设置SA_NODEFER标志,在sigaction表中sa_mask字段对应的信号就必须在信号处理程序执行期间被阻塞

1
2
3
4
5
6
7
8
9
if(!(ka->sa.sa_flags & SA_NODEFER))
{
spin_lock_irq(&current->sighand->siglock);
sigorsets(&current->blocked, &current->blocked, &ka->sa.sa_mask);
sigaddset(&current->blocked, sig); // sig 为信号编号

recalc_sigpending(curent);
spin_unlock_irq(&current->sighand->siglock);
}

recalc_sigpending()检查进程是否有非阻塞的挂起信号,并因此设置它的TIF_SIGPENDING标志。然后,返回到do_signal()do_signal()也立即返回。

开始执行信号处理程序

do_signal()返回时,当前进程恢复它在用户态的执行。由于setup_frame()的准备,eip寄存器执行信号处理程序的第一条指令,而esp指向已推进用户态堆栈顶的帧的第一个内存单元。因此,信号处理程序被执行。

终止信号处理程序

信号处理程序结束时,返回栈顶地址,该地址指向帧的pretcode字段所引用的vsyscall页中的代码:

1
2
3
4
__kernel_sigreturn:
popl %eax
movl $__NR_sigreturn, %eax
int $0x80

信号编号(即帧的sig字段)被从栈中丢弃,然后调用sigreturn()

sys_sigreturn()函数:

  • 计算类型为pt_regsregs的地址,pt_regs包含用户态进程的硬件上下文。根据存放在esp字段中的地址,导出并检查帧在用户态堆栈内的地址:

    1
    2
    3
    4
    5
    6
    frame = (struct sigframe *)(regs.esp - 8);
    if(verify_area(VERIFY_READ, frame, sizeof(*frame))
    {
    force_sig(SIGSEGV, current);
    return 0;
    }
  • 把调用信号处理程序前所阻塞的信号的位数组从帧的sc字段拷贝到currentblocked字段。结果,为信号处理函数的执行而屏蔽的所有信号解除阻塞。

  • 调用recalc_sigpending()
  • 把来自帧的sc字段的进程硬件上下文拷贝到内核态堆栈中,并从用户态堆栈中删除帧,这两个任务通过调用restore_sigcontext()完成。
  • rt_sigqueueinfo()需要与信号相关的siginfo_t表。
  • 扩展帧的pretcode指向vsyscall页中的__kernel_rt_sigturn代码,它调用rt_sigreturn(),相应的sys_rt_sigreturn()服务例程把来自扩展帧的进程硬件上下文拷贝到内核态堆栈,并通过从用户态堆栈删除扩展帧以恢复用户态堆栈原来的内容。

系统调用的重新执行

内核不总是能立即满足系统调用发出的请求,这时,把发出系统调用的进程置为TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE状态。

如果进程处于TASK_INTERRUPTIBLE状态,并且某个进程向它发送了一个信号,则内核不完成系统调用就把进程置成TASK_RUNNING状态。当切换回用户态时信号被传递给进程。这时,系统调用服务例程没有完成,但返回EINTRERESTARTNOHANDERESTART_RESTARTBLOCKERESTARTSYSERESTARTNOINTR错误码。

实际上,用户态进程获得的唯一错误码是 EINTR,表示系统调用还没有执行完。内核内部使用剩余的错误码来指定信号处理程序结束后是否自动重新执行系统调用

表中列出了与未完成的系统调用相关的出错码以及这些出错码对信号三种可能的操作产生的影响。

  • Terminate,不会自动重新执行系统调用:进程在int $0x80sysenter指令紧接着的那条指令将恢复它在用户态的执行,这时eax寄存器包含的值为 -EINTR。
  • Reexecute,内核强迫用户态进程把系统调用号重新装入eax寄存器,并重新执行int $0x80sysenter指令。进程意识不到这种重新执行,出错码也不传递给进程。
  • Depends,只有被传递信号的SA_RESTART标志被设置,才重新执行系统调用;否则,系统调用以 -EINTR 出错码结束。

传递信号时,内核在试图重新执行一个系统调用前,必须确定进程确实发出过该系统调用。regs硬件上下文的orig_eax字段起该作用。中断或异常处理程序开始时初始化该字段:

  • 中断,与中断相关的IRQ号减去 256
  • 0x80sysenter,系统调用号
  • 其他异常,-1

因此,orig_eax字段中的非负数意味着信号已经唤醒了在系统调用上睡眠的TASK_INTERRUPTIBLE进程。服务例程认识到系统调用曾被中断,并返回前面提到的某个错误码。

重新执行被未捕获信号中断的系统调用

如果信号被显式忽略,或者它的缺省操作被强制执行,do_signal()就分析系统调用的出错码,并确定是否重新自动执行未完成的系统调用。如果必须重新开始执行系统调用,do_signal()就修改regs硬件上下文,以便在进程返回用户态时,eip指向int $0x80sysenter指令,且eax包含系统调用号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if(regs->orig_eax >= 0)
{
if(regs->eax == -ERESTARTNOHAND || regs->eax == -ERESTARTSYS || regs->eax == -ERESTARTNOINTR)
{
regs->eax = regs->orig_eax;
regs->eip -= 2;
}

if(regs->eax == -ERESTART_RESTARTBLOCK)
{
regs->eax = __NR_restart_syscall;
regs->eip -= 2;
}
}

把系统调用服务例程的返回码赋给regs->eaxint $0x80sysreturn的长度都是两个字节,eip减 2 后,指向引起系统调用的指令。ERESTART_RESTARTBLOCK错误码是特殊的,因为eax寄存器存放了restart_syscall()的系统调用号,因此,用户态进程不会重新指向被信号中断的同一系统调用,该错误码仅用于与时间相关的系统调用,重新指向这些系统调用时,应该调整它们的用户态参数。

为所捕获的信号重新执行系统调用

如果信号被捕获,那么handle_signal()可能分析出错码,也可能分析sigaction表的SA_RESTART标志,来决定是否必须重新执行未完成的系统调用。如果系统调用必须被重新开始执行,handle_signal()就与do_signal()完全一样继续执行;否则,向用户态进程返回一个出错码 -EINTR。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if(regs->orig_eax >= 0)
{
switch(regs->eax)
{
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs->eax = -EINTR;
break;
case -ERESTARTSYS:
if(!(ka->sa.sa_flags & SA_RESTART))
{
regs->eax = -EINTR;
brea;
}
case -ERESTARTNOINTR:
regs->eax = regs->orig_eax;
regs->eip -= 2;
}
}

与信号处理相关的系统调用

kill

kill(pid, sig)向普通进程或多线程应用发送信号,其服务例程是sys_kill()pid参数的含义取决于它的值:

  • pid > 0,把sig信号发送到PID等于pid的进程所属的线程组。
  • pid = 0,把sig信号发送到与调用进程同组的进程的所有线程组。
  • pid = -1,把信号发送到所有进程,除了swapper(PID = 0),init(PID = 1)和current
  • pid < -1,把信号发送到进程组 -pid 中进程的所有线程组。

sys_kill()为信号建立最小的siginfo_t表,然后调用kill_something_info()

1
2
3
4
5
6
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info._sifields._kill._pid = current->tgid;
info._sifields._kill._uid = current->uid;
return kill_something_info(sig, &info, pid);

kill_something_info还依次调用kill_proc_info()(通过group_send_sig_info()向一个单独的线程组发送信号),或调用kill_pg_info()(扫描目标进程组的所有进程,并为目标进程组中的所有进程调用send_sig_info()),或为系统中的所有进程反复调用group_send_sig_info()(如果pid等于 -1)。

kill()能发送任何信号,包括 32 ~ 64 间的实时信号,但不能确保一个新的元素加入到目标进程的挂起信号队列,因此,挂起信号的多个实例可能被丢失。实时信号应当通过rt_sigqueueinfo()进程发送。

tkill() 和 tgkill()

向线程组中的指定进程发送信号。

tkill()的两个参数:

  • PID,信号接收进程的pid
  • sig,信号编号

sys_tkill()服务例程为siginfo表赋值、获取进程描述符地址、进行许可性检查,并调用specific_send_sig_info()发送信号。

tgkill()还需要第三个参数:

  • tgid,信号接收进程组所在线程组的线程组 ID

sys_tgkill()服务例程执行的操作与sys_tkill()一样,但还需要检查信号接收进程是否确实属于线程组tgid该附加的检查解决了向一个正在被杀死的进程发送消息时出现的竞争条件的问题

  • 如果另外一个多线程应用正以足够快的速度创建轻量级级进程,信号就可能被传递给一个错误的进程。
  • 因为线程组 ID 在多线程应用的整个生存期中是不会改变的。

改变信号的操作

sigaction(sig, act, oact)允许用户为信号指定一个操作。如果没有自定义的信号操作,则执行与传递的信号相关的缺省操作。

sys_sigaction()服务例程作用于两个参数:

  • sig,信号编号
  • act,类型为old_sigactionact表(表示新的操作)
  • oact,可选的输出参数,获得与信号相关的以前的操作。

函数首先检查act地址的有效性。用*act的字段填充类型为k_sigactionnew_ka局部变量的sa_handlersa_flagssa_mask字段:

1
2
3
4
__get_user(new_ka.sa.sa_handler, &act->sa_handler);
__get_user(new_ka.sa.sa_flags, &act->sa_flags);
__get_user(mask, &act->sa_mask);
siginitset(&new_ka.sa.sa_mask, mask);

调用do_sigaction()把新的new_ka表拷贝到current->sig->actionsig-1位置的表项中(没有 0 信号):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
k = &current->sig->action[sig-1];
if(act)
{
*k = *act;

sigdelsetmask(&k->sa.sa_mask, sigmask(SIGKILL) | sigmask(SITSTOP));

if(k->sa.sa_handler == SIG_IGN || (k->sa.sa_handler == SIG_DFL &&
(sig == SIGCONT || sig == SIGCHLD || sig == SIGWINCH || sig == SIGURG)))
{
rm_from_queue(sigmask(sig), &current->signal->shared_pendig);
t = current;
do
{
rm_from_queue(sigmask(sig), &current->pending);
recalc_sigpending_tsk(t);
t = next_thread(t);
}while(t != current);
}
}

信号处理程序从不屏蔽SIGKILLSIGSTOP。POSIX 标准规定,当缺省操作是Ignore时,把信号操作设置为SIG_IGNSIG_DFL将引起同类型的任意挂起信号被丢弃。

sigaction()还允许用户初始化表sigactionsa_flags字段。

原来的 System V Unix 变体提供了signal()系统调用,Linux提供了sys_signal()服务例程:

1
2
3
4
new_sa.sa_handler = handler;
new_sa.sa_flags = SA_ONESHOT | SA_NOMASL.
ret = do_sigaction(sig, &new_sa, &old_sa);
return ret ? ret : (unsigned long)old_sa.sa.sa_handler;

检查挂起的阻塞信号

sigpending()允许进程检查信号被阻塞时已经产生的那些信号。服务例程sys_sigpending()只作用于一个参数set,即用户变量的地址,必须将位数组拷贝到该变量中:

1
2
3
sigorsets(&pending, &current->pending.signal, &current->signal->shared_pending.signal);
sigandsets(&pending, &current->blocked, &pending);
copy_to_user(set, &pending, 1);

修改阻塞信号的集合

sigprocmask()允许进程修改阻塞信号的集合,只应用于常规信号(非实时信号)。sys_sigprocmask()服务例程作用于三个参数:

  • oset,进程地址空间的一个指针,执行存放以前位掩码的一个位数组。
  • set,进程地址空间的一个指针,执行包含新位掩码的位数组。
  • how,一个标志,可采取如下值:
    • SIG_BLOCK*set位掩码数组,指定必须加到阻塞信号的位掩码数组中的信号。
    • SIG_UNBLOCK*set位掩码数组,指定必须从阻塞信号的位掩码数组中删除的信号。
    • SIG_SETMASK*set位掩码数组,指定阻塞信号新的位掩码数组。

sys_sigprocmask()调用copy_from_user()set参数拷贝到局部变量new_set中,把current标准阻塞信号的位掩码数组拷贝到old_set局部变量中,然后根据how标志进行相应操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if(copy_from_user(&new_set, set, sizeof(*set)))
return -EFAULT;

new_set &= ~(sigmask(SIGKILL) | sigmask(SIGSTOP));
old_set = current->blocked.sig[0];

if(how == SIG_BLOCK)
sigaddsetmask(&current->blocked, new_set);
else if(how == SIG_UNBLOCK)
sigdelsetmask(&current->blocked, new_set);
else if(how == SIG_SETMASK)
current->blocked.sig[0] = new_set;
else
return -EINVAL;

recalc_sigpending(current);
if(oset && copy_to_user(oset, &old_set, sizeof(*oset)))
return -EFAULT;
return 0;

挂起进程

sigsuspend()把进程置为TASK_ITERRUPTIBLE状态,这发生在把mask参数指向的位掩码数组所指定的标准信号阻塞后。只有当一个非忽略、非阻塞的信号发送到进程后,进程才被唤醒。sys_sigsuspend()服务例程:

1
2
3
4
5
6
7
8
9
10
11
12
mask&= ~(sigmask(SIGKILL) | sigmask(SIGSTOP));
saveset = current->blocked;
siginitset(&current->blocked, mask);
recalc_sigpending(current);
regs->eax = -EINTR;
while(1)
{
current->state = TASK_INTERRUPTIBLE;
schedule();
if(do_signal(regs, &saveset))
return -EINTR;
}

schedule()选择另一个进程运行,当发出sigsuspend()的进程又开始执行时,sys_sigsuspend()调用do_signal()传递唤醒了该进程的信号,返回值为 1 时,不忽略该信号,因此返回 -EINTR 出错码后终止。

实时信号的系统调用

实时信号的几个系统调用(rt_sigaction()rt_sigpending()rt_sigprocmask()rt_sigsuspend())与前面的描述类似。

rt_sigqueueinfo():发送一个实时信号以便把它加入到目标进程的共享信号队列中。一般通过标准库函数sigqueue()调用rt_sigqueueinfo()

rt_sigtimedwait():把阻塞的挂起信号从队列中删除而不传递它,并向调用者返回信号编号;如果没有阻塞的信号挂起,就把当前进程挂起一个固定的时间间隔。一般通过标准库函数sigwaitinfo()sigtimedwait()调用rt_sigtimedwait()

虚拟文件系统

在Linux下可以安装挂载很多格式的文件系统,之所以能实现就是通过虚拟文件系统这一中间系统层,虚拟文件系统所隐含的思想是把表示很多不同种类文件系统的共同信息放入内核;其中有一个字段或函数来支持Linux所支持的所有实际文件系统所提供的任何操作

虚拟文件系统的作用

虚拟文件系统(Virtual Filesystem)也可以称之为虚拟文件系统转换(Virtual Filesystem Switch,VFS),是一个内核软件层,用来处理与Unix标准文件系统相关的所有系统调用。

VFS支持的文件系统可以划分为三种主要类型:

  • 磁盘文件系统:这些文件系统管理在本地磁盘分区中可用的存储空间或者其他可以起到磁盘作用的设备(比如一个USB闪存)。
    • VFS支持的基于磁盘的某些著名文件系统还有:
      • Linux使用的文件系统,如广泛使用的第二扩展文件系统(Ext2),新近的第三扩展文件系统(Third Extended Filesystem, Ext3)及Reiser文件系统(ReiserFS)。
      • Unix家族的文件系统。
      • 微软公司的文件系统,如MS-DOS、VFAT及NTFS。
      • ISO9660 CD-ROM文件系统(以前的High Sierra文件系统)和通用磁盘格式(UDF)的DVD文件系统。
      • 其他有专利权的文件系统。HPFS,HFS,AFFS,ADFS
      • 起源于非Linux系统的其他日志文件系统,如IBM的JFS和SGI的XFS。
  • 网络文件系统:这些文件系统允许轻易地访问属于其他网络计算机的文件系统所包含的文件
    • 虚拟文件系统所支持的一些著名的网络文件系统有:NFS、Coda、AFS(Andrew文件系统)、CIFS以及NCP。
  • 特殊文件系统:这些文件系统不管理本地或者远程磁盘空间。/proc文件系统是特殊文件系统的一个典型范例。

Unix的目录建立了一颗根目录为“/”的树。根目录包含在根文件系统(root filesystem)中,在Linux中这个根文件系统通常就是Ext2或Ext3类型。其他所有的文件系统都可以被安装在根文件系统的子目录中。

通用文件模型

VFS引入一个通用文件模型,能够表示所有支持的文件系统。由下列对象类型组成:

  • 超级块对象(superblock object):存放已安装文件系统有关信息。对基于磁盘的文件系统,这类对象通常对应于存放在磁盘上的文件系统控制块(filesystem control block)。
  • 索引节点对象(inode object):存放关于具体文件的一般信息。对基于磁盘的文件系统,这类对象通常对应于存放在磁盘上的文件控制块(file control block)。每个索引节点对象都有一个索引节点号,这个节点号唯一地标识文件系统中的文件。
  • 文件对象(file object):存放打开文件与进程之间进行交互的有关信息。这类信息仅当进程访问文件期间存在于内核内存中。
  • 目录项对象(dentry object):存放目录项(也就是文件的特定名称)与对应文件进行链接的有关信息。每个磁盘文件系统都以自己特有的方式将该类信息存在磁盘上。

下图是一个简单的实例,说明进程怎样与文件交互。三个不同的进程打开同一个文件,其中两个进程使用同一个硬链接。每个进程使用自己的文件对象,但只需要两个目录项对象,每个硬链接对应一个目录项对象。

VFS除了能为所有文件系统的实现提供一个通用接口外,还具有另一个与系统性能相关的重要作用。最近最常用的目录项被放在目录项高速缓存(dentry cache)的磁盘高速缓存中,以加速从文件路径名到最后一个路径分量的索引节点的转换过程。磁盘高速缓存属于软件机制,它允许内核将原本存在磁盘上的某些信息保存在RAM中,以便对这些数据的进一步访问能快速进行,而不必慢慢访问磁盘本身

注意,磁盘高速缓存不同于硬件高速缓存或内存高速缓存,后两者都与磁盘或其他设备无关。硬件高速缓存是一个快速静态RAM,它加快了直接对慢速动态RAM的请求。内存高速缓存是一种软件机制,引入它是为了绕过内核内存分配器。

除了目录项高速缓存和索引节点高速缓存之外,Linux还使用其他磁盘高速缓存。其中最重要的一种就是所谓的页高速缓存。

VFS所处理的系统调用


可以把VFS看成通用文件系统,它在必要时依赖某种具体文件系统。

VFS的数据结构

每个VFS对象都存放在一个适当的数据结构中,其中包括对象的属性指向对象方法表的指针。内核可以动态地修改对象的方法,因此可以为对象建立专用的行为。

超级块对象

超级块对象由super_block结构组成:

所有超级块对象都以双向循环链表的形式链接在一起。链表中第一个元素用super_blocks变量来表示,而超级块对象的s_list字段存放指向链表相邻元素的指针。sb_lock自旋锁保护链表免受多处理器系统上的同时访问。s_fs_info指向属于具体文件系统的超级块信息。为了效率,由s_fs_info字段所指向的数据被复制到内存,任何基于磁盘的文件系统都需要访问更改自己的磁盘分配位图,以便分配释放磁盘块,VFS支持文件系统对内存超级块的s_fs_info进行操作,无需访问磁盘。这需要引入一个s_dirt表示该超级块是否是脏的,磁盘上的数据是否需要更新。

与超级块关联的方法就是所谓的超级块操作。这些操作是由数据结构super_operations来描述的,该结构的起始地址存放在超级块的s_op字段中。

每个具体的文件系统都自定义超级块操作。当VFS需要调用其中一个操作,比如read_inode(),执行下列操作:

1
sb->s_op->read_inode(inode);

超级块操作实现了一些高级操作:

  • struct inode *(*alloc_inode)(struct super_block *sb);:为索引节点对象分配空间,包括具体文件系统的数据所需要的空间。
  • void (*destroy_inode)(struct inode *);:撤销索引节点对象,包括具体文件系统的数据。
  • void (*read_inode) (struct inode *);:用磁盘上的数据填充以参数传递过来的索引节点对象的字段,索引节点对象的i_ino字段标识从磁盘上要读取的具体文件系统的索引节点。
  • void (*dirty_inode) (struct inode *);:当索引节点标记为修改(脏)时调用。
  • int (*write_inode) (struct inode *, int);:用通过传递参数指定的索引节点对象的内容更新一个文件系统的索引节点。索引节点对象的i_ino字段标识所涉及磁盘上文件系统的索引节点。flag参数表示I/O操作是否应当同步。
  • void (*put_inode) (struct inode *);:释放索引节点时调用(减少该节点引用计数器值)以执行具体文件系统操作。
  • void (*drop_inode) (struct inode *);:在即将撤消索引节点时调用——也就是说, 当最后一个用户释放该索引节点时;实现该方法的文件系统通常使用generic_drop_inode()函数。该函数从VFS数据结构中移走对索引节点的每一个引用, 如果索引节点不再出现在任何目录中,则调用超级块方法delete_inode将它从文件系统中删除。
  • void (*delete_inode) (struct inode *);:在必须撤消索引节点时调用。删除内存中的VFS索引节点和磁盘上的文件数据及元数据。
  • void (*put_super) (struct super_block *);:释放通过传递的参数指定的超级块对象(因为相应的文件系统被卸载)。
  • void (*write_super) (struct super_block *);:用指定对象的内容更新文件系统的超级块。
  • int (*sync_fs)(struct super_block *sb, int wait);:在清除文件系统来更新磁盘上的具体文件系统数据结构时调用(由日志文件系统使用)。
  • void (*write_super_lockfs) (struct super_block *);:阻塞对文件系统的修改并用指定对象的内容更新超级块。当文件系统被冻结时调用该方法,例如,由逻辑卷管理器驱动程序(LVM)调用。
  • void (*unlockfs) (struct super_block *);:取消由write_super_lockfs()超级块方法实现的对文件系统更新的阻塞。
  • int (*statfs) (struct dentry *, struct kstatfs *);:将文件系统的统计信息返回,填写在buf缓冲区中。
  • int (*remount_fs) (struct super_block *, int *, char *);:用新的选项重新安装文件系统(当某个安装选项必须被修改时被调用)。
  • void (*clear_inode) (struct inode *);:当撤消磁盘索引节点执行具体文件系统操作时调用。
  • void (*umount_begin) (struct vfsmount *, int);:中断一个安装操作,因为相应的卸载操作已经开始(只在网络文件系统中使用)。
  • int (*show_options)(struct seq_file *, struct vfsmount *);:用来显示特定文件系统的选项。
  • int (*show_stats)(struct seq_file *, struct vfsmount *);:用来显示特定文件系统的状态。
  • ssize_t (*quota_read)(struct super_block *, int, char *, size_t, loff_t);:限额系统使用该方法从文件中读取数据,该文件详细说明了所在文件系统的限制。
  • ssize_t (*quota_write)(struct super_block *, int, const char *, size_t, loff_t);:限额系统使用该方法将数据写入文件中,该文件详细说明了所在文件系统的限制。

索引节点对象

文件系统处理文件所需要的所有信息都放在一个名为索引节点的数据结构中。文件名可以随时更改,但是索引节点对文件是唯一的,并且随文件的存在而存在。内存中的索引节点对象由一个inode数据结构组成,字段如图。

每个索引节点对象都会复制磁盘索引节点包含的一些数据,比如分配给文件的磁盘块数。如果i_state字段的值等于I_DIRTY_SYNCI_DIRTY_DATASYNCI_DIRTY_PAGES,那么该索引节点就是“脏”的,也就是说,对应的磁盘索引节点必须被更新。I_DIRTY宏可以用来立即检查这三个标志的值。i_state字段的其他值有I_LOCK(涉及的索引节点对象处于I/O传送中)、I_FREEING(索引节点对象正在被释放)、I_CLEAR(索引节点对象的内容不再有意义)以及I_NEW(索引节点对象已经分配但还没有用从磁盘索引节点读取来的数据填充)。

每个索引节点对象总是出现在下列双向循环链表的某个链表中(所有情况下,指向相邻元素的指针存放在i_list字段中):

  • 有效未使用的索引节点链表,典型的如那些镜像有效的磁盘索引节点,且当前未被任何进程使用。这些索引节点不为脏,且它们的i_count字段置为0。链表中的首元素和尾元素是由变量inode_unusednext字段和prev字段分别指向的。这个链表用作磁盘高速缓存。
  • 正在使用的索引节点链表,也就是那些镜像有效的磁盘索引节点,且当前被某些进程使用。这些索引节点不为脏,但它们的i_count字段为正数。链表中的首元素和尾元素是由变量inode_in_use引用的。
  • 脏索引节点的链表。链表中的首元素和尾元素是由相应超级块对象的s_dirty字段引用的。

此外,每个索引节点对象也包含在每文件系统(per filesystem)的双向循环链表中,链表的头存放在超级块对象的s_inodes字段中;索引节点对象的i_sb_list字段存放了指向链表相邻元素的指针。

最后,索引节点对象也存放在一个称为inode_hashtable的散列表中。散列表加快了对索引节点对象的搜索,前提是系统内核要知道索引节点号及文件所在文件系统对应的超级块对象的地址。由于散列技术可能引发冲突,所以索引节点对象包含一个i_hash字段,该字段中包含向前和向后的两个指针,分别指向散列到同一地址的前一个索引节点和后一个索引节点;该字段因此创建了由这些索引节点组成的一个双向链表

与索引节点对象关联的方法也叫索引节点操作。它们由inode_operations结构来描述,该结构的地址存放在i_op字段中:

  • int (*create) (struct inode *,struct dentry *,int, struct nameidata *);:在某一目录下,为与目录项对象相关的普通文件创建一个新的磁盘索引节点。
  • struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);:为包含在一个目录项对象中的文件名对应的索引节点查找目录。
  • int (*link) (struct dentry *,struct inode *,struct dentry *new_dentry);:创建一个新的名为new_dentry的硬链接,它指向dir目录下名为old_dentry的文件。
  • int (*unlink) (struct inode *,struct dentry *);:从一个目录中删除目录项对象所指定文件的硬链接。
  • int (*symlink) (struct inode *,struct dentry *,const char *);:在某个目录下,为与目录项对象相关的符号链接创建一个新的索引节点。
  • int (*mkdir) (struct inode *,struct dentry *,int);:在某个目录下,为与目录项对象相关的目录创建一个新的索引节点。
  • int (*rmdir) (struct inode *,struct dentry *);:从一个目录删除子目录,子目录的名称包含在目录项对象中。
  • int (*mknod) (struct inode *,struct dentry *,int,dev_t);:在某个目录中,为与目录项对象相关的特定文件创建一个新的磁盘索引节点。其中参数mode和rdev分别表示文件的类型和设备的主次设备号。
  • int (*rename) (struct inode *old_dir, struct dentry *old_entry, struct inode *new_dir, struct dentry *new_dentry);:将old_dir目录下由old_entry标识的文件移到new_dir目录下。新文件名包含在new_dentry指向的目录项对象中。
  • int (*readlink) (struct dentry, char *buffer, int len);:将目录项所指定的符号链接中对应的文件路径名拷贝到buffer所指定的用户态内存区。
  • void * (*follow_link) (struct inode *, struct nameidata *);:解析索引节点对象所指定的符号链接;如果该符号链接是一个相对路径名,则从第二个参数所指定的目录开始进行查找。
  • void (*put_link) (struct dentry *, struct nameidata *);:释放由follow_link方法分配的用于解析符号链接的所有临时数据结构。
  • void (*truncate) (struct inode *);:修改与索引节点相关的文件长度。在调用该方法之前,必须将inode对象的i_size字段设置为需要的新长度值。
  • int (*permission) (struct inode *, int, struct nameidata *);:检查是否允许对与索引节点所指的文件进行指定模式的访问。
  • int (*setattr) (struct dentry *, struct iattr *);:在触及索引节点属性后通知一个“修改事件”。
  • int (*getattr) (struct vfsmount *mnt, struct dentry *, struct kstat *);:由一些文件系统用于读取索引节点属性。
  • int (*setxattr) (struct dentry *, const char *,const void *,size_t,int);:为索引节点设置“扩展属性”(扩展属性存放在任何索引节点之外的磁盘块中)。
  • ssize_t (*getxattr) (struct dentry *, const char *, void *, size_t);:获取索引节点的扩展属性。
  • ssize_t (*listxattr) (struct dentry *, char *, size_t);:获取扩展属性名称的整个链表。
  • int (*removexattr) (struct dentry *, const char *);:删除索引节点的扩展属性。

文件对象

文件对象描述进程怎样与一个打开的文件进行交互。交互对象是在文件被打开时创建的,由一个file结构组成。注意,文件对象在磁盘上没有对应的映像,因此file结构中没有设置“脏”字段来表示文件对象是否已被修改。

存放在文件对象中的主要信息是文件指针,即文件中当前的位置,下一个操作将在该位置发生。由于几个进程可能同时访问同一文件,因此文件指针必须存放在文件对象而不是索引节点对象中

文件对象通过一个名为filp的slab高速缓存分配,filp描述符地址存放在filp_cachep变量中。由于分配的文件对象数目是有限的,因此files_stat变量在其max_files字段中指定了可分配文件对象的最大数目,也就是系统可同时访问的最大文件数。

在使用文件对象包含在由具体文件系统的超级块所确立的几个链表中。每个超级块对象把文件对象链表的头存放在s_files字段中;因此,属于不同文件系统的文件对象就包含在不同的链表中。链表中分别指向前一个元素和后一个元素的指针都存放在文件对象的f_list字段中。files_lock自旋锁保护超级块的s_files链表免受多处理器系统上的同时访问。

文件对象的f_count字段是一个引用计数器:它记录使用文件对象的进程数(记住,以CLONE_FILES标志创建的轻量级进程共享打开文件表,因此它们可以使用相同的文件对象)。当内核本身使用该文件对象时也要增加计数器的值—例如,把对象插入链表中或发出dup()系统调用时。

当VFS代表进程必须打开一个文件时,它调用get_empty_filp()函数来分配一个新的文件对象。该函数调用kmem_cache_alloc()从filp高速缓存中获得一个空闲的文件对像,然后初始化这个对象的字段,如下所示:

1
2
3
4
5
6
7
8
9
f = kmem_cache_alloc(filp_cachep, GFP_KERNEL);
percpu_counter_inc(&nr_files);
memset(f, 0, sizeof(*f));
tsk = current;
INIT_LIST_HEAD(&f->f_u.fu_list);
atomic_set(&f->f_count, 1);
rwlock_init(&f->f_owner.lock);
f->f_uid = tsk->fsuid;
f->f_gid = tsk->fsgid;

每个文件系统都有其自己的文件操作集合,执行诸如读写文件这样的操作。当内核将一个索引节点从磁盘装入内存时,就会把指向这些文件操作的指针存放在file_operations结构中,而该结构的地址存放在该索引节点对象的i_fop字段中。当进程打开这个文件时,VFS就用存放在索引节点中的这个地址初始化新文件对象的f_op字段,使得对文件操作的后续调用能够使用这些函数。如果需要,VFS随后也可以通过在f_op字段存放一个新值而修改文件操作的集合。

下面的列表描述了文件的操作:

  • struct module *owner;:指向一个模块的拥有者,该字段主要应用于那些有模块产生的文件系统
  • loff_t (*llseek) (file, offset, origin);:更新文件指针。
  • ssize_t (*read) (file, buf, count, offset);:从文件的*offset处开始读出count个字节;然后增加*offset的值(一般与文件指针对应)。
  • ssize_t (*aio_read) (req, buf, len, pos);:启动一个异步I/O操作,从文件的pos处开始读出len个字节的数据并将它们放入buf中(引入它是为了支持io_submit()系统调用)。
  • ssize_t (*write) (file, buf, count, offset);:从文件的*offset处开始写入count个字节,然后增加*offset的值(一般与文件指针对应)。
  • ssize_t (*aio_write) (req, buf, len, pos);:启动一个异步I/O操作,从buf中取len个字节写入文件pos处。
  • int (*readdir) (dir, dirent, filldir);:返回一个目录的下一个目录项,返回值存人参数dirent;参数filldir存放一个辅助函数的地址,该函数可以提取目录项的各个字段。
  • unsigned int (*poll) (file, poll_table);:检查是否在一个文件上有操作发生,如果没有则睡眠,直到该文件上有操作发生。

  • int (*ioctl) (inode, file, cmd, args);:向一个基本硬件设备发送命令。该方法只适用于设备文件。

  • long (*unlocked_ioctl) (file, cmd, args);:与ioctl方法类似,但是它不用获得大内核锁。我们认为所有的设备驱动程序和文件系统都将使用这个新方法而不是loctl方法。
  • long (*compat_ioctl) (file, cmd, args);:64位的内核使用该方法执行32位的系统调用ioctl()
  • int (*mmap) (file, vm_area_struct);:执行文件的内存映射,并将映射放入进程的地址空间。
  • int (*open) (inode, file);:通过创建一个新的文件对象而打开一个文件,并把它链接到相应的索引节点对象。
  • int (*flush) (file);:当打开文件的引用被关闭时调用该方法。该方法的实际用途取决于文件系统。
  • int (*release) (inode, file);:释放文件对象。当打开文件的最后一个引用被关闭时(即文件对象f_count字段的值变为0时)调用该方法。
  • int (*fsync) (file, dentry, datasync);:将文件所缓存的全部数据写入磁盘。
  • int (*aio_fsync) (req, datasync);:启动一次异步I/O刷新操作。
  • int (*fasync) (fd, file, on);:通过信号来启用或禁止I/O事件通告。
  • int (*lock) (file, cmd, file_lock);:对file文件申请一个锁。
  • ssize_t (*readv) (file, vector, count, offset);:从文件中读字节,并把结果放入vector描述的缓冲区中;缓冲区的个数由count指定。
  • ssize_t (*writev) (file, vector, count, offset);:把vector描述的缓冲区中的字节写人文件;缓冲区的个数由count指定。
  • ssize_t (*sendfile) (in_file, count, offset, read_actor_t, out_file);:把数据从in_file传送到out_file(引人它是为了支持sendfile()系统调用)。
  • ssize_t (*sendpage) (file, page, offset, size, pointer, fill);:把数据从文件传送到页高速缓存的页;这个低层方法由sendfile()和用于套接字的网络代码使用。
  • unsigned long (*get_unmapped_area)(file, addr, len, offset, flag);:获得一个未用的地址范围来映射文件。
  • int (*check_flags)(flags);:当设置文件的状态标志(F_SETFL命令)时,fcntl()系统调用的服务例程调用该方法执行附加的检查。当前只适用于NFS网络文件系统。
  • int (*dir_notify)(file, arg);:当建立一个目录更改通告(F_NOTIFY命令)时,由fcntl()系统调用的服务例程调用该方法。当前只适用于CIFS(Common Internet File system,公用互联网文件系统)网络文件系统。
  • int (*flock) (file, falg, file_lock);:用于定制flock()系统调用的行为。官方Linux文件系统不使用该方法。*/

目录项对象

VFS把每个目录看作由若干子目录和文件组成的一个普通文件。然而目录项不同,一旦目录项被读入内存,VFS就把它转换成基于dentry结构的一个目录项对象。对于进程查找的路径名中的每个分量,内核都为其创建一个目录项对象;目录项对象将每个分量与其对应的索引节点相联系。

请注意,目录项对象在磁盘上并没有对应的映像,因此在dentry结构中不包含指出该对象已被修改的字段。目录项对象存放在名为dentry_cache的slab分配器高速缓存中。因此,目录项对象的创建和删除是通过调用kmem_cache_alloc()kmem_cache_free()实现的。

每个目录项对象可以处于以下四种状态之一:

  • 空闲状态(free):处于该状态的目录项对象不包括有效的信息,且还没有被VFS使用。对应的内存区由slab分配器进行处理。
  • 未使用状态(unused):处于该状态的目录项对象当前还没有被内核使用。该对象的引用计数器d_count的值为0,但其d_inode字段仍然指向关联的索引节点。该目录项对象包含有效的信息,但为了在必要时回收内存,它的内容可能被丢弃。
  • 正在使用状态(in use):处于该状态的目录项对象当前正在被内核使用。该对象的引用计数器d_count的值为正数,其d_inode字段指向关联的索引节点对象。该目录项对象包含有效的信息,并且不能被丢弃。
  • 负状态(negative):与目录项关联的索引节点不复存在,那是因为相应的磁盘索引节点已被删除,或者因为目录项对象是通过解析一个不存在文件的路径名创建的。目录项对象的d_inode字段被置为NULL,但该对象仍然被保存在目录项高速缓存中,以便后续对同一文件目录名的查找操作能够快速完成。术语“负状态”容易使人误解,因为根本不涉及任何负值。

与目录项对象关联的方法称为目录项操作。这些方法由dentry_operations结构加以描述,该结构的地址存放在目录项对象的d_op字段中。尽管一些文件系统定义了它们自己的目录项方法,但是这些字段通常为NULL,而VFS使用缺省函数代替这些方法:

  • int (*d_revalidate)(struct dentry *, struct nameidata *);:在把目录项对象转换为一个文件路径名之前,判定该目录项对象是否仍然有效。缺省的VFS函数什么也不做,而网络文件系统可以指定自己的函数。
  • int (*d_hash) (struct dentry *, struct qstr *);:生成一个散列值;这是用于目录项散列表的、特定干具体文件系统的散列函数。参数dentry标识包含路径分量的目录。参数name指向一个结构,该结构包含要查找的路径名分量以及由散列函数生成的散列值。
  • int (*d_compare) (struct dentry *, struct qstr *, struct qstr *);:比较两个文件名。name1应该属于dir所指的目录。缺省的VFS函数是常用的字符串匹配函数。不过,每个文件系统可用自己的方式实现这一方法。例如,MS.DOS文件系统不区分大写和小写字母。
  • int (*d_delete)(struct dentry *);:当对目录项对象的最后一个引用被删除(d_count变为“0”)时,调用该方法。缺省的VFS函数什么也不做。
  • void (*d_release)(struct dentry *);:当要释放一个目录项对象时(放入slab分配器),调用该方法。缺省的VFS函数什么也不做。
  • void (*d_iput)(struct dentry *, struct inode *);:当一个目录项对象变为“负”状态(即丢弃它的索引节点)时,调用该方法。缺省的VFS函数调用iput()释放索引节点对象。
    };

目录项高速缓存

为了最大限度地提高处理同一个文件需要被反复访问的这些目录项对象的效率,Linux使用目录项高速缓存,它由两种类型的数据结构组成:

  • 一个处于正在使用、未使用或负状态的目录项对象的集合
  • 一个散列表,从中能够快速获取与给定的文件名和目录名对应的目录项对象。同样,如果访问的对象不在目录项高速缓存中,则散列表函数返回一个空值。

目录项高速缓存的作用还相当于索引节点高速缓存(inode cache)的控制器。在内核内存中,并不丢弃与未用目录项相关的索引节点,这是由于目录项高速缓存仍在使用它们。因此,这些索引节点对象保存在RAM中,并能够借助相应的目录项快速引用它们。

所有“未使用”目录项对象都存放在一个最近最少使用(Least Recently used, LRU)的双向链表中,该链表按照插入的时间顺序。换句话说,最后释放的目录项对象放在链表的首部,所以最近最少使用的目录项对象总是靠近链表的尾部。一旦目录项高速缓存的空间开始变小,内核就从链表的尾部删除元素,使得最近最常使用的对象得以保留。

LRU链表的首元素和尾元素地址存放在list_head类型的dentry_unused变量的nextprev字段中。目录项对象的d_lru字段包含指向链表中相邻目录项的指针。

每个正在使用的目录项对象都被插入一个双向链表中,该链表由相应索引节点对象的i_dentry字段指向。目录项对象的d_alias字段存放链表中相邻元素的地址。指向相应文件的最后一个硬链接被删除之后,一个正在使用的目录项可能会变成负状态。这时该目录项对象被移动到未使用目录项对象组成的LRU链表中。这些对象就逐渐被释放。

与进程相关的文件

每个进程都有它自己当前的工作目录和它自己的根目录。这仅仅是内核用来表示进程与文件系统相互作用所必须维护的数据中的两个例子。类型为fs_struct的整个数据结构就用于此目的,且每个进程描述符的fs字段就指向进程的fs_struct结构。

第二个表表示进程当前打开的文件,表的地址存放于进程描述符的files字段,类型为files_struct

fd字段指向文件对象的指针数组。该数组的长度存放在max_fds字段中。通常,fd字段指向files_struct结构的fd_array字段,该字段包括32个文件对象指针。如果进程打开的文件数目多余32,内核就分配一个新的、更大的文件指针数组,并将其地址存放在fd字段中,内核同时也更新max_fds字段的值。

对于在fd数组中有元素的每个文件来说,数组的索引就是文件描述符(file descriptor)。通常,数组的第一个元素(索引为0)是进程的标准输入文件,数组的第二个元素(索引为1)是进程的标准输出文件,数组的第三个元素(索引为2)是进程的标准错误文件。两个文件描述符可以指向同一个文件。进程不能使用多于NR_OPEN个文件描述符。内核也在进程描述符的signal->rlim[RLIMIT_NOFILE]结构上强制动态限制文件描述符的最大数。

open_fds字段最初包含open_fds_init的地址,open_fds_init表示当前已打开文件的文件描述符的位图。max_fdset存放位图中的位数。当内核开始使用一个文件对象时,内核提供fget()函数。这个函数接收文件描述符fd作为参数,返回在current->files->fd[fd]中的地址,即对应文件对象的地址,如果没有任何文件与fd对应,则返回NULL。

当内核控制路径完成对文件对象的使用时,调用内核提供的fput()函数。该函数将文件对象的地址作为参数,并递减文件对象引用计数器f_count的值,另外,如果这个域变为0,该函数就调用文件操作的release方法(如果已定义),释放相应的目录项对象,并递减对应索引节点对象的i_write域的值(如果该文件是写打开),最后,将该文件对象从“正在使用”链表移到“未使用”链表。 

文件系统类型

文件系统注册——也就是通常在系统初始化期间并且在使用文件系统类型之前必须执行的基本操作。一旦文件系统被注册,其特定的函数对内核就是可用的,因此文件系统类型可以安装在系统的目录树上。

特殊文件系统

特殊文件系统为系统程序员和管理员提供一种容易的方式来操作内核的数据结构并实现系统的特殊特征。常用特殊文件系统如表,有几个文件系统没有固定的安装点,可以自由安装使用。一些文件系统不是用于与用户交互,所以没有安装点。

特殊文件系统不限于物理设备。然而,内核给每个安装的特殊文件系统分配一个虚拟的块设备,让其主设备号是0,而次设备号具有任意值(每个特定文件系统具有不同的值)。set_anon_super()函数用于初始化特殊文件系统的超级块;该函数本质上获得一个未使用的次设备号dev,然后用主设备号0和次设备号dev设置新超级块的s_dev字段。而另一个kill_anon_super()函数移走特殊文件系统的超级块unnamed_dev_ida变量包含一个辅助结构(记录当前在用的次设备号)的指针。

文件系统类型注册

文件系统的源代码实际上要么包含在内核映像中,要么作为一个模块被动态装入。VFS必须对代码目前已经在内核中的所有文件系统的类型进行跟踪,这是通过进行文件系统类型注册实现的。注册的文件系统用file_system_type类型表示。

所有文件系统类型的对象都插入到一个单向链表中。由变量file_systems指向链表的第一个元素,而结构中的next字段指向链表的下一个元素。file_systems_lock读/写自旋锁保护整个链表免受同时访问。fs_supers表示给定类型的已安装文件系统所对应的超级块链表的头。链表元素的向后和向前链接存放在超级块对象的s_instances字段中。get_sb指向依赖于文件系统类型的函数,该函数分配一个新的超级块对象并初始化。kill_sb指向删除超级块的函数。fs_flags存放几个标志:

在系统初始化期间,调用register_filesystem()函数来注册编译时指定的每个文件系统;该函数把相应的file_system_type对象插入到文件系统类型的链表中。当实现了文件系统的模块被装入时,也要调用register_filesystem()函数。在这种情况下,当该模块被卸载时,对应的文件系统也可以被注销(调用unregister_filesystem()函数)。

get_fs_type()函数扫描已注册的文件系统链表以查找文件系统类型的name字段,并返回指向相应的file_system_type对象的指针。

文件系统处理

就像每个传统的Unix系统一样,Linux也使用系统的根文件系统(system’s root filesystem):它由内核在引导阶段直接安装,并拥有系统初始化脚本以及最基本的系统程序

其他文件系统要么由初始化脚本安装,要么由用户直接安装在已安装文件系统的目录上。作为一个目录树,每个文件系统都拥有自己的根目录(root directory)。安装文件系统的这个目录称之为安装点(mount point)。已安装文件系统属于安装点目录的一个子文件系统。例如,/proc虚拟文件系统是系统的根文件系统的孩子(且系统的根文件系统是/proc的父亲)。已安装文件系统的根目录隐藏了父文件系统的安装点目录原来的内容,而且父文件系统的整个子树位于安装点之下。

命名空间

在传统的Unix系统中,只有一个已安装文件系统树:从系统的根文件系统开始,每个进程通过指定合适的路径名可以访问已安装文件系统中的任何文件。从这个方面考虑,Linux2.6更加的精确:每个进程可拥有自己的已安装文件系统树——叫做进程的命令空间(namespace)。

通常大多数进程共享同一个命名空间,即位于系统的根文件系统且被init进程使用的已安装文件系统树。不过,如果clone()系统调用以CLONE_NEWNS标志创建一个新进程,那么进程将获取一个新的命名空间。这个新的命名空间随后由子进程继承(如果父进程没有以CLONE_NEWNS标志创建这些子进程)。

当进程安装或卸载一个文件系统时,仅修改它的命名空间。因此,所做的修改对共享同一命名空间的所有进程都是可见的,并且也只对它们可见。进程甚至可通过使用Linux特有的pivot_root()系统调用来改变它的命名空间的根文件系统。

进程的命名空间由进程描述符的namespace字段指向的namespace结构描述。root表示已安装文件系统。

文件系统安装

当一个文件系统被安装了n次时,它的根目录可通过n个安装点访问。不管一个文件系统被安装了多少次,都仅有一个超级块对象。

安装的文件系统形成一个层次:一个文件系统的安装点可能成为第二个文件系统的目录,而第二个文件系统又安装在第三个文件系统之上。

把多个安装堆叠在一个单独的安装上也是可能的。尽管已经使用先前安装下的文件和目录的进程可以继续使用,但在同一安装点上的新安装隐藏前一个安装的文件系统。当最顶层的安装被删除时,下一层的安装再一次变为可见的。

对每个安装操作,内核必须在内存中保存安装点和安装标志,以及要安装文件系统与其他已安装文件系统之间的关系。每个描述符是一个具有vfsmount类型的数据结构。

vfsmount结构会被保存在几个双向循环链表中:

  • 由父文件系统vfsmount描述符的地址和挂载点目录的目录项对象的地址索引的散列表。散列表存放在mount_hashtable数组(fs/namespace.c, static struct list_head *mount_hashtable __read_mostly;)中,其大小取决于系统中RAM的容量。表中每一项是具有同一散列值的所有描述符形成的双向循环链表的头。描述符的mnt_hash字段包含指向链表中相邻元素的指针。
  • 对于每一个命名空间,所有属于此命名空间的已挂载文件系统描述符形成了一个双向链表。namespace结构的list字段存放链表的头,vfsmount描述符的mnt_list字段包含链表中指向相邻元素的指针。
  • 对于每一个已安装的文件系统,所有已安装的子文件系统形成了一个双向循环链表。链表的头存放在vfsmount描述符的mnt_mounts字段;子描述符的mnt_child字段存放指向链表中相邻元素的指针。

vfsmount_lock自旋锁(fs/namespace.c, __cacheline_aligned_in_smp DEFINE_SPINLOCK(vfsmount_lock);)保护vfsmount对象的链表免受同时访问。

描述符的mnt_flags字段存放几个标志的值,用以指定如何处理已安装文件系统中的某些种类的文件。这些标志可通过mount命令的选项进行设置。所有的标志定义如下:

  • MNT_NOSUID:在已安装的文件系统中禁止setuidsetgid标志
  • MNT_NODEV:在已安装的文件系统中禁止访问设备文件
  • MNT_NOEXEC:在已安装文件系统中禁止程序执行

内核提供了几个用以处理vfsmount描述符的函数:

  • struct vfsmount *alloc_vfsmnt(const char *name):分配和初始化一个vfsmount描述符
  • void free_vfsmnt(struct vfsmount *mnt):释放由mnt指向的vfsmount描述符
  • struct vfsmount *lookup_mnt(struct path *path):在散列表中查找一个vfsmount并返回它的地址

安装普通文件系统

mount()系统调用被用来安装一个普通文件系统;它的服务例程sys_mount()作用于以下参数:

  • 文件系统所在的设备文件的路径名,或者如果不需要的话就为NULL。
  • 文件系统被安装其上的某个目录的目录路径名(安装点)。
  • 文件系统的类型,必须是已注册文件系统的名字。
  • 安装标志(见表格)。
  • 指向一个与文件系统相关的数据结构的指针(也许为NULL)。

sys_mount()函数把参数拷贝到临时内核缓冲区,获取大内核锁,并调用do_mount()函数。一旦do_mount()返回,释放大内核锁并释放临时缓冲区。

do_mount()步骤:

  1. 检查安装标志MS_NOSUIDMS_NODEVMS_NOEXEC,并在vfsmount对象中设置相应标志
  2. 调用path_lookup()查找安装点路径名
  3. 检查安装标志:
    1. 如果MS_REMOUNT,其目的是改变超级块对象s_flags的安装标志,以及已安装文件系统对象mnt_flags的安装文件系统标志。do_remount()执行这些改变;
    2. 否则检查MS_BIND,用户要求在系统目录树的另一个安装点上的文件或目录能够可见
    3. 否则检查MS_MOVE,用户要求改变安装点,do_move_mount()
    4. 否则,do_new_mount(),当用户安装一个特殊文件系统或存放在磁盘分区中的普通文件系统时触发该函数。它调用do_kern_mount()。它出击时机的安装操作并返回一个新安装文件系统描述符的地址。然后do_new_mount()调用do_add_mount(),后者执行:
      1. 获得当前进程的写信号量namespace->sem
      2. 验证在安装点上最近安装的文件系统是否仍指向当前的namespace,不是释放sem,返回错误码
      3. 如果要安装的文件系统已经被安装在由系统调用的参数所指定的安装点上,或该安装点是一个符号链接,则释放sem,返回错误码
      4. 初始化do_kernel_mount()分配的新的安装文件系统对象的mnt_flags字段的标志
      5. 调用graft_tree()把新安装的文件系统对象插入到namespace链表、散列表以及父文件系统的子链表中
      6. 释放sem,返回。
  4. 调用path_release()终止安装点路径名查找,并返回0.

do_kern_mount()函数:

安装操作的核心是do_kern_mount(),他检查文件系统类型以决定安装操作是如何完成的。参数:

  1. fstype:要安装文件系统类型名
  2. flags:安装标志
  3. name:存放文件系统的块设备路径名
  4. data:指向传递给文件系统read_super方法的附加数据指针

该函数通过下列操作实现安装:

  1. 调用get_fs_type()在文件系统列表中搜索并确定放在fstype参数中的名字的位置;返回局部变量type中对应的file_system_type描述符的位置
  2. 调用alloc_vfsmnt()分配vfsmount描述符,并给mnt局部变量赋值
  3. 调用type->get_sb()分配并初始化一个新的超级块
  4. 用新的超级块给mnt->mnt-sb赋值
  5. mnt->mnt_root字段初始化为与文件系统根目录对应的目录项对象的地址,存在于super_block中的s_root字段中,并增加该目录项对象引用计数器值。
  6. mnt中的值初始化mnt->mnt_parent字段,
  7. current->namespace初始化mnt->mnt_namespace
  8. 释放超级块的读写信号量s_unmont
  9. 返回已安装文件系统对象的地址mnt

分配超级块对象

文件系统对象的get_sb方法由单行函数实现的。

get_sb_bdev()VFS函数分配并初始化一个新的适合于磁盘文件系统的超级块;get_sb_pseudo()针对没有安装点的特殊文件系统如pipefs;get_sb_single()针对具有唯一安装点的特殊文件系统如sysfs;get_sb_nodev()针对可以安装多次的特殊文件系统如tmpfs

get_sb_bdev()步骤:

  1. 调用open_bdev_excl()打开设备文件名为dev_name的块设备
  2. 调用sget()搜索文件系统的超级块链表(type->fs_supers)。如果找到一个与块设备相关的超级块,则返回它的地址;否则分配并初始化一个新的超级块,将其插入到文件系统链表和超级块全局链表中,并返回其地址
  3. 如果不是新的超级块,则跳6
  4. 把参数flags中的值拷贝到超级块的s_flags中,并将s_ids_old_blocksize以及s_blocksize字段置为块设备的合适值
  5. 调用以来文件系统的函数访问磁盘上超级块的信息,并填充新超级块对象的其他字段
  6. 返回新超级块对象的地址

安装根文件系统

安装根文件系统是系统初始化的关键部分。当编译内核时,或者向最初的启动装入程序传递一个合适的“root”选项时,根文件系统可以被指定为/dev目录下的一个设备文件。类似地,根文件系统的安装标志存放在root_mountflags变量中。用户可以指定这些标志,或者通过对已编译的内核映像使用rdev外部程序,或者向最初的启动装入程序传递一个合适的rootflags选项来达到。

安装根文件系统分为两个阶段:

  • 内核安装特殊rootfs文件系统,该文件系统仅提供一个作为初始安装点的空目录。
  • 内核在空目录上安装实际的根文件系统。

rootfs文件系统允许内核容易地改变实际根文件系统。实际上,在大多数情况下,系统初始化是内核会逐个地安装和卸载几个根文件系统。

阶段1:安装rootfs文件系统

第一阶段由init_rootfs()init_mount_tree()函数完成,他们在系统初始化过程中执行。

init_rootfs()函数注册特殊文件系统类型rootfs:

1
2
3
4
5
struct file_system_type rootfs_fs_type = {
.name = "rootfs";
.get_sb = rootfs_get_sb;
.kill_sb = kill_litter_super;
};

init_mount_tree()执行如下操作:

  • 调用do_kern_mount()函数,把字符串”rootfs”作为文件系统类型和设备名参数传递给它,把该函数返回的新安装文件系统描述符的地址保存在mnt局部变量中。如前面所说,do_kern_mount()调用get_sb,也就是get_fs_type()
    • get_sb_nodev()则执行操作:
    • 调用sget()函数分配新的超级块,传递set_anon_super()函数的地址作为参数,用合适的方式设置超级块的s_dev字段,主设备号为0,次设备号不同于其他已安装的特殊文件系统的次设备号。
    • flags参数的值拷贝到超级块的s_flags字段中
    • 调用ramfs_fill_super()函数分配索引节点对象和对应的目录项对象,并填充超级块字段值。
    • 返回新超级块的地址
  • 为进程0的已挂载文件系统命名空间分配一个namespace对象,并将它插入到由do_kern_mount()函数返回的已安装文件系统描述符中
  • 将系统中其他每个进程的namespace字段设置为namespace对象的地址;同时初始化引用计数器namespace->count
  • 将进程0的根目录和当前工作目录设置为根文件系统。

安装实际根文件系统

根文件系统安装操作的第二阶段是由内核在系统初始化即将结束时进行的。根据内核被编译时所选择的选项,和内核装入程序所传递的启动选项,可以有几种方法安装实际根文件系统。为了简单起见,我们只考虑磁盘文件系统的情况,它的设备文件名已通过“root”启动参数传递给内核。同时我们假定除了 rootfs文件系统外,没有使用其他初始特殊文件系统。

prepare_namespace()函数执行如下操作:

  1. root_device_name变量置为从启动参数“root”中获取的设备文件名。同样把ROOT_DEV变量置为同一设备文件的主设备号和次设备号
  2. 调用mount_root()函数,依次执行如下操作:
    1. 调用sys_mknod()在rootfs初始化根文件系统中创建设备文件/dev/root,其主次设备号与存放在ROOT_DEV中的一样。
    2. 分配一个缓冲区并用文件系统类型名链表填充它。该链表要么通过启动参数“rootfstype”传送给内核,要么通过扫描文件系统类型单向链表中的元素建立
    3. 扫描上一步建立的文件系统类型名链表,对每个名字,调用sys_mount()试图在根设备上安装给定的文件系统类型。
    4. 调用sys_chdir("/root")改变进程的当前目录
  3. 移动rootfs文件系统根目录上的已安装文件系统的安装点sys_mount(".", "/", NULL, MS_MOVE, NULL)

卸载文件系统

umount()系统调用用来卸载一个文件系统。相应的sys_umount()服务例程作用于两个参数:文件名(多是安装点目录或是块设备文件名)和一组标志。

  1. 调用path_lookup()查找安装点的路径名,将结果存放在nameidata类型的局部变量nd中;
  2. 如果查找的最终目录不是文件系统的安装点,则设置retval返回码为-EINVAL并跳到第6步。这种检查是通过验证nd->mnt->mnt_root(它包含由nd.dentry指向的目录项对象地址)进行的。
  3. 如果要卸载的文件系统还没有安装在命名空间中,则设置retval返回码为-EINVAL并跳到第6步。这种检查是通过在nd->mnt上调用check_mnt()函数进行的。
  4. 如果用户不具有卸载文件系统的特权,则设置retval返回码为-EPERM并跳到第6步。
  5. 调用do_umount(),传递给它的参数为nd.mnt(已安装文件系统对象)和flags(一组标志)。该函数执行下列操作:
    1. 从已安装文件系统对象的mnt_sb字段检索超级块对象sb的地址。
    2. 如果用户要求强制卸载操作,则调用umount_begin超级块操作中断任何正在进行的安装操作。
    3. 如果要卸载的文件系统是根文件系统,且用户并不要求真正地把它卸载下来则调用do_remount_sb()重新安装根文件系统为只读并终止。
    4. 为进行写操作而获取当前进程的namespace->sem读/写信号量和vfsmount_lock自旋锁。
    5. 如果已安装文件系统不包含任何子安装文件系统的安装点,或者用户要求强制卸载文件系统,则调用umount_tree()卸载文件系统(及其所有子文件系统)
    6. 释放vfsmount_lock自旋锁和当前进程的namespace->sem读/写信号量。
  6. 减少相应文件系统根目录的目录项对象和已安装文件系统描述符的引用计数器值;这些计数器值由path_lookup()增加。
  7. 返回retval的值。

路径名查找

当进程必须识别一个文件时,就把它的文件路径名传递给某个VFS系统调用,如open()mkdir()rename()stat()
执行这一任务的标准过程就是分析路径名并把它拆分成一个文件名序列除了最后一个文件名以外,所有的文件名都必定是目录。

如果路径名的第一个字符是“/”,那么这个路径名是绝对路径,因此从current->fs->root(进程的根目录)所标识的目录开始搜索。否则,路径名是相对路径,因此从current->fs->pwd(进程的当前目录)所标识的目录开始搜索。

在对初始目录的索引节点进行处理的过程中,代码要检查与第一个名字匹配的目录项,以获得相应的索引节点。然后,从磁盘读出包含那个索引节点的目录文件,并检查与第二个名字匹配的目录项,以获得相应的索引节点。对于包含在路径中的每个名字,这个过程反复执行。

目录项高速缓存极大地加速了这一过程,因为它把最近最常使用的目录项对象保留在内存中。正如我们以前看到的,每个这样的对象使特定目录中的一个文件名与它相应的索引节点相联系,因此在很多情况下,路径名的分析可以避免从磁盘读取中间目录

但是,事情并不像看起来那么简单,因为必须考虑如下的Unix和VFS文件系统的特点:

  • 对每个目录的访问权必须进行检查,以验证是否允许进程读取这一目录的内容。
  • 文件名可能是与任意一个路径名对应的符号链接;在这种情况下,分析必须扩展到那个路径名的所有分量。
  • 符号链接可能导致循环引用;内核必须考虑这个可能性,并能在出现这种情况时将循环终止。
  • 文件名可能是一个已安装文件系统的安装点。这种情况必须检测到,这样,查找操作必须延伸到新的文件系统。
  • 路径名查找应该在发出系统调用的进程的命名空间中完成。由具有不同命名空间的两个进程使用的相同路径名,可能指定了不同的文件。

路径名查找是由path_lookup()函数执行的,它接收三个参数:

  • name:指向要解析的文件路径名的指针。
  • flags:标志的值,表示将会怎样访问查找的文件。
  • ndnameidata数据结构的地址,这个结构存放了查找操作的结果。

path_lookup返回时,nd指向的nameidata结构用与路径名查找操作有关的数据来填充/

dentrymnt字段分别指向所解析的最后一个路径分量的目录项对象和已安装文件系统对象。这两个字段“描述”由给定路径名表示的文件。

由于path_lookup()函数返回的nameidata结构中的目录项对象和已安装文件系统对象代表了查找操作的结果,因此在path_lookup()的调用者完成使用查找结果之前,这个两个对象都不能被释放。因此,path_lookup()增加这两个对象引用计数器的值。如果调用者想释放这些对象,则调用path_release()函数,传递给它的参数就是nameidata结构的地址。

flags字段存放查找操作中使用的某些标志的值,这些标志中的大部分可由调用者在path_lookup()flags参数中进行设置:

path_lookup()执行下列步骤:

  1. 首先,如下初始化nd参数的某些字段:
    1. nd->last_type = LAST_ROOT;
    2. nd->flags = flags;
    3. nd->depth = 0;
  2. 为进行读操作而获取当前进程的current->fs->lock读写信号量
  3. 如果路径名的第一个字符是“/”,那么查找操作必须从当前根目录开始:获取相应已安装文件对象(current->fs->rootmnt)和目录项对象(current->fs->root)的地址,增加引用计数器的值,并把它们的地址分别存放在nd->mntnd->dentry中。
  4. 否则,如果路径名的第一个字符不是“/”,则查找操作必须从当前工作目录开始:获得相应已安装文件系统对象(current->fs->mt)和目录项对象(current->fs->pwd)的地址,增加引用计数器的值,并把它们的地址分别存放在nd->mntnd->dentry中。
  5. 释放当前进程的current->fs->lock读写信号量
  6. 把当前进程描述符中的total_link_count字段置为0
  7. 调用link_path_walk()函数处理真正进行的查找操作:retval = link_path_walk(name, nd);

我们现在准备描述路径名查找操作的核心,也就是link_path_walk()函数。它接收的参数为要解析的路径名指针name和拥有目录项信息和安装文件系统信息的nameidata数据结构的地址nd

标准路径名查找

LOOKUP_PARENT标志被清零时,link_path_walk()执行下列步骤:

  1. nd->flagsnd->dentry->d_inode分别初始化lookup_flagsinode局部变量。
  2. 跳过路径名第一个分量前的任何斜杠(/)。
  3. 如果剩余的路径名为空,则返回0。在nameidata数据结构中,dentrymnt字段指向原路径名最后一个所解析分量对应的对象。
  4. 如果nd描述符中的depth字段的值为正(大于0),则把lookup_flags局部变量置为LOOKUP_FOLLOW标志(这个跟符号链接查找相关)。
  5. 执行一个循环,把name参数中传递的路径名分解为分量(中间的“/”被当做文件名分隔符对待);对于每个找到的分量,该函数:
    1. nd->dentry->d_inode检索最近一个所解析分量的索引节点对象的地址
    2. 检查存放到索引节点中的最近那个所解析分量的许可权是否允许执行(在Unix中,只有目录是可执行的,它才可以被遍历)。执行exec_permission_lite()函数,该函数检查存放在索引节点i_mode字段的访问模式和运行进程的特权。在两种情况中,如果最近所解析分量不允许执行,那么link_path_walk()跳出循环并返回一个错误码:
    3. 考虑要解析的下一个分量。从它的名字,函数为目录项高速缓存散列表计算一个32位的散列值
    4. 如果“/”终止了要解析的分量名,则跳过“/”之后的任何尾部“/”
    5. 如果要解析的分量是原路径名中的最后一个分量,则跳到第6步
    6. 如果分量名是一个“.”(单个圆点),则继续下一个分量(“.”指的是当前目录,因此,这个点在目录内没有什么效果)
    7. 如果分量名是“..”(两个圆点),则尝试回到父目录:
      1. 如果最近解析的目录是进程的根目录(nd->dentry等于curren->fs->root,而nd->mnt等于current->fs->rootmnt),那么再向上追踪是不允许的:在最近解析的分量上调用follow_mount()(见下面),继续下一个分量。
      2. 如果最近解析的目录是nd->mnt文件系统的根目录(nd->dentry等于nd->mnt->mnt_root),并且这个文件系统也没有被安装在其他文件系统之上(nd->mnt等于nd->mnt->mnt_parent),那么nd->mnt文件系统通常就是命名空间的根文件系统:在这种情况下,再向上追踪是不可能的,因此在最近解析的分量上调用follow_mount()(参见下面),继续下一个分量。
      3. 如果最近解析的目录是nd->mnt文件系统的根目录,而这个文件系统被安装在其他文件系统之上,那么就需要文件系统交换。因此,把nd->dentry置为nd->mnt->mnt_mountpoint,且把nd->mnt置为nd->mnt->mnt_parent,然后重新开始第5g步(回想一下,几个文件系统可以安装在同一个安装点上)。
      4. 如果最近解析的目录不是已安装文件系统的根目录,那么必须回到父目录:把nd->dentry置为nd->dentry->d_parent,在父目录上调用follow_mount(),继续下一个分量。
      5. follow_mount()函数检查nd->dentry是否是某文件系统的安装点(nd->dentry->d_mounted的值大于0);如果是,则调用lookup_mnt()搜索目录项高速缓存中已安装文件系统的根目录,并把nd->dentrynd->mnt更新为相应已安装文件系统的安装点和安装系统对象的地址;然后重复整个操作(几个文件系统可以安装在同一个安装点上)。从本质上说,由于进程可能从某个文件系统的目录开始路径名的查找,而该目录被另一个安装在其父目录上的文件系统所隐藏,那么当需要回到父目录时,则调用follow_mount()函数。
    8. 分量名既不是“.”,也不是“..”,因此函数必须在目录项高速缓存中查找它。如果低级文件系统有一个自定义的d_hash目录项方法,则调用它来修改已在第5b步计算出的散列值。
    9. nd->flagsLOOKUP_CONTINUE对应的位置位,这表示还有下一个分量要分析
    10. 调用do_lookup(),得到与给定的父目录(nd->dentry)和文件名(要解析的路径名分量&next结果参数)相关的目录项对象,存放在结果参数next中:err = do_lookup(nd, &this, &next, atomic);。该函数本质上首先调用__d_lookup()在目录项高速缓存中搜索分量的目录项对象。如果没有找到这样的目录项对象,则调用real_lookup()。而real_lookup()执行索引节点的lookup方法从磁盘读取目录,创建一个新的目录项对象并把它插入到目录项高速缓存中,然后创建一个新的索引节点对象并把它插入到索引节点高速缓存中。在这一步结束时,next局部变量中的dentrymnt字段将分别指向这次循环要解析的分量名的目录项对象和已安装文件系统对象。
    11. 调用follow_mount()检查刚解析的分量(next.dentry)是否指向某个文件系统安装点的一个目录(next.dentry->d_mounted值大于0)。follow_mount()更新next.dentrynext.mnt的值,以使它们指向由这个路径名分量所表示的目录上安装的最上层文件系统的目录项对象和已安装文件系统对象
    12. 检查刚解析的分量是否指向一个符号链接(next.dentry->d_mode具有一个自定义的follow_link方法)。
    13. 检查刚解析的分量是否指向一个目录(next.dentry->d_inode具有一个自定义的lookup方法)。
    14. nd->dentrynd->mnt分别置为next.dentrynext.mnt,然后继续路径名的下一个分量
  6. 除了最后一个分量,原路径名的所有分量都被解析,即循环体的qstr结构的this指向最后一个分量(/test.conf),name内部变量已经是NULL了,nd指向的dentry对应this的前一个分量(/system-configure-ext2/test.conf),此时goto last_component;如果跳出循环的话,那么清除nd->flags中的LOOKUP_CONTINUE标志
  7. 如果路径名尾部有一个“/”,则把lookup_flags局部变量中LOOKUP_FOLLOWLOOKUP_DIRECTORY标志对应的位置位,以强制后边的函数来解释最后一个作为目录名的分量。
  8. 检查lookup_flags变量中LOOKUP_PARENT标志的值。下面假定这个标志被置为0,并把相反的情况推迟到lookup_parent
  9. 如果最后一个分量名是“.”(单个圆点),则终止执行并返回值0(无错误)。在nd指向的nameidata数据结构中,dentrymnt字段指向路径名中倒数第二个分量对应的对象(/system-configure-ext2/test.conf,任何分量“.”在路径名中没有效果)。
  10. 如果最后一个分量名是“..”(两个圆点),则尝试回到父目录:
    1. 如果最后解析的目录是进程的根目录(nd->dentry等于current->fs->rootnd->mnt等于current->fs->rootmnt),则在倒数第二个分量上调用follow_mount(),终止执行并返回值0(无错误)。nd->dentrynd->mnt指向路径名的倒数第二个分量对应的对象,也就是进程的根目录。
    2. 如果最后解析的目录是nd->mnt文件系统的根目录(nd->dentry等于nd->mnt->mnt_root),并且该文件系统没有被安装在另一个文件系统之上(nd->mnt等于nd->mnt->mnt_parent),那么再向上搜索是不可能的,因此在倒数第二个分量上调用follow_mount(),终止执行并返回值0(无错误)。
    3. 如果最后解析的目录是nd->mnt文件系统的根目录,并且该文件系统被安装在其他文件系统之上,那么把nd->dentrynd->mnt分别置为nd->mnt->mnt_mountpointnd->mnt_mnt_parent,然后重新执行第10步。
    4. 如果最后解析的目录不是已安装文件系统的根目录,则把nd->dentry置为nd->dentry->d_parent,在父目录上调用follow_mount(),终止执行并返回值0(无错误)。nd->dentrynd->mnt指向前一个分量(即路径名倒数第二个分量)对应的对象。
  11. 路径名的最后分量名既不是“.”也不是“..”,因此,必须用do_lookup在高速缓存中查找它。如果低级文件系统有自定义的d_hash目录项方法,则该函数调用它来修改在第5c步已经计算出的散列值。
  12. 调用do_lookup()得到与父目录和文件名相关的目录项对象。这一步结束时,next局部变量存放的是指向最后分量名对应的目录项和已安装文件系统描述符的指针。
  13. 调用follow_mount()检查最后一个分两名是不是一个文件系统的安装点,如果是,则把next局部变量更新为最上层已安装文件系统根目录对应的目录项对象和已安装文件系统对象的地址。
  14. 检查在lookup_flags中是否设置了LOOKUP_FOLLOW标志,且索引节点对象next.dentry->d_inode是否有一个自定义的follow_link方法。如果是,分量就是一个必须进行解释的符号链接。
  15. 要解析的分量不是一个符号链接或符号链接不该被解释。把nd->mnt和nd->dentry字段分别置为next.mntnext.dentry的值。最后的目录项对象就是整个查找操作的结果
  16. 检查nd->dentry->d_inode是否为NULL。这发生在没有索引节点与目录项对象关联时,通常是因为路径名指向一个不存在的文件。在这种情况下,返回一个错误码-ENOENT。
  17. 路径名的最后一个分量有一个关联的索引节点。如果在lookup_flags中设置了LOOKUP_DIRECTORY标志,则检查索引节点是否有一个自定义的lookup方法,也就是说它是一个目录。如果没有,则返回一个错误码-ENOTDIR。
  18. 返回值0(无错误)。nd->dentrynd->mnt指向路径名的最后分量。

父路径名查找

在很多情况下,查找操作应当取回最后分量的前一个分量的目录项对象。当查找操作必须解析的是包含路径名最后一个分量的目录而不是最后一个分量本身时,就使用LOOKUP_PARENT标志

LOOKUP_PARENT标志被设置时,link_path_walk()函数也在nameidata数据结构中建立lastlast_type字段。last字段存放路径名中的最后一个分量名。last_type字段标识最后一个分量的类型;可以把它置为如下所示的值之一:

  • LAST_NORM:最后一个分量是普通文件名
  • LAST_ROOT:最后一个分量是“/”(也就是整个路径名为“/”)
  • LAST_DOT:最后一个分量是“.”
  • LAST_DOTDOT:最后一个分量是“..”
  • LAST_BIND:最后一个分量是链接到特殊文件系统的符号链接

当整个路径名的查找操作开始时,LAST_ROOT标志是由path_lookup()设置的缺省值。如果路径名正好是“/”,则内核不改变last_type字段的初始值。

last_type字段的其他值在LOOKUP_PARENT标志置位时由link_path_walk()设置;在这种情况下,函数执行直到第8步。不过,从第8步往后,路径名中最后一个分量的查找操作是不同的:

  1. nd->last置为最后一个分两名
  2. nd->last_type初始化为LAST_NORM
  3. 如果最后一个分量名为“.”(一个圆点),则把nd->last_type置为LAST_DOT。
  4. 如果最后一个分量名为“..”(两个圆点),则把nd->last_type置为LAST_DOTDOT。
  5. 通过返回值0(无错误)终止。

你可以看到,最后一个分量根本就没有被解释。因此,当函数终止时,nameidata数据结构的dentrymnt字段指向最后一个分量所在目录对应的对象。

符号链接的查找

路径名可以包含符号链接,且必须由内核来解析。内核必须执行两个不同的查找操作:

  • 第一个操作解析/foo/bar:当内一核发现bar是一个符号链接名时,就必须提取它的内容并把它解释为另一个路径名;
  • 第二个路径名操作从第一个操作所达到的目录开始,继续到符号链接路径名的最后一个分量被解析。
  • 接下来,原来的查找操作从第二个操作所达到的目录项恢复,且有了原目录名中紧随符号链接的分量。

假定一个符号链接指向自己,解析含有这样符号链接的路径名可能导致无休止的递归调用流,这又依次引发内核栈的溢出。当前进程的描述符中的link_count字段用来避免这种问题:每次递归执行前增加这个字段的值,执行之后减少其值。如果该字段的值达到6,整个循环操作就以错误码结束。因此,符号链接嵌套的层数不超过5

另外,当前进程的描述符中的total_link_count字段记录在原查找操作中有多少符号链接(甚至非嵌套的)被跟踪。如果这个计数器的值到40,则查找操作中止。没有这个计数器,怀有恶意的用户就可能创建一个病态的路径名,让其中包含很多连续的符号链接,使内核在无休止的查找操作中冻结。

这就是代码基本工作的方式:一旦link_path_walk()函数检索到与路径名分量相关的录项对象,就检查相应的索引节点对象是否有自定义的follow_link方法。如果是,索引节点就是一个符号链接,在原路径名的查找操作进行之前就必须先对这个符号链接进行解释。

在这种情况下,link_path_walk()函数调用do_follow_link(),前者传递给后者的参数为符号链接目录项对象的地址dentrynameidata数据结构的地址nd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static inline int do_follow_link(struct path *path, struct nameidata *nd)
{
int err = -ELOOP;
if (current->link_count >= MAX_NESTED_LINKS)
goto loop;
if (current->total_link_count >= 40)
goto loop;
BUG_ON(nd->depth >= MAX_NESTED_LINKS);
cond_resched();
err = security_inode_follow_link(path->dentry, nd);
if (err)
goto loop;
current->link_count++;
current->total_link_count++;
nd->depth++;
err = __do_follow_link(path, nd);
current->link_count--;
nd->depth--;
return err;
loop:
dput_path(path, nd);
path_release(nd);
return err;
}

do_follow_link()依次执行下列步骤:

  1. 检查current->link_count小于MAX_NESTED_LINKS,一般来说是5;否则,返回错误码-ELOOP。
  2. 检查current->total_link_count小于40;否则,返回错误码-ELOOP。
  3. 如果当前进程需要,则调用cond_resched()进行进程交换(设置当前进程描述符thread_info中的TIF_NEED_RESCHED标志)。
  4. 递增current->link_countcurrent->total_link_countnd->depth的值。
  5. 更新与要解析的符号链接关联的索引节点的访问时间。
  6. 调用与具体文件系统相关的函数来实现follow_link方法,给它传递的参数为dentrynd。它读取存放在符号链接索引节点中的路径名,并把这个路径名保存在nd->saved_names数组的合适项中。
  7. 通过__do_follow_link调用__vfs_follow_link()函数,给它传递的参数为地址ndnd->saved_names数组中路径名的地址:
  8. 如果定义了索引节点对象的put_link方法,就执行它,释放由follow_link方法分配的临时数据结构。
  9. 减少current->link_countnd->depth字段的值。
  10. 返回由__vfs_follow_link()函数返回的错误码(0表示无错误):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static __always_inline int __do_follow_link(struct path *path, struct nameidata *nd)
{
int error;
void *cookie;
struct dentry *dentry = path->dentry;

touch_atime(path->mnt, dentry);
nd_set_link(nd, NULL);

if (path->mnt != nd->mnt) {
path_to_nameidata(path, nd);
dget(dentry);
}
mntget(path->mnt);
cookie = dentry->d_inode->i_op->follow_link(dentry, nd);
error = PTR_ERR(cookie);
if (!IS_ERR(cookie)) {
char *s = nd_get_link(nd);
error = 0;
if (s)
error = __vfs_follow_link(nd, s);
if (dentry->d_inode->i_op->put_link)
dentry->d_inode->i_op->put_link(dentry, nd, cookie);
}
dput(dentry);
mntput(path->mnt);

return error;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static __always_inline int __vfs_follow_link(struct nameidata *nd, const char *link)
{
int res = 0;
char *name;
if (IS_ERR(link))
goto fail;

if (*link == '/') {
path_release(nd);
if (!walk_init_root(link, nd))
/* weird __emul_prefix() stuff did it */
goto out;
}
res = link_path_walk(link, nd);
out:
if (nd->depth || res || nd->last_type!=LAST_NORM)
return res;
/*
* If it is an iterative symlinks resolution in open_namei() we
* have to copy the last component. And all that crap because of
* bloody create() on broken symlinks. Furrfu...
*/
name = __getname();
if (unlikely(!name)) {
path_release(nd);
return -ENOMEM;
}
strcpy(name, nd->last.name);
nd->last.name = name;
return 0;
fail:
path_release(nd);
return PTR_ERR(link);
}

__vfs_follow_link()函数本质上依次执行下列操作:

  1. 检查符号链接路径名的第一个字符是否是“/”:在这种情况下,已经找到一个绝对路径名,因此没有必要在内存中保留前一个路径的任何信息。如果是,对nameidata数据结构调用path_release(),因此释放由前一个查找步骤产生的对象;然后,设置nameidata数据结构的dentrymnt字段,以使它们指向当前进程的根目录。
  2. 调用link_path_walk()解析符号链的路径名,传递给它的参数为路径名和nd。
  3. 返回从link_path_walk()取回的值。

do_follow_link()最后终止时,它把局部变量nextdentry字段设置为目录项对象的地址,而这个地址由符号链接传递给原先就执行的link_path_walk()link_path_walk()函数然后进行下一步。

VFS系统调用的实现

open()系统调用

open()系统调用的服务例程为sys_open()函数,该函数接收的参数为:

  • 要打开文件的路径名filename
  • 访问模式的一些标志flags
  • 该文件被创建所需要的许可权位掩码mode

如果该系统调用成功,就返回一个文件描述符,也就是指向文件对象的指针数组current->files->fd中分配给新文件的索引;否则,返回-1。

下面列出了open()系统调用的所有标志:

  • O_RDONLY:为读而打开
  • O_WRONLY:为写而打开
  • O_RDWR:为读和写而打开
  • O_CREAT:如果文件不存在,就创建它
  • O_EXCL:对于O_CREAT标志,如果文件已经存在,则失败
  • O_NOCTTY:从不把文件看作控制终端
  • O_TRUNC:截断文件(删除所有现有的内容)
  • O_APPEND:总是在文件末尾写
  • O_NONBLOCK:没有系统调用在文件上阻塞
  • O_NDELAY:与O_NONBLOCK相同
  • O_SYNC:同步写(阻塞,直到物理写终止)
  • FASYNC:通过信号发出I/O事件通告
  • O_DIRECT:直接I/O传送(不使用缓存)
  • O_LARGEFILE:大型文件(长度大于2GB)
  • O_DIRECTORY:如果文件不是一个目录,则失败
  • O_NOFOLLOW:不解释路径名中尾部的符号链接
  • O_NOATIME:不更新索引节点的上次访问时间

下面来描述一下sys_open()函数的操作。它执行如下操作:

  1. 调用getname()从进程地址空间读取该文件的路径名,想查看细节请看博文“文件系统安装预备知识”。
  2. 调用get_unused_fd()current->files->fd中查找一个空的位置。相应的索引(新文件描述符)存放在fd局部变量中。
  3. 调用do_filp_open()函数,传递给它的参数为路径名、访问模式标志以及许可权位掩码。
    1. 把访问模式标志拷贝到namei_flags标志中,但是,用特殊的格式对访问模式标志。O_RDONLYO_WRONLYO_RDWR进行编码:如果文件访问需要读特权,那么只设置namei_flags标志的下标为0的位(最低位);类似地,如果文件访问需要写特权,就只设置下标为1的位。注意,不可能在open()系统调用中不指定文件访问的读或写特权;不过,这种情况在涉及符号链接的路径名查找中则是有意义的。
    2. 调用open_namei(),传递给它的参数为dfdAT_FDCWD)、路径名、修改的访问模式标志以及局部nameidata数据结构的地址。open_namei,这个函数执行以下流程:
      1. 如果访问模式标志中没有设置O_CREAT,则不设置LOOKUP_PARENT标志而设置LOOKUP_OPEN标志后开始查找操作。此外,只有O_NOFOLLOW被清零,才设置LOOKUP_FOLLOW标志,而只有设置了O_DIRECTORY标志,才设置LOOKUP_DIRECTORY标志。
      2. 如果在访问模式标志中设置了O_CREAT,则以LOOKUP_PARENTLOOKUP_OPENLOOKUP_CREATE标志的设置开始查找操作。一旦path_lookup()函数成功返回,则检查请求的文件是否已存在。如果不存在,则调用父索引节点的create方法分配一个新的磁盘索引节点。
      3. open_namei()函数也在查找操作确定的文件上执行几个安全检查。例如,该函数检查与已找到的目录项对象关联的索引节点是否存在、它是否是一个普通文件,以及是否允许当前进程根据访问模式标志访问它。如果文件也是为写打开的,则该函数检查文件是否被其他进程加锁。
    3. 调用dentry_open()函数,传递给它的参数为访问模式标志、目录项对象的地址以及由查找操作确定的已安装文件系统对象:
      1. 根据传递给open()系统调用的访问模式标志初始化文件对象的f_flagsf_mode字段。
      2. 根据作为参数传递来的目录项对象的地址和已安装文件系统对象的地址初始化文件对象的f_fentryf_vfsmnt字段。
      3. 重点步骤:把f_op字段设置为相应索引节点对象i_fop字段的内容。这就为进一步的文件操作建立起所有的方法。
      4. 把文件对象插入到文件系统超级块的s_files字段所指向的打开文件的链表。
      5. 如果文件操作的open方法被定义,则调用它。
      6. 调用file_ra_state_init()初始化预读的数据结构(参见第十六章)。
      7. 如果O_DIRECT标志被设置,则检查直接I/O操作是否可以作用于文件(参见第十六章)。
      8. 返回文件对象的地址。
    4. 返回文件对象的地址
  4. 回到do_sys_open,把current->files->fd[fd]置为由dentry_open()返回的文件对象的地址:
  5. 返回fd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
int get_unused_fd(void)
{
struct files_struct * files = current->files;
int fd, error;
struct fdtable *fdt;

error = -EMFILE;
spin_lock(&files->file_lock);

repeat:
fdt = files_fdtable(files);
fd = find_next_zero_bit(fdt->open_fds->fds_bits,
fdt->max_fdset,
files->next_fd);

if (fd >= current->signal->rlim[RLIMIT_NOFILE].rlim_cur)
goto out;

/* Do we need to expand the fd array or fd set? */
error = expand_files(files, fd);
if (error < 0)
goto out;

if (error) {
/*
* If we needed to expand the fs array we
* might have blocked - try again.
*/
error = -EMFILE;
goto repeat;
}

FD_SET(fd, fdt->open_fds);
FD_CLR(fd, fdt->close_on_exec);
files->next_fd = fd + 1;
#if 1
/* Sanity check */
if (fdt->fd[fd] != NULL) {
printk(KERN_WARNING "get_unused_fd: slot %d not NULL!/n", fd);
fdt->fd[fd] = NULL;
}
#endif
error = fd;

out:
spin_unlock(&files->file_lock);
return error;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static struct file *do_filp_open(int dfd, const char *filename, int flags,
int mode)
{
int namei_flags, error;
struct nameidata nd;

namei_flags = flags;
if ((namei_flags+1) & O_ACCMODE)
namei_flags++;
error = open_namei(dfd, filename, namei_flags, mode, &nd);
if (!error)
return nameidata_to_filp(&nd, flags);

return ERR_PTR(error);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
static struct file *__dentry_open(struct dentry *dentry, struct vfsmount *mnt,
int flags, struct file *f,
int (*open)(struct inode *, struct file *))
{
struct inode *inode;
int error;

f->f_flags = flags;
f->f_mode = ((flags+1) & O_ACCMODE) | FMODE_LSEEK |
FMODE_PREAD | FMODE_PWRITE;
inode = dentry->d_inode;
if (f->f_mode & FMODE_WRITE) {
error = get_write_access(inode);
if (error)
goto cleanup_file;
}

f->f_mapping = inode->i_mapping;
f->f_dentry = dentry;
f->f_vfsmnt = mnt;
f->f_pos = 0;
f->f_op = fops_get(inode->i_fop);
file_move(f, &inode->i_sb->s_files);

if (!open && f->f_op)
open = f->f_op->open;
if (open) {
error = open(inode, f);
if (error)
goto cleanup_all;
}

f->f_flags &= ~(O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC);

file_ra_state_init(&f->f_ra, f->f_mapping->host->i_mapping);

/* NB: we're sure to have correct a_ops only after f_op->open */
if (f->f_flags & O_DIRECT) {
if (!f->f_mapping->a_ops ||
((!f->f_mapping->a_ops->direct_IO) &&
(!f->f_mapping->a_ops->get_xip_page))) {
fput(f);
f = ERR_PTR(-EINVAL);
}
}

return f;

cleanup_all:
fops_put(f->f_op);
if (f->f_mode & FMODE_WRITE)
put_write_access(inode);
file_kill(f);
f->f_dentry = NULL;
f->f_vfsmnt = NULL;
cleanup_file:
put_filp(f);
dput(dentry);
mntput(mnt);
return ERR_PTR(error);
}
1
2
3
4
5
6
7
8
9
10
void fastcall fd_install(unsigned int fd, struct file * file)
{
struct files_struct *files = current->files;
struct fdtable *fdt;
spin_lock(&files->file_lock);
fdt = files_fdtable(files);
BUG_ON(fdt->fd[fd] != NULL);
rcu_assign_pointer(fdt->fd[fd], file);
spin_unlock(&files->file_lock);
}

read()和write()系统调用

read()write()系统调用非常相似。它们都需要三个参数:

  • 一个文件描述符fd
  • 一个内存区的地址buf(该缓冲区包含要传送的数据)
  • 一个数count指定应该传送多少字节

read()把数据从文件传送到缓冲区,而write()执行相反的操作。两个系统调用都返回所成功传送的字节数,或者发送一个错误条件的信号并返回-1。返回值小于count并不意味着发生了错误。即使请求的字节没有都被传送,也总是允许内核终止系统调用,因此用户应用程序必须检查返回值并重新发出系统调用(如果必要)。

一般会有这几种典型情况下返回小于count的值:当从管道或终端设备读取时,当读到文件的末尾时,或者当系统调用被信号中断时。文件结束条件(EOF)很容易从read()的空返回值中判断出来。这个条件不会与因信号引起的异常终止混淆在一起,因为如果读取数据之前read()被一个信号中断,则发生一个错误。

读或写操作总是发生在由当前文件指针所指定的文件偏移处(文件对象的f_pos字段)。两个系统调用都通过把所传送的字节数加到文件指针上而更新文件指针。

简而言之,sys_read()read()的服务例程)和sys_write()write())的服务例程)几乎都执行相同的步骤:

  1. 调用fget_light()fd获取当前进程相应文件对象的地址file
  2. 如果file->f_mode中的标志不允许所请求的访问(读或写操作),则返回一个错误码-EBADF。
  3. 如果文件对象没有read()aio_read()write()aio_write())文件操作,则返回一个错误码-EINVAL。
  4. 调用access_ok()粗略地检查buf和count参数(参见博文“文件系统安装预备知识”)。
  5. 调用rw_verify_area()对要访问的文件部分检查是否有冲突的强制锁。如果有,则返回一个错误码,如果该锁已经被F_SETLKW命令请求,那么就挂起当前进程。
  6. 调用file->f_op->readfile->f_op->write方法(如果已定义)来传送数据;否则,调用file->f_op->aio_readfile->f_op->aio_write方法。所有这些方法都返回实际传送的字节数。另一方面的作用是,文件指针被适当地更新。
  7. 调用fput_light()释放文件对象。
  8. 返回实际传送的字节数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct file fastcall *fget_light(unsigned int fd, int *fput_needed)
{
struct file *file;
struct files_struct *files = current->files;
……
file = fcheck_files(files, fd);
……
return file;
}
static inline struct file * fcheck_files(struct files_struct *files, unsigned int fd)
{
struct file * file = NULL;
struct fdtable *fdt = files_fdtable(files);

if (fd < fdt->max_fds)
file = rcu_dereference(fdt->fd[fd]);
return file;
}

close()系统调用

close()系统调用接收的参数为要关闭文件的文件描述符fdsys_close()服务例程执行下列操作:

  1. 获得存放在current->files->fd[fd]中的文件对象file的地址;如果它为NULL,则返回一个出错码。
  2. current->files->fd[fd]置为NULL。释放文件描述符fd,这是通过清除current->files中的open_fdsclose_on_exec字段的相应位来进行的。
  3. 调用file_close(),该函数执行下列操作:
    1. 调用文件操作的flush方法(如果已定义)。
    2. 释放文件上的任何强制锁。
    3. 调用fput()释放文件对象。
  4. 返回0或一个出错码。出错码可由flush方法或文件中的前一个写操作错误产生

文件加锁

Unix提供了一种允许进程对一个文件区进行加锁的机制,以使同时访问可以很容易的被避免。POSIX标准规定了基于fcntl()系统调用的文件加锁机制。这可以对文件的任意一部分加锁或对整个文件加锁。这种锁并不把不知道加锁的其他进程关到外边,只有在访问文件之前其他进程合作检查锁的存在时,锁才起作用。因此POSIX的锁称为劝告锁。传统的BSD变体通过flock()系统调用实现劝告锁,这个调用不允许进程对文件的一个区字段进行加锁,而只能对整个文件加锁。

不管是劝告锁还是强制锁,它们都可以使用共享读锁和独占写锁。在文件的某个区字段上,可以有任意多个进程进行读,但在同一个时刻只能有一个进程进行写。当其他进程对同一个文件都有自己的读锁时,就不可能获得一个写锁。

Linux文件加锁

Linux支持所有的文件加锁方式:劝告锁强制锁,以及fcntl()flock()lockf()系统调用。不过,lockf()系统调用仅仅是一个标准的库函数。flock()系统调用不管MS_MANDLOCK安装标志如何设置,只产生劝告锁。这是任何类Unix操作系统所期望的系统调用行为。在Linux中,增加了一种特殊的flock()强制锁,以允许对专有的网络文件系统的实现提供适当的支持。这就是所谓的共享模式强制锁当这个锁被设置时,其他任何进程都不能打开与锁访问模式冲突的文件

在Linux中还引人了另一种基于fcntl()的强制锁,叫做租借锁。当一个进程试图打开由租借锁保护的文件时,它照样被阻塞。然而,拥有锁的进程接收到一个信号。一旦该进程得到通知,它应当首先更新文件,以使文件的内容保持一致,然后释放锁。如果拥有者不在预定的时间间隔内这么做,则租借锁由内核自动删除,且允许阻塞的进程继续执行。

进程可以采用以下两种方式获得或释放一个文件劝告锁:

  • 发出flock()系统调用。传递给它的两个参数为文件描述符fd和指定锁操作的命令。该锁应用于整个文件。
  • 使用fcntl()系统调用。传递给它的三个参数为文件描述符fd、指定锁操作的命令以及指向flock结构的指针。flock结构中的几个字段允许进程指定要加锁的文件部分。因此进程可以在同一文件的不同部分保持几个锁。

fcntl()flock()系统调用可以在同一文件上同时使用,但是通过fcntl()加锁的文件看起来与通过flock()加锁的文件不一样,反之亦然。这样当应用程序使用一种依赖于某个库的锁,而该库同时使用另一种类型的锁时,可以避免发生死锁。

处理强制文件锁会更复杂:

  1. 安装文件系统时强制锁是必需的,可使用mount命令的-o mand选项在mount()系统调用中设置MS_MANDLOCK标志,缺省操作是不使用强制锁。
  2. 通过设置文件的set-group位和清除group-execute许可权位将他们标记为强制锁的候选者。
  3. 使用fcntl()系统调用获得或释放一个文件锁

处理租借锁:调用具有F_SETLEASEF_GETLEASE命令的系统调用fcntl()就足够了。使用另一个带有F_SETSIG命令的fcntl()可以改变传送给租借锁进程拥有者的信号类型。

文件锁的数据结构

在 Linux 内核中,所有类型的文件锁都是由数据结构file_lock来描述的:

指向磁盘上相同文件的所有lock_file结构会被链接成一个单链表,索引节点结构中的i_flock字段会指向该单链表结构的首元素,fl_next用于指向该链表中的下一个元素;因为锁被挂起的进程会被插入到由阻塞锁file_lock结构的fl_wait指向的等待队列中。所有的活动锁被链接在全局文件锁链表中,该表的首元素存放在file_lock_list中。所有的阻塞锁被链接在阻塞链表中,该表的首元素存放在block_list中。fl_link字段指向这两个列表其中一个。

内核必须跟踪所有与给定活动锁关联的阻塞锁

FL_FLOCK锁

FL_FLOCK锁总是与一个文件相关联,因此由一个打开该文件的今晨来维护。当一个锁被请求或允许时,内核就把进程保持在同一个文件对象上的任何其他锁都替换掉。这只发生在进程想把一个已经拥有的读锁改变为一个写锁,或把一个写锁改变为读锁。

flock()系统调用允许进程在打开文件上申请或删除劝告锁。它作用于两个参数:要加锁文件的文件描述符fd和指定锁操作的参数cmd。如果cmd参数为LOCK_SH,则请求一个共享的锁,为LOCK_EX则请求一个互斥的锁,为LOCK_UN则释放一个锁。

sys_flock()例程被调用时,执行下列步骤:

  1. 检查fd是否是一个有效的文件描述符,如果不是,就返回一个错误码。否则获得相应文件对象flip的地址。
  2. 检查进程在打开文件上是否有读和/或写权限;如果没有,就返回一个错误码。
  3. 获得一个新的file_lock对象锁并用适当的锁操作初始化它:根据参数cmd的值设置fl_type字段,把fl_file字段设为文件对象filp的地址,fl_flags字段设为FL_FLOCKfl_pid字段设为current->tgid,并把fl_end字段设为-1,这表示对整个文件(而不是文件的一部分)加锁的事实。
  4. 如果参数cmd不包含LOCK_NB位,则把FL_SLEEP标志加入fl_flags
  5. 如果文件具有一个flock文件操作,则调用它,传递给它的参数为文件对象指针filp、一个标志(F_SETLKWF_SETLK,取决于LOCK_NB位的值)以及新的file_lock对象锁的地址。
  6. 否则,如果没有定义flock文件操作(通常情况下),则调用flock_lock_file_wait()试图执行请求的锁操作。传递给它的两个参数为:文件对象指针filp和在第3步创建的新的file_lock对象的地址lock
  7. 如果上一步中还没有把file_lock描述符插入活动或阻塞链表中,则释放它。
  8. 返回成功

flock_lock_file_wait()函数执行下列循环操作:

  1. 调用flock_lock_file(),传递给它的参数为文件对象指针filp和新的file_lock对象锁的地址lock。这个函数依次执行下列操作:
    1. 搜索filp->f_dentry->d_inode->i_flock指向的链表。如果在同一文件对象中找到FL_FLOCK锁,则检查它的类型(LOCK_SHLOCK_EX):如果该锁的类型与新锁相同,则返回0(什么也没有做)。否则,从索引节点锁链表和全局文件锁链表中删除这个file_lock元素,唤醒fl_block链表中在该锁的等待队列的所有进程,并释放file_lock结构
    2. 如果进程正在执行开锁(LOCK_UN),则什么事情都不需要做:该锁已不存在或已被释放,因此返回0。
    3. 如果已经找到同一个文件对象的FL_FLOCK锁——表明进程想把一个已经拥有的读锁改变为一个写锁(反之亦然),那么调用cond_resched()给予其他更高优先级进程(特别是先前在原文件锁上阻塞的任何进程)一个运行的机会。
    4. 再次搜索索引节点锁链表以验证现有的FL_FLOCK锁并不与所请求的锁冲突。在索引节点链表中,肯定没有FL_FLOCK写锁,此外,如果进程正在请求一个写锁,那么根本就没有FL_FLOCK锁。
    5. 如果不存在冲突锁,则把新的file_lock结构插入索引节点锁链表和全局文件锁链表中,然后返回0(成功)。
    6. 发现一个冲突锁:如果fl_flags字段中FL_SLEEP对应的标志位置位,则把新锁(waiter锁)插入到blocker锁循环链表和全局阻塞链表中。
    7. 返回一个错误码-EAGAINO
  2. 检查flock_lock_file()的返回码:
    1. 如果返回码为0(没有冲突迹象),则返回0(成功)。
    2. 不相容的情况。如果fl_flags字段中的FL_SLEEP标志被清除,就释放file_lock锁描述符,并返回一个错误码-EAGAIN。
    3. 否则,不相容但进程能够睡眠的情况:调用wait_event_interruptible()把当前进程插入到lock->fl_wait等待队列中并挂起它。当进程被唤醒时(正好在释放blocker锁后),跳转到第1步再次执行这个操作。

FL_POSIX锁

FL_POSIX锁总是与一个进程和一个索引节点相关联。当进程死亡或一个文件描述符被关闭时(即使该进程对同一文件打开了两次或复制了一个文件描述符),这种锁会被自动地释放。此外,FL_POSIX锁绝不会被子进程通过fork()继承。

当使用fcntl()系统调用对文件加锁时,该系统调用作用于三个参数:

  • 要加锁文件的文件描述符fd
  • 指向锁操作的参数cmd
  • 指向存放在用户态进程地址空间中的flock

sys_fcntl()执行的操作取决于在cmd参数中所设置的标志值。

  • F_GETLK:确定由flock结构描述的锁是否与另一个进程已获得的某个FL_POSIX锁互相冲突。冲突时用现有锁的有关信息重写flock结构
  • F_SETLK:设置由flock结构描述的锁,如果不能获得该锁,就返回错误码
  • F_SETLKW:设置由flock结构描述的锁,如果不能获得该锁,则阻塞系统调用,直至该锁可以获取
  • F_GETLK64F_SETLK64F_SETLKW64:使用的时flock64而不是flock

sys_fcntl()服务例程首先获取与参数fd对应的文件对象,然后调用fcntl_getlk()fcntl_setlk()函数(这取决于传递的参数:F_GETLK表示前一个函数,F_SETLKF_SETLKW表示后一个函数)。我们仅仅考虑第二种情况。

fcntl_setlk()函数作用于三个参数:

  • 指向文件对象的指针filp
  • cmd命令(F_SETLKF_SETLKW)
  • 指向flock数据结构的指针

该函数执行下列操作:

  1. 读取局部变量中的参数fl所指向的flock结构。
  2. 检查这个锁是否应该是一个强制锁,且文件是否有一个共享内存映射。在肯定的情况下,该函数拒绝创建锁并返回-EAGAIN出错码,说明文件正在被另一个进程访问。
  3. 根据用户flock结构的内容和存放在文件索引节点中的文件大小,初始化一个新的file_lock结构。
  4. 如果命令cmdF_SETLKW,则该函数把file_lock结构的fl_flags字段设为FL_SLEEP标志对应的位置位。
  5. 如果flock结构中的l_type字段为F_RDLCK,则检查是否允许进程从文件读取;类似地,如果l_typeF_WRLCK,则检查是否允许进程写入文件。如果都不是,则返回一个出错码。
  6. 调用文件操作的lock方法(如果已定义)。对于磁盘文件系统,通常不定义该方法。
  7. 调用__posix_lock_file()函数,传递给它的参数为文件的索引节点对象地址以及file_lock对象地址。该函数依次执行下列操作:
    1. 对于索引节点的锁链表中的每个FL_POSIX锁,调用posix_locks_conflict()。该函数检查这个锁是否与所请求的锁互相冲突。从本质上说,在索引节点的链表中,必定没有用于同一区的FL_POSIX写锁,并且,如果进程正在请求一个写锁,那么同一个区字段也可能根本没有FL_POSIX锁。但是,同一个进程所拥有的锁从不会冲突;这就允许进程改变它已经拥有的锁的特性。
    2. 如果找到一个冲突锁,则检查是否以F_SETLKW标志调用fcntl()。如果是,当前进程应当被挂起:在这种情况下,调用posix_locks_deadlock()来检查在等待FL_POSIX锁的进程之间没有产生死锁条件,然后把新锁(waiter锁)插入到冲突锁(blocker锁)blocker链表和阻塞链表中,最后返回一个出错码。否则,如果以F_SETLK标志调用fcntl(),则返回出错码。
    3. 只要索引节点的锁链表中不包含冲突的锁,就检查把文件区重叠起来的当前进程的所有FL_POSIX锁,当前进程按需要对文件区中相邻的区字段进行锁定、组合及拆分
    4. 把新的file_lock结构插入到全局锁链表和索引节点链表中。
    5. 返回值0(成功)。
  8. 检查__posix_lock_file()的返回码:
    1. 如果返回码为0(没有冲突迹象),则返回0(成功)。
    2. 不相容的情况。如果fl_flags字段的FL_SLEEP标志被清除,就释放新的file_lock描述符,并返回一个错误码-EAGAIN。
    3. 否则如果不相容但进程能够睡眠时,调用wait_event_interruptible()把当前进程插入到lock->fl_wait等待队列中并挂起它。当进程被唤醒时(正好在释放blocker锁后),跳转到第7步再次执行这个操作。