4. Python中的多线程与GIL:GIL是什么?为什么GIL限制并行?如何绕过GIL?
聊到Python多线程,有个绕不开的话题——GIL。很多新手刚接触时都会困惑:明明开了多个线程,怎么CPU利用率上不去?
我当年第一次遇到这个问题,是在做一个爬虫项目。开了16个线程抓数据,结果CPU只跑满了一个核。当时我还以为是代码写错了,排查了半天……后来才意识到,是GIL在背后搞鬼。
GIL到底是什么?
GIL,全称Global Interpreter Lock,全局解释器锁。它是CPython解释器中的一个机制。
说白了,GIL就是一个互斥锁。它规定:同一时刻,只有一个线程能执行Python字节码。
你想想看,这就像只有一个收银台的超市。不管来了多少顾客(线程),一次只能服务一个人。其他人只能排队等着。
核心要点:GIL保护的是解释器内部状态,防止多线程同时操作内存导致数据混乱。它让Python的内存管理变得简单,但也牺牲了并行能力。
为什么GIL会限制并行?
这个问题,得从Python的内存管理说起。
Python使用引用计数来管理内存。每个对象都有一个引用计数器,当引用数为0时,对象被回收。
如果多个线程同时修改同一个对象的引用计数,会发生什么?
嗯,数据竞争。计数可能不准,导致对象被提前释放,或者永远不被释放。这就是内存泄漏和崩溃的根源。
GIL的解决方案很简单粗暴:让所有线程串行执行。一次只让一个线程跑,其他线程等着。这样引用计数的操作就安全了。
我在项目中遇到过这样一个场景:用多线程做CPU密集型的图像处理。开了8个线程,结果比单线程还慢。为什么?因为线程切换有开销,而GIL又让它们没法并行,白白浪费了切换时间。
注意:GIL只影响CPU密集型任务。对于I/O密集型任务(如网络请求、文件读写),多线程依然有效。因为线程在等待I/O时会释放GIL,让其他线程执行。
GIL的切换机制
GIL不是永远不释放的。Python解释器会定期切换线程。
具体来说,每个线程执行一定数量的字节码指令后,会主动释放GIL。这个数量默认是100条。你也可以通过sys.setswitchinterval()来调整。
另外,当线程遇到I/O操作时,也会主动释放GIL。这就是为什么I/O密集型任务用多线程效果不错。
import sys
import threading
import time
# 查看当前切换间隔
print(sys.getswitchinterval()) # 默认0.005秒
# 自定义切换间隔
sys.setswitchinterval(0.01)
def cpu_task():
count = 0
for i in range(10**7):
count += 1
# 创建多个CPU密集型线程
threads = []
start = time.time()
for _ in range(4):
t = threading.Thread(target=cpu_task)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"多线程耗时: {time.time() - start:.2f}秒")
# 你会发现,4个线程跑完,耗时和单线程差不多
这段代码我建议你亲自跑一下。你会发现,4个线程加起来跑了4亿次循环,但耗时和单线程跑4亿次差不多。这就是GIL的“功劳”。
如何绕过GIL?
既然GIL限制了并行,那有没有办法绕过它?当然有。我总结了三种主流方案。
方案一:使用多进程
每个进程有独立的Python解释器和内存空间,自然也就有独立的GIL。多进程可以真正实现并行。
from multiprocessing import Process
import time
def cpu_task():
count = 0
for i in range(10**7):
count += 1
processes = []
start = time.time()
for _ in range(4):
p = Process(target=cpu_task)
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"多进程耗时: {time.time() - start:.2f}秒")
# 4核机器上,耗时大约是单线程的1/4
多进程的缺点也很明显:进程间通信(IPC)比线程间通信慢,而且内存开销更大。每个进程都要加载一份Python解释器和相关库。
方案二:使用C扩展
GIL只限制Python字节码的执行。如果你用C语言写扩展,可以在执行C代码时释放GIL。
Python的ctypes和Cython都支持这个操作。我曾在项目中用Cython重写了一个图像处理的核心循环,性能提升了将近10倍。
# 使用Cython释放GIL的示例
# 在.pyx文件中:
# with nogil:
# # 这里是C代码,不持有GIL
# cdef int i
# for i in range(1000000):
# do_something()
不过,这个方法门槛较高。你得懂C语言,还得熟悉Python的C扩展机制。
方案三:使用异步编程
对于I/O密集型任务,异步编程(asyncio)是更好的选择。它用单线程实现并发,没有GIL的困扰。
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = ["https://example.com"] * 10
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
# 运行异步任务
results = asyncio.run(main())
异步编程的优点是轻量、高效。但缺点是代码结构跟传统多线程不一样,学习曲线有点陡。
GIL的未来
Python社区一直在讨论要不要去掉GIL。PEP 703(no-GIL方案)已经在推进中。Python 3.12开始有了实验性的no-GIL构建。
不过,去掉GIL不是小事。很多C扩展都依赖GIL来保证线程安全。一旦去掉,这些扩展可能出问题。
我个人觉得,短期内GIL还会存在。对于大多数应用场景,多进程或异步编程已经够用了。
我的建议:
- I/O密集型任务 → 用多线程或异步
- CPU密集型任务 → 用多进程
- 需要极致性能 → 考虑C扩展或换语言
我曾经在一个数据处理项目中,把多线程改成多进程,处理时间从3小时降到了45分钟。选对方案,效果立竿见影。
知识体系总览
下面这张图,帮你理清GIL相关的核心概念和解决方案。
这张图把GIL的核心脉络都串起来了。你从左到右看,先理解GIL是什么,再明白它为什么限制并行,最后找到绕过方案。这样学起来,思路会清晰很多。