try ai
科普
编辑
分享
反馈
  • 读写锁

读写锁

SciencePedia玻尔百科
关键要点
  • 读写锁通过允许多个读者但仅限一个独占写者来提升并发性,但这会带来写者饿死等公平性挑战。
  • 不当使用,例如升级读锁或在等待条件变量时持有锁,可能导致系统范围的死锁。
  • 序列锁(Sequence Locks)和读-复制-更新(RCU)等先进的乐观模式为读密集型工作负载提供了无锁读取,从而实现卓越性能。
  • 读写锁概念是一种基础模式,出现在操作系统内核、分布式系统、数据库隔离级别和区块链验证等领域。

引言

在并发编程的世界里,对性能的追求常常与数据完整性的需求相冲突。读写锁作为一种优雅的解决方案应运而生,在共享资源被频繁读取但很少修改的场景下,它是一种基础的同步工具。其运行规则简单而强大:允许多个线程同时读取,但只授权单个线程进行独占写入。这一机制有望在不牺牲安全性的前提下,释放高水平的并发能力。

然而,这种看似简单的机制背后隐藏着深度的复杂性。简单地应用此规则可能导致“写者饿死”——频繁的读者会永久阻塞等待中的写者,反之亦然。本文将揭开读写锁的神秘面纱,探讨其在性能、公平性和正确性之间的关键权衡。

在接下来的章节中,你将对这一核心并发原语建立稳固的理解。第一章“原理与机制”将剖析其核心概念,审视公平性问题及其解决方案,探究危险的死锁场景,并介绍挑战传统锁定思想的乐观替代方案。随后的“应用与跨学科联系”将揭示该模式的巨大影响,展示其在操作系统、分布式数据结构、数据库事务模型乃至现代区块链中的作用,从而阐明其在计算领域的普遍重要性。

原理与机制

想象一座宏伟的图书馆,里面藏有一份无价的孤本手稿。许多学者可以同时围在桌边研读,因为他们的阅读行为互不干扰。然而,当一位抄写员前来修正,增补新见或纠正旧误时,他需要绝对的清静。任何人都不能在旁边看,以免他们读到未完成的句子或分散抄写员的注意力。这个简单的类比抓住了​​读写锁​​的精髓:一种为共享资源被频繁读取但很少写入的场景设计的同步工具。其核心规则既优雅又简单:​​允许多个读者,但只允许一个写者,且写者要求独占访问​​。

这似乎是一个完美的解决方案,一种鱼与熊掌兼得的方式——既为读者提供高并发性,又为写者保障安全性。但正如物理学和计算机科学中许多美好的思想一样,真正的思想探险始于我们探究其细节之时。简单的规则并未告诉我们当出现排队时该怎么办。谁能先走下一步?

多数派的暴政:公平性问题

假设我们图书馆的规则很简单:“只要抄写员(写者)当前没有在写,新的学者(读者)就可以进入。”假设抄写员到达时,发现一群学者已在桌边。抄写员必须等待。但就在一位学者准备离开时,一位新学者来到门口。根据我们的规则,新来者可以自由进入,因为抄写员并未在主动写入。如果学者们源源不断地到来,最后一个在桌边的人可以有效地将“接力棒”传给下一个人,使得房间永远被占据。我们可怜的抄写员,虽然耐心等待,却可能永远没有机会工作。这是一个经典的并发问题,称为​​写者饿死​​(writer starvation)。

这并非偶然;一个恶意的“对抗性调度器”可以精确地策划这一场景,确保在活跃读者数量 r(t)r(t)r(t) 即将降为零时,总有一个新读者准备好获取锁,从而使 r(t)≥1r(t) \ge 1r(t)≥1 无限期地保持下去。这种被称为​​读优先​​(reader-preference)的策略最大化了读者的并发性,但对写者可能极其不公。

受挫之下,我们可能会彻底颠覆这个策略:“如果一个抄写员在等待,任何新来的学者都不得进入。”这是一种​​写优先​​(writer-preference)策略。现在,一旦有抄写员加入队列,新来的学者就会被拒之门外。这解决了抄写员的问题,但如果那天抄写员特别多呢?连续不断的等待中的抄写员可能会无限期地将学者拒之门外,导致​​读者饿死​​(reader starvation)。我们只是用一种暴政换取了另一种。

我们陷入了两难的境地,这种困境具有非常现实的后果。例如,广泛使用的 POSIX 线程标准甚至没有规定其读写锁应采用哪种策略。一个在某个系统(恰好偏向写者)上运行完美的应用程序,在另一个系统(偏向读者)上可能会遭受严重的写者延迟。要构建健壮、可移植的软件,我们需要自己理解并解决这个公平性问题。

寻求公正的秩序:制定公平的策略

要创建一个公正的系统,我们必须首先明确我们的术语。我们试图防止的是​​饿死​​(starvation),即一个线程在系统其余部分都在取得进展时,被永久性地拒绝访问资源。这是一种活性(liveness)失败。它与​​死锁​​(deadlock)有细微差别,后者是更具灾难性的安全性(safety)失败,其中一组线程都陷入了相互等待的状态,没有任何线程能取得进展。

那么,我们如何才能构建一个对读者和写者都公平的锁呢?

一种直接的方法是排成一个队。我们可以为所有人实现一个​​基于票据的先进先出(FIFO)队列​​。当读者或写者到达时,他们领取一个编号的票据并等待轮到自己。这无疑是公平的;没有人可以插队,所以饿死是不可能的。但这种简单的公正性是有代价的。我们失去了我们特殊锁的主要优势!一个写者后面可能跟着十个读者,然后又是一个写者。锁不是让所有十个读者一起进入,而是串行地传递:写者、读者、写者,等等。我们为了公平牺牲了并发性。

一个更复杂的解决方案是按“轮次”或“阶段”来思考。让我们建立一个​​读者阶段​​(Reader Phase)和一个​​写者阶段​​(Writer Phase)。当有写者在等待时,我们让当前的一批读者完成他们的工作。然后,锁翻转到写者阶段。一个或多个写者轮流工作。一旦他们完成,并且有读者在等待,锁就翻转回读者阶段。

要使这种机制奏效,关键在于纪律。当我们进入一个阶段时,我们必须“冻结”成员资格。我们对等待中的线程进行快照,并设定一个​​配额​​,声明只有这个有限的群体将在当前阶段得到服务。任何新来者都必须等待下一个合适的阶段。这确保了每个阶段都保证会“耗尽”并在有限时间内结束,从而使系统能够为每个人交替推进。这个优雅的概念被称为​​阶段公平​​(phase-fairness)。我们甚至可以创建一个可调策略,即允许在写者开始等待后,固定数量的读者 α\alphaα 进入。如果 α=0\alpha=0α=0,我们得到写优先策略;如果 α=∞\alpha=\inftyα=∞,我们得到读优先策略。一个有限的 α\alphaα 提供了一种有界的、公平的折中方案。

死锁陷阱:当好锁变坏时

手握一个公平而巧妙的锁,我们可能感觉自己无懈可击。但并发世界充满了微妙的陷阱,其中完美无缺的组件在组合时可能导致系统完全瘫痪。这些死锁不是由锁的公平策略引起的,而是由我们使用锁的方式造成的。

陷阱一:致命的锁升级

想象一下,一位学者在阅读手稿时,突然灵光一闪,意识到需要进行修正。他是一个想要成为写者的读者。他可能会尝试将自己的读锁​​升级​​为写锁。现在,假设桌边的另一位学者在同一时刻产生了完全相同的想法。两人都持有读锁,这是兼容的。但要升级,每个人都需要独占访问权,这意味着他们需要对方释放其读锁。于是,学者1等待学者2释放其锁,而学者2则等待学者1释放其锁。他们将永远等待下去。这是一个完美的、对称的死锁。我们甚至可以将其形象化为正式的资源分配图中的一个环路,其中每个进程都持有一个资源,同时请求环路中邻居持有的另一个资源。

我们如何摆脱这个陷阱?我们必须打破对称性。

  • ​​礼貌性退让​​:我们可以建立一个任意规则,例如,基于线程ID。如果两个线程试图升级,ID较高的那个必须“礼貌”——它放弃,释放其读锁,然后从头开始尝试获取写锁。另一个ID较低的线程则被允许等待。这为其中一个线程打破了“持有并等待”的循环,从而解决了死锁。
  • ​​乐观失败​​:另一种策略是使升级尝试成为原子且有条件的。一个线程只有在它是唯一的读者时才尝试升级。如果不是,尝试立即失败。该线程必须随后释放其读锁,并像普通写者一样排队。这也打破了“持有并等待”的模式。

陷阱二:锁的混用

当我们组合使用不同的同步原语时,死锁常常出现。想象一下,我们的读者线程获取了 RW_lock 来访问一个共享目录。然后它检查一个标志,发现所需数据尚不可用。为了等待数据变得可用,它在​​条件变量​​(另一种同步工具)上调用 wait。陷阱就在这里:在急于等待时,该线程忘记释放它持有的 RW_lock。

现在,写者线程到来了。它的任务是更新数据,设置标志,然后 signal 条件变量以唤醒读者。但要做到这一切,它首先需要以写模式获取 RW_lock。但它做不到!锁被读者持有,而读者现在正处于休眠状态。读者在等待来自写者的信号,但写者在等待读者持有的锁。又是一个致命的拥抱。

这引导我们得出一个并发编程的铁律:​​永远不要在等待一个需要其他线程获取同一把锁的外部事件时持有该锁。​​ 解决方法简单但至关重要:总是在等待条件变量之前释放读写锁。

超越锁:乐观主义者的道路

到目前为止,我们的整个方法都是“悲观的”。我们假设冲突很可能发生,所以我们用锁来序列化访问,迫使线程等待。但如果写入极其罕见呢?获取和释放锁的开销,即使是对读者来说,也可能纯属浪费。我们能做得更好吗?

让我们试着乐观一点。我们发明一种新机制,​​序列锁​​(seqlock)。它基于一个简单而巧妙的原则,即读者从不获取锁,也从不等待。以下是读者的策略:

  1. 读取一个序列计数器(一个整数版本号)。假设其值为 424242。
  2. 从共享结构中自由读取所需的所有数据。
  3. 再次读取序列计数器。

现在,有两种可能性。如果序列号仍然是 424242,这意味着没有写者介入。你读取的数据是一致的,你可以继续。但如果数字变了,变成了 434343 或 444444 呢?这是给你的信号,表明在你读取期间有写者在活动。你拥有的数据可能已损坏——是新旧值的混合。你必须丢弃它,并从第1步开始重试整个过程。

写者的工作也很简单:它递增序列号(使其变为奇数),写入数据,然后再次递增序列号(使其变为偶数)。奇数值作为一个“写者活动中”的标志,供任何竞争的读者识别。

对于读者来说,这是一场优美的、无锁的舞蹈。它们从不阻塞写者,写者也从不阻塞它们。读者唯一的成本是可能需要重试。在一个写者很少的系统中,这种乐观的方法可能比传统的读写锁快得多。我们甚至可以进行数学计算,找到盈亏平衡点——即写者冲突的概率,在该概率下,重试的成本超过了悲观锁的开销。

最后的思考:现实世界是复杂的

这段进入读写锁世界的旅程揭示了计算机科学中一个深刻的主题:简单的思想往往隐藏着深刻的复杂性,而每一种解决方案都是一系列权衡的产物。即使是我们最好的算法,当它们遇到真实操作系统的混乱现实时,也可能表现出令人惊讶的行为。

考虑一个实时系统,其中一个高优先级的写者被几个低优先级的读者阻塞。解决这种优先级反转的一个常见方法是​​优先级继承​​,即低优先级的读者暂时继承写者的高优先级。这似乎应该能解决问题。但在单处理器系统上,它会产生一种新的病态现象:​​链式阻塞​​(chained blocking)。所有读者现在都以高优先级运行,一个接一个地执行它们的临界区,形成一条链,而写者则在等待。写者的总阻塞时间变成了它们所有临界区时间的总和!。

没有完美的、一刀切的解决方案。只有在并发性、公平性、性能和正确性之间进行权衡的、美丽而富有挑战性的图景。学会遵循第一性原理来驾驭这片图景,是真正的软件工匠的标志。

应用与跨学科联系:并发的交响乐

读写锁不仅仅是一段巧妙的代码;它是一种基本协调原则的体现,一种在广阔的计算领域中以不同形式反复出现的模式。把它想象成一个交响乐团的规则。许多音乐家——“读者”——可以同时演奏他们的乐器,让和谐的乐声充满大厅。但当某个声部需要调音,或某件乐器需要更换时——这是一种“写者”行为——乐团必须暂停片刻,以确保变更干净利落地完成。这个简单的协议,即允许多路并发读取但独占写入,是创造复杂、优美且连贯的音乐的关键。

在本章中,我们将踏上一段旅程,见证这一原则的实际应用。我们将从机器的核心——操作系统内核——开始,观察它如何向外扩展,塑造我们构建的数据结构、连接我们世界的分布式系统,甚至像数据库和区块链这样伟大的商业和信息账本。你将看到,这不仅仅是零散应用的集合,而是一个自然——或者至少是计算的本质——似乎钟爱的、优美而统一的主题。

机器之心:操作系统内核

我们的第一站是操作系统(OS),你电脑资源的总指挥。其最基本的任务之一是管理对文件的访问。想象一下,你正在浏览一个照片文件夹。许多应用程序可能正在读取这些文件以显示缩略图。与此同时,你可能使用照片编辑器来保存对其中一个文件的更改。浏览器是读者;编辑器是写者。操作系统如何防止浏览器试图显示一个只保存了一半的照片?答案是使用读写锁。

但这引入了一个微妙而关键的策略选择。操作系统应该优先考虑读者还是写者?

  • ​​读优先​​策略就像告诉指挥家,让音乐家们想演奏多久就演奏多久。即使有写者在等待,新的读者也可以加入。这使得系统在读密集型任务中感觉响应非常迅速,但可能导致​​写者饿死​​:随着读者流的持续到来,写者可能无限期地等待。

  • ​​写优先​​策略则相反。一旦写者表明其意图,就不再允许新的读者进入。写者只需等待当前的读者完成。这确保了写入能够及时发生,但可能给读者带来明显的停顿,导致​​读者饿死​​。

这不是一个抽象的困境。这是一个真正的工程权衡,内核开发者必须做出选择,平衡系统响应性、公平性以及确保所有任务都能向前推进。

在现代高性能网络服务中,情况变得更加复杂。这些系统不仅仅是闲置等待数据;它们使用高效的事件通知机制,如Linux的[epoll](/sciencepedia/feynman/keyword/epoll),来在I/O就绪时获得通知。人们很容易认为这种通知就足够了。一个写者更新了一个共享配置,然后发送一个信号来唤醒等待中的读者。但这是一种危险的幻觉。信号,即[epoll](/sciencepedia/feynman/keyword/epoll)事件,只是在肩膀上轻拍一下;它不携带任何关于写者内存状态的信息。一个被唤醒并在不同CPU核心上调度的读者,可能会在竞争中胜过写者,并在写者的更改对其可见之前读取配置,从而基于过时的数据采取行动。

解决方案是认识到通知和同步是两项不同的工作。[epoll](/sciencepedia/feynman/keyword/epoll)事件将读者带到门口,但读写锁才是守门人。读者在醒来后,必须通过获取读锁来正式请求进入。这一获取行为通过处理器内存模型微妙的物理机制,保证了一个happens-before关系。它确保了写者在释放锁之前的所有更改对读者完全可见。锁不仅仅是看门人;它还是内存一致性的保证者。

架构师的蓝图:数据结构与分布式系统

读写锁模式太过有用,不能仅限于操作系统内核。对于任何构建复杂系统的程序员来说,它都是一个至关重要的工具。考虑一个内存中的数据结构,比如一个自平衡二叉搜索树(例如,红黑树)。这些结构为快速查找进行了优化。你可以让多个线程并发地搜索树而没有任何问题——这些就是我们的读者。但是一次插入或删除——一次写操作——可能会触发一系列复杂的调整,如旋转和重新着色,以维持树的平衡。

保护树完整性的最简单方法是用一个单一的、粗粒度的读写锁来保护整个结构。任意数量的search操作都可以在共享的读锁下并行进行。但是insert或delete操作必须获取一个独占的写锁,以确保它在执行其精细的手术时独占对树的控制权。这就像在一个图书管理员重新整理整个区域时,在整个图书馆的门上挂一个“请勿打扰”的牌子。它简单且可证明是安全的。

现在,让我们将视野从单台计算机扩展到计算机网络。在分布式文件系统(DFS)中,你的计算机通常会为正在读取的文件保留一个本地缓存以加快访问速度。如果别人在中央服务器上修改了那个文件会发生什么?你的缓存现在就过时了。一个简单的“打开时验证”策略可能看起来很高效,但它让你变得脆弱。你打开文件,你的缓存被认为是新鲜的,但随后一个写者在服务器上更改了文件。你的下一次读取,从你的本地缓存中提供服务,将看到旧的、过时的数据。

服务器端的读写锁提供了一个优美的解决方案。当你的客户端打开文件进行读取“会话”时,它从服务器获取一个读锁并持有它。只要你持有该锁,服务器就会拒绝来自其他客户端的任何写请求。这保证了文件状态在你整个会话期间是稳定的,防止了会话中途的过时读取。锁已经超越了单台机器的内存,现在正在跨网络强制执行一致性。

但权力越大,责任越大。一旦我们拥有多个被锁定的资源,我们就会面临可怕的​​死锁​​危险。想象两个资源 AAA 和 BBB,每个都有自己的读写锁。

  • 线程 T1T_1T1​ 获取了 AAA 的写锁,然后试图获取 BBB 的读锁。
  • 与此同时,线程 T2T_2T2​ 获取了 BBB 的写锁,然后试图获取 AAA 的读锁。

我们陷入了僵局。T1T_1T1​ 在得到 BBB 之前不会释放 AAA,而 T2T_2T2​ 在得到 AAA 之前不会释放 BBB。它们将永远在致命的拥抱中等待。解决方案不是一个更复杂的锁,而是一个更简单的纪律:​​锁顺序​​。如果系统中的每个人都同意以一个固定的全局顺序获取锁(例如,你必须总是在锁定 BBB 之前锁定 AAA),那么这种循环等待就变得不可能。管理并发不仅仅是设计巧妙的原语;它关乎建立系统级的协议,以防止我们自己把自己绑成死结。

超越锁:读写锁思想的演进

传统的读写锁,尽管用途广泛,但有一个主要缺点:读者可以阻塞写者,而写者总是阻塞读者。在追求极致性能的过程中,计算机科学家们问道:我们能让读者在没有任何锁定的情况下读取吗?我们能实现真正的无阻塞读取吗?这个问题引出了读写锁思想优美而微妙的演进。

考虑一下扩展版的哲学家就餐问题,增加了“观察者”线程,它们希望定期检查哪些叉子正在被使用,而不打扰哲学家。如果我们使用标准的读写锁,其中哲学家是写者,观察者是读者,那么持有读锁的观察者会阻止哲学家拿起或放下叉子。这是不可接受的。我们需要一个更好的方法。

两种这样的“更好的方法”已经出现,它们都是读写锁模式的高级形式:

  • ​​序列锁(Seqlocks):​​ 在这里,数据由一个版本计数器保护。写者就像画廊里的画家。在绘画(更新数据)之前,他们增加计数器,使其变为奇数(一个“油漆未干”的标志)。完成后,他们再次增加计数器,使其变为偶数。想要观察艺术品的读者首先看一眼计数器。如果是偶数,他们就继续查看数据,然后最后再看一眼计数器。如果计数器的值没有改变并且保持偶数,他们就知道自己的观察是一致的。如果计数器变了,就意味着画家在工作,他们只需丢弃所见,然后重新看一遍。这种乐观的、冲突时重试的方法为读者提供了一条完全无锁的路径,这对于读密集型场景(如大型分布式云缓存,其中即使是共享锁上的争用也会成为瓶颈)的性能来说是惊人的。

  • ​​读-复制-更新(Read-Copy-Update, RCU):​​ 这可能是并发领域中最优雅的思想之一。写者不是就地修改数据,而是制作数据结构的完整副本,修改副本,然后通过一个单一的原子操作,切换一个指针,使新副本成为“官方”版本。那些正忙于遍历旧版本的读者可以继续他们的工作,完全不受干扰。一旦所有旧的读者都完成了(经过一个“宽限期”后),旧副本就可以被安全地回收。这就像出版一本新版的书;阅读旧版的人不会被打断。RCU 提供了无等待读取,是那些对读者延迟和吞吐量要求至高无上,并且可以接受复制数据成本的系统中的首选机制。

这些高级模式以其最精炼的形式展示了读写锁的思想,用无阻塞、乐观并发的极致性能换取了阻塞锁的简单性。

统一的原则:数据库与区块链

我们旅程的最后阶段揭示了读写锁模式真正的普遍性。我们发现它以其最宏大的形式,存在于我们这个时代两种最重要的数据技术的核心:数据库和区块链。

一个​​数据库管理系统(DBMS)​​,其核心就是一个解决大规模读写问题的复杂方案。每个 SELECT 查询都是一个读者,而每个 UPDATE、INSERT 或 DELETE 语句都是一个写者。数据库努力防止的事务“异常”——脏读、不可重复读、幻读——与我们一直以来看到的都是相同的一致性问题,只是名称不同。

不同的​​SQL隔离级别​​可以被理解为不同的读写锁策略:

  • ​​READ COMMITTED​​(读已提交),即一个事务可以看到其他事务中途提交的数据,通常通过短期的、语句级别的锁来实现。一个 SELECT 在其执行期间获取读锁,然后释放它,如果另一个事务在两个 SELECT 语句之间提交了更新,就可能发生不可重复读。
  • ​​REPEATABLE READ​​(可重复读),保证对同一记录的重复读取将产生相同的值,这是通过持有对所有已访问记录的读锁直到事务结束来实现的。这阻止了任何其他事务写入这些记录,从而防止了不可重复读。
  • ​​SNAPSHOT隔离​​(快照隔离)是顶峰。它为每个事务提供了截至该事务开始时数据库的一致性快照。读者不阻塞写者,写者也不阻塞读者。这种魔力是如何实现的?通过一种称为多版本并发控制(MVCC)的技术,这是我们在序列锁和RCU中看到的相同版本控制和写时复制思想的宏大、数据库规模的实现。这种深层的联系揭示了从操作系统锁的底层世界到数据库事务的高层理论之间深刻的统一性。

那么​​区块链​​这个新前沿呢?在这里,这种模式也同样清晰可辨。将一个新区块与现有链进行验证的过程是一个只读操作。因为它可能计算成本高昂,我们希望许多“验证者”线程能够并行执行这项工作。它们就是读者。成功地将一个经过验证的新区块附加到链上的行为是一个写操作,必须是独占的,以维护单一、权威账本的完整性。这与读写锁协议完美契合。写优先锁或基于RCU的方案都是极佳的选择,它们允许大规模的并行验证,同时确保链以一致、序列化的方式增长,没有写者饿死的危险。

从一个简单的文件规则,我们已经旅行到了全球金融和去中心化信任的核心。读写锁模式,以其多种形式,不仅仅是一个技术解决方案。它是数字宇宙中合作的基本原则,是一个永恒的逻辑片段,使我们复杂的并发计算世界能够和谐、完整地进行。