文章目录
概念
GIL锁不是Python的特性,而是解释器的特性,Python常见的解释器有CPython,PyPy,Osyco,JPython,其中,JPython没有GIL。也就是说,Python是完全可以不依赖于GIL的。以下是官方解释:
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)
GIL的历史
早期的时候,单核CPU调度多个线程任务,就需要一个全局锁,每个此刻占有CPU的线程就拥有这把锁,直到因为IO操作或其他原因让出CPU,再由其他的线程得到。
而在多核CPU当中,其他的线程可以在空闲的核心上执行,假设线程1正在核心1上执行,线程2需要在核心2上执行,但由于GIL的存在,线程2需要等待线程1释放GIL,但在这个等待的过程中,核心2处于空闲状态。
GIL锁的组成
locked = 0
,用于标识锁的状态mutex = pthreads_mutex()
,一个互斥量,保护条件变量cond = pthreads_cond()
,条件变量
Python中的获取锁(acquire)和释放锁(release)的伪代码如下:
def release():
mutex.acquire()
locked = False
mutex.release()
cond.signal()
def acquire():
mutex.acquire()
while locked:
cond.wait(mutex)
locked = True
mutex.release()
Python解释器对GIL锁的演变
锁一定会引发竞争问题,按照上面提到的思路,如果一个线程获得了CPU,并且一直进行运算工作,那么其他线程则无法获得CPU,这显然是不合理的。
早期设计
为了解决这个问题,在早期的设计中,将CPU分割为小片段,每一个称之为tick,每过100个tick,等价于执行完100行字节码,线程则会主动释放一次GIL,并参与下一轮的锁竞争。假设现在有A,B两个线程,现在线程A主动让出了GIL,执行了cond.signal()
,但这不意味着系统会立即调用线程B,系统可能会处于性能优化的考虑让线程A继续运行一段时间然后再调度线程B运行,那么当线程B被调度后,就会遇到两种情况:
- 线程A持有GIL(
locked = True
),那么线程B则在while循环里等待。 - 线程A正好释放了GIL,线程B恰好拿到,CPU转而执行线程B。
对于单核CPU而言,线程B可能会面临一直无法被调度的问题,而在多核CPU中,位于不同核上的线程B是会立即被cond.signal()
唤醒去竞争得到锁的。
在这样的设计下,实际情况中,线程B往往很难抢到GIL,大约每十万至一百万tick会触发一次GIL易主(来源:https://zhuanlan.zhihu.com/p/577244034),在这个过程中,在野的线程会被频繁唤醒,每次唤醒都意味着一次线程的上下文调度,从而引入开销。
总结:活跃的线程在释放GIL后,往往能更容易再次抢夺GIL,在多核CPU中会影响性能。
改进
早期设计的缺陷在于,休眠线程被频繁唤醒,而GIL的低获取率也意味着大部分唤醒是无效的,反而增加了上下文切换所带来的性能损失。
新的设计中取消了tick,不再以指令执行的条数作为释放GIL的依据,而是用gil_drop_request
(来源:https://github.com/python/cpython/blob/v3.11.0/Python/ceval_gil.h#L12),任何线程需要获得GIL,就将该变量置为1并进入等待状态。而当前持有GIL的线程则会检查这个变量,如果发现为1则会主动放弃GIL,并通过条件变量通知其他线程。
这个活跃的线程则会等待其他线程获得GIL,而不是立即尝试获取GIL。这样的设计确保了每个线程都得到了一定的CPU时间,然而如果多个线程频繁地设置这个变量,则会让每个线程所得到的CPU时间很短,从而浪费在上下文加载中。因此,线程在设置gil_drop_request
之前需要等待5ms,这样可以避免频繁设置变量和减小线程切换开销,但是也会带来响应时间变慢的代价(在快速IO操作的线程中带来负面影响),可以通过sys.setswitchinterval()
来调整。
Python 3.9和Python3.10对GIL的不同
Python虚拟机的核心在_PyEval_EvalFrameDefault
函数中。其中使用DISPATCH
的,不会跳过是否释放GIL的检查,使用FAST_DISPATCH
的则会跳过是否释放GIL的检查
Python3.9大多使用的是DISPATCH
,因此大部分的字节码在运行时都可能交出GIL。而在Python3.10中,DISPATCH
被修改,使得大部分字节码都会跳过检查,只有在和branch相关(函数调用和字节码不顺序运行的情况)的字节码会检查。
GIL对多线程运行的影响
计算密集型
# coding:utf-8
import threading, time
def my_counter():
i = 0
for _ in range(100000000):
i = i+1
return True
def main1():
thread_ary = {}
start_time = time.time()
for tid in range(2):
t = threading.Thread(target=my_counter)
t.start()
t.join() # 第一次循环的时候join方法引起主线程阻塞,但第二个线程并没有启动,所以两个线程是顺序执行的
print("单线程顺序执行total_time: {}".format(time.time() - start_time))
def main2():
thread_ary = {}
start_time = time.time()
for tid in range(2):
t = threading.Thread(target=my_counter)
t.start()
thread_ary[tid] = t
for i in range(2):
thread_ary[i].join() # 两个线程均已启动,所以两个线程是并发的
print("并发执行total_time: {}".format(time.time() - start_time))
if __name__ == "__main__":
main1()
main2()
在这个示例中,my_counter()
函数主要做的是运算,也意味着是CPU密集型,会存在上述的线程等待问题,因此CPU只能在计算第一个线程的任务,或第二个线程的任务,而不能同时进行(并发)。因此,如果执行上述程序,则会发现线程并发和先后执行的结果差距不大。
IO密集型
如果将函数换为IO密集型函数,如下
import time
def my_counter():
time.sleep(10)
return True
则会发现main1()
所需的时间是main2()
的两倍,这是因为并发的时候,由于IO等待,线程让出了GIL,CPU的使用效率更高。
线程安全问题
GIL并不能保证线程安全,这是因为GIL是解释器级别的锁,是粗粒度的锁,因此不能保证原子性操作,例如下面这段代码,如果add()
由两个线程来执行,同时对var
这个变量进行修改,不能保证var
的安全性,因为在执行var += 1
的时候,GIL有可能会被释放,由第二个线程操作变量,而在第二个线程中,储存的var
的值和线程1中的不一样,导致操作后var
不安全。
var = 1
def add():
global var
var += 1
打印add()
的字节码如下
5 0 RESUME 0
7 2 LOAD_GLOBAL 0 (var)
14 LOAD_CONST 1 (1)
16 BINARY_OP 13 (+=)
20 STORE_GLOBAL 0 (var)
22 LOAD_CONST 0 (None)
24 RETURN_VALUE
None
可以发现var += 1
一共有四步骤,分别是加载变量var
,加载常量1,执行运算符,存储变量。假设在执行最后一步的时候发生了GIL易主,那么其他线程获得到的var
则仍是原来的值,那就产生了线程不安全问题。
特殊情况-sort
自带的排序是线程安全的,这是因为,如下程序
import dis
list = [4, 2, 1, 3]
def func():
global list
list.sort()
print(dis.dis(func))
输出的字节码为
5 0 RESUME 0
7 2 LOAD_GLOBAL 0 (list)
14 LOAD_METHOD 1 (sort)
36 PRECALL 0
40 CALL 0
50 POP_TOP
52 LOAD_CONST 0 (None)
54 RETURN_VALUE
None
sort
只占了一行,因此是原子级的操作,是线程安全的。