3. 并发与并行:多线程、多进程、异步IO(asyncio)的适用场景与性能对比、GIL锁的影响与规避
聊到自动化脚本的性能优化,并发与并行是绕不开的话题。很多同学一上来就开多线程,觉得“线程多=跑得快”。其实不然。我见过太多项目,线程开了一堆,结果比单线程还慢。为什么?因为没搞清楚并发和并行的区别,也没搞懂Python的GIL锁到底在干什么。
说白了,并发是“看起来同时做”,并行是“真正同时做”。Python的多线程,因为GIL的存在,很多时候只是并发,不是并行。而多进程和异步IO,才是真正能压榨CPU或IO资源的手段。
3.1 多线程:IO密集型任务的“老朋友”
多线程适合什么场景?IO密集型任务。比如网络请求、文件读写、数据库查询。这些任务大部分时间在等待,CPU几乎不干活。这时候多线程可以“趁等待的空档切到另一个线程”,提高整体吞吐量。
但要注意,Python的GIL(全局解释器锁)让同一时刻只有一个线程能执行Python字节码。所以,如果你的任务是CPU密集型的(比如大量数学计算),多线程反而会因为锁竞争和上下文切换,拖慢速度。
核心结论:多线程适合IO密集型,不适合CPU密集型。
举个例子,我写过一个爬虫脚本,要抓取1000个网页。用单线程跑,一个接一个请求,等了快10分钟。改成多线程(比如20个线程),3分钟就搞定了。因为大部分时间花在等待服务器响应上,线程切换几乎无感知。
import threading
import requests
urls = ["https://example.com"] * 100
def fetch(url):
resp = requests.get(url)
print(f"Fetched {url}, status: {resp.status_code}")
threads = []
for url in urls:
t = threading.Thread(target=fetch, args=(url,))
threads.append(t)
t.start()
for t in threads:
t.join()
我的经验:线程数不是越多越好。一般建议线程数 = IO等待时间 / CPU处理时间 × CPU核心数。但实际项目中,我习惯先设成CPU核心数的2-4倍,再压测调整。我曾经试过开200个线程抓数据,结果把目标服务器打挂了……嗯,悠着点。
3.2 多进程:绕过GIL,真正并行
如果你要做CPU密集型任务,比如图像处理、视频编码、大量数值计算,那么多进程才是正解。每个进程有独立的Python解释器和内存空间,GIL互不干扰,可以真正利用多核CPU。
我参与过一个日志分析项目,每天要处理几十GB的日志,做正则匹配和统计。单进程跑要4小时。改成多进程(8个进程),40分钟搞定。说白了,就是把任务拆成小块,每个CPU核心跑一块。
import multiprocessing
def cpu_heavy_task(n):
# 模拟CPU密集计算
result = sum(i * i for i in range(n))
return result
if __name__ == "__main__":
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(cpu_heavy_task, [10**7] * 8)
print(results)
避坑指南:多进程的缺点也很明显——内存开销大。每个进程都会复制一份Python环境。我曾经在内存只有4GB的服务器上开了16个进程,结果直接OOM(内存溢出)。所以,进程数一般不超过CPU核心数,或者根据内存量来算。
3.3 异步IO(asyncio):轻量级并发利器
异步IO是Python 3.4引入的,用async/await语法。它适合高IO、低CPU的场景,比如Web服务器、API调用、消息队列消费。相比多线程,异步IO没有线程切换的开销,也没有锁竞争,单线程就能处理成千上万个连接。
我记得有一次优化一个WebSocket推送服务,原来用多线程,每个连接一个线程,结果连接数一上2000,CPU就飙到100%。改成asyncio后,单线程处理5000个连接,CPU才30%。
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, f"https://example.com/page/{i}") for i in range(100)]
results = await asyncio.gather(*tasks)
print(f"Fetched {len(results)} pages")
asyncio.run(main())
关键点:异步IO要求所有IO操作都是非阻塞的。如果你在异步代码里用了time.sleep()或同步的requests.get(),整个事件循环都会被卡住。一定要用asyncio.sleep()和aiohttp这类异步库。
3.4 性能对比:一张表说清楚
| 特性 | 多线程 | 多进程 | 异步IO |
|---|---|---|---|
| 适用场景 | IO密集型 | CPU密集型 | 高IO、低CPU |
| GIL影响 | 严重(无法并行) | 无(独立解释器) | 无(单线程) |
| 内存开销 | 低(共享内存) | 高(独立内存) | 极低(单线程) |
| 上下文切换 | 操作系统级,开销大 | 操作系统级,开销大 | 用户态,开销极小 |
| 代码复杂度 | 中等(需处理锁) | 中等(需处理IPC) | 较高(需理解事件循环) |
| 最大连接数 | 几百到几千 | 几十到几百 | 几万到几十万 |
3.5 GIL锁的影响与规避策略
GIL是Python的“原罪”之一。它保证了解释器的线程安全,但也限制了多线程的并行能力。你想想看,一个4核CPU,用多线程跑CPU密集型任务,可能还不如单线程快——因为线程之间在抢锁,切换开销反而更大。
怎么规避?我总结了三条路:
- 用多进程代替多线程:CPU密集型任务,直接上
multiprocessing。每个进程独立GIL,真正并行。 - 用异步IO代替多线程:IO密集型任务,用
asyncio。单线程无锁,性能更高。 - 用C扩展释放GIL:比如
numpy、pandas、ctypes等C扩展,在执行计算时会主动释放GIL。这样多线程也能并行计算。
我的习惯:写自动化脚本时,我会先判断瓶颈是CPU还是IO。如果是IO,优先用asyncio;如果代码改造成本高,就用多线程。如果是CPU,直接上多进程。如果既有CPU又有IO(比如下载文件后解压),我会用多进程+异步IO的组合——进程内用asyncio处理IO,进程间用队列通信。
3.6 实战选择建议
最后,给你一个简单的决策流程:
- 任务主要是等待(网络、磁盘、数据库)? → 用
asyncio(首选)或threading - 任务主要是计算(循环、数学、图像处理)? → 用
multiprocessing - 任务混合型? → 用
concurrent.futures的ThreadPoolExecutor或ProcessPoolExecutor,统一接口,方便切换 - 需要处理成千上万个连接? → 必须用
asyncio,多线程扛不住
我曾经接手过一个监控脚本,用多线程轮询1000台服务器,每5秒一次。结果线程数一多,CPU就飙高,轮询延迟越来越大。后来改成asyncio,单线程搞定,CPU占用从80%降到15%。说白了,选对工具,事半功倍。
嗯,关于并发与并行,今天就聊到这儿。下一章我们聊聊内存管理与垃圾回收,这也是自动化脚本容易踩坑的地方。