Python进阶:深入GIL(上篇)HackPython致力于有趣有价值的编程教学
简介
熟悉Python的人理应都听过GIL(Global Interpreter Lock,全局解释器锁) ,大概也知道它就是造成Python多线程并发其实是「伪并行」的核心原因,但依旧很多人没有深入其中,所以HackPython尝试以上、下两篇文章来阐释GIL,分别从其表现现象、对应源码以及Python对GIL改进等方面进行讨论 。
线程与进程
在讨论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 import Process
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等等 。
如果你对程序的性能要求的特别严格,此时更好的方法是选择其他语言 。
结尾
本节简单的讨论了Python中GIL相关的内容,在下一篇中会从代码层面再次深入的讨论GIL的相关内容,欢迎学习 HackPython 的教学课程并感觉您的阅读与支持。
参考文章: