
在任何现代计算环境中,无论是单个多核处理器还是遍布全球的分布式网络,都会出现一个根本性挑战:众多独立进程如何能在同时访问和修改共享数据时而不破坏它?这正是并发控制旨在解决的核心问题。它试图提供一种每个进程都在隔离环境中运行的假象,确保数据完整性的同时,释放并行化带来的性能优势。本文将探讨这一复杂主题,首先探索其理论基础,然后审视其实际应用。第一章“原则与机制”将解析悲观控制与乐观控制的核心哲学,详细介绍锁机制、版本控制和死锁管理等关键技术。随后的“应用与跨学科联系”一章将揭示这些抽象概念如何在数据库、操作系统甚至CPU硬件中具体实现,展现这些思想在计算机科学领域的统一优雅之美。
想象一个繁忙的厨房,几位厨师都想准备自己的菜肴。他们共享同一个食品储藏室的食材,使用同一个炉灶,并且需要协调行动以避免混乱。一位厨师可能正要拿面粉,而另一位正要把它放回去。第三位可能在检查罐子里的盐量,而另一位正在补充。没有一套规则,厨房将陷入疯狂,菜肴被毁,脾气暴躁。这就是并发的世界。
在计算领域,无论是在管理数百万事务的数据库中,还是在处理无数任务的操作系统中,抑或是在遍布全球的分布式系统中,我们都面临着同样的挑战。我们如何允许多个独立进程在共享数据上并发工作,而不会造成数据损坏、不一致的混乱局面?解决这个难题的艺术与科学被称为并发控制。其最终目标是创造一种强大的独处错觉:给予每个进程或事务一种它独占整个系统的印象,从头到尾运行不受任何干扰,即使它实际上正与成千上万的其他进程并行运行。这种保证结果等同于某种一次一个(即串行)执行的效果,被称为可串行化。
并发控制的核心是两种对立的哲学,这场辩论反映了一个经典问题:是请求许可更好,还是请求原谅更好?
第一种哲学是悲观并发控制。顾名思义,它假设最坏的情况:冲突频繁发生,必须主动预防。悲观主义者的主要工具是锁。在事务读取或写入一条数据之前,它必须首先获取该数据的锁。如果另一个事务已经持有一个冲突的锁(例如,你无法在一个事务正在写入的数据上获取写锁),请求者必须等待。这种方法,通常实现为两阶段锁(2PL),确保一旦事务获取了其所有锁并开始工作,就没有人能干扰其数据,直到它完成。
然而,这种安全性是有代价的。获取锁需要时间,更重要的是,等待锁会极大地拖慢速度。想象一下网上商店里的一件热门商品。在悲观方案下,一次只能有一个人查看结账页面,导致一长队沮丧的顾客。一个事务花费的总时间不仅是其自身的工作,还包括加锁的开销和潜在的、通常很可观的等待时间。这种方法在争用激烈时最有效——即许多事务很可能发生冲突时。在这种情况下,等待的成本通常低于撤销和重做工作的成本。
第二种哲学是乐观并发控制(OCC)。乐观主义者假设冲突很少发生。它不是通过锁定数据来预防冲突,而是让事务自由进行,就好像没有其他事务存在一样。每个事务都在数据的私有副本上工作。当它准备好提交更改时,会进入一个验证阶段。它会检查:“我工作所依赖的任何数据,在此期间是否被其他人更改了?”这通常通过检查数据的版本号来完成。如果没有变化,事务的更新就会被应用。如果检测到冲突,事务就会被中止,其工作被丢弃,并且必须从头再来。
OCC的美妙之处在于其“零开销”路径。当没有冲突时,事务可以飞速完成,无需任何等待或加锁延迟。然而,请求原谅的代价可能很高。如果一个事务被中止,它所消耗的所有计算资源都被浪费了。在高争用情况下,事务可能会陷入中止和重试的循环,这种现象称为颠簸(thrashing),其性能可能比悲观系统更差。
那么哪个更好呢?没有唯一的答案。这种选择是一种微妙的权衡,深度依赖于工作负载的性质。对于冲突率低的任务,乐观主义占优。对于冲突是常态的任务,例如对单个数据(如共享B树索引的根节点)的激烈争用,悲观主义通常是更明智的选择。
科学与工程中基本原则的美妙之处在于它们如何在不同领域中产生共鸣。数据库中事务面临的冲突与CPU流水线为正确执行指令而必须处理的数据冒险有着惊人的相似之处。这个类比不仅富有诗意;它揭示了操作排序问题中深层次的、共通的结构。
写后读(RAW): CPU指令 需要读取一个前序指令 刚刚写入的寄存器。在数据库中,这是事务 读取事务 刚刚写入的数据。如果 尚未提交,那么 正在执行脏读——读取可能稍后会被回滚的数据。这是对隔离性的根本违反,大多数系统至少提供读已提交隔离级别来防止这种情况。
读后写(WAR): 指令 想要写入一个前序指令 仍需读取的寄存器。重排它们会导致 读取错误的值。在数据库中,这发生在 写入一个 已经读取过的项。如果 再次读取该项,它会看到一个不同的值,这种异常称为不可重复读。“读已提交”级别允许这种情况,但更严格的可重复读级别旨在防止它。
写后写(WAW): 指令 和 都写入同一个寄存器。寄存器的最终状态取决于哪条指令最后执行。在数据库中,这是经典的丢失更新问题,即两个事务读取相同的值,都计算出一个新值,其中一个的写入覆盖并“丢失”了另一个的写入。
当我们考虑解决方案时,这个类比变得真正深刻。现代CPU如何在不暂停流水线的情况下解决WAR冒险?它们使用一个巧妙的技巧,称为寄存器重命名。CPU为写指令()提供一个全新的、不可见的物理寄存器来写入,从而打破了依赖关系。读指令()可以继续使用旧的物理寄存器,两条指令都可以并行进行。
这个优雅解决方案在数据库中的等价物是多版本并发控制(MVCC)。写入者不是覆盖数据,而是创建该数据项的一个新版本。一个较早开始的事务可以继续从其世界的“快照”中读取旧的、一致的版本,完全不知道写入者的更改。写入者不阻塞读取者,读取者也不阻塞写入者。MVCC是数据库版本的寄存器重命名,是计算机系统思想统一性的一个美丽证明。
虽然锁是一种强大的悲观工具,但它带来了一个危险的副作用:死锁。这是一种“致命拥抱”,一个循环等待模式,其中两个或多个事务被卡住,每个都在等待对方持有的锁。例如, 持有项目 的锁并请求 的锁,而 持有 的锁并请求 的锁。两者都无法继续,除非系统干预,否则它们将永远等待下去。
只有当四个条件——Coffman条件——同时满足时,死锁才会发生:互斥(资源被独占持有)、占有并等待(一个事务在等待另一个资源时持有至少一个资源)、不可抢占(资源不能被强制夺走)和循环等待。
这里的微妙之处在于,隔离级别的选择本身就可能为死锁创造条件。考虑一个操作调度。在宽松的READ COMMITTED(读已提交)级别下,读锁会立即释放,死锁可能不会发生。但如果你在严格的SERIALIZABLE(可串行化)级别下运行完全相同的调度,该级别会持有所有锁直到事务提交,更长的锁持有时间可能会创建死锁所需的循环依赖。
系统处理死锁主要有三种方式:
MVCC以其非阻塞读的特性,成为并发控制中最优雅的解决方案之一。它的工作原理是不仅维护数据的当前状态,还维护其历史。每个数据项都是一个版本链,每个版本都用提交它的事务的时间戳进行标记。当一个新事务开始时,它被赋予自己的快照时间戳。要读取一个项目,它只需遍历版本链,并选择其提交时间戳小于或等于自身快照时间戳的最新版本。
这创造了一个新的、引人入胜的问题:所有这些旧版本堆积起来,消耗内存。这就是垃圾回收的问题。一个旧版本何时可以被安全删除?答案并不像“当有更新的版本存在时”那么简单。一个很久以前开始的、长期运行的事务可能仍然需要看到那个非常旧的版本,以维持其对过去的一致快照。
正确而优雅的解决方案是找到一个“低水位标记”。系统跟踪所有当前活动读取事务的快照时间戳。它找到最旧的一个,即具有最小时间戳的那个,我们称之为 。任何事务,无论多老,都需要看到一个至少与 处的读取者可见版本一样新的数据版本。因此,系统可以安全地回收任何比这个低水位标记处可见的版本更旧的版本。这确保了历史为所有仍然需要它的人保留,同时允许系统有效地清理遥远的过去。这是一个务实的解决方案,使MVCC的“时间旅行”成为现实。
从悲观主义和乐观主义的高层哲学,到垃圾回收的精细机制,并发控制是一个充满巧妙权衡和优美抽象的领域。它是使我们复杂、互联的数字世界能够兼具速度与理性的沉默而必不可少的机器。
在遍历了并发控制的基本原则之后,我们可能会倾向于将它们视为一套为计算机科学家制定的抽象规则。但这就像学习了和声定律却从未听过交响乐。这些思想的真正美妙之处不在于其抽象的表述,而在于看到它们在现实世界中上演,指挥着现代计算的复杂机器。在本章中,我们将踏上一段旅程,见证这场交响乐的实际演奏。我们将看到同样的核心原则——原子性、隔离性以及对共享资源的规范访问——如何在各处体现,从驱动我们数字生活的数据库,到管理我们设备的操作系统,甚至深入到我们处理器的硅片之中。
也许并发控制最经典、最具体的应用是在数据库中——那个为我们细致地记录一切的数字文书。想象一个高吞吐量的系统,比如一个视频处理服务,许多任务在队列中等待由一组工作机处理。构建这个队列的一个简单方法是使用一个数据库表,其中每一行都是一个待处理的任务。
现在,问题出现了。所有空闲的工作机都渴望一个新任务,它们都涌向队列“头部”去抢夺那个最旧的任务。第一个工作机获得了那行的锁,但其他工作机怎么办?它们形成了一个“护航队”(convoy),一个数字交通堵塞,都在等待第一个工作机。即使锁只被持有片刻,队列前端的这种串行化也造成了一个瓶颈,阻碍了系统的扩展。我们雇佣了更多的工人,但他们大部分时间都在排队等待!
解决方案不是告诉工作机要有耐心,而是给它们一种更聪明的方式来寻找工作。现代数据库为此提供了一种非常优雅的机制,通常称为SKIP LOCKED。当一个工作机查询任务时,它被指示简单地跳过任何已被其他工作机锁定的行,然后继续处理下一个可用的行。 这个小小的改变完全消解了交通堵塞。现在每个工作机都可以并行地从队列头部抓取一个唯一的、未锁定的任务,使得吞吐量能够随着工作机数量的增加而完美扩展。这是一个绝佳的例子,说明了对并发控制的精妙理解如何将瓶颈变成高速公路。
但让我们看得更深一些。数据库是如何如此迅速地找到这些行的呢?数据通常以复杂的树状结构组织在磁盘上,著名的就是B树。当我们向一个已经满了的B树节点插入一个新键——也许是我们队列的一个新任务——该节点必须被分裂成两个。这是一个精细的结构修改。如果两个线程试图同时对同一个节点执行此操作,它们就有可能破坏整个树。
为了防止这种情况,系统采用了一种优美的、分层的锁舞,称为闩锁耦合(latch coupling)或“蟹行协议(crabbing)”。一个线程在树中向下寻找插入点时,在获取子节点的闩锁(一种短期锁)之前,会先获取父节点的闩锁。一旦子节点被安全地闩锁,父节点的闩锁就可以释放。当一个线程需要执行分裂时,它必须在修改父节点之前获取其上更强的排他锁。这种严格的自顶向下协议确保了两个线程永远不会因为试图获取对方节点上的锁而死锁,并且对于任何可能在同一时刻搜索树的其他线程来说,树的结构保持一致。 在这里,我们看到并发控制不仅管理用户数据,还在保护支撑数据的骨架本身。
如果说数据库是文书,那么操作系统(OS)就是总指挥,管理着计算机的所有资源。它的主要工作是在无数并发进程的混乱中维持秩序。
想一想当你启动一个程序时会发生什么。一个线程可能会尝试访问一段当前不在主内存(RAM)中的代码或数据。这会触发一个页错误(page fault),一个无形的中断,操作系统必须介入,从速度慢得多的磁盘中获取所需的页面。但如果同一个程序中的两个线程几乎同时在同一个页面上发生页错误怎么办?我们又遇到了一个“惊群效应”(thundering herd)问题。如果我们不小心,操作系统可能会愚蠢地向磁盘发出两个独立的、相同的读取请求,浪费宝贵的时间和I/O带宽。
操作系统用一种我们现在应该感到熟悉的协议来处理这个问题。第一个处理该错误的线程会原子地翻转该页元数据中的一个位,将其状态标记为“处理中”(in-flight)。然后它发出单个磁盘读取请求,并让自己进入休眠状态。当第二个线程片刻后发生错误时,它会看到“处理中”的标志,并知道援助已在路上。它只是将自己添加到该特定页面的等待列表中,然后也进入休眠。一旦磁盘读取完成,操作系统会同时唤醒所有等待的线程,它们都可以继续工作,因为页面此时已存在于内存中。 这是一种效率的奇迹,确保了共享的、昂贵的操作只执行一次。
操作系统的角色也延伸到管理逻辑资源。在像Kubernetes这样的现代云环境中,系统必须将“pod”(容器组)调度到具有有限CPU、RAM和I/O容量的机器上。一个天真的调度器可能会在当时有足够可用资源的情况下就批准pod的请求。但这可能导致死锁:一种多个pod部分完成了分配,但没有一个pod能够获取其剩余所需资源的状态,导致它们全部卡住。
经典的解决方案是银行家算法(Banker's Algorithm),它确保系统始终保持在一个“安全”状态,即所有pod都存在一条完成路径。在一个高度并发的、分布式的调度器中,为了执行这种全局安全检查而持有一个巨大的锁,对性能来说是灾难性的。取而代之的是,现代调度器使用了一种直接源自并发控制教科书的乐观方法。调度器使用其底层数据存储(如etcd)的多版本并发控制(MVCC)特性,获取整个系统状态——所有pod的分配和可用资源——的一个一致性“快照”。 然后,它在这个私有快照上执行复杂的安全计算。如果分配被认为是安全的,它会尝试使用一个单一的原子事务来提交更改,该事务会验证它所读取的快照仍然是当前的。如果在此期间有其他调度器改变了状态,验证将失败,调度器只需使用一个新的快照重试即可。 这种“快照并验证”的模式是经典操作系统理论与现代分布式系统工程的强大融合。
并发控制的原则是如此基础,以至于它们并不仅限于软件。它们被直接蚀刻在我们处理器的硅片中。硬件事务内存(HTM)是现代CPU中的一项功能,它为将小代码块作为事务执行提供了直接的硬件支持。
当一个程序进入一个HTM区域时,处理器开始跟踪它读取和写入的所有内存位置(缓存行)。如果另一个CPU核心试图写入当前事务的读集或写集中的某个位置,硬件会通过其缓存一致性协议检测到冲突,并自动中止该事务,回滚其更改。 这非常强大,但并非魔法。硬件本质上是在实现一种非常快速的乐观并发控制方案。就像它的软件表亲一样,它也有局限性。它可能容易受到像“幻读”这样的逻辑异常的影响,并且将硬件事务与传统软件锁混合使用是一件危险的事情,如果处理不当,可能会破坏隔离保证。
最后,让我们退后一步,从一个不同的视角来看待这些系统,不是通过逻辑的镜头,而是通过物理学——吞吐量和流动的物理学。考虑一个无人机舰队,每架无人机都需要执行一个长的计算,该计算由通过单一、有限带宽的无线电信道发送的短命令来首尾衔接。我们能有多少架无人机并行计算?
这不是一个锁或版本的问题;这是一个流量守恒的问题。在稳态下,命令生成(每个任务两个)的速率不能超过信道的容量。从这个简单的定律中,我们可以计算出最大可持续的并行水平。试图更贪婪地启动超过这个限制的任务,将不可避免地导致命令积压不断增长和系统不稳定。最优策略不是一个复杂的算法,而是简单的准入控制:将并发任务的数量限制在计算出的可持续最大值。
这把我们带到了并发控制中最终极的工程权衡:是悲观更好还是乐观更好?是在行动前通过获取锁来“请求许可”更好,还是乐观地进行,如果发生冲突就重试来“请求原谅”更好?
事实证明,答案是“视情况而定”,我们甚至可以量化它。通过对等待锁的成本与中止并重试事务的成本进行建模,我们可以推导出一个临界阈值。 在某个冲突概率以下,乐观方法更快——锁的开销不值得。超过那个阈值,重复回滚的成本变得太高,而耐心等待锁的悲观策略胜出。这就是并发控制设计的量化核心:为预期的争用水平选择正确的策略。
我们的旅程结束了。我们已经看到同样的基本思想——原子性、隔离性、对共享状态的审慎管理——在各种各样的情境中回响。B树中闩锁的优雅舞蹈,页错误的规范协议,云调度器的乐观赌博,以及CPU事务内存的内在逻辑,都是同一首宏大交响乐的不同乐章。并发控制是无形的编舞,它为现代计算的并行世界带来秩序,实现了否则不可能达到的性能和规模。它证明了少数简单而优美的思想能够从混乱中创造和谐的力量。