Lec 1 模块化,抽象
[toc]
阅读参考书 §1.1-1.5, §4.1-4.3
思考题:
- 为什么难以构建复杂系统?
- 我们如何才能降低复杂性?
- 什么是模块化?模块化意味着什么?
- 客户端/服务器模型如何帮助我们实现模块化的?
- RPC与普通的过程调用有什么不同?RPC引入了哪些问题?
- 在设计系统时,除了模块化,我们还需要关心哪些其他事情?
1. 系统与复杂性
设计原则并非不可变的法则,而是总结了智慧与经验的指导方针,能够帮助设计者避免犯错。
IMPORTANT
要避免过度通用。 ——如果它适用于一切,那它就不适用于任何东西。
系统
很多领域的系统很正常地存在共同的,至少是类似问题。可以分成四类:
- emergent properties(涌现特性):涌现特性是指在系统的各个独立组件中并不明显,但在组合这些组件后才显现出来的特性,因此它们也可以被称为“意外现象”。有些现象只有在系统真正建成后才会出现。
- propagation of effects(影响传播):微妙的局部变化可能引起整个系统的连锁反应。民间用一句话总结了影响的传播特性:“在一个庞大的系统中,没有微不足道的改动。”
- incommensurate scaling(不成比例的扩展性):当一个系统的规模或速度增加时,不同部分的扩展规律可能不相同,导致系统无法正常运行。
- 伽利略(Galileo)曾观察到:“自然无法造出一个比普通人高十倍的巨人,除非大幅改变他的四肢比例,尤其是骨骼的尺寸,必须远大于普通人。”(出自《两门新科学的对话与数学证明》,第二天,1638年,莱顿。在1928年的经典论文《合适的体型》,它会因自身重量而倒塌。其根本原因在于,重量随着体积增加,而体积与线性尺寸的立方成正比,但骨骼的强度主要依赖于横截面积,而横截面积只随着线性尺寸的平方增长。因此,真正的大象的骨骼结构必须与放大版的老鼠完全不同,否则无法支撑其体重。
- trade-offs(权衡取舍): 许多约束以权衡的形式出现。宇宙中某种“良好属性”的总量是有限的,而设计的挑战首先是最大化这种良好属性,其次是避免浪费它,最后是将其分配到最能发挥作用的地方。
系统的定义?
Soution: 由许多通常不同的部分组成的复杂统一体,这些部分服从一个共同的计划或服务于一个共同的目的。工程师的角度来说,系统是一个由互连(interconnections, 描述“统一性”和“共同计划”)组件组成的集合,该集合在与环境的接口处表现出预期的行为。
系统概念的核心思想是将世界上的所有事物划分为两类:正在讨论的事物和未被讨论的事物。那些被讨论的事物构成系统,而未被讨论的事物属于环境。系统和环境之间总是存在相互作用,这些相互作用构成了接口(interface)。例如,太阳系与宇宙的接口包括与最近恒星的引力相互作用以及电磁辐射的交换。研究系统的目的是基于其组件、组件的互连方式以及各自的行为,来预测系统的整体行为。然而,如何确定系统的组件,取决于观察视角(point of view),而观察视角又有目的(purpose)和粒度(granularity)两个方面。
如何分析一个系统?
要分析一个系统,必须先确定观察视角,以便:
- 确定哪些事物被视为系统的组件;
- 确定这些组件的粒度(是更大的系统,还是较小的子系统);
- 确定系统的边界(哪些事物属于系统,哪些属于环境);
- 确定系统与环境的接口(哪些交互是研究的重点)。
复杂性
有5个复杂性的特征:
- 大量的组件
- 大量的相互连接
- 众多的不规则性
- 冗长的描述
- 需要团队进行设计、实施或维护
如果一个系统仅符合其中一两个特征,可能仍然难以被认定为复杂系统。例如,热力学研究的系统包含了不可思议数量的基本粒子和相互作用,但由于其行为可以用简单的方法进行系统化描述,因此并不被认为是复杂系统。真正的复杂性在于缺乏这种简单、系统化的描述。
一个可能的反对意见是:按照上述五个特征,所有系统都可以被认为是无限复杂的,因为研究越深入,就会发现越多的复杂性。例如,即使是最简单的数字计算机,也是由逻辑门组成的,而逻辑门由晶体管构成,晶体管由硅材料制成,而硅由质子、中子和电子构成,而这些粒子又由夸克构成,而物理学家甚至推测夸克本质上是振动的弦……如此下去,复杂性似乎永无止境。
我们可以用一种称为“抽象”(abstraction)的技术来解决这个问题,即限制研究的深入程度。我们关注的是,即使在使用抽象方法后仍然无法简化的复杂性。
复杂性的来源:
- 需求变化
- 保持高利用率
需求变化
一个密切相关的问题是,随着时间推移,系统的复杂性不断增长,即使是最简单的修改,比如修复一个 bug,也可能带来新的 bug,因为复杂性使得修复的影响难以完全预测。一个老化系统中常见的现象是,某个 bug 修复版本中引入的新 bug 可能比它修复的 bug 还要多。最终的结果是,随着系统的老化,它会不断积累变化,使得其复杂性逐渐增加。系统的生命周期通常受到这种不断累积的复杂性所限制,因为系统会逐渐偏离其最初的设计。
IMPORTANT
“一个能够正常运作的复杂系统,必然是从一个能够正常运作的简单系统演化而来的。” ——约翰·高尔 (John Gall), 《系统学》(1975)
保持高利用率

单个需求本身通常是复杂性的一个具体来源。它通常始于对高性能或高效率的追求。每当涉及到稀缺资源时,就会产生努力,试图保持其高利用率。越是努力提高有限资源的利用率,其复杂性就会越大。
2. 应对复杂性
有4个通用的技术来克服复杂性: 模块化、抽象、分层和分级
模块化
最简单但重要的降低复杂度方法就是分治技术,将系统作为交互子系统的集合。
抽象
尽管系统可以通过多种方式划分为模块,但其中一些划分方式比其他方式更优越——“应当按照自然的分界来切割“。更一般地说,它们的特点是,任何一个模块都可以仅根据其他模块的外部规范来使用它们,而无需了解其内部运作。这一额外的模块化要求被称为 抽象(Abstraction)。抽象是 接口与内部实现的分离,也是 规范与具体实现的分离。由于抽象几乎总是伴随着模块化,因此有些作者并不区分这两个概念。有时,人们使用 功能模块化(Functional Modularity) 一词,来表示带有抽象的模块化设计。
分层
采用 良好抽象 设计的系统,通常会尽量减少其组件模块之间的相互联系。其中,一个 强大的方法 是 分层(Layering)。在 分层设计 中,系统的上层建立在 已经完整的下层机制 之上,并使用它们来创建 另一套完整的机制。一个层可以由多个模块组成,但一般来说,某一层的模块只与同层的其他模块交互,或者与相邻的上下层模块交互。这一约束极大地减少了系统中的 模块间相互作用的数量。
几乎 所有计算机系统 都是分层的:
- 最低层 由 逻辑门(Gates) 和 存储单元(Memory Cells) 组成。
- 在此基础上,构建 处理器(Processor) 和 存储器(Memory) 层。
- 操作系统(Operating System) 层进一步扩展了 处理器和存储器 的功能。
- 应用程序(Application Programs) 运行在 增强后的处理器和存储器层 之上。
层次结构
另一种 应对复杂性 的 核心技术 也是 减少模块间的相互作用,但采用了一种 不同的、更加特殊的方法。
具体而言,我们可以:
- 从少量模块开始,将它们组合成一个 稳定的、独立的子系统,并为其 定义良好的接口。
- 再将少量子系统 组合成一个 更大的子系统。
- 如此递归,直到最终形成 整个系统。
这一过程会生成一个 类似树状结构(Tree-like Structure),称为 层次结构(Hierarchy)
但是这些还不够!!!这四种方法 都 假设设计者已经充分理解了系统。因此,计算机系统设计者 发展并完善了一种 额外的应对复杂性的方法,这种方法就是 迭代(Iteration)。
迭代
迭代的核心思想 是 先构建一个简单的、可运行的系统,满足 一小部分需求,然后 逐步改进,使其 逐步涵盖所有完整需求。这种方法的优点在于:
- 小步推进,可以 降低系统设计因复杂性而失败的风险。
- 始终拥有一个可工作的系统,可以:确保 至少有一个可用的成果;积累当前技术环境下的经验;及时发现和修复错误。
- 能够更轻松地适应技术变革:在 开发过程中,如果 技术发生变化,只需要 在某次迭代中做出调整,而不必彻底推翻整个系统。
首先,设计要适应迭代。你不可能在第一次设计时就完全正确,一定要让系统易于修改,记录设计背后的假设,当你需要修改设计时,能够识别出其他需要调整的部分。可能随着对系统和需求的深入理解,可能会涉及到重新进行模块化设计。
采取小步前进策略(Take Small Steps)。目的是尽快发现设计错误和不合理的想法,这样可以迅速更改或移除,以最小的成本解决问题。处于积极开发中的系统,甚至可能每天进行一次完整的系统重建(rebuild),因为 重建过程中会进行大量检查和测试,可以及时发现实现上的错误。同时,开发人员还能及时修复错误,因为他们 刚刚编写了这些代码,对问题仍然有 清晰的认知。
不要急于求成。尽管每一步迭代都很小,但仍然需要充分规划。在大多数项目中,团队往往急于进入实现阶段。在迭代式设计中,这种冲动可能更加强烈,因此设计者必须 确保设计已经准备好再进入下一步。
规划反馈机制(Plan for Feedback)。在 设计阶段 就 包含反馈机制,并 设立积极的激励措施,鼓励用户提供反馈。测试人员、安装人员、维护人员和最终用户 能够 提供大量关键信息,帮助系统不断优化。例如:Alpha 测试("我们完全不确定这个系统是否能正常运行"), Beta 测试("系统似乎可以运行,但请自行承担风险")。良好设计的系统 会在 各个层面提供反馈机制,以帮助开发团队 发现并解决问题
研究失败案例(Study Failures)。目标是从失败中学习,而不是追究责任。反馈机制必须设计合理,避免因 害怕被指责 而导致问题 被忽略或隐藏。当发现 失败的表面原因 后,还需要继续深入挖掘(Keep Digging)。复杂系统的失败往往有复杂的原因,持续寻找更深层次的原因,不要只停留在表面,许多 系统正常工作的原因 可能 并未被完全理解,因此,新版本的发布可能会 暴露 一个 长期存在但从未被触发的 bug。分析该 bug 过去为何不起作用,可以获得很多有价值的经验。不要忽视无法解释的现象。如果某个问题突然“自己消失了”,这可能是系统存在更深层次的问题,而不是它 神奇地修复了自己。
迭代的挑战
尽管 迭代 是一个 看似简单 的方法,但它 面临多个障碍。
概念完整性的丧失(Loss of Conceptual Integrity):随着系统通过多次迭代演进,有可能 逐渐丧失最初的概念完整性。这意味着 系统的最初设计 就必须 为最终版本的所有迭代做好规划(因此 需要前瞻性)。必须 有人持续关注整体设计逻辑,确保 即使在不断修改中,设计思路仍然清晰可见。
坏消息“二极管”现象(Bad-News Diode):好消息(如某个重要组件提前完成)传播得很快,但坏消息(如某个关键模块仍未完成)往往被局限在发现问题的团队内部,直到他们 解决问题并报告好消息。可能会导致 团队未能及时发现更合适的解决方案,例如 调整系统的其他部分来规避问题
模块化设计难以调整(Difficulty of Changing Modularity)。如果在后续迭代中发现模块化方案不合理,修改可能 非常困难,原因有两个:1. 不可动摇的基础(Unyielding Foundations)——改变模块化设计 本质上意味着 不只是修改单个模块,而是涉及多个模块,甚至 影响整个系统架构; 2. 人的心理因素——设计人员 已经投入了大量时间和精力,如果他们的模块 表面上“工作正常”,他们可能 不愿意接受模块化需要被推倒重来。
"第二系统效应"(The Second-System Effect),如果第一个版本的系统取得成功,设计师可能会 变得过于自信。他们会 在后续迭代中变得过于激进,由于 技术发展和用户反馈,系统会 加入大量新特性,但 这些新特性彼此之间如何相互影响 却很难判断。结果往往是 系统过度膨胀,导致灾难性的失败。
迭代 = 模块化管理,从某种意义上说,迭代方法 其实就是模块化管理的一种形式
保持简单
令人惊讶的是,在应对复杂性的技术中,最有效的技术之一也是最难应用的:简单性。这些考虑因素使得很难对任何一个需求、特性、例外或复杂性说“不”。系统设计者必须始终牢记这一累积影响。底线是,计算机系统设计者应对复杂性的最强武器是能够说“没有,这会使它太复杂”。
3. C/S模型加强模块化
如果所有模块都正确实现,那么任务就完成了。然而,在实践中,程序员会犯错,如果没有额外的考虑,错误的实现可能很容易从一个模块传播到另一个模块。为了避免这个问题,我们需要加强模块化。就是将系统组织为客户端和服务。三个主要的好处:
- 消息是程序员请求模块提供服务的唯一方式。将交互限制为消息使得程序员更难违反模块化约定。
- 消息是错误在模块之间传播的唯一方式。如果客户端和服务独立失败,并且客户端和服务都检查消息,它们可能能够限制错误的传播。
- 消息是攻击者渗透模块的唯一方式。如果客户端和服务在执行之前仔细检查消息,它们可以阻止攻击。
实现客户端/服务模型的一种有效方式是让每个客户端和服务模块运行在各自的计算机上,并在计算机之间设置一条通信路径。如果每个模块都有自己的计算机,那么如果一台计算机(模块)发生故障,另一台计算机(模块)仍然可以继续运行。由于唯一的通信路径是那条线,它也是错误传播的唯一路径。
3.1 C/S的组织
在大型程序中实现模块化的标准方法是将其划分为 具名的过程(named procedures),并让它们相互调用。尽管这种结构可以称为 模块化(modular),软模块化 可以限制正确实现的模块之间的交互,使其仅限于指定的接口,但实现错误 仍然可能导致超出接口范围的交互,影响系统的稳定性。
3.1.1 从软模块化到强模块化
考虑下面这个例子, 过程Measure测量func的运行时间。

为了实现模块分离,我们不希望所有需要获取时间的函数(视为调用者)都必须知道时钟的物理地址(在 get_time 的第 2 行中访问时钟)以及所有使用该时钟的应用程序(例如 measure)的具体实现。在一台计算机上,假设时钟在物理地址0x17E5,但是另外一个计算机上是0x24FF2;此外,一些时钟返回的是微秒,而另一些时钟返回的是六十分之一秒。调用者通过将时钟的具体属性封装到get_time中,调用get_time时无需进行更改。然而,get_time 与其调用者之间的边界是柔性的。尽管过程调用是实现模块化的主要工具,但错误仍然很容易在模块之间传播。
要理解为什么过程调用允许传播多种错误,需要深入了解过程调用的工作原理以及处理器执行过程调用的指令。将 measure 调用 get_time 编译成处理器指令的方法有很多种。为了具体说明,我们选择一种过程调用约定,尽管其他约定在细节上有所不同,但都会暴露出类似的问题。
我们使用栈来实现 get_time 的调用,这样 get_time 也可以调用其他过程(尽管在这个例子中它没有这样做)。为了支持对其他过程的调用,过程调用的实现必须遵循栈的使用规则,必须有一套约定来规定:由谁保存哪些寄存器、由谁在栈上存放参数、由谁负责移除参数、以及由谁在栈上分配临时变量的空间。

系统所采用的具体约定被称为过程调用约定。我们使用图 4.1 所示的约定。每次过程调用都会创建一个新的栈帧,该栈帧包含用于存储被保存的寄存器、传递给被调用过程的参数、存储被调用过程返回地址的空间,以及被调用过程的局部变量。

根据这一调用约定,这两个模块的处理器指令如图 4.2 所示。在本示例中,调用者(measure)的指令从地址 100 开始,被调用者(get_time)的指令从地址 200 开始。栈从低地址向高地址增长,过程的返回值通过寄存器 r0 传递。为简单起见,我们假设指令、内存地址和数据单元的大小均为 4 字节。在本示例中,measure 以如下方式调用 get_time:
- 调用者保存临时寄存器
r1和r2的内容(地址 100~112)。 - 调用者在栈上存储参数(地址 116~124),以便被调用者可以获取它们。(
get_time需要一个参数unit。) - 调用者在栈上存储返回地址(地址 128~136),以便被调用者知道调用者应从哪里恢复执行。(返回地址是 148。)
- 被调用者从栈中加载参数到
r2(地址 200~208)。 - 被调用者使用这些参数进行计算,可能会调用其他函数(地址 212)。
- 被调用者将
get_time的返回值存入r0,即实现中用于返回值的寄存器(地址 220)。 - 被调用者从栈中加载返回地址到
pc(地址 224~232),从而使调用者在地址 148 处恢复控制权。 - 调用者调整栈(地址 148)。
- 调用者恢复
r1和r2的内容(地址 152~164)。
我们使用低级处理器指令来展示这个具体示例(如图 4.2),因为它揭示了调用者和被调用者之间契约的细节,并说明了错误如何传播。在 measure 的示例中,契约规定被调用者应返回当前时间,并按照某种约定的格式传递给调用者。然而,当我们深入研究其内部实现时,会发现这种功能性的规范并不足以完全定义契约,而契约本身也没有有效的机制来限制错误传播。为了揭示模块之间契约的细节,我们需要检查图 4.2 中的栈是如何用于在不同模块之间传递控制的。
调用者和被调用者之间的契约存在几个潜在的问题:
- 按照契约,调用者和被调用者只能修改共享参数和它们各自的局部变量,并且被调用者应保持栈指针和栈的状态不变。如果被调用者出错导致栈上的调用者区域被破坏,那么调用者后续的计算可能会出错,甚至发生崩溃。
- 按照契约,被调用者应返回到调用者指定的地址。如果被调用者错误地跳转到了错误的地址,调用者可能会执行错误的计算,甚至完全失去控制。
- 按照契约,被调用者应在寄存器
r0中存储返回值。如果被调用者错误地将返回值存储在其他寄存器中,调用者会读取r0中的旧值,进而执行错误计算。 - 按照契约,调用者在调用被调用者之前应在栈上保存临时寄存器(如
r1、r2等)的值,并在控制权返回后恢复它们。如果调用者未遵守这一契约,那么被调用者可能已经修改了这些寄存器的值,从而导致调用者执行错误的计算。 - 被调用者的错误可能会对调用者产生副作用。例如,如果被调用者发生除零错误并因此终止,则调用者可能也会被迫终止。这种效应通常被称为命运共享(fate sharing)。
- 如果调用者和被调用者共享全局变量,那么按契约,它们只能修改约定共享的全局变量。然而,如果调用者或被调用者修改了其他未共享的全局变量,则它们(或其他模块)可能会计算错误,甚至发生崩溃。
因此,过程调用契约提供了一种可称之为软模块化(soft modularity)的机制。如果程序员犯了错误,或者过程调用约定的实现存在缺陷,这些错误很容易从被调用者传播到调用者。
3.1.2 C/S的组织结构

图4.3 展示了常见的C/S交互模式:客户端是发起请求的模块:它构造一个包含服务执行任务所需全部数据的消息,并将其发送给服务。服务则是响应请求的模块:它从请求消息中提取参数,执行所请求的操作,构造响应消息,将其返回给客户端,并等待下一个请求。
- 请求总是伴随着响应
- 设计者通常使用消息时序图(参见侧边栏 4.2)来表示交互过程。
从概念上讲,客户端/服务模型将客户端和服务运行在两台独立的计算机上,并通过网络连接。这种实现方式允许客户端和服务在地理上分离。这种实现方式的缺点在于,每个模块都需要一台独立的计算机,这可能会增加设备成本。此外,它还可能带来性能开销,尤其是在计算机距离较远时,消息传输可能需要较长时间。在某些情况下,这些缺点无关紧要;而在需要优化的场景下,虚拟化实现客户端/服务模型。
NOTE
时序图是一种方便的方式来表示模块之间的交互。当系统采用客户端/服务(client/service) 结构时,时序图尤其直观,因为在这种组织方式下,模块之间的交互仅限于消息传递。
在时序图中,模块的生命周期 由竖直线(vertical line) 表示,时间沿纵轴向下增加。以下示例展示了污水泵系统(sewage pumping system) 的时序图。
- 时间轴顶部的标签 表示不同的模块(例如:泵控制器(pump controller)、传感器服务(sensor service) 和 泵服务(pump service))。
- 模块之间的水平分隔 表示它们在物理上是独立的。
- 消息传输需要时间,因此,从泵控制器发送到泵服务的消息 在时序图上表现为向右下方倾斜的箭头。
时序图的基本要素
- 模块执行动作(actions),并发送/接收消息(messages)。
- 时间轴上的标签 表示某个时间点上某个模块执行的特定动作。
- 如果模块运行在不同的处理器上,它们可以同时执行 动作。
在某些情况下,消息可能被重排序(arrows cross)或丢失(arrows terminate midflight)

为了使客户端/服务模型更加具体,我们可以将测量程序(measure program)重新组织为一个简单的客户端/服务架构(见图 4.4)。客户端必须将参数转换为一种标准化表示,使服务能够正确解析,这个转换过程称之为编组(marshaling),涉及将对象转换为一个字节数组,并附加足够的元数据,以便解编组(unmarshal)过程可以将其还原回语言对象。

与基于过程调用的模块化方式相比,客户端/服务架构具有以下优势:
- 客户端和服务端除了通过消息传递外,不依赖于共享数据结构(如堆栈)。因此,错误只能通过消息传播,而不会直接影响彼此。
- 客户端与服务端之间的事务是“远程”事务,许多错误无法从一方传播到另一方。
- 客户端可以保护自己免受服务端无响应的影响,因为它可以设置等待响应的超时时间。如果服务端进入无限循环,或者发生故障而丢失请求,客户端可以检测到异常并采取恢复措施
- 客户端/服务架构鼓励使用显式、定义良好的接口。
如果网络出现故障,导致第一个请求没有得到确认,而发生了第二次请求。处理这类问题有几种方法,比如给每个请求分配一个id,然后在服务器上保持对这些ID的跟踪——但是有新的问题产生:如果服务期在处理请求的中途crashed了怎么办?


3.2 客户端与服务器的通信
本节介绍了发送和接收消息的两个扩展。首先,它引入了远程过程调用(RPC),这是一种风格化的客户端/服务端交互形式。其中每个请求后都会跟随一个响应。RPC 系统的目标是使远程过程调用看起来像普通的过程调用。然而,由于服务可能独立于客户端失败,远程过程调用通常无法提供与本地过程调用完全相同的语义。
其次,在某些应用中,希望能够向不在线的接收者发送消息,并从不在线的发送者接收消息。例如,电子邮件允许用户发送电子邮件,而无需接收者在线。通过使用中介进行通信,我们可以实现这些应用。
3.2.1 RPC
在上面的时间服务示例中,程序员必须调用 send_message 和 receive_message,并将结果转换为数字等。类似地,在文件服务示例中,客户端和服务端必须构建消息并将数字转换为位串等。编程这些转换是繁琐且容易出错的。

存根(stub),是一种用于RPC系统的辅助程序,他在C/S服务器之间起到了代理作用,主要功能是隐藏调用的复杂性,具体来说,是抽象化了格式化消息,等待响应等这些细节,使得远程调用看起来像本地调用。存根(stub) 减轻了程序员的负担(见图 4.7)。存根是一个过程,它向调用者和被调用者隐藏了编组(marshaling)和通信的细节。RPC 系统可以如下使用存根:客户端模块调用远程过程(例如 get_time),就像调用任何其他过程一样。然而,get_time 实际上只是运行在客户端模块内的存根过程的名称(见图 4.8)。存根将调用的参数编组成消息,发送消息,并等待响应。当响应到达时,客户端存根解组响应并返回给调用者。类似地,服务端存根等待消息,解组参数,并调用客户端请求的过程(例如 get_time)。在过程返回后,服务端存根将过程调用的结果编组成消息,并将其作为响应发送给客户端存根。
编写存根以将更复杂的对象转换为适合在网络上传输的表示形式可能会变得非常繁琐。一些高级编程语言(如 Java)可以根据接口规范自动生成这些存根,从而进一步简化客户端/服务端编程。图 4.9 展示了这种 RPC 系统的客户端。RPC 系统会生成类似于图 4.8 中 get_time 存根的过程。


3.2.2 RPCs 不同于过程调用
很容易认为,通过使用存根(stub),远程过程调用(RPC)可以完全与普通过程调用相同,从而使程序员无需考虑某个过程是在本地还是远程运行。事实上,当初提出 RPC 时,这正是一个主要目标,因此它被命名为“远程过程调用”(Remote Procedure Call)。
然而,RPC 与普通过程调用在三个重要方面存在差异:
RPC 可能会减少调用方与被调用方之间的命运共享(fate sharing),因为它会将被调用方的故障暴露给调用方,使调用方能够进行恢复。
- 需要处理间隔超时计时器,因为C/S的网络可能丢失消息
RPC 会引入一些普通过程调用不会出现的新故障。这两个差异改变了远程过程调用的语义,相较于普通过程调用,这些变化通常需要程序员调整相关代码。RPC 引入了一种新的失败模式——“无响应”失败(no response failure)。当服务端没有响应时,客户端无法判断具体发生了哪种错误:
- 在服务端执行请求之前发生了某种失败。
- 服务端已经执行了请求,但在响应返回之前发生了失败,导致响应丢失。
大多数 RPC 设计在处理“无响应”失败时,会选择以下三种实现策略之一:
- 至少执行一次(At-least-once)RPC:如果客户端存根在特定时间内没有收到响应,它会不断重发请求,直到收到服务端的响应。例如,对于
sqrt这样的函数来说,重复执行是无害的,因为相同的输入总会产生相同的输出。在编程语言的术语中,sqrt这样的服务是无副作用(side-effect-free)的,即它是幂等的(idempotent):无论请求执行多少次,结果都与执行一次相同。然而,“至少执行一次”并不能真正保证请求一定被执行。例如,如果服务端所在的大楼被飓风摧毁,那么重试也无济于事。 - 至多执行一次(At-most-once)RPC:如果客户端存根在特定时间内没有收到响应,它不会重试,而是直接返回错误,表明该请求可能已经被执行,也可能没有被执行。对于有副作用的请求,这种语义可能更合适。这样的结果更可控。然而,实现“至多执行一次”比听起来要难,因为底层网络可能会在不通知客户端存根的情况下复制请求消息
- 恰好执行一次(Exactly-once)RPC:这种语义是最理想的,但由于客户端和服务端是独立的,因此原则上不可能完全保证。例如,如果服务端所在的大楼被飓风摧毁,客户端存根最多只能返回错误状态。一般思路是:如果请求 $100 从账户 A 转账到账户 B 的 RPC 发生“无响应”失败,客户端存根可以发送一个单独的 RPC 请求,向服务端查询该请求的执行状态。这种解决方案要求客户端和服务端存根都要仔细记录每个 RPC 请求和响应,并且这些记录必须具有容错能力,因为服务端的计算机可能在最初的 RPC 和查询状态的 RPC 之间崩溃并丢失状态
远程过程调用的开销比普通过程调用更大。调用一个普通过程所需的指令数量(见图 4.2)远少于执行 RPC 时的开销,因为后者涉及调用存根、参数编组(marshaling)、通过网络发送请求、调用服务端存根、参数解组(unmarshaling)、结果编组、通过网络接收响应以及解组响应等多个步骤。
为了隐藏 RPC 的高成本,客户端存根可能采用各种性能优化技术。
最后一个不同点是,某些编程语言特性与 RPC 结合得不好。例如,两个过程如果通过全局变量进行通信,通常无法远程执行,因为不同计算机通常有独立的地址空间。同样,使用显式地址的语言结构也无法正常工作。例如,包含指针的数据结构作为参数传递时会成为问题,因为客户端计算机的指针是本地地址,在服务端解析时会绑定到不同的地址。可以设计系统使用全局引用来处理传递给远程过程调用的对象引用,但这需要额外的机制,并可能引入新的问题。例如,我们需要新的策略来确定何时可以删除一个本地对象,因为远程计算机可能仍然持有该对象的引用。不过,确实存在解决方案,例如 Network Objects 文章
由于 RPC 并不提供与过程调用相同的语义,因此“远程过程调用”这一术语可能会产生误导。多年来,RPC 的概念已经从最初试图完全模拟普通过程调用,发展为更广义的定义,即任何请求后紧跟响应的客户端/服务端交互
3.2.3 通过中介进行通信
从发送方向接收方发送消息要求双方必须同时在线。然而,在许多应用场景下,这种要求过于严格。例如,在电子邮件系统中,我们希望用户即使在收件人不在线时也能发送邮件。发送方发送消息,而接收方稍后才收到消息,甚至可能是在发送方已经离线的情况下。为实现这样的应用,我们可以引入 中介(intermediary)。在通信场景下,这个中介通常无需被信任,因为通信应用通常将其视为不受信任的网络的一部分,并通过其他手段保障消息安全。
中介的主要作用是实现 缓冲通信(buffered communication)。缓冲通信提供了 发送/接收(send/receive) 的抽象,但避免了发送方和接收方必须同时在线的要求,使得消息的传输可以在不同时间点进行。例如,中介可以暂存消息,直到接收方上线。中介可以将消息缓存在 易失性存储(volatile memory) 或 非易失性存储(non-volatile memory)(如文件系统)中。
一旦引入了中介,便出现了三个有趣的设计机会:
- push 和 pull:发送和接收方可以分别决定采用push还是pull方式传输消息。
- 使用中介进行模块解耦: 中介可以决定消息的最终接受者,而不是有原始发送方直接指定。
- 通过中介确定消息的复制方式和时间:设计者可以选择 何时 以及 在哪里 复制消息
发布/订阅(Publish/Subscribe)模式
发布/订阅(Publish/Subscribe)是一种充分利用 中介通信 设计机会的通用通信模式。
3.3 尚未解决问题
客户端/服务端模型强制模块化,并且是组织复杂计算机系统的基本方法。本书的其余部分将解决本章中提到但未深入探讨的主要问题:
- 在计算机内部强制模块化。将客户端/服务端系统的实现限制为每个模块一个计算机可能过于昂贵。后面展示了操作系统如何使用称为虚拟化的技术,将一个物理计算机分割成多个虚拟计算机。操作系统可以通过为每个客户端和每个服务提供一个独立的虚拟计算机来强制执行模块化。
- 性能。计算机系统有隐含或显式的性能目标。如果服务设计不当,系统中最慢的服务可能成为性能瓶颈,导致整个系统的性能受限于最慢的服务。识别和避免性能瓶颈是设计师在大多数计算机系统中面临的挑战。
- 网络。客户端/服务端模型必须有一种方法将请求消息从客户端发送到服务端,并将响应消息返回。实现
send_message和receive_message是一个具有挑战性的问题,因为网络可能在将消息路由到客户端和服务端的过程中丢失、重新排序或重复消息。此外,网络具有各种各样的性能特性,使得直接的解决方案变得不适用。 - 容错。我们可能需要一个服务在某些硬件和软件模块发生故障时仍能继续运行。例如,我们可能希望构建一个容错的日期和时间服务,它运行在多台计算机上,这样如果其中一台计算机故障,其他计算机仍然可以响应日期和时间的请求。在利用大量计算机提供单一服务的系统中,不可避免地在任何时间点都有一些计算机发生故障。例如,Google 使用超过10万台计算机来提供 Web 索引服务。随着计算机数量的增加,其中一些计算机不可用是必然的。容错技术使得设计人员能够从不可靠的组件中实现可靠的服务。这些技术包括检测故障、限制故障的传播和从故障中恢复。
- 原子性。本章描述的文件服务(第4.6节中的图4.6)必须能够在并发访问和故障的情况下正确工作,并使用
open和close调用来标记相关的读写操作。第9章介绍了一个名为原子性的框架,解决了这两个问题。这个框架允许在open和close调用之间的操作作为一个原子、不可分割的动作执行。正如我们在第4.2.2节中看到的,"精确一次"的RPC是实现银行应用程序的理想选择。第9章介绍了实现精确一次RPC和构建此类应用程序所需的工具。 - 一致性。本章使用消息实现各种协议,以确保不同计算机上数据存储的一致性。
- 安全性。客户端/服务端模型能够防止意外错误从一个模块传播到另一个模块。有些服务可能需要保护自己免受恶意攻击。例如,当文件服务存储敏感数据时,需要确保恶意用户无法读取这些数据。这种保护要求服务可靠地识别用户,以便做出授权决策。在面临恶意用户的情况下设计系统是安全性这一主题的内容。
在设计系统时,除了模块化,我们还需要关心哪些其他事情?
性能(Performance): 系统需要高效地处理请求,避免瓶颈和资源浪费。 安全性(Security): 系统需要保护数据和操作免受未授权的访问和攻击。 可扩展性(Scalability): 系统应能够处理增加的负载,支持横向和纵向扩展。 容错性(Fault Tolerance): 系统需要能够应对各种故障,提供高可用性。 可维护性(Maintainability): 系统应易于维护和升级,代码清晰且文档完善。 兼容性(Compatibility): 系统应能够与现有系统和标准兼容,支持集成和互操作性。 用户体验(User Experience, UX): 系统应提供良好的用户界面和交互体验,满足用户需求。