Python全局解释器锁(GIL)

Python全局解释器锁(GIL)

以如下代码为例:

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

首先测试其在单线程情况下的运行时长,在我的机器上,上述代码的运行时间为6s左右。

下面测试上述代码的多线程版本的运行时间:

1
2
3
4
5
6
7
8
9
10
from threading import Thread

n = 100000000

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

多线程版本的运行时间为9s左右。

从结果对比看,多线程反而比单线程耗时更久。那么,原因是什么?

实际上,正是Python中的全局解释器锁(GIL)导致这一问题的出现。

为什么会有GIL?

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

但是为了模仿并行,CPython会轮流执行Python线程,在多个线程之间不断切换。CPython这么做的原因在于Python的内存管理机制,CPython使用引用计数来管理内存,所有Python脚本中创建的实例,都会有一个引用计数来记录有多少个指针指向它。当引用计数为0时,则会自动释放内存。如下述代码所示:

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

其中,a的引用计数为3,因为有a,b和作为参数传递的getrefcount三个指针指向该空列表。

那么,如果有两个Python线程同时引用了a,就会导致对引用计数的race condition,引用计数可能只会增加1,导致内存被污染。当第一个线程结束时,就会把引用计数减一,如果达到了内存释放条件,当第二个线程试图访问a时,就无法找到有效的内存。

因而,CPython引入GIL主要有以下两个原因:

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

GIL的工作原理

如下图所示,Thread1、2、3轮流执行,当每一个线程开始执行时都会锁住GIL,以阻止别的线程执行;同样,当每一个线程执行完成后,会释放GIL,以允许别的线程开始利用资源。

img

但是如果一个线程一直锁住GIL,其他线程岂不是永远没有执行的机会?为了解决这个问题,CPython中还有另一个机制,check_interval。在程序运行过程中,CPython解释器会轮询检查线程GIL的锁住情况。每个一段时间,Python解释器会强制当前线程释放GIL,以给别的线程运行机会。

img

Python的线程安全

有了GIL并不意味着Python就一定是线程安全的。即使GIL仅允许一个Python线程执行,但是由于check interval抢占机制的存在,同样有可能发生竞争风险问题。以如下代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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这句代码会导致线程不安全。打印这句话的字节码:

1
2
3
4
5
6
>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL 0 (n)
LOAD_CONST 1 (1)
INPLACE_ADD
STORE_GLOBAL 0 (n)

Check interval有可能发生在这四行代码的任何一句之后。

因而,在编写Python多线程程序时仍旧要注意线程安全问题。

GIL的设计主要是为了方便CPython解释器层面的编写者,而不是为了Python应用层面的程序员。在使用Python时,还是要使用lock等工具来确保线程安全。

1
2
3
4
5
6
7
n = 0
lock = threading.Lock()

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

如何绕过GIL?

Python的GIL是通过CPython的解释器添加的限制,如果程序不需要CPython解释器来执行,就不受GIL的限制。因而,绕过GIL可以有以下两种方式:

  • 不适用CPython解释器,转而使用别的解释器;
  • 将关键性能代码使用其它语言实现。

思考题

  • 为什么开头的cpu-bound的程序,多线程会比单线程慢?

    该程序不涉及I/O耗时环节,CPU都是被充分利用的。但是相比于单线程,多线程多了线程的切换,因而性能不如单线程。

参考