Skip to content
Published on

并发 vs 并行 — "应对"与"真正同时做"的区别

分享
Authors

引言 — 两个总被混淆的词

"并发(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 标签页里,亲自看看事件循环是如何在任务之间来回切换的;再到神经网络实验室里,看看多个运算并行流动的计算图。

参考资料