谈到Python,大多数人的印象都是简单、实用但是多线程效率不高,而导致这点的罪魁祸首就是---GIL(Global Interpreter Lock,全局解释器锁)。接下来给大家揭秘GIL的神秘面纱。

一、Python多线程

首先我们来进行一个cpu heavy的任务,做一个大数字的自减

最基本的单线程版本如下:

def Decrement(n):
while n > 0:
n -= 1
Decrement(100000000)
运行这段代码,我们得到时间为:
Wall time: 7.5 s
我们来运行一个多线程的版本:
from threading import Thread

def multi_version(n):
t1 = Thread(target=Decrement, args=(n // 2,))
t2 = Thread(target=Decrement, args=(n // 2,))
t1.start()
t2.start()
t1.join()
t2.join()
multi_version(100000000)

运算时间为:

Wall time: 7.19 s

我们会发现,实际上与单线程的版本所运算的时间并没有大的差别,速度并没有想象中的提升一倍。

基于此,我们会自然而然的想到,Python的多线程并没有起到并行计算的作用,难道Python的线程是假的线程?

实际上,Python的线程,的的确确是封装的底层的操作系统的线程,并且,Python的线程也完完全全的受到操作系统管理,但是,由于解释器的C语言实现部分在完全并行执行时并不是线程安全的。因此,解释器被一个全局解释器锁保护着,能确保任何时候都只能一个Python线程执行。这样就导致,Python的多线程并不能利用到多核CPU的优势,如我们上面的例子中,使用了多线程的计算密集型程序只会在单个CPU上面运行。

不过,解释器在执行时,会轮流执行Python线程,使线程交错执行,来模拟真正并行的线程。

说了这么多,那不禁要问:

为什么需要GIL?

这与Cpython的实现有关,Cpython是当下最流行的Python的解释器,使用引用计数来管理内存,在Python中,一切都是对象,引用计数就是指向对象的指针数,当这个数字变成0,则会进行垃圾回收,自动释放内存。

我们来看一个例子:

import sys

a = [1]
b = a
c = b

print(f"引用a {sys.getrefcount(a)}次")

结果为:引用a 4次

这个例子中,因为a、b、c、getrefcount都有引用[1]这个列表,所以是四次。

那我们想一下,如果有两个线程,同时引用a,这样就有可能a的引用计数只增加了一次,这就会导致内存被污染了,因为当第一个线程结束的时候,a的引用计数减去1,而如果这时候a的引用计数刚好为0的时候,a所引用的列表就会被释放,这时候另一个线程去访问a的时候,就找不到有效的内存了。GIL的工作方式

线程1、2、3都会轮流执行,每一个线程执行前都会acquire GIL即锁住线程,然后运行完再release GIL释放线程。而释放线程的时机由python的另一个机制----check_interval来决定,该机制会轮询检查线程GIL的锁住情况,并每隔一段“合理的”时间强制正在运行的当前线程去释放GIL以让其他线程能够执行。

二、线程安全

虽然说Python有了GIL,但是并不意味着我们编写Python的程序的时候就不需要去注重线程安全了,因为,虽然说Python线程执行会有GIL来锁住线程,但是也因为check_interval机制,同样还是会导致线程安全的问题,talk is cheap,let’s look at the code.

import threading

num = 0

def change_num(n):
global num
num += n
num -= n

def run(n):
for _ in range(100000):
change_num(n)

def main():
thread1 = threading.Thread(target=run, args=[5])
thread2 = threading.Thread(target=run, args=[6])
thread1.start()
thread2.start()
thread1.join()
thread2.join()

main()
print(num)

当循环的次数够大,上述代码每次运行结果都不一致,如这次的结果就是-6

这又是为什么呢?

原因是在高级语音中,一条语句在CPU执行中实际上时若干条语句,即使是简单的num += n,也是由多条bytecode组成:

便于理解,我们就将这个过程拆解成两步:计算num + n的值

将计算出来的值赋值给num

也就是:

tmp = num + n
num = tmp
在程序正常运行的过程中,我们的代码应该是:
tmp1 = num + 5 --->tmp1 = 0 + 5 = 5
num = tmp1 --->num = tmp1 = 5
tmp1 = num - 5 --->tmp1 = 5 - 5 = 0
num = tmp1 --->num = tmp1 = 0

tmp2 = num + 6 --->tmp2 = 0 + 6 = 6
num = tmp2 --->num = tmp2 = 6
tmp2 = num - 6 --->tmp2 = 6 - 6 = 0
num = tmp2 --->num = tmp2 = 0
这种情况下,num的值是正确的,就是0
但是由于Python的线程是交替运行的,所以可能的运行过程是这样的:
tmp1 = num + 5 --->tmp1 = 0 + 5 = 5
tmp2 = num + 6 --->tmp2 = 0 + 6 = 6
num = tmp2 --->num = tmp2 = 6
num = tmp1 --->num = tmp1 = 5
tmp1 = num - 5 --->tmp1 = 5 - 5 = 0
num = tmp1 --->num = tmp1 = 0

tmp2 = num - 6 --->tmp2 = 0 - 6 = -6
num = tmp2 --->num = tmp2 = -6

这样结果就是-6了。

所以也不能因为GIL就完全不注重race condition问题,使用Python多线程还是要加锁,threading模块的lock()方法,这样结果就不会出现差错了。