Python 并发模型选型:GIL、多线程、asyncio 与多进程到底怎么选

同事用 8 个线程跑纯 CPU 的图像处理,结果比单线程还慢——这是 Python 并发里最经典的困惑。从 GIL 这把全局锁讲起,把多线程、asyncio、多进程三套模型的脾性、坑和适用边界逐一摊开:IO 密集用线程或协程,CPU 密集只能靠多进程,asyncio 全程异步否则卡死事件循环,有 GIL 也照样要加锁。最后收口成一棵先问任务在等还是在算的选型决策树。

有天一个同事拿着代码来找我,一脸困惑:他写了个批量图像处理的脚本,纯 CPU 计算,想用多线程加速,开了 8 个线程,结果跑出来比单线程还慢了一点。"不是说多线程能并行吗?我这 8 核机器,怎么开了 8 个线程一点没快,CPU 还是只跑满一个核?"

这个问题我被问过不下十次,几乎是 Python 并发里最经典的困惑。答案一句话能说完——CPython 有 GIL,多线程跑不了 CPU 密集任务的并行;但要真正用对 Python 的并发,得把这背后的来龙去脉理清楚:Python 其实有三套并发模型(多线程、asyncio、多进程),它们各自适合什么、各自的坑在哪,选错一个不仅白忙活,还可能像我同事那样越优化越慢。这篇就把这三套模型摊开讲清楚:它们分别解决什么问题、底层差在哪、到底什么场景该选哪个——核心就一个判断:你的任务到底是 IO 密集还是 CPU 密集。

先搞懂那个绕不开的 GIL

要理解为什么我同事的 8 个线程不加速,绕不开 GIL(Global Interpreter Lock,全局解释器锁)。GIL 是 CPython 解释器里的一把全局锁,它保证任意时刻只有一个线程在执行 Python 字节码。也就是说,哪怕你开了 8 个线程、机器有 8 个核,同一时刻真正在跑 Python 代码的也只有一个线程,其余的都在排队等这把锁。这就是为什么纯 CPU 计算用多线程不但不快,反而因为线程切换和抢锁的开销变得更慢。

但这里有个关键转折,也是很多人没绕明白的地方:当一个线程去做 IO(读文件、发网络请求、查数据库)时,它会主动释放 GIL,让别的线程趁机执行。所以对 IO 密集任务,多线程是有效的——大家轮流在"等 IO"的间隙里干活,GIL 在 IO 等待期间根本不是瓶颈。一句话概括 GIL 的影响:它卡死了 CPU 密集任务的多线程并行,却基本不影响 IO 密集任务的多线程并发。这条结论,直接决定了三套并发模型的分工:

模型 并行能力 适合的任务 受 GIL 影响 典型开销
多线程 threading 并发(非真并行) IO 密集 是,但 IO 时释放 线程切换、抢锁
asyncio 协程 并发(单线程) IO 密集、海量连接 无 GIL 争抢 极低,但需全异步
多进程 multiprocessing 真并行 CPU 密集 不受(各进程独立) 进程创建、数据序列化

看这张表就明白我同事错在哪了:CPU 密集任务该用多进程(每个进程有独立的解释器和 GIL,能真正吃满多核),他却用了被 GIL 锁死的多线程。下面这棵决策树,是我贴给团队的"并发模型怎么选"的判断流程:

IO 密集、并发量不大:多线程是最省事的选择

先看最常见的场景:要并发地发一批 HTTP 请求、读一堆文件,数量在几十到几百这个量级。这类 IO 密集任务,多线程是改造成本最低的方案——因为线程在等 IO 时会释放 GIL,多个请求的等待时间被重叠起来,总耗时大幅下降。别自己手搓线程,用标准库的 ThreadPoolExecutor 就够优雅:

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

urls = [f"https://api.example.com/items/{i}" for i in range(100)]

# ❌ 串行:100 个请求挨个发,每个等 200ms,总耗时 ≈ 20 秒
def fetch_all_serial():
    return [requests.get(u).json() for u in urls]

# ✅ 多线程:IO 等待重叠起来,总耗时 ≈ 最慢的那一批,降到 1~2 秒
def fetch_all_threaded(max_workers=16):
    results = {}
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        future_to_url = {pool.submit(requests.get, u): u for u in urls}
        for fut in as_completed(future_to_url):
            u = future_to_url[fut]
            try:
                results[u] = fut.result().json()
            except Exception as e:
                results[u] = {"error": str(e)}   # 单个失败不拖垮整体
    return results

这里有两个实战要点。一是 max_workers 不是越大越好:线程太多,线程切换和内存开销反而拖后腿,IO 密集场景一般几十个就够,具体值要压测。二是一定要逐个接住每个任务的异常——用 fut.result() 包 try/except,让单个请求失败不会让整批崩掉。多线程改造的最大好处是几乎不用改原有的同步代码:你的 requests.get 还是那个 requests.get,只是丢进线程池里跑,这也是它在中小并发场景最受欢迎的原因。

IO 密集、并发量巨大:asyncio 用单线程扛住上万连接

当并发量从几百涨到成千上万(比如一个要同时维持上万个长连接的服务,或要并发抓几万个 URL),多线程就开始吃力了:每个线程都要占一块栈内存,上万个线程光内存就压垮机器,线程切换的开销也变得可观。这时该请出 asyncio——它用单个线程 + 事件循环,靠协程在一个线程内"协作式"地切换,没有线程切换开销、没有 GIL 争抢,能用极小的资源扛住极高的并发:

import asyncio, aiohttp

urls = [f"https://api.example.com/items/{i}" for i in range(10000)]

async def fetch(session, url, sem):
    async with sem:                          # ✅ 信号量限并发,别一次性发一万个打爆对端
        async with session.get(url) as resp:
            return await resp.json()

async def fetch_all(concurrency=200):
    sem = asyncio.Semaphore(concurrency)
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, u, sem) for u in urls]
        # return_exceptions=True:单个失败不中断整体,异常作为结果返回
        return await asyncio.gather(*tasks, return_exceptions=True)

results = asyncio.run(fetch_all())

asyncio 的威力在于:上面这段代码用一个线程就能并发处理一万个请求,内存占用和线程数都和并发量解耦。但它有个硬门槛——"全程异步":从 aiohttp(而非 requests)到 asyncpg(而非同步的数据库驱动),链路上每一个 IO 操作都必须是异步的。一旦你在协程里调了一个同步阻塞的函数(比如随手用了 requests.gettime.sleep),它会把整个事件循环卡死——因为只有一个线程,这个线程被阻塞,所有协程一起停摆。这就是 asyncio 最大的代价:它要求整条技术栈都换成异步生态,改造成本远高于多线程,所以更适合从一开始就按高并发设计的新服务,而非给老代码打补丁。

CPU 密集:多进程才能真正吃满多核

回到我同事那个图像处理的问题。CPU 密集任务的唯一正解是多进程:每个进程有自己独立的 Python 解释器、独立的 GIL,所以多个进程能真正同时在多个核上跑 Python 代码,实现物理意义上的并行。同样别手搓进程,用 ProcessPoolExecutor:

from concurrent.futures import ProcessPoolExecutor
import os

def process_image(path):          # 纯 CPU 密集:解码、滤镜、压缩
    img = decode(path)
    return compress(apply_filters(img))

# ❌ 同事的写法:CPU 密集用多线程,被 GIL 锁死,8 线程还跑在一个核上
# with ThreadPoolExecutor(8) as pool: ...   # 不但不快,还更慢

# ✅ 多进程:进程各有独立 GIL,真正并行吃满多核
def process_all(paths):
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
        return list(pool.map(process_image, paths))

if __name__ == "__main__":        # ⚠️ 多进程必须放在 main 保护块里,否则子进程会递归重启
    results = process_all(image_paths)

把同事代码里的 ThreadPoolExecutor 换成 ProcessPoolExecutor,8 核瞬间全部跑满,耗时直接降到原来的约八分之一。但多进程有两个绕不开的成本:一是进程创建开销比线程大得多,所以适合"任务粒度较大、值得为它开个进程"的场景,别拿它跑一堆几毫秒就结束的小任务;二是进程间不共享内存,传给子进程的参数和返回值都要经过序列化(pickle),如果在进程间传大对象(几百 MB 的数组),序列化开销可能把并行省下的时间全吃掉。还有那个新手必栽的坑:多进程代码必须放在 if __name__ == "__main__" 保护块里,否则子进程导入模块时会递归地再启动进程,直接炸掉。

进程间共享数据:别让序列化吃掉你的并行收益

承接上面的第二个成本,当 CPU 密集任务确实需要在进程间共享大块数据时,反复 pickle 来 pickle 去会成为新瓶颈。这时要用专门的共享内存机制,让多个进程直接读同一块物理内存,而不是各拷一份:

from multiprocessing import shared_memory
import numpy as np

# ✅ 大数组放进共享内存,各进程直接映射访问,零拷贝、免序列化
def make_shared(arr: np.ndarray):
    shm = shared_memory.SharedMemory(create=True, size=arr.nbytes)
    buf = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
    buf[:] = arr[:]                      # 只拷一次进共享内存
    return shm, buf.shape, buf.dtype

# 子进程里按名字挂载同一块共享内存,不再走 pickle 传整个数组
def worker(shm_name, shape, dtype, lo, hi):
    shm = shared_memory.SharedMemory(name=shm_name)
    data = np.ndarray(shape, dtype=dtype, buffer=shm.buf)
    result = heavy_compute(data[lo:hi])  # 只算自己负责的分片
    shm.close()
    return result

这里的原则是:多进程的性能账,要把"并行省下的计算时间"和"序列化/进程通信的额外开销"放在一起算。任务越重、数据传递越少,多进程越划算;反过来,如果任务很轻却要频繁传大数据,多进程的收益可能被通信成本抵消,甚至不如单进程。共享内存、把数据分片让每个进程只拿自己那块、或者把数据先落地成文件让子进程各自读——都是为了把"进程间搬数据"这件事的成本压到最低。记住:多进程不是免费的并行,它是用通信开销换计算并行,这笔账必须算明白。

混合场景:asyncio 里碰到同步阻塞,用 run_in_executor 救场

真实项目里很少是纯粹的某一种,最常见的难题是:整体是个 asyncio 服务,但中间要调一个只有同步版本的库(比如某个没有异步实现的 SDK),或者要插一段 CPU 密集的计算。前面说过,在协程里直接调同步阻塞会卡死整个事件循环。解法是把这段阻塞代码丢到一个执行器(线程池或进程池)里去跑,用 run_in_executor 把它"异步化"——事件循环把它交给别的线程/进程,自己继续转:

import asyncio
from concurrent.futures import ProcessPoolExecutor

# ❌ 在协程里直接调同步阻塞:整个事件循环被卡死,所有协程一起停摆
async def bad():
    data = legacy_sync_sdk.query()   # 同步阻塞 → 事件循环冻结
    return data

# ✅ 同步阻塞的 IO:丢进默认线程池,事件循环不被卡住
async def good_io():
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, legacy_sync_sdk.query)

# ✅ CPU 密集计算:丢进进程池,既不卡事件循环,又能真正并行
process_pool = ProcessPoolExecutor()
async def good_cpu(data):
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(process_pool, heavy_compute, data)

这其实揭示了三套模型不是互斥的、而是可以组合的:asyncio 负责高并发的 IO 调度,线程池接住"不得不用的同步阻塞 IO",进程池接住"夹在异步流程里的 CPU 密集计算"。run_in_executor 就是把后两者桥接进事件循环的那座桥。判断标准还是那条:阻塞的是 IO,丢线程池;阻塞的是 CPU,丢进程池;绝不让任何阻塞调用在事件循环线程里裸跑。

多线程的隐形杀手:共享状态的竞态条件

最后必须警告一个多线程特有、且极其隐蔽的坑:竞态条件(race condition)。多个线程同时读写同一个共享变量时,看起来人畜无害的操作其实不是原子的,会丢更新。很多人以为"有 GIL 保护,Python 多线程不会有线程安全问题"——这是个致命误解:GIL 只保证单条字节码原子,而 counter += 1 这种操作其实是"读—改—写"三条字节码,完全可能在中间被切换:

import threading

counter = 0

# ❌ 看似简单的自增,在多线程下会丢更新:counter += 1 不是原子操作
def unsafe_incr():
    global counter
    for _ in range(100000):
        counter += 1          # 读 counter → 加 1 → 写回,三步之间可能被切换

# ✅ 用锁把"读—改—写"保护成临界区,保证原子性
lock = threading.Lock()
def safe_incr():
    global counter
    for _ in range(100000):
        with lock:            # 进入临界区,同一时刻只有一个线程能改
            counter += 1

# 起 10 个线程跑 unsafe_incr,结果几乎永远小于预期的 1000000(丢了更新)

记住:"有 GIL" ≠ "线程安全"。GIL 保证的是"不会同时执行两条 Python 字节码",但保证不了"一组操作不被中途打断"。凡是多个线程会读写的共享状态,都要用 Lock(或 queue.Queue 这类本身线程安全的结构)把它保护起来。这也从另一个角度凸显了 asyncio 和多进程的好处:asyncio 是单线程协作式调度,只在你明确 await 的点才切换,共享状态的竞态比多线程少得多;多进程则各有独立内存,根本不共享状态,自然没有这类问题。共享内存带来并行,也带来竞态——这是并发编程永恒的权衡。

顺带一提:GIL 正在松动,但别指望它立刻消失

讲到这里,有人可能会问:既然 GIL 这么碍事,Python 官方就不管吗?其实在管。Python 3.13 已经引入了实验性的"自由线程"(free-threaded,即可选地编译出不带 GIL 的解释器),目标正是让多线程能真正并行跑 CPU 密集任务。这是个意义深远的方向,长远看可能改变上面这套"CPU 密集只能靠多进程"的格局。

但就当下而言,有几点要清醒认识:一是它还是实验性的,生态(大量 C 扩展)的适配远未完成,贸然用于生产为时尚早;二是去掉 GIL 后,单线程性能会有一定回退,且前面讲的竞态条件问题会变得更普遍、更严峻——GIL 这些年其实无意中帮我们挡掉了不少粗糙的线程安全问题。所以我的态度是:关注它、了解它的方向,但现阶段做技术选型,依然老老实实按"IO 密集用线程/asyncio、CPU 密集用多进程"这套成熟规则来。等自由线程真正成熟、生态跟上,再把它纳入考量不迟。

一句话把选型逻辑钉死:先问任务是 CPU 密集还是 IO 密集

讲到这里,三套模型各自的脾性、坑、适用边界都摊开了。但真到写代码那一刻,你需要的不是这些细节,而是一个能在三秒内给出答案的判断流程。把前面所有内容收口成一棵决策树——下次要做并发优化,先在脑子里走一遍它,绝大多数选择当场就定了:

这棵树的根节点只有一个问题——任务到底在"等"还是在"算"。我同事最初的错误,本质就是没分清这一点:图像处理是在"算",却用了只擅长"等"的多线程。把这个根节点判断对,后面的分支几乎是自动展开的。而判断"等还是算"也有个朴素的土办法:盯着任务跑时的 CPU 占用看——单核被打满、其余核闲着,是 CPU 密集被 GIL 锁住的典型信号;CPU 整体不高却跑得慢,那就是卡在 IO 等待上,该上多线程或 asyncio。

沉淀成清单的几条并发选型铁律

这套实践最后收口成了我们团队的并发编程规范,新人写并发代码前先过一遍这几条:

  1. 动手前先判定"等还是算":IO 密集(在等)用多线程或 asyncio,CPU 密集(在算)用多进程。这一步判错,后面全错,再怎么调参也救不回来。
  2. IO 密集、并发不大,首选多线程:用 ThreadPoolExecutor,几乎不用改原有同步代码,max_workers 压测定值而非越大越好,每个任务的异常逐个用 fut.result() 接住。
  3. IO 密集、并发上万,才上 asyncio:单线程事件循环扛海量连接,但要求全程异步,链路上任何一个同步阻塞调用都会冻住整个事件循环。
  4. CPU 密集只有多进程一条路:ProcessPoolExecutor 让每个进程有独立 GIL 真正并行;务必放进 if __name__ == "__main__" 保护块,否则子进程递归重启直接炸。
  5. 多进程要算通信账:进程间不共享内存,参数和返回值都走 pickle 序列化;传大对象就用共享内存、分片或落地文件,别让序列化吃掉并行省下的时间。
  6. asyncio 里绝不让阻塞调用裸跑:阻塞的是 IO 就 run_in_executor 丢线程池,是 CPU 就丢进程池,三套模型本就是可组合的而非互斥的。
  7. 有 GIL ≠ 线程安全:多线程共享的状态一律用 Lockqueue.Queue 保护,counter += 1 这种"读—改—写"在 GIL 下照样会丢更新。

几个反复见到的认知误区

推广这套规范时,我发现有几个误区几乎人人都踩过,值得专门点破。

第一个误区,也是我同事那个最经典的:"多线程就是并行,开 N 个线程就快 N 倍"。这是所有 Python 并发困惑的总源头。在 CPython 里,GIL 决定了多线程对 CPU 密集任务根本没有并行能力——同一时刻只有一个线程在跑 Python 字节码,开再多线程也只是轮流用一个核,还白搭上线程切换和抢锁的开销,所以越"优化"越慢。多线程的并行是个错觉,它真正的价值是"并发"——在 IO 等待的间隙里让别的线程见缝插针地干活。把"并发"和"并行"这两个词分清楚,这个误区就破了一大半。

第二个误区是"有 GIL 兜着,Python 多线程不用加锁"。这是个能让你栽大跟头的误解。GIL 保证的只是"任意时刻不会有两条 Python 字节码同时执行",但它保证不了一组操作的原子性。像 counter += 1 看着是一行,底层却是"读取—加一—写回"三条字节码,完全可能在中间被切走,导致并发自增丢更新。所以只要有多个线程读写同一份共享状态,锁就一个都不能省——GIL 这些年其实无意中替我们挡掉了不少更粗糙的崩溃,但它从来不是线程安全的替代品。

第三个误区是"asyncio 是性能银弹,什么都该用它"。asyncio 在海量 IO 并发下确实惊艳,但它有极高的"准入门槛":要求整条技术栈全异步,改造成本远高于多线程,而且对 CPU 密集任务毫无帮助(单线程,照样跑不满多核)。给一个并发量本就不大的老服务硬套 asyncio,往往是花了几倍的改造力气,换来一个还不如线程池省事的结果。asyncio 适合从一开始就按高并发设计的新服务,不适合给中小并发的老代码打补丁。

第四个误区是"协程/线程/进程开得越多越好"。三套模型都有各自的"过犹不及":线程太多,切换和内存开销反而拖后腿;协程虽轻,但并发数失控同样会打爆对端服务,所以要用信号量(Semaphore)限流;进程则更"重",创建开销大、还要为每个进程付一份内存和序列化成本,数量通常以 CPU 核数为基准,绝不是越多越快。并发度从来不是越大越好,而是有一个由资源和对端共同决定的最优区间,这个值要靠压测找,不能拍脑袋。

写在最后

从我同事那 8 个线程不加速的困惑出发,绕了一大圈,最后其实收束到一个特别朴素的判断上:你的任务到底是在"等"还是在"算"。在等(IO 密集)就用多线程或 asyncio,让等待的时间重叠起来;在算(CPU 密集)就用多进程,让多个核真正同时开工。GIL、事件循环、序列化、竞态这些看起来很硬核的概念,本质都是在为这一个判断提供注脚——它们解释的是"为什么该这么选",而不是要你死记硬背。

所以下次再有人拿着"我开了 N 个线程怎么没快"的代码来找你,别急着调参数、加线程,先问一句:这段代码到底卡在哪儿——是在等一个外部响应,还是在闷头做计算?把这个问题答对了,该用哪套并发模型,答案自己就浮出来了。Python 的并发不难,难的是很多人跳过了这个最该先问的问题,一上来就和工具较劲。理解了"等与算"这条分水岭,你写的就不再是越优化越慢的伪并发,而是真正能把机器的能力榨出来的并发代码。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

AI Agent 工程化实战:工具设计、循环控制、上下文管理与可观测性

2026-5-29 19:00:45

技术教程

JavaScript 内存泄漏排查实战:定时器、闭包、缓存与游离 DOM

2026-5-29 19:13:06

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索