Python全局解释器锁(GIL)

目录

1.引言

2.GIL存在的背景

3.GIL主要工作原理

4.Python 线程安全问题

5.可以如何绕过 GIL?

6.延伸阅读


1.引言

我们来看下Python 多线程另一个很重要的话题——GIL(Global Interpreter Lock,即全局解释器锁),这个概念可能大多数人听过,但是真正理解的人可能不多。

我们先从一个简单的 cpu-bound (计算密集型)代码看起,如下:

import time


def CountDown(n):
    while n > 0:
        n -= 1


start_time = time.time()

n = 100000000
CountDown(n)

end_time = time.time()
print("time cost: {} seconds".format(end_time - start_time))

在我的四核MacBook Pro机器上面运行结果耗时5秒左右,如下:

time cost: 5.597759962081909 seconds

嗯,太慢了,此时我们考虑用多线程来加速,如下代码所示,我们使用了4个线程:

import time
from threading import Thread

n = 100000000


def CountDown(n):
    while n > 0:
        n -= 1


start_time = time.time()

t1 = Thread(target=CountDown, args=[n // 2])
t2 = Thread(target=CountDown, args=[n // 2])
t3 = Thread(target=CountDown, args=[n // 2])
t4 = Thread(target=CountDown, args=[n // 2])
t1.start()
t2.start()
t3.start()
t4.start()
t1.join()
t2.join()
t3.join()
t4.join()

end_time = time.time()
print("time cost: {} seconds".format(end_time - start_time))

在我的四核MacBook Pro机器上面运行结果耗时11秒左右,如下:

time cost: 11.078598022460938 seconds

什么,使用多线程反而比单线程处理慢,难道Python 的线程是假的线程?

Python 的线程,的的确确封装了底层的操作系统线程,在 Linux 系统里是 Pthread(全称为 POSIX Thread),而在 Windows 系统里是 Windows Thread。另外,Python 的线程,也完全受操作系统管理,比如协调何时执行、管理内存资源、管理中断等等。所以,虽然 Python 的线程和 C++ 的线程本质上是不同的抽象,但它们的底层并没有什么不同。

2.GIL存在的背景

通过上述的示例引入我们今天的主角,也就是 GIL,导致了 Python 线程的性能并不像我们期望的那样。

GIL,是最流行的 Python 解释器 CPython 中的一个技术术语。它的意思是全局解释器锁,本质上是类似操作系统的 Mutex(互斥锁)。每一个 Python 线程,在 CPython 解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。

当然,CPython 会做一些小把戏,轮流执行 Python 线程。这样一来,用户看到的就是“伪并行”——Python 线程在交错执行,来模拟真正并行的线程。

为什么 CPython 需要 GIL 呢?这其实和 CPython 的实现有关。CPython 使用引用计数来管理内存,所有 Python 脚本中创建的实例,都会有一个引用计数,来记录有多少个指针指向它。当引用计数只有 0 时,则会自动释放内存。

我们来看下面这个例子:

>>> import sys
>>>
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

这个例子中,a 的引用计数是 3,因为有 a、b 和作为参数传递的 getrefcount 这三个地方,都引用了一个空列表。

这样一来,如果有两个 Python 线程同时引用了 a,就会造成引用计数的race condition,也就是竞争条件,引用计数可能最终只增加 1,这样就会造成内存被污染。因为第一个线程结束时,会把引用计数减少 1,这时可能达到条件释放内存,当第二个线程再试图访问 a 时,就找不到有效的内存了。

条件竞争漏洞(Race condition)官方概念是“发生在多个线程同时访问同一个共享代码、变量、文件等没有进行锁操作或者同步操作的场景中。”

所以说,CPython 引进 GIL 其实主要就是这么两个原因:

  • 一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
  • 二是因为 CPython 大量使用 C 语言库,但大部分 C 语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。

3.GIL主要工作原理

下面这张图,就是一个 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。

Python全局解释器锁(GIL)_第1张图片

为什么 Python 线程会去主动释放 GIL 呢?毕竟,如果仅仅是要求 Python 线程在开始执行时锁住 GIL,而永远不去释放 GIL,那别的线程就都没有了运行的机会。

没错,CPython 中还有另一个机制,叫做 check_interval,意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。

不同版本的 Python 中,check interval 的实现方式并不一样(不用过多的纠结,先知道有这么回事就行了)。早期的 Python 是 100 个 ticks,大致对应了 1000 个 bytecodes;而 Python 3 以后,interval 是 15 毫秒。当然,我们不必细究具体多久会强制释放 GIL,这不应该成为我们程序设计的依赖条件,我们只需明白,CPython 解释器会在一个“合理”的时间范围内释放 GIL 就可以了。

Python全局解释器锁(GIL)_第2张图片

4.Python 线程安全问题

不过,有了 GIL,并不意味着我们 Python 编程者就不用去考虑线程安全了。即使我们知道,GIL 仅允许一个 Python 线程执行,但前面我也讲到了,Python 还有 check interval 这样的抢占机制。我们来考虑这样一段代码:

import threading

n = 0


def foo():
    global n
    n += 1


threads = []
for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)

for t in threads:
    t.start()

for t in threads:
    t.join()

print(n)

如果你执行的话,就会发现,尽管大部分时候它能够打印 100,但有时侯也会打印 99 或者 98。这其实就是因为,n+=1这一句代码让线程并不安全。如果你去翻译 foo 这个函数的 bytecode,就会发现,它实际上由下面四行 bytecode 组成:

>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL              0 (n)
LOAD_CONST               1 (1)
INPLACE_ADD
STORE_GLOBAL             0 (n)

而这四行 bytecode 中间都是有可能被打断的

所以,千万别想着,有了 GIL 你的程序就可以高枕无忧了,我们仍然需要去注意线程安全。正如我开头所说,GIL 的设计,主要是为了方便 CPython 解释器层面的编写者,而不是 Python 应用层面的程序员。

作为 Python 的使用者,我们还是需要 lock 等工具,来确保线程安全。比如我下面的这个例子:

n = 0
lock = threading.Lock()

def foo():
    global n
    with lock:
        n += 1

5.可以如何绕过 GIL?

Python因为存在GIL的问题,感觉不太行 ?其实不需要太沮丧。

Python 的 GIL,是通过 CPython 的解释器加的限制。如果你的代码并不需要 CPython 解释器来执行,就不再受 GIL 的限制。事实上,很多高性能应用场景都已经有大量的 C 实现的 Python 库,例如 NumPy 的矩阵运算,就都是通过 C 来实现的,并不受 GIL 影响。

所以,大部分应用情况下,你并不需要过多考虑 GIL。因为如果多线程计算成为性能瓶颈,往往已经有 Python 库来解决这个问题了。

换句话说,如果你的应用真的对性能有超级严格的要求,比如 100us 就对你的应用有很大影响,那我必须要说,Python 可能不是你的最优选择。

当然,可以理解的是,我们难以避免的有时候就是想临时给自己松松绑,摆脱 GIL,比如在深度学习应用里,大部分代码就都是 Python 的。在实际工作中,如果我们想实现一个自定义的微分算子,或者是一个特定硬件的加速器,那我们就不得不把这些关键性能(performance-critical)代码在 C++ 中实现(不再受 GIL 所限),然后再提供 Python 的调用接口。

总的来说,你只需要重点记住,绕过 GIL 的大致思路有这么两种就够了:

  • 绕过 CPython,使用 JPython(Java 实现的 Python 解释器)等别的实现;
  • 把关键性能代码,放到别的语言(一般是 C++)中实现。

6.延伸阅读

  • Python有可能删除GIL吗?
  • Python 字节码bytecode

你可能感兴趣的:(#,Python技术学习,python,GIL,全局解释器锁,GIL原理,线程安全)