20 并发与竞态:锁、无锁、死锁、并发模型

开篇提问

先给你一段最普通不过的代码:

counter := 0
for i := 0; i < 1000; i++ {
    go func() { counter++ }()   // 1000 个 goroutine 各加一次
}
// 结果是多少?

你大概知道答案:几乎肯定不是 1000,而是一个小于 1000 的不确定值。

但我想让你回答更深的一层,这一层很多人答不上来:

counter++ 明明只有”一条语句”,它为什么不是原子的?具体来说,它在 CPU 层面到底被拆成了几步,这几步又是怎么被”打断”从而丢失更新的?

先别往下看。你自己在心里把那几步拆一遍,拆不出来也硬拆一遍——因为这一步想清楚了,这一章后面所有的东西(锁为什么有效、锁为什么贵、无锁为什么能行)都是顺水推舟。

能把这个”三步”说清楚,你就真正理解了竞态的本质。而理解了这个本质,你才能回答下一个问题:为什么”加锁”能解决它,以及加锁为什么要付出那么大的代价。


子主题一:竞态的本质——“读-改-写”的中断

先拆 counter++:

它看起来是一条语句,但在 CPU 层面是三步:

  1. 读(Load):把 counter 的值从内存读到寄存器
  2. 改(Modify):寄存器里的值 +1
  3. 写(Store):把寄存器的值写回内存

问题就出在这三步之间可以被打断。

想象两个 goroutine 并发执行,初始 counter = 0:

时刻G1G2counter
t1读:寄存器=00
t2读:寄存器=00
t3改:寄存器=10
t4改:寄存器=10
t5写:counter=11
t6写:counter=11

两个 goroutine 各加了一次,结果却是 1 而不是 2。这就是”丢失更新”。

关键看 t1 和 t2:两个 goroutine 都读到了同样的旧值 0,然后各自基于 0 算出 1,最后互相覆盖。

所以竞态的本质,一句话:

竞态 = 一个”多步操作”在没有保护的情况下,被另一个并发执行流插到了中间,导致中间状态被破坏。

判据:原子性

判据很简单:只要一个操作不是”原子的”(不能被拆成多步、中间不会被打断),在并发下就可能出问题。

这里有一个值得你停一下的追问:“原子”这个词到底是什么意思?

原子的英文 “atomic” 来自希腊语 “atomos”,意思是”不可再分”。在并发语境里,“原子操作”指的就是:这个操作要么完全没发生,要么完全发生了,不存在”做了一半”这个中间状态被外人看见的可能。

你发现没有——竞态的根源,从来不是”快”或”慢”,而是”中间状态可见”。只要一个操作有中间状态,而且这个中间状态会被另一个执行流撞见,竞态就成立了。

反过来说,防止竞态的办法也就两条路:

  1. 让多步操作变成原子(用锁、用原子指令)
  2. 消除并发访问(不共享数据、用消息传递)

这一章,我们就把这两条路,以及它们的各种变体,一条一条走清楚。


子主题二:锁——让多步操作变成”不可分割”

锁的思路最直观:把”读-改-写”这三步圈起来,同一时刻只允许一个人进入。

mu.Lock()
counter++    // 这三步现在"看起来"是原子的了
mu.Unlock()

锁的本质:把一段”多步操作”变成”临界区”,通过互斥让它对外表现为原子。

锁的代价(这是理解并发性能的关键)

很多人以为锁的代价就是”慢一点”。错。锁有四个代价,一个比一个隐蔽:

代价一:串行化。 临界区只能一个人进,其他人必须等待。这就是第 6 章 Amdahl 定律里的”串行部分”——锁是并行度最大的杀手。

这里有个反直觉的点,你要想清楚:你锁住的哪怕只是一行代码,只要它是热点,它就能把整个系统的并行度拖到接近 1。 因为那行代码同一时刻只能一个人执行,其余所有人都在排队。锁不是”让某处慢一点”,它是”把某个地方变成单行道”。

代价二:上下文切换与阻塞。 抢不到锁的线程要阻塞、被唤醒,这涉及内核态切换,开销不小(微秒级)。

代价三:锁本身的开销。 即使无竞争,加锁解锁也要几条原子指令(几十纳秒)。

代价四:死锁风险。 下面详述。

所以锁的使用原则很明确:临界区越小越好。

  • 只在真正需要保护的那一小段代码加锁
  • 绝对不要在锁内做耗时操作(IO、网络调用、复杂计算)——这会让所有等待者排队,把并行度直接归零

一个狠类比:单车道施工

锁就像一条双向四车道的高速公路,突然有一段在施工,四车道并成一条单行道。问题不在”那段路变慢了”,而在”所有车都要在那一段排队”。

你锁住的那段代码越”长”(占用的时间越久),排队就越长。而”在锁里做网络请求”,相当于”在单行道上修车”——后面所有车全堵死。

所以那句老话”临界区越小越好”,翻译成人话就是:把你不得不锁的那段单行道,修得越短越好。


子主题三:死锁——四个必要条件与破解

死锁是并发编程最经典的坑。它的四个必要条件(Coffman 条件):

  1. 互斥:资源同时只能被一个持有
  2. 持有并等待:持有一个资源的同时,等待另一个
  3. 不可抢占:不能强行抢走别人的资源
  4. 循环等待:A 等 B 的,B 等 A 的

四个条件同时满足才会死锁。所以破解死锁,就是破坏其中任意一个。

这个”四个条件缺一不可”的结构,特别像一座四根柱子的亭子——抽掉任意一根,亭子就塌了。这也是破解死锁的总纲:你不用同时解决四个,你只要精准地抽掉一根。

破解方法一:破坏”循环等待”(最常用、最有效)

给所有锁定义一个全局的顺序,所有地方都按这个顺序加锁。

如果所有代码都”先锁 A 再锁 B”,就不可能出现”A 持有 A 等 B、B 持有 B 等 A”的循环。这是工业界最主流的防死锁手段,简单且有效。

为什么有效?因为它从结构上让”循环等待”不可能形成——就像规定所有路口都”先让直行再让左转”,就不可能有两辆车互相让出死循环。这又是前面第 19 章说的”最好的约束是让人无法违反,而不是让人记得遵守”。

破解方法二:破坏”持有并等待”

一次性申请所有需要的锁(要么全拿到,要么一个都不拿)。

破解方法三:破坏”不可抢占”

用带超时的锁(tryLock with timeout)——拿不到就等一会儿,超时就放弃并释放已持有的。这是很多分布式锁的做法。

破解方法四:死锁检测

运行时维护”等待图”,检测到环就强制回滚某个事务(数据库的做法)。

其他常见并发问题(不只是死锁)

死锁只是”并发问题家族”里最出名的一个,它还有几个同样危险的兄弟:

  • 活锁(Livelock):线程一直在”让路”,但谁也走不成。现象:CPU 很忙但没有进展。(两个人面对面让路,同时往左、同时往右,一直让不开。)
  • 饥饿(Starvation):某个线程永远抢不到资源。现象:某些请求永远得不到处理。
  • 优先级反转(Priority Inversion):低优先级任务持有锁,高优先级任务等锁,结果中等优先级任务抢着跑——高优先级的反而最后完成。

一个真实到肉疼的案例:火星探路者号的优先级反转

1997 年,NASA 的火星探路者号(Mars Pathfinder)在火星上出了个诡异的故障:探测器会周期性自动重启,导致数据丢失。

排查到最后,发现是优先级反转:一个低优先级的”气象数据收集”任务,抢到了一把高优先级的”总线通信”任务也需要的锁。于是高优先级任务干等着,而一个中等优先级的任务(恰好不需要这把锁)一直在跑,抢占了低优先级任务的 CPU——低优先级任务拿着锁却跑不起来,高优先级任务想拿锁却拿不到,两者都被一个不相干的中等任务卡死。系统看门狗判定”出事了”,重启。

修复办法很精妙,也很有普适价值:优先级继承(Priority Inheritance)——当高优先级任务等待低优先级任务持有的锁时,暂时把低优先级任务的优先级”抬高”到和高优先级一样,让它赶紧干完释放锁。

这个案例的价值在于:它说明并发 bug 不是”代码写错了”那么简单,而是”系统各个部件之间的交互出现了你没想到的耦合”。 你写的每一行代码都正确,但组合起来的行为却错了。这也是并发问题最难调试的原因——它不是局部错误,是涌现出来的错误。


子主题四:无锁(Lock-Free)—— 用原子指令代替锁

锁有代价,那能不能不用锁?可以——用硬件提供的原子指令。

核心武器:CAS(Compare-And-Swap,比较并交换)

CAS 的语义(伪代码):

CAS(addr, expected, new):
    如果 *addr == expected:
        把 *addr 设为 new,返回成功
    否则:
        什么都不做,返回失败

关键:这整个操作是硬件保证的原子操作(一条 CPU 指令,如 x86 的 LOCK CMPXCHG)。

用 CAS 实现无锁的 counter++:

for {
    old := atomic.LoadInt64(&counter)
    if atomic.CompareAndSwapInt64(&counter, old, old+1) {
        break   // 成功
    }
    // 失败:说明期间被别人改了,重试
}

乐观 vs 悲观:两种”预期冲突”的态度

这个模式叫乐观并发控制:

  • 锁是”悲观”的:先假设会冲突,所以先加锁
  • CAS 是”乐观”的:先假设不冲突,直接做;发现冲突(CAS 失败)就重试

这个”乐观/悲观”的区分,是一个贯穿整个计算机领域的大主题——数据库的乐观锁/悲观锁、版本控制、甚至你写代码时的防御式编程,都是同一个思想的两面。

无锁的优劣

优点:

  1. 不会死锁(没有锁就没有死锁)
  2. 不会被阻塞(线程不会挂起,只是重试)
  3. 高并发下性能往往更好(无上下文切换)

缺点:

  1. 只能用于简单的操作(单个变量的原子操作)。复杂的数据结构(比如无锁队列)实现极难,容易出 bug。
  2. ABA 问题:值从 A 变成 B 又变回 A,CAS 以为没变。需要版本号来解决。
  3. 高竞争下性能反而更差:大量线程反复重试,浪费 CPU(这叫”活锁”的一种)。

ABA 问题,值得你专门想一下

ABA 是 CAS 最经典的坑。想象一个场景:你 CAS(addr, A, C),期望 *addr 还是 A。但在你检查之前,另一个线程把 *addr 从 A 改成了 B,又改回了 A。你的 CAS 发现”还是 A”,于是成功——但它没发现中间发生过 B。

对”计数器”这种场景,ABA 无害(值还是那个值)。但对”链表节点”这种场景,ABA 可能致命:那个节点在你检查期间被删掉、又被复用,你的指针可能指向了一个已经”名存实亡”的对象。

解法是加版本号:把 (值, 版本) 绑在一起 CAS。Go 里可以用 atomic.Value 或结构体里的版本字段来绕;Java 有 AtomicStampedReference。核心思想都是同一个:CAS 比较的不只是”值”,还要比较”它有没有被改过”。

什么时候用无锁?

  • 简单计数、标志位、单个指针的更新 → 用原子操作
  • 复杂的数据结构(队列、链表)→ 除非有成熟库,否则不要自己写无锁版本

这里有一条被严重低估的工程判断: 无锁数据结构的正确性证明极其困难(要处理内存序、可见性、GC 交互),99% 的人自己写的”无锁代码”其实是错的,只是 bug 没被触发而已。 所以除非你是为了学习,否则优先用语言提供的原子类型和成熟的无锁库,别自己造。


子主题五:并发模型——三条不同的路

最后把视野拉高:除了”锁”和”无锁”,还有别的处理并发的思路吗?

并发模型本质上就三条路,各有各的代表:

路一:共享内存 + 锁(Shared Memory + Locks)

  • 多个线程共享数据,用锁保护
  • 代表:Java、C++、Go 的 sync 包
  • 优点:直观、灵活、性能可控
  • 缺点:容易出错(死锁、竞态、忘记加锁),需要开发者高度自律

路二:消息传递(Message Passing / Actor 模型 / CSP)

  • 不共享内存,线程/进程之间通过消息通信
  • 代表:Erlang(Actor)、Go(CSP + channel)
  • 核心思想(Go 的那句名言):“不要通过共享内存来通信,而要通过通信来共享内存”
  • 优点:从结构上消灭了数据竞争(数据不共享,就没有竞态)
  • 缺点:不是所有问题都适合消息传递(比如高频共享状态的更新会变得别扭)

路三:不可变数据(Immutability)

  • 数据一旦创建就不修改,所有”修改”都是创建新版本
  • 代表:函数式语言(Haskell、Clojure)、Rust 的默认不可变
  • 优点:不可变的数据天然线程安全(没人能改它,就没有竞态)
  • 缺点:需要适应函数式思维;大量创建新对象可能带来 GC 压力(但持久化数据结构 persistent data structures 用结构共享缓解了这点)

一个深刻的观察

这三条路不是互斥的,而是可以组合:

  • Go:主要是 CSP(channel),但也提供锁(sync 包)和原子操作
  • Erlang:纯消息传递 + 不可变数据,没有锁(因为不需要)
  • Rust:共享内存 + 锁,但用类型系统在编译期强制保证没有数据竞争

这三条路的本质,都是在回答同一个问题:“怎么让并发访问变得安全”。

  • 锁:靠”运行时互斥”
  • 消息传递:靠”不共享”
  • 不可变:靠”不能改”
  • Rust:靠”编译期证明”

越是”靠约定和自律”的方案,越容易出错;越是”靠结构和编译器”的方案,越安全但约束越强。 这个光谱,是理解各种并发设计的核心坐标系。

一个贯穿全书的模式:从”纪律”走向”结构”

你注意到没有,这三条路的演进,和全书其他章节高度同构:

  • 第 19 章的状态机:把”记得判断”变成”结构上不允许”
  • 第 16 章的抽象:把”复杂”藏起来
  • 这里的并发:把”记得加锁”变成”结构上不共享/不能改/编译期证明”

它们共享同一个深层信念:人脑不擅长在运行时遵守纪律(总会忘、总会漏),所以最可靠的方案,是让”犯错”这件事变得结构上不可能。 这个信念,是理解现代编程语言设计(尤其 Rust 的崛起)的一把钥匙。


演进脉络

把并发模型的演进从头串一遍,你会看到一条极其清晰的主线。

  • 1960s:Dijkstra 提出信号量(Semaphore)和”哲学家就餐问题”,奠定并发理论基础。这是并发从”硬件细节”变成”可研究的抽象”的起点。
  • 1965:Dijkstra 的并发算法论文(被认为是并发编程的开山之作)。
  • 1970s:Hoare 提出 Monitor(管程);CSP(通信顺序进程)理论提出。注意:Hoare 的 CSP 理论,几十年后成了 Go 的 goroutine + channel 的直接思想来源——这是一条跨越 30 年的思想接力。
  • 1973:Coffman 等人总结死锁的四个必要条件。
  • 1990s:无锁数据结构研究兴起;CAS 指令成为硬件标配。
  • 2000s:多核时代到来,并发成为主流;软件事务内存(STM)被提出但未大规模流行。
  • 2010s:Go 用 CSP + goroutine 让并发变得亲民;Rust 用所有权系统在编译期消灭数据竞争。

主线:并发模型的演进,是从”靠程序员自律”(手动加锁)走向”靠结构保证”(消息传递、不可变数据),再走向”靠编译器证明”(Rust 的类型系统)。

驱动力非常清楚:人脑不擅长推理并发,所以最好的方案是让”写出错误的并发代码”变得困难甚至不可能——把安全从”纪律”变成”结构”,再从”结构”变成”类型”。

这条主线里有一个耐人寻味的细节:Dijkstra 和 Hoare 这些 1960-70 年代的理论,当时只是”优雅的学术成果”,直到 2000 年代多核时代到来,才真正变成工程刚需。 理论走在工程前面几十年,是计算机领域反复出现的现象——这也是为什么那些”看起来没用”的基础理论,最终都值大钱。


本章思考题

【思考题】

  1. counter++ 在 CPU 层面被拆成了哪三步?请画出两个 goroutine 交错执行导致丢失更新的时序表。竞态的本质是什么?

  2. 锁的四个代价是什么?为什么”临界区越小越好”?请用一个生活化的类比说明”锁内做耗时操作”的危害。

  3. 死锁的四个必要条件是什么?最常用、最有效的破解方法是什么?为什么它有效?“四个条件缺一不可”这个结构,给了我们什么破解总纲?

  4. 三种并发模型(共享内存+锁、消息传递、不可变数据)各自的思路和代表是什么?它们的本质在回答同一个什么问题?

  5. 什么是 ABA 问题?为什么它对计数器无害,对链表节点可能致命?怎么解决?

  6. 火星探路者号的故障是”优先级反转”,它的修复(优先级继承)体现了什么普适思想?

【参考答案】

  1. 三步:读(把 counter 从内存读到寄存器)、改(寄存器值 +1)、写(写回内存)。时序表:t1 G1 读 0;t2 G2 读 0(两人都读到旧值);t3 G1 改为 1;t4 G2 改为 1;t5 G1 写回 1;t6 G2 写回 1。结果 counter=1 而非 2,丢失一次更新。竞态的本质:一个多步操作(读-改-写)在没有保护的情况下,被另一个并发执行流插到中间,导致中间状态被破坏。根因不是”快或慢”,而是”中间状态可见”——只要操作有中间状态且会被别的执行流撞见,竞态就成立。

  2. 四个代价:串行化(临界区只能一人进,是并行度最大杀手,即 Amdahl 定律的串行部分——锁不是让某处慢一点,是把某个地方变成单行道)、上下文切换与阻塞(抢不到锁要阻塞唤醒,微秒级)、锁本身的开销(即使无竞争也要几条原子指令)、死锁风险。临界区越小越好:只在真正需要保护的一小段加锁,绝不在锁内做 IO/网络/复杂计算。类比:锁像四车道高速施工并成单行道,问题不在”那段路慢”而在”所有车都要在那段排队”;“锁内做网络请求”相当于”在单行道上修车”,后面全堵死。

  3. 四个条件:互斥、持有并等待、不可抢占、循环等待(四个同时满足才死锁)。最常用有效的破解是”破坏循环等待”——给所有锁定义全局顺序,所有代码都按此顺序加锁。有效原因:若所有地方都”先 A 后 B”,就不可能出现”A 持 A 等 B、B 持 B 等 A”的环路,循环等待被结构性破坏。“四个条件缺一不可”给的总纲是:像四根柱子的亭子,抽掉任意一根就塌——你不用同时解决四个,只需精准抽掉一根(对应第 19 章”最好的约束是让人无法违反”)。

  4. 共享内存+锁(Java/C++/Go sync):多线程共享数据、用锁保护,直观灵活但易出错(死锁、竞态、忘加锁)。消息传递(Erlang Actor、Go CSP):不共享内存、通过消息通信,从结构上消灭数据竞争,但高频共享状态更新会变别扭。不可变数据(Haskell/Clojure、Rust 默认不可变):数据创建后不改、修改即创建新版本,不可变天然线程安全,需要函数式思维、有 GC 压力。三者本质都在回答”怎么让并发访问变得安全”:锁靠运行时互斥、消息传递靠不共享、不可变靠不能改。光谱规律:越靠约定和自律越易错,越靠结构和编译器越安全但约束越强。

  5. ABA:CAS 比较的是”值”,若期间值从 A→B→A,CAS 误以为”没变过”而成功。对计数器无害(值还是那个值,加一不受影响);对链表节点可能致命(节点在检查期间被删除又复用,指针可能指向名存实亡的对象)。解决:加版本号,把 (值, 版本) 绑在一起 CAS(Java 的 AtomicStampedReference、Go 里用 atomic.Value 或结构体版本字段),核心是”CAS 比较的不只是值,还要比较它有没有被改过”。

  6. 修复用”优先级继承”:当高优先级任务等低优先级任务持有的锁时,临时把低优先级任务优先级抬高到和高优先级一样,让它赶紧干完释放锁。体现的普适思想:并发 bug 不是”代码写错”,而是”系统各部件交互出现了你没想到的耦合”——每行代码都正确,组合起来的行为却错了。它说明并发问题是”涌现”出来的错误,也因此最难调试;同时也说明这类”低优先级反被卡死”的问题,解法往往不是”更小心地写代码”,而是”引入一个机制让优先级关系自动修复”。