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密集型任务,可能还不如单线程快——因为线程之间在抢锁,切换开销反而更大。

怎么规避?我总结了三条路:

  1. 用多进程代替多线程:CPU密集型任务,直接上multiprocessing。每个进程独立GIL,真正并行。
  2. 用异步IO代替多线程:IO密集型任务,用asyncio。单线程无锁,性能更高。
  3. 用C扩展释放GIL:比如numpypandasctypes等C扩展,在执行计算时会主动释放GIL。这样多线程也能并行计算。

我的习惯:写自动化脚本时,我会先判断瓶颈是CPU还是IO。如果是IO,优先用asyncio;如果代码改造成本高,就用多线程。如果是CPU,直接上多进程。如果既有CPU又有IO(比如下载文件后解压),我会用多进程+异步IO的组合——进程内用asyncio处理IO,进程间用队列通信。

3.6 实战选择建议

最后,给你一个简单的决策流程:

  • 任务主要是等待(网络、磁盘、数据库)? → 用asyncio(首选)或threading
  • 任务主要是计算(循环、数学、图像处理)? → 用multiprocessing
  • 任务混合型? → 用concurrent.futuresThreadPoolExecutorProcessPoolExecutor,统一接口,方便切换
  • 需要处理成千上万个连接? → 必须用asyncio,多线程扛不住

我曾经接手过一个监控脚本,用多线程轮询1000台服务器,每5秒一次。结果线程数一多,CPU就飙高,轮询延迟越来越大。后来改成asyncio,单线程搞定,CPU占用从80%降到15%。说白了,选对工具,事半功倍。

嗯,关于并发与并行,今天就聊到这儿。下一章我们聊聊内存管理与垃圾回收,这也是自动化脚本容易踩坑的地方。