两年前,我接了一个任务:优化一个数据处理程序。
现有代码是单线程跑的,处理200万条数据要花12秒。我看了看服务器配置——8核。心想这不简单吗?开8个线程并行处理,理论上1.5秒就能跑完。
我信心满满地改了代码:
import threading
import time
def cpu_task(n):
total = 0
for i in range(n):
total += i
return total
# 单线程
start = time.time()
for _ in range(4):
cpu_task(50_000_000)
print(f"单线程: {time.time() - start:.2f}秒")
# 4个线程并行
threads = []
start = time.time()
for _ in range(4):
t = threading.Thread(target=cpu_task, args=(50_000_000,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"4线程: {time.time() - start:.2f}秒")跑完一看结果:
多线程反而更慢了?我盯着屏幕看了五分钟,不敢相信自己的眼睛。
后来又做了一个测试:在4核CPU上计算100万以内的质数,单线程3.2秒,4线程反而要6.1秒。线程越多越慢,这个结果让我彻底懵了。
后来我才知道,这不是Python的bug,这是Python最著名的“特性”——GIL(全局解释器锁)。
GIL的全称是Global Interpreter Lock,中文叫“全局解释器锁”。
它是CPython解释器(就是我们平时用的那个Python)里的一个互斥锁机制。它的作用简单粗暴:同一时刻,只有一个线程能执行Python字节码。
你可以把它想象成一把“令牌”——哪个线程拿到令牌,才能执行代码。执行一会儿就把令牌放下,让其他线程去抢。
也就是说,即使你的电脑有8核16核,跑Python多线程程序的时候,同一时间只有一个核心在工作,其他核心都在旁边看着。
听到这儿你可能想骂人了:“那Python的多线程不就是个摆设吗?”
别急,先搞清楚它为什么存在。
很多人以为GIL是Python的一个愚蠢设计失误,其实不是。
时间倒回到上世纪90年代,Python刚诞生的时候,计算机基本都是单核的,多核CPU是后来的事。当时Python的设计者Guido van Rossum面临一个很现实的问题:如何实现内存管理?
Python的内存管理用的是引用计数——每个对象都有一个计数器,记录有多少个地方引用了它。当计数器归零时,对象就被回收了。
问题来了:在多线程环境下,如果两个线程同时修改同一个对象的引用计数,就会出现竞争条件——计数可能只增加了一次而不是两次,导致对象永远无法被回收,造成内存泄漏。
举个例子:假设有3个线程同时持有对同一个Python对象的引用,此时该对象的引用计数为3。当一个线程释放对该对象的引用时,计数值降为2。但如果两个线程同时释放引用,引用计数可能只减少一次而不是两次,导致最终计数为2而不是1——这个对象永远无法被回收。
解决方案有两个:
Python选择了方案二,这就是GIL的由来。
在单核年代,这个设计非常合理。多线程其实是通过时间片轮换来模拟“同时运行”的,GIL并没有造成实质性的性能损失。直到多核CPU普及,这个设计才变成了一个问题。
打个比方:GIL就像一条单车道隧道的交通信号灯。车不多的时候,有信号灯反而更安全。但车流量大了之后,明明双向八车道的高速公路,到了隧道口还是只能一辆一辆地过。
理论归理论,咱们动手验证一下。
写一个计算密集型的任务——算斐波那契数列,专门消耗CPU:
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def fib(n):
if n <= 2:
return 1
return fib(n-1) + fib(n-2)
def cpu_bound_task(n=35):
return fib(n)分别用单线程、多线程、多进程跑:
# 单线程
start = time.time()
for _ in range(8):
cpu_bound_task(35)
print(f"单线程: {time.time() - start:.2f}s")
# 多线程(8个线程)
start = time.time()
with ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(cpu_bound_task, [35]*8))
print(f"多线程: {time.time() - start:.2f}s")
# 多进程(8个进程)
start = time.time()
with ProcessPoolExecutor(max_workers=8) as executor:
list(executor.map(cpu_bound_task, [35]*8))
print(f"多进程: {time.time() - start:.2f}s")结果呢?
多进程直接飙到了接近8倍的加速,而多线程比单线程还慢。
原因很简单:四个线程轮流抢GIL,频繁的线程切换带来了额外开销。每个线程拿到GIL后执行一小会儿就被迫让出,这种“上下文切换”是有成本的。
有用,但要看场景。
GIL只限制Python字节码的执行。当线程执行I/O操作(比如网络请求、文件读写、数据库查询)时,它会主动释放GIL。其他等待的线程就可以趁机获取GIL并执行。
这就好比一个食堂只有一个打饭窗口(GIL)。如果你要做的只是“站在窗口前等饭”(I/O等待),那多几个人排队也没问题——反正大家都在等,谁先谁后差别不大。但如果你要做的是一人霸占窗口做复杂操作(CPU计算),那其他人就只能干等着。
I/O密集型任务:多线程非常有效。
比如写一个爬虫,同时请求100个网页。每个请求发出后,线程就在等网络响应,这时候GIL是释放的,其他线程可以趁机执行。100个请求并发,总耗时约等于最慢的一个请求的时间,而不是100个请求的时间之和。
CPU密集型任务:多线程基本没用,甚至更慢。
比如大规模数学计算、图像处理、数据解析。每个线程都在拼命算,都在抢GIL,频繁切换带来的开销反而让总时间变长了。
一个简单的判断标准:你的程序大部分时间在等什么?
只对了一半。
对于纯CPU密集型计算,GIL确实让多线程没法并行。但I/O密集型任务是另一回事。一个线程阻塞不影响其他线程,并发效果相当好。
我后来用多线程写过爬虫,8个线程并发请求,速度比单线程快了7倍多。多线程不是没用,是你没用对地方。
这是CPython解释器的实现细节,不是Python语言规范的一部分。Jython(基于JVM)和IronPython(基于.NET)都没有GIL。
当年设计CPython时单核CPU是主流,在简单性、安全性和性能之间选GIL是合理的工程权衡。
这个最危险。
GIL只保证了解释器级别的字节码执行和基础引用计数的原子性,你自己的业务逻辑它管不了。
一个典型例子:i += 1。
看着像一步操作,实际上对应“读取i的值 → 计算i+1 → 写回i”三个字节码步骤,多线程下照样数据竞争:
import threading
class UnsafeCounter:
def __init__(self):
self.value = 0
def increment(self):
# 这行代码在字节码层面不是原子的
self.value = self.value + 1
counter = UnsafeCounter()
def worker():
for _ in range(100000):
counter.increment()
threads = []
for _ in range(5):
t = threading.Thread(target=worker)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"预期结果: 500000, 实际结果: {counter.value}")
# 多次运行,实际结果几乎每次都小于500000GIL不是万能锁,该加锁的地方还是要加锁。
多进程在Windows上有个大问题——进程启动方式跟Linux不一样。Windows用的是spawn,每次创建进程都要重新导入模块,开销巨大。
如果你在Windows上跑多进程,创建进程本身可能比计算还慢。生产环境尽量用Linux跑多进程。
多进程之间不共享内存,数据传递需要序列化(pickle)。如果你要传的数据很大,序列化本身就要花不少时间。
所以多进程适合“任务重、数据轻”的场景。每个任务计算量大,但输入输出数据小。
有些C扩展库在执行时不释放GIL,即使在做I/O操作也不放。这意味着即使你的任务是I/O密集型的,如果用了某些不释放GIL的库,多线程照样跑不起来。
用之前查一下文档,或者做个简单测试。
进程数超过CPU核心数之后,加速效果就不明显了,反而会增加调度开销。一般进程数等于CPU核心数就够了。
每个进程有自己的Python解释器和内存空间,互不干扰。每个进程都有自己的GIL,可以跑在不同的CPU核心上,真正实现并行。
适用场景:CPU密集型任务。
代价:进程创建开销大,进程间通信需要序列化。
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=8) as executor:
results = executor.map(cpu_task, data)像NumPy这样的库,核心计算是用C写的,不经过Python字节码,不受GIL限制。
如果你的计算任务能用NumPy、Pandas这些库实现,直接用就好,不用操心GIL。
单线程+事件循环,适合高并发I/O场景。比多线程更轻量,能支撑的并发量更大。
适用场景:大量I/O操作,比如Web服务器、爬虫。
Jython(基于JVM)、IronPython(基于.NET)都没有GIL。PyPy也支持分代GIL以提高并发性能。
不过这些解释器的生态和兼容性跟CPython有差异,生产环境用之前要评估清楚。
说了这么多GIL的“坏话”,好消息是——Python 3.13开始,GIL终于可以关了。
从Python 3.13发布版开始,CPython支持一种叫 “自由线程”(free threading) 的构建模式,禁用了全局解释器锁(GIL),允许多个线程在可用的CPU核心上并行运行。
不过目前还是实验性的。你需要用--disable-gil选项从源码编译Python,或者使用专门的python3.13t可执行文件。
自由线程的Python在执行时,可以选择性地启用或禁用GIL,通过环境变量PYTHON_GIL或命令行选项-X gil来控制。
早期基准测试显示,在M4 MacBook Air上,自由线程版本相比带GIL的版本,能带来约2.83倍的性能提升。在某些场景下,甚至能达到8-10倍的加速。
不过要注意:自由线程模式目前有个已知的单线程性能折损。因为去掉了GIL之后,解释器内部需要加更多细粒度的锁来保证线程安全,这些锁本身也有开销。在单线程场景下,可能会比带GIL的版本慢一些。
这个特性至少要到未来某个版本才会默认禁用GIL。目前建议在非生产环境下实验和测试。
GIL是CPython的一个历史遗留设计,在单核时代是合理的工程选择,到了多核时代成了性能瓶颈。
但它不是洪水猛兽。理解它、利用它、绕过它,而不是抱怨它。
那个让我怀疑人生的周五下午之后,我做了一张小卡片贴在显示器边上:
问:我的程序在等什么?
希望你能避开我踩过的这些坑。GIL不是Python的“缺陷”,它是Python历史的一部分。理解它,你才能真正用好Python的并发。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。