Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

向量、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步再次执行这个操作。

内存管理

RAM中的某些部分永久的分配给内核,并用来存放内核代码以及静态的内核数据结构。其余的部分我们称为动态内存,这不仅是进程所需要的宝贵资源,也是内核本身所需要的宝贵资源。

页框管理

Linux采用4KB页框大小作为标准的内存分配单元。基于以下两个原因,这会使事情变得简单:

  • 由分页单元引发的缺页异常很容易得到解释,或者是由于请求的页存在但不允许进程对其访问,或者是由于请求的页不存在。在第二种情况下,内存分配器必须找到一个4KB的空闲页框,并将其分配给进程。
  • 虽然4KB和4MB都是磁盘块大小的倍数,但是在绝大多数情况下,当主存和磁盘之间传输小块数据时更高效。

页描述符

内核必须记录每个页框当前的状态。在以下情况下页框是不空闲的:

  • 包含用户态进程的数据;
  • 某个软件高速缓存的数据;
  • 动态分配的内核数据结构;
  • 设备驱动程序缓冲的数据;
  • 内核模块的代码等等。

页框的状态信息保存在一个类型为page的页描述符中,其中的字段如表8.1所示。所有的页描述符存放在mem_map数组中。因为每个描述符长度为32字节,所以mem_map所需要的空间略小于整个RAM的1%。virt_to_page(addr)宏产生线性地址addr对应的页描述符地址。pfn_to_page(pfn)宏产生与页框号pfn对应的页描述符地址。

_count字段:为页的引用计数器。如果为-1,那么说明该页框空闲,可以被分配给任何一个进程或者内核本身;如果大于0或者等于0,则说明页框被分配给了一个或者多个进程,或者用于存放一些内核数据结构。page_count()函数就是返回这个count的值加1,也即是该页使用者的数目。

flags字段包含多达32个用于描述页框状态的标志。

非一致性内存访问(NUMA)

内核2.6支持这种内存访问模型,这种模型中,给定CPU对不同内存单元的访问时间可能不一样。系统的物理内存被划分为几个节点(node).在一个单独的节点内,任何一个给定CPU访问页面所需的时间都是相同的。然而,对不同的CPU,这个时间可能就不同,对于每个CPU而言,内核都试图把耗时节点的访问次数减到最小,这就要小心地选择CPU最常引用的内核数据结构的存放位置。

每个节点中的物理内存又可以分为几个管理区每个节点都有一个pg_data_t的描述符,所有节点的描述符存放在一个单向链表中,第一个元素由pgdata_list变量指向。

IBM兼容PC使用一致访问内存(UMA)模型,因此并不真正需要NUMA的支持。但是即使对NUMA的支持没有被编译进内核,Linux还是使用一个节点,只是这个节点包含了系统中所有的物理内存。因此pgdata_list指向一个只包含一个节点的链表,这个节点也就是节点0的描述符,存放于contig_page_data变量中。这样做的好处是有助于内存代码的处理更具可移植性。

内存管理区

由于计算机体系结构有硬件的制约,所以内核必须处理80x86体系结构的两种硬件约束:

  • ISA总线的直接内存存取(DMA)处理器有一个严格的限制:只能对主存的前16M寻址。
  • 在具有大容量的内存的现代32位计算机中,CPU不能直接访问所有的物理内存,因为线性地址空间太小,只有4G。超过4G的部分就不能直接进行寻址了。

为了应对上述的两种限制,内核2.6把每个内存节点的物理内存划分为3个管理区(zone),在x86 UMA体系结构下的管理区为:

  • ZONE_DMA包含低于16M的内存页框
  • ZONE_NORMAL包含高于16M低于896M的内存页框
  • ZONE_HIGHMEN包含从896M开始到高于896M的内存页框

前两个包含内存的常规页框,通过把它们映射到虚拟地址空间中的第4个G,内核就可以直接进行访问。第三个区包含的内存页不能由内核直接访问。

同样,每个管理区也都有自己的描述符。

每个页描述符都有到内存节点node以及到节点内管理区(即这个页所在的管理区)的指针。为了节省空间,这下指针和典型的指针不一样,而是被编码成索引放到了flags字段的高位。

page_zone()函数接收一个页描述符的物理地址作为参数,读取页描述符中flags字段的最高位,然后通过查看zone_table数组来确定相应管理区描述符的地址。在启动时用所内存节点的所有管理区描述符的地址初始化这个数组。

当内核调用一个内存分配函数的时候,必须指明请求的页框所在的管理区。为了在内存分配中请求中指定首选管理区,内核使用zonelist数据结构,也就是管理区描述符指针数组。

保留的页框池

用两种不同的方法来满足内存分配请求,如果有足够的空闲内存则满足请求;否则必须回收一些内存,并且将发出请求的内核控制路径阻塞,直到有内存被释放。

保留内存的数量(以KB为单位)存放在min_free_kbytes变量中,初始值在内核初始化时设置,并取决于直接映射到内核线性地址空间第4个GB的物理内存的数量,即取决于包含在ZONE_DMAZONE_NORMAL内存管理区内的页框数目。保留的原因是因为在原子请求从不被阻塞,如果没有足够的空闲页,那么就是分配失败,为了尽量减少这种情况发生,当内存不足的时候,就会使用保留的页框池

ZONE_DMAZONE_NORMAL内存管理区将一定量的页框贡献给保留内存,这个数目与两个管理区的相对大小成比例。管理区描述符中的pages_min字段存储了管理区内保留的页框的数据。这个字段和pages_lowpages_high字段一起还在页框回收算法中起作用。page_low字段总是被设为pages_min的值的5/4,而pages_high总是被设置为pages_min的值的3/2。

分区页框分配器

叫做分区页框分配器(zoned page frame allocator)的内核子系统,处理对连续页框组的内存分配请求。

管理区分配器部分接受动态分配和释放的请求,在请求分配的情况下,搜索一个能满足请求的一组连续页框内存的管理区。在每个管理区内,页框被伙伴系统来处理,为了达到更好的系统性能,一小部分页框保留在高速缓存中用于快速地满足对单个页框的分配请求

请求和释放页框

6个函数被用来请求页框,一般都返回第一个所分配页框描述符的地址,分配失败则返回NULL。

  • alloc_pages(gfp_mask, order):请求2的order次方个连续的页框,
  • alloc_page(gfp_mask):用于获得单独页框的宏
  • __get_free_pages(gfp_mask,order):返回第一个所分配页的线性地址
  • __get_free_page(gfp_mask):用于获得单独页框的宏
  • __get_zeroed_page(gfp_mask):获取归零的页框,它调用alloc_pages(gfp_mask | __GFP_ZERO, 0)
  • __get_dma_pages(gfp_mask,order):获取适用于DMA的页框,它扩展为__get_free_pages(gfp_mask | __GFP_DMA, order)

gfp_mask是一组标志,它指明了如何寻找空闲的页框,能在gfp_mask中使用的标志如图

实际上Linux使用预定义标志值的集合,如下表。

__GFP_DMA__GFP_HIGHMEM被称作管理区修饰符,他们标示寻找空闲页框时内核所搜索的管理区。contig_page_data节点描述符的node_zonelists字段是一个管理区描述符链表的数组,它代表后备管理区,对管理区修饰符的每一个设置,相应的链表包含的内存管理区能在原来的管理区缺少页框的情况下,被用于满足内存分配需求,在80x86下,后备管理区如下:

  • 如果__GFP_DMA标志被置位,则只能从内存管理区获取页框。
  • 否则,如果__GFP_HIGHMEM标志没有被置位,则只能接优先次序从ZONE_NORMALZONE_DMA内存管理区获取页框。
  • 否则(__GFP_HIGHMEM标志被置位),则可以按优先次序从ZONE_HIGHMEMZONE_NORMALZONE_DMA内存管理区获得页框。

下面4个函数和宏中的任一个都可以释放页框:

  • __free_pages(page,order):该函数先检查page指向的页描述符;如果该页框未被保留(PG_reserved标志为0),就把描述符的字段减1。如果count值变为0,就假定从与page对应的页框开始的2的order次方个连续页框不再被使用。在这种情况下,该函数释放页框
  • free_pages(addr, order):这个函数类似于__free_pges(page,order),但是它接收的参数为要释放的第一个页框的线性地址addr。
  • __free_page(page):这个宏释放page所指描述符对应的页框;它扩展为:__free_pages(page, 0)
  • free_page(addr):该宏释放线性地址为addr的页框,它扩展为free_pages(addr, 0)

高端内存页框的内核映射

与直接映射的物理内存末端、高端内存的始端所对应的线性地址存放在high_memory变量中,它被设置为896MB。896MB边界以上的页框并不映射在内核线性地址空间的第四个GB,因此内核不能直接访问。这意味着返回所分配页框线性地址的页分配器函数并不适用于高端内存。

Linux设计者不得不找到某种方法来允许内核使用所有可使用的RAM,达到PAE所支持的64GB0采用的方法如下:

  • 高端内存页框的分配只能通过alloc_pages()函数和它的快捷函数alloc_page()。这些函数不返回第一个被分配页框的线性地址,因为如果该页框属于高端内存,那么这样的线性地址根本不存在。取而代之,这些函数返回第一个被分配页框的页描述符的线性地址。这些线性地址总是存在的,因为所有页描述符一旦被分配在低端内存中,它们在内核初始化阶段就不会改变。
  • 没有线性地址的高端内存中的页框不能被内核访问。因此,内核线性地址空间的最后128MB中的一部分专门用于映射高端内存页框。当然,这种映射是暂时的,否则只有128MB的高端内存可以被访问。取而代之,通过重复使用线性地址,使得整个高端内存能够在不同的时间被访问。

内核可以采用三种不同的机制将页框映射到高端内存;分别叫做永久内核映射临时内核映射非连续内存分配

建立永久内核映射可能阻塞当前进程;这发生在空闲页表项不存在时,也就是在高端内存上没有页表项可以用作页框的“窗口”时。因此,永久内核映射不能用于中断处理程序和可延迟函数。相反建立临时内核映射绝不会要求阻塞当前进程。

永久内核映射

永久内核映射允许内核建立高端页框到内核地址空间的长期映射。他们使用主内核页表中一个专门的页表,其地址存放在变量pkmap_page_table中,这在前面的页表机制管理区初始化中已经介绍过了。页表中的表项数由LAST_PKMAP宏产生,内核一次最多访问2MB或4MB的高端内存。

1
2
3
/*这里由定义可以看出永久内存映射为固定映射下面的4M空间*/
#define PKMAP_BASE ((FIXADDR_BOOT_START - PAGE_SIZE * (LAST_PKMAP + 1)) \
& PMD_MASK)

该页表映射的线性地址从PKMAP_BASE开始。pkmap_count数组包含LAST_PKMAP个计数器,pkmap_page_table页表中的每一项都有一个。

  • 计数器为0:对应页表项没有映射任何高端内存页框,并且是可用的。
  • 计数器为1:对应页表项没有映射任何高端内存页框,但是不能使用,因为其相应的TLB还未刷新。
  • 计数器为n,远大于1:对应页表项映射一个高端内存页框,正好有n-1个内核成分在使用这个页框。

为了记录高端内存页框与永久内核映射包含的线性地址之间的联系,内核使用了page_address_htable散列表。该表包含一个page_address_map结构,用于为高端内存中的每一个页框进行当前映射。而该数据结构还包含一个指向页描述符的指针和分配给该页框的线性地址。

page_address()函数返回页框对应的线性地址,如果页框在高端内存中并且没有被映射,则返回NULL。这个函数接受一个页描述符指针page作为其参数,并区分以下两种情况:

  1. 如果页框不在高端内存中(PG_highmen标志为0),则线性地址总是存在并且是通过计算页框下标,然后将其转换成物理地址,最后根据相应的物理地址得到线性地址。这是由下面的代码完成的:

    1
    __va((unsigned long)(page - mem_map) << 12)
  2. 如果页框在高端内存(PG_highmen标志为1)中,该函数就到page_address_htable散列表中查找。如果在散列表中找到页框,page_address()就返回它的线性地址,否则返回NULL。

kmap()函数建立永久内核映射。

1
2
3
4
5
6
7
8
/* 高端内存映射,运用数组进行操作分配情况 */
/* 分配好后需要加入哈希表中;*/
void *kmap(struct page *page)
{
if (!PageHighMem(page)) /*如果页框不属于高端内存*/
return page_address(page);
return kmap_high(page);/*页框确实属于高端内存*/
}

页框确实属于高端内存,则调用kmap_high()函数,该函数获取kmap_lock自旋锁,以保护页表免受多处理器系统上的并发访问,检查页框是否已经被映射,如果不是则调用map_new_virtual()把页框的物理地址插入到pkmap_page_table的一个项中并在page_address_htable散列表中加入一个元素。使页框的分配计数加1来将调用该函数的新内核成分考虑在内,此时流程都正确应该是2了。最后释放自旋锁并返回线性地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* kmap_high - map a highmem page into memory
* @page: &struct page to map
*
* Returns the page's virtual memory address.
*
* We cannot call this from interrupts, as it may block.
*/
void *kmap_high(struct page *page)
{
unsigned long vaddr;
lock_kmap();

vaddr = (unsigned long)page_address(page);
if (!vaddr)
vaddr = map_new_virtual(page);
pkmap_count[PKMAP_NR(vaddr)]++;
BUG_ON(pkmap_count[PKMAP_NR(vaddr)] < 2);
unlock_kmap();
return (void*) vaddr;/*返回地址*/
}

map_new_virtual()实际上执行两个嵌套循环:

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
static inline unsigned long map_new_virtual(struct page *page)
{
count = LAST_PKMAP;
for (;;) {
int count;
DECLARE_WAITQUEUE(wait, current);
for(count = LAST_PKMAP; count > 0; count --) {
last_pkmap_nr = (last_pkmap_nr + 1) & LAST_PKMAP_MASK;
if (!last_pkmap_nr) {
flush_all_zero_pkmaps();
count = LAST_PKMAP;
}
if (!pkmap_count[last_pkmap_nr]) {
unsigned long vaddr = PKMAP_BASE + (last_pkmap_nr << PAGE_SHIFT);
set_pte(&(pkmap_page_table[last_pkmap_nr]), mk_pte(page, __pgprot(0x63)));
pkmap_count[last_pkmap_nr] = 1;
set_page_address(page, (void*)vaddr);
return vaddr;
}
}

current->state = TASK_UNITERRUPTIBLE;
add_wait_queue(&pkmap_map_wait, &wait);
unlock_kmap();
schedule();
remove_wait_queue(&pkmap_map_wait, &wait);
lock_kmap();

if (page_address(page))
return (unsigned long)page_address(page);

}
}

在内循环中,该函数扫描pkmap_count中的所有计数器直到找到一个空值。当在pkmap_count中找到了一个未使用的项时,大的if代码块运行。这段代码确定该项对应的线性地址,为它在pkmap_page_table页表中创建一个项,将count置1,因为该项现在已经被使用了,调用set_page_address()函数插入一个新元素到page_address_htable散列表中,并返回线性地址。

函数从上次停止的地方开始,穿越pkmap_count数组执行循环。这是函数通过将pkmap_page_table页表中上次使用过页表项的索引保存在一个名为last_pkmap_nr的变量中做到的。 因此,搜索从上次因调用map_new_virtual()函数而跳出的地方重新开始。

当在pkmap_count中搜索到最后一个计数器时,就又从下标为0的计数器重新开始搜索。不过,在继续之前,map_new_virtual()调用flush_all_zero_pkmaps()函数来开始寻找计数器 为1 的另一趟扫描。每个值为1的计数器都表示在pkmap_page_table页表中表项是空闲的,但不能使用,因为相应的TLB表项还没有被刷新。flush_all_zero_pkmaps()把它们的计数器重置为0,删除page_address_htable散列表中对应的元素,并在pkmap_page_table的所有项上进行TLB刷新。

如果内循环在pkmap_count中没有找到空的计数器,map_new_virtual()函数就阻塞当前进程,直到某个进程释放了pkmap_page_table页表中的一个表项。通过把current插入到pkmap_map_wait等待队列,把current状态设置为TASK_UNINTERRUPTIBLE并调用schedule()放弃CPU来达到此目的。一旦进程被唤醒,该函数就通过调用page_address()检查是否存在另一个进程已经映射了该页;如果还没有其他进程映射该页,则内循环重新开始。

kunmap()函数撤销先前由kmap()建立的永久内核映射。如果页确实在高端内存中,则调用kunmap_high()函数,它本质上等价于下列代码:

1
2
3
4
5
6
7
8
void kunmap_high(struct page * page)
{
spin_lock(&kmap_lock);
if ((--pkmap_count[((unsigned long)page_address(page) - PKMAP_BASE) >> PAGE_SHIFT]) == 1)
if (waitqueue_active(&pkmap_map_wait))
wake_up(&pkmap_map_wait);
spin_unlock(&kmap_lock);
}

中括号内的表达式从页的线性地址计算出pkmap_count数组的索引。计数器被减1并与1相比。匹配成功表明没有进程在使用页。该函数最终能唤醒由map_new_virtual()添加在等待队列中的进程(如果有的话)。

总结一下,如果是通过alloc_page()获得了高端内存对应的page,内核专门为此留出一块线性空间,从PKMAP_BASEFIXADDR_START,用于映射高端内存。在2.6内核上,如果不指定PAE,这个地址范围是4G-8M到4G-4M之间。这个空间叫内核永久映射空间或者永久内核映射空间

这个空间和其它空间使用同样的页全局目录表,对于内核来说,就是swapper_pg_dir,对普通进程来说,通过 CR3寄存器指向。通常情况下,这个空间是 4M 大小,因此仅仅需要一个页表即可,内核通过来pkmap_page_table寻找这个页表。

通过kmap(), 可以把一个page映射到这个空间来。通过kunmap(),可以把一个page对应的线性地址从这个空间释放出来。

临时内核映射

临时内核映射比永久内核映射的实现要简单。在高端内存的任一页框都可以通过一个窗口(为此而保留的一个页表项)映射到内核地址空间。留给临时内核映射的窗口数是非常少的。

每个CPU都有它自己的包含13个窗口的集合,它们用enum km_type数据结构表示。该数据结构中定义的每个符号,如KM_BOUNCE_READKM_USER0KM_PTE0,标识了窗口的线性地址。

内核必须确保同一窗口永不会被两个不同的控制路径同时使用。因此,km_type结构中的每个符号只能由一种内核成分使用,并以该成分命名。最后一个符号KM_TYPE_NR本身并不表示一个线性地址,但由每个CPU 用来产生不同的可用窗口数。

km_type中的每个符号(除了最后一个)都是固定映射的线性地址的一个下标。enum_fixed_addresses数据结构包含符号FIX_KMAP_BEGINFIX_KMAP_END;把后者赋给下标FIX_KMAP_BEGIN+(KM_TYPE_NR*NR_CPUS)-1。在这种方式下,系统中的每个CPU都有KM_TYPE_NR个固定映射的线性地址。此外,内核用fix_to_virt(FIX_KMAP_BEGIN)线性地址对应的页表项的地址初始化kmap_pte变量。

为了建立临时内核映射,内核调用kmap_atomic()函数,它本质上等价于下列代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void * kmap_atomic(struct page * page, enum km_type type)
{
enum fixed_addresses idx;
unsigned long vaddr;

current_thread_info( )->preempt_count++;
if (!PageHighMem(page))
return page_address(page);
idx = type + KM_TYPE_NR * smp_processor_id( );
vaddr = fix_to_virt(FIX_KMAP_BEGIN + idx);
set_pte(kmap_pte-idx, mk_pte(page, 0x063));
__flush_tlb_single(vaddr);
return (void *) vaddr;
}

type参数和CPU标识符(通过smp_processor_id())指定必须用哪个固定映射的线性地址映射请求页。如果页框不属于高端内存,则该函数返回页框的线性地址;否则,用页的物理地址及Present、Accessed、Read/Write 和Dirty 位建立该固定映射的线性地址对应的页表项。最后,该函数刷新适当的TLB 项并返回线性地址。

为了撤销临时内核映射,内核使用kunmap_atomic()函数。在80x86结构中,这个函数减少当前进程的preempt_count;因此,如果在请求临时内核映像之前能抢占内核控制路径,那么在同一个映射被撤销后可以再次抢占。此外,kunmap_atomic()检查当前进程的TIF_NEED_RESCHED标志是否被置位,如果是,就调用schedule()

总结一下临时内核映射。前边提到从线性地址4G向前倒数若干的页面有一个空间称为固定映射空间,在这个空间中,有一部分用于高端内存的临时映射。这块空间具有如下特点:

  1. 每个 CPU 占用一块空间
  2. 在每个 CPU 占用的那块空间中,又分为多个小空间,每个小空间大小是 1 个 page,每个小空间用于一个目的,这些目的定义在kmap_types.h 中的 km_type 中。

当要进行一次临时映射的时候,需要指定映射的目的,根据映射目的,可以找到对应的小空间,然后把这个空间的地址作为映射地址。这意味着一次临时映射会导致以前的映射被覆盖。通过 kmap_atomic() 可实现临时映射。

伙伴系统算法

频繁的请求和释放不同大小的一组连续页框,必然导致在已分配页框的块内分散了许多小块的空闲页框。从本质上来说,避免外碎片的方法有两种:

  • 利用分页单元把一组非连续的空闲页框映射到连续的线性地址区间
  • 开发一种适当的技术来记录现存的空闲连续页框块的情况,以尽量避免为满足对小块的请求而分割大的空闲块。

内核首选第二种方法,由于以下的原因:

  • 在某些情况下,连续的页框确实是必要的,因为连续的线性地址不足以满足请求。比如DMA分配缓冲区的时候,会忽略分页单元而直接访问地址总线,因此,所请求的缓冲区就必须位于连续的页框中。
  • 频繁的修改页表势必导致平均访问内存次数的增加,因为会频繁的刷新TLB。
  • 内核通过4MB的页可以访问大块连续的物理内存,减少了转换后缓冲器失效率,提高访问内存的平均速度。

伙伴系统算法就是用来解决外碎片问题。

把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512,1024个连续的页框。对1024个页框的最大请求对应着4M大小的连续内存块。每个块的第一个页框的物理地址是该块大小的整数倍。

假设要请求一个256个页框的块(即1MB):

  • 算法先在256个页框的链表中检查是否有一个空闲块。
  • 如果没有这样的块,算法会查找下一个更大的页块,也就是,在512个页框的链表中找一个空闲块。
  • 如果存在这样的块,内核就把512的页框分成两等份,一半用作满足请求,另一半插入到256个页框的链表中。
  • 如果在512个页框的块链表中也没找到空闲块,就继续找更大的块 —— 1024个页框的块。
  • 如果这样的块存在,内核把1024个页框块的256个页框用作请求,然后从剩余的768个页框中拿512个插入到512个页框的链表中,再把最后的256个插入到256个页框的链表中。
  • 如果1024个页框的链表还是空的,算法就放弃并发出错信号。

内核试图把大小为b的一对空闲伙伴块合并为一个大小为2b的单独块,满足一下条件的两个块为伙伴:

  • 两个块具有相同的大小,记作b;
  • 物理地址连续
  • 第一个块的第一个页框的物理地址是2*b*2^12的倍数

数据结构

有三种伙伴系统:第一种处理适合ISA DMA的页框,第二种处理“常规”页框,第三种处理高端内存页框。每个伙伴系统使用的主要数据结构如下:

  1. 前面介绍过的mem_map数组。实际上,每个管理区都关系到mem_map元素的子集。子集中的第一个元素和元素的个数分别由管理区描述符的zone_mem_mapsize字段指定。
  2. 包含有11个元素、元素类型为free_area的一个数组,每个元素对应一种块大小。该数组存放在管理区描述符zone_tfree_area字段中。

我们考虑管理区描述符中free_area数组的第k个元素,它标识所有大小为2^k的空闲块。这个元素的free_list字段是双向循环链表的头,这个双向循环链表集中了大小为2^k页的空闲块对应的页描述符。更精确地说,该链表包含每个空闲页框块(大小为2^k)的起始页框的页描述符;指向链表中相邻元素的指针存放在页描述符page的lru字段中。

除了链表头外,free_area数组的第k个元素同样包含字段nr_free,它指定了大小为2^k页的空闲块的个数。当然,如果没有大小为2^k的空闲页框块,则nr_free等于0且free_list为空(free_list的两个指针next和prev都指向它自己的free_list字段)。

最后,一个2^k的空闲页块的第一个页的描述符的private字段存放了块的order,也就是数字k。正是由于这个字段,当页块被释放时,内核可以确定这个块的伙伴是否也空闲。如果是的话,它可以把两个块结合成大小为2^(k+1)页的单一块。

块分配

内核使用__rmqueue()函数来在管理区中找到一个空闲块。该函数需要两个参数:管理区描述符的地址zoneorderorder表示请求的空闲页块大小的对数值(0 表示一个单页块,1 表示一个两页块,2表示四个页块)。如果页框被成功分配,__rmqueue()函数就返回第一个被分配页框的页描述符。否则,函数返回NULL。

__rmqueue()函数中,从所请求order的链表开始,它扫描每个可用块链表进行循环搜索,如果需要搜索更大的order,就继续搜索:

1
2
3
4
5
6
7
8
9
struct free_area *area;
unsigned int current_order;

for (current_order=order; current_order<11; ++current_order) {
area = zone->free_area + current_order;
if (!list_empty(&area->free_list))
goto block_found;
}
return NULL;

如果直到循环结束还没有找到合适的空闲块,那么__rmqueue()就返回NULL。否则,找到了一个合适的空闲块,在这种情况下,从链表中删除它的第一个页框描述符,并减少管理区描述符中的free_pages的值:
1
2
3
4
5
6
7
block_found:
page = list_entry(area->free_list.next, struct page, lru);
list_del(&page->lru);
ClearPagePrivate(page);
page->private = 0;
area->nr_free--;
zone->free_pages -= 1UL << order;

如果从curr_order链表中找到的块大于请求的order,就执行一个while循环。这几行代码蕴含的原理如下:当为了满足2^h个页框的请求而有必要使用2^k个页框的块时(h < k),程序就分配前面的2^h 个页框,而把后面2^k - 2^h个页框循环再分配给free_area链表中下标在h到k之间的元素:
1
2
3
4
5
6
7
8
9
10
11
12
size = 1 << curr_order;
while (curr_order > order) {
area--;
curr_order--;
size >>= 1;
buddy = page + size;
list_add(&buddy->lru, &area->free_list);
area->nr_free++;
buddy->private = curr_order;
SetPagePrivate(buddy);
}
return page;

因为__rmqueue()函数已经找到了合适的空闲块,所以它返回所分配的第一个页框对应的页描述符的地址page。

块释放

__free_pages_bulk()函数按照伙伴系统的策略释放页框。它使用3个基本输入参数:

  • page:被释放块中所包含的第一个页框描述符的地址。
  • zone:管理区描述符的地址。
  • order:块大小的对数。

__free_pages_bulk()首先声明和初始化一些局部变量:

1
2
3
4
struct page * base = zone->zone_mem_map;
unsigned long buddy_idx, page_idx = page - base;
struct page * buddy, * coalesced;
int order_size = 1 << order;

page_idx局部变量包含块中第一个页框的下标,这是相对于管理区中的第一个页框而言的。order_size局部变量用于增加管理区中空闲页框的计数器:
1
zone->free_pages += order_size;

现在函数开始执行循环,最多循环 (10-order) 次,每次都尽量把一个块和它的伙伴进行合并。函数以最小的块开始,然后向上移动到顶部:
1
2
3
4
5
6
7
8
9
10
11
12
while (order < 10) {
buddy_idx = page_idx ^ (1 << order);
buddy = base + buddy_idx;
if (!page_is_buddy(buddy, order))
break;
list_del(&buddy->lru);
zone->free_area[order].nr_free--;
ClearPagePrivate(buddy);
buddy->private = 0;
page_idx &= buddy_idx; /* 合并 */
order++;
}

这个循环我看了半天没有看懂,后来举个例子,再画个图才渐渐明白。比如,我们这里order是4,那么order_size的值为2^4,也就是16,表明要释放16个连续的page。page_idx为这个连续16个page的老大的mem_map数组的下标。进入循环后,函数首先寻找该块的伙伴,即mem_map数组中page_idx-16或page_idx+16的下标buddy_idx,进一步说明一下,就是为了在下标为16的free_area中找到一个空闲的块,并且这个块与page所带的那个拥有16个page的块相邻。

尤其要注意:buddy_idx = page_idx ^ (1 << order)这行代码。这行代码很巧妙,短小精干。因为order一来就等于4,所以循环从4开始的,即第一个循环为buddy_idx = page_idx ^ (1<<4),即buddy_idx = page_idx ^ 10000。如果page_idx第5位为1,比如是20号页框(10100),那么在异或以后,buddy_idx为4号页框(00100)。如果page_idx第5位为0,比如是第40号页框(101000),那么在异或以后,buddy_idx为56号页框(111000)。

为什么要做这么一个运算呢?想想我们的目的是什么。__free_pages_bulk是将以其参数page为首的2^order个页面找到一个伙伴,并与其合并。在mem_map数组中,这个伙伴的老大要么是在这个page的前2^order,要么就是后2^order。如果单单是加或者减,那么就会忽略前面的或者后面的伙伴。

找到伙伴以后,把该伙伴的老大page的地址赋给buddy:

1
buddy = base + buddy_idx;

现在函数调用page_is_buddy()来检查buddy是否是真正的值得信赖的伙伴,也就是大小为order_size的空闲页框块的第一个页。
1
2
3
4
5
6
7
int page_is_buddy(struct page *page, int order)
{
if (PagePrivate(buddy) && page->private == order &&
!PageReserved(buddy) && page_count(page) ==0)
return 1;
return 0;
}

正如所见,要想成为伙伴,必须满足以下四个条件:

  1. buddy的第一个页必须为空闲(_count字段等于-1);
  2. 它必须属于动态内存(PG_reserved 位清零);
  3. 它的private字段必须有意义(PG_private 位置位);
  4. 它的private字段必须存放将要被释放的块的order。

如果所有这些条件都符合,就说明有新的伙伴存在啦,那么伙伴块就要跟page结合,先必须得脱离原来的free_list,执行page_idx &= buddy_idx合并,并再执行一次循环以寻找两倍大小的伙伴块。

如果page_is_buddy()中至少有一个条件没有被满足,则该函数跳出循环,因为获得的空闲块不能再和其他空闲块合并。函数将它插入适当的链表并以块大小的order 更新第一个页框的private 字段。

1
2
3
4
5
coalesced = base + page_idx;
coalesced->private = order;
SetPagePrivate(coalesced);
list_add(&coalesced->lru, &zone->free_area[order].free_list);
zone->free_area[order].nr_free++;

每CPU页框高速缓存

内核经常请求和释放单个页框。为了提升系统性能,如果请求单个或释放单个页框时,内核在使用伙伴算法之前多添了一个步骤,即每CPU页框高速缓存。每个内存管理区定义了一个每CPU页框高速缓存,所有每CPU高速缓存包含一些预先分配的页框,它们被用于满足本地CPU 发出的单个页内存请求。

更进一步,内核为每个内存管理区和每个CPU提供了两个高速缓存:一个热高速缓存它存放的页框中所包含的内容很可能就在CPU 硬件高速缓存中;还有一个冷高速缓存

如果内核或用户态进程在刚分配到页框后就立即向页框写,那么从热高速缓存中获得页框就对系统性能有利。我们知道,CPU中的硬件高速缓存存在有最近使用过的页框。而我们每次对页框存储单元的访问将都会导致原来一个存在于硬件高速缓存的一页被替换掉。当然,除非硬件高速缓存包含有一行:它映射刚被访问的 “热”页框单元,那么我们称为“命中”。

反过来,如果页框将要被DMA操作填充,那么从冷高速缓存中获得页框是方便的。在这种情况下,不会涉及到CPU,并且硬件高速缓存的行不会被修改。从冷高速缓存获得页框为其他类型的内存分配保存了热页框储备。

如果实在理解不了上面对热缓存和冷缓存的定义,那我们就干脆这样理解:热缓存跟CPU有关,要使用到对应CPU的高速缓存,当我们读写一个页面时,如果没有命中硬件高速缓存就替换一个页;冷缓存跟CPU无关,当我们读写一个页面时根本不去管有没有命中CPU的硬件缓存。

数据结构

实现每CPU页框高速缓存的主要数据结构是存放在内存管理区描述符zone_tpageset字段中的一个per_cpu_pageset数组数据结构。该数组包含为每个CPU 提供的一个元素;这个元素依次由两个per_cpu_pages描述符组成,一个留给热高速缓存而另一个留给冷高速缓存。

内核使用两个位标来监视热高速缓存或冷高速缓存的大小如果页框个数低于下界low,内核通过从伙伴系统中分配batch个单一页框来补充对应的高速缓存;否则,如果页框个数高过上界high,内核从高速缓存中释放batch个页框到伙伴系统中。值batch、low和high本质上取决于内存管理区中包含的页框个数。

通过每CPU 页框高速缓存分配页框

buffered_rmqueue()函数在指定的内存管理区中分配页框。它使用每CPU页框高速缓存来处理单一页框请求。

参数为内存管理区描述符的地址,请求分配的内存大小的对数order,以及分配标志gfp_flags。如果gfp_flags中的__GFP_COLD标志被置位,那么页框应当从冷高速缓存中获取,否则它应从热高速缓存中获取(此标志只对单一页框请求有意义)。该函数本质上执行如下操作:

  1. 如果order不等于0,每CPU页框高速缓存就不能被使用:函数跳到第4步。
  2. 检查由__GFP_COLD标志所标识的内存管理区本地每CPU高速缓存是否需要补充(per_cpu_pages描述符的count字段小于或等于low字段)。在这种情况下,它执行如下子步骤:
    1. 通过反复调用__rmqueue()函数从伙伴系统中分配batch 个单一页框。
    2. 将已分配页框的描述符插入高速缓存链表中。
    3. 通过给count 增加实际被分配页框的个数来更新它。
  3. 如果count值为正,则函数从高速缓存链表获得一个页框,count减1并跳到第5步。(注意,每CPU 页框高速缓存有可能为空,当在第2a 步调用__rmqueue()函数而分配页框失败时就会发生这种情况。)
  4. 到这里,内存请求还没有被满足,或者是因为请求跨越了几个连续页框,或者是因为被选中的页框高速缓存为空。调用__rmqueue()函数从伙伴系统中分配所请求的页框。
  5. 如果内存请求得到满足,函数就初始化(第一个)页框的页描述符:清除一些标志,将private字段置0,并将页框引用计数器置1。此外,如果gfp_flags中的__GPF_ZERO标志被置位,则函数将被分配的内存区域填充0。
  6. 返回(第一个)页框的页描述符地址,如果内存分配请求失败则返回NULL。

释放页框到每CPU 页框高速缓存

为了释放单个页框到每CPU 页框高速缓存,内核使用free_hot_page()free_cold_page()函数。它们都是free_hot_cold_page()函数的简单封装,接收的参数为将要释放的页框的描述符地址page和cold标志(指定是热高速缓存还是冷高速缓存)。

free_hot_cold_page()函数执行如下操作:

  1. page->flags字段获取包含该页框的内存管理区描述符地址。
  2. 获取由cold标志选择的管理区高速缓存的per_cpu_pages 描述符的地址。
  3. 检查高速缓存是否应该被清空:如果count值高于或等于high,则调用free_pages_bulk()函数,将管理区描述符、将被释放的页框个数(batch字段)、高速缓存链表的地址以及数字0(为0 到order 个页框)传递给该函数。free_pages_bulkl()函数依次反复调用__free_pages_bulk()函数来释放指定数量的(从高速缓存链表获得的)页框到内存管理区的伙伴系统中。
  4. 把释放的页框添加到高速缓存链表上,并增加count 字段。

应该注意的是,在当前的Linux 2.6内核版本中,从没有页框被释放到冷高速缓存中:至于硬件高速缓存,内核总是假设被释放的页框是热的。当然,这并不意味着冷高速缓存是空的:当达到下界时通过buffered_rmqueue()补充冷高速缓存。

管理区分配器

管理区分配器是内核页框分配器的前端。该成分必须分配一个包含足够多空闲页框的内存管理区,使他能满足内存请求。管理区分配器必须满足几个目标:

  • 它应当保护保留的页框池。
  • 当内存不足且允许阻塞当前进程时,它应当触发页框回收算法;一旦某些页框被释放,管理区分配器将再次尝试分配。
  • 如果可能,它应当保存小而珍贵的ZONE_DMA内存管理区。例如,如果是对ZONE_NORMALZONE_HIGHMEM页框的请求,那么管理区分配器会不太愿意分配ZONE_DMA内存管理区中的页框。

对一组连续页框的每次请求实质上是通过执行alloc_pages宏实现的,接着这个宏又调用__alloc_pages()函数,它接收以下三个参数:

  • gfp_mask:在内存分配请求中指定的标志
  • order:将要分配的一组连续页框数量的对数
  • zonelist:指向zonelist的指针,该数据结构按优先次序描述了适于内存分配的内存管理区

__alloc_pages()扫描包含在zonelist中的每个内存管理区:

1
2
3
4
5
6
7
for (i = 0; (z = zonelinst->zones[i]) != NULL; i ++) {
if (zone_watermark_of(z, order, ...)) {
page = buffered_rmqueue(z, order, gfp_mask);
if (page)
return page;
}
}

对于每个内存管理区,该函数将空闲页框的个数与一个阈值作比较,该阈值取决于内存分配标志、当前进程的类型以及管理区被函数检查过的次数。实际上,如果空闲内存不足,那么每个内存管理区一般会被检查n遍,每一遍在所请求的空闲内存最低量的基础上使用更低的阈值扫描。因此前面一段代码在__alloc_pages()函数体内被复制了几次,每次变化很小。__alloc_pages()函数调用buffered_rmqueue()函数:它返回第一个被分配的页框的页描述符;如果内存管理区没有所请求大小的一组连续页框,则返回NULL。

zone_watermark_ok()辅助函数,他的目的就是来探测对应的内存管理区中有没有足够的空闲页框,该函数接收几个参数,它们决定对应内存管理区中空闲页框个数的阈值min。同时满足下列两个条件则返回1的情况:

  1. 除了被分配的页框外,在内存管理区中至少还有min个空闲页框,不包括为内存不足保留的页框(管理区描述符的lowmem_reserve 字段)。
  2. 除了被分配的页框外,这里在order至少为k的块中起码还有min/2^k 个空闲页框,其中,对于每个k,取值在1 和分配的order 之间。因此,如果order大于0,那么在大小至少为2的块中至少还有min/2个空闲页框;如果order大于1,那么在大小至少为4的块中起码还有min/4个空闲页框。

__alloc_pages()本质上执行如下步骤:

  1. 执行对内存管理区的第一次扫描。在第一次扫描中,阈值min被设为z->pages_low,其中的z指向正在被分析的管理区描述符(参数can_try_harder 和gfp_high 被设为0)。
  2. 如果函数在上一步没有终止,那么没有剩下多少空闲内存:函数唤醒kswapd内核线程来异步地开始回收页框。
  3. 执行对内存管理区的第二次扫描,将值z->pages_min作为阈值base传递。正如前面解释的,实际阈值由the_can_try_hardergfp_high标志决定。这一步与第1步相似,但该函数使用了较低的阈值。
  4. 如果函数在上一步没有终止,那么系统内存肯定不足。如果产生内存分配请求的内核控制路径不是一个中断处理程序或一个可延迟函数,并且它试图回收页框(或者是currentPF_MEMALLOC标志被置位,或者是它的PF_MEMDIE标志被置位),那么函数随即执行对内存管理区的第三次扫描,试图分配页框并忽略内存不足的阈值, 也就是说,不调用zone_watermark_ok()。唯有在这种情况下才允许内核控制路径耗用为内存不足预留的页(由管理区描述符的lowmem_reserve字段指定)。其实,在这种情况下产生内存请求的内核控制路径最终将试图释放页框,因此只要有可能它就应当得到它所请求的。如果没有任何内存管理区包含足够的页框,函数就返回NULL 来提示调用者发生了错误。
  5. 在这里,正在调用的内核控制路径并没有试图回收内存。如果gfp_mask__GFP_WAIT标志没有被置位,函数就返回NULL 来提示该内核控制路径内存分配失败:在这种情况下,如果不阻塞当前进程就没有办法满足请求。
  6. 在这里当前进程能够被阻塞:调用cond_resched()检查是否有其它的进程需要CPU。
  7. 设置currentPF_MEMALLOC标志来表示进程已经准备好执行内存回收。
  8. 将一个指向reclaim_state数据结构的指针存入current->reclaim_state。这个数据结构只包含一个字段reclaimed_slab,被初始化为0。
  9. 调用try_to_free_pages()寻找一些页框来回收。后一个函数可能阻塞当前进程。一旦函数返回,__alloc_pages()就重设currentPF_MEMALLOC标志并再次调用cond_resched()
  10. 如果上一步已经释放了一些页框,那么该函数还要执行一次与第3步相同的内存管理区扫描。如果内存分配请求不能被满足,那么函数决定是否应当继续扫描内存管理区:如果__GFP_NORETRY标志被清除, 并且内存分配请求跨越了多达8个页框或__GFP_REPEAT__GFP_NOFAIL标志其中之一被置位,那么函数就调用blk_congestion_wait()使进程休眠一会儿,并且跳回到第6步。否则,函数返回NULL来提示调用者内存分配失败了。
  11. 如果在第9步中没有释放任何页框,就意味着内核遇到很大的麻烦,因为空闲页框已经少到了危险的地步,并且不可能回收任何页框。也许到了该作出重要决定的时候了。如果允许内核控制路径执行依赖于文件系统的操作来杀死一个进程(gfp_mask中的__GFP_FS标志被置位)并且__GFP_NORETRY标志为0,那么执行如下子步骤:
    1. 使用等于z->pages_high的阈值再一次扫描内存管理区。
    2. 调用out_of_memory()通过杀死一个进程开始释放一些内存。
    3. 跳回第1 步。

因为第11a 步使用的界值远比前面扫描时使用的界值要高,所以这个步骤很容易失败。实际上,只有当另一个内核控制路径已经杀死一个进程来回收它的内存后,第11a 步才会成功执行。因此,第11a步避免了两个无辜的进程(而不是一个)被杀死。

释放一组页框

管理区分配器同样负责释放页框。

释放页框的所有内核宏和函数都依赖于__free_pages()函数。它接收的参数为将要释放的第一个页框的页描述符的地址(page)和将要释放的一组连续页框的数量的对数(order)。该函数执行如下步骤:

  1. 检查第一个页框是否真正属于动态内存(它的PG_reserved标志被清0);如果不是,则终止。
  2. 减少page->_count使用计数器的值;如果它仍然大于或等于0,则终止。
  3. 如果order 等于0,那么该函数调用free_hot_page()来释放页框给适当内存管理区的每CPU 热高速缓存。
  4. 如果order大于0,那么它将页框加入到本地链表中,并调用free_pages_bulk()函数把它们释放到适当内存管理区的伙伴系统中。

内存区管理

内部碎片的产生主要是由于请求内存的大小与分配给它的大小不匹配而造成的,即使多个此数据结构通过一定规则挤进一个页面,那么也还有若干字节的空间被浪费。使用slab分配器解决,它有以下特性:

  • 所存放数据的类型可以影响内存区的分配方式
    • 例如,当给用户态进程分配一个页框时,内核调用get_zeroed_page()函数用0填充这个页。
    • slab分配器概念扩充了这种思想,并把内存区看作对象(object),这些对象由一组数据结构和几个叫做构造或析构函数组成。前者初始化内存区,而后者回收内存区。
    • 为了避免重复初始化对象,slab分配器并不丢弃已分配的对象,而是释放但把它们保存在内存中。当以后又要请求新的对象时,就可以从内存获取而不用重新初始化。
  • 内核函数倾向于反复请求同一类型的内存区。例如,只要内核创建一个新进程,它就要为一些固定大小的数据结构分配内存区。当进程结束时,包含这些数据结构的内存区还可以被重新使用。slab分配器把那些页框保存在高速缓存中并很快地重新使用它们。
  • 对内存区的请求可以根据它们发生的频率来分类。对于预期频繁请求一个特定大小的内存区而言,可以通过创建一组具有适当大小的专用对象来高效地处理,由此以避免内碎片的产生。另一种情况,对于很少遇到的内存区大小,可以通过基于一系列几何分布大小的对象的分配模式来处理,即使这种方法会导致内碎片的产生。
  • 在引入的对象大小不是几何分布的情况下,也就是说,数据结构的起始地址不是物理地址值的2的幂次方,事情反倒好办。这可以借助处理器硬件高速缓存而导致较好的性能。
  • 硬件高速缓存的高性能又是尽可能地限制对伙伴系统分配器调用的另一个理由,因为对伙伴系统函数的每次调用都“弄脏”硬件高速缓存,所以增加了对内存的平均访问时间。内核函数对硬件高速缓存的影响就是所谓的函数足迹(footprint),其定义为函数结束时重写高速缓存的百分比。显而易见,大的“足迹”导致内核函数刚执行之后较慢的代码执行,因为硬件高速缓存此时填满了无用的信息。

slab分配器把对象分组放进高速缓存每个高速缓存都是同种类型对象的一种“储备”。例如,当一个文件被打开时,存放相应“打开文件”对象所需的内存区是从一个叫做filp(“文件指针”)的slab 分配器的高速缓存中得到的。

包含高速缓存的主内存区被划分为多个slab,每个slab 由一个或多个连续的页框组成,这些页框中既包含已分配的对象,也包含空闲的对象。

高速缓存描述符

每个高速缓存都是由kmem_cache_t(等价于struct kmem_cache_s类型)类型的数据结构来描述的;

kmem_cache_t描述符的lists字段又是一个kmem_lists结构体,

slab描述符

高速缓存中的每个slab都有自己的类型为slab的描述符

slab描述符可能存放在两个地方:

  • 外部slab描述符,存放在slab外部,位于cache_sizes指向的一个不适合ISA DMA的普通高速缓存中。
  • 内部slab描述符,存放在slab内部,位于分配给slab的第一个页框的起始位置。

普通和专用高速缓存

高速缓存被分为两种类型:普通和专用。普通高速缓存只由slab分配器用于自己的目的,而专用高速缓存由内核的其余部分使用。

普通高速缓存:

  • 第一种:第一个高速缓存叫做kmem_cache,包含有内核使用的其余高速缓存描述符。cache_cache变量包含第一个高速缓存的描述符;
  • 第二种:用作普通用途的内存区。内存区大小一般分为13个内存区。malloc_sizes的表(其元素类型为cache_sizes)分别指向26个高速缓存描述符,与其相关的内存区大小为:32,64,128,256,…,131072字节。每种大小,都有两个高速缓存:一个适用于ISA DMA分配,另一个使用与常规分配。

在系统初始化调用kmem_cache_init()kmem_cache_sizes_init()来建立普通高速缓存。

专用高速缓存是由kmem_cache_create()函数创建的。从普通高速缓存中的cache_cache中取出来的一个描述符,并把描述符插入到高速缓存描述符的cache_chain链表中;还可以调用kmem_cache_destory()撤销一个高速缓存并将它从cache_chain链表上删除。为避免浪费空间,将分配的slab撤销,kmem_cache_shrink()函数通过反复调用slab_destroy()撤销所有的slab

slab分配器与分区页框分配器的接口

当slab分配器创建新的slab时,需要依靠分区页框分配器来获得一组连续的空闲页框。为了达到此目的,需要调用kmem_getpages()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void * kmem_getpages(kmem_cache_t *cachep, int flags)
{
struct page *page;
int i;

flags |= cachep->gfpflags;
page = alloc_pages(flags, cachep->gfporder);
if (!page)
return NULL;
i = (1 << cachep->gfporder);

while (i--)
SetPageSlab(page++);
return page_address(page);
}

cachep指向需要外页框的高速缓存的高速缓存描述符,flags说明如何请求页框,与存放在高速缓存描述符的gfpflags中的专用高速缓存分配标志相结合。

内存分配请求的大小由高速缓存描述符的gfporder字段指定,该字段将高速缓存中slab的大小编码,注意不可能从ZONE_HIGHMEM内存管理区分配页框,因为kmem_getpages()函数返回由page_address()函数产生的线性地址。

在相反的操作中,通过调用kmem_freepages()函数可以释放分配给slab的页框。这个函数从线性地址addr开始释放页框,这些页框曾分配给由cachep标识的高速缓存中的slab。

给高速缓存分配slab

一个新创建的高速缓存没有包含任何slab,因此也没有空闲的对象。只有当以下两个条件都为真时,才给高速缓存分配slab:

  • 已发出一个分配新对象的请求。
  • 高速缓存不包含任何空闲对象。

当这些情况发生时,slab 分配器通过调用cache_grow()函数给高速缓存分配一个新的slab:

1
static int cache_grow (kmem_cache_t * cachep, int flags, int nodeid)

而这个函数调用kmem_getpages()从分区页框分配器获得一组页框来存放一个单独的slab,然后又调用alloc_slabmgmt()获得一个新的slab描述符。如果高速缓存描述符的CFLGS_OFF_SLAB标志置位,则从高速缓存描述符的slabp_cache字段指向的普通高速缓存中分配这个新的slab描述符;否则,从slab的第一个页框中分配这个slab描述符。

给定一个页框,内核必须确定它是否被slab分配器使用,如果是,就迅速得到相应高速缓存和slab描述符的地址。因此,cache_grow()扫描分配给新slab的页框的所有页描述符,并将高速缓存描述符和slab描述符的地址分别赋给页描述符中lru字段的nextprev子字段。这项工作不会出错,因为只有当页框空闲时伙伴系统的函数才会使用lru字段,而只要涉及伙伴系统,slab 分配器函数所处理的页框就不空闲并将PG_slab标志置位。

接着,cache_grow()调用cache_init_objs(),它将构造方法(如果定义了的话)在新slab 上添加对象。

最后,cache_grow()调用list_add_tail()来将新得到的slab 描述符*slabp,添加到高速缓存描述符*cachep的全空slab 链表的末端,并更新高速缓存中的空闲对象计数器:

1
2
list_add_tail(&slabp->list, &cachep->lists->slabs_free);
cachep->lists->free_objects += cachep->num;

从高速缓存中释放slab

在两种条件下才能撤销slab:

  • slab 高速缓存中有太多的空闲对象。
  • 被周期性调用的定时器函数确定是否有完全未使用的slab 能被释放。

在两种情况下,调用slab_destroy()函数撤销一个slab,并释放相应的页框到分区页框分配器:

1
2
3
4
5
6
7
8
9
10
11
void slab_destroy(kmem_cache_t *cachep, slab_t *slabp) {
if(cachep_dtor) {
for(int i = 0; i < cachep->num; i ++) {
void *objp = slabp->s_mem + cachep->objsize * i;
(cachep->dtor)(objp, cachep, 0);
}
}
kmem_freepages(cachep, slabp->s_mem - slabp->colouroff);
if(cachep->flags && CFLAGS_OFF_SLAB)
kmem_cache_free(cachep->slabp_cache, slabp);
}

这个函数检查高速缓存是否为它的对象提供了析构方法,如果是,就使用析构方法释放slab 中的所有对象。objp 局部变量记录当前已检查的对象。接下来,又调用kmem_freepages(),该函数把slab 使用的所有连续页框返回给伙伴系统。最后,如果slab 描述符存放在slab 的外面,那么,就从slab 描述符的高速缓存释放这个slab 描述符。

对象描述符

每个对象都有类型为kmem_bufctl_t的一个描述符,对象描述符放在数组中,位于相应的slab描述符之后,两种可能的存放:

  • 外部对象描述符:在slab外面,位于高速缓存描述符的slabp_cache字段指向的一个普通高速缓存中。
  • 内部对象描述符:在slab内部,正好位于描述符所描述的对象之前。

数组中的第一个对象描述符描述slab中的第一个对象,依次类推。它包含的是下一个空闲对象在slab中的下标,因此实现了slab内部空闲对象的一个简单链表。空闲对象链表中的最后一个元素的对象描述符用常规值BUFCTL_END(0xffff)标记。例如,某个slab中有16个对象,其中只有1、3、5号对象空闲。那么1号对象描述符的值为3,3号对象描述符的值为5,5号对象描述符的值为BUFCTL_END

对齐内存中的对象

slab 分配器所管理的对象可以在内存中进行对齐,也就是说,存放它们的内存单元的起始物理地址是一个给定常量的倍数,通常是2的倍数。这个常量就叫对齐因子(alignment factor)。

slab分配器所允许的最大对齐因子是4096,即页框大小。这就意味着通过访问对象的物理地址或线性地址就可以对齐对象。在这两种情况下,只有最低的12 位才可以通过对齐来改变。

通常情况下,如果内存单元的物理地址是字大小(即计算机的内部内存总线的宽度)对齐的, 那么, 微机对内存单元的存取会非常快。因此,缺省情况下,kmem_cache_create()函数根据BYTES_PER_WORD宏所指定的字大小来对齐对象。对于80x86 处理器,这个宏产生的值为4,因为字长是32 位。

当创建一个新的slab高速缓存时,就可以让它所包含的对象在第一级硬件高速缓存中对齐。为了做到这点,设置SLAB_HWCACHE_ALIGN高速缓存描述符标志。kmem_cache_create()函数按如下方式处理请求:

  • 如果对象的大小大于高速缓存行(cache line)的一半,就在RAM中根据L1_CACHE_BYTES的倍数(也就是行的开始)对齐对象。
  • 否则,对象的大小就是L1_CACHE_BYTES的因子取整。这可以保证一个小对象不会横跨两个高速缓存行。

显然,slab 分配器在这里所做的事情就是以内存空间换取访问时间,即通过人为地增加对象的大小来获得较好的高速缓存性能,由此也引起额外的内碎片。

slab着色

在CPU中,同一硬件高速缓存行可以映射RAM中不同的块。高速缓存的硬件可能因此而花费内存周期在同一高速缓存行与RAM内存单元之间来来往往传送两个对象,而其他的高速缓存行并未充分使用。slab 分配器通过一种叫做slab 着色的策略,尽量降低高速缓存的这种不愉快行为:把叫做颜色的不同随机数分配给slab

在讨论slab着色之前,我们再回顾一下高速缓存内对象的布局。让我们考虑某个高速缓存,它的对象在RAM中被对齐。这就意味着对象的地址肯定是某个给定正数值(比如说aln,我们设aln=0x100)的倍数。连对齐的约束也考虑在内,在slab 内放置对象就有很多种可能的方式。方式的选择取决于对下列变量所做的决定:

  • num:可以在slab 中存放的对象个数(其值在高速缓存描述符的num 字段中)。
  • osize:对象的大小,包括对齐的字节。
  • dsize:slap描述符的大小加上所有对象描述符的大小,就等于硬件高速缓存行大小的最小倍数。如果slab 描述符和对象描述符都存放在slap 的外部,那么这个值等于0。
  • free:在slab 内未用字节(没有分配给任一对象的字节)的个数。

一个slab 中的总字节长度可以表示为如下表达式:

1
slab 的长度 = (num × osize) + dsize + free

free 总是小于osize,因为否则的话,就有可能把另外的对象放在slab 内。不过,free 可以大于aln。

slab 分配器利用空闲未用的字节free 来对slab着色。术语着色只是用来再细分slab,并允许内存分配器把对象展开在不同的线性地址之中。这样的话,内核从微处理器的硬件高速缓存中可能获得最好性能。

具有不同颜色的slab把slab的第一个对象存放在不同的内存单元,同时满足对齐约束。可用颜色的个数是free/aln(这个值存放在高速缓存描述符的colour 字段)。因此,第一个颜色表示为0,最后一个颜色表示为(free/aln)-1。(一种特殊情况是,如果free 比aln 小,那么colour 被设为0,不过所有slab 都使用颜色0,因此颜色真正的个数为1。)

如果用颜色col 对一个slab 着色,那么,第一个对象的偏移量(相对于slab 的起始地址)就等于col × aln + dsize字节。图8-6 显示了slab 内对象的布局对slab 颜色的依赖情况。着色本质上导致把slab 中的一些空闲区域从末尾移到开始。

只有当free 足够大时,着色才起作用。显然,如果对象没有请求对齐,或者如果slab 内的未用字节数小于所请求的对齐(free ≤ aln),那么,唯一可能着色的slab 就是具有颜色0 的slab,也就是说,把这个slab 的第一个对象的偏移量赋为0。

通过把当前颜色存放在高速缓存描述符的colour_next字段,就可以在一个给定对象类型的slab 之间平等地发布各种颜色。cache_grow()函数把colour_next所表示的颜色赋给一个新的slab,并递增这个字段的值。当colour_next的值变为colour后,又从0 开始。这样,每个新创建的slab 都与前一个slab 具有不同的颜色,直到最大可用颜色。此外,cache_grow()函数从高速缓存描述符的colour_off字段获得值aln,根据slab内对象的个数计算dsize,最后把col×aln+dsize的值存放到slab描述符的colouroff字段中。

空闲slab对象的本地高速缓存

为了减少处理器之间对自旋锁的竞争并更好的利用硬件高速缓存,slab分配器的每个高速缓存包含一个被称作slab本地高速缓存的每CPU数据结构,该结构由一个指向被释放对象的小指针数组组成。slab对象的大多数分配和释放只影响本地数组,只有在本地数组下溢或上溢时才涉及slab数据结构。

高速缓存描述符的array字段是一组指向array_cache数据结构的指针,系统中每个CPU对应于一个元素。每个array_cache数据结构是空闲对象的本地高速缓存的一个描述符。

本地高速缓存描述符并不包含本地高速缓存本身的地址;事实本地高速缓存本身的地址在本地高速缓存描述符之后。本地高速缓存存放的是指向已经释放的对象的指针,而不是对象本身,对象本身总是位于高速缓存的slab中。

当创建一个新的slab高速缓存时,kmem_cache_create()函数决定本地高速缓存大小、分配本地高速缓存,并将它们的指针存放在高速缓存描述符的array字段。

多处理器系统中,小对象使用的slab高速缓存同样包含一个附加的本地高速缓存,他的地址被存放在高速缓存描述符的lists.shared中,被所有的CPU共享,它使得将空闲对象从一个本地高速缓存移动到另一个高速缓存的任务更加容易。

分配slab对象

通过调用kmem_cache_alloc()函数获得新对象。参数cachep指向高速缓存描述符,新空闲对象必须从该高速缓存描述符获得,而参数flag表示传递给分区页框分配器函数的标志,该高速缓存的所有slab应当是满的。该函数本质上等价于下列代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void * kmem_cache_alloc(kmem_cache_t *cachep, int flags)
{
unsigned long save_flags;
void *objp;
struct array_cache *ac;

local_irq_save(save_flags);
ac = cache_p->array[smp_processor_id()];
if (ac->avail) {
ac->touched = 1;
objp = ((void **)(ac+1))[--ac->avail];
} else
objp = cache_alloc_refill(cachep, flags);
local_irq_restore(save_flags);
return objp;
}

函数首先试图从本地高速缓存获得一个空闲对象。如果有空闲对象,avail字段就包含指向最后被释放的对象的项在本地高速缓存中的下标。因为本地高速缓存数组正好存放在ac 描述符���面,所以((void**)(ac+1))[--ac->avail]获得那个空闲对象的地址并递减ac->avail的值。当本地高速缓存中没有空闲对象时,调用cache_alloc_refill()函数重新填充本地高速缓存并获得一个空闲对象。

cache_alloc_refill()函数本质上执行如下步骤:

  1. 将本地高速缓存描述符的地址存放在ac 局部变量中:ac = cachep->array[smp_processor_id()];
  2. 获得cachep->spinlock
  3. 如果slab高速缓存包含共享本地高速缓存,并且该共享本地高速缓存包含一些空闲对象,函数就通过从共享本地高速缓存中上移ac->batchcount个指针来重新填充CPU 的本地高速缓存。然后,函数跳到第6 步。
  4. 函数试图填充本地高速缓存, 填充值为高速缓存的slab中包含的多达ac->batchcount个空闲对象的指针:
    1. 查看高速缓存描述符的slabs_partialslabs_free链表,并获得slab 描述符的地址slabp,该slab描述符的相应slab或者部分被填充,或者为空。如果不存在这样的描述符,则函数转到第5 步。
    2. 对于slab 中的每个空闲对象,函数增加slab描述符的inuse字段,将对象的地址插入本地高速缓存,并更新free字段使得它存放了slab 中下一个空闲对象的下标:
    3. slabp->inuse++;((void**)(ac+1))[ac->avail++] = slabp->s_mem + slabp->free * cachep->obj_size;slabp->free = ((kmem_bufctl_t*)(slabp+1))[slabp->free];
    4. 如果必要,将清空的slab 插入到适当的链表上,可以是slab_full链表,也可以是slab_partial链表。
  5. 在这一步,被加到本地高速缓存上的指针个数被存放在ac->avail字段:函数递减同样数量的kmem_list3结构的free_objects字段来说明这些对象不再空闲。
  6. 释放cachep->spinlock
  7. 如果现在ac->avail字段大于0(一些高速缓存再填充的情况发生了),函数将ac->touched字段设为1,并返回最后插入到本地高速缓存的空闲对象指针:return ((void**)(ac+1))[--ac->avail];
  8. 否则,没有发生任何高速缓存再填充情况:调用cache_grow()获得一个新slab,从而获得了新的空闲对象。
  9. 如果cache_grow()失败了,则函数返回NULL;否则它返回到第1 步重复该过程。

释放Slab对象

kmem_cache_free()函数释放一个曾经由slab分配器分配给某个内核函数的对象。它的参数为cachep和objp,前者是高速缓存描述符的地址,而后者是将被释放对象的地址:

1
2
3
4
5
6
7
8
9
10
11
12
void kmem_cache_free(kmem_cache_t *cachep, void *objp)
{
unsigned long flags;
struct array_cache *ac;

local_irq_save(flags);
ac = cachep->array[smp_processor_id()];
if (ac->avail == ac->limit)
cache_flusharray(cachep, ac);
((void**)(ac+1))[ac->avail++] = objp;
local_irq_restore(flags);
}

函数首先检查本地高速缓存是否有空间给指向一个空闲对象的额外指针。如果有,该指针就被加到本地高速缓存然后函数返回。否则,它首先调用cache_flusharray()来清空本地高速缓存,然后将指针加到本地高速缓存。

cache_flusharray()函数执行如下操作:

  1. 获得cachep->spinlock自旋锁。
  2. 如果slab高速缓存包含一个共享本地高速缓存,并且如果该共享本地高速缓存还没有满,函数就通过从CPU的本地高速缓存中上移ac->batchcount个指针来重新填充共享本地高速缓存。
  3. 调用free_block()函数将当前包含在本地高速缓存中的ac->batchcount个对象归还给slab 分配器。对于在地址objp处的每个对象,函数执行如下步骤:
    1. 增加高速缓存描述符的lists.free_objects字段。
    2. 确定包含对象的slab 描述符的地址:slabp = (struct slab *)(virt_to_page(objp)->lru.prev);,请记住,slab 页的描述符的lru.prev 字段指向相应的slab 描述符。
    3. 从它的slab 高速缓存链表(cachep->lists.slabs_partial或是cachep->lists.slabs_full)上删除slab 描述符。
    4. 计算slab 内对象的下标:objnr = (objp - slabp->s_mem) / cachep->objsize;
    5. slabp->free的当前值存放在对象描述符中,并将对象的下标放入slabp->free(最后被释放的对象将再次成为首先被分配的对象):((kmem_bufctl_t *)(slabp+1))[objnr] = slabp->free;slabp->free = objnr;
    6. 递减slabp->inuse字段。
    7. 如果slabp->inuse等于0(也就是slab 中所有对象空闲),并且整个slab 高速缓存中空闲对象的个数(cachep->lists.free_objects)大于cachep->free_limit字段中存放的限制,那么函数将slab 的页框释放到分区页框分配器:cachep->lists.free_objects -= cachep->num;lab_destroy(cachep, slabp);。放在cachep->free_limit字段中的值通常等于cachep->num+(1+N)×cachep->batchcount,其中N 代表系统中CPU 的个数。
    8. 否则,如果slab->inuse等于0,但整个slab 高速缓存中空闲对象的个数小于cachep->free_limit,函数就将slab描述符插入到cachep->lists.slabs_free链表中。
    9. 最后,如果slab->inuse大于0,slab 被部分填充,则函数将slab 描述符插入到cachep->lists.slabs_partial链表中。
  4. 释放cachep->spinlock自旋锁。
  5. 通过减去被移到共享本地高速缓存或被释放到slab分配器的对象的个数来更新本地高速缓存描述符的avail 字段。
  6. 移动本地高速缓存数组起始处的那个本地高速缓存中的所有指针。这一步是必需的,因为已经把第一个对象指针从本地高速缓存上删除,因此剩下的指针必须上移。

通用对象

初始化阶段建立了一些高速缓存包含用作通用用途的类型的slab对象。如果对存储区的请求不频繁,就用一组普通高速缓存来处理,普通高速缓存中的对象具有几何分布的大小,范围为32~131072 字节。

调用kmalloc()函数就可以得到这种类型的对象,函数等价于下列代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void * kmalloc(size_t size, int flags)
{
struct cache_sizes *csizep = malloc_sizes;
kmem_cache_t * cachep;
for (; csizep->cs_size; csizep++) {
if (size > csizep->cs_size)
continue;
if (flags & _ _GFP_DMA)
cachep = csizep->cs_dmacachep;
else
cachep = csizep->cs_cachep;
return kmem_cache_alloc(cachep, flags);
}
return NULL;
}

该函数使用malloc_sizes表为所请求的大小分配最近的2 的幂次方大小的内存。然后,调用kmem_cache_alloc()分配对象,传递的参数或者为适用于ISA DMA 页框的高速缓存描述符,还是为适用于“常规”页框的高速缓存描述符,这取决于调用者是否指定了__GFP_DMA标志。

调用kmalloc()所获得的对象可以通过调用kfree()来释放:

1
2
3
4
5
6
7
8
9
10
11
void kfree(const void *objp)
{
kmem_cache_t * c;
unsigned long flags;
if (!objp)
return;
local_irq_save(flags);
c = (kmem_cache_t *)(virt_to_page(objp)->lru.next);
kmem_cache_free(c, (void *)objp);
local_irq_restore(flags);
}

通过读取内存区所在的第一个页框描述符的lru.next子字段,就可确定出合适的高速缓存描述符。通过调用kmem_cache_free()来释放相应的内存区。

内存池

内存池(memory pool)是Linux 2.6 的一个新特性,主要供一些驱动程序使用。基本上讲,一个内存池允许一个内核成分,如块设备子系统,仅在内存不足的紧急情况下分配一些动态内存来使用。

一个内存池常常叠加在slab 分配器之上 —— 也就是说,它被用来保存slab 对象的储备。但是一般而言,内存池能被用来分配任何一种类型的动态内存,从整个页框到使用kmalloc()分配的小内存区。因此,我们一般将内存池处理的内存单元看作内存元素

内存池由mempool_t对象描述,它的字段如下所示。

min_nr字段存放了内存池中元素的初始个数。换句话说,存放在该字段中的值代表了内存元素的个数,内存池的拥有者确信能从内存分配器得到这个数目。curr_nr字段总是低于或等于min_nr,它存放了内存池中当前包含的内存元素个数。内存元素自身被一个指针数组引用,指针数组的地址存放在elements字段中。

allocfree方法与基本的内存分配器进行交互,分别用于获得和释放一个内存元素。两个方法可以是拥有内存池的内核成分提供的定制函数。
当内存元素是slab对象时,allocfree方法一般由mempool_alloc_slab()mempool_free_slab()函数实现,它们只是分别调用kmem_cache_alloc()kmem_cache_free()函数。在这种情况下,mempool_t对象的pool_data字段存放了slab高速缓存描述符的地址。

mempool_create()函数创建一个新的内存池;它接收的参数为内存元素的个数min_nr、实现allocfree方法的函数的地址和赋给pool_data字段的任意值。该函数分别为mempool_t对象和指向内存元素的指针数组分配内存,然后反复调用alloc方法来得到min_nr个内存元素。相反地,mempool_destroy()函数释放池中所有内存元素,然后释放元素数组和mempool_t对象自己。

为了从内存池分配一个元素,内核调用mempool_alloc()函数,将mempool_t对象的地址和内存分配标志传递给它。函数本质上依据参数所指定的内存分配标志,试图通过调用alloc方法从基本内存分配器分配一个内存元素。如果分配成功,函数返回获得的内存元素而不触及内存池。否则,如果分配失败,就从内存池获得内存元素。当然,在内存不足的情况下过多的分配会用尽内存池:在这种情况下,如果__GFP_WAIT标志没有置位,则mempool_alloc()阻塞当前进程直到有一个内存元素被释放到内存池中。

相反地,为了释放一个元素到内存池,内核调用mempool_free()函数。如果内存池未满(curr_min小于min_nr),则函数将元素加到内存池中。否则,mempool_free()调用free方法来释放元素到基本内存分配器。

非连续内存区管理

把内存区映射到一组连续的页框是最好的选择,这样可以充分利用高速缓存就并获得较低的平均访问时间。不过,如果对内存区的请求不是很频繁,那通过连续的线性地址来访问非连续的页框这样一种分配方式将会很有意义,因为这样可以避免外部碎片,而缺点是必须打乱内核页表。显然,非连续内存区的大小必须是4096的倍数。

非连续内存的线性地址

查找线性地址的空闲区,可以从PAGE_OFFSET开始查找(通常为0xc0000000,即第四个GB的起始地址):

  • 内存区的开始部分包含的是对前896MB RAM进行映射的线性地址。直接映射的物理内存末尾所对应的线性地址保存在high_memory全局变量中。当物理内存小于896MB,则线性地址0xc0000000以后的896MB与其一一对应;当物理内存大于896MB而小于4GB时,只直接映射前896MB的地址到0xc0000000以后的线性空间,然后把线性空间的其他部分与896MB和4GB物理空间映射起来,称为动态重映射;当物理内存大于4GB,则需要考虑PAE的情况。
  • PKMAP_BASE开始,我们查找用于高端内存页框的永久内核映射的线性地址。
  • 其余的线性地址可以用于非连续内存区。在物理内存映射的末尾与第一个内存区之间插入一个大小为8MB(宏VMALLOC_OFFSET)的安全区,目的是为了“捕获”对内存的越界访问。出于同样的理由,插入其他4KB 大小的安全区来隔离非连续的内存区。

为非连续内存区保留的线性地址空间的起始地址由VMALLOC_START宏定义,而末尾地址由VMALLOC_END宏定义。

非连续内存区的描述符

每个非连续内存区都对应着一个类型为vm_struct的描述符:

通过next字段,这些描述符被插入到一个简单的链表中,链表第一个元素的地址存放在vmlist变量中。对这个链表的访问依靠vmlist_lock读/ 写自旋锁来保护。flags字段标识了非连续区映射的内存的类型:

  • VM_ALLOC表示使用vmalloc()得到的页;
  • VM_MAP表示使用vmap()映射的已经被分配的页;
  • VM_IOREMAP表示使用ioremap()映射的硬件设备的板上内存。

get_vm_area()函数在线性地址VMALLOC_STARTVMALLOC_END之间查找一个空闲区域。该函数使用两个参数:将被创建的内存区的字节大小(size)和指定空闲区类型的标志(flag)。步骤执行如下:

  1. 调用kmalloc()vm_struct类型的新描述符获得一个内存区。
  2. 为写得到vmlist_lock锁,并扫描类型为vm_struct的描述符链表来查找线性地址一个空闲区域,至少覆盖size + 4096个地址(4096 是内存区之间的安全区间大小)。
  3. 如果存在这样一个区间,函数就初始化描述符的字段,释放vmlist_lock锁,并以返回这个非连续内存区的起始地址而结束。
  4. 否则,get_vm_area()释放先前得到的描述符,释放vmlist_lock,然后返回NULL。

分配非连续内存区

vmalloc()函数给内核分配一个非连续内存区。参数size表示所请求内存区的大小。如果这个函数能够满足请求,就返回新内存区的起始地址;否则,返回一个NULL指针(mm/vmalloc.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
void * vmalloc(unsigned long size)
{
struct vm_struct *area;
struct page **pages;
unsigned int array_size, i;
size = (size + PAGE_SIZE - 1) & PAGE_MASK;
area = get_vm_area(size, VM_ALLOC);
if (!area)
return NULL;
area->nr_pages = size >> PAGE_SHIFT;
array_size = (area->nr_pages * sizeof(struct page *));
area->pages = pages = kmalloc(array_size, GFP_KERNEL);
if (!area->pages) {
remove_vm_area(area->addr);
kfree(area);
return NULL;
}
memset(area->pages, 0, array_size);
for (i=0; i<area->nr_pages; i++) {
area->pages[i] = alloc_page(GFP_KERNEL|_ _GFP_HIGHMEM);
if (!area->pages[i]) {
area->nr_pages = i;
fail: vfree(area->addr);
return NULL;
}
}
if (map_vm_area(area, _ _pgprot(0x63), &pages) )
goto fail;
return area->addr;
}

函数首先将参数size设为4096(页框大小)的整数倍。然后,vmalloc()调用get_vm_area()来创建一个新的描述符,并返回分配给这个内存区的线性地址。描述符的flags字段被初始化为VM_ALLOC标志,该标志意味着通过使用vmalloc()函数,非连续页框将被映射到一个线性地址区间。然后vmalloc()函数调用kmalloc()来请求一组连续页框,这组连续页框足够包含一个页描述符指针数组。调用memset()函数来将所有这些指针设为NULL。接着重复调用alloc_page()函数,每一次为区间中nr_pages个页的每一个分配一个页框,并把对应页描述符的地址存放在area->pages数组中。注意,必须使用area->pages数组是因为页框可能属于ZONE_HIGHMEM内存管理区,所以此时它们不必被映射到一个线性地址上。

简要介绍一下memset(area->pages, 0, array_size)的实现函数:

1
2
3
4
5
6
7
8
9
10
11
static inline void * __memset_generic(void * s, char c,size_t count)
{
int d0, d1;
__asm__ __volatile__(
"rep/n/t"
"stosb"
: "=&c" (d0), "=&D" (d1)
:"a" (c),"1" (s),"0" (count)
:"memory");
return s;
}

到这里,已经得到了一个新的连续线性地址区间,并且已经分配了一组非连续页框来映射这些线性地址。最后至关重要的步骤是修改内核使用的页表项 ,以此表明分配给非连续内存区的每个页框现在对应着一个线性地址,这个线性地址被包含在vmalloc()产生的非连续线性地址区间中。这就是map_vm_area()所要做的,下面来详细说说:

map_vm_area()函数使用以下3 个参数:

  • area:指向内存区的vm_struct描述符的指针。
  • prot:已分配页框的保护位。它总是被置为0x63,对应着Present、Accessed、Read/Write 及Dirty。
  • pages:指向一个指针数组的变量的地址,该指针数组的指针指向页描述符(因此,struct page ***被当作数据类型使用!)。

函数首先把内存区的开始和末尾的线性地址分别分配给局部变量address和end:

1
2
address = area->addr;
end = address + (area->size - PAGE_SIZE);

请记住,area->size存放的是内存区的实际地址加上4KB 内存之间的安全区间。然后函数使用pgd_offset_k宏来得到在主内核页全局目录中的目录项,该项对应于内存区起始线性地址,然后获得内核页表自旋锁:
1
2
pgd = pgd_offset_k(address);
spin_lock(&init_mm.page_table_lock);

然后,函数执行下列循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int ret = 0;
for (i = pgd_index(address); i < pgd_index(end-1); i++) {
pud_t *pud = pud_alloc(&init_mm, pgd, address);
ret = -ENOMEM;
if (!pud)
break;
next = (address + PGDIR_SIZE) & PGDIR_MASK;
if (next < address || next > end)
next = end;
if (map_area_pud(pud, address, next, prot, pages))
break;
address = next;
pgd++;
ret = 0;
}
spin_unlock(&init_mm.page_table_lock);
flush_cache_vmap((unsigned long)area->addr, end);
return ret;

每次循环都首先调用pud_alloc()来为新内存区创建一个页上级目录,并把它的物理地址写入内核页全局目录的合适表项。然后调用alloc_area_pud()为新的页上级目录分配所有相关的页表。接下来,把常量2^30(在PAE被激活的情况下,否则为2^22)与address的当前值相加(2^30 就是一个页上级目录所跨越的线性地址范围的大小),最后增加指向页全局目录的指针pgd。

循环结束的条件是:指向非连续内存区的所有页表项全被建立

map_area_pud()函数为页上级目录所指向的所有页表执行一个类似的循环:

1
2
3
4
5
6
7
8
9
do {
pmd_t * pmd = pmd_alloc(&init_mm, pud, address);
if (!pmd)
return -ENOMEM;
if (map_area_pmd(pmd, address, end-address, prot, pages))
return -ENOMEM;
address = (address + PUD_SIZE) & PUD_MASK;
pud++;
} while (address < end);

map_area_pmd()函数为页中间目录所指向的所有页表执行一个类似的循环
1
2
3
4
5
6
7
8
9
do {
pte_t * pte = pte_alloc_kernel(&init_mm, pmd, address);
if (!pte)
return -ENOMEM;
if (map_area_pte(pte, address, end-address, prot, pages))
return -ENOMEM;
address = (address + PMD_SIZE) & PMD_MASK;
pmd++;
} while (address < end);

pte_alloc_kernel()函数分配一个新的页表,并更新页中间目录中相应的目录项。接下来,map_area_pte()为页表中相应的表项分配所有的页框。address值增加2^22(2^22 就是一个页表所跨越的线性地址区间的大小),并且循环反复执行。

map_area_pte()的主循环为:

1
2
3
4
5
6
7
do {
struct page * page = **pages;
set_pte(pte, mk_pte(page, prot));
address += PAGE_SIZE;
pte++;
(*pages)++;
} while (address < end);

将被映射的页框的页描述符地址page是从地址pages处的变量指向的数组项读得的。通过set_ptemk_pte宏,把新页框的物理地址写进页表。把常量4096(即一个页框的长度)加到address上之后,循环又重复执行。

注意,map_vm_area()并不触及当前进程的页表。因此,当内核态的进程访问非连续内存区时,缺页发生,因为该内存区所对应的进程页表中的表项为空。然而,缺页处理程序要检查这个缺页线性地址是否在主内核页表中(也就是init_mm.pgd页全局目录和它的子页表)。一旦处理程序发现一个主内核页表含有这个线性地址的非空项,就把它的值拷贝到相应的进程页表项中,并恢复进程的正常执行

除了vmalloc()函数之外,非连续内存区还能由vmalloc_32()函数分配,该函数与vmalloc()很相似,但是它只从ZONE_NORMALZONE_DMA内存管理区中分配页框。

Linux 2.6 还特别提供了一个vmap()函数,它将映射非连续内存区中已经分配的页框:本质上,该函数接收一组指向页描述符的指针作为参数,调用get_vm_area()得到一个新vm_struct描述符,然后调用map_vm_area()来映射页框。因此该函数与vmalloc()相似,但是它不分配页框。

释放非连续内存区

vfree()函数释放vmalloc()vmalloc_32()创建的非连续内存区,而vunmap()函数释放vmap()创建的内存区。两个函数都使用同一个参数 —— 将要释放的内存区的起始线性地址address;它们都依赖于__vunmap()函数来做实质性的工作。

__vunmap()函数接收两个参数:将要释放的内存区的起始地址的地址addr,以及标志deallocate_pages,如果被映射到内存区内的页框应当被释放到分区页框分配器(调用vfree())中,那么这个标志被置位,否则被清除(vunmap()被调用)。该函数执行以下操作:

  1. 调用remove_vm_area()函数得到vm_struct描述符的地址area,并清除非连续内存区中的线性地址对应的内核的页表项。
  2. 如果deallocate_pages被置位,函数扫描指向页描述符的area->pages指针数组;对于数组的每一个元素,调用__free_page()函数释放页框到分区页框分配器。此外,执行kfree(area->pages)来释放数组本身。
  3. 调用kfree(area)来释放vm_struct描述符。

remove_vm_area()函数执行如下循环:

1
2
3
4
5
6
7
8
9
10
write_lock(&vmlist_lock);
for (p = &vmlist ; (tmp = *p) ; p = &tmp->next) {
if (tmp->addr == addr) {
unmap_vm_area(tmp);
*p = tmp->next;
break;
}
}
write_unlock(&vmlist_lock);
return tmp;

内存区本身通过调用unmap_vm_area()来释放。这个函数接收单个参数,即指向内存区的vm_struct描述符的指针area。它执行下列循环以进行map_vm_area()的反向操作:
1
2
3
4
5
6
7
8
9
10
11
address = area->addr;
end = address + area->size;
pgd = pgd_offset_k(address);
for (i = pgd_index(address); i <= pgd_index(end-1); i++) {
next = (address + PGDIR_SIZE) & PGDIR_MASK;
if (next <= address || next > end)
next = end;
unmap_area_pud(pgd, address, next - address);
address = next;
pgd++;
}

unmap_area_pud()依次在循环中执行map_area_pud()的反操作:
1
2
3
4
5
do {
unmap_area_pmd(pud, address, end-address);
address = (address + PUD_SIZE) & PUD_MASK;
pud++;
} while (address && (address < end));

unmap_area_pmd()函数在循环中执行map_area_pmd()的反操作:
1
2
3
4
5
do {
unmap_area_pte(pmd, address, end-address);
address = (address + PMD_SIZE) & PMD_MASK;
pmd++;
} while (address < end);

最后,unmap_area_pte()在循环中执行map_area_pte()的反操作:
1
2
3
4
5
6
7
do {
pte_t page = ptep_get_and_clear(pte);
address += PAGE_SIZE;
pte++;
if (!pte_none(page) && !pte_present(page))
printk("Whee... Swapped out page in kernel page table/n");
} while (address < end);

在每次循环过程中,ptep_get_and_clear宏将pte指向的页表项设为0。

vmalloc()一样,内核修改主内核页全局目录和它的子页表中的相应项,但是映射第4个GB的进程页表的项保持不变。这是在情理之中的,因为内核永远也不会收回扎根于主内核页全局目录中的页上级目录、页中间目录和页表。

例如,假定内核态的进程访问一个随后要释放的非连续内存区。进程的页全局目录项等于主内核页全局目录中的相应项,由于“缺页异常处理程序”博文中所描述的机制,这些目录项指向相同的页上级目录、页中间目录和页表。unmap_area_pte()函数只清除页表中的项(不回收页表本身)。进程对已释放非连续内存区的进一步访问必将由于空的页表项而触发缺页异常。但是,缺页处理程序会认为这样的访问是一个错误,因为主内核页表不包含有效的表项。

进程地址空间

内核中的函数以相当直接了当的方式获得动态内存。当给用户态进程分配内存时,情况完全不同了:

  • 进程对动态内存的请求被认为是不紧迫的,一般来说,内核总是尽量推迟给用户态进程分配内存。
  • 由于用户进程时不可信任的,因此,内核必须能随时准备捕获用户态进程引起的所有寻址错误。

当用户态进程请求动态内存时,并没有获得请求的页框,而仅仅获得对一个新的线性地址区间的使用权,而这一线性地址区间就成为进程地址空间的一部分

进程地址空间

进程地址空间由允许进程使用的全部线性地址组成。内核可以通过增加或删除某些线程地址区间来动态地修改进程的地址空间。内核通过所谓线性去得资源来标示线性地址区间,线性区是由起始线性地址、长度和一些访问权限来描述的。进程获得新线性区的一些典型情况:

  1. 用户在控制台输入一条命令时,shell进程创建一个新的进程去执行这个命令。结果是,一个全新的地址空间(也就是一组线性区)分配给新进程。
  2. 正在运行的进程有可能决定装入一个完全不同的程序。这时,进程描述符不变,可是在装入这个程序以前所有的线性区却被释放,并有一组新的线性区被分配给这个进程。
  3. 正在运行的进程可能对一个文件执行内存映像。
  4. 进程可能持续向他的用户态堆栈增加数据,知道映像这个堆栈的线性区用完为止,此时,内核也许会决定扩展这个线性区的大小。
  5. 进程可能创建一个IPC共享线性区来与其他合作进程共享数据。此时,内核给这个进程分配一个新的线性区以实现这个方案。
  6. 进程可能通过调用类似malloc这样的函数扩展自己的动态堆。结果是,内核可能决定扩展给这个堆所分配的线性区。

内存描述符

数据结构描述

进程描述符task_struct中的mm字段描述了进程地址空间:

所有内存描述符存放在一个双向链表中,每个描述符在mmlist字段存放相邻元素的地址。链表的每一个元素是init_mmmmlist字段,init_mm是初始化阶段进程0所使用的内存描述符。mmlist_lock自旋锁保护多处理器系统对链表的同时访问。

mm_user字段存放共享mm_struct数据结构的轻量级进程的个数。mm_count字段是内存描述符的主使用计数器,在mm_users次使用计数器中的所有用户在mm_count中只作为一个单位。每当mm_count递减时,内核都要检查它是否变为0,如果是,就要解除这个内存描述符,因为不再有用户使用它。

以下例子解释mm_usersmm_count之间的不同。考虑一个内存描述符由两个轻量级进程共享。它的mm_users字段通常存放的值为2,而mm_count字段存放的值为1(两个所有者进程算作一个)。

如果把内存描述符暂时借给一个内核线程,那么,内核就增加mm_count。这样即两个轻量级进程都死亡,且mm_users字段变为0,这个内存描述符也不被释放,直到内核线程使用完为止,因为mm_count字段仍然大于0。

如果内核想确保内存描述符在一个长操作的中间不被释放,那么就应该增加mm_users字段,而不是mm_count字段的值. 最终的结果是相同的,因为mm_users的增加确保了mm_count来变为0,即使拥有这个内存描述符的所有轻量级进程全部死亡。

mm_alloc()函数用来从slab分配器高速缓存中获取一个新的内存描述符。mm_alloc()调用kmem_cache_alloc()来初始化新的内存描述符,并把mm_countmm_users字段都置为1。mmput()函数递减内存描述符的mm_users字段。如果该字段变为0,这个函数就释放局部描述符表/线性区描述符及由内存描述符所引用的页表,并调用mmdrop()后一个函数把mm_count字段减1,如果该字段变为0,就释放mm_struct数据结构。

内核线程的内存描述符

内核线程仅运行在内核态,它们永远不会访问TASK_SIZE(等于PAGE_OFFSET,x86下通常为0xC0000000)以下的地址。内核线程不用线性区,因而内存描述符的很多字段对内核线程没有意义。大于TASK_SIZE线性地址的相应页表项都是相同的,因此一个内核线程到底使用什么样的页表集根本没关系。为了避免无用的TLB和高速缓存刷新,内核线程使用一组最近运行的普通进程的页表。进程描述符用mmactive_mm处理此情况。

mm字段指向进程所拥有的内存描述符,active_mm字段指向进程运行时所使用的内存描述符。对普通进程而言,这两个字段存放相同的指针。但是,内核线程不拥有内存描述符,因此它们的mm字段总是NULL。内核线程运行时,它的active_mm字段被初始化为前一个运行进程的active_mm值。

内核态的进程为高于TASK_SIZE的线性地址修改页表项,那么它也就应当更新系统中所有进程页表集合中的相应表项。事实上,一旦内核态的一个进程进行了设置。映射应该对内核态的其他所有进程都有效.触及所有进程的页表集合是相当费时的操作,因此,linux采用延迟方式。每当一个高端地址必须被重新映射时,内核就更新根目录在swapper_pg_dir主内存页全局目录中的常规页表集合。这个页全局目录由主内存描述符的pgd字段指向,而主内存描述符存放于init_mm变量。

线性区

Linux通过vm_area_struct的对象实现线性区:

每个线性区描述符表示一个线性地址区间。vm_start字段指向线性区的第一个线性地址,而vm_end字段指向线性区之后的第一个线性地址。vm_end - vm_start表示线性区的长度。vm_mm字段指向拥有这个区间的进程的mm_struct内存描述符。

进程所拥有的线性区从来不重叠,并且内核尽力把新分配的线性区与紧邻的现有线性区进行合并。如果两个线性区的访问权限相匹配,则合并。

vm_ops指向vm_operations_struct结构,存放的是线性区的方法,以下四种方法可应用于UMA系统:

mmap_cache字段保存进程最后一次引用线性区的描述符地址,引用此字段可减少查找一个给定线性地址所在线性区花费的时间。

线性区数据结构

进程所拥有的所有线性区是通过一个简单的链表链接在一起的。每个vm_area_struct元素的vm_next字段指向链表的下一个元素。内核通过进程的内存描述符的nmap字段来查找线性区,其中nmap字段指向链表中的第一个线性区描述符。内存描述符中的map_count字段存放进程所拥有的线性区数目。默认情况下,一个进程可以最多拥有65536个不同的线性区。

进程地址空间、内存描述符和线性区链表之间的关系:

内核频繁执行的一个操作是查找包含指定线性地址的线性区。只要在指定线性地址之后找到一个线性区,搜索就可以结束。多数Linux的线性区非常少,但是如果线性区过于庞大,线性区链表的管理会变得非常低效。

Linux2.6把内存描述符存放在叫做红黑树(read-black-tree)的数据结构中。每个元素(或说节点)通常有两个孩子:左孩子和右孩子。树中的元素被排序。对关键字为N的节点,它的左子树上的所有元素的关键字都比N小;相反,它的右子树上的所有元素的关键字都比N大;节点的关键字被写入节点内部。而除了具有基本的二叉排序树的特点以外,红-黑树必须满足下列5条规则:

  1. 每个节点必须或为黑或为红。
  2. 树的根必须为黑。
  3. 红节点的孩子必须为黑。
  4. 从一个节点到后代叶子节点的每个路径都包含相同数量的黑节点。当统计黑节点个数时,空指针也算作黑节点。

这4条规则确保具有n个内部节点的任何红一黑树其高度最多为2 × log(n+1)。在红-黑树中搜索一个元素因此而变得非常高效,因为其操作的执行时间与树大小的对数成线性比例。换句话说,双倍的线性区个数只多增加一次循环。

为了存放进程的线性区,Linux既使用了链表,又使用了红黑树。这两种结构包含指向同一线性区描述符的指针,当插入或删除一个线性区描述符的时候,内核通过红黑树搜索前后元素,并用搜索结果快速更新链表而不用扫描链表。链表的头由内存描述符的mmap字段所指向,任何线性区对象都在vm_next字段存放指向链表下一个元素的指针。红黑树的首部由mm_rb字段所指向,任何线性区对象都在类型为rb_nodevm_rb字段存放节点颜色以及指向双亲、左孩子和右孩子的指针。

一般的,红黑树用来确定含有指定地址的线性区,而链表通常在扫描整个线性区集合时使用。

线性区访问权限

每个线性区都由一组号码连续的页组成。线性区的大小是4KB的倍数(必须包含完整的页),而栈的大小却是任意的。与页相关的几种标志:

  • 在每个页表项中存放的几个标志,如Read/WritePresentUser/Supervisor
  • 存放在每个页描述符flags字段的一组

第一种标志由80x86硬件用来检查能否执行所请求的寻址类型,第二种则由Linux用于许多不同目的,第三种则被存放在vm_area_struct描述符的vm_flags字段中。一些标志给内核提供关于这个线性区的全部页信息。

线性区描述符所包含的页访问权限可以任意组合。例如,存在这样一种可能性,允许一个线性区中的页可以执行但是不可以读取。为了有效地实现这种保护方案,与线性区的页相关的访问权限(读、写及执行)必须被复制到相应的所有表项中,以便由分页单元直接执行检查。换句话说,页访问权限表示何种类型的访问应该产生一个缺页异常。Linux委派缺页处理程序查找导致缺页的原因,因为缺页处理程序实现了许多页处理策略。

页表标志的初值存放在vm_area_struct描述符的vm_page_prot字段中。当增加一个页时,内核根据vm_page_prot字段的值设置相应页表项中的标志。

然而,并不能把线性区的访问权限直接转换为页保护位:

  • 在某些情况下,即使由相应线性区描述符的vm_flags字段所指定的某个页的访问权限允许对该页进行访问,但是,对该页的访问还是应当产生一个缺页异常。例如“写时复制”的情况,内核可能决定把属于两个不同进程的两个完全一样的可写私有页(它的VM_SHARE标志被清0)存入同一个页框中;在这种情况下,无论哪一个进程试图改动这个页都应当产生一个异常。
  • 80x86处理器的页表仅有两个保护位,即Read/WriteUser/Supervisor标志。此外,一个线性区所包含的任何一个页的User/Supervisor标志必须总置为1,因为用户态进程必须总能够访问其中的页。
  • 启用PAE的新近Intel Pentium 4微处理器,在所有64位页表项中支持NX(No eXecute)标志。

如果内核没有被编译成支持PAE,那么Linux采取以下规则以克服80x86微处理器的硬件限制:

  • 读访问权限总是隐含着执行访问权限,反之亦然。
  • 写访问权限总是隐含着读访问权限。

反之,如果内核被编译成支持PAE,而且CPU有NX标志,Linux就采取不同的规则:

  • 行访问权限总是隐含着读访问权限。
  • 访问权限总是隐含着读访问权限。

此外,为了能做到“写时复制”中适当推迟页框的分配,只要相应的页不是由多个进程共享,那么这种页框都是写保护的。因此,要根据以下规则精简由读、写、执行和共享访问权限的16种可能组合:

  • 如果页具有写和共享两种访问权限,那么,Read/Write位被设置为1。
  • 如果页具有读或执行访问权限,但是既没有写也没有共享访问权限,那么,Read/Write位被清0。
  • 如果支持NX位,而且页没有执行访问权限,那么,把NX位设置为1。
  • 如果页没有任何访问权限,那么,Present位被清0,以便每次访问都产生一个缺页异常。然而,为了把这种情况与真正的页框不存在的情况相区分,Linux还把Page size位置为1

访问权限的每种组合所对应的精简后的保护位存放在protection_map数组的16个元素中(mm/Mmap.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pgprot_t protection_map[16] = {
__P000, __P001, __P010, __P011, __P100, __P101, __P110, __P111,
__S000, __S001, __S010, __S011, __S100, __S101, __S110, __S111
};

//include/asm-i386/Pgtable.h
#define __P000 PAGE_NONE
#define __P001 PAGE_READONLY
#define __P010 PAGE_COPY
#define __P011 PAGE_COPY
#define __P100 PAGE_READONLY_EXEC
#define __P101 PAGE_READONLY_EXEC
#define __P110 PAGE_COPY_EXEC
#define __P111 PAGE_COPY_EXEC

#define __S000 PAGE_NONE
#define __S001 PAGE_READONLY
#define __S010 PAGE_SHARED
#define __S011 PAGE_SHARED
#define __S100 PAGE_READONLY_EXEC
#define __S101 PAGE_READONLY_EXEC
#define __S110 PAGE_SHARED_EXEC
#define __S111 PAGE_SHARED_EXEC

例如:
1
2
#define COPY_EXEC /
__pgprot(_PAGE_PRESENT | _PAGE_USER | _PAGE_ACCESSED)

线性区的处理

对线性区描述符进行操作的低层函数应当被看作简化了do_map()do_unmap()实现的辅助函数。这两个函数分别扩大或者缩小进程的地址空间。这两个函数所处的层次比我们这里所考虑函数的层次要高一些,它们并不接受线性区描述符作为参数,而是使用一个线性地址区间的起始地址、长度和访问限权作为参数。

查找给定地址的最邻近区

find_vma()函数有两个参数:进程内存描述符的地址mm线性地址addr,查找线性区的vm_end字段大于addr的第一个线性区的位置,并返回这个线性区描述符的地址。

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
struct vm_area_struct * find_vma(struct mm_struct * mm, unsigned long addr)
{
struct vm_area_struct *vma = NULL;

if (mm) {
/* Check the cache first. */
/* (Cache hit rate is typically around 35%.) */
vma = mm->mmap_cache;
if (!(vma && vma->vm_end > addr && vma->vm_start <= addr)) {
struct rb_node * rb_node;

rb_node = mm->mm_rb.rb_node;
vma = NULL;

while (rb_node) {
struct vm_area_struct * vma_tmp;

vma_tmp = rb_entry(rb_node,
struct vm_area_struct, vm_rb);

if (vma_tmp->vm_end > addr) {
vma = vma_tmp;
if (vma_tmp->vm_start <= addr)
break;
rb_node = rb_node->rb_left;
} else
rb_node = rb_node->rb_right;
}
if (vma)
mm->mmap_cache = vma;
}
}
return vma;
}

find_vma()函数所选择的线性区并不一定要包含addr,也就是说vm_end可能会小于addr,因为addr可能位于任何线性区之外,这时候我们就要新建一个线性区对象,但不是在find_vma函数中创建,find_vma函数仅返回mm->mmap_cache,也就是当前的那个vma结构。

每个内存描述符包含一个mmap_cache字段,这个字段保存进程最后一次引用线性区的描述符地址。引进这个附加的字段是为了减少查找一个给定线性地址所在线性区而花费的时间。程序中引用地址的局部性使下面这种情况出现的可能性很大:如果检查的最后一个线性地址属于某一给定的线性区,那么,下一个要检查的线性地址也属于这一个线性区

因此,该函数一开始就检查由mmap_cache所指定的线性区是否包含addrvma && vma->vm_end > addr && vma->vm_start <= addr)。如果是,就返回这个线性区描述符的指针:

1
2
3
vma = mm->mmap_cache;
if (vma && vma->vm_end > addr && vma->vm_start <= addr)
return vma;

否则,必须扫描进程的线性区,红-黑树算法就用到了:

1
2
3
4
5
6
7
8
9
10
11
12
rb_node = mm->mm_rb.rb_node;
vma = NULL;
while (rb_node) {
vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);
if (vma_tmp->vm_end > addr) {
vma = vma_tmp;
if (vma_tmp->vm_start <= addr)
break;
rb_node = rb_node->rb_left;
} else
rb_node = rb_node->rb_right;
}

函数使用的宏rb_entry,从指向红-黑树中一个节点的指针导出相应线性区描述符的地址:

1
2
3
4
5
#define rb_entry(ptr, type, member) container_of(ptr, type, member)
#define container_of(ptr, type, member) ({ /
const typeof( ((type *)0)->member ) *__mptr = (ptr); /
(type *)( (char *)__mptr - offsetof(type,member) );})
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

那么,根据上面的代码,翻译过来就是:

1
2
vma_tmp = ({ typeof( ((vm_area_struct *)0)->vm_rb ) *__mptr = (rb_node); /
(type *)( (char *)__mptr - ((size_t) &((vm_area_struct *)0)->vm_rb) );})

第一行typeof( ((vm_area_struct *)0)->vm_rb )得到vm_rb的类型,即指向顶地址0的那个vm_area_struct结构vm_rb字段的那个类型,即rb_node类型,把他用一个临时变量__mptr表示,其值为函数类的临时变量rb_node,所以*__mptr就是临时变量rb_node的真实地址值。那么第二行用这个*__mptr减去vm_rb成员相对于vm_area_struct顶部的偏移值,就得到了其宿主的vm_area_struct的地址的真实值,即最后得出对应的线性区vm_area_struct的地址。

函数find_vma_prev()find_vma()类似,不同的是它把函数选中的前一个线性区描述符的指针赋给附加参数的结果参数pprev。最后,函数find_vma_prepare()确定新叶子节点在与给定线性地址对应的红-黑树中的位置,并返回前一个线性区的地址和要插入的叶子节点的父节点的地址。

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
static struct vm_area_struct *
find_vma_prepare(struct mm_struct *mm, unsigned long addr,
struct vm_area_struct **pprev, struct rb_node ***rb_link,
struct rb_node ** rb_parent)
{
struct vm_area_struct * vma;
struct rb_node ** __rb_link, * __rb_parent, * rb_prev;

__rb_link = &mm->mm_rb.rb_node;
rb_prev = __rb_parent = NULL;
vma = NULL;

while (*__rb_link) {
struct vm_area_struct *vma_tmp;

__rb_parent = *__rb_link;
vma_tmp = rb_entry(__rb_parent, struct vm_area_struct, vm_rb);

if (vma_tmp->vm_end > addr) {
vma = vma_tmp;
if (vma_tmp->vm_start <= addr)
return vma;
__rb_link = &__rb_parent->rb_left;
} else {
rb_prev = __rb_parent;
__rb_link = &__rb_parent->rb_right;
}
}

*pprev = NULL;
if (rb_prev)
*pprev = rb_entry(rb_prev, struct vm_area_struct, vm_rb);
*rb_link = __rb_link;
*rb_parent = __rb_parent;
return vma;
}

查找一个与给定的地址区间相重叠的线性区

find_vma_intersection()函数查找与给定的线性地址区间相重叠的第一个线性区mm参数指向进程的内存描述符,而线性地址start_addrend_addr指定这个区间。

1
2
3
4
5
6
7
8
static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr)
{
struct vm_area_struct * vma = find_vma(mm,start_addr);

if (vma && end_addr <= vma->vm_start)
vma = NULL;
return vma;
}

如果没有这样的线性区存在,函数就返回一个NULL指针。准确地说,如果find_vma()函数回一个有效的地址,但是所找到的线性区是从这个线性地址区间的末尾开始的,vma就被置为NULL。

查找一个空闲的地址区间

get_unmapped_area()查找进程的地址空间以找到一个可以使用的线性地址区间。len参数指定区间的长度,addr指定必须从哪个地址开始查找。查找成功则返回这个新区间的地址;否则返回错误码-ENOMEM

1
2
static inline unsigned long get_unmapped_area(struct file * file, unsigned long addr,
unsigned long len, unsigned long pgoff, unsigned long flags)

如果参数addr不等于NULL,函数就检查所指定的地址是否在用户态空间(if (addr > TASK_SIZE - len))并与页边界对齐(if (addr & ~PAGE_MASK))。接下来,函数根据线性地址区间是否应该用于文件内存映射或匿名内存映射,调用两个方法(get_unmapped_area文件操作file->f_op->get_unmapped_area和内存描述符的get_unmapped_area方法current->mm->get_unmapped_area(file, addr, len, pgoff, flags))中的一个。在前一种情况下,函数执行get_unmapped_area文件操作。

第二种情况下,函数执行内存描述符的get_unmapped_area方法。根据进程的线性区类型,由函数arch_get_unmapped_area()arch_get_unmapped_area_topdown()实现get_unmapped_area方法。通过系统调用mmap(),每个进程都可能获得两种不同形式的线性区:一种从线性地址0x40000000(1G)开始并向高端地址增长,即所谓的“堆”;另一种正好从用户态堆栈开始并向低端地址增长,即所谓的“栈”。前者就调用arch_get_unmapped_area函数,后者就会调用arch_get_unmapped_area_topdown函数,后面博文会详细讨论。

现在我们讨论函数arch_get_unmapped_area(),在分配从低端地址向高端地址移动的线性区时使用这个函数。它本质上等价于下面的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (len > TASK_SIZE)
return -ENOMEM;
addr = (addr + 0xfff) & 0xfffff000;
if (addr && addr + len <= TASK_SIZE) {
vma = find_vma(current->mm, addr);
if (!vma || addr + len <= vma->vm_start)
return addr;
}
start_addr = addr = mm->free_area_cache;
for (vma = find_vma(current->mm, addr); ; vma = vma->vm_next) {
if (addr + len > TASK_SIZE) {
if (start_addr == (TASK_SIZE/3+0xfff)&0xfffff000)
return -ENOMEM;
start_addr = addr = (TASK_SIZE/3+0xfff)&0xfffff000;
vma = find_vma(current->mm, addr);
}
if (!vma || addr + len <= vma->vm_start) {
mm->free_area_cache = addr + len;
return addr;
}
addr = vma->vm_end;
}

函数首先检查区间的长度是否在用户态下线性地址区间的限长TASK_SIZE(通常为3GB)之内。如果addr不为0,函数就试图从addr开始分配区间。为了安全起见,函数把addr的值调整为4KB的倍数(addr = (addr + 0xfff) & 0xfffff000)。

如果addr等于0或前面的搜索失败,函数arch_get_unmapped_area()扫描用户态线性地址空间以查找一个可以包含新区的足够大的线性地址范围,但任何已有的线性区都不包括这个地址范围。为了提高搜索的速度,让搜索从最近被分配的线性区后面的线性地址开始(vma = find_vma(current->mm, addr))。

把内存描述符的字段mm->free_area_cache初始化为用户态线性地址空间的三分之一(通常是1GB,start_addr = addr = (TASK_SIZE/3+0xfff)&0xfffff000),并在以后创建新线性区时对它进行更新。如果函数找不到一个合适的线性地址范围,就从用户态线性地址空间的三分之一的开始处重新开始搜索:其实,用户态线性地址空间的三分之一是为有预定义起始线性地址的线性区(典型的是可执行文件的正文段、数据段和bss段)而保留的。

函数调用find_vma()以确定搜索起点之后第一个线性区终点的位置。可能出现三种情况:

  • 如果所请求的区间大于正待扫描的线性地址空间部分(addr + len > TASK-SIZE),函数就从用户态地址空间的三分之一处重新开始搜索,如果已经完成第二次搜索,就返回-ENOMEM(没有足够的线性地址空间来满足这个请求)。
  • 刚刚扫描过的线性区后面的空闲区没有足够的大小(vma != NULL && vma->vm_start < addr + len)。此时,继续考虑下一个线性区。
  • 如果以上两种情况都没有发生,则找到一个足够大的空闲区,此时,函数返回addr。

向内存描述符链表中插入一个线性区

insert_vm_struct()函数在线性区对象链表和内存描述符的红-黑树中插入一个vm_area_struct结构。这个函数使用两个参数:mm指定进程内存描述符的地址vmp指定要插入的vm_area_struct对象的地址。线性区对象的vm_startvm_end字段必定已经初始化过。

该函数调用find_vma_prepare()在红-黑树mm->mm_rb中查找vma应该位于何处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int insert_vm_struct(struct mm_struct * mm, struct vm_area_struct * vma)
{
struct vm_area_struct * __vma, * prev;
struct rb_node ** rb_link, * rb_parent;

if (!vma->vm_file) {
BUG_ON(vma->anon_vma);
vma->vm_pgoff = vma->vm_start >> PAGE_SHIFT;
}
__vma = find_vma_prepare(mm,vma->vm_start,&prev,&rb_link,&rb_parent);
if (__vma && __vma->vm_start < vma->vm_end)
return -ENOMEM;
vma_link(mm, vma, prev, rb_link, rb_parent);
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
static struct vm_area_struct *
find_vma_prepare(struct mm_struct *mm, unsigned long addr,
struct vm_area_struct **pprev, struct rb_node ***rb_link,
struct rb_node ** rb_parent)
{
struct vm_area_struct * vma;
struct rb_node ** __rb_link, * __rb_parent, * rb_prev;

__rb_link = &mm->mm_rb.rb_node;
rb_prev = __rb_parent = NULL; /* rb_prev内部变量表示vma的前一个vm_area_struct结构在树中的位置 */
vma = NULL;

while (*__rb_link) {
struct vm_area_struct *vma_tmp;

__rb_parent = *__rb_link;
vma_tmp = rb_entry(__rb_parent, struct vm_area_struct, vm_rb);

if (vma_tmp->vm_end > addr) {
vma = vma_tmp;
if (vma_tmp->vm_start <= addr)
return vma;
__rb_link = &__rb_parent->rb_left;
} else {
rb_prev = __rb_parent;
__rb_link = &__rb_parent->rb_right;
}
}

*pprev = NULL;
if (rb_prev)
*pprev = rb_entry(rb_prev, struct vm_area_struct, vm_rb);
*rb_link = __rb_link;
*rb_parent = __rb_parent;
return vma;
}

然后,insert_vm_struct()又调用vma_link()函数:

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
static void vma_link(struct mm_struct *mm, struct vm_area_struct *vma,
struct vm_area_struct *prev, struct rb_node **rb_link,
struct rb_node *rb_parent)
{
struct address_space *mapping = NULL;

if (vma->vm_file)
mapping = vma->vm_file->f_mapping;

if (mapping) {
spin_lock(&mapping->i_mmap_lock);
vma->vm_truncate_count = mapping->truncate_count;
}
anon_vma_lock(vma);

__vma_link(mm, vma, prev, rb_link, rb_parent);
__vma_link_file(vma);

anon_vma_unlock(vma);
if (mapping)
spin_unlock(&mapping->i_mmap_lock);

mm->map_count++;
validate_mm(mm);
}

1
2
3
4
5
6
7
8
9
static void
__vma_link(struct mm_struct *mm, struct vm_area_struct *vma,
struct vm_area_struct *prev, struct rb_node **rb_link,
struct rb_node *rb_parent)
{
__vma_link_list(mm, vma, prev, rb_parent);
__vma_link_rb(mm, vma, rb_link, rb_parent);
__anon_vma_link(vma);
}

vma_link依次执行以下操作:

  1. mm->mmap所指向的链表中插入线性区
  2. 在红-黑树mm->mm_rb中插入线性区
  3. 如果线性区是匿名的,就把它插入以相应的anon_vma数据结构作为头节点的链表中(__anon_vma_link(vma))。
  4. 递增mm->map_count计数器(mm->map_count++;)。

如果线性区包含一个内存映射文件,则vma_link()函数执行在回收页框相关博文描述的其他任务。

__vma_unlink()函数接收的参数为一个内存描述符地址mm和两个线性区对象地址vma和prev。两个线性区都应当属于mm,prev应当在线性区的排序中位于vma之前。该函数从内存描述符链表和红-黑树中删除vma,如果mm->mmap_cache(存放刚被引用的线性区)字段指向刚被删除的线性区,则还要对mm->mmap_cache进行更新。

分配线性地址空间

do_mmap()函数为当前进程创建并初始化一个新的线性区。不过分配成功之后,可以把这个新的线性区与进程已有的其他线性区合并。函数相关参数:

  • fileoffset:如果新的线性区将把一个文件映射到内存,则使用文件描述符指针和文件偏移量offset
  • addr:这个线性地址指定从何时开始查找一个空闲的区间。
  • len:线性地址区间的长度。
  • prot:这个参数指定这个线性区所包含页的访问权限。
  • flag:这个参数指定线性区的其他标志。

    • MAP_GROWSDOWNMAP_LOCKEDMAP_DENYWRITEMAP_EXECUTABLE
      • 它们的含义与“线性区数据结构”博文中所列出标志的含义相同。
    • MAP_SHAREDMAP_PRIVATE
      • 前一个标志指定线性区中的页可以被几个进程共享;后一个标志作用和两个标志都指向vm_area_struct描述符中的VM_SHARED标志。
    • MAX_FIXED
      • 区间的起始地址必须是由参数addr所指定的。
    • MAX_ANONYMOUS
      • 没有文件与这个线性区相关联。
    • MAP_NORESERVE
      • 函数不必预先检查空闲页框的数目。
    • MAP_POPULATE
      • 函数应该为线性区建立的映射提前分配需要的页框。该标志仅对映射文件的线性区和IPC共享的线性区有意义。
    • MAX_NONBLOCK
      • 只有在MAP_POPULATE标志置位时才有意义:提前分配页框时,函数肯定不阻塞。

    我们看到do_mmap()函数对offset的值进行一些初步检查,然后执行do_mmap_pgoff()函数。这里假设新的线性地址区间映射的不是磁盘文件,仅对实现匿名线性区的do_mmap_pgoff()函数进行说明(mm/Mmap.c):

  • 首先检查参数的值是否正确,所提的请求是否能被满足。尤其是要检查以下不能满足请求的条件:
    • 线性地址区间的长度为0或者包含的地址大于TASK_SIZE
    • 进程已经映射了过多的线性区,mm内存描述符的map_count字段的值超过了允许的最大值
    • flag指定新线性地址区间的页必须被锁在RAM中,但不允许创建上锁的线性区,或者进程加锁页的总数超过了保存在进程描述符signal->rlim[RLIMIT_MEMLOCK].rlim_cur字段中的阈值
    • 以上任意一个成立则do_mmap_pgoff()终止并返回一个负值
  • 执行内存描述符的get_unmapped_area()方法
  • 通过把存放在prot和flags参数中的值进行组合来计算新线性区描述符的标志
    • 只有在prot中设置了相应的PROT_READPROT_WRITEPROT_EXEC标志,calc_vm_prot_bits()函数才在vm_flags中设置VM_READVM_WRITEVM_EXEC标志;只有在flags设置了相应的MAP_GROWSDOWNMAP_DENYWRITEMAP_EXECUTABLEMAP_LOCKED标志,calc_vm_flag_bits()也才在VM_flags中设置VM_GROWSDOWNVM_DENYWRITEVM_EXECUTABLEVM_LOCKED标志。在vm_flags中还有几个标志被置为1:VM_MAYREADVM_MAYWRITEVM_MAYEXEC,在mm->def_flags中所有线性区的默认标志,以及如果线性区的页与其他进程共享时的VM_SHAREDVM_MAYSHARE
1
2
3
4
5
6
vm_flags = calc_vm_prot_bits(prot) | calc_vm_flag_bits(flags) |
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
if (flags & MAP_LOCKED) {
if (!can_do_mlock())
return -EPERM;
vm_flags |= VM_LOCKED;
  • 调用find_vma_prepare()确定处于新区间之前的线性区对象的位置,以及在红-黑树中新线性区的位置
1
2
3
4
5
6
vma = find_vma_prepare(mm, addr, &prev, &rb_link, &rb_parent);
if (!vma || vma->vm_start >= addr + len) {
break;
if (do_munmap(mm, addr, len))
return -ENOMEM;
}
  • 检查插是否还存在与新区间重叠的线性区,这种情况发生在函数返回一个非空的地址,这个地址指向一个线性区,而该区的起始位置位于新区间结束地址之前的时候。这时do_mmap_pgoff()调用do_munmap()删除新的区间。
  • 检查新的线性区是否引起进程地址空间的大小(mm->total_vm<<PAGE_SHIFT) + len超过存放在进程描述符signal->rlim[RLIMIT_AS].rlim_cur字段中的阈值。
    • 如果是,就返回出错码-ENOMEM。注意,这个检查只在这里进行,而不在前面与其他检查一起进行,
    • 因为一些线性区可能在刚才调用do_munmap()时候被删除。
  • 如果在flags参数中没有设置MAP_NORESERVE标志,新的线性区包含私有可写页,并且没有足够的空闲页框,则返回出错码-ENOMEM;这最后一个检查是由security_vm_enough_memory()函数实现的。
  • 如果新区间是私有的(没有设置VM_SHARED),且映射的不是磁盘上的一个文件,那么,调用vma_merge()检查前一个线性区是否可以以这样的方式进行扩展来包含新的区间。
    • 当然,前一个线性区必须与在vm_flags局部变量中存放标志的那些线性区具有完全相同的标志。
    • 如果前一个线性区可以扩展,那么,vma_merge()也试图把它与随后的线性区进行合并(这发生在新区间填充两个线性区之间的空洞,且三个线性区全部具有相同的标志的时候)。
    • 万一在扩展前一个线性区时获得成功,则跳到12步。
  • 调用slab分配函数kmem_cache_alloc()为新的线性区分配一个vm_area_struct数据结构
  • 初始化新的线性区对象(由vma指向):
1
2
3
4
5
6
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = protection_map[vm_flags & (VM_READ|VM_WRITE|VM_EXEC|VM_SHARED)];
vma->vm_pgoff = pgoff;
  • 如果MAP_SHARED标志被设置(以及新的线性区不映射磁盘上的文件),则该线性区是一个共享匿名区:
    • 调用shmem_zero_setup()对它进行初始化。共享匿名区主要用于进程间通信。
  • 调用vma_link()把新线性区插人到线性区链表和红-黑树中
  • 增加存放在内存描述符total_vm字段中的进程地址空间的大小
  • 如果设置了VM_LOCKED标志,就调用make_pages_present()连续分配线性区的所有页,并把它们锁在RAM中
1
2
mm->locked_vm += len >> PAGE_SHIFT;
make_pages_present(addr, addr + len);
  • make_pages_present()函数按如下方式调用get_user_pages()

    1
    2
    write = (vma->vm_flags & VM_WRITE) != 0;
    get_user_pages(current, current->mm, addr, len, write, 0, NULL, NULL);
  • get_user_pages()函数在addraddr+len之间页的所有起始线性地址上循环;

    • 对于其中的每个页,该函数调用follow_page()检查在当前页表中是否有到物理页的映射。
    • 如果没有这样的物理页存在,则get_user_pages()调用handle_mm_fault()
    • 以后我们会看到,后一个函数分配一个页框并根据内存描述符的vm_flags字段设置它的页表项。
  • 最后,函数通过返回新线性区的线性地址而终止。

释放线性地址区间

内核使用do_munmap()函数从当前进程的地址空间中删除一个线性地址区间。参数mm为进程内存描述符的地址,start地址区间的起始地址,len长度。

1
do_munmap(struct mm_struct *mm, unsigned long addr, size_t len)    //

该函数主要两个阶段:

  • 第一阶段,扫描进程所拥有的线性区链表,并把包含在进程地址空间的线性地址区间中的所有线性区从链表中解除链接。
  • 第二阶段,更新进程的页表,并把第一阶段找到并标识出的线性区删除。函数利用后面要说明的spilt_vma()unmap_region()函数。

do_munmap()执行下边的步骤:

  • 参数值进行一些初步检查:如果线性地址区间所含的地址大于TASK_SIZE,如start不是4096的倍数,或者如果线性地址区间的长度为0,则函数返回一个错误代码-EINVAL。
  • 确定要删除的线性地址区间之后第一个线性区结构vma的位置(mpnt->end > start),如果有这样的线性区:mpnt = find_vma_prev(mm, start, &prev);
  • 如果没有这样的线性区,也没有与线性地址区间重叠的线性区,就什么都不做,因为该线性地址区间上没有线性区:
  • 如果线性区的起始地址在线性区vma内,就调用split_vma()把线性区vma划分成两个较小的区:一个区在线性地址区间外部,而另一个在区间内部。
1
2
3
4
5
6
if (start > vma->vm_start) {
int error = split_vma(mm, vma, start, 0);
if (error)
return error;
prev = vma;
}
  • 更新局部变量prev,以前它存储的是指向线性区vma前面一个线性区的指针,现在要让它指向vma,即指向线性地址区间外部的那个新线性区。这样,prev仍然指向要删除的第一个线性区前面的那个线性区。
  • 如果线性地址区间的结束地址在一个线性区内部,就再次调用split_vma()把最后重叠的那个线性区划分成两个较小的区:一个区在线性地址区间内部,而另一个在区间外部。
1
2
3
4
5
6
last = find_vma(mm, end);
if (last && end > last->vm_start) {
int error = split_vma(mm, last, end, 1);
if (error)
return error;
}
  • 如果线性地址区间正好包含在某个线性区内部,就必须用用个较小的新线性区取代该线性区。当发生这种情况时,在第4步和第5步把该线性区分成三个较小的线性区:删除中间的那个线性区,而保留第一个和最后一个线性区。
  • 更新mpnt的值,使它指向线性地址区间的第一个线性区。如果prev为NULL,即没有上述线性区,就从mm->mmap获得第一个线性区的地址:mpnt = prev? prev->vm_next: mm->mmap;
  • 调用detach_vmas_to_be_unmapped()从进程的线性地址空间中删除位于线性地址区间中的线性区。
    • 要删除的线性区的描述符放在一个排好序的链表中,
    • 局部变量mpnt指向该链表的头(实际上,这个链表只是进程初始线性区链表的一部分)。
  • 获得mm->page_table_lock自旋锁
  • 调用unmap_region()清除与线性地址区间对应的页表项并释放相应的页框:unmap_region(mm, vma, prev, start, end);
  • 释放mm->page_table_lock自旋锁
  • 释放建立链表时收集的线性区描述符
    • 对在链表中的所有线性区调用unmap_vma()函数,本质上:
      • 更新mm->total_vmmm->locked_vm字段
      • 执行内存描述符的mm->unmap_area方法,根据进程线性区的不同类型选择arch_unmap_area()arch_unmap_area_topdown()中的一个来实现mm->unmap_area方法
      • 调用线性区的close方法
      • 如果线性区是匿名的,则函数把它从mm->anon_vma所指向的匿名线性区链表中删除
      • 调用kmem_cache_free()释放线性区描述符
  • 返回成功

split_vma()函数

split_vma()函数把与线性地址区间交叉的线性区划分成两个较小的区,一个在线性地址区间外部,另一个在区间的内部。该函数接收4个参数:内存描述符指针mm线性区描述符指针vma(标识要被划分的线性区),表示区间与线性区之间交叉点的地址addr,以及表示区间与线性区之间交叉点在区间起始处还是结束处的标志new_below。我们来看看该函数的代码:

  • 调用kmem_cache_alloc()获得一个新的线性区描述符vm_area_struct,并把它的地址存放在新的局部变量中new,如果没有可用的空闲空间,就返回-ENOMEM。
  • vma描述符的字段值初始化新描述符的字段。
  • 如果new_below标志等于1,说明线性地址区间的结束地址在vma线性区的内部,因此必须把新线性区放在vma线性区的前面,所以函数把字段new->vm_endvma->vm_start都赋值为addr。
  • 相反,new_below为0,说明线性地址区间的起始地址在vma线性区的内部,因此必须把新线性区放在vma线性区之后,所以函数把new->vm_startvma->vm_end字段都赋值为addr
  • 如果定义了新线性区的open方法,函数就执行它。
  • 根据new_below的值调用vma_adjust函数把新线性区描述符链接到线性区链表mm->mmap和红-黑树mm->mm_rb。此外,vma_adjust函数还要根据线性区vma的最新大小对红-黑树进行调整。

unmap_region()函数

unmap_region()函数遍历线性区链表并释放它们的页框,该函数有五个参数:

  • 内存描述符指针mm
  • 指向第一个被删除线性区描述符的指针vma
  • 指向进程链表中vma前面的线性区的指针prev
  • 界定被删除线性地址区间的范围的两个地址startend

  • 调用lru_add_drain()

  • 调用tlb_gather_mmu()函数初始化每CPU变量mmu_gathersmmu_gathers的值依赖于CPU体系结构:通常该变量应该存放成功更新进程页表项所需要的所有信息。在80x86体系结构中,tlb_gather_mmu()函数只是简单地把内存描述符指针mm的值赋给本地CPU的mmu_gathers变量。
  • mmu_gathers变量的地址保存在局部变量tlb中。
  • 调用unmap_vmas()扫描线性地址空间的所有页表项:如果只有一个有效CPU,函数就调用free_swap_and_cache()反复释放相应的页(页面回收中会提到);否则,函数就把相应页描述符的指针保存在局部变量mmu_gathers中。
  • 调用free_pgtables(tlb, prev, start, end)回收在上一步已经清空的进程页表。
  • 调用tlb_finish_mmu(tlb, start, end)结束unmap_region()函数的工作,tlb_finish_mmu(tlb, start, end)依次执行下面的操作:
    • 调用flush_tlb_mm()刷新TLB。
    • 在多处理器系统中,调用freepages_and_swap_cache()释放页框,这些页框的指针已经集中存放在mmu_gather数据结构中了。

缺页异常处理程序

Linux的缺页(Page Fault)异常处理程序必须区分一下两种情况:

  • 由编程错误引起的异常
  • 由引用属于进程地址空间还尚未分配物理页框的页所引起的异常。

线性描述符可以让缺页异常处理程序非常有效的完成它的工作。do_page_fault()函数是80x86上的缺页中断服务程序,它把引起缺页的线性地址和当前进程的线性区相比较,从而能够选择适当的方法处理异常。

详细流程如图所示,图中标识good_areabad_areano_context等是出现在do_page_fault()中的标记,它们有助于你理清流程图中的块与代码中特定行之间的关系:

do_page_fault()函数接收以下输入参数:

  • pt_regs结构的地址regs,该结构包含当异常发生时的微处理器寄存器的值。
  • 3位的error_code,当异常发生时由控制单元压入栈中。这些位有以下含义:
    • 如果第0位被清0,则异常由访问一个不存在的页所引起(页表项中的Present标志被清0);否则,如果第0位被设置,则异常由无效的访问权限所引起。
    • 如果第1位被清0,则异常由读访问或者执行访问所引起;如果该位被设置,则异常由写访问所引起。
    • 如果第2位被清0,则异常发生在处理器处于内核态时;否则,异常发生在处理器处于用户态时。

do_page_fault()的第一步操作是读取引起缺页的b线性地址baddress = read_cr2();)。当异常发生时,CPU控制单元把这个值存放在cr2控制寄存器中:

1
2
3
4
5
6
7
#define read_cr2() ({ /
unsigned int __dummy; /
__asm__ __volatile__( /
"movl %%cr2,%0/n/t" /
:"=r" (__dummy)); /
__dummy; /
})

函数将这个线性地址保存在address局部变量中,并把指向current进程描述符的指针保存在tsk局部变量中(tsk = current;)。

函数首先检查引起缺页的线性地址是否属于内核空间,即是否是第3GB~4GB之间:

1
2
3
4
5
6
7
8
9
si_code = SEGV_MAPERR;
if (unlikely(address >= TASK_SIZE)) { /* 0xd = 1101 */
if (!(error_code & 0x0000000d) && vmalloc_fault(address) >= 0)
return;
if (notify_page_fault(DIE_PAGE_FAULT, "page fault", regs, error_code, 14,
SIGSEGV) == NOTIFY_STOP)
return;
goto bad_area_nosemaphore;
}

如果发生了由于内核试图访问不存在的页框引起的异常,就去执行vmalloc_fault(address)函数,该部分代码处理可能由于在内核态访问非连续内存区而引起的缺页,否则就跳转去执行bad_area_nosemaphore标记处的代码。接下来缺页处理程序检查异常发生时内核是否正在执行一些关键例程或运行内核线程。如果缺页发生在下面任何一种情况,则in_atomic()产生等于1的值。

  • 内核正在执行中断处理程序或可延迟函数。
  • 内核正在禁用内核抢占的情况下执行临界区代码。

如果缺页的确发生在中断处理程序、可延迟函数、临界区或内核线程中,do_page_fault()就不会试图把这个线性地址与current的线性区做比较。再一个,内核线程从来不使用小于TASK_SIZE的地址。同样,中断处理程序、可延迟函数和临界区代码也不应该使用小于TASK_SIZE的地址,因为这可能导致当前进程的阻塞。

现在,让我们假定缺页没有发生在中断处理程序、可延迟函数、临界区或者内核线程中。于是函数必须检查进程所拥有的线性区以决定引起缺页的线性地址是否包含在进程的地址空间中,为此必须获得进程的mmap_sem读/写信号量:

1
2
3
4
5
6
if (!down_read_trylock(&mm->mmap_sem)) {
if ((error_code & 4) == 0 &&
!search_exception_tables(regs->eip))
goto bad_area_nosemaphore;
down_read(&mm->mmap_sem);
}

如果内核bug和硬件故障有可能被排除,那么当缺页发生时,当前进程就不会有为写而获得信号量mmap_sem。尽管如此,do_page_fault()还是想确定的确没有获得这个信号量,因为如果不是这样就会发生死锁。所以,函数使用down_read_trylock()而不是down_read()

如果这个信号量被关闭而且缺页发生在内核态(error_code & 4),do_page_fault()就要确定异常发生的时候,是否正在使用作为系统调用参数被传递给内核的线性地址。此时,因为每个系统调用服务例程都小心地避免在访问用户态地址空间以前为写而获得mmap_sem信号量,所以do_page_fault()确信mmap_sem信号量由另外一个进程占有了(!search_exception_tables(regs->eip)),从而do_page_fault()一直等待直到该信号量被释放(down_read(&mm->mmap_sem))。否则,如果缺页是由于内核bug或严重的硬件故障引起的,函数就跳转到bad_area_nosemaphore标记处。

我们假设已经为读而获得了mmap_sem信号量。现在,do_page_fault()开始搜索错误线性地址所在的线性区:

1
2
3
4
5
6
7
vma = find_vma(mm, address);
if (!vma)
goto bad_area;
if (vma->vm_start <= address)
goto good_area;
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;

如果vma为NULL,说明address之后没有线性区,因此这个错误的地址肯定是无效的。另一方面,如果在address之后结束处的第一个线性区包含address,则函数跳到标记为good_area的代码处。

如果两个“if”条件都不满足,则函数就确定address没有包含在任何线性区中,但内部变量vma指向当前进程的mm_struct的mmap_cache指向的那个vm_area_struct。函数就执行进一步的检查,由于这个错误地址可能是由push或pusha指令在进程的用户态堆栈上的操作所引起的。

我们稍微离题一点,解释一下栈是如何映射到线性区上的。每个向低地址扩展的栈所在的区,它的VM_GROWSDOWN标志被设置,这样,当vm_start字段的值可能被减小的时候,而vm_end字段的值保持不变。这种线性区的边界包括、但不严格限定用户态堆栈当前的大小。这种细微的差别主要基于以下原因:

  • 线性区的大小是4KB的倍数(必须包含完整的页),而栈的大小却是任意的。
  • 分配给一个线性区的页框在这个线性区被删除前永远不被释放。尤其是,一个栈所在线性区的vm_start字段的值只能减小,永远也不能增加。甚至进程执行一系列的pop指令时,这个线性区的大小仍然保持不变。

现在这一点就很清楚了,当进程填满分配给它的堆栈的最后一个页框后,进程如何引起一个“缺页”异常——push引用了这个线性区以外的一个地址(即引用一个不存在的页框)。注意,这种异常不是由程序错误引起的,因此它必须由缺页处理程序单独处理。

do_page_fault()它检查上面所描述的情况:

1
2
3
4
5
6
7
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4 && address + 32 < regs->esp)
goto bad_area;
if (expand_stack(vma, address))
goto bad_area;
}

如果线性区的VM_GROWSDOWN标志被设置,并且异常发生在用户态,函数就要检查address是否小于regs->esp栈指针的值(它应该只小于一点点)。因为几个与栈相关的汇编语言指令只有在访问内存之后才执行减esp寄存器的操作,所以允许进程有32字节的后备区间。如果这个地址足够高,则代码调用espand_stack()函数检查是否允许进程既扩展它的栈也扩展它的地址空间。如果一切都可以,expand_stack就把vmavm_start字段设为address,并返回0;否则,expand_stack返回-ENOMEM。

注意:只要线性区的VM_GROWSDOWN标志被设置,但异常不是发生在用户态,上述代码就跳过容错检查。这些条件意味着内核正在访问用户态的栈,也意味着这段代码总是应当运行expand_stack()

处理地址空间以外的错误地址

如果address不属于进程的地址空间,那么do_page_fault()函数执行bad_area标记处的语句,如果错误在用户态,则发送一个SIGSEGV信号给current进程并结束函数;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bad_area:
up_read(&mm->mmap_sem);

bad_area_nosemaphore:
if (error_code & 4) {
tsk->thread.cr2 = address;
tsk->thread.error_code = error_code | (address >= TASK_SIZE);
tsk->thread.trap_no = 14;
info.si_signo = SIGSEGV;
info.si_errno = 0;
info.si_addr = (void*)address;
force_sig_info_fault(SIGSEGV, si_code, address, tsk);
return;
}

force_sig_info_fault()函数确信进程不忽略或阻塞SIGSEGV信号,并通过info局部变量传递附加信息的同时把该信号发送给用户态进程。info.si_code变量已被置为SEGV_MAPERR(如果异常是由于一个不存在的页框引起),或置为SEGV_ACCERR(如果异常是由于对现有页框的无效访问引起)。

如果异常发生在内核态,仍然有两种可选的情况:

  • 异常的引起是由于把某个线性地址作为系统调用的参数传递给内核。
  • 异常是因一个真正的内核缺陷所引起的。

函数这样区分这两种可选的情况:

1
2
3
4
5
no_context:
if ((fixup = search_exception_table(regs->eip)) != 0)
regs->eip = fixup;
return;
}

在第一种情况中,代码跳到一段“修正代码”处,这段代码的典型操作就是向当前进程发送SIGSEGV信号,或用一个适当的出错码终止系统调用处理程序。

在第二种情况中,函数把CPU寄存器和内核态堆栈的全部转储打印到控制台,并输出到一个系统消息缓冲区,然后调用函数do_exit()杀死当前进程。这就是所谓按所显示的消息命名的内核漏洞(Kernel oops)错误。

处理地址空间内的错误地址

如果addr地址属于进程的地址空间,则do_page_fault()转到good_area标记处执行

1
2
3
4
5
6
7
8
9
10
good_area:
info.si_code = SEGV_ACCERR;
write = 0;
if(error_code & 2) {
if (!(vma->vm_flags & VM_WRITE))
goto bad_area;
write++;
} else
if ((error_code & 1) || !(vma->vm_flags & (VM_READ | VM_EXEC)))
goto bad_area;

如果异常由写访问引起,函数检查这个线性区是否可写(!(vma->vm_flags & VM_WRITE))。如果不可写,跳到bad_area代码处;如果可写,把write局部变量置为1。

如果异常由读或执行访问引起,函数检查这一页是否已经存在于RAM中。在存在的情况下,异常发生是由于进程试图访问用户态下的一个有特权的页框(页框的User/Supervisor标志被清除),因此函数跳到bad_area代码处(然而,这种情况从不会发生,因为内核不会把具有特权的页框贼给进程。)。在不存在的情况下(error_code & 3 = 0),函数还将检查这个线性区是否可读或可执行。

如果这个线性区的访问权限与引起异常的访问类型相匹配,则调用handle_mm_fault()函数分配一个新的页框:

1
2
3
4
5
6
7
8
9
10
survive:
ret = handle_mm_fault(tsk->mm, vma, address, write);
if (ret == VM_FAULT_MINOR || ret == VM_FAULT_MAJOR) {
if(ret == VM_FAULT_MINOR)
tsk->min_flt ++;
else
tsk->maj_flt++;
up_read(&tsk->mm->mmap_sem);
return;
}

如果handle_mm_fault()函数成功地给进程分配一个页框,则返回VM_FAULT_MINORVM_FAULT_MAJOR。值VM_FAULT_MINOR表示在没有阻塞当前进程的情况下处理了缺页;这种缺页叫做次缺页(minor fault)。值VM_FAULT_MAJOR表示缺页迫使当前进程睡眠(很可能是由于当用磁盘上的数据填充所分配的页框时花费时间);阻塞当前进程的缺页就叫做主缺页(major fault)。函数也返回VM_FAULT_OOM(没有足够的内存)或VM_FAULT_STGBOS(其他任何错误)。

如果handle_mm_fault()返回值VM_FAULT_SIGBUS,则向进程发送SIGBUS信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if(ret == VM_FAULT_SIGBUS) {

do_sigbus:
up_read(&tsk->mm->mmap_sem);
if (!(error_code & 4))
goto no_context;
tsk->thread.cr2 = address;
tsk->thread.error_code = error_code;
tsk->thread.trap_no = 14;
info.si_signo = SIGBUS;
info.si_errno = 0;
info.si_code = BUS_ADRESS;
info.si_addr = (void*)address;
force_sig_info_fault(SIGBUS, &info, tsk);
}

如果handle_mm_fault()不分配新的页框,就返回值VM_FAULT_OOM,此时内核通常杀死当前进程,不过,如果当前进程是init进程(tsk->pid == 1),则只是把它放在运行队列的末尾并调用调度程序;一旦init恢复执行,则又去执行handle_mm_fault()

1
2
3
4
5
6
7
8
9
10
11
12
13
if(ret == VM_FAULT_OOM) {

out_of_memory:
up_read(&tsk->mm->mmap_sem);
if (tsk->pid == 1) {
if(error_code & 4)
do_exit(SIGKILL);
goto no_context;
}
yield();
down_read(&tsk->mm->mmap_sem);
goto survive;
}

handle_mm_fault()函数作用于4个参数:

  • mm:指向异常发生时正在CPU上运行的进程的内存描述符。
  • vma:指向引起异常的线性地址所在线性区的描述符。
  • address:引起异常的线性地址。
  • write_access:如果tsk试图向address写,则置为1;如果tsk试图在address读或执行,则置为0。

这个函数会检查用来映射address的页中间目录和页表是否存在:

1
2
3
if (!pud)
if (!pmd)
if (!pte)

即使address属于进程的地址空间,相应的页表也可能还没有被分配,因此,在做别的事情之前首先执行分配页目录和页表的任务:

1
2
3
pud = pud_alloc(mm, pgd, address);
pmd = pmd_alloc(mm, pud, address);
pte = pte_alloc_map(mm, pmd, address);

pgd局部变量包含引用address的页全局目录项。如果需要的话,调用pud_alloc()pmd_alloc()函数分别分配一个新的页上级目录和页中间目录(在80x86微处理器中,这种分配永远不会发生,因为页上级目录总是包含在页全局目录中,并且页中间目录或者包含在页上级目录中(PAE未激活),或者与页上级目录一块被分配(PAE被激活))。然后,如果需要的话调用的pte_alloc_map()函数会分配一个新的页表。

如果以上两步都成功,pte局部变量所指向的页表项就是引用address的表项。然后调用handle_pte_fault()函数检查address地址所对应的页表项,并决定如何为进程分配一个新页框:

  1. 如果被访问的页不存在,也就是说,这个页还没有被存放在任何一个页框中,那么,内核分配一个新的页框并适当地初始化。这种技术称为请求调页(demand paging)。
  2. 如果被访问的页存在但是标记为只读,也就是说,它已经被存放在一个页框中,那么,内核分配一个新的页框,并把旧页框的数据拷贝到新页框来初始化它的内容。这种技术称为写时复制(Copy On Write,COW)。

请求调页

术语请求调页指的是一种动态内存分配技术,它把页框的分配推迟到不能再推迟位置,也就是说,一直推迟到进程要访问的页不在RAM中时位置,由此引起一个缺页异常。

请求调页技术背后的动机是:进程开始运行的时候并不访问其地址空间中的全部地址;事实上,有一部分地址也许永远不被进程使用。对于全局分配来说,请求调页是首选的,因为它增加了系统中空闲页框的平均数,从而更好地利用空闲内存。从另一个观点来看,在RAM总体保持不变的情况下,请求调页从总体上能使系统有更大的吞吐量。

被访问的页可能不在主存中,其原因或者是进程从没访问过该页,或者是内核已经回收了相应的页框。在这两种情况下,缺页处理程序必须为进程分配新的页框。不过,如何初始化这个页框取决于是哪一种页以及页以前是否被进程访问过。特殊情况下:

  1. 这个页从未被进程访问到且没有映射磁盘文件,或者页映射了磁盘文件。内核能够识别这些情况,它根据页表相应的表项被填充为0,也就是说,pte_none宏返回1。
  2. 页属于非线性磁盘文件的映射。内核能够识别这种情况,因为Present标志被清0而且Dirty标志被置1,也就是说,pte_file宏返回1。
  3. 进程已经访问过这个页,但是其内容被临时保存在磁盘上。内核能够识别这种情况,这是因为相应表项没被填充为0,但是PresentDirty标志被清0。

因此,handle_pte_fault()函数通过检查address对应的页表项能够区分三种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static inline int handle_pte_fault(struct mm_struct *mm,
struct vm_area_struct * vma, unsigned long address,
int write_access, pte_t *pte, pmd_t *pmd)
{
pte_t entry;

entry = *pte;
if (!pte_present(entry)) {
if (pte_none(entry))
return do_no_page(mm, vma, address, write_access, pte, pmd);
if (pte_file(entry))
return do_file_page(mm, vma, address, write_access, pte, pmd);
return do_swap_page(mm, vma, address, pte, pmd, entry, write_access);
}

在第一种情况下,当页从未被访问或页线性地映射磁盘文件时则调用do_no_page()函数。通过检查vma线性区描述符的nopage确定这个页是否被映射到一个磁盘文件:

  • vma->vm_ops->nopage不为NULL,线性区映射了一个磁盘文件,nopage字段指向装入页的函数。
  • vma->vm_ops为NULL,或者vma->vm_ops->nopage为NULL,线性区没有映射文件,它是一个匿名映射。因此do_no_page()调用do_anonymous_page()获得一个新的页框。
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
static int do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, pte_t *page_table, pmd_t *pmd,
int write_access)
{
struct page *page;
spinlock_t *ptl;
pte_t entry;

if (write_access) {
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);
page = alloc_page(GFP_HIGHUSER | __GFP_ZERO);
spin_lock(&mm->page_table_lock);
page_table = pte_offset_map(pmd, addr);
mm->rss ++;

entry = maybe_mkwrite(pte_mkdirty(mk_pte(page, vma->vm_page_prot)), vma);
lru_cache_add_active(page);
SetPageReference(page);
set_pte(page_table, entry);
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR;
}
}

pte_unmap宏的第一次执行释放一种临时内核映射,这种映射了在调用handle_pte_fault()函数之前由pte_offset_map宏所建立页表项的高端内存物理地址。pte_offset_mappte_unmap这对宏获取和释放同一个临时内核映射。临时内核映射必须在调用alloc_zeroed_user_highpage,本质上也就是alloc_page()之前释放,因为这个函数可能会阻塞当前进程。

函数递增内存描述符的rss字段以记录分配给进程的页框总数。相应的页表项设置为页框的物理地址,页表框被标记为既脏又可写的。lru_cache_add_active()函数把新页框插入与交换相关的数据结构中。

相反,当处理读访问时,即write_access为0,页的内容是无关紧要的,因为进程第一次对它访问。给进程一个填充为0的页要比给它一个由其他进程填充了信息的旧页更为安全。Linux在请求调页方面做得更深入一些。没有必要立即给进程分配一个填充为0的新页框,由于我们也可以给它一个现有的称为零页(zero page)的页,这样可以进一步推迟页框的分配。零页在内核初始化期间被静态分配,并存放在empty_zero_page变量中(长为4096字节的数组,并用0填充)。

写时复制

第一代Unix系统实现了一种傻瓜式的进程创建:当发出fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为非常耗时,因为它需要:

  • 为子进程的页表分配页框
  • 为子进程的页分配页框
  • 初始化子进程的页表
  • 把父进程的页复制到子进程相应的页中

写时复制(Copy On Write,COW)的思想相当简单:父进程和子进程共享页框而不是复制页框。然后只要页框被共享,它们就不能被修改。无论父进程还是子进程何时试图写一个共享的页框,就产生一个异常,这是内核就把这个页复制到一个新的页框中并标记可写。原来的页框仍然是写保护的:当其他进程试图写入时,内核检查写进程是否是这个页框的唯一属主,如果是,就把这个页框标记为对这个进程可写。

页描述符的_count字段用于跟踪共享相应页框的进程数目。只要进程释放一个页框或者在它上面执行写时复制,它的_count字段就减小;只有当_count变为-1时,这个页框才被释放。

handle_pte_fault()函数确定缺页异常是由访问内存中现有的一个页而引起时:

1
2
3
4
5
6
7
8
9
10
11
12
13
if(pte_present(entry)) {
if (write_access) {
if (!pte_write(entry))
return do_wp_page(mm, vma, address, pte, pmd, ptl, entry);
entry = pte_mkdirty(entry);
}
entry = pte_mkyoung(entry);
set_pte(pte,entry);
flush_tlb_page(vma, address);
pte_unmap(pte);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR;
}

handle_pte_fault()函数是与体系结构无关的:它考虑任何违背页访问权限的可能。然而,在80x86体系结构上,如果页是存在的,那么,访问权限是写允许的(write_access=1)而页框是写保护的(参见前面“处理地址空间内的错误地址”一博)。因此,总是要调用do_wp_page()函数。

do_wp_page()函数首先获取与缺页异常相关的页框描述符:

1
old_page = vm_normal_page(vma, address, orig_pte);

接下来,函数确定页的复制是否真正必要。如果仅有一个进程拥有这个页,那么,写时复制就不必应用,且该进程应当自由地写该页。具体来说,函数读取页描述符的_count字段:如果它等于0(只有一个所有者),写时复制就不必进行。

实际上,检查要稍微复杂些,因为当页插入到交换高速缓存(并且当设置了页描述符的PG_private标志时,_count字段也增加。不过,当写时复制不进行时,就把该页框标记为可写的,以免试图写时引起进一步的缺页异常:

1
2
3
4
5
set_pte(page_table, maybe_mkwrite(pte_mkyoung(pte_mkdirty(pte)),vma));
flush_tlb_page(vma, address);
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR;

如果两个或多个进程通过写时复制共享页框,那么函数就把旧页框(old page)的内容复制到新分配的页框(new page)中。为了避免竞争条件,在开始复制操作前调用get_page()old_page的使用计数加1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
old_page = pte_page(pte);
pte_unmap(page_table);
get_page(old_page);
spin_unlock(&mm->page_table_lock);
if (old_page == virt_to_page(empty_zero_page))
new_page = alloc_page(GFP_HIGHUSER | _ _GFP_ZERO);
} else {
new_page = alloc_page(GFP_HIGHUSER);
vfrom = kmap_atomic(old_page, KM_USER0)
vto = kmap_atomic(new_page, KM_USER1);
copy_page(vto, vfrom);
kunmap_atomic(vfrom, KM_USER0);
kunmap_atomic(vto, KM_USER0);
}

如果旧页框是零页,就在分配新的页框时(__GFP_ZERO标志)把它填充为0。否则,使用copy_page()宏复制页框的内容。不要求一定要对零页做特殊的处理,但是特殊处理确实能够提高系统的性能,因为它减少地址引用而保护了微处理器的硬件高速缓存。

因为页框的分配可能阻塞进程,因此,函数检查自从函数开始执行以来是否已经修改了页表项(pte*page_table具有不同的值)。如果是,新的页框被释放,old_page的使用计数器被减少(取消以前的增加),函数结束。

如果所有的事情看起来进展顺利,那么,新页框的物理地址最终被写进页表项,且使用相应的TLB寄存器无效:

1
2
3
4
5
6
7
8
spin_lock(&mm->page_table_lock);
entry = maybe_mkwrite(pte_mkdirty(mk_pte(new_page,
vma->vm_page_prot)),vma);
set_pte(page_table, entry);
flush_tlb_page(vma, address);
lru_cache_add_active(new_page);
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);

lru_cache_add_active()函数把新页框插入到与交换相关的数据结构中。

最后,do_wp_page()old_page的使用计数器减少两次。第一次的减少是取消复制页框内容之前进行的安全性增加;第二次的减少是反映当前进程不再拥有该页框这一事实。

处理非连续内存区访问

内核在更新非连续内存区对应的页表项时是非常懒惰的。事实上,vmalloc()vfree()函数只把自己现在在更新主内核页表。

然而一旦内核初始化结束,任何进程或内核线程便都不直接使用主内核页表。因此,我们来考虑内核态进程对非连续内存区的第一次访问。当把线性地址转换为物理地址时,CPU的内存管理单元遇到空的页表项并产生一个缺页。但是缺页异常处理程序认识这种特殊的情况,因为异常发生在内核态且产生缺页的线性地址大于TASK_SIZE

因此do_page_fault()检查相应的主内核页表项。把存放在cr3寄存器中的当前进程页全局目录的物理地址赋给局部变量pgd_paddr(内核不使用current->mm_pgd导出当前进程的页全局目录地址,因为这种缺页可能在任何时刻都发生,甚至在进程切换期间发生。),把与pgd_paddr相应的线性地址赋给局部变量pgd,并且把主内核页全局目录的线性地址赋给pgd_k局部变量。

如果产生缺页的线性地址所对应的主内核页全局目录项为空,即if(!pud_present(*pud_k)),则函数跳到标号为no_context处。否则,函数检查与错误线性地址相对应的主内核页上级目录项和主内核页中间目录项。如果它们中有一个为空,再次跳到标号为no_context处。

创建和删除进程的地址空间

重点关注fork()系统调用为子进程创建一个完整的新地址空间。相反,当进程结束时,内核撤消它的地址空间。

创建进程的地址空间

当创建一个新的进程时内核调用copy_mm()函数。这个函数通过建立新进程的所有页表和内存描述符来创建进程的地址空间

1
static int copy_mm(unsigned long clone_flags, struct task_struct * tsk)

通常,每个进程都有自己的地址空间,但是轻量级进程可以通过调用clone()函数来创建。这些轻量级进程共享同一地址空间,也就是说,允许它们对同一组页进行寻址

按照前面讲述的写时复制方法,传统的进程继承父进程的地址空间,只要页是只读的,就依然共享它们。当其中的一个进程试图对某个页进行写时,此时,这个页才被复制一份。一段时间之后,所创建的子进程通常会因为缺页异常而获得与父进程不一样的完全属于自己的地址空间。

另一方面,轻量级的进程使用父进程的地址空间。Linux实现轻量级进程很简单,即不复制父进程地址空间。创建轻量级的进程(clone)比创建普通进程相应要快得多,而且只要父进程和子进程谨慎地协调它们的访问,就可以认为页的共享是有益的

如果通过clone()系统调用已经创建了新进程,并且flag参数的CLONE_VM标志被设置,则copy_mm()函数把父进程(current)地址空间给子进程(tsk):

1
2
3
4
5
6
7
if (clone_flags & CLONE_VM) {
atomic_inc(&current->mm->mm_users);
spin_unlock_wait(&current->mm->page_table_lock);
tsk->mm = current->mm;
tsk->active_mm = current->mm;
return 0;
}

如果没有设置CLONE_VM标志,copy_mm()函数就必须创建一个新的地址空间(在进程请求一个地址之前,即使在地址空间内没有分配内存)。函数分配一个新的内存描述符,把它的地址存放在新进程描述符tskmm字段中,并把current->mm的内容复制到tsk->mm中。然后改变新进程描述符的一些字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static struct mm_struct *dup_mm(struct task_struct *tsk)
{
tsk->mm = kmem_cache_alloc(mm_cachep, SLAB_KERNEL);
memcpy(tsk->mm, current->mm, sizeof(*tsk->mm));
atomic_set(&mm->mm_users, 1);
atomic_set(&mm->mm_count, 1);
init_rwsem(&mm->mmap_sem);
tsk->mm->core_waiters = 0;
tsk->mm->page_table_lock = SPIN_LOCK_UNLOCKED;
tsk->mm->ioctx_list_lock = RW_LOCK_UNLOCKED;
tsk->mm->ioctx_list = NULL;
tsk->default_kioctx = INIT_KIOCTX(tsk->mm->default_kioctx, *tsk->mm);
tsk->mm->free_area_cache = (TASK_SIZE/3+0xfff)&0xfffff000;
tsk->mm->pgd = pgd_alloc(tsk->mm);
tsk->mm->def_flags = 0;

mm_alloc_pgd()调用pgd_alloc()宏为新进程分配一个全新的页全局目录,随后调用依赖于体系结构的init_new_context()函数:对于80x86处理器,该函数检查当前进程是否拥有定制的局部描述符表,如果是,init_new_context()复制一份current的局部描述符表并把它插入tsk的地址空间。最后调用dup_mmap()函数既复制父进程的线性区,也复制父进程的页表,把新内存描述符tsk->mm插入到内存描述符的全局链表中。然后从current->mm->mmap所指向的线性区开始扫描父进程的线性区链表。它复制遇到的每个vm_area_struct线性区描述符,并把复制品插入到子进程的线性区链表和红-黑树中。

在插入一个新的线性区描述符之后,如果需要的话,dup_mmap()立即调用copy_page_range()创建必要的页表来映射这个线性区所包含的一组页,并且初始化新页表的表项。尤其是,与私有的、可写的页(VM_SHARED标志关闭,VM_MAYWRITE标志打开)所对应的任一页框都标记为对父子进程是只读的,以便这种页框能用写时复制机制进行处理:

删除进程的地址空间

当进程结束时,内核调用exit_mm()函数释放进程的地址空间。

1
2
3
4
mm_release(tsk, tsk->mm);
if (!(mm = tsk->mm)) /* kernel thread ? */
return;
down_read(&mm->mmap_sem);

mm_release()函数唤醒在tsk->vfork_done补充信号量上睡眠的任一进程。典型地,只有当现有进程通过vfork()系统调用被创建时,相应的等待队列才会为非空。如果正在被终止的进程不是内核线程,exit_mm()函数就必须释放内存描述符和所有相关的数据结构。首先,它检查mm->core_waiters标志是否被置位:如果是,进程就把内存的所有内容卸载到一个转储文件中。为了避免转储文件的混乱,函数利用mm->core_donemm->core_startup_done补充原语使共享同一个内存描述符mm的轻量级进程的执行串行化。

接下来,函数递增内存描述符的主使用计数器,重新设置进程描述符的mm字段,并使处理器处于懒惰TLB模式:

1
2
3
4
5
6
7
atomic_inc(&mm->mm_count);
spin_lock(tsk->alloc_lock);
tsk->mm = NULL;
up_read(&mm->map_sem);
enter_lazy_tlb(mm, current);
spin_unlock(tsk->alloc_lock);
mmput(mm);

最后,调用mmput()函数释放局部描述符表、线性区描述符和页表。不过,因为exit_mm()已经递增了主使用计数器,所以并不释放内存描述符本身。当要把正在被终止的进程从本地CPU撤消时,将由finish_task_switch()函数释放内存描述符。

堆的管理

每个Unix进程都拥有一个特殊的线性区,这个线性区就是所谓的(heap),堆用于满足进程的动态内存请求。内存描述符的start_brkbrk字段分别限定了这个区的开始地址和结束地址。进程可以使用下面的API来请求和释放动态内存:

  • malloc(size):请求size个字节的动态内存
  • calloc(n,size):请求含n个大小为size的元素的一个数组。
  • realloc(ptr,size):该表由前面的malloc()calloc()分配内存区字段的大小。
  • free(addr):释放由malloc()calloc()分配的其实地址为addr的线性区。
  • brk(addr):直接修改堆的大小。addr参数指定current->mm->brk的新值,返回值是线性区新的结束地址。
  • sbrk(incr):类似于brk(),其中的incr参数指定是增加还是减小以字节为单位的堆大小

brk()函数和以上列出的函数有所不同,因为它是唯一以系统调用的方式实现的函数,而其他所有的函数都是使用brk()mmap()系统调用实现的C语言库函数。

当用户态的进程调用brk()系统调用时,内核执行sys_brk(addr)函数。该函数首先验证addr参数是否位干进程代码所在的线性区。如果是,则立即返回,因为堆不能与进程代码所在的线性区重叠:

1
2
3
4
5
6
7
mm = current->mm;
down_write(&mm->mmap_sem);
if (addr < mm->end_code) {
out:
up_write(&mm->mmap_sem);
return mm->brk;
}

由于brk()系统调用作用于某一个非代码的线性区,它分配和释放完整的页。因此,该函数把addr的值调整为PAGE_SIZE的倍数,然后把调整的结果与内存描述符的brk字段的值进行比较:

1
2
3
4
5
6
newbrk = (addr + 0xfff) & 0xfffff000;
oldbrk = (mm->brk + 0xfff) & 0xfffff000;
if (oldbrk == newbrk) {
mm->brk = addr;
goto out;
}

如果进程请求缩小堆,则sys_brk()调用do_munmap()函数完成这项任务,然后返回:

1
2
3
4
5
if (addr <= mm->brk) {
if (!do_munmap(mm, newbrk, oldbrk-newbrk))
mm->brk = addr;
goto out;
}

如果进程请求扩大堆,则sys_brk()首先检查是否允许进程这样做。如果进程企图分配在其跟制范围之外的内存,函数并不多分配内存,只简单地返回mm->brk的原有值:

1
2
3
rlim = current->signal->rlim[RLIMIT_DATA].rlim_cur;
if (rlim < RLIM_INFINITY && addr - mm->start_data > rlim)
goto out;

然后,函数检查扩大后的堆是否和进程的其他线性区相重叠,如果是,不做任何事情就返回:

1
2
if (find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE))
goto out;

如果一切都顺利,则调用do_brk()函数。如果它返回oldbrk,则分配成功且sys_brt()函数返回addr的值;否则,返回旧的mm->brk值:

1
2
3
if (do_brk(oldbrk, newbrk-oldbrk) == oldbrk)
mm->brk = addr;
goto out;

do_brk()函数实际上是仅处理匿名线性区的do_mmap()的简化版。可以认为它的调用等价于:

1
2
do_mmap(NULL, oldbrk, newbrk-oldbrk, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_FIXED|MAP_PRIVATE, 0)

当然,do_brk()do_mmap()稍快,因为前者假定线性区不映射磁盘上的文件,从而避免了检查线性区对象的几个字段。