如果你的 Python 程序开了 4 个线程去处理一批 CPU 密集型任务,然后在任务管理器或者top里发现 CPU 占用率只有 25%,4 个核只有 1 个在忙,你会怎么想?很多人第一反应是线程没写对,或者操作系统没调度好。但真正的原因往往不在你的代码逻辑,而在 CPython 解释器底层的 GIL。
GIL(Global Interpreter Lock,全局解释器锁)是 Python 并发编程里最容易被误解、也最值得搞清楚的概念之一。本文会用可复现的对比实验,把下面几个问题一次讲透:GIL 到底锁住了什么?为什么 Python 多线程在 CPU 密集场景下不仅没有加速,反而可能更慢?什么时候应该换成多进程?什么时候又可以放心继续用多线程?读完你应该能建立一套清晰的并发选型判断标准,而不是再靠猜。
1. 这篇文章真正要解决的问题
先给一个明确判断:GIL 限制的并不是“线程存在”这件事,而是“同一进程内多个线程同时执行 Python 字节码”这件事。换句话说,多线程在 Python 里并不是无效的,它只是在某些场景下无法利用多核。
很多开发者遇到的问题是类似的:
- 写了一个多线程爬虫,发现速度确实快了,但换成一个需要大量数学计算的程序,多线程反而比单线程还慢。
- 明明机器有 8 核 16 线程,Python 程序却始终只跑满一个核。
- 面试里被问到“GIL 是什么、多线程和多进程怎么选”,能说出大概,但一落到代码就不知道怎么做实验验证。
这篇文章会带你做 4 组实验:单线程、多线程、多进程、线程池与进程池协作。每个实验都配有完整代码和运行说明。你不需要先背概念,可以直接复制代码到本地跑一遍,用结果建立对 GIL 的直观认知。
适合读这篇文章的人有三类:刚接触 Python 并发编程的新手,想知道多线程为什么“不按常理出牌”;写过一段时间 Python、在真实项目里纠结过线程和进程选择的开发者;以及准备面试、需要系统梳理并发选型逻辑的求职者。
2. GIL 是什么:先搞清楚它到底锁住了什么
2.1 一句话理解 GIL
GIL 的全称是 Global Interpreter Lock,翻译过来就是“全局解释器锁”。它的作用非常直白:在 CPython 解释器内部,同一时刻只允许一个线程执行 Python 字节码。
你可以把 CPython 想象成一个只有一间办公室的处理中心,Python 代码是这个中心要处理的文件,线程是员工。GIL 就是办公室门口的一把钥匙。无论你招了多少员工(线程),同一时刻只有拿到钥匙的那个人能进入办公室处理文件,其他人只能在门口等着。
所以,在 CPython 里,多线程无法真正并行地执行 Python 代码,只能并发地轮流执行。这也是“4 个线程跑不满 4 个核”的根本原因。
2.2 为什么 CPython 要设计 GIL
GIL 的存在与 CPython 的内存管理机制直接相关。CPython 使用引用计数来管理对象生命周期,每个 Python 对象都有一个引用计数字段,当引用计数降为 0 时,对象会被立即回收。如果允许多个线程同时操作同一个对象,引用计数的加减就不是原子操作,可能出现一个线程正在释放对象、另一个线程还在使用对象的情况,导致内存崩溃。
为了规避这个复杂度,早期的 CPython 选择了一个非常“简单粗暴”的方案:直接给解释器加一把大锁。任何线程要执行 Python 字节码,都必须先拿到这把锁。这个设计让解释器内部的引用计数操作天然安全,代价就是多线程无法利用多核并行执行 Python 代码。
这里要强调一个常见误区:GIL 不等于线程安全。很多初学者以为“反正有 GIL 锁,多线程操作共享变量不会出问题”,这是完全错误的。GIL 只保证单个字节码指令执行时的原子性,但你的count += 1会被翻译成多条字节码指令,执行过程中可能发生线程切换,所以多个线程同时更新同一个 Python 对象时,数据竞争依然存在。要保证正确性,仍然需要threading.Lock这类同步机制。
2.3 GIL 是 Python 语言的问题吗
不是。GIL 是 CPython 实现的一个内部机制,不是 Python 语法规定的。实际上,Python 还有其他实现,比如 Jython(Java 平台)、IronPython(.NET 平台),它们都没有 GIL。我们平时下载的 python.org 官方安装包、绝大多数 Linux 发行版里的 Python,都是 CPython。所以日常讨论 Python 并发时,GIL 是一个绕不开的问题。
从公开资料看,Python 官方也一直在探索去掉 GIL 的方案,比如 PEP 703 提出的 free-threaded 构建。Python 3.13 中已经出现了不带 GIL 的实验性构建选项,但默认安装仍然带 GIL。对绝大多数开发者来说,现阶段写代码时仍然要以“CPython 默认有 GIL”为前提,不能赌未来会变化。
3. 实验一:CPU 密集场景,4 个线程跑不满 4 个核
这一节我们直接写代码验证。实验思路是:创建一个非常消耗 CPU 的计算函数,然后分别用单线程和 4 个线程去执行,观察耗时和 CPU 占用情况。
3.1 单线程与多线程对比代码
先看单线程版本:
# file: cpu_single.py import time def cpu_bound_task(count): """模拟一个 CPU 密集型计算任务。""" total = 0 for i in range(count): total += i * i return total def run_single_thread(): start = time.perf_counter() result = cpu_bound_task(80_000_000) end = time.perf_counter() print(f"单线程执行结果: {result}") print(f"单线程耗时: {end - start:.4f} 秒") if __name__ == "__main__": run_single_thread()然后在同一台机器上跑 4 线程版本:
# file: cpu_multi_thread.py import threading import time def cpu_bound_task(count): total = 0 for i in range(count): total += i * i return total def run_multi_thread(): start = time.perf_counter() threads = [] for _ in range(4): t = threading.Thread(target=cpu_bound_task, args=(20_000_000,)) t.start() threads.append(t) for t in threads: t.join() end = time.perf_counter() print(f"4 个线程总耗时: {end - start:.4f} 秒") if __name__ == "__main__": run_multi_thread()注意,两个版本里的总计算量是基本相同的:单线程循环 8000 万次,多线程每个线程循环 2000 万次、共 4 个线程。也就是说,如果多线程能真正利用 4 核并行执行,理论上 4 线程版本应该比单线程快接近 4 倍。
3.2 运行结果与解读
运行方式:
python cpu_single.py python cpu_multi_thread.py在常见的 4 核 CPython 环境里,你会看到类似下面的趋势:
- 单线程版本耗时约 5 秒左右(具体数值取决于 CPU 主频)。
- 4 线程版本耗时约 6 秒到 7 秒,有时甚至比单线程更慢。
为什么 4 个线程处理相同总量,反而更慢?因为每个线程执行一小段时间后,就需要释放 GIL、让其他线程获取 GIL。频繁的锁竞争和线程切换带来了额外的开销。线程数量越多,切换成本越高,整体性能往往越差。
同时,你在系统监控里看 CPU 占用率,会发现 4 核机器上 CPU 占用率始终只有 100% 左右,也就是只跑满了一个核。这就是标题里“4 个线程没跑满 4 个核”的真实实验现象。
这一节的结论很明确:在 CPython 中,多线程对 CPU 密集任务没有帮助,甚至会因为 GIL 竞争而降低性能。
4. 实验二:IO 密集场景,多线程依然远快于单线程
如果多线程在 CPU 密集场景里这么“没用”,那它到底什么时候有用?答案是 IO 密集场景。这里的 IO 不只是文件读写,还包括网络请求、数据库查询、外部接口调用等所有需要等待外部返回的操作。
4.1 对比代码
我们用time.sleep来模拟一次耗时的 IO 等待。真实的网络请求在这个等待期间 CPU 基本是空闲的,多线程可以在这段时间切换去执行其他任务。
# file: io_compare.py import threading import time def io_bound_task(seconds): """模拟耗时 IO 操作。""" time.sleep(seconds) def run_single_io(): start = time.perf_counter() for _ in range(4): io_bound_task(1) end = time.perf_counter() print(f"单线程 IO 任务耗时: {end - start:.4f} 秒") def run_multi_io(): start = time.perf_counter() threads = [] for _ in range(4): t = threading.Thread(target=io_bound_task, args=(1,)) t.start() threads.append(t) for t in threads: t.join() end = time.perf_counter() print(f"4 线程 IO 任务耗时: {end - start:.4f} 秒") if __name__ == "__main__": run_single_io() run_multi_io()4.2 运行结果与解读
运行:
python io_compare.py预期结果是:
- 单线程版本耗时约 4 秒,因为 4 次 sleep 是串行执行的。
- 4 线程版本耗时约 1 秒,因为 4 个 sleep 在等待期间被并发调度,总时长接近单次 sleep 的时长。
这是多线程在 Python 里最经典也最有价值的应用场景:当一个线程在等待 IO 时,GIL 会被释放,其他线程可以继续执行。CPython 对这类阻塞型操作有比较明确的处理方式:线程进入阻塞状态时,会主动释放 GIL,等 IO 完成后再重新获取。
这一节的结论与上一节形成鲜明对比:多线程在 IO 密集场景下能显著提升吞吐量,这正是 Python 多线程仍然被广泛用于爬虫、Web 应用和接口调用的原因。
5. 多线程在 CPU 密集场景反而更慢的深层原因
现在已经有了两组实验结果。接下来需要从原理层面回答一个问题:明明是多线程,为什么 CPU 密集场景反而更慢?这里面有几个叠加因素:
第一个因素是 GIL 的获取与释放本身有开销。Python 内部有一个sys.setswitchinterval()控制的线程切换间隔(默认是 5 毫秒左右)。线程执行一段字节码后,即使不主动让出 GIL,解释器也会检查是否应该切换线程。每次切换都涉及锁状态的保存、恢复和竞争。
第二个因素是锁竞争会让线程频繁地“醒过来又等回去”。当 4 个线程都在执行纯计算时,它们没有 IO 等待,因此谁都不愿意让出 GIL。一旦某个线程被切换走,其他线程会立刻争抢 GIL。争抢过程中,被切换出去的线程可能很快又想重新拿锁,但锁已经被别人持有,于是它只能继续等待。这个过程实际上是在“空转”,白白消耗 CPU。
第三个因素是操作系统层面的线程调度与 Python 层面的 GIL 调度叠加在一起,产生了双重调度成本。操作系统负责把线程调度到 CPU 核心上,Python 解释器又负责用 GIL 限制同一时刻只有一个线程能执行字节码。两个调度器的目标并不一致,结果就是线程在多个 CPU 核之间来回切换,缓存命中率下降、调度开销上升。
第四个因素比前面几个更隐蔽:这类纯计算任务中,Python 字节码的解释执行本身就很依赖解释器内部状态,比如当前栈帧、异常处理链等。GIL 的存在让这些状态无法真正并行,所以加再多线程,核心计算部分仍然是串行的。你可以把 CPython 里执行.py代码的过程看成“一个处理器在逐条翻译指令”,多线程只是在翻译的间隙互相抢翻译员的笔,并没有增加翻译员的数量。
所以结论不是“多线程没用”,而是“多线程不适合在没有等待、纯计算、需要多核并行的场景下使用”。这两个边界要分清。
6. 什么时候该换多进程:选型判断框架
先看一个最简单的判断标准:任务是 CPU 密集还是 IO 密集?
- 如果任务长时间占用 CPU 做计算,几乎不等待外部资源,那么应该使用多进程。
- 如果任务大部分时间在等待网络、磁盘、数据库,CPU 使用率很低,那么多线程是成本更低、更合适的选择。
6.1 判断三步法
第一步:看任务的瓶颈在哪里。用cProfile或简单打点统计函数耗时,如果耗时集中在大段循环、数学计算、数据处理,说明是 CPU 密集;如果耗时集中在大大小小的等待调用上,说明是 IO 密集。
第二步:看数据是否需要大量共享。多进程之间不共享内存,传递数据需要通过序列化和进程间通信,成本比较高。如果你的并发任务需要频繁读写同一个大字典、大列表,直接换多进程反而会陷入数据传输的泥潭。这时候要么重新设计任务边界,让每个进程处理相对独立的数据块,要么用共享内存方案。
第三步:看扩展趋势。任务量增大后,瓶颈是计算量还是并发数?计算量增长远超等待时长增长,优先考虑多进程;并发请求数增长而单个请求计算量小,优先考虑多线程或者协程。
6.2 选型对比表
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| CPU 密集,数据相互独立 | 多进程 | 每个进程有独立的解释器实例,可以真正并行使用多核 |
| CPU 密集,数据需要频繁交换 | 多进程 + 队列/共享内存 | 需要为通信设计数据格式,减少传输频率 |
| IO 密集,短任务多 | 多线程 / 线程池 | 等待 IO 时释放 GIL,线程开销小 |
| IO 密集,超大量连接 | 协程(asyncio) | 单线程内事件循环,开销最低 |
| 混合场景 | 线程池 + 进程池 | IO 部分用线程处理,CPU 运算部分交给进程池 |
需要说明的是,多进程并不是没有代价。进程的内存开销比线程大,创建和销毁进程也更耗时,进程间通信还需要序列化和反序列化。所以,如果任务本身很小、很碎,单次执行只需要几毫秒,那么用多进程的通信成本可能超过并行收益。这类场景更适合用多线程或协程。
7. 多进程实战:ProcessPoolExecutor 完整示例
7.1 多进程代码实现
回到第 3 节的 CPU 密集实验,我们用concurrent.futures.ProcessPoolExecutor改写一遍。这是 Python 标准库提供的进程池接口,用法和线程池几乎一样,非常适合原地替换。
# file: cpu_multi_process.py import concurrent.futures import time def cpu_bound_task(count): total = 0 for i in range(count): total += i * i return total def run_multi_process(): start = time.perf_counter() with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor: futures = [ executor.submit(cpu_bound_task, 20_000_000) for _ in range(4) ] results = [f.result() for f in futures] end = time.perf_counter() print(f"4 个进程计算完成,结果: {results}") print(f"4 个进程总耗时: {end - start:.4f} 秒") if __name__ == "__main__": run_multi_process()这段代码的核心区别在哪里?ProcessPoolExecutor(max_workers=4)会创建 4 个独立的 Python 解释器进程,每个进程都拥有自己独立的 GIL。4 个进程可以真正同时运行在 4 个 CPU 核心上,互不干扰。
7.2 运行与验证
运行方式:
python cpu_multi_process.py在 4 核机器上,这个版本的耗时应该接近单线程版本的 1/4。如果机器有更多核心,适当调大max_workers还能进一步缩短耗时。
有一点要注意:ProcessPoolExecutor的任务函数必须能被 pickle 序列化,也就是能被标准库的序列化机制打包传给子进程。如果你在函数里传递了 lambda、局部嵌套函数、某些对象方法,可能会报PicklingError。遇到这种情况,可以把任务函数放到模块顶层,并用普通参数传递数据。
7.3 进程间通信与共享状态
多进程之间没有共享内存,如果需要汇总结果,通常用concurrent.futures的返回值就够了。如果任务需要更复杂的协作,比如多个消费者进程同时从任务队列取任务,可以使用multiprocessing.Queue或者multiprocessing.Manager。使用时要注意:Queue里传的数据会被序列化,因此尽量让传递的数据结构简单,避免超大对象在进程间反复复制。
如果你在 Windows 上开发,还要记得把进程池相关代码放到if __name__ == "__main__":保护块里,否则进程池重导入模块时会递归创建子进程,导致程序卡死甚至崩溃。
8. 混合场景实战:线程池处理 IO,进程池处理计算
实际项目里很少有一整条流水线全是纯计算或者纯 IO。更常见的情况是:任务里有网络请求,请求拿回数据后还需要做较重的解析、统计、特征提取,之后又要写数据库。面对这种混合场景,正确的做法不是二选一,而是分层处理。
一个实用的模型是:外层用线程池并发发起 IO 请求,拿到数据后把需要计算的部分丢给进程池,进程池返回结果后,再交给线程池做后续的 IO 写入。这样可以同时利用多线程在 IO 密集场景的吞吐优势,以及多进程在 CPU 密集场景的并行优势。
8.1 综合示例:文件下载 + 数据分析
下面的例子模拟一个真实场景:读取多个远程数据文件(用 sleep 模拟下载耗时),然后对每个文件的内容做一次 CPU 密集的统计计算。
# file: hybrid_demo.py import concurrent.futures import time def download_file(url): """模拟网络下载,实际场景这里会发起 HTTP 请求。""" time.sleep(1) # 模拟下载耗时 # 模拟文件内容:一个数字列表 return [i * 2 for i in range(200_000)] def analyze_data(data): """对文件数据做 CPU 密集统计。""" total = 0 for value in data: total += value * value return total def process_one_file(url): """单个文件的完整处理流程。""" # 1. 下载文件(IO 密集) data = download_file(url) # 2. 分析数据(CPU 密集) result = analyze_data(data) return result def main(): urls = [f"https://example.com/data_{i}.txt" for i in range(8)] start = time.perf_counter() # 线程池负责 IO 部分,本次只做下载 with concurrent.futures.ThreadPoolExecutor(max_workers=4) as thread_pool: data_list = list(thread_pool.map(download_file, urls)) # 进程池负责 CPU 密集分析 with concurrent.futures.ProcessPoolExecutor(max_workers=4) as process_pool: results = list(process_pool.map(analyze_data, data_list)) end = time.perf_counter() print(f"处理完成,结果数量: {len(results)}") print(f"混合方案总耗时: {end - start:.4f} 秒") if __name__ == "__main__": main()8.2 代码解释与运行
运行:
python hybrid_demo.py在这个例子里,如果只用单线程,8 个文件每个下载 1 秒,串行就是 8 秒,再加上 8 次分析计算;如果只用线程池,下载时间能压缩到约 2 秒,但分析阶段会受到 GIL 限制;如果只用进程池,分析阶段虽然能并行,但 8 个下载请求被进程池调度后,进程切换成本和内存占用都会偏高,而且把下载这类 IO 任务放在多进程里,收益并不明显。
拆成“线程池下载 + 进程池分析”后,下载阶段充分利用了网络等待时的空闲 CPU,分析阶段又真正用上了多核。这就是混合方案最直接的价值。
真实项目中,你甚至可以在同一个with块里嵌套两个执行器,但要注意避免无限创建线程和进程。更推荐的做法是用独立的线程池和进程池实例,集中管理,按任务类型分配。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 多线程 CPU 占用率只有 100% | CPython 的 GIL 限制了字节码并行执行 | 用系统监控工具查看各核占用率,确认任务是否为纯计算 | CPU 密集场景改用 ProcessPoolExecutor 或 multiprocessing |
| 多线程处理 IO 任务变慢 | 任务粒度过小,线程切换开销占比过高 | 统计单个任务耗时和线程创建数量 | 用 ThreadPoolExecutor 复用线程,或改用协程 |
| ProcessPoolExecutor 报 PicklingError | 任务函数或参数无法被 pickle 序列化 | 检查函数是否为顶层函数,参数是否为基本类型 | 把函数移到模块顶层,避免 lambda 和局部函数 |
| Windows 下运行进程池程序卡住 | 缺少if __name__ == "__main__":保护 | 检查入口文件和模块导入逻辑 | 将所有进程池代码放入 main 函数并在 main 中调用 |
| 多进程后内存占用飙升 | 每个进程都有一份独立的 Python 解释器和数据副本 | 用任务管理器或 ps 查看各进程内存 | 减少进程数,使用生成器流式处理数据,或改用共享内存 |
| 数据竞争导致变量结果不正确 | GIL 不保证复合操作的原子性 | 在关键操作前后添加 Lock,对比结果 | 使用 threading.Lock 保护共享变量,或改用 multiprocessing.Value 默认带锁 |
| 任务总数较小但并发耗时反而增加 | 并发创建和通信开销超过收益 | 对比单线程与并发版本耗时 | 小任务使用单线程或线程池,不要盲目上多进程 |
10. 最佳实践与工程建议
第一,先用time.perf_counter做基准测试,不要靠感觉选型。把任务拆成 CPU 和 IO 两个阶段,分别统计耗时占比。如果 IO 占比超过 70%,多线程通常是成本最低的解法;如果计算占比超过一半,考虑多进程。
第二,优先使用concurrent.futures,而不是直接操作底层threading.Thread和multiprocessing.Process。线程池和进程池的接口统一,能批量提交任务、统一获取结果,也更方便在两种方案之间切换测试。从ThreadPoolExecutor换成ProcessPoolExecutor,通常只需要改一个类名。
第三,限制并发数,不要盲目设置为 CPU 核数。对 CPU 密集场景,进程数设置为os.cpu_count()或者os.cpu_count() - 1是比较稳妥的起点。进程数超过物理核数,多余进程反而会抢 CPU。对 IO 密集场景,线程数一般可以比核数大很多,但也要根据下游服务的承受能力来定,过大可能会拖垮数据库或者外部接口。
第四,注意任务的边界和数据粒度。多进程之间传输大对象开销很高。如果一个任务本来可以用 1 个进程在 10 秒内跑完,你为了“用满 4 核”把它拆成 4 份,结果每次要传 1GB 数据,那么收益会被数据传输完全吃掉。正确做法是让进程内部尽可能完成独立计算,返回尽量小的结果。
第五,在多线程代码里,不要因为存在 GIL 就忽略同步。GIL 只保护单个字节码指令,不能保护你的业务逻辑。操作共享计数器、缓存、队列时,仍然需要显式加锁,或者使用queue.Queue这类线程安全的容器。
第六,生产环境中要区分开发机和多核服务器。本地只有 4 核,线上有 32 核,两者启用进程池后的性能差异可能非常大。建议在配置文件中暴露WORKER_NUM之类的参数,部署时根据机器实际核数调整。
11. 总结与后续学习方向
本文的核心结论可以归纳成三句话:
- GIL 是 CPython 解释器的全局解释器锁,它限制的是“同一进程内多线程并行执行 Python 字节码”,而不是“多线程本身不可用”。
- 多线程在 IO 密集场景是高效的,因为等待 IO 时会释放 GIL,让其他线程继续跑。
- CPU 密集场景需要真正利用多核时,应该换多进程,标准库的
ProcessPoolExecutor是最容易上手的方案。
如果你要自己动手验证,建议按这个顺序练习:先跑通第 3 节的 CPU 对比实验,观察 GIL 的影响;再把第 4 节的 IO 实验跑一遍,理解多线程适用的边界;最后把第 7 节的多进程示例改成自己的计算任务,体会加速效果。
后续值得深入学习的方向有三个:一是multiprocessing的共享内存与同步机制,适合需要高性能进程间数据交换的场景;二是asyncio协程模型,它比多线程更适合超高并发的网络 IO 任务,比如大量 WebSocket 连接;三是通过阅读 CPython 源码或相关 PEP 了解 GIL 的未来演进,Python 3.13 之后的 free-threaded 构建会成为一个重要变量。
最后提醒一点:并发选型没有银弹,任何方案都要基于任务实际特征来验证。代码写好以后,用真实数据和真实环境做一次基准测试,结果比任何经验都可靠。建议收藏这篇实验步骤,等你在项目里真正遇到并发性能瓶颈时,照着对比一遍,答案会自己浮出来。