Python的全局锁GIL

本文介绍了Python的全局解释锁GIL,探讨了其在多线程中的作用,包括优点(如简单设计和避免死锁)、缺点(在多核环境下性能受限)以及如何通过多进程和GIL的工作原理来理解竞争冒险。
摘要由CSDN通过智能技术生成

Python的全局锁GIL

global interupter lock
全局解释锁

为什么要有全局锁

a=1
if a>0:
	a-=1

上面这段代码,正常情况下,是希望将a置为0,
如果多线程运行,会出现这样一种情况
1、线程A,判断a=1,然后进入if block,等待执行a-=1;
2、线程B,判断a=1,然后进入if block,等待执行a-=1;
3、执行两次a-=1,结果a=-1
那我们知道C语言中,我们需要显示的分配内存和释放内存,如果你只分配不释放,内存就会爆炸,但是在python里,list啊dict,所有的python object都是直接拿过来用的,那分配和释放内存,你不做,总要有人帮你做,那就是python的memory management,分配内存很简单,那什么时候回收,才是关键,python使用的机制是reference count 引用计数,核心就是每一个python object他都数着到底有多少个地方在用它,每新增一个地方引用自己,他就把自己的引用数(refcount)+1,当这个数字=0了,那就没人有需要我了,我自己就可以把这块内存释放掉。
上面这个事情就类似于java的可达性分析,本身并不难理解,但是如果我们和上面的代码放在一起,也就是和竞争冒险结合在一起,我们可以这么想,如果一个进程里有多个线程在操作,当这个引用数–的时候,并不是atomic(原子性)的,也就是在运行的时候不会被其他线程打断
1、将refcount的值取出来
2、将refcount-1
3、然后再将新的refcout存进去
那在这三个步骤中间,是会有其他线程进来的,在你第三步骤之前来做一些事情,那一旦数数数不明白了,那你就没有办法正常的释放内存了,就可能造成严重的内存泄露。
那在线程中,一般怎么解决这个问题呢,一般来说,是通过加锁,就是在和一段程序,只有我一个人在运行,其他的线程不可以进入这个程序,你可以理解成你去厕所了,找个隔间,插上门。通过锁,可以解决竞争冒险的问题。伪代码

a=1
lock.aquire()
if a>0:
	a-=1
lock.release()

GIL的优点

但是这个只解决了这一个问题啊,其实所有的python object都有这个问题,所以python的开发者,为了解决这个问题,决定使用全局锁GIL,这个锁,每个bytecode在运行的时候都是拿到了线程锁的,也就是没有bytecode被其他线程打断,那这种全局锁有什么好处
1、简单的设计
2、避免了死锁的问题
3、对于单线程的程序,或者没有办法多线程的程序,那全局锁的性能非常好,因为要锁是要时间的,全局锁保证了每个bytecode只要一次锁,但是如果你是那种每个object都有自己的锁的话,嗯,可想而知。

GIL的缺点

python大家以为是一个新兴的语言,其实python比java还要大,90年代初期,在那个年代,多核几乎不存在,多线程存在的意义,就是这些线程可以轮流执行,不会因为某一个线程计算量过大而卡住了其他的事情,就是一个切片并行的概念,当时的多线程就是把一个任务分段执行,以便利用有限的资源做更多的事情,那个年代,本来就只有一个cpu,本来就只能运行一个线程的代码,所以这个GIL没什么影响,但是到了21世纪,多核是一个标配了,每个cpu核心都可以独立的进行运算,也就是说,一个进程的若干个线程,可以同时在若干个cpu核心上一起跑,通过这种并行,来增加你运行的速度,这个时候GIL就出现了水土不服,因为它每一个interupter只允许一个线程运行它的bytecode,所以你不管有多少个线程,只能有一个线程在实际的运行它的代码,一核有难,八核围观,这就导致python的多线程,没有办法利用多核,这就是最大的缺点。

如何避开GIL

那python就真的不行了,没有啊,还是生机勃勃的,为什么呢,python还可以通过多进程来解决问题,虽然一个进程没有办法利用多个cpu,但是我可以多进程啊,这是大部分人的解决办法。

python的多线程简单使用

python 的多线程机制可以的适用场景不适合与计算密集型的,因为 GIL 的存在,多线程在处理计算密集型时,实际上也是串行的,因为每个时刻只有一个线程可以获得 GIL,但是对于 IO 处理来说,不管是网络IO还是文件读写IO还是数据库IO,由于从用户态切换到内核态时,此时线程就陷入等待,线程让出对应 CPU,此时就可以切换到其他线程上继续执行任务,总的来说, python 的多线程机制适用于处理 IO 密集型任务。

这里引申出多线程机制的相关应用,python 的标准库中已经为我们提供了 threading模块,我们可以根据其中的 Thread类 进行线程的相关处理,主要就是创建,运行,阻塞,判断是否存活等操作:

- Thread(target=func, args=(), name="myname")
- Thread.is_alive()
- Thread.start()
- Thread.join()

但 python 的标准库的线程类仅提供了些简单操作,更多的线程控制,实际上并没有,比如针对超时或者对正在运行的线程停掉等,而且只要子线程 start() 后,其运行就脱离控制了,即使 join(timeout=10) 设置,也只是针对 is_alive() 进行属性的更改,这一点 golang 就在 goroutine 中做得很好,这里不是讨论重点。

import time, datetime
import threading
import sys
 
def foo(sleep=2):
    print("当前thread: [{}]".format(threading.current_thread().name))
    time.sleep(sleep)
    print("thread: [{}] end.".format(threading.current_thread().name))
 
def multiThread_v1():
    """
    version1: 多个线程start后再join
    :return:
    """
    print("[{}] [{}] start...".format(datetime.datetime.now(), sys._getframe().f_code.co_name))
    t1 = threading.Thread(target=foo, name="t1")
    t2 = threading.Thread(target=foo, name="t2")
    t3 = threading.Thread(target=foo, name="t3")
    t4 = threading.Thread(target=foo, name="t4")
    t5 = threading.Thread(target=foo, name="t5")
    t1.start()
    t1.join()
    t2.start()
    t2.join()
    t3.start()
    t3.join()
    t4.start()
    t4.join()
    t5.start()
    t5.join()
    print("[{}] [{}] end...".format(datetime.datetime.now(), sys._getframe().f_code.co_name))
 
if __name__ == "__main__":
    multiThread_v1()

以下是运行结果:

[2023-04-13 10:40:55.820157] [multiThread_v1] start...
当前thread: [t1]
thread: [t1] end.
当前thread: [t2]
thread: [t2] end.
当前thread: [t3]
thread: [t3] end.
当前thread: [t4]
thread: [t4] end.
当前thread: [t5]
thread: [t5] end.
[2023-04-13 10:41:05.833481] [multiThread_v1] end...

可以看到本来我们创建5个子线程,想着可以并发跑,实际上是串行的,那多线程还有啥意义呢,还不如主线程里串行执行。

这里就要主要到 join() 的作用了,当 start() 后,子线程就开始运行了,我们通过调用 join(),这里就是阻塞主线程,告诉主线程,你得等我子线程运行完才能执行接下来的逻辑,多个子线程都这样,能不是串行执行了吗。
下面介绍一种常见的多线程的应用,通过下面的编码实现多线程执行并发的效果:

def multiThread_v3():
    print("[{}] [{}] start...".format(datetime.datetime.now(), sys._getframe().f_code.co_name))
    t_list = []
    for i in range(5):
        arg = 5 if i % 2 == 1 else 4
        t = threading.Thread(target=foo, args=(arg,), name="thread_"+str(i))
        t_list.append(t)
    for t in t_list:
        t.start()
    for t in t_list:
        t.join()
    print("[{}] [{}] end...".format(datetime.datetime.now(), sys._getframe().f_code.co_name))
 
if __name__ == "__main__":
    multiThread_v3()

结果如下

[2023-04-13 10:46:28.393077] [multiThread_v3] start...
当前thread: [thread_0]
当前thread: [thread_1]
当前thread: [thread_2]
当前thread: [thread_3]
当前thread: [thread_4]
thread: [thread_4] end.thread: [thread_0] end.thread: [thread_2] end.
 
 
thread: [thread_1] end.
thread: [thread_3] end.
[2023-04-13 10:46:33.395467] [multiThread_v3] end...

代码中通过设置几个sleep 5秒,几个sleep 4秒,模拟不同的处理耗时,可以看到,从开始到结束,线程单个时间总和应该是 4+5+4+5+4=22秒,实际上只运行5秒就全部结束了,我们还是回到 start() 和 join() 的功能上来分析,start() 后,都在跑子线程,通过 join(), 阻塞主线程,由于子线程都已经在运行,实际上的耗时取决于耗时最长的那个,也就是 sleep 5秒的的线程,所以 thread_0/2/4几乎同时结束,运行4秒,接着是thread_3/5,运行5秒,实际在业务时实现时,我们并不能预知耗时情况,比如涉及到网络抖动、磁盘IO等,所以通过遍历 join() 就更合理点。

危险的多线程

import threading
counter = 0
def f():
	global counter
	for _ in range(10000):
		counter = counter+1
t1 = threading.Thread(target=f)
t2 = threading.Thread(target=f)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter)

上面这段代码,如果在python3.11版本中,结果是20000,也就是说竞争冒险没有出现,可能有人就觉得,这个是因为python中有GIL,python的多线程程序,压根儿就不会出现竞争冒险,也就是说,python的线程就不用锁。
但是3.9版本中,就不是20000,经典的竞争冒险他就出现了

import threading
counter = 0
def f():
	global counter
	counter_next  =  counter
	for _ in range(10000):
		counter_next  =  counter+1
		counter = counter_next
t1 = threading.Thread(target=f)
t2 = threading.Thread(target=f)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter)

这个在3.11中结果是不变的,那我们再变换一下

import threading
counter = 0
def f():
	global counter
	counter_next  =  counter
	for _ in range(10000):
		counter = counter_next
		counter_next  =  counter+1
t1 = threading.Thread(target=f)
t2 = threading.Thread(target=f)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter)

就会发现竞争冒险就又出现了,这一点其实我是不理解的,这个不应该为0么,而不是低于20000的一个值,但不管怎么说,不会出现说python有了GIL多线程就不需要锁这样一个说法,想要知道为什么,那就需要进入Cpython的源码,看看这个GIL什么时候释放的,在python3.9中,几乎每一个字节码的执行,都会检查是否需要交出GIL,就会出现,结果还没保存到counter里的时候,这个GIL就交给了另一个线程,另一个线程就加啊加啊加,运行完了之后,再把运行权也就是GIL返回给原来的线程,现在这个线程,再把很久之前的那个值存到了counter里,这就形成了一个经典的竞争冒险,那新的版本有什么不同呢,绝大部分的字节码的运行,都不会触发GIL的检查,哪些会触发这个GIL的检查,第一就是函数调用,第二就是jump,那jump简单的理解就是,这个字节码不顺序运行了,常见的有if,for循环或者while循环,不满足条件了,就交出了GIL,这就是为什么python3.11跑第一个程序结果是正确的,因为在counter=counter+1的时候是不可能转移GIL的,
但是如果我们把计算和赋值分开,在两次循环中分别进行,竞争冒险就又出现了,因为每一次循环都会触发jump,而jump可能会触发GIL的转移。

  • 16
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值