[Python] GIL锁影响探究

文章详细阐述了Python的全局解释器锁(GIL)的历史、结构,以及它如何影响多线程在计算密集型和IO密集型任务中的表现。还讨论了Python3.9和3.10对GIL处理的差异,以及GIL对线程安全性和特殊情况(如排序)的影响。
摘要由CSDN通过智能技术生成

概念

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,再由其他的线程得到。
image.png
而在多核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被调度后,就会遇到两种情况:

  1. 线程A持有GIL(locked = True),那么线程B则在while循环里等待。
  2. 线程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只占了一行,因此是原子级的操作,是线程安全的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

The Daylight

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值