GIL是python解释器(CPython)引入的概念,全称:Global Interpreter Lock(全局解释器锁)。
通过例子看GIL对多线程的影响。
import time
start = time.time()
def CountDown(n):
while n > 0:
n -= 1
CountDown(100000)
print("Time used:",(time.time() - start))
Time used: 0.005017518997192383
import time
from threading import Thread
start = time.time()
def CountDown(n):
while n > 0:
n -= 1
t1 = Thread(target=CountDown, args=[100000 // 2])
t2 = Thread(target=CountDown, args=[100000 // 2])
t1.start()
t2.start()
t1.join()
t2.join()
print("Time used:",(time.time() - start))
Time used: 0.00699925422668457
从上边可以看出,加入多线程后,运行效率没有提升反而降低。这是因为GIL限制了python多线程的性能。
那么什么是GIL?
GIL是CPython解释器中的一个概念。功能是:在CPython解释器中执行每一个python线程时,都会先锁住自己,以阻止别的线程执行。CPython会轮流执行python线程,这样用户看到的就是伪并行。
CPython能控制线程伪并行,那么为什么还需要GIL呢?
这和CPython的底层内存管理有关。CPython使用引用计数来管理内容,所有Python脚本中创建的实例,都会配备一个引用计数来记录多少个指针来指向它。当实例的引用计数的值为0时,会自动释放其所占的内存。
import sys
a = []
b = a
sys.getrefcount(a)
3
可以看到,a的引用计数值是3,因为有a、b和作为参数传递的getrefcount都引用了一个空列表。
那么问题来了,假如有两个线程同时引用a,那么双方都会尝试操作该数据,很有可能会造成引用计数的条件竞争,导致引用计数只增加1(实际应该增加2),这样会导致当第一个线程结束时,会把引用计数减少1,因此可能已经达到了释放内存的条件,当第二个线程再次试图访问a时,就会无法找到有效的内存了。所以,CPython引入GIL可以很大程度上避免类似内存管理这样复杂的竞争风险问题。
从上面这张图可以看出,Thread1,2,3轮流执行,每一个线程在开始执行时,都会锁住GIL,以阻止别的线程执行。同样的,每一个线程执行完一段后,会释放GIL,以允许别的线程执行。
为什么python线程会主动释放GIL?如果不释放,别的线程就没有运行的机会。其实,CPython中还有另一个机制,间隔式检查(check_interval),即CPython会轮询检查线程GIL的锁住状态,每隔一段时间,python解释器会强制当前线程释放GIL,这样别的线程才有执行的机会。
注意,不同版本的 Python,其间隔式检查的实现方式并不一样。早期的 Python 是 100 个刻度(大致对应了 1000 个字节码);而 Python 3 以后,间隔时间大致为 15 毫秒。当然,我们不必细究具体多久会强制释放 GIL,只需要明白,CPython 解释器会在一个“合理”的时间范围内释放 GIL 就可以了。
for (;;) {
if (--ticker < 0) {
ticker = check_interval;
/* Give another thread a chance */
PyThread_release_lock(interpreter_lock);
/* Other threads may run now */
PyThread_acquire_lock(interpreter_lock, 1);
}
bytecode = *next_instr++;
switch (bytecode) {
/* execute the next instruction ... */
}
}
从这段代码中可以看出,每个 Python 线程都会先检查 ticker 计数。只有在 ticker 大于 0 的情况下,线程才会去执行自己的代码。
很明显,在一个现代多核心的处理器上,上面的模型就有很大优化空间了,原来只能等待的线程任务,现在可以在其它空闲的核心上调度并发执行。由于古老GIL机制,如果线程2需要在CPU 2上执行,它需要先等待在CPU 1上执行的线程1释放GIL(记住:GIL是全局的)。如果线程1是因为 i/o 阻塞让出的GIL,那么线程2必定拿到Gil。但如果线程1是因为timer ticks计数满100让出GIL,那么这个时候线程1和线程2公平竞争。但在Python 2.x, 线程1不会动态的调整自身的优先级,所以很大概率下次被选中执行的还是线程1,在很多个这样的竞争周期内,线程2只能安静的看着线程1拿着GIL在CPU 1上欢快的执行。这样的结果是很糟糕的。
上边说过,python中一个线程有两种情况释放GIL:一种情况是在该线程进入IO操作之前,会主动释放GIL,另一种情况是解释器不间断运行了1000字节码(Py2)或运行15毫秒(Py3)后,该线程也会放弃GIL。既然一个线程可能随时会失去GIL,那么就涉及到线程安全。GIL设计的出发点是考虑到线程安全,但是这种线程安全是粗粒度(不需要程序员自己对线程加锁,语言层面本身维护着一个全局的锁机制,用来保证线程安全)。
什么时候需要自己加锁?首先来看第一种线程释放GIL的情况。假设现在线程A因为进入IO操作而主动释放了GIL,那么在这种情况下,由于线程A的IO操作等待时间不确定,那么等待的线程B一定会得到GIL锁,这种比较“礼貌的”情况我们一般称为“协同式多任务处理”,相当于大家按照协商好的规则来,线程是安全的,不需要额外加锁。
接下来,我们来看另外一种情况,即线程A是因为解释器不间断执行了1000字节码的指令或不间断运行了15毫秒而放弃了GIL,那么此时实际上线程A和线程B将同时竞争GIL锁。在同时竞争的情况下,实际上谁会竞争成功是不确定的一个结果,所以一般被称为“抢占式多任务处理”。在python3上由于对GIL做了优化,并且会动态调整线程的优先级,所以线程B的优先级会比较高,但仍然无法肯定线程B就一定会拿到GIL。那么在这种情况下,线程可能就会出现不安全的状态。
下面从代码理解这种线程不安全状态。
import threading
n = 0
def add():
global n
for i in range(1000000):
n = n + 1
def sub():
global n
for i in range(1000000):
n = n - 1
if __name__ == '__main__':
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=sub)
t1.start()
t2.start()
t1.join()
t2.join()
print('n = ', n)
n = 407213
分别用线程1和线程2对全局变量n进行了1000000次的加和减操作。如果线程安全的话,那么最终的结果n应该还是为0。但实际上,我们运行之后,会发现这个n的值有时大有时小,完全不确定。这就是典型的多个线程操作同一个全局变量造成的线程不安全的问题。
接下来,我们从代码层面分析下产生这个问题的原因。在线程中,我们主要是执行了一个加法和减法的操作。为了方便说明问题,我们把函数最简化到一个加法函数和一个减法函数,来分析它们的字节码执行过程,来看看释放GIL锁是怎么引起这个问题的。演示代码如下:
import dis
n = 0
def add():
global n
n = n + 1
print(dis.dis(add))
def sub():
global n
n = n - 1
print(dis.dis(sub))
5 0 LOAD_GLOBAL 0 (n)
2 LOAD_CONST 1 (1)
4 BINARY_ADD
6 STORE_GLOBAL 0 (n)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
None
9 0 LOAD_GLOBAL 0 (n)
2 LOAD_CONST 1 (1)
4 BINARY_SUBTRACT
6 STORE_GLOBAL 0 (n)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
None
dis模块中的dis方法可以打印出一个函数对应的字节码执行过程,所以非常方便我们进行分析。
不管是加法还是减法运算,都会分为4步完成。以加法为例:
这四个指令如果能够保证被作为一个整体完整地运行,那么是不会产生问题的,但根据前面说的线程释放GIL的原则,那么很有可能在线程正在执行这四步中的任何一步的时候释放掉GIL而进入等待状态,这个时候发生的事情就比较有意思了。为了方便大家理解,我拿一种比较极端的情况来说明一下。比如我们在加法运算中,正准备执行第四步的时候,很不幸失去了GIL,进入等待状态(注意此时n值仍然为0)。减法运算的线程开始执行,它加载了全局变量n(值为0),并进行减法相关的计算,它也在执行第三步的时候失去了GIL,此时它进入等待状态,加法运算继续。上一次加法计算继续运行第4步,即把加法运算结果赋值给全局变量n,那么此时n的值为1。同样道理,减法操作拿回GIL时,它之前已经加载了为0的n的值,所以它继续操作到最后赋值那步时,n的值就为0-1=-1。换句话说,n的值要么为1,要么为-1,但我们期望的应该是0。这就造成了线程不安全的情形。最终,经过百万次这样不确定的加减操作,那么结果一定是不确定的。这就是引起这个问题的过程和原因。
接下来,我们还要解决另外一个问题,也就是既然GIL从粗粒度情况下存在线程不安全的可能性,那么是不是所有非IO操作引起的GIL释放都要加锁来解决线程安全的问题。这个问题同样要分情况,因为python跟其他线程自由的语言比如 Java相比,它有很多操作是原子级的,针对原子级的操作,由于方法本身是单个字节码,所以线程没有办法在调用期间放弃GIL。典型的例子比如sort方法,我们同样可以看看这种原子级的操作在python的字节码中是什么样子,代码演示如下:
import dis
lst = [4, 1, 3, 2]
def foo():
lst.sort()
print(dis.dis(foo))
4 0 LOAD_GLOBAL 0 (lst)
2 LOAD_METHOD 1 (sort)
4 CALL_METHOD 0
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
None
从字节码的角度,调用sort操作是原子级无法再分的,所以线程不会在执行期间发生GIL释放的情况,也就是说我们可以认为sort操作是线程安全的,不需要加锁。而我们上面演示的加法和减法操作则不是原子级的,所以我们必须要加锁才能保证线程安全。
所以,总结一下,如果多线程的操作中不是IO密集型,并且计算操作不是原子级的操作时,那么我们需要考虑线程安全问题,否则都不需要考虑线程安全。当然,为了避免担心哪个操作是原子的,我们可以遵循一个简单的原则:始终围绕共享可变状态的读取和写入加锁。毕竟,在 Python 中获取一个 threading.Lock 也就是一行代码的事。
在以IO操作为主的IO密集型应用中,多线程和多进程的性能区别并不大,原因在于即使在Python中有GIL锁的存在,由于线程中的IO操作会使得线程立即释放GIL,切换到其他非IO线程继续操作,提高程序执行效率。相比进程操作,线程操作更加轻量级,线程之间的通讯复杂度更低,建议使用多线程。
如果是计算密集型的应用,尽量使用多进程或者协程来代替多线程。
reference:
http://c.biancheng.net/view/5537.html
https://www.cnblogs.com/woniuxy/p/11936623.html