为什么 Python 并发选型让人纠结
很多 Python 开发者第一次尝试并发编程时,大概率会先写个多线程版本。代码跑起来后却发现,加了四个线程处理图片压缩,速度不仅没快,CPU 占用率还只卡在 100% 上不去。这时你才会意识到,那个传说中的 GIL(全局解释器锁)不是理论限制,而是实实在在的性能天花板。
Python 提供了多种并发路径,但每一条路都通向不同的风景,也设下了不同的路障。选错模型,性能差个十倍是常有的事。这篇文章的目的不是复述官方文档,而是帮你建立一套清晰的决策框架,弄清楚在什么场景下应该用线程、进程还是协程,以及它们各自的“坑”埋在哪里。
理解 GIL:一切性能讨论的起点
在讨论任何并发模型前,必须先把 GIL 讲透。GIL 是 CPython 解释器(也就是我们最常用的那个 Python)为了简化内存管理而引入的一把全局互斥锁。它的核心规则很简单:任何时候,只有一个线程可以执行 Python 字节码。
这意味着什么?假设你写了一个计算斐波那契数列的函数,用 4 个线程去跑,在四核 CPU 上你期望看到 400% 的 CPU 占用。但实际情况是,这四个线程会轮流获得 GIL 来执行计算,大部分时间都在“争抢”这把锁,最终 CPU 利用率会被限制在 100% 左右,这就是纯 CPU 密集型任务使用多线程无法提速的根本原因。
很多从 Java 或 Go 转过来的工程师会在这里踩坑,他们习惯性地用线程去处理计算任务,结果发现性能毫无提升。GIL 的存在,直接决定了 Python 并发模型的适用边界。
多线程:I/O 等待时的好帮手
既然 GIL 让多线程在计算上使不上劲,那它的价值在哪里?答案是:I/O 等待。
当一个线程在执行网络请求、读取磁盘文件或等待数据库响应时,它会主动释放 GIL。这时,其他正在等待的线程就可以获得 GIL 去执行自己的代码。对于 I/O 密集型任务,程序大部分时间都在等待外部响应,而不是执行计算,因此 GIL 造成的阻塞影响就小得多。
想象一个爬虫场景,需要顺序请求 100 个网页。单线程下,大部分时间都在等待网络返回。如果用多线程,当一个线程在等网络时,其他线程可以继续发起新的请求,从而显著缩短总耗时。
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch_url(url):
response = requests.get(url)
return len(response.content)
urls = ['http://example.com/page{}'.format(i) for i in range(10)]
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fetch_url, urls))
print(results)
使用多线程的典型场景:
- 批量处理 HTTP API 调用。
- 并发读写大量小文件。
- 调用一些只提供同步客户端的第三方服务或 SDK。
- 处理 GUI 应用中的后台任务,保持界面响应。
它的优势是创建成本低、共享内存方便(但需注意线程安全)、生态成熟。但记住,线程数不是越多越好,一旦超出某个阈值(通常与 I/O 等待时间有关),大量的线程切换开销反而会拖累性能。
多进程:榨干多核 CPU 的利器
要真正突破 GIL 的限制,让 Python 代码在多核 CPU 上并行跑起来,就必须请出多进程。每个 Python 进程都有自己独立的解释器和内存空间,也就拥有自己独立的 GIL。多个进程可以在不同的 CPU 核心上同时执行字节码,实现真正的并行计算。
一个典型的场景是数据预处理:你需要对一万张图片进行滤镜处理。这是一个纯计算任务,几乎没有 I/O 等待。使用多进程,你可以把任务分发给多个子进程,每个进程跑满一个 CPU 核心,总处理时间可以接近线性缩短。
from multiprocessing import Pool, cpu_count
import cv2 # 假设使用 OpenCV 处理图片
def process_image(image_path):
img = cv2.imread(image_path)
# ... 一些耗时的图像处理计算 ...
return processed_img
if __name__ == '__main__':
image_paths = [...] # 图片路径列表
with Pool(processes=cpu_count()) as pool:
results = pool.map(process_image, image_paths)
然而,多进程的代价也很明显:
- 启动慢,内存开销大: 每个进程都是一份完整的 Python 解释器副本。
- 进程间通信(IPC)复杂: 内存不共享,数据交换需要通过队列(Queue)、管道(Pipe)或共享内存(Manager)等方式,这些操作涉及序列化和反序列化,成本很高。
- 调试更麻烦: 子进程崩溃可能不会直接导致主进程退出。
所以,多进程是解决 CPU 密集型问题的标准答案,但引入它就意味着接受更高的复杂度和资源成本。
协程(asyncio):高并发 I/O 的终极形态
如果说多线程是“见缝插针”式地利用 I/O 等待时间,那么协程(以 asyncio 为代表)就是“精准调度”的协奏曲。协程是用户态线程,由事件循环(Event Loop)在单线程内进行协作式调度。当一个协程遇到 I/O 操作时,它会主动挂起(yield),把控制权交还给事件循环,事件循环则去执行其他就绪的协程。
关键的区别在于,协程的切换发生在用户空间,成本极低,不像操作系统线程切换那样需要保存和恢复大量的 CPU 上下文。因此,单线程内支撑上万甚至十万个并发连接成为可能,这非常适合现代高并发的网络服务,比如实时消息推送、API 网关或爬虫调度中心。
很多团队在开发 FastAPI 或 aiohttp 服务时感受到了协程的性能红利。但协程有一个非常强的约束:全链路异步。
你不能在 asyncio 的协程里调用一个阻塞式的 requests.get(),这会阻塞整个事件循环,让其他所有协程都停下来干等。你必须使用异步版本的库,如 aiohttp、asyncpg、aioredis 等。如果你的依赖链中有一个关键的库没有异步版本,协程这条路就可能走不通。
决策框架:一张表看清边界
理论讲完了,落到工程上怎么选?下面这个表格总结了三种模型的核心特征和适用边界。
| 模型 | 核心调度者 | 能否并行执行字节码 | 内存开销 | 通信复杂度 | 最佳适用场景 |
|---|---|---|---|---|---|
| 多线程 (Threading) | 操作系统 | 否 (受 GIL 限制) | 较低 | 低 (共享内存,需加锁) | I/O 密集型,任务量适中,存在阻塞式同步库依赖。 |
| 多进程 (Multiprocessing) | 操作系统 | 是 | 高 (每进程独立内存空间) | 高 (需 IPC,如 Queue/Pipe) | CPU 密集型计算,需要利用多核,计算任务可独立拆分。 |
| 协程 (Asyncio) | 用户代码 (事件循环) | 否 (单线程协作) | 极低 | 中 (通过 asyncio.Queue 等) | 高并发 I/O 密集型,如网络服务、微服务,且全链路有异步库支持。 |
混合使用模式
现实中的系统往往是复杂的,一个常见的混合模式是:“协程处理高并发 I/O,进程池消化 CPU 重活”。
例如,一个实时数据处理服务,使用 asyncio 协程来处理海量的客户端 WebSocket 连接(I/O 密集),当收到消息需要做复杂的实时分析(CPU 密集)时,通过 run_in_executor 方法将计算任务丢给一个 ProcessPoolExecutor 去执行,算完后再将结果传回事件循环。这样既享受了协程的高并发优势,又绕过了 GIL 对计算的限制。
实战建议与常见误区
1. 先 profiling,再并发: 不要一上来就考虑并发。先用性能分析工具(如 cProfile)找到瓶颈。如果瓶颈是单次数据库查询慢,加线程也没用。
2. 理解任务的本质: 明确你的任务是 CPU-bound 还是 I/O-bound。一个简单的判断方法是:如果任务运行时 CPU 占用率持续很高,就是 CPU 密集型;如果 CPU 经常在 idle 等待,就是 I/O 密集型。
3. 注意资源限制: 线程和进程都不是无限创建的。线程数受限于内存和上下文切换开销,进程数受限于 CPU 核心数和内存大小。协程虽然轻量,但也受限于操作系统文件描述符数量。
4. 避免的误区:
- 误区一: “用多线程总比单线程快。” —— 在 CPU 密集型任务上,多线程可能更慢。
- 误区二: “多进程是万能的。” —— 进程间通信成本高,不适合频繁交换大量数据的场景。
- 误区三: “协程是新一代线程,可以全面替代。” —— 协程要求异步生态,改造现有同步代码成本巨大。
总结
Python 的并发编程没有银弹。多线程、多进程、协程是三把不同的钥匙,用来打开不同的锁。
- 当你需要快速并发一些 I/O 操作,且不想大动干戈时,多线程 是最直接的选择。
- 当你的程序性能瓶颈在于纯计算,需要榨干多核 CPU 时,多进程 是必由之路。
- 当你构建高并发网络服务,追求极致的资源利用率和吞吐量,并且有能力构建异步技术栈时,协程 会带来质的飞跃。
最关键的,是跳出对某一种模型的偏爱,根据任务的实际属性和系统的约束条件,做出清醒、务实的技术选型。这张并发全景图,希望能帮你更清晰地看清每一条路的起点与终点。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/158/