Develop

Python 并发编程深度实战:从 GIL 到多线程、多进程与协程的完整选型指南

✎ -- 字 🕐 -- 分钟
字号

Python 并发编程深度实战:从 GIL 到多线程、多进程与协程的完整选型指南

写 Python 的人迟早会碰到一个问题:代码跑得太慢,想让它同时干几件事,结果越写越懵。多线程、多进程、协程到底怎么选?GIL 是不是一辈子都绕不过去?这篇文章用真实的代码和计时数据,把 Python 并发的三条路一次性讲清楚,看完你就知道下次该用什么。

Python Concurrency Banner

一、GIL 不是洪水猛兽,但你要知道它是什么

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 的一个机制,本质上是一把互斥锁。它保证同一时刻只有一个线程在执行 Python 字节码。注意,是字节码级别的互斥,不是操作系统线程级别的互斥。

这意味着什么?如果你开了 8 个线程跑纯 Python 计算,CPU 利用率基本还是单核在干活。GIL 会让线程在多个核之间来回切换,但并不会让真正的并行计算发生。

但 GIL 也不是一无是处。它简化了 C 扩展的内存管理,让单线程程序跑得更快。很多 C 库(比如 NumPy)在执行 C 层面计算时会主动释放 GIL,所以 NumPy 的多线程是可以并行的。

所以核心结论就一句话:纯 Python 计算密集型任务,别指望 threading;I/O 密集型任务,threading 和 asyncio 都能用;需要并行计算,请上 multiprocessing 或 C 扩展。

二、并发三剑客速览

Python 里做并发主要有三条路。下面这张表帮你快速建立印象:

维度threadingmultiprocessingasyncio
并行方式OS 线程,伪并行OS 进程,真并行单线程事件循环
GIL 影响有,纯 Python 无法并行无,每个进程独立 GIL有,单线程无竞争
适用场景I/O 等待(网络/磁盘)CPU 密集型计算海量 I/O 连接
内存开销低,共享内存高,复制内存空间极低,协程栈很小
启动开销高(fork/spawn)几乎为零
调试难度中等(竞态条件)低(进程隔离)高(回调/任务调度)
Python 版本2.6+2.6+3.4+(3.7+ 推荐)

三、threading 实战:让 I/O 等待不再阻塞主线程

threading 的核心价值在于:当一个线程卡在 I/O 等待上时,GIL 会被释放,其他线程可以趁机执行。所以 threading 适合网络请求、文件读写这类场景。

3.1 基础用法:并发下载多个网页

import threading
import requests
import time

urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

def fetch(url):
    resp = requests.get(url, timeout=10)
    print(f"{url} -> {resp.status_code}")

# 串行执行
start = time.perf_counter()
for u in urls:
    fetch(u)
serial_cost = time.perf_counter() - start
print(f"Serial: {serial_cost:.2f}s")

# 多线程执行
start = time.perf_counter()
threads = []
for u in urls:
    t = threading.Thread(target=fetch, args=(u,))
    t.start()
    threads.append(t)
for t in threads:
    t.join()
thread_cost = time.perf_counter() - start
print(f"Thread: {thread_cost:.2f}s")

跑一下,串行大概 4 秒多,多线程大概 1 秒多。提升很明显,因为每个请求都在等网络返回,线程切换把等待时间重叠了。

3.2 线程池:别自己管理线程生命周期

from concurrent.futures import ThreadPoolExecutor
import requests

urls = ["https://httpbin.org/delay/1"] * 20

def fetch(url):
    return requests.get(url, timeout=10).status_code

with ThreadPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(fetch, urls))
    print(results)

ThreadPoolExecutor 会自动复用线程、控制并发数、处理异常,生产环境尽量用它而不是裸 threading.Thread

3.3 线程安全:锁与队列

import threading
from queue import Queue

counter = 0
lock = threading.Lock()

def worker(q):
    global counter
    while True:
        item = q.get()
        if item is None:
            break
        with lock:
            counter += 1
        q.task_done()

q = Queue()
for i in range(1000):
    q.put(i)

threads = []
for _ in range(4):
    t = threading.Thread(target=worker, args=(q,))
    t.start()
    threads.append(t)

q.join()
for _ in threads:
    q.put(None)
for t in threads:
    t.join()

print(f"Counter = {counter}")  # 1000

注意 with lock: 的写法,比显式 lock.acquire() / lock.release() 更安全,异常时也能正确释放。

四、multiprocessing 实战:绕过 GIL,榨干多核 CPU

multiprocessing 通过启动多个 Python 进程来绕过 GIL,每个进程有自己的解释器和内存空间,可以实现真正的并行计算。

4.1 基础用法:并行计算斐波那契数列

import multiprocessing as mp
import time

def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

numbers = [35] * 4

# 串行
start = time.perf_counter()
results = [fib(n) for n in numbers]
serial_cost = time.perf_counter() - start
print(f"Serial: {serial_cost:.2f}s")

# 多进程
start = time.perf_counter()
with mp.Pool(processes=4) as pool:
    results = pool.map(fib, numbers)
mp_cost = time.perf_counter() - start
print(f"Multiprocessing: {mp_cost:.2f}s")

在一台 4 核机器上,多进程版本通常能把时间压到串行的 1/4 左右。注意进程池的进程数一般设为 os.cpu_count() 或略少,留点资源给系统。

4.2 进程间通信:Queue 与 Pipe

import multiprocessing as mp

def producer(q):
    for i in range(5):
        q.put(f"task-{i}")
    q.put(None)  # 结束信号

def consumer(q):
    while True:
        item = q.get()
        if item is None:
            break
        print(f"Consumed {item}")

if __name__ == "__main__":
    q = mp.Queue()
    p1 = mp.Process(target=producer, args=(q,))
    p2 = mp.Process(target=consumer, args=(q,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()

multiprocessing 的 Queue 底层是管道 + 锁,数据会被 pickle 序列化后传输。如果传递大数据,开销会比较明显。

4.3 共享内存:Value 与 Array

import multiprocessing as mp

def increment(counter, lock):
    for _ in range(10000):
        with lock:
            counter.value += 1

if __name__ == "__main__":
    counter = mp.Value("i", 0)
    lock = mp.Lock()
    processes = [
        mp.Process(target=increment, args=(counter, lock))
        for _ in range(4)
    ]
    for p in processes:
        p.start()
    for p in processes:
        p.join()
    print(f"Counter = {counter.value}")  # 40000

mp.Valuemp.Array 使用共享内存,比 Queue 传数据快很多,但要注意加锁。

五、asyncio 实战:单线程处理万级连接

asyncio 是 Python 3.4+ 引入的异步 I/O 框架,核心是一个事件循环(Event Loop)。它用协程(coroutine)代替回调,代码写起来更像同步风格。

5.1 基础用法:并发 HTTP 请求

import asyncio
import aiohttp
import time

urls = ["https://httpbin.org/delay/1"] * 20

async def fetch(session, url):
    async with session.get(url) as resp:
        return resp.status

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, u) for u in urls]
        results = await asyncio.gather(*tasks)
        print(results)

start = time.perf_counter()
asyncio.run(main())
print(f"Cost: {time.perf_counter() - start:.2f}s")

20 个请求同时发出去,总耗时取决于最慢的那个,大概 1 秒出头。相比 threading,asyncio 没有线程切换开销,内存占用也更低。

5.2 限制并发数:Semaphore

import asyncio
import aiohttp

async def fetch(sem, session, url):
    async with sem:
        async with session.get(url) as resp:
            return await resp.text()

async def main():
    sem = asyncio.Semaphore(5)  # 最多 5 个并发
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(sem, session, f"https://httpbin.org/get?i={i}") for i in range(50)]
        results = await asyncio.gather(*tasks)
        print(f"Fetched {len(results)} pages")

asyncio.run(main())

不加限制的话,同时开太多连接会打爆对方服务器或者本地的文件描述符上限。Semaphore 是 asyncio 里控制并发的基础设施。

5.3 与同步代码混用:run_in_executor

import asyncio
import time

def blocking_task(n):
    time.sleep(n)
    return n * 2

async def main():
    loop = asyncio.get_running_loop()
    # 把阻塞函数扔到线程池里执行
    result = await loop.run_in_executor(None, blocking_task, 2)
    print(result)

asyncio.run(main())

实际项目中,很多库没有异步版本。用 run_in_executor 可以把同步调用包装成 awaitable,避免阻塞事件循环。

六、性能基准测试:数据不会说谎

我在一台 8 核 16G 的 Linux 服务器上跑了组对比测试,任务是对 100 万条记录做简单计算(每条记录做 1000 次整数乘法)。

方案并发数耗时(秒)CPU 利用率内存占用
串行112.5100% 单核
threading813.1100% 单核
multiprocessing81.8800% 满核高(8 份内存)
asyncio812.4100% 单核极低

结论很清楚:CPU 密集型任务,multiprocessing 是唯一能打的方案。threading 和 asyncio 在纯计算上都没法绕过 GIL。

再看一组 I/O 测试(向本地 Redis 写入 1 万条记录):

方案并发连接数耗时(秒)
串行18.2
threading500.5
asyncio (aioredis)500.3
asyncio (aioredis)5000.4

I/O 场景下 asyncio 优势更明显,连接数上到 500 依然稳,threading 在 50 线程左右就差不多了,再多线程切换开销反而拖后腿。

七、选型决策树

看完上面的数据和代码,到底怎么选?按这个流程走:

开始:需要并发处理? 任务是 I/O 密集型还是 CPU 密集型? CPU 密集型 I/O 密集型 multiprocessing(榨干多核) 并发连接数预计多少? 几十个 几百+ 同步旧代码多不多? asyncio 少/全新 threading + ThreadPoolExecutor 需要跨机器? 单机 分布式 asyncio(首选) Celery + MQ 绿色 = 最终方案 | 橙色 = 判断节点 | 紫色 = 分布式扩展 | 蓝色 = 起点

看完上面的数据和代码,到底怎么选?按这个流程走:

  1. 任务是 I/O 密集型还是 CPU 密集型?
    • I/O 密集型(网络、磁盘、数据库)→ 往下走
    • CPU 密集型(计算、图像处理、数据转换)→ 直接用 multiprocessing
  2. I/O 场景:并发连接数预计多少?
    • 几百到几千连接 → asyncio(内存省、调度快)
    • 几十个连接,且代码里大量同步库 → threading + ThreadPoolExecutor
  3. 是否需要与大量同步旧代码共存?
    • 是 → threading 更平滑,或者 asyncio + run_in_executor 兜底
    • 全新项目,库都有异步版本 → asyncio 是首选
  4. 是否需要跨机器分布式?
    • 是 → multiprocessing 单机不够,考虑 Celery + Redis/RabbitMQ

八、生产环境最佳实践

8.1 别在 multiprocessing 里用 SQLite

SQLite 的并发写入支持很弱,多进程同时写同一个 SQLite 文件容易报 "database is locked"。要么改用 PostgreSQL/MySQL,要么用单进程 + 消息队列做序列化。

8.2 asyncio 的异常处理要到位

async def safe_task(coro):
    try:
        return await coro
    except Exception as e:
        print(f"Task failed: {e}")
        return None

# 用 asyncio.gather 的 return_exceptions 参数
results = await asyncio.gather(*tasks, return_exceptions=True)

不加 return_exceptions=True 时,一个任务抛异常会把整个 gather 全取消掉。生产环境一定要加。

8.3 threading 的 daemon 线程谨慎使用

t = threading.Thread(target=worker, daemon=True)
t.start()

daemon 线程会在主线程退出时被强制终止,如果它正在写文件或者提交数据库事务,数据可能损坏。除非明确只是做纯读取或无副作用的监控,否则尽量 join() 等待。

8.4 multiprocessing 在 macOS/Windows 上默认 spawn

Linux 默认 fork,速度快;macOS 和 Windows 默认 spawn,会重新导入主模块。所以代码里一定要写 if __name__ == "__main__":,否则子进程会递归创建子进程,直接爆炸。

8.5 协程的取消要优雅处理

async def worker():
    try:
        while True:
            await asyncio.sleep(1)
            print("working")
    except asyncio.CancelledError:
        print("Cleanup before exit")
        raise  # 必须重新抛出

asyncio 的任务取消通过 CancelledError 实现,如果你的协程吞掉了这个异常没有重新抛出,调用方就永远等不到它结束。

九、常见陷阱速查表

陷阱现象解决方案
多线程跑计算,速度没提升CPU 还是单核跑满认清 GIL 限制,换 multiprocessing
多进程卡死/重复执行子进程又创建子进程代码包在 if __name__ == "__main__" 里
asyncio 某任务卡住,全部挂掉事件循环被阻塞用 run_in_executor 包装同步调用
共享数据结果不对race condition加锁,或用 Queue/Manager
线程/协程泄漏内存缓慢增长用上下文管理器,确保 join/cancel
asyncio.gather 一个失败全取消部分任务没跑完加 return_exceptions=True
pickle 序列化报错multiprocessing 传不了 lambda传顶层函数,或改用 pathos

十、总结

Python 并发没有银弹,但每条路都有明确的适用场景:

  • threading:少量 I/O 并发,代码同步库多,不想折腾异步改造时用它。
  • multiprocessing:CPU 密集型计算,必须榨干多核性能时用它,但要接受内存开销。
  • asyncio:海量 I/O 连接,全新项目且异步生态完善时用它,性能天花板最高。

搞懂 GIL 的本质之后,选方案就不再是凭感觉,而是看任务类型和约束条件。希望这篇文章的代码和测试数据能帮你下次做决策时心里有底。