← 返回画廊

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

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


目录

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

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

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

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. 进程、线程、协程:对照表与心智模型

先建立心智模型,再看细节:

类比:如果把计算机比作一栋写字楼,进程就是一家独立公司,有自己独立的办公室(地址空间)、独立的门禁卡(资源权限),公司之间互相看不到对方的文件柜;线程就是公司里的员工,共用同一间办公室的文件柜(共享内存),可以直接互相传纸条,但也正因为共用同一套文件柜,两个人同时去改同一份文件就会打架;协程就是员工手头同时开着的多个任务——同一个人在处理任务 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。

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)

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

代价

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

4.2 读写锁(Read-Write Lock / 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 年的论文中系统提出,是操作系统教材里的标准理论:

  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)

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

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

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

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

常见解决方案:

  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,出问题的概率极低但后果可能是内存损坏、极难复现的偶发崩溃。绝大多数业务场景,用成熟并发库(ConcurrentHashMapsync.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 里被肉眼发现。

这不是说锁被淘汰了——锁仍然是这些高层模型内部的实现基石(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。