什么是GIL
GIL (Global Interpreter Lock),全局解释性锁,它上锁的对象是解释器,而Python代码的运行需要解释器进行解释成字节码并提供虚拟机运行,这么大粒度的锁意味着,一个Python进程内的线程只有先获得GIL,才能得到代码执行的机会,这个锁使得Python进程的多线程无法利用多核cpu带来性能提升。
但需要明确的一点是,GIL并不是Python的特性,而是CPython(最常用的Python解释器)的特性。这意味着我们可以使用其他的Python解释器从而绕过GIL的困扰,如JPython就没有GIL。但由于CPython是大部分环境下默认的Python解释器,而且Python社区大部分第三方库也依赖GIL这个特性提供的线程安全,由于这样的历史路径依赖,我们对GIL是欲罢不能。
看见GIL
接下来我们通过几个简单的例子来看看GIL对多核利用的影响
运行如下代码看看CPU的占用率
123456
def dead_loop():
while True:
pass
if __name__ == "__main__":
dead_loop()
测试的机器是个人 macbook pro,4个物理CPU,划分为8个逻辑CPU;可以看到该程序的确是把单核的占用跑满了;接着我们多开一个线程一起跑dead_loop,线程是CPU调度的基本单位,按道理两个线程应该并行运行,CPU占用应该提高一倍;
12345678910111213
import threading
def dead_loop():
while True:
pass
if __name__ == "__main__":
t = threading.Thread(target=dead_loop)
t.start()
dead_loop()
t.join()
如图,确实是运行了两个线程,但是只有一个线程是激活的,只跑满一个核,CPU的占用率依旧是 1⁄8 左右 【因为有其他用户程序,因此略高于 1⁄8 】。
作为对比,我们使用Golang跑两个线程看看。
123456789101112131415
package main
func main(){
ch := make(chan int, 0)
k := 1
for i:=0; i<2; i++{
go func() {
for ; k>0 ; {
}
}()
}
}
可以看到,Golang的确按照预期的那样,两个线程在死循环运行,CPU占用率达到总的 1⁄4 左右。
从以上的示例我们可以发现,GIL确实限制了Python进程的多线程对多核CPU的利用。
怎么办
使用其他解释器
GIL只是CPython的产物,像JPython和IronPython这样的解释器由于实现语言的特性,它们不需要GIL的帮助,但是由于用来Java/C#用于解释器的实现,它们也失去了利用社区众多C语言模块有用特性的机会。【Done is better than perfect】
用multiprocessing替代Thread
multiprocessing库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷,它使用了多进程而不是多线程,而每个进程有自己独立的GIL,因此不会出现进程间的GIL争抢。
但是multiprocessing也有其他麻烦,比如本来的多线程的同步和通信机制在多进程下就用不了了,拿计数器来举例子,如果要多个线程同时累加一个变量,对于thread来说,声明一个global变量,加个访问锁即可搞定;但是由于进程有自己独立的地址空间,无法直接访问彼此的变量数据,因此这个共享数据就必须从进程里提出到更高层的存储中,苦呀。
看看多进程的CPU占用
12345678910111213
import multiprocessing
def dead_loop():
while True:
pass
if __name__ == "__main__":
p = multiprocessing.Process(target=dead_loop)
p.start()
dead_loop()
p.join()
开了两个进程,俱跑满了单核
社区的努力
Python社区也一直在努力地改进GIL,甚至尝试去除GIL
将切换粒度从基于opcode计数改成基于时间片计数
避免最近一次释放GIL锁的线程再次被立即调度
新增线程优先级功能(高优先级可以迫使其他线程释放所持有的GIL锁)
总结
Python GIL是功能与性能之间权衡后的产物,虽然它的存在导致Python单进程的CPU密集型多线程形同虚设,但它有其存在的合理性【简单有用地实现线程安全】,也有其较难改变的客观因素【历史路径依赖】。