并发和并行:等待型任务与计算型任务
上一篇认识了进程、线程和协程。订单详情接口的一次请求里,有两种性质完全不同的工作:查订单库、问库存和物流服务,这是在等别人;给响应算签名,这是自己在算。同样是“开四个线程”,对这两种工作的效果截然不同。读完这一篇,你应该能分清并发和并行,判断一个任务是等待型还是计算型,并据此选择线程、协程或多进程。
实验:四个线程,一个快了四倍,一个没变
下面的实验准备两种任务,各做 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 线程越多越快吗:上下文切换与调度。
