“Python 多线程到底行不行?”每次讨论这个问题,评论区都会为 GIL 吵起来。哪怕你只写了半年 Python,也一定听过那句口头禅:“Python 多线程是假的,因为有个全局解释器锁(GIL)。”这句话不能说错,但它太粗糙了。实际工作中,我用多线程处理过批量下载、日志采集、Web接口并发请求,一分钟能跑完串行半小时的量;我也用多线程跑过纯数值计算,八核机器上反而比单线程慢。真正有价值的结论不是“行或者不行”,而是弄清楚 GIL 到底卡在哪一类任务、多线程和多进程各自适合什么场景、以及你怎么把两者放进同一套代码里各司其职。这篇文章就从 GIL 的底层机制说起,把多线程、多进程的选型逻辑、实测表现和常见坑一次性讲透。
1. 先搞清楚你要解决什么问题
1.1 并发与并行:一字之差,结果完全不同
很多新手把“并发”和“并行”当成一个东西,实际上这是两件完全不同的事。并发(concurrency)指的是程序能同时应对多个任务,比如你在等一个网络请求返回的时候,顺手去处理另一个请求;并行(parallelism)指的是程序真的在同一时刻用多个 CPU 核心执行多个任务。
举个生活里的例子。你在厨房做饭:一边烧水一边切菜,一个人来回切换、交替推进,这是并发。叫来两个朋友,一个炒菜一个煲汤,两个人同时干活,这是并行。Python 的多线程受 GIL 限制,擅长的是“来回切换、交替推进”这种并发;多进程因为有独立的解释器实例,才真正做到了并行。先把这两个概念分开,后面很多问题就自然清楚了。
1.2 任务类型的判断:IO密集还是CPU密集
选多线程还是多进程,第一件事不是查文档,而是判断你的任务是 IO 密集还是 CPU 密集。IO 密集任务的特点是大量时间花在“等待”上:等待网络响应、等待硬盘读写、等待数据库返回结果。这类任务把 CPU 闲置了,所以多线程的价值是把等待时间重叠起来。
CPU 密集任务则相反,每一步都需要 CPU 实实在在算完:数据清洗、加密解密、图像处理、矩阵运算。这类任务真正考验的是 CPU 计算能力。判断方法也很简单:任务逻辑里大部分是函数调用、算术运算,就偏 CPU 密集;只要出现请求、读文件、sleep、查数据库,就要进一步分析耗时占比。我见过不少人把数据库查询封装成函数,以为它是 IO 任务,实际跑起来才发现 80% 的时间都耗在结果集的循环计算上,那其实是 CPU 密集。
1.3 GIL 并不是多线程唯一的敌人,但它是最大的变量
就算没有 GIL,Python 多线程也逃不开线程切换的开销、线程安全带来的锁竞争、以及多线程本身不容易调试这些问题。GIL 是压在所有 Python 开发者头顶的一个特殊变量:它决定了一个进程里的多个线程,同一时刻只能有一个线程在解释器里执行 Python 字节码。
网络热词里出现“python中的多线程”“python多进程”搜索量很高,说明这是很多人实际开发中的痛点。之所以大家反复纠结,就是因为同样一段代码,放在网络请求场景下多线程提升明显,放在计算场景下多线程毫无收益甚至倒退。接下来我会先拆 GIL 的原理,再用实测数据把两类场景的差异摆清楚。
2. GIL 到底是什么:一个专属于CPython的设计妥协
2.1 引用计数:为什么需要一把全局锁
先明确一点:GIL 不是 Python 语言本身的特性,而是官方 CPython 解释器的实现细节。PyPy、Jython、IronPython 等实现并不都受 GIL 限制,只是绝大多数人日常用的 python.exe 或 Linux 下的 python,都是 CPython。
CPython 的内存管理依赖引用计数。每一个 Python 对象内部有一个名为ob_refcnt的计数器,记录这个对象被多少个变量引用。当某个变量不再指向这个对象时,解释器就把计数减一,计数归零就立刻释放内存。
问题来了:如果两个线程同时操作同一个对象,比如同时给同一个字典赋值,两个线程同时读写ob_refcnt,就可能导致计数错乱——一个对象明明还被引用着,却被提前释放,程序轻则崩溃,重则产生难以复现的内存错误。为了避免这种竞争,CPython 选择了一个最简单粗暴的策略:整个解释器同一时刻只允许一个线程执行 Python 字节码。持有这把全局锁的线程才能操作 Python 对象,其他线程想干活,得先拿到锁。这就是全局解释器锁(GIL)的由来。
2.2 GIL 的切换机制与时间片
GIL 不是无限期占有的。CPython 内部有一个切换间隔(switch interval),默认是 5 毫秒,也就是 0.005 秒。你可以用以下代码看到当前值:
import sys print(sys.getswitchinterval()) # 默认输出 0.005旧版本 Python(3.2 之前)不是按时间,而是按字节码指令数切换,每执行 100 条字节码就让出一次 GIL。后来改成时间片,是为了在不同线程之间更公平地分配执行时间。当你启动多个线程做 CPU 密集计算时,每个线程大概跑 5 毫秒就会遇到一个检查点,被迫让出 GIL,让另一个线程拿锁执行。这个让出和重抢的过程,就是 GIL 竞争的核心开销。
这里要注意,GIL 切换和操作系统线程调度的切换是叠加的。Python 线程让出 GIL 后,操作系统还可能再做一次线程上下文切换。两层切换叠加,CPU 密集场景下 8 个线程反复争抢 GIL 的开销,常常比任务本身的计算量还大。
2.3 GIL 什么时候主动释放,什么时候被迫让出
搞清楚 GIL 的释放条件,很多困惑就迎刃而解。GIL 的释放分两种情况:主动释放和被动让出。
主动释放发生在线程执行阻塞操作的时候。比如time.sleep()、socket.recv()、requests.get()等待响应、threading.Lock.acquire()等待锁、读取大文件时等待磁盘 IO,这些操作会让线程进入阻塞状态。既然线程已经等着了,继续握着 GIL 也没什么用,CPython 会在进入阻塞前主动释放 GIL,等其他线程拿到锁继续干活。这就是为什么多线程处理网络请求有显著加速效果:线程 A 在等服务器响应,线程 B 就能利用这块时间发送另一个请求。
被动让出发生在线程持续执行 CPU 密集型字节码的场景。每过sys.getswitchinterval()的时间,解释器的 eval loop 会检查信号、异步任务等,这时候如果发现有其他线程在等待 GIL,当前线程就会被要求让出。所以 CPU 密集任务并不是不让出 GIL,而是让出得太频繁,锁竞争成本太高。
用一句话概括:阻塞等待时多线程能并行等待,计算期间多线程只能串行执行。这就是多线程适合 IO 密集、不适合 CPU 密集的根本原因。
2.4 自由线程与 Python 3.13 之后的变化
Python 3.13 引入了一个实验性的“自由线程”(Free-threaded)构建,也就是传说中的 no-GIL 版本。这个构建方式禁用了 GIL,用更细粒度的锁来保护对象操作,目标是让多线程真正利用多核。
但目前这个方案还在实验期,默认安装的 Python 依然是带 GIL 的。自由线程版本在 3.13 里需要特殊构建参数启用,很多第三方 C 扩展库还没有为它适配。我个人的建议是:可以关注,可以用测试环境跑实验,但生产项目暂时不要赌它。真实世界里,绝大多数 Python 服务仍然运行在带 GIL 的 CPython 上,我们讨论选型策略也以此为准。
3. 多线程与多进程的实际表现对比
3.1 线程池处理IO密集任务的真实收益
空谈理论没用,我说一个自己踩过的实际案例。有段时间我需要批量下载几十个城市的天气数据,每个请求大约耗时 1 秒。串行跑 50 个请求,稳稳的 50 秒。后来改成线程池,max_workers设为 16,同样的 50 个请求跑下来大约 4 到 5 秒,耗时缩短到原来的十分之一。
核心代码如下:
from concurrent.futures import ThreadPoolExecutor import requests def fetch(city_id): url = f"https://api.example.com/weather/{city_id}" resp = requests.get(url, timeout=10) return city_id, len(resp.content) city_ids = list(range(1, 51)) with ThreadPoolExecutor(max_workers=16) as executor: results = list(executor.map(fetch, city_ids))为什么快这么多?因为每个请求发出后,线程都在等待网络响应。等待期间 GIL 是释放的,其他线程可以发出新的请求。16 个线程相当于同时有 16 个请求在网络中飞行,等待时间被大量重叠。这种任务你换成单线程再快也快不起来,因为瓶颈本来就不在 CPU,而在网络延迟。
3.2 进程池处理CPU密集任务的表现
同样是这批数据,如果我要对每一份 JSON 做复杂的特征计算,比如逐字段解析、重采样、统计聚合,那就是 CPU 密集任务。我用多线程跑过一次,8 核机器上 8 个线程的耗时几乎等于单线程,有时候还会因为 GIL 竞争稍微慢一点。
换成进程池之后,效果立刻不一样。每个进程有独立的 Python 解释器和独立的 GIL,真正的并行执行。代码大致是:
from concurrent.futures import ProcessPoolExecutor import os def heavy_compute(city_id): total = 0 for i in range(2_000_000): total += (i * city_id) % 10007 return city_id, total city_ids = list(range(1, 21)) if __name__ == "__main__": with ProcessPoolExecutor(max_workers=os.cpu_count() - 1) as executor: results = list(executor.map(heavy_compute, city_ids))这里有个细节我特别提醒:进程池代码一定要放在if __name__ == "__main__":里面。在 Windows 上,multiprocessing 模块使用 spawn 方式启动子进程,子进程会重新导入主模块;如果不加保护,子进程会递归创建进程,直接报错或者程序卡死。这个坑几乎每个用进程池的人都会踩一次。
3.3 关键数据对照表
为了更直观,我把同一台机器上的实测表现整理成表格。这里的时间不是绝对值,不同机器差异很大,看的是相对趋势。
| 场景 | 方案 | 8核机器实测趋势 | 原因 |
|---|---|---|---|
| 网络请求50次 | 单线程 | 基准耗时 | 每个请求都在等网络 |
| 网络请求50次 | 16线程 | 约1/8耗时至1/10耗时 | 等待期重叠,GIL释放 |
| 纯计算任务8个 | 8线程 | 约等于单线程或多于单线程 | 线程争抢GIL |
| 纯计算任务8个 | 8进程 | 接近单线程的6-7倍耗时缩减 | 并行执行,无GIL争抢 |
| 混合型任务 | 线程+进程 | 视比例而定 | 每一段各自获益 |
表里最后一行“混合型任务”值得展开说。真实业务很少有纯 IO 或纯 CPU 的任务。比如爬虫加解析,爬取是 IO,解析是 CPU;数据入库,读取是 IO,索引构建是 CPU。这时候就有了分层设计的需求,下面第 4.4 节具体讲怎么做。
3.4 混合方案:线程负责IO、进程负责计算
一个比较成熟的实践方式是:用线程池做 IO 阶段,把结果汇总到一个队列,再交给进程池做 CPU 密集的计算。这样做并不是为了炫技,而是让每一类任务都在适合自己的执行模型下运行。
我自己维护过一个数据管道项目:线程池负责从消息队列拉取数据并写入本地文件,进程池负责对文件做清洗和特征工程。两个池子之间通过queue.Queue传递文件路径字符串,而不是传递 Python 对象。这样做的好处是,跨进程传递的是一个轻量字符串,避免了大量序列化开销;如果直接传递巨大的 DataFrame 或复杂对象,光 pickle 序列化就能让性能崩掉。
4. 如何选择:决策思路与落地代码
4.1 一张决策树解决90%的选择问题
我总结了几个判断规则,按顺序走下来基本不会选错:
- 任务是 CPU 密集还是 IO 密集?不确定就先写个计时脚本,统计耗时构成。有时序分析用
cProfile或者简单time.perf_counter()打点。 - CPU 密集任务:优先用
multiprocessing或ProcessPoolExecutor。进程数建议参考os.cpu_count() - 1,多留一个核给系统和主进程,不然你会在开着 IDE、浏览器的情况下把机器卡到鼠标都动不了。 - IO 密集任务:优先用
threading.ThreadPoolExecutor,或者直接考虑asyncio。线程池适合简单快速改造的同步代码;如果项目本身是 async 环境,协程通常比线程更轻量。 - 任务规模不大、逻辑简单,用
concurrent.futures就够了,别把multiprocessing.Process和Pool用出花来。 - 任务需要共享大量可变状态,多线程加锁会很痛苦;多进程只能通过 IPC 传递消息。如果两者都难写,重新审视一下架构,把共享状态设计成外部存储(数据库、Redis、文件)往往更省事。
4.2 多线程落地:ThreadPoolExecutor与Queue
多线程共享进程内存,天然方便,但“方便”也意味着“容易出错”。多线程里最忌讳直接让多个线程去改同一个全局字典,不加锁的话,你可能会遇到数据丢失或者逻辑错乱。
如果只是简单任务,用executor.map就够了。如果任务之间有明确的流程衔接,建议用queue.Queue做任务分发。比如从网页抓标题的场景:
import threading import queue import requests from bs4 import BeautifulSoup q_in = queue.Queue() q_out = queue.Queue() def worker(): while True: url = q_in.get() if url is None: break text = requests.get(url, timeout=10).text title = BeautifulSoup(text, "html.parser").title.string q_out.put((url, title)) urls = ["https://example.com/a", "https://example.com/b"] threads = [] for _ in range(4): t = threading.Thread(target=worker) t.start() threads.append(t) for url in urls: q_in.put(url) for _ in threads: q_in.put(None) # 哨兵值,让线程结束 for t in threads: t.join() while not q_out.empty(): print(q_out.get())这种“生产者-消费者”模式比裸开线程再 join 要清晰得多。把None作为结束信号是个常见技巧,它能让线程优雅退出,而不是强制杀线程。
4.3 多进程落地:ProcessPoolExecutor与Pipe/Queue
多进程的开销远大于线程。每创建一个进程,Python 都要复制一份解释器环境;在 spawn 模式下还会重新导入主模块,所以启动进程池本身就有一个固定成本。
如果任务是重 CPU 计算,这个启动成本会被任务执行时间摊薄,无所谓。但如果任务是那种只算几十毫秒的小计算,进程池的启动开销反而比计算本身还大,这时候老老实实单线程可能更快。进程间通信也是重点:能用Pool.map传递基本参数和结果就尽量不要手动创建Pipe。手动管理Pipe很容易写出“双方都在 etc.” 的逻辑错误,进程间互相等待直接死锁。
跨进程传数据还受限于可序列化。你传一个 lambda 函数给进程池会直接报错提示无法 pickle,因为 lambda 没有名字,序列化机制不认。实践中尽量传基础类型,自建类也别写得太复杂。
4.4 进程池里共享数据:Value、Array与Manager
多线程里多个线程可以直接读写同一个全局变量;多进程里不行,每个进程的内存空间是隔离的。如果你确实需要多进程共享一个计数器或标志位,可以看这几个工具:
multiprocessing.Value:共享一个 C 类型的数值变量,适合计数器、标志位。multiprocessing.Array:共享一个数组,适合存储固定类型的数据。multiprocessing.Manager:共享字典、列表等 Python 容器,使用方便,但性能很一般,频繁读写时会成为瓶颈。
我自己统计多进程任务进度时用过Value。注意一定要配套加锁,否则多个进程同时counter.value += 1,读改写三步之间有间隙,计数会丢。别问怎么知道的,这种坑总要自己踩一次才长记性。
5. 常见坑与排查经验
5.1 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 多线程跑 CPU 计算没有加速 | GIL 被线程争抢 | 换多进程,或接受串行现实 |
| 多进程程序在 Windows 上反复启动 | 没有加if __name__ == "__main__"保护 | 按规范包裹入口代码 |
| 进程池传 lambda 报 pickle 错误 | lambda 无法序列化 | 改成模块级函数或使用functools.partial |
| 多进程任务完成后子进程不退出 | 有未关闭的非 daemon 子进程 | 检查Process是否正确 join 或 terminate |
| 共享计数不准、结果随机丢 | 多线程/多进程共享数据未加锁 | 使用threading.Lock或multiprocessing.Lock |
| 线程池里某个任务抛异常,主程序没反应 | executor.map的异常要迭代结果时才抛出 | 用as_completed或add_done_callback捕获异常 |
| 进程池每跑一次都要很久 | 进程启动/序列化开销过大 | 增大单次任务量,或换线程/协程 |
5.2 为什么线程多了反而变慢
这个问题我回答过很多次。线程数从 2 加到 16,IO 密集任务的吞吐量一路上升;一旦超过某个临界点,反而开始下降。原因有几个:线程太多导致频繁的线程上下文切换;每个线程都在竞争 GIL,切换间隔 5 毫秒是高并发下线程切换成本的主要来源;还有大量线程同时持有 socket 连接,文件描述符也容易被耗光。
我处理过一个日志采集脚本,开了 64 个线程去拉数据,4 核机器直接卡住。后来把max_workers降到 8,吞吐量反而翻倍。对于 Python 多线程,不是越激进越好,线程数大约是实际并发上限的 2 到 4 倍比较合理,而且一定要实测调参。
另外可以尝试缩小线程切换间隔:
import sys sys.setswitchinterval(0.001)这个操作把 GIL 切换间隔从 5 毫秒调到 1 毫秒,对本来看似“线程不够快”的场景往往没有帮助,因为切换更频繁了;但在某些锁竞争场景下,反而能让等待线程更快拿到锁,提升响应速度。想试可以,但要用压测数据说话,不要凭感觉调。
5.3 为什么 print 顺序乱套
多线程程序里 print 出的日志顺序不对,是另一个高频困惑。print 本身是“解释器调用操作系统写文件描述符”,虽然 GIL 保护了字节码,但print内部至少包含准备字符串、写入缓冲区、刷新输出三个步骤,中间 GIL 可能被让出,不同线程的输出就会交错。
解决办法很简单:让输出只发生在单个线程里,其他线程把日志内容丢进queue.Queue,由一个专门的日志线程统一输出。还有一个小技巧:给 print 传flush=True也不能解决顺序问题,因为多线程下顺序本来就取决于调度,不是输出缓冲的问题。
5.4 什么时候 GIL 会释放:一个经验性的答案
有人会问:我写 C 扩展或者做数值计算的时候,GIL 会不会释放?这取决于第三方扩展的实现是否主动释放 GIL。经典的numpy在做大规模矩阵运算时就会释放 GIL,让其他 Python 线程有机会运行。所以用 numpy 做计算时,开线程可能反而有点效果。而纯 Python 写的循环,比如for i in range(10000000): total += i,全程持有 GIL,多个线程只会互相拖后腿。
Python 3.13 的自由线程构建在逐步改变这个局面,但目前默认解释器还是老规矩。另外一个经验是:别把 GIL 当作所有性能问题的挡箭牌。我见过一个项目,明明瓶颈是大量日志 fmt 字符串的 CPU 密集格式化,产品经理坚持要改成多进程,结果因为 IPC 开销过大反而更慢。先测清楚瓶颈在哪一层,再谈选型。
5.5 从 cProfile 到真实压测:别让感觉代替数据
最后给一个排查性能问题的标准流程。先写一个小型压测脚本,把任务的耗时打出来;用cProfile看函数级耗时分布;确认瓶颈是 IO 等待还是 CPU 计算。如果耗时主要花在socket、read、sleep这类系统调用上,那是 IO 密集;如果耗时集中在纯 Python 的热点函数里,那是 CPU 密集。
判断清楚之后再套用前文的决策树。命令行里可以顺手跑一下python -X importtime your_script.py看看启动时模块导入开销,这个对多进程场景特别有用,因为每个子进程都会重新导入依赖,如果第三方库导入很重,进程池的收益会被大幅稀释。
写在最后的体会
最后分享一点我自己踩过多次坑之后的感受。接手过几个并发项目,最典型的错误是一开始就上重型方案:有人把所有数据处理逻辑都丢进程池,结果每秒钟要在父子进程之间传几百个复杂对象,光 pickle 序列化就把耗时吃回去了;也有人迷信多线程,线程开了一百个,结果大量时间花在来回抢 GIL。从此我给自己定了一条规矩:先量化,再选型。任务类型没搞清楚之前,多线程和多进程的选择就像闭眼买车——选项本身没有对错,错的是场景。
你如果也遇到“多线程没提升”的情况,建议先跑一个计时脚本,数一数耗时到底花在等待还是计算。想通了 GIL,你就不会被它吓住,也不会再迷信任何一刀切的结论。Python 的并发工具箱里,线程和进程只是其中两把扳手,关键永远是你要修的机器是哪种型号。