并发编程入门:进程、线程、协程与那些锁
读者画像:会写代码,知道
Thread、async/await、lock这些关键字怎么敲,但如果被追问"为什么要用读写锁而不是互斥锁""死锁到底怎么产生的""channel 和共享内存+锁到底哪里不一样",会开始含糊。<br><br>读完这篇文章,你会建立一个心智模型:并发问题的根源只有一个——多个执行流以不可预测的顺序读写同一份状态;所有并发原语,本质上都是在用不同的代价换取"让这份状态在某个时刻只被一个执行流以确定的方式访问"这件事。 一旦这个模型立住了,锁、信号量、CAS、channel 看起来就不再是一堆互不相关的 API,而是同一个问题在不同抽象层上的不同解法。
目录
- 并发 vs 并行:两个经常被混用的词
- 进程、线程、协程:对照表与心智模型
- 竞态条件:余额 +1 丢更新的完整剖析
- 同步原语:锁、信号量、条件变量、原子操作
- 死锁、活锁与饥饿
- 无锁编程与 CAS:乐观并发与 ABA 问题
- 更高层的模型:线程池、Actor、CSP、async/await
- 实战建议:场景 → 并发模型对照表
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":
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):
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 的并发数(简易限流)。比如:
# 限制同时最多 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 且响应有延迟。条件变量提供了"释放锁并挂起,等被通知后重新获取锁并醒来"这个原子操作:
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),可以保证"读-改-写"这一整个过程不被其他核心的操作打断,不需要操作系统介入挂起/唤醒线程。
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 里更直观:
var counter int64
atomic.AddInt64(&counter, 1) // 一条硬件级原子指令,没有锁的调度开销
代价:原子操作只能保护单个变量的单次简单操作(加减、比较交换、位运算等),无法覆盖"多个变量需要保持一致"的复合逻辑(比如同时更新余额和交易流水这两个字段,原子操作做不到把两者绑定成一个不可分割的整体,这时还是需要锁)。滥用原子操作拼凑复杂逻辑,容易写出"每一步单独看都对,组合起来却有竞态窗口"的 bug。
4.6 一张对照表:谁解决什么、代价是什么
| 原语 | 解决的问题 | 主要代价 | 典型场景 |
|---|---|---|---|
| 互斥锁 | 同时只允许 1 个执行流进入临界区 | 阻塞+调度开销;完全串行化 | 保护任意共享状态的读改写 |
| 读写锁 | 允许多读者并行,写者独占 | 元数据开销更大;可能写者饥饿 | 读多写少的缓存/配置 |
| 信号量 | 限制同时访问某资源的并发数为 N | 计数器需同步;无所有权概念易误用 | 连接池限流、并发数控制 |
| 条件变量 | 等待某条件成立再继续,避免忙轮询 | 必须配合锁;虚假唤醒需用 while 防御 | 生产者-消费者队列 |
| 原子操作 | 单变量的简单读改写不被打断 | 只能覆盖单变量简单操作 | 计数器、标志位、无锁数据结构的基石 |
5. 死锁的四个必要条件与避免手段;活锁与饥饿
5.1 死锁:一个具体的例子
死锁最常见的诱因是"多把锁、加锁顺序不一致"。经典例子——转账,线程A执行"账户1转给账户2",线程B同时执行"账户2转给账户1":
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 年的论文中系统提出,是操作系统教材里的标准理论:
- 互斥(Mutual Exclusion):资源同一时刻只能被一个执行流持有(比如锁本身就是互斥的)。
- 持有并等待(Hold and Wait):一个执行流已经持有至少一个资源,同时又在等待获取其他被占用的资源(上例中 A 持有 lock1 的同时还在等 lock2)。
- 不可剥夺(No Preemption):资源只能由持有者主动释放,不能被外部强行抢走。
- 循环等待(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)
活锁和死锁的核心区别:死锁里的线程是静止的(阻塞挂起,什么都不做);活锁里的线程是忙碌的,一直在执行代码、状态在变化,但整体没有任何实质性进展。
经典类比:两个人在走廊里迎面相遇,都想给对方让路,于是同时往左边让,结果还是撞上;意识到后又同时往右边让,又撞上——如此反复,双方都在"努力避让",但谁都没能通过。
代码层面的例子:两个线程都尝试获取两把锁,为了避免死锁,约定"如果拿不到第二把锁,就释放已持有的第一把锁,稍后重试":
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 实现一个无锁计数器:
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 只能验证"表面的值没变",无法验证"这期间是否发生过变化又变回原样"——它验证的是"结果相等"而不是"历史不变"。这个问题在无锁栈/队列这类需要频繁弹出/压入节点的数据结构里尤其容易出现,是无锁编程里最经典也最隐蔽的陷阱之一。
常见解决方案:
- 带版本号的 CAS(tagged pointer / ABA counter):不只比较值本身,额外绑定一个单调递增的版本号一起做 CAS(也叫 double-word CAS)。哪怕值被改回了
A,只要中间发生过修改,版本号必然递增过,两次的"值+版本号"组合就不会相等,CAS 会正确地失败。Java 的AtomicStampedReference就是这个思路的直接实现。 - 危险指针(Hazard Pointers):线程在访问某个指针前,先把它登记到一个全局可见的"我正在用这个指针"列表里,其他线程在真正回收/复用这块内存前,必须检查是否有人还在用,避免内存被过早复用导致地址值"看起来没变、其实已经变了又变回来"。
- Epoch-based / RCU 类回收机制:把内存回收延迟到"确定所有可能持有旧指针的线程都已经离开临界区"之后,本质上是把 ABA 问题转化为"内存何时可以安全复用"的问题来解决。
工程上的建议是:除非在写底层无锁数据结构库(如高性能队列、内存分配器),否则不要自己手写 CAS 循环去解决业务问题——这类代码的正确性证明极其烧脑,一旦有 ABA 这类隐蔽 bug,出问题的概率极低但后果可能是内存损坏、极难复现的偶发崩溃。绝大多数业务场景,用成熟并发库(ConcurrentHashMap、sync.Map、语言内置的原子类型)远比自己发明无锁结构划算。
7. 更高层的模型:线程池、Actor、CSP、async/await
前面几节的锁、信号量、CAS都是"底层同步原语"——用对了能解决问题,但直接在业务代码里裸用它们管理共享状态,心智负担极重:每一处共享状态访问都要人肉推理"这里要不要加锁、加哪把锁、顺序对不对",规模一大几乎必然出错。工程上更常见的做法,是选择一个更高层的并发模型,把"如何同步"这件事封装进模型本身,业务代码只需要遵循模型的规则。
7.1 线程池(Thread Pool)
解决什么:线程创建/销毁本身有实打实的开销(第 2 节表格里提到的几十 µs 量级),如果每来一个任务就创建一个新线程、用完销毁,在高频短任务场景下这个开销会成为瓶颈,而且无限制创建线程可能把机器的内存和调度资源耗尽。线程池预先创建一批线程常驻,任务通过一个队列分发给空闲线程执行,用完不销毁、放回池子等下一个任务。
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 重写,不需要任何显式的锁:
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 一个异步操作(网络请求、定时器、文件读取),当前函数会被挂起,事件循环转去执行其他就绪的任务;等这个异步操作真正完成(比如网络包回来了),对应的回调被重新排入队列,事件循环空闲时再把它捞出来继续跑。
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 点,另一个协程完全可能在这个间隙插进来修改共享状态:
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) | 避免锁的挂起/唤醒开销和锁护送问题;但只应由熟悉内存模型的专家实现,不建议业务代码自造 |
| 简单的"读-改-写"临界区,逻辑复杂、涉及多个变量的复合不变量 | 互斥锁 | 心智负担最低,正确性最容易推理;性能不是瓶颈时,可读性和可维护性优先 |
几条更朴素的经验规则
- 先问"能不能不共享状态",再问"用什么同步"。能通过消息传递、不可变数据、每个任务处理独立数据分片来避免共享,永远比事后补一把锁更省心。这是第 7 节一路的核心结论。
- 锁的粒度宁可先粗后细。一开始用一把大锁保护一整块相关状态,正确性容易保证;性能分析显示这把锁确实是瓶颈时,再考虑拆分成细粒度的锁——过早的细粒度加锁是死锁和难以维护代码的常见来源。
- 临界区越小越好,但不能小到破坏原子性。临界区里只做真正需要同步保护的操作,网络请求、磁盘 I/O、日志打印这类耗时且与共享状态无关的操作要挪到锁外面;但不能为了"减小临界区"而把一个逻辑上必须绑在一起的读-改-写拆开——那样反而会重新引入竞态。
- 测试并发代码要用工具而不是靠肉眼:Go 有内置的
-race检测器,C/C++ 有 ThreadSanitizer,Java 有各种死锁检测工具。这类 bug 靠 code review 肉眼审查漏检率很高,靠运行几次手测更是几乎测不出来(第 3 节已经解释过为什么),务必让工具介入。 - 写并发代码前先想清楚"不变量"是什么:这块共享状态在任意时刻应该始终满足什么条件(比如"总账户余额之和恒定""队列长度非负"),同步机制的作用就是保护这个不变量在并发访问下不被破坏。想不清楚不变量是什么,基本等于还没想清楚该锁哪里、锁多大范围。
小结:一条主线贯穿全文
回到开头的心智模型:并发问题的根源是多个执行流不可预测地交错访问同一份状态。进程、线程、协程只是"执行流"这个概念在不同代价/隔离性权衡下的三种实现;锁、信号量、条件变量、CAS 是解决"交错访问"问题的不同工具,代价各异;死锁、活锁、饥饿是这些工具用不好时出现的几种典型故障模式;而 Actor、CSP、async/await 这些更高层的模型,本质上是在架构层面减少"状态被共享"的机会面,把正确性从"依赖程序员的纪律"升级为"由设计保证"。理解了这条主线,遇到新的并发原语或框架时,第一反应应该是问:"它在解决交错访问的哪个方面?付出的代价是什么?"——而不是死记它的 API。