- 引言 — 两个总被混淆的词
- 用咖啡馆做比喻来理解
- 线程 vs 事件循环 — 两种执行模型
- 竞态条件与数据竞争
- 锁与互斥锁 — 强制顺序
- 死锁与哲学家就餐问题
- 原子操作 — 无锁也安全
- 什么时候 async 占优,什么时候线程占优
- 常见误解与陷阱
- 结语
- 参考资料
引言 — 两个总被混淆的词
"并发(concurrency)"和"并行(parallelism)"是开发者最常混用的一对词,因为二者都给人"多件事同时在发生"的印象。但这是两个不同的概念,把区别留得模糊,不管是性能问题还是 bug,都会让你在错误的方向上排查。
Go 语言的设计者 Rob Pike 留下的定义最为明快。
并发是应对(dealing with)多件事,并行是同时做(doing)多件事。
这一句话把核心都说尽了。并发关乎结构 — 是如何把多个任务拆分、协调的问题。并行关乎执行 — 是多个计算是否真的在同一时刻进行的问题。并发可以脱离并行而存在(哪怕只有一个核心的电脑,也能交替应对多个任务),并行只是实现并发的其中一种方式。
本文从这个区分出发,梳理线程与事件循环、竞态条件与锁、死锁与原子操作,勾勒出并发编程的核心地形。想亲眼看看事件循环具体如何交替处理任务,可以打开消息队列游乐场的 asyncio 标签页;想看多个运算并行流动的计算图,可以一并打开神经网络实验室。
用咖啡馆做比喻来理解
把 Rob Pike 的定义搬进日常场景,是这样的。
咖啡馆里有一位咖啡师。三份订单进来了。这位咖啡师在为第一份订单萃取意式浓缩的同时,给第二份订单蒸牛奶,中间又抽空准备第三份订单的杯子。虽然只有一个人,却在应对三份订单。这就是并发 — 不是真的在同一瞬间用两只手做两件事,而是利用等待的时间在任务之间来回切换。
现在雇三位咖啡师。每人负责一份订单,真正同时制作。这就是并行 — 三杯饮品在同一瞬间被真实地做出来。
核心洞察是这样的。并发是把问题结构化为可独立执行的片段的方法,并行则是把这些片段实际分给多个工作者、让它们同时执行。设计良好的并发结构,工作者只有一个时就交替处理,工作者有多个时就自然地扩展为并行。
线程 vs 事件循环 — 两种执行模型
用代码实现并发的代表性方式有两种 — 基于线程的模型,以及事件循环(async)模型。
线程模型由操作系统创建多条执行流(线程),调度器把它们分配到各个核心上。如果核心有多个,线程就会真正并行执行。每个线程拥有自己的栈、独立推进,操作系统可以在任意时刻暂停一个线程、切换到另一个线程(抢占式调度)。
事件循环模型只有一个线程,在其内部交替处理多个任务(协程、task),就像前面那位咖啡师一样。任务等待 I/O 的那一刻(await),就把它暂时放下,转去处理别的任务。因为切换点由程序员显式标记,所以这是协作式调度。
线程模型 (抢占式)
线程 A ████░░░░████░░░░ OS 在任意时点切换
线程 B ░░░░████░░░░████ 多核心时可真正并行
事件循环模型 (协作式)
单一线程 A─await─B─await─A─await─C ...
仅在任务自行让出的地点切换
两种模型的根本区别在于"切换何时发生"。线程由操作系统随时插手切换,因此可以实现真正的并行执行,但也正因为如此,处理共享数据时很危险。事件循环只在 await 这类显式地点切换,所以是可预测的 — 但如果一个任务不让出、死死抱住计算,其余全部都会停下。
竞态条件与数据竞争
并发编程之所以困难,核心原因是竞态条件(race condition) — 多条执行流访问共享资源,而结果会因执行顺序(时序)不同而不同的情形。
最经典的例子是计数器自增。counter += 1 这一行,实际上由三个步骤组成。
1. 读取 counter 的值 (例如: 10)
2. 给这个值加 1 (11)
3. 把结果写回 counter (11)
如果两个线程同时执行这三个步骤,可能发生下面这样的事。
线程 A: 读取(10) ....... 加一(11) 写入(11)
线程 B: ......... 读取(10) ....... 加一(11) 写入(11)
结果: counter = 11 (明明加了两次,却是 11!)
加了两次,结果却是 11。有一次自增凭空消失了。像这样多个线程在没有同步的情况下访问同一块内存、且其中至少有一个在写入的情形,特别称为数据竞争(data race)。数据竞争是竞态条件的一种,在很多语言里被当作未定义行为(undefined behavior)处理。
用 Python 复现这个问题是这样的。
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # 不是原子操作 — 可能发生竞争
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # 可能不是 400000,而是比它小的值
竞态条件可怕的地方在于复现率低。大多数情况下顺序恰好对上,程序正常运行,只有在负载高或时序落在特定条件时才会出错。于是它就成了那种在测试里从不出现、却专挑生产环境爆发的臭名昭著的 bug。
锁与互斥锁 — 强制顺序
防止竞态条件的基本工具是锁(lock),其中尤以表示互斥的互斥锁(mutex, mutual exclusion)为代表。思路很简单 — 让触碰共享资源的代码区段(临界区,critical section)一次只允许一条执行流进入。
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # 一次只有一个线程能进入这个代码块
counter += 1
# 现在结果总是 400000
要进入 with lock 代码块必须先获取锁,一个线程握着锁的时候,其他线程就得等到它释放为止。这样一来,读取-加一-写入这三个步骤就不会被拆开,而是原子地执行。
锁很强大,但不是免费的,伴随着三项代价。
- 性能: 获取和释放锁本身有开销,多个线程争夺同一把锁时(竞争,contention),大家排队等待,并行性就消失了。
- 粒度(granularity)的选择: 锁的范围划得宽(coarse-grained)比较安全,但并行性下降;划得窄(fine-grained)并行性好,但更复杂、更容易出 bug。
- 死锁风险: 用多把锁时,可能出现彼此等待对方手中的锁、永远卡住的僵局。
死锁与哲学家就餐问题
死锁(deadlock,即僵局)是两条或更多执行流各自等待对方握有的资源、谁都无法推进的状态。最能说明这一点的经典问题,是 Edsger Dijkstra 提出的哲学家就餐问题(dining philosophers)。
圆桌旁坐着五位哲学家。每人面前都有一盘意大利面,哲学家之间各放着一把叉子,一共五把。要吃到面,左右两把叉子都得拿到手。
哲学家0
叉子4 叉子0
哲学家4 哲学家1
叉子3 叉子1
哲学家3 叉子2 哲学家2
每位哲学家都要拿到两侧的 2 把叉子才能进食
现在假设所有哲学家都同时遵循同一条规则 — "先拿左边的叉子,再拿右边的叉子"。所有人同时拿起左边的叉子后,五把叉子就都被各自的一只手握住了。这时所有人都想去拿右边的叉子,但右边的叉子在邻座的左手里。没有人能拿到第二把叉子,也没有人愿意放下第一把。永远的僵局。
死锁要成立,必须同时满足四个条件(Coffman 条件)。
- 互斥: 资源一次只能被一方使用(叉子不能共享)。
- 持有并等待: 握着一个资源的同时等待另一个(握着左边的叉子,等右边的)。
- 不可抢占: 不能强行夺走别人手中的资源。
- 循环等待: 等待的链条首尾相连成环。
只要打破其中任意一条,就能阻止死锁。一种代表性的解法是打破循环等待。比如给叉子编号,规定"永远先拿编号较小的那把",这样一来只有最后一位哲学家会以相反的顺序去拿,循环被切断,死锁也就消失了。
原子操作 — 无锁也安全
锁是重量级的。仅仅为了安全地把一个计数器加一,就去获取再释放锁,未免小题大做。因此有了由硬件直接支持的原子操作(atomic operation)。
原子操作是 CPU 保证为"不可再分的单一动作"的操作 — 把前面看到的读取-加一-写入三个步骤,作为一条中途不会被其他线程插入的单一指令来执行。最具代表性的就是 CAS(Compare-And-Swap)。
CAS(地址, 期望值, 新值):
如果 *地址 == 期望值
把 *地址 改为新值,返回成功
否则
什么都不做,返回失败
— 这整个过程是原子发生的
用 CAS 可以在不用锁的情况下实现安全的自增。做法是"读取当前值,如果它还没变,就换成 +1 后的值;如果这期间被别人改动过,就失败,然后重试"。这种方式被称为无锁(lock-free)编程。
原子操作的优点是没有锁的开销、也没有死锁风险。但它不是万能药。对计数器、标志位这类简单的单一值来说效果很好,但在需要一次性一致地改动多个数据结构的复杂场景里,光靠原子操作很难表达,用错了反而会招来微妙的 bug(比如 ABA 问题)。所以实务中通常这样分工 — 简单场景用原子操作,复杂场景用锁。
什么时候 async 占优,什么时候线程占优
现在是最实用的问题了。需要并发时,该用 async(事件循环),还是线程(或多进程)?答案取决于任务的性质。
任务大致分为两类。
- I/O 密集型(I/O-bound): 大部分时间都花在等待某样东西上的任务。网络响应、磁盘读取、数据库查询。CPU 几乎都在闲置。
- CPU 密集型(CPU-bound): 大部分时间都花在实际计算上的任务。图像处理、加密、数值模拟。CPU 一刻不停地转。
这个区分决定了选择。
I/O 密集型任务里 async 占优。因为等待的时间多,一个线程完全可以利用等待的空隙去处理别的任务。用一个事件循环去应对成千上万个连接,比创建成千上万个线程要省得多的内存,切换成本也更低。像 Web 服务器、代理、爬虫这类"大量等待"的任务,正好契合这里。
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay) # 等待期间执行其他协程
return name
async def main():
# 三个任务同时进行 — 接近等待时间的最大值,而不是总和
results = await asyncio.gather(
fetch("a", 2), fetch("b", 1), fetch("c", 3),
)
print(results)
asyncio.run(main())
CPU 密集型任务需要线程 — 更准确地说,是多进程。计算没有可以让出的空隙,所以在事件循环里,一个任务占住 CPU,其余的就全部停摆。这时需要真正的并行执行,也就是要把任务分散到多个核心上。这里有一个 Python 特有的陷阱。CPython 里有 GIL(Global Interpreter Lock,全局解释器锁),即使有多个线程,也只有一个线程能在同一时刻执行 Python 字节码。所以在 Python 里要让 CPU 密集型任务真正并行运行,用的不是线程,而要用多进程(multiprocessing)。
归纳如下。
| 情况 | 好的选择 | 原因 |
|---|---|---|
| 大量等待的 I/O (网络、磁盘) | async / 事件循环 | 重复利用等待的空隙,轻量 |
| 繁重的计算 (CPU 密集) | 多进程 / 多核心 | 需要真正的并行执行 |
| 是 I/O 但没有 async 库 | 线程池 | 把阻塞调用隔离到线程里 |
| Python 中的 CPU 并行 | 多进程 | 受 GIL 影响,线程无法并行 |
常见误解与陷阱
下面列出并发编程中常踩的坑。
- "async 总是更快"的误解: async 只在 I/O 密集型场景里占优。把 CPU 密集型任务包进 async,反而会堵住事件循环,变得更慢。
- 事件循环里的阻塞调用: 在单线程事件循环里调用同步阻塞函数(普通的
time.sleep、阻塞式的 DB 驱动等),会让整个循环停摆。要么换成支持 async 的库,要么转交给单独的线程。 - "线程越多越快"的误解: 线程各自占用栈内存,还有切换成本。远超核心数量的线程,反而会因为切换开销而变慢。
- 锁的范围划得太宽,扼杀并行性: 为了"保险起见"把临界区划得很大,实际上就变成了顺序执行,多核心的优势也随之消失。
- 把竞态条件当成"偶尔出现的 bug"放着不管: 因为难以复现就搁置,回头就会在生产环境里以数据损坏的形式反噬。共享状态必须设计同步机制。
结语
并发和并行看起来相似,但处于不同的层次。并发是把多件事结构化成可以被应对的形式,并行是把这些事在多个核心上真正同时执行。设计良好的并发程序,核心只有一个时交替处理,核心有多个时自然地扩展为并行。
而让这套结构变得安全的,是同步的技术。用锁或原子操作挡住竞态条件,用顺序规则避开死锁,再根据任务的性质(I/O 还是 CPU)在 async 和多进程之间做选择。把这张地图记在脑子里,"为什么慢"和"为什么偶尔出错"这两类问题,就能在准确得多的地方找到答案。
到消息队列游乐场的 asyncio 标签页里,亲自看看事件循环是如何在任务之间来回切换的;再到神经网络实验室里,看看多个运算并行流动的计算图。
参考资料
- Rob Pike, "Concurrency Is Not Parallelism": https://go.dev/blog/waza-talk
- Dining philosophers problem (Wikipedia): https://en.wikipedia.org/wiki/Dining_philosophers_problem
- Deadlock and Coffman conditions (Wikipedia): https://en.wikipedia.org/wiki/Deadlock
- Compare-and-swap (Wikipedia): https://en.wikipedia.org/wiki/Compare-and-swap
- Python asyncio 官方文档: https://docs.python.org/3/library/asyncio.html
- Python GIL 说明: https://docs.python.org/3/glossary.html#term-global-interpreter-lock
현재 단락 (1/117)
"并发(concurrency)"和"并行(parallelism)"是开发者最常混用的一对词,因为二者都给人"多件事同时在发生"的印象。但这是两个不同的概念,把区别留得模糊,不管是性能问题还是 bu...