深入系统同步锁机制 GIL
GIL(Global Interpreter Lock,全局解释器锁)
引自原作者 GoT阳仔
注明: 这位大牛的文章实在是够味儿
引自原作者 二两
注明:作者写的通俗易懂,受益匪浅啊
维基百科
A global interpreter lock (GIL) is a mechanism used in computer-language interpreters to synchronize the execution of threads so that only one native thread can execute at a time.
从上面的定义可以看出,GIL是计算机语言解析器用于同步线程执行的一种同步锁机制。很多编程语言都有GIL,例如CPython、Ruby
为什么会有GIL
Python作为一种面向对象的动态类型编程语言,开发者编写的代码是通过解析器顺序解析执行的。
大多数人目前使用的Python解析器是CPython提供的,
而CPython的解析器是使用引用计数来进行内存管理
为了对多线程安全的支持,引用了global intepreter lock,只有获取到GIL的线程才能执行。
其他等待的线程就会去争夺GIL锁,这就造成了,在Python中使用多线程,但同一时刻下单个CPU只有一个线程在运行 ,所以Python多线程其实并不是「并行」的,而是「并发」
如果没有这个锁,在多线程编码中即使是简单的操作也会引起共享变量被多个线程同时修改的问题。
例如有两个线程同时对同一个对象进行引用时,这两个线程都会将变量的引用计数从0增加为1,明显这是不正确的。
可以通过sys模块获取一个变量的引用计数
>>> import sys
>>> a = []
>>> sys.getrefcount(a)
2
>>> b=2
>>> sys.getrefcount(a)
2
>>> c=a
>>> sys.getrefcount(a)
3
sys.getrefcount()方法中的参数对a的引用也会引起计数的增加.
是否可以对每个变量都分别使用锁来同步呢?
如果有多个锁的话,线程同步时就容易出现死锁,而且编程的复杂度也会上升。当全局只有一个锁时,所有线程都在竞争一把锁,就不会出现相互等待对方锁的情况,编码的实现也更简单。此外只有一把锁时对单线程的影响其实并不是很大。
可以移除GIL吗?
Python核心开发团队以及Python社区的技术专家对移除GIL也做过多次尝试,然而最后都没有令各方满意的方案。
内存管理技术除了引用计数外,一些编程语言为了避免引用全局解析锁,内存管理就使用垃圾回收机制。
当然这也意味着这些使用垃圾回收机制的语言就必须提升其它方面的性能(例如JIT编译),来弥补单线程程序的执行性能的损失。
对于Python的来说,选择了引用计数作为内存管理。一方面保证了单线程程序执行的性能,另一方面GIL使得编码也更容易实现。
在Python中很多特性是通过C库来实现的,而在C库中要保证线程安全的话也是依赖于GIL。
所以当有人成功移除了GIL之后,Python的程序并没有变得更快,因为大多数人使用的都是单线程场景。
对多线程程序的影响
首先来GIL对IO密集型程序和CPU密集型程序的的区别
- 像文件读写、网络请求、数据库访问等操作都是IO密集型的,它们的特点需要等待IO操作的时间,然后才进行下一步操作;
- 而像数学计算、图片处理、矩阵运算等操作则是CPU密集型的,它们的特点是需要多个多核CPU算力来支持。
-
对于IO密集型操作,当前拥有锁的线程会先释放锁,然后执行IO操作,最后再获取锁。
-
线程在释放锁时会把当前线程状态存在一个全局变量
PThreadState
的数据结构中,当线程获取到锁之后恢复之前的线程状态 -
用文字描述执行流程:
保存当前线程的状态到一个全局变量中
释放GIL
... 执行IO操作 ...
获取GIL
从全局变量中恢复之前的线程状态
下面这段代码是测试单线程执行500万次消耗的时间
import time
COUNT = 50000000
def countdown(n):
while n > 0:
n -= 1
start = time.time()
countdown(COUNT)
print('Time taken in seconds =', time.time() - start)
# 执行结果
# Time taken in seconds = 2.44541597366333
在我的8核的macbook上跑大约是2.4秒,然后再看一个多线程版本
import time
from threading import Thread
COUNT = 50000000
def countdown(n):
while n > 0:
n -= 1
t1 = Thread(target=countdown, args=(COUNT // 2,))
t2 = Thread(target=countdown, args=(COUNT // 2,))
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
print('Time taken in seconds = ', time.time() - start)
# 执行结果
# Time taken in seconds = 2.4634649753570557
-
上文代码每个线程都执行250万次,如果线程是并发的,执行时间应该是上面单线程版本的一半时间左右,然而在我电脑中执行时间大约为2.5秒!
-
多线程不但没有更高效率,反而还更耗时了。这个例子就说明Python中的线程是交替执行的,多线程执行多出来的那点时间就是获取锁和释放锁消耗的时间。
那如何实现并行呢?
答案是使用多进程或进程池,利用Queue和Pipe实现多进程间通信,
更高级的就是用Manager实现共享一处数据.
GIL的存在让Python多线程在运行CPU密集型性程序时显得非常无力,为了绕过GIL的限制,一种简单的方法就是使用多进程 ,这是因为GIL只会存在于线程级别,即一个进程为了确保某一时刻下只有一个线程在运行,才使用GIL ,但多个进程之间并不会出现这种限制,不同的进程会运行在CPU不同的核上,实现真正的「并行」
使用进程池Pool
from multiprocessing import Pool
import time
COUNT = 50000000
def countdown(n):
while n > 0:
n -= 1
if __name__ == '__main__':
pool = Pool(processes=2)
start = time.time()
r1 = pool.apply_async(countdown, [COUNT // 2])
r2 = pool.apply_async(countdown, [COUNT // 2])
pool.close()
pool.join()
print('Time taken in seconds =', time.time() - start)
# 执行结果
# Time taken in seconds = 1.2389559745788574
使用多进程,每个进程运行250万次,大约消耗1.2秒的时间。差不多是上面线程版本的一半时间。
当然还可以使用其它Python解析器,例如Jython、IronPython或PyPy。
既然每个线程执行前都要获取锁,那么有一个线程获取到锁一直占用不释放,怎么办?
IO密集型的程序会主动释放锁,但对于CPU密集型的程序或IO密集型和CPU混合的程序,解析器将会如何工作呢?
早期的做法是Python会执行100条指令后就强制线程释放GIL让其它线程有可执行的机会。
可以通过以下获取到这个配置
>>> sys.getswitchinterval()
0.005
这个方法返回0.05秒,意思是每个线程执行0.05秒后就释放GIL,用于线程的切换
因为传统的实现中每解析100指令的就强制线程释放锁的做法,会导致CPU密集型的线程会一直占用GIL而IO密集型的线程会一直得不到解析的问题。于是新的线程切换方案就被提出来了
总结
- 在CPython解析器的实现由于global interpreter lock(全局解释锁)的存在,任何时刻都只有一个线程能执行Python的bytecode(字节码)。
- 常见的内存管理方案有引用计数和垃圾回收,Python选择了前者,这保证了单线程的执行效率,同时对编码实现也更加简单。想要移除GIL是不容易的,即使成功将GIL去除,对Python的来说是牺牲了单线程的执行效率。
- Python中GIL对IO密集型程序可以较好的支持多线程并发,然而对CPU密集型程序来说就要使用多进程或使用其它不使用GIL的解析器。
- 目前最新的Cpython解析器实现中线程每执行0.05秒就会强制释放GIL,进行线程的切换。
- 有了多进程后,大部分程序都可以通过多进程的方式绕过GIL ,但如果依旧不满足,就需要使用C/C++来实现这部分代码,并生成对应的so或dll文件,再通过Python的ctypes将其调用起来 ,Python中很多对计算性能有较高要求的库都采用了这种方式,如Numpy、Pandas等等 。
多进程也不是一定安全的
标注此作者博客原地址 xiaorui.cc
python requests多进程不安全的场景