深入理解 Python GIL

引言

Python 的使用者都知道 Cpython 解释器有一个弊端,真正执行时同一时间只会有一个线程执行,这是由于设计者当初设计的一个缺陷,叫 GIL ,全称 Global Interpreter Lock,但它到底是什么?我们只知道因为它导致 Python 使用多线程执行时,其实一直是单线程在执行任务,但是原理却不知道,那么接下来我们就认识一下 GIL

什么是 GIL 锁

GILGlobal Interpreter Lock )不是 Python 独有的特性,它只是在实现 CPythonPython 解释器)时,引入的一个概念。在官方网站中定义如下:

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

由定义可知, GIL 是一个互斥锁( mutex )。它阻止了多个线程同时执行 Python 字节码,毫无疑问,这降低了执行效率。理解 GIL 的必要性,需要了解 CPython 对于线程安全的内存管理机制。

CPython 对线程安全的内存管理机制

Python 使用引用计数来进行内存管理,在 Python 中创建的对象都会有引用计数,来记录有多少个指针指向它。当引用计数的值为 0 时,就会自动释放内存。

1
2
3
4
5
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

可以看到,a 的引用计数值为 3 ,因为有 ab 和作为参数传递的 getrefcount 都引用了一个空列表。

假设有两个 Python 线程同时引用 a,那么双方就都会尝试操作该数据,很有可能造成引用计数的条件竞争,导致引用计数只增加 1(实际应增加 2),这造成的后果是,当第一个线程结束时,会把引用计数减少 1 ,此时可能已经达到释放内存的条件(引用计数为 0),当第 2 个线程再次视图访问 a 时,就无法找到有效的内存了。

由于上述原因, CPython 引进 GIL ,可以最大程度上规避类似内存管理这样复杂的竞争风险问题。

但是既然有了锁,一个对象就需要一把锁,那么多个对象就会有多把锁,可能会给我们带来 2 个问题

  • 死锁
  • 反复获取和释放锁而导致性能降低

为了保证单线程情况下 Python 的正常执行和效率, GIL 锁(单一锁)由此产生了,它添加了一个规则,即任何 Python 字节码的执行都需要获取解释器锁。这样可以防止死锁(因为只有一个锁),并且不会带来太多的性能开销。但这实际上使所有受 CPU 约束的 Python 程序都是单线程的,所以这也是大家所说 Python 的多线程是伪多线程的原因。

关于上述说的 GIL 带来的反复获取和释放锁而导致性能降低这个问题我们可以通过下面两段代码来深入理解:

1
2
3
4
5
6
7
8
9
import time
start = time.time()
def count_down(n):
while n > 0:
n -= 1
count_down(100000)
print("Time used:",(time.time() - start))

# Time used: 0.006524324417114258
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import time
from threading import Thread
start = time.time()
def count_down(n):
while n > 0:
n -= 1

t1 = Thread(target=count_down, args=[100000 // 2])
t2 = Thread(target=count_down, args=[100000 // 2])
t1.start()
t2.start()
t1.join()
t2.join()
print("Time used:",(time.time() - start))

# Time used: 0.013454914093017578

在上面的两段程序中,第一段程序是采用循环的方式实现具体功能;第二段程序则采用我们印象中多线程的方式来加速程序的执行,使用了 2 个线程来执行和第一段代码相同的工作,但从输出结果中可以看到,运行效率非但没有提高,反而降低了。

Python GIL 底层实现原理

GIL 工作流程示意图

上面这张图,就是 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL ,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL ,以允许别的线程开始利用资源。上述流程图和解释我们可以参考操作系统 CPU 调度来理解。

为什么 Python 线程会去主动释放 GIL 呢?毕竟,如果仅仅要求 Python 线程在开始执行时锁住 GIL ,且永远不去释放 GIL ,那别的线程就都没有运行的机会。其实 CPython 中还有另一个机制,叫做间隔式检查check_interval),意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况,每隔一段时间, Python 解释器就会强制当前线程去释放 GIL ,这样别的线程才能有执行的机会。

虽然都是释放 GIL 锁,但这两种情况是不一样的。比如, Thread1 遇到 IO 操作释放 GIL ,由 Thread2Thread3 来竞争这个 GIL 锁, Thread1 不再参与这次竞争。如果是 Thread1 因为 Time Tick 到期释放 GIL ,那么三个线程可以同时竞争这把 GIL 锁,可能出现 Thread1 在竞争中胜出,再次执行的情况。单核 CPU 下,这种情况不算特别糟糕。因为只有 1CPU ,所以 CPU 的利用率是很高的。在多核 CPU 下,由于 GIL 锁的全局特性,无法发挥多核的特性, GIL 锁会使得多线程任务的效率大大降低。

注意,不同版本的 Python 其间隔式检查的实现方式并不一样。早期的 Python100 个刻度(大致对应了 1000 个字节码);而 Python3 以后,间隔时间大致为 15 毫秒。当然,我们不必细究具体多久会强制释放 GIL,我们只需要明白, CPython 解释器会在一个“合理”的时间范围内释放 GIL 就可以了。

Python GIL 不能绝对保证线程安全

有了 GIL ,并不意味着 Python 程序员就不用去考虑线程安全了,因为即便 GIL 仅允许一个 Python 线程执行,但别忘了 Python 还有 check interval 这样的抢占机制。

例如下面这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 这个函数的字节码就会发现,它实际上是由下面四行字节码组成:

1
2
3
4
5
6
>>> import dis
>>> dis.dis(foo)
0 LOAD_GLOBAL 0 (n)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_GLOBAL 0 (n)
  • 加载全局变量 n
  • 加载常数 1
  • 进行替代加法运算
  • 将运算结果存入变量 n

而这四行字节码中间都是有可能被打断的!所以,千万别以为有了 GIL 程序就不会产生线程问题,我们仍然需要注意线程安全。

总结

优势

  • GIL 非常简单的设计
  • 由于只有一个线程锁,避免死锁
  • 对于单线程的程序或者没法并行的多线程程序全局锁性能非常优秀(你在每一次运行一个 bytecode 的时候至多只需要一个锁,但是如果你是那种比如每一个 object 都有自己的锁的话一个 bytecode 由于你要 access 很多个 object,你就可能要拿很多次锁)
  • 让你写 c extension 容易很多,因为你可以确定在你每一个 bytecode 的时候你的 python 代码没有竞争冒险,然后你在你的 c extension 里面去修改你的 python object 的时候就不用管乱七八糟的锁

为什么要设计这个 GIL

这是一个切片并行的理念,那个年代本来只有一个 cpu ,只能运行一个线程的代码。

怎么解决这个问题

  • 多进程 + 协程
  • c extension

参考

Powered by Hexo and Hexo-theme-hiker

Copyright © 2018 - 2023 Leamx's Blog All Rights Reserved.

UV : | PV :