Python 进阶:深入 GIL(上篇)
简介
熟悉 Python 的人理应都听过 GIL(Global Interpreter Lock,全局解释器锁) ,大概也知道它就是造成 Python 多线程并发其实是「伪并行」的核心原因,但依旧很多人没有深入其中,所以 尝试以上、下两篇文章来阐释 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 的相关内容。
参考文章:
David Beazley 的 Understanding the Python GIL