关于python:你需要了解的GIL

44次阅读

共计 3155 个字符,预计需要花费 8 分钟才能阅读完成。

GIL 初体验

先来看上面的代码

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

当初,假如一个很大的数字 n = 100000000,咱们先来试试单线程的状况下执行 reduce_num(n)。在我手上这台号称 8 核的 MacBook 上执行后,我发现它的耗时为 5.3s。

这时,咱们想要用多线程来减速,比方上面这几行操作:

from threading import Thread
n = 100000000
t1 = Thread(target=reduce_num, args=[n // 2])
t2 = Thread(target=reduce_num, args=[n // 2])
t1.start()
t2.start()
t1.join()
t2.join()

我又在同一台机器上跑了一下,后果发现,这不仅没有失去速度的晋升,反而让运行变慢,总共花了 9.4s。

我还是不死心,决定应用四个线程再试一次,后果发现运行工夫还是 9.8s,和 2 个线程的后果简直一样。

这是怎么回事呢?难道是我买了假的 MacBook 吗?你能够先本人思考一下这个问题,也能够在本人电脑上测试一下。我当然也要自我反思一下,并且提出了上面两个猜测。

第一个狐疑:我的机器出问题了吗?这不得不说也是一个正当的猜测。因而我又找了一个单核 CPU 的台式机,跑了一下下面的试验。这次我发现,在单核 CPU 电脑上,单线程运行须要 11s 工夫,2 个线程运行也是 11s 工夫。

尽管不像第一台机器那样,多线程反而比单线程更慢,然而这两次整体成果简直一样呀!看起来,这不像是电脑的问题,而是 Python 的线程生效了,没有起到并行计算的作用。

牵强附会,我又有了第二个狐疑:Python 的线程是不是假的线程?

Python 的线程,的的确确封装了底层的操作系统线程,在 Linux 零碎里是 Pthread(全称为 POSIX Thread),而在 Windows 零碎里是 Windows Thread。

另外,Python 的线程,也齐全受操作系统治理,比方协调何时执行、治理内存资源、治理中断等等。

为什么会有 GIL

看来我的两个猜测,都不能解释结尾的这个未解之谜。那到底谁才是“罪魁祸首”呢?事实上,正是咱们明天的配角,也就是 GIL,导致了 Python 线程的性能并不像咱们冀望的那样。

GIL,是最风行的 Python 解释器 CPython 中的一个技术术语。它的意思是全局解释器锁,实质上是相似操作系统的 Mutex。每一个 Python 线程,在 CPython 解释器中执行时,都会先锁住本人的线程,阻止别的线程执行。

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

CPython 应用援用计数来治理内存,所有 Python 脚本中创立的实例,都会有一个援用计数,来记录有多少个指针指向它。当援用计数只有 0 时,则会主动开释内存。

什么意思呢?咱们来看上面这个例子:

import sys
a = []
b = a
sys.getrefcount(a)
输入后果为 3 

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

这样一来,如果有两个 Python 线程同时援用了 a,就会造成援用计数的 race condition,援用计数可能最终只减少 1,这样就会造成内存被净化。因为第一个线程完结时,会把援用计数缩小 1,这时可能达到条件开释内存,当第二个线程再试图拜访 a 时,就找不到无效的内存了。

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

  • 一是设计者为了躲避相似于内存治理这样的简单的竞争危险问题(race condition);
  • 二是因为 CPython 大量应用 C 语言库,但大部分 C 语言库都不是原生线程平安的(线程平安会升高性能和减少复杂度)。

GIL 是如何工作的?

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

仔细的你可能会发现一个问题:为什么 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 线程都是相似这样循环的封装,咱们来看上面这段代码:

for (;;) {if (--ticker < 0) {
        ticker = check_interval;
    
        /* Give another thread a chance */
        PyThread_release_lock(interpreter_lock);
    
        /* Other threads may run now */
    
        PyThread_acquire_lock(interpreter_lock, 1);
    }
    bytecode = *next_instr++;
    switch (bytecode) {/* execute the next instruction ... */}
}

从这段代码中,咱们能够看到,每个 Python 线程都会先查看 ticker 计数。只有在 ticker 大于 0 的状况下,线程才会去执行本人的 bytecode。

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

正文完
 0