支持软件容错的增量恢复缓存技术解析
1. 恢复缓存(Recovery Cache)
恢复缓存是一种用于支持软件容错的重要机制。具体的私有类型
Recovery_Cache
扩展了
Recovery_Point
包导出的抽象类型
Recovery_Data
。
Establish
、
Restore
和
Discard
这几个过程是基本恢复接口的具体实现。
恢复缓存本质上是恢复区域的集合。由于恢复缓存设施主要用于恢复块,其中恢复区域是严格嵌套的,因此使用栈来管理这些区域是合适的。在私有部分的完整声明是一个包含栈的记录扩展。每个栈元素包含一系列缓存条目,每个条目是存储值和对相应可恢复应用对象的引用的组合。由于支持的可恢复对象数量没有上限,序列用列表表示。这种表示方式(缓存条目列表的栈)与 Anderson 和 Lee 以及 Flaviu Cristian 引入的设施类似。为了实现终结处理,私有部分的完整类型被声明为
Limited_Controlled
的扩展。
2. 可恢复应用类型(Recoverable Application Types)
具体实现导出了一个名为
Recoverable
的类型,用于定义可恢复应用类型的接口。其目的是通过对
Recoverable
进行扩展,支持任意应用定义类型的对象的状态保存和恢复。由于这些扩展将在
Recoverable
的派生类中,调度机制允许实现操作应用定义类型的对象,而无需对它们有具体的了解。这是面向对象编程中抽象类型、继承和动态调度的经典应用。
应用程序可以根据需要定义任意数量的
Recoverable
抽象类型的不同扩展。每当对指定的
Recovery_Cache
对象调用
Establish
、
Restore
或
Discard
时,所有指定该
Recovery_Cache
对象的(从
Recoverable
派生的类型的)对象都可以被操作。程序员在详细说明可恢复应用对象时,通过判别式永久指定一个
Recovery_Cache
对象。
Recoverable
的所有后代都会继承该判别式,并且所有此类对象都必须指定其
Recovery_Cache
对象。
Recoverable
类型在私有部分被完全定义为
Controlled
类型的后代,因此它具有用户定义赋值的功能。应用程序定义的具体后代将继承我们包中定义的赋值语义。
3. 特定于实现的组件(Implementation - Specific Components)
Recovery_Cache
和
Recoverable
类型被定义为具有实现所需组件的类型扩展。与
Recoverable
类型相关的组件给用户定义赋值带来了困难。
从性能角度来看,与列表栈的逻辑表示的一个差异是根本性的。由于恢复相对不常使用,正常执行必须优先于恢复设施进行优化。因此,增量赋值语句必须尽可能高效。在使用恢复时,恢复点被丢弃的频率比恢复的频率更高,所以
Discard
过程也必须尽可能高效。这两种情况的主要困难在于需要搜索无界的恢复区域列表。赋值的实现必须确定一个条目是否已经被放置在当前恢复区域中。对于当前区域中的每个条目,
Discard
过程的实现还必须检查前一个区域列表,以确定该条目是否存在于那里。为了避免这些搜索,我们使用 Anderson 和 Lee 描述的方法之一,额外消耗一些存储。
具体来说,恢复区域与对应于其在栈中深度的编号级别相关联;每个
Recovery_Cache
对象有一个专用值表示该缓存的当前级别。每个可恢复应用对象有一个关联的数值,表示该对象最近的缓存条目所在的恢复区域。赋值实现会比较应用对象的级别和关联的
Recovery_Cache
对象的级别,以确定是否需要新的缓存条目。同样,在丢弃当前区域之前,
Discard
过程会比较这些字段,以确定当前区域的任何条目是否应合并到前一个区域。因此,每个
Recovery_Cache
对象有一个数值
Current_Level
组件,每个可恢复应用对象有一个名为
Max_Region_Entered
的数值。
另一个组件处理
Recoverable
和
Recovery_Cache
对象生命周期的差异。
Restore
过程遍历栈顶的恢复区域列表,以恢复相应可恢复应用对象的最近缓存值。然而,只有当我们知道相应的应用对象仍然存在时,才能恢复这些值。由于对象可以在嵌套的声明区域中声明,并且分配的对象可以手动释放,可能会出现悬空引用。后续调用
Restore
过程可能会尝试使用这些无效引用,产生不可预测的影响。为了避免解引用任何悬空引用,我们为每个可恢复应用对象关联一个布尔标志
Known_Valid
。每个
Known_Valid
标志指示对象是否已知存在。
Known_Valid
为
False
的对象不会被恢复。
下面是相关组件的关系图:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
A(Recoverable):::process --> B(Reference):::process
A --> C(Known_Valid):::process
A --> D(Max_Region_Entered):::process
A --> E(Prior_Values Stack):::process
A --> F(Descriptor):::process
F --> G(Recovery_Object):::process
F --> H(Application Extension):::process
I(Recovery_Cache):::process --> J(Descriptor List):::process
I --> K(Known_Objects Map):::process
I --> L(Current_Level):::process
I --> M(Active_Regions Stack):::process
4. 赋值(Assignment)
在对给定的
Recovery_Cache
对象调用
Establish
之后,对关联的可恢复对象的第一次赋值将在获取新值之前存储当前值。我们使用 Ada 的用户定义赋值来实现这一效果。
在 Ada 中,对于
Controlled
类型的后代,可以通过重写
Adjust
过程以及根据情况重写
Finalize
过程来间接重新定义赋值操作。在典型实现中,当一个受控类型的对象是赋值语句的目标时,首先会调用
Finalize
过程,并将该对象作为参数传递。然后,将右侧值的逐位副本复制到目标对象中。最后,调用
Adjust
过程,并将目标对象作为参数传递,以完成赋值效果。需要注意的是,编译器可能会使用临时对象,而不是直接进行复制。
然而,
Finalize
和
Adjust
过程也会因其他原因被调用,包括对象销毁和赋值操作在多个地方被调用,例如赋值语句、对象销毁(包括声明对象的销毁和分配对象的释放)以及函数返回的受控类型值的终结处理等。
在赋值语句中,虽然
Adjust
是与用户定义赋值相关的主要过程,但我们必须使用
Finalize
过程来保存
Recoverable
子类的值。因为要增量存储的值是赋值新值之前的当前值,而
Adjust
过程在右侧值的逐位副本替换目标对象的值之后才被调用,此时存储当前值就太晚了。而且,
Adjust
过程在某些情况下(如带有显式初始值的对象声明)存储值是不合适的。
我们对
Recoverable
类型的
Finalize
过程的实现不尝试进行任何存储回收,因为无法区分是由于赋值还是对象销毁而调用该过程。由于不能将所有必要的资源直接声明为
Recoverable
类型中的简单记录组件,一些存储不会自动回收。例如,从
Recoverable
派生的类型的每个对象都有一个布尔标志
Known_Valid
来指示对象是否已知存在。显然,不能将
Known_Valid
声明为
Recoverable
类型中的记录组件,因为这些标志会随着它们要指示的对象一起消失。因此,我们定义了一个隐藏类型
Descriptor
来维护关于可恢复应用对象的所有此类信息;每个
Descriptor
对象专门用于一个可恢复应用对象。恢复区域列表中的指针实际上指向
Descriptor
,而不是应用对象本身。为了回收分配给
Descriptor
的存储,
Recovery_Cache
类型的
Finalize
过程会自动回收它们,而不是
Recoverable
类型的
Finalize
过程。
为了防止在相应的应用对象不再存在后使用
Descriptor
,我们在
Finalize
过程中执行增量赋值功能,并将对象标记为可能不存在。
Finalize
过程将
Descriptor
中的对象
Known_Valid
标志设置为
False
,而
Adjust
过程将其重置为
True
。如果应用对象真的即将被销毁,
Adjust
过程不会被调用,标志将保持为
False
。
Restore
过程会忽略
Known_Valid
标志为
False
的
Descriptor
。
赋值操作对非受限值的身份维护也有影响。任何可以通过赋值操作赋值的对象在赋值过程中会被逐位复制,整个对象会被覆盖,因此被认为是“匿名”的。任何单个“身份”都会丢失,因此赋值兼容的值必须被认为是可以相互自由替换的。Ada 的用户定义赋值模型支持这种值的透明性,因为在调用
Finalize
和
Adjust
过程时,只传递赋值的目标对象,而不是目标和源对象。这两个过程执行的所有处理都必须基于单个目标对象。对于非受限类型,使用源和目标信息进行部分更新是不可能的。
为了维护可恢复应用对象的身份,我们使用地址作为永久且不受赋值影响的身份标识。每个
Recovery_Cache
对象有一个从它管理的对象的地址到相应
Descriptor
的映射。为了提高效率,我们使用哈希映射,对传递给
Finalize
和
Adjust
的实际参数的地址进行哈希处理。由于
Recoverable
及其后代是带标签的类型,它们总是通过引用传递,因此形式参数不是副本,这些地址是有效的。
Finalize
和
Adjust
过程使用这个映射来获取指向相应
Descriptor
的指针。
赋值操作对用户界面也有影响。由于
Adjust
和
Finalize
过程只传递赋值的目标对象,目标对象必须包含对关联的
Recovery_Cache
对象的引用,以便
Adjust
和
Finalize
过程可以访问哈希
Descriptor
映射。然而,对
Recovery_Cache
对象的引用也是一个在赋值过程中不被保留的身份实例。与每个
Recoverable
对象关联的访问类型判别式用于避免这种身份丢失。因此,可恢复对象的声明需要指定管理它们的
Recovery_Cache
对象。虽然判别式可能会被覆盖,但如果程序员尝试对具有不同判别式的对象进行赋值,编译器会引发
Constraint_Error
,从而防止任何有效的更改。不幸的是,这种方法意味着可恢复应用对象只能赋值给与同一
Recovery_Cache
对象关联的其他可恢复应用对象,不支持让
Recoverable
对象与多个
Recovery_Cache
对象关联的更动态的方法。
在错误检测方面,
Finalize
和
Adjust
过程除了在赋值语句中被调用外,还会在各种情况下被调用,包括临时对象、聚合和对象声明的初始值。为了忽略这些情况,
Finalize
和
Adjust
过程会忽略那些地址未绑定在哈希
Descriptor
映射中的对象,实际上就是忽略没有
Descriptor
的对象。不幸的是,这种方法意味着我们无法检测到那些无意中没有通过
Register
过程分配
Descriptor
对象的对象的赋值。由于程序员完全负责调用
Register
过程,不检测这种错误是特别遗憾的。一种替代方法是在
Finalize
过程中根据需要生成
Descriptor
并将它们绑定到哈希映射中。虽然这些不必要的
Descriptor
不会导致失败,因为
Known_Valid
标志将为
False
,但可能会分配大量这样的
Descriptor
对象。调用
Restore
和
Discard
过程时会访问每个
Descriptor
来检查
Known_Valid
标志,从而导致性能不佳。目前的方法似乎是最好的折衷方案。
下面是赋值操作的相关步骤总结:
1. 调用
Establish
后,首次赋值存储旧值。
2. 重写
Adjust
和
Finalize
实现赋值操作。
3.
Finalize
存储旧值并标记对象可能不存在。
4.
Adjust
重置
Known_Valid
标志。
5. 使用地址映射维护对象身份。
6. 声明可恢复对象指定
Recovery_Cache
。
7. 忽略无
Descriptor
对象避免不必要调用。
5. 初始化应用对象
对于类型
Ada.Finalization.Controlled
(以及
Limited_Controlled
类型)定义的子程序包括
Initialize
过程。当一个受控对象创建后,如果声明时没有提供初始值,就会自动调用
Initialize
过程的具体重写版本。对于有限受控类型,由于不允许赋值(包括初始值赋值),
Initialize
过程总是会被调用。
然而,增量恢复缓存依赖于赋值操作,因此不能使用有限类型。如前面所述,实现会将某些数据结构与
Recoverable
类型的具体子类对象相关联(这些数据结构是恢复缓存对象的一部分,而不是可恢复应用对象本身)。由于
Initialize
过程并非总是被调用,所以不能用它来创建这些数据结构。
为了解决这个问题,我们定义了类范围的
Register
过程来初始化可恢复应用对象。当检测到此类对象未注册时,会引发
Registration_Error
异常。我们没有将该过程命名为 “Initialize”,是为了避免与受控类型的
Initialize
过程混淆。
初始化应用对象的步骤如下:
1. 定义
Register
过程用于初始化可恢复应用对象。
2. 当检测到对象未注册时,抛出
Registration_Error
异常。
6. 相关技术对比
在软件容错领域,有其他一些方法与我们所讨论的增量恢复缓存技术有所不同。
Rubira - Calsavara 和 Stroud 在 C++ 中的方法也依赖于用户定义赋值。在他们的方案中,所有可恢复状态会合并到一个对象(一种类型)中,并在执行恢复块变体之前保存。为了避免复制大量数据,用户需要为每种类型定义一个 “懒” 赋值,即只有当值实际改变时才创建副本。所有类型(包括预定义的基本类型,如
int
和
float
)都需要进行这样的定义。而我们的方法将复杂性从用户转移到了一个中央设施,但要求可恢复应用类型继承自我们定义的具有增量赋值语义的类型。
另一种方法是扩展编译器或其他工具,使容错支持完全自动化。在给定的程序文本部分,实现定义的编译指示(编译器指令)会要求编译器生成代码以实现容错机制。这种方法与我们的方法的主要区别在于,我们的方法在程序源代码中直接表达容错机制。编译器扩展方法的优点是用户无需确定哪些对象需要状态保存和恢复。而在我们的方法中,程序员必须明确声明可恢复应用对象,并将其与恢复点关联起来,这存在疏忽的可能性。不过,恢复块的实现会调用我们的例程,所以在这方面编译器生成方法并没有优势。此外,无论哪种方法,开发者都需要表明需要恢复机制。我们所描述的设施的主要优点是任何 Ada 编译器都可以支持它们,不需要扩展编译器或其他专用工具。而且,通过适当的同步,本文描述的设施可以集成到涉及多个控制线程的容错机制中。
下面是不同方法的对比表格:
| 方法 | 复杂性承担方 | 状态保存方式 | 对用户要求 | 编译器依赖 | 多线程支持 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| Rubira - Calsavara 和 Stroud 的 C++ 方法 | 用户 | 合并到一个对象保存 | 为所有类型定义 “懒” 赋值 | 无特殊要求 | 未提及 |
| 编译器扩展方法 | 编译器 | 编译器自动生成代码 | 无需确定保存对象 | 需要扩展编译器 | 未提及 |
| 增量恢复缓存技术 | 中央设施 | 增量保存 | 继承特定类型,明确声明对象 | 普通 Ada 编译器 | 可集成 |
7. 总结
我们已经证明 Ada 95 可以直接表达抽象恢复接口,并基于增强赋值产生与应用无关的增量恢复实例。这是一个有价值的展示,因为 Ada 广泛应用于有容错要求的应用程序中,并且通常被认为非常适合该领域。
然而,Ada 的初始化、终结处理和用户定义赋值模型使实现变得复杂。具体来说,这三个功能在一个基于可赋值值可替换性的模型中由三个过程共享。因此,当需要赋值时,由于模型基于值的匿名性,很难保留和管理对象身份。其他语言提供了不同的模型,其中初始化、终结处理和赋值是单独的构造。在那种模型中,只有必要的组件会被更改,从而保留其他值和目标对象的任何身份。Ada 的赋值模型使用逐位复制,因此不支持同时保留身份和进行赋值。此外,由于初始值可以通过多种方式提供,对于非受限类型,使用任何特定机制进行初始化都不能保证。同样,终结处理也存在类似问题,无法保证创建唯一身份。
尽管存在这些挑战,但我们通过合理的设计和实现,如使用地址映射维护对象身份、定义
Register
过程初始化对象等,使得增量恢复缓存技术在 Ada 中得以有效实现,为软件容错提供了一种可行的解决方案。
下面是整个技术流程的 mermaid 流程图:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
A(定义 Recovery_Cache):::process --> B(定义 Recoverable 类型):::process
B --> C(关联 Recovery_Cache 和 Recoverable):::process
C --> D(调用 Establish):::process
D --> E(首次赋值存储旧值):::process
E --> F(执行赋值操作):::process
F --> G{是否需要恢复?}:::process
G -->|是| H(调用 Restore):::process
G -->|否| I(继续正常执行):::process
H --> J(恢复对象状态):::process
I --> K(可能调用 Discard):::process
K --> L(处理恢复区域):::process
M(初始化应用对象):::process --> C
综上所述,支持软件容错的增量恢复缓存技术在 Ada 中虽然面临一些挑战,但通过精心设计和实现,能够有效地为软件提供容错能力,并且具有较好的通用性和可扩展性。