鲁汶大学高级编程语言概念笔记-全-
鲁汶大学高级编程语言概念笔记(全)
001:惰性求值 🚀


在本节课中,我们将要学习惰性求值,这是函数式编程中一种重要的方法。我们将探讨其基本概念、工作原理,并通过实例展示其在编程中的强大应用。

概述
惰性求值是声明式函数式编程中的一项关键技术。它允许我们延迟表达式的计算,直到其结果真正被需要时。这种“按需计算”的模式,使得我们可以处理无限数据结构、简化代码逻辑,并实现更灵活的程序控制流。

惰性求值的基本概念

上一节我们介绍了本课程的主题。本节中,我们来看看惰性求值的基本概念。
惰性求值是一种计算策略,其中函数或表达式的求值被推迟到其值被实际需要时。这与我们通常熟悉的及早求值(或称为严格求值)形成对比,在及早求值中,函数参数在函数调用前就会被计算。
一个简单的例子
以下是理解惰性求值的一个简单示例。我们定义一个惰性加法函数:
fun lazy {LazyAdd X Y}
X + Y
end
当我们调用 {LazyAdd 10 20} 时,并不会立即得到结果 30。相反,系统会创建一个名为惰性挂起的对象。计算只会在我们“需要”这个结果时才会发生。
那么,什么是“需要”呢?例如,如果我们尝试对这个结果进行另一个操作,比如 S + 100,这里的加法操作 + 需要它的两个参数都是具体的值。因此,它会触发对 {LazyAdd 10 20} 的求值,最终我们得到 30 和 130。
核心思想:是“需求”驱动了计算。这个简单的想法在实际编程中具有极其强大的威力。
惰性求值的语义
理解了基本概念后,我们需要深入其底层语义,看看它是如何实现的。
数据流语义回顾
首先,让我们回顾一下确定性数据流的语义。它基于两个基本操作:
wait X:挂起当前线程,直到变量X被绑定为一个值。bind X:将变量X绑定为一个值,并释放所有正在等待X的线程。
例如,对于 C = A + B 这样的操作,其语义是:
wait A(等待A成为值)wait B(等待B成为值)- 执行原始的加法操作
V = A + B bind V(将结果绑定到V)
这种 bind 和 wait 的配合,实现了线程间的数据流同步。
引入惰性:waitNeeded
为了支持惰性求值,我们在上述语义基础上增加一个新的原语操作:waitNeeded X。
wait X:等待直到X被绑定为一个值。waitNeeded X:等待直到X被需要(即,有另一个线程对X执行了wait操作)。

关键区别:wait 关注的是值何时准备好,而 waitNeeded 关注的是值何时被索取。
惰性函数的翻译
在语言层面,惰性函数通过 waitNeeded 来实现。一个及早函数和惰性函数的对比如下:
及早函数 fun {F X} E end 被翻译为:
proc {F X R} R = {Translate E} end
惰性函数 fun lazy {F X} E end 被翻译为:
proc {F X R}
thread
{WaitNeeded R} % 等待结果R被需要
R = {Translate E} % 然后才进行计算
end
end
可以看到,惰性函数创建了一个新线程,该线程会一直等待,直到有人需要函数的结果 R。当 R 被需要时(即有其他线程执行了 wait R),该线程被唤醒并执行计算 E,最后将结果绑定到 R。

惰性编程技术
掌握了惰性求值的原理后,本节我们来看看它能实现哪些强大的编程技术。
1. 无限数据结构

惰性求值允许我们轻松定义和使用逻辑上无限的数据结构,而无需担心程序陷入无限循环或耗尽内存,因为只有被请求的部分才会被实际计算。
示例:无限自然数列表
fun lazy {LazyNats N}
N | {LazyNats N+1}
end
调用 L = {LazyNats 1} 会创建一个惰性的无限列表 [1 2 3 4 ...]。只有当我们通过 {Touch L 10} 这样的函数请求前10个元素时,相应的部分才会被计算出来。
2. 推(Push)与拉(Pull)模式
这是程序设计中控制流的一个根本区别:
- 推模式(及早求值):生产者控制节奏。生产者生成数据并“推”给消费者。控制权在输入侧。
- 拉模式(惰性求值):消费者控制节奏。消费者按需“拉取”数据,驱动生产者进行计算。控制权在输出侧。
这两种模式是互补的。一个完整的系统通常会结合使用两者。例如,数据库查询是典型的拉模式(按需获取),而实时数据流处理可能是推模式(数据到达即处理)。
生产者-消费者示例对比
以下是两种模式的简单代码对比,它们都计算1到100的和:
及早(推)版本:生产者决定生成100个元素。
fun {Producer N} ... end % 生成N个元素后停止
fun {Sum Xs} ... end % 消费所有到来的元素
L = {Producer 100}
Result = {Sum L}
惰性(拉)版本:生产者无限生成,消费者决定只取前100个。
fun lazy {Producer} ... end % 无限生成元素
fun {Sum Xs N} ... end % 只求和前N个元素
L = {Producer}
Result = {Sum L 100}
3. 海明问题(Hamming‘s Problem)
这是一个经典问题,展示了惰性求值如何优雅地解决复杂算法。目标是生成所有形如 2^a * 3^b * 5^c(a, b, c为非负整数)的数,并按升序排列。
惰性解法:
- 从1开始。
- 将当前序列分别乘以2、3、5,得到三个新序列。
- 合并这三个序列,每次取最小的数加入结果序列。
- 递归地进行下去。
fun lazy {Times Xs N}
case Xs of X|Xr then (X*N)|{Times Xr N} end
end
fun lazy {Merge Xs Ys}
... % 合并两个有序流,去重
end
H = 1 | {Merge {Times H 2} {Merge {Times H 3} {Times H 5}}}
这个程序简洁地定义了一个无限的海明数序列 H。由于是惰性的,我们可以按需获取任意多个海明数,而内存消耗很小。
4. 有界缓冲区(Bounded Buffer)
这是结合及早和惰性求值的一个精妙例子。有界缓冲区插入在生产者和消费者之间,用于提升性能。
工作原理:
- 启动时(及早):缓冲区立即向生产者预取N个元素存起来。
- 消费时(快速响应):当消费者请求元素时,缓冲区可以立即从库存中给出,无需等待生产者。
- 补充库存(惰性触发):每当缓冲区给出一个元素,它就会异步地(在另一个线程中)再向生产者请求一个元素,试图将库存维持在N个。
这样,生产者和消费者都能更高效地工作:消费者等待时间减少,生产者的空闲时间也减少。关键在于,生产者和消费者的代码完全不用修改,缓冲区作为一个声明式组件被透明地插入。

总结
本节课中我们一起学习了惰性求值这一核心概念。
我们首先了解了它的基本思想:延迟计算,直到结果被真正需要。然后,我们深入探讨了其语义基础,即通过 waitNeeded 原语操作来实现“按需驱动”的计算模型。
基于这一模型,我们看到了多种强大的编程技术:
- 创建和操作无限数据结构,简化算法逻辑。
- 在推和拉两种控制模式间进行选择,以适应不同的应用场景。
- 使用惰性流优雅解决海明问题等经典算法挑战。
- 设计有界缓冲区这类混合了及早和惰性计算的组件,以提升系统性能,同时保持代码的声明性和模块化。

惰性求值不仅是函数式编程的利器,其思想也可以应用于任何编程范式,帮助我们编写出更高效、更简洁、更灵活的代码。
002:惰性求值详解



在本节课中,我们将深入学习惰性求值。我们将探讨惰性挂起的工作原理,分析惰性程序的执行过程,并区分整体式函数与增量式函数。最后,我们将通过一个惰性快速排序的实例,展示惰性求值如何能够“发明”出高效的算法。

惰性挂起
上一节我们介绍了惰性函数的基本概念。本节中,我们来看看惰性求值在程序执行时具体是如何工作的。理解这一点至关重要,因为仅仅在函数前加上 fun lazy 关键字很容易让人忘记其背后的机制。


惰性求值的核心是惰性挂起。一个惰性挂起就是一个线程,它正在等待某个变量的 WaitNeeded 操作。当一个惰性函数被调用时,它并不会立即执行,而是创建一个惰性挂起并附加到结果变量上。
例如,考虑一个生成无限整数列表的简单惰性函数:
fun lazy {Ins N}
N|{Ins N+1}
end
当我们调用 Xs = {Ins 1} 时,变量 Xs 会附加一个惰性挂起。这个挂起的“函数体”就是 N|{Ins N+1}。
当我们首次需要 Xs 的值时(例如通过 {Browse Xs.1}),Xs 上的 WaitNeeded 操作被触发,执行其挂起的函数体。这会将 Xs 绑定为 1|XsPrime,并为 XsPrime 创建一个新的惰性挂起 {Ins 2}。
这个过程可以持续进行。每次我们要求列表的下一个元素(如 Xs.2),都会激活当前的惰性挂起,计算出一个值,并为列表的剩余部分创建一个新的惰性挂起。最终,我们得到一个流,其末尾总是一个等待被激活的惰性挂起。
惰性数据结构(如树)的原理类似,整个数据结构的“前沿”(即边界)都可能由惰性挂起构成。
关于性能的说明:每个惰性挂起都会创建一个线程,这确实会带来一定的开销。然而,在 Oz/Mozart 系统中,线程是非常轻量级的。虽然创建成千上万个挂起比直接使用变量更昂贵,但这只是一个常数因子,不会改变算法的复杂度。惰性的优势在于它只计算真正需要的部分,这对于处理未知大小的数据或构建动态、增量式的程序非常有用。
汉明数问题再探
为了理解多个惰性挂起如何协同工作,让我们重新审视汉明数问题。汉明数的核心方程是:
H = 1 | {Merge {Times 2 H} {Merge {Times 3 H} {Times 5 H}}}
其中 Merge 和 Times 都是惰性函数。
当我们执行 {Browse H.1} 来获取第一个元素时,会触发一系列连锁反应:
- 首先,附加在
H上的Merge惰性挂起被激活。 - 为了执行其函数体,这个
Merge需要其两个参数(即{Times 2 H}和另一个Merge)的第一个元素。 - 这导致这两个参数的惰性挂起被激活。
- 第二个
Merge的激活又需要其参数({Times 3 H}和{Times 5 H})的第一个元素,从而激活这两个Times的惰性挂起。 - 由于
Times函数读取的是已知的H,它们执行后不会触发新的惰性挂起。
最终,为了得到 H.1 这一个值,我们总共激活了 5 个惰性挂起,绑定了 5 个变量,并进行了 5 个递归调用(这又创建了 5 个新的惰性挂起)。这个例子清晰地展示了惰性求值如何通过需求驱动,精确地执行最小必要计算。
整体式函数 vs. 增量式函数

并非所有函数都能从惰性求值中获益。根据惰性是否减少计算量,我们可以将函数分为两类。



- 增量式函数:惰性求值可以显著减少计算量。例如
Merge和Times,要得到结果的第一个元素,无需计算整个结果。 - 整体式函数:惰性求值无法减少计算量。要得到结果的第一个元素,必须计算整个结果。
一个典型的整体式函数例子是列表反转 Reverse:
fun {Reverse L A}
case L
of H|T then {Reverse T H|A}
[] nil then A
end
end
即使我们将其标记为 fun lazy 并只要求反转后列表的第一个元素(即原列表的最后一个元素),程序也必须遍历整个输入列表才能得到答案。惰性版本虽然能正确运行,但不会带来计算量的减少,反而会因为创建线程而引入额外开销。
因此,在应用惰性求值时,需要判断函数是否是增量式的。
惰性快速排序:算法“发明”
惰性求值最强大的特性之一,是它能将某些算法自动转化为高效的增量式算法。快速排序就是一个绝佳的例子。


标准的快速排序算法是:
fun {QuickSort L}
case L
of nil then nil
[] Pivot|Rest then
L1 L2 in
{Partition Rest Pivot L1 L2} % 分割列表
{Append {QuickSort L1} Pivot|{QuickSort L2}}
end
end
如果我们将其改为惰性版本(并使 Append 也惰性),会发生什么?
当我们要求排序结果的第一个元素(即最小值)S.1 时:
- 程序会对整个列表进行一次分割(
Partition),得到小于基准的列表L1和大于等于基准的列表L2。 - 惰性的
Append需要其第一个参数{QuickSort L1}的第一个元素。 - 这触发了对
L1的递归快速排序,而L2的排序则保持挂起状态。 - 对
L1的排序又会递归地进行分割和排序,直到找到最小的元素。
关键洞察:为了找到最小的 K 个元素,这个惰性快速排序所做的工作量大约是 O(N + K log K)。当 K 远小于 N 时,这远优于完全排序的 O(N log N),并且与专门寻找前 K 小元素的算法(如快速选择)效率相当。
更重要的是,我们没有显式地设计一个“寻找前 K 小元素”的算法。我们只是写了一个标准的、声明式的快速排序,然后通过惰性求值,就自动获得了一个高效的、增量式的结果生成器。这展示了惰性求值在“发明”算法方面的强大能力。
总结与前瞻
本节课中我们一起学习了惰性求值的深层机制。我们剖析了惰性挂起的执行过程,理解了惰性求值如何通过需求驱动计算。我们区分了整体式函数与增量式函数,认识到惰性并非万能。最后,通过惰性快速排序的案例,我们见证了惰性求值如何将标准算法转化为高效的增量式算法,这体现了声明式编程的强大之处。
惰性求值,结合数据流并发,构成了一个极其强大的编程模型。在这个模型中,程序可以既是惰性的(增量计算)又是并发的(并行执行),而所有这些都无需担心竞态条件,因为程序的声明式本质保证了确定性。

在接下来的课程中,我们将更精确地定义这种支持增量计算的确定性并发模型(涉及“部分终止”的概念),并探索更多利用惰性求值构建高效、优雅算法的实例。
003:第4讲 - 声明式编程范式与算法设计
概述
在本节课中,我们将学习声明式编程范式的完整体系,理解其理论基础,并探索如何利用声明式概念(如单次赋值、并发、数据流和惰性求值)来设计更智能、更高效的算法。课程分为两部分:首先,我们将系统性地回顾并比较各种声明式编程范式;其次,我们将深入探讨高级算法设计技术。
声明式编程范式概览
声明式编程是命令式算法的最终课程。我们将解释为何以声明式方式编写大部分程序是明智之举,并展示其强大能力。
首先,我们将概述所有已见的声明式编程范式。事实上,范式众多。我将制作一个表格,展示不同编程语言对这些范式的支持情况,以便你可以下载这些语言并亲自尝试。此外,像MapReduce这样的云工具也支持其中一些范式。
接下来,我将给出声明式编程,特别是声明式并发的精确定义,因为并发性至关重要。
声明式范式的分类
为了理解所有这些不同的范式,我们从顺序函数式编程开始,然后加入并发性和数据流变量(单次赋值变量),最后再加入惰性执行。为了理解所有这些不同的模型,我们将通过讨论变量生命周期中的三个特殊时刻来提供良好的直觉。
变量生命周期的三个时刻
- 变量的创建:当你定义变量时,例如使用
declare或local语句。 - 指定赋予其值的函数:例如,
X = F(Y)。这里我们指定了函数F,但尚未求值。 - 实际求值该函数:执行函数以获取变量的值。
在许多编程语言中,这些时刻是合并在一起的。但将它们分离会带来一系列不同的可能性。
范式表格
以下表格基于教材第286页,我将提供更多信息使其更清晰。表格包含六个部分。
- 横轴:执行策略 - 及早求值 或 惰性求值。
- 纵轴:计算模型 - 顺序 或 并发,以及是否使用 数据流变量。
以下是不同象限的详细说明:
顺序 + 及早求值 + 值(无数据流变量)
这是算法设计课程中常见的内容。标准的函数式编程,通常称为严格函数式编程。Scheme 和 ML 是位于此象限的语言,它们拥有活跃的社区。
顺序 + 及早求值 + 数据流变量
这本质上是带有数据流变量的顺序语言。它允许你实现尾递归列表函数。例如,Prolog(在确定性子集下)就属于此类。更强大的扩展是约束编程,这是一个非常强大且实用的范式。
并发 + 及早求值 + 数据流变量
这是我们讨论过的确定性数据流模型,也称为“傻瓜式并发”。这是一个非常优雅的并发范式。实际上,所有的大数据云分析工具(如 MapReduce、Apache Flink)都使用此模型。
顺序 + 惰性求值 + 值(无数据流变量)
这被称为惰性函数式编程或非严格函数式编程。Haskell 是此象限的代表性语言。其惰性求值实际上是通过协程实现的。
并发 + 惰性求值 + 数据流变量
这是我们称为惰性确定性数据流的模型。在此模型中,你可以编写有界缓冲区,因为生产者和消费者是并发且独立的。这比纯惰性模型(如Haskell)更具优势,因为它允许生产者提前计算。
顺序 + 惰性求值 + 数据流变量
这个模型使用较少,但也可以实现一些算法,如惰性快速排序。
声明式范式的优势与总结
所有这些模型都是声明式的,它们没有可变状态。其优势包括:
- 并发编程中没有竞态条件,无需担心锁。
- 易于并行化。
- 易于测试,各部分可独立测试,因为它们没有历史状态。
本课程的一个重要启示是:在构建系统时,应尽可能保持在此模型内。当然,并非总能如此,尤其是在与现实世界交互时(如处理用户界面、外部事件),有时需要引入状态。但对于大型应用程序的大部分代码,声明式范式是可行且高效的。
理论基础:超越邱奇-罗瑟定理
上一节我们介绍了各种声明式范式的分类。本节中,我们将从更理论的角度探讨声明式编程的基础。
在之前的课程中,我们看到了λ演算和邱奇-罗瑟定理。该定理指出,所有归约顺序都会产生相同的结果(至多变量重命名)。这被称为合流性,是声明式编程优势的基础。
但λ演算只描述单一计算:从一个初始表达式开始,经过归约,得到一个最终结果。我们的声明式模型则超越了这一点。
扩展方式一:部分终止
在声明式模型中,我们可以有永不真正终止的智能体。例如,一个处理流 [1, 2, 3, ...] 并输出其平方 [1, 4, 9, ...] 的智能体。输入流可以无限扩展,每次扩展都会触发新的计算,但每次计算本身会达到一个部分终止或静止点。邱奇-罗瑟定理适用于每一次这样的计算步骤。
扩展方式二:逻辑等价性
λ演算没有数据流变量(即未绑定变量)。在我们的模型中,变量可以未绑定。我们通过考虑所有可能的绑定方式来处理它们。即使结果中包含未绑定变量,只要所有可能的执行路径产生的变量可能值集合是相同的,那么这些结果就是逻辑等价的。这可以用一阶逻辑中的约束来形式化。
声明式并发的定义
基于以上,我们可以定义声明式并发程序:一个并发程序是声明式的,当且仅当对于相同的输入,所有执行要么都不终止,要么都达到部分终止,并且结果逻辑等价。
扩展方式三:封装非声明式部分
大型应用程序通常混合了声明式和非声明式代码。非声明式部分(如可变状态、错误)会“污染”整个程序,使其变得非确定性。解决方案是封装非声明式部分,使其从外部看仍然是声明式的。例如,通过异常处理来捕获错误并返回合理结果。
总结与实例:MapReduce

所有优势都源于邱奇-罗瑟定理。声明式模型通过部分终止和逻辑等价性扩展了该定理,从而实现了强大的声明式程序。
MapReduce 是一个绝佳的例子。它是一个纯粹的函数式(声明式)计算模型,用于处理海量数据。在由数千台计算机组成的分布式系统中,节点可能崩溃。但由于计算是声明式的,f(x) 总是对相同的 x 产生相同的结果。因此,如果一个节点崩溃,系统可以简单地在另一节点上重新执行 f(x),而整个系统的其他部分不受影响。这正是声明式编程在大规模、容错分布式系统中成功的关键原因。
高级算法设计技术
上一节我们探讨了声明式编程的理论基础。本节中,我们将转向更实际的内容,看看如何利用声明式概念设计巧妙的算法。
差异列表
首先介绍的概念是差异列表。差异列表是一种列表表示法,它将一个列表表示为两个列表的差。
例如,列表 [1, 2, 3] 可以表示为差异列表 (S, E),其中 S = [1, 2, 3 | X],E = X。当 X 被绑定为 nil 时,S 就代表了列表 [1, 2, 3]。
优势:常数时间追加
差异列表的妙处在于,它允许以常数时间追加两个列表。假设我们有差异列表 L1 = (S1, X) 代表列表 [1, 2, 3],L2 = (S2, Y) 代表列表 [4, 5, 6]。要追加它们得到代表 [1,2,3,4,5,6] 的差异列表 L = (S, E),只需将 X 绑定到 S2,并令 S = S1, E = Y。这只是一个简单的绑定操作。
示例:展平函数
以下是使用差异列表实现列表展平函数的示例,它避免了低效的追加操作。

% 普通展平函数,使用追加(低效)
fun {Flatten Xs}
case Xs
of nil then nil
[] X|Xr andthen {IsList X} then
{Append {Flatten X} {Flatten Xr}}
[] X|Xr then
X|{Flatten Xr}
end
end
% 使用差异列表的展平函数(高效)
proc {DFlatten Xs S E} % S, E 构成结果差异列表
case Xs
of nil then S = E % 空列表表示为 S=E
[] X|Xr andthen {IsList X} then
M in
{DFlatten X S M} % 展平X,结果差异列表为(S, M)
{DFlatten Xr M E} % 展平Xr,结果差异列表为(M, E)。这相当于追加。
[] X|Xr then
M in
S = X|M % 将X放在结果差异列表的开头
{DFlatten Xr M E}
end
end
% 调用方式:{DFlatten [1 [2 3] 4] S nil},结果在S中。
0-1-无穷大规则


差异列表体现了 0-1-无穷大规则:对于一个操作(如赋值),应该有三种可能性:
- 0次:不可能。例如,常量值。
- 1次:只能执行一次。例如,数据流变量(单次赋值)。
- 无穷次:可以执行任意多次。例如,可变状态。
单次赋值 获得了可变状态的部分优势(可以赋值),同时保留了声明式编程的所有优点,是一个非常有益的折中方案。

声明式队列实现
接下来,我们看看如何用声明式范式实现队列数据结构。队列支持在队尾插入、在队头删除。
简单(低效)实现
最简单的实现是使用一个列表,将队头放在列表头部。这样,插入是常数时间(在头部添加),但删除是 O(n) 时间(需要遍历到列表尾部)。
摊销常数时间队列
我们可以用两个列表 (F, R) 更巧妙地表示队列。队列的实际内容是 F ++ {Reverse R}。
- 插入:将新元素添加到
R的头部。O(1)。 - 删除:从
F的头部取出元素。O(1),前提是F非空。 - 当
F为空时,执行一次F = {Reverse R}并清空R。这个Reverse操作是 O(n) 的,但因为它只在一系列删除操作后F变空时才发生一次,所以平摊下来,每个操作仍然是常数时间。
这是一个纯函数式(严格)的实现。
最坏情况常数时间队列
通过引入数据流变量和差异列表的概念,我们可以实现最坏情况常数时间的队列。
我们将队列表示为一个差异列表 (S, E),并额外使用一个计数器来区分空队列。
- 插入:在差异列表的末尾添加元素。这涉及创建一个新的末尾变量,是 O(1)。
- 删除:从差异列表的头部移除元素。这涉及将头部绑定到一个新的变量,是 O(1)。
这个实现保证了每次插入和删除操作都是常数时间,但需要数据流变量的支持。
持久化数据结构

上述两种队列实现都是短暂性的:你只能有一个当前版本。如果你想要一个持久化数据结构(可以同时存在多个版本,例如用于协作编辑),则需要引入惰性求值。惰性求值可以构建出既是持久化又在每次操作中具有平摊甚至最坏情况常数时间复杂度的数据结构,但这超出了本次讨论的范围。
总结
本节课中,我们一起学习了声明式编程范式的完整图谱,从理论基础到实际应用。我们了解到,声明式编程通过避免可变状态,带来了确定性、易于并发、测试和容错等巨大优势。通过邱奇-罗瑟定理的扩展(部分终止、逻辑等价性、封装),声明式模型能够支撑从函数式编程到大规模分布式计算(如MapReduce)的广泛应用。
在算法设计部分,我们探索了如何利用声明式概念(如单次赋值、数据流)设计高效的数据结构和算法。差异列表展示了如何通过巧妙的表示法实现常数时间的列表追加。0-1-无穷大规则为我们设计语言特性提供了有益的指导。最后,我们比较了摊销常数时间队列和最坏情况常数时间队列的实现,直观地看到了数据流变量带来的性能提升。

掌握这些声明式编程的核心概念和技巧,将帮助你构建更可靠、更易于维护且高效的程序。
004:高级声明式编程

在本节课中,我们将要学习高级声明式编程的核心概念。我们将从回顾Lambda演算开始,探讨其在所有编程语言中的核心地位及其作为声明式编程范式的优势。接着,我们将深入分析声明式范式的分类,并学习如何利用惰性求值等高级技术,构建高效且具有持久性的数据结构。最后,我们会探讨如何实现安全的数据抽象。
Lambda演算回顾 🧮
上一节我们介绍了课程的整体结构,本节中我们来看看Lambda演算的基础知识。Lambda演算是一个简单的计算模型,它只包含两个核心概念:函数定义和函数应用。
一个Lambda项可以定义为:
- 变量:例如
x - Lambda抽象(函数定义):例如
λx.T,其中T是一个Lambda项。 - 函数应用:例如
T1 T2,其中T1是函数,T2是参数。

尽管看起来极其简单,但Lambda演算是图灵完备的。这意味着你可以用它来编码所有数据类型和控制结构,从而编写任何程序。
以下是一个简单的编码示例,展示如何用Lambda演算表示布尔逻辑:
- 真值:
true = λx.λy.x - 假值:
false = λx.λy.y - 与操作:
and = λp.λq.p q p
通过函数应用的规约规则,计算 and true false 最终会得到与 false 等价的结果。
Lambda演算之所以是所有编程语言的核心,主要有两个原因。首先,它本质上是高阶编程,即函数可以作为参数传递、作为结果返回,并可以任意创建和操作。通过高阶编程,可以定义所有形式的抽象,如对象、类、抽象数据类型和模块。抽象是构建大型软件系统的关键,而Lambda演算正是抽象的语言。
其次,Lambda演算定义了一种声明式编程范式。实际的编程语言为了与现实世界交互(如处理可变状态、硬件时钟等),会扩展Lambda演算。然而,尽可能保持在Lambda演算的范畴内编程具有巨大优势,这主要得益于其合流性属性。
合流性(由丘奇-罗瑟定理证明)是指:对于一个Lambda表达式,无论以何种顺序进行规约,最终都会得到相同的结果(至多变量名不同)。这一属性带来了诸多优势:
- 设计优势:具有引用透明性,即“相等的可以替换相等”,便于进行等式推理和优化。
- 测试优势:函数仅由输入和输出定义,没有内部状态或历史依赖,易于单独测试。
- 并发优势:没有可变状态,因此不存在竞态条件和非确定性,极大地简化了并发编程。
因此,Lambda演算不仅是计算的理论核心,其声明式范式也为构建可靠、可测试、可并发的程序提供了强大的实践基础。
声明式范式分类 📊
上一节我们探讨了Lambda演算的核心地位,本节中我们来看看基于Lambda演算的各种声明式范式如何分类。分类的关键在于观察变量生命周期中的三个特殊时刻:
- 定义变量。
- 定义计算其值的函数。
- 求值该函数。
根据这三个时刻是同时发生还是分离,我们可以对声明式范式进行分类。以下是主要的分类:
以下是主要的声明式范式分类:

| 范式 | 变量定义 | 函数定义 | 求值 | 关键特性 | 示例语言/模型 |
|---|---|---|---|---|---|
| 顺序函数式编程 | 同时 | 同时 | 同时 | 及早求值,无未绑定变量 | Scheme, ML |
| 惰性函数式编程 | 同时 | 同时 | 之后 | 惰性求值,值均为常量 | Haskell |
| 确定性数据流 | 分离 | 同时 | 同时 | 包含数据流变量(未绑定变量) | 声明式子集,Prolog(确定性部分) |
| 惰性确定性数据流 | 分离 | 同时 | 之后 | 包含数据流变量和惰性求值 | 未来的云编程模型 |
| 并发确定性数据流 | 分离 | 同时 | 同时 | 包含线程控制规约顺序 | MapReduce模型 |
| 并发惰性数据流 | 分离 | 分离 | 之后 | 变量、函数、求值三者完全分离,最通用 | “傻瓜式并发”,未来的高级模型 |
顺序函数式编程是最简单的形式,所有操作同时完成。惰性函数式编程推迟了求值,直到需要结果时才计算。确定性数据流引入了数据流变量,允许变量先声明后绑定值。
当我们引入线程(即对规约顺序的显式控制)时,就进入了并发领域。由于合流性,并发不会改变最终结果。并发确定性数据流模型(如MapReduce)在实践中非常成功。而并发惰性数据流模型结合了数据流变量和惰性求值,提供了最大的灵活性(例如“推”模型与“拉”模型),代表了声明式编程的未来方向。
高级声明式算法设计 ⚙️
上一节我们分类了各种声明式范式,本节中我们来看看如何利用这些范式的强大概念来设计高效的声明式算法。我们的目标是在保持声明式范式优点的同时,追求极致的效率。我们将重点关注两个关键概念:数据流变量和惰性求值。
利用这些概念,我们可以实现非常高效的数据结构。以下是根据不同范式所能达到的算法复杂度:
以下是不同范式下的队列算法复杂度:
| 范式 | 复杂度目标 | 持久性 |
|---|---|---|
| 顺序函数式编程 | 分摊O(1)时间 | 短暂性 |
| 顺序函数式编程 + 数据流变量 | 最坏情况O(1)时间 | 短暂性 |
| 惰性确定性数据流 | 分摊O(1)时间 | 持久性 |
| 惰性确定性数据流(优化后) | 最坏情况O(1)时间 | 持久性 |
短暂性数据结构在操作后,旧版本不再可用。持久性数据结构则允许同时存在和操作多个版本(例如Git仓库),这在协作和版本控制场景中至关重要。
我们以一个队列的实现为例。一个简单的函数式队列可以用两个列表表示:F(前端)用于出队,R(后端,反转后)用于入队。当F为空时,需要将R反转并移动到F。这个反转操作是O(n)的,使得单次操作可能很慢,但分摊下来每次操作仍是O(1)时间。然而,这个队列是短暂性的。
为了获得持久性且保持分摊O(1)复杂度,关键技巧是提前创建惰性求值的反转操作。我们引入一个“银行家方法”:昂贵的反转操作就像买房,我们需要提前储蓄。我们在R的长度刚好超过F时(即len(R) > len(F)),就创建一个惰性求值的“反转并追加到F”的承诺。此后,每次对F进行的出队操作,都相当于为这个未来的反转“支付”一点成本。当最终需要执行这个反转时,我们已经通过多次出队操作“储蓄”了足够的成本来支付它。这样,所有版本共享同一个惰性计算,分摊成本得以维持。
但上述方法中的反转仍然是** monolithic(整体性)的。为了达到最坏情况O(1)时间,我们需要一个更巧妙的技巧:将反转操作与列表的遍历(追加操作)合并,使其增量式进行**。
我们不再分别实现append(F, reverse(R)),而是定义一个合并函数 lazyAppRev,它同时遍历F和R:
fun {LazyAppRev F R}
case F of nil then R
[] X|F1 then
case R of Y|R1 then
Y|{LazyAppRev F1 R1} % 同时消耗F和R的一个元素
end
end
end
这个函数在每一步消耗F的一个元素(用于构建结果列表)和R的一个元素(R的头部正是reverse(R)的尾部元素)。由于我们总是在len(R) = len(F) + 1时调用它,因此它能完美地交错进行,使反转成本分摊到每一次出队操作中,从而实现了最坏情况O(1)时间的持久性队列。
这个例子展示了声明式编程的威力:通过高阶函数、数据流变量和惰性求值这些纯声明式的构件,我们能够设计出极其高效且优雅的算法,同时保留声明式范式在并发、测试和推理方面的所有优点。
安全的数据抽象 🔒
本节课中我们一起学习了Lambda演算的核心地位、声明式范式的分类以及利用惰性求值设计高级算法。最后,我们简要探讨安全的数据抽象。
安全的数据抽象意味着模块的内部实现被完全隐藏,外部代码无法通过任何方式(如反射、内存访问)“窥视”或“作弊”。这是所有现代编程语言保护模块化、维护不变性和保证系统完整性的基础机制。实现安全抽象通常需要语言运行时提供隔离环境、严格的类型系统或封装规则,确保用户只能通过定义的接口与抽象交互。

总结 📝
在本节课中,我们深入探讨了高级声明式编程。我们首先回顾了Lambda演算,理解了它作为所有编程语言抽象核心以及声明式编程基础的双重重要性。接着,我们基于变量生命周期的三个时刻,系统地对声明式范式进行了分类。然后,我们重点学习了如何利用数据流变量和惰性求值来设计高级算法,并以实现一个最坏情况O(1)时间的持久性队列为例,展示了声明式编程在保持纯度的同时达到极高效率的能力。最后,我们提到了安全的数据抽象作为构建可靠大型系统的基石。

通过本课的学习,你应该认识到声明式范式并非只是理论或“慢”的代名词,而是提供了一套强大、优雅且适用于现代计算(尤其是并发和分布式环境)的编程工具集。
005:消息传递并发(第一部分)


在本节课中,我们将要学习消息传递并发的高级概念,包括消息协议、主动对象,并通过一个经典问题来比较不同并发模型的实现方式。
消息协议回顾与延续

上一节我们介绍了基本的远程方法调用(RMI)协议。本节中,我们来看看如何通过“延续”技术来实现带有回调的RMI,而无需创建额外的线程。
延续是一种数据结构,它封装了需要在未来某个时刻执行的计算。在带有延续的RMI回调中,客户端将原本需要等待服务器回调后才能执行的计算,封装成一个独立的“延续”消息。
以下是其工作原理:
- 客户端向服务器发送初始请求消息。
- 服务器执行计算,并在需要时向客户端发送回调请求(如请求一个
delta值)。 - 客户端处理回调请求后,服务器将最终结果连同“延续”消息一起发送回客户端。
- 客户端收到“延续”消息后,执行其中封装的计算。
这种方法避免了在客户端创建新线程,但需要服务器了解并发送延续消息。其核心代码结构如下:

% 客户端发送请求,并准备接收延续
proc {ClientMethod}
{Send ServerPort calc(x:X cont:$)} % 发送请求,指定返回延续
end
% 服务器处理请求并发送延续
proc {ServerLoop S State}
case S of calc(x:X cont:Cont)|Sr then
... % 执行计算,可能涉及回调
{Send ClientPort Cont(result:Y)} % 将结果通过延续消息发回
{ServerLoop Sr NewState}
end
end


从被动对象到主动对象 🧵

上一节我们讨论了基于消息的端口对象。本节中,我们来看看如何将其与面向对象编程结合,形成“主动对象”。

传统的对象(我们称之为被动对象)本身不包含线程。当多个线程同时调用同一个被动对象的方法时,这些方法的执行会在调用者的线程中交错进行,可能导致竞态条件。例如,两个线程同时递增一个计数器,最终结果可能由于读取和写入操作的交叉而错误地只为1。
主动对象通过为每个对象绑定一个专属线程来解决这个问题。所有对该对象的方法调用都转化为异步消息,发送到该对象内部的消息队列中,由对象自己的线程顺序处理。这保证了对象内部状态的一致性,无需额外的锁机制。
在Oz中,创建一个主动对象的简化模式如下:
fun {NewActive Class Init}
Obj = {New Class Init} % 创建内部的被动对象
P S
in
{NewPort S P} % 创建端口
thread % 对象专属线程
for M in S do {Obj M} end % 顺序处理所有消息
end
P % 返回端口,用于向对象发送消息
end
主动对象结合了面向对象的封装、继承多态等优点和消息传递并发模型的线程安全性,是现代并发系统中的一个重要概念。

案例研究:弗拉维乌斯·约瑟夫斯问题 ⚔️
为了深入比较不同并发模型,我们使用一个经典问题:弗拉维乌斯·约瑟夫斯问题。
问题描述:N个人围成一圈,从第一个人开始报数,每数到第K个人就将其淘汰出圈,然后从下一个人继续报数,直到只剩一人。求最后幸存者的位置。

我们将用两种风格实现这个淘汰协议:
- 主动对象风格:每个参与者是一个主动对象,通过异步消息传递来模拟报数和淘汰。
- 确定性数据流风格:每个参与者是一个流式处理器(函数),通过流(列表)连接成环,消息在流中传递。


以下是两种实现的核心思路对比。
主动对象实现
在主动对象实现中,每个“士兵”是一个主动对象,它们组成一个环。每个对象有一个kill(X, S)方法,其中X是当前计数值,S是剩余存活人数。
协议规则如下:
- 如果对象存活且
S==1,则它是幸存者。 - 如果对象存活且
X mod K == 0,则该对象“死亡”,并向下一对象发送kill(X+1, S-1)。 - 如果对象存活且
X mod K != 0,则它存活,并向下一对象发送kill(X+1, S)。 - 如果对象已死亡,则直接转发消息。
初始向第一个对象发送kill(1, N)消息即可启动过程。
确定性数据流实现
在数据流实现中,每个“士兵”是一个函数,它读入一个包含kill消息的输入流,并根据协议产生一个输出流。这些函数通过高阶函数Pipe连接成一个环。
每个处理函数Victim的逻辑与主动对象的协议类似,但它通过是否进行递归调用来表示自身的“存活”状态。当士兵死亡时,它停止递归,不再处理后续消息,相当于从环中被短路移除。
fun {Victim I K Last InStream} % I:身份, K:步长, Last:幸存者变量, InStream:输入流
case InStream of kill(X S)|Ir then
if S==1 then Last=I nil % 找到幸存者,终止流
elseif X mod K == 0 then
kill(X+1 S-1)|{Victim I K Last Ir} % 死亡,转发消息,自身不再递归(短路)
else
kill(X+1 S)|{Victim I K Last Ir} % 存活,转发消息,继续递归
end
end
end
对比与总结
通过对约瑟夫斯问题的两种实现,我们可以得出以下观察:
- 代码复杂度:确定性数据流版本通常更紧凑、更声明式,代码行数更少,尤其是结合了高阶函数后。
- 概念模型:主动对象模型更贴近我们对“自主个体”和“消息传递”的直观理解。数据流模型则更侧重于“计算”和“变换”。
- 适用性:确定性数据流仅适用于确定性并发问题(如约瑟夫斯问题)。对于涉及非确定性选择(如随机淘汰)或复杂交互状态的系统,主动对象模型更为灵活和强大。
- 性能:对于此类问题,两种实现经过优化(如在数据流中短路死节点,在主动对象中移除死节点)后,性能差异不大。

本节课中我们一起学习了消息协议中的延续技术、被动对象与主动对象的区别,并通过弗拉维乌斯·约瑟夫斯问题实践和比较了主动对象与确定性数据流这两种并发编程范式。下一部分,我们将探讨如何设计由大量主动对象组成的多智能体系统。
006:多智能体编程 🚀

在本节课中,我们将学习如何构建多智能体系统。多智能体编程涉及创建由多个并发组件(或称“智能体”)组成的大型系统,这些组件通过相互发送消息进行通信。我们将通过一个电梯控制系统的具体实例,来理解从设计到实现的全过程。
多智能体系统概述 🤖
多智能体编程是指构建由多个智能体组成的大型系统。这些智能体可以是活动对象、端口对象或确定性的数据流对象(流对象)。程序本质上是一组具有内部状态、并能相互发送消息的智能体的集合。
我们之前学习的确定性数据流,实际上是多智能体编程的一种受限形式,因为它保证了确定性。然而,在真实的复杂系统中,由于需要与外部世界(如用户输入)交互,程序往往必须表现出非确定性行为。例如,在客户端-服务器模型中,服务器必须对首先到达的请求做出反应,而请求到达的顺序是不可控的,这就引入了非确定性。
通过使用端口对象,我们可以超越确定性数据流,设计出具有非确定性行为的程序。
核心概念与构建模块 🔧
构建多智能体系统非常复杂,因为存在大量的并发交互。因此,我们必须采用严谨的方法。以下是构建此类系统的基本概念和操作。

并发组件
在我们的例子中,一个并发组件是一个带有输入和输出的过程。调用这个过程会创建一个智能体实例。这类似于面向对象编程中从类创建对象。
代码示例:创建组件实例
% 定义一个全加器组件
proc {FullAdder Xs Ys Cs ?S ?C}
...
end
% 实例化一个全加器智能体
{FullAdder Xs Ys Cs S C}
四种基本操作
构建系统时涉及四种基本操作:
- 实例化:创建一个组件的新实例(即一个新的智能体)。
- 组合:将多个较小的组件组合成一个更大的组件。例如,全加器内部由多个逻辑门组成。
- 连接:将组件通过“连线”连接起来,以便它们可以通信。连线有不同的类型:
- 单次发送 vs. 多次发送:单次赋值变量(数据流变量)是“单发”连线,只能绑定一次值。端口和流是“多发”连线,可以发送多条消息。
- 单源 vs. 多源:流是单源的(只有一个写入者,多个读取者),因此是确定性的。端口是多源的(多个写入者),因此是非确定性的。
- 限制:在连接组件时,隐藏内部的连线,实现封装。这就像在构建电子电路时,将内部连线封装在芯片内部。
设计方法论:从规范到实现 📐
由于系统复杂性,我们不能直接开始编码。我们需要一个系统化的设计方法,其核心是状态图。以下是设计步骤:
- 非形式化规范:首先用自然语言描述系统需要做什么。
- 定义组件:确定系统中有哪些类型的智能体,它们各自负责什么,以及它们之间传递哪些消息。
- 定义消息协议:精确定义组件间通信的消息格式和顺序。
- 绘制状态图:为每个组件绘制有限状态自动机图。这是设计过程中最关键、最困难的部分。状态图需要枚举组件所有可能的状态,以及在每个状态下,接收到各种可能消息时应如何转换状态。
- 实现:根据状态图编写代码。一旦状态图正确,实现过程几乎是机械式的,甚至可以使用工具自动生成代码。
- 验证与测试:对实现进行形式化验证(证明某些属性始终成立)和大量测试(在各种场景下运行)。发现问题后,可能需要回到前面的步骤进行修改。
上一节我们介绍了多智能体系统的基本概念和设计方法论,本节中我们来看看如何将这些理论应用到一个具体的、复杂的例子中。


实例:电梯控制系统 🛗
我们将构建一个控制多部电梯在多层建筑中运行的系统。这个系统很好地展示了多智能体、并发和非确定性的特点。
系统组件
系统包含三类基本组件:


- 控制器:控制电梯马达,使电梯一次移动一层楼。它接收来自电梯的“步进”指令,并通知电梯它已到达某一层。
- 楼层:代表建筑中的每一层楼。它接收用户的“呼叫”请求,并管理电梯门的开关。
- 电梯:电梯本身。它接收来自楼层和电梯内部用户的“呼叫”请求,维护一个需要前往的楼层“日程表”,并与控制器协作来移动。
消息协议


组件间通过以下协议通信:
- 用户 -> 楼层:
call(用户按下楼层呼叫按钮) - 楼层 -> 电梯:
call(F)(楼层F呼叫一部电梯) - 电梯 -> 楼层:
arrive(F, Ack)(电梯到达楼层F,并等待一个确认Ack才能离开) - 楼层 -> 电梯:
Ack=unit(楼层绑定Ack,允许电梯离开) - (电梯内)用户 -> 电梯:
call(F)(用户按下电梯内前往F层的按钮) - 电梯 -> 控制器:
step(Dest)(电梯请求移动到目标楼层Dest) - 控制器 -> 电梯:
at(F)(控制器通知电梯现在位于楼层F)
组件状态图分析
以下是各组件状态图的要点分析。
控制器状态图 🎛️
控制器有两种状态:stopped(停止在某层)和running(正在移动)。
- 在
stopped状态收到step(Dest)消息时,如果目标Dest与当前层F不同,则启动定时器模拟移动,并进入running状态。 - 在
running状态收到stopTimer消息(表示移动完成)时,向电梯发送at(F)消息,并返回stopped状态。


楼层状态图 🏢
楼层有三种状态:notcalled(空闲)、called(已呼叫电梯,等待中)和doorsOpen(门已打开)。
- 关键点在于,在任何状态下,都必须能处理
call(用户再次按键)和arrive(电梯到达)消息,并做出合理反应(例如,在门已开时再次收到呼叫,则忽略;在空闲时收到到达消息,也需开门)。
电梯状态图 📈
电梯的状态更复杂,核心是维护一个日程表(需要访问的楼层列表)。主要状态包括moving(移动中)和waitForDoors(等待门关闭)。
- 在
moving状态,它可以接收新的call(F)消息,并将其加入日程表。 - 当收到控制器的
at(F)消息且F是日程表的第一项时,电梯进入waitForDoors状态,等待楼层发来的确认Ack。 - 收到
Ack后,如果日程表还有后续楼层,则返回moving状态前往下一层;否则进入空闲状态。
从状态图到代码实现 💻
一旦状态图设计完成并经过推敲,代码实现就相对直接了。以下是如何将状态图转化为Oz代码的示例。
控制器代码示例
proc {Controller Init}
% 状态表示为元组:Motor#Floor#LiftID
fun {StateLoop Motor F Lid}
case Motor
of running then
% 在运行状态,只处理 stopTimer 消息
receive stopTimer then
{Send Lid at(F)} % 通知电梯到达
{StateLoop stopped F Lid}
end
[] stopped then
% 在停止状态,处理 step 消息
receive step(D) then
if F==D then {StateLoop stopped F Lid}
elseif F<D then
{StartTimer} % 启动定时器模拟移动
{StateLoop running F+1 Lid} % 状态变为运行,目标层+1
else
{StartTimer}
{StateLoop running F-1 Lid} % 目标层-1
end
end
end
end
in
thread {StateLoop Init} end
end
这段代码几乎是状态图的逐行翻译。receive语句等待特定消息,case语句区分不同状态。
关于消息处理的说明
在本例的模型(基于端口对象)中,每个智能体有一个消息队列,并严格按照消息到达的顺序处理。如果某个状态不处理某种消息(例如电梯在waitForDoors状态不处理call),该消息会留在队列中,直到智能体进入一个能处理它的状态。消息永远不会被丢弃。
测试、验证与软件工程三大支柱 🏗️

实现完成后,必须进行严格的测试和验证。
- 测试:运行系统,模拟各种场景和极端情况(如同时呼叫多部电梯、在电梯门开启时按键等)。
- 验证:通过逻辑推理或模型检测等形式化方法,证明系统始终满足某些关键属性。例如:
- 属性1:电梯最终总会停在其日程表中的每一个楼层。
- 属性2:如果向电梯发送了
call(F)消息,电梯最终总会到达楼层F。

这引出了稳健软件开发的三大支柱,对于多智能体系统尤为重要:
- 设计:使用清晰的方法论(如基于状态图的设计)来构建坚实的系统结构。
- 测试:通过运行大量案例来发现错误。
- 验证:通过形式化推理来保证关键属性的正确性,覆盖测试难以触及的角落。
三者缺一不可,忽略任何一项都会带来风险。

总结 📚


本节课中我们一起学习了多智能体编程的核心内容。我们从基本概念出发,了解了如何将系统视为由交互的智能体组成。然后,我们深入探讨了一个严谨的设计方法论,其核心是绘制详尽的状态图。最后,我们通过一个完整的电梯控制系统实例,演示了如何从非形式化规范开始,逐步定义组件、协议、绘制状态图,并最终实现为可运行的代码。我们强调了在构建此类复杂并发系统时,结合设计、测试和验证这三大支柱的重要性。
007:构建并发程序的最佳方式 🚀


在本节课中,我们将要学习构建并发程序的最佳方式,即结合确定性数据流和端口。我们将从回顾并发组合开始,然后深入探讨如何实现动态的线程同步,并理解其背后的证明。
概述


构建并发程序是困难的。在多智能体编程中,我们必须确保消息可以随时发生,这在处理大量智能体时尤其复杂。然而,确定性数据流模型则简单得多,它允许我们随意添加线程。本节课的核心问题是:我们能否在保持确定性数据流优势的同时,进行通用的分布式编程?答案是肯定的。我们将学习如何尽可能多地使用确定性数据流,并在必要时引入端口,从而以最简单、最易于理解和证明正确性的方式编写并发程序。
并发组合(屏障同步)🧱
上一节我们提到了确定性数据流的优势。本节中,我们来看看如何实现一个基础的并发控制结构:并发组合,也称为屏障同步。
线程语句 thread S end 会创建一个独立执行的新线程。但有时,原始线程需要等待其创建的所有子线程都完成后才能继续。这就是并发组合的作用。


并发组合的表示法为 S1 || S2。它创建两个并发执行的线程(S1 和 S2),并等待它们都终止后,再执行后续语句 S3。
以下是使用单赋值数据流变量实现并发组合的代码:
local X1 X2 in
thread
S1
X1 = unit
end
thread
S2
X2 = unit
end
{Wait X1}
{Wait X2}
S3
end
在这段代码中,我们引入了变量 X1 和 X2 用于同步。每个子线程在完成其任务 S 后,会将对应的变量绑定为 unit 值。原始线程通过 {Wait X} 语句等待这两个变量都被绑定,从而确保在继续执行 S3 之前,所有子线程都已结束。
构建高阶抽象:Barrier 过程

上述实现较为冗长。我们可以利用高阶编程构建一个通用的 Barrier 抽象,它接受一个语句(过程)列表,并等待所有语句执行完毕。
以下是 Barrier 过程的实现:
fun {Barrier Ps}
Xs = {Map Ps
fun {$ P}
X
in
thread
{P}
X = unit
end
X
end}
in
for X in Xs do {Wait X} end
end
Barrier 过程的工作原理如下:
- 使用
Map函数为列表Ps中的每个过程P创建一个新线程来执行它,并返回一个与该线程关联的单赋值变量X。 - 线程执行完
P后,会将X绑定为unit。 - 最后,使用
for循环等待列表Xs中的所有变量都被绑定。
这个抽象完全使用确定性数据流实现,没有引入任何非确定性。


动态并发组合:挑战与定义
上一节我们介绍了静态已知线程数量的并发组合。本节中,我们来看看一个更强大的概念:动态并发组合,其中线程数量在运行时动态变化且未知。
考虑一个场景:主线程创建子线程,而子线程自身也可以创建更多的子线程(递归地)。我们需要同步所有后代线程的终止,但事先并不知道线程的总数。


这个抽象无法仅用确定性数据流实现,它需要一个端口。我们定义以下 API:
NewThread:一个过程,它接受一个主语句S作为参数,并返回一个Subthread过程。Subthread:一个由NewThread返回的过程。在S或任何Subthread的执行体中,可以调用Subthread来创建新的子线程。- 当调用
NewThread时,它会执行S,并等待所有通过Subthread创建的后代线程(包括递归创建的)都结束后才返回。
使用端口实现动态并发组合 🧮
我们使用一个端口来跟踪活跃线程的数量。基本算法是:
- 创建一个端口
P及其流S。 - 每当通过
Subthread创建一个新线程时,在创建线程之前,立即向端口发送+1。 - 在每个新创建的线程内部,在其执行体结束之后、终止之前,向端口发送
-1。 - 主线程读取流
S,维护一个运行总和(初始为0)。每收到一个+1则加一,收到-1则减一。 - 当运行总和再次变为0时,意味着所有创建的线程都已终止,此时主线程的等待结束。

以下是 NewThread 和 Subthread 的实现代码框架:

fun {NewThread P}
Pt = {NewPort S}
proc {Subthread P}
{Send Pt 1} % 发送 +1
thread
{P}
{Send Pt ~1} % 发送 -1。~1 在 Oz 中表示 -1。
end
end
in
{Subthread P} % 启动主任务
{ZeroExit 0 S} % 等待总和归零
Subthread % 返回 Subthread 过程
end
proc {ZeroExit N S}
case S of X|S2 then
if N+X == 0 then skip
else {ZeroExit N+X S2}
end
end
end
正确性证明:不变式断言 🔍
为了确信上述算法的正确性,我们需要进行形式化推理。我们使用一个不变式断言:流 S 上所有元素的总和,始终大于或等于当前活跃线程的数量。
我们通过归纳法来证明这个不变式:
- 基础情况:程序开始时,流为空(总和为0),活跃线程数为0。不变式成立。
- 归纳步骤:考虑可能改变不变式的四种动作:
- 发送 +1:这只会增加流的总和,不会减少活跃线程数,因此不变式保持或变得更安全(总和更大)。
- 创建新线程:此动作使活跃线程数加1。关键点在于,根据代码,
Send Pt 1(发送+1)发生在线程创建之前,并且与创建动作在同一个(父)线程中顺序执行。因此,在活跃线程数增加之前,流的总和已经增加了1。所以不变式仍然成立。 - 发送 -1:这发生在子线程内部,且在其执行体
{P}完成之后。我们可以认为线程的“终止”逻辑上发生在此发送动作之前。发送-1会减少流的总和,而活跃线程数也因该线程即将终止而减少。因此,在调整后,不变式依然成立。 - 线程终止:这使活跃线程数减1。由于对应的
-1消息已经在流中(或紧随其后),总和也已反映,不变式成立。

由于不变式始终成立,那么当 ZeroExit 检测到流的总和(运行总和 N)为0时,根据不变式(总和 >= 活跃线程数),可以推出活跃线程数也必须为0。这就证明了所有线程都已终止,同步是正确的。
总结
本节课中,我们一起学习了构建并发程序的一种强大方法。
- 我们首先回顾了并发组合(屏障同步),并使用确定性数据流实现了它。
- 接着,我们将其抽象为高阶的
Barrier过程。 - 然后,我们探讨了更复杂的动态并发组合问题,其中线程数量动态未知。
- 我们利用一个端口来计数活跃线程,并设计了
NewThread/Subthread的 API 和实现算法。 - 最后,为了确保算法的正确性,我们引入了不变式断言进行推理和证明,展示了形式化方法在并发编程中的实际应用。


这种方法的核心思想是:尽可能使用纯粹的确定性数据流,仅在必要时(如管理未知数量的动态线程)引入最少量的非确定性元素(如端口),从而在保持程序易于理解和推理的同时,获得强大的并发表达能力。
008:并发编程技术与Erlang简介


在本节课中,我们将学习两种重要的并发编程技术:如何通过移除顺序依赖来提升程序灵活性,以及工业级并发编程语言Erlang的核心概念。

移除顺序依赖:并发过滤器示例

上一节我们介绍了端口和屏障等并发构建块。本节中,我们来看看如何利用它们来移除程序中的“无用”顺序依赖,从而提升程序的灵活性和响应性。
在顺序程序中,指令按顺序执行,后一条指令必须等待前一条指令执行完毕。这有时会引入“无用依赖”:即使两条指令在逻辑上无关,后一条指令也会因为前一条指令(例如,等待一个未绑定的变量)的阻塞而被阻塞。
我们可以通过引入新线程和端口来移除这些依赖,这种方法有时被称为“傻瓜式并发”。但需要注意,移除顺序依赖会引入并发,而并发意味着不确定性(输出顺序可能改变)。这是一个本质上的权衡:在确定性和灵活性之间做出选择。
顺序过滤器的问题

以下是一个标准的函数式过滤器定义,它存在无用依赖问题:
declare
fun {Filter L F}
case L of H|T then
if {F H} then H|{Filter T F}
else {Filter T F} end
[] nil then nil
end
end
当我们对一个包含未绑定变量的列表(例如 [A 5 1 B 4 0 6])应用过滤函数(例如 fun {$ X} X>2 end)时,整个计算会因为在A上的等待而阻塞。即使我们已知5、4、6肯定满足条件,也无法立即输出它们。

构建支持关闭操作的端口
为了构建并发过滤器,我们需要一种能“关闭”的端口,以便在收集完所有结果后将流转换为一个以nil结尾的列表。这需要一个“单元”(Cell)来动态追踪流的末端。

以下是带有关闭操作的端口实现:
proc {NewPortClose S Send Close}
C = {NewCell S} % 单元C始终保存流的当前末端
in
proc {Send M}
Old New in
{Exchange C Old New} % 原子操作:读取Old值,并将C更新为New
Old = M|New % 扩展流
end
proc {Close}
{Assign C nil} % 将流的末端绑定为nil,关闭端口
end
end
Exchange是一个原子操作,确保了在并发环境下发送消息的正确性。

实现并发过滤器
利用新端口和屏障,我们可以实现并发过滤器{ConFilter L F}。其核心思想是:为输入列表中的每一个元素 X 单独创建一个线程来执行 {F X}。如果结果为真,则通过端口发送X;所有线程结束后,关闭端口。
以下是并发过滤器的实现:
proc {ConFilter L F ?L2}
Send Close
in
{NewPortClose L2 Send Close} % L2是输出流
{Barrier
{Map L
fun {$ X}
proc {$}
if {F X} then {Send X} end
end
end}}
{Close}
end

代码解析:
{NewPortClose L2 Send Close}: 创建端口,L2是输出流。{Map L ...}: 为输入列表L中的每个元素X,生成一个过程(线程任务)。该过程检查{F X},若为真则发送X。{Barrier ...}: 屏障接收这个由过程组成的列表,为每个过程创建线程,并等待所有线程结束。{Close}: 所有线程结束后,关闭端口,使L2成为一个完整的列表。
运行{ConFilter [A 5 1 B 4 0 6] fun {$ X} X>2 end}会立即输出[5 4 6],而不会因A和B未绑定而阻塞。1和0被立即排除。但请注意,输出结果[5 4 6]的顺序可能是任意的(如[4 6 5]),这体现了移除依赖后引入的非确定性。
并发编程范式总结
到目前为止,我们学习了三种构建并发程序的有效范式:
- 确定性数据流:这是最佳选择,如果适用。它结合了函数式编程的清晰性和并发性。许多现代大数据处理框架(如Apache Flink/Spark)都基于此范式。
- 多智能体编程:适用于存在大量外部非确定性的系统(如电梯控制系统)。程序由多个有状态的、通过异步消息通信的智能体(端口对象)组成。Erlang语言是这一范式的杰出工业代表。
- 确定性数据流与端口结合:这是作者推荐的、最全面的方法。首先尽可能使用确定性数据流编写程序主体,仅在需要非确定性的地方(如与外部交互、收集异步结果)引入端口。这种方法在保持大部分代码纯净的同时,提供了必要的灵活性。


接下来我们将简要了解Erlang,看看多智能体编程在工业实践中是如何应用的。
Erlang语言简介
Erlang是一种为构建高并发、分布式、容错系统而设计的编程语言,广泛应用于电信、金融等领域。
核心概念
Erlang程序由大量轻量级进程组成,这类似于我们学习的端口对象。关键区别在于:
- 无共享:进程间不共享内存,消息传递时进行复制。这使得单个进程的失败不会直接影响其他进程。
- 邮箱:进程拥有一个邮箱(而非严格的流),可以基于模式匹配从邮箱中提取消息,消息处理顺序更灵活。
- “Let it Crash”哲学:不试图避免所有错误,而是设计系统在部分进程崩溃后能够快速恢复。这通过进程链接和监督树等机制实现。
语法速览
Erlang在语法和理念上与课程内容高度相似:
- 单次赋值:变量不可变。
- 函数式核心:支持高阶函数、模式匹配、递归。
- 进程创建与通信:
% 创建一个新进程(类似于NewPortObject) Pid = spawn(fun() -> server_loop() end). % 向进程发送消息(! 操作符) Pid ! {rectangle, Width, Height}. % 接收消息(从邮箱中模式匹配) receive {rectangle, W, H} -> io:format("Area: ~p~n", [W * H]); Other -> io:format("I don't know: ~p~n", [Other]) end.
一个著名的成功案例是爱立信的AXD301 ATM交换机,其声称达到了99.9999999%的可用性,展示了Erlang在构建超高可靠性系统方面的强大能力。

本节课中我们一起学习了:
- 如何通过并发过滤器的实例,利用端口和屏障移除顺序依赖,并理解了由此带来的确定性与灵活性的权衡。
- 回顾了三种主流的并发编程范式:确定性数据流、多智能体编程以及结合二者优点的混合方法。
- 对工业级并发语言Erlang进行了初步了解,认识了其基于轻量级进程和“Let it Crash”哲学的设计理念,及其在构建分布式容错系统中的应用。


这些概念和工具为你设计和理解复杂的并发系统提供了坚实的基础。
009:共享状态并发


在本节课中,我们将要学习共享状态并发。这是一种编写并发程序的方式,它允许线程通过共享可变状态(称为“单元”)进行通信和协作。我们将介绍单元的概念、共享状态并发的挑战,以及如何使用锁来管理并发访问的复杂性。
概述:共享状态并发
共享状态并发是一种基于线程和可变单元的并发编程范式。它被许多主流语言(如Java、C++)广泛使用,但也是最复杂的范式之一。为了驾驭这种复杂性,我们引入了三个核心概念:锁、监视器和事务。
上一节我们回顾了三种良好的并发范式。本节中,我们来看看第四种范式——共享状态并发。


单元:可变状态的基础


共享状态意味着存在一些可变的、在线程间共享的信息。共享状态并发基于可变状态的概念,即可以被多次赋值的变量。
为了避免术语混淆,我们将这种可变的存储位置称为单元。一个单元就像一个盒子,它有一个固定的名称(或身份标识)和一个可以改变的内容。
我们向内核语言添加了三个操作来使用单元:
- 创建单元:
C = {NewCell A}。创建一个初始内容为A的新单元,并将单元的名称绑定到变量C。 - 赋值:
C := X。将单元C的内容替换为X的值。 - 读取:
Z = @C。将变量Z绑定到单元C的当前内容,而不改变单元。
例如,在类似Java的语言中,语句 i = i + 1 实际上隐含了读取 i 的值和向 i 赋值两个操作。在我们的模型中,这会明确地写为类似 i := @i + 1 的形式。

此外,对于并发编程,还有一个至关重要的原子操作:
- 交换:
{Exchange C X Y}。这个操作原子性地执行读取和写入:它将单元C的当前内容读取到X,同时将Y作为新内容写入C。处理器架构(如x86的CMPXCHG指令)提供了对这类操作的原生支持,确保其执行过程不会被调度器中断。

共享状态并发的挑战
当我们有多个线程操作共享单元时,就会面临交错执行的问题。如果每个线程对共享单元执行 n 次操作,那么总的可能操作序列(交错)数量是 C(2n, n),这大约等于 2^(2n) / sqrt(πn)。

这是一个关于 n 的指数级函数。这意味着随着操作数增加,可能的执行路径数量会爆炸式增长。程序要正确,所有可能的交错都必须正确。仅靠测试无法覆盖所有情况,而形式化验证在面对指数级状态空间时也会变得困难。
因此,确保共享状态并发程序正确的唯一可行方法,是通过设计来减少需要担心的交错数量。
解决方案:构建大型原子操作

所有并发范式都在通过不同方式减少交错。对于共享状态并发,核心策略是将多个细粒度操作组合成大型原子操作。调度器要么在原子操作开始前中断,要么在其完成后中断,但绝不会在操作中间中断。这样,我们只需要考虑这些大操作之间的交错,而非其内部每个原始步骤的交错。
这通常通过以下三种技术实现,它们构成了一个层次结构:
- 锁:确保一次只有一个线程能执行受保护的代码段(临界区)。
- 监视器:在锁的基础上,增加了让线程在条件不满足时等待、并在条件满足时被唤醒的机制。
- 事务:在锁的基础上,增加了对失败(如崩溃)的处理能力,能保证操作的原子性、一致性、隔离性和持久性(ACID属性)。
锁是最基础的概念。监视器非常复杂且在现代设计中通常不推荐使用。而事务(尤其是具备ACID属性的完整事务)则至关重要且被广泛应用。
锁的概念与语义
锁是一种语言概念,它保证在程序的某个部分(称为临界区)内,一次最多只有一个线程在执行。


我们定义锁的抽象,它包含以下操作:
L = {NewLock}:创建一个新的锁L。{IsLock L}:测试L是否为锁。lock L then S end:用锁L保护语句S。S就是受保护的临界区。
锁的语义如下:
- 如果没有线程正在执行任何由锁
L保护的语句,那么任何线程都可以进入其临界区。 - 如果一个线程试图进入时,已有另一个线程在临界区内,则该线程将挂起等待,直到内部线程离开。
- 可重入性:如果一个线程已经在锁
L保护的临界区内,它再次尝试获取同一个锁L时会立即成功(允许进入)。这个特性对于构建复杂的、可能递归调用自身方法的抽象至关重要。
同一个锁可以保护程序中多个不连续的代码段,所有这些代码段的集合构成了该锁的临界区。


示例:使用锁实现并发队列
为了理解锁的应用,我们来实现一个并发队列。队列支持在尾部插入元素和在头部删除元素。
我们使用一个元组 q(N, F, B) 来表示队列内部结构:
N:队列中的元素数量。F:一个列表,表示队列的头部。这个列表的末尾不是一个空值,而是一个未绑定的逻辑变量B。B:指向列表末尾的未绑定变量,代表队列的尾部。
这种利用单赋值变量表示动态结构的方法,可以实现常数时间的插入和删除操作。

以下是使用锁保护的队列实现的核心思路:

fun {NewQueue}
Q = {NewCell q(0 X X)} % X是未绑定变量,代表空队列的尾部和头部末端
L = {NewLock}
proc {Insert X}
lock L then
... % 1. 读取当前队列状态 q(N, F, B)
... % 2. 将B绑定为新的列表节点 [X|B2],并将B2作为新的尾部
... % 3. 将单元格更新为 q(N+1, F, B2)
end
end
proc {Delete ?X} % X是输出变量
lock L then
... % 1. 读取当前队列状态 q(N, F, B)
... % 2. 从列表F中取出第一个元素,绑定到X,剩余的列表作为新的F
... % 3. 将单元格更新为 q(N-1, 新的F, B)
end
end
in
queue(insert:Insert delete:Delete)
end
Insert 和 Delete 过程都使用同一个锁 L 来保护。这确保了修改队列内部单元格的操作(读取状态、计算新状态、写入新状态)是原子性的。一个线程在执行 Insert 时,另一个线程试图执行 Delete 或另一个 Insert 都必须等待。
总结

本节课中我们一起学习了共享状态并发的基础。我们首先引入了单元作为可变状态的基本构件,并解释了原子交换操作对并发的重要性。我们分析了共享状态并发的主要挑战——指数级增长的执行交错,并指出解决之道在于构建大型原子操作以减少需要分析的交互点。

我们详细介绍了最基础的同步原语——锁。锁通过保证临界区的互斥访问来创建原子操作。我们还了解了锁的可重入特性。最后,我们通过一个并发队列的实例,演示了如何使用锁来保护对共享数据结构的复合操作,确保其线程安全性。



锁是更高级概念(监视器、事务)的基石。理解锁的工作原理是掌握共享状态并发编程的关键第一步。
010:并发队列、锁与元组空间


在本节课中,我们将学习并发编程中的三个核心概念:并发队列的实现、锁的内部机制,以及一种结合了共享状态和消息传递的协调模型——元组空间。我们将通过具体的代码示例来理解这些概念。



队列的插入与删除操作
上一节我们介绍了并发编程的基础,本节中我们来看看一个具体的并发数据结构——队列的实现。首先,我们需要理解队列的插入和删除操作是如何工作的。
以下是插入操作的逻辑描述:
- 绑定队列的“后端”到一个新的变量
X|B2。 - 更新队列,将新的后端
B2放入,并将元素X添加到队列中。 - 队列大小
N增加 1。

以下是删除操作的逻辑描述:
- 将队列的“前端”绑定到模式
X|F2,从而获取第一个元素X和剩余部分F2。 - 更新队列,使用
F2作为新的前端。 - 队列大小
N减少 1。
这两个操作都涉及对共享状态(队列)的读取和更新,因此它们必须被放在一个锁保护的临界区内,以确保原子性。
并发队列的实现
理解了基本操作后,我们现在来实现一个具体的并发队列。这个实现将使用一个存储队列状态的 Cell 和一个用于同步的锁。

以下是并发队列的代码实现:
fun {NewQueue}
C = {NewCell q(0 X X)} % 初始队列:大小0,前端X,后端X
L = {NewLock}
proc {Insert X}
{Lock L
proc {$}
q(N F B) = {Access C}
B = X|B2 % 绑定后端,添加新元素
in
{Assign C q(N+1 F B2)} % 更新队列状态
end}
end
proc {Delete ?X}
{Lock L
proc {$}
q(N F B) = {Access C}
X|F2 = F % 绑定前端,获取第一个元素
in
{Assign C q(N-1 F2 B)} % 更新队列状态
end}
end
in
queue(insert:Insert delete:Delete)
end

这个 NewQueue 函数返回一个记录,包含 insert 和 delete 两个过程。它们内部都使用同一个锁 L 来保护对 Cell C 的访问,确保了操作的线程安全。
关于空队列删除和可重入锁
现在,我们来探讨两个重要问题。首先,如果对空队列调用 Delete 操作会发生什么?其次,为什么我们需要可重入锁?

对空队列执行删除
在我们的实现中,如果队列为空时调用 Delete,队列大小 N 会变为 -1。这实际上创建了一个“承诺”:变量 X 会被绑定,但要等到未来某个 Insert 操作执行时才会实现。这是单次赋值特性带来的一个有趣优势,它允许某些操作非阻塞地进行。

可重入锁的必要性
考虑一个场景:我们需要原子性地插入两个元素 X 和 Y,确保它们中间不会被其他线程的操作打断。我们可能会创建一个 Insert2 过程:
proc {Insert2 X Y}
{Lock L}
{Insert X}
{Insert Y}
{Unlock L}
end
如果锁 L 不是可重入的,那么当线程在 Insert2 中已经持有锁 L,再调用 Insert(其内部也试图获取锁 L)时,线程会等待自己释放锁,导致死锁。可重入锁允许同一个线程多次获取它已持有的锁,从而安全地构建这种复合操作。所有实际系统中的锁(如 Java 的 synchronized)都是可重入的。
使用 Exchange 操作实现高效队列
我们之前的队列实现使用了锁。然而,由于插入和删除都只包含一次 Cell 的读和写,我们可以利用更底层的原子 Exchange 操作来实现一个无需显式锁的高效队列。

以下是使用 Exchange 实现的队列:
fun {NewQueueExch}
C = {NewCell q(0 X X)}
proc {Insert X}
Old New
in
{Exchange C Old New}
q(N F B) = Old
B = X|B2
New = q(N+1 F B2) % 注意:N+1 的计算在 Exchange 之后
end
proc {Delete ?X}
Old New
in
{Exchange C Old New}
q(N F B) = Old
X|F2 = F
New = q(N-1 F2 B) % 注意:N-1 的计算在 Exchange 之后
end
in
queue(insert:Insert delete:Delete)
end
这个实现的关键在于,Exchange 原子性地读取旧值并准备一个新值(初始未绑定)。在 Exchange 之后,我们根据读出的旧值计算新值并绑定它。这种实现非常高效,并且依然保留了“可提前删除”的特性。当然,如果需要像 Insert2 这样的复合原子操作,仍然需要额外的锁。

锁的实现:令牌传递
我们已经多次使用了锁,现在来看看锁本身是如何实现的。我们将使用令牌传递技术和 Exchange 操作来实现一个锁。
锁的核心是保证只有一个线程能进入临界区。我们可以用一个唯一的令牌来代表这个进入权限。线程必须持有令牌才能进入,离开时则将令牌传递给下一个等待的线程。
以下是简单锁(非可重入)的实现代码:
fun {NewSimpleLock}
Token = {NewCell unit} % 初始令牌可用(unit)
proc {LockProc P}
Old New
in
{Exchange Token Old New} % 获取一个位置/令牌
{Wait Old} % 等待令牌变为绑定状态(即获得权限)
try {P} finally New=unit end % 执行操作,最后传递令牌
end
in
lock(lock:LockProc)
end
它的工作原理如下:
- 每个试图获取锁的线程调用
Exchange,得到一个“旧令牌”Old和一个“新令牌”New。 - 第一个线程的
Old是初始值unit(已绑定),因此它无需等待,直接执行过程P。 - 后续线程的
Old是前一个线程的New(初始未绑定),因此会在{Wait Old}处阻塞。 - 当持有锁的线程执行完
P后,在finally子句中将自己的New绑定为unit,这相当于释放了令牌,唤醒下一个正在等待这个New(即它的Old)的线程。


实现可重入锁
简单锁的问题在于,如果同一个线程在已持有锁的情况下再次尝试获取锁,它会再次调用 Exchange 并等待自己的令牌,导致死锁。为了实现可重入,我们需要知道当前是哪个线程持有锁。
以下是可重入锁的实现:
fun {NewReentrantLock}
Token = {NewCell unit}
CurThread = {NewCell unit} % 记录当前持有锁的线程
proc {LockProc P}
if {Thread.this} == {Access CurThread} then
{P} % 同一线程重入,直接执行
else
Old New
in
{Exchange Token Old New}
{Wait Old} % 进入临界区
{Assign CurThread {Thread.this}} % 记录当前线程
try {P}
finally
{Assign CurThread unit} % 清除记录
New=unit % 传递令牌
end
end
end
in
lock(lock:LockProc)
end
这个实现增加了一个 Cell CurThread 来记录当前锁的持有者。当线程尝试获取锁时:
- 如果当前线程 ID 等于
CurThread中的记录,说明是重入,直接执行操作P。 - 否则,执行标准的令牌获取、等待流程。在获得令牌后,立即将
CurThread设为自己,在执行完操作后再将其清空。
注意:语句顺序至关重要。{Wait Old}(进入)必须在 {Assign CurThread ...} 之前;{Assign CurThread unit}(清除)必须在 New=unit(退出)之前。这样才能保证对 CurThread 的修改发生在受保护的临界区内。

元组空间:一种协调模型

最后,我们介绍一种介于共享状态和消息传递之间的并发编程模型——元组空间。它是一个由多个元组组成的多重集合(袋子)。

元组空间提供三种基本操作:
Write(T):将元组T放入空间。永不阻塞。Read(L ?T):从空间中取出一个标签为L的元组,并绑定到T。如果不存在,则阻塞等待。ReadNonblock(L ?T ?B):尝试取出一个标签为L的元组。如果成功,B为true且T被绑定;否则B为false。


元组空间像共享状态,因为所有线程共享同一个数据空间。它也像消息传递,因为 Write 类似发送消息,Read 类似接收消息。关键区别在于,发送者(Write)不知道接收者(Read)是谁,实现了完全的解耦。


元组空间可以用我们已学的并发原语(如锁和队列)来实现,为构建分布式并发程序提供了一个高级抽象。
总结
本节课中我们一起学习了并发编程中的几个核心实践:
- 并发队列的实现:我们使用锁和 Cell 实现了一个线程安全的队列,并探讨了使用原子
Exchange操作的高效无锁实现。 - 锁的内部机制:我们深入了解了锁如何通过令牌传递技术来实现互斥,并实现了支持同一线程重入和异常安全的可重入锁。
- 元组空间:我们介绍了一种结合共享状态和消息传递的协调模型,它通过
Write和Read操作在不知道通信对方的情况下协调线程间的合作。


这些概念是构建正确、高效并发系统的基础。下一讲,我们将继续深入,探讨监控器和事务内存等更高级的并发控制概念。
011:共享状态并发(第一部分)


在本节课中,我们将继续学习共享状态并发。我们将深入探讨元组空间的概念,并介绍监视器。元组空间是一种介于共享状态和消息传递之间的有趣模型,而监视器则是Java等语言中广泛使用的并发控制机制。
元组空间详解
上一节我们介绍了锁,并快速提到了元组空间。本节中,我们将详细解释元组空间的概念。
元组空间是一个多重集合,有时也称为“包”。其中存储着元组,并且相同的元组可以出现多次。它包含三个核心操作:
write(T):向空间添加一个元组T。read(L):从空间中移除一个标签为L的元组。这是一个同步操作,如果空间中没有匹配的元组,调用线程将等待。readNonBlocking(L):read的非阻塞版本。如果存在匹配的元组则移除并返回true,否则立即返回false。

这个概念由 David Gelernter 在1985年提出,称为 Linda。它也被称为协调模型,现代常见的发布-订阅系统就是其变体。

元组空间结合了共享状态和消息传递的特点:
- 类似共享状态:多个线程可以访问同一个元组空间,进行读写。其
write和read操作本身就是大型原子操作,简化了编程。 - 类似消息传递:
write类似于发送消息,read类似于接收消息。关键区别在于,发送者不知道接收者是谁。例如,向一个打印机池发送打印任务,任何空闲的打印机都可以接收并处理。
使用元组空间实现队列


为了理解元组空间的优势,我们可以用它来实现一个并发队列,并与之前用锁实现的版本进行对比。
以下是使用元组空间实现队列的核心思路:
fun {NewQueue}
TS = {NewTupleSpace}
State = q(0 nil nil) % 初始状态元组
in
{TS write(State)}
proc {Insert Q X}
{TS read(State $)} % 取出状态令牌
{TS write(q(... X ...))} % 放入新状态
end
proc {Delete Q X}
{TS read(State $)} % 取出状态令牌
{TS write(q(...))} % 放入新状态
end
...
end
关键点:队列的状态本身被表示为一个唯一的元组(令牌)。read 操作会从空间中移除这个令牌,从而自动获得了对该状态的独占访问权,因此不再需要额外的锁。相比之下,使用锁的实现需要显式地获取和释放锁来保护共享变量。

元组空间的实现
元组空间内部是如何工作的呢?其实现通常需要三个组件:
- 一个锁:用于保护内部数据结构。
- 一个字典(哈希表):键是元组的标签,值是对应标签的元组队列。这确保了高效查找和公平(先进先出)访问。
- 一个并发队列:用于实际存储每个标签下的元组。
因此,元组空间和队列是相互构建的有趣概念。
监视器介绍

锁和元组空间虽然有用,但在某些场景下能力有限。本节中我们来看看监视器。
考虑一个有界缓冲区问题:缓冲区有固定容量。当缓冲区满时,put 操作必须等待;当缓冲区空时,get 操作必须等待。单纯的锁无法实现这种“条件等待”,它只能管理临界区的互斥访问。
监视器解决了这个问题。它本质上是锁的扩展,在锁的基础上增加了:
- 一个等待集:用于存放被挂起的线程。
wait和notify操作:用于管理线程进出等待集。wait:调用线程释放锁并进入等待集。notify:从等待集中唤醒一个(任意)线程。被唤醒的线程会尝试重新获取锁,并从它之前调用wait的地方继续执行。
在Java中,每个对象都关联着一个监视器,并提供了 wait(), notify(), notifyAll() 方法。notifyAll() 会唤醒等待集中的所有线程。


监视器的编程模式

使用监视器编程需要遵循一个特定模式,以确保正确性。以有界缓冲区的 put 操作为例,正确的模式如下:

// 伪代码模式
public synchronized void put(Object x) {
while (buffer.isFull()) { // 必须用 while,不能用 if
wait();
}
// ... 执行核心操作(如放入元素) ...
notifyAll(); // 通常使用 notifyAll 更安全
}
为什么必须用 while 循环检查条件?
当线程从 wait() 中被唤醒时,条件(例如“缓冲区不满”)可能已经再次变为假。因为被唤醒的线程在重新获得锁之前,可能有其他线程抢先进入并修改了状态(例如填满了缓冲区)。使用 while 循环可以确保在继续执行前重新检查条件。


为什么推荐使用 notifyAll()?
使用 notify() 只唤醒一个任意线程,可能唤醒的是“错误”的线程(例如,缓冲区有空间后唤醒了一个等待 get 的线程)。虽然被错误唤醒的线程会因为条件不满足而再次 wait(),但这可能导致效率低下。notifyAll() 唤醒所有线程,让它们竞争锁并各自检查条件,简化了逻辑,避免了“信号丢失”的复杂性问题。
有界缓冲区实现示例

一个有界缓冲区通常使用一个环形数组来实现,有两个指针 first 和 last 分别指向头部和下一个空闲位置。使用监视器实现其 put 和 get 方法,正是上述编程模式的典型应用。

总结
本节课中我们一起学习了共享状态并发的两个重要概念。
- 元组空间:一种结合了共享状态和消息传递的协调模型。它通过将数据表示为可原子存取的无状态令牌,简化了并发编程,无需显式锁。
- 监视器:锁的扩展,通过
wait、notify和等待集机制,实现了基于条件的线程同步。我们学习了其标准编程模式(while循环检查条件、notifyAll),这对于实现如“有界缓冲区”这类经典同步问题至关重要。



下一讲,我们将继续探讨更高级的并发控制概念——事务。
012:管程与事务


在本节课中,我们将要学习并发编程中的两个核心高级概念:管程和事务。我们将首先回顾管程的使用模式,然后深入探讨其实现原理。接着,我们将转向事务的概念,了解其在数据库系统中的关键作用及其核心属性。
管程的实现
上一节我们介绍了管程的编程模式。本节中我们来看看如何实现管程。管程是对锁的扩展。
记住可重入锁的工作原理。现在展示如何实现管程。
管程始于一个可重入锁。记住可重入锁使用交换操作来管理令牌,使用线程标识来检查是否已进入锁,并且使用Try-finally进行异常处理。我们实际上是在扩展这个机制。
与可重入锁相比,我们将做两处修改。首先,我们将稍微修改锁的接口。它被称为获取-释放锁。这样进入和退出是两个独立的操作,我们称之为获取和释放。因为我们需要在不同的地方进入锁。当持有锁时我们进入,但等待操作也必须尝试进入,并且也必须释放锁。所以我们在多个地方使用它。因此我们将有一个获取-释放锁。
第二点是等待集,我们将添加一个队列。队列确实被大量使用,我们将等待集实现为队列是为了保证公平性。
队列对于公平性非常好。你将一个线程放入队列,线程沿着队列移动最终会出来。所以最老的线程最先出来。如果你使用队列,线程不可能永远停留在队列中,线程总是会移动,老的线程会出来。这也是为什么在商店里人们排队等待,因为他们不会永远等待。这确保了公平性,所以每个人的等待时间大致相同。这就是为什么不用栈实现。如果用栈实现,你放在顶部,从顶部移动,栈底的线程可能会停留非常长的时间。所以栈不公平。这就是我们将其实现为队列的原因。
等待集是一个队列。我们稍微扩展之前看到的队列。我们有一个删除所有元素的操作。我们有一个获取大小的操作,可以找出队列的大小。我们有一个非阻塞删除操作,它立即返回元素,或者如果队列为空则返回nil,立即告诉你队列是否为空。
有了这些,我们可以实现管程。第一件事是获取-释放锁。这实际上是对可重入锁的修改。我们不是用一个包含交换操作和环绕过程执行退出的过程,而是现在有两个操作:获取锁和释放锁。可以看到它们实际上在做同样的事情。
获取锁发生在临界区之前,释放锁发生在之后。获取锁将在这里用交换操作进行令牌管理。它还将检查ID,并返回。如果它实际上必须进入,则返回true;如果它已经进入且无需做其他事,则返回false。
这里的释放锁注意它不处理异常。我们将在其他地方处理异常,它只是传递令牌。我们还需要另一个单元来确保获取和释放可以相互通信。获取中的新变量被放入一个名为token2的单元中,这样释放锁实际上可以释放它。我们需要这个额外的单元,并且它必须是一个单元,因为我们可以多次调用获取锁和释放锁。
锁有两个操作:获取和释放。这就是获取-释放锁。我们还扩展了队列。这里有一个队列。我将使用带单元和锁的那个。这里有一个包含队列状态的单元,插入和删除操作如前所述。我们还有一个标准的可重入锁来实现队列。我们有一个获取-释放锁,但我们也需要一个锁来实现队列。可能有更高效的方法只用一个锁,但这里我使用一个单独的锁,这样我们实际上有一个单独的队列。

以下是队列的额外操作:
- 大小操作:基本上只是告诉我大小,返回大小。
- 删除所有操作:返回所有元素的列表。它获取S,即队列前端的引用。它将尾部绑定为nil,这将S变成一个列表。然后它清空队列,进行赋值以便移除所有元素。
- 非阻塞删除操作:检查大小是否大于0。如果是,返回一个包含一个元素的列表,即删除的元素;否则,如果为空则返回nil。
非阻塞删除实际上会告诉我队列是否为空。这是我的队列,带有我实现管程所需的额外操作。获取-释放锁和扩展的队列。

以下是管程本身的代码。我有一个队列和一个获取-释放锁。首先是实际的锁。它的工作方式是,如果L.get为真,则执行try P finally L.release L P。仔细看,这与之前可重入锁的代码完全相同。这是一个标准的可重入锁。
现在我们有了等待操作。这个操作管理插入。我们如何将一个线程插入队列?基本上,我们想要挂起线程。我们将创建一个新的未绑定的数据流变量。我们将把这个变量放入队列。这个变量是我们管理挂起的方式。然后我们将等待x。这样线程就会挂起。可以看到x实际上在队列中,所以如果其他线程绑定了x,那么该线程将继续。这给了我们挂起线程并将其放入队列的效果。

注意这里我们释放了锁。我们插入到队列中,然后释放锁。这就是为什么我们需要一个获取-释放锁,因为我们在两个地方进行释放。然后我们执行等待。等待之后,我们再次执行获取。可以看到锁中有一个获取和释放的组合。我们将执行获取然后释放。但在内部,当我们执行等待时,记住我们已经在主锁中执行了获取,我们将执行释放。然后最终,当线程继续时,我们再次执行获取。总是成对出现:获取-释放。原始的是获取后跟释放。但如果我们执行等待,那么它将执行一个释放然后另一个获取。最后,在结束时执行释放。如果我们多次执行等待,你总是会得到获取-释放,获取-释放,获取-释放。这就是我们实现等待的方式。
现在我们有了通知和通知所有操作。通知和通知所有的区别在于它是一个还是所有线程。通知移除一个任意线程,基本上我们执行非阻塞删除。如果它返回一个列表,意味着我们有一个挂起的线程。我们通过执行x=unit来使线程继续。否则,如果队列为空,我们什么也不做。
通知所有将删除所有。L实际上是一个变量列表,即挂起线程的列表。对于L中的所有x,我们执行x=unit,我们启动它们全部。当然,它们都将尝试执行L.get。你可以看到它是如何工作的。具体来说,在等待之后,当等待继续时,它会立即执行获取操作,这样当你从等待中退出时,你将立即再次执行获取操作。这是等待的一部分。

对于实现来说,这实际上是一个相当微妙的实现。这本书的原始版本在这个实现中实际上有一个错误,但据我所知,这是一个正确的实现。
管程总结
现在让我总结一下管程。可以看到我们实际上扩展了锁。我们可以根据条件挂起和恢复线程。注意管程只有一个等待集。
存在管程的变体,你可以根据需要拥有多个等待集,为每个条件定义一个单独的等待集。非空是一个等待集,非满是另一个等待集,依此类推。程序员声明这一点。有些语言这样做。如果你有争用,这会给你更高的效率。因为在Java实现中,相同的等待集用于所有条件,所以如果你有很多争用,可能会有点低效,但这使得它简单得多。实际上,如果你有那么多争用,通常你的应用程序结构有问题,无论如何你必须重构。这就是Java的哲学。
等待集通过等待和通知来添加和移除一个线程进行管理。然而在实践中,我们几乎总是使用通知所有操作。实际上有一本关于Java并发编程的书,第二版,作者是Doug Lea。这本书全是关于使用管程等编程的内容,它提供了许多特殊技术。所以它可能变得复杂,但基本上很多内容实际上是使用管程构建抽象,使用它们,构建线程池,构建元组空间等等。所以基本上试图通过使用管程构建抽象来做其他事情来摆脱管程。实际上,除非你使用模式,否则用管程编程是困难的。这就是我给你的建议。总是使用这个通用模式,否则你会遇到问题。
事务简介
在管程之后,我们将讨论事务。事务是锁的扩展。但事务是用于数据库的概念。现在我们不担心管理多个线程和抽象,现在我们管理数据。我们有一个大型数据库,大量需要更新的信息。我们将更新数据库。但数据库很重要,所以我们希望它即使在崩溃时也能存活。我们将有并发更新。我们将有崩溃。数据库必须在崩溃中存活。这是事务为我们解决的关键问题。
大型数据库极其重要。许多大公司都基于此。100年前,一切都写在书上,人们在书上记录,但如今大公司的所有关键信息都存储在这些大型数据库中。如果数据库消失,公司基本上会破产,无法继续,所以这很关键。它是大公司的核心。你的银行账户存储在数据库中,你拥有的金额存储在数据库中。所有人员、所有部件、采购,一切都存储在数据库中。所以数据库是计算基础设施的关键部分。它被实现为完全能够抵御崩溃。可能存在的最高可靠性软件就是数据库软件。它必须具有弹性。弹性是一个技术术语,弹性意味着它在崩溃后存活。我们甚至可以说能承受多少次崩溃。信息必须存活,即使计算机崩溃或存储信息的磁盘崩溃。信息必须存活。有一整门课程和书籍都是关于如何使系统具有弹性。基本思想是使用冗余,所以数据库存储在多个副本中。如果一个副本崩溃,另一个副本存活,然后当你更新数据库时,你必须更新两个副本。但问题也可能是你可能会在更新期间崩溃。你可以看到这非常棘手,我们将解释这是如何完成的。崩溃可能发生在更新期间或之后的任何时间,数据库必须存活。这就是它被实现的原因。
第二点是性能。我们对这个东西的要求非常高。数据库仅仅存活是不够的,它们必须快速,因为银行有客户。如果客户必须等待几天才能完成交易,客户会不满意并转向另一家银行。所以交易必须快速完成。需要高并发的更新速率。
最后是可扩展性。需要支持大量客户端,银行和公司,大公司。它们有数千或数百万客户。一家大银行通常有数百万客户,所有这些客户的信息都存储在数据库中。你可以看到数据库是当今存在的最令人印象深刻的软件之一。
事务是访问数据库的方式。问题是我们如何实现所有这三个属性,这并不简单,但它们确实实现了。这是计算机技术最令人印象深刻的结果之一,我们实际上可以实现它。
让我展示一些例子来说明它是如何工作的。这是我的数据库。它有信息。我们将其表示为一个单元数组,因为我们可以更新它们。这里有1000个,但在真实数据库中,我们可以有更多。可以有数十亿个。它非常庞大。我们想要更新这个数据库。让我首先从更新开始,然后讨论弹性。许多客户端想要更新。我们如何做?我们有一个庞大的数据库,许多客户端进行并发访问。很简单,我们使用锁来保护数据库。我们可以有一个锁,假设一个单独的锁保护整个数组。但这是个问题,我们有数百万客户端。可能1000个客户端想同时进行交易。每当你去ATM机从墙上取钱时,你就在进行交易。每当用信用卡买东西时,就在这个数据库上进行交易。但这个单独的锁是一个瓶颈。如果一个客户端锁定了它,其他客户端就不能做任何事情。所以如果客户端A在这里操作,客户端B被阻塞。但为什么?这不合逻辑,因为客户端B使用的是完全不同的数据库部分。客户端A没有理由阻塞客户端B。这有点人为。所以这是一个非常幼稚的实现。真正的系统,我们想做得比这好得多。这会非常非常慢。
让我们改进它。让我们为每个单元放一个锁。这更聪明,因为我们可以有一个客户端在这里,客户端A。我们可以有客户端B在这里。如果它们使用数据库的不同部分,它们不会干扰,它们实际上可以同时运行。这样系统会非常快。但如果存在冲突呢?假设我们这里有一个银行账户。每个存储金额。假设有一个交易T1将资金从C1转移到C2,这是两个账户。假设有另一个交易T2将资金从C3转移到C2。所以C2很高兴,因为它从C1和C3获得资金。
我们如何用锁来做这个?我们必须非常小心。如果这两个交易重叠会怎样?如果T1读取C1,T2读取C3并写入C2,然后T1写入C2。C2将忘记来自C3的数字,所以C2中的值将不正确。因为它们之间存在干扰。更新必须是原子的,并且不能相互干扰。原子意味着我们将移除资金并添加资金。资金总额相同。但不干扰意味着如果两者并发,它们不应该相互踩踏。这就是我们想要做的,以实现高并发更新。我们想保证这一点。但我们如何做到?这就是我们要在这里解决的问题。
事务总结与ACID属性

现在让我总结一下事务在做什么以及为什么我们需要事务这个概念。首先,每个交易(我称之为更新)可以进行许多操作。你正在转账,但不仅仅是转账。你还在更新审计,跟踪其他信息。银行跟踪大量信息。所以我们有一个大的原子操作。有点像我们需要锁的原因。此外,我们不希望有干扰。如果我有两个并发更新修改数据库的同一部分,它们不应该相互干扰。这是第一件事。
第二件事。系统可能崩溃。现在我们在讨论弹性。它可能在任何时间崩溃。为了使事物持久化,数据库存储在磁盘上。但很多事情可能出错。计算机可能崩溃。磁盘本身可能崩溃,可能损坏。所以一切都必须保持正确。如果我正在执行更新,系统在更新期间崩溃,可能无法完成更新。那么我们该怎么办。实际上我们只能做两件事,要么提交,意味着我们执行更新;要么中止,意味着我们不执行更新,就像我们什么都没做一样。如果更新只完成了一部分,我们无法使其完成。我们实际上完全丢弃它,并保持初始状态。所以当你进行交易时,你总是可以中止。这意味着你保持初始状态。交易中止时,你所做的所有工作,可能做了一些工作,全部被丢弃,你保证拥有初始状态。这是如何实现的?但看一个想法。
更新可以做很多事情。如果计算机崩溃会发生什么?如果磁盘崩溃,我们将有磁盘的另一个副本,让我说说如果计算机崩溃会发生什么。然后你重新启动,磁盘上有损坏的信息。那么这是如何工作的?方法是,当你进行更新时,你将其存储在磁盘上的一个特殊位置。所有新东西。当更新完成时,你将磁盘上的旧数据切换到新数据。为了做到这一点,你向磁盘写入一个字,基本上像一个从旧到新的开关。这假设你可以原子地写入磁盘上的字,这实际上是正确的。你实际上可以原子地写入磁盘上的块。所以切换是正确的。如果磁盘在这个字写入之前崩溃,那么新信息被丢弃。这是一个中止。如果磁盘在这个字写入之后崩溃,那没关系,因为数据已经存在于磁盘上。当我们重新启动磁盘时,我们将看到新数据,这是一个提交。所以就是这个字的切换确保磁盘实际上正确地执行提交或中止。所以就是这个,我不打算多说这项技术。
我要说的是,你可以并行执行许多操作。只要不相互干扰,你可以在性能允许的情况下,让多个更新同时访问磁盘的不同部分。你必须保证它们不会相互干扰。这就是事务的真正技巧。你如何保证它们不相互干扰。

事务,你可以看到它们有一堆要求。实际上,事务背后有巨大的实现。人们谈论ACID属性。因为这是一个首字母缩写。事务有四个重要属性,它们以A、C、I和D开头。所以人们说事务具有ACID属性。这些是事务最重要的属性。
- 原子性:意味着磁盘将执行所有操作或不执行任何操作。就像我刚才解释的。它像一个单一操作。所以这就是原子性,操作是原子的。
- 一致性:意味着我们尊重数据库的不变量。当我进行更新时,在银行中,例如,我将资金从一个地方转移到另一个地方,那么银行中的资金总额不应改变。如果我从一个账户移除100,我向另一个账户添加100。我不会添加95并把5块放进口袋。不允许。我必须尊重不变量。这被称为一致性。注意这个属性实际上是程序员的责任,而原子性是事务实现如何实现的责任。
- 隔离性:这是当你有并发交易访问相同数据时发生的情况。在相同账户之间转账,它们不应该相互干扰。所以隔离意味着交易不相互干扰。我们知道它们是原子的,但这还不够。如果两个交易并发,它们实际上不应该能够相互踩踏。这被称为隔离。它也被称为可串行化,但人们更喜欢隔离,因为它更适合首字母缩写,但人们谈论可串行化。想法是,如果你进行两个访问相同数据的交易,就好像它们以某种顺序进行。所以就好像是顺序的。实现实际上是并行的,但在语义上,一个交易总是在另一个之前。所以就好像数据库的变化就像它们是顺序完成的一样,即使它们实际上是并行执行的。所以这被称为隔离或可串行化。这对性能当然非常重要。
- 持久性:持久性,也称为持久化。意味着它在崩溃后存活。意味着有像磁盘这样的稳定存储。即使计算机关闭电源,即使没有电源,系统也会存活。这对于事务的所有现实世界实现都很重要,持久性。通常,这也称为持久化。数据持久存在,即使电源关闭。
轻量级事务与并发控制
有一个轻量级事务的概念。记住在银行中,交易存储在磁盘上。它是持久的。所以它在崩溃后存活。但有时不需要。有时你不需要持久性,你想要执行提交或中止的良好能力。但你不在乎持久性。它们就像编程抽象,是事务。它只是一个可中止的原子操作。所以如果有一些问题,那么程序代码被修复。实际上,这是一种异常处理的智能版本。记住,我们进行异常处理,当有问题时,比如你试图读取文件,但文件消失了。那么你有一个异常,程序必须修复所有错误的变量。事务将自动完成。所有变量都恢复到初始值,这都是自动的。事务完成这个。

提供软件事务或轻量级事务的语言,通常称为软件事务内存,这就是轻量级事务。它们不持久,不持久化。但如果有问题,所有变量都会自动恢复。这当然需要更多工作。效率不高,但对程序员来说容易得多。所以现在有一些语言。这也称为STM,有时是软件事务内存。当你看到人们谈论STM时,这就是他们谈论的内容,意味着你拥有除持久性之外的一切,你不在乎磁盘之类的东西,但你的程序可能有问题,所以你想处理它。这是当你不需要持久性时事务的一个变体。
现在让我谈谈我们如何实现事务,这是一个很大的领域。实现事务是一个巨大的产业,你可以想象,因为涉及这么多钱,因为它对公司和银行和工业如此重要,有大量的工作。所以人们用来构建事务系统的所有技术都有一个名称,称为并发控制。这是一个奇怪的名字,但每当你构建一个事务系统时,你需要做所谓的并发控制。
我们将解释并发控制的一部分。我们将给你介绍并发控制。我们将实际展示如何实现这些轻量级事务。我们将给你实现这个的实际代码,我们将要做的算法是一种非常特定形式的并发控制,称为具有严格两阶段锁和死锁避免的乐观并发控制。
红色部分是我们选择的特殊部分,我们将解释所有这些,所以你会理解它。我们将给你具有严格两阶段锁和死锁避免的乐观并发控制的完整代码,这已经是一种很好的事务处理方式。当然,还有其他方式,但这是我们将要展示的方式。实际上在书中有这个实现。
我不打算详细讲解这个实现,因为它有点复杂,但我们将展示算法。我们将算法展示为状态图。你将确切看到它是如何工作的。我们如何一起完成所有这些事情。这实际上是书中最复杂的算法。是两页复杂的代码。但思想非常简单直接。
让我简单解释一下事务系统是如何工作的。它的工作方式是,事务系统看起来像这样。当我启动一个像T1这样的交易时。我这里有一个线程。一个线程实际上运行一个交易,它做事情,读取和写入,读取和写入,等等。但在内部,就像在锁内部。它与系统的一个特殊部分对话,称为事务管理器。
每个交易,当它需要一个锁时,它会询问事务管理器。当它需要一个锁时,它请求,请给我单元55的锁。事务管理器说,好的,锁给你,但事务管理器可以做很多事情。它可以给锁。它可以延迟。它可以询问那个交易。它可以让那个交易等待,因为锁可能在别处,或者它可以拒绝给锁。它可能说不。不,你得不到。请中止。你必须中止。所以事务管理器决定如何给锁。然后当交易T不再需要锁时,最终它将锁交还给事务管理器。所以事务管理器以智能的方式管理数据库中所有单元的所有锁,以便所有交易尽可能并行运行。但它仍然是可串行化的,就好像所有交易都以某种顺序运行一样,在语义上,数据库中的状态变化就像那样。
所以事务管理器是处理顺序串行化幻象和实际并行实现现实之间接口的魔术师。所以事务管理器实际上是系统的核心。这是我们将要讨论的部分。我们将向你展示事务管理器如何做这些神奇的事情。

总结
本节课中我们一起学习了并发编程中的两个高级概念:管程和事务。
我们首先深入探讨了管程的实现,了解到它是对可重入锁的扩展,通过获取-释放锁和队列管理等待集,实现了线程的挂起与恢复。我们强调了使用固定编程模式的重要性,以避免复杂性和错误。
接着,我们转向了事务的概念。事务是管理大型、关键数据库系统的核心机制,需要满足ACID属性:原子性、一致性、隔离性和持久性。我们讨论了事务如何在高并发环境下保证数据的一致性和性能,并简介了其背后的并发控制机制,特别是乐观并发控制。


事务是现代计算基础设施的基石,其实现涉及复杂的技术以平衡性能、可靠性和可扩展性。理解这些原理对于构建健壮的并发系统至关重要。
013:事务管理器与死锁处理


在本节课中,我们将学习一个真实的事务管理器实现,并深入探讨并发控制中一个关键问题——死锁。我们将从最简单的“天真”算法开始,分析其问题,然后逐步引入死锁避免机制,最终得到一个结合了乐观并发控制、严格两阶段锁和死锁避免的完整算法。
概述:一个简单的事务系统
休息之后,我们现在开始。我将向你们展示一个真实的事务管理器。
让我们从幻灯片开始。我将从最简单、最“天真”的一个算法开始,看看它是如何工作的。我们将构建一个简单的事务系统,它采用乐观并发控制,并具有严格两阶段锁。
以下是算法,非常简单:
- 当一个事务请求一个未上锁的单元格的锁时,它会立即获得锁。事务管理器会说“好的,请用”。
- 如果单元格已经被锁住,请求的事务将等待,直到它被解锁。因为另一个事务正在使用它,所以第二个事务会等待第一个事务完成,然后才能获得锁。
- 当事务提交或中止时,它会一次性释放所有锁。
这是一个严格两阶段锁的例子。它有一个增长阶段,即获取锁。中间可能需要等待,但最终它会获得所有锁,当它完全完成后,就释放所有锁。
这个算法实现了严格两阶段锁。该算法也是乐观的,因为它总是把锁给予请求的事务。它不检查事务是否“在轨道上运行”,它不担心这个。当锁未锁定时,它总是给予锁。它假设给予锁不会导致问题。
这是一个使用了我们目前所见技术的算法。但这个算法有一个大问题:它遭受死锁。这是一个新问题,我们还没见过。这个算法实际上有一个非常具体的问题叫做死锁。我们在这里没做错任何事。我们有乐观的、严格的两阶段锁,这很好。但仍然会出问题。
让我展示一个场景来说明死锁问题是如何出现的。
死锁场景与分析
假设我有两个事务,T1 和 T2。我可以画一个图,T1 和 T2。它们各自使用两个单元格。T1 将使用 C1 和 C2,T2 也使用 C1 和 C2。但 T1 从使用 C1 开始,稍后才使用 C2。T2 从使用 C2 开始,稍后才使用 C1。
这取决于时间安排。如果 T1 执行,很久之后 T2 才执行,那就没问题。但如果执行过程中有一些并发性,有一些重叠,那么就可能出现问题。
假设 T1 获得了 C1 的锁,T2 获得了 C2 的锁,它们都在运行。现在 T1 想要 C2 的锁,T2 想要 C1 的锁。根据我们的算法,T1 请求 C2 的锁时,它会等待,直到 T2 释放它。但 T2 在做什么?T2 想要 C1 的锁。它会等待,直到 T1 释放它。所以 T1 等待 T2 释放锁,而 T2 等待 T1 释放锁。这是一个循环等待条件,它们将永远等待下去。因此,系统被阻塞了。这就是一个死锁情况的例子。
这是一个活性问题。事务没有做任何不正确的事,只是它们无法进展。这是一个活性问题。

让我们理解这个问题,然后看看如何解决它。这实际上是一个非常普遍的问题——死锁。它可以发生在任何类型的系统中,不仅仅是事务。
系统中有两种实体:事务(主动实体)和单元格(资源)。主动实体需要资源。当我占用一个资源时,其他人就无法拥有该资源并必须等待。一次只能有一个主动实体使用该资源。
当我有这种情况时,我可以画一个图,这被称为等待图。这个图有两种节点:事务节点和单元格节点。事务节点,单元格节点。然后我有边或箭头。我有一个从事务指向它所需单元格的箭头,以及一个从单元格指向锁定它的事务的箭头。
T1 在等待 C2,但 C2 被 T2 锁定。T2 在等待 C1,而 C1 被 T1 锁定。这就在图中形成了一个环。因此,死锁在形式上被定义为等待图中的一个环。
这里我有一个包含两个事务 T1 和 T2 的环。但在现实生活中,我可以有包含三个、四个、五个事务的环。我可以有 T1 等待 T2,T2 等待 T3,T3 等待 T1。所以我可以有任意数量的环。当然,2 是最小的环。
这就是我们定义死锁的方式,它可以发生在任何有资源的系统中。
在现实世界中,你已经经历过死锁。一个典型的死锁例子是十字路口的汽车。
这是两条路,这里有一个十字路口,有不同的部分。这里有一辆车向上行驶,可能这里有一辆车试图向前,这里的车试图向前,这里的车试图向前。汽车是主动实体,资源是十字路口的一部分,这个方块是一个资源。当一辆车在这个方块上时,另一辆车就不能上去。
这里,四个主动实体之间存在循环依赖。这辆车需要资源 B,它想向前。但 B 已经被占用,而 B 需要 C,C 需要 D,D 需要 A。所以我再次在这个依赖等待图中得到一个环。这也是死锁的一个例子。
所以死锁是一个资源分配问题。当我们有被分配的资源,并且资源之间存在循环依赖时,就会发生。这不仅仅是事务的问题,当然它在事务中经常发生,因为锁是资源——当一个事务获得锁时,它只能被一个事务持有。在这个意义上,它是一个资源,另一个事务无法获得它。这是一个典型的资源分配问题。
但这是大型系统中一个真正的问题,可能造成实际问题。假设你有一个大银行,它有很多 ATM 机,很多分行,有一个管理所有交易的庞大数据库。这个数据库每秒可能有数百个事务。如果你有一百万客户,就会产生大量事务,每秒可能有数百个。
假设有四个事务陷入死锁,在一个环中,它们停止了。这是活性问题。但它不会停止其他事务。有数百个事务在进行,其中四个死锁了。我们甚至可能检测不到,可能根本看不到。系统的一部分死了,停止了,但其余部分运行良好。所以甚至很难检测到这一点。我们必须非常小心。这个大银行有大量事务,其中许多可能死锁了,除非我们专门去查看是否能找到它们,否则我们甚至不会知道。

所以死锁是大型系统中一个真正的问题。你在路上看到很多车时,死锁也是一个问题。所以当你有许多主动实体和许多资源时,这是系统中的一个实际问题。
解决死锁:检测与避免
那么,我们如何解决它?我们必须解决这个问题。有两种方法来解决它。就像对待疾病一样,如何解决疾病?有预防或治疗。预防就像疫苗,治疗就像抗生素。
要么你等到系统出现死锁,然后你对等待图进行计算,看看是否能找到一个环,然后你中止,强制其中一个事务中止,从而打破死锁。这被称为死锁检测。
另一种方式称为死锁避免,在这里,当你启动事务时非常小心,事务管理器只会在不会导致死锁的情况下给你锁。它会管理锁,所以没有死锁。
我们将展示一个死锁避免的例子。死锁的概念清楚了吗?死锁是一个存在于所有系统中的重要概念,不仅仅是事务。所有有资源的系统都可能遇到这个问题。你理解了它是如何工作的:在这个称为等待图的资源图中存在循环依赖。这个环意味着系统的一部分无法进展,所以这是一个活性问题。
现在我可以解释如何修复死锁,我们如何修复它?
你会看到我们必须修复它。你不可能让一个系统存在死锁,它根本无法工作。所以修复的方法,我们将采用我们的天真算法,并希望实现死锁避免。
我们要做的是打破环,打破环的方法是确保它们不对称。如果我有 T1 等待 T2,T2 等待 T1,这是一种对称情况。但我们想引入一些不对称性,一些不对称的东西。所以我们将为事务添加优先级。有些事务的优先级高于其他事务。我们将给最早的事务最高的优先级。系统中的第一个事务拥有所有事务中最高的优先级,第二个优先级较低,依此类推,优先级不断下降。
现在我们可以进行死锁避免了:当一个事务试图获取锁时,我们将比较优先级。如果我试图获取锁,而别人已经持有锁,那么我们将比较两者的优先级。如果我的优先级低于已经持有锁的事务,那么我必须等待,优先级高的事务优先。但如果我的优先级高,而有一个低优先级事务持有锁,那么我将重启那个低优先级事务。这意味着我将强制它释放所有锁并重新开始,然后我将获得锁。
所以,如果低优先级事务持有锁,那么事务管理器会强制它重启,释放所有锁,然后高优先级事务获得锁。当然,低优先级事务会尝试再次开始运行,它会再次尝试获取锁,但它必须等待,因为现在高优先级事务持有锁。
另一种情况是第一种情况。所以,如果我试图获取锁,而一个更高优先级的事务持有锁,那么低优先级事务就等待。所以,新的想法是我有优先级,并且我可以重启事务。事务管理器实际上可以产生强大的效果:它可以强制一个事务释放所有锁,恢复其状态,并重新开始。这不是中止,因为它可能仍然提交,但它必须重新做所有工作。这很重要,因为单元格的值可能已经改变,因为高优先级事务可能会修改它们,但它必须再次开始运行其代码。
我们现在可以证明,这种方法将避免所有死锁。我们通过对事务创建顺序的归纳来证明。系统中的第一个事务当然拥有最高优先级,没有人能阻止它。如果它试图获取锁,它总是能获得那个锁,所以没有人能阻止它,它将完成。一旦它完成,第二个事务就拥有最高优先级。同样,没有人能阻止它。因此,通过归纳,最终所有事务都将完成。
这就是它的工作原理。我们现在可以制定一个协议,然后我将为此运行代码。
当我创建一个新事务时,我给它最低的优先级,所以优先级总是不断下降,它们就像整数,所以我递增整数,整数越大,优先级越低。
当一个事务试图获取锁时:
- 如果它未锁定,我就获得锁。我在这里仍然有点乐观。
- 如果它被一个更高优先级的事务锁定,我就等待。我非常谦卑,让高优先级的人完成。
- 如果它被一个更低优先级的事务锁定,那么我比那个事务有优先权。所以事务管理器将重启那个低优先级事务。基本上,它强制性地——我称之为中止——它强制该事务恢复所有状态,释放所有锁,并重新开始,但具有相同的优先级,这非常重要。所以它始终是同一个事务。一个事务永远不会改变其优先级。它可能会因为更高优先级的事务而被重启几次,但它永远不会改变自己的优先级。所以它最终仍然会成为最高的,然后继续。如果我把优先级改得更低,那么我可能会遇到饥饿问题。你看,重启时保持相同优先级非常重要。
然后高优先级事务获得锁,低优先级事务尝试获取锁但无法获得并等待。当一个事务提交时,它释放其锁,并且可能有等待它锁的事务,它会唤醒一个,所以等待的事务可以继续。当一个事务中止时,它做同样的事,但它也恢复状态。就像什么都没发生一样,并且每个锁也会唤醒一个等待事务,使其可以执行。
你看到它是如何工作的了吗?这个算法。实际上,我画一个状态图来展示这个算法是如何工作的。
算法开始时,事务从这里开始,这是一个开始状态。它开始运行代码。现在可能发生不同的事情:它可以请求锁并获得锁,很好,继续运行。它可以请求一个已经被占用的锁,通常它会等待直到锁可用,获得锁,然后继续运行。最终它完成了,然后提交或中止。这就是正常的生命周期。
现在,当事务正在运行时,可能高优先级事务需要我的一个锁,糟糕,所以我必须重启。所以我会重启。重启就像再次进入开始状态。这被称为算法的一个“化身”。一个事务在最终提交或中止之前,可能会经历几个化身。
重启也可能发生在等待状态。我正在等待一个锁,但一个高优先级事务需要我已经持有的一个锁。所以在这种情况下我也会重启。所以有两种方式可以重启。
重启当然有点低效,因为事务所做的所有工作都浪费了,它必须重新开始。所以,我们为死锁避免付出的代价是性能稍低。因为重启。但这是不可避免的。很难。你必须付出一些代价。死锁检测算法也要付出代价,因为它必须去分析等待图以查看是否有环,这也需要计算,然后它必须强制某个事务中止或重启。所以同样,会有一些浪费的工作。所以死锁解决方案总是要付出代价的。在这里,代价是当事务重启时会有一些浪费的工作。所以我们希望不会有太多的重启。当许多事务试图访问相同的单元格时,就会发生重启。所以这被称为热点。如果数据库中有热点,那么我们可能会有一些性能问题。

你看到它是如何工作的了吗?让我继续。
算法总结与状态图
现在你看到死锁避免是如何工作的了。所以优先级解决了这个问题。
我们有两件事:一是严格两阶段锁解决了安全性问题,它确保可串行化。这意味着系统是安全的,不会发生坏事。但仍然可能发生死锁,所以死锁避免解决了一个活性问题。因为没有发生错误,只是系统停止了。所以我们希望系统继续运行。死锁避免使系统具有活性,确保事务可以进展。
你看到我们如何用严格两阶段锁解决安全性问题,使其可串行化,以及我们如何用优先级解决死锁这个活性问题。注意,死锁是一个基本问题,不是人为的。任何有资源的系统都会有这个问题。没有办法绕过它,死锁会发生。
你在现实生活中见过死锁。如果你开车或在车里,如果你在一个拥挤的城市,你见过死锁。那么,当汽车在十字路口行驶时,死锁是如何解决的呢?实际上,这很难。汽车必须倒车,也许它们开上人行道,它们必须让出空间。这实际上是一个死锁检测算法。事实上,如果你看交通规则,交通规则的设计是为了实现死锁避免。实际上有一条规则说,如果你不确定之后能离开十字路口,你就不允许进入。所以,如果另一边没有空间离开十字路口,你通常不允许进入。你必须在之前停下。所以,如果另一边有一大堆车,那么通常,在那些车移动、你在另一边有一些空间之前,你不允许进入这个十字路口。实际上有一条规则说,除非你确定能离开,否则不允许进入十字路口。这条规则实际上是在实现死锁避免。但当人们匆忙时,并不总是遵守这条规则。
清楚了吗?让我现在转到幻灯片,让我谈谈其他事情,一个实现问题。

实现优化:标记与缓刑状态
这是一个很好的算法。这实际上是一个相当不错的算法。我们将实现这个算法,但这个算法有一个实现问题。我可以说这是一个棘手的问题。问题是我们随时随地终止(重启)这些事务。它正在运行。所以一个事务有一个锁,它正在运行。然后一个高优先级事务进来。它将立即重启那个低优先级事务。它可能在任何地方,那个低优先级事务可能在任何地方运行。所以它在任意点终止(实际上是重启)。在它们执行过程中的任意点重启正在运行的事务,这实际上不是很好。我们如何实现它?我们杀死线程吗?所以这对于实现来说不是很好,如果你做错了,可能会在运行时数据结构中产生严重问题。
我们实际上想要一种更有纪律的重启方式。当我们重启时,我们希望在一个明确定义的点重启。我们不希望在任何地方重启那个可怜的低优先级事务。我们希望在一个明确定义的点重启它。这样数据结构就很整洁,一切都很整洁。
什么是明确定义的点?例如,当事务请求锁时。让我们改进我们的算法,这将给我们最终的算法,当它重启算法时,它将在事务执行的一个明确定义的点重启它们。所以一切都很整洁和有纪律。
我们如何做到这一点?嗯,我们必须在重启时更加小心。与其立即重启,我们不如标记它。我们在它上面设置一个小标志。我们说,你被标记了。当那个低优先级事务试图获取锁时,如果它试图获取锁,它不会得到锁,因为它被标记了,它会重启。如果它稍后试图获取锁,它会重启。你看到它是如何工作的了吗?所以,与其立即重启,我们标记它,当它试图获取锁时,它实际上会重启,而不是获得锁。
实际上,这也稍微提高了性能,因为也许那个低优先级事务不再需要获取锁了,也许它快完成了。在那种情况下,我们让它正常完成。这意味着我们实际上浪费了更少的工作。所以这个新版本的算法实际上也更高效。
这导致了一个更新的状态图。这是我们的状态图。事务正在运行,它运行得很好,忙着自己的事。然后某个高优先级事务来了,需要我的一个锁。嗯,我不立即重启。我继续运行,但我进入一个特殊状态,我称之为缓刑状态。这就是被标记的状态。我仍然在运行。如果我完成了,我可以正常完成,这可能会发生。但如果我请求一个锁,那么我就完了,结束了。然后我必须重启。所以这将是一个明确定义的点。
低优先级事务被置于一个特殊状态,允许它完成。但如果它试图获取锁,它就会被重启。所以它实际上会在一个明确定义的点重启。但也许高优先级事务将不得不等待更久一点。因为低优先级事务可能在这里做一些计算,但它不需要更多的锁。高优先级事务将等待,但它知道该事务最终会完成,所以它最终会获得锁。所以高优先级事务可能不得不等待更久一点。但低优先级事务实际上将有机会完成。它不会立即被重启。这意味着系统实际上甚至因为这个改进的算法而具有更高的性能。
这就是我们在这里实现的最终算法。这实际上是一个相当不错的算法,实现了乐观的严格两阶段锁和带有缓刑状态的死锁避免。你看,这实际上是一种相当微妙的算法。它并不那么简单,我的意思是,一切都有其原因。你必须理解它为什么这样做,但你可以看到它获得了所有好的属性,它给了我们这个非常好的算法,做了很多事情。
很好。现在让我运行这个东西。
代码实现与示例
首先让我回到事务这张幻灯片。我将向你们展示一点这是如何实现的。

这是一个状态图,很清楚,但实际上,当你真正编写代码时,你会看到有很多小细节需要考虑。让我说几句关于实现的话,实际上我们将运行一些例子。

我们将实际定义一个抽象。这在书中有解释。然后我将展示一个实现。这是一个 ACI 事务,没有持久性,没有磁盘,但它仍然是一个带有中止和提交的事务。

每次我创建一个事务管理器,所以 newTrans 实际上会创建一个全新的事务管理器系统,有两个操作:trans 用于启动新事务,newCell 用于创建由事务管理的单元格。
我有这两个操作,所以 newCell 看起来就像旧的 newCell,我称之为 newCellT。它用初始值 x 创建一个新单元格 C。而 trans 实际上是一个高阶函数。它把这个表达式作为一个函数。这个表达式实际上是事务的代码,它读取和写入单元格。B 是最终结果,当事务完成时,它将被绑定到提交或中止。
这个表达式如何执行单元格操作?这是因为这个 T 参数。你看事务在这里设计得很巧妙。这个 T 是一个记录,有四个字段,提供访问、赋值、交换操作。这些实际上是标准的三个单元格操作。还有第四个操作叫做中止。我们可以强制事务中止。
在表达式内部,我们可以运行 T.access、T.assign、T.exchange 和 T.abort。你看,这有点复杂。当然,我们可以给它语法,让它看起来非常非常漂亮。但你看这里的抽象,我们使用这个高阶函数,但确实有这个额外的参数 T,因为我们需要使用这些特殊的单元格操作。
事务如何中止?事务要么在引发异常时中止(我们假设异常是一种中止情况,它将恢复所有状态),要么显式调用 T.abort。
让我在系统中实际运行这个。你看那个抽象。它不那么简单,因为我们没有特殊语法,但它确实为你提供了事务。
让我看看是否有问题。问题是关于事务中的优先级。问题是:我们能否根据某个事务等待完成的时间来分配优先级,类似于老化?这可以避免饥饿。
首先,没有饥饿。无论如何,没有饥饿,因为最高优先级的事务总是会完成。没有人能阻止它。所以通过归纳,它们都将完成。其次,也许我们可以优化优先级。这取决于我们如何使用事务。也许一个事务等待了很长时间。通常,它已经比所有后来创建的事务拥有更高的优先级。所以如果一个事务真的等待了很长时间,在某个时刻,它将成为最高优先级,没有人能阻止它。所以这已经避免了饥饿。所以我展示的算法已经避免了饥饿。
但也许你可以稍微优化一下优先级,也许有些事务你想给它们非常低的优先级,但你必须小心不要引入饥饿。当然,所有事务最终都必须完成。这又是一种公平性属性,你希望系统是公平的。优先级在我展示的算法中保证了这一点。
这回答了这个问题吗?
让我转到这里的系统。


这是我的事务管理器。它使用活动对象。所以事务和事务管理器被实现为活动对象,所以它们交换消息。我们还使用所谓的优先级队列。这就像一个队列,只是元素有优先级,最高优先级的元素自动到前面。然后我们实际上有事务管理器的代码,这有点复杂。
让我复制所有这些。我把它带到这里的系统中。这里我们有事务管理器,这实际上有点复杂,但它运行的正是我展示的算法。
让我首先定义活动对象。然后我将定义优先级队列。
让我在这里腾出一些空间。这是优先级队列。现在我来定义实际的事务管理器。我忘了声明。现在应该没问题了。编译好了。
现在事务管理器,它实际上被定义为一个类。所以我这里有一个活动对象。我有一个叫做 newTrans 的过程。
我在这里定义了一切。一切都编译好了,现在让我展示一个它是如何工作的例子。
我这里有一个现成的例子,但你可以做你自己的例子。只是一个简单的例子。
这是一个简单的例子。首先,我调用 newTrans 来创建一个新的事务管理器,它让我可以用 newCellT 操作定义事务。每次我调用 newTrans,我都有一个全新的世界,一个新的事务管理器,它独立于其他管理器。
在第二个例子中,我实际上创建了一个小数据库。让我调用这个。在第二个例子中,我实际上创建了一个数据库,一个小数据库。所以我创建一个包含 1000 个元素的元组,对于这 1000 个元素中的每一个,我创建一个单元格。值将是 I。所以是索引。所以我有 1000 个元素,索引从 1 到 1000,每个的初始值是 1, 2, 3, 4, ..., 1000。我这里有一个小数据库。就像一个小型内存数据库。
现在我可以在上面做事务。这里我有一个有点复杂的事务混合。
假设这个数据库就像一个银行。每个单元格告诉你账户里有多少钱。单元格 10 有 10 欧元,单元格 20 有 20 欧元。现在我要把钱到处移动。我将随机取三个账户,A、B 和 C。我将从 1 到 1000 中随机选取数字,取三个账户。第一个账户,我将放入 B 的钱并减去 C 的金额。第二个账户,我将做 A 减 B 加 C。第三个账户,我将做负 A 加 B 加 C。你看,我在三个账户之间以一种有趣的方式洗钱。我还有这个中止。如果 I 等于 J 或 I 等于 K 或 J,那么随机中止。
所以这个混合过程,每当我执行时,都会做一个事务。但它不会改变总金额,如果你注意 A 加 B 减 C,A 减 B 加 C,和负 A 加 B 加 C,如果你把它们加起来,你得到 A 加 B 加 C,所以银行里的总金额不变。
这是另一个叫做 sum 的事务。这个事务函数,我将创建一个值为 0 的新单元格。我将把 0 赋给这个单元格。然后我将做一个循环,我将汇总所有的钱,所以我把银行里所有的钱加起来。T S 将是访问 S 加上访问 D.I(D.I 是数据库中的单元格),这个函数的结果将是结果。所以这实际上是一个函数,它在这里返回一个结果。我不关心。我假设它总是会提交。
我编译这个。如果我执行一些事务,你会看到它显示 500500。这是初始总和,因为我有 1, 2, 3, ..., 1000 欧元在里面,单元格 100 有 100 欧元,等等。如果我把它们全部加起来,我得到 500,500。
现在我将混合它们。我将做 1000 次混合,但我将在线程中做它们。所以每个混合都在做一个事务,所以我有 1000 个事务冲击这个数据库,进行混合,它们都是并发的,运行在 1000 个线程上。我运行那个。
实际上,我可以看到它混合了。所以如果我查看元素 D1 到 D10,我将浏览它们。我有一个事务来显示元素。你可以看到元素是奇怪混合的东西,-795, -1, 2, 3, 4,所以混合真的混合了东西。但如果我对所有这些元素运行求和,我仍然得到相同的结果。所以尽管有所有这些混合,我仍然得到相同的结果。

你看,我实际上在这里做了相当复杂的事务。但你可以看到它是如何工作的。你可以自己试试。我会把代码放在 Moodle 上。你看,通过这个,我实际上在系统中创建了一个小数据库,我可以在那个小数据库上做事务。
现在让我说几句关于我如何实现这个东西的。
实现细节与数据结构
让我现在转到幻灯片。让我说几句关于实现的话。这段代码不那么简单,所以我不打算向你展示代码的所有细节。但我会给你一些信息,这样你就可以看到构建一个真实的事务系统是什么样子。
你可以看到它使用了活动对象。每个事务有一个活动对象,所以它在一个线程中运行。事务管理器也被实现为一个活动对象。所以它实际上是使用消息传递实现的。这很有趣。我有这个共享状态抽象,即共享数据上的事务,但我使用消息传递来实现它。这很好,因为这实际上是最简单的实现方式。
事务向管理器发送消息。它可以请求锁,可以保存单元格的状态(因为如果需要中止,我们必须恢复状态),可以说它提交或说中止。事务管理器必须做相当多的工作。它管理单元格锁,当然我们已经看到了,给予锁或拒绝锁(导致重启)。它还管理单元格的状态,保存状态和事务本身,提交、中止和重启。
如果你看消息,右边是我们的事务系统,每个事务是一个活动对象,还有事务管理器,它们彼此发送消息。这些是消息。如果一个事务想要锁,它会为事务和单元格请求 getLock。然后事务管理器最终返回。S 是 OK,意思是“是的,你有锁,可以继续”,或者 S 是 Halt,意思是“抱歉,我拒绝锁,你必须重启”。所以事务实际上必须重启。保存状态:当事务更新单元格状态时,它实际上首先运行 saveState。然后同步说“好的,我已经保存了状态”,这样如果我们中止,我们必须恢复状态。事务也可以在最后提交或中止。
所以事务管理器跟踪所有这些消息。
让我简单说一下内部的数据结构。我就说这些,不展示实际代码,因为那样就太深入了。
每个事务存储在一个记录中。它有以下数据:时间步长(就像我们说的,这是优先级,数值越低优先级越高,所以它是递增的),已保存的状态(实际上有一个字典,记住带有元组的字典,对于每个单元格,它有一个记录单元格和它保存的初始状态),所以它有一个包含其已保存状态的字典,每次你做越来越多的更新,这个字典会增长并跟踪所有已保存的状态。它有主体,即作为事务体的单参数函数。它还有事务的实际状态。有三种状态:运行状态、等待状态(这里它正在等待单元格 C 的锁),还有我们看到的缓刑状态。你看,这和我们看到的状态图非常相似。还有一些额外信息:每个额外的已保存状态。当我等待时,我也知道我在等待什么。
单元格也有一个记录。所以单元格,即资源。每个单元格有一个唯一的名字。这是我们明确定义的名字。我们实际上在这里重新发明了单元格。它不是内置的单元格。我们有所有者,所有者是当前锁定单元格的事务。这里我说它是 newCell。所以所有者字段可以更改。它指的是一个单元格,可以被更新。它是当前锁定单元格的事务,如果是 unit 则表示没有人。然后有一个队列,这是一个优先级队列,是等待该单元格锁的事务。可能有很多事务在等待那个锁,但那些等待事务中优先级最高的将首先获得它。所以它不是常规队列,而是一个按优先级排序的队列。最后,单元格的当前状态。
所以单元格有一点额外信息需要管理。我们需要管理的信息。
现在让我简单说一下优先级队列。为了实现事务管理器,我们需要这个优先级队列。每个条目 x 有一个整数优先级。当我们出队一个实体时,我们将移除优先级值最小的条目。所以它们总是在队列内部排序。当我放入一个新条目时,它不是放在后面,而是根据其优先级放在一个位置。如果它是最高的,它完全放在最前面。我有一些其他操作:入队、出队、删除操作(我也可以从中间移除一个条目,移除一个优先级为 P 的条目,也许这是因为……有时我实际上必须从中间移除一个事务),以及检查它是否为空。
所以每个被锁定的单元格,每个被事务使用的单元格,都有一个等待事务的队列,但它是优先级队列。
就是这样。我不展示代码了。这给了你它是如何工作的概念。让我现在来总结。
总结:大型原子操作与事务

大型原子操作,提交或中止。管理大型数据库,它们有很强的实现约束,包括安全性和活性。所以弹性、性能和可扩展性。弹性是一个安全属性,意味着即使有崩溃,事情也是正确的,安全性不会被破坏。性能是在一定时间内发生事情,这是一个活性属性。可扩展性既是安全性又是活性,因为随着规模增加,我们希望保持弹性和性能。安全性和活性必须随着规模增加而保持。
我们看到了一个漂亮的算法,它实现了乐观并发控制、严格两阶段锁和死锁避免。你可以用它给你的朋友留下深刻印象。
现在让我快速回到聊天。

这就是我想说的全部。通过这个,你实际上可以看到真实事务系统是如何工作的基础。一个真实的算法,所有真实的问题以及解决它们的方法。这为你打下了良好的基础,如果你想进一步研究事务是如何工作的。
下周我们将讨论分布式编程,所以我们不再讨论事务了。但事务极其重要,对于使用计算机的大公司来说至关重要。所以事务是一个非常重要的概念。


非常感谢。我现在要结束课程了,现在是 10:30,10:33。课程现在结束。你们可以自由离开了。如果你们有任何问题,我会再待一会儿。非常感谢。
014:构建真实世界的弹性应用

在本节课中,我们将学习如何运用本课程所学的所有知识,结合Erlang语言和系统特有的新原则,来构建真实世界中具有弹性的分布式应用。
🚀 Erlang简介与优势
上一节我们介绍了课程目标,本节中我们来看看Erlang的背景及其核心优势。
Erlang由瑞典大型电信公司爱立信于1986年开发。它是一个拥有悠久历史的系统,附带大量库,统称为Erlang/OTP(开放电信平台)。Erlang程序基于消息传递,其基本概念是进程,这与我们在本课程中学到的端口对象非常相似。Erlang进程不共享任何数据,所有信息都是复制的,进程间没有指针。
Erlang/OTP支持构建长生命周期的可靠系统,其核心是行为(一种通用的并发模式)和监督者(处理故障的模式)。一个著名的成功案例是爱立信的AXD301 ATM交换机,它拥有超过100万行Erlang代码,文档声称其可用性达到99.9999999%,这意味着在一年中,系统宕机时间少于1秒。
以下是Erlang相比其他系统的性能优势:
- 进程创建:Erlang进程创建速度极快,可达微秒级,且可轻松创建数万个进程。相比之下,Java或C#的线程创建需要数百微秒,且数量有限。
- 消息传递:Erlang进程间的消息发送时间约为1微秒,可支持数万个进程。而Java或C#中线程间的消息传递则需要数十到数千微秒。
📈 Erlang应用案例
让我们通过两个具体案例来了解Erlang的实际表现。
1. Web服务器 (Yaws)
Yaws是一个用Erlang编写的Web服务器。下图展示了其并发处理能力。横轴是并发请求数(进程数),纵轴是吞吐量(服务器性能)。可以看到,即使并发请求数增加到数万,Erlang服务器的吞吐量下降也非常平缓。相比之下,Apache服务器在大约4000个并发请求时就会崩溃,性能急剧下降。这表明Erlang能非常优雅地处理高负载情况。
2. AXD301 ATM交换机
该交换机用于处理电信网络中的连接(如电话呼叫)。下图测量了其在不同负载下的表现。当负载达到系统容量的100%时,吞吐量保持线性增长。当负载超过100%(即过载)时,系统接受的呼叫数保持在其最大性能水平,而不会崩溃。随着过载程度的加剧(例如达到1000%),系统性能会平缓下降,这是因为需要资源来处理被拒绝的呼叫。这种优雅降级的能力在紧急情况(如短时间内涌入海量呼叫)下至关重要。
🧱 Erlang基本概念
我们已经了解了Erlang的威力和应用场景,现在深入探讨其实现这些特性的基本构建块。这些概念建立在我们已学的函数式编程和消息传递之上。
核心语言特性
Erlang的核心是一种纯函数式语言(在每个进程内部)。它具有以下特点:
- 变量是单次赋值的。
- 函数是一等公民,支持高阶函数和词法作用域。
- 支持模式匹配(在
case、if和稍后提到的receive语句中)。 - 数据结构包括符号值、任意精度整数、浮点数、原子、列表、元组等。
- 字符串是ASCII码列表(即整数列表)。
- 包含二进制向量,用于低层协议计算,方便处理不同宽度的位域。
Erlang是动态类型但强类型的语言。类型在运行时被强制执行,但变量的类型在编译时未知。虽然静态类型有助于在编译时捕获更多表面错误,但Erlang通过其提供的容错机制,能够克服更深层的逻辑错误,从而构建出极其健壮的软件。
Erlang源代码组织在模块中。一个模块可以导出和导入函数。
进程与消息传递
Erlang的并发模型基于进程和异步消息传递(也称为Actor模型)。
- 使用
spawn函数创建进程,并赋予其一个定义行为的函数(通常是尾递归函数)。 - 每个进程有一个唯一的进程标识符。
- 使用
!操作符(bang符号)向进程ID发送消息,消息是异步的且数据会被复制。 - 使用
receive语句接收消息。
receive 语句的语义是Erlang系统的基础:
- 如果邮箱为空,
receive会阻塞。 - 如果邮箱非空,
receive会按顺序尝试匹配消息与模式。 - 如果找到匹配的模式,则执行相应代码并继续。
- 如果没有消息匹配任何模式,
receive会阻塞并等待下一条消息。 - 不匹配的消息会保留在邮箱中,可供后续的
receive调用处理。
这意味着消息可以不按到达顺序被移除,进程的不同部分可以处理不同类型的消息。开发者需要小心,确保最终移除所有消息,否则可能导致内存泄漏。
关键机制:注册、链接与监控
除了基本的进程通信,Erlang还提供了几个关键机制来构建健壮系统。
进程注册
可以将进程ID与一个原子(符号名称)注册起来,使其全局可访问。这允许接口名称保持不变,即使背后的进程崩溃并被新进程替代。相关操作包括 register、unregister、whereis 等。
进程链接
进程可以相互链接,形成双向连接。当一个进程终止(正常或崩溃)时,它会向所有链接的进程发送一个退出信号(包含终止原因)。默认情况下,如果一个进程收到原因不是 normal 的退出信号,它自身也会终止,并将同样的信号传播给它的链接进程。这导致整个链接集中的所有进程都会崩溃。这种机制确保协作进程组中一个成员失败时,逻辑上无法继续工作的其他成员也会被终止。
陷阱退出
进程可以通过调用 process_flag(trap_exit, true) 将自己设置为系统进程。这样的进程在收到链接进程的退出信号时不会崩溃,而是将该信号作为一条普通消息(格式为 {‘EXIT’, FromPid, Reason})放入其邮箱。这使得该进程可以观察其他进程的崩溃并采取相应行动(例如重启它们),这是构建监督者的基础。
监控
监控是链接的一种不对称变体。一个进程可以监控另一个进程。当被监控进程崩溃时,监控进程会收到一条 {‘DOWN’, …} 消息,但监控进程的崩溃不会影响被监控进程。这在客户端-服务器场景中很有用(客户端崩溃不应导致服务器崩溃)。
分布式Erlang与动态代码更新
透明分布
Erlang系统可以运行在多个节点(机器)上。节点间的进程通信语法与节点内通信完全相同,实现了透明分布。节点在首次被引用时会自动连接(例如通过TCP/UDP),默认形成全连接的网状拓扑,这对于可靠性很重要,但可能影响可扩展性。
动态代码更新
对于需要持续运行的系统(如丹麦国家医疗数据库),Erlang支持动态代码更新而无需停止系统。其机制基于模块:
- 每个模块可以同时存在两个版本的代码:旧版本和新版本。
- 新创建的进程使用新版本代码。
- 已运行的进程继续使用旧版本代码,直到它们主动决定切换到新版本(例如,通过调用
Module:Function而非Function来使用最新版本)。 - 系统中只维护两个版本,待所有进程升级后,旧版本方可被移除,为下一次更新腾出空间。
此外,也可以利用高阶函数,通过向进程发送新函数来实现单个进程的代码热交换,但这通常用于特定场景,大规模应用更依赖基于模块的机制。

🎯 本节总结


本节课我们一起学习了构建弹性Erlang应用的基础。我们首先回顾了Erlang的背景及其在性能和可靠性方面的优势,并通过Web服务器和ATM交换机的案例看到了其实际表现。然后,我们深入探讨了Erlang的核心概念:函数式核心、基于Actor模型的进程与消息传递、用于容错的进程链接与监控、透明的分布式支持,以及实现永不停机系统的动态代码更新机制。这些低层原语为构建健壮应用提供了坚实的基础。在接下来的部分,我们将看到Erlang/OTP如何在这些基础之上,通过提供高级抽象(如行为和监督者树)来极大地简化可靠系统的开发。
015:Erlang 哲学与OTP行为模式


在本节课中,我们将学习Erlang语言的核心设计哲学,以及其OTP框架如何通过“行为模式”和“监督树”等概念来构建高容错、高可用的软件系统。
概述:Erlang的容错哲学
上一节我们介绍了Erlang的动态代码更新。本节中,我们来看看Erlang如何构建健壮的软件。Erlang的设计哲学与Java、Python等主流语言截然不同,它认为错误无法完全消除,因此系统必须内置机制来处理错误。
动态代码更新的潜在问题
在继续之前,我们先回答一个关于动态代码更新的问题。
问题是:如果一个进程保留了旧版本的代码,是否会导致整个模块无法更新?
答案是肯定的,这会是一个错误。如果一个进程由于某种原因(例如自身存在Bug,或在等待一个永远不会到达的消息)没有更新代码,那么整个模块的更新就无法完成。
Erlang提供了一种解决方式:监督者可以注意到该进程没有更新,并强制终止该进程,然后重启它。这体现了Erlang的哲学:如果进程有问题,就终止它。
Erlang的五大设计原则

Erlang构建健壮系统基于以下原则,这些原则源自Erlang主要设计者Joe Armstrong的博士论文:

- 错误无法消除:无论进行多么彻底的调试,软件中总会存在残留的错误。因此,必须假设错误会发生,并设计机制来处理它们(包括硬件和软件错误)。
- 故障隔离:系统应被划分为独立的组件(“盒子”)。当一个组件失败时,其他组件不应受到影响。这与Java等语言的单体架构形成对比。
- 快速失败:当程序出现错误时,不应尝试在内部修复,而应立即失败。进程要么正确工作,要么快速停止。
- 故障可检测:一个组件的失败必须能被其他组件察觉和通知。这正是“链接”机制的作用。
- 无共享状态:这是隔离原则的必然结果。组件之间通过消息传递进行通信,不共享任何内存或状态。
Erlang的核心口号
基于以上原则,Erlang衍生出几个著名的编程口号:
- Let it crash(让它崩溃):如果一个进程无法完成工作,就让它崩溃。将所有可能的故障状态映射到单一的“崩溃”状态,使问题简化。
- Let someone else do error recovery(让他人进行错误恢复):当一个进程崩溃后,由另一个进程(通常是监督者)来修复问题。这使得工作进程的逻辑可以保持简单。
- Do not program defensively(不要进行防御式编程):Erlang认为过多的类型检查、输入验证等防御性代码会使程序变得复杂,且无法根除所有错误。更好的方法是保持程序简洁,如果出错就崩溃,由监督体系来处理。
这种鼓励崩溃、在更高层级统一恢复的哲学,对于构建需要长期稳定运行的系统非常有效。
OTP框架的层次结构
Erlang/OTP平台为构建大型系统提供了分层的抽象。以下是主要的层次:
- 发布:包含构建和运行完整系统所需的所有信息(包括软件、配置、安装和升级程序),是最大的单元。
- 应用:一个独立的功能单元,如Web服务器、数据库。一个发布通常由多个应用组成。
- 行为模式:这是我们将重点关注的层次。行为模式是一种并发模式,是一组进程的模板,用于实现如客户端-服务器、事件处理等通用模式。
- 工作者进程:实际执行具体任务的进程,它们实现了特定的行为模式。
行为模式:隐藏复杂性的关键


并发和容错是编程中的难题。Erlang通过“行为模式”将这些复杂性封装起来。
行为模式就像一个通用的、高阶的库。它将并发、消息传递、容错等困难部分实现为通用代码。程序员只需要“填空”,即提供特定的业务逻辑回调函数,就能轻松获得一个健壮的并发组件。
OTP提供了几种标准行为模式:
gen_server(通用服务器):用于构建客户端-服务器系统,内置了注册、启动、停止、超时、状态管理和错误处理。gen_event(通用事件处理器):用于管理事件通知和日志记录。gen_fsm(通用有限状态机):用于实现状态机,与我们之前电梯控制系统中的概念完全一致。应用处于某个状态,接收事件,执行动作,并转移到新状态。application(应用):对完整应用的生命周期(启动、停止)进行打包和管理。supervisor(监督者):最重要的行为之一,用于创建监督树,监控并管理其他进程(工作者或其他监督者)的生命周期。
所有非监督者的行为模式都设计为可以轻松嵌入监督树中。
监督树:容错的执行机制
监督树是Erlang实现容错的核心架构。其基本思想是:大多数故障(如消息丢失、临时死锁、超时)是瞬时的,简单的重试往往就能解决。
监督树是一个层级结构:
- 根节点和中间节点是监督者进程。
- 叶子节点是工作者进程(如
gen_server)。 - 监督者负责启动、停止、监控其子进程,并在子进程失败时采取重启策略。
主要的重启策略有:
one_for_one(一对一):一个子进程失败,只重启该进程。适用于子进程相互独立的场景(如管理多条独立的电话线路)。one_for_all(全部为一):一个子进程失败,终止并重启所有子进程。适用于子进程紧密协作、相互依赖的场景(如数据库、计算模块、用户界面组成的应用)。rest_for_one(剩余为一):一个子进程失败,终止并重启该进程及其后启动的所有子进程。
如果某个进程在短时间内反复崩溃,超过监督者配置的重启频率上限,监督者会认为问题无法通过重启解决,进而选择终止自身,将问题上报给更高级的监督者。
稳定存储与测试
为了支持监督者的重启机制,系统需要稳定存储来保存和恢复状态。OTP提供了不同级别的稳定存储:
- ETS:极快的内存存储,适用于进程在单节点内崩溃恢复。
- DETS:基于磁盘的存储,适用于节点崩溃恢复。
- Mnesia:功能完整的分布式事务数据库,支持复制和复杂查询。


此外,强大的测试工具也是构建可靠系统不可或缺的部分。OTP提供了从单元测试到系统测试的全套框架(如EUnit、Common Test),支持测试驱动开发、随机顺序测试、竞态条件测试和故障注入。
示例剖析:通用服务器 (gen_server) 的工作原理
让我们通过一个简单的“频道管理服务器”例子,看行为模式如何将通用代码与特定逻辑分离。
1. 特定服务器实现(传统方式)
一个管理“频道”分配和释放的服务器,需要手动编写消息循环、状态维护等代码。
2. 通用服务器骨架
我们可以抽象出一个通用服务器循环,它处理消息接收、循环、状态传递等通用逻辑。它定义了几个回调接口,如 init(初始化)、handle_call(处理同步请求)、handle_cast(处理异步请求)。
3. 回调模块(程序员编写)
程序员只需编写一个“回调模块”,实现 init、handle_call、handle_cast 等函数。在这些函数中填入具体的业务逻辑(如调用 allocate 或 free 函数)。


通过这种方式,复杂的并发服务器逻辑被封装在 gen_server 行为中,程序员只需关注业务逻辑。真正的OTP gen_server 还集成了注册、监督树集成等更多功能。
总结

本节课中我们一起学习了Erlang构建健壮软件的核心思想:
- 哲学基础:接受错误必然存在,通过隔离、快速失败、消息传递和无共享状态来设计系统。
- 核心机制:采用“Let it crash”哲学,配合监督树进行错误恢复,取代复杂的防御式编程。
- OTP框架:通过行为模式(如
gen_server,gen_fsm,supervisor)将并发与容错的复杂性封装成可重用的通用模板,极大降低了开发难度。 - 支持设施:利用稳定存储(ETS, DETS, Mnesia)保证状态持久化,并通过强大的测试工具确保系统可靠性。

Erlang/OTP的这一整套理念和工具链,使其非常适合构建需要极高可用性和容错能力的长生命周期系统,例如电信交换机、大型多人在线游戏后台等。其思想也影响了其他基于Actor模型的语言和框架。
016:课程介绍与基础回顾 🎓


在本节课中,我们将学习《高级编程语言概念》这门课程的整体安排,并回顾函数式编程、高阶编程、递归、fold操作以及确定性数据流并发等核心概念。这些是后续学习更高级主题的基础。

课程概述与安排 📅
本课程是之前课程 LM41104 的延续。今天的内容旨在帮助大家回顾,并假设大家已经忘记了之前课程的所有内容。这通常是一个合理的假设。希望今天之后,大家能重新记起所有内容。

本课程有一位助教 Lu Na lada,他是系里的博士生。此外,还有两位导师 Cooper 和 Thomas Ho,他们将负责安排实践实验课。课程在每周三上午进行,实验课则从第二周开始,在每周二的下午4:15至6:15进行。
为了鼓励大家参加实验课,我们设计了一个方案:每次出席实验课并签到,将计为1分。在学期末,如果你获得了所有分数,将在期末考试中获得1分的额外加分。如果只参加了一部分,加分将按比例计算。这个方案有双重好处:首先,它提供了额外的分数;其次,更重要的是,它鼓励大家进行规律的学习。只有通过每周的练习和思考,才能真正掌握课程内容,而不是在考试前一天突击学习。
课程组织方式
本课程的组织方式如下:
- 项目:占总分5分,是必修内容。项目需要两人一组完成,旨在运用课程所学知识完成一个更大的任务。只要合理安排时间,每个人都能在项目中获得好成绩。
- 期中考试:大约在第七周进行,时长为1小时,通常在课程时间内完成,占总分5分。期中考试是可选的,但参加有好处:它提供了提前学习材料的机会,并且对期末考试成绩有积极影响。
- 期末考试:占总分15分,实际上由期中考试的5分和期末的10分组成。期末考试的这部分成绩将取期中考试和期末考试相应部分的最高分。这意味着如果你期中考试考得好,期末考试时这部分内容就不必再学了,但你在期末考试中仍然可以尝试,并且只会取更好的成绩。

这种设计的目的是让大家在没有太大压力的情况下学习课程材料,并通过多种途径获得好成绩。
课程内容与工具
本课程名为“高级编程语言概念”,我们将使用 1104 课程中使用过的教材,但会更深入地探讨之前未涉及的复杂技术。我们将实际编程实现这些技术。
我们将继续使用 Oz 语言,因为它可以在这个系统中编程实现所有我们要学习的技术。当然,这些技术可以应用于任何语言。我们还将学习另一种名为 Erlang 的语言。在 1104 课程中我们已经对 Erlang 有了初步了解,本课程将更深入地学习 Erlang 如何实现通用行为和容错,这对于互联网应用来说是非常优秀的语言。
首先需要安装 Mozart 系统(用于 Oz),Erlang 的安装将在课程后期进行。
教学方式与反馈
这是我第二次教授这门课程,第一次由于疫情是在线上进行的。因此,这实际上是我第一次在课堂上教授这门课,这意味着课程可能会有所调整。我希望从大家那里获得关于课程进展的反馈。
我会尽力录制课程视频,但不能保证每周都有。因此,大家最好还是来上课并做笔记,这样可以提问和互动。
课程内容导览 🗺️

接下来,我将介绍本课程将要涵盖的许多概念和编程范式,这些内容比之前的课程更加深入。
我们将学习一个之前未涉及的概念:惰性求值。它可以与并发确定性结合,功能非常强大,允许计算无限数据结构,并且仍然是声明式的函数式编程。
我们还将学习一些高级编程技术和函数式编程的高级算法设计技术。函数式编程是一个非常强大的范式,但有些操作可能更难实现。实际上,有一些技术几乎可以在函数式范式中实现任何你想要的功能。我们将学习如何以非常巧妙的方式结合惰性和并发性,来创建高效的纯函数式算法。
此外,我们将学习多智能体编程的更强大形式,构建由许多并发智能体相互通信的复杂系统。
本课程的一个主题是并发性。在像 Java 这样的语言中,并发性通常很困难,但在其他范式中则容易得多。我们将在此基础之上进行更深入的学习。

另一个要学习的内容是共享状态并发,这是最困难的并发形式,因为它涉及可变状态。我们将学习其中的概念,如锁、监视器和事务。
- 监视器 是
Java中实现线程协作的常用方式,但在我看来,这是一种非常糟糕的方式,不过由于有大量遗留软件仍在使用它,我们仍然需要了解。 - 事务 则是一个非常好且有用的概念,被广泛应用于数据库等大型互联网操作中。
以上是课程内容的概览和重点。
今日回顾内容 🔄
今天,我将带大家回顾以下内容:
- 函数式编程
- 递归函数(本质上是循环)
- 高阶编程
- 一个非常重要且特殊的原语:
fold操作 - 确定性数据流并发
希望通过今天的回顾,大家能重新记起所有内容,下周我们将开始学习惰性求值。
函数式编程回顾
函数式编程是最重要的编程范式。它使用纯函数和单次赋值,没有状态(即没有可以多次赋值的变量)。它的理论基础是 λ 演算。
函数式编程是最好的范式,因为它使测试、维护和证明正确性变得容易。虽然由于没有内存(没有可赋值的变量)而不能做所有事情,但我们会尽可能多地使用它。在本课程中,我们将通过添加并发性、惰性求值和高级算法设计,将函数式编程推向极致。
递归与循环
在函数式编程中,我们经常使用递归函数。一个基本规则是:尾递归等价于 while 循环。
让我们以一个简单的例子来回顾:计算阶乘。我们总是从一个循环不变量开始。对于阶乘,不变量是:n! = i! * a。我们的想法是将 n 的阶乘分成两部分,i 和 a 一起给出 n!。然后我们减少 i 并增加 a,同时保持不变量为真。
在 Java 风格中,我们使用 while 循环和局部变量。在纯函数式风格中,我们使用递归函数,其中 a 和 i 作为参数,循环条件 i > 0 放在 if 语句中,递归调用则相当于再次循环。这两种方式生成的代码是相同的,都是循环指令。关键在于从不变量开始思考。
高阶编程与闭包
函数式编程最强大的特性之一是高阶编程,即函数是语言中的值,就像数字或记录一样。你可以创建局部函数值,将其作为参数传递或返回。这是构建所有数据抽象概念的关键。
例如,创建一个返回另一个函数的函数。每次调用这个函数时,都会创建一个新的匿名函数(闭包)。闭包不仅仅是函数代码,还包括代码加上其内部所需标识符的环境(即代码 + 环境)。每次创建匿名函数时,都必须捕获其词法作用域中的变量。
这个想法被称为闭包或词法作用域闭包,可能是编程语言中最重要的发明。所有现代语言都以某种形式实现了它。它是所有数据抽象的基础,使得构建由数百万行代码组成的大型软件成为可能。
Map 函数与列表
另一个高阶编程的例子是 map 函数,它接受一个列表和一个函数(闭包),并将该函数应用于列表的每个元素。

首先,回顾一下列表。列表是计算机科学中最重要的数据结构。我们可以用 EBNF 语法定义列表:List ::= nil | Element ‘|’ List。这是一个递归定义。
列表可以写成树形结构,其中竖线 | 是根节点。我们使用模式匹配来分解列表。例如,模式 H|T 会将列表的头元素匹配到 H,剩余列表匹配到 T。模式匹配实际上会创建一个新的环境,将变量绑定到相应的部分。
map 函数是尾递归的,这意味着它使用常量内存空间,就像一个 while 循环。
Fold 操作
fold 是最高阶、最有用的原语之一,它抽象了一个累加器。fold 是一个通用的循环,它通过一个二元函数组合列表中的所有元素。
例如,对列表 [a0, a1, ..., a_{n-1}] 求和,可以看作是用加法函数 + 和初始单位元 0 进行 fold 操作:((...(0 + a0) + a1) + ...) + a_{n-1}。
fold 的代码隐藏了累加器 U,用户无需关心累加器或不变量。这是一种数据抽象。fold 用途广泛,例如,在并发智能体中,U 可以是智能体的状态,H 是接收到的消息,F 是状态转换函数。因此,fold 是智能体的核心。
并发性与确定性数据流 ⚡
现在,我们来谈谈并发性以及随之而来的非确定性等问题。在上一门课程中,我们看到了一个非常好的范式:确定性数据流。它本质上是函数式编程加上线程,并且仍然是确定性的,因此非常有趣。
并发与线程
并发是指多个活动同时执行。在程序中,我们通过线程来实现这一点。线程是一个独立执行的活动。在抽象机器中,每个线程都有自己的语义栈。调度器负责在每一步选择哪个线程执行,这被称为交错执行。
调度器必须满足某些条件,最重要的是公平性:每个能够执行的线程最终都必须被选中。其他规则包括时间片(为了效率,线程通常运行一段时间后才被切换)和优先级(某些线程,如处理网络数据包或用户界面的线程,需要更高的优先级)。
非确定性
非确定性意味着程序做出了程序员无法决定的、由外部控制的选择。在并发系统中,如果有一个以上的线程,就本质上是非确定性的。非确定性可能来自调度器的选择,也可能来自现实世界(如用户何时点击按钮发送消息)。你的程序必须在所有可能的外部选择下都保持正确,这并不容易。
确定性数据流的优势
确定性数据流之所以如此优秀,是因为它对所有来自并发的非确定性免疫。在这个范式中,观察不到非确定性。这是因为它是函数式的,遵循 λ 演算的规则,特别是 Church-Rosser 定理 或 合流性。该定理指出,一个 λ 表达式无论以何种顺序归约,只要终止,都会得到相同的结果。函数式编程就是一个大的 λ 表达式,添加线程只是使不同位置的归约更加随机,但根据 Church-Rosser 定理,结果总是相同的。这是一个非常深刻的属性。
数据流同步
其基本概念是数据流同步。例如,有两个线程共享一个变量 y。一个线程执行 x = y + 10,然后打印 x;另一个线程执行 y = 5。x = y + 10 这个加法操作需要 y 的值。如果 y 尚未被绑定(即没有值),该操作会等待,直到数据可用。这就是数据流:指令根据数据的可用性来执行。
无论调度器如何选择执行顺序,最终结果总是 x = 15。因为 y 是单次赋值变量,这是纯函数式的概念。我们观察不到调度器的行为,这就是“没有可观察的非确定性”。即使有数百个线程,它也能很好地工作。
我们可以轻松地编写并发程序,例如一个生产者-消费者管道,只需几行代码。这展示了在此范式中编写并发程序是多么容易。

总结 📝

本节课中我们一起学习了《高级编程语言概念》课程的总体安排,并重点回顾了函数式编程的核心概念。我们重温了如何通过递归(特别是尾递归)实现循环,理解了高阶编程中函数作为值的威力,以及闭包作为构建数据抽象基石的重要性。我们还学习了强大的 fold 操作,它抽象了累加过程。最后,我们回顾了确定性数据流并发,理解了其通过数据流同步消除可观察非确定性的原理,这是编写可靠并发程序的关键范式。这些概念是后续学习更高级主题(如惰性求值)的基础。请确保理解这些内容,以便跟上后续课程。
017:惰性执行




概述

在本节课中,我们将要学习惰性执行的概念。这是一种编程范式,程序会尽可能少地工作,只在结果被真正需要时才进行计算。我们将通过定义惰性函数、理解其语义、探索惰性流和无限列表,并解决一些经典问题(如Hamming问题)来深入理解惰性执行。最后,我们会探讨其内部执行机制,并构建一个更复杂的例子——有界缓冲区。
从确定性数据流到惰性计算
上一节我们介绍了确定性数据流的并发编程。本节中我们来看看如何将这种思想与惰性计算结合。
以下是一个简单的生产者-消费者模型,使用确定性数据流实现:
fun {Producer L H}
if L > H then nil
else
{Delay 1000}
L|{Producer L+1 H}
end
end
fun {Consumer S Acc}
case S of H|T then
(Acc+H)|{Consumer T Acc+H}
[] nil then nil
end
end
这个例子中,生产者生成一个数字列表,消费者计算累积和。当它们并发运行时,我们可以看到结果逐步产生。
惰性计算简介
现在,让我们转向惰性计算的核心概念。惰性计算是函数式编程的一种形式,其核心思想是只在需要时才执行计算。
以下是一个简单的惰性函数定义:
fun lazy {LazyAdd X Y}
X+Y
end
当我们调用 {LazyAdd 10 20} 时,它不会立即计算 10+20,而是创建一个惰性挂起。只有当这个结果被另一个操作(例如加法或模式匹配)真正需要时,计算才会被触发。
惰性执行的语义
为了理解惰性函数如何工作,我们需要了解其底层语义。惰性函数在内部被翻译成内核语言。

一个惰性函数 fun lazy {F X} B end 大致被翻译为:
proc {F X R}
thread
{WaitNeeded R}
R = {B} % B 是函数体
end
end
这里的关键操作是 {WaitNeeded R}。它会阻塞当前线程,直到变量 R 被某个操作需要。一个变量被“需要”意味着另一个操作(如算术运算 +、case 语句或记录访问 .)必须知道它的值才能继续执行。
{Browse R} 操作不需要 R 的值,因此它不会触发计算。这使得惰性挂起可以无限期地等待,除非被垃圾回收器回收(当没有任何运行中的线程引用它时)。
惰性流与无限列表
惰性计算的一个强大特性是能够轻松处理无限数据结构,例如无限列表。
以下是一个生成无限自然数列表的惰性函数:
fun lazy {Ints N}
N|{Ints N+1}
end

调用 L = {Ints 1} 不会导致无限循环,它只是创建一个惰性挂起。只有当我们尝试访问列表的元素时,计算才会逐步进行。
我们可以定义一个过程来“强制”计算列表的前 N 个元素:
proc {Touch L N}
if N==0 then skip
else
{Touch L.2 N-1} % L.2 需要 L 的值,从而触发计算
end
end
执行 {Touch L 10} 会迫使惰性流计算并生成前10个元素。
惰性生产者-消费者
我们可以将之前的生产者-消费者模型改写为惰性版本,从而创建无限流的管道。
fun lazy {Producer N}
N|{Producer N+1}
end
fun lazy {Consumer S Acc}
case S of H|T then
(Acc+H)|{Consumer T Acc+H}
end
end
注意,这里移除了终止条件(nil 分支),因为惰性计算允许我们处理无限流。
当我们连接 S1 = {Producer 1} 和 S2 = {Consumer S1 0},并使用 {Touch S2 10} 触发计算时,控制流会从输出(Touch)反向传播到输入(Producer),通过 WaitNeeded 操作一步步唤醒计算。
Hamming 问题
Hamming 问题是一个经典的编程问题,要求按升序生成所有形如 2^A * 3^B * 5^C(A, B, C 为非负整数)的数字序列。使用惰性流可以给出极其简洁的解决方案。
序列 H 定义如下:
- 第一个元素是 1。
- 后续的每个元素都是
2*H、3*H、5*H这三个序列合并后最小的那个数字,并去除重复项。
我们可以用惰性流直接实现这个递归定义:
fun lazy {Times N H}
case H of X|T then (N*X)|{Times N T} end
end
fun lazy {Merge S1 S2}
case S1#S2 of (H1|T1)#(H2|T2) then
if H1 < H2 then H1|{Merge T1 S2}
elseif H1 > H2 then H2|{Merge S1 T2}
else H1|{Merge T1 T2} % 相等时只取一个
end
end
end
H = 1|{Merge {Times 2 H} {Merge {Times 3 H} {Times 5 H}}}
这个程序非常简短,却高效地生成了所需的无限序列。惰性计算确保我们只生成需要的部分,内存使用最小化。

有界缓冲区
有界缓冲区是一个用于平滑生产者和消费者之间速度差异的通用组件。在惰性计算框架下,我们可以用很少的代码实现它。
有界缓冲区的核心思想是:让生产者提前于消费者工作。缓冲区在启动时立即向生产者请求 N 个元素进行缓冲。每当消费者取走一个元素,缓冲区就立即再向生产者请求一个元素,试图始终保持缓冲区中有 N 个待消费元素。
以下是实现代码:
proc {Buffer S1 ?S2 N}
fun lazy {Loop S1 End}
case S1 of H|T then
H|{Loop T End.2} % 将元素传递给消费者
end
end
in
S2 = {Loop S1 {List.drop S1 N}} % 启动时请求N个元素
thread {List.drop S1 N} end % 在独立线程中执行初始请求,不阻塞
end
这个 Buffer 过程插在惰性生产者和惰性消费者之间。它巧妙地结合了急切(启动时和消费后主动请求)和惰性(仅当消费者需要时才传递数据)两种求值策略,从而提升了管道整体的效率。
总结

本节课中我们一起学习了惰性执行的核心概念。我们从惰性函数的基本定义和语义(WaitNeeded)开始,了解了惰性求值如何通过“按需计算”来工作。我们探索了惰性流和无限列表的威力,并看到了如何用简洁的惰性代码解决经典的 Hamming 问题。最后,我们深入探讨了有界缓冲区的实现,这是一个结合了惰性与并发、用于优化数据流管道的强大模式。惰性计算通过将复杂的控制流隐藏在简单的声明式代码之后,让程序员能够编写出既简洁又高效的复杂程序。
018:惰性求值与声明式并发



在本节课中,我们将要学习两个核心概念。首先,我们将通过一个惰性快速排序算法的例子,深入理解惰性求值如何催生出高效的算法。其次,我们将探讨声明式并发的本质,理解其理论基础,并了解不同声明式编程范式的全景图。
惰性快速排序:一个高效的算法
上一节我们介绍了惰性求值的基本概念。本节中我们来看看一个具体的例子:如何通过引入惰性求值,将一个普通的快速排序算法转变为一个极其高效的新算法。
快速排序算法回顾
快速排序是一种高效的递归排序算法,其平均时间复杂度为 O(n log n)。它的工作原理如下:
- 从列表中选择一个元素作为“基准”。
- 将列表划分为两个子列表:一个包含所有小于基准的元素,另一个包含所有大于或等于基准的元素。
- 对这两个子列表递归地应用快速排序。
- 将排序好的两个子列表与基准值连接起来。
以下是该算法的一个简单示例。假设我们有列表 [7, 3, 2, 8, 6, 4, 1, 9],选择第一个元素 7 作为基准:
- 划分后得到
L1 = [3, 2, 6, 4, 1]和L2 = [8, 9]。 - 递归排序
L1得到[1, 2, 3, 4, 6],排序L2得到[8, 9]。 - 最终结果为
[1, 2, 3, 4, 6] ++ [7] ++ [8, 9] = [1, 2, 3, 4, 6, 7, 8, 9]。

引入惰性求值
现在,我们在这个算法中引入惰性求值。关键点在于将递归调用和列表连接操作标记为惰性的。这意味着,除非确实需要某个结果,否则不会执行相应的计算。
考虑以下场景:我们只要求排序后列表的前 k 个最小元素,而不是整个排序列表。在传统的快速排序中,无论你需要多少元素,算法都会完成整个 O(n log n) 的排序过程。
然而,在惰性版本中,情况发生了根本变化。当你请求第一个元素时,算法只会执行必要的计算来找出最小的元素。请求第二个元素时,它也只进行找出第二小元素所必需的最小计算量。
惰性快速排序的效率分析
惰性快速排序的性能表现非常出色:
- 传统快速排序:时间复杂度为 O(n log n),无论你需要多少元素。
- 惰性快速排序:如果你只请求前
k个最小元素,其时间复杂度为 O(n + k log k)。
这相当于一个专门用于查找“前 k 个最小元素”的算法,而且 k 的值无需预先知道。你可以持续请求元素,算法会增量式地、高效地提供下一个最小元素。这种增量式算法手动实现起来相当复杂,但惰性求值使其变得简单自然。
惰性如何工作:深入分析
惰性求值并非魔法。其工作原理基于我们之前定义的“按需计算”规则。当程序需要一个惰性表达式的值时,会触发其计算。在快速排序的例子中:
- 请求结果列表的第一个元素
S.1,触发了整个惰性快速排序函数体的计算。 - 函数体执行划分操作(这是严格的,会立即完成),并创建两个惰性的递归排序调用
S1和S2。 - 接着执行惰性的
append(S1, [X] | S2)。为了得到结果的第一个元素,append函数需要S1的第一个元素。 - 这又触发了对
S1的求值,即对左子列表L1进行惰性快速排序,而S2的计算则保持挂起状态。 - 这个过程会递归地进行下去,沿着“左分支”深入,每次只对一个子列表进行划分和进一步探索,直到找到最小的元素。
本质上,算法只构建和探索了排序树中为获取所需前 k 个元素所必需的那部分路径。其他分支(对应较大的元素)的计算永远不会被触发。这就是效率提升的来源:惰性求值自动地、最小化地执行了任务所必需的工作。

通过这个例子,我们看到了惰性求值不仅是一种语言特性,更是一种强大的算法设计工具,它能从传统的算法中衍生出更高效、更灵活的增量式算法。

声明式并发的本质
在探讨了惰性求值的实践威力后,我们现在转向一个更理论性的问题:究竟什么是声明式并发?我们已经见识了顺序函数式编程、确定性数据流和惰性编程,它们的共同点是什么?
函数式编程的基础:Church-Rosser 定理
所有函数式程序都可以用 λ 表达式表示。函数式编程的一个强大特性是程序分析更简单,这源于一个关键定理:Church-Rosser 定理(或称合流性)。
该定理指出,对于一个 λ 表达式,无论你以何种顺序对其子表达式进行归约(求值),只要最终能归约到一个不可再归约的形式(范式),那么所有这些不同的归约路径最终都会得到相同的结果。
这意味着函数式程序具有确定性。即使计算过程是并发的(以不同顺序执行子计算),最终 observable 的结果总是唯一的。这是顺序函数式编程的基石。
扩展到并发与数据流
然而,我们讨论的声明式并发模型包含了更多内容:流(无限序列)和数据流变量(单次赋值变量)。
-
流与部分终止:流处理程序通常永不终止。为了用函数式模型理解它,我们引入部分终止的概念。假设输入流暂时固定(不再有新元素),那么程序运行一段时间后,输出流也会达到一个稳定的、不再变化的状态。这相当于完成了一次“函数调用”。当新的输入元素到达时,程序从该状态“重启”,进行下一轮计算并再次达到部分终止。因此,流计算可以被视为一系列连续的函数式计算,每一段都满足确定性。
-
数据流变量与逻辑等价:数据流变量允许先声明变量(创建一个“洞”),稍后再绑定值。这可能导致存储(store)中的绑定状态因调度顺序不同而有所差异。例如,考虑三个线程:
thread X=foo(Z W) end thread Y=foo(Z W) end thread X=Y end不同的执行顺序可能产生不同的存储绑定(如
X=foo(...), Y=foo(...)或X=foo(...), Y=X)。虽然表面绑定不同,但这些存储是逻辑等价的。它们对变量X和Y所施加的约束是相同的(即,X和Y最终都必须是具有两个参数的foo记录)。所有可能的变量值集合在两种存储下是一致的。因此,从程序外部观察到的效果仍然是确定性的。
声明式并发的定义
基于以上概念,我们可以给出声明式并发的形式化定义:
一个并发程序是声明式的,当且仅当:对于给定的所有输入,其所有可能的执行轨迹,要么都永不终止(发散),要么都达到(部分)终止,并且所有终止状态产生的输出(存储)都是逻辑等价的。
这精确定义了“无 observable 非确定性”的含义:无论调度器如何安排线程执行,程序最终表现出的行为(结果或永不终止)是唯一确定的。
处理非声明式情况:故障隔离
有时程序可能包含错误,导致其偏离声明式模型(例如,尝试多次绑定同一个变量为不同的值)。在实际系统(如 Mozart)中,这会引发异常,并且此时程序行为可能变得非确定性。
作为程序员,我们的目标应是尽可能保持程序的声明式特性。如果不可避免地引入了非确定性,应使用故障隔离技术将其封装起来,防止其污染程序的其他部分。核心思想是使用异常处理机制,在局部范围内捕获非确定性错误,并提供一个一致的默认值或处理方式,使得程序的其余部分仍然运行在声明式的、确定性的环境中。
以下是故障隔离的一个示例模式:
local X1 Y1 in
try
thread X1=1 end
catch error then S1=error end
try
thread Y1=2 end
catch error then S2=error end
try
thread X1=Y1 end
catch error then S3=error end
if S1==error orelse S2==error orelse S3==error then
X=default % 发生错误时提供默认值
Y=default
else
X=X1 % 无错误,使用计算值
Y=Y1
end
end
声明式编程范式全景图
最后,让我们俯瞰整个声明式编程的世界。我们可以根据两个维度对范式进行分类:求值策略(严格求值 vs 惰性求值)和并发与变量绑定时机(顺序、支持数据流变量的并发、完全分离声明/指定/求值)。
由此可以形成一个包含六种主要范式的全景图:
| 严格求值 | 惰性求值 | |
|---|---|---|
| 顺序函数式 | Scheme, ML | Haskell |
| 顺序 + 数据流变量 | (Prolog 确定性子集) | (Prolog 协程子集) |
| 并发 + 数据流变量 | 确定性数据流 (Oz, Apache Flink 等云数据分析工具) | 惰性确定性数据流 (实验性语言) |
组织原则:一个数据流变量的生命周期有三个关键时刻:1) 声明, 2) 指定计算其值的函数, 3) 实际求值。不同范式区别在于这些时刻是合并还是分离:
- 严格顺序函数式:三者同时发生。
- 惰性函数式:声明与指定同时,求值推迟。
- 确定性数据流:声明可早于指定和求值。
- 惰性数据流:三者完全分离,是最通用、最灵活的声明式范式。
趋势与展望:传统的函数式语言(如 Scheme, Haskell)位于表格上方。当前,并发确定性数据流范式正变得越来越重要,尤其是在云计算和分布式数据分析领域(如 Apache Flink),因为它结合了函数式的易于推理和并发的执行效率。未来,随着对按需计算和增量处理的需求增长,云平台可能会更多地融入惰性特性,向惰性数据流范式演进,以实现更高的资源利用率和响应速度。

本节课中我们一起学习了惰性求值如何通过“按需计算”创造出高效的算法(如惰性快速排序),并深入探讨了声明式并发的理论基础,理解了其确定性的本质源于 Church-Rosser 定理和对流、数据流变量的扩展定义。最后,我们纵览了声明式编程的多种范式,看到了从传统函数式语言到现代云数据分析平台的技术演进脉络。理解这些核心概念,将为你在未来选择和运用合适的编程语言与范式打下坚实的基础。
019:算法设计与声明式编程




在本节课中,我们将学习如何设计高效的声明式算法。我们将探讨几个核心概念,包括摊销复杂度、持久化算法以及惰性函数,并了解如何利用不同的声明式编程范式来实现高效的数据结构。
概述
声明式编程的核心优势在于其清晰性和可维护性。为了构建高效的声明式应用,我们需要理解算法设计的深层概念。本节将介绍摊销复杂度、持久化算法以及惰性函数中的增量与整体计算概念,并展示如何在不同的声明式范式中实现高效的队列数据结构。
核心概念介绍
在深入具体算法之前,我们需要理解几个评估和设计算法时的重要概念。
摊销复杂度 vs. 最坏情况复杂度
通常,我们分析算法时关注的是最坏情况时间复杂度,即每个操作在最坏情况下的时间上界。然而,对于某些算法,单个操作的成本可能差异很大:有些操作成本低廉(常数时间),有些则非常昂贵(例如 O(n) 时间)。如果仅看最坏情况,整个算法可能被判定为低效。
摊销复杂度的思想是,如果我们执行一系列 N 个操作,虽然其中个别操作可能很昂贵,但这些昂贵操作非常稀少。因此,N 个操作的总时间仍然是 O(f(N))。那么,我们可以说每个操作的摊销复杂度是 O(f(N)/N)。在实践中,如果操作在平均意义上成本低廉,这通常就足够了。
上一节我们介绍了复杂度分析的基本概念,本节中我们来看看算法的另一种分类方式。
短暂算法 vs. 持久化算法
大多数传统的数据结构(如用可变状态实现的栈或队列)是短暂的。这意味着数据结构在任何时刻只有一个“版本”。一旦你更新了它,旧的状态就不复存在。
然而,在某些实际场景中(如版本控制系统 Git、数据库或协同编辑),我们需要能够同时存在并独立修改的多个数据版本。支持这种功能的数据结构被称为持久化的。声明式范式(尤其是函数式编程)因其不可变性,使得实现持久化算法比在命令式范式中要容易得多。
理解了算法的持久性概念后,我们接下来探讨函数计算方式对效率的影响。
惰性函数:增量计算 vs. 整体计算
在惰性求值中,增量函数意味着当你只需要结果的一部分时,它只执行必要的部分计算。例如,快速排序在只需求最小元素时效率很高。
相反,整体函数则意味着即使你只需要结果的一部分(如列表的第一个元素),它也必须完成全部计算。惰性求值对这类函数没有帮助。一个典型的例子是反转列表的函数 reverse。为了得到反转后列表的第一个元素(即原列表的最后一个元素),它必须遍历整个列表。
以下是 reverse 函数的一个可能实现,它展示了整体计算的性质:
reverse(L) ->
case L of
nil -> nil;
[H|T] -> append(reverse(T), [H])
end.
我们的一个目标就是学习如何将整体函数转化为增量函数。
声明式编程范式
我们将使用三种声明式范式来构建算法:
- 顺序函数式编程(无数据流变量):例如 Scheme、Haskell 或 Java 中的不可变操作。
- 带数据流变量(单次赋值)的顺序函数式编程:这提供了额外的表达能力。
- 惰性函数式编程:我们将主要关注其顺序版本。
队列数据结构的实现之旅

我们将以队列数据结构为例,展示在不同范式下如何实现高效算法。主要实现四个变体:
- 短暂队列,摊销常数时间。
- 短暂队列,最坏情况常数时间。
- 持久化队列,摊销常数时间。
- 持久化队列,最坏情况常数时间。
首先,我们从最基础的实现开始。

1. 朴素队列(顺序函数式编程)
这是最简单的实现,但效率低下。
% 辅助函数,移除列表最后一个元素
butLast(L) ->
case L of
[X] -> {nil, X};
[H|T] -> {R, X} = butLast(T), {[H|R], X}
end.
% 队列表示为列表
newQueue() -> nil.
insert(Q, X) -> [X|Q]. % 在头部插入,常数时间
delete(Q) ->
{NewQ, X} = butLast(Q), % 删除尾部,需要 O(n) 时间
{NewQ, X}.
效率分析:insert 是 O(1),但 delete 是 O(n)。执行 n 次插入和删除的总时间为 O(n²)。
2. 高效短暂队列(摊销常数时间)
我们使用更聪明的表示方法:用两个列表 F(前端)和 R(后端,但元素是反向存储的)来表示队列。队列的实际内容是 F 与 reverse(R) 的拼接。
newQueue() -> {nil, nil}.
insert({F, R}, X) ->
check(F, [X|R]). % 插入到 R 头部
delete({F, R}) ->
case F of
[X|F1] -> {check(F1, R), X}; % 从 F 头部删除
nil -> % 如果 F 为空,则先反转 R 到 F
[X|F1] = reverse(R),
{check(F1, nil), X}
end.
% 检查函数,确保当 F 为空时,将 R 反转并移至 F
check(F, R) ->
case F of
nil -> {reverse(R), nil};
_ -> {F, R}
end.
效率分析:insert 和 delete 通常是 O(1)。昂贵的 reverse 操作仅在 F 为空且需要执行 delete 时发生,并且它会将多个元素从 R 一次性移到 F。因此,一系列 n 个操作的总时间是 O(n),每个操作的摊销复杂度是 O(1)。但此实现不是持久化的,因为从同一初始队列分叉出的不同版本会独立触发 reverse,导致重复计算。
3. 高效短暂队列(最坏情况常数时间)
为了获得最坏情况下的常数时间性能,我们需要改变范式,引入数据流变量(单次赋值)。关键是用差异列表来表示队列。
差异列表概念:
一个常规列表 [a, b, c] 可以表示为一个“差异列表”对 (S, E),其中 S = [a, b, c | E],而 E 是一个未绑定的变量。S 是列表的“起始”,E 是列表的“结束”(一个未绑定的尾部)。通过绑定 E,我们可以在常数时间内向列表末尾添加元素。
基于差异列表的队列实现:
% 队列表示为三元组:{元素数量, 起始差异列表 S, 结束变量 E}
newQueue() -> {0, S, S}. % 空差异列表:S 和 E 是同一个未绑定变量
insert({N, S, E}, X) ->
% 绑定当前结束 E 为 [X | E1],从而在末尾添加 X
E = [X | E1],
{N+1, S, E1}.

delete({N, S, E}) ->
% S 绑定为 [X | S1],从而从头部取出 X
S = [X | S1],
{N-1, S1, E}.
效率分析:insert 和 delete 都是最坏情况 O(1) 操作。这得益于差异列表允许在头尾两端进行常数时间的添加/删除操作。此实现仍然是声明式的(单次赋值),并且是短暂的。
差异列表的威力不仅限于队列,它还能实现常数时间的列表拼接。
常数时间列表拼接示例:
假设有两个差异列表 (S1, E1) 和 (S2, E2),分别代表列表 L1 和 L2。拼接操作就是绑定 E1 = S2,并返回新的差异列表 (S1, E2)。这仅需常数时间。

我们可以利用这个特性优化一些算法,例如展平嵌套列表的 flatten 函数。朴素版本使用 append,时间复杂度高。而使用差异列表的版本可以将所有 append 操作变为常数时间,显著提升效率。
展望:持久化队列与惰性求值
要实现持久化且高效的队列,我们需要引入惰性求值。
基本思路类似于摊销常数时间的短暂队列(使用 F 和 R),但关键区别在于:将昂贵的 reverse 操作封装为一个惰性悬挂。这个悬挂在需要时(即当 F 为空时)才会被求值。由于惰性求值的记忆化特性,一旦这个 reverse 在一个版本中被计算,其结果会被存储起来,所有其他从同一状态分叉出来的版本都会共享这个已计算的结果,从而避免了重复计算。
这展示了惰性求值的另一强大之处:它能够以优雅的方式实现高效的持久化数据结构。我们将在后续课程中详细探讨其实现。
总结

本节课中我们一起学习了高效声明式算法设计的核心概念。我们了解了摊销复杂度如何更公平地评估算法,认识了持久化数据结构在实际中的重要性,并区分了增量计算与整体计算。通过实现从朴素到高效的队列,我们实践了在顺序函数式编程、带数据流变量的编程范式中进行算法优化的技巧。最后,我们看到了惰性求值为实现持久化数据结构提供的强大潜力。掌握这些概念和技术,将帮助你设计出既清晰又高效的声明式程序。
020:惰性求值与持久化数据结构



在本节课中,我们将要学习如何利用惰性求值技术,将之前介绍的高效但非持久化的队列数据结构,改造为支持多版本操作的持久化数据结构。我们将从分析问题开始,逐步引入惰性求值、银行家方法等技巧,最终实现一个最坏情况常数时间的持久化队列。
回顾:非持久化(瞬时)队列
上一节我们介绍了高效的瞬时队列。它的核心思想是使用两个列表 F(前端)和 R(后端)来表示队列。插入操作在 R 列表末尾进行,删除操作从 F 列表头部进行。当 F 列表为空时,我们会将 R 列表反转并移动到 F 中。这个 reverse 操作是昂贵的,但通过摊还分析,可以证明每个操作的平均时间是常数。
然而,这个队列设计存在一个问题:它不支持持久化。持久化意味着我们可以同时拥有并操作数据结构的多个历史版本。在瞬时队列中,如果从同一个版本进行多次删除操作,每次删除都可能触发一次昂贵的 reverse 操作,导致总时间复杂度变为 O(n²),不再是常数时间。
迈向持久化:惰性求值的威力
本节中我们来看看如何利用惰性求值来解决持久化问题。核心思路是:提前执行昂贵的操作(如 reverse),但以惰性的方式。
问题根源:重复的 reverse 操作
假设我们有一个队列版本 Q26,它经过了多次插入,R 列表很长。如果我们从这个版本派生出多个新版本(例如 Q27, Q28),每个版本都执行一次删除操作,那么在瞬时队列的实现中,每个删除操作都会独立地触发一次 reverse 操作,因为每个版本都“看到” F 列表为空。这导致了 O(n²) 的总工作量。
解决方案:银行家方法与惰性求值
我们可以采用银行家方法进行摊还分析。将每次插入操作视为向“银行”存入一笔钱(一个信用单位)。昂贵的 reverse 操作则被视为从银行取钱。我们只在银行有足够存款(即 R 列表的长度接近 F 列表的长度)时,才“计划”执行 reverse 操作。
关键改进在于,我们并不立即执行 reverse,而是创建一个惰性求值的“承诺”(或称为挂起计算)。这个承诺封装了 F 列表与 reverse(R) 列表的连接操作。当后续的删除操作需要访问 F 列表的元素时,才会逐步强制求值这个惰性承诺。
由于这个惰性承诺是在版本分裂之前创建的,所有从同一父版本派生的子版本都会共享这个承诺的计算结果。因此,昂贵的 reverse 操作只会被执行一次,其成本被摊还到创建该承诺之前的多次插入操作中。这样就实现了摊还常数时间的持久化队列。
以下是队列数据结构的核心表示和检查函数:
fun {Check Q}
case Q of q(F LenF R LenR) then
if LenR =< LenF then Q
else q({LazyAppend F {Reverse R}} LenF+LenR nil 0)
end
end
end
其中,LazyAppend 是一个惰性函数,它不会立即连接列表,而是返回一个承诺。
实现最坏情况常数时间

上一节我们利用惰性求值实现了摊还常数时间的持久化队列。然而,它还不是最坏情况常数时间,因为惰性承诺中的 reverse 操作本身是整体性的:一旦开始求值,就必须计算整个反转后的列表,这需要 O(n) 时间。

本节中我们来看看如何通过一个巧妙的技巧,将 reverse 操作变得增量式,从而实现最坏情况常数时间。
合并 Append 与 Reverse
问题的症结在于 LazyAppend F {Reverse R} 中的 Reverse。我们可以创建一个新的函数 AppRev,它同时执行连接和反转两个操作,并且是增量式的。
AppRev 函数同时遍历 F 列表和 R 列表。在每一步中,它从 F 中取出一个元素放入结果列表(执行 Append 的部分),同时从 R 中取出一个元素放入一个累加器(执行 Reverse 的部分)。当 F 列表遍历完毕时,R 列表恰好只剩下一个元素(因为我们在检查条件 LenR > LenF 时才调用它),此时将累加器中的反转结果与这最后一个元素组合即可。
fun {AppRev F R B} % B 是反转结果的累加器
case F of nil then
case R of nil then B
[] Y|R2 then Y|B % 注意:这里是将 Y 添加到 B 的前面
end
[] X|F2 then
case R of nil then nil % 理论上不会发生,因为 LenR > LenF
[] Y|R2 then
X|{AppRev F2 R2 Y|B} % 增量式地构建结果和反转结果
end
end
end
这个 AppRev 函数是增量式的:每次递归调用只处理一个元素,是常数时间操作。我们用惰性版本的 AppRev 替换掉原来的 LazyAppend 和 Reverse。
最终的持久化队列
将增量式的 AppRev 函数以惰性方式集成到检查函数中,我们就得到了最终的、支持持久化、且所有操作都是最坏情况常数时间的队列实现。
fun {Check Q}
case Q of q(F LenF R LenR) then
if LenR =< LenF then Q
else q({LazyAppRev F R nil} LenF+LenR nil 0)
end
end
end
这个实现虽然代码简短,但融合了惰性求值、摊还分析和增量计算等多个高级概念,展示了声明式编程在实现高效、持久化数据结构方面的强大能力。

声明式编程的边界与现实世界交互
本节课我们一起学习了如何利用惰性求值构建强大的持久化数据结构。然而,纯粹的声明式编程(如 λ 演算)有其根本性限制:它无法在计算过程中与外部世界进行交互。
声明式的优势与代价
声明式执行(即 λ 演算的归约)具有汇合性:无论以何种顺序求值,最终结果都相同。这带来了巨大的好处:易于推理、调试、测试、优化以及并行/分布式执行,且没有效率损失。
但是,为了获得汇合性,我们必须付出代价:所有信息都必须预先编码在初始表达式中。程序在执行过程中(即归约步骤之间)不能接收新的输入(如用户按键)或产生新的输出(如更新屏幕)。因为任何外部交互都会影响归约的顺序或内容,从而破坏汇合性。
引入非声明式构造
为了与真实世界交互,我们必须超越纯粹的声明式范式,引入一些非声明式(或称为命令式)的构造。最重要的两种是:
- 可变变量:我们称之为 Cell。它可以被多次赋值,读取时返回最后一次写入的值。这类似于 Java 中的变量,引入了状态和顺序依赖。
- 通信通道:我们称之为 Port。它是一个流,可以向其发送消息,并从中按顺序接收消息。这类似于 Erlang 或 Actor 模型中的消息传递。
这两种构造都破坏了汇合性,因为操作的结果 now 依赖于操作发生的具体时间和顺序。
实际应用中的平衡
尽管如此,在构建实际系统时,我们仍然可以遵循一个黄金法则:尽可能使用声明式编程,仅在必要时引入命令式构造。
例如,在一个客户端-服务器系统中(如亚马逊):
- 服务器端处理订单逻辑、数据库查询等绝大部分代码都可以是声明式的。
- 只有接收客户端请求的那个点——一个从多个客户端并发接收消息的
Port——需要是非声明式的。

这样,我们既享受了声明式编程在主体部分带来的所有好处(易于测试、维护、并行化),又通过最小化的命令式接口实现了必要的现实世界交互。
现有的主流语言(如 Java, Erlang)由于其历史原因,往往包含了过多不必要的命令式特性。而现代大规模分布式系统(如云计算框架)由于其复杂性,正被迫更多地采用声明式范式,这代表了未来的发展方向。
总结

本节课中我们一起学习了:
- 惰性求值的高级应用:通过银行家方法和惰性承诺,将高效的瞬时队列改造为摊还常数时间的持久化队列。
- 增量计算技巧:通过合并
Append和Reverse操作,实现了AppRev函数,进而构建了最坏情况常数时间的持久化队列。这展示了惰性求值实现高效算法的强大能力。 - 声明式编程的边界:纯粹的 λ 演算具有汇合性,但无法与运行时的外部世界交互。
- 命令式扩展的必要性:为了交互,需要引入
Cell(可变变量)和Port(通信通道)这两种基本的非声明式构造。 - 实践哲学:在构建系统时,应最大化声明式部分,仅将命令式构造严格限制在必须与外界交互的少数接口上。这是构建健壮、可维护系统的最佳实践。
021:数据抽象与多智能体编程



概述
在本节课中,我们将要学习数据抽象的核心概念,包括如何构建安全的抽象数据类型和对象。我们将探讨四种不同类型的数据抽象,并理解其背后的语言机制。随后,我们将转向多智能体编程,通过一个历史问题——弗拉维乌斯·约瑟夫斯问题——来比较确定性数据流和主动对象这两种不同的并发编程范式。
数据抽象
数据抽象是程序的一部分,它包含一个内部实现和一个外部接口。内部实现对外部程序是隐藏的,只能通过定义好的接口操作来访问。这种封装保证了数据结构的完整性,防止外部代码破坏其内部状态。
未受保护的栈示例
以下是一个简单的栈实现,但它没有封装,任何人都可以直接操作其内部列表结构,因此无法保证其被正确使用。
fun {NewStack} nil end
fun {Push S X} X|S end
fun {Pop S X} X=S.1 S.2 end
fun {IsEmpty S} S==nil end
这个实现不是数据抽象,因为它没有受到保护。
抽象数据类型与对象
数据抽象有两种基本形式:抽象数据类型和对象。
- 抽象数据类型:将值和操作分离为两种不同的实体。例如,整数
5和6是值,+是操作。 - 对象:将值和操作捆绑在单个实体中。例如,在纯面向对象语言中,整数
5本身就是一个对象,+是发送给它的消息。
一个现实世界的例子是电视机(对象,所有功能集于一身)和自动售货机(抽象数据类型,机器、硬币、商品是分离的实体)。
构建安全的抽象数据类型
为了构建一个真正受保护的抽象数据类型,我们需要一种机制来“加密”内部值,使得只有授权的操作才能“解密”它。这需要两个核心语言概念:
- 不可伪造的常量:一个无法被猜测或创建的密钥。
- 安全记录(盒子):一个只能用特定密钥访问的受限记录。
在 Oz 语言中,这两个概念分别称为 Name 和 Chunk。
以下是实现包装和解包装函数的代码,它们共同守护着内部数据:
proc {NewWrapper Wrap Unwrap}
Key={NewName}
in
fun {Wrap X}
{Chunk w(Key:X)}
end
fun {Unwrap W}
W.Key
end
end
NewWrapper 过程创建一个新的密钥 Key,并返回两个函数 Wrap 和 Unwrap。Wrap 函数将值 X 包装成一个安全记录(Chunk),Unwrap 函数用密钥 Key 从安全记录中提取出原始值。由于 Key 是通过 {NewName} 新生成的、且仅在 NewWrapper 的词法作用域内可知,因此外部代码无法伪造或访问它。
利用这些函数,我们可以构建一个安全的栈抽象数据类型:
local Wrap Unwrap in
{NewWrapper Wrap Unwrap}
fun {NewStack} {Wrap nil} end
fun {Push W X} S={Unwrap W} in {Wrap X|S} end
fun {Pop W X} S={Unwrap W} in X=S.1 {Wrap S.2} end
fun {IsEmpty W} {Unwrap W}==nil end
end
每个操作在执行前先解包(Unwrap)内部状态,执行操作后再重新包装(Wrap)起来。这类似于在银行的保险库中操作保险箱:只有进入安全环境(调用授权函数)才能打开箱子进行操作。
对象语义
对象将状态和操作捆绑在一起。从语义上看,一个对象可以表示为一个单参数过程,参数是消息,内部通过 case 语句进行方法分派。
以下是一个有状态栈对象的实现示例:
fun {NewStack}
C={NewCell nil}
proc {Push X} C:=X|@C end
fun {Pop X} S=@C in X=S.1 C:=S.2 end
fun {IsEmpty} @C==nil end
in
proc {$ M}
case M
of push(X) then {Push X}
[] pop(X) then {Pop X}
[] isEmpty(B) then B={IsEmpty}
end
end
end
这里,栈的状态存储在一个单元(Cell)C 中。对象本身是一个过程,根据接收到的不同消息(push, pop, isEmpty)来更新或查询内部状态。
四种数据抽象
我们可以根据两个轴对数据抽象进行分类:捆绑方式(对象 vs 抽象数据类型)和状态性(声明式无状态 vs 有状态)。这产生了四种类型:
- 声明式抽象数据类型:如我们刚刚构建的安全栈。值不可变,操作无副作用。
- 有状态对象:如上例中的栈对象。状态可变,操作有副作用。
- 函数式对象:像对象一样捆绑,但内部没有可变状态。任何“更新”操作都会返回一个全新的对象。这种对象在分布式和大数据系统中很有用,因为它们易于序列化和传输。
fun {StackObject S} fun {Push X} {StackObject X|S} end fun {Pop X} X=S.1 {StackObject S.2} end fun {IsEmpty} S==nil end in stack(push:Push pop:Pop isEmpty:IsEmpty) end - 有状态抽象数据类型:值(通过安全包装)和操作分离,但内部值本身是可变的。这种类型目前不常用。
历史上,CLU 是第一个支持抽象数据类型的语言。而像 Java 这样的现代语言实际上混合了对象和抽象数据类型的特性。
多智能体编程
上一节我们介绍了数据抽象,本节中我们来看看如何利用通信通道进行多智能体编程。我们将使用端口(Port)来构建智能体,并比较基于消息传递的主动对象范式和声明式的确定性数据流范式。
端口对象
端口对象是一种基本的智能体模型,它包含一个内部状态和一个用于接收消息的端口。其行为由一个状态转移函数定义:新状态 = F(旧状态, 消息)。
我们可以用高阶函数 Fold 来简洁地定义端口对象,它不断将流中的消息折叠到当前状态中:
fun {NewPortObject Init F}
P S in
thread {FoldL S F Init _} end
{NewPort S P}
P
end
NewPortObject 创建一个新线程来运行 FoldL,该折叠操作以初始状态 Init 开始,并将流 S 中的每个消息通过函数 F 应用到当前状态。NewPort 创建端口 P 及其对应的流 S。任何发送到 P 的消息都会出现在流 S 中,从而触发状态更新。
弗拉维乌斯·约瑟夫斯问题
为了对比不同的并发范式,我们引入一个经典问题:弗拉维乌斯·约瑟夫斯问题。
问题描述:N 个人围成一圈,从第一个人开始报数,每数到第 K 个人就将其淘汰出圈,然后从下一个人继续报数,直到只剩一人。求最后幸存者的位置。
我们将设计一个消息协议来解决这个问题。消息格式为 kill(X, S),其中 X 是已遍历的存活者计数,S 是当前存活的总人数。协议规则如下:
以下是每个士兵(智能体)接收到 kill(X, S) 消息后的处理规则:
- 如果士兵存活且
S == 1:他是最后的幸存者。 - 如果士兵存活且
X mod K == 0:该士兵被淘汰。他发送kill(X+1, S-1)给下一个人。 - 如果士兵存活且
X mod K != 0:该士兵安全。他发送kill(X+1, S)给下一个人。 - 如果士兵已死亡:他直接转发消息
kill(X, S)给下一个人。
主动对象实现
首先,我们使用主动对象(类似于端口对象,但用类定义)来实现。每个士兵是一个主动对象,拥有 alive 状态、id 和指向下一个士兵的 successor 引用。
class Victim
attr alive id step successor last
meth init(I K L)
alive:=true
id:=I
step:=K
last:=L
end
meth setSuccessor(S)
successor:=S
end
meth kill(X S)
if @alive then
if S==1 then
@last=@id
elseif X mod @step == 0 then
alive:=false
{@successor kill(X+1 S-1)}
else
{@successor kill(X+1 S)}
end
else
{@successor kill(X S)}
end
end
end
fun {Josephus N K}
Last
A={NewArray 1 N null}
in
for I in 1..N do
A.I:={New Active.object init(I K Last)}
end
for I in 1..N-1 do
{A.I setSuccessor(A.(I+1))}
end
{A.N setSuccessor(A.1)}
{A.1 kill(1 N)}
Last
end
代码首先创建 N 个 Victim 对象并连接成环,然后向第一个对象发送初始消息 kill(1, N) 启动流程。最终,全局变量 Last 会绑定到幸存者的 ID。
这个实现直观地反映了协议规则,易于理解。
总结
本节课中我们一起学习了数据抽象的核心原理。我们深入探讨了如何通过不可伪造的常量和安全记录来构建受保护的抽象数据类型,并对比了抽象数据类型与对象在语义上的根本区别。我们还将数据抽象分为四种类型,特别指出了函数式对象在分布式系统中的新兴应用。

随后,我们进入了多智能体编程领域,介绍了基于端口和状态转移函数的智能体模型。最后,我们通过弗拉维乌斯·约瑟夫斯问题,展示了如何使用主动对象范式来实现一个具体的并发协议。在接下来的课程中,我们将看到同一问题如何用更简洁的确定性数据流范式来解决,从而深刻理解这两种并发编程范式的差异与联系。
022:多智能体编程 🧠


概述
在本节课中,我们将学习多智能体编程。我们将比较确定性数据流与消息传递这两种并发编程范式,并通过一个设计过程来展示如何构建一个复杂的多智能体系统——电梯控制系统。
确定性数据流与消息传递的比较 🔄
上一节我们介绍了约瑟夫环问题。本节中,我们来看看如何用两种不同的并发范式实现它。
约瑟夫环问题回顾
约瑟夫环问题描述如下:有 n 个对象(士兵)围成一个环,从某个位置开始,每数到第 k 个对象就将其移除,直到只剩下一个幸存者。我们的目标是编写一个程序 Josephus(n, k),返回最终幸存者的位置。
确定性数据流版本
在确定性数据流范式中,每个“士兵”是一个流对象,即一个读取输入列表并生成输出列表的函数。它们通过流连接成一个环。
以下是构建管道(环)的高阶函数 Pipe:
fun {Pipe L H F}
if L > H then nil
else {F L {Pipe L+1 H F}}
end
end
以下是实现约瑟夫环协议的 Victim 函数:
fun {Victim I Xs}
case Xs of kill(X S)|Xr then
if S==1 then
Last=I
nil
elseif (X mod K)==0 then
kill(X+1 S-1)|Xr
else
{Victim I X+1 S Xr}
end
[] nil then nil
end
end
整个 Josephus 函数通过调用 Pipe 来创建环,并注入初始消息:
fun {Josephus N K}
Zs
{Pipe 1 N
fun {$ I Is}
thread {Victim I Is} end
end}
Zs = kill(1 N)|Zs
Last
end
核心思想:当“士兵”死亡(X mod K == 0)时,函数不进行递归调用,该流对象自动从环中消失,实现了“短路”优化。
主动对象(消息传递)版本
在主动对象范式中,每个“士兵”是一个拥有独立线程和消息队列的主动对象。它们通过显式地发送 send 消息进行通信。
以下是 Victim 类的简化代码框架:
class Victim
attr alive successor
meth init(...) ... end
meth kill(X S)
if @alive andthen S\=1 then
if (X mod @Step)==0 then
alive := false
{Send @successor kill(X+1 S-1)}
else
{Send @successor kill(X+1 S)}
end
else
{Send @successor kill(X S)} % 转发消息
end
end
end
初始版本的问题:死亡的“士兵”对象(僵尸)仍保留在环中,仅转发消息,导致效率低下。
优化版本:短路环
为了优化,我们需要在对象死亡时将其从环中移除。这需要双向链表(前驱和后继指针),并通过消息更新指针。
关键优化代码段如下:
meth kill(X S)
if @alive andthen S\=1 then
if (X mod @Step)==0 then
alive := false
{Send @predecessor setSuccessor(@successor)}
{Send @successor setPredecessor(@predecessor)}
{Send @successor kill(X+1 S-1)}
else ... end
else ... end
end
正确性依赖:此优化的正确性关键依赖于主动对象消息通道的 FIFO(先进先出) 属性。由于 setSuccessor 和 setPredecessor 消息在 kill 消息之前发送,并且FIFO保证它们先到达,因此当 kill 消息到达相关节点时,环的更新已经完成。
两种范式的对比总结
以下是两种实现方式的对比:
| 特性 | 确定性数据流 | 主动对象(消息传递) |
|---|---|---|
| 士兵实体 | 流对象(列表函数) | 主动对象(类实例) |
| 通信方式 | 通过流连接 | 显式 send 消息 |
| 死亡处理 | 自动消失(无递归调用) | 需显式更新指针或保留为僵尸 |
| 代码复杂度 | 非常紧凑,逻辑简洁 | 相对冗长,需处理消息协议、指针更新 |
| 确定性 | 完全确定性 | 可处理非确定性 |
| 适用场景 | 适用于确定性计算 | 适用于需要与非确定性环境交互的系统(如客户端-服务器) |
确定性数据流在适用场景下能自动生成高效、简洁的代码,而主动对象则提供了更强大、灵活的非确定性编程能力,但需要开发者进行更细致的设计和推理。
多智能体系统设计方法论 🏗️
上一节我们比较了两种并发范式。本节中,我们来看看如何系统地设计一个复杂的多智能体系统。
多智能体程序由多个并发执行的主动对象(智能体)组成,它们通过消息任意通信。由于其固有的复杂性,必须采用系统化的设计方法,否则极易出现难以调试的并发错误。
设计流程
一个稳健的多智能体系统设计通常遵循以下步骤:
- 非形式化规约:用自然语言描述系统应有的行为。
- 识别组件:确定系统中有哪些类型的智能体。
- 定义消息协议:规定不同组件间如何通信。
- 绘制状态图:为每个智能体设计其内部状态机(最核心、最困难的部分)。
- 编写代码:根据状态图实现(相对容易)。
- 测试与验证:通过测试和形式化推理确保系统正确性。

智能体与连接类型
智能体通过连接进行通信,主要有两种类型:
- 单源连接:只有一个发送者。通常是确定性的,如数据流变量或流,用于管道或确认。
- 多源连接:多个发送者可以向同一个端口发送消息。这是非确定性的,是类似 Erlang 语言的基础,用于客户端-服务器等场景。
实战:电梯控制系统设计 🛗
现在,我们将应用上述方法论来设计一个酒店电梯控制系统。
系统规约与组件
系统包含以下类型的智能体:
- 用户:模拟按下楼层按钮的行为(我们只建模其产生的消息)。
- 楼层:代表建筑的每一层,接收用户的呼叫并通知电梯。
- 电梯:核心控制器,接收目标楼层请求,并决定移动。
- 控制器:控制电梯马达,模拟电梯在井道中的移动。
消息协议
智能体间通过以下协议通信:
- 用户 -> 楼层:
call(F)。用户在楼层按下上行/下行按钮。 - 用户 -> 电梯:
call(F)。用户在电梯内按下目标楼层按钮。 - 楼层 -> 电梯:
call(F)。楼层请求电梯前来。 - 电梯 -> 控制器:
step(Dest)。电梯请求控制器移动至目标方向。 - 控制器 -> 电梯:
at(Floor)。控制器通知电梯当前到达的楼层。 - 电梯 -> 楼层:
arrive(F)。电梯通知楼层它已到达。 - 楼层 -> 电梯:
ack()。楼层确认门已关闭,电梯可以离开。
智能体状态图设计
1. 控制器状态图
控制器是最简单的,它只有两个状态:stopped(停止)和 running(运行)。它通过一个计时器来模拟电梯移动一层楼所需的时间。
状态: stopped(F, LID)
接收消息: step(Dest)
条件: 如果 F == Dest -> 无操作,保持状态。
条件: 如果 F != Dest -> 发送 startTimer 给计时器,计算新楼层 NewF (F+1 或 F-1),转移到状态 running(NewF, LID)。
状态: running(F, LID)
接收消息: stopTimer -> 发送 at(F) 给电梯 LID,转移到状态 stopped(F, LID)。
2. 楼层状态图
楼层有三种状态:notCalled(未呼叫)、called(已呼叫)和 doorsOpen(门开启)。它也需要一个计时器来模拟门开启的时间。
状态: notCalled(F)
接收消息: call -> 发送 call(F) 给某个电梯,转移到状态 called(F)。
接收消息: arrive -> 发送 startTimer,转移到状态 doorsOpen(F)。
状态: called(F)
接收消息: call -> 已呼叫,忽略,保持状态。
接收消息: arrive -> 发送 startTimer,转移到状态 doorsOpen(F)。
状态: doorsOpen(F)
接收消息: call -> 门已开,忽略,保持状态。
接收消息: arrive -> 多个电梯可能同时到达,忽略,保持状态。
接收消息: stopTimer -> 发送 ack() 给所有等待的电梯,转移到状态 notCalled(F)。
注意:每个状态都必须处理所有可能接收到的消息(call 和 arrive),即使处理方式是“忽略”。
3. 电梯状态图
电梯是最复杂的组件,因为它需要维护一个行程计划,记录所有需要停靠的楼层列表。其状态图包含嵌套循环。
状态: idle(Pos, Sched) // Sched 是楼层列表
接收消息: call(N) -> 更新计划 Sched' = smartSchedule(Pos, Sched, N)。如果 Sched' != nil,则发送 step(Sched'.1) 给控制器。转移到状态 moving(Pos, Sched')。
状态: moving(Pos, Sched) // 内层循环:移动至下一目标楼层
接收消息: at(F) -> Pos' = F。
条件: 如果 F != Sched.1 -> 未到目标,发送 step(Sched.1) 给控制器,保持状态 moving(Pos‘, Sched)。
条件: 如果 F == Sched.1 -> 到达目标,发送 arrive(F) 给楼层,转移到状态 waiting(Pos‘, Sched)。
状态: waiting(Pos, Sched) // 等待门关闭
接收消息: ack -> 门已关。
条件: 如果 Sched.2 != nil -> 还有后续楼层,新计划 Sched‘ = Sched.2,发送 step(Sched‘.1) 给控制器,转移到状态 moving(Pos, Sched‘)。
条件: 如果 Sched.2 == nil -> 行程结束,转移到状态 idle(Pos, nil)。
智能调度:smartSchedule 函数负责将新的楼层请求 N 插入到当前计划 Sched 中的合适位置,以实现高效的“电梯算法”(例如,扫描算法)。
从状态图到代码
一旦状态图设计完成,编写代码就变得直接。通常使用嵌套的 case 语句来实现:外层 case 匹配当前状态,内层 case 处理接收到的消息。代码结构严格遵循状态图的转换逻辑。
测试与验证
完成编码后,必须进行:
- 测试:模拟各种场景,包括极端情况(如连续快速按键、多个电梯同时到达同一楼层)。
- 验证:通过形式化推理证明系统的某些关键属性始终成立。例如:
- 活性:如果一个楼层
F在电梯的计划中,电梯最终总会停靠在F。 - 安全性:电梯不会在未到达目标楼层前发送
arrive消息。
可以使用模型检测工具(如 SPIN, TLA+)来自动化部分验证过程。
- 活性:如果一个楼层
总结 🎯
本节课中我们一起学习了:
- 多智能体编程的概念,即多个并发智能体通过消息交互。
- 确定性数据流与主动对象(消息传递) 两种并发范式的对比,并通过约瑟夫环问题展示了它们在代码复杂度、效率和适用性上的差异。
- 系统化的多智能体设计方法论,包括规约、组件识别、协议定义、状态图设计、编码、测试和验证。
- 电梯控制系统这一完整案例,实践了从需求分析到状态机设计,再到代码实现和正确性考虑的全过程。


多智能体模型是构建复杂并发系统(如分布式系统、物联网、机器人协作)的强大工具。掌握其系统化的设计方法,是编写可靠、高效并发程序的关键。
023:Erlang 系统概述与核心概念 🚀


在本节课中,我们将学习 Erlang 语言及其系统。Erlang 是一种由爱立信公司自1990年左右开始开发的多代理系统,专为并发和分布式编程设计。尽管它与 Java 同龄,但在处理高并发和消息传递方面表现出色。课程将包括两次实验课和两次讲座,以帮助你掌握 Erlang 的核心概念和编程模式。
为什么选择 Erlang?📈
上一节我们介绍了课程概览,本节中我们来看看 Erlang 的一些关键特性,特别是它在并发和消息传递方面的卓越性能。
Erlang 非常擅长处理并发和分布式系统,尤其是在需要大量消息传递的场景中。为了展示其优势,我们来看一些数据。
以下是关于进程创建时间的图表。在 Erlang 中,进程类似于 Java 中的线程,但它们不共享数据,更像操作系统进程。图表显示了系统中并发实体(进程)的数量与每个进程创建所需时间(微秒)的关系。数据虽然来自2002年,但相对关系在今天仍然成立。
- Erlang(红色线):在创建多达2500个进程时,每个进程的创建时间不到1微秒。即使创建10,000个进程,时间也低于10微秒。
- Java(绿色线) 和 C#(蓝色线):进程创建时间显著更高,并且随着进程数量增加,开销增长更快。
这表明 Erlang 能够轻松创建大量并发代理,而无需担心性能开销。
接下来,我们看看消息发送时间。图表显示了进程间发送一条消息所需的时间。
- Erlang(红色线):消息发送时间大约为1微秒(2002年数据)。
- Java 和 C#:初始时间更高,并且随着进程数量增加,开销急剧上升。
这些数据表明,Erlang 在进程创建和通信方面进行了深度优化。它运行在一个名为 BEAM 的虚拟机上,虽然不像本地代码那样为矩阵或浮点运算优化,但专为进程和通信而设计。
Erlang 的实际应用案例 🌐
了解了 Erlang 的理论优势后,我们通过几个实际应用来看看它在现实世界中的表现。
第一个案例是一个名为 Yaws(Yet Another Web Server)的 Web 服务器(2002年数据)。图表展示了并发请求数(HTML请求)与系统总吞吐量(千字节/秒)的关系。
- Apache(使用本地磁盘和NFS):在约4000个并发进程时性能良好,但超过此负载后会崩溃。
- Erlang(Yaws):可以处理高达80,000个并发请求(是 Apache 的20倍),且系统不会崩溃。虽然随着进程增加,吞吐量会下降并变得波动,但系统仍能继续工作,只是响应变慢。
这体现了 Erlang 的关键特性:它能优雅地处理极高的并发峰值,性能会下降但不会崩溃,保证了系统的可用性。
第二个案例是爱立信的 AXD 301 ATM 交换机。这是一个用于大型工业网络的高性能异步传输模式交换机,用于创建端到端的保证性能电路。该系统非常庞大:
- 包含120万行 Erlang 代码。
- 90万行 C++ 代码。
- 1.3万行 Java 代码。
- 使用了包含24万行 Erlang 代码的 OTP 库。
图表展示了该系统的呼叫吞吐量性能。当尝试建立的呼叫(负载)超过系统容量时,系统需要处理大量被拒绝的呼叫,这会产生开销。Erlang 实现的系统在过载时,吞吐量会逐渐下降,但不会崩溃,每个成功建立的呼叫仍能正常工作。这种“优雅降级”的行为对于电话网络等关键系统至关重要。
爱立信的工程师报告称,使用 Erlang 后,生产力和质量提升了四倍。这充分展示了 Erlang 在构建高并发、高可靠性系统方面的优势。
深入剖析:Para 传感器融合框架 🧠
前面我们看到了 Erlang 在服务器和电信设备中的应用,现在让我们深入一个更具体的物联网(IoT)应用实例,以理解 Erlang 所擅长的软件类型。
Para 是一个运行在 IoT 环境下的传感器融合框架。IoT 设备数量正在指数级增长,预计到2025年将达到750亿台,远超数据中心每年5%的增长率。这些设备是并发的、分布式的,并且运行在可能不稳定的环境中(如断电、网络中断)。Erlang 是应对此类挑战的绝佳选择。
Para 系统运行在一种名为 GRiSP 的专用硬件板上,该板专为运行 Erlang 和 IoT 应用而设计。系统架构如下:
- 多个 GRiSP 板通过 WiFi 组成网络。
- 每个板都运行完整的 Para 框架,实现冗余。
- 传感器数据被广播到所有板。
- 每个板都进行传感器融合计算、存储数据并管理通信。
Para 的核心技术是卡尔曼滤波器。其工作原理可以直观理解为:
- 预测:根据物理模型(如人的运动)预测系统可能的状态(一个概率范围)。
- 测量:通过传感器获取实际数据(另一个概率范围)。
- 融合:将预测范围与测量范围相交,得到一个更精确的状态估计。
Para 系统充分利用了 Erlang 的特性:
- 异步与动态:传感器数据可能在任何时间到达,板或传感器可以随时加入或离开网络。
- 容错性:如果一块板崩溃,只要至少还有一块板在运行,传感器融合就能继续工作(性能可能下降)。这是通过 Erlang 的监督机制实现的。
- 实时性:在 GRiSP 板上,系统能够以约375赫兹的频率进行更新迭代。
这个复杂的“姿态与航向参考系统”由两名学生在四个月的硕士课题中实现,并最终形成了学术出版物。这证明了在 Erlang 和合适硬件的帮助下,可以快速构建出复杂、健壮的 IoT 应用。
Erlang 语言核心概念刷新 🔄
在进入 Erlang 更高级的特性之前,我们先来回顾和明确一些核心的语言概念。如果你学过之前的课程,会对其中一些概念感到熟悉。
Erlang 的核心是纯函数式的,具有词法作用域和高阶闭包。变量是单次赋值(不可变)。它具有模式匹配(在 case 和 if 中)以及用于接收消息的 receive 语句。Erlang 是一种符号化语言。
基本数据类型和语法:
- 原子(Atoms):符号常量,如
ok,error。 - 列表(Lists):
[1, 2, 3]。 - 元组(Tuples):
{ok, Result}。 - 字符串:是 ASCII 码列表。
- 二进制数据(Binaries):用于高效处理协议数据,是 Erlang 的强项。
Erlang 的语法源于 Prolog,因此有些独特。后来社区创建了 Elixir 语言,它提供了更接近 Ruby 的语法,但两者都运行在相同的 BEAM 虚拟机上,并且可以互操作。本课程将使用原始的 Erlang。
关于类型系统,Erlang 是强类型且动态类型的。这意味着类型在运行时由语言强制执行,但变量无需声明类型,可以绑定到任何类型的值。尽管动态类型语言常被认为不如静态类型语言健壮,但 Erlang 通过其独特的机制(如链接和监督),构建出了极其健壮和容错的系统,其实际表现甚至超过了许多静态类型语言。
一个 Erlang 程序由模块组成。模块类似于 Java 中的包。以下是一个简单的模块示例:
-module(geometry).
-export([area/1]).
area({square, Side}) -> Side * Side;
area({circle, Radius}) -> 3.14159 * Radius * Radius.
sum_areas(Shapes) ->
lists:sum(lists:map(fun(Shape) -> area(Shape) end, Shapes)).

进程与消息传递:Erlang 的并发模型 ⚙️
Erlang 在函数式核心之上,构建了基于进程和消息传递的并发模型,这是其强大能力的基石。
Erlang 进程非常轻量级(类似线程),可以创建数十万甚至数百万个。关键特性是进程间不共享数据。当一个进程向另一个进程发送消息时,数据总是被复制,而非共享指针。这意味着一个进程崩溃不会直接影响其他进程的数据。
创建进程:使用 spawn 函数。它接受一个函数(定义进程行为)并返回一个进程标识符(PID)。
Pid = spawn(fun() -> ... end).
发送消息:使用 ! 操作符。消息发送是异步的。
Pid ! {self(), some_message}.
接收消息:使用 receive 语句。每个进程都有一个邮箱存放收到的消息。receive 会查看邮箱中的消息,并提取第一个与给定模式匹配的消息。不匹配的消息会留在邮箱中。这不同于严格的流处理,提供了更大的灵活性。
receive
{From, Message} ->
io:format("Received ~p from ~p~n", [Message, From]);
Other ->
io:format("Unexpected: ~p~n", [Other])
after 5000 ->
io:format("Timeout after 5 seconds~n")
end.
receive 还支持超时机制(after 子句),增强了在真实世界系统中的实用性。
receive 的语义需要特别注意:它从邮箱中按顺序尝试匹配消息,但可以跳过不匹配的消息,取出后面匹配的消息。这使得一个进程可以轻松处理多种不同类型的消息。
超越基础:Erlang 的健壮性机制 🛡️
现在我们将进入 Erlang 独有的、用于构建真正健壮的多代理系统的核心概念。这些机制是 Erlang 哲学的具体体现。
进程注册:进程 PID 在进程崩溃重启后会改变。register/2 函数允许将一个进程与一个原子名称(如 web_server)关联。这样,即使底层进程重启,对外接口(名称)保持不变,提供了更稳定的抽象。
进程链接:这是容错的基础。link/1 在两个进程间建立双向连接。当其中一个进程终止时(无论是正常结束还是运行时错误),它会向所有链接的进程发送一个退出信号。
默认情况下,收到退出信号的进程也会终止(除非设置了陷阱退出标志)。这体现了 Erlang “让它崩溃”的哲学:如果协作组件中的一个失败,最好让整个相关组都快速失败,然后由外部实体来修复。
监督者:通过设置 trap_exit 标志,一个进程可以捕获链接进程的退出信号而不终止自身。这样的进程就成为监督者。它监视着工作者进程(P1, P2, P3),当它们崩溃时,监督者会收到通知并可以采取行动(如重启它们)。监督树就是这样构建的。
监控:这是链接的不对称版本。例如,在客户端-服务器模型中,如果服务器崩溃,所有客户端都无法工作,因此客户端需要链接到服务器。但如果一个客户端崩溃,服务器不应终止,因为它还有其他客户端。monitor 就提供了这种单向的监视关系。
分布式编程:Erlang 使分布式编程变得透明。进程可以位于不同计算机(节点)上。spawn 可以指定在远程节点创建进程,而消息传递、链接和监控在本地和远程进程间的工作方式完全相同,代码无需更改。这得益于“无共享”模型,消息传递总是涉及数据拷贝,自然适用于网络。
代码热更新:对于永不停机的关键系统(如丹麦的国家医疗数据库),软件更新不能导致服务中断。Erlang 支持代码热更新。其机制是:同一模块最多可以同时存在两个版本。新创建的进程使用新版本,而正在运行的进程继续使用旧版本,直到它们终止。程序员可以通过特定的递归调用方式(loop() 使用旧版本,?MODULE:loop() 使用新版本)来控制更新过程。这支持了数据库迁移等复杂操作。
Erlang 哲学与 OTP 行为 🧩
Erlang 的成功不仅源于其机制,更源于其独特的编程哲学,并且这些哲学通过 OTP 框架中的“行为”得到了完美封装。
Erlang 哲学:
- 错误不可避免:所有软件都有缺陷,硬件也会出错。
- 故障隔离:需要强隔离的故障单元。Erlang 进程不共享数据实现了这一点。
- 快速失败:一旦出错,立即崩溃,不要试图在错误状态下继续运行。
- 故障检测:通过链接机制来传播故障信息。
- 无共享状态:这是隔离的必然结果。
核心口号是 “让它崩溃”。不要进行防御性编程(到处添加检查),这会使代码复杂且无法覆盖所有错误。相反,当问题出现时,直接让进程崩溃。然后,由另一个专门负责监督和恢复的进程来修复问题。这就像生病了去找医生,而不是自己给自己动手术。
OTP 行为:OTP 是 Erlang 的标准库,它封装了常见的并发和容错模式,称为“行为”。专家编写了这些行为,隐藏了所有复杂性。普通程序员只需关注业务逻辑。
OTP 提供了五种标准行为:
- 通用服务器:用于构建客户端-服务器模式,处理注册、启动、停止、超时、状态管理、同步/异步调用等。
- 通用事件处理器:用于处理事件流,如日志记录。
- 通用有限状态机:用于将应用建模为状态机。
- 通用应用:用于将多个组件打包成一个可启动、停止、升级的单元。
- 监督者:用于构建监督树,实现容错。
使用这些行为,构建健壮的并发应用变得非常简单。
持久化、测试与总结 🏁
为了构建完整的健壮系统,仅有监督树还不够,还需要持久化存储和测试框架。
持久化存储:
- ETS:内存中的键值存储,用于在同一虚拟机内进程崩溃后快速恢复状态。
- DETS:基于磁盘的 ETS,速度较慢,但能在虚拟机重启后恢复数据。
- Mnesia:完整的分布式事务数据库,构建在 ETS/DETS 之上,功能强大但更复杂。通常 ETS 或 DETS 已足够。
测试:Erlang 同样重视测试。
- 单元测试:基础的代码测试。
- 通用测试:支持并行测试、竞态条件测试、依赖项测试和故障注入(模拟崩溃)。
- Dialyzer:一个强大的静态分析工具,即使对于动态类型的 Erlang 代码,也能进行类型推断和缺陷检测,非常有用。


总结:本节课我们一起深入探讨了 Erlang 语言和系统。我们了解了 Erlang 在处理高并发和消息传递方面的卓越性能,并通过 Web 服务器、ATM 交换机和物联网传感器融合框架等案例看到了其实际应用。我们回顾并深化了 Erlang 的核心语言概念,特别是其基于进程和消息传递的并发模型。最重要的是,我们学习了 Erlang 构建健壮系统的核心机制:进程链接、监督、代码热更新,以及其“让它崩溃”的哲学。这些机制通过 OTP 框架中的“行为”得到了完美封装,使得开发高并发、高可用的分布式系统变得更加简单。最后,我们还简要介绍了持久化存储和测试工具,它们是构建生产级系统不可或缺的部分。下节课我们将深入查看通用服务器和监督者的具体代码实现。
024:Erlang OTP 与工业级应用


在本节课中,我们将学习 Erlang 语言及其 OTP 平台的核心概念,特别是通用服务器和监控树。我们还将通过一个游戏公司的实际案例,了解这些概念如何应用于构建高并发、高可用的工业级系统。
通用服务器
上一节我们介绍了 Erlang 的“任其崩溃”哲学。本节中,我们来看看如何通过 OTP 中的“行为”来构建健壮的并发应用,首先从通用服务器开始。
通用服务器是一种设计模式,它封装了服务器进程的通用逻辑,如消息循环、状态管理和错误处理。程序员只需编写特定于应用的回调函数。
服务器基本结构
一个简单的 Erlang 服务器通常包含以下部分:
- 初始化:设置服务器的初始状态。
- 消息循环:一个递归函数,持续接收并处理客户端消息。
- 消息处理:根据接收到的消息类型执行特定操作,并更新状态。
- 终止:在服务器停止时进行清理工作。
以下是一个简化服务器的核心循环代码:
loop(State) ->
receive
{request, From, Data} ->
NewState = handle(Data, State),
From ! {reply, NewState},
loop(NewState);
stop ->
terminate(State)
end.
通用化与特定化
我们可以将服务器代码分为两部分:
- 通用部分:所有服务器共有的逻辑(如消息循环、进程管理)。这部分由 OTP 的
gen_server行为模块提供。 - 特定部分:与应用相关的逻辑(如初始化状态、处理请求的具体方式)。这部分由程序员编写的回调模块实现。
这种分离带来了巨大优势:代码复用、更少的错误、以及自动获得追踪、统计、日志和容错(通过监控树)等功能。
示例:频率分配服务器
为了具体说明,我们构建一个简单的频率分配服务器。该服务器管理一组频道,客户端(如手机)可以请求分配和释放频道。
服务器接口
服务器提供以下外部 API 函数:
start(FreqList):启动服务器,FreqList是初始可用频率列表。stop():停止服务器。allocate():请求分配一个频率。返回{ok, Freq}或{error, no_frequency}。deallocate(Freq):释放一个频率。
代码结构
使用 gen_server 时,我们需要编写一个回调模块(例如 frequency.erl)。该模块包含两部分接口:
- 外部接口:供客户端调用的函数。这些函数内部会调用
gen_server模块的call或cast等函数。 - 回调接口:由
gen_server调用的函数,如init/1,handle_call/3,handle_cast/2,terminate/2。
内部的状态管理(如从空闲列表移动频率到已分配列表)则在回调函数中实现。
架构图示
整个系统的架构涉及三个接口:
- 客户端接口:调用
frequency:allocate()等。 - 服务器接口:
gen_server:start,gen_server:call,gen_server:stop。 - 回调接口:
frequency:init,frequency:handle_call,frequency:terminate。
gen_server 通用模块作为中间层,协调客户端请求和特定的回调函数。
通用服务器的优势
现在我们已经了解了通用服务器的基本工作原理,本节中我们来看看它如何简化编程并解决一些常见并发问题。
1. 解决竞态条件
在普通的消息传递中,如果多个进程都可能向客户端发送格式相似的消息,客户端可能无法区分回复的来源。
gen_server 内部通过使用唯一引用 Ref 来解决这个问题。它在请求中嵌入一个唯一的 Ref,并只接收包含相同 Ref 的回复,从而确保了请求-回复的精确匹配。程序员无需关心此细节。
2. 支持同步与异步调用
gen_server 原生支持两种调用方式:
- 同步调用 (
gen_server:call):客户端发送请求并等待回复。例如allocate操作。 - 异步调用 (
gen_server:cast):客户端发送请求后立即继续,不等待回复。例如deallocate操作。
这种区分使得 API 设计更清晰,资源使用更高效。
监控树
通用服务器处理了单个进程的健壮性,但复杂的系统由许多进程组成。本节中,我们将学习 OTP 的另一个核心行为——监控树,它负责管理整个进程组的生命周期和容错。
监控原则
大多数系统错误是瞬时的(如临时网络拥堵、时序问题)。监控树的核心理念是:当子进程崩溃时,简单地重启它通常就能解决问题。
监控树是一个层级结构:
- 监控进程:观察一个或多个子进程(工作进程或其他监控进程)。
- 工作进程:执行实际应用逻辑的进程(如
gen_server)。 - 链接:监控进程通过链接来监视子进程的终止。
如果子进程异常终止,监控进程会根据预设策略决定如何重启。如果重启过于频繁,监控进程自身可能终止,将问题上报给更高级别的监控进程。
重启策略

OTP 提供了几种标准的重启策略:
- one_for_one:如果一个子进程终止,仅重启该进程。适用于子进程相互独立的场景。
- one_for_all:如果一个子进程终止,终止并重启所有子进程。适用于子进程紧密协作的场景。
- rest_for_one:如果一个子进程终止,终止并重启在该进程之后启动的所有子进程。适用于进程间有启动依赖的场景。
- simple_one_for_one:用于动态添加子进程的场景。所有子进程都是同一类型的动态实例。
一个简单的监控器实现
以下是一个极度简化的监控器循环的核心逻辑,它展示了监控的基本思想:
loop(ChildList) ->
receive
{'EXIT', Pid, normal} ->
% 正常退出,从列表中移除
NewList = lists:keydelete(Pid, 1, ChildList),
loop(NewList);
{'EXIT', Pid, _Reason} ->
% 异常退出,重启该子进程
{M, F, A} = find_child_spec(Pid, ChildList),
NewPid = spawn_link(M, F, A),
NewList = replace_pid(ChildList, Pid, NewPid),
loop(NewList);
stop ->
terminate_all(ChildList)
end.
实际的 supervisor 行为模块要复杂得多,并提供了配置策略、重启频率限制等丰富功能。
监控与稳定存储
为了实现一致性重启,进程状态需要保存在崩溃后仍能存留的地方。Erlang 提供了不同级别的稳定存储:
- ETS:内存中的快速键值存储,独立于创建它的进程而存在。通常链接到监控进程进行管理。
- DETS:基于磁盘的简单存储。
- Mnesia:分布式关系型数据库,功能强大但复杂。
监控树与稳定存储结合,确保了系统在崩溃后能恢复到一致状态。
Erlang 方法 vs. 防御式编程
让我们对比一下 Erlang 的“任其崩溃”哲学与传统的防御式编程。
研究表明,采用 Erlang/OTP 的方法可以显著减少代码量。例如,在 Motorola 的一个项目中,Erlang 实现比 C++ 实现少了 85% 的代码,并且只有 1% 的代码用于错误处理(C++ 中这一比例为 27%)。这是因为错误处理逻辑被抽象到了通用的监控树和服务器行为中,应用代码只需关注核心逻辑。
工业案例:Demonware 公司
理论需要实践检验。本节中,我们通过游戏中间件公司 Demonware 的案例,看看 Erlang 如何在实际的大型系统中发挥作用。
公司背景
Demonware 为大型多人在线游戏(如《使命召唤》系列)提供后台服务,包括匹配、排行榜、社交功能和反作弊等。在 2011 年,其系统已能支持 200 万并发用户和每秒 5 万次请求。
转向 Erlang
公司最初使用 C++ 和 Python 构建系统,但遇到了并发处理困难、错误处理复杂、维护性差以及无法在线更新配置等问题。
2007 年,一位开发者用 Erlang/OTP 重写了核心系统。短短几周内完成原型,四个月后投入生产。新系统展现了巨大优势:几乎不崩溃、支持动态配置更新、拥有强大的管理工具,且代码量大幅减少。
Erlang 在 Demonware 的应用架构
- 核心服务器:用 Erlang 编写,管理数十万 TCP 连接,调度任务,处理度量统计。
- 监督树:使用
simple_one_for_one策略管理海量用户连接进程。 - 胶水语言:Erlang 作为“胶水”,协调和组织用 Python、C++ 编写的业务逻辑模块。
- 状态存储:使用 ETS 实现高速计数器和高频访问数据;使用 Mnesia 实现分布式、高可用的玩家状态(Presence)服务器和消息系统。
- 运维与监控:利用 Erlang 的远程 Shell、YaWS 网页服务器和 SNMP 支持,构建了实时监控仪表盘和远程管理工具。
经验与教训
Demonware 团队分享了他们的宝贵经验:
关于 Erlang 编程:
- 拥抱 OTP:尽可能使用
gen_server、supervisor等行为,避免直接使用send、receive、spawn等底层原语。 - 保持模块简单:模块应是顺序的、无副作用的。并发性由行为模块管理。
- KISS 原则:保持简单。避免进程间不必要的依赖,多使用异步的
cast。
关于系统设计:
- 消除瓶颈:如果某个进程成为瓶颈,将其改造成进程池,或将工作分发出去。
- 惰性消费:让工作进程主动拉取任务,而不是被动接收推送,这样可以更好地控制负载。
- 善用 ETS 和 Mnesia:了解两者的优劣。ETS 极快且简单,适合缓存和计数器;Mnesia 功能全面但复杂,适合需要分布式事务和复杂查询的场景。
- 重视测试:进行自动化测试、故障注入测试(随机杀死进程)和重型端到端负载测试。
关于团队协作:
- 保持友好:Erlang 通常是多语言架构中的“胶水”,要与其他语言(如 Python、C++)的开发者良好协作,不要强求他人理解 Erlang。
Erlang 的优缺点
他们喜爱的方面:
- 轻松实现并发。
- 为复杂并发问题提供完整解决方案。
- 开源、透明。
- 系统极其健壮可靠,可长时间运行。
他们认为的不足:
- 网络分区处理、最终一致性等高级分布式特性需要自行实现或整合。
- 与其它语言的互操作可以更简单。
- 他们希望有更强大的静态类型检查(尽管 Dialyzer 已很好)和更灵活的监控策略。
总结
本节课中我们一起学习了 Erlang/OTP 的核心概念。我们深入探讨了 通用服务器 如何通过分离通用和特定逻辑来简化并发编程,并提供了容错基础。接着,我们了解了 监控树 如何通过层级监督和不同的重启策略来构建“任其崩溃”但能自我恢复的健壮系统。最后,我们通过 Demonware 公司的真实案例,看到了这些概念如何被用于支撑拥有数百万并发用户的工业级系统,并总结了其中的实践经验和教训。

Erlang/OTP 提供了一套完整的理念和工具集,用于构建高并发、分布式且高可用的系统。虽然它有其特定的适用领域和学习曲线,但在需要极高可靠性和并发能力的场景下,它已被证明是一种极其强大的解决方案。
025:共享状态并发


概述
在本节课中,我们将要学习并发编程的另一种形式——共享状态并发。这是我们将要探讨的最后一种并发范式,虽然它被认为是实现起来最复杂的一种,但在工业界仍被广泛使用。我们将了解其基本概念、面临的挑战以及如何通过构建抽象(如锁、事务和监视器)来管理其复杂性。
共享状态并发简介
上一节我们介绍了多种并发编程范式。本节中,我们来看看共享状态并发。
共享状态并发是一种编程范式,它在线程和可变状态(我们称之为“单元”)的同一程序中共存。这种范式被许多主流语言(如 Java、C++)广泛使用,尽管它被认为是实现正确并发程序最困难的方式。
其核心挑战在于,当多个线程共享一个可变单元时,由于调度器对操作执行顺序(交错执行)的不同选择,会产生指数级数量的可能执行路径。程序必须保证在所有可能的交错执行下都是正确的。
为了管理这种复杂性,我们需要构建“大原子操作”,将多个小操作组合成一个不可分割的整体执行单元。锁、事务和监视器都是构建此类原子操作的抽象工具。
核心概念:单元与交换操作
在深入共享状态并发之前,我们需要回顾其基础构建块:单元 和关键的 交换 操作。
- 单元:一个可以多次赋值的命名容器。它类似于传统语言中的“变量”,但为了清晰区分数学中的常量变量,我们称之为“单元”。一个单元包含一个名称和一个内容。
- 操作:
- 创建:
{NewCell C X}创建一个初始内容为X的新单元C。 - 赋值:
{Assign C X}将单元C的内容替换为X。 - 访问:
{Access C X}将单元C的当前内容绑定到X。 - 交换:
{Exchange C X Y}这是一个原子操作。它读取单元C的当前内容并绑定到X,同时将C的新内容设置为Y。读和写作为一个不可分割的步骤完成。
- 创建:
公式:
交换操作可以形式化地理解为:Exchange(C, Old, New) ≡ (Old := Access(C); Assign(C, New)),且整个过程是原子的。
交换操作至关重要,以至于现代处理器架构都直接提供了类似(如“测试并设置”、“比较并交换”)的硬件指令支持,这大大简化了并发原语的实现。
为何共享状态并发困难?
上一节我们定义了基础操作。本节中我们来看看为何组合使用线程和单元会如此困难。
考虑一个简单场景:两个线程(T1 和 T2)共享一个单元 C,每个线程对该单元执行 N 次读写操作。调度器可以任意交错执行这两个线程的共 2N 个操作。
可能的交错执行序列数量是一个组合数问题:从 2N 个位置中选择 N 个位置给 T1 的操作(剩下的给 T2)。这个数量近似于 2^(2N) / sqrt(πN)。
核心问题:可能的交错执行序列数量是操作数量的指数函数。程序必须保证在所有这些指数级数量的执行路径下都表现正确,只要有一条路径存在错误,整个程序就是错误的。通过测试或模型检查来穷尽所有可能性是不现实的。
因此,编写正确的共享状态并发程序的唯一方法是通过设计保证正确性。我们需要管理这些交错执行。
管理复杂性:构建大原子操作
既然我们理解了问题的根源,那么如何管理这种复杂性呢?关键思路是减少需要担心的交错执行数量。
以下是不同范式管理交错执行的方式:
- 确定性数据流:最佳方式。所有交错执行产生相同结果,因此我们无需关心顺序。
- 消息传递/多智能体:每个智能体(如端口对象)内部只有一个线程,消息只在特定点被接收和处理,大大减少了内部状态的交错。
- 共享状态并发:我们通过将多个小操作分组为大的原子操作来减少交错。调度器现在只能在原子操作的边界进行调度,而不是在单个读写操作之间。
锁、事务和监视器都是构建这种大原子操作的机制。它们都基于一个更基础的概念:锁。
基础抽象:锁
锁是构建大原子操作最基本的概念。它保护一段代码区域(称为临界区),确保同一时间最多只有一个线程可以执行该区域内的代码。

锁的抽象接口
在支持锁的语言中,通常提供如下抽象:
{NewLock L}:创建一个新锁L。{IsLock L}:检查L是否为锁。lock L then <statement> end:用锁L保护<statement>。如果锁已被其他线程持有,当前线程将被阻塞(挂起),直到锁被释放。
一个锁可以保护程序中多个不连续的代码段。所有被同一锁保护的代码段共同构成一个逻辑上的大原子操作。
可重入锁
一个重要的概念是可重入锁。如果一个线程已经持有了某个锁,当它再次尝试获取同一个锁时,会被允许立即进入,而不会被阻塞。这对于实现递归调用或复杂抽象内部的锁操作至关重要。不可重入的锁(简单锁)在这种情况下会导致死锁。
示例:并发队列
让我们看一个使用锁实现并发队列的例子。队列内部使用差异列表和单元来存储状态。
代码:
fun {NewQueue}
C = {NewCell q(0 X X)} % q(数量, 头部差异列表F, 尾部B)
L = {NewLock}
proc {Insert X}
{lock L then
Old
in
{Exchange C Old q(N F B)}
B = X|B2 % 将X附加到尾部
{Assign C q(N+1 F B2)}
end}
end
proc {Delete X}
{lock L then
Old
in
{Exchange C Old q(N F B)}
X|F2 = F % 从头部取出X
{Assign C q(N-1 F2 B)}
end}
end
in
queue(insert:Insert delete:Delete)
end
在这个实现中,Insert 和 Delete 操作各自被锁 L 保护,使得每个操作内部的读单元和更新单元步骤成为原子操作。多个线程可以安全地调用这些操作。
锁的实现
了解了锁的用法后,本节我们深入其内部,看看如何利用交换操作来实现锁。
简单锁(不可重入)的实现
简单锁的核心思想是令牌传递。锁维护一个代表“令牌”的单元。持有令牌的线程才被允许进入临界区。
代码:
fun {SimpleNewLock}
Token = {NewCell unit}
proc {Lock P}
Old New
in
{Exchange Token Old New} % 尝试获取令牌
{Wait Old} % 等待令牌可用(Old被绑定)
try {P} finally New=unit end % 执行操作,最后释放令牌
end
in
Lock
end
工作原理:
- 第一个线程执行
Exchange时,Old被绑定为初始值unit,因此{Wait Old}立即通过,该线程进入临界区。New是一个新的未绑定变量。 - 第二个线程执行
Exchange时,它得到的Old正是第一个线程的New(此时未绑定),因此{Wait Old}会阻塞。 - 当第一个线程执行完毕,执行
New=unit,这恰好绑定了第二个线程正在等待的Old变量,从而唤醒第二个线程。 - 如此传递,形成了一个隐式的等待队列。
处理异常
上述简单锁在受保护代码 P 抛出异常时,可能无法执行 New=unit,导致令牌无法传递。修复方法是在 try...finally 块中执行 P,确保无论是否发生异常,最后都会释放令牌。
可重入锁的实现
为了实现可重入,锁需要记录当前持有它的线程标识。当线程尝试获取锁时,先检查当前持有者是否是自己。
代码:
fun {NewReentrantLock}
Token = {NewCell unit}
CurrentThread = {NewCell unit} % 记录当前持有线程
proc {Lock P}
if {Access CurrentThread} == {Thread.this} then
{P} % 如果已经是持有者,直接执行
else
Old New
in
{Exchange Token Old New}
{Wait Old}
{Assign CurrentThread {Thread.this}} % 设置持有者
try {P} finally
{Assign CurrentThread unit} % 清除持有者
New=unit % 传递令牌
end
end
end
in
Lock
end
关键点:检查当前线程和设置/清除 CurrentThread 单元的顺序至关重要,错误的顺序会导致竞态条件和死锁。这凸显了并发编程中精确顺序的敏感性。
总结
本节课中我们一起学习了共享状态并发这一复杂但广泛使用的范式。
我们首先理解了其核心挑战:线程与可变单元的组合导致了执行路径的指数级爆炸。为了管理这一复杂性,我们引入了大原子操作的概念。
接着,我们学习了最基础的原子操作抽象——锁。锁通过确保临界区代码的互斥执行来创建原子性。我们探讨了锁的接口、可重入性的重要性,并看了一个使用锁实现并发队列的实例。
最后,我们深入探讨了锁的实现原理,从基于令牌传递的简单锁开始,逐步增加了异常处理机制,最终实现了完整的可重入锁。这个过程展示了如何利用原子性的交换操作和单次赋值变量来构建更高级的并发控制抽象。

锁是构建更复杂抽象(如监视器和事务)的基础。在接下来的课程中,我们将以此为基础,继续探索共享状态并发的其他强大工具。
026:共享状态(续)与监视器


概述
在本节课中,我们将继续探讨共享状态并发模型。我们将学习两个核心概念:元组空间和监视器。元组空间是一个介于共享状态和消息传递之间的有趣模型,而监视器则是对锁的一种扩展,用于解决更复杂的同步问题。
元组空间:介于共享状态与消息传递之间
上一节我们介绍了共享状态的基本概念,本节中我们来看看一种独特的协调模型:元组空间。
元组空间是一个元组的多重集合。你可以将元组放入其中,也可以从中移除元组。它是一个多重集合,因此相同的元组可以多次放入,类似于物理令牌。
元组空间支持三种基本操作:
write(T):将元组T添加到元组空间。read(L T):等待直到元组空间中存在一个标签为L的元组,然后将其移除并绑定到变量T。这是一个会阻塞的并发操作。readNB(L T B):非阻塞读取。如果存在标签为L的元组,则将其绑定到T并将布尔值B设为true;否则将B设为false。
元组空间的概念由 David Gelernter 在1985年提出,他称之为“Linda”。它使用模式匹配来读取特定类型的元组。
与共享状态和消息传递的比较
元组空间是一个有趣的概念,因为它介于共享状态和消息传递之间。
- 在共享状态中,多个线程访问同一个存储单元。
- 在消息传递中,一个线程向一个端口对象(另一个线程)发送消息。
- 在元组空间中,它像共享状态一样被多个线程共享(
read/write),但也像消息传递,因为元组被发送(write)和接收(read)。
一个关键区别在于:在常规消息传递中,发送者知道接收者(即目标端口)。而在元组空间中,发送者(执行 write 的线程)不知道是哪个线程会接收(执行 read)这个元组。接收者对于发送者是匿名的。
我们可以根据发送者和接收者是否彼此知晓来对并发范式进行分类:
| 发送者知晓接收者? | 接收者知晓发送者? | 并发范式 |
|---|---|---|
| 是 | 是 | 确定性数据流 (如管道) |
| 是 | 否 | 常规消息传递 (如端口对象) |
| 否 | 否 | 元组空间 |
这三种范式都有其用途。元组空间在现代“发布-订阅”系统中被广泛使用。
使用元组空间实现并发队列
我们可以使用元组空间轻松实现一个并发队列,而无需使用锁。
以下是实现思路:
- 队列的状态(例如,元素计数和内容)被存储为一个元组,放在元组空间中。
insert操作:从元组空间中read出代表当前队列的元组(这会将其移除,防止其他线程同时操作),计算新的队列状态,然后write回元组空间。delete操作:过程类似,read出元组,取出头部元素,计算新状态,再write回去。
因为 read 操作会移除元组,所以它就像进入了临界区,自然实现了互斥,无需额外的锁。
元组空间的实现
元组空间内部通常使用以下数据结构实现:
- 一个字典(哈希表):键是元组标签(如
foo,bar),值是对应的队列。 - 多个队列:每个标签对应一个队列,用于存储所有具有该标签的元组。
- 一个锁:用于保护字典和队列的内部操作。
当执行 write(L, ...) 时,系统会根据标签 L 找到对应的队列,并将元组插入队尾。当执行 read(L, ...) 时,系统会找到标签 L 对应的队列,并从队头移除一个元组返回;如果队列为空,则调用线程被阻塞并放入等待集。

监视器:扩展的锁
上一节我们介绍了元组空间,本节我们来看看另一个扩展锁的概念:监视器。
锁对于实现像无界队列这样的数据结构是足够的。但是,对于有界缓冲区这类结构,锁就不够了。因为有界缓冲区的 put 操作在缓冲区满时需要等待,get 操作在缓冲区空时也需要等待。而基本的锁只提供互斥,不具备让线程在条件不满足时等待的能力。
监视器(也称为同步对象)就是为了解决这个问题而发明的。一个监视器包含:
- 一个锁:用于保证互斥。
- 一个等待集:用于存放因条件不满足而等待的线程。
wait和notify操作:用于线程间协调。
监视器的语义(以Java为例)
wait():- 挂起当前线程,停止执行。
- 将该线程放入监视器的等待集中。
- 释放锁,以便其他线程可以进入监视器。
notify():- 如果等待集非空,则从中任意移除一个线程。
- 恢复该线程的执行。被恢复的线程会尝试重新获取锁(它会在当初调用
wait()的地方继续执行)。
notifyAll():- 移除并恢复等待集中的所有线程。所有被恢复的线程都会尝试重新获取锁。
在实际编程中,notify() 很少使用,因为可能唤醒“错误”的线程(例如,需要唤醒一个 put 线程却唤醒了一个 get 线程)。因此,notifyAll() 是更常用的选择。
使用监视器实现有界缓冲区
有界缓冲区是一个固定大小的队列,put 和 get 操作可能会阻塞。
以下是使用监视器的正确实现模式:
class Buffer
attr ... % 属性:数组、头指针、尾指针、当前大小等
meth init(N) ... end
meth put(X)
M.lock(proc {$}
if @I >= @N then {M.wait} {self put(X)} % 条件不满足,等待并重试
else
% 执行实际的put操作
@Buff.@Last = X
@Last := (@Last + 1) mod @N
@I := @I + 1
{M.notifyAll} % 操作完成后通知所有等待线程
end
end)
end
meth get(X)
M.lock(proc {$}
if @I == 0 then {M.wait} {self get(X)} % 条件不满足,等待并重试
else
% 执行实际的get操作
X = @Buff.@First
@First := (@First + 1) mod @N
@I := @I - 1
{M.notifyAll} % 操作完成后通知所有等待线程
end
end)
end
end
关键模式:在调用 wait() 后,当线程被唤醒时,必须重新检查条件(这里通过递归调用 {self put(X)} 或 {self get(X)} 实现)。这是因为可能有多个线程被同时唤醒(例如,多个 put 线程在缓冲区满时等待,一个 get 操作后调用了 notifyAll),但只有一个线程能成功执行操作(缓冲区只空出一个位置)。其他线程在重新检查条件时会发现条件仍不满足,从而再次调用 wait()。
一个常见的错误是使用 if 而不是 while(或等价的递归)来检查条件,这会导致多个被唤醒的线程在没有重新检查条件的情况下都去执行操作,从而破坏数据结构的不变性。
监视器的实现
监视器可以在可重入锁的基础上实现。主要扩展是增加了等待集(通常用一个队列实现)以及 wait、notify、notifyAll 操作。
wait的实现:- 创建一个新的单次赋值变量
X。 - 将这个未绑定的变量
X放入等待集队列。 - 释放监视器的锁。
- 对变量
X执行wait操作(线程在此挂起)。 - 当
X被绑定时(由notify触发),线程恢复,并立即尝试重新获取锁。
- 创建一个新的单次赋值变量
notify的实现:- 从等待集队列中取出一个未绑定的变量
X。 - 将
X绑定为一个值(例如unit)。这会唤醒正在等待该变量的线程。
- 从等待集队列中取出一个未绑定的变量
notifyAll的实现:- 取出等待集队列中的所有未绑定变量。
- 将所有变量绑定。这会唤醒所有等待的线程。
总结
本节课中我们一起学习了两个重要的高级并发概念:
- 元组空间:一种协调模型,作为共享状态和消息传递的桥梁。它通过一个共享的元组多重集合来协调线程,发送者和接收者彼此匿名。我们了解了其操作、与其它范式的比较,以及如何使用它实现无锁的并发队列。
- 监视器:对锁的扩展,引入了条件等待机制。它包含锁、等待集以及
wait/notify操作。我们重点学习了使用监视器实现有界缓冲区的正确编程模式,即必须在循环中重新检查条件。虽然监视器被广泛使用,但其编程模型容易出错。

下一节课,我们将学习另一种扩展锁的机制:事务,它通过引入“提交/中止”语义来解决更复杂的并发问题。
027:事务处理 🧾


在本节课中,我们将要学习数据库系统中的核心概念——事务。事务是确保数据一致性、可靠性和并发访问的关键机制。我们将从基本概念入手,逐步深入到其实现原理,包括ACID属性、并发控制、安全性与活性,以及一个具体的事务管理算法。
概述
事务是数据库中的一个核心概念,它使得互联网商务等应用得以可靠运行。事务用于管理大型数据库,追踪大量信息,例如公司、银行账户、亚马逊购书记录、股票信息和会计信息等。这些信息至关重要,因此数据库系统必须具备三个关键属性:弹性、高性能和可扩展性。事务的设计目标正是同时满足这三个属性。
8.5 事务的基本概念
上一节我们介绍了事务的重要性,本节中我们来看看事务需要满足的三个核心数据库属性。
首先,数据库必须具有弹性。这意味着信息绝不能丢失或损坏,因为这些信息对使用它的公司或个人至关重要。
其次,数据库需要高性能。系统可能拥有数百万客户,他们都希望执行操作,速度越慢体验越差,因此性能必须很高。
最后,数据库需要可扩展性。这意味着随着用户数量的增加,公司规模的增长,系统应能保持弹性和高性能。
事务的目的就是同时实现这三个属性。
为了解释事务,我们来看一个简单的例子。假设有一个数据库,它是一个单元格数组。客户端(例如银行账户)想要执行操作,比如将资金从单元格C1转移到C2。在简单实现中,客户端会请求一个保护整个数组的锁,获得锁后更新C1和C2,然后释放锁。这种方法虽然可行,但性能很低,因为整个数组只有一个锁,同一时间只能有一个客户端进行修改,这在高并发场景下会成为瓶颈。
更智能的实现是为每个单元格配备一个锁。这样,客户端只需锁定它实际使用的单元格,其他客户端可以同时修改其他单元格,从而大幅提高性能。然而,这带来了新的复杂性:冲突更新。
考虑一个例子:单元格C1和C3都向C2转账。如果两个事务T1和T2并发执行,且都涉及C2,调度器的不当调度可能导致C2收到的资金“消失”,从而使数据库状态不正确。这表明,当事务有共同的操作对象时,必须非常小心地协调它们的工作。
事务的目标与挑战
上一节我们看到了并发事务可能引发的问题,本节我们来明确事务处理的具体目标和面临的挑战。
我们希望通过一个更新操作(例如将资金从一个账户转移到另一个账户)来修改多个数据项。这个更新是一个大型原子操作,意味着它要么全部完成,要么完全不发生。事务执行过程中,其他事务不能看到其中间状态,因为数据库只有在更新完全完成后才是一致的。
此外,系统可能在任何时候崩溃。由于数据库存储在永久性磁盘上,即使断电数据也应存在。我们必须确保磁盘上的数据永远不会损坏。这似乎很困难,因为磁盘可能在多个更改过程中崩溃。
技巧如下:进行事务更新时,先将所有更改写入磁盘的一个特殊区域,而不是直接更新目标区域。当整个更新完成并安全存储在特殊区域后,再向磁盘写入一个字,将内容从旧区域切换到新区域。这要求磁盘硬件保证单个字的写入是原子的(要么完全写入,要么完全没写)。利用这个特性,可以使整个更新具有原子性。如果在此过程中发生崩溃,重启后可以恢复旧值,忽略可能损坏的新区域数据。这样就实现了提交或中止的能力,确保了弹性属性。
当然,系统还必须快速。为了提高速度,许多事务更新可以先在快速的RAM内存中进行,只在某些时候才写入磁盘。这意味着如果系统崩溃,RAM中的数据会丢失。但由于崩溃很少发生,为了在绝大多数无崩溃情况下获得最佳性能,这是可以接受的代价。
ACID 属性
上一节我们讨论了事务实现的基本技术,本节我们来了解描述事务特性的著名缩写——ACID属性。
ACID是一个缩写,代表了事务的四个重要属性:
- A - 原子性:事务中的所有操作要么全部完成,要么全部不发生。不会看到中间状态,崩溃会导致中止,不会看到任何更改,也不会损坏数据。
- C - 一致性:数据库始终保持某些不变量。例如,在银行系统中,资金总额必须始终保持不变(银行不能凭空创造或销毁资金)。一致性实际上是程序员的责任——他们必须编写正确的事务代码。而系统的实现则必须确保不破坏程序员设定的正确性。
- I - 隔离性:涉及多个并发事务。隔离性意味着,即使有多个事务并发执行,其结果也如同它们按某种顺序依次执行一样。这也称为可串行化。实现上为了性能可以并行执行,但最终数据库的更新效果看起来像是事务按某个顺序发生。
- D - 持久性:信息是永久性的。它存储在稳定的存储介质上,即使断电或系统崩溃也能存活。这也常被称为持久化。
事务需要维护所有这些属性。我们之前已经解释了如何实现持久性(通过原子写切换)。本讲其余部分将主要关注原子性、一致性和隔离性。
值得注意的是,并非所有事务都需要持久性。例如,软件事务内存 可以在程序中使用事务来处理软件错误,作为一种可中止的原子操作,类似于锁但增加了中止能力。这被称为轻量级事务,它没有磁盘持久性,但可以在可能发生中止的软件场景中替代锁,提高软件可靠性。
并发控制简介
上一节我们介绍了事务的抽象属性,本节我们开始探讨如何实际实现事务,这个领域被称为并发控制。
实现事务系统(即并发控制)的技术多种多样。今天我们将介绍一种特定的算法,可以概括为:采用严格两阶段锁定的乐观并发控制与死锁避免。
这个算法是书中介绍的最复杂的算法之一,用两页代码实现,并采用了多智能体风格的主动对象。它涉及几个重要的设计维度:
- 乐观 vs. 悲观:这取决于事务管理器如何授予锁。乐观策略更自由地授予锁,即使可能增加后续中止的几率;悲观策略则非常谨慎,尽量避免中止。选择取决于失败的成本。
- 锁管理(可串行化):确保并发执行的事务效果等价于某种串行顺序。我们之前看到的转账和计算总额的例子,就是因为锁管理不当导致了不可串行化。
- 死锁管理:当多个事务循环等待彼此持有的资源时,系统会陷入永久阻塞,即死锁。必须通过预防或检测并解决来处理。
在深入算法细节前,我们需要理解两个贯穿系统设计的基础概念:安全性和活性。
安全性与活性
在构建任何软件系统,特别是事务系统时,我们需要关注两类属性:安全性和活性。
- 安全性:意味着“坏事永远不会发生”。例如,开车时不发生事故。
- 活性:意味着“好事最终会发生”。例如,开车最终到达目的地。
一个执行过程可以建模为一个无限的执行状态序列。每个状态包含指令(如多个线程的栈)和内存数据。一个属性P是关于整个执行过程的布尔函数。
任何属性P都可以表示为某个安全性属性和某个活性属性的合取。这是一个深刻的数学定理。
安全性的形式化定义:一个属性P是安全性的,如果对于任何违反P的执行E(即P(E)为假),都存在E的一个有限前缀,使得任何从该前缀开始的扩展执行也都违反P。换句话说,一旦系统在某个点“变坏”,之后就再也无法修复。就像生鸡蛋掉在地上摔碎了,无论之后做什么,鸡蛋始终是碎的。
活性的形式化定义:一个属性P是活性的,如果对于任何满足P的执行E(即P(E)为真),对于E的任何一个有限前缀,都存在从该前缀开始的某个扩展执行满足P。这意味着,即使当前执行尚未使P成立,也总是有可能通过后续操作使P成立。只要执行还在继续,就总有希望。就像开车迷路进了山区,你总是可以调头驶向海滩。
示例:
- 安全性属性:“消息最多被传递一次”。如果一条消息被传递了两次,属性就被永久破坏,无法修复。
- 活性属性:“消息至少被传递一次”。如果在前缀中消息还未被传递,你总是可以通过安排一次传递来使属性成立。
安全性通常关注“禁止”类条件(如“至多”、“不能”),而活性通常关注“最终”类条件(如“至少”、“必须”、“最终”)。安全性只能在执行完成后最终判定是否被违反,而活性可以在有限时间内被满足。
事务系统必须同时保证安全性(如数据一致性)和活性(如事务最终能提交)。
事务系统架构与锁管理
现在回到事务实现。我们将定义一个事务系统架构。

系统由多个事务(T1...TN)和一个事务管理器组成。每个事务是一个在独立线程中运行的代码段,事务管理器也是一个代码段,它们都类似于主动对象。
工作原理如下:当事务需要更新一个单元格时,它必须先锁定该单元格。它向事务管理器发送锁定请求。事务管理器可以回复:
- 是:授予锁。
- 否:不授予锁,这通常导致事务中止。
- 等待:不立即回复,让事务等待。
当事务完成并不再需要锁时,它通知事务管理器释放锁。
这种架构下,并发控制的三个核心方面(乐观/悲观、锁管理、死锁)都需要在事务管理器中实现,以确保安全性和活性,并会用到锁和时间戳。
乐观 vs. 悲观策略
这取决于失败的成本。
- 乐观策略示例:航空公司订票。航空公司经常超售机票(乐观地授予“座位锁”),因为航班空座的损失很大。如果所有乘客都到场,有些乘客就无法登机(事务中止),但航空公司会提供补偿。失败成本(乘客不满、补偿)相对较低,而提高上座率的收益很高。
- 悲观策略示例:铁路轨道调度。一列火车需要预定一段轨道。绝不能有两列火车同时使用同一段轨道(死锁意味着撞车)。失败成本极高(人员伤亡)。因此调度非常谨慎(悲观),火车经常在信号前等待,即使轨道可能实际上是空闲的。
锁管理与可串行化:两阶段锁定
我们之前看到了一个“天真”的锁管理器:只要单元格未被锁定,就立即授予锁。但这可能导致不可串行化。
问题示例:事务T1从账户C1转账到C2。事务T2计算C1和C2的总余额。如果T1先锁定并读取C1,然后释放C1的锁,再锁定C2进行写入。与此同时,T2可能锁定C1和C2并读取它们。这时T2读取到的C1是转账后的值,而C2是转账前的值,导致总额不一致。这两个事务的执行无法等价于任何串行顺序。
解决方案:两阶段锁定。规则是:事务在增长阶段获取所有需要的锁,期间不能释放任何锁;在收缩阶段释放锁,期间不能再获取新锁。这样,事务在中间阶段持有所有需要的锁,从而保证了可串行化。
严格两阶段锁定
两阶段锁定虽然保证了可串行化,但可能存在级联中止问题。
场景:事务T1释放了锁L1但还持有锁L2,事务T2获得了L1并开始执行(依赖于T1对L1的修改),事务T3又依赖于T2。如果此时T1中止,那么T2和T3也必须中止,因为它们的输入依赖于一个已回滚的事务。这称为级联中止,实现起来很复杂。
为了避免级联中止,几乎所有的商业数据库都采用严格两阶段锁定:事务在提交或中止时,一次性释放所有锁,而不是逐个释放。这样,在事务释放锁之前,其他事务无法看到它的任何修改,从而消除了级联中止的可能性。代价是锁持有的时间可能稍长,但简化了实现。
死锁问题与解决
即使采用严格两阶段锁定,还有一个关键问题:死锁。
死锁示例:事务T1需要先锁C1,再锁C2。事务T2需要先锁C2,再锁C1。如果并发执行,T1锁了C1,T2锁了C2,然后T1等待C2,T2等待C1,两者互相等待,形成循环依赖,系统阻塞。
死锁是一种普遍现象,发生在任何包含主动实体(需要资源)和资源(一次只能被一个实体持有)的系统中。例如,十字路口四辆车各占一方,互不相让,形成交通死锁。
在事务系统中,可以用等待图来检测死锁:节点是事务和资源(单元格),边表示“事务等待资源”或“资源被事务持有”。如果图中存在环,则发生死锁。
解决死锁有两种主要方法:
- 死锁预防:通过制定策略(如规定资源请求顺序)来防止环的形成。
- 死锁检测与恢复:允许死锁发生,但定期检测等待图中的环,一旦发现,就强制中止环中的一个或多个事务。
带死锁避免的乐观算法
我们之前提出了一个“天真算法”:采用严格两阶段锁的乐观并发控制。但它存在死锁问题。现在我们来修改它以避免死锁。
核心思想是引入优先级(基于时间戳)来打破对称性。较早开始的事务拥有较高优先级。
算法规则:
- 事务请求一个未锁定的单元格时,立即获得锁。
- 如果单元格已被一个优先级更高的事务锁定,则当前事务等待。
- 如果单元格已被一个优先级更低的事务锁定,则事务管理器重启那个低优先级事务(强制其中止、释放所有锁、并以相同优先级重新开始)。然后,高优先级事务获得该锁。
这样,最高优先级的事务永远不会被阻塞。它要么立即获得锁,要么通过重启低优先级事务来获得锁。最终,每个事务都会在某个时刻成为最高优先级并得以完成。这通过归纳法保证了不会发生死锁。
状态图:事务有运行、等待、重启等状态。重启意味着事务以一个新的“化身”重新开始。
优化:与其立即重启低优先级事务,不如将其标记为“察看期”。被标记的事务可以继续运行,但如果它再次尝试获取任何锁,则会被重启。这样,事务可以在一个定义良好的点(即它下次请求锁时)被终止,而不是在代码任意位置被强行中止,效率更高。
算法实现与示例
上述最终的算法是:采用严格两阶段锁定的乐观并发控制与死锁避免。它在书中以两页代码实现,是本书最复杂的算法。
实现基于主动对象。定义了一个抽象接口来创建事务管理器、事务和可中止单元格。
以下是关键操作:
NewTrans:创建新的事务管理器。Trans:执行一个事务代码块。NewCell:在事务中创建一个新的可中止单元格。T.abort:在事务内部中止事务。
事务管理器内部维护:
- 事务记录:包含时间戳(优先级)、保存的状态字典、事务体代码、事务状态(运行/等待/察看期)。
- 单元格记录:包含名称、当前持有锁的事务、一个按优先级排序的等待事务队列、当前值。
- 优先级队列:用于管理等待锁的事务,确保高优先级者优先。
事务与事务管理器之间通过消息交互:GetLock, SaveState, Commit, Abort。
代码示例:演示了创建包含1000个单元格的数据库,一个用于混合数值但保持总和不变的事务,以及一个计算总和的事务。即使并发运行1000个混合事务,总和事务仍能正确计算出不变的总和,展示了事务的隔离性和一致性。
总结
本节课中我们一起学习了事务处理的核心内容:
- 事务是大型原子操作,具有ACID属性(原子性、一致性、隔离性、持久性),用于大型数据库系统,需满足弹性、高性能和可扩展性。
- 我们探讨了安全性(坏事永不发生)和活性(好事终将发生)这两个系统基本属性。
- 实现事务的技术领域称为并发控制,涉及乐观/悲观策略、锁管理(确保可串行化)和死锁处理。
- 两阶段锁定是保证可串行化的常用技术,而严格两阶段锁定通过一次性释放所有锁来避免级联中止。
- 死锁是循环等待问题,可通过引入基于优先级的死锁避免策略来解决。
- 我们详细介绍并展示了一个完整的算法实现:采用严格两阶段锁定的乐观并发控制与死锁避免。该算法使用主动对象和优先级队列,保证了事务的安全性和活性。

通过理解这些概念和算法,你掌握了构建可靠、高效并发数据系统的核心原理。
028:课程回顾与总结


在本节课中,我们将回顾整个学期所学的核心概念,涵盖从声明式编程范式到非声明式扩展,以及数据抽象和并发模型。我们将通过代码示例和关键解释,帮助您巩固理解,并为考试做好准备。
声明式编程范式
我们首先回顾了本学期探讨的各种编程范式。声明式范式是核心,它们从经典的顺序函数式编程开始,逐步扩展到更具表达力的形式。
以下是本学期涉及的声明式范式:
- 顺序函数式编程:这是大多数语言中常见的范式,例如 Scheme 和 Erlang。
- 带数据流变量的顺序函数式编程:在顺序函数式编程基础上增加了数据流变量。
- 确定性数据流:在数据流基础上增加了线程,用于编写并发程序。
- 惰性确定性数据流:在确定性数据流基础上增加了按需驱动的惰性求值。
我们同样学习了非声明式范式,它们通过引入可变状态或通信通道来处理现实世界中的不确定性。
以下是本学期涉及的非声明式范式:
- 带端口的确定性数据流:通过端口引入非确定性。
- 多智能体编程:基于端口和线程的并发模型。
- 共享状态并发:基于可变内存单元的并发模型。
声明式范式的特殊性质
上一节我们介绍了各种范式,本节中我们来看看声明式范式的一些特殊且重要的性质。在理想情况下,程序的大部分应使用声明式风格编写。
数据流变量的重要性
在顺序函数式编程中加入数据流变量,带来了两个关键优势。
首先,它使得所有递归函数都成为尾递归。这意味着递归调用在函数的最后一步执行,且不保留额外的栈帧。在支持数据流变量的语言中,编译器可以自动实现这一点。
例如,考虑列表连接函数 append。在支持数据流变量的语言中,其核心逻辑可以表示为:
proc {Append Xs Ys Zs}
case Xs
of nil then Zs = Ys
[] X|Xr then Zr in
Zs = X|Zr
{Append Xr Ys Zr}
end
end
这里,结果列表 Zs 的头部 X 在递归调用 {Append Xr Ys Zr} 之前就已构建好,这得益于未绑定的数据流变量 Zr。尾递归等价于循环,保证了恒定的栈空间使用,这对于实现高效的并发代理(其输入输出是作为流的列表)至关重要。
其次,数据流变量支持常数时间的列表连接操作。通过使用差异列表表示法,可以将列表表示为一对变量 (S, E),其中 S 是列表的起始部分,E 是未绑定的尾部。连接两个差异列表 (Sx, Ex) 和 (Sy, Ey) 只需将 Ex 绑定到 Sy 即可,这是一个常数时间操作,尽管它是一次性的(瞬态的)。
确定性数据流与惰性求值
确定性数据流通过添加线程,允许编写并发的、声明式的代理程序,例如数字逻辑模拟器。所有计算仍然是确定性的。
惰性确定性数据流在此基础上增加了 WaitNeeded 操作,实现了按需驱动。Wait 会挂起线程直到变量被绑定,而 WaitNeeded X 会挂起线程直到有另一个线程在等待 X。这为惰性求值提供了语义基础。
惰性的一个典型应用是解决生产者-消费者问题中的资源与速度平衡。纯急切实现在消费者慢时可能导致内存溢出;纯惰性实现则可能因生产者和消费者交替执行而导致速度下降。有界缓冲区作为中间节点,结合了急切和惰性的优点:它对生产者表现为消费者(主动拉取数据直到缓冲区满),对消费者表现为生产者(立即提供数据)。当缓冲区不满时,它会向生产者请求数据,从而平衡了内存使用和并发执行速度。
高级声明式算法
有时人们批评声明式编程无法实现高效算法,因为存在单次赋值的限制。但事实并非如此,我们可以利用不同的声明式范式特性来实现高效的数据结构。
我们需要考虑算法设计的两个关键维度:

- 瞬态 vs 持久化:瞬态数据结构只允许存在一个有效版本;持久化数据结构允许多个版本同时存在并可用。
- 摊还 vs 最坏情况 时间复杂度:摊还复杂度考虑一系列操作的平均成本;最坏情况复杂度则保证每个操作的成本上限。
不同的声明式范式支持不同类型的高效实现:
- 标准顺序函数式编程:支持摊还常数时间的瞬态算法(例如使用双列表表示的队列)。
- 顺序函数式编程 + 数据流变量:支持最坏情况常数时间的瞬态算法(例如使用差异列表)。
- 惰性顺序函数式编程:支持摊还常数时间的持久化算法。
- 惰性顺序函数式编程 + 数据流变量:在某些情况下,可以支持最坏情况常数时间的持久化算法。
以队列为例,标准函数式队列使用两个列表 (F, R) 表示,F 是队列前端,R 是反向的后端。入队操作加到 R,出队操作从 F 移除。当 F 为空时,需要执行一次 O(n) 的 Reverse(R) 操作,并将结果作为新的 F。通过摊还分析,一系列操作的平均成本是常数。
然而,这个实现不是持久化的。如果创建多个队列版本,每个版本在出队时都可能触发独立的 O(n) 反转操作,导致总成本过高。
惰性求值通过延迟计算和共享结果来解决持久化问题。在惰性持久化队列中,Reverse(R) 操作被封装成一个惰性计算(suspension)。这个计算在创建时并不执行,只有当实际需要其结果(出队)时才执行。关键的是,这个惰性计算被所有引用该队列的版本所共享。当第一个版本触发计算并绑定结果后,其他所有版本都能立即看到这个已绑定的结果,无需重复计算。通过“银行家方法”确保在需要执行反转操作时,已经通过之前的入队操作“支付”了足够的成本。
更进一步,通过将惰性的、整体式的 Reverse 操作与增量式的列表构造相结合,可以将反转的成本均匀分摊到多个出队操作中,从而实现最坏情况持久化。
声明式编程的优势与局限
上一节我们看到了声明式算法的高效性,本节我们来探讨声明式编程的根本优势及其不可避免的局限性。
声明式编程的核心优势在于其确定性和基于 λ演算 的数学基础。根据 Church-Rosser 定理,声明式程序的计算结果与求值顺序无关。这使得测试非常容易,因为程序各部分如同数学函数,可以独立测试,无需考虑内部状态和历史操作。
然而,声明式编程的强项也是其弱点。其根本局限在于无法直接与非确定性的现实世界交互。一个经典的例子是客户端-服务器应用。多个独立客户端向服务器发送请求,由于网络延迟和用户操作,请求到达的顺序是非确定的。服务器必须立即处理最先到达的请求以满足性能要求。
在纯声明式范式中,我们无法编写这样的服务器。例如,尝试使用 case 语句同时等待两个输入流 S1 和 S2:
case S1 of M1|S1r then {Handle M1} ...
[] S2 of M2|S2r then {Handle M2} ...
end
case 语句是确定性的:它会严格按顺序尝试分支。如果 S1 暂时为空(但客户端还在),即使 S2 有消息到达,程序也会一直等待 S1,而不会处理 S2。要处理这种非确定性选择,需要语言提供类似 select 的原语,但这已超出了声明式范式的范畴。
因此,为了与现实世界交互,必须引入非声明式操作。主要有两种扩展方式:
- 可变状态:变量可以多次赋值,新值覆盖旧值。
- 通信通道(端口):可以发送和接收消息,所有发送的消息都保留在通道中。
两者都引入了非确定性。在客户端-服务器例子中,可以使用一个端口来暴露客户端请求的非确定性顺序。服务器从该端口的流中读取消息,消息到达的顺序由现实世界决定。这样,程序的非声明式部分(端口)被限制在最小的边界内,内部处理逻辑仍然可以是声明式的。
数据抽象
数据抽象是组织程序的重要方法,它与声明式/非声明式的划分是正交的。数据抽象通过接口将内部实现与外部使用隔离,是构建大型可维护系统的关键。
语言需要三个概念来支持强大的数据抽象:
- 词法作用域(静态作用域)
- 高阶编程
- 封装键:类似于“锁和钥匙”的概念,例如 Oz 语言中的
Name和Chunk,使得不知道字段名称就无法访问记录内部。
数据抽象主要有两种形式,它们在数学上有本质区别:
- 对象:只有一种实体,将状态和行为封装在一起。
- 抽象数据类型:有两种实体(类型和操作),操作在类型外部定义。
结合声明式与非声明式,我们可以得到四种数据抽象方式。其中特别有趣的是函数式对象——具有对象接口但完全用声明式风格实现的抽象。
例如,一个函数式栈对象:
fun {NewStack}
S = nil
fun {Push X} {StackObject X|S} end
fun {Pop} S end % 简化示例,实际需返回新栈和顶部元素
fun {StackObject State}
fun {Push2 Y} {StackObject Y|State} end
fun {Pop2} State end
in
stack(push:Push2 pop:Pop2)
end
in
stack(push:Push pop:Pop)
end
{NewStack} 返回一个记录,包含 push 和 pop 两个函数。这些函数通过词法作用域捕获了当前的栈状态 S。调用 push 会返回一个新的栈对象,其内部捕获了新的状态。这完全通过高阶函数和词法作用域实现,没有可变状态,却提供了对象的接口。
多智能体编程与总结
最后,我们简要提及其他主题。多智能体编程基于端口和线程,Erlang 语言是杰出代表。其核心贡献在于一系列通用的行为模式,如通用服务器、事件处理器,尤其是监督者行为。监督者行为可以管理其他行为的生命周期和故障恢复,这使得构建高可靠、高并发的系统变得非常直接。

本节课中我们一起回顾了高级编程语言概念的核心内容:从声明式范式的数学基础与高效算法,到其与非声明式扩展的边界;从数据抽象的两种基本形态,到函数式对象的巧妙实现;以及用于构建可靠系统的并发模型。理解这些概念的本质区别和联系,是掌握高级编程语言设计的关键。
029:课程介绍与基础回顾 🎓

在本节课中,我们将学习LINFO1131《高级编程语言概念》课程的整体安排、核心主题,并对先修课程LINFO1104中的关键概念进行快速回顾。这些基础是理解后续高级内容的前提。
课程概述与组织
本课程是LINFO1104课程的进阶。课程内容将更加深入,涵盖并发编程、声明式编程等高级主题。
课程组织如下:
- 每周有一次讲座。强烈建议参加,因为讲座会提供大量直观解释和示例。
- 每周有一次实验课(第一周除外)。实验课出勤率将计入最终成绩。
- 所有课程资料和通知将通过Moodle平台发布。
以下是评分细则:
- 期中考试:可选,约在第7周进行,占5分。
- 项目:必须完成,需两人一组(特殊情况可单人),占5分。
- 期末考试:占15分。
- 实验课出勤奖励:全勤可获得1分额外加分。
最终成绩将取期中考试和期末考试相应部分的最大值。这意味着,如果在期中考试和项目中表现出色,并参加所有实验,你甚至可以在不参加期末考试的情况下通过本课程。这种安排旨在鼓励大家在学期中持续学习。
我们将使用两个软件系统:Mozart系统(使用Oz语言)和Erlang/OTP系统(版本25)。Erlang是构建大型、健壮软件系统的优秀工业级语言,被许多公司(尤其是游戏公司)使用。
课程核心主题
本课程旨在为你展示未来10年编程的发展方向,主要围绕三大主题展开。
上一节我们介绍了课程的整体安排,本节中我们来看看课程将要探讨的核心内容。




第一个主题是并发编程。并发编程通常被认为非常困难。我们将继续学习确定性数据流等并发范式,并展示如何使并发编程变得简单。

第二个主题是声明式编程。这是目前最容易的编程方式,其理念正被越来越多的人理解和使用(例如云计算工具)。本课程将用大约一半的时间深入探讨这种方法的强大之处。
第三个主题是非声明式编程。遗憾的是,我们无法始终使用声明式方法。本课程将探讨如何应对非确定性,并使用正确的抽象使非声明式编程也变得更容易。
总而言之,本课程的主题是:如何使用正确的概念和抽象,使编程尽可能简单。
课程内容大致可分为两大部分:声明式编程(红框)和非声明式编程(黑框)。





在声明式部分,我们将学习四种范式:
- 顺序函数式编程
- 确定性数据流
- 惰性求值
- 惰性确定性数据流




这些范式允许你以非常强大的、完全声明式的方式编写算法,具有易于测试、维护等优点。然而,声明式编程存在局限性,主要与非确定性有关。
在非声明式部分,我们将学习基于非确定性、消息传递和共享状态的不同形式的并发编程。我们将重点学习Erlang,这是构建大型、容错并发程序的最佳系统之一。Erlang能够编写出永不崩溃、可在线更新和调试的软件,这一特性在游戏服务器、国家医疗数据库等关键系统中至关重要。
先修课程关键概念回顾
接下来的内容是对先修课程LINFO1104中关键概念的快速回顾。从下周的讲座开始,我们将默认大家已完全掌握这些内容。
函数式编程与高阶编程
我们首先看到的是函数式编程,这是一切的基础。特别是高阶编程,其中函数是“一等公民”,可以像数据一样被传递和返回。
高阶函数具有“阶”的概念。一个函数的阶为1(一阶),如果其所有输入和输出都不是函数。否则,如果其输入或输出中包含阶为 n 的函数,则该函数的阶为 n+1。
高阶编程之所以能工作,依赖于闭包的概念。闭包是一个包含函数代码及其定义时环境的“包裹”。这使得函数可以记住并访问其定义时的变量。
以下是一个高阶函数的例子,函数作为参数:
fun {Map L F}
case L of nil then nil
[] H|T then {F H}|{Map T F}
end
end
Map 函数接收一个列表 L 和一个函数 F,将 F 应用于 L 的每个元素。这个函数是尾递归的,这意味着递归调用是函数体中的最后一个操作。尾递归之所以重要,是因为它允许我们将列表函数转化为并发的代理(agent),这是将声明式编程推向并发的关键。
另一个重要的高阶函数是 Fold(或称为 Reduce)。它抽象了一个累加器,将列表中的所有元素“折叠”成一个单一结果。Fold 可以建模并发代理的状态转换,是核心的高阶函数之一。
fun {FoldL L F U}
case L of nil then U
[] H|T then {FoldL T F {F U H}}
end
end

数据抽象

数据抽象是将程序的一部分封装起来,只通过定义的接口进行访问。这有三个主要优点:保证正确使用、降低复杂性、使得构建大型程序成为可能。

数据抽象建立在词法作用域和高阶编程(闭包)之上。主要有两种形式:对象和抽象数据类型。
- 对象:将数据和操作封装在一个实体中(如汽车)。
- 抽象数据类型:将值(数据)和操作分开(如自动售货机和硬币)。
在Oz中,可以使用 Wrap/Unwrap(基于加密概念的封装)来实现抽象数据类型,也可以使用单元(可变的存储位置)和基于消息分发的过程来实现对象。


并发编程与确定性数据流

在并发程序中,多个活动(线程)同时独立执行。我们通过 thread 语句创建新线程。
线程之间可以通过共享的单赋值变量进行通信。确定性数据流的关键在于:即使线程的执行顺序不确定(由调度器决定),程序的结果始终是确定的。
当一个线程尝试读取一个尚未被绑定的变量时,它会等待(数据流行为),直到另一个线程绑定了该变量。这种同步机制是确定性的基础。
为了传递多个值,我们使用流。流是一个以未绑定变量结尾的列表。生产者线程逐步扩展这个列表,消费者线程逐步读取。流在两者之间建立了一个通信通道。





fun {Producer N S}
S = N|{Producer N+1}
end


fun {Consumer S}
case S of H|T then
{Browse H}
{Consumer T}
end
end
local S in
thread {Producer 1 S} end
thread {Consumer S} end
end
由于列表函数(如 Map、Fold)是尾递归的,它们具有恒定的步长,因此非常适合作为并发代理运行。
非确定性及其重要性
非确定性是指程序执行的操作由程序外部选择(如调度器或真实世界的事件)。任何拥有至少两个线程的并发程序本质上都是非确定性的。
非确定性的根本来源是与真实世界的交互。例如,一个服务器程序必须及时响应多个客户端的请求,哪个请求先到达是无法由程序预先决定的,这就引入了非确定性。
非确定性程序比确定性程序更难调试和验证,因为必须确保其在所有可能的执行顺序下都能正确工作。工业中大量的调试难题都源于非确定性。
因此,本课程的一个核心目标是:驯服非确定性。我们尽可能使用声明式(确定性)编程,对于无法避免的非确定性部分,则通过正确的抽象将其影响范围最小化。
总结与展望
本节课我们一起学习了LINFO1131课程的结构、三大核心主题(并发、声明式、非声明式),并回顾了先修课程中的关键概念,包括函数式与高阶编程、数据抽象、确定性数据流并发以及非确定性的本质与挑战。
本课程的深层目标是教你如何利用声明式编程的强大威力,并学会管理与真实世界交互所必然带来的非确定性,从而构建更健壮、更易维护的软件。

从下周开始,我们将深入第一个高级主题:惰性求值。这是一种全新的、强大的声明式编程范式,能让我们以更优雅的方式编写高效算法。
030:惰性求值



概述
在本节课中,我们将要学习一种名为“惰性求值”的函数式编程范式。我们将了解其核心概念、工作原理,并通过具体示例展示其强大之处。


课程公告
在开始之前,先宣布一些课程安排。



- 下周开始,每周一和周二将有三场实验课。
- 你每周只需参加其中一场实验课。
- 周一实验课时间为8:30-10:30,地点在Auditorium Me Ke 14。
- 周二实验课时间为14:00-16:00,地点在Methods 12。
- 如果你在整个学期中每周都参加实验课,你将在考试中获得1分的额外加分。如果缺勤少数几次,分数会按比例计算(例如,出勤率80%可获得0.8分)。这是为了鼓励大家参与实验课。
- 理论上,本课程最高可获得21分(包含这1分加分)。历史上曾有学生通过努力获得超过20分。
- 实验课将进行签到。助教Das Poque会发送邮件详细说明。
- 我们期望你在实验课上认真完成练习。如果你只是去那里看视频,我们可能会请你离开。


惰性求值简介
今天我们将讨论一种名为“惰性求值”的函数式编程范式。这是一种非常不同且强大的思维方式。我们将看到一些运行中的代码。


最著名的基于惰性求值的语言是Haskell。这门语言拥有庞大的社区,甚至有用它构建的加密货币。学习惰性求值不仅对Haskell有帮助,也对许多其他类型的编程大有裨益。
惰性求值是对确定性数据流范式的一个非常自然的扩展,实际上非常简单,只增加了一个新操作,但功能非常强大。
一个简单的例子
一个惰性程序看起来就像一个普通的函数式程序,但它的执行方式不同。当你调用一个函数时,它不会立即被求值。
例如,在Oz语言中,我们可以定义一个惰性加法函数:
fun lazy {LazyAdd X Y}
X + Y
end
然后我们调用它:
S = {LazyAdd 10 20}
你期望结果是10加20,没错,它确实是10加20,但不是立即得到。当我调用这个函数时,什么计算都没有发生(实际上,系统内部会设置一个称为“惰性挂起”的东西,但计算本身没有执行)。





browse 是一个特殊操作,它显示变量时不需要其值。所以如果我们执行 {browse S},它会显示 S 是一个未绑定的变量,而不是30。


但是,如果我执行 {browse S+100},情况就不同了。加法操作 + 需要它的两个参数才能计算。为了计算 S+100,加法操作需要 S 的值,这将触发 {LazyAdd 10 20} 的计算。这就是惰性求值:只有当其他操作需要函数的结果时,该函数才会被执行。




核心思想:我们只在别人需要结果时才进行计算。当我调用一个函数时,它不会立即执行,直到有其他人需要它的结果。

惰性求值的语义
为了准确理解其含义,我们需要解释其语义。这实际上非常简单。
底层原理
在底层,惰性加法函数 {LazyAdd X Y} 会被翻译成一个过程,并带有一个额外的结果参数 R:
proc {LazyAdd X Y R}
thread
{WaitNeeded R}
R = X + Y
end
end
它创建了一个线程,在线程内部执行一个新的操作:{WaitNeeded R}。
WaitNeeded 操作
WaitNeeded 是我们添加到确定性数据流范式中的一个新操作。它的行为非常有趣:
{WaitNeeded R}会挂起(阻塞)当前线程,直到另一个线程需要R。- 例如,另一个线程执行
R + 100,加法操作+需要它的参数R,这将导致WaitNeeded继续执行。


更精确地说,“需要”的定义是:一个线程需要 R,如果它执行了 {Wait R} 操作,即等待 R 被绑定一个值。这是一个数据流操作。所以,如果一个线程执行 {Wait R},就意味着它需要 R。而 {WaitNeeded R} 则等待直到某个其他线程执行 {Wait R}。


数据流操作回顾
让我们回顾一下确定性数据流的工作原理。操作 S = X + Y 在底层被翻译为等待 X 和 Y,执行原始加法,然后将结果绑定到 S。这涉及到 wait 和 bind 两个基本操作。
许多操作都需要等待:
- 算术运算:需要等待操作数。
- Case语句:
case L of H|T ...需要等待L被绑定为一个列表。 - 函数调用:
{F X}需要等待F被绑定为一个函数值。 - 点操作:
R.name需要等待记录R。


任何需要输入的操作都会执行 wait。
完整的语义模型
因此,完整的语义模型包含三个操作:
wait:等待变量被绑定。bind:将变量绑定到一个值。waitNeeded:新增的操作,挂起当前线程,直到另一个线程对变量执行wait。


所有我们今天将看到的奇妙代码,其背后的“魔法”都源于这个简单的 waitNeeded 操作。
直接使用 WaitNeeded
你可以直接使用 {WaitNeeded}。例如:
{WaitNeeded X}
X = 100
{Browse X} % 显示未绑定变量
因为 browse 不需要 X 的值,所以 X 不会被绑定。如果我们通过一个需要 X 的操作来“需要”它,例如 X+0,那么 {WaitNeeded X} 就会继续,X 被绑定为100。
惰性函数的一般转换
在Oz中,用 fun lazy 定义的函数会被通用地转换。对于一个函数 fun lazy {F X1 ... Xn} <expr> end,它被转换为:
proc {F X1 ... Xn R}
thread
{WaitNeeded R}
R = <expr>
end
end
每个惰性函数都是这样定义的。


生产者-消费者示例
现在让我们看一些程序,了解惰性求值能做什么。我们将通过一个简单的生产者-消费者例子,展示同一段代码的三种不同运行方式。



程序代码
我们有一个生产者,它生成从 L 到 H 的整数列表,但每次生成一个元素后延迟1000毫秒,从而创建一个流。消费者读取这个流,并计算累积和。
以下是三种运行方式:

1. 顺序执行
生产者和消费者在同一个线程中顺序执行。生产者先生成完整的列表,然后消费者再处理它。结果是“批处理”式的:我们只在整个过程结束后才看到最终结果。
2. 确定性数据流(并发)执行
生产者和消费者各自在自己的线程中运行,成为并发的函数式代理。这是“增量式”的:当生产者生成元素时,消费者可以同时处理它们,输出结果会逐步出现。
3. 惰性执行
将生产者和消费者函数都定义为 fun lazy。在这种情况下,当我们调用它们时,什么都不会发生,因为 browse 操作不需要其结果。要开始计算,我们必须“需要”结果。
例如,执行 {Browse S2.1}(即需要输出列表的第一个元素)。点操作 . 需要其操作数,这将触发计算链:消费者需要 S1 的第一个元素,生产者因此被触发生成第一个元素。计算只进行到满足需求为止。如果我们再需要第二个元素 {Browse S2.2.1},计算会再前进一点。


三种方式的对比:
- 顺序:批处理,全部完成才输出。
- 数据流:增量式,生产消费同时进行。
- 惰性:按需计算,只计算明确需要的部分。
惰性求值中,甚至递归调用也是惰性的。你可以让任何操作变得惰性,只需将其包装在 fun lazy 中。所有函数式编程的技术在惰性求值中依然有效。
推与拉
这引出了“推”和“拉”的编程模式:
- 急切流(推):生产者是“老板”,决定何时生成数据并推给消费者。我们之前看到的数据流就是这种方式。
- 惰性流(拉):消费者是“老板”,决定何时需要数据并向生产者拉取。生产者只在被要求时才工作。
大多数传统编程语言(如Java)都采用急切方式。惰性方式同样重要且强大,它提供了一种对称的视角。
无限列表计算
由于惰性求值只计算所需部分,因此它可以处理无限列表而不会陷入无限循环。这是一个非常强大的特性。
生成无限整数列表
fun lazy {Ints N}
N|{Ints N+1}
end
{Ints 1} 会生成一个从1开始的无限整数列表。在惰性程序中,这不是错误,而是一种完美的编程方式。
执行 L={Ints 1} 后,L 只是一个未绑定的变量。只有当我们“需要”元素时,例如 {Browse L.2.2.1}(第三个元素),计算才会进行到生成该元素为止,然后再次挂起。

强制求值:Touch 函数
有时我们需要强制求值列表的前N个元素。我们可以定义一个 Touch 函数:
proc {Touch L N}
if N==0 then skip
else {Touch L.2 N-1} end
end
这个函数遍历列表的前N个元素。由于 L.2 需要列表的头部,因此它会强制惰性列表生成元素。在急切求值中,这个函数没有意义,但在惰性程序中,它非常有用。
例如,{Touch L 10} 会强制 L 生成前10个元素。
汉明问题
现在让我们看一个更复杂的例子,展示惰性求值解决实际问题的能力:汉明问题。


问题描述
我们需要按升序生成所有形如 2^A * 3^B * 5^C 的数(其中A, B, C是非负整数),不能遗漏任何数。序列开始于:1, 2, 3, 4, 5, 6, 8, 9, 10, 12, ...
问题是:我们不知道需要生成多少个数,程序必须能够动态地、增量地生成这个序列,并且要高效。

这是一个典型的、适合用惰性求值解决的问题。静态数组分配不好,因为可能浪费空间或不够用。

解决方案思路
假设我们已有序列 H,下一个数 X 必须是 2*H、3*H 或 5*H 中某个序列的最小值,且该值尚未被使用。
- 生成三个惰性序列:
2*H,3*H,5*H。 - 合并这三个有序序列,并在合并时跳过重复项。
- 序列
H定义为1后接这个合并序列的结果。
程序实现
我们需要两个基本操作:
Times:将列表每个元素乘以常数N。Merge:合并两个有序列表。
% 将列表L每个元素乘以N
fun lazy {Times L N}
case L of H|T then N*H|{Times T N} end
end
% 合并两个有序列表L1和L2,去重
fun lazy {Merge L1 L2}
case L1#L2 of (H1|T1)#(H2|T2) then
if H1 < H2 then H1|{Merge T1 L2}
elseif H1 > H2 then H2|{Merge L1 T2}
else H1|{Merge T1 T2} % 相等时去重
end
end
end
% 汉明序列
H = 1|{Merge {Times H 2} {Merge {Times H 3} {Times H 5}}}
{Browse H}
这个程序非常简洁高效。它使用的内存和计算量都与生成的元素数量成正比。尝试用Java编写同样简洁高效的汉明问题解决方案会很有挑战性。
执行过程分析
当我们定义 H 时,会创建多个惰性挂起(WaitNeeded线程)。例如,Merge 调用、Times 调用都会挂起。
当我们需要第一个元素 H.1 时,会触发一个链式反应:激活相关的惰性挂起,进行计算,生成结果 1,并为后续元素创建新的惰性挂起。
每次请求一个新元素,都会激活并重新创建一系列惰性挂起。所有这些复杂性都隐藏在 fun lazy 注解之后。
结合惰性与并发
我们目前看到的惰性求值本身并不是真正的并发。我们可以将线程(并发)与惰性函数结合起来,形成一个更强大的范式。
这个组合范式拥有三种强大能力:
- 合流性:函数式编程的确定性。
- 并发性:独立的代理活动。
- 惰性:按需计算。
几乎没有语言能同时提供这三者。Haskell只做惰性,不做确定性数据流并发。Oz的这个组合范式非常强大,未来会看到更多应用。
实例:有界缓冲区
一个经典的并发问题是生产者-消费者速度不匹配。解决方案是在它们之间加入一个有界缓冲区。缓冲区平滑了波动:生产者过快时,缓冲区存储数据;消费者需要而生产者未就绪时,缓冲区提供已存储的数据。
传统上,有界缓冲区用状态变量实现。但我们可以用声明式方式(结合惰性与并发)来实现它。
设计思路
假设生产者和消费者都是惰性的。我们在它们之间插入有界缓冲区。对于生产者,缓冲区表现得像一个消费者;对于消费者,缓冲区表现得像一个生产者。
缓冲区过程 BoundedBuffer 有三个参数:输入流 S1,输出流 S2,缓冲区大小 N。
实现步骤
我们分四步构建这个缓冲区:
-
基本流转:所有从
S1输入的元素最终都必须输出到S2。我们定义一个惰性循环来传递元素。fun lazy {Loop S1} case S1 of H1|T1 then H1|{Loop T1} end end -
初始填充:缓冲区启动时是空的,需要立即向生产者请求
N个元素来填满自己。这使用了一个急切操作List.drop来遍历并“消耗”掉S1的前N个元素,从而触发生产者的计算。S1 = {List.drop S1 N} % 初始填充,急切操作 -
保持充盈:每当消费者从
S2取走一个元素(即Loop迭代一次),缓冲区就少一个元素。为了保持充盈,我们需要立即向生产者再请求一个元素。我们修改Loop,让它每次迭代时都去读取S1的尾部(即下一个元素)。fun lazy {Loop S1} case S1 of H1|T1 then H1|{Loop T1} end % T1 会触发对下一个元素的请求 end -
并发化:上述步骤中的初始填充 (
List.drop) 和循环中的等待可能会阻塞。为了避免阻塞整个系统,我们将这些操作放在独立的线程中。这样,即使生产者暂时没有数据,消费者线程和其他部分也能继续运行。thread S1 = {List.drop S1 N} end % 在独立线程中初始填充 thread S2 = {Loop S1} end % 在独立线程中运行传递循环
最终的 BoundedBuffer 代码就是这些部分的组合,它完全以声明式风格编写,没有使用可变状态。

总结
本节课我们一起学习了惰性求值。我们从其核心语义 WaitNeeded 开始,理解了它如何延迟计算直到结果被需要。我们通过生产者-消费者示例看到了顺序、并发和惰性三种执行方式的区别。我们探索了惰性求值处理无限列表的能力,并用它优雅地解决了经典的汉明问题。最后,我们了解了如何将惰性与并发结合,实现了一个声明式的有界缓冲区。惰性求值是一种强大的范式,它提供了不同的程序构建和思考方式。
031:惰性求值与声明式编程 🧠



在本节课中,我们将深入学习惰性求值的机制,并通过一个具体的例子(有界缓冲区)来展示惰性求值与并发编程的结合。最后,我们将从理论层面探讨声明式编程的精确定义,理解其核心属性。
惰性求值机制回顾 🔄
上一节我们介绍了惰性求值的基本概念。本节中,我们来看看其内部执行机制。
惰性求值通过一个特殊的操作 WaitNeeded 实现。当执行一个惰性函数时,它会创建一个“惰性挂起”,这本质上是一个等待结果的线程。只有当另一个线程需要这个结果值时,WaitNeeded 操作才会被触发,从而唤醒挂起的线程继续执行。



核心机制:
% 惰性求值示例:创建一个惰性挂起
local R in
thread
{WaitNeeded R}
% 当R被需要时,执行计算并绑定R
R = 42
end
% 此时R尚未被计算
{Browse R} % 浏览R不会触发计算
{Browse R+1} % 需要R的值,触发计算
end
这个过程允许我们编写潜在的无限循环,这在惰性程序中非常常见且有用。

海明序列示例 🧮


为了理解惰性求值如何工作,让我们回顾海明序列的例子。我们需要合并三个无限列表(2的倍数、3的倍数、5的倍数)来生成一个有序序列。




关键点:
- 我们使用惰性求值来合并这些流。
- 函数
Merge本身是惰性的,只有当其结果被需要时才会执行。 - 在
Merge函数内部,有一个case语句需要检查两个输入流的第一个元素。这会导致一个链式反应:如果需要Merge的结果,那么它就需要其输入流,进而触发上游的惰性计算。
以下是当请求海明序列的第二个元素时,系统中发生的线程激活与数据绑定的简化流程:


- 请求
M1.1(第二个元素)。 - 这激活了等待
M1的惰性挂起(即Merge(T2, M2))。 Merge函数体开始执行,其case语句需要T2和M2的第一个元素。- 这分别激活了等待
T2(即TimesH 2)和M2(即Merge(T3, T5))的惰性挂起。 - 这些被激活的线程运行,计算值并绑定变量(例如
T2绑定为2|T2'),同时它们还会创建新的惰性挂起用于后续计算(如T2')。 - 一旦
T2和M2都被绑定,等待它们的case语句得以继续,比较这两个值,输出较小的那个(例如2),并绑定M1为2|M1',同时为M1'创建新的惰性挂起。 - 最终,最初等待
M1.1的操作得到结果2。

重要结论:一个简单的点操作(M1.1)背后,会触发一系列惰性线程的激活、执行和新的惰性挂起的创建,形成一个复杂的、协调的计算网络。所有实际工作都由 WaitNeeded 机制在幕后完成,从请求者的角度看,它只是在等待,然后值就“神奇地”出现了。




声明式有界缓冲区 🚧

现在,我们来看一个结合了惰性和并发的强大例子:声明式的有界缓冲区。


有界缓冲区是生产者-消费者管道中的一个组件,它允许生产者稍微领先于消费者,从而提高系统性能。其美妙之处在于,我们可以将其插入现有管道,而无需修改生产者或消费者的代码,体现了极佳的模块性。
实现步骤
以下是构建有界缓冲区的四个步骤:
- 基本通路:缓冲区最基本的功能是将输入流
S1的元素传递到输出流S2。我们使用一个惰性循环来实现,它只是简单地将输入连接到输出。fun {Buffer S1 S2 N} % ... 步骤1: 基本通路 S2 = S1 % 初始版本,非最终代码 end


- 启动填充:缓冲区启动时,会立即向生产者请求 N 个元素,试图让自己保持满的状态。
fun {Buffer S1 S2 N} % ... 步骤2: 启动时请求N个元素 {List.take S1 N _} % 非最终代码,示意 eager 请求 end


-
保持充盈:当消费者从缓冲区取走一个元素后,缓冲区必须请求生产者再生成一个元素以保持充盈。我们通过给循环添加一个额外的惰性参数来实现这一点,该参数的求值由消费者触发。
fun {Buffer S1 S2 N} % ... 步骤3: 消费者触发补充 S2 = {Loop S1 N} % Loop 函数内包含补充逻辑 end -
引入线程避免阻塞:步骤2和3中的操作(
List.take和N.2)可能会等待生产者。我们不希望整个缓冲区因此阻塞。在声明式并发模型中,添加线程不会引入错误。因此,我们将这些可能阻塞的操作放在独立的线程中,让缓冲区的主流程可以继续前进。fun {Buffer S1 S2 N} thread % 步骤4: 将可能阻塞的操作放入独立线程 {List.take S1 N _} end thread S2 = {Loop S1 N} end end
最终成果:通过这四个步骤,我们得到了一个完全声明式的、并发安全的有界缓冲区。这与在 Java 等语言中使用线程和共享状态实现缓冲区形成了鲜明对比——在声明式模型中,线程是廉价且友好的工具。
执行演示
在演示中,我们设置了一个生产者(缓慢生成数字)、一个有界缓冲区(大小为3)和一个消费者(计算斐波那契数列)。观察到:
- 即使没有消费者请求,生产者也会预先生成3个元素填满缓冲区。
- 当消费者请求第一个元素时,它能立即得到,无需等待生产延迟。
- 每当缓冲区被取走一个元素,它会自动触发生产者生成一个新元素来填补空缺。

这展示了有界缓冲区如何解耦生产者和消费者,使它们能独立、高效地运行。





惰性快速排序的魔力 ✨
本节我们将探索惰性求值如何将一个普通算法转化为一个更强大的增量式算法。
标准的快速排序算法的时间复杂度为 O(n log n)。当我们对其应用惰性求值后,它变成了一个增量算法:排序列表被逐段生成。此时,获取前 k 个最小元素的时间复杂度变为 O(n + k log k)。
这意味着什么?
- 如果只需要最小的元素 (k=1),复杂度接近 O(n)。
- 如果需要所有元素 (k=n),复杂度回归 O(n log n)。
- 最重要的是,你无需提前知道 k 的大小。你可以随时请求下一个元素,算法只会做必要的额外工作。
这比在命令式语言中先分配一个大小为 k 的数组然后遍历要强大和灵活得多。
算法原理


-
标准快速排序:
fun {QuickSort L} case L of nil then nil [] Pivot|Rest then Left Right in {Partition Rest Pivot Left Right} {Append {QuickSort Left} Pivot|{QuickSort Right}} end end -
惰性快速排序:只需将
QuickSort和Append函数声明为惰性的,而Partition保持急切求值(因为它需要检查所有元素来分区)。fun lazy {LQuickSort L} ... % 主体与QuickSort相同,但递归调用LQuickSort end fun lazy {LAppend A B} ... % 惰性追加 end
执行分析:当只请求排序结果的第一个(最小)元素时:
- 算法会对原始列表进行分区。
- 但只有包含较小元素的“左子列表”会继续被递归排序。
- 包含较大元素的“右子列表”的排序调用保持惰性挂起状态,不会被激活。
- 为了找到第一个元素,算法本质上只遍历了列表的“左半部分链”,总工作量约为 2n,即 O(n)。
当请求第 k 个元素时,算法会完全执行一个针对大约 k 个元素的“迷你快速排序”,带来 O(k log k) 的额外开销。因此总复杂度为 O(n + k log k)。
结论:惰性求值自动地将快速排序转化成了一个智能的、增量式的“查找前 k 个最小元素”的算法,并且代码几乎与原始版本一致。
声明式编程的精确定义 📚
在课程的最后,我们从理论层面探讨究竟什么是声明式编程。
我们接触了五种声明式范式:
- 基于值的函数式编程(如 Scheme)
- 带有单赋值变量的函数式编程
- 确定性数据流(并发)
- 惰性求值
- 惰性确定性数据流(2和3的结合)
它们的共同核心是确定性,即程序行为与执行顺序(调度)无关。
理论基础:合流性
纯函数式编程(λ演算)具有合流性(Church-Rosser 定理)。这意味着,无论以何种顺序对表达式进行归约(求值),最终都会得到相同的结果。这是声明式编程无不确定性的根源。
扩展到并发与状态
当我们引入并发、流和单赋值变量时,需要扩展这个定义:
- 并发:合流性天然支持并发,因为不同的调度顺序被视为不同的归约路径,最终结果一致。
- 流与部分终止:流处理程序可能永不终止,但我们可以定义部分终止:当输入流暂时固定时,程序会运行到一个“静止点”,产生部分输出。声明式要求所有执行路径到达逻辑上等价的静止点。
- 单赋值变量与逻辑等价:存储(包含绑定变量的环境)可被视为一个逻辑公式。例如,绑定
X = f(Y Z)表示逻辑语句“X 等于以 Y 和 Z 为参数的 f 记录”。- 一个解释为所有变量赋值。
- 一个模型是使该逻辑公式为真的解释。
- 两个存储 σ 和 σ‘ 是逻辑等价的,如果它们在所有解释下同真同假(即
σ ⇔ σ‘是永真式)。这意味着它们包含相同的信息,即使具体的绑定顺序不同。


正式定义
一个程序是声明式的,当且仅当:对于所有可能的输入,该程序的所有可能执行(即所有调度选择)都会:
- 要么都不终止(都进入无限循环),
- 要么都达到部分终止,
- 并且在这些部分终止点上,产生的存储是逻辑等价的。
换言之,没有可观察到的非确定性。所有执行路径在逻辑意义上产生相同的结果。
错误限制
在大型程序中,错误难免。一个错误可能导致程序行为变成非声明式的(例如,因变量冲突导致不同执行产生不同结果)。错误限制是一种技术,通过 try-catch 结构捕获异常,并提供一个默认的确定性行为,从而将非确定性错误的影响封装起来,使程序整体仍保持声明式特性。
总结 🎯
本节课中我们一起学习了:
- 惰性求值的深层机制:理解了
WaitNeeded如何触发链式计算,以及惰性程序背后的多线程协作模型。 - 声明式有界缓冲区:看到了如何结合惰性和并发,以完全声明式的方式实现一个经典的同步组件,并体会了声明式并发中线程的“友好性”。
- 惰性快速排序:见证了惰性求值如何将标准算法魔法般地转变为强大的增量算法,优化了“查找前 k 小元素”问题的复杂度。
- 声明式编程的理论基础:从 λ演算的合流性出发,学习了如何通过部分终止和逻辑等价的概念,精确定义包含状态和并发的声明式程序。我们还了解了错误限制的概念。

这些内容展示了声明式范式的强大表达能力和理论美感。下一节课,我们将继续探索如何利用这些概念(尤其是惰性)来设计更高级、更高效的声明式算法。
032:声明式算法构建技巧


在本节课中,我们将学习如何构建高效的声明式算法。我们将继续探讨惰性求值和单次赋值,并介绍一些巧妙的技术,使算法在保持声明式风格的同时,也能达到理想的性能。
概述
声明式编程在测试、并发和分布式方面具有巨大优势。本节课的目标是探讨如何为特定任务(如队列抽象)编写高效的声明式算法。我们将看到,许多原本需要可变状态才能高效的算法,实际上可以用纯粹的声明式方式实现。
核心概念
在深入代码之前,我们先介绍几个关键的算法概念。

复杂度分析:摊销与最坏情况

我们通常关注算法时间复杂度的上界,主要有两种类型:摊销复杂度和最坏情况复杂度。
- 摊销复杂度:如果你执行一系列操作,其中大部分操作代价低廉,但偶尔有一个操作代价高昂,那么最坏情况可能很糟糕。然而,如果N个操作的总复杂度是F(N),那么每个操作的平均复杂度就是F(N)/N。对于用户来说,平均感受是快速的,即使偶尔有慢操作。例如,一个队列的大多数插入/删除操作是O(1),但偶尔有一个操作是O(N),只要N次操作的总时间是O(N),那么摊销复杂度就是O(1)。
- 最坏情况复杂度:这是标准的“大O”表示法,关注的是单次操作可能遇到的最坏性能。理想情况下,我们希望最坏情况也是常数时间。



数据结构:短暂性与持久性
- 短暂性数据结构:在任何时刻,只存在数据结构的一个版本。当你通过操作(如插入)创建一个新版本时,旧版本就被“销毁”或不再有效。使用可变变量(如Java中的对象更新)构建的数据结构总是短暂性的。
- 持久性数据结构:可以同时存在多个版本。当你通过操作创建一个新版本(如Q2)时,旧版本(Q1)仍然存在且可用。你可以从Q1派生出Q3,从Q2派生出Q4,所有这些版本都可以同时使用。垃圾回收器会清理不再被访问的旧数据。
持久性数据结构非常有用,例如在协作编辑(多人同时编辑文本的不同版本)、版本控制系统(如Git)、支持撤销操作,或需要同时访问数据的不同历史状态时。
差异列表:一个强大的声明式工具


在进入队列算法之前,我们先看一个强大的声明式数据结构:差异列表。它允许我们以常数时间进行列表追加操作。



差异列表的原理

一个差异列表通过一对列表 (S, E) 来表示另一个列表 L。其中,E 必须是 S 的后缀。所表示的列表就是 S 减去后缀 E 的部分,即 L = S - E。

示例:列表 [1 2 3 4] 可以表示为:
( [1 2 3 4] , nil )( [1 2 3 4 5] , [5] )- 更一般地,
( [1 2 3 | X] , X ),其中X是一个未绑定变量。



关键在于使用未绑定变量带来的灵活性。列表 [1 2 3] 可以表示为 ( [1 2 3 | X] , X )。
常数时间操作
给定一个差异列表 D = (S, E),假设 E 是未绑定变量 X。

- 前端插入:在列表头部插入元素
4,得到新差异列表( [4 | S] , E )。这是常数时间操作。 - 后端插入:在列表尾部插入元素
4。我们创建一个新的未绑定变量Y,然后将E(即X)绑定为[4 | Y]。新差异列表为(S, Y)。这只是一个绑定操作,也是常数时间。 - 列表追加:合并两个差异列表
D1 = (S1, E1)和D2 = (S2, E2)。我们将E1绑定到S2,结果差异列表为(S1, E2)。这同样是常数时间的绑定操作。

限制:由于依赖变量绑定,每个差异列表的“末端”通常只能被操作(追加)一次。但这在构建列表时(例如,将许多小列表合并成一个大列表)通常足够用,并且它完全是声明式的。

应用示例:展平列表


以下是一个使用追加操作的展平函数 Flatten:


fun {Flatten Xs}
case Xs
of nil then nil
[] X|Xr andthen {IsList X} then
{Append {Flatten X} {Flatten Xr}}
[] X|Xr then
X|{Flatten Xr}
end
end

它的时间复杂度较高,因为 Append 操作代价与列表长度成正比。



我们可以用差异列表重写它,消除 Append:
proc {FlattenD Xs S E} % S和E构成输出差异列表
case Xs
of nil then S=E % 空差异列表
[] X|Xr andthen {IsList X} then M in
{FlattenD X S M}
{FlattenD Xr M E} % 关键:将第一个列表的末端M绑定为第二个列表的开头
[] X|Xr then M in
S = X|M
{FlattenD Xr M E}
end
end


这个版本没有显式的追加操作,效率更高。

队列算法的演进

我们将以实现一个队列(FIFO,先进先出)为例,展示五种不同的实现,它们接口相同,但内部实现和复杂度不同。我们将看到更强大的编程范式如何催生更智能、更高效的算法。
以下是我们的演进路线图:
- 朴素函数式队列:最坏情况O(N)。
- 摊销常数时间短暂队列:使用纯函数式编程。
- 最坏情况常数时间短暂队列:需要单次赋值变量(差异列表思想)。
- 摊销常数时间持久队列:需要惰性求值。
- 最坏情况常数时间持久队列:需要精心组织的惰性求值。



1. 朴素函数式队列


用单个列表表示队列,前端是列表头。
newQ: 返回nil。insert Q X: 返回X|Q(O(1))。delete Q: 使用辅助函数ButLast移除列表的最后一个元素 (O(N))。



复杂度:插入是O(1),删除是O(N)。即使考虑摊销,一系列操作的复杂度也很差。
2. 摊销常数时间短暂队列
我们用两个列表 (F, R) 表示队列。F 是队列前端,R 是队列后端(但以逆序存储)。插入时加到 R 头部,删除时从 F 头部移除。
关键操作是 check:当 F 为空时,将 R 反转并变为新的 F,R 置为空。反转操作是O(|R|)。
工作原理:插入(加到R)总是O(1)。删除(从F取)通常也是O(1)。只有当F为空时,才需要执行一次O(N)的反转来补充F。在一次性的使用中(短暂性),这个O(N)的成本会被分摊到许多次O(1)的操作上,因此摊销复杂度为O(1)。
代码框架:
fun {Insert Q X}
F#R = Q
in
{Check (F # (X|R))}
end
fun {Delete Q X}
F#R = Q
F1 in
F = X|F1
{Check (F1 # R)}
end
fun {Check Q}
case Q of F#R then
if F==nil then {Reverse R}#nil else Q end
end
end
局限性:这个算法只在短暂性使用时具有摊销O(1)复杂度。如果持久性使用(同时保留多个版本并操作),那么从同一个“F为空”的旧版本出发的多次 delete,每次都会触发独立的O(N)反转,导致性能退化。
3. 最坏情况常数时间短暂队列
为了获得真正的(最坏情况)常数时间,我们需要更强大的范式:单次赋值变量。这里我们运用差异列表的思想来表示队列。
队列表示为三元组 (N, S, E),其中 N 是元素个数,(S, E) 是一个差异列表,表示队列内容(S 是头部,E 是末端未绑定变量)。
newQ:(0, X, X)。insert (N,S,E) X:(N+1, S, E1),其中我们将E绑定为X|E1(O(1))。delete (N,S,E) X:(N-1, S1, E),其中我们将S绑定为X|S1,并返回X(O(1))。
复杂度:插入和删除都是最坏情况O(1)。但这是短暂性的,因为绑定操作会消耗掉差异列表的“末端”,旧版本无法再用于插入。
一个有趣的现象:由于依赖逻辑绑定(统一),你甚至可以先删除,后插入。删除操作会创建一个“槽位”(未绑定变量),随后的插入操作会填充这个槽位。这展示了逻辑编程范式的威力,其中绑定是双向的,操作顺序更灵活。

4. & 5. 持久队列与惰性求值
持久性允许同时存在多个版本。使用惰性求值,我们可以构建高效的持久队列。
核心思想:在短暂性摊销队列中,性能问题源于持久使用时,多个分支需要独立执行昂贵的反转操作。惰性求值允许我们将这个昂贵的计算(reverse)提前并惰性地包装起来。当第一个需要该结果的删除操作触发求值时,计算结果被存储下来。后续所有从同一版本派生的操作都会共享这个已计算的结果,无需重复计算。
实现概览(摊销常数时间持久队列):
队列表示为四元组 (LenF, F, LenR, R),其中 F 和 R 是列表,但 F 可能是一个惰性求值的结果。我们维护一个不变式:LenF >= LenR。
当 LenF < LenR 时,我们不是立即反转,而是创建一个惰性悬挂:newF = {LazyAppend F {Reverse R}},然后将队列更新为 (LenF+LenR, newF, 0, nil)。
LazyAppend 是一个惰性的追加函数。
后续的删除操作会逐步强制求值 newF,但该计算只发生一次,并被所有看到该版本的操作共享。
银行家方法:这本质上是一种“银行家方法”。每次廉价的插入操作相当于“存钱”,当“存款”(R 比 F 长出的部分)足够支付一次昂贵的反转操作时,我们就执行它(但以惰性方式),从而保证摊销复杂度。
要获得最坏情况常数时间的持久队列,需要更精细地安排惰性求值的节奏,确保任何单次操作都不会引发过长的计算链。
总结
本节课我们一起探索了构建高效声明式算法的技巧。我们首先学习了摊销复杂度与最坏情况复杂度,以及短暂性与持久性数据结构的区别。然后,我们见识了差异列表如何利用未绑定变量实现常数时间的列表追加。
接着,我们以队列为例,遍历了五种实现:
- 朴素的函数式队列(性能差)。
- 使用纯函数式编程实现的摊销常数时间短暂队列。
- 利用单次赋值变量实现的最坏情况常数时间短暂队列(蕴含逻辑编程思想)。
- 引入惰性求值实现的摊销常数时间持久队列。
- (原理介绍)更复杂的最坏情况常数时间持久队列。
我们看到了编程范式(函数式、单次赋值+逻辑、惰性求值)的增强如何直接带来算法效率(摊销->最坏情况)和功能(短暂->持久)的提升。惰性求值尤其强大,它能通过共享计算来实现高效的持久性数据结构。
033:数据抽象与多智能体编程入门

概述
在本节课中,我们将要学习数据抽象的核心概念,并初步了解多智能体编程。我们将探讨如何通过封装来构建大型计算系统,并比较抽象数据类型与对象这两种不同的数据抽象方式。最后,我们将引入多智能体编程的基本思想。

数据抽象:核心概念
上一周我们探讨了声明式编程的局限性,现在我们将超越它。为了开始这一过程,我们首先需要理解数据抽象。
数据抽象是使大型计算系统成为可能的关键。它是一个程序片段,由内部实现和外部接口两部分组成。外部必须遵循特定规则来访问内部,否则操作将被拒绝。这种封装必须由编程语言来保证,以确保数据抽象的正确性。
以下是数据抽象的两个主要实现方式:
- 对象:将数据抽象的内部状态和操作组合在单个实体中。
- 抽象数据类型:将内部状态(值)和操作分开管理。
你已经使用过这两种方式,尽管可能没有意识到。例如,电视机是一个对象,它是一个具有开关、频道选择等接口的单一实体。而自动售货机则更像一个抽象数据类型:你投入硬币(值),机器(操作)输出产品(另一个值)。
抽象数据类型
抽象数据类型是一组值以及一组作用于这些值的操作。例如,整数1、2、3和算术运算。在像Java这样的语言中,整数既是抽象数据类型,也是对象。
一个未受保护的例子
让我们以实现一个简单的栈为例。栈可以用列表来实现,nil表示空栈,push和pop分别在列表前端添加和移除元素,isEmpty检查列表是否为nil。
然而,这个实现并未封装。用户可以直接看到并操作底层的列表,而无需通过定义的接口(那四个操作)。这不是一个正确的抽象,因为它缺乏保护。
如何实现保护


问题的关键在于保护内部表示。我们希望定义一个新的、受保护的抽象数据类型。这通常通过一种“加密包装”机制来实现。
我们引入一对函数:Wrap和Unwrap。Wrap(X)返回一个受保护的包装值W,外部无法查看W的内部。只有Unwrap(W)可以返回原始的X。可以想象Wrap和Unwrap在进行加密和解密,并且共享一个只有它们知道的密钥。
对于每个抽象数据类型,我们都需要一个新的(Wrap, Unwrap)函数对。我们通过一个高阶函数NewWrapper来创建这样的对,它本质上生成一个新的共享密钥并返回这两个函数。
实现保护所需的语言支持
为了实现Wrap和Unwrap,语言需要支持两个核心概念:
- 不可伪造的常量:在Oz语言中称为
Name。通过NewName函数创建,它是一个无法打印、无法猜测的全新常量。程序只有通过传递才能获得它,无法自行构造。 - 安全记录:一种特殊的记录类型,只支持字段选择操作(
.),不支持列出所有字段等操作。如果记录的字段名是一个Name,那么只有知道该Name才能访问对应的字段值。在Oz中,这被称为Chunk。
有了这两个概念,我们就可以在语言层面(无需实际加密)实现安全的封装。NewWrapper的实现如下:它创建一个新的Name作为密钥,Wrap函数用该Name作为字段名将值X存入一个安全记录,Unwrap函数则用同一个Name从安全记录中取出值X。
现在,我们可以构建一个真正的、受保护的栈抽象数据类型:
NewStack返回Wrap(nil)。Push操作接收一个包装后的栈W,先Unwrap(W)得到内部列表,添加新元素,然后再Wrap结果并返回。Pop和IsEmpty同理。
这样,栈的内部列表表示对用户完全不可见,所有操作都必须通过接口进行。
抽象数据类型的概念由来已久,例如CLU语言(1974年)。有趣的是,许多现代“面向对象”语言(如Java)实际上也支持抽象数据类型。
对象
在对象中,值和操作是融合在一起的。对象本身知道如何执行操作。
对象的语义定义
我们可以给出对象在核心语言层面的语义定义,无需特殊语法。以下是一个栈对象的定义:
它本质上是一个单参数过程,参数是消息(一个记录)。根据消息的内容(push(X)、pop(X)、isEmpty(B)),它调用相应的方法(也是过程)。对象内部使用一个单元来保存状态(栈的当前列表)。方法的访问通过一个带case语句的过程进行分派,这称为过程分派。另一种方式是记录分派,对象本身是一个记录,其字段是方法,通过Object.push等方式调用。
与抽象数据类型的关键区别在于:对象的状态始终被包裹在对象内部(通过静态作用域隐藏),因此不需要Wrap和Unwrap机制。任何数据抽象都可以选择实现为对象或抽象数据类型。
对象编程自Simula 67和Smalltalk等语言以来影响深远。但值得注意的是,大多数主流“面向对象”语言实际上同时支持对象和抽象数据类型,更准确的称呼应是“数据抽象语言”。
四种数据抽象
实际上,数据抽象有四种类型,由两个维度区分:
- 捆绑 vs 非捆绑:操作和值是组合在一起(对象)还是分开(抽象数据类型)。
- 有状态 vs 无状态:内部是否使用可变的单元。
这形成了四个象限:
- 无状态抽象数据类型:如函数式编程中的不可变栈。我们之前实现的受保护栈就是这种(尽管例子中栈内容可变,但包装器本身不可变,且操作是纯函数)。
- 有状态对象:典型的Java对象,具有可变属性。
- 有状态抽象数据类型:值本身可以被修改。例如,Java中带有静态(可修改)属性的类。它使用
Wrap来保护对包含单元的值的访问。 - 无状态对象(函数式对象):对象不可变,操作后返回一个新对象。这是高阶编程的一个巧妙应用,仅通过静态作用域和高阶函数实现,无需单元或包装器,是最简单的数据抽象形式。Scala语言中的不可变对象就是例子。
函数式对象在“大数据”处理中很常见,其中数据转换管道生成新数据,而不修改原数据。
多智能体编程入门
现在,我们开始探讨多智能体编程。这是一种基于端口(通信通道)的强大并发编程范式。
端口对象
多智能体系统的基本构建块是端口对象。它是一个具有内部内存(状态)的实体,拥有自己的线程和一个端口。当消息发送到其端口时,它会根据状态转移函数改变内部状态。
我们可以定义一个通用的NewPortObject抽象:它接受初始状态S0和状态转移函数F,创建一个端口,并启动一个线程运行一个循环。该循环等待端口流上的消息M,应用F(State, M)计算新状态,并递归继续。
有趣的是,这个循环函数本质上就是折叠操作。fold是一个高阶函数,它依次将函数F应用于初始状态和列表中的每个元素。在这里,列表就是端口的消息流。因此,端口对象可以看作是fold、端口和线程三者的结合。
消息协议
当多个端口对象(智能体)相互通信时,它们遵循消息协议——一系列有意义的、完成更高级别任务的消息交换。例如:
- 远程方法调用:客户端发送请求,服务器回复。
- 异步RMI:客户端发送请求后不等待,可以立即发送下一个请求。
- 带回调的RMI:服务器在处理过程中需要向客户端请求更多数据。
使用线程可以简化这些协议的实现。
主动对象
我们可以将端口对象与类结合起来,形成主动对象。一个主动对象是一个端口对象,但其行为由一个类定义。它运行在自己的线程中。
与被动对象(如普通Java对象)相比,主动对象在并发环境中具有巨大优势:
- 被动对象被多个线程调用时,方法执行会重叠,导致竞态条件,需要使用锁等机制进行保护,非常复杂。
- 主动对象由于有自己的线程,所有发送给它的消息都在其线程中串行处理,因此从多个线程调用时是天然安全的。
因此,在并发系统中,应优先使用主动对象。
实例比较:Flavius Josephus 问题

为了对比主动对象编程和声明式数据流编程,我们以Flavius Josephus问题为例。
问题描述:n个人围成一圈,从第一个人开始报数,每数到第k个人就将其淘汰,然后从下一个人继续报数,直到只剩一人。求幸存者的位置。
主动对象解决方案
我们将每个人建模为一个主动对象(Victim类),它们形成一个环。协议消息为kill(X, S),其中X是已遍历的活人数计数,S是剩余活人数。
- 初始向第一个人发送
kill(1, n)。 - 每个活着的对象收到
kill(X, S)后:- 如果
S==1,自己是幸存者,记录结果。 - 如果
X % k == 0,淘汰自己,向下一人发送kill(X+1, S-1)。 - 否则,向下一人发送
kill(X+1, S)。
- 如果
- 已淘汰的对象只是转发消息。
这个方案通过创建n个主动对象并设置它们的后继来建立环,然后发送起始消息即可。代码直接反映了协议逻辑,清晰且并发安全。

声明式数据流解决方案
我们将在下一讲中看到此问题的声明式数据流解决方案。它将使用流和纯函数式转换,代码会更加简短,但思维模型与主动对象方案截然不同。
总结
本节课中我们一起学习了:
- 数据抽象的核心重要性及其两种主要形式:抽象数据类型和对象。
- 实现真正封装的机制,包括
Wrap/Unwrap函数对、不可伪造的常量(Name)和安全记录(Chunk)。 - 从是否有状态和是否捆绑两个维度区分的四种数据抽象,特别是函数式对象的巧妙之处。
- 多智能体编程的起点:端口对象作为基本构件,结合线程、端口和状态转移函数(本质上是
fold操作)。 - 消息协议的概念以及主动对象如何通过将类与端口对象结合,提供一种安全、优雅的并发编程方式,优于传统的被动对象。
- 通过Flavius Josephus问题的主动对象解决方案,初步实践了多智能体编程的设计思路。
下一讲,我们将继续深入多智能体编程,并查看Josephus问题的声明式数据流解决方案,以对比两种并发范式。
034:Flavius Josephus 问题与多智能体系统


在本节课中,我们将学习两种不同的编程范式——主动对象和确定性数据流——如何解决同一个问题:Flavius Josephus 问题。我们将比较这两种实现方式,并探讨它们各自的优缺点。最后,我们将简要介绍如何构建一个大型的多智能体系统,例如电梯控制系统。

问题回顾
上一节我们介绍了Flavius Josephus问题。现在我们来回顾一下其定义。
我们有 N 个受害者围成一个圈,编号从 1 到 N。每数到第 K 个人,该受害者就被淘汰。我们持续这个过程,直到只剩下最后一人。问题的目标是找出最后幸存者的编号。
我们定义了一个多智能体协议来解决这个问题。每个受害者都是一个智能体,可以接收和发送消息,从而在环中传递淘汰指令。
主动对象实现
上一节我们介绍了主动对象版本。本节中,我们来看看其代码实现,以加深理解。
首先,我们需要定义一个创建主动对象的抽象。这个抽象结合了对象、端口和线程三个概念。
fun {NewActive Class Init}
Obj = {New Class Init}
P
in
thread S in
{NewPort S P}
for M in S do {Obj M} end
end
proc {$ M} {Send P M} end
end
这段代码创建了一个主动对象。它在一个线程中运行,持续从端口流 S 中读取消息 M 并执行 {Obj M}。proc {$ M} {Send P M} end 是返回的“对象”,用于向该主动对象发送消息。
以下是受害者类的定义:
class Victim
attr id alive step last succ
meth init(I K S Succ)
id := I
alive := true
step := K
last := _
succ := Succ
end
meth setSucc(S) succ := S end
meth kill(S)
if @alive then
if S==1 then
last := @id
elseif (S mod @step)==0 then
alive := false
{@succ kill(S-1)}
else
{@succ kill(S)}
end
else
{@succ kill(S)}
end
end
end
id:受害者编号。alive:布尔值,表示是否存活。step:淘汰步长K。last:用于存储最后幸存者编号。succ:指向环中下一个受害者的引用。
kill 方法是协议的核心:
- 如果
S==1,则当前受害者为最后幸存者,记录其id。 - 如果
S mod K == 0,则淘汰当前受害者(alive := false),并向后继发送kill(S-1)消息。 - 否则,向后续发送
kill(S)消息。 - 如果受害者已死亡,则直接传递消息。
以下是构建环并启动程序的函数:
fun {Josephus N K}
Last
A = {Array.new 1 N _}
in
for I in 1..N do
A.I := {NewActive Victim init(I K _)}
end
for I in 1..N-1 do
{A.I setSucc(A.(I+1))}
end
{A.N setSucc(A.1)}
{A.1 kill(N)}
{Wait Last}
Last
end
- 创建 N 个主动对象(受害者)。
- 将它们连接成环:设置每个对象的
succ属性。 - 向第一个受害者发送初始消息
kill(N)启动淘汰过程。 - 等待
Last变量被绑定(即找到幸存者)并返回。
运行示例:{Browse {Josephus 40 3}} 输出 28。
这个实现简单直观,但效率不高。随着淘汰进行,环中会有大量已死亡的“僵尸”对象,消息需要在它们之间无效传递很多次。
确定性数据流实现
上一节我们看到了主动对象的实现。本节中,我们来看看另一种截然不同的范式:确定性数据流。
在数据流版本中,每个受害者被建模为一个流对象。它接收一个输入流(消息),并根据协议产生一个输出流。受害者之间通过流连接成环。
以下是核心的 victim 函数:
fun {Victim Xs I}
case Xs of kill(X)|Xr then
if X==1 then
Last = I
nil
elseif (X mod K)==0 then
kill(X+1)|{Victim Xr I}
else
kill(X)|{Victim Xr I}
end
end
end
Xs是输入流。I是受害者编号。- 使用
case语句从流中读取消息。 - 协议逻辑与主动对象版本对应:
- 如果
X==1,绑定Last为I,并返回nil表示流终止。 - 如果
X mod K == 0,输出kill(X+1),并递归调用{Victim Xr I}。这表示受害者存活并继续处理后续消息。 - 否则,输出
kill(X),并递归调用{Victim Xr I}。受害者存活。 - 关键点:如果受害者应被淘汰,我们只需返回
kill(X+1)|Xr而不进行递归调用。这意味着该函数实例终止,受害者从处理链中“消失”。
- 如果
以下是构建管道和环的函数:
fun {Pipe L H F Xs}
if L =< H then {F Xs L} else Xs end
end
fun {Josephus N K}
Last
fun {F Xs I} {Victim Xs I} end
Zs
in
Zs = {Pipe 1 N F kill(1)|Zs}
Last
end
Pipe函数递归地从L到H创建一串连接的流对象(受害者)。F是创建单个受害者的函数。Zs = {Pipe 1 N F kill(1)|Zs}是关键:它创建了一个从 1 到 N 的管道,并将管道的输出流Zs连接回其输入流kill(1)|Zs,从而形成一个环。kill(1)是启动消息。
运行示例:{Browse {Josephus 40 3}} 同样输出 28。
这个实现非常简洁,并且自动实现了“短路”优化:当受害者被淘汰时,通过不进行递归调用,它自然地从流处理链中被移除,消息无需再经过它。
两种实现的比较
我们已经分别介绍了主动对象和确定性数据流的实现。现在我们来系统地比较一下这两种范式。
以下是两种实现方式的核心对比:
| 特性 | 主动对象版本 | 确定性数据流版本 |
|---|---|---|
| 每个受害者 | 一个主动对象(类实例+线程) | 一个流对象(函数+线程) |
| 协议实现 | 类中的 kill 方法 |
victim 函数中的 case 语句 |
| 初始化 | 需显式设置 succ(和 pred)指针构建环 |
通过 Pipe 函数和流连接构建环 |
| 移除死亡者 | 需显式发送消息更新前驱和后继指针(“短路”协议) | 自动移除(不进行递归调用) |
| 代码复杂度 | 较高,需处理指针和并发消息顺序 | 较低,更声明式,更紧凑 |
| 并发模型 | 非确定性,需考虑消息交错(依赖 FIFO 属性) | 确定性,无竞争条件 |
| 适用场景 | 通用并发编程,工业界常用 | 确定性并发算法,表达更简洁 |
主动对象“短路”优化详解:
为了让主动对象版本也能移除死亡节点,需要实现更复杂的“短路”协议:
- 每个受害者需要同时持有
succ(后继)和pred(前驱)指针。 - 当受害者被淘汰时,它必须向它的前驱发送
setSucc(MySucc)消息,并向它的后继发送setPred(MyPred)消息,从而在环中绕过自己。 - 必须确保在淘汰消息绕环一周返回之前,这些指针更新消息已被处理。这依赖于端口FIFO(先进先出) 的属性:从同一个线程发送到同一个端口的消息,其接收顺序与发送顺序一致。通过精心设计消息发送顺序,可以保证环的正确修复。
确定性数据流的优势:
其代码量约为主动对象版本的一半,且更易理解。移除死亡节点在语义上非常自然(停止递归)。对于像 Josephus 问题这样的确定性协议,它是更优的选择。
迈向多智能体系统
通过比较,我们看到了不同并发范式的特点。本节作为过渡,将引入一个更复杂的现实世界例子:电梯控制系统。
许多现实世界的程序本质上是多智能体系统:由大量相互发送消息的智能体组成,每个智能体拥有内部状态。电梯系统就是一个经典例子:
- 组件:多个电梯轿厢、多个楼层、用户、中央控制器。
- 并发:用户随机在楼层外按按钮、在轿厢内按目标楼层按钮。
- 消息:呼叫电梯、请求前往楼层、到达通知、移动指令等。
- 目标:设计一个正确、高效、无死锁的系统。
如何设计这样的系统?
- 建模:将每个电梯、楼层、控制器建模为智能体(如主动对象)。
- 定义协议:精确定义智能体之间所有可能的交互消息。
- 绘制状态图:为每个智能体类型绘制状态转换图。状态图清晰描述了智能体在不同状态下接收到不同消息时应如何响应并转换到新状态。
- 实现:根据状态图编写每个智能体的代码。
我们将在后续课程中详细构建这个电梯控制系统,展示如何使用状态图和主动对象范式来设计和实现一个大型、并发、非确定性的多智能体程序。
总结
本节课中我们一起学习了:
- 回顾了 Flavius Josephus 问题及其多智能体协议。
- 深入分析了主动对象实现,包括其基本版本和需要处理指针更新的“短路”优化版本。
- 探讨了确定性数据流实现,其利用流和递归函数,以更声明式、更简洁的方式表达了相同的协议,并自动实现了优化。
- 系统比较了两种范式,指出了它们在代码复杂度、并发模型和适用场景上的区别。
- 引入了多智能体系统的概念,并以电梯控制系统为例,概述了使用状态图设计复杂并发系统的流程。

通过这个案例,我们看到了解决同一个问题可以有多种编程范式,选择合适的范式能极大影响代码的简洁性、清晰度和正确性。
035:多智能体系统 🧠

概述
在本节课中,我们将学习如何设计和实现正确、大型的多智能体系统。多智能体系统在程序与现实世界交互时至关重要,因为现实世界本质上是并发和分布式的,很少存在纯粹的顺序程序。我们将从基本概念入手,然后将其应用于电梯控制系统,并贯穿整个设计方法学。这实际上并不简单,但我们将一步步完成。本节课也是为下周介绍一个工业级的多智能体系统——Erlang——做铺垫,Erlang 在故障处理方面尤为强大。
基本概念与背景
上一节我们介绍了活动对象或确定性数据流。本节中,我们来看看如何构建真正的、大型且正确的多智能体系统。
多智能体系统涉及对并发对象(我们称之为智能体或并发组件)进行编程。这些智能体之间会到处发送消息。如果处理不当,系统会变得非常混乱,因此我们必须非常小心。这就是为什么我们要展示一种良好的实现方法。
我们从确定性数据流开始,它非常简单,因为它是确定性的、函数式的。多智能体编程类似于函数式编程,确定性数据流就像是一种非常简单的、使用流对象的多智能体系统。但这还不够,因为当程序与现实世界交互时,我们经常需要非确定性行为。现实世界的事件(如电梯系统中人们随时按下按钮)可能在任何时间发生,这使得系统比确定性数据流复杂得多。
多智能体系统的挑战
多智能体系统之所以困难,是因为存在交错执行。所有智能体同时、并发地独立执行。根据交错语义,它们在任何时刻都可能执行步骤,而系统必须对所有可能的交错情况都保持正确。这非常困难,因为可能性是指数级的。许多系统因此存在大量缺陷,因为人们没有充分认识到其复杂性。
如今,我们有了构建这类系统的技术,甚至有了验证工具。例如,巴黎某些完全自动化的地铁线路就使用了类似的方法,并通过软件工具进行了形式化验证,以确保在所有交错情况下都是正确的。验证与测试不同:测试是在各种条件下运行程序,但这还不够,因为有些罕见情况可能每百万次执行才出现一次;验证则能保证正确性。
形式化概念与基本操作
首先,我们介绍一些多智能体系统的形式化概念、基本操作链接、示例和方法论,然后应用到电梯控制系统的例子中。
一个并发组件本质上定义了智能体的规格,类似于一个类。当你实例化一个并发组件时,就创建了一个智能体实例。我们的示例将使用端口对象(在 Oz 中),这与活动对象非常接近。
以下是四个基本操作:
- 实例化:创建组件的实例。
- 组合:用其他组件构建新的组件。
- 连接:定义组件之间的消息通道(谁可以向谁发送消息)。
- 限制:在新组件内部隐藏某些链接,类似于词法作用域。
当两个智能体连接时,它们之间就存在一个链接。链接的类型非常重要,因为它决定了是确定性的还是非确定性的,以及是使用流对象还是端口对象。
链接类型
链接类型主要有两个区分维度:
-
单源 vs 多源:
- 单源链接:发送方始终是同一个智能体。确定性数据流只使用单源链接,因此是完全函数式和声明式的。
- 多源链接:多个发送方可以向同一个目的地发送消息。这引入了非确定性,因为消息到达的顺序取决于时间,可能导致相同程序多次运行结果不同。
-
单次 vs 多次:
- 单次通道:只能发送一条消息(类似于单次赋值变量)。这对于同步操作非常有用,可以保证只收到一条消息。
- 多次通道:可以发送多条消息(即流)。
工业级语言(如 Erlang)通常只使用多源、多次的链接。但我们应该尽可能使用限制性最强的链接类型,以减少错误。
设计方法学
设计多智能体程序比设计顺序程序困难得多,因为所有交互都可能产生不同的结果和隐藏的错误。历史上曾有过因并发缺陷导致严重后果的案例。因此,遵循一个严格的方法学至关重要。
以下是设计方法学的步骤:
- 非形式化规格说明:首先在头脑中明确系统要做什么。
- 定义组件:确定系统中有哪些组件以及它们之间如何通信。
- 定义消息协议:明确组件间交换的消息类型和格式。
- 定义状态图:为每个组件(智能体)定义其内部状态和状态转换图。这是最困难的部分。
- 编写代码:在完成状态图设计后,最后才编写代码。
- 测试与验证:进行大量测试,并使用形式化方法(如模型检测)验证系统属性。
智能体是具有内部状态的状态机。我们使用有限状态自动机来建模。状态用圆圈表示,状态转换用箭头表示。箭头上的标注格式为 条件 / 动作:左边是触发转换的条件(如接收到特定消息),右边是转换时执行的动作(如发送消息或执行实际世界动作)。
应用实例:电梯控制系统
现在,我们将上述方法学应用于电梯控制系统。我们将从非形式化规格说明开始,逐步完成设计。
非形式化规格说明与组件识别
系统包含电梯井、楼层和用户。组件主要有四类:
- 用户:按下按钮的人(我们只建模其行为,不编写具体代码)。
- 楼层:每个楼层有一个呼叫按钮。
- 电梯:内部有目标楼层按钮。
- 控制器:控制电梯马达(每个电梯井一个)。
消息协议
组件间的通信协议如下:
- 用户 -> 楼层:发送
call消息(在楼层按下呼叫按钮)。 - 用户 -> 电梯:发送
call(F)消息(在电梯内按下目标楼层按钮)。 - 楼层 -> 电梯:发送
call(F)消息(楼层呼叫电梯)。 - 电梯 -> 楼层:到达楼层时发送
arrive消息。 - 楼层 -> 电梯:发送
ack消息(允许电梯离开)。 - 电梯 -> 控制器:发送
step(D)消息(请求移动到目标楼层 D)。 - 控制器 -> 电梯:发送
at(F)消息(通知电梯已到达楼层 F)。
状态图设计
我们将为控制器、楼层和电梯分别设计状态图。
控制器状态图
控制器状态相对简单,主要状态为“停止”和“运行”。它接收 step(D) 消息,如果当前楼层 F 不等于目标 D,则启动计时器(模拟马达运行),一段时间后到达新楼层,发送 at(F) 消息,并返回“停止”状态。
楼层状态图
楼层状态包括“未呼叫”、“已呼叫”和“门已开”。它接收 call 和 arrive 消息。当从“未呼叫”收到 call 时,随机选择一部电梯发送 call(F) 消息,并进入“已呼叫”状态。当收到 arrive 消息时,启动开门计时器,进入“门已开”状态。计时器结束后,发送 ack 消息,并返回“未呼叫”状态。必须确保在所有状态下都能处理可能到达的消息。
电梯状态图
电梯状态最复杂,因为它需要记住一个要访问的楼层序列(时间表)。主要状态包括“空闲”、“移动”和“等待门关闭”。电梯维护一个时间表列表。当收到 call(N) 消息时,如果 N 不在时间表中,则将其添加到列表末尾。只要时间表非空,电梯就会向控制器发送 step 消息前往第一个目标楼层。到达后,通知楼层,等待 ack 消息,然后从时间表中移除该楼层,并继续处理下一个。这是一个包含嵌套循环的复杂状态机。
实现与测试


根据状态图,我们可以将其翻译成端口对象的代码。代码结构通常是两层嵌套的 case 语句:外层匹配状态,内层匹配消息。

实现后,我们进行模拟测试,例如创建一栋有若干楼层和电梯的建筑,模拟用户在不同楼层呼叫电梯和在电梯内选择楼层,观察系统运行是否符合预期。
更智能的协议
基本的电梯调度策略(将新请求添加到列表末尾)可能不是最优的。我们可以引入更智能的协议,例如:
- 协商协议:楼层呼叫电梯时,先询问所有电梯的位置和状态,然后选择最优的一部。
- 电梯算法:电梯沿一个方向移动,服务所有该方向上的请求,直到该方向没有更多请求,然后调转方向。这类似于磁盘调度算法,可以减少不必要的移动。
验证与推理
除了测试,我们还需要对系统属性进行推理验证。例如,可以证明:如果电梯的时间表中包含某个楼层 F,那么电梯最终一定会到达 F。这个证明依赖于时间表的更新策略(如队列而非栈),以确保不会发生“饥饿”现象。
总结
本节课我们一起学习了多智能体系统的设计与实现。我们了解到,由于现实世界的并发性和非确定性,多智能体系统比顺序程序或确定性数据流系统复杂得多。为了构建正确的系统,我们需要遵循严格的方法学:从非形式化规格说明和组件识别开始,定义清晰的消息协议,设计详细的状态图,最后才编写代码。此外,必须结合严格的设计、充分的测试和形式化验证这三根支柱,才能最大程度地保证系统的正确性。通过电梯控制系统的实例,我们实践了从设计到模拟的完整流程,并探讨了如何通过更智能的协议来优化系统。下周,我们将学习一个工业级的、擅长处理故障的多智能体系统语言——Erlang。
036:多智能体编程与 Erlang


在本节课中,我们将学习如何使用 Erlang 构建健壮的多智能体系统。Erlang 是一种专为高并发、高可用性和容错性设计的编程语言,广泛应用于电信、金融和游戏服务器等领域。我们将探讨其核心概念,包括进程、消息传递、链接、监督树和动态代码更新,并了解其“任其崩溃”的独特哲学。
10.1:Erlang 概述与动机
上一节我们介绍了多智能体系统的基本复杂性。本节中,我们来看看一个工业级的解决方案:Erlang。
Erlang 由瑞典的爱立信公司于 1986 年开发,比 Java 更早。它作为一个名为 OTP 的平台发布,至今仍由爱立信的团队维护。Erlang 是免费的,拥有一个活跃的社区生态系统。
Erlang 的核心是多智能体编程。程序由非常轻量级的进程组成,类似于线程,但彼此不共享任何数据。所有数据在进程间传递时都会被复制。这种“无共享”原则是实现容错的关键,因为一个进程的失败不会影响其他进程。
Erlang 支持构建长寿命、健壮的分布式系统。其哲学是“任其崩溃”,这与 Java 等系统试图修复错误的做法不同。事实证明,这种哲学在构建容错系统方面更为有效。
一个著名的例子是爱立信的 AXD301 ATM 交换机,它包含 170 万行 Erlang 代码,其营销部门声称其可用性达到 99.9999999%。虽然这个数字可能有些夸张,但它确实是一个极其高可用的系统。
Erlang 也被用于许多大型在线游戏的后台基础设施,例如《使命召唤》系列,因为它即使在极高负载下也能持续运行。
10.2:Erlang 核心概念回顾
上一节我们了解了 Erlang 的用途。本节中,我们来回顾其核心编程概念。
Erlang 是一种多智能体语言。每个进程内部是纯函数式的,类似于我们之前学过的端口对象。变量是单次赋值。进程内部支持高阶函数和模式匹配。
Erlang 使用动态类型系统,但它是强类型的,这意味着语言会强制执行类型规则,但类型检查在运行时进行。虽然静态类型有助于在编译时捕获更多错误,但 Erlang 的动态类型系统与容错设计相结合,使其能够构建出比许多静态类型语言更健壮的系统。
源代码被组织成模块。一个模块导出函数,并可以导入其他模块的函数。
以下是模块示例:
-module(math).
-export([area/1]).
-import(lists, [map/2]).
area(Radius) -> 3.14159 * Radius * Radius.
10.3:消息传递与邮箱
上一节我们介绍了 Erlang 的基本语法和模块。本节中,我们深入探讨其消息传递机制。
Erlang 程序由进程组成。使用 spawn 函数可以轻松创建新进程,该函数接受一个定义进程行为的函数作为参数。spawn 返回一个进程标识符,用于向该进程发送消息。
使用 ! 操作符发送消息,消息是异步发送的,并且所有数据都会被复制。
接收消息使用 receive 语句。每个进程都有一个邮箱,用于按到达顺序存放消息。receive 的关键特性是,它可以根据模式匹配从邮箱中提取消息,而不必按顺序提取。这提供了比流或端口对象更大的灵活性。
receive 的语义如下:
- 如果邮箱为空,则阻塞。
- 如果邮箱不为空,则按顺序检查邮箱中的第一条消息。
- 按顺序尝试
receive语句中的每个模式。 - 如果找到匹配的模式,则取出该消息并执行相应代码。
- 如果没有消息匹配任何模式,则再次阻塞。
- 未匹配的消息会留在邮箱中。
这允许一个进程处理多种类型的消息,但也需要注意避免内存泄漏(即消息永远留在邮箱中)。
进程可以注册一个易记的原子名称,而不是使用进程标识符。这有助于保持接口稳定,即使进程崩溃并重启。
10.4:进程链接与容错基础
上一节我们学习了如何发送和接收消息。本节中,我们来看看 Erlang 实现容错的核心原语:进程链接。
两个进程可以通过 link 操作链接在一起。链接是对称且双向的。当一个进程终止时(无论是正常终止还是崩溃),它会向所有与其链接的进程发送一个退出信号。
默认情况下,如果一个进程接收到一个非正常的退出信号(即链接的进程崩溃了),它自己也会终止。这意味着,默认情况下,链接组中的一个进程崩溃会导致整个组崩溃。这看似严苛,但将故障统一映射到一个简单的失败状态。
可以通过设置 trap_exit 标志来改变默认行为。如果一个进程设置了 trap_exit 为 true,那么退出信号会被转换为一条普通的消息发送到该进程的邮箱,而该进程不会崩溃。这样,该进程就可以观察到其他进程的失败并采取相应措施。
监督者 就是利用这个机制构建的:工作者进程相互链接,而监督者进程设置 trap_exit 来监控工作者,并在它们崩溃时决定如何恢复。
还有一个称为 monitor 的操作,它是链接的非对称版本。例如,在客户端-服务器模型中,如果服务器崩溃,我们希望客户端终止;但如果客户端崩溃,服务器应继续运行。
这些原语为构建容错系统提供了基础。著名的分布式数据库 Riak 和丹麦的全国医疗保健数据库就是用 Erlang 构建的,它们都依赖于这种链接和监督机制。
10.5:分布式系统与动态代码更新
上一节我们学习了进程链接。本节中,我们探讨 Erlang 的分布式特性和另一个关键功能:动态代码更新。
Erlang 系统可以分布在多个计算节点上运行。消息传递对于进程位置是透明的,你无法从进程标识符看出进程是在本地还是远程节点上。可以使用 spawn 的变体在特定节点上创建进程。
Erlang 运行时会自动在节点间建立连接(通常是全连接的),这适合集群环境。对于更广泛的互联网应用,新版本的 Erlang 允许更可扩展的连接方式。
对于需要永不停止的系统(如电信交换机或医疗数据库),软件更新是一个挑战。Erlang 支持动态代码更新。其虚拟机允许每个模块同时存在两个版本:旧版本和新版本。
新创建的进程会自动使用最新版本的代码。已经运行的进程可以继续使用旧版本。进程可以在一个合适的时机(例如,一个电话呼叫结束后)显式切换到新版本的代码。这允许系统在更新期间持续运行。
除了系统级的支持,还可以使用高阶函数进行更细粒度的更新。例如,一个服务器进程可以接收一条消息来替换其内部的处理函数,而无需更改模块代码。
10.6:Erlang/OTP 哲学与行为模式
上一节我们介绍了所有底层原语。本节中,我们来看看 Erlang 如何通过 OTP 框架将这些原语组织起来,让编写健壮软件变得简单。
Erlang 的哲学基于几个原则:
- 错误不可避免:必须处理错误。
- 故障隔离:通过“无共享”实现组件间的强隔离。
- 快速失败:一旦出错,立即停止,不要试图继续运行在一个可能已损坏的状态。
- 另一个进程负责恢复:错误恢复应由专门的监督者进程处理,而不是出错的进程自己尝试修复。
这些原则总结为著名的口号:“任其崩溃”和“不要进行防御性编程”。
OTP 将这些理念实现为一系列可重用的 行为。行为是一种并发设计模式库,它封装了所有复杂的部分:并发、容错、代码更新、启动/关闭等。
程序员只需编写“回调模块”,提供具体的业务逻辑。复杂的部分则由“行为模块”处理。这极大地简化了健壮并发程序的开发。
OTP 提供了五种标准行为:
gen_server:用于客户端-服务器模型。gen_event:用于事件处理。gen_fsm:用于有限状态机。application:用于将多个组件打包成一个应用。supervisor:用于构建容错的监督树。
所有这些行为都设计为可以嵌入监督树中。
容错的另一个支柱是 稳定存储。OTP 提供了不同级别的稳定存储:
- ETS/DETS:内存表和磁盘表,非常快,适合简单数据。
- Mnesia:完整的分布式事务数据库,速度较慢但功能强大。
Erlang 还提供了强大的测试工具,包括单元测试、系统测试、故障注入和一个名为 Dialyzer 的强大的静态分析工具。
10.7:构建通用服务器示例
上一节我们介绍了 OTP 行为的概念。本节中,我们通过一个例子来具体看看如何构建一个通用的服务器。
我们将构建一个简单的“频率分配器”服务器,用于管理电话信道。客户端可以请求分配一个空闲频率,使用完毕后释放它。
我们希望将代码分为两部分:
- 通用部分:处理服务器进程的生成、消息循环、回复发送等。
- 特定部分:定义服务器的状态(哪些频率空闲/已分配)和处理请求的业务逻辑(分配、释放)。
以下是频率服务器的回调模块框架:
-module(frequency).
-export([start/0, stop/0, allocate/0, deallocate/1]).
-export([init/0, terminate/1, handle/2]).
% 客户端 API
start() -> ...
stop() -> ...
allocate() -> ...
deallocate(Freq) -> ...
% 服务器回调
init() -> {Free, Allocated} = {[10,11,12,13,14,15], []}.
terminate(_State) -> ok.
handle(allocate, {Free, Allocated}) ->
case Free of
[] -> {reply, {error, no_frequency}, {Free, Allocated}};
[F|Free1] -> {reply, {ok, F}, {Free1, [F|Allocated]}}
end;
handle({deallocate, Freq}, {Free, Allocated}) ->
...
程序员主要编写 init、terminate 和 handle 这些回调函数。
通用服务器模块则包含固定的模式:
-module(generic_server).
-export([start/2, stop/1, call/2]).
start(Mod, Args) ->
spawn(fun() -> init(Mod, Args) end).
stop(Server) -> ...
call(Server, Request) ->
Server ! {request, self(), Request},
receive {reply, Reply} -> Reply end.
init(Mod, Args) ->
State = Mod:init(Args),
loop(Mod, State).
loop(Mod, State) ->
receive
{request, From, Request} ->
{reply, Reply, NewState} = Mod:handle(Request, State),
From ! {reply, Reply},
loop(Mod, NewState);
stop ->
Mod:terminate(State)
end.
这个简单的通用服务器展示了如何将通用逻辑与特定逻辑分离。真实的 OTP gen_server 要复杂得多,但原理相同。
10.8:监督树概念
上一节我们构建了一个通用服务器。本节中,我们探讨另一个核心行为:监督者。
监督树是一个进程层次结构,用于管理其他进程(工作者)的启动、停止和重启。其核心思想是:大多数错误是暂时的(如网络丢包),简单的重试往往就能解决问题。
监督树包含两种节点:
- 工作者节点:执行实际工作的进程。
- 监督者节点:监控其子节点(可以是工作者,也可以是下级监督者)的进程。
监督者采用简单的重启策略:
- one_for_one:如果一个子进程崩溃,只重启该进程。适用于子进程相互独立的情况(如每个进程处理一个独立的电话连接)。
- one_for_all:如果一个子进程崩溃,重启所有子进程。适用于子进程紧密协作,需要保持一致状态的情况。
监督者本身也可能失败,因此通常会有多层监督树。顶层的监督者非常简单,几乎不会崩溃。如果底层监督者在短时间内重启子进程过于频繁,超过了限制,它自己也会终止,并由其上级监督者采取更进一步的措施(如重新配置整个应用)。
这种层次化的“任其崩溃”和“简单重启”策略,在实践中被证明能非常有效地构建高可用性系统。
总结
本节课中,我们一起学习了 Erlang 语言及其 OTP 框架,这是构建高并发、高可用、容错分布式系统的强大工具。我们从其“无共享”和“任其崩溃”的哲学出发,探讨了进程、消息传递、邮箱、进程链接等核心原语。我们了解了如何通过监督树和稳定存储这两个支柱来实现容错。最后,我们看到了 OTP 如何通过封装了复杂性的行为模式,让开发者能够专注于业务逻辑,从而相对轻松地构建出工业级的健壮软件。Erlang 的设计思想对现代分布式系统开发产生了深远影响。
037:共享状态并发 🧵

在本节课中,我们将要学习并发编程的第四种范式:共享状态并发。我们将探讨其核心概念、面临的挑战以及如何通过构建大型原子操作来设计正确的并发程序。
概述
共享状态并发是一种结合了线程和可变状态(我们称之为“单元”)的编程范式。它被广泛使用,但也因其复杂性而臭名昭著。本节课,我们将了解其工作原理、为何如此困难,并学习如何通过构建锁、监视器和事务等原子操作来管理这种复杂性。
共享状态并发的挑战
上一节我们介绍了多种并发范式,本节中我们来看看共享状态并发的核心难题。
假设有两个线程 T1 和 T2,它们都对同一个单元 C 执行一系列读写操作。调度器可以自由地在这些操作之间切换执行顺序,这就产生了“交错执行”。
关键问题:如果每个线程执行 n 次操作,那么可能的交错执行总数是组合数 C(2n, n)。使用斯特林公式近似,这个数量级约为 2^(2n) / sqrt(πn)。这是一个关于 n 的指数级增长的数字。
结论:程序必须保证在所有可能的指数级数量的交错执行下都是正确的。即使只有一个交错执行存在错误,程序也可能很快崩溃。我们不能依赖特定的调度时序,因为环境因素可能导致任何交错发生。
为了应对这一挑战,我们主要依靠三种方法:
- 测试:只能覆盖一小部分可能的交错。
- 验证(如模型检测):非常有效,但受限于状态空间爆炸问题。
- 通过设计保证正确性:这是最根本的方法,即设计程序结构使其本质上正确。本节课将重点介绍这种方法。
通过设计保证正确性:减少交错
设计正确并发程序的基本技术是减少需要考虑的交错数量。不同的范式有不同的实现方式:
- 确定性数据流:最佳情况。大多数操作是确定性的,所有交错产生相同结果。非确定性仅通过少量“端口”引入,从而将需要检查的交错限制在很少的数量。
- 多智能体编程(如 Erlang):每个端口对象内部是顺序执行的。交错只发生在消息接收之间,而不是每条指令之间,这大大减少了交错数量。
- 共享状态并发:我们将许多细粒度的操作组合成大型的原子操作(如锁)。调度器不能在原子操作内部切换线程,从而将交错限制在原子操作的边界上。
本节我们将聚焦于共享状态并发,并学习如何构建这些大型原子操作。
核心构件:单元
共享状态并发基于可变状态,我们称之为“单元”。让我们快速回顾其核心操作。
一个单元是一个具有身份(名称)和内容(值)的“盒子”。身份是常量,内容可以改变。
三个基本操作:
- 创建:
C = {NewCell A}(创建初始内容为 A 的新单元,C 绑定到单元的名称) - 赋值:
C := B(将单元 C 的内容替换为 B) - 访问:
Z = @C(将 Z 绑定到单元 C 的当前内容,不改变内容)
一个重要概念:别名。两个变量可以指向同一个单元(共享相同的单元名称)。这意味着通过一个变量修改内容,通过另一个变量访问时会看到修改。
用于并发的第四个操作:交换
这是一个原子操作,对于并发编程至关重要。
{Exchange C X Y}
其语义等价于原子地执行 X = @C 然后 C := Y。调度器不能在读和写之间中断,这保证了操作的原子性。现代处理器架构都提供了类似的机器指令(如测试并设置、比较并交换)。
锁:基本的原子操作
现在,我们来看看如何构建大型原子操作。最基本的形式是锁。
锁是一种语言构造,它保证最多只有一个线程能执行受特定锁保护的代码段(称为临界区)。如果一个线程在临界区内,其他试图进入同一锁保护区域的线程将被阻塞(等待),直到当前线程退出。


抽象接口:
L = {NewLock}:创建一个新锁 L。lock L then <statement> end:用锁 L 保护<statement>。同一个锁可以保护程序中多个不同的语句块。
可重入性:
- 简单锁:如果线程已经持有锁,再次尝试获取同一把锁会导致死锁。
- 可重入锁:允许已经持有锁的线程再次进入。这是更实用、更正确的概念,Java 的
synchronized关键字实现的就是可重入锁。
使用锁的示例:并发队列
让我们看一个使用锁实现并发队列的例子。队列支持 insert 和 delete 操作,多个线程可以安全地调用它们。
队列实现(使用锁):
fun {NewQueue}
C = {NewCell q(0 X X)} % 初始状态:0个元素,前后指针均为X
L = {NewLock}
proc {Insert X}
{lock L then
N F B2 % 读取当前状态
q(N F B)=@C
in
B=X|B2 % 在差异列表末尾添加X
C:=q(N+1 F B2) % 写回新状态
end}
end
proc {Delete ?X}
{lock L then
N F2 B
q(N X|F2 B)=@C % 从差异列表头部取出X
in
C:=q(N-1 F2 B) % 写回新状态
end}
end
in
queue(insert:Insert delete:Delete)
end
关键点:Insert 和 Delete 操作中的“读-修改-写”步骤被包裹在 lock L then ... end 中。这确保了每个操作都是原子的,从而维护了队列的不变式(如“队列内容等于所有插入减去所有删除”)。
锁的实现:令牌传递算法
理解了锁的用法后,我们来看看如何仅使用单元和交换操作来实现一个锁。我们将采用令牌传递的技术。
核心思想:锁关联一个唯一的“令牌”。只有持有令牌的线程才能进入临界区。令牌在等待的线程之间传递。
简单锁(不可重入)的实现:
fun {NewLock}
Token = {NewCell unit} % 令牌,初始可用(unit)
proc {Lock P}
Old New
in
{Exchange Token Old New} % 1. 获取“票据”
{Wait Old} % 2. 等待令牌可用(Old被绑定)
try {P} finally New=unit end % 3. 执行操作,最后释放令牌
end
in
Lock
end
工作原理:
Exchange是原子操作,线程通过它获取一个“票据”(变量Old)。Wait Old:如果Old是unit(令牌可用),线程立即继续;如果Old是未绑定的变量(令牌被占用),线程在此等待。- 线程执行过程
P(临界区)。 finally子句确保无论P正常结束还是抛出异常,都会执行New=unit,这将令牌传递给下一个等待的线程(因为New和下一个线程的Old是同一个变量)。
使其可重入:
简单锁的问题在于,同一个线程无法再次进入。为了实现可重入,我们需要:
- 线程身份识别:
{Thread.this}返回当前线程的唯一标识。 - 一个额外的单元来记录当前持有锁的线程。
在尝试获取锁时,先检查当前线程是否已经是持有者。如果是,则直接执行操作,无需经过令牌获取流程。
可重入锁的实现(概念):
fun {NewReentrantLock}
Token = {NewCell unit}
CurrentThread = {NewCell unit} % 记录当前持有线程
proc {Lock P}
if {Thread.this} == @CurrentThread then
{P} % 已经是持有者,直接执行
else
Old New
in
{Exchange Token Old New}
{Wait Old}
CurrentThread := {Thread.this} % 设置持有者
try {P}
finally
CurrentThread := unit % 清除持有者
New=unit % 传递令牌
end
end
end
in
Lock
end
关键顺序:CurrentThread 的赋值必须在 Wait Old 之后(进入临界区后),而清除必须在 New=unit 之前(离开临界区前)。顺序错误会破坏锁的互斥保证。
元组空间:介于共享状态和消息传递之间
最后,我们介绍一个有趣的概念:元组空间。它像一个共享的多元组集合,兼具共享内存和消息传递的特点。
基本操作:
{TS write(T)}:向元组空间 TS 写入一个元组 T(令牌)。{TS read(L T)}:从 TS 中读取并移除一个标签为 L 的元组,绑定到 T。如果没有,则阻塞等待。{TS read(L T B)}:非阻塞读取。B 返回布尔值,指示是否成功读取。
特点:
- 发送者不知接收者:生产者写入元组,任何消费者都可以读取,实现了解耦。
- 基于模式匹配:读取操作可以指定标签进行筛选。
- 应用:发布-订阅系统的底层模型。
使用元组空间实现队列:
fun {NewQueue}
TS = {NewTupleSpace}
{TS write(q(0 X X))} % 初始状态作为一个元组放入空间
proc {Insert X}
{TS read(q(N F B) $)} % 取出状态元组
{TS write(q(N+1 F X|B))} % 写入新状态元组
end
proc {Delete ?X}
{TS read(q(N X|F B) $)} % 取出状态元组并解构出X
{TS write(q(N-1 F B))} % 写入新状态元组
end
in
queue(insert:Insert delete:Delete)
end
优点:无需显式锁,因为 read 操作会取出元组,其他线程会自然阻塞在空的元组空间上,直到新状态被 write 回去。这提供了内在的同步机制。
总结
本节课中我们一起学习了共享状态并发:
- 核心挑战:线程与可变单元结合导致指数级数量的交错执行,使得正确性难以保证。
- 解决方案:通过设计减少交错,核心是构建大型原子操作。
- 锁:最基本的原子操作构造,通过互斥确保临界区代码原子执行。我们学习了其使用、并通过令牌传递算法实现了可重入锁。
- 其他原子操作:还简要介绍了监视器(锁 + 条件等待)和事务(原子操作 + 中止/提交能力)的概念。
- 元组空间:一种有趣的并发模型,以共享多元组集合的形式,结合了消息传递和共享状态的特点,常用于发布-订阅系统。
共享状态并发虽然应用广泛,但设计正确的程序需要格外小心。锁是基础,但下周我们将看到,监视器引入了更多复杂性。理解这些构建块的原理,是编写健壮并发代码的关键。
038:监视器(Monitors)


在本节课中,我们将学习一种用于线程协作的并发控制机制——监视器。我们将了解其核心操作、工作原理,并通过一个有界缓冲区的例子来展示其应用。最后,我们会探讨其实现细节和常见的编程模式。
概述
监视器是一种锁的扩展机制,用于在多个线程需要协作访问共享数据(如缓冲区或数据库)时进行协调。与简单的锁不同,监视器允许线程在某些条件不满足时主动等待,并在条件满足时被其他线程唤醒。
监视器的定义与核心概念
上一节我们介绍了锁的基本概念,本节中我们来看看监视器如何扩展锁的功能。
一个监视器本质上是一个锁,但它额外提供了两个核心操作:等待(wait) 和 通知(notify),以及一个内部数据结构——等待集(wait set)。
- 锁(Lock): 保证对共享资源的互斥访问。
- 等待集(Wait Set): 一个存放被挂起(等待)线程的集合。
- 等待(Wait): 当线程调用此操作时,它会被挂起并放入等待集,同时释放持有的锁。
- 通知(Notify): 当线程调用此操作时,它会从等待集中任意唤醒一个线程。被唤醒的线程会尝试重新获取锁,然后从它之前等待的位置继续执行。
重要原则: wait 和 notify 操作必须在持有锁(即在监视器保护的代码块内部)时调用。
监视器的工作原理(Java语义)
为了更好地理解,我们来看一下监视器在Java中的标准工作流程。
假设有两个线程 T1 和 T2 访问同一个监视器保护的对象:
- T1 获取锁并执行。
- T1 遇到某个条件不满足(例如,缓冲区已满),于是调用
wait()。- T1 被挂起,放入等待集。
- T1 释放它持有的锁。
- T2 现在可以获取锁并执行。
- T2 执行某些操作后,使 T1 等待的条件变为满足(例如,从缓冲区取出了一个元素),然后调用
notify()。- 这会将 T1 从等待集中移除并唤醒。注意:此时 T1 并未立即恢复执行,它需要重新竞争锁。
- T2 继续执行,直到释放锁。
- 锁被释放后,T1(以及其他可能竞争的线程)尝试获取锁。当 T1 成功获取锁后,它从之前调用
wait()的位置之后继续执行。
这个流程揭示了 wait 和 notify 协作的关键:wait 会释放锁以允许其他线程进入,而被 notify 唤醒后必须重新获取锁才能继续。
示例:使用监视器实现有界缓冲区
现在,让我们将理论应用于实践,实现一个支持多生产者(put)和多消费者(get)线程的有界缓冲区。
缓冲区规格
- 缓冲区有一个固定容量
N。 put(x)操作:将元素x放入缓冲区。如果缓冲区已满,则调用线程必须等待,直到有空位。get(x)操作:从缓冲区取出一个元素并绑定到x。如果缓冲区为空,则调用线程必须等待,直到有元素可用。
数据结构
我们使用一个环形数组来实现缓冲区,并维护两个索引:
first:指向缓冲区中第一个有效元素。last:指向第一个空闲位置(在最后一个有效元素之后)。I:缓冲区中当前元素的数量。

实现代码(模式)
以下是实现 put 操作的正确模式。get 操作与之对称。
proc {Put X}
{M.lock} % 进入监视器(获取锁)
try
if I >= N then % 条件检查:缓冲区满了吗?
{M.wait} % 等待,释放锁,线程挂起
{Put X} % 被唤醒后,重新尝试整个Put操作(递归)
else
% 执行核心操作
@Buff.@last = X
last := (@last + 1) mod N
I := @I + 1
{M.notifyAll} % 操作完成后,通知所有等待的线程
end
finally
{M.release} % 离开监视器(释放锁)
end
end
关键点分析:
- 条件重验: 线程被
notify唤醒并重新获得锁后,必须再次检查等待条件(I >= N)。因为从它被唤醒到它重新获得锁的这段时间里,其他线程可能已经改变了缓冲区的状态(例如,另一个put线程可能已经填满了缓冲区)。这就是为什么我们使用递归调用{Put X}或等价的while循环来重试。 - 使用
notifyAll: 我们通常使用notifyAll而不是notify,以确保唤醒所有等待在相关条件上的线程。虽然这可能导致一些“惊群效应”(多个线程被唤醒但只有一个能继续),但它能保证正确性,避免某个线程被永久遗漏。
一个错误的实现(Buggy Version)
对比一下下面这个有缺陷的实现:
if I >= N then
{M.wait} % 等待
end
% 直接执行操作
@Buff.@last = X
...
{M.notifyAll}
为什么它是错的?
线程在 wait 处被挂起并释放锁。当它被 notify 唤醒后,会尝试重新获取锁。但在它成功获取锁之前,可能有另一个也被唤醒的 put 线程抢先获取了锁,执行了 put 操作,并再次将缓冲区填满。当第一个线程终于获取到锁时,它跳过了条件检查,直接执行 @Buff.@last = X,此时缓冲区可能已经满了,这会导致数据覆盖或数组越界错误。因此,条件检查必须在持有锁的情况下,紧挨着核心操作之前进行。
监视器的编程模式
从上面的例子中,我们可以总结出使用监视器的通用正确模式。这个模式确保了条件检查和核心操作的原子性。
以下是两种等价的表述:
1. 循环模式(While Loop Pattern)
lock.lock();
try {
while (!condition) { // 必须用while,不能用if
lock.wait();
}
// ... 执行操作 ...
lock.notifyAll(); // 或 lock.notify()
} finally {
lock.unlock();
}
2. 尾递归模式(Tail Recursion Pattern) (Oz风格,无循环)
proc {Method}
{Lock}
try
if {Not Condition} then
{Wait}
{Method} % 尾递归调用,重新尝试
else
% ... 执行操作 ...
{NotifyAll}
end
finally
{Release}
end
end
模式核心: 在持有锁的情况下,如果条件不满足,就等待。被唤醒后,必须回到循环开始或方法开头,重新验证条件,然后再决定是继续等待还是执行操作。
监视器的实现
最后,我们简要探讨一下监视器如何基于可重入锁来实现。这有助于深入理解其工作原理。
监视器在可重入锁的基础上做了两处关键扩展:
- 分离的锁操作: 将锁的“进入”和“离开”拆分成两个独立操作
getLock和releaseLock。这是因为wait操作需要在代码中间释放锁,而被唤醒后又需要重新获取锁。 - 等待队列: 实现一个线程等待队列(Wait Set),通常用公平的FIFO队列实现,以避免线程饥饿。
核心操作伪代码示意
wait的实现:proc {Wait} X = {NewCell _} % 创建一个用于挂起线程的变量 {Insert Q X} % 将该变量放入等待队列 {ReleaseLock} % 释放监视器锁 {Wait X} % 挂起当前线程,直到X被绑定 {GetLock} % 被唤醒后,尝试重新获取锁 endnotify的实现:proc {Notify} X = {DeleteNonBlocking Q} % 从队列中取出一个等待变量 if X \= nil then X = unit % 绑定该变量,唤醒对应的线程 end endnotifyAll的实现: 遍历整个等待队列,绑定所有等待变量,从而唤醒所有线程。
总结
本节课中我们一起学习了:
- 监视器的概念: 它是锁的扩展,用于线程间协作,核心是
wait、notify操作和等待集。 - 工作原理: 线程在条件不满足时通过
wait释放锁并等待;其他线程通过notify唤醒等待线程。被唤醒的线程需要重新竞争锁。 - 实践应用: 我们实现了线程安全的有界缓冲区,并强调了条件重验的重要性。
- 编程模式: 掌握了使用
while循环或尾递归来确保条件检查与核心操作原子性的正确模式。 - 实现概览: 了解了监视器如何通过分离锁操作和维护等待队列来实现。
监视器是一种经典的并发控制机制,理解它对于阅读和维护现有并发代码至关重要。然而,由于其正确使用较为复杂,在现代并发编程中,更高级的抽象(如接下来要学习的事务内存)正逐渐成为更受推荐的选择。
039:多智能体编程与工业应用


在本节课中,我们将继续探讨多智能体编程,并深入了解Erlang/OTP中的监督者机制。随后,我们将通过一个来自游戏行业的真实案例,看看这些概念如何在复杂的大型工业系统中得到应用。
进程链接与基本概念回顾
上一节我们介绍了多智能体编程的基础。本节中,我们来看看构建Erlang强大功能(即监督者和被监督的行为)的核心机制——进程链接。
两个进程可以链接在一起。当一个进程终止时,会向另一个进程发送信号,并且这种链接关系是可传递的。如果多个进程相互链接,它们就形成了一个链接进程集合。
我们区分正常退出和错误退出。正常退出(退出原因为 normal)可以忽略。任何导致进程失败的运行时错误、硬件或软件错误,其退出原因都会被传递给所有链接的进程。
进程在收到退出信号时可以不终止,而是将其作为消息接收,从而能够观察到失败进程。这是一个非常重要的基础机制,它之所以有效,是因为进程是独立的,没有共享内存或指针。每个进程都是一个独立的小岛,一个进程可以观察另一个进程。这个机制和消息传递,就是构建Erlang强大功能所需的一切。
Erlang哲学与行为模式
Erlang有一种哲学:故障处理应该简单、快速,故障模式应该尽可能少。这就是“让它崩溃”的理念。
行为模式是一种编程模式,但它不仅仅是程序员遵循的规范,还有实现该行为的软件库。这是一个完整的并发模式。
在Erlang中编写程序时,你通常不会直接使用链接,甚至不会直接发送消息,而是使用行为模式。OTP平台提供了五种标准行为,其中四种是应用的一部分,第五种是监督者,这是我们今天要讨论的重点。监督者与其他所有行为都有关联。
以下是三种重要的行为:
- 通用服务器:
gen_server - 通用事件处理器:
gen_event - 通用有限状态机:
gen_fsm
任何计算都可以建模为有限状态机:一个状态和一个事件会触发一些动作并产生一个新状态。这是计算的核心。另外两种行为用于处理通信,以及将所有内容打包成一个可以一起启动和停止的应用(应用行为)。这五种行为是核心,它们建立在简单的消息传递机制之上。
通用服务器模式回顾
现在,我们来回顾一下上次课看到的通用服务器模式。我展示了一个非常简单的通用服务器。
通用服务器模块是OTP系统的一部分。程序员编写的是特定的回调模块。程序员调用服务器的启动、停止和调用函数,但服务器有回调接口。你只需要定义外部接口和实际的计算逻辑:如何初始化、如何处理计算以及如何终止。这非常简单:启动、工作、停止。
我展示的简单服务器代码没有使用监督者。真正的 gen_server 是受监督的,它做了更多的事情,尽管从外部看仍然很简单。
编程模式:消息传递的挑战
现在,我想讨论两个编程中的小问题,以刷新大家对Erlang编程方式的记忆。
第一个是关于消息传递的。你从一个进程向另一个进程发送消息,有时需要等待回复,但这有时会很棘手,可能存在竞态条件。gen_server 在内部处理了这个问题,但了解其工作原理很重要。
问题是:客户端向服务器发送请求并等待回复。如果另一个进程发送了一条格式与回复完全相同的消息,我们如何区分?这可能是一个竞态条件,因为伪造的回复可能先到达。
另一个问题是:我们如何确信回复确实来自服务器,而不是其他人?使用简单的代码无法保证这一点。
我们可以通过使消息唯一来解决这个问题。思路是在消息中放入只有客户端知道的内容。
Erlang有一个函数叫 make_ref(),它可以创建一个唯一的常量(引用),其他人无法伪造。修复方法是:在发送消息时,创建一个唯一的引用 Ref,并将这个唯一常量放入消息元组中。回复的元组中也会包含这个唯一常量。接收方使用模式匹配的 receive 语句,会挑选出包含此常量的消息。如果有一个伪造的消息,它不会被匹配。receive 可以无序地从邮箱中取出消息,这很重要,它允许你无论消息到达顺序如何,总能匹配到正确的消息。这是一种确保请求-回复不被破坏的简单模式。
同步与异步调用
第二个要讨论的是同步和异步调用。当你从一个进程向另一个进程发送消息时,有时需要回复(同步),有时不需要(异步)。
gen_server 提供了两种模式接口:call 和 cast。call 是往返调用,它会等待返回值。cast 是异步调用,你只是发送消息,不等待回复。在我们的频率分配示例中,解除分配实际上可以使用 cast,因为你不需要等待结果。这种行为模式会自动处理这些细节。

构建监督者
现在,让我们进入构建监督者的部分。首先,回顾一下监督者的概念。监督者是Erlang中一个关键概念,它使得Erlang成为一个构建健壮软件的完整且优秀的系统。
我们构建具有故障的并发分布式系统。大多数故障和错误是暂时的,这意味着如果重试,系统可能会正常工作。简单的重试实际上是一个很好的初始策略。监督者最初就是这样设计的,后来功能更加丰富。
你有一组工作进程,它们受到监督。监督意味着有进程观察工作进程,但只关心它们的故障,不关心它们在做什么。监督者拥有权限,可以停止和重启它们控制的被监督进程。这是递归的,监督者本身也可以被监督,形成一个树形结构。
监督者构成一棵树。根节点是顶级监督者,非常重要。监督者有子进程。所有其他行为从一开始就被设计成可以被监督。
当监督者想要重启工作进程时,它有一个策略。有四种基本的内置策略:
- one_for_one:如果一个子进程死亡,只重启该子进程。
- one_for_all:如果一个子进程死亡,重启所有子进程。
- rest_for_one:如果某个子进程死亡,重启在该子进程之后启动的所有子进程。
- simple_one_for_one:
one_for_one的动态版本,可以在执行期间动态创建和停止新的子进程。
基本重试策略只在第一层。重启次数是有限的。如果超过限制,监督者将终止,由更高级别的监督者处理。
一个简单监督者的代码示例
我将展示一个简单监督者的完整代码,并详细解释其工作原理。这是一个非常简单的监督者。
这个监督者有子进程。如果子进程异常终止,它会被重启(类似于 one_for_one)。如果子进程正常终止,它会被从监督树中移除。如果停止监督者,所有子进程都会被终止。
这个监督者假设在启动子进程时不会发生异常。真正的监督者需要更仔细地处理启动失败的情况。
每个子进程通过调用一个Erlang函数 apply 来启动,该函数接受模块名、函数名和参数。这个函数会创建子进程并将其链接到父进程。
我们使用链接来实现:当监督者终止时,链接的进程会收到退出信号。
以下是代码的关键部分解释:
start/2:启动监督者,注册名称,并创建一个新进程来运行init/1。init/1:设置trap_exit为true(这样监督者不会失败,而是接收消息),然后启动循环。start_children/1:使用列表推导式,根据子进程规范列表创建子进程,并返回一个包含子进程PID和原始规范的元组列表。- 监督者循环:类似于服务器循环,处理来自子进程的退出消息。
- 如果收到
{'EXIT', Pid, normal},则从子进程列表中移除该子进程。 - 如果收到
{'EXIT', Pid, Reason}且Reason不是normal,则重启该子进程(查找其规范,创建新进程,替换PID)。 - 如果收到
stop消息,则终止所有子进程。
- 如果收到
这个代码展示了链接、高阶函数和具有消息传递的独立进程的概念。虽然简单,但功能强大。这就是Erlang构建健壮系统的基础:让它崩溃、无共享和监督者。
监督与稳定存储
监督的第二部分是稳定存储。当你重启子进程时,仅仅使用初始参数可能不够,子进程可能在现实世界中有一些状态。
Erlang对此有很好的支持。最基本且快速的支持是ETS。ETS是内存中的稳定存储,进程崩溃时,ETS值不受影响。ETS表通常由监督者管理,而不是子进程管理,因为当监督者停止时,ETS表会被删除。
除了ETS,还有更慢但更可靠的DETS(磁盘ETS),以及功能完整的Mnesia事务数据库。ETS是最常用的。
监督者机制的优势
让我们退一步比较一下。监督者处理错误。当子进程出错时,它被设计为立即崩溃,而不是尝试修复问题。所有错误条件都映射到一个操作:崩溃。没有防御性编程。
有研究表明,对于相同的功能,Erlang实现通常比C++等语言的代码量少得多(例如85%),因为错误处理代码大大减少。Erlang代码往往更小。
工业应用案例:Deware公司
现在,我将介绍一个工业应用案例。这是2011年Erlang Factory会议上,Deware公司的工程师Malcolm Dowse所做的 keynote 演讲。Deware是一家为游戏系统提供大量支持的公司。
Deware为游戏提供在线基础设施支持,特别是大厅服务(匹配、排行榜、统计、消息、反作弊等),而不是游戏引擎本身。他们支持了《使命召唤》等顶级游戏,拥有数百万并发用户。
他们最初使用C++和MySQL,但遇到了并发处理难、代码复杂、易崩溃等问题。后来,一位开发者用Erlang重写了服务器原型,效果很好。经过几个月开发,在2007年首次投入使用。
Erlang带来的改进包括:系统不再频繁崩溃、配置更简单、支持热代码升级、日志管理更好。随着用户量从2万增长到250万,他们成功应对了增长挑战,Erlang代码成为核心。
他们如何使用Erlang:
- Erlang是核心服务器,控制Python脚本,管理数十万并发TCP连接。
- 使用监督树管理进程(每个连接用户对应两个Erlang进程,使用
simple_one_for_one策略)。 - 使用Mnesia(分片)作为在线状态数据库。
- 使用ETS进行高速计数和指标收集。
- 使用Yaws作为高并发Web服务器,提供管理界面。
- 实现了排行榜、键值存储等服务。
他们学到的经验教训:
- 核心思想:函数式思维、并发思维、进程廉价。
- OTP行为:坚持使用
gen_server、监督者,避免直接使用spawn、send、receive。 - KISS原则:保持简单,避免节点间依赖,避免特殊职责的节点。
- 消息传递:优先使用异步的
cast,而非同步的call,以减少依赖和等待。 - 避免瓶颈:对于可能成为瓶颈的进程(如日志、指标),使用进程池,并采用“拉”模式而非“推”模式分配工作。
- 稳定存储:ETS非常快,是大多数内存存储的标准解决方案;需要事务时使用Mnesia。
- 测试:进行自动化测试、故障注入(随机杀死进程)、端到端负载测试(模拟数百万用户)。
- 其他:注意进程优先级,谨慎使用热代码升级,提供“紧急开关”,使用Dialyzer等工具。
- 团队协作:Erlang可能只是系统的一部分,要对使用其他技术的同事保持友好。
他们希望Erlang改进的地方:
- Mnesia在网络分区和节点崩溃时能更好处理,支持最终一致性,自动重新平衡。
- OTP监督树更灵活,支持指数退避重启。
- 更好地与其他语言集成。
- 改进类型系统和动态特性。
- 降低学习曲线(注:后来出现的Elixir语言部分解决了此问题)。
尽管如此,他们热爱Erlang:它提供了轻松的并发,是解决硬并发问题的完整方案,开源、健壮、可靠,系统可以连续运行数月不崩溃。
总结
本节课中,我们一起学习了Erlang/OTP中的监督者机制,它是构建健壮、并发、分布式系统的核心。我们分析了监督者的工作原理、策略和代码实现,并探讨了稳定存储(如ETS)的作用。随后,我们通过Deware公司的工业案例,看到了这些理论概念如何在真实的大型游戏服务系统中得到应用,并带来了可靠性、可扩展性和开发效率方面的巨大优势。Erlang的“让它崩溃”哲学,结合强大的OTP行为库和监督树,使其成为处理高并发、高可用性需求的强大工具。
040:事务处理 🔄

在本节课中,我们将要学习并发编程中的一个核心概念:事务处理。事务是管理共享状态并发访问的强大工具,尤其适用于需要保证数据一致性、高可用性和高性能的系统,例如大型数据库。我们将探讨事务的基本原理、关键属性以及实现一个事务管理器所需的核心算法。
概述 📋
事务是一个原子操作序列,要么全部成功执行(提交),要么全部不执行(中止)。它被设计用来处理共享状态并发访问中的复杂问题,特别是在系统可能崩溃的情况下。事务需要满足ACID属性:原子性、一致性、隔离性和持久性。本节课我们将深入理解这些属性,并学习如何构建一个支持乐观并发控制、严格两阶段锁和死锁避免的事务管理器。
为什么需要事务?💡
上一节我们介绍了共享状态并发的基本挑战。本节中我们来看看为什么简单的锁机制不足以应对复杂场景。
考虑一个包含多个数据单元(例如银行账户)的数据库。如果使用一个全局锁来保护所有转账操作,那么这个锁会成为性能瓶颈,导致系统非常缓慢。
一个自然的改进是为每个数据单元设置独立的锁。然而,这仍然存在问题。假设有两个并发事务:
- T1: 从账户C1转账到账户C2。
- T2: 从账户C3转账到账户C2。
如果T1和T2的执行顺序交错不当,例如T2先写入C2,然后T1再读取并覆盖C2,那么T2的转账金额可能会丢失。这表明,对多个数据单元的操作需要一种更精细的协调机制,这就是事务要解决的问题。
核心思想:事务将涉及多个读写操作的任务组合成一个大的、原子的动作。这个动作要么完全生效,要么完全回滚,从而保证数据的一致性。
ACID属性与实现基础 🧱
事务必须满足四个关键属性,合称为ACID:
- 原子性 (Atomicity):事务中的所有操作要么全部完成,要么全部不发生。不存在中间状态。
- 一致性 (Consistency):事务必须保持数据库的完整性约束(不变量)。例如,转账前后银行账户的总金额应保持不变。这是程序员的责任,需要正确设计事务逻辑。
- 隔离性 (Isolation):并发执行的事务互不干扰,最终效果等同于它们按某种顺序串行执行。这也称为可串行化。
- 持久性 (Durability):一旦事务提交,其对数据的修改就是永久性的,即使系统崩溃也能恢复。
实现持久性的基础技术:数据库通常将数据存储在磁盘上。为了应对写入时崩溃导致数据损坏的问题,采用以下方法:
- 先将所有更新写入磁盘上一个特殊的临时区域。
- 当所有更新都安全写入后,执行一个“切换”操作(例如,原子性地写入一个特殊的标记字)。
- 硬件需要保证单个字的磁盘写入是原子的(要么完全成功,要么完全失败)。这样,崩溃后重启时,系统可以根据这个标记字决定是使用新数据还是回滚到旧数据。
为了提升性能,系统会尽可能在内存中缓存临时副本,最后以块传输的方式批量写入磁盘。
安全性与活性:系统属性的两个维度 ⚖️
在构建事务系统(乃至任何大型系统)时,我们需要明确两类根本不同的属性,它们对应着不同的实现技术。
-
安全性 (Safety):指“坏事永远不会发生”。它是一种不变性。一旦被违反,就无法修复。
- 形式化定义:属性P是安全属性,如果对于任何不满足P的执行,都存在一个有限前缀,使得该前缀的所有可能扩展也都不满足P。
- 例子:“每条消息最多被传递一次”。如果一条消息被传递了两次,属性即被破坏且无法挽回。
-
活性 (Liveness):指“好事最终会发生”。它是一种进展保证。
- 形式化定义:属性P是活性属性,如果对于任何满足P的执行,其每一个有限前缀都存在一个扩展能满足P。
- 例子:“每条消息至少被传递一次”。即使当前前缀中消息还未送达,总存在一种未来的执行方式使其送达。
重要结论:任何系统属性都可以分解为一个安全性属性和一个活性属性的合取。在事务系统中,锁管理用于保证安全性(可串行化),而死锁处理用于保证活性(系统持续进展)。两者在设计和实现上是正交的。
构建事务管理器 🏗️
我们将设计一个事务管理器,它采用乐观并发控制、严格两阶段锁和死锁避免策略。该系统结构如下:

[事务 T1] ---消息---> [事务管理器] <---消息--- [事务 T2]
[事务 T3] ---消息---> (管理所有锁和状态) <---消息--- [事务 T4]
每个事务在独立的线程中运行,并通过消息与中心化的事务管理器通信。事务管理器负责所有锁的授予、等待和释放,确保ACID属性。
以下是设计事务管理器时需要考虑的三个主要维度:
1. 乐观 vs. 悲观策略
- 乐观策略:假设冲突很少发生,先执行操作,仅在提交时检查冲突。如果冲突发生,则中止并重试事务。适用于冲突代价低的场景(如航空超售)。
- 悲观策略:假设冲突经常发生,在访问数据前先获取锁,阻止其他事务访问。适用于冲突代价高的场景(如火车轨道分配)。
我们的示例将采用乐观策略。
2. 锁管理与可串行化(安全性)
简单的逐个获取和释放锁可能导致不可串行化的调度。
解决方案:两阶段锁
- 增长阶段:事务可以不断获取新锁,但不能释放任何锁。
- 缩减阶段:事务可以释放锁,但不能获取新锁。
- 这保证了事务的可串行化。
优化:严格两阶段锁
- 在标准两阶段锁中,事务在缩减阶段逐步释放锁,可能导致级联中止(一个事务中止导致依赖它的事务也中止)。
- 严格两阶段锁取消了独立的缩减阶段,所有锁在事务提交或中止时一次性释放。这简化了实现(无级联中止),虽可能略微降低并发度,但被广泛采用。
3. 死锁处理(活性)
即使使用严格两阶段锁,也可能发生死锁。例如:
- T1 持有锁L1,请求锁L2。
- T2 持有锁L2,请求锁L1。
- 两者互相等待,形成循环。
等待图:用有向图表示事务和资源(锁)之间的依赖关系。边 T -> C 表示事务T正在等待资源C;边 C -> T 表示资源C正被事务T持有。图中存在环就意味着死锁。
死锁避免策略(基于优先级):
我们为每个事务分配一个唯一的、递减的时间戳作为优先级。规则如下:
- 事务请求锁时,如果锁空闲,则获得锁。
- 如果锁被更高优先级的事务持有,则当前事务等待。
- 如果锁被更低优先级的事务持有,则强制中止并重启那个低优先级事务,让高优先级事务获得锁。
- 这样可以证明系统不会产生死锁,因为最高优先级的事务永远不会被阻塞。
为了更友好,可以引入“察看期”状态:被标记的低优先级事务可以继续运行直至完成,但如果它在察看期内尝试获取新的锁,则会被重启。
算法总结与示例 📝
综合以上,我们得到一个完整的事务管理器算法概要:
以下是事务状态转换的核心逻辑描述(非正式代码):
事务状态:运行中、等待、察看期、提交/中止。
1. 事务启动,进入“运行中”状态。
2. 事务请求锁:
a. 锁空闲 -> 获得锁,保持“运行中”。
b. 锁被更高优先级事务持有 -> 进入“等待”状态。
c. 锁被更低优先级事务持有 -> 强制中止该低优先级事务,本事务获得锁。
3. 运行中的事务,如果其持有的锁被更高优先级事务需求,则进入“察看期”。
4. 察看期中的事务:
a. 若正常执行完毕 -> 可提交。
b. 若尝试获取新锁 -> 被重启。
5. 事务提交或中止时,释放所有锁,唤醒等待该锁的最高优先级事务。
示例操作:
假设一个数据库有1000个单元,初始值分别为1到1000。
- 混合事务:随机选取三个单元
I, J, K,读取其值a, b, c,然后重新赋值使得(a+b-c) + (a-b+c) + (-a+b+c) = a+b+c,总和不变。如果I, J, K有重复,则主动中止。 - 求和事务:计算所有1000个单元的总和。
可以并发启动上千个“混合事务”,同时运行“求和事务”。尽管数据被大量随机修改,但每次求和事务的结果都保持为 500500(1到1000的和),验证了事务的一致性保持。
总结 🎯
本节课中我们一起学习了事务处理的核心概念:
- 事务是将多个操作封装为原子动作的强大抽象,尤其适用于数据库和需要容错、高性能的系统。
- ACID属性(原子性、一致性、隔离性、持久性)定义了事务的核心要求。
- 实现事务需要区分安全性(如可串行化,通过锁管理实现)和活性(如无死锁,通过优先级和时间戳实现)。
- 我们探讨了一种具体的实现方案:采用乐观并发控制、严格两阶段锁和基于优先级的死锁避免算法来构建事务管理器。
- 事务是支持现代信息经济的基石技术,理解其原理对设计可靠、高效的并发系统至关重要。
041:课程介绍与回顾

在本节课中,我们将学习《高级编程语言概念》这门课程的整体安排、核心主题,并对先修课程的关键概念进行回顾,为后续深入学习打下基础。
课程概述与组织

本课程名为《并发编程概念》,但实质上是一门关于高级编程概念的课程。它是先前课程(LINFO1104)的延续,我们将在此基础之上,对许多概念进行更深入、更广泛的探讨。
课程由一名教师和一名助教负责,并设有每周三次的实验课。实验课从下周开始,强烈建议每位同学选择并参加其中一节。
评分方式
课程的评分结构如下:
- 项目:在学期后半段进行,必须完成。建议两人一组,有充足时间完成,预计大多数同学能获得高分。
- 期中考试:可选,但强烈建议参加。成绩优异可在期末考试中获得优势。
- 期末考试:必须参加。其分值占总分的四分之三,但分为两部分。第一部分与期中考试内容对应,最终取期中考试和期末考试第一部分中的较高分计入总成绩。
- 出勤奖励:参加所有实验课可获得期末考试额外1分的奖励。此举旨在鼓励参与,实践证明这对理解课程内容和提高成绩有双重积极作用。
所有课程资料、公告和项目信息将通过Moodle平台发布。期中考试预计在第7周左右进行。
软件环境
本课程将使用两个编程系统:
- Mozart系统:在先修课程中已接触。
- Erlang/OTP系统:用于构建高容错、高可靠的软件,是业界在此领域的最佳实践。
核心主题
本课程将贯穿三个核心主题:
- 并发编程:处理程序中的不确定性,展示简化并发编程的技术。
- 声明式编程:基于函数式编程,强调无可变状态的编程范式。这是编写可测试、易维护、高正确性代码的最佳方式,代表未来趋势。
- 非声明式编程:当程序需要与具有实时性和不确定性的真实世界交互时,必须超越声明式范式。我们将学习如何管理这种复杂性。
具体编程范式
课程内容可具体划分为以下编程范式:
声明式编程
- 顺序函数式编程:已在先修课程中学习。
- 惰性求值:一种强大的声明式编程形式,程序执行方向与传统相反。
- 惰性确定性数据流:结合了函数式和惰性求值,是最强大的声明式范式。
非声明式编程
- 数据抽象:封装数据与操作。
- 多智能体编程:构建基于消息传递的并发系统。
- Erlang中的健壮多智能体编程:学习使用Erlang/OTP构建容错性极高的应用程序,其监督者行为模式能有效处理故障。
- 共享状态并发:涉及锁、监视器和事务。其中,事务是最有用的概念,但理解共享状态对于处理遗留代码至关重要。
课程日程安排
课程大致分为两部分:
- 前半部分:聚焦声明式编程,涵盖惰性求值、声明式并发语义和高级声明式算法设计。
- 后半部分:转向非声明式编程,深入数据抽象、Erlang多智能体编程以及共享状态并发。
- 期中考试:预计在第7周,内容覆盖声明式编程部分。
先修课程核心概念回顾
为确保后续学习顺利,以下回顾先修课程中的关键概念。从下周起,将默认大家已熟练掌握这些内容。
函数式编程与尾递归
函数式编程基于数学函数和高阶函数,是测试、维护和保证正确性的最佳范式,如今已被广泛应用。
编写循环或递归函数时,关键是使用尾递归,它对应着循环的执行方式,能保证恒定的内存使用。实现尾递归的技巧在于找到并维护循环不变式。


例如,计算阶乘的朴素递归版本 factorial(N) 不是尾递归,因为递归调用后还需进行乘法操作,导致调用栈深度与N成正比。
正确的尾递归版本需要引入一个累加器参数,并维护不变式 I! * A = N!。初始时 I=N, A=1,每次迭代递减 I 并乘以 A,直到 I=0,此时 A 即为结果。
代码示例:尾递归阶乘
% 非尾递归版本(不佳)
fun {Factorial N}
if N==0 then 1 else N*{Factorial N-1} end
end
% 尾递归版本(正确)
fun {FactorialTail I A}
if I==0 then A
else {FactorialTail I-1 I*A} end
end
% 调用:{FactorialTail N 1}
尾递归函数在语义上等同于命令式语言中的循环,但它遵守λ演算规则,是声明式的,因此可以在并发环境中安全使用而不会引入竞态条件。


高阶编程
在高阶编程中,函数像数字、记录或列表一样,是语言中的一等公民。可以随时随地定义函数,并且函数的行为是确定的。
一个关键概念是闭包(或称静态作用域过程值)。当一个函数值被创建时,它会捕获其定义时所在词法环境中的标识符绑定。
代码示例:创建加法函数
fun {MakeAdd N}
fun {$ X} X+N end
end
Add5 = {MakeAdd 5}
Add10 = {MakeAdd 10}
{Browse {Add5 11}} % 显示 16
{Browse {Add10 10}} % 显示 20
这里,MakeAdd 是一个二阶函数,它返回一个一阶函数。Add5 和 Add10 是两个不同的闭包,各自拥有指向不同 N 值(5和10)的环境。
高阶编程是数据抽象的基础,它支持泛化、实例化、组合、封装(通过Fold抽象累加器)和延迟执行等强大功能。


符号编程与列表操作



符号编程处理的是像列表这样的递归数据结构。列表定义为空(nil)或一个元素与另一个列表的组合(H|T)。
模式匹配是处理符号数据结构的核心机制,它允许我们将数据结构与模板(模式)进行比较,并提取其中的部分。




Fold操作是一个极其重要的高阶函数,它接收一个列表、一个二元函数和一个初始值,然后从左到右(或从右到左)将二元函数累积地应用到列表的所有元素上。Fold是许多并发智能体的核心,其中列表代表消息流,二元函数是状态转移函数,初始值是内部状态。
代码示例:FoldL 定义
fun {FoldL L F U}
case L
of nil then U
[] H|T then {FoldL T F {F U H}}
end
end
% 求和:{FoldL [1 2 3] fun {$ X Y} X+Y end 0} 返回 6
% 求积:{FoldL [1 2 3] fun {$ X Y} X*Y end 1} 返回 6
FoldL 是尾递归的。在并发智能体中,L 可以是一个无限的消息流,F 是处理消息并更新状态的函数,U 是初始状态。
数据抽象
数据抽象将程序封装在“盒子”里,只能通过定义的接口进行访问。这保证了功能正确、隐藏了复杂性,并支持团队分工。
数据抽象主要基于词法作用域和高阶编程。有两种主要的抽象方式:
- 抽象数据类型:数据(如栈的值)和操作(如push、pop)是分离的。通过包装(wrap)和解包(unwrap)函数在接口处隐藏内部表示。
- 对象:将数据和方法组合成一个单一的实体。对象通常是一个接收消息(如push、pop)并执行相应方法的一元过程。封装通过内部数据的词法作用域来强制执行。


并发与确定性数据流
并发是指多个独立的活动同时执行。语言通过线程来创建新的活动。
将线程与函数式编程及单赋值变量结合,可以创建确定性数据流并发。在这种模型中,如果线程尝试使用一个尚未绑定的变量,该操作会等待(数据流),直到变量被另一个线程绑定。无论线程调度顺序如何,程序的最终结果总是确定的。
代码示例:生产者-消费者
fun {Producer N}
if N<100 then
{Delay 1000} % 每秒生产一个
N|{Producer N+1}
else nil end
end
fun {Consumer S}
case S of H|T then
{Browse H}
{Consumer T}
else skip end
end
local S in
thread S={Producer 0} end % 生产者线程
thread {Consumer S} end % 消费者线程
end
生产者构建一个流(惰性列表),消费者通过模式匹配读取。如果消费者更快,它会在 case 语句处等待,直到生产者提供下一个元素。这种同步是自动且完美的。生产者和消费者函数都是尾递归的,内存使用恒定。
非确定性的本质
在并发程序中,调度器决定在任一时刻执行哪个线程,这个选择对于程序员来说是非确定性的。当程序与外部世界(如网络请求、用户输入)交互时,事件到达的顺序也是非确定的。
例如,在一个多客户端服务器中,服务器必须及时响应每个客户端的请求,但请求到达的顺序无法预测。程序必须正确处理所有可能的顺序,而由于可能性太多,无法通过测试覆盖所有情况。因此,程序必须通过设计来保证正确性。
确定性数据流是应对非确定性的优秀方案,因为它能将非确定性隐藏在函数式语义之下,使结果总是确定。本课程的一个重要主题就是学习如何掌控和最小化非确定性,尤其是在必须与真实世界交互时。
总结



本节课我们一起学习了《高级编程语言概念》课程的整体框架、评分方式和三大核心主题:并发编程、声明式编程和非声明式编程。我们对先修课程的关键概念进行了回顾,包括函数式编程与尾递归、高阶编程与闭包、符号编程与Fold操作、数据抽象的两种形式,以及并发编程中的确定性数据流和非确定性的本质。这些概念是后续学习更高级主题(如惰性求值、Erlang容错编程等)的重要基础。从下节课开始,我们将正式深入这些高级主题。
042:惰性求值 🧠

在本节课中,我们将要学习一种非常不同的计算方式——惰性求值。如果你之前没有接触过它,它可能会显得非常奇怪,但同时也非常强大。我们将从惰性求值的定义和语义开始,然后通过编程实例展示其应用,包括生产者-消费者模式、无限数据结构,以及一个经典的“汉明问题”。最后,我们会探讨惰性求值如何与并发结合,形成更强大的编程范式。
什么是惰性求值? 🤔
惰性求值是一种函数式编程的执行策略。在我们之前见过的任何函数式程序中,你都可以采用惰性方式运行它。这意味着,当你调用一个函数时,它并不会立即执行。相反,它会创建一个称为“惰性挂起”的结构。只有当其他部分真正需要这个函数的结果时,它才会被执行。这听起来可能有些奇怪,但我们将通过其语义来精确理解它。
惰性求值的语义与数据流执行 🧱
要理解惰性求值如何工作,我们需要回顾程序的语义,即通过将其翻译成核心语言来获得。惰性求值正是通过这种方式被翻译到核心语言中的。
以下是其翻译规则。假设 add X Y 的结果是 R,其翻译如下:
thread
{WaitNeeded R}
R = X + Y
end
这段代码创建了一个新线程。在该线程中,它执行一个特殊的操作 WaitNeeded,该操作会等待,直到结果 R 被需要。
那么,“需要 R”是什么意思呢?系统中有一个称为 Wait 的操作,它会等待直到 R 被绑定为一个值。WaitNeeded 则会等待,直到另一个线程对 R 执行了 Wait 操作。
为了理解这在完整系统中如何工作,我们需要回顾一下确定性数据流是如何执行的。
数据流执行回顾
假设我们执行 X = Y + Z。这个加法操作需要等待 Y 和 Z 都成为数字(因为它们可能最初是未绑定的变量)。其翻译如下:
thread
{Wait Y}
{Wait Z}
V = {PrimitiveAdd Y Z}
{Bind X V}
end
Wait X 会挂起当前执行,直到 X 被绑定为一个值。Bind X V 操作则将值 V 绑定到变量 X。每当对 X 执行 Bind 时,任何正在 Wait X 的线程都会继续执行。这就是数据流执行的基础,所有操作(如算术运算、case 语句、函数调用、记录访问)都建立在 Wait 和 Bind 之上。
惰性执行只是在数据流之上增加了一个新操作。因此,我们有:
Wait X: 等待直到X被绑定。Bind X V: 将值V绑定到X。WaitNeeded X: 等待直到另一个线程对X执行Wait。
WaitNeeded 是实现惰性的关键。它并不等待值被绑定,而是等待有线程需要这个值。通过这个单一的新操作,我们就获得了惰性求值的全部能力。
惰性函数与编程技巧 🛠️
在系统中,你可以直接使用 WaitNeeded。例如:
thread {WaitNeeded X} {Bind X 100} end
{Browse X+0}
第一个 Browse 会显示未绑定变量,因为 WaitNeeded 在等待。第二个 Browse 执行 X+0,加法操作需要对 X 执行 Wait,这触发了 WaitNeeded,导致 X 被绑定为 100,从而显示结果。这是一种非常有趣的控制计算的方式。
更常见的是,我们可以定义惰性函数。使用 fun lazy 关键字定义的函数会被自动翻译。例如:
fun lazy {F A}
Expression
end
会被翻译为:
proc {F A R}
thread
{WaitNeeded R}
R = Expression
end
end
当你调用惰性函数 F 时,并不会立即执行函数体,而是创建一个线程,该线程等待结果 R 被需要。这被称为“惰性挂起”。
需要注意的是,这种基于线程的实现可能看起来开销较大,但编译器可以采用更高效的方式来实现相同的语义。例如,Haskell 语言就完全基于惰性求值,并且拥有高效的编译器。
现在,你已经了解了惰性求值的工作原理。接下来,我们将通过实际编程来展示你能用这个思想做些什么。
生产者-消费者示例:三种执行方式 🔄
我们将通过一个简单的生产者-消费者示例,展示三种不同的执行方式:顺序执行、并发(急切)执行和惰性执行。三者最终会得到相同的结果,这得益于函数式程序的“合流性”理论(Church-Rosser 定理),即对同一个 lambda 表达式进行不同的规约,最终会得到相同的结果。惰性求值只是另一种规约方式。
以下是代码。生产者创建从 L 到 H 的整数流,每个元素产生后延迟1秒:
fun {Producer L H}
if L > H then nil
else
{Delay 1000}
L|{Producer L+1 H}
end
end
消费者计算输入流的累积和:
fun {Consumer S Acc}
case S of H|T then
(Acc+H)|{Consumer T Acc+H}
[] nil then nil
end
end

1. 顺序执行
local S1 S2 in
{Browse S1}
{Browse S2}
S1 = {Producer 1 10}
S2 = {Consumer S1 0}
end
这里只有一个线程。生产者会逐步绑定 S1,但消费者必须等待生产者完全结束后才能开始运行,因此消费者是“批处理”的,一次性显示所有结果。

2. 并发(急切)执行
local S1 S2 in
thread S1 = {Producer 1 10} end
thread S2 = {Consumer S1 0} end
{Browse S1}
{Browse S2}
end
生产者和消费者在各自的线程中运行。生产者一旦产生第一个元素,消费者就可以立即开始处理并输出增量结果,实现了流水线式的处理。
3. 惰性执行
我们重新定义惰性版本的生产者和消费者:
fun lazy {LProd L H} ... end % 体与Producer相同
fun lazy {LCons S Acc} ... end % 体与Consumer相同
然后执行:
local S1 S2 in
S1 = {LProd 1 10}
S2 = {LCons S1 0}
{Browse S2.1}
end
Browse S2.1 中的点操作(.)需要第一个元素,这迫使惰性消费者 LCons 执行。LCons 执行时需要对 S1 进行 case 分析,这又迫使惰性生产者 LProd 执行。它们各自只产生一个元素后就再次挂起,因为递归调用也是惰性的。只有当你需要更多元素(例如 Browse S2.2.1)时,计算才会继续。这种方式只进行最小必要的工作。
在急切执行中,终止由生产者决定(生产到上限为止)。在惰性执行中,终止由消费者(或最终用户)决定,他们需要多少就计算多少。这是一种对称性的转变。
无限数据结构与惰性求值 ♾️
惰性求值允许我们安全地处理无限数据结构。例如,一个生成无限递增整数流的函数:
fun lazy {Ints N}
N|{Ints N+1}
end
如果这个函数不是惰性的,它将是一个无限循环并耗尽内存。但作为惰性函数,调用 L = {Ints 1} 只会创建一个惰性挂起,不进行实际计算。
当我们真正需要元素时,例如 Browse L.2.2.1(请求第4个元素),点操作会触发计算。Ints 函数体执行,产生第一个元素 1,递归调用 {Ints 2} 又创建一个新的惰性挂起。这个过程会重复,直到产生所需的第4个元素 4,然后计算停止。
为了更方便地查看多个元素,我们可以定义一个辅助函数 Touch:
proc {Touch L N}
if N > 0 then
{Touch L.2 N-1}
else skip end
end
Touch 函数本身不“做”任何事,但它通过遍历列表的前 N 个元素,迫使这些元素被计算出来。例如 {Touch {Ints 1} 20} 会显示前20个整数。
汉明问题:一个经典的惰性编程案例 🧮
汉明问题是一个动态的数字序列生成问题,非常适合用惰性求值解决。问题描述:生成所有形如 2^A * 3^B * 5^C(其中 A, B, C 是非负整数)的数字,并按递增顺序排列,且不重复。序列开始于:1, 2, 3, 4, 5, 6, 8, 9, 10, 12, ...
我们不知道最终需要生成多少个数字,需要动态生成。算法思路如下:
- 序列
H以1开始。 - 下一个数字是
2*H,3*H,5*H这三个(潜在无限)序列中,大于当前最后一个数字的最小值。 - 通过合并(
Merge)和映射(Times)操作来实现。
以下是完整的惰性程序:
% 将流中每个元素乘以 N
fun lazy {Times S N}
case S of H|T then
(N*H)|{Times T N}
end
end
% 合并两个递增流,去重
fun lazy {Merge S1 S2}
case S1#S2 of (H1|T1)#(H2|T2) then
if H1 < H2 then H1|{Merge T1 S2}
elseif H1 > H2 then H2|{Merge S1 T2}
else H1|{Merge T1 T2} % 相等时去重
end
end
end
% 汉明流
H = 1|{Merge {Times H 2} {Merge {Times H 3} {Times H 5}}}
Times 和 Merge 都是惰性的。整个定义 H 是递归的,如果急切求值,会立即无限展开。但在惰性求值下,只有当我们通过 Touch 等操作需要元素时,计算才会按需进行。例如 {Touch H 50} 会生成前50个汉明数。
这个程序简洁地展示了惰性的威力。如果不用惰性,程序员必须手动管理计算和内存,代码会复杂得多。
惰性求值的执行细节与并发结合 ⚡
让我们更深入地看看汉明程序是如何执行的。当我们请求 H.1(第二个元素)时,会触发一系列惰性挂起的激活:
- 激活
Merge(Times(H,2), Merge(Times(H,3), Times(H,5)))。 - 为了执行这个
Merge,需要Times(H,2)和内部Merge的第一个元素。 - 这又激活
Times(H,2)和内部的Merge。 - 内部的
Merge又需要激活Times(H,3)和Times(H,5)。 - ... 如此递归下去。
最终,所有这些激活的挂起会运行,产生结果 2,并为后续元素创建新的惰性挂起。整个过程虽然幕后发生了复杂的激活链,但对程序员来说,只是简单地请求了一个元素。这体现了声明式编程的威力:将复杂的操作逻辑隐藏在简洁的语义背后。
惰性求值本身是顺序的(尽管用线程描述语义),但它可以与并发结合,形成更强大的范式——惰性确定性数据流。这是本课程将介绍的最强大的通用声明式范式之一。它具备合流性、高阶函数、并发以及惰性求值。
有界缓冲区示例
一个能体现此范式威力的例子是“有界缓冲区”。在生产者和消费者速度不匹配时,有界缓冲区可以作为中间层平滑波动。当生产者快时,它存储元素;当消费者快时,它提供已存储的元素。
在声明式惰性并发模型中,我们可以编写一个纯粹函数式的有界缓冲区。生产者代码和消费者代码无需任何修改,只需在它们之间插入这个缓冲区组件。从生产者看,缓冲区像消费者;从消费者看,缓冲区像生产者。这实现了关注点分离和强大的流控制。
我们将在后续课程中详细探讨如何编写这个声明式的有界缓冲区。
总结 📝


本节课我们一起学习了惰性求值。我们从其核心语义 WaitNeeded 出发,理解了它如何通过“按需计算”来实现。我们通过生产者-消费者示例对比了顺序、并发和惰性三种执行策略。我们看到了惰性求值如何安全且优雅地处理无限数据结构。我们还深入探讨了汉明问题,这是一个利用惰性递归流巧妙解决的经典案例。最后,我们展望了将惰性求值与并发结合所形成的强大编程范式,并以有界缓冲区为例说明了其潜力。惰性求值将复杂的执行控制隐藏在简单的声明式代码之下,是函数式编程中一个非常强大且优雅的概念。
043:惰性求值、有界缓冲区与惰性快速排序

概述
在本节课中,我们将深入学习惰性求值的强大功能。我们将首先回顾并完善上节课的生产者-消费者模型,然后探讨惰性求值如何与并发结合,实现有界缓冲区。最后,我们将见证一个“奇迹”:惰性求值如何创造出一种极其高效的惰性快速排序算法。

回顾:四种编程范式
上一节我们介绍了声明式编程的几种范式。本节中,我们来看看它们的完整对比,特别是上节课遗漏的第三种范式。

我们以一个生产者-消费者流水线为例,它包含一个生成数字流的Producer和一个计算运行总和的Consumer。以下是运行此代码的四种不同范式:
- 无单赋值的顺序函数式编程:这是传统的函数式语言(如Lisp、ML、OCaml)。没有未绑定变量,也没有单赋值。执行是批处理的:生产者完全运行完毕后才开始消费者。
% 生产者:递归调用,结果完全计算后才返回 fun {Prod2 N} H S in {Delay 1000} % 模拟耗时操作 H = N S = {Prod2 N+1} H|S end % 消费者:等待生产者完全结束后才开始 S1 = {Prod2 1} S2 = {Cons S1} {Browse S2}



-
带单赋值的顺序函数式编程:这是Oz的默认模式。允许数据结构中存在“洞”(未绑定变量),支持尾递归。执行是增量的:生产者每秒生成一个新元素,但消费者仍需等待生产者完全结束。
% 生产者:尾递归,结果在递归调用过程中逐步绑定 fun {Prod1 N} H S in {Delay 1000} H = N S = {Prod1 N+1} % S 是未绑定的,但递归调用立即进行 H|S % 结果链表在递归过程中逐步构建 end % 消费者:仍在单一线程中,等待生产者 S1 = {Prod1 1} S2 = {Cons S1} {Browse S2} -
确定性数据流:引入线程和数据流同步。生产者和消费者在各自的线程中并发运行。执行是完全增量且并发的:生产者每秒生成一个元素,消费者几乎同时处理并输出结果。
% 使用线程实现并发 thread S1 = {Prod1 1} end % 生产者在独立线程中运行 thread S2 = {Cons S1} end % 消费者在另一个独立线程中运行 {Browse S2} -
惰性求值:在确定性数据流基础上,增加
WaitNeeded操作。计算仅在结果被需要时才执行。这允许处理无限数据结构,是本节课的核心。
核心概念:WaitNeeded X 是一个同步原语,它会阻塞调用线程,直到有另一个线程需要变量X的值。此时我们说X是被需要的。
惰性求值的威力:惰性快速排序 🧙♂️
现在,我们来看看惰性求值如何创造出高效的算法。以快速排序为例。
标准快速排序
标准快速排序是分治算法,平均时间复杂度为 O(n log n)。
fun {QuickSort L}
case L of nil then nil
[] Pivot|T then
Left Right
in
{Partition T Pivot Left Right} % 分区,O(n)
{Append {QuickSort Left} Pivot|{QuickSort Right}} % 递归排序和合并
end
end
惰性快速排序
我们只需将递归调用和Append操作改为惰性的。
fun lazy {LQuickSort L}
case L of nil then nil
[] Pivot|T then
Left Right
in
{Partition T Pivot Left Right} % 分区仍然是急切的
{LAppend {LQuickSort Left} Pivot|{LQuickSort Right}} % 惰性递归和合并
end
end
fun lazy {LAppend A B}
case A of nil then B
[] H|T then H|{LAppend T B}
end
end

算法奇迹
惰性快速排序的魔力在于其增量计算的特性。

- 如果你只询问前K个最小元素:算法复杂度仅为 O(n + K log K),远优于完全排序的 O(n log n)。
- K无需预先知道:你可以逐个询问元素,算法会根据你的需求动态计算,始终保持高效。
工作原理直觉:当你需要第一个(最小)元素时,算法进行分区,然后只在左子列表(所有小于基准的元素)上递归地进行惰性快速排序,完全忽略右子列表。随着你请求更多元素,算法只计算必要的部分。本质上,它将大问题分解,并只“征服”需要的那部分。
结合并发与惰性:有界缓冲区 🧱
上一节我们看到了惰性求值在算法上的妙用,本节中我们来看看如何将惰性与并发结合,解决一个经典问题:有界缓冲区。


问题背景
在生产者和消费者速度不匹配时,会出现一方等待,导致效率低下。有界缓冲区作为中间组件,提供一定容量的存储,平滑两者的速度差异。
- 当生产者快时,多余元素暂存于缓冲区。
- 当消费者快时,从缓冲区预存的元素中获取。
实现步骤
我们将逐步构建一个声明式的、无副作用的缓冲区。
-
透明通道(起点):一个不做任何事的缓冲区,只是传递流。
proc {Buffer S1 S2} {Loop S1 S2} end proc {Loop S1 S2} case S1 of H|T then S2 = H|{Loop T _} % 简单传递 end end -
启动时预填充:缓冲区启动后,立即尝试向生产者请求
N个元素(缓冲区容量)以填满自己。这里使用惰性函数List.drop来“需要”前N个元素。proc {Buffer S1 S2 N} Start in thread Start = {List.drop S1 N} end % 在独立线程中请求N个元素,不阻塞主线程 {Loop S1 S2 Start} end -
运行时保持充盈:每当消费者消费一个元素(
S2的H被取走),缓冲区就向生产者请求一个新元素,试图保持充盈状态。这是通过在循环递归调用中,在独立线程里对尾部流T进行WaitNeeded操作来实现的。proc {Loop S1 S2 End} case S1 of H|T then S2 = H|{Loop T NewEnd End2} % 输出元素给消费者 thread End2 = {WaitNeeded NewEnd} end % 在独立线程中请求新元素,保持充盈 end end -
完整的有界缓冲区:结合步骤2和3,并确保所有可能阻塞的操作都在独立线程中,以避免不必要的阻塞。
proc {Buffer S1 S2 N} % 启动预填充 thread _ = {List.drop S1 N} end % 开始循环,传递元素并保持充盈 {Loop S1 S2} end proc {Loop S1 S2} case S1 of H|T then S2 = H|{Loop T _} thread _ = {WaitNeeded T} end % 关键:在独立线程中触发对新元素的需求 end end
核心思想:
- 惰性:通过
WaitNeeded,缓冲区只在需要时才驱动生产者计算。 - 并发(线程):在声明式编程中,线程是你的朋友。它们将潜在的阻塞操作隔离,使程序更增量、更高效,且不会引入竞态条件或破坏确定性。
管道组装
最终,生产者-消费者管道如下运行:
% 声明流
S1 S2 S3
% 创建组件
thread S1 = {LazyProducer 1} end % 惰性生产者
{Buffer S1 S2 3} % 容量为3的有界缓冲区
thread S3 = {LazyConsumer S2} end % 惰性消费者
{Browse S3}
运行后,缓冲区会立即尝试填充3个元素。当你消费S3的第一个元素时,结果立即可得(因为缓冲区已预存),同时缓冲区在后台异步请求第4个元素,如此循环。
什么是声明式编程? 🧠
我们已经看到了五种范式(顺序、单赋值、数据流、惰性、惰性并发),它们都被称为声明式编程。那么,其核心定义是什么?
这一切的理论基础是 Church-Rosser 定理(或合流性)。对于λ演算(所有函数式程序的本质),定理指出:如果一个表达式可以通过不同的求值路径得到两个结果,那么必然存在第三个表达式,使得这两个结果都能归约到它。这意味着,对于终止的计算,结果与求值顺序无关。
我们的编程模型(单赋值变量、线程、流、惰性)都是λ演算之上的“语法糖”。只要这些扩展遵守合流性,那么整个程序就是声明式的:程序定义了一组逻辑关系(“什么”是真),而非一系列操作步骤(“如何”做)。求值器可以自由选择任何顺序执行,最终结果保证确定无误。
为了更形式化地理解“逻辑关系”,我们将在后续课程简要介绍一阶逻辑及其模型论,这将帮助我们更深刻地理解声明式编程的数学基础。

总结
本节课中我们一起学习了:
- 完整回顾了四种编程范式,理解了单赋值和并发如何带来增量计算。
- 探索了惰性求值的核心魔法:通过
WaitNeeded实现按需计算,并能处理无限数据结构。 - 见证了惰性快速排序的算法奇迹:它将算法复杂度从O(n log n)降至O(n + K log K),并能增量式地提供排序结果。
- 实现了结合并发与惰性的有界缓冲区:这是一个实用的、声明式的并发模式,展示了线程在声明式范式中的友好性。
- 探讨了声明式编程的理论基础:即程序定义了不变的逻辑关系,其结果由Church-Rosser定理保证确定性。

惰性求值不仅是一种求值策略,更是一种强大的算法设计工具,能够催生出像惰性快速排序这样高效而优雅的解决方案。
044:声明式编程与高效算法

概述
在本节课中,我们将要学习声明式编程的确切定义,并探索如何利用单赋值和惰性求值来构建高效的算法。课程分为两部分:首先,我们将深入探讨声明式编程的理论基础,特别是它与一阶逻辑的关系;其次,我们将学习一系列高效算法设计技术,包括差异列表和多种队列实现。
声明式编程的定义
在之前的课程中,我们接触了多种编程范式,包括顺序函数式编程、确定性数据流、惰性求值以及惰性与并发性的结合。它们都被称为声明式的。但声明式编程的确切含义是什么?
这一切都始于λ演算。所有函数式程序都可以编码为λ表达式。λ演算有一个非常强大的性质,称为丘奇-罗瑟定理。该定理指出,对于一个表达式,无论我们选择何种归约路径,最终都能汇聚到同一个结果。这个性质也称为汇合性。对于终止的程序,这个最终结果就是最终值;对于不终止的程序,该性质同样成立。这是λ演算成为所有编程语言基础的重要原因之一。
顺序函数式编程显然是汇合的,因为它可以相当直接地翻译到λ演算。但在我们的声明式编程课程中,我们走得更远:我们添加了并发线程、调度器、公平性、永不终止的流以及单赋值变量。那么,这些特性如何保持声明式的性质呢?
我们将从三个方面来探讨声明式编程的含义:并发性、流(部分终止)以及单赋值变量。
并发性与汇合性
首先,并发性本身并不破坏声明性。线程和调度器只是改变了每一步执行哪个归约,但由于丘奇-罗瑟定理,这没有问题。因此,我们不再深入讨论这一点。
流与部分终止
接下来,我们讨论永不终止的程序,例如流处理。输入流不断进入,输出流不断产生。这如何是函数式的呢?
我们可以定义一种“临时终止”。如果输入流S1在一段时间内不变化,程序最终会执行完毕并产生一个特定的输出值。我们称这个点为“静止点”。程序暂时“休息”。之后,如果输入流被扩展(例如绑定新值),输出流也会相应扩展,程序到达一个新的静止点。因此,输出流S2始终是输入流S1的函数。这就是我们为永不终止的程序定义函数式执行的方式。
单赋值变量与逻辑等价
单赋值变量带来了一个重大变化,它使得这个范式类似于逻辑编程。存储中包含未绑定的变量,这些变量的绑定可以按不同顺序进行,但存储从某种逻辑意义上说仍然是相同的。
为了解释这一点,我们需要引入一阶逻辑。
一阶逻辑简介
一阶逻辑是一种形式化逻辑,它包含变量、量词(全称∀、存在∃)、谓词(多参数关系)以及连接词(如∧、∨、⇒、⇔)。它由三部分组成:
- 语言:语法定义的公式集合。
- 证明论:一套从给定公理推导出新公式的规则(纯语法操作)。
- 模型论:为公式赋予真值的“世界”或数学结构。
例如,公式 ∀x ∀z (Grandparent(x, z) ⇔ ∃y (Parent(x, y) ∧ Parent(y, z))) 本身只是一串符号。但我们可以为其赋予一个模型,例如“所有人类及其亲子关系”的集合。在这个模型中,该公式为真,因为它定义了“祖父母”关系。
编程语言如Prolog、约束编程和SQL都基于一阶逻辑。
逻辑符号
我们有两种方式连接公式:
- 语法可证:
α ⊢ β,表示在公理α下,可以证明β。 - 语义蕴含:
α ⊨ β,表示在所有使α为真的模型中,β也为真。
前者关乎证明过程,后者关乎真值。
现在,我们可以回到声明式编程中的存储概念。
存储的逻辑模型
程序运行后会产生一个存储σ,它是一系列变量绑定的集合。我们可以将存储σ视为一个逻辑公式。例如,绑定 x = f(x1, ..., xn) 在某个解释下为真,当且仅当函数f应用于变量x1, ..., xn的值等于变量x的值。
如果两个存储σ和σ‘看起来不同(绑定顺序不同),但它们“说”的是同一件事,那么它们在逻辑上是等价的。形式化定义如下:
两个存储σ和σ‘是逻辑等价的,当且仅当 σ ⇔ σ‘ 是一个永真式(在所有模型中为真)。这意味着,任何一组使σ为真的变量赋值,也同样使σ‘为真,反之亦然。它们表达了完全相同的约束集合。
声明式编程的正式定义
基于以上概念,我们现在可以给出声明式编程的定义:
一个程序是声明式的,当且仅当对于所有可能的输入,所有可能的执行(无论调度器如何选择):
- 要么全部不终止(都进入无限循环);
- 要么全部达到(部分)终止,并且产生的存储是逻辑等价的。
换句话说,程序没有“可观察的非确定性”。调度器的选择是自由的,但程序员无法观察到其影响,因为最终结果在逻辑意义上是相同的。
我们之前学过的所有五种范式(顺序函数式、带单赋值的顺序函数式、确定性数据流、惰性求值、惰性+并发)都符合这个定义,因此都是声明式的。
故障隔离
声明式编程非常强大,但有时程序不得不与非声明式的部分交互(例如与现实世界交互),或者程序中可能存在错误(bug),这会导致可观察的非确定性。
我们可以通过“故障隔离”技术来封装这种非确定性,尝试恢复声明式的行为。基本思想是:如果程序某部分因错误产生非确定性结果,我们可以捕获这个错误,并返回一个预定义的、确定性的值(如一个错误标识),从而从外部看,程序行为仍然是确定性的。
声明式编程范式总结
声明式编程包含多种范式,主要可以从两个维度划分:
- 求值策略:严格(及早)求值 与 惰性(延迟)求值。
- 变量特性:仅值(无未绑定变量) 与 数据流变量(单赋值,支持未绑定变量)。
结合这两个维度,我们可以得到:
- 严格函数式:Scheme, ML。使用值,及早求值。
- 惰性函数式:Haskell。使用值,惰性求值。
- 并发数据流:Oz, Alice。使用数据流变量,及早求值,支持并发。
- 逻辑编程:Prolog。使用数据流变量(和更多特性),及早求值。
- 惰性并发:用于流处理和分析工具。使用数据流变量,惰性求值,支持并发。



高效声明式算法设计
声明式编程常被认为效率低下,但这并非事实。接下来,我们将学习如何利用单赋值和惰性求值设计高效的算法。
我们将关注以下概念:
- 语言概念:单赋值变量、惰性值。
- 复杂度概念:摊销复杂度、最坏情况复杂度。
- 算法类型:临时算法、持久化算法。

复杂度概念回顾
- 最坏情况复杂度:传统的O(f(n))记法,给出执行时间的上界。
- 摊销复杂度:考虑一连串n个操作的总时间复杂度O(f(n)),则每个操作的摊销复杂度为O(f(n)/n)。这适用于某些操作偶尔很昂贵,但平均成本很低的情况。
临时算法 vs 持久化算法
这是一个关键区别,在传统算法课程中较少强调:
- 临时算法:当你通过操作(如插入)从旧版本数据结构创建新版本(如Q2 = insert(Q1, a))后,旧版本Q1就不可用了。大多数命令式语言(如Java)中的算法都是临时的。
- 持久化算法:创建新版本Q2后,旧版本Q1仍然完全可用且可操作。你可以同时拥有并操作多个版本。这在版本控制系统(如Git)、协作文本编辑、支持回滚的数据库中非常有用。在命令式语言中实现持久化通常需要显式拷贝,而利用惰性求值,我们可以更优雅、自动地实现它。
持久化算法有时还支持合并操作,将两个分支版本合并为一个。
差异列表:一个高效的基础数据结构
在深入队列算法之前,我们先介绍一个非常实用的声明式数据结构:差异列表。
差异列表是表示列表的一种方式,它支持常数时间的追加和末端添加操作。
定义与原理
一个差异列表表示为一对列表(S, E),其中E是S的一个后缀。它表示的列表是“S减去E”,即从S中移除后缀E后剩下的部分。
例如,列表 [1, 2, 3] 可以用多种差异列表表示:
( [1,2,3 | X], X ),其中X是未绑定变量。( [1,2,3,4 | Y], [4 | Y] )( [1,2,3,4,5], [4,5] )
高效操作
差异列表的强大之处在于其操作效率:
- 追加:给定两个差异列表
(S1, E1)和(S2, E2),将它们追加为(S1, E2),同时绑定E1 = S2。这个绑定操作是常数时间的,从而实现了常数时间的列表追加。 - 前端添加:向
(S, E)前端添加元素X,得到( [X | S], E ),常数时间。 - 末端添加:向
(S, E)末端添加元素X,绑定E = [X | E1],得到新差异列表(S, E1),常数时间。
示例:展平列表
考虑一个展平嵌套列表的函数。使用普通列表和追加操作的实现效率不高,因为追加需要遍历第一个列表。
使用差异列表,我们可以实现一个无追加、无额外垃圾产生的高效展平算法。算法核心思想是递归处理子列表时,通过共享和绑定中间变量,直接“串联”差异列表,避免显式的遍历和拷贝。
差异列表在功能上类似于命令式语言中的链表加末端指针,但它具有函数式语义(不可变、更健壮)的所有优点。
队列算法系列
我们将以队列(FIFO,先进先出)这一抽象数据类型为例,展示五种不同的声明式实现,其复杂度特性各不相同。队列支持两个操作:
insert(Q, X):将元素X插入队列末端。delete(Q):从队列前端移除并返回一个元素。
1. 朴素队列(最坏情况O(n)删除)
使用普通列表实现队列。insert在列表前端添加元素,是O(1)操作。delete需要移除列表最后一个元素,必须遍历整个列表,是O(n)操作,其中n是队列长度。
2. 摊销常数时间临时队列
使用一对列表(F, R)表示队列。F是队列前端,R是队列后端的反转。insert操作将元素添加到R前端(O(1))。delete操作从F前端移除元素(O(1))。
关键是一个称为check的辅助函数:当F为空时,check将整个R反转后放入F(O(|R|)操作),并将R置空。insert和delete操作后都调用check。
摊销分析:每次delete导致F为空时,会触发一次昂贵的reverse操作(O(k),k为当时R的长度)。但在此之后,可以进行k次便宜的delete(从新的F中取)。因此,在一系列操作中,昂贵的reverse被“摊销”到许多便宜操作上,使得每个操作的摊销时间复杂度为O(1)。
注意:这个算法在临时使用时是高效的。但如果以持久化方式使用(保留多个旧版本并基于它们进行操作),摊销分析可能失效,因为针对同一个旧版本的多次delete可能各自触发昂贵的reverse,导致最坏情况行为。
总结
本节课中,我们一起学习了声明式编程的精确定义,它建立在λ演算的汇合性和一阶逻辑的模型论之上。一个程序是声明式的,意味着其所有可能的执行路径要么都不终止,要么都终止于逻辑等价的存储。
我们还开始探索高效声明式算法的设计。我们介绍了差异列表,这是一个支持常数时间追加和末端添加的强大工具。接着,我们以队列为例,分析了朴素实现和一种摊销常数时间的临时队列实现。后者通过维护前后端两个列表,并批量反转后端列表到前端,实现了高效的摊销性能。



在接下来的课程中,我们将继续探索更高级的队列实现,包括最坏情况常数时间的临时队列,以及利用惰性求值实现的摊销和最坏情况常数时间的持久化队列。这些技术将展示声明式编程在实现高效、复杂算法方面的强大能力。

浙公网安备 33010602011771号