Skip to content

约 5 分钟阅读

并发和并行:等待型任务与计算型任务 ​

上一篇认识了进程、线程和协程。订单详情接口的一次请求里,有两种性质完全不同的工作:查订单库、问库存和物流服务,这是在等别人;给响应算签名,这是自己在算。同样是“开四个线程”,对这两种工作的效果截然不同。读完这一篇,你应该能分清并发和并行,判断一个任务是等待型还是计算型,并据此选择线程、协程或多进程。

实验:四个线程,一个快了四倍,一个没变 ​

下面的实验准备两种任务,各做 4 次。“等数据库”用 sleep(0.5) 模拟一次 0.5 秒的查询,期间线程什么都不做;“算签名”是一段纯 Python 循环,一直占着 CPU。每种任务分别用三种方式跑:一个接一个顺序执行、4 个线程同时执行、4 个进程同时执行。保存为 cpu_io.py:

python
import os, time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

def wait_db(_):            # 等数据库返回:线程在等,CPU 空闲
    time.sleep(0.5)

def sign(_):               # 纯 Python 计算:一直占着 CPU
    n = 0
    for i in range(15_000_000):
        n += i * i
    return n

def run(label, fn, pool):
    cpu0, t0 = os.times(), time.perf_counter()
    if pool is None:
        for i in range(4):
            fn(i)
    else:
        with pool(4) as ex:
            list(ex.map(fn, range(4)))
    wall = time.perf_counter() - t0
    c = os.times()
    cpu = (c.user - cpu0.user) + (c.system - cpu0.system) + (c.children_user - cpu0.children_user)
    print(f"{label:<16} 墙钟 {wall:4.2f} 秒   CPU 时间 {cpu:4.2f} 秒")

for name, fn in (("等数据库", wait_db), ("算签名", sign)):
    run(f"{name}·顺序", fn, None)
    run(f"{name}·4 线程", fn, ThreadPoolExecutor)
    run(f"{name}·4 进程", fn, ProcessPoolExecutor)
    print()

用 --cpuset-cpus 0-3 把容器限制在 4 个 CPU 上运行:

bash
docker run --rm --cpuset-cpus 0-3 -v "$PWD":/lab -w /lab capacity-lab python cpu_io.py

实际输出:

text
等数据库·顺序          墙钟 2.01 秒   CPU 时间 0.00 秒
等数据库·4 线程        墙钟 0.51 秒   CPU 时间 0.00 秒
等数据库·4 进程        墙钟 0.54 秒   CPU 时间 0.04 秒

算签名·顺序           墙钟 1.56 秒   CPU 时间 1.56 秒
算签名·4 线程         墙钟 1.53 秒   CPU 时间 1.54 秒
算签名·4 进程         墙钟 0.54 秒   CPU 时间 2.12 秒

“墙钟”是从开始到结束实际经过的时间,“CPU 时间”是所有线程和进程在 CPU 上真正执行的时间之和。等数据库的任务,顺序执行要 2 秒,4 个线程只要 0.5 秒,CPU 时间几乎是 0。算签名的任务,4 个线程和顺序执行一样慢;换成 4 个进程,墙钟时间降到三分之一。

并发是同时应付,并行是同时执行 ​

并发与并行的时间线对比

这两个现象分别对应两个经常被混用的词。

并发是指一段时间内有多件事都处于“进行中”,它们可以轮流推进,不要求在同一瞬间都在执行。四个线程等数据库,就是并发:每个线程发出查询后就进入等待,操作系统让出 CPU 去做别的事,四次等待在时间上重叠起来,所以总时间约等于一次查询。这里一个 CPU 核就足够,因为等待本身不需要 CPU。

并行是指同一瞬间真的有多件事在执行,前提是有多个 CPU 核。四个进程算签名就是并行:四个核各算一份,所以总时间缩短。

并发是程序的组织方式,并行是硬件层面的执行方式。一个单核机器上的服务可以有很高的并发,同时处理上千个在等数据库的请求;但只有多核才能并行,计算型工作也只有靠并行才能加速。

为什么四个线程算不快:Python 的 GIL ​

在 Java、Go、C++ 里,4 个线程做计算,本来就能在 4 个核上并行。实验里线程没有加速,是 Python 自己的原因:默认的 CPython 解释器有一把全局解释器锁(GIL),任何线程执行 Python 字节码之前都要先拿到它。四个线程轮流拿锁,同一时刻只有一个在算,所以总时间和顺序执行一样。

线程在等待 I/O 时会释放 GIL,所以等数据库的实验不受影响。一些用 C 写的库在执行大块计算时也会主动释放 GIL,例如 hashlib 计算大块数据的摘要、zlib 压缩和 NumPy 的许多运算,这时多个线程也能并行。纯 Python 代码的计算要并行,传统做法是多进程:每个进程有自己的解释器和自己的 GIL。

Python 3.13 起提供了一个可选的“自由线程”构建(free-threaded build),去掉了 GIL;从 3.14 起,它成为官方支持的构建方式,但默认安装的解释器仍然带 GIL,而且部分 C 扩展在这个构建里会把 GIL 重新打开(截至 2026 年 10 月)。迁移时的检查方法见 GIL、free-threading 与未来选型。

进程版的 CPU 时间从 1.56 秒升到了 2.12 秒,多出来的部分有两个来源:启动进程池和在进程之间传递参数、结果的开销;另外,实验机是 Apple M4,虚拟机的 10 个 CPU 背后既有性能核也有能效核,落在能效核上的那份算得慢一些。所以四个进程只快了约 2.8 倍,而不是 4 倍。在核心规格一致、没有其他负载的服务器上会更接近 4 倍。宿主机繁忙时这组数字波动很大,顺序计算曾慢到 3.6 秒,但“线程不变、进程加速”的关系每次都成立。

判断一个任务是等待型还是计算型 ​

最直接的方法,是比较一个任务的 CPU 时间和墙钟时间。实验里等数据库的任务,CPU 时间接近 0,墙钟 0.5 秒,几乎全在等待,这叫 I/O 密集型;算签名的任务,两者几乎相等,一直在算,这叫 CPU 密集型。

订单详情接口属于前者:一次请求 40 毫秒,其中查库和调用下游占了三十多毫秒,算签名只有一两毫秒。大多数 Web 服务都是这样,所以靠线程池或协程提高并发,就能在少量 CPU 上服务大量请求。图片缩放、视频转码、模型推理、大批量数据计算则属于后者,加线程(在 Python 里)或者加协程都没用,只能增加 CPU 核数,用多进程或原生线程把计算并行起来。

两种工作对应的选择大致如下:

任务性质加速靠什么常见做法
等待为主(查库、调接口、读写网络)并发:让等待重叠线程池、协程、事件循环
计算为主(签名、压缩、图片、推理)并行:更多 CPU 核多进程;Java、Go 等语言用多线程;Python 用释放 GIL 的原生库
两者混合拆开处理等待部分用并发,计算部分交给独立的进程池或计算服务

在服务器上判断 ​

服务已经在运行时,可以用下面的命令看它的时间花在哪里(来自 sysstat 包,PID 换成服务进程号):

bash
pidstat -u -p PID 1     # 每秒一行:%usr 是用户态计算,%system 是内核态
pidstat -t -p PID 1     # 按线程拆开看
top -H -p PID           # 哪几个线程在占 CPU

请求量很大而 %usr 很低,说明大部分时间在等待,瓶颈在数据库或下游,加 CPU 无济于事;某个进程长期接近 100%(一个核满了)而其他核空闲,说明计算没有并行起来,常见原因就是 Python 的 GIL、单线程的事件循环里做了重计算,或者程序本身只有一个工作线程。

并发让等待重叠,并行让计算分摊。那么线程是不是开得越多越好?一个核同时有十几个线程要算的时候,会发生什么?下一篇:03 线程越多越快吗:上下文切换与调度。

别急,先让缓存热一下。