第 21 章 死锁——1960年代,为什么两个程序都没有出错,整个计算机却停止了工作?标记本章已读大字阅读打印本章回到上次阅读位置交互模块加载失败。正文仍可阅读,请刷新重试。正在加载交互实验…第一季 · 人类如何驯服数据来源历史主线:1965—1971年,以及现代数据库 · Dijkstra、Havender、Coffman、银行家算法与死锁检测1960年代,计算机开始同时运行越来越多的程序。这是一项巨大的技术进步。过去,一台昂贵的大型计算机可能在等待磁带、打印机或其他输入输出设备时,浪费大量处理器时间。现在,操作系统可以让一个程序等待输入输出,同时安排另一个程序继续执行。这种技术叫作:Multiprogramming,多道程序设计。但新问题出现了。假设计算机只有一台打印机和一台磁带设备。程序 A 已经占用了打印机,准备进一步使用磁带。程序 B 已经占用了磁带,准备进一步使用打印机。A 说:“我必须等 B 释放磁带,才能继续。”B 说:“我必须等 A 释放打印机,才能继续。”两个程序都没有崩溃。两个程序执行的指令,也未必存在任何局部错误。但它们永远无法继续。两个程序之间的循环等待程序 A持有打印机程序 B持有磁带机A 等待 BB 等待 A查看 SVG 配图原件21-003.svg · 点击查看大图这就是:Deadlock,死锁。比死锁本身更危险的是,它可能隐藏得非常深。程序运行几万次都正常。某一次,只因为两个线程执行顺序略有不同,就突然卡住。增加 CPU 核心数未必能解决。增加内存也未必有用。因为问题不是资源总量一定不够,而是资源被持有和等待的关系形成了无法自行打破的循环。这段历史里有几位重要研究者。1960年代的 Edsger W. Dijkstra 研究并发程序、信号量和多道程序系统。1968年,IBM 研究人员 James W. Havender 发表关于避免多任务系统死锁的论文。1971年,Edward G. Coffman Jr.、M. J. Elphick 和 Arie Shoshani 发表经典综述 System Deadlocks,系统整理了死锁的条件、预防、检测和恢复方法。[数字对象唯一标识符][bitsavers.org][DBLP]今天,我们就从这些真实研究出发,回答一个问题:为什么每段代码单独运行都正确,放在一起却可能永远无法完成?第一幕:1965年前后,Dijkstra 开始认真研究并发程序的规则来源Edsger W. Dijkstra1930—2002 · 荷兰计算机科学家我们已经在最短路径、结构化程序设计和并发系统的故事中多次见到他。他的重要贡献之一,是把并发程序的正确性变成可以严格推理的问题。Dijkstra 当时在荷兰埃因霍温技术大学工作。他关心的不是怎样让两个程序“看起来同时运行”。而是:当多个独立程序共享资源时,如何保证它们之间的协调是正确的?例如:一个线程正在修改共享变量。另一个线程不能在修改进行到一半时,看到不完整的状态。于是产生了互斥、临界区、信号量等经典概念。在1967年第一届 ACM 操作系统原理研讨会上,Dijkstra 介绍了 THE 多道程序系统的分层设计;这项工作后来也发表于1968年的 Communications of the ACM。[数字对象唯一标识符][德克萨斯大学奥斯汀分校计算机科学]他的研究体现了一个重要转变:并发程序不能仅仅依赖“实际运行时通常不会出错”,必须分析所有允许的执行交错。一种危险的执行交错考虑两个线程。线程 A:python复制代码lock_x.acquire() lock_y.acquire() # 使用 X 和 Y lock_y.release() lock_x.release()线程 B:python复制代码lock_y.acquire() lock_x.acquire() # 使用 Y 和 X lock_x.release() lock_y.release()单独运行 A,没有问题。单独运行 B,也没有问题。甚至 A 先全部执行完,再运行 B,也没有问题。但如果调度器按照下面的顺序执行:A 获得 X。B 获得 Y。A 请求 Y,被阻塞。B 请求 X,被阻塞。就形成死锁。这个结果并不需要任何 CPU 指令执行错误。调度器只是让两个线程在一种不幸的时序下交错运行。这意味着:并发程序的正确性,不能仅仅由每个线程单独的正确性推出。第二幕:亲手制造一次死锁我们不必真的让你的计算机卡住。可以通过一个状态模拟器,完整观察死锁是如何形成的。双线程死锁实验室步骤 0/4线程 A未持有资源线程 B未持有资源资源 X空闲资源 Y空闲两个线程尚未开始。执行下一步 →↶运行到最后,你会发现:A 不会主动释放 X,因为程序规定它要先拿到 Y 才能继续。B 不会主动释放 Y,因为程序规定它要先拿到 X 才能继续。如果没有外部干预或超时撤销机制,它们就会一直等待。所以死锁不是一种普通的“执行很慢”。它是等待关系自身阻止了任何参与者继续完成。第三幕:1971年,三位研究者总结出死锁的四个必要条件1971年6月,Edward G. Coffman Jr.、M. J. Elphick 和 Arie Shoshani 在 ACM Computing Surveys 发表论文:System Deadlocks(《系统死锁》)。这篇论文把多任务系统中的死锁研究整理为一套清晰的分析框架,其中最著名的就是四个必要条件。[uobdv.github.io][DBLP]1互斥(Mutual Exclusion)至少某种资源不能被多个任务同时以相冲突的方式使用。例如,一把互斥锁只能由一个线程持有。2占有并等待(Hold and Wait)某任务持有部分资源,同时继续等待新的资源。A 已经持有 X,却不释放它,而是继续等待 Y。3不可抢占(No Preemption)资源不能由操作系统随意、安全地从持有者手中夺走。例如,随意剥夺一个正在修改共享数据的线程所持有的互斥锁,可能破坏数据一致性。4循环等待(Circular Wait)存在闭环:A 等 B,B 等 C,最终某个任务又等待 A。这四个条件需要同时成立,才可能形成经典资源死锁。但这里有个逻辑细节:四个条件是必要条件框架,不意味着随便观察到四种现象,就一定能证明任意复杂系统已经发生了死锁。尤其当每种资源有多个实例时,简单的等待环未必足以证明死锁。如何阻止死锁?既然四个条件必须同时成立,那么只要在设计层面可靠地破坏其中一个条件,就能够防止这类死锁。例如,规定所有线程必须按照同一顺序获取资源。资源 X 永远先于 Y。那么:A 可以先拿 X,再拿 Y。B 也必须先拿 X,再拿 Y。不允许 B 先拿 Y,再等待 X。这样就从设计上消除了“资源获取顺序形成闭环”的可能性。1968年,IBM 的 James W. Havender 就在 IBM Systems Journal 发表 Avoiding Deadlock in Multitasking Systems,系统讨论了类似的预防方法及其工程代价。[bitsavers.org]第四幕:资源分配图——把“谁在等谁”变成数学上的环如果资源种类越来越多,单靠阅读程序代码很难看出死锁。于是,研究者使用图论来表示资源关系。我们在第六章已经学习过图。现在终于看到了图论在操作系统中的另一项重要应用。资源分配图(Resource Allocation Graph)可以使用两类节点:进程节点。资源节点。通常:进程指向资源,表示正在请求资源。资源指向进程,表示资源已经分配给进程。例如:A 持有 X,却请求 Y。B 持有 Y,却请求 X。可以画成:进程 A资源 Y资源 X进程 B请求分配请求分配查看 SVG 配图原件21-005.svg · 点击查看大图图中 A → Y 表示 A 请求 Y;Y → B 表示 Y 被 B 持有。四条关系组成一个有向环。在每种资源只有一个实例、并采用适当资源分配模型的情况下,图中存在等待环,就意味着发生死锁。这让问题从复杂的执行历史,转化为熟悉的图论问题:检测有向图中是否存在环。等待图(Wait-for Graph)如果我们把资源节点消去,只保留进程之间的等待依赖,就得到:A → B。B → A。只要在这个单实例等待模型中检测到环,就可以确认死锁。实现上可以使用 DFS 检测环:python复制代码def has_cycle(graph): WHITE = 0 GRAY = 1 BLACK = 2 color = {node: WHITE for node in graph} def dfs(node): color[node] = GRAY for nxt in graph[node]: if color[nxt] == GRAY: return True if color[nxt] == WHITE and dfs(nxt): return True color[node] = BLACK return False return any( color[node] == WHITE and dfs(node) for node in graph ) wait_for = { "A": ["B"], "B": ["A"], } print(has_cycle(wait_for)) # True这里的三种颜色不是图像颜色。它们表示 DFS 中的节点状态:WHITE:尚未访问。GRAY:正在当前递归路径中。BLACK:已经完成搜索。如果 DFS 遇到 GRAY 节点,就说明发现了一条回边,也就是有向环。你可以看到,我们此前学过的 DFS,在这里直接变成了操作系统故障检测算法。第五幕:1964年,Dijkstra 提出了一个非常奇特的比喻——银行家现在故事回到 Dijkstra。在1964年前后的手稿 EWD108 中,他讨论了如何避免任务之间的“致命拥抱”(Deadly Embrace)。手稿的荷兰文标题是:Een algorithme ter voorkoming van de dodelijke omarming。也就是:《一种防止致命拥抱的算法》。后来相关思想发展为著名的:Banker's Algorithm,银行家算法。这里的银行家不是现实金融系统的存款清算算法,而是一种用来解释资源授予决策的比喻。Dijkstra 原始手稿可在德克萨斯大学的 EWD 档案中查到。有关研究者的历史回顾也明确指出,银行家算法的早期形式见于1964年的 EWD108。[德克萨斯大学奥斯汀分校计算机科学][Inference]银行家究竟面临什么问题?假设一家银行拥有一定数量的可借出资金。有多名客户。每名客户都可能分批申请资金。银行必须决定:当客户提出一笔新的借款申请时,是不是应该立刻同意?只要当前账户还有足够现金,就一定能借吗?不一定。因为如果把所有剩余资金都借给当前申请者,可能导致其他客户无法完成自己的项目并归还资金。最后,所有客户都在等待额外资金。银行也没有钱继续发放。整个系统就陷入僵局。Dijkstra 的思路是:不要只问“当前有没有足够资源”,还要问“批准这次请求后,是否仍然存在一种让所有任务最终完成的顺序”。这就是银行家算法的核心。第六幕:安全状态不等于空闲资源很多这一点非常关键。假设系统共有10个单位的同类资源。当前运行三个进程。每个进程都有最大需求量。进程已分配 Allocation最大需求 Max尚需 NeedP0374P1242P2253总共已分配:3+2+2=73+2+2=73+2+2=7因此剩余可用资源:Available=10−7=3Available=10-7=3Available=10−7=3每个进程尚需:Needi=Maxi−AllocationiNeed_i=Max_i-Allocation_iNeedi=Maxi−Allocationi现在我们问:系统是否安全?所谓安全状态,并不是:“此刻所有进程都能同时获得全部剩余需求。”而是:是否存在某个执行顺序,使进程依次获得所需资源、完成任务并释放已经占用的资源,最终全部结束。先看看 P1。它还需要2个资源。当前有3个。足够。我们假设 P1 能够获得剩余需求并完成,然后释放自己持有的全部资源。系统可用资源变成:3+2=53+2=53+2=5这里为什么只加2?因为在安全性算法中,P1 完成时释放原先分配给它的2个资源;其临时获得的剩余需求可视为被申请并归还,净效果是回收原有 Allocation。现在可用资源为5。P0还需要4,足够。P0完成,释放3。Available 变为8。最后 P2还需要3,足够。因此存在:P1→P0→P2\boxed{P1\rightarrow P0\rightarrow P2}P1→P0→P2这样的安全序列。所以当前状态安全。如果 P0 现在还想再借3个资源呢?P0 目前已占用3。它的最大需求是7。尚需4。因此申请3并没有超过声明的最大值。当前可用资源也恰好为3。从表面看:“有3个资源,申请3个,那就批准吧。”但银行家算法不会立刻批准。它会先试着假装批准,再检查整体状态。批准后:Available = 0。P0已分配6,尚需1。P1仍然需要2。P2仍然需要3。现在谁能够完成?没有人。P0还差1。P1还差2。P2还差3。但可用资源为0。于是,系统进入一个没有安全完成序列的状态。这次申请必须暂缓。这就是银行家算法最精彩的地方:资源够用,不代表现在就应该发放。第七幕:你来当银行家,批准还是拒绝资源申请?银行家算法模拟器系统总资源10 · 初始空闲3安全性检查申请资源的进程P0P1P2本次申请资源数量0进程已分配最大需求尚需P0374P1242P2253模拟批准后空闲资源3状态检查安全✓ 可以批准,仍存在安全序列P1 → P0 → P2演示:P0申请3个资源这个实验真正展示了“避免死锁”与“检测死锁”的区别。检测死锁:允许资源请求发生,然后发现系统已经进入等待环,再尝试恢复。避免死锁:在授予请求之前,模拟分配后的状态;如果无法确认安全,就暂时不授予。银行家算法属于后者。不过,它依赖一个现实中经常很难满足的条件:每个进程必须事先声明自己可能需要的最大资源量。对于很多动态业务程序,开发者未必知道每个请求最终需要多少资源。因此银行家算法是一种极好的理论模型,却不是所有现代操作系统或数据库都会直接采用的通用策略。第八幕:为什么银行家算法不能只看当前谁被卡住?现在我们深入一点数学。银行家算法判断一个状态是否安全,通常使用如下变量。AvailableAvailableAvailable表示剩余可用资源。Allocation[i]Allocation[i]Allocation[i]表示当前分配给进程 iii 的资源数量。Max[i]Max[i]Max[i]表示进程 iii 声明的最大资源需求。Need[i]=Max[i]−Allocation[i]Need[i]=Max[i]-Allocation[i]Need[i]=Max[i]−Allocation[i]表示它还可能申请多少资源。如果有多种资源,以上量就变成向量。例如某个进程需要:2个打印机资源。1个磁带设备资源。3个内存缓冲区。则:Need[i]=[2,1,3]Need[i]=[2,1,3]Need[i]=[2,1,3]安全性检查从当前可用资源开始,反复寻找满足:Need[i]≤WorkNeed[i]\leq WorkNeed[i]≤Work的尚未完成进程。一旦找到,就假设它可以完成,并归还当前已经分配的资源:Work=Work+Allocation[i]Work=Work+Allocation[i]Work=Work+Allocation[i]然后继续。如果最终所有进程都能被标记为完成,说明存在一条安全序列。Python 实现python复制代码def is_safe(available, allocation, maximum): n = len(allocation) r = len(available) need = [ [ maximum[i][j] - allocation[i][j] for j in range(r) ] for i in range(n) ] work = available.copy() finished = [False] * n sequence = [] while len(sequence) < n: found = False for i in range(n): if finished[i]: continue if all(need[i][j] <= work[j] for j in range(r)): # 假定该进程完成,释放已持有资源 for j in range(r): work[j] += allocation[i][j] finished[i] = True sequence.append(i) found = True break if not found: return False, sequence return True, sequence使用我们刚才的单资源实例:python复制代码available = [3] allocation = [ [3], # P0 [2], # P1 [2], # P2 ] maximum = [ [7], [4], [5], ] safe, sequence = is_safe( available, allocation, maximum ) print(safe) print(sequence)可能输出:text复制代码True [1, 0, 2]即安全序列:P1 → P0 → P2。现在假装批准 P0 的额外3个资源:python复制代码available = [0] allocation = [ [6], # P0 新获得3个 [2], [2], ] safe, sequence = is_safe( available, allocation, maximum ) print(safe) print(sequence)输出:text复制代码False []为什么是 False?因为现在没有任何进程的剩余需求能够被满足。整个系统虽然还没有形成我们熟悉的“A等B、B等A”具体锁等待图,但已经进入一种不安全状态。这里必须区分:不安全状态(Unsafe State)不等于当前一定已经死锁(Deadlock)。不安全的含义是:系统已经不能保证,无论进程按照其声明的最大需求如何继续申请,都存在安全的完成策略。是否真的进入死锁,还取决于后续实际请求行为。第九幕:现实中的 PostgreSQL 如何处理死锁?现在我们把故事从1960年代带回数据库工程。上一章,我们学习了数据库事务与行级锁。假设有两条事务同时运行。事务 T1:sql复制代码BEGIN; UPDATE accounts SET balance = balance - 100 WHERE account_id = 1; UPDATE accounts SET balance = balance + 100 WHERE account_id = 2; COMMIT;事务 T2:sql复制代码BEGIN; UPDATE accounts SET balance = balance - 50 WHERE account_id = 2; UPDATE accounts SET balance = balance + 50 WHERE account_id = 1; COMMIT;注意资源访问顺序。T1:先更新账户1,再更新账户2。T2:先更新账户2,再更新账户1。假设 T1 已经锁住账户1。与此同时,T2 锁住账户2。然后:T1 等账户2。T2 等账户1。结果就形成数据库死锁。PostgreSQL 会怎么办?PostgreSQL 官方文档明确说明,它会检测死锁并通过中止其中一个相关事务来解除死锁,让其他事务有机会继续完成。但是,哪个事务被选为牺牲者,并不是应用程序应该依赖的固定规则。[PostgreSQL]因此,应用程序必须能够处理死锁错误。在 PostgreSQL 中,死锁检测得到的 SQLSTATE 通常为:text复制代码40P01其错误类别属于 Transaction Rollback。这意味着:数据库可能主动回滚其中一笔事务。应用程序应当根据业务语义决定是否重试。SQL Server 又如何?SQL Server 也能够检测死锁,并选择一个事务作为牺牲者,回滚其工作,使其他事务继续推进。常见的死锁错误号是:text复制代码1205两种数据库的具体检测策略、死锁图格式、锁模型和错误处理接口不同,但思想相通:让数据库判断哪组锁等待无法自行解除,然后撤销一部分工作,打破等待闭环。一个非常实用的修复方式:统一访问顺序如果两笔事务都遵守:先锁较小的账户 ID,再锁较大的账户 ID。那么:T1 锁1,再锁2。T2 也必须先锁1,再锁2。这样 T2 可能在锁1时等待 T1。但它不会先持有锁2,形成反向等待。于是,这一类由于锁获取顺序相反而形成的死锁,就可以被预防。这正是1968年 Havender 讨论的全局资源排序思想,在现代数据库事务中的一个直接应用。第十幕:死锁、活锁和饥饿,千万不要混为一谈在并发程序中,还有两个很容易与死锁混淆的概念。Deadlock:死锁A 等 B。B 等 A。双方都无法继续完成。典型特征:相互等待,执行无法推进。Livelock:活锁两个程序都没有被永久阻塞。它们还在不断执行动作。但始终无法取得真正进展。例如,两个人在狭窄走廊迎面相遇。A 向左避让。B 也向同一方向避让。两人又同时向右。接着又同时向左。双方一直在行动,却始终无法通过。这就是一种活锁的直观比喻。Starvation:饥饿某个线程始终拿不到自己需要的资源。但系统整体并没有停止运行。例如:高优先级任务不断到来。某个低优先级任务长期得不到执行机会。这叫饥饿。现象程序是否仍在行动整体是否在推进死锁相关任务阻塞相关任务无法完成活锁仍不断改变状态可能长期没有有效进展饥饿其他任务可能正常运行某个任务长期无法进展这三种问题需要不同的处理方法。解决死锁常用资源排序、检测回滚、避免策略等。活锁可能需要随机退避、打破对称的优先级规则。饥饿则可能需要公平队列、优先级老化或调度策略调整。第十一幕:工程师到底应该怎样选择死锁处理策略?我们已经认识了三种最主要的思路。第一种:死锁预防从设计上破坏死锁的必要条件。例如:统一资源获取顺序。一次性申请需要的资源。让某些资源能够安全地撤销或释放。优点是可以从结构上避免特定类型的死锁。缺点是可能降低并发度,或者不容易在复杂动态系统中严格执行。第二种:死锁避免银行家算法就是典型代表。每次发放资源之前,都先检查系统是否仍然安全。优点是能够在已知最大资源需求的模型下,主动避免进入不安全状态。缺点是依赖对未来最大需求的先验声明,而且运行时检查会产生额外开销。第三种:死锁检测与恢复允许系统正常竞争资源。如果真的出现死锁,就检测等待关系。然后中止任务、回滚事务或采取其他恢复措施。PostgreSQL 与 SQL Server 对事务死锁的处理,都体现了这一类思想。这种策略非常符合很多现代数据库的实际需求。因为数据库通常无法提前知道每笔事务最终会访问哪些行、需要多少把锁。一个实际开发建议假设你正在实现库存、财务或 ERP 系统中的多表事务。通常可以从以下顺序着手:缩短事务持续时间,避免持有锁期间执行外部网络请求或长时间业务计算。对同类业务操作采用稳定一致的锁获取顺序。为适当的查询建立索引,减少不必要的扫描和锁影响范围;具体效果取决于数据库锁模型。根据业务正确性要求选择隔离级别。记录死锁诊断信息,而不是只把错误作为普通超时忽略。对可重试事务采用幂等业务请求与有限次数的退避重试。尤其需要注意:死锁发生后重试,必须重试完整事务,而不是从失败的某条 SQL 中间继续执行。因为数据库可能已经回滚了整笔事务。而如果涉及外部支付、消息投递等不可直接回滚的操作,更需要业务级幂等性设计。终章挑战:安全状态与死锁,到底有什么区别?系统当前有3个可用资源单位。进程 P0 还需要4个单位才能完成。P1还需要2个。P2还需要3个。如果先让P1完成,它能够释放资源,随后其他进程也有机会完成。那么系统当前处于什么状态?已经死锁,因为 P0 当前无法立即完成安全状态,因为存在至少一种让所有进程完成的顺序不安全状态,因为不是所有进程都能同时完成尾声:真正危险的不是资源不足,而是彼此等待的结构1964年前后,Dijkstra 已经在手稿中研究如何防止任务之间的“致命拥抱”。1968年,IBM 的 James Havender 系统讨论多任务操作系统中避免死锁的方法。1971年,Coffman、Elphick 和 Shoshani 总结了死锁的四个必要条件,并将预防、检测、恢复和避免方法组织为一个系统的研究框架。后来,这些思想逐渐成为操作系统、数据库、并发编程与分布式软件中的基础知识。它们最重要的启示是:不能只判断某个任务是否正确,还必须判断多个任务之间形成的资源依赖是否正确。两个事务单独执行可以完全正常。但它们组合在一起,就可能形成循环等待。增加资源不一定能消除所有死锁。提高线程优先级也不能从根本上打破等待闭环。真正可靠的办法,是识别资源依赖结构,并在设计中消除循环、避免危险分配,或者检测后安全恢复。这也是我们整个算法系列中又一次重要的思想转变。从数组、链表到哈希表,我们研究怎样组织数据。从排序、动态规划到回溯,我们研究怎样组织计算。从一致性哈希、分布式共识到数据库事务,我们研究怎样让多个参与者共同工作。而死锁告诉我们:多个局部正确的决策,并不一定组成一个全局正确的系统。第二十一章知识总结概念核心思想Deadlock多个任务形成无法自行解除的等待关系Mutual Exclusion某些资源只能排他使用Hold and Wait持有资源的同时等待其他资源No Preemption资源不能任意被强制收回Circular Wait资源等待关系形成闭环Resource Allocation Graph用图表示请求与分配关系Wait-for Graph用进程间等待关系检测循环Banker's Algorithm只有模拟分配后仍安全才批准请求Safe State存在使所有任务完成的安全顺序Deadlock Prevention破坏死锁必要条件Deadlock Detection发现死锁后回滚或终止相关任务Livelock / Starvation与死锁不同的并发进展问题本章历史原始资料德克萨斯大学 Dijkstra 档案:EWD108 和 EWD116 银行家算法早期手稿Dijkstra(1967—1968):《The Structure of the THE-Multiprogramming System》Havender(1968):《Avoiding Deadlock in Multitasking Systems》原始论文Coffman、Elphick、Shoshani(1971):《System Deadlocks》原始论文PostgreSQL 官方文档:锁与死锁处理PostgreSQL 官方文档:deadlock_timeout 与锁管理参数下一章预告第二十二章:垃圾回收——1960年,为什么让计算机自动管理内存,反而创造了更复杂的算法?1958年前后,John McCarthy 在麻省理工学院研究人工智能和 Lisp 编程语言。他面对一个非常现实的问题。程序运行时,会不断创建新的链表节点。但这些节点不一定永远有用。当某个对象再也无法被程序访问时,它占用的内存就应该被重新利用。如果每次都要求程序员手工释放,不仅繁琐,还很容易发生错误。于是,1960年,McCarthy 发表关于 Lisp 的经典论文,描述了早期自动存储管理与垃圾回收机制。后来,标记-清除、引用计数、复制式回收、分代回收等技术不断发展,成为现代语言运行时的重要基础。下一章,我们将研究:为什么计算机能够判断一个对象是否已经变成“垃圾”?如果对象之间形成引用环,又该怎样正确回收?最令人惊讶的是:垃圾回收不只是一个自动调用 free() 的功能,而是一次关于对象可达性、图遍历与内存布局的算法革命。上一章第二十章:数据库事务——1970年代,为什么一笔转账绝不能只成功一半?下一章第二十二章:垃圾回收——1960年,为什么让计算机自动释放内存,反而创造了更复杂的算法?