Python 并发怎么选?一篇讲清 threading、multiprocessing、asyncio 的边界
写过一些 Python 服务和脚本后我发现,很多人(包括早期的我)一遇到"要并发"就凭感觉开线程:请求慢了开线程、算得慢了也开线程,跑起来才发现,IO 任务确实快了,可 CPU 密集的任务开了一堆线程反而更慢。问题不在并发本身,而在没搞清楚三种模型各自解决什么问题。
这篇把 threading、multiprocessing、asyncio 的边界、适用场景和高频踩坑一次讲清,代码都可直接运行。
一、前提:GIL 决定了线程能做什么、不能做什么
CPython 有一把全局解释器锁(GIL):同一个进程里,同一时刻只有一个线程在执行 Python 字节码。这句话是后面所有选型的基础。
- 对 CPU 密集型任务(大量纯 Python 计算),多线程在 CPython 里无法真正并行,多个线程轮流拿 GIL,还要付切换开销,结果往往是"开了多线程,速度不升反降"。
- 对 IO 密集型任务(网络请求、读写文件、查数据库),线程在等待 IO 时会释放 GIL,这段等待时间可以让给别的线程,所以多线程、协程都能拿到明显的并发收益。
- 想在 CPU 密集场景利用多核,正统办法是多进程(每个进程有独立解释器和 GIL);另外 NumPy 这类 C 扩展在做底层计算时会主动释放 GIL,属于特例。
所以选型的第一刀,永远是先判断任务到底是 CPU 密集 还是 IO 密集。
二、三种模型分别是什么
1. threading:线程,配合同步阻塞式 API
线程共享同一块内存,上手最简单。实际工程里一般不直接 Thread().start(),而是用线程池:
import concurrent.futures as cf
import time
def fetch(url):
time.sleep(1) # 模拟一次 1 秒的网络往返(阻塞、会释放 GIL)
return f"done: {url}"
urls = [f"https://example.com/{i}" for i in range(10)]
start = time.time()
with cf.ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(fetch, urls))
print(round(time.time() - start, 2)) # 约 1.0 秒,而不是串行的 10 秒
适合:用的是 requests、传统数据库驱动这类同步阻塞库,任务以等待 IO 为主。
注意:有 GIL 不等于不用加锁。GIL 只保证单条字节码的原子性,而 counter += 1 在字节码层面是"读值→相加→写回"三步,线程可能在中间被切换:
counter = 0
def inc():
global counter
for _ in range(100_000):
counter += 1 # 非原子操作,多线程下最终值会小于预期
凡是"先读后写"的复合操作、对共享可变状态的修改,都要显式用 threading.Lock 保护。
2. multiprocessing:进程,绕开 GIL 吃满多核
多进程各自有独立的解释器和内存空间,能真正跑在多个 CPU 核心上,是 CPU 密集任务的标准答案:
import concurrent.futures as cf
import math, os, time
def heavy(n):
# 纯 Python 的 CPU 密集计算
return sum(math.factorial(i) % 7919 for i in range(n))
if __name__ == "__main__":
tasks = [30_000] * os.cpu_count()
start = time.time()
with cf.ProcessPoolExecutor() as pool:
list(pool.map(heavy, tasks))
print(round(time.time() - start, 2), "s")
同样的 heavy 换成 ThreadPoolExecutor,你会发现耗时基本不变甚至更长——这就是 GIL 的直观体现。
多进程的代价也要清楚:
- 进程启动比线程重,任务太小、数量太多时,创建和调度开销会盖过收益。
- 进程间内存不共享,传给子进程的参数和返回值都要能被 pickle 序列化,跨进程传大对象很慢。
- 跨平台(尤其 Windows、macOS 的 spawn 启动方式)必须写
if __name__ == "__main__":保护,否则子进程递归 import 主模块会反复创建进程甚至报错。
3. asyncio:单线程事件循环 + 协程,扛海量 IO 连接
asyncio 不开多线程也不开多进程,而是在一个线程里跑一个事件循环,靠 await 在任务之间协作式切换。它特别适合"连接数特别多、大部分时间都在等"的场景,比如爬虫、网关、长连接:
import asyncio, time
async def fetch(i):
await asyncio.sleep(1) # 模拟异步 IO,等待时把控制权交还事件循环
return i
async def main():
return await asyncio.gather(*(fetch(i) for i in range(10)))
start = time.time()
asyncio.run(main())
print(round(time.time() - start, 2)) # 约 1.0 秒
协程的切换只发生在 await 点,没有线程切换和 GIL 争用,所以单线程就能轻松挂起成百上千个等待中的任务。但它有一个极其关键的前提:整条调用链都得是异步的。
三、最容易踩的坑:在协程里写阻塞代码
协程是协作式调度,事件循环本身只有一个线程。一旦某个协程里出现同步阻塞调用,整个循环都会被卡住,所有"并发"瞬间退化成串行:
import asyncio, time, requests
async def bad_fetch(url):
return requests.get(url, timeout=5).status_code # 同步阻塞!整个循环卡死
async def main():
await asyncio.gather(*(bad_fetch(u) for u in urls)) # 看起来并发,实际一个个排队
time.sleep、requests.get、同步的数据库驱动、任何纯 Python 的重计算,都不能直接放进协程。正确做法有两种:
- 用对应的异步库:
aiohttp替代requests、异步数据库驱动替代同步驱动、asyncio.sleep替代time.sleep; - 老的同步库一时换不掉,就用
loop.run_in_executor把阻塞函数丢进线程池,事件循环继续调度别的协程:
import asyncio, concurrent.futures as cf
import requests
def get(url):
return requests.get(url, timeout=5).status_code
async def main(urls):
loop = asyncio.get_running_loop()
with cf.ThreadPoolExecutor(max_workers=10) as pool:
results = await asyncio.gather(
*(loop.run_in_executor(pool, get, u) for u in urls)
)
return results
这也是"asyncio + 线程池"混合模型最常见的用法:事件循环负责海量连接的调度,少量阻塞调用外包给线程池。
四、一张表做选型
| 任务特征 | 推荐方案 | 关键原因 |
|---|---|---|
| 大量网络/文件 IO,且用的是异步库(aiohttp、异步驱动) | asyncio |
单线程高并发,连接数多时开销最低 |
| IO 密集,但用的是同步阻塞库(requests、传统驱动) | ThreadPoolExecutor |
等待 IO 时释放 GIL,改造量最小 |
| CPU 密集(计算、压缩、图像处理、数据加工) | ProcessPoolExecutor |
绕开 GIL,真正利用多核 |
| 既要海量 IO 并发,又有少量同步阻塞调用 | asyncio + run_in_executor |
事件循环调度,阻塞活外包给线程池 |
| 需要严格隔离、绕过崩溃影响 | 多进程 | 进程内存独立,互不拖垮 |
补充两个工程默认值:Python 3.8+ 里 ThreadPoolExecutor 的 max_workers 默认是 min(32, os.cpu_count() + 4),ProcessPoolExecutor 默认是 os.cpu_count()。线程/进程不是越多越好,还要考虑对方服务的限流、连接池大小和本机内存。
五、结论
我的判断顺序固定成三步:
- 先量再选:用计时或 cProfile 确认瓶颈是 CPU 还是 IO,别靠猜。
- IO 看库:全链路异步就上
asyncio;还在用同步阻塞库就先用线程池,改造最省事。 - CPU 上进程:纯计算要吃多核就用进程池,并记得
__main__保护和参数可 pickle。
并发不是银弹。很多时候脚本慢,根因是外部接口、数据库索引或串行的重复请求,而不是"并发开得不够多"。先定位瓶颈,再按上面的边界选模型,比一上来就堆线程、堆协程靠谱得多。
浙公网安备 33010602011771号