简介

​ 熟悉Python的人理应都听过GIL(Global Interpreter Lock,全局解释器锁) ,大概也知道它就是造成Python多线程并发其实是「伪并行」的核心原因,但依旧很多人没有深入其中,所以HackPython尝试以上、下两篇文章来阐释GIL,分别从其表现现象、对应源码以及Python对GIL改进等方面进行讨论

Python多线程的伪并行

​ Python中可以使用「threading」模块来创建并使用多线程,为了直观比较,先试一下一个没有使用多线程的代码 ,如下:

import time 
def add(n):
sum = 0
while sum <= n:
sum +=1
print(f'sum:{sum}')
if __name__ =='__main__':
start = time.time()
add(500000000)
print('run time: %s'%str(time.time()- start))

​ 代码非常简单,就是一个add()方法一直做累加操作,运行结果为 :

python 5.py
sum:500000001
run time:23.80576515197754

​ 那我使用多线程效果会不会好一些呢?凭感觉直观而言,应该是会的 ,因为上面的程序只使用了一个线程,那我开两个线程,让其同时工作,其运行时间应该短一半才对 ,但事实时使用多线程后,运行时间依旧没有变动 ,多线程版本的代码如下:

import threading, time
def add(n):
sum =0
while sum <= n:
sum +=1
print(f'sum:{sum}')
if __name__ =='__main__':
start = time.time()
n =500000000
t1 = threading.Thread(target=add, args=[n//2])
t2 = threading.Thread(target=add, args=[n//2])
t1.start()
t2.start()
t1.join()
t2.join()
print('run time: %s'%str(time.time()- start))

​ 为了让相加的数量相近,这里每个线程只需要执行「n//2」次,使用join的目的是得等线程运行完后,再执行后续的逻辑,这里只是为了方便记录运行时间 。运行结果如下:

python 6.py
sum:250000001
sum:250000001
run time:23.04693603515625

​ 发现跟一开始单线程的程序在运行时间上没有什么差异,而造成这种现象的原因就是GIL ,需要注意的是,GIL只存在于通过C语言实现的Python解释器上,即CPython上 ,后人为了绕过GIL的问题利用Java开发了Jpython或使用Python自己开发了自己的解释器PyPy,这些上都不存在GIL全局解释器锁的问题 ,但CPython才是当前最多人使用的主流Python解释器 。

​ 在CPython中,每一个Python线程执行前都需要去获得GIL锁 ,获得该锁的线程才可以执行,没有获得的只能等待 ,当具有GIL锁的线程运行完成后,其他等待的线程就会去争夺GIL锁,这就造成了,在Python中使用多线程,但同一时刻下依旧只有一个线程在运行 ,所以Python多线程其实并不是「并行」的,而是「并发」 。

​ 看到下图,图中是Python中GIL的工作实例,其中有3个线程,线程与线程之间是顺序执行的 ,每个线程开始执行时都会去获得GIL,防止其他线程线程运行 ,每执行完一段时间后,就会释放GIL,让别的线程可以去争夺执行权限,如果自己本身也没有执行完,则本身也会参与这次争夺 。

​ 可以发现,Python中的线程工作一段时间后,会主动释放GIL,这是为了让其他线程都有机会执行 ,而释放的时机就涉及到了「检查间隔」(check interval)机制 ,在早期版本的Python中,检查机制是100ticks,而Python3后,每15毫米使用一次检查间隔,然后就会释放GIL锁 。

​ 但需要注意的是线程有了GIL后并不意味着使用Python多线程时不需要考虑线程安全 ,「GIL的存在是为了方便使用C语言编写CPython解释器的编写者,而顶层使用Python时依旧要考虑线程安全」 ,在下一篇中会从原始编码层面来解释存在GIL后,依旧会有线程不安全现象的原因。

多进程实现并行

​ GIL的存在让Python多线程在运行CPU密集型性程序时显得非常无力,为了绕过GIL的限制,一种简单的方法就是使用多进程 ,这是因为GIL只会存在于线程级别,即一个进程为了确保某一时刻下只有一个线程在运行,才使用GIL ,但多个进程之间并不会出现这种限制,不同的进程会运行在CPU不同的核上,实现真正的「并行」 。

​ 通过进程的方式将上面的任务再执行一遍,看一下运行时长,具体代码如下:

from multiprocessing importProcess
import time
def add(procname, n):
sum =0
while sum <= n:
sum +=1
print(f'process name: {procname}')
print(f'sum: {sum}')
if __name__ =='__main__':
start = time.time()
n =500000000
p1 =Process(target=add, args=('Proc-1',n//2))
p2 =Process(target=add, args=('Proc-2',n//2))
p1.start()
p2.start()
p1.join()
p2.join()
print('run time: %s'%str(time.time()- start))

​ Python中多进程可以使用 multiprocessing 这个库,使用方法与使用线程类似 ,代码中启用了两个进程,分别运行 n//2 数据量的数据,其结果如下:

python 7.py
process name:Proc-1
sum:250000001
process name:Proc-2
sum:250000001
run time:12.768253087997437

​ 从结果可以看出,时间确实减少了一半左右,多进程状态下确实是真正的「并行」。

如何绕过GIL?

​ 有了多进程后,大部分程序都可以通过多进程的方式绕过GIL ,但如果依旧不满足,就需要使用C/C++来实现这部分代码,并生成对应的so或dll文件,再通过Python的ctypes将其调用起来 ,Python中很多对计算性能有较高要求的库都采用了这种方式,如Numpy、Pandas等等 。

​ 如果你对程序的性能要求的特别严格,此时更好的方法是选择其他语言 。