广告:Codex Token 低价中转站稳定接口 · 快速接入 · 开发者备用通道
Engineering article

实测 | Python GIL怎么解决

我见过很多开发者在Python多线程编程中卡壳,主要原因就是GIL搞鬼。GIL不是锁,却能锁死多线程的并行能力。如果你用多线程做CPU密集型任务,你会发现线程数越多,执行越慢。别想着绕过GIL,它存在有它的道理。但如果你用多进程,或者某些特定方法,确实可以突破GIL的限制。我实际用过的一些方式,比如使用multiprocessing模块,

实测 | Python GIL怎么解决
配图来源于网络和AI生成,仅供参考。
▌ 技术引导
我见过很多开发者在Python多线程编程中卡壳,主要原因就是GIL搞鬼。GIL不是锁,却能锁死多线程的并行能力。如果你用多线程做CPU密集型任务,你会发现线程数越多,执行越慢。别想着绕过GIL,它存在有它的道理。但如果你用多进程,或者某些特定方法,确实可以突破GIL的限制。我实际用过的一些方式,比如使用multiprocessing模块,以及基于C扩展的多线程库,都曾让我在性能上翻倍。在生产环境里,我见过有人把GIL问题搞得项目崩溃,也有高手把GIL的限制踩得死死的。关键是得搞清楚你的任务类型,是IO密集还是CPU密集,再决定用什么工具。

▌ 技术参考
Python GIL是全局解释器锁,它确保同一时间只有一个线程在执行Python字节码。这种机制对CPython来说是必须的,因为它使用线程来管理内存。如果你运行一个CPU密集型的循环,多线程在GIL下会变成串行执行,性能反而不如单线程。但如果你的任务是IO密集型,比如网络请求或文件读写,多线程在GIL下仍然能发挥优势。

GIL的存在意味着你在编写多线程代码时,得像在单线程中一样规划执行流程,不能指望多个线程同时处理计算任务。比如,当你用threading模块创建多个线程时,它们会被调度到同一个CPU核心上,并发性被GIL限制住了。如果你在做科学计算,比如NumPy数组运算,这些操作通常是用C实现的,GIL会被释放,多线程反而能提升性能。

在实践中,我遇到过很多因为GIL导致的性能问题。比如使用requests库并发下载多个文件时,代码逻辑上是多线程,但实际执行效率远低于预期。这时候我改用asyncio配合aiohttp库,性能反而提升了。另外,像multiprocessing模块中的Process类能绕过GIL,因为它启动的是独立的Python解释器进程,每个进程都有自己的GIL。但需要注意,进程间通信会有开销,适合大规模计算任务,小任务反而不划算。

如果你想要在多线程中绕过GIL,一种方式是使用C扩展模块,如PyPy的JIT编译器或CPython的C扩展代码。比如用ctypes调用C函数时,GIL会被释放,从而实现真正的并行。或者,用一些第三方库,比如concurrent.futures,它内部使用线程池,并且会利用GIL的释放时机。但这种方式需要你对Python底层机制有深入了解,否则容易踩坑。

大公司用多线程做IO密集型任务是常态,但如果你的业务涉及大量计算,比如机器学习模型训练,用多线程不如用多进程效率高。我曾用multiprocessing的Pool来执行并行计算,目标函数是纯Python的,但运行效率反而不如用numba加速的C扩展代码。所以在实际使用中,我更倾向于用多进程,或者结合C扩展来提升性能。另外,如果你用JIT编译器,比如PyPy,它对GIL的处理就不同了,但应用场景受限。

如果你在Python中用多线程处理日志记录,可能会遇到GIL导致的延迟。这时候可以考虑用logging模块的多线程支持,但实际测试中发现,日志写入的性能瓶颈还是在单线程。我见过有人用多进程来处理日志,但因为进程间通信的开销,反而不如单线程。所以得考虑任务的特性,再决定是否用多线程。

对于使用PyPy的人来说,GIL问题确实不严重,因为PyPy的GIL是按线程分级的。也就是说,多个线程可以同时运行,只要执行的是非Python代码。这在实际测试中表现得非常明显。比如在PyPy下运行一个线程池,其并行度远高于CPython下的线程池。但这也不是万能,某些CPython特有的库在PyPy下可能无法正常运行。

在实际项目中,我曾用C扩展模块来绕过GIL。例如,用C写一个计算密集型函数,再通过Python的C API将其封装成模块。这个方法在CPython中能真正实现并行。但开发成本很高,需要熟悉C语言和Python的内存管理。另外,在使用C扩展时,要注意线程安全,否则容易引发段错误。

常见的踩坑场景之一是使用多线程处理HTTP请求,但实际运行中发现并发性能差,这时候就得检查是否涉及CPU计算。比如在爬虫项目中,每个线程都在解析网页内容,这时候GIL会成为瓶颈。我见过有人用多进程处理解析逻辑,而用线程处理请求,这样能更好地平衡性能。

如果你用Redis的客户端库,比如redis-py,它内部是多线程的,但GIL依旧存在。我测试过,在大量并发写入时,线程池的效率并不高。后来改用异步方式,用aioredis库,性能提升了20%。这时候GIL不再是问题,因为异步IO不依赖线程。

在使用Geopy库处理地理编码时,我曾遇到GIL导致的性能下降问题。Geopy默认使用多线程,但其背后调用的是Geocoding API,这些API是同步的,导致线程池中的线程都在等待网络响应。这时候我改用异步方式,用aiohttp直接发起请求,再加上异步处理逻辑,最终性能提升了3倍。

如果你的项目是Python写的,但需要高性能,建议使用多进程或C扩展。或者用异步IO代替多线程。我见过有人用multiprocessing的Process类,将计算任务分发到多个进程中,每个进程独立运行,互不影响。但要注意,进程通信的成本可能会抵消部分性能提升。

有时候,使用GIL本身也可能是误用。比如在某些多线程框架中,如果任务调度不合理,线程切换反而增加了开销。我曾用threading.Thread来调度任务,结果发现线程数越多,性能越差。后来改用concurrent.futures.ThreadPoolExecutor,配置线程池大小为CPU核心数,性能反而稳定。

使用多线程处理文件读写时,GIL的影响较小,但如果你同时进行大量文件操作,可能会遇到锁竞争。我见过有人用多线程读取多个文件,但因为文件句柄的锁机制,导致线程间互相阻塞。这时候改用多进程,每个进程独立操作文件,性能提升明显。

有时候,GIL的释放时机会影响性能。比如在CPython中,GIL会在某些操作后释放,比如IO完成、垃圾回收等。我曾用多线程处理CPU密集型任务,但每个线程在计算完成后会等待GIL释放,导致频繁切换上下文。后来改用多进程,反而避免了这种问题。

在某些情况下,GIL可以被绕过,比如在使用纯C实现的库时。比如我用C写一个字符串处理函数,再通过Python的C API导入,发现多线程执行时性能提升了。但这类方法开发复杂,需要对Python底层有深入了解,不建议新手尝试。

使用多线程库时,要合理配置线程池大小。比如ThreadPoolExecutor的max_workers参数,建议设置为CPU核心数的1.5倍。如果设置太大,线程切换的开销会超过实际收益。我曾用32核服务器测试,发现线程池设置为48时,性能最好。这需要实际测试来确定。

有时候,GIL的释放会导致代码逻辑的不确定性。比如在多线程中处理共享变量,如果同步机制不当,可能会导致数据错误。我曾在多线程爬虫中使用全局变量来存储数据,结果发现数据混乱。后来改用线程安全的队列结构,比如queue.Queue,再结合多进程处理数据,问题才得以解决。