
在计算世界中,允许多个进程同时安全地访问共享数据是一个根本性挑战。传统的解决方案——悲观控制——通过锁定数据来防止冲突,但这种谨慎往往会导致性能瓶颈和可怕的死锁风险。这就提出了一个关键问题:是否存在一种更高效的并发管理方式,尤其是在直接冲突很少见的情况下?本文通过全面探讨乐观并发控制(OCC)——一种“先斩后奏,请求原谅”的哲学——来解决这个问题。首先,在“原理与机制”部分,我们将剖析 OCC 的核心思想,比较其与悲观锁的性能权衡,并探讨其对死锁的优雅解决方案,以及其自身独特的陷阱,如活锁和系统颠簸。随后,“应用与跨学科联系”部分将揭示这一强大概念不仅是理论上的好奇心,更是现代数据库、操作系统乃至物理世界类比背后的引擎。
想象有两位合作者,一位是悲观主义者,一位是乐观主义者,他们正在共同处理一份共享的数字手稿。悲观主义者总是小心翼翼,他认为只要自己开始写作,就必定会有人跳出来做出冲突的修改。因此,在敲下第一个字之前,他会“锁定”文档,阻止其他任何人打开它。只有当他完全完成后,他才会解锁。这种方法保证了安全——在他工作时没有人能制造混乱——但效率可能极低。如果实际上没有其他人打算编辑,那么所有其他合作者都只能毫无理由地等待。
乐观主义者采取了不同的策略。他假设冲突是罕见的。他只是打开文档,制作一个副本,然后兴致勃勃地开始编辑自己的副本。完成后,他会进行一次快速检查:在此期间,原始文档是否被其他人更改过?如果答案是否定的,他就将自己的更改合并到原始文档中。然而,如果确实有人做了更改,乐观主义者会叹口气,丢弃自己的工作,然后用最新更新的文档版本重新开始。
这个小故事抓住了计算中管理共享资源的两种基本哲学精髓:悲观并发控制和乐观并发控制。
在软件世界里,“文档”可以是数据库中的一行、存储系统中的一个文件,或内存中的一段数据。“合作者”则是同时运行的不同线程或进程。
悲观并发控制(Pessimistic Concurrency Control, PCC),就像我们那位谨慎的合作者一样,通过获取锁来工作。在一个线程可以读取或修改一段数据之前,它必须首先获取该数据的锁。如果另一个线程已经持有一个冲突的锁(例如,一个“排他性”写锁),那么新线程必须停止并等待。它会被阻塞,直到锁被释放。这种方法,通常通过互斥锁(mutexes)或两阶段锁定(two-phase locking, 2PL)等机制实现,可以防止冲突的发生。你付出的代价是获取和释放锁的开销,更重要的是,等待他人完成工作所花费的时间。
乐观并发控制(Optimistic Concurrency Control, OCC)体现了相反的哲学。它遵循一个简单而强大的原则:先工作,后检查冲突。线程不获取锁。它们读取一段数据,在一个私有副本上工作,当准备提交更改时,它们会执行一个验证步骤。系统会检查自该乐观线程首次读取以来,底层数据是否已被其他线程修改。如果没有发现冲突,更改将被原子性地应用。如果检测到冲突,事务将中止。其所有工作都被丢弃,线程通常必须重试整个操作。
这揭示了根本性的权衡。悲观主义支付了预付的、有保证的成本(锁定开销和潜在的等待)来防止冲突。乐观主义避免了这种预付成本,全速前进,但如果真的发生冲突,它会支付可能更大的成本(浪费的工作和重试)。
那么,哪种方法更好呢?这不是一个哲学问题,而是一个数学和概率问题。我们可以分析两种策略的性能,以找到“盈亏平衡点”。
让我们想象一个简单的任务,其计算时间为 。在悲观方案下,我们总是要支付一个锁定开销 。如果存在冲突(以某个概率 发生),我们还必须等待。总的期望时间将是 。
在乐观方案下,我们支付一个验证开销 。以 的概率,我们成功了,时间就是 。但以概率 ,我们失败了,必须重做一遍。期望时间大约变为 。
通过建立并求解不等式 ,我们可以找到乐观主义胜出的确切条件。答案总是取决于几个关键因素:
我们甚至可以推导出盈亏平衡冲突概率 的符号表达式,这是一个阈值,在此阈值下两种策略的预期开销相等。这个阈值取决于读写操作的混合比例,以及锁和验证的相对成本,从而将我们关于何时应该成为一个乐观主义者的直觉形式化。
性能权衡引人注目,但欣赏乐观并发还有一个更深层次、更优美的原因:它与死锁的关系。死锁是两个或多个线程之间的致命拥抱,其中每个线程都卡在等待另一个线程所持有的资源上。例如,线程1持有资源A并等待资源B,而线程2持有资源B并等待资源A。两者都无法继续前进,除非系统干预,否则它们将永远等待下去。
死锁的发生必须同时满足四个著名的条件(即 Coffman 条件):互斥、持有并等待、不可抢占和循环等待。其中最关键的是持有并等待:一个线程在等待另一个资源时被阻塞,同时还持有一个资源。
乐观并发优雅地避开了这个陷阱。一个乐观线程在持有资源时从不等待。事实上,它根本不以传统意义上的方式“持有”共享资源。如果其验证失败,它不会阻塞;它会中止并释放一切。通过消除“持有并等待”条件,它使得在乐观管理下的资源上发生死锁成为不可能。这是一个深刻的转变:我们不是试图检测和打破死锁,而是改变游戏规则,使它们从一开始就无法发生。
当然,在计算机科学中没有免费的午餐。虽然乐观主义战胜了死锁,但它可能会陷入其病态的表亲:活锁。在活锁中,线程没有被阻塞,但它们仍然无法取得进展。想象一下在狭窄的走廊里两个过于礼貌的人;每个人都试图让开让对方通过,但他们都朝同一个方向移动,反复地相互阻挡。他们很活跃,但寸步难行。类似地,两个乐观线程可以反复尝试一个事务,它们的工作被对方作废,然后回退并重试,所有这些都处于完美的、毫无成效的同步中。
一个更隐蔽的问题源于乐观主义本身的成功。通过不阻塞,OCC 可以让活跃运行的线程数量急剧增加。这种高的多道程序设计度可能导致一种称为系统颠簸(thrashing)的系统级灾难。
到目前为止,我们一直在谈论“验证”或“检查更改”。线程实际上是如何做到这一点的呢?最常见的机制是版本号。每一块共享数据都带有一个计数器。
一个朴素的方法可能是:
这看起来似乎可行,但它包含一个致命的关键缺陷。如果一个写入者的整个修改过程恰好发生在你的两次读取之间怎么办?一个写入者可以进入其临界区,扰乱数据结构,然后退出,而你的线程正忙于在它的副本上工作。如果写入者只在最后才更新版本号,你的线程就有可能在前后读取到相同的版本号,但其操作的数据却是暂时不一致的。这是一个典型的违反正确性的竞争条件。
真正稳健的解决方案是一种巧妙的机制,通常称为序列锁(sequence lock)或seqlock。这是一场关于偶数和奇数的优美舞蹈。
写入者在开始制造混乱之前就发出信号。
一个乐观的读取者遵循以下协议:
这个简单的协议优雅地解决了问题。如果在读取者的遍历期间,一个写入者开始或结束其操作,版本号将会改变,最终的检查将会失败。如果读取者的遍历恰好与写入者的整个临界区重叠,它要么在开始时看到一个奇数,要么在结束时看到一个改变了的数字。这个小巧而精确的机制是使乐观并发既可行又安全的引擎。
在探索了乐观并发的原理之后,我们现在来到了探索中最激动人心的部分:看这个思想在现实世界中如何被应用。这种“先斩后奏,请求原谅”的哲学究竟在哪里奏效?它又教会了我们关于系统、冲突与合作的本质什么呢?你可能会惊讶地发现,这一个思想如同一条线索,贯穿了现代计算的核心,从支撑全球经济的数据库,到你设备上的操作系统,甚至在交通模式和几何学中都能找到它的回响。
想象一个繁忙的十字路口。一种管理方式是使用交通灯——一种悲观的方法。我们假设冲突很可能发生,所以我们强制大多数车辆停下等待,每次只给一个方向授予独占通行权。这很安全,但即使只有一辆车驶近,开销也是恒定的。这就是悲观锁定的世界。
现在,想象一个现代环岛。这就是乐观方法的实际应用。进入环岛的车辆需要让行给已经在环岛内的车辆,但如果路径清晰,它们并不会停下来。它们乐观地前进。这里的假设是,车流通常会足够稀疏,可以顺利汇入。当冲突发生——两辆车在同一时间到达同一点——通过一个简单的局部规则解决:让行。在交通拥堵时,这可能会迫使一辆车减速甚至绕圈(一种“回滚”),但在轻度到中度交通中,吞吐量非常可观,因为没有人会不必要地停车。
这个简单的类比抓住了乐观并发控制(OCC)的精髓。它是一种系统设计哲学,我们用锁的确定性、预付的等待成本,换取在真正冲突的罕见情况下可能发生的“回滚”成本。让我们看看这个优雅的权衡在我们的数字世界中是如何发挥作用的。
或许,乐观并发最重要的应用是在数据库和分布式数据存储领域。当你使用银行应用、预订航班或浏览在线商店时,你是成千上万个同时访问相同数据的用户之一。系统如何在不陷入停顿的情况下保持一致性?
许多现代数据库,从 PostgreSQL 到 Google 的 Spanner,都使用一种强大的 OCC 形式,称为多版本并发控制(Multi-Version Concurrency Control, MVCC)。其核心思想是永不覆盖数据。相反,当进行更改时,会创建该数据的一个新版本。每个事务都被赋予一个数据库在特定时间点的“快照”。它从这个一致、不变的视图中读取数据,完全与其他并发事务的混乱隔离开来。
想象数据库由一个持久化平衡二叉搜索树表示。当一个事务需要更新一个值时,它不会锁定这棵树。相反,它使用一种称为“路径复制”的技术,创建树的一个新版本,只为通往被更新叶子节点的路径上的节点创建新副本。树的其余部分是结构共享的——这是一种创建世界新“版本”的极其高效的方式。当事务准备好提交时,它会进行一次验证检查:在我工作所依据的数据上,有没有其他人进行了修改?如果没有,它的新版本树就成为新的官方状态。如果有,它就中止并用一个全新的快照重试。
这个原理正是现代云计算的核心。像 Kubernetes 这样的容器编排系统使用一个名为 etcd 的分布式键值存储,它依赖于 MVCC。当调度器决定将一个新应用(一个“Pod”)放置在哪里时,它必须考虑整个集群中可用的 CPU、RAM 和磁盘 I/O 资源。这个决策过程,可能是一个像银行家算法那样复杂的计算,是乐观地完成的。调度器从 etcd 中读取集群状态的一致快照,在不持有任何锁的情况下执行其计算,然后尝试原子性地提交其决策。只有当集群的底层状态在此期间没有改变时,提交才会成功。这使得复杂的全局决策可以在不让整个系统停滞的情况下做出。
同样的模式也适用于分布式文件系统。像重命名文件这样的操作,可能涉及更新两个不同的父目录和文件自身的元数据(其“inode”),可以被当作一个单一的、乐观的事务来处理。系统读取源目录、目标目录和文件本身的版本。然后,它提出一个原子性更新,其条件是所有这些版本都没有改变。这使得复杂的多对象操作可以在不诉诸繁琐的全局锁的情况下安全地并发进行。
乐观哲学不仅适用于大规模分布式系统;它在单台计算机的引擎室深处同样强大。在操作系统内核或高性能多线程应用中,我们面临着同样的挑战:如何在不造成性能瓶瓶颈的情况下管理共享数据结构。
考虑经典的用于避免死锁的银行家算法。一个朴素的实现可能会使用一个巨大的锁来保护所有的资源分配表,同时检查一个新请求是否安全。这将所有请求串行化,扼杀了性能。一个更复杂的、乐观的方法可以使用像序列锁这样的机制。线程可以在没有锁的情况下读取共享数据结构,但它们在读取前后检查一个版本计数器。如果计数器没有改变,那么读取就是一致的。安全性检查的计算可以并行进行。只有最后对表的微小更新需要一个短暂的、串行化的提交,这个提交会再次验证版本。
这个思想延伸到了软件的基石:数据结构。我们如何构建一个线程安全的红黑树,这是有序映射的基本结构?为每次插入或删除锁定整棵树的效率极低。乐观的方法允许更细粒度的并发。当一次删除需要重新平衡操作(一次旋转)时,线程不会锁定整棵树。相反,它识别出涉及的节点的“邻域”——父节点、兄弟节点和侄子节点——并试图在执行更新前验证并仅锁定这几个节点。如果成功,更改被提交。如果因为另一个线程正在操作一个重叠的邻域而失败,它就简单地重试。这使得对树的遥远部分的操作可以完全并行进行。
在最优雅的情况下,这种机制可以通过软件事务内存(Software Transactional Memory, STM)完全对程序员隐藏。程序员只需将一段代码——例如,一个反转链表的函数——标记为一个单一的“原子事务”。语言的运行时系统随后会乐观地执行这段代码,自动跟踪所有被读取和写入的内存位置。最后,它会验证是否存在冲突,并要么提交更改,要么透明地回滚并重试。这为通用编程带来了数据库风格事务的强大能力。
乐观主义是一场赌博——赌冲突是罕见的。当这场赌博输了会发生什么?这就是工程权衡变得至关重要的地方。悲观锁定有一个恒定的、预付的获取锁和等待的成本。乐观并发避免了这一点,但冒着支付不同成本的风险:中止事务的浪费工作和回滚的开销。
我们可以相当精确地对这种权衡进行建模。想象一个分布式共享内存系统,其中每个事务都涉及一系列消息。在悲观方案中,获取锁有固定的成本。在乐观方案中,成功尝试的成本较低,但有中止的概率。通过分析两种方案的期望时间,我们可以计算出一个临界中止概率——一个特定的争用水平,在此水平上两种策略的性能相同。低于这个概率,乐观主义胜出;高于它,悲观主义是更安全的选择。这为我们提供了一种强大的、量化的方法来推理选择哪种策略。
理解争用是关键。冲突到底在哪里发生?在一个比较并发搜索树的两种设计(一种无锁,一种使用乐观锁)的思想实验中,我们可以看到“热点”并不总是在同一个地方 [@problem-id:3664153]。在一个沿根路径使用细粒度乐观锁的设计中,根节点本身成为主要的争用点,因为每个操作都必须经过它。在一个更新只在叶子节点发生的无锁设计中,争用被分散到树的底部。策略的选择从根本上改变了性能景观。
此外,回滚本身的成本也取决于我们的设计选择。如果一个事务中止,系统必须撤销其操作。这个“撤销”过程的成本在很大程度上取决于底层的数据结构。在用简单线性列表实现的目录中撤销一个创建操作,需要缓慢地扫描以找到要删除的条目。在哈希表中,查找几乎是瞬时的 [@problem-id:3634400]。一个好的乐观系统不仅要设计得让常见情况快速,还要让失败情况(回滚)尽可能廉价。
乐观并发之所以如此引人注目,是因为这一个原理在看似无关的领域中显现出来,揭示了我们面临问题的更深层次的统一性。
考虑经典的哲学家就餐问题,这是资源分配和死锁的一个隐喻。一个悲观的解决方案涉及一个复杂的协议,比如按特定顺序拿起叉子,或者使用一个中央“服务员”来避免死锁。一个乐观的哲学家则表现得更大胆:他们推测性地尝试同时拿起两只叉子。如果成功了,他们会进行一次验证检查:在我拿起叉子的时候,有没有其他人开始在我这张桌子上吃饭?如果没有,他们就提交并吃饭。如果有,他们就中止,放下叉子,然后重试。这个策略通过打破“持有并等待”条件,优雅地避开了死锁。然而,它也引入了乐观主义的典型风险:饥饿(一个不幸的哲学家可能总是输掉提交的竞争)和活锁(所有哲学家可能同步他们的尝试和中止,形成一个重复的、无用的循环)。
最后一个优美的转折是,让我们通过几何学家的视角来看待这个问题。想象时间是一条水平轴。一个从开始时间 运行到结束时间 的事务可以被绘制成这条轴上的一个线段。如果两个事务使用相同的资源,它们之间的冲突就仅仅是它们线段的交集。突然之间,我们的并发控制问题变成了一个计算几何问题!我们可以使用一种经典的几何技术,即扫描线算法,来设计一个调度器。我们用一条垂直线扫过时间轴,按顺序处理事务的“开始”和“结束”事件。通过跟踪当前活动的线段,我们可以检测到交集(冲突)并应用我们的乐观规则。
从交通环岛到数据库理论,从内核编程到哲学家就餐和几何算法的抽象世界,乐观并发的原理处处回响。它教给我们一个深刻的教训:在设计合作系统时,有时最有效的策略不是谨慎地为最坏的情况做计划,而是大胆地为最好的情况采取行动,同时始终为我们的乐观主义被证明是错误的时候准备好备用计划。