当前位置: 首页 > 后端技术 > Python

关于GIL你需要知道的

时间:2023-03-26 17:34:23 Python

GIL初体验先看下面的代码defreduce_num(n):whilen>0:n-=1现在,假设一个非常大的数n=100000000,我们先试一下顺序在线程的情况下,执行reduce_num(n)。在我手上那台号称8核的MacBook上执行后,发现用了5.3s。这时候我们就想使用多线程来提速,比如下面几行操作:fromthreadingimportThreadn=100000000t1=Thread(target=reduce_num,args=[n//2])t2=Thread(target=reduce_num,args=[n//2])t1.start()t2.start()t1.join()t2.join()我在同一台机器上跑了一遍,发现这不仅没有提升速度,反而拖慢了运行速度,总共耗时9.4s。我还是没有放弃,决定用4个线程再试一次,发现运行时间还是9.8s,和2个线程的结果差不多。这里发生了什么?我买了假的MacBook吗?这个问题你可以先自己想一想,也可以在自己的电脑上测试一下。当然,我也不得不反省自己,提出以下两个猜想。第一个怀疑:是不是我的机器有问题?这不得不说是一个合理的推测。于是又找了一台单核CPU的台式机,跑了上面的实验。这次发现在单核CPU的电脑上,跑一个线程需要11s,跑两个线程需要11s。虽然和第一次机器不同,多线程比单线程慢,但是这两次的整体效果是差不多的!看来这不是计算机的问题,而是Python的线程失效了,没有起到并行计算的作用。从逻辑上讲,我有第二个疑问:Python线程是假线程吗?Python线程确实封装了底层的操作系统线程,在Linux系统中是Pthreads(全称POSIXThread),在Windows系统中是WindowsThreads。另外,Python线程也完全由操作系统来管理,比如协调何时执行、管理内存资源、管理中断等等。为什么会有GIL看来我的两个猜想都解释不了开头的未解之谜。那么谁是“罪魁祸首”?其实就是我们今天的主角,GIL,导致了Python线程的性能并没有达到我们的预期。GIL,是CPython中的一个技术术语,是最流行的Python解释器。意思是GlobalInterpreterLock,本质上是一个类似Mutex的操作系统。每个Python线程在CPython解释器中执行时,都会先锁定自己的线程,防止其他线程执行。当然,CPython会做一些技巧,轮流执行Python线程。这样,用户看到的就是“伪并行”——??交织Python线程来模拟真正的并行线程。CPython使用引用计数来管理内存。在Python脚本中创建的所有实例都会有一个引用计数来记录有多少指针指向它。当引用计数仅为0时,内存会自动释放。这意味着什么?我们看下面的例子:importsysa=[]b=asys.getrefcount(a)输出结果为3在这个例子中,a的引用计数为3,因为有a,b,getrefcount三个地方作为参数传入,两者都引用一个空列表。这样,如果两个Python线程同时引用a,就会造成引用计数的竞争,最终引用计数可能只会增加1,从而造成内存污染。因为当第一个线程结束时,引用计数会减1,此时可能释放了条件。当第二个线程再次尝试访问a时,找不到有效的内存。因此,CPython引入GIL主要有两个原因:一是设计者避免内存管理等复杂的竞争条件;另一个是CPython使用了大量的C语言库,但是大多数C语言库都不是原生线程安全的(线程安全会降低性能并增加复杂性)。GIL是如何工作的?下图是Python程序中GIL的工作示例。其中线程1、2、3依次执行。当每个线程开始执行时,它会锁定GIL以防止其他线程执行;同样,每个线程执行一段时间后,都会释放GIL让其他线程执行。线程开始使用资源。如果细心的话,你可能会发现一个问题:为什么Python线程会主动释放GIL?毕竟,如果只要求Python线程在开始执行时锁定GIL,永远不释放GIL,那么其他线程就没有机会运行了。没错,CPython中还有一个机制叫check_interval,意思是CPython解释器会轮询检查线程GIL的锁状态。每隔一段时间,Python解释器就会强制当前线程释放GIL,让其他线程有机会执行。在不同版本的Python中,检查间隔以不同的方式实现。早期的Python是100个ticks,大致对应1000个字节码;Python3之后,间隔为15毫秒。当然,我们不用去研究GIL要强制发布多久。这不应该是我们程序设计的依赖。我们只需要了解CPython解释器会在“合理”的时间范围内发布GIL。总的来说,每个Python线程都是这样一个循环的封装,我们看下面的代码:for(;;){if(--ticker<0){ticker=check_interval;/*给另一个线程一个机会*/PyThread_release_lock(interpreter_lock);/*其他线程现在可以运行*/PyThread_acquire_lock(interpreter_lock,1);}字节码=*next_instr++;switch(bytecode){/*executenextinstruction...*/}}在这段代码中,我们可以看到每个Python线程首先检查ticker计数。只有当ticker大于0时,线程才会执行自己的字节码。Python的线程安全但是有了GIL,不代表我们Python程序员就不用考虑线程安全了。虽然我们知道GIL只允许一个Python线程执行,但是正如我前面提到的,Python也有一种抢占机制,比如检查间隔。让我们考虑这样一段代码:importthreadingn=0deffoo():globalnn+=1threads=[]foriinrange(100):t=threading.Thread(target=foo)threads.append(t)fortinthreads:t.start()fortinthreads:t.join()print(n)如果你执行它,你会发现虽然它大部分时间可以打印100,但有时它会打印99或98。这个其实是因为代码n+=1让线程不安全。如果翻译foo函数的字节码,你会发现它实际上由以下四行字节码组成:>>>importdis>>>dis.dis(foo)LOAD_GLOBAL0(n)LOAD_CONST1(1)INPLACE_ADDSTORE_GLOBAL0(n)而这四行字节码中间可能会被打断!所以,别想了,你的程序有了GIL就可以高枕无忧了,我们还是要注意线程安全。正如我开头所说,GIL的设计主要是为了方便CPython解释器层面的编写者,而不是Python应用层面的程序员。作为Python用户,我们还是需要lock等工具来保证线程安全。例如,我下面的例子:n=0lock=threading.Lock()deffoo():globalnwithlock:n+=1