# 并发编程入门：进程、线程、协程与那些锁

> 读者画像：会写代码，知道 `Thread`、`async/await`、`lock` 这些关键字怎么敲，但如果被追问"为什么要用读写锁而不是互斥锁""死锁到底怎么产生的""channel 和共享内存+锁到底哪里不一样"，会开始含糊。
>
> 读完这篇文章，你会建立一个心智模型：**并发问题的根源只有一个——多个执行流以不可预测的顺序读写同一份状态；所有并发原语，本质上都是在用不同的代价换取"让这份状态在某个时刻只被一个执行流以确定的方式访问"这件事。** 一旦这个模型立住了，锁、信号量、CAS、channel 看起来就不再是一堆互不相关的 API，而是同一个问题在不同抽象层上的不同解法。

---

## 目录

1. [并发 vs 并行：两个经常被混用的词](#1-并发-vs-并行两个经常被混用的词)
2. [进程、线程、协程：对照表与心智模型](#2-进程线程协程对照表与心智模型)
3. [竞态条件：余额 +1 丢更新的完整剖析](#3-竞态条件余额-1-丢更新的完整剖析)
4. [同步原语：锁、信号量、条件变量、原子操作](#4-同步原语锁信号量条件变量原子操作)
5. [死锁、活锁与饥饿](#5-死锁活锁与饥饿)
6. [无锁编程与 CAS：乐观并发与 ABA 问题](#6-无锁编程与-cascas乐观并发与-aba-问题)
7. [更高层的模型：线程池、Actor、CSP、async/await](#7-更高层的模型线程池actorcspasyncawait)
8. [实战建议：场景 → 并发模型对照表](#8-实战建议场景--并发模型对照表)

---

## 1. 并发 vs 并行：两个经常被混用的词

这两个词几乎所有人第一次学都会搞混,而且混淆的方式还很统一：大家都下意识地把它们当同义词用。先把定义钉死：

- **并发（concurrency）**：程序结构上"同时处理多件事情"的能力——多个任务在逻辑上交替推进，但不一定真的同时执行。
- **并行（parallelism）**：多个任务在物理上真的同时执行，通常依赖多核 CPU。

Rob Pike（Go 语言的作者之一）那句话是这个领域最好的总结："并发是同一时间应对（deal with）多件事情的能力，并行是同一时间做（doing）多件事情的能力。" 并发关心的是**程序的结构**，并行关心的是**执行的物理事实**。

一个具体的例子讲清楚区别：单核 CPU 上跑一个支持多线程的 Web 服务器，同时处理 100 个请求。操作系统通过时间片轮转，让这 100 个线程轮流跑一小段再切换——任何一个瞬间，其实只有一个线程真的在 CPU 上执行。这是**并发，但不是并行**。如果这台机器换成 8 核 CPU，同一时刻真的有 8 个线程分别在 8 个核心上跑，这才是**并行**。

反过来，并行不一定需要并发的程序结构：一条 SIMD 指令同时对一个数组的 8 个元素做加法，这是并行计算，但通常不会被称为"并发编程"——因为这里没有多个独立的、可能以任意顺序交错执行的"任务"这个概念，指令本身就是一次完成的。

### 1.1 为什么需要并发：I/O 密集 vs CPU 密集

"为什么不能顺序执行就好"这个问题,答案要分两种场景看,因为原因完全不同：

**I/O 密集型场景**：程序大部分时间在"等"——等磁盘返回数据、等网络请求响应、等数据库查询结果。CPU 在这段时间是空闲的。举例：一个 Web 服务器处理一个 HTTP 请求，查数据库耗时 20ms，这 20ms 里 CPU 几乎什么都没干，只是在等网卡把数据传回来。如果顺序处理请求，CPU 利用率会低得惊人——1000 个并发请求，如果每个查库 20ms、CPU 计算部分只需要 0.1ms，顺序执行完 1000 个请求要 20 秒，而如果能在等待 I/O 的间隙去处理别的请求，理论上可以把总时间压缩到接近 CPU 计算时间的总和（约 100ms）加上一次 I/O 延迟。**这里并发要解决的问题是"榨干 CPU 的空闲时间"，不需要多核也能拿到巨大收益**——这也是为什么 Node.js 单线程事件循环、Python 的 asyncio 在 I/O 密集场景下能有很好的吞吐。

**CPU 密集型场景**：程序大部分时间在真正计算——图像处理、科学计算、数据压缩、机器学习训练。这种场景下光靠"结构上的并发"没用，因为 CPU 一直是忙的，没有空闲时间可以见缝插针。要提速只能真正利用多核，把计算任务切分后分发到不同核心**并行**执行。举例：把一张 4000 万像素的图片做高斯模糊，切成 8 块分别丢给 8 个核心处理，理论上能拿到接近 8 倍的加速（实际会因为任务切分、结果合并的开销打折扣，这是 Amdahl 定律要处理的问题）。

一句话归纳：**I/O 密集靠并发结构掩盖等待延迟；CPU 密集靠并行利用多核算力。** 很多人写的所谓"高并发系统"其实主要在解决第一类问题，而"跑得慢"的科学计算脚本要解决的往往是第二类。搞反了优化方向是常见的性能翻车现场——比如给一个 CPU 密集型的 Python 计算任务疯狂开多线程，因为 GIL（全局解释器锁）的存在，多线程根本无法并行执行 CPU 密集代码，收效甚微甚至更慢，此时应该用多进程或者上 Rust/C 扩展。

---

## 2. 进程、线程、协程：对照表与心智模型

先建立心智模型，再看细节：

- **进程**是操作系统分配资源（内存空间、文件句柄、网络连接）的基本单位，是一个独立的地址空间。
- **线程**是操作系统调度 CPU 的基本单位，共享所属进程的地址空间。
- **协程**是用户态（应用程序自己）调度的执行单元，通常运行在少量线程之上，切换不经过操作系统内核。

类比：如果把计算机比作一栋写字楼，**进程就是一家独立公司**，有自己独立的办公室（地址空间）、独立的门禁卡（资源权限），公司之间互相看不到对方的文件柜；**线程就是公司里的员工**，共用同一间办公室的文件柜（共享内存），可以直接互相传纸条，但也正因为共用同一套文件柜，两个人同时去改同一份文件就会打架；**协程就是员工手头同时开着的多个任务**——同一个人在处理任务 A 卡在等审批（等 I/O）的空档，切换去处理任务 B，但任何时刻这个人只能真正动手做一件事，切换是他自己决定的，不需要经理（操作系统）介入调度。

### 2.1 三者的核心差异

| 维度 | 进程（Process） | 线程（Thread） | 协程（Coroutine） |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | 用户态运行时/程序自身 |
| 地址空间 | 独立（互相隔离） | 共享所属进程的地址空间 | 共享所属线程的地址空间 |
| 创建开销 | 大（几百 µs 到几 ms，需要复制/建立新地址空间、页表） | 中（几十 µs，需要内核栈、线程控制块） | 极小（几百 ns 到几 µs，通常只是分配一小块栈内存） |
| 切换开销 | 大（需要切换页表、刷新 TLB，可能有几 µs 到几十 µs） | 中（需要陷入内核，保存/恢复寄存器上下文，微秒级） | 极小（用户态直接跳转，不陷入内核，通常几十 ns） |
| 默认栈大小 | 由进程决定 | 通常 1MB～8MB（可配置） | 通常几 KB 起（如 Go goroutine 初始 2KB，可增长） |
| 隔离性/崩溃影响 | 强隔离：一个进程崩溃通常不影响其他进程 | 弱隔离：一个线程崩溃（如未捕获异常、段错误）通常拖垮整个进程 | 无隔离：和所属线程共生死，一个协程死循环会饿死同线程的其他协程 |
| 通信方式 | 需要 IPC：管道、socket、共享内存、消息队列，代价较高 | 直接共享内存读写，代价低但需要同步机制 | 直接共享内存，或用 channel 等高层封装 |
| 并行能力 | 天然可跨核并行 | 可跨核并行（真正的多核并行） | 默认不跨核并行（除非运行时把协程调度到多个线程上，如 Go） |
| 典型数量级 | 单机几十到几百个 | 单机几十到几千个（受栈内存和调度开销限制） | 单机可轻松到几十万到百万个 |
| 适用场景 | 需要强隔离、独立崩溃恢复、不同语言/权限边界的场景 | CPU 密集型并行计算、需要真正抢占式调度的场景 | 海量 I/O 密集型任务（高并发网络服务） |

### 2.2 几个容易搞混的细节

**协程为什么切换快？** 因为协程切换不涉及"用户态到内核态"的切换（system call/trap），不需要操作系统介入保存整个 CPU 上下文、不需要刷新 TLB（地址转换缓存）。线程切换即便是同进程内的两个线程，也要经过内核调度器决定"接下来跑谁"，这个决策和上下文保存/恢复的过程有实打实的固定开销。协程的切换点通常是**协作式**的——协程自己在某个点（比如 `await` 一个 I/O 操作）主动让出执行权，运行时直接在用户态把 CPU 交给下一个协程，跳过了内核这一层。

**协程是不是"轻量级线程"？** 这个说法流传很广，但容易造成误解。协程更准确的心智模型是"可以在等待时被挂起、稍后恢复的函数调用"。它没有独立的调度权——真正决定协程何时运行、运行在哪个 OS 线程上的，是背后的运行时（runtime）。Go 的 goroutine 是这里最典型也最容易被误解的例子：goroutine 常被叫做"协程"，但它实际上由 Go runtime 的 **GMP 调度器**做**抢占式**调度（自 Go 1.14 起支持基于信号的异步抢占，不完全依赖 goroutine 自愿让出），并且默认会被调度到 `GOMAXPROCS`（通常等于 CPU 核数）个操作系统线程上并行执行——这是一种 **M:N 线程模型**：M 个 goroutine（G）映射到 N 个操作系统线程（M，即 Machine），中间由逻辑处理器 P 做本地队列和负载均衡。这跟 Python 的 `asyncio`、JavaScript 的 `async/await` 这种**单线程协作式**协程（同一时刻只有一个协程真正在跑，靠 `await` 主动让出）在并行能力上是本质不同的两回事——Go 的 goroutine 能利用多核并行，Python 的原生协程不能（GIL 之下即便开多线程跑 asyncio 事件循环，同一时刻能执行 Python 字节码的也只有一个线程）。

**进程隔离的代价是什么？** 隔离性越强，通信成本越高。两个线程共享内存，传递一个大对象只是传一个指针，几乎零成本；两个进程之间传同样大小的数据，要么走 socket/管道做序列化和拷贝，要么用共享内存段（这本身又引入了新的同步问题）。这就是为什么"要不要用多进程"本质上是在拿隔离性换通信效率——数据库连接池、支付网关这类需要强隔离（一个请求处理崩溃不能拖垮整个服务）的场景常用多进程（如 Nginx worker 进程、PostgreSQL 的每连接一进程模型），而需要频繁共享大量状态的计算密集场景更倾向多线程。

---

## 3. 竞态条件：余额 +1 丢更新的完整剖析

理解并发 bug，最好的方式是先看清楚它到底是怎么在硬件层面发生的，而不是死记"要加锁"这个结论。

### 3.1 一个看起来"显然正确"的例子

假设账户余额是一个整数，两个线程同时执行"余额加 100"：

```python
balance = 1000

def deposit(amount):
    balance = balance + amount   # 伪代码，忽略变量作用域细节
```

`balance = balance + amount` 这一行代码，看起来是"一步"，但它在 CPU 层面**从来都不是原子的**，通常会被编译/解释成至少三个独立步骤：

```
LOAD  balance -> 寄存器R1     # 1. 读：把内存里的 balance 读到寄存器
ADD   R1, amount -> R1        # 2. 算：寄存器里做加法
STORE R1 -> balance           # 3. 写：把结果写回内存
```

这三步之间，操作系统随时可能把 CPU 切给另一个线程。两个线程同时执行 `deposit(100)`，一种可能的交错执行顺序：

```
初始 balance = 1000

线程A: LOAD balance -> R1_A         # R1_A = 1000
线程B: LOAD balance -> R1_B         # R1_B = 1000   （此时线程A还没写回！）
线程A: ADD  R1_A, 100 -> R1_A       # R1_A = 1100
线程A: STORE R1_A -> balance        # balance = 1100
线程B: ADD  R1_B, 100 -> R1_B       # R1_B = 1100   （B用的是它自己读到的旧值1000）
线程B: STORE R1_B -> balance        # balance = 1100   ← 覆盖了线程A的写入！

期望结果：1000 + 100 + 100 = 1200
实际结果：1100
```

这就是经典的**丢失更新（lost update）**：两次 `+100` 应该让余额变成 1200，但因为两个线程各自读到了同一份"旧值"，B 的写入把 A 的写入直接覆盖掉了，相当于 A 的这次存款凭空消失。

**竞态条件（race condition）**的准确定义是：程序的最终结果依赖于多个线程执行顺序的相对快慢，而这个顺序是不可控、不可预测的。上面这个交错顺序不是必然发生的——大多数时候两个线程的执行速度不会精确到这样"手拉手"交错，所以这类 bug 极难通过测试复现：本地跑一万次可能一次都不出错，上了生产环境在高并发、CPU 负载高、线程调度更容易被打断的情况下才低概率出现。这也是并发 bug 声名狼藉的根本原因——**它不是"错就是错"的确定性 bug，而是概率性的，复现门槛高，日志里往往只留下一个对不上的数字，没有异常堆栈可查**。

### 3.2 数据竞争（data race）vs 竞态条件（race condition）

这两个词经常混用，但学术上有精确区分，值得说清楚：

- **数据竞争（data race）**：两个及以上线程并发访问同一块内存，至少有一个是写操作，且没有任何同步机制协调这次访问。这是一个可以被工具（如 Go 的 `-race`、C++ 的 ThreadSanitizer）静态/动态检测出来的、定义明确的技术条件。
- **竞态条件（race condition）**：程序的行为/结果依赖于不可控的执行时序，是一个更宽泛的逻辑概念。

两者常常同时出现，但不是一回事：有数据竞争不一定导致错误的业务结果（比如两个线程同时写同一个日志计数器，哪怕丢了几次计数，业务上可能可以接受）；反过来，有竞态条件也不一定存在"未同步的内存访问"——比如两个进程通过文件系统竞争创建同一个文件，属于时序依赖导致的逻辑错误，但不是内存层面的 data race。

### 3.3 用锁修复：串行化临界区

修复方式是给这段"读-改-写"操作加一把锁，保证任意时刻只有一个线程能进入这段代码（这段代码称为**临界区，critical section**）：

```python
balance = 1000
lock = Lock()

def deposit(amount):
    lock.acquire()
    try:
        balance = balance + amount
    finally:
        lock.release()
```

加锁后的交错执行：

```
线程A: lock.acquire()  成功，进入临界区
线程B: lock.acquire()  阻塞，因为锁已被A持有
线程A: LOAD/ADD/STORE   balance: 1000 -> 1100
线程A: lock.release()
线程B: lock.acquire()  成功，进入临界区（此时才LOAD，读到的是1100）
线程B: LOAD/ADD/STORE   balance: 1100 -> 1200
线程B: lock.release()

最终结果：1200，符合预期
```

锁做的事情本质上很简单：**把"逻辑上应该是一步的操作"，通过强制串行化，变成物理上真正的一步**。代价也很直观：线程B必须等待，牺牲了并行度换取正确性。这就是并发编程里最基本的权衡——**正确性和吞吐量/延迟是一对此消彼长的力**，几乎所有同步原语的设计都是在这条线上找一个更好的平衡点，而不是"消灭"这个权衡。

---

## 4. 同步原语：互斥锁、读写锁、信号量、条件变量、原子操作

前面用锁解决了丢失更新，这里系统过一遍常见同步原语，讲清楚"各解决什么问题、代价是什么"，而不只是 API 用法。

### 4.1 互斥锁（Mutex）

**解决什么**：保证同一时刻只有一个线程能进入临界区，是最基础的同步原语。

**代价**：
- 未获取到锁的线程会被**阻塞**（挂起，让出 CPU，等被唤醒），这个挂起/唤醒的过程本身有内核调度开销。
- 完全串行化——即使临界区里的操作彼此没有真实冲突（比如两个线程分别读写数组的不同下标），互斥锁也会把它们变成排队执行，浪费并行潜力。
- 使用不当会导致死锁（第 5 节详述）。

**变体：自旋锁（spinlock）**——线程获取不到锁时不挂起，而是在一个空循环里不断重试（"忙等"）。代价是持续占用 CPU，但省去了挂起/唤醒的内核调度开销。适用场景是**临界区极短、预期等待时间比一次线程切换还短**的场合（比如操作系统内核内部保护几条指令的场景）；应用层业务代码几乎不应该自己实现自旋锁。

### 4.2 读写锁（Read-Write Lock / RWMutex）

**解决什么**：互斥锁有一个明显的浪费——如果临界区内全是"读"操作（不修改数据），多个读者其实互相不冲突，让他们排队没有必要。读写锁把访问分成两种模式：
- 多个**读锁**可以同时被持有（读者之间不互斥）；
- **写锁**是独占的，且写锁和任何读锁都互斥。

典型场景：一个配置缓存，读的频率是写的几百倍（大多数服务大部分时间在读配置，配置变更是低频事件）。用互斥锁会让所有并发读请求排队，用读写锁则允许所有读请求并行执行，只有在写入时才短暂阻塞所有人。

**代价**：
- 实现比互斥锁复杂，锁本身的元数据开销更大，单次加/解锁的固定成本略高于互斥锁。
- 存在**写者饥饿**风险：如果读请求持续不断，写者可能一直抢不到独占权（不同实现对这个问题的处理策略不同，比如 Go 的 `sync.RWMutex` 会在有写者等待时阻塞后续新来的读者，优先让写者插队，以避免饥饿）。
- 只有在"读多写少"且临界区不算极短的场景才划算；如果临界区本身很短，读写锁额外的元数据管理开销可能反而比互斥锁慢。

### 4.3 信号量（Semaphore）

**解决什么**：互斥锁只能表达"最多 1 个线程进入"，信号量把这个数字泛化成 **N**——最多允许 N 个线程/协程同时进入某段代码或持有某种资源。内部维护一个计数器，`acquire()` 时计数器减一（减到负数则阻塞等待），`release()` 时计数器加一并唤醒等待者。

典型场景：限制数据库连接池的并发连接数、限制同时下载的文件数、限制同时调用某个下游 API 的并发数（简易限流）。比如：

```python
# 限制同时最多 5 个协程去请求下游 API
semaphore = Semaphore(5)

async def call_downstream(request):
    async with semaphore:      # acquire，计数器-1；用完自动 release，计数器+1
        return await http_client.get(url, params=request)
```

计数器初始值为 1 的信号量（二元信号量，binary semaphore）在行为上接近互斥锁，但有一个关键区别经常被忽略：**互斥锁有"所有权"概念**（谁加的锁必须由谁释放，很多实现会检查这一点甚至报错），**信号量没有所有权概念**（任何线程都可以 `release` 一个信号量，哪怕它从未 `acquire` 过）。这个区别让信号量更适合"生产者通知消费者"这种跨线程的信号传递场景，而不适合单纯替代互斥锁做临界区保护。

**代价**：计数器本身的维护需要原子操作或锁保护；用错场景（比如用信号量做互斥锁但忘记了没有所有权检查）容易埋下难排查的 bug。

### 4.4 条件变量（Condition Variable）

**解决什么**：锁只能解决"互斥访问"，但很多场景还需要解决"等待某个条件成立"——比如生产者-消费者模型里，消费者需要在队列为空时等待，等生产者放入数据后被唤醒。如果用锁 + 忙轮询（不断加锁检查队列是否为空、解锁、sleep 一下再重试）能work，但浪费 CPU 且响应有延迟。条件变量提供了"释放锁并挂起，等被通知后重新获取锁并醒来"这个原子操作：

```python
lock = Lock()
not_empty = Condition(lock)
queue = []

def consumer():
    with lock:
        while len(queue) == 0:      # 注意用 while 不用 if，见下方说明
            not_empty.wait()        # 原子地：释放lock + 挂起；被唤醒后重新获取lock
        item = queue.pop(0)
    process(item)

def producer(item):
    with lock:
        queue.append(item)
        not_empty.notify()          # 唤醒一个等待者（notify_all 唤醒全部）
```

**为什么必须用 `while` 而不是 `if` 检查条件**：这是条件变量最容易踩的坑。`notify()` 只是把等待的线程唤醒、放回可运行队列，被唤醒的线程重新获取锁之后，**条件不保证仍然成立**——可能有另一个消费者线程抢先一步把队列里唯一的数据取走了。这种现象叫**虚假唤醒（spurious wakeup）**（有些系统实现上甚至可能无缘无故唤醒，POSIX 标准也明确允许这种情况），所以醒来后必须重新检查条件，不满足就继续等，用 `while` 循环包住 `wait()` 是保证正确性的标准写法，不是可选的防御性编程。

**代价**：条件变量必须搭配一把锁使用（用于保护"检查条件"和"挂起等待"之间不出现竞态），使用复杂度是这几个原语里最高的，容易因为 `notify` 时机不对、忘记用 while 检查等问题引入难以复现的 bug。

### 4.5 原子操作（Atomic Operations）

**解决什么**：对于像"给一个计数器加一"这种极简单的操作，用互斥锁有点杀鸡用牛刀——加锁/解锁本身的开销可能比操作本身还大。现代 CPU 提供了硬件级别的原子指令（如 x86 的 `LOCK` 前缀指令、`CMPXCHG`），可以保证"读-改-写"这一整个过程不被其他核心的操作打断，不需要操作系统介入挂起/唤醒线程。

```python
import threading
counter = 0
lock = threading.Lock()

# 用锁：能工作，但有加锁/解锁的完整开销
def increment_with_lock():
    global counter
    with lock:
        counter += 1

# 用原子操作：底层是CPU的原子指令，无需操作系统调度介入
import itertools
atomic_counter = itertools.count()   # 概念示意；Python 真实原子计数器需用 multiprocessing.Value 等
```

Go 里更直观：

```go
var counter int64
atomic.AddInt64(&counter, 1)   // 一条硬件级原子指令，没有锁的调度开销
```

**代价**：原子操作只能保护单个变量的单次简单操作（加减、比较交换、位运算等），无法覆盖"多个变量需要保持一致"的复合逻辑（比如同时更新余额和交易流水这两个字段，原子操作做不到把两者绑定成一个不可分割的整体，这时还是需要锁）。滥用原子操作拼凑复杂逻辑，容易写出"每一步单独看都对，组合起来却有竞态窗口"的 bug。

### 4.6 一张对照表：谁解决什么、代价是什么

| 原语 | 解决的问题 | 主要代价 | 典型场景 |
|---|---|---|---|
| 互斥锁 | 同时只允许 1 个执行流进入临界区 | 阻塞+调度开销；完全串行化 | 保护任意共享状态的读改写 |
| 读写锁 | 允许多读者并行，写者独占 | 元数据开销更大；可能写者饥饿 | 读多写少的缓存/配置 |
| 信号量 | 限制同时访问某资源的并发数为 N | 计数器需同步；无所有权概念易误用 | 连接池限流、并发数控制 |
| 条件变量 | 等待某条件成立再继续，避免忙轮询 | 必须配合锁；虚假唤醒需用 while 防御 | 生产者-消费者队列 |
| 原子操作 | 单变量的简单读改写不被打断 | 只能覆盖单变量简单操作 | 计数器、标志位、无锁数据结构的基石 |

---

## 5. 死锁的四个必要条件与避免手段；活锁与饥饿

### 5.1 死锁：一个具体的例子

死锁最常见的诱因是"多把锁、加锁顺序不一致"。经典例子——转账，线程A执行"账户1转给账户2"，线程B同时执行"账户2转给账户1"：

```python
lock1 = Lock()  # 保护账户1
lock2 = Lock()  # 保护账户2

# 线程A: transfer(account1 -> account2)
def transfer_A():
    lock1.acquire()      # A 拿到 lock1
    # ...此时线程被切走...
    lock2.acquire()      # A 想要 lock2，但 B 已经拿着，A 阻塞等待
    ...

# 线程B: transfer(account2 -> account1)，几乎同时发生
def transfer_B():
    lock2.acquire()      # B 拿到 lock2
    lock1.acquire()      # B 想要 lock1，但 A 已经拿着，B 阻塞等待
    ...
```

如果这两个线程的执行恰好交错成"A 拿到 lock1，同时 B 拿到 lock2，然后 A 等 lock2、B 等 lock1"，两边永远等不到对方释放——这就是死锁，程序在这两个线程上永久卡住，且通常没有任何报错，只是安静地挂起，非常难排查。

### 5.2 死锁的四个必要条件（Coffman 条件）

死锁的发生必须**同时**满足以下四个条件，缺一不可——这个结论最早由 Edward G. Coffman 等人在 1971 年的论文中系统提出，是操作系统教材里的标准理论：

1. **互斥（Mutual Exclusion）**：资源同一时刻只能被一个执行流持有（比如锁本身就是互斥的）。
2. **持有并等待（Hold and Wait）**：一个执行流已经持有至少一个资源，同时又在等待获取其他被占用的资源（上例中 A 持有 lock1 的同时还在等 lock2）。
3. **不可剥夺（No Preemption）**：资源只能由持有者主动释放，不能被外部强行抢走。
4. **循环等待（Circular Wait）**：存在一个执行流的环形等待链，A 等 B 持有的资源，B 等 A 持有的资源（形成闭环）。

破坏其中**任意一个**条件，死锁就不可能发生。这也是所有死锁预防手段的理论依据：

| 破坏的条件 | 手段 | 例子 |
|---|---|---|
| 循环等待 | **锁排序（lock ordering）**：给所有锁定义一个全局固定顺序，任何执行流都必须按同一顺序获取多把锁 | 上例改成：所有转账不管方向，永远先锁 `min(account_id)` 再锁 `max(account_id)`，A 和 B 都会先抢账户1的锁，天然消除环形等待 |
| 持有并等待 | 一次性申请所有需要的资源，要么全部拿到，要么一个都不拿（**原子化加锁**） | Go 的 `sync.Mutex` 没有内置这个能力，但可以用 `TryLock` 实现"拿不全就全部释放重试"的模式 |
| 不可剥夺 | 允许超时后自动放弃、回滚 | 数据库事务里的锁等待超时（lock_timeout）、检测到死锁后由数据库主动 abort 其中一个事务 |
| 互斥 | 用不需要互斥的数据结构（如无锁结构、不可变数据） | 见第 6 节 |

工程实践中最常用、成本最低的手段是**锁排序**（也叫锁分级，lock hierarchy）：约定"先锁 ID 小的资源，后锁 ID 大的资源"这样一条全局规则，代码 review 时重点检查是否有违反顺序的加锁路径。数据库层面则普遍依赖**死锁检测**：数据库维护一张"等待图"（wait-for graph），周期性检测图里有没有环，一旦发现死锁就选一个牺牲者（victim，通常是代价最小的那个事务）强制回滚，让另一方得以继续——这是"不可剥夺"这一条件被打破的真实案例。

### 5.3 活锁（Livelock）

活锁和死锁的核心区别：死锁里的线程是**静止**的（阻塞挂起，什么都不做）；活锁里的线程是**忙碌**的，一直在执行代码、状态在变化，但整体没有任何实质性进展。

经典类比：两个人在走廊里迎面相遇，都想给对方让路，于是同时往左边让，结果还是撞上；意识到后又同时往右边让，又撞上——如此反复，双方都在"努力避让"，但谁都没能通过。

代码层面的例子：两个线程都尝试获取两把锁，为了避免死锁，约定"如果拿不到第二把锁，就释放已持有的第一把锁，稍后重试"：

```python
def transfer_A():
    while True:
        lock1.acquire()
        if lock2.try_acquire():   # 非阻塞尝试
            ...  # 成功，执行转账
            lock2.release(); lock1.release()
            break
        lock1.release()           # 拿不到就放弃，重试
        # 如果没有随机退避，A 和 B 可能永远以相同节奏互相谦让、互相撞车
```

如果两个线程重试的时机高度同步（比如没有随机等待，每次都以完全一致的节奏重试），会陷入"A 让给 B，B 同时也在让给 A"的死循环，两边都在消耗 CPU、都在"努力"，但没有一方真正完成任务。修复手段通常是引入**随机退避（randomized backoff）**——让重试的等待时间带一点随机抖动，打破这种同步节奏，这也是以太网 CSMA/CD 冲突避免、TCP 拥塞控制里"指数退避+随机抖动"思想的同源应用。

### 5.4 饥饿（Starvation）

饥饿是指某个执行流因为调度策略或资源分配不公平，**长期甚至永久**得不到执行机会，即便系统整体在正常运转、其他线程都在正常推进。

典型例子：4.2 节提到的读写锁——如果调度策略是"只要有读者在排队就一直优先满足读者"，而读请求持续不断到来，写者可能永远排不上号，被活活饿死，即便每个读操作本身都很快完成、系统吞吐量看起来很健康。另一个例子：操作系统按优先级调度进程，如果高优先级进程持续不断，低优先级进程可能永远轮不到 CPU 时间片。

避免饥饿的常见手段是**公平性策略**：老化（aging，等待越久优先级越高，最终必然轮到）、先来先服务（FIFO 队列而非优先级抢占）、给写者设置"优先权"（如前面提到的 Go `RWMutex` 在有写者等待时会阻塞新读者插队）。

---

## 6. 无锁编程与 CAS：乐观并发的思路、ABA 问题

### 6.1 悲观锁 vs 乐观并发

前面几节的锁都是**悲观**思路：假设冲突大概率会发生，所以先加锁把别人挡在外面，再操作数据。

**乐观并发**的思路反过来：假设大多数时候不会有人跟你抢，所以直接尝试修改，修改完再检查这期间有没有被别人抢先改过——如果没被改过，提交成功；如果被改过，说明冲突了，放弃这次修改，重新读取最新值再试一次。这个"检查+提交"必须是硬件保证的单条原子指令，否则检查和提交之间又会出现新的竞态窗口——这正是 **CAS（Compare-And-Swap，比较并交换）** 存在的原因。

CAS 的语义：`CAS(内存地址, 期望的旧值, 新值)`——如果内存地址当前的值等于"期望的旧值"，就原子地把它更新为"新值"，返回成功；如果当前值不等于期望值（说明被别人改过了），什么都不做，返回失败。这条指令由 CPU 硬件直接支持（x86 的 `CMPXCHG`，ARM 的 `LDXR/STXR` 系列），执行过程不可被打断。

用 CAS 实现一个无锁计数器：

```python
def increment_lockfree(counter_ref):
    while True:
        old_value = counter_ref.get()          # 1. 读当前值
        new_value = old_value + 1               # 2. 计算新值（不加锁，纯本地计算）
        if counter_ref.compare_and_swap(old_value, new_value):
            break     # CAS成功，说明这期间没人抢先修改过，完成
        # CAS失败：说明old_value已经不是最新值了，重新读取再试（这叫"自旋重试"）
```

和互斥锁版本对比：互斥锁版本里，没抢到锁的线程会被挂起（内核调度介入）；CAS 版本里，"抢不到"表现为 CAS 返回失败，线程立刻重新尝试（不挂起，是用户态的忙等/自旋），在冲突不激烈的场景下，这样通常比"挂起-唤醒"的开销更低，也不存在"持有锁的线程被操作系统抢占导致其他人白等"的问题（锁的这个副作用叫**锁护送，lock convoy**）。

**代价**：在冲突非常激烈（很多线程同时抢同一个变量）的场景下，CAS 会陷入大量无效重试，浪费 CPU 空转，此时性能可能反而不如直接用锁老老实实排队；而且无锁编程只能自然地保护单个变量的原子更新，涉及多个变量的复合不变量（invariant）时，实现复杂度会陡增（通常需要设计成单个指针的原子替换，比如把"多个字段"打包进一个不可变对象，用 CAS 替换整个对象的指针）。

### 6.2 ABA 问题

CAS 判断"没人改过"的依据是**值相等**，但这是一个隐藏的漏洞：**值相等不代表这期间真的没有发生任何修改**。

具体场景：线程 A 读到某个共享指针的值是 `A`，正准备做 CAS。就在这个间隙，线程 B 把值从 `A` 改成了 `B`，又改回了 `A`（比如一个无锁栈：B 把节点 A 弹出、压入了节点 C、又把节点 A 重新压回栈顶——注意这里内存地址 A 有可能已经被释放又被重新分配，看起来还是同一个指针值）。线程 A 恢复执行，CAS 检查发现当前值确实还是 `A`，于是"成功"地完成了替换——但此时系统的实际状态已经和 A 最初读取时完全不同了，A 的这次操作基于的是一个已经过期、且被悄悄"复原"过外观的假设，可能导致数据结构内部指针错乱，甚至读写已被释放的内存。

这就是 **ABA 问题**：CAS 只能验证"表面的值没变"，无法验证"这期间是否发生过变化又变回原样"——它验证的是"结果相等"而不是"历史不变"。这个问题在无锁栈/队列这类需要频繁弹出/压入节点的数据结构里尤其容易出现，是无锁编程里最经典也最隐蔽的陷阱之一。

常见解决方案：

1. **带版本号的 CAS（tagged pointer / ABA counter）**：不只比较值本身，额外绑定一个单调递增的版本号一起做 CAS（也叫 double-word CAS）。哪怕值被改回了 `A`，只要中间发生过修改，版本号必然递增过，两次的"值+版本号"组合就不会相等，CAS 会正确地失败。Java 的 `AtomicStampedReference` 就是这个思路的直接实现。
2. **危险指针（Hazard Pointers）**：线程在访问某个指针前，先把它登记到一个全局可见的"我正在用这个指针"列表里，其他线程在真正回收/复用这块内存前，必须检查是否有人还在用，避免内存被过早复用导致地址值"看起来没变、其实已经变了又变回来"。
3. **Epoch-based / RCU 类回收机制**：把内存回收延迟到"确定所有可能持有旧指针的线程都已经离开临界区"之后，本质上是把 ABA 问题转化为"内存何时可以安全复用"的问题来解决。

工程上的建议是：**除非在写底层无锁数据结构库（如高性能队列、内存分配器），否则不要自己手写 CAS 循环去解决业务问题**——这类代码的正确性证明极其烧脑，一旦有 ABA 这类隐蔽 bug，出问题的概率极低但后果可能是内存损坏、极难复现的偶发崩溃。绝大多数业务场景，用成熟并发库（`ConcurrentHashMap`、`sync.Map`、语言内置的原子类型）远比自己发明无锁结构划算。

---

## 7. 更高层的模型：线程池、Actor、CSP、async/await

前面几节的锁、信号量、CAS都是"底层同步原语"——用对了能解决问题，但直接在业务代码里裸用它们管理共享状态，心智负担极重：每一处共享状态访问都要人肉推理"这里要不要加锁、加哪把锁、顺序对不对"，规模一大几乎必然出错。工程上更常见的做法，是选择一个更高层的并发模型，把"如何同步"这件事封装进模型本身，业务代码只需要遵循模型的规则。

### 7.1 线程池（Thread Pool）

**解决什么**：线程创建/销毁本身有实打实的开销（第 2 节表格里提到的几十 µs 量级），如果每来一个任务就创建一个新线程、用完销毁，在高频短任务场景下这个开销会成为瓶颈，而且无限制创建线程可能把机器的内存和调度资源耗尽。线程池预先创建一批线程常驻，任务通过一个队列分发给空闲线程执行，用完不销毁、放回池子等下一个任务。

```python
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=10) as pool:
    futures = [pool.submit(handle_request, req) for req in requests]
    results = [f.result() for f in futures]
```

线程池本身不解决"共享状态怎么同步"的问题——池子里的线程之间共享内存，竞态条件、死锁风险原样存在，线程池只是解决了"线程数量的资源管理"问题（控制并发上限、复用线程、提供任务队列做背压）。

### 7.2 Actor 模型

**核心思想**：**不要通过共享内存来通信**。每个 Actor 是一个独立的执行单元，拥有自己私有的、外界无法直接访问的状态；Actor 之间只能通过发送不可变消息来交互，每个 Actor 内部串行地、一条一条地处理收到的消息。

因为状态是私有的、消息处理是串行的，Actor 内部**根本不存在数据竞争**——不是因为加了锁，而是因为架构上排除了"两个执行流同时碰同一块内存"这种可能性。想更新一个 Actor 的状态，唯一的方式是给它发一条消息,让它自己在处理消息的时候去改自己的状态。

```
Actor间通信示意：
  账户Actor(id=1) <--"转出100"消息-- 转账协调者
  账户Actor(id=1) --"已扣款"消息--> 转账协调者
  转账协调者 --"转入100"消息--> 账户Actor(id=2)
```

Erlang/Elixir 的进程模型、Akka（JVM）是 Actor 模型最著名的实现。代价是消息传递本身有序列化/排队开销，且"一个 Actor 一条条串行处理消息"意味着单个 Actor 内部没有并行度——并行度来自于**大量 Actor 之间**的并行，而不是单个 Actor 内部。

### 7.3 CSP：Go 的 channel

**核心思想**：CSP（Communicating Sequential Processes，由 Tony Hoare 在 1978 年提出）和 Actor 目标一致——都主张"用通信代替共享内存"，但设计的关注点不同：Actor 模型里通信的对象是"具名的 Actor"（发消息要知道对方的地址/引用）；CSP 里通信的核心抽象是 **channel** 本身，发送方和接收方彼此不需要知道对方是谁，只需要知道往哪个 channel 读/写。

Go 语言把这句话直接写进了官方文档："Do not communicate by sharing memory; instead, share memory by communicating."（不要通过共享内存来通信，而要通过通信来共享内存。）

回到第 3 节那个丢失更新的例子，用 Go channel 重写，不需要任何显式的锁：

```go
type depositRequest struct {
    amount int
    result chan int  // 用于接收处理后的余额，实现"请求-响应"
}

func accountManager(requests <-chan depositRequest) {
    balance := 1000
    for req := range requests {
        balance += req.amount   // 只有这一个 goroutine 会碰 balance，天然无竞态
        req.result <- balance
    }
}

func deposit(requests chan<- depositRequest, amount int) int {
    result := make(chan int)
    requests <- depositRequest{amount: amount, result: result}
    return <-result   // 阻塞等待处理完成
}
```

这里的关键设计：`balance` 这个变量**只被一个 goroutine（`accountManager`）访问**，其他所有 goroutine 想读写余额，必须通过 channel 发一条消息，由 `accountManager` 串行处理。这和 3.3 节用锁保护 `balance` 达到的效果完全一致（串行化对 `balance` 的访问），但换了一种表达方式：**不是"允许所有人碰它，但一次只放一个人进去"（锁），而是"只有一个人能碰它，别人只能托它转达"（channel）**。两种思路殊途同归,都是在解决同一个问题,但 channel 版本把"谁能访问共享状态"这件事在代码结构上直接锁死了,少了一处"忘记加锁"就能出 bug 的地方。

### 7.4 async/await 与事件循环

**核心思想**：在单线程（或者说，单个执行上下文）内，用协作式调度实现高并发 I/O 处理，避免线程切换开销，也因为"同一时刻只有一段代码在跑"而天然避免了很多种数据竞争。

以 JavaScript/Node.js 为例：一个事件循环（event loop）在单线程里跑，每当代码执行到 `await` 一个异步操作（网络请求、定时器、文件读取），当前函数会被挂起，事件循环转去执行其他就绪的任务；等这个异步操作真正完成（比如网络包回来了），对应的回调被重新排入队列，事件循环空闲时再把它捞出来继续跑。

```javascript
async function fetchUserOrders(userId) {
    const user = await db.getUser(userId);       // 挂起，让出控制权，等待I/O
    const orders = await db.getOrders(user.id);   // 挂起
    return { user, orders };
}
```

因为整个过程只有一个线程在跑用户代码，`await` 之间没有其他代码能"插进来"同时修改同一个变量——**代码逻辑上是并发的（多个请求交替处理），但从来不是并行的（同一时刻只有一段业务代码真正在执行）**，这正好呼应第 1 节的定义。这带来一个直接的好处：绝大多数简单的共享变量读写不需要加锁，因为两次修改之间不可能真的"同时"发生，只可能是"交替"发生，而 `await` 之间的代码段本身仍然是不可分割的（同步代码执行不会被随意打断，除非显式 `await` 交出控制权）。

但要小心一个常见误区：**"单线程无需加锁"不等于"没有竞态条件"**。如果两个 `await` 之间的操作被拆开，中间有 `await` 点，另一个协程完全可能在这个间隙插进来修改共享状态：

```javascript
let balance = 1000;

async function deposit(amount) {
    const current = balance;          // 读
    await sleep(10);                  // 挂起点——其他协程可能在这10ms内插进来
    balance = current + amount;       // 写，用的是挂起前读到的旧值
}

// 两次并发调用 deposit(100)，如果调度顺序不巧，
// 依然会重现第3节里那个丢失更新的问题——
// 因为"读"和"写"之间隔着一个真实的挂起点
```

这说明"单线程事件循环"消除的是**多核并行带来的数据竞争**，但消除不了**逻辑交错带来的竞态条件**——只要代码里有 `await`/`yield` 这样的显式让出点，两次让出点之间的状态就可能被其他任务改变，"读-改-写"如果被 `await` 从中打断，问题和多线程版本在逻辑上是同构的。

### 7.5 为什么现代语言集体转向"不要用共享内存来通信"

把 Actor、CSP、async/await 放在一起看，会发现一个共同的设计取向：**它们都在想办法减少"多个执行流可以同时看到并修改同一块内存"的机会面**，因为经验证明，这种机会面越大，并发 bug 就越多、越隐蔽、越难在 code review 里被肉眼发现。

- 锁是"事后补救"：状态天生共享，靠纪律（记得加锁、锁顺序一致）来防止出错，纪律是会被违反的，而且违反的代价通常是概率性的、生产环境才暴露的 bug。
- Actor/CSP/事件循环是"架构级预防"：从设计上让"同时访问同一状态"这件事在大多数情况下根本无法发生，即使某个开发者不那么熟悉并发理论，也很难写出经典的竞态 bug。

这不是说锁被淘汰了——锁仍然是这些高层模型内部的实现基石（Go 的 channel 内部用锁和 futex 实现；事件循环的任务队列也需要同步机制保护），只是**业务代码层面**，能用消息传递表达的地方，就不需要业务开发者自己去操心锁的顺序和粒度。这是一种"把复杂度下沉"的工程策略：把最容易出错的部分收敛到少数几个被反复验证过的运行时/标准库实现里，而不是让每个业务开发者在每一处共享状态旁边都重新推理一遍。

---

## 8. 实战建议：场景 → 并发模型对照表

| 场景 | 推荐模型 | 为什么 |
|---|---|---|
| Web 服务器处理大量并发 HTTP 请求，业务逻辑主要是查库、调下游 API（I/O 密集） | 协程/async-await（Go goroutine、Python asyncio、Node.js） | I/O 等待期间不占用宝贵的线程/CPU 资源，单机可支撑远超线程模型的并发连接数 |
| 图像处理、科学计算、数据压缩等纯计算任务（CPU 密集） | 多进程 + 数据并行切分（Python `multiprocessing`）或原生多线程并行（Go、Java、Rust，无 GIL 限制） | 需要真正利用多核，语言有 GIL 限制的（如 CPython）必须用多进程绕开 |
| 多个服务/组件之间共享少量简单计数状态（限流计数、指标统计） | 原子操作 | 开销最小，避免为一个简单加法引入锁的调度开销 |
| 配置缓存、字典类数据，读远多于写 | 读写锁，或直接用不可变数据+替换整个引用（COW，Copy-On-Write） | 读写锁允许并发读；COW 在读路径完全不需要任何锁 |
| 生产者-消费者流水线（如日志采集、任务队列） | 有界 channel（Go）/ 阻塞队列（Java `BlockingQueue`）/ 消息队列中间件（跨进程场景，如 Kafka/RabbitMQ） | 天然带背压（生产快于消费时阻塞生产者），比手写条件变量更不容易出错 |
| 需要强隔离、单个任务崩溃不能影响其他任务（插件系统、多租户批处理） | 多进程或 Actor 模型 | 进程级隔离防止级联崩溃；Actor 内部状态私有，一个 Actor 出错不会污染别人的内存 |
| 复杂状态机、需要保证同一实体的操作严格按顺序处理（订单状态流转、账户余额） | Actor 模型 或 CSP（单一 goroutine 拥有该实体的写权限，其他协程通过 channel 请求） | 从架构上保证同一实体永远只被一个执行流修改，不需要在每处业务代码里记得加锁 |
| 需要精细控制并发上限（连接池、限流） | 信号量 或 带缓冲的 channel（用 channel 的容量天然实现限流） | 语义直接对应"最多 N 个同时进行" |
| 极高性能要求的底层数据结构（消息队列内核、内存分配器） | 无锁结构（CAS） | 避免锁的挂起/唤醒开销和锁护送问题；但只应由熟悉内存模型的专家实现，不建议业务代码自造 |
| 简单的"读-改-写"临界区，逻辑复杂、涉及多个变量的复合不变量 | 互斥锁 | 心智负担最低，正确性最容易推理；性能不是瓶颈时，可读性和可维护性优先 |

### 几条更朴素的经验规则

1. **先问"能不能不共享状态"，再问"用什么同步"**。能通过消息传递、不可变数据、每个任务处理独立数据分片来避免共享，永远比事后补一把锁更省心。这是第 7 节一路的核心结论。
2. **锁的粒度宁可先粗后细**。一开始用一把大锁保护一整块相关状态，正确性容易保证；性能分析显示这把锁确实是瓶颈时，再考虑拆分成细粒度的锁——过早的细粒度加锁是死锁和难以维护代码的常见来源。
3. **临界区越小越好，但不能小到破坏原子性**。临界区里只做真正需要同步保护的操作，网络请求、磁盘 I/O、日志打印这类耗时且与共享状态无关的操作要挪到锁外面；但不能为了"减小临界区"而把一个逻辑上必须绑在一起的读-改-写拆开——那样反而会重新引入竞态。
4. **测试并发代码要用工具而不是靠肉眼**：Go 有内置的 `-race` 检测器，C/C++ 有 ThreadSanitizer，Java 有各种死锁检测工具。这类 bug 靠 code review 肉眼审查漏检率很高，靠运行几次手测更是几乎测不出来（第 3 节已经解释过为什么），务必让工具介入。
5. **写并发代码前先想清楚"不变量"是什么**：这块共享状态在任意时刻应该始终满足什么条件（比如"总账户余额之和恒定""队列长度非负"），同步机制的作用就是保护这个不变量在并发访问下不被破坏。想不清楚不变量是什么,基本等于还没想清楚该锁哪里、锁多大范围。

---

## 小结：一条主线贯穿全文

回到开头的心智模型：**并发问题的根源是多个执行流不可预测地交错访问同一份状态**。进程、线程、协程只是"执行流"这个概念在不同代价/隔离性权衡下的三种实现；锁、信号量、条件变量、CAS 是解决"交错访问"问题的不同工具，代价各异；死锁、活锁、饥饿是这些工具用不好时出现的几种典型故障模式；而 Actor、CSP、async/await 这些更高层的模型，本质上是在架构层面减少"状态被共享"的机会面，把正确性从"依赖程序员的纪律"升级为"由设计保证"。理解了这条主线，遇到新的并发原语或框架时，第一反应应该是问："它在解决交错访问的哪个方面？付出的代价是什么？"——而不是死记它的 API。
